Skip to content

Commit

Permalink
🪟 🎉 Column selection UI (#20267)
Browse files Browse the repository at this point in the history
  • Loading branch information
josephkmh authored Dec 14, 2022
1 parent ea3db51 commit c0ca3b3
Show file tree
Hide file tree
Showing 19 changed files with 311 additions and 64 deletions.
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[]) => {
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.
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
@@ -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
42 changes: 36 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,47 @@ 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} />}>
<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;
`;
2 changes: 1 addition & 1 deletion airbyte-webapp/src/core/domain/catalog/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export * from "./models";
export * from "./api";
export * from "./fieldUtil";
export * from "./traverseSchemaToField";
4 changes: 4 additions & 0 deletions airbyte-webapp/src/core/domain/catalog/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ export class SyncSchemaFieldObject {
static isPrimitive(field: SyncSchemaField): boolean {
return !(field.type === "object" || field.type === "array");
}

static isNestedField(field: SyncSchemaField): boolean {
return field.path.length > 1;
}
}
Loading

0 comments on commit c0ca3b3

Please sign in to comment.