Skip to content

Commit 9008ad2

Browse files
committed
feat: first iteration of integrating SchemaForm into Builder UI (behind feature flag) (#15845)
## What https://github.com/user-attachments/assets/8ed2f725-1567-441c-bd96-88a812607f19 This is the first step in integrating SchemaForm into Builder UI. This PR adds a feature flag `connectorBuilder.schemaForm`, which when set to `true` causes the UI mode of Builder to render a raw SchemaForm for the stream view. ## How I approached this by adding a third case to the ConnectorBuilderEditPage's [rendering logic](https://github.com/airbytehq/airbyte-platform-internal/pull/15845/files#diff-f605f9d9694d2052bb126db465f2c37d16fa81917fce08c485db4e44581f8f28R142) (alongside yaml mode and current UI), which is executed when the feature flag is true and `mode` is `ui`. Most of the new logic lies in the new [SchemaFormBuilder](https://github.com/airbytehq/airbyte-platform-internal/pull/15845/files#diff-538753de1b3ea281377366b407b242856f39ecc6ec93e035bbca4166ae85fde7) component, which handles rendering both a new sidebar and the new stream config view. While this meant I had to repeat a bit of logic from the other UI mode, I felt this was better for feature flagging. **Note**: this state is not perfect, namely: - You can't always switch to YAML mode and then back to UI mode, since it is still validating against what is possible in the old UI mode - There is no way to configure `User Inputs` - I just didn't have time to get this working - No AI assist buttons, jinja expression suggestions, linking, etc., as I didn't have time - Doesn't break apart stream fields into separate cards, didn't have time But since this is behind a feature flag and the old implementation _should_ be unaffected by these changes, I think this is fine to merge in its current state. ## Testing If you want to test this out yourself, I recommend checking out [this branch](airbytehq/airbyte-python-cdk#480) in the `airbyte-python-cdk` repo, and after you run `pnpm start` on this branch, just copy over the `declarative_component_schema.yaml` file into this repo's webapp `build` folder, e.g (running from the `airbyte-python-cdk` repo): ``` cp airbyte_cdk/sources/declarative/declarative_component_schema.yaml ~/code/airbyte-platform-internal/oss/airbyte-webapp/build ``` This will use the corrected declarative_component_schema that changes some field ordering and titles to get to a slightly better UX.
1 parent 86d46e6 commit 9008ad2

File tree

20 files changed

+419
-85
lines changed

20 files changed

+419
-85
lines changed

airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ interface ViewSelectButtonProps {
2929
"data-testid": string;
3030
}
3131

32-
const ViewSelectButton: React.FC<React.PropsWithChildren<ViewSelectButtonProps>> = ({
32+
export const ViewSelectButton: React.FC<React.PropsWithChildren<ViewSelectButtonProps>> = ({
3333
children,
3434
className,
3535
selected,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
@use "scss/variables";
2+
@use "scss/colors";
3+
4+
.container {
5+
height: 100%;
6+
}
7+
8+
.formContainer {
9+
display: flex;
10+
flex-direction: column;
11+
overflow: auto;
12+
padding: variables.$spacing-lg;
13+
width: 100%;
14+
min-width: 600px;
15+
}
16+
17+
.streamViewText {
18+
color: inherit;
19+
overflow: hidden;
20+
white-space: nowrap;
21+
text-overflow: ellipsis;
22+
}
23+
24+
.emptyStreamViewText {
25+
color: colors.$grey-300;
26+
}
27+
28+
.streamsHeader {
29+
width: 100%;
30+
padding: 0 variables.$spacing-sm 0 variables.$spacing-md;
31+
}
32+
33+
.streamsHeading {
34+
color: colors.$blue;
35+
text-align: center;
36+
text-transform: uppercase;
37+
38+
// Set line-height to 1 to make this centered with the info tooltip icon, and because this is a single line anyway
39+
line-height: 1;
40+
padding-top: 2px;
41+
}
42+
43+
$buttonWidth: 26px;
44+
45+
.addStreamButton {
46+
width: $buttonWidth;
47+
height: $buttonWidth !important;
48+
border-radius: 50%;
49+
display: flex;
50+
align-items: center;
51+
justify-content: center;
52+
padding: 5px !important;
53+
z-index: 3;
54+
position: relative;
55+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { useMemo, useEffect } from "react";
2+
import { useFormContext, useWatch } from "react-hook-form";
3+
import { FormattedMessage } from "react-intl";
4+
5+
import { SchemaFormControl } from "components/forms/SchemaForm/Controls/SchemaFormControl";
6+
import { SchemaForm } from "components/forms/SchemaForm/SchemaForm";
7+
import { AirbyteJsonSchema } from "components/forms/SchemaForm/utils";
8+
import { Button } from "components/ui/Button";
9+
import { Card } from "components/ui/Card";
10+
import { FlexContainer, FlexItem } from "components/ui/Flex";
11+
import { Text } from "components/ui/Text";
12+
import { InfoTooltip } from "components/ui/Tooltip";
13+
14+
import {
15+
ConnectorManifest,
16+
DeclarativeComponentSchema,
17+
DeclarativeComponentSchemaStreamsItem,
18+
} from "core/api/types/ConnectorManifest";
19+
import { BuilderView, useConnectorBuilderFormState } from "services/connectorBuilder/ConnectorBuilderStateService";
20+
21+
import styles from "./SchemaFormBuilder.module.scss";
22+
import declarativeComponentSchema from "../../../../build/declarative_component_schema.yaml";
23+
import { ViewSelectButton } from "../Builder/BuilderSidebar";
24+
import { Sidebar } from "../Sidebar";
25+
import { DEFAULT_JSON_MANIFEST_STREAM_WITH_URL_BASE } from "../types";
26+
import { useBuilderErrors } from "../useBuilderErrors";
27+
28+
export const SchemaFormBuilder = () => {
29+
const view: BuilderView = useWatch({ name: "view" });
30+
const viewPath = useMemo(() => convertViewToPath(view), [view]);
31+
const { jsonManifest } = useConnectorBuilderFormState();
32+
const streams = jsonManifest.streams ?? [];
33+
34+
return (
35+
<FlexContainer className={styles.container} direction="row" gap="none">
36+
<SchemaFormBuilderSidebar />
37+
<SchemaForm<AirbyteJsonSchema, DeclarativeComponentSchema>
38+
schema={declarativeComponentSchema}
39+
nestedUnderPath="manifest"
40+
>
41+
<SyncValuesToBuilderState />
42+
{streams.length > 0 && (
43+
<FlexContainer direction="column" className={styles.formContainer}>
44+
<FlexItem alignSelf="flex-end">
45+
<DeleteStreamButton />
46+
</FlexItem>
47+
<Card>{viewPath ? <SchemaFormControl key={viewPath} path={viewPath} /> : null}</Card>
48+
</FlexContainer>
49+
)}
50+
</SchemaForm>
51+
</FlexContainer>
52+
);
53+
};
54+
55+
const convertViewToPath = (view: BuilderView) => {
56+
if (typeof view === "number") {
57+
return `manifest.streams.${view}`;
58+
}
59+
60+
if (typeof view === "string" && view.startsWith("dynamic_stream_")) {
61+
const dynamicStreamIndex = /dynamic_stream_(\d+)/.exec(view)?.[1];
62+
return dynamicStreamIndex ? `dynamic_streams.${dynamicStreamIndex}` : null;
63+
}
64+
65+
return null;
66+
};
67+
68+
const DeleteStreamButton = () => {
69+
const { setValue } = useFormContext();
70+
const view: BuilderView = useWatch({ name: "view" });
71+
const streams: DeclarativeComponentSchemaStreamsItem[] = useWatch({ name: "manifest.streams" });
72+
return (
73+
<Button
74+
variant="danger"
75+
onClick={() => {
76+
if (typeof view !== "number") {
77+
return;
78+
}
79+
if (view === streams.length - 1) {
80+
setValue("view", streams.length - 2);
81+
}
82+
setValue(
83+
"manifest.streams",
84+
streams.filter((_, index) => index !== view)
85+
);
86+
}}
87+
>
88+
<FormattedMessage id="connectorBuilder.deleteStreamModal.title" />
89+
</Button>
90+
);
91+
};
92+
93+
const SyncValuesToBuilderState = () => {
94+
const { updateJsonManifest, setFormValuesValid } = useConnectorBuilderFormState();
95+
const { trigger } = useFormContext();
96+
const schemaFormValues = useWatch({ name: "manifest" }) as ConnectorManifest;
97+
98+
useEffect(() => {
99+
// The validation logic isn't updated until the next render cycle, so wait for that
100+
// before triggering validation and updating the builder state
101+
setTimeout(() => {
102+
trigger().then((isValid) => {
103+
setFormValuesValid(isValid);
104+
updateJsonManifest(schemaFormValues);
105+
});
106+
}, 0);
107+
}, [schemaFormValues, setFormValuesValid, trigger, updateJsonManifest]);
108+
109+
return null;
110+
};
111+
112+
const SchemaFormBuilderSidebar = () => {
113+
const { setValue } = useFormContext();
114+
const { hasErrors } = useBuilderErrors();
115+
const selectedView: BuilderView = useWatch({ name: "view" });
116+
const { jsonManifest } = useConnectorBuilderFormState();
117+
const streams = jsonManifest.streams ?? [];
118+
119+
return (
120+
<Sidebar yamlSelected={false}>
121+
<FlexContainer className={styles.streamsHeader} alignItems="center" justifyContent="space-between">
122+
<FlexContainer alignItems="center" gap="none">
123+
<Text className={styles.streamsHeading} size="xs" bold>
124+
<FormattedMessage id="connectorBuilder.streamsHeading" values={{ number: streams.length }} />
125+
</Text>
126+
<InfoTooltip placement="top">
127+
<FormattedMessage id="connectorBuilder.streamTooltip" />
128+
</InfoTooltip>
129+
</FlexContainer>
130+
<Button
131+
type="button"
132+
className={styles.addStreamButton}
133+
onClick={() => {
134+
setValue("manifest.streams", [...streams, DEFAULT_JSON_MANIFEST_STREAM_WITH_URL_BASE]);
135+
setValue("view", streams.length);
136+
}}
137+
icon="plus"
138+
/>
139+
</FlexContainer>
140+
<FlexContainer direction="column" gap="xs">
141+
{streams.map((stream, index) => (
142+
<ViewSelectButton
143+
key={`${stream?.name}-${index}`}
144+
selected={selectedView === index}
145+
onClick={() => {
146+
console.log("clicked ViewSelectButton", index);
147+
setValue("view", index);
148+
}}
149+
showIndicator={hasErrors([index]) ? "error" : undefined}
150+
data-testid="schema-form-builder-view-select-button"
151+
>
152+
{stream?.name && stream?.name?.trim() ? (
153+
<Text className={styles.streamViewText}>{stream.name}</Text>
154+
) : (
155+
<Text className={styles.emptyStreamViewText}>
156+
<FormattedMessage id="connectorBuilder.emptyName" />
157+
</Text>
158+
)}
159+
</ViewSelectButton>
160+
))}
161+
</FlexContainer>
162+
</Sidebar>
163+
);
164+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { SchemaFormBuilder as default } from "./SchemaFormBuilder";

airbyte-webapp/src/components/connectorBuilder/Sidebar.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import { NameInput } from "./NameInput";
1414
import { SavingIndicator } from "./SavingIndicator";
1515
import styles from "./Sidebar.module.scss";
1616
import { UiYamlToggleButton } from "./UiYamlToggleButton";
17-
import { useBuilderWatch } from "./useBuilderWatch";
1817

1918
interface SidebarProps {
2019
className?: string;
@@ -23,9 +22,8 @@ interface SidebarProps {
2322

2423
export const Sidebar: React.FC<React.PropsWithChildren<SidebarProps>> = ({ className, yamlSelected, children }) => {
2524
const analyticsService = useAnalyticsService();
26-
const { toggleUI, isResolving, currentProject } = useConnectorBuilderFormState();
27-
const formValues = useBuilderWatch("formValues");
28-
const showSavingIndicator = yamlSelected || formValues.streams.length > 0;
25+
const { toggleUI, isResolving, currentProject, jsonManifest } = useConnectorBuilderFormState();
26+
const showSavingIndicator = yamlSelected || (jsonManifest.streams && jsonManifest.streams.length > 0);
2927

3028
const OnUiToggleClick = () => {
3129
toggleUI(yamlSelected ? "ui" : "yaml");

airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export const StreamTestButton: React.FC<StreamTestButtonProps> = ({
105105

106106
const testButton = (
107107
<Button
108-
className={classNames(styles.testButton, className, { [styles.pulsate]: isStreamTestStale })}
108+
className={classNames(styles.testButton, className, { [styles.pulsate]: isStreamTestStale && !showWarningIcon })}
109109
size="sm"
110110
onClick={executeTestRead}
111111
disabled={buttonDisabled}

airbyte-webapp/src/components/connectorBuilder/types.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export interface BuilderState {
118118
testStreamId: StreamId;
119119
generatedStreams: Record<string, DeclarativeStream[]>;
120120
testingValues: ConnectorBuilderProjectTestingValues | undefined;
121+
manifest?: ConnectorManifest;
121122
}
122123

123124
export interface AssistData {
@@ -1672,7 +1673,6 @@ function schemaRef(streamName: string) {
16721673
return { $ref: `#/schemas/${streamName}` };
16731674
}
16741675

1675-
export const DEFAULT_JSON_MANIFEST_VALUES: ConnectorManifest = convertToManifest(DEFAULT_BUILDER_FORM_VALUES);
16761676
export const DEFAULT_JSON_MANIFEST_STREAM: DeclarativeStream = {
16771677
type: "DeclarativeStream",
16781678
retriever: {
@@ -1687,13 +1687,48 @@ export const DEFAULT_JSON_MANIFEST_STREAM: DeclarativeStream = {
16871687
requester: {
16881688
type: "HttpRequester",
16891689
url_base: "",
1690-
authenticator: undefined,
1691-
path: "",
16921690
http_method: "GET",
16931691
},
1694-
paginator: undefined,
16951692
},
1696-
primary_key: undefined,
1693+
};
1694+
export const DEFAULT_JSON_MANIFEST_VALUES: ConnectorManifest = {
1695+
type: "DeclarativeSource",
1696+
version: CDK_VERSION,
1697+
check: {
1698+
type: "CheckStream",
1699+
stream_names: [],
1700+
},
1701+
streams: [],
1702+
spec: {
1703+
type: "Spec",
1704+
connection_specification: {
1705+
type: "object",
1706+
properties: {},
1707+
},
1708+
},
1709+
};
1710+
1711+
export const DEFAULT_JSON_MANIFEST_STREAM_WITH_URL_BASE: DeclarativeStream = {
1712+
type: "DeclarativeStream",
1713+
retriever: {
1714+
type: "SimpleRetriever",
1715+
record_selector: {
1716+
type: "RecordSelector",
1717+
extractor: {
1718+
type: "DpathExtractor",
1719+
field_path: [],
1720+
},
1721+
},
1722+
requester: {
1723+
type: "HttpRequester",
1724+
url_base: "https://api.com",
1725+
http_method: "GET",
1726+
},
1727+
},
1728+
};
1729+
export const DEFAULT_JSON_MANIFEST_VALUES_WITH_STREAM: ConnectorManifest = {
1730+
...DEFAULT_JSON_MANIFEST_VALUES,
1731+
streams: [DEFAULT_JSON_MANIFEST_STREAM_WITH_URL_BASE],
16971732
};
16981733

16991734
export type StreamPathFn = <T extends string>(fieldPath: T) => `formValues.streams.${number}.${T}`;

airbyte-webapp/src/components/connectorBuilder/useBuilderErrors.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ export const useBuilderErrors = () => {
118118
return;
119119
}
120120

121+
// Mark the manifest as touched to show error messages on all fields
122+
setValue("manifest", getValues("manifest"), { shouldTouch: true });
123+
121124
const errorPathAndView = getErrorPathAndView(limitToViews);
122125
if (errorPathAndView) {
123126
setValue("view", errorPathAndView.view);
@@ -136,7 +139,7 @@ export const useBuilderErrors = () => {
136139
callback?.();
137140
});
138141
},
139-
[getErrorPathAndView, getOauthErrorPathAndView, setScrollToField, setValue, trigger]
142+
[getErrorPathAndView, getOauthErrorPathAndView, setScrollToField, setValue, trigger, getValues]
140143
);
141144

142145
return { hasErrors, validateAndTouch };
@@ -187,6 +190,10 @@ const getBuilderViewToErrorPaths = (errors: FieldErrors<BuilderState>) => {
187190
: "unknown"
188191
: currentPath[0] === "testingValues"
189192
? "inputs"
193+
: currentPath[0] === "manifest"
194+
? currentPath[1] === "streams"
195+
? Number(currentPath[2])
196+
: "unknown"
190197
: "unknown";
191198
const fullPath = [...currentPath, key].join(".");
192199
if (!result[view]) {

airbyte-webapp/src/components/connectorBuilder/useLockedInputs.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useEffect } from "react";
22
import { useFormContext } from "react-hook-form";
33

4+
import { useExperiment } from "hooks/services/Experiment";
5+
46
import {
57
API_KEY_AUTHENTICATOR,
68
BASIC_AUTHENTICATOR,
@@ -24,8 +26,13 @@ export const useUpdateLockedInputs = () => {
2426
const formValues = useBuilderWatch("formValues");
2527
const testingValues = useBuilderWatch("testingValues");
2628
const { setValue, trigger } = useFormContext();
29+
const isSchemaFormEnabled = useExperiment("connectorBuilder.schemaForm");
2730

2831
useEffect(() => {
32+
if (isSchemaFormEnabled) {
33+
return;
34+
}
35+
2936
const keyToDesiredLockedInput = getKeyToDesiredLockedInput(formValues.global.authenticator, formValues.streams);
3037
const existingLockedInputKeys = formValues.inputs.filter((input) => input.isLocked).map((input) => input.key);
3138
const lockedInputKeysToCreate = Object.keys(keyToDesiredLockedInput).filter(
@@ -53,7 +60,15 @@ export const useUpdateLockedInputs = () => {
5360
});
5461
setValue("testingValues", newTestingValues);
5562
trigger("testingValues");
56-
}, [formValues.global.authenticator, formValues.inputs, formValues.streams, setValue, testingValues, trigger]);
63+
}, [
64+
formValues.global.authenticator,
65+
formValues.inputs,
66+
formValues.streams,
67+
isSchemaFormEnabled,
68+
setValue,
69+
testingValues,
70+
trigger,
71+
]);
5772
};
5873

5974
export const useGetUniqueKey = () => {

0 commit comments

Comments
 (0)