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 00000000000000..89a06bcba7a653 --- /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 4107fe7331582c..06a2957f29adec 100644 --- a/airbyte-webapp/src/components/connectorBuilder/Builder/Builder.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/Builder.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; 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"; @@ -29,7 +29,7 @@ function getView(selectedView: BuilderView) { export const Builder: React.FC = ({ values, toggleYamlEditor }) => { const { setBuilderFormValues, selectedView } = useConnectorBuilderState(); useEffect(() => { - setBuilderFormValues(values); + setBuilderFormValues(values, builderFormValidationSchema.isValidSync(values)); }, [values, setBuilderFormValues]); return ( 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 00000000000000..822d999275748e --- /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 00000000000000..b5a7816f6ae11e --- /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 00000000000000..639f5358726beb --- /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.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.tsx index 092b84374326be..3894595d67bd5d 100644 --- a/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/BuilderSidebar.tsx @@ -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"; @@ -115,7 +115,10 @@ export const BuilderSidebar: React.FC = ({ className, toggl onClick={() => handleViewSelect("inputs")} > - +
diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/GlobalConfigView.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/GlobalConfigView.tsx index 433d2d2e529602..bc8e29a532a55f 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"; @@ -16,6 +17,7 @@ export const GlobalConfigView: React.FC = () => { + ); }; diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/InputsView.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/InputsView.tsx index 0f6a46449748d8..a934422b60f6fd 100644 --- a/airbyte-webapp/src/components/connectorBuilder/Builder/InputsView.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/InputsView.tsx @@ -7,7 +7,6 @@ import { FormattedMessage, useIntl } from "react-intl"; import { useEffectOnce } from "react-use"; import * as yup from "yup"; -import Label from "components/Label"; import { Button } from "components/ui/Button"; import { Card } from "components/ui/Card"; import { InfoBox } from "components/ui/InfoBox"; @@ -16,7 +15,7 @@ import { Text } from "components/ui/Text"; import { FormikPatch } from "core/form/FormikPatch"; -import { BuilderFormInput } from "../types"; +import { BuilderFormInput, BuilderFormValues, getInferredInputs } from "../types"; import { BuilderConfigView } from "./BuilderConfigView"; import { BuilderField } from "./BuilderField"; import styles from "./InputsView.module.scss"; @@ -30,6 +29,7 @@ interface InputInEditing { isNew?: boolean; showDefaultValueField: boolean; type: typeof supportedTypes[number]; + isInferredInputOverride: boolean; } function sluggify(str: string) { @@ -44,10 +44,14 @@ function newInputInEditing(): InputInEditing { isNew: true, showDefaultValueField: false, type: "string", + isInferredInputOverride: false, }; } -function formInputToInputInEditing({ key, definition, required }: BuilderFormInput): InputInEditing { +function formInputToInputInEditing( + { key, definition, required }: BuilderFormInput, + isInferredInputOverride: boolean +): InputInEditing { const supportedType = supportedTypes.find((type) => type === definition.type) || "unknown"; return { key, @@ -56,6 +60,7 @@ function formInputToInputInEditing({ key, definition, required }: BuilderFormInp isNew: false, showDefaultValueField: Boolean(definition.default), type: supportedType !== "unknown" && definition.enum ? "enum" : supportedType, + isInferredInputOverride, }; } @@ -79,9 +84,14 @@ function inputInEditingToFormInput({ export const InputsView: React.FC = () => { const { formatMessage } = useIntl(); + const { values, setFieldValue } = useFormikContext(); const [inputs, , helpers] = useField("inputs"); const [inputInEditing, setInputInEditing] = useState(undefined); - const usedKeys = useMemo(() => inputs.value.map((input) => input.key), [inputs.value]); + 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({ @@ -99,29 +109,20 @@ export const InputsView: React.FC = () => { }), [inputInEditing?.isNew, inputInEditing?.key, usedKeys] ); + return ( - {inputs.value.length > 0 && ( + {(inputs.value.length > 0 || inferredInputs.length > 0) && (
    + {inferredInputs.map((input) => ( + + ))} {inputs.value.map((input) => ( -
  1. - - -
  2. + ))}
@@ -142,12 +143,16 @@ export const InputsView: React.FC = () => { initialValues={inputInEditing} validationSchema={inputInEditValidation} onSubmit={(values: InputInEditing) => { - const newInput = inputInEditingToFormInput(values); - helpers.setValue( - inputInEditing.isNew - ? [...inputs.value, newInput] - : inputs.value.map((input) => (input.key === inputInEditing.key ? newInput : input)) - ); + 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); }} > @@ -179,7 +184,9 @@ const InputModal = ({ 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 @@ -202,7 +209,9 @@ const InputModal = ({ path="definition.title" type="string" onChange={(newValue) => { - setFieldValue("key", sluggify(newValue || ""), true); + if (!isInferredInputOverride) { + setFieldValue("key", sluggify(newValue || ""), true); + } }} label={formatMessage({ id: "connectorBuilder.inputModal.inputName" })} tooltip={formatMessage({ id: "connectorBuilder.inputModal.inputNameTooltip" })} @@ -226,7 +235,7 @@ const InputModal = ({ label={formatMessage({ id: "connectorBuilder.inputModal.description" })} tooltip={formatMessage({ id: "connectorBuilder.inputModal.descriptionTooltip" })} /> - {values.type !== "unknown" ? ( + {values.type !== "unknown" && !isInferredInputOverride ? ( <> ) : ( - + {isInferredInputOverride ? ( + + ) : ( + + )} )} - {!inputInEditing.isNew && ( + {!inputInEditing.isNew && !inputInEditing.isInferredInputOverride && (
+ + ); +}; diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/UserInputField.module.scss b/airbyte-webapp/src/components/connectorBuilder/Builder/UserInputField.module.scss new file mode 100644 index 00000000000000..da549df1adae45 --- /dev/null +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/UserInputField.module.scss @@ -0,0 +1,12 @@ +@use "scss/variables"; +@use "scss/colors"; + +.container { + display: flex; + justify-content: space-between; + align-items: center; +} + +.icon { + color: colors.$blue-400; +} diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/UserInputField.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/UserInputField.tsx new file mode 100644 index 00000000000000..80eb465e323e9a --- /dev/null +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/UserInputField.tsx @@ -0,0 +1,18 @@ +import { faUser } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { FormattedMessage } from "react-intl"; + +import { ControlLabels } from "components/LabeledControl"; +import { Tooltip } from "components/ui/Tooltip"; + +import styles from "./UserInputField.module.scss"; + +export const UserInputField: React.FC<{ label: string; tooltip: string }> = ({ 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 bf4573ee6edbb9..73b43de5ec13c3 100644 --- a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.module.scss +++ b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.module.scss @@ -33,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 d2560c251ec7e7..b2b3f224d96b38 100644 --- a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.tsx @@ -1,12 +1,12 @@ -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 { 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"; @@ -18,10 +18,12 @@ 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 { configJson, setConfigJson, jsonManifest, editorView, setEditorView } = useConnectorBuilderState(); @@ -36,13 +38,20 @@ export const ConfigMenu: React.FC = ({ className }) => { <> setIsOpen(true)} - disabled={!jsonManifest.spec} - icon={} - /> + <> + + {configJsonErrors > 0 && ( + + )} + } placement={editorView === "yaml" ? "left" : "top"} containerClassName={className} diff --git a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestButton.tsx b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/StreamTestButton.tsx index 482893c176e029..4e382aef751844 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 9c0908cd3ac148..dc8027a6d0a97b 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 3b1d67fa022d6b..fa2474e5efbfdb 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 6cc2632bf6ad7c..f3799ed1fe4685 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 53c70df264e1f7..a76d49dec56efe 100644 --- a/airbyte-webapp/src/components/connectorBuilder/types.ts +++ b/airbyte-webapp/src/components/connectorBuilder/types.ts @@ -3,20 +3,43 @@ import * as yup from "yup"; import { SourceDefinitionSpecificationDraft } from "core/domain/connector"; import { PatchedConnectorManifest } from "core/domain/connectorBuilder/PatchedConnectorManifest"; -import { DeclarativeStream } from "core/request/ConnectorManifest"; +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: JSONSchema7; + 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[]; } @@ -31,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({ @@ -52,6 +226,24 @@ export const builderFormValidationSchema = yup.object().shape({ ), }); +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 { @@ -67,6 +259,7 @@ export const convertToManifest = (values: BuilderFormValues): PatchedConnectorMa request_headers: Object.fromEntries(stream.requestOptions.requestHeaders), request_body_data: 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: {}, }, @@ -82,11 +275,13 @@ export const convertToManifest = (values: BuilderFormValues): PatchedConnectorMa }; }); + const allInputs = [...values.inputs, ...getInferredInputs(values)]; + const specSchema: JSONSchema7 = { $schema: "http://json-schema.org/draft-07/schema#", type: "object", - required: values.inputs.filter((input) => input.required).map((input) => input.key), - properties: Object.fromEntries(values.inputs.map((input) => [input.key, input.definition])), + required: allInputs.filter((input) => input.required).map((input) => input.key), + properties: Object.fromEntries(allInputs.map((input) => [input.key, input.definition])), additionalProperties: true, }; diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json index c55bc6a2d1c2fa..c405e8f56b28f1 100644 --- a/airbyte-webapp/src/locales/en.json +++ b/airbyte-webapp/src/locales/en.json @@ -703,6 +703,7 @@ "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", @@ -715,6 +716,9 @@ "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 fc28253ee5c7b4..d0cef295f23e72 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 983f9b71c32ac5..035b3ff9a23cbb 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"; @@ -14,8 +14,10 @@ export const DEFAULT_BUILDER_FORM_VALUES: BuilderFormValues = { global: { connectorName: "", urlBase: "", + authenticator: { type: "NoAuth" }, }, inputs: [], + inferredInputOverrides: {}, streams: [], }; @@ -42,7 +44,7 @@ interface Context { selectedView: BuilderView; configJson: StreamReadRequestBodyConfig; editorView: EditorView; - setBuilderFormValues: (values: BuilderFormValues) => void; + setBuilderFormValues: (values: BuilderFormValues, isInvalid: boolean) => void; setJsonManifest: (jsonValue: PatchedConnectorManifest) => void; setYamlEditorIsMounted: (value: boolean) => void; setYamlIsValid: (value: boolean) => void; @@ -58,10 +60,23 @@ export const ConnectorBuilderStateProvider: React.FC( + const [storedBuilderFormValues, setStoredBuilderFormValues] = useLocalStorage( "connectorBuilderFormValues", DEFAULT_BUILDER_FORM_VALUES ); + + 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]); @@ -86,6 +101,21 @@ 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 [configJson, setConfigJson] = useState({}); @@ -94,7 +124,7 @@ export const ConnectorBuilderStateProvider: React.FC