diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/AddStreamButton.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/AddStreamButton.tsx index 6a368fa5b319..f4a72b90aa35 100644 --- a/airbyte-webapp/src/components/connectorBuilder/Builder/AddStreamButton.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/AddStreamButton.tsx @@ -79,13 +79,13 @@ export const AddStreamButton: React.FC = ({ onAddStream, b diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/AuthenticationSection.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/AuthenticationSection.tsx new file mode 100644 index 000000000000..89a06bcba7a6 --- /dev/null +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/AuthenticationSection.tsx @@ -0,0 +1,179 @@ +import { BuilderCard } from "./BuilderCard"; +import { BuilderField } from "./BuilderField"; +import { BuilderOneOf } from "./BuilderOneOf"; +import { BuilderOptional } from "./BuilderOptional"; +import { KeyValueListField } from "./KeyValueListField"; +import { UserInputField } from "./UserInputField"; + +export const AuthenticationSection: React.FC = () => { + return ( + + + + + + ), + }, + { + label: "Bearer", + typeValue: "BearerAuthenticator", + default: { + api_token: "{{ config['api_key'] }}", + }, + children: ( + + ), + }, + { + label: "Basic HTTP", + typeValue: "BasicHttpAuthenticator", + default: { + username: "{{ config['username'] }}", + password: "{{ config['password'] }}", + }, + children: ( + <> + + + + ), + }, + { + label: "OAuth", + typeValue: "OAuthAuthenticator", + default: { + client_id: "{{ config['client_id'] }}", + client_secret: "{{ config['client_secret'] }}", + refresh_token: "{{ config['client_refresh_token'] }}", + refresh_request_body: [], + token_refresh_endpoint: "", + }, + children: ( + <> + + + + + + + + + + + + + + ), + }, + { + label: "Session token", + typeValue: "SessionTokenAuthenticator", + default: { + username: "{{ config['username'] }}", + password: "{{ config['password'] }}", + session_token: "{{ config['session_token'] }}", + }, + children: ( + <> + + + + + + + + + ), + }, + ]} + /> + + ); +}; diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/Builder.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/Builder.tsx index fb40a24a2d9f..06a2957f29ad 100644 --- a/airbyte-webapp/src/components/connectorBuilder/Builder/Builder.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/Builder.tsx @@ -1,12 +1,13 @@ import { Form } from "formik"; import { useEffect } from "react"; -import { useConnectorBuilderState } from "services/connectorBuilder/ConnectorBuilderStateService"; +import { BuilderView, useConnectorBuilderState } from "services/connectorBuilder/ConnectorBuilderStateService"; -import { BuilderFormValues } from "../types"; +import { builderFormValidationSchema, BuilderFormValues } from "../types"; import styles from "./Builder.module.scss"; import { BuilderSidebar } from "./BuilderSidebar"; import { GlobalConfigView } from "./GlobalConfigView"; +import { InputsView } from "./InputsView"; import { StreamConfigView } from "./StreamConfigView"; interface BuilderProps { @@ -14,18 +15,27 @@ interface BuilderProps { toggleYamlEditor: () => void; } +function getView(selectedView: BuilderView) { + switch (selectedView) { + case "global": + return ; + case "inputs": + return ; + default: + return ; + } +} + export const Builder: React.FC = ({ values, toggleYamlEditor }) => { const { setBuilderFormValues, selectedView } = useConnectorBuilderState(); useEffect(() => { - setBuilderFormValues(values); + setBuilderFormValues(values, builderFormValidationSchema.isValidSync(values)); }, [values, setBuilderFormValues]); return (
-
- {selectedView === "global" ? : } - +
{getView(selectedView)}
); }; diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderField.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderField.tsx index 1b9ad8849d7b..1bffdfcc8d72 100644 --- a/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderField.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderField.tsx @@ -2,10 +2,12 @@ import { useField } from "formik"; import { FormattedMessage } from "react-intl"; import { ControlLabels } from "components/LabeledControl"; +import { LabeledSwitch } from "components/LabeledSwitch"; import { DropDown } from "components/ui/DropDown"; import { Input } from "components/ui/Input"; import { TagInput } from "components/ui/TagInput"; import { Text } from "components/ui/Text"; +import { InfoTooltip } from "components/ui/Tooltip/InfoTooltip"; import styles from "./BuilderField.module.scss"; @@ -28,10 +30,17 @@ interface BaseFieldProps { path: string; label: string; tooltip?: string; + readOnly?: boolean; optional?: boolean; } -type BuilderFieldProps = BaseFieldProps & ({ type: "text" | "array" } | { type: "enum"; options: string[] }); +type BuilderFieldProps = BaseFieldProps & + ( + | { type: "string" | "number" | "integer"; onChange?: (newValue: string) => void } + | { type: "boolean"; onChange?: (newValue: boolean) => void } + | { type: "array"; onChange?: (newValue: string[]) => void } + | { type: "enum"; onChange?: (newValue: string) => void; options: string[] } + ); const EnumField: React.FC = ({ options, value, setValue, error, ...props }) => { return ( @@ -51,23 +60,56 @@ const ArrayField: React.FC = ({ name, value, setValue, error }) return setValue(value)} error={error} />; }; -export const BuilderField: React.FC = ({ path, label, tooltip, optional = false, ...props }) => { +export const BuilderField: React.FC = ({ + path, + label, + tooltip, + optional = false, + readOnly, + ...props +}) => { const [field, meta, helpers] = useField(path); const hasError = !!meta.error && meta.touched; + if (props.type === "boolean") { + return ( + + {label} {tooltip && {tooltip}} + + } + /> + ); + } + + const setValue = (newValue: unknown) => { + props.onChange?.(newValue as string & string[]); + helpers.setValue(newValue); + }; + return ( - {props.type === "text" && } + {(props.type === "number" || props.type === "string" || props.type === "integer") && ( + { + field.onChange(e); + props.onChange?.(e.target.value); + }} + type={props.type} + value={field.value ?? ""} + error={hasError} + readOnly={readOnly} + /> + )} {props.type === "array" && ( - + )} {props.type === "enum" && ( - + )} {hasError && ( diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderOneOf.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderOneOf.tsx new file mode 100644 index 000000000000..822d99927574 --- /dev/null +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderOneOf.tsx @@ -0,0 +1,62 @@ +import { useField } from "formik"; +import React from "react"; + +import GroupControls from "components/GroupControls"; +import { ControlLabels } from "components/LabeledControl"; +import { DropDown } from "components/ui/DropDown"; + +interface Option { + label: string; + value: string; + default?: object; +} + +interface OneOfOption { + label: string; // label shown in the dropdown menu + typeValue: string; // value to set on the `type` field for this component - should match the oneOf type definition + default?: object; // default values for the path + children?: React.ReactNode; +} + +interface BuilderOneOfProps { + options: OneOfOption[]; + path: string; // path to the oneOf component in the json schema + label: string; + tooltip: string; +} + +export const BuilderOneOf: React.FC = ({ options, path, label, tooltip }) => { + const [, , oneOfPathHelpers] = useField(path); + const typePath = `${path}.type`; + const [typePathField] = useField(typePath); + const value = typePathField.value; + + const selectedOption = options.find((option) => option.typeValue === value); + + return ( + } + dropdown={ + { + return { label: option.label, value: option.typeValue, default: option.default }; + })} + value={value ?? options[0].typeValue} + onChange={(selectedOption: Option) => { + if (selectedOption.value === value) { + return; + } + // clear all values for this oneOf and set selected option and default values + oneOfPathHelpers.setValue({ + type: selectedOption.value, + ...selectedOption.default, + }); + }} + /> + } + > + {selectedOption?.children} + + ); +}; diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderOptional.module.scss b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderOptional.module.scss new file mode 100644 index 000000000000..b5a7816f6ae1 --- /dev/null +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderOptional.module.scss @@ -0,0 +1,34 @@ +@use "scss/variables"; +@use "scss/colors"; +@use "scss/mixins"; + +.wrapper { + display: flex; + flex-direction: column; + align-items: flex-start; + border-top: variables.$border-thin solid colors.$grey-100; + gap: variables.$spacing-lg; +} + +.container { + padding-left: variables.$spacing-xl; + display: flex; + flex-direction: column; + align-items: stretch; + align-self: stretch; + gap: variables.$spacing-lg; +} + +.label { + cursor: pointer; + background: none; + border: none; + display: flex; + gap: variables.$spacing-sm; + margin-top: variables.$spacing-lg; + align-items: center; + + &.closed { + color: colors.$grey-400; + } +} diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderOptional.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderOptional.tsx new file mode 100644 index 000000000000..639f5358726b --- /dev/null +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderOptional.tsx @@ -0,0 +1,25 @@ +import { faAngleDown, faAngleRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import classNames from "classnames"; +import React, { useState } from "react"; +import { FormattedMessage } from "react-intl"; + +import styles from "./BuilderOptional.module.scss"; + +export const BuilderOptional: React.FC> = ({ children }) => { + const [isOpen, setIsOpen] = useState(false); + return ( +
+ + {isOpen &&
{children}
} +
+ ); +}; diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.module.scss b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.module.scss index ee4927d9320c..1248a975e097 100644 --- a/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.module.scss +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.module.scss @@ -31,6 +31,7 @@ .connectorNameText { margin-left: auto; margin-right: auto; + margin-bottom: variables.$spacing-lg; } .streamsHeader { @@ -83,7 +84,7 @@ } .globalConfigButton { - margin-top: variables.$spacing-xl; + margin-top: variables.$spacing-sm; } .streamList { diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.tsx index 11a2f53b553e..3894595d67bd 100644 --- a/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.tsx @@ -1,4 +1,4 @@ -import { faSliders } from "@fortawesome/free-solid-svg-icons"; +import { faSliders, faUser } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import classnames from "classnames"; import { useFormikContext } from "formik"; @@ -17,7 +17,7 @@ import { } from "services/connectorBuilder/ConnectorBuilderStateService"; import { DownloadYamlButton } from "../DownloadYamlButton"; -import { BuilderFormValues } from "../types"; +import { BuilderFormValues, getInferredInputs } from "../types"; import { useBuilderErrors } from "../useBuilderErrors"; import { AddStreamButton } from "./AddStreamButton"; import styles from "./BuilderSidebar.module.scss"; @@ -76,7 +76,7 @@ export const BuilderSidebar: React.FC = ({ className, toggl }; const handleViewSelect = (selectedView: BuilderView) => { setSelectedView(selectedView); - if (selectedView !== "global") { + if (selectedView !== "global" && selectedView !== "inputs") { setTestStreamIndex(selectedView); } }; @@ -108,6 +108,19 @@ export const BuilderSidebar: React.FC = ({ className, toggl + handleViewSelect("inputs")} + > + + + +
diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/GlobalConfigView.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/GlobalConfigView.tsx index d8495cd83000..bc8e29a532a5 100644 --- a/airbyte-webapp/src/components/connectorBuilder/Builder/GlobalConfigView.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/GlobalConfigView.tsx @@ -1,5 +1,6 @@ import { useIntl } from "react-intl"; +import { AuthenticationSection } from "./AuthenticationSection"; import { BuilderCard } from "./BuilderCard"; import { BuilderConfigView } from "./BuilderConfigView"; import { BuilderField } from "./BuilderField"; @@ -14,8 +15,9 @@ export const GlobalConfigView: React.FC = () => { {/* Not using intl for the labels and tooltips in this component in order to keep maintainence simple */} - + + ); }; diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/InputsView.module.scss b/airbyte-webapp/src/components/connectorBuilder/Builder/InputsView.module.scss new file mode 100644 index 000000000000..36580839d42d --- /dev/null +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/InputsView.module.scss @@ -0,0 +1,50 @@ +@use "scss/variables"; +@use "scss/colors"; + +.list { + display: flex; + flex-direction: column; + gap: variables.$spacing-md; + list-style-type: none; + padding: 0; + align-items: stretch; + margin: 0; +} + +.listItem { + display: flex; + align-items: center; +} + +.itemLabel { + flex-grow: 1; +} + +.itemButton { + background: none !important; + border: none !important; + padding: variables.$spacing-xs; +} + +.inputForm { + gap: variables.$spacing-lg; + display: flex; + flex-direction: column; +} + +.inputsDescription { + margin-top: variables.$spacing-xl; + margin-bottom: variables.$spacing-lg; +} + +.addInputButton { + align-self: center; +} + +.inputsCard { + align-self: stretch; +} + +.deleteButtonContainer { + flex-grow: 1; +} diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/InputsView.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/InputsView.tsx new file mode 100644 index 000000000000..a934422b60f6 --- /dev/null +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/InputsView.tsx @@ -0,0 +1,352 @@ +import { faGear, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Form, Formik, useField, useFormikContext } from "formik"; +import { JSONSchema7 } from "json-schema"; +import { useMemo, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useEffectOnce } from "react-use"; +import * as yup from "yup"; + +import { Button } from "components/ui/Button"; +import { Card } from "components/ui/Card"; +import { InfoBox } from "components/ui/InfoBox"; +import { Modal, ModalBody, ModalFooter } from "components/ui/Modal"; +import { Text } from "components/ui/Text"; + +import { FormikPatch } from "core/form/FormikPatch"; + +import { BuilderFormInput, BuilderFormValues, getInferredInputs } from "../types"; +import { BuilderConfigView } from "./BuilderConfigView"; +import { BuilderField } from "./BuilderField"; +import styles from "./InputsView.module.scss"; + +const supportedTypes = ["string", "integer", "number", "array", "boolean", "enum", "unknown"] as const; + +interface InputInEditing { + key: string; + definition: JSONSchema7; + required: boolean; + isNew?: boolean; + showDefaultValueField: boolean; + type: typeof supportedTypes[number]; + isInferredInputOverride: boolean; +} + +function sluggify(str: string) { + return str.toLowerCase().replaceAll(/[^a-zA-Z\d]/g, "_"); +} + +function newInputInEditing(): InputInEditing { + return { + key: "", + definition: {}, + required: false, + isNew: true, + showDefaultValueField: false, + type: "string", + isInferredInputOverride: false, + }; +} + +function formInputToInputInEditing( + { key, definition, required }: BuilderFormInput, + isInferredInputOverride: boolean +): InputInEditing { + const supportedType = supportedTypes.find((type) => type === definition.type) || "unknown"; + return { + key, + definition, + required, + isNew: false, + showDefaultValueField: Boolean(definition.default), + type: supportedType !== "unknown" && definition.enum ? "enum" : supportedType, + isInferredInputOverride, + }; +} + +function inputInEditingToFormInput({ + type, + showDefaultValueField, + isNew, + ...values +}: InputInEditing): BuilderFormInput { + return { + ...values, + definition: { + ...values.definition, + type: type === "enum" ? "string" : type === "unknown" ? values.definition.type : type, + // only respect the enum values if the user explicitly selected enum as type + enum: type === "enum" && values.definition.enum?.length ? values.definition.enum : undefined, + default: showDefaultValueField ? values.definition.default : undefined, + }, + }; +} + +export const InputsView: React.FC = () => { + const { formatMessage } = useIntl(); + const { values, setFieldValue } = useFormikContext(); + const [inputs, , helpers] = useField("inputs"); + const [inputInEditing, setInputInEditing] = useState(undefined); + const inferredInputs = useMemo(() => getInferredInputs(values), [values]); + const usedKeys = useMemo( + () => [...inputs.value, ...inferredInputs].map((input) => input.key), + [inputs.value, inferredInputs] + ); + const inputInEditValidation = useMemo( + () => + yup.object().shape({ + // make sure key can only occur once + key: yup + .string() + .notOneOf( + inputInEditing?.isNew ? usedKeys : usedKeys.filter((key) => key !== inputInEditing?.key), + "connectorBuilder.duplicateFieldID" + ), + required: yup.bool(), + definition: yup.object().shape({ + title: yup.string().required("form.empty.error"), + }), + }), + [inputInEditing?.isNew, inputInEditing?.key, usedKeys] + ); + + return ( + + + + + {(inputs.value.length > 0 || inferredInputs.length > 0) && ( + +
    + {inferredInputs.map((input) => ( + + ))} + {inputs.value.map((input) => ( + + ))} +
+
+ )} + + {inputInEditing && ( + { + if (values.isInferredInputOverride) { + setFieldValue(`inferredInputOverrides.${values.key}`, values.definition); + } else { + const newInput = inputInEditingToFormInput(values); + helpers.setValue( + inputInEditing.isNew + ? [...inputs.value, newInput] + : inputs.value.map((input) => (input.key === inputInEditing.key ? newInput : input)) + ); + } + setInputInEditing(undefined); + }} + > + <> + + { + helpers.setValue(inputs.value.filter((input) => input.key !== inputInEditing.key)); + setInputInEditing(undefined); + }} + onClose={() => { + setInputInEditing(undefined); + }} + /> + + + )} +
+ ); +}; + +const InputModal = ({ + inputInEditing, + onClose, + onDelete, +}: { + inputInEditing: InputInEditing; + onDelete: () => void; + onClose: () => void; +}) => { + const isInferredInputOverride = inputInEditing.isInferredInputOverride; + const { isValid, values, setFieldValue, setTouched } = useFormikContext(); + + const { formatMessage } = useIntl(); + useEffectOnce(() => { + // key input is always touched so errors are shown right away as it will be auto-set by the user changing the title + setTouched({ key: true }); + }); + + return ( + + } + onClose={onClose} + > +
+ + { + if (!isInferredInputOverride) { + setFieldValue("key", sluggify(newValue || ""), true); + } + }} + label={formatMessage({ id: "connectorBuilder.inputModal.inputName" })} + tooltip={formatMessage({ id: "connectorBuilder.inputModal.inputNameTooltip" })} + /> + + + {values.type !== "unknown" && !isInferredInputOverride ? ( + <> + { + setFieldValue("definition.default", undefined); + }} + label={formatMessage({ id: "connectorBuilder.inputModal.type" })} + tooltip={formatMessage({ id: "connectorBuilder.inputModal.typeTooltip" })} + /> + {values.type === "enum" && ( + + )} + + + + {values.showDefaultValueField && ( + + )} + + + ) : ( + + {isInferredInputOverride ? ( + + ) : ( + + )} + + )} + + + {!inputInEditing.isNew && !inputInEditing.isInferredInputOverride && ( +
+ +
+ )} + + +
+
+
+ ); +}; + +const InputItem = ({ + input, + setInputInEditing, + isInferredInput, +}: { + input: BuilderFormInput; + setInputInEditing: (inputInEditing: InputInEditing) => void; + isInferredInput: boolean; +}): JSX.Element => { + return ( +
  • +
    {input.definition.title || input.key}
    + +
  • + ); +}; diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/StreamConfigView.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/StreamConfigView.tsx index 372c730c3f05..f346fbfd4819 100644 --- a/airbyte-webapp/src/components/connectorBuilder/Builder/StreamConfigView.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/StreamConfigView.tsx @@ -69,7 +69,7 @@ export const StreamConfigView: React.FC = ({ streamNum })
    = ({ label, tooltip }) => { + return ( + + }> + + + + ); +}; diff --git a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.module.scss b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.module.scss index d55c44c2758c..73b43de5ec13 100644 --- a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.module.scss +++ b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.module.scss @@ -1,6 +1,10 @@ @use "scss/colors"; @use "scss/variables"; +.icon { + color: colors.$grey-500; +} + .formContent { max-height: 60vh; overflow: auto; @@ -29,3 +33,9 @@ gap: variables.$spacing-md; align-items: center; } + +.inputsErrorBadge { + position: absolute; + top: -1 * variables.$spacing-md; + right: -1 * variables.$spacing-md; +} diff --git a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.tsx b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.tsx index 21f45bb9e26a..b2b3f224d96b 100644 --- a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.tsx @@ -1,14 +1,15 @@ -import { faClose, faGear } from "@fortawesome/free-solid-svg-icons"; +import { faClose, faUser } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useLocalStorage } from "react-use"; import { Button } from "components/ui/Button"; import { InfoBox } from "components/ui/InfoBox"; import { Modal, ModalBody } from "components/ui/Modal"; +import { NumberBadge } from "components/ui/NumberBadge"; import { Tooltip } from "components/ui/Tooltip"; +import { StreamReadRequestBodyConfig } from "core/request/ConnectorBuilderClient"; import { useConnectorBuilderState } from "services/connectorBuilder/ConnectorBuilderStateService"; import { ConnectorForm } from "views/Connector/ConnectorForm"; @@ -17,19 +18,17 @@ import { ConfigMenuErrorBoundaryComponent } from "./ConfigMenuErrorBoundary"; interface ConfigMenuProps { className?: string; + configJsonErrors: number; + isOpen: boolean; + setIsOpen: (open: boolean) => void; } -export const ConfigMenu: React.FC = ({ className }) => { - const [isOpen, setIsOpen] = useState(false); +export const ConfigMenu: React.FC = ({ className, configJsonErrors, isOpen, setIsOpen }) => { const { formatMessage } = useIntl(); - const { configString, setConfigString, jsonManifest, editorView, setEditorView } = useConnectorBuilderState(); + const { configJson, setConfigJson, jsonManifest, editorView, setEditorView } = useConnectorBuilderState(); const [showInputsWarning, setShowInputsWarning] = useLocalStorage("connectorBuilderInputsWarning", true); - const formValues = useMemo(() => { - return { connectionConfiguration: JSON.parse(configString) }; - }, [configString]); - const switchToYaml = () => { setEditorView("yaml"); setIsOpen(false); @@ -39,13 +38,20 @@ export const ConfigMenu: React.FC = ({ className }) => { <> setIsOpen(true)} - disabled={!jsonManifest.spec} - icon={} - /> + <> + + {configJsonErrors > 0 && ( + + )} + } placement={editorView === "yaml" ? "left" : "top"} containerClassName={className} @@ -88,14 +94,17 @@ export const ConfigMenu: React.FC = ({ className }) => { bodyClassName={styles.formContent} footerClassName={styles.inputFormModalFooter} selectedConnectorDefinitionSpecification={jsonManifest.spec} - formValues={formValues} + formValues={{ connectionConfiguration: configJson }} onSubmit={async (values) => { - setConfigString(JSON.stringify(values.connectionConfiguration, null, 2) ?? ""); + setConfigJson(values.connectionConfiguration as StreamReadRequestBodyConfig); setIsOpen(false); }} onCancel={() => { setIsOpen(false); }} + onReset={() => { + setConfigJson({}); + }} submitLabel={formatMessage({ id: "connectorBuilder.saveInputsForm" })} /> diff --git a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestButton.tsx b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestButton.tsx index 482893c176e0..4e382aef7518 100644 --- a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestButton.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestButton.tsx @@ -14,13 +14,23 @@ import styles from "./StreamTestButton.module.scss"; interface StreamTestButtonProps { readStream: () => void; + hasConfigJsonErrors: boolean; + setTestInputOpen: (open: boolean) => void; } -export const StreamTestButton: React.FC = ({ readStream }) => { +export const StreamTestButton: React.FC = ({ + readStream, + hasConfigJsonErrors, + setTestInputOpen, +}) => { const { editorView, yamlIsValid, testStreamIndex } = useConnectorBuilderState(); const { hasErrors, validateAndTouch } = useBuilderErrors(); const handleClick = () => { + if (hasConfigJsonErrors) { + setTestInputOpen(true); + return; + } if (editorView === "yaml") { readStream(); return; @@ -39,7 +49,7 @@ export const StreamTestButton: React.FC = ({ readStream } tooltipContent = ; } - if (editorView === "ui" && hasErrors(true, ["global", testStreamIndex])) { + if ((editorView === "ui" && hasErrors(true, ["global", testStreamIndex])) || hasConfigJsonErrors) { showWarningIcon = true; tooltipContent = ; } diff --git a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTester.tsx b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTester.tsx index 9c0908cd3ac1..dc8027a6d0a9 100644 --- a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTester.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTester.tsx @@ -13,7 +13,10 @@ import { ResultDisplay } from "./ResultDisplay"; import { StreamTestButton } from "./StreamTestButton"; import styles from "./StreamTester.module.scss"; -export const StreamTester: React.FC = () => { +export const StreamTester: React.FC<{ + hasConfigJsonErrors: boolean; + setTestInputOpen: (open: boolean) => void; +}> = ({ hasConfigJsonErrors, setTestInputOpen }) => { const { formatMessage } = useIntl(); const { jsonManifest, configJson, streams, testStreamIndex } = useConnectorBuilderState(); const { @@ -55,7 +58,11 @@ export const StreamTester: React.FC = () => { {streams[testStreamIndex]?.url}
    - + {isFetching && (
    diff --git a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestingPanel.module.scss b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestingPanel.module.scss index 3b1d67fa022d..fa2474e5efbf 100644 --- a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestingPanel.module.scss +++ b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestingPanel.module.scss @@ -15,8 +15,6 @@ $buttonHeight: 36px; position: absolute; top: variables.$spacing-lg; left: variables.$spacing-lg; - height: $buttonHeight; - width: $buttonHeight; } .streamSelector { diff --git a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestingPanel.tsx b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestingPanel.tsx index 6cc2632bf6ad..f3799ed1fe46 100644 --- a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestingPanel.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestingPanel.tsx @@ -1,10 +1,15 @@ -import React from "react"; +import React, { useMemo, useState } from "react"; import { FormattedMessage } from "react-intl"; +import { ValidationError } from "yup"; import { Heading } from "components/ui/Heading"; import { Spinner } from "components/ui/Spinner"; import { Text } from "components/ui/Text"; +import { SourceDefinitionSpecificationDraft } from "core/domain/connector"; +import { jsonSchemaToFormBlock } from "core/form/schemaToFormBlock"; +import { buildYupFormForJsonSchema } from "core/form/schemaToYup"; +import { StreamReadRequestBodyConfig } from "core/request/ConnectorBuilderClient"; import { useConnectorBuilderState } from "services/connectorBuilder/ConnectorBuilderStateService"; import { links } from "utils/links"; @@ -13,8 +18,33 @@ import { StreamSelector } from "./StreamSelector"; import { StreamTester } from "./StreamTester"; import styles from "./StreamTestingPanel.module.scss"; +const EMPTY_SCHEMA = {}; + +function useConfigJsonErrors( + configJson: StreamReadRequestBodyConfig, + spec?: SourceDefinitionSpecificationDraft +): number { + return useMemo(() => { + try { + const jsonSchema = spec && spec.connectionSpecification ? spec.connectionSpecification : EMPTY_SCHEMA; + const formFields = jsonSchemaToFormBlock(jsonSchema); + const validationSchema = buildYupFormForJsonSchema(jsonSchema, formFields); + validationSchema.validateSync(configJson, { abortEarly: false }); + return 0; + } catch (e) { + if (ValidationError.isError(e)) { + return e.errors.length; + } + return 1; + } + }, [configJson, spec]); +} + export const StreamTestingPanel: React.FC = () => { - const { jsonManifest, streamListErrorMessage, yamlEditorIsMounted } = useConnectorBuilderState(); + const [isTestInputOpen, setTestInputOpen] = useState(false); + const { jsonManifest, configJson, streamListErrorMessage, yamlEditorIsMounted } = useConnectorBuilderState(); + + const configJsonErrors = useConfigJsonErrors(configJson, jsonManifest.spec); if (!yamlEditorIsMounted) { return ( @@ -38,10 +68,15 @@ export const StreamTestingPanel: React.FC = () => { )} {hasStreams && streamListErrorMessage === undefined && ( <> - +
    - + 0} setTestInputOpen={setTestInputOpen} />
    )} diff --git a/airbyte-webapp/src/components/connectorBuilder/types.ts b/airbyte-webapp/src/components/connectorBuilder/types.ts index 7219e5c13c71..9350135732a9 100644 --- a/airbyte-webapp/src/components/connectorBuilder/types.ts +++ b/airbyte-webapp/src/components/connectorBuilder/types.ts @@ -1,12 +1,45 @@ +import { JSONSchema7 } from "json-schema"; import * as yup from "yup"; -import { ConnectorManifest, DeclarativeStream } from "core/request/ConnectorManifest"; +import { SourceDefinitionSpecificationDraft } from "core/domain/connector"; +import { PatchedConnectorManifest } from "core/domain/connectorBuilder/PatchedConnectorManifest"; +import { AirbyteJSONSchema } from "core/jsonSchema/types"; +import { + ApiKeyAuthenticator, + BasicHttpAuthenticator, + BearerAuthenticator, + DeclarativeOauth2AuthenticatorAllOf, + DeclarativeStream, + HttpRequesterAllOfAuthenticator, + NoAuth, + SessionTokenAuthenticator, +} from "core/request/ConnectorManifest"; + +export interface BuilderFormInput { + key: string; + required: boolean; + definition: AirbyteJSONSchema; +} + +type BuilderFormAuthenticator = ( + | NoAuth + | (Omit & { + refresh_request_body: Array<[string, string]>; + }) + | ApiKeyAuthenticator + | BearerAuthenticator + | BasicHttpAuthenticator + | SessionTokenAuthenticator +) & { type: string }; export interface BuilderFormValues { global: { connectorName: string; urlBase: string; + authenticator: BuilderFormAuthenticator; }; + inputs: BuilderFormInput[]; + inferredInputOverrides: Record>; streams: BuilderStream[]; } @@ -21,11 +54,162 @@ export interface BuilderStream { requestBody: Array<[string, string]>; }; } +function getInferredInputList(values: BuilderFormValues): BuilderFormInput[] { + if (values.global.authenticator.type === "ApiKeyAuthenticator") { + return [ + { + key: "api_key", + required: true, + definition: { + type: "string", + title: "API Key", + airbyte_secret: true, + }, + }, + ]; + } + if (values.global.authenticator.type === "BearerAuthenticator") { + return [ + { + key: "api_key", + required: true, + definition: { + type: "string", + title: "API Key", + airbyte_secret: true, + }, + }, + ]; + } + if (values.global.authenticator.type === "BasicHttpAuthenticator") { + return [ + { + key: "username", + required: true, + definition: { + type: "string", + title: "Username", + }, + }, + { + key: "password", + required: true, + definition: { + type: "string", + title: "Password", + airbyte_secret: true, + }, + }, + ]; + } + if (values.global.authenticator.type === "OAuthAuthenticator") { + return [ + { + key: "client_id", + required: true, + definition: { + type: "string", + title: "Client ID", + airbyte_secret: true, + }, + }, + { + key: "client_secret", + required: true, + definition: { + type: "string", + title: "Client secret", + airbyte_secret: true, + }, + }, + { + key: "refresh_token", + required: true, + definition: { + type: "string", + title: "Refresh token", + airbyte_secret: true, + }, + }, + ]; + } + if (values.global.authenticator.type === "SessionTokenAuthenticator") { + return [ + { + key: "username", + required: false, + definition: { + type: "string", + title: "Username", + }, + }, + { + key: "password", + required: false, + definition: { + type: "string", + title: "Password", + airbyte_secret: true, + }, + }, + { + key: "session_token", + required: false, + definition: { + type: "string", + title: "Session token", + description: "Session token generated by user (if provided username and password are not required)", + airbyte_secret: true, + }, + }, + ]; + } + return []; +} + +export function getInferredInputs(values: BuilderFormValues): BuilderFormInput[] { + const inferredInputs = getInferredInputList(values); + return inferredInputs.map((input) => + values.inferredInputOverrides[input.key] + ? { + ...input, + definition: { ...input.definition, ...values.inferredInputOverrides[input.key] }, + } + : input + ); +} export const builderFormValidationSchema = yup.object().shape({ global: yup.object().shape({ connectorName: yup.string().required("form.empty.error"), urlBase: yup.string().required("form.empty.error"), + authenticator: yup.object({ + header: yup.mixed().when("type", { + is: (type: string) => type === "ApiKeyAuthenticator" || type === "SessionTokenAuthenticator", + then: yup.string().required("form.empty.error"), + otherwise: (schema) => schema.strip(), + }), + token_refresh_endpoint: yup.mixed().when("type", { + is: "OAuthAuthenticator", + then: yup.string().required("form.empty.error"), + otherwise: (schema) => schema.strip(), + }), + session_token_response_key: yup.mixed().when("type", { + is: "SessionTokenAuthenticator", + then: yup.string().required("form.empty.error"), + otherwise: (schema) => schema.strip(), + }), + login_url: yup.mixed().when("type", { + is: "SessionTokenAuthenticator", + then: yup.string().required("form.empty.error"), + otherwise: (schema) => schema.strip(), + }), + validate_session_url: yup.mixed().when("type", { + is: "SessionTokenAuthenticator", + then: yup.string().required("form.empty.error"), + otherwise: (schema) => schema.strip(), + }), + }), }), streams: yup.array().of( yup.object().shape({ @@ -42,7 +226,25 @@ export const builderFormValidationSchema = yup.object().shape({ ), }); -export const convertToManifest = (values: BuilderFormValues): ConnectorManifest => { +function builderFormAuthenticatorToAuthenticator( + globalSettings: BuilderFormValues["global"] +): HttpRequesterAllOfAuthenticator { + if (globalSettings.authenticator.type === "OAuthAuthenticator") { + return { + ...globalSettings.authenticator, + refresh_request_body: Object.fromEntries(globalSettings.authenticator.refresh_request_body), + }; + } + if (globalSettings.authenticator.type === "SessionTokenAuthenticator") { + return { + ...globalSettings.authenticator, + api_url: globalSettings.urlBase, + }; + } + return globalSettings.authenticator as HttpRequesterAllOfAuthenticator; +} + +export const convertToManifest = (values: BuilderFormValues): PatchedConnectorManifest => { const manifestStreams: DeclarativeStream[] = values.streams.map((stream) => { return { name: stream.name, @@ -57,6 +259,7 @@ export const convertToManifest = (values: BuilderFormValues): ConnectorManifest request_headers: Object.fromEntries(stream.requestOptions.requestHeaders), request_body_json: Object.fromEntries(stream.requestOptions.requestBody), }, + authenticator: builderFormAuthenticatorToAuthenticator(values.global), // TODO: remove these empty "config" values once they are no longer required in the connector manifest JSON schema config: {}, }, @@ -72,11 +275,26 @@ export const convertToManifest = (values: BuilderFormValues): ConnectorManifest }; }); + const allInputs = [...values.inputs, ...getInferredInputs(values)]; + + const specSchema: JSONSchema7 = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + required: allInputs.filter((input) => input.required).map((input) => input.key), + properties: Object.fromEntries(allInputs.map((input) => [input.key, input.definition])), + additionalProperties: true, + }; + + const spec: SourceDefinitionSpecificationDraft = { + connectionSpecification: specSchema, + }; + return { version: "0.1.0", check: { stream_names: [], }, streams: manifestStreams, + spec, }; }; diff --git a/airbyte-webapp/src/components/ui/Input/Input.module.scss b/airbyte-webapp/src/components/ui/Input/Input.module.scss index 1d6d0f9f7a6f..ebdcad777282 100644 --- a/airbyte-webapp/src/components/ui/Input/Input.module.scss +++ b/airbyte-webapp/src/components/ui/Input/Input.module.scss @@ -57,7 +57,8 @@ color: colors.$grey-300; } - &.disabled { + &.disabled, + &:read-only { pointer-events: none; color: colors.$grey-400; } diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json index 022869453100..c405e8f56b28 100644 --- a/airbyte-webapp/src/locales/en.json +++ b/airbyte-webapp/src/locales/en.json @@ -665,6 +665,7 @@ "connectorBuilder.resetModal.submitButton": "Reset", "connectorBuilder.streamsHeading": "STREAMS ({number})", "connectorBuilder.globalConfiguration": "Global Configuration", + "connectorBuilder.userInputs": "User inputs ({number})", "connectorBuilder.noStreamsMessage": "Add a stream to test it here", "connectorBuilder.toggleModal.text": "Toggling back to the UI will erase any changes you have made in the YAML editor.\n\nIn order to export your current yaml, click the Download Config button.", "connectorBuilder.toggleModal.title": "Warning", @@ -677,6 +678,32 @@ "connectorBuilder.uiYamlToggle.yaml": "YAML", "connectorBuilder.resetAll": "Reset all", "connectorBuilder.emptyName": "(empty)", + "connectorBuilder.inputsTitle": "User Inputs", + "connectorBuilder.inputsDescription": "User inputs will be asked to the end-user in order to set up the connector.", + "connectorBuilder.addInputButton": "Add new user input", + "connectorBuilder.inputModal.newTitle": "New user input", + "connectorBuilder.inputModal.editTitle": "Edit user input", + "connectorBuilder.inputModal.inputName": "Input name", + "connectorBuilder.inputModal.inputNameTooltip": "The name of the input as it will show up in the form when configuring a connection", + "connectorBuilder.inputModal.fieldId": "Field ID", + "connectorBuilder.inputModal.fieldIdTooltip": "The ID of the field - reference it in double curly braces when referencing the value of the input somewhere in the definition of a stream or global configuration: {syntaxExample}. This value is derived automatically from the input name", + "connectorBuilder.inputModal.description": "Hint", + "connectorBuilder.inputModal.descriptionTooltip": "The hint shown in the tooltip next to the input in the form when confguring a connection", + "connectorBuilder.inputModal.type": "Type", + "connectorBuilder.inputModal.typeTooltip": "The type of the input", + "connectorBuilder.inputModal.secret": "Secret field", + "connectorBuilder.inputModal.secretTooltip": "If this option is enabled, the form input will be masked and can't be looked at again", + "connectorBuilder.inputModal.required": "Required field", + "connectorBuilder.inputModal.requiredTooltip": "If this option is enabled, the user will need to provide a value when confguring a connection", + "connectorBuilder.inputModal.showDefaultValueField": "Enable default value", + "connectorBuilder.inputModal.showDefaultValueFieldTooltip": "If a default value is provided, the input is prefilled in the configuration form", + "connectorBuilder.inputModal.default": "Default value", + "connectorBuilder.inputModal.placeholder": "Placeholder", + "connectorBuilder.inputModal.placeholderTooltip": "Placeholder shown in the form in case the user did not pick a value yet", + "connectorBuilder.inputModal.enum": "Allowed values", + "connectorBuilder.inputModal.enumTooltip": "The user will only be able to choose from one of these values. If none are provided the user will be able to enter any value", + "connectorBuilder.inputModal.unsupportedInput": "Detailed configuration for this property type is disabled, switch to YAML view to edit", + "connectorBuilder.inputModal.inferredInputMessage": "Detailed configuration for this user input is disabled as it is tied to the selected authentication method", "connectorBuilder.key": "key", "connectorBuilder.value": "value", "connectorBuilder.addKeyValue": "Add", @@ -689,6 +716,10 @@ "connectorBuilder.inputsTooltip": "Define test inputs to check whether the connector configuration works", "connectorBuilder.inputsNoSpecUITooltip": "Add User Input fields to allow setting test inputs", "connectorBuilder.inputsNoSpecYAMLTooltip": "Add a spec to your manifest to allow setting test inputs", + "connectorBuilder.setInUserInput": "This setting is configured as part of the user inputs in the testing panel", + "connectorBuilder.inputsButton": "Inputs", + "connectorBuilder.optionalFieldsLabel": "Optional fields", + "connectorBuilder.duplicateFieldID": "Make sure no field ID is used multiple times", "jobs.noAttemptsFailure": "Failed to start job.", diff --git a/airbyte-webapp/src/pages/ConnectorBuilderPage/ConnectorBuilderPage.tsx b/airbyte-webapp/src/pages/ConnectorBuilderPage/ConnectorBuilderPage.tsx index fc28253ee5c7..d0cef295f23e 100644 --- a/airbyte-webapp/src/pages/ConnectorBuilderPage/ConnectorBuilderPage.tsx +++ b/airbyte-webapp/src/pages/ConnectorBuilderPage/ConnectorBuilderPage.tsx @@ -21,35 +21,40 @@ const ConnectorBuilderPageInner: React.FC = () => { return ( undefined} validationSchema={builderFormValidationSchema}> - {({ values }) => ( - - {editorView === "yaml" ? ( - setEditorView("ui")} /> - ) : ( - setEditorView("yaml")} /> - )} - - ), - className: styles.leftPanel, - minWidth: 100, - }} - secondPanel={{ - children: , - className: styles.rightPanel, - flex: 0.33, - minWidth: 60, - overlay: { - displayThreshold: 325, - header: formatMessage({ id: "connectorBuilder.testConnector" }), - rotation: "counter-clockwise", - }, - }} - /> - )} + {({ values }) => { + return ( + + {editorView === "yaml" ? ( + setEditorView("ui")} /> + ) : ( + setEditorView("yaml")} /> + )} + + ), + className: styles.leftPanel, + minWidth: 100, + }} + secondPanel={{ + children: , + className: styles.rightPanel, + flex: 0.33, + minWidth: 60, + overlay: { + displayThreshold: 325, + header: formatMessage({ id: "connectorBuilder.testConnector" }), + rotation: "counter-clockwise", + }, + }} + /> + ); + }} ); }; diff --git a/airbyte-webapp/src/services/connectorBuilder/ConnectorBuilderStateService.tsx b/airbyte-webapp/src/services/connectorBuilder/ConnectorBuilderStateService.tsx index 0f4ec2c67548..035b3ff9a23c 100644 --- a/airbyte-webapp/src/services/connectorBuilder/ConnectorBuilderStateService.tsx +++ b/airbyte-webapp/src/services/connectorBuilder/ConnectorBuilderStateService.tsx @@ -1,5 +1,5 @@ import { dump } from "js-yaml"; -import React, { useContext, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useIntl } from "react-intl"; import { useLocalStorage } from "react-use"; @@ -7,7 +7,6 @@ import { BuilderFormValues, convertToManifest } from "components/connectorBuilde import { PatchedConnectorManifest } from "core/domain/connectorBuilder/PatchedConnectorManifest"; import { StreamReadRequestBodyConfig, StreamsListReadStreamsItem } from "core/request/ConnectorBuilderClient"; -import { ConnectorManifest } from "core/request/ConnectorManifest"; import { useListStreams } from "./ConnectorBuilderApiService"; @@ -15,11 +14,14 @@ export const DEFAULT_BUILDER_FORM_VALUES: BuilderFormValues = { global: { connectorName: "", urlBase: "", + authenticator: { type: "NoAuth" }, }, + inputs: [], + inferredInputOverrides: {}, streams: [], }; -const DEFAULT_JSON_MANIFEST_VALUES: ConnectorManifest = { +const DEFAULT_JSON_MANIFEST_VALUES: PatchedConnectorManifest = { version: "0.1.0", check: { stream_names: [], @@ -28,7 +30,7 @@ const DEFAULT_JSON_MANIFEST_VALUES: ConnectorManifest = { }; export type EditorView = "ui" | "yaml"; -export type BuilderView = "global" | number; +export type BuilderView = "global" | "inputs" | number; interface Context { builderFormValues: BuilderFormValues; @@ -40,16 +42,15 @@ interface Context { streamListErrorMessage: string | undefined; testStreamIndex: number; selectedView: BuilderView; - configString: string; configJson: StreamReadRequestBodyConfig; editorView: EditorView; - setBuilderFormValues: (values: BuilderFormValues) => void; - setJsonManifest: (jsonValue: ConnectorManifest) => void; + setBuilderFormValues: (values: BuilderFormValues, isInvalid: boolean) => void; + setJsonManifest: (jsonValue: PatchedConnectorManifest) => void; setYamlEditorIsMounted: (value: boolean) => void; setYamlIsValid: (value: boolean) => void; setTestStreamIndex: (streamIndex: number) => void; setSelectedView: (view: BuilderView) => void; - setConfigString: (configString: string) => void; + setConfigJson: (value: StreamReadRequestBodyConfig) => void; setEditorView: (editorView: EditorView) => void; } @@ -59,21 +60,36 @@ export const ConnectorBuilderStateProvider: React.FC( + const [storedBuilderFormValues, setStoredBuilderFormValues] = useLocalStorage( "connectorBuilderFormValues", DEFAULT_BUILDER_FORM_VALUES ); - const formValues = builderFormValues ?? DEFAULT_BUILDER_FORM_VALUES; - const [jsonManifest, setJsonManifest] = useLocalStorage( + const lastValidBuilderFormValuesRef = useRef(storedBuilderFormValues as BuilderFormValues); + + const setBuilderFormValues = useCallback( + (values: BuilderFormValues, isValid: boolean) => { + setStoredBuilderFormValues(values); + if (isValid) { + lastValidBuilderFormValuesRef.current = values; + } + }, + [setStoredBuilderFormValues] + ); + + const builderFormValues = useMemo(() => { + return { ...DEFAULT_BUILDER_FORM_VALUES, ...(storedBuilderFormValues ?? {}) }; + }, [storedBuilderFormValues]); + + const [jsonManifest, setJsonManifest] = useLocalStorage( "connectorBuilderJsonManifest", DEFAULT_JSON_MANIFEST_VALUES ); const manifest = jsonManifest ?? DEFAULT_JSON_MANIFEST_VALUES; useEffect(() => { - setJsonManifest(convertToManifest(formValues)); - }, [formValues, setJsonManifest]); + setJsonManifest(convertToManifest(builderFormValues)); + }, [builderFormValues, setJsonManifest]); const [yamlIsValid, setYamlIsValid] = useState(true); const [yamlEditorIsMounted, setYamlEditorIsMounted] = useState(true); @@ -85,25 +101,30 @@ export const ConnectorBuilderStateProvider: React.FC("ui"); + const lastValidBuilderFormValues = lastValidBuilderFormValuesRef.current; + /** + * The json manifest derived from the last valid state of the builder form values. + * In the yaml view, this is undefined. Can still be invalid in case an invalid state is loaded from localstorage + */ + const lastValidJsonManifest = useMemo( + () => + editorView !== "ui" + ? undefined + : builderFormValues === lastValidBuilderFormValues + ? jsonManifest + : convertToManifest(lastValidBuilderFormValues), + [builderFormValues, editorView, jsonManifest, lastValidBuilderFormValues] + ); + // config - const [configString, setConfigString] = useState("{\n \n}"); const [configJson, setConfigJson] = useState({}); - useEffect(() => { - try { - const json = JSON.parse(configString) as StreamReadRequestBodyConfig; - setConfigJson(json); - } catch (err) { - console.error(`Config value is not valid JSON! Error: ${err}`); - } - }, [configString]); - // streams const { data: streamListRead, isError: isStreamListError, error: streamListError, - } = useListStreams({ manifest, config: configJson }); + } = useListStreams({ manifest: lastValidJsonManifest || manifest, config: configJson }); const unknownErrorMessage = formatMessage({ id: "connectorBuilder.unknownError" }); const streamListErrorMessage = isStreamListError ? streamListError instanceof Error @@ -124,7 +145,7 @@ export const ConnectorBuilderStateProvider: React.FC("global"); const ctx = { - builderFormValues: formValues, + builderFormValues, jsonManifest: manifest, yamlManifest, yamlEditorIsMounted, @@ -133,7 +154,6 @@ export const ConnectorBuilderStateProvider: React.FC void; + /** + * Called in case the user reset the form - if not provided, no reset button is rendered + */ + onReset?: () => void; isTestConnectionInProgress?: boolean; onStopTesting?: () => void; @@ -60,6 +64,7 @@ export const ConnectorForm: React.FC = (props) => { selectedConnectorDefinitionSpecification, errorMessage, connectorId, + onReset, } = props; const { formFields, initialValues, validationSchema } = useBuildForm( @@ -81,7 +86,6 @@ export const ConnectorForm: React.FC = (props) => { async (values: ConnectorFormValues) => { const valuesToSend = getValues(values); await onSubmit(valuesToSend); - clearFormChange(formId); }, [clearFormChange, formId, getValues, onSubmit] @@ -96,7 +100,7 @@ export const ConnectorForm: React.FC = (props) => { onSubmit={onFormSubmit} enableReinitialize > - {({ dirty }) => ( + {({ dirty, resetForm }) => ( = (props) => { {...props} formFields={formFields} errorMessage={errorMessage} + onReset={ + onReset && + (() => { + onReset?.(); + resetForm(); + }) + } onStopTestingConnector={onStopTesting ? () => onStopTesting() : undefined} onRetest={testConnector ? async () => await testConnector() : undefined} /> diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/FormRoot.tsx b/airbyte-webapp/src/views/Connector/ConnectorForm/FormRoot.tsx index 8c471a0bb268..fbc00004eafe 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/FormRoot.tsx +++ b/airbyte-webapp/src/views/Connector/ConnectorForm/FormRoot.tsx @@ -24,6 +24,10 @@ interface FormRootProps { * Called in case the user cancels the form - if not provided, no cancel button is rendered */ onCancel?: () => void; + /** + * Called in case the user reset the form - if not provided, no reset button is rendered + */ + onReset?: () => void; } export const FormRoot: React.FC = ({ @@ -38,6 +42,7 @@ export const FormRoot: React.FC = ({ footerClassName, bodyClassName, onCancel, + onReset, }) => { const { dirty, isSubmitting, isValid } = useFormikContext(); const { resetConnectorForm, isEditMode, formType } = useConnectorForm(); @@ -72,6 +77,7 @@ export const FormRoot: React.FC = ({ formType={formType} submitLabel={submitLabel} onCancel={onCancel} + onReset={onReset} connectionTestSuccess={connectionTestSuccess} /> )} diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.module.scss b/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.module.scss index 8b96a3953685..d93a86168241 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.module.scss +++ b/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.module.scss @@ -13,3 +13,7 @@ align-self: flex-end; gap: variables.$spacing-sm; } + +.deleteButtonContainer { + flex-grow: 1; +} diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.tsx b/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.tsx index c4eda4898499..425157a83cc7 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.tsx +++ b/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.tsx @@ -14,6 +14,10 @@ interface CreateControlProps { * Called in case the user cancels the form - if not provided, no cancel button is rendered */ onCancel?: () => void; + /** + * Called in case the user reset the form - if not provided, no reset button is rendered + */ + onReset?: () => void; submitLabel?: string; isSubmitting: boolean; errorMessage?: React.ReactNode; @@ -31,6 +35,7 @@ const CreateControls: React.FC = ({ errorMessage, onCancelTesting, onCancel, + onReset, submitLabel, }) => { if (isSubmitting) { @@ -44,6 +49,13 @@ const CreateControls: React.FC = ({ return (
    {errorMessage && } + {onReset && ( +
    + +
    + )}
    {onCancel && (