Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🪟 🎉 Column selection UI #20267

Merged
merged 18 commits into from
Dec 14, 2022
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions airbyte-webapp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { I18nProvider } from "core/i18n";
import { ServicesProvider } from "core/servicesProvider";
import { AppMonitoringServiceProvider } from "hooks/services/AppMonitoringService";
import { ConfirmationModalService } from "hooks/services/ConfirmationModal";
import { defaultFeatures, FeatureService } from "hooks/services/Feature";
import { defaultOssFeatures, FeatureService } from "hooks/services/Feature";
import { FormChangeTrackerService } from "hooks/services/FormChangeTracker";
import { ModalServiceProvider } from "hooks/services/Modal";
import NotificationService from "hooks/services/Notification";
Expand Down Expand Up @@ -42,7 +42,7 @@ const Services: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => (
<AppMonitoringServiceProvider>
<ApiErrorBoundary>
<WorkspaceServiceProvider>
<FeatureService features={defaultFeatures}>
<FeatureService features={defaultOssFeatures}>
<NotificationService>
<ConfirmationModalService>
<ModalServiceProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { mockConnection } from "test-utils/mock-data/mockConnection";
import { mockDestination } from "test-utils/mock-data/mockDestination";
import { TestWrapper } from "test-utils/testutils";

import { defaultFeatures, FeatureItem } from "hooks/services/Feature";
import { defaultOssFeatures, FeatureItem } from "hooks/services/Feature";
import * as sourceHook from "hooks/services/useSourceHook";

import { CreateConnectionForm } from "./CreateConnectionForm";
Expand Down Expand Up @@ -118,7 +118,7 @@ describe("CreateConnectionForm", () => {
it("should not allow cron expressions under one hour when feature not enabled", async () => {
jest.spyOn(sourceHook, "useDiscoverSchema").mockImplementationOnce(() => baseUseDiscoverSchema);

const featuresToInject = defaultFeatures.filter((f) => f !== FeatureItem.AllowSyncSubOneHourCronExpressions);
const featuresToInject = defaultOssFeatures.filter((f) => f !== FeatureItem.AllowSyncSubOneHourCronExpressions);

const container = tlr(
<TestWrapper features={featuresToInject}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { FormikErrors, getIn } from "formik";
import isEqual from "lodash/isEqual";
import React, { memo, useCallback, useMemo } from "react";
import { useToggle } from "react-use";

import { DropDownOptionDataItem } from "components/ui/DropDown";

import { SyncSchemaField, SyncSchemaFieldObject, SyncSchemaStream } from "core/domain/catalog";
import { traverseSchemaToField } from "core/domain/catalog/fieldUtil";
import { traverseSchemaToField } from "core/domain/catalog/traverseSchemaToField";
import {
AirbyteStreamConfiguration,
DestinationSyncMode,
NamespaceDefinitionType,
SyncMode,
SelectedFieldInfo,
} from "core/request/AirbyteClient";
import { useDestinationNamespace } from "hooks/connection/useDestinationNamespace";
import { useConnectionFormService } from "hooks/services/ConnectionForm/ConnectionFormService";
Expand Down Expand Up @@ -96,6 +98,32 @@ const CatalogSectionInner: React.FC<CatalogSectionInnerProps> = ({
[updateStreamWithConfig]
);

const numberOfFieldsInStream = Object.keys(streamNode?.stream?.jsonSchema?.properties).length ?? 0;

const onSelectedFieldsUpdate = (selectedFields: SelectedFieldInfo[]) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A thought: if onSelectedFieldsUpdate took previouslySelectedFields as a second param, instead of just selectedFields, then the special-case props onFirstFieldDeselected and onAllFieldsSelected could go away and and the fact that those are special cases would be more cleanly scoped to this one file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I think that would indeed be cleaner! As discussed offline, I'll defer this to a follow-up PR

updateStreamWithConfig({
selectedFields,
fieldSelectionEnabled: true,
});
};

// All fields in a stream are implicitly selected. When deselecting the first one, we also need to explicitly select the rest.
josephkmh marked this conversation as resolved.
Show resolved Hide resolved
const onFirstFieldDeselected = (fieldPath: string[]) => {
const allOtherFields = fields.filter((field: SyncSchemaField) => !isEqual(field.path, fieldPath)) ?? [];
const selectedFields: SelectedFieldInfo[] = allOtherFields.map((field) => ({ fieldPath: field.path }));
updateStreamWithConfig({
selectedFields,
fieldSelectionEnabled: true,
});
};

const onAllFieldsSelected = () => {
updateStreamWithConfig({
selectedFields: [],
fieldSelectionEnabled: false,
});
};

const pkRequired = config?.destinationSyncMode === DestinationSyncMode.append_dedup;
const cursorRequired = config?.syncMode === SyncMode.incremental;
const shouldDefinePk = stream?.sourceDefinedPrimaryKey?.length === 0 && pkRequired;
Expand Down Expand Up @@ -182,8 +210,12 @@ const CatalogSectionInner: React.FC<CatalogSectionInnerProps> = ({
<StreamFieldTable
config={config}
syncSchemaFields={flattenedFields}
numberOfSelectableFields={numberOfFieldsInStream}
onCursorSelect={onCursorSelect}
onPkSelect={onPkSelect}
onSelectedFieldsUpdate={onSelectedFieldsUpdate}
onFirstFieldDeselected={onFirstFieldDeselected}
onAllFieldsSelected={onAllFieldsSelected}
shouldDefinePk={shouldDefinePk}
shouldDefineCursor={shouldDefineCursor}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const CatalogTreeBody: React.FC<CatalogTreeBodyProps> = ({ streams, chang
const { mode } = useConnectionFormService();

const onUpdateStream = useCallback(
// TODO (josephkmh): selectedFields should be defined by orval/backend
(id: string | undefined, newConfig: Partial<AirbyteStreamConfiguration>) => {
const streamNode = streams.find((streamNode) => streamNode.id === id);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,45 @@
import React, { memo } from "react";
import { FormattedMessage } from "react-intl";

import { HeaderCell, NameContainer } from "./styles";
import { useExperiment } from "hooks/services/Experiment";

const FieldHeaderInner: React.FC = () => (
<>
<HeaderCell lighter flex={1.5}>
<NameContainer>
<FormattedMessage id="form.field.name" />
</NameContainer>
</HeaderCell>
<HeaderCell lighter>
<FormattedMessage id="form.field.dataType" />
</HeaderCell>
<HeaderCell lighter>
<FormattedMessage id="form.field.cursorField" />
</HeaderCell>
<HeaderCell lighter>
<FormattedMessage id="form.field.primaryKey" />
</HeaderCell>
<HeaderCell lighter flex={1.5}>
<FormattedMessage id="form.field.destinationName" />
</HeaderCell>
</>
);
import { HeaderCell, SyncHeaderContainer, NameContainer } from "./styles";

const FieldHeaderInner: React.FC = () => {
const isColumnSelectionEnabled = useExperiment("connection.columnSelection", false);

return (
<>
{isColumnSelectionEnabled && (
<HeaderCell lighter flex={0}>
<SyncHeaderContainer>
<FormattedMessage id="form.field.sync" />
</SyncHeaderContainer>
</HeaderCell>
)}
<HeaderCell lighter flex={1.5}>
{!isColumnSelectionEnabled && (
<NameContainer>
<FormattedMessage id="form.field.name" />
</NameContainer>
)}
{isColumnSelectionEnabled && <FormattedMessage id="form.field.name" />}
</HeaderCell>
<HeaderCell lighter>
<FormattedMessage id="form.field.dataType" />
</HeaderCell>
<HeaderCell lighter>
<FormattedMessage id="form.field.cursorField" />
</HeaderCell>
<HeaderCell lighter>
<FormattedMessage id="form.field.primaryKey" />
</HeaderCell>
<HeaderCell lighter flex={1.5}>
<FormattedMessage id="form.field.destinationName" />
</HeaderCell>
</>
);
};

const FieldHeader = memo(FieldHeaderInner);

Expand Down
51 changes: 45 additions & 6 deletions airbyte-webapp/src/components/connection/CatalogTree/FieldRow.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
import React, { memo } from "react";
import { FormattedMessage } from "react-intl";
import styled from "styled-components";

import { Cell } from "components/SimpleTableComponents";
import { CheckBox } from "components/ui/CheckBox";
import { RadioButton } from "components/ui/RadioButton";
import { Switch } from "components/ui/Switch";
import { Tooltip } from "components/ui/Tooltip";

import { SyncSchemaField } from "core/domain/catalog";
import { SyncSchemaField, SyncSchemaFieldObject } from "core/domain/catalog";
import { AirbyteStreamConfiguration } from "core/request/AirbyteClient";
import { useExperiment } from "hooks/services/Experiment";
import { equal } from "utils/objects";
import { useTranslateDataType } from "utils/useTranslateDataType";

import DataTypeCell from "./DataTypeCell";
import { pathDisplayName } from "./PathPopout";
import { NameContainer } from "./styles";
import { NameContainer, SyncCheckboxContainer } from "./styles";

interface FieldRowProps {
isPrimaryKeyEnabled: boolean;
isCursorEnabled: boolean;

isSelected: boolean;
onPrimaryKeyChange: (pk: string[]) => void;
onCursorChange: (cs: string[]) => void;
onToggleFieldSelected: (fieldPath: string[], isSelected: boolean) => void;
field: SyncSchemaField;
config: AirbyteStreamConfiguration | undefined;
}
Expand All @@ -35,22 +40,56 @@ const LastCell = styled(Cell)`
const FieldRowInner: React.FC<FieldRowProps> = ({
onPrimaryKeyChange,
onCursorChange,
onToggleFieldSelected,
field,
config,
isCursorEnabled,
isPrimaryKeyEnabled,
isSelected,
}) => {
const isColumnSelectionEnabled = useExperiment("connection.columnSelection", false);
const dataType = useTranslateDataType(field);
const name = pathDisplayName(field.path);

const isCursor = equal(config?.cursorField, field.path);
const isPrimaryKey = !!config?.primaryKey?.some((p) => equal(p, field.path));
const isNestedField = SyncSchemaFieldObject.isNestedField(field);

return (
<>
<FirstCell ellipsis flex={1.5}>
<NameContainer title={name}>{name}</NameContainer>
</FirstCell>
{isColumnSelectionEnabled && (
<Cell flex={0}>
<SyncCheckboxContainer>
{!isNestedField && (
<Switch small checked={isSelected} onChange={() => onToggleFieldSelected(field.path, !isSelected)} />
)}
{isNestedField && (
<Tooltip
control={
<Switch
small
disabled
checked={isSelected}
onChange={() => onToggleFieldSelected(field.path, !isSelected)}
/>
}
>
<FormattedMessage id="form.field.sync.nestedFieldTooltip" values={{ fieldName: field.path[0] }} />
</Tooltip>
)}{" "}
</SyncCheckboxContainer>
</Cell>
)}
{isColumnSelectionEnabled && (
<Cell ellipsis flex={1.5}>
<span title={name}>{name}</span>
</Cell>
)}
{!isColumnSelectionEnabled && (
<FirstCell ellipsis flex={1.5}>
<NameContainer title={name}>{name}</NameContainer>
</FirstCell>
)}
<DataTypeCell>{dataType}</DataTypeCell>
<Cell>{isCursorEnabled && <RadioButton checked={isCursor} onChange={() => onCursorChange(field.path)} />}</Cell>
<Cell>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from "react";
import isEqual from "lodash/isEqual";
import React, { useCallback } from "react";

import { SyncSchemaField, SyncSchemaFieldObject } from "core/domain/catalog";
import { AirbyteStreamConfiguration } from "core/request/AirbyteClient";
import { AirbyteStreamConfiguration, SelectedFieldInfo } from "core/request/AirbyteClient";

import { FieldHeader } from "./FieldHeader";
import { FieldRow } from "./FieldRow";
Expand All @@ -10,30 +11,75 @@ import styles from "./StreamFieldTable.module.scss";
import { TreeRowWrapper } from "./TreeRowWrapper";

interface StreamFieldTableProps {
syncSchemaFields: SyncSchemaField[];
config: AirbyteStreamConfiguration | undefined;
shouldDefinePk: boolean;
shouldDefineCursor: boolean;
onCursorSelect: (cursorPath: string[]) => void;
onFirstFieldDeselected: (fieldName: string[]) => void;
onPkSelect: (pkPath: string[]) => void;
onSelectedFieldsUpdate: (selectedFields: SelectedFieldInfo[]) => void;
onAllFieldsSelected: () => void;
shouldDefineCursor: boolean;
shouldDefinePk: boolean;
syncSchemaFields: SyncSchemaField[];
numberOfSelectableFields: number;
}

export const StreamFieldTable: React.FC<StreamFieldTableProps> = (props) => {
export const StreamFieldTable: React.FC<StreamFieldTableProps> = ({
config,
onCursorSelect,
onFirstFieldDeselected,
onPkSelect,
onSelectedFieldsUpdate,
onAllFieldsSelected,
shouldDefineCursor,
shouldDefinePk,
syncSchemaFields,
numberOfSelectableFields,
}) => {
const handleFieldToggle = (fieldPath: string[], isSelected: boolean) => {
const previouslySelectedFields = config?.selectedFields || [];

if (!config?.fieldSelectionEnabled && !isSelected) {
onFirstFieldDeselected(fieldPath);
} else if (isSelected && previouslySelectedFields.length === numberOfSelectableFields - 1) {
// In this case we are selecting the only unselected field
onAllFieldsSelected();
} else if (isSelected) {
onSelectedFieldsUpdate([...previouslySelectedFields, { fieldPath }]);
} else {
onSelectedFieldsUpdate(previouslySelectedFields.filter((f) => !isEqual(f.fieldPath, fieldPath)) || []);
}
};

const isFieldSelected = useCallback(
(field: SyncSchemaField): boolean => {
// All fields are implicitly selected if field selection is disabled
if (!config?.fieldSelectionEnabled) {
return true;
}

// path[0] is the top-level field name for all nested fields
return !!config?.selectedFields?.find((f) => isEqual(f.fieldPath, [field.path[0]]));
},
[config]
);

return (
<div className={styles.container}>
<TreeRowWrapper noBorder>
<FieldHeader />
</TreeRowWrapper>
<div className={styles.rowsContainer}>
{props.syncSchemaFields.map((field) => (
{syncSchemaFields.map((field) => (
<TreeRowWrapper depth={1} key={pathDisplayName(field.path)}>
<FieldRow
field={field}
config={props.config}
isPrimaryKeyEnabled={props.shouldDefinePk && SyncSchemaFieldObject.isPrimitive(field)}
isCursorEnabled={props.shouldDefineCursor && SyncSchemaFieldObject.isPrimitive(field)}
onPrimaryKeyChange={props.onPkSelect}
onCursorChange={props.onCursorSelect}
config={config}
isPrimaryKeyEnabled={shouldDefinePk && SyncSchemaFieldObject.isPrimitive(field)}
isCursorEnabled={shouldDefineCursor && SyncSchemaFieldObject.isPrimitive(field)}
onPrimaryKeyChange={onPkSelect}
onCursorChange={onCursorSelect}
onToggleFieldSelected={handleFieldToggle}
isSelected={isFieldSelected(field)}
/>
</TreeRowWrapper>
))}
Expand Down
10 changes: 10 additions & 0 deletions airbyte-webapp/src/components/connection/CatalogTree/styles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,13 @@ export const ArrowCell = styled(HeaderCell)`
export const NameContainer = styled.span`
padding-left: 30px;
`;

export const SyncHeaderContainer = styled.span`
padding-left: 34px;
display: inline-block;
min-width: 62px;
`;

export const SyncCheckboxContainer = styled.div`
padding-left: 24px;
`;
josephkmh marked this conversation as resolved.
Show resolved Hide resolved
Loading