Skip to content

Commit e41700a

Browse files
committed
refactor: switch Builder Stream Templates to SchemaForm (#16468)
## What Adds support for dynamic streams (i.e. `Stream Templates` in the UI) to the schema-form Builder feature branch. ## How Similar to the other PRs in the stack, switches logic from the old `formValues` to the new `manifest` field in Builder state which SchemaForm writes to. This PR also involves shifting `generatedStreams`, an ephemeral form state, to the top level of `BuilderState` so that it is no longer part of `formValues`, and updating things accordingly. --- To achieve the generated streams view, I had to have a nested SchemaForm since `generatedStreams` are not actually part of the manifest (they are ephemeral). This required resolving the refs in the schema so that I could point it to the `DeclarativeStream` definition in the schema. If I didn't resolve first, then there would be unresolved refs in that definition which SchemaForm couldn't resolve since it wasn't given the full schema. Since resolving all refs in the schema is somewhat slow, I chose to put that into a webworker and show a loading icon in the meantime so that it could just run in the background. Once it has been resolved once, the result is cached and therefore doesn't need to be resolved again. --- The generated stream view used to put everything inside of a disabled fieldset, but this wasn't a great approach since it also disabled things like `Advanced` collapsibles, so users wouldn't be able to see what was inside of those. It also wasn't obvious that the controls themselves were disabled since they weren't styled any differently - they were just uninteractible, which made it feel somewhat broken. I took a different approach here, where I added a `disableFormControls` prop to `SchemaForm` and then passed this through as a `disabled` prop on each form control/button/checkbox. This way, we actually use the form controls' own `disabled` styles which is a lot clearer and still allows things like `Advanced` collapsibles to be interacted with. ## Testing To test, I recommend following the guide at https://docs.airbyte.com/platform/next/connector-development/connector-builder-ui/stream-templates - some of it might be slightly outdated from the changes made in this PR but it still should be pretty consistent. ## Recommended reading order 1. `oss/airbyte-webapp/src/components/connectorBuilder/Builder/DynamicStreamConfigView.tsx` main changes for the dynamic stream view 2. `oss/airbyte-webapp/src/components/connectorBuilder/Builder/GeneratedStreamView.tsx` and `oss/airbyte-webapp/src/core/services/connectorBuilder/SchemaResolverWorker.ts` - changes to achieve rendering the generated stream view with a nested SchemaForm and the web-worker needed to resolve the schema 3. `oss/airbyte-webapp/src/components/connectorBuilder/Builder/StreamConfigView.tsx` 4. `oss/airbyte-webapp/src/components/forms/SchemaForm/*` - add ability to disable all controls
1 parent a53ebd0 commit e41700a

31 files changed

+419
-260
lines changed

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import styles from "./Builder.module.scss";
1919
import { BuilderSidebar } from "./BuilderSidebar";
2020
import { ComponentsView } from "./ComponentsView";
2121
import { DynamicStreamConfigView } from "./DynamicStreamConfigView";
22+
import { GeneratedStreamView } from "./GeneratedStreamView";
2223
import { GlobalConfigView } from "./GlobalConfigView";
2324
import { InputForm, newInputInEditing } from "./InputsForm";
2425
import { InputsView } from "./InputsView";
@@ -36,16 +37,17 @@ function getView(selectedView: BuilderState["view"], scrollToTop: () => void) {
3637
case "components":
3738
return <ComponentsView />;
3839
case "dynamic_stream":
39-
return <DynamicStreamConfigView key={selectedView.index} streamId={selectedView} scrollToTop={scrollToTop} />;
40+
return <DynamicStreamConfigView key={selectedView.index} streamId={selectedView} />;
4041
case "stream":
41-
case "generated_stream":
4242
return (
4343
<StreamConfigView
4444
streamId={selectedView}
4545
key={`${selectedView.type}-${selectedView.index}`}
4646
scrollToTop={scrollToTop}
4747
/>
4848
);
49+
case "generated_stream":
50+
return <GeneratedStreamView streamId={selectedView} scrollToTop={scrollToTop} />;
4951
default:
5052
assertNever(selectedView);
5153
}

airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.module.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,7 @@ $buttonWidth: 26px;
109109
height: $buttonWidth !important;
110110
border-radius: 50%;
111111
}
112+
113+
.streamTemplateList {
114+
min-height: 70px;
115+
}

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ const DynamicStreamViewButton: React.FC<DynamicStreamViewButtonProps> = ({ name,
115115
}}
116116
>
117117
<FlexContainer className={styles.streamViewButtonContent} alignItems="center">
118-
{generatedStreams && (
118+
{generatedStreams && generatedStreams.length > 0 && (
119119
<Icon type={isOpen ? "chevronDown" : "chevronRight"} onClick={() => setIsOpen((isOpen) => !isOpen)} />
120120
)}
121121
{name && name.trim() ? (
@@ -127,7 +127,7 @@ const DynamicStreamViewButton: React.FC<DynamicStreamViewButtonProps> = ({ name,
127127
)}
128128
</FlexContainer>
129129
</ViewSelectButton>
130-
{generatedStreams && isOpen && (
130+
{generatedStreams && generatedStreams.length > 0 && isOpen && (
131131
<FlexContainer direction="column" gap="none" className={styles.generatedStreamViewContainer}>
132132
{generatedStreams.map((stream, index) => {
133133
const streamId: StreamId = { type: "generated_stream", index, dynamicStreamName: name ?? "" };
@@ -326,7 +326,12 @@ export const BuilderSidebar: React.FC<BuilderSidebarProps> = () => {
326326
</FlexContainer>
327327

328328
{areDynamicStreamsEnabled && (
329-
<FlexContainer direction="column" alignItems="stretch" gap="sm" className={styles.streamListContainer}>
329+
<FlexContainer
330+
direction="column"
331+
alignItems="stretch"
332+
gap="sm"
333+
className={classNames(styles.streamListContainer, styles.streamTemplateList)}
334+
>
330335
<FlexContainer className={styles.streamsHeader} alignItems="center" justifyContent="space-between">
331336
<FlexContainer alignItems="center" gap="none">
332337
<Text className={styles.streamsHeading} size="xs" bold>

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

Lines changed: 47 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,64 +2,66 @@ import React, { useCallback } from "react";
22
import { useFormContext } from "react-hook-form";
33
import { FormattedMessage, useIntl } from "react-intl";
44

5+
import { SchemaFormControl } from "components/forms/SchemaForm/Controls/SchemaFormControl";
56
import { Button } from "components/ui/Button";
7+
import { Card } from "components/ui/Card";
68
import { FlexContainer } from "components/ui/Flex";
79
import { Heading } from "components/ui/Heading";
810
import { InfoTooltip } from "components/ui/Tooltip";
911

12+
import { DynamicDeclarativeStream } from "core/api/types/ConnectorManifest";
1013
import { Action, Namespace, useAnalyticsService } from "core/services/analytics";
11-
import { links } from "core/utils/links";
1214
import { useConfirmationModalService } from "hooks/services/ConfirmationModal";
1315
import { BuilderView } from "services/connectorBuilder/ConnectorBuilderStateService";
1416

15-
import { BuilderCard } from "./BuilderCard";
1617
import { BuilderConfigView } from "./BuilderConfigView";
17-
import { BuilderField } from "./BuilderField";
1818
import styles from "./DynamicStreamConfigView.module.scss";
19-
import { getDescriptionByManifest, getLabelByManifest } from "./manifestHelpers";
2019
import { StreamConfigView } from "./StreamConfigView";
21-
import { manifestRecordSelectorToBuilder } from "../convertManifestToBuilderForm";
22-
import { builderRecordSelectorToManifest, DynamicStreamPathFn, StreamId } from "../types";
23-
import { useBuilderWatch } from "../useBuilderWatch";
20+
import { getStreamFieldPath, StreamId } from "../types";
2421

2522
interface DynamicStreamConfigViewProps {
2623
streamId: StreamId;
27-
scrollToTop: () => void;
2824
}
2925

30-
export const DynamicStreamConfigView: React.FC<DynamicStreamConfigViewProps> = ({ streamId, scrollToTop }) => {
26+
export const DynamicStreamConfigView: React.FC<DynamicStreamConfigViewProps> = ({ streamId }) => {
3127
const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService();
3228
const analyticsService = useAnalyticsService();
33-
const { setValue } = useFormContext();
29+
const { setValue, getValues } = useFormContext();
3430
const { formatMessage } = useIntl();
35-
const baseUrl = useBuilderWatch("formValues.global.urlBase");
36-
const dynamicStreamFieldPath: DynamicStreamPathFn = useCallback(
37-
<T extends string>(fieldPath: T) => `formValues.dynamicStreams.${streamId.index}.${fieldPath}` as const,
38-
[streamId.index]
39-
);
4031

41-
const dynamicStreams = useBuilderWatch("formValues.dynamicStreams");
32+
const dynamicStreamFieldPath = useCallback(
33+
(fieldPath?: string) => getStreamFieldPath(streamId, fieldPath),
34+
[streamId]
35+
);
4236

43-
const handleDelete = () => {
37+
const handleDelete = useCallback(() => {
4438
openConfirmationModal({
4539
text: "connectorBuilder.deleteDynamicStreamModal.text",
4640
title: "connectorBuilder.deleteDynamicStreamModal.title",
4741
submitButtonText: "connectorBuilder.deleteDynamicStreamModal.submitButton",
4842
onSubmit: () => {
43+
const dynamicStreams: DynamicDeclarativeStream[] = getValues("manifest.dynamic_streams");
4944
const updatedStreams = dynamicStreams.filter((_, index) => index !== streamId.index);
5045
const streamToSelect = streamId.index >= updatedStreams.length ? updatedStreams.length - 1 : streamId.index;
5146
const viewToSelect: BuilderView =
5247
updatedStreams.length === 0 ? { type: "global" } : { type: "dynamic_stream", index: streamToSelect };
53-
setValue("formValues.dynamicStreams", updatedStreams);
48+
setValue("manifest.dynamic_streams", updatedStreams);
5449
setValue("view", viewToSelect);
5550
closeConfirmationModal();
5651
analyticsService.track(Namespace.CONNECTOR_BUILDER, Action.DYNAMIC_STREAM_DELETE, {
5752
actionDescription: "Dynamic stream deleted",
58-
dynamic_stream_name: dynamicStreams[streamId.index].dynamicStreamName,
53+
dynamic_stream_name: dynamicStreams[streamId.index].name,
5954
});
6055
},
6156
});
62-
};
57+
}, [analyticsService, closeConfirmationModal, getValues, openConfirmationModal, setValue, streamId]);
58+
59+
const templateHeaderRef = React.useRef<HTMLDivElement>(null);
60+
const scrollToTopOfTemplate = useCallback(() => {
61+
if (templateHeaderRef.current) {
62+
templateHeaderRef.current.scrollIntoView({ behavior: "auto" });
63+
}
64+
}, []);
6365

6466
if (streamId.type !== "dynamic_stream") {
6567
return null;
@@ -69,10 +71,11 @@ export const DynamicStreamConfigView: React.FC<DynamicStreamConfigViewProps> = (
6971
<BuilderConfigView className={styles.fullHeight}>
7072
<FlexContainer direction="column" gap="2xl" className={styles.fullHeight}>
7173
<FlexContainer justifyContent="space-between" alignItems="center">
72-
<BuilderField
73-
type="string"
74-
path={dynamicStreamFieldPath("dynamicStreamName")}
75-
containerClassName={styles.streamNameInput}
74+
<SchemaFormControl
75+
path={dynamicStreamFieldPath("name")}
76+
titleOverride={null}
77+
className={styles.streamNameInput}
78+
placeholder={formatMessage({ id: "connectorBuilder.streamTemplateName.placeholder" })}
7679
/>
7780
<Button variant="danger" onClick={handleDelete}>
7881
<FormattedMessage id="connectorBuilder.deleteDynamicStreamModal.title" />
@@ -82,67 +85,44 @@ export const DynamicStreamConfigView: React.FC<DynamicStreamConfigViewProps> = (
8285
<FlexContainer direction="column" gap="md">
8386
<FlexContainer gap="none">
8487
<Heading as="h2" size="sm">
85-
<FormattedMessage id="connectorBuilder.dynamicStream.retriever.header" />
88+
<FormattedMessage id="connectorBuilder.dynamicStream.resolver.header" />
8689
</Heading>
8790
<InfoTooltip placement="top">
88-
<FormattedMessage id="connectorBuilder.dynamicStream.retriever.tooltip" />
91+
<FormattedMessage id="connectorBuilder.dynamicStream.resolver.tooltip" />
8992
</InfoTooltip>
9093
</FlexContainer>
9194

92-
<BuilderCard>
93-
<BuilderField
94-
type="jinja"
95-
path={dynamicStreamFieldPath("componentsResolver.retriever.requester.path")}
96-
manifestPath="HttpRequester.properties.path"
97-
preview={baseUrl ? (value) => `${baseUrl}${value}` : undefined}
98-
/>
99-
</BuilderCard>
100-
<BuilderCard
101-
docLink={links.connectorBuilderRecordSelector}
102-
label={getLabelByManifest("RecordSelector")}
103-
tooltip={getDescriptionByManifest("RecordSelector")}
104-
inputsConfig={{
105-
toggleable: false,
106-
path: dynamicStreamFieldPath("componentsResolver.retriever.record_selector"),
107-
defaultValue: {
108-
fieldPath: [],
109-
normalizeToSchema: false,
110-
},
111-
yamlConfig: {
112-
builderToManifest: builderRecordSelectorToManifest,
113-
manifestToBuilder: manifestRecordSelectorToBuilder,
114-
},
115-
}}
116-
>
117-
<BuilderField
118-
type="array"
119-
path={dynamicStreamFieldPath("componentsResolver.retriever.record_selector.extractor.field_path")}
120-
manifestPath="DpathExtractor.properties.field_path"
121-
optional
95+
<Card>
96+
<SchemaFormControl
97+
path={dynamicStreamFieldPath("components_resolver")}
98+
nonAdvancedFields={NON_ADVANCED_RESOLVER_FIELDS}
12299
/>
123-
<BuilderField
124-
type="jinja"
125-
path={dynamicStreamFieldPath("componentsResolver.retriever.record_selector.record_filter.condition")}
126-
label={getLabelByManifest("RecordFilter")}
127-
manifestPath="RecordFilter.properties.condition"
128-
pattern={formatMessage({ id: "connectorBuilder.condition.pattern" })}
129-
optional
130-
/>
131-
</BuilderCard>
100+
</Card>
132101
</FlexContainer>
133102

134103
<FlexContainer direction="column" gap="none" className={styles.fullHeight}>
135-
<FlexContainer gap="none">
104+
<FlexContainer gap="none" ref={templateHeaderRef}>
136105
<Heading as="h2" size="sm">
137106
<FormattedMessage id="connectorBuilder.dynamicStream.template.header" />
138107
</Heading>
139108
<InfoTooltip placement="top">
140109
<FormattedMessage id="connectorBuilder.dynamicStream.template.tooltip" />
141110
</InfoTooltip>
142111
</FlexContainer>
143-
<StreamConfigView streamId={streamId} scrollToTop={scrollToTop} />
112+
<StreamConfigView streamId={streamId} scrollToTop={scrollToTopOfTemplate} />
144113
</FlexContainer>
145114
</FlexContainer>
146115
</BuilderConfigView>
147116
);
148117
};
118+
119+
const NON_ADVANCED_RESOLVER_FIELDS = [
120+
"components_mapping",
121+
"retriever.requester.url_base",
122+
"retriever.requester.path",
123+
"retriever.requester.url",
124+
"retriever.requester.http_method",
125+
"retriever.requester.authenticator",
126+
"retriever.record_selector.extractor",
127+
"stream_config",
128+
];
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.loadingContainer {
2+
width: 100%;
3+
height: 100%;
4+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Suspense, useCallback } from "react";
2+
3+
import { SchemaForm } from "components/forms/SchemaForm/SchemaForm";
4+
import { LogoAnimation } from "components/LoadingPage/LogoAnimation";
5+
import { FlexContainer } from "components/ui/Flex";
6+
7+
import { IncomingSchema, OutgoingSchema } from "core/services/connectorBuilder/SchemaResolverWorker";
8+
import SchemaResolverWorker from "core/services/connectorBuilder/SchemaResolverWorker?worker";
9+
10+
import styles from "./GeneratedStreamView.module.scss";
11+
import { StreamConfigView } from "./StreamConfigView";
12+
import declarativeComponentSchema from "../../../../build/declarative_component_schema.yaml";
13+
import { StreamId, getStreamFieldPath } from "../types";
14+
15+
const worker = new SchemaResolverWorker();
16+
17+
let cachedResolvedSchema: object | null = null;
18+
let resolvedSchemaPromise: Promise<object> | null = null;
19+
20+
// Use a web-worker to resolve the schema, since it is a large file
21+
// and we don't want to block the main thread.
22+
const getResolvedSchema = (): object => {
23+
if (cachedResolvedSchema) {
24+
return cachedResolvedSchema;
25+
}
26+
27+
if (!resolvedSchemaPromise) {
28+
resolvedSchemaPromise = new Promise((resolve) => {
29+
// Setup the worker message handler
30+
worker.onmessage = (event: MessageEvent<OutgoingSchema>) => {
31+
const { resolvedSchema } = event.data;
32+
cachedResolvedSchema = resolvedSchema;
33+
resolve(resolvedSchema);
34+
};
35+
36+
// Post the message to the worker
37+
worker.postMessage({ schema: declarativeComponentSchema } as IncomingSchema);
38+
39+
// Suspend rendering while we wait
40+
throw resolvedSchemaPromise;
41+
});
42+
}
43+
44+
throw resolvedSchemaPromise;
45+
};
46+
47+
export const GeneratedStreamView: React.FC<{ streamId: StreamId; scrollToTop: () => void }> = ({
48+
streamId,
49+
scrollToTop,
50+
}) => {
51+
return (
52+
<Suspense
53+
fallback={
54+
<FlexContainer className={styles.loadingContainer} justifyContent="center" alignItems="center">
55+
<LogoAnimation />
56+
</FlexContainer>
57+
}
58+
>
59+
<GeneratedStreamForm streamId={streamId} scrollToTop={scrollToTop} />
60+
</Suspense>
61+
);
62+
};
63+
64+
const GeneratedStreamForm = ({ streamId, scrollToTop }: { streamId: StreamId; scrollToTop: () => void }) => {
65+
// Resolve the schema so that we can use a sub-schema in it for SchemaForm below
66+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
67+
const resolvedSchema = getResolvedSchema() as Record<string, any>;
68+
const streamFieldPath = useCallback((fieldPath?: string) => getStreamFieldPath(streamId, fieldPath), [streamId]);
69+
70+
return (
71+
<SchemaForm
72+
key={streamFieldPath()}
73+
schema={resolvedSchema.definitions.DeclarativeStream}
74+
nestedUnderPath={streamFieldPath()}
75+
disableFormControls
76+
disableValidation
77+
>
78+
<StreamConfigView streamId={streamId} scrollToTop={scrollToTop} />
79+
</SchemaForm>
80+
);
81+
};

0 commit comments

Comments
 (0)