diff --git a/airbyte-webapp/src/components/ArrayOfObjectsEditor/ArrayOfObjectsEditor.tsx b/airbyte-webapp/src/components/ArrayOfObjectsEditor/ArrayOfObjectsEditor.tsx index 67641397aa92..97cc2587e3b6 100644 --- a/airbyte-webapp/src/components/ArrayOfObjectsEditor/ArrayOfObjectsEditor.tsx +++ b/airbyte-webapp/src/components/ArrayOfObjectsEditor/ArrayOfObjectsEditor.tsx @@ -3,7 +3,7 @@ import { FormattedMessage } from "react-intl"; import { Modal, ModalProps } from "components/ui/Modal"; -import { ConnectionFormMode } from "views/Connection/ConnectionForm/ConnectionForm"; +import { ConnectionFormMode } from "hooks/services/ConnectionForm/ConnectionFormService"; import styles from "./ArrayOfObjectsEditor.module.scss"; import { EditorHeader } from "./components/EditorHeader"; diff --git a/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorHeader.tsx b/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorHeader.tsx index 467e5655fd6a..55a49eb0c42c 100644 --- a/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorHeader.tsx +++ b/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorHeader.tsx @@ -4,7 +4,7 @@ import styled from "styled-components"; import { Button } from "components/ui/Button"; -import { ConnectionFormMode } from "views/Connection/ConnectionForm/ConnectionForm"; +import { ConnectionFormMode } from "hooks/services/ConnectionForm/ConnectionFormService"; const Content = styled.div` display: flex; diff --git a/airbyte-webapp/src/components/CreateConnection/CreateConnection.module.scss b/airbyte-webapp/src/components/CreateConnection/CreateConnection.module.scss deleted file mode 100644 index e521aa3fea35..000000000000 --- a/airbyte-webapp/src/components/CreateConnection/CreateConnection.module.scss +++ /dev/null @@ -1,9 +0,0 @@ -.tryArrowIcon { - margin: 0 10px -1px 0; - font-size: 14px; -} - -.connectionFormContainer { - width: 100%; - padding: 0 20px; -} diff --git a/airbyte-webapp/src/components/CreateConnection/CreateConnection.tsx b/airbyte-webapp/src/components/CreateConnection/CreateConnection.tsx deleted file mode 100644 index 4f21e6414d0f..000000000000 --- a/airbyte-webapp/src/components/CreateConnection/CreateConnection.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { faRedoAlt } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React, { Suspense, useCallback, useMemo } from "react"; -import { FormattedMessage } from "react-intl"; -import { useNavigate } from "react-router-dom"; - -import { JobItem } from "components/JobItem/JobItem"; -import LoadingSchema from "components/LoadingSchema"; -import { Button } from "components/ui/Button"; -import { Card } from "components/ui/Card"; - -import { LogsRequestError } from "core/request/LogsRequestError"; -import { ConnectionFormServiceProvider } from "hooks/services/ConnectionForm/ConnectionFormService"; -import { useChangedFormsById, useFormChangeTrackerService, useUniqueFormId } from "hooks/services/FormChangeTracker"; -import { useCreateConnection, ValuesProps } from "hooks/services/useConnectionHook"; -import { ConnectionForm } from "views/Connection/ConnectionForm"; - -import { DestinationRead, SourceRead } from "../../core/request/AirbyteClient"; -import { useDiscoverSchema } from "../../hooks/services/useSourceHook"; -import styles from "./CreateConnection.module.scss"; -import TryAfterErrorBlock from "./TryAfterErrorBlock"; - -interface CreateConnectionProps { - source: SourceRead; - destination: DestinationRead; - afterSubmitConnection?: () => void; -} - -export const CreateConnection: React.FC = ({ source, destination, afterSubmitConnection }) => { - const { mutateAsync: createConnection } = useCreateConnection(); - const navigate = useNavigate(); - - const formId = useUniqueFormId(); - const { clearFormChange } = useFormChangeTrackerService(); - const [changedFormsById] = useChangedFormsById(); - const formDirty = useMemo(() => !!changedFormsById?.[formId], [changedFormsById, formId]); - - const { schema, isLoading, schemaErrorStatus, catalogId, onDiscoverSchema } = useDiscoverSchema( - source.sourceId, - true - ); - - const connection = { - syncCatalog: schema, - destination, - source, - catalogId, - }; - - const onSubmitConnectionStep = useCallback( - async (values: ValuesProps) => { - const createdConnection = await createConnection({ - values, - source, - destination, - sourceDefinition: { - sourceDefinitionId: source?.sourceDefinitionId ?? "", - }, - destinationDefinition: { - name: destination?.name ?? "", - destinationDefinitionId: destination?.destinationDefinitionId ?? "", - }, - sourceCatalogId: catalogId, - }); - - // We only want to go to the new connection if we _do not_ have an after submit action. - if (!afterSubmitConnection) { - // We have to clear the form change to prevent the dirty-form tracking modal from appearing. - clearFormChange(formId); - // This is the "default behavior", go to the created connection. - navigate(`../../connections/${createdConnection.connectionId}`); - } - }, - [afterSubmitConnection, catalogId, clearFormChange, createConnection, destination, formId, navigate, source] - ); - - if (schemaErrorStatus) { - const job = LogsRequestError.extractJobInfo(schemaErrorStatus); - return ( - - - {job && } - - ); - } - - return isLoading ? ( - - ) : ( - }> -
- - - - - - } - /> - -
-
- ); -}; diff --git a/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.module.scss b/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.module.scss new file mode 100644 index 000000000000..982150eae326 --- /dev/null +++ b/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.module.scss @@ -0,0 +1,12 @@ +@use "../../scss/variables"; + +.connectionFormContainer { + width: 100%; + padding: 0 variables.$spacing-xl; + + > form { + display: flex; + flex-direction: column; + gap: variables.$spacing-md; + } +} diff --git a/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.test.tsx b/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.test.tsx new file mode 100644 index 000000000000..b55dde8ab39f --- /dev/null +++ b/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.test.tsx @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { act, render as tlr } from "@testing-library/react"; +import mockConnection from "test-utils/mock-data/mockConnection.json"; +import mockDest from "test-utils/mock-data/mockDestinationDefinition.json"; +import { TestWrapper } from "test-utils/testutils"; + +import { AirbyteCatalog } from "core/request/AirbyteClient"; +import * as sourceHook from "hooks/services/useSourceHook"; + +import { CreateConnectionForm } from "./CreateConnectionForm"; + +jest.mock("services/connector/DestinationDefinitionSpecificationService", () => ({ + useGetDestinationDefinitionSpecification: () => mockDest, +})); + +jest.mock("services/workspaces/WorkspacesService", () => ({ + useCurrentWorkspace: () => ({}), + useCurrentWorkspaceId: () => "workspace-id", +})); + +describe("CreateConnectionForm", () => { + const Wrapper: React.FC = ({ children }) => {children}; + const render = async () => { + let renderResult: ReturnType; + + await act(async () => { + renderResult = tlr( + + + + ); + }); + return renderResult!; + }; + + const baseUseDiscoverSchema = { + schemaErrorStatus: null, + isLoading: false, + schema: mockConnection.syncCatalog as AirbyteCatalog, + catalogId: "", + onDiscoverSchema: () => Promise.resolve(), + }; + + it("should render", async () => { + jest.spyOn(sourceHook, "useDiscoverSchema").mockImplementationOnce(() => baseUseDiscoverSchema); + const renderResult = await render(); + expect(renderResult.container).toMatchSnapshot(); + expect(renderResult.queryByText("Please wait a little bit more…")).toBeFalsy(); + }); + + it("should render when loading", async () => { + jest + .spyOn(sourceHook, "useDiscoverSchema") + .mockImplementationOnce(() => ({ ...baseUseDiscoverSchema, isLoading: true })); + + const renderResult = await render(); + expect(renderResult.container).toMatchSnapshot(); + }); + + it("should render with an error", async () => { + jest.spyOn(sourceHook, "useDiscoverSchema").mockImplementationOnce(() => ({ + ...baseUseDiscoverSchema, + schemaErrorStatus: new Error("Test Error") as sourceHook.SchemaError, + })); + + const renderResult = await render(); + expect(renderResult.container).toMatchSnapshot(); + }); +}); diff --git a/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.tsx b/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.tsx new file mode 100644 index 000000000000..56612c8eaf93 --- /dev/null +++ b/airbyte-webapp/src/components/CreateConnection/CreateConnectionForm.tsx @@ -0,0 +1,159 @@ +import { Form, Formik, FormikHelpers } from "formik"; +import React, { Suspense, useCallback, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import LoadingSchema from "components/LoadingSchema"; + +import { DestinationRead, SourceRead } from "core/request/AirbyteClient"; +import { + ConnectionFormServiceProvider, + tidyConnectionFormValues, + useConnectionFormService, +} from "hooks/services/ConnectionForm/ConnectionFormService"; +import { useFormChangeTrackerService } from "hooks/services/FormChangeTracker"; +import { useCreateConnection } from "hooks/services/useConnectionHook"; +import { SchemaError as SchemaErrorType, useDiscoverSchema } from "hooks/services/useSourceHook"; +import { useCurrentWorkspaceId } from "services/workspaces/WorkspacesService"; +import CreateControls from "views/Connection/ConnectionForm/components/CreateControls"; +import { OperationsSection } from "views/Connection/ConnectionForm/components/OperationsSection"; +import { ConnectionFormFields } from "views/Connection/ConnectionForm/ConnectionFormFields"; +import { connectionValidationSchema, FormikConnectionFormValues } from "views/Connection/ConnectionForm/formConfig"; + +import styles from "./CreateConnectionForm.module.scss"; +import { CreateConnectionNameField } from "./CreateConnectionNameField"; +import { SchemaError } from "./SchemaError"; + +interface CreateConnectionProps { + source: SourceRead; + destination: DestinationRead; + afterSubmitConnection?: () => void; +} + +interface CreateConnectionPropsInner extends Pick { + schemaError: SchemaErrorType; +} + +const CreateConnectionFormInner: React.FC = ({ schemaError, afterSubmitConnection }) => { + const navigate = useNavigate(); + + const { mutateAsync: createConnection } = useCreateConnection(); + + const { clearFormChange } = useFormChangeTrackerService(); + + const workspaceId = useCurrentWorkspaceId(); + + const { connection, initialValues, mode, formId, getErrorMessage, setSubmitError } = useConnectionFormService(); + const [editingTransformation, setEditingTransformation] = useState(false); + + const onFormSubmit = useCallback( + async (formValues: FormikConnectionFormValues, formikHelpers: FormikHelpers) => { + const values = tidyConnectionFormValues(formValues, workspaceId, mode); + + try { + const createdConnection = await createConnection({ + values, + source: connection.source, + destination: connection.destination, + sourceDefinition: { + sourceDefinitionId: connection.source?.sourceDefinitionId ?? "", + }, + destinationDefinition: { + name: connection.destination?.name ?? "", + destinationDefinitionId: connection.destination?.destinationDefinitionId ?? "", + }, + sourceCatalogId: connection.catalogId, + }); + + formikHelpers.resetForm(); + // We need to clear the form changes otherwise the dirty form intercept service will prevent navigation + clearFormChange(formId); + + if (afterSubmitConnection) { + afterSubmitConnection(); + } else { + navigate(`../../connections/${createdConnection.connectionId}`); + } + } catch (e) { + setSubmitError(e); + } + }, + [ + workspaceId, + mode, + createConnection, + connection.source, + connection.destination, + connection.catalogId, + clearFormChange, + formId, + afterSubmitConnection, + navigate, + setSubmitError, + ] + ); + + if (schemaError) { + return ; + } + + return ( + }> +
+ + {({ values, isSubmitting, isValid, dirty }) => ( +
+ + + setEditingTransformation(true)} + onEndEditTransformation={() => setEditingTransformation(false)} + /> + + + )} +
+
+
+ ); +}; + +export const CreateConnectionForm: React.FC = ({ + source, + destination, + afterSubmitConnection, +}) => { + const { schema, isLoading, schemaErrorStatus, catalogId, onDiscoverSchema } = useDiscoverSchema( + source.sourceId, + true + ); + + const partialConnection = { + syncCatalog: schema, + destination, + source, + catalogId, + }; + + return ( + + {isLoading ? ( + + ) : ( + + )} + + ); +}; diff --git a/airbyte-webapp/src/components/CreateConnection/CreateConnectionNameField.module.scss b/airbyte-webapp/src/components/CreateConnection/CreateConnectionNameField.module.scss new file mode 100644 index 000000000000..3e92797468b9 --- /dev/null +++ b/airbyte-webapp/src/components/CreateConnection/CreateConnectionNameField.module.scss @@ -0,0 +1,13 @@ +@forward "../../views/Connection/ConnectionForm/ConnectionFormFields.module.scss"; +@use "../../scss/variables"; + +.labelHeading { + line-height: 16px; + display: inline; +} + +.connectionLabel { + max-width: 328px; + margin-right: variables.$spacing-xl; + vertical-align: top; +} diff --git a/airbyte-webapp/src/components/CreateConnection/CreateConnectionNameField.tsx b/airbyte-webapp/src/components/CreateConnection/CreateConnectionNameField.tsx new file mode 100644 index 000000000000..2510ee642e71 --- /dev/null +++ b/airbyte-webapp/src/components/CreateConnection/CreateConnectionNameField.tsx @@ -0,0 +1,50 @@ +import { Field, FieldProps } from "formik"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { ControlLabels } from "components/LabeledControl"; +import { Input } from "components/ui/Input"; +import { Text } from "components/ui/Text"; + +import { Section } from "views/Connection/ConnectionForm/components/Section"; + +import styles from "./CreateConnectionNameField.module.scss"; + +export const CreateConnectionNameField = () => { + const { formatMessage } = useIntl(); + + return ( +
+ + {({ field, meta }: FieldProps) => ( +
+
+ + + + } + message={formatMessage({ + id: "form.connectionName.message", + })} + /> +
+
+ +
+
+ )} +
+
+ ); +}; diff --git a/airbyte-webapp/src/components/CreateConnection/SchemaError.tsx b/airbyte-webapp/src/components/CreateConnection/SchemaError.tsx new file mode 100644 index 000000000000..bfa7fc2d00ee --- /dev/null +++ b/airbyte-webapp/src/components/CreateConnection/SchemaError.tsx @@ -0,0 +1,19 @@ +import { JobItem } from "components/JobItem/JobItem"; +import { Card } from "components/ui/Card"; + +import { LogsRequestError } from "core/request/LogsRequestError"; +import { useConnectionFormService } from "hooks/services/ConnectionForm/ConnectionFormService"; +import { SchemaError as SchemaErrorType } from "hooks/services/useSourceHook"; + +import { TryAfterErrorBlock } from "./TryAfterErrorBlock"; + +export const SchemaError = ({ schemaError }: { schemaError: SchemaErrorType }) => { + const job = LogsRequestError.extractJobInfo(schemaError); + const { refreshSchema } = useConnectionFormService(); + return ( + + + {job && } + + ); +}; diff --git a/airbyte-webapp/src/components/CreateConnection/TryAfterErrorBlock.tsx b/airbyte-webapp/src/components/CreateConnection/TryAfterErrorBlock.tsx index 4c7a1145e86d..276bf1364494 100644 --- a/airbyte-webapp/src/components/CreateConnection/TryAfterErrorBlock.tsx +++ b/airbyte-webapp/src/components/CreateConnection/TryAfterErrorBlock.tsx @@ -12,7 +12,7 @@ interface TryAfterErrorBlockProps { onClick: () => void; } -const TryAfterErrorBlock: React.FC = ({ message, onClick }) => ( +export const TryAfterErrorBlock: React.FC = ({ message, onClick }) => (
@@ -23,5 +23,3 @@ const TryAfterErrorBlock: React.FC = ({ message, onClic
); - -export default TryAfterErrorBlock; diff --git a/airbyte-webapp/src/components/CreateConnection/__snapshots__/CreateConnectionForm.test.tsx.snap b/airbyte-webapp/src/components/CreateConnection/__snapshots__/CreateConnectionForm.test.tsx.snap new file mode 100644 index 000000000000..f32652563982 --- /dev/null +++ b/airbyte-webapp/src/components/CreateConnection/__snapshots__/CreateConnectionForm.test.tsx.snap @@ -0,0 +1,1006 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreateConnectionForm should render 1`] = ` +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ Transfer +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+ Every 24 hours +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+ Streams +
+ +
+
+
+ +
+
+
+
+ + +
+
+
+
+
+ Mirror source structure +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ Activate the streams you want to sync +
+ +
+
+
+ +
+
+
+
+ +
+
+
+ Sync +
+
+ Source +
+
+
+ + + +
+
+
+
+
+
+ Sync mode +
+
+
+ + + +
+
+
+
+
+ Cursor field +
+
+
+ + + +
+
+
+
+
+ Primary key +
+
+
+ + + +
+
+
+
+
+ Destination +
+
+
+ + + +
+
+
+
+
+
+
+
+ Namespace +
+
+ Stream name +
+
+ Source | Destination +
+
+
+
+ Namespace +
+
+ Stream name +
+
+
+
+
+
+ +
+
+ + + +
+
+
+ +
+
+ + No namespace + +
+
+ pokemon +
+
+
+ + +
+
+
+
+
+ Full refresh +
+
+ | +
+
+ Overwrite +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ '<source schema> +
+
+ pokemon +
+
+
+
+
+
+
+
+
+
+
+
+
+ Normalization & Transformation +
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+`; + +exports[`CreateConnectionForm should render when loading 1`] = ` +
+
+
+
+
+ Please wait a little bit more… +
+
+
+ We are fetching the schema of your data source. +This should take less than a minute, but may take a few minutes on slow internet connections or data sources with a large amount of tables. +
+
+
+`; + +exports[`CreateConnectionForm should render with an error 1`] = ` +
+
+
+
+ +
+

+ Failed to fetch schema. Please try again +

+ +
+
+
+`; diff --git a/airbyte-webapp/src/components/CreateConnection/index.tsx b/airbyte-webapp/src/components/CreateConnection/index.tsx new file mode 100644 index 000000000000..8d57b01b25fc --- /dev/null +++ b/airbyte-webapp/src/components/CreateConnection/index.tsx @@ -0,0 +1 @@ +export * from "./CreateConnectionForm"; diff --git a/airbyte-webapp/src/hooks/services/ConnectionEdit/ConnectionEditService.test.tsx b/airbyte-webapp/src/hooks/services/ConnectionEdit/ConnectionEditService.test.tsx new file mode 100644 index 000000000000..b12e4e2ac8fa --- /dev/null +++ b/airbyte-webapp/src/hooks/services/ConnectionEdit/ConnectionEditService.test.tsx @@ -0,0 +1,108 @@ +import { act, renderHook } from "@testing-library/react-hooks"; +import React from "react"; +import mockConnection from "test-utils/mock-data/mockConnection.json"; +import mockDest from "test-utils/mock-data/mockDestinationDefinition.json"; +import { TestWrapper } from "test-utils/testutils"; + +import { WebBackendConnectionUpdate } from "core/request/AirbyteClient"; + +import { useConnectionFormService } from "../ConnectionForm/ConnectionFormService"; +import { ConnectionEditServiceProvider, useConnectionEditService } from "./ConnectionEditService"; + +jest.mock("services/connector/DestinationDefinitionSpecificationService", () => ({ + useGetDestinationDefinitionSpecification: () => mockDest, +})); + +jest.mock("../useConnectionHook", () => ({ + useGetConnection: () => mockConnection, + useWebConnectionService: () => ({ + getConnection: () => mockConnection, + }), + useUpdateConnection: () => ({ + mutateAsync: jest.fn(async (connection: WebBackendConnectionUpdate) => { + return { ...mockConnection, ...connection }; + }), + isLoading: false, + }), +})); + +describe("ConnectionEditService", () => { + const Wrapper: React.FC[0]> = ({ children, ...props }) => ( + + {children} + + ); + + const refreshSchema = jest.fn(); + + beforeEach(() => { + refreshSchema.mockReset(); + }); + + it("should load a Connection from a connectionId", async () => { + const { result } = renderHook(useConnectionEditService, { + wrapper: Wrapper, + initialProps: { + connectionId: mockConnection.connectionId, + }, + }); + + expect(result.current.connection).toEqual(mockConnection); + }); + + it("should update a connection and set the current connection object to the updated connection", async () => { + const { result } = renderHook(useConnectionEditService, { + wrapper: Wrapper, + initialProps: { + connectionId: mockConnection.connectionId, + }, + }); + + const mockUpdateConnection: WebBackendConnectionUpdate = { + connectionId: mockConnection.connectionId, + name: "new connection name", + prefix: "new connection prefix", + syncCatalog: { streams: [] }, + }; + + await act(async () => { + await result.current.updateConnection(mockUpdateConnection); + }); + + expect(result.current.connection).toEqual({ ...mockConnection, ...mockUpdateConnection }); + }); + + it("should refresh connection", async () => { + // Need to combine the hooks so both can be used. + const useMyTestHook = () => { + return [useConnectionEditService(), useConnectionFormService()] as const; + }; + + const { result } = renderHook(useMyTestHook, { + wrapper: Wrapper, + initialProps: { + connectionId: mockConnection.connectionId, + }, + }); + + const mockUpdateConnection: WebBackendConnectionUpdate = { + connectionId: mockConnection.connectionId, + name: "new connection name", + prefix: "new connection prefix", + syncCatalog: { streams: [] }, + }; + + await act(async () => { + await result.current[0].updateConnection(mockUpdateConnection); + }); + + expect(result.current[0].connection).toEqual({ ...mockConnection, ...mockUpdateConnection }); + + await act(async () => { + await result.current[1].refreshSchema(); + }); + + expect(result.current[0].schemaHasBeenRefreshed).toBe(true); + expect(result.current[0].connection).toEqual(mockConnection); + }); +}); diff --git a/airbyte-webapp/src/hooks/services/ConnectionEdit/ConnectionEditService.tsx b/airbyte-webapp/src/hooks/services/ConnectionEdit/ConnectionEditService.tsx new file mode 100644 index 000000000000..31a5cfa6ebb8 --- /dev/null +++ b/airbyte-webapp/src/hooks/services/ConnectionEdit/ConnectionEditService.tsx @@ -0,0 +1,73 @@ +import { useContext, useState, createContext, useCallback } from "react"; +import { useAsyncFn } from "react-use"; + +import { ConnectionStatus, WebBackendConnectionUpdate } from "core/request/AirbyteClient"; + +import { ConnectionFormServiceProvider } from "../ConnectionForm/ConnectionFormService"; +import { useGetConnection, useUpdateConnection, useWebConnectionService } from "../useConnectionHook"; +import { SchemaError } from "../useSourceHook"; + +interface ConnectionEditProps { + connectionId: string; +} + +const useConnectionEdit = ({ connectionId }: ConnectionEditProps) => { + const [connection, setConnection] = useState(useGetConnection(connectionId)); + const connectionService = useWebConnectionService(); + const [schemaHasBeenRefreshed, setSchemaHasBeenRefreshed] = useState(false); + + const [{ loading: schemaRefreshing, error: schemaError }, refreshSchema] = useAsyncFn(async () => { + const refreshedConnection = await connectionService.getConnection(connectionId, true); + setConnection(refreshedConnection); + setSchemaHasBeenRefreshed(true); + }, [connectionId]); + + const { mutateAsync: updateConnectionAction, isLoading: connectionUpdating } = useUpdateConnection(); + + const updateConnection = useCallback( + async (connection: WebBackendConnectionUpdate) => { + setConnection(await updateConnectionAction(connection)); + }, + [updateConnectionAction] + ); + + return { + connection, + connectionUpdating, + schemaError, + schemaRefreshing, + schemaHasBeenRefreshed, + updateConnection, + setSchemaHasBeenRefreshed, + refreshSchema, + }; +}; + +const ConnectionEditContext = createContext, + "refreshSchema" | "schemaError" +> | null>(null); + +export const ConnectionEditServiceProvider: React.FC = ({ children, ...props }) => { + const { refreshSchema, schemaError, ...data } = useConnectionEdit(props); + return ( + + + {children} + + + ); +}; + +export const useConnectionEditService = () => { + const context = useContext(ConnectionEditContext); + if (context === null) { + throw new Error("useConnectionEditService must be used within a ConnectionEditServiceProvider"); + } + return context; +}; diff --git a/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.test.tsx b/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.test.tsx index 91fa31ddb4e6..b869fdbc0777 100644 --- a/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.test.tsx +++ b/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.test.tsx @@ -1,217 +1,127 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { act } from "@testing-library/react"; -import { renderHook } from "@testing-library/react-hooks"; +import { act, renderHook } from "@testing-library/react-hooks"; import React from "react"; -import { MemoryRouter } from "react-router-dom"; import mockConnection from "test-utils/mock-data/mockConnection.json"; import mockDest from "test-utils/mock-data/mockDestinationDefinition.json"; -import mockWorkspace from "test-utils/mock-data/mockWorkspace.json"; import { TestWrapper } from "test-utils/testutils"; -import { ConnectionScheduleType, WebBackendConnectionRead } from "core/request/AirbyteClient"; +import { AirbyteCatalog, WebBackendConnectionRead } from "core/request/AirbyteClient"; +import { FormError } from "utils/errorStatusMessage"; -import { ModalCancel } from "../Modal"; import { ConnectionFormServiceProvider, - ConnectionServiceProps, + ConnectionOrPartialConnection, useConnectionFormService, } from "./ConnectionFormService"; -["services/workspaces/WorkspacesService"].forEach((s) => - jest.mock(s, () => ({ - useCurrentWorkspaceId: () => mockWorkspace.workspaceId, - useCurrentWorkspace: () => mockWorkspace, - })) -); - -jest.mock("../FormChangeTracker", () => ({ - useFormChangeTrackerService: () => ({ clearFormChange: () => null }), - useUniqueFormId: () => "blah", -})); jest.mock("services/connector/DestinationDefinitionSpecificationService", () => ({ useGetDestinationDefinitionSpecification: () => mockDest, })); describe("ConnectionFormService", () => { - const Wrapper: React.FC = ({ children, ...props }) => ( + const Wrapper: React.FC[0]> = ({ children, ...props }) => ( - - {children} - + {children} ); - const onSubmit = jest.fn(); - const onAfterSubmit = jest.fn(); - const onCancel = jest.fn(); + const refreshSchema = jest.fn(); beforeEach(() => { - onSubmit.mockReset(); - onAfterSubmit.mockReset(); - onCancel.mockReset(); + refreshSchema.mockReset(); }); - it("should call onSubmit when submitted", async () => { + it("should take a partial Connection", async () => { + const partialConnection: ConnectionOrPartialConnection = { + syncCatalog: mockConnection.syncCatalog as AirbyteCatalog, + source: mockConnection.source, + destination: mockConnection.destination, + }; const { result } = renderHook(useConnectionFormService, { wrapper: Wrapper, initialProps: { - connection: mockConnection as WebBackendConnectionRead, + connection: partialConnection, mode: "create", - formId: Math.random().toString(), - onSubmit, - onAfterSubmit, - onCancel, - formDirty: false, + refreshSchema, }, }); - const resetForm = jest.fn(); - const testValues: any = {}; - await act(async () => { - await result.current.onFormSubmit(testValues, { resetForm } as any); - }); + expect(result.current.connection).toEqual(partialConnection); + }); - expect(resetForm).toBeCalledWith({ values: testValues }); - expect(onSubmit).toBeCalledWith({ - operations: [], - scheduleData: { - cron: { - cronExpression: undefined, - cronTimeZone: undefined, - }, - }, - syncCatalog: { - streams: undefined, + it("should take a full Connection", async () => { + const { result } = renderHook(useConnectionFormService, { + wrapper: Wrapper, + initialProps: { + connection: mockConnection as WebBackendConnectionRead, + mode: "create", + refreshSchema, }, }); - expect(onAfterSubmit).toBeCalledWith(); - expect(result.current.errorMessage).toBe(null); - }); - const expectation = { - [ConnectionScheduleType.basic]: { - basicSchedule: { - timeUnit: undefined, - units: undefined, - }, - }, - [ConnectionScheduleType.manual]: undefined, - [ConnectionScheduleType.cron]: { - cron: { - cronExpression: undefined, - cronTimeZone: undefined, - }, - }, - }; + expect(result.current.connection).toEqual(mockConnection); + }); - Object.values(ConnectionScheduleType).forEach((scheduleType) => { - it(`should return expected results when onSubmit is called with ${scheduleType}`, async () => { + describe("Error Message Generation", () => { + it("should show a validation error if the form is invalid and dirty", async () => { const { result } = renderHook(useConnectionFormService, { wrapper: Wrapper, initialProps: { connection: mockConnection as WebBackendConnectionRead, mode: "create", - formId: Math.random().toString(), - onSubmit, - onAfterSubmit, - onCancel, - formDirty: false, + refreshSchema, }, }); - const resetForm = jest.fn(); - const testValues: any = { - scheduleType, - }; - await act(async () => { - await result.current.onFormSubmit(testValues, { resetForm } as any); - }); + expect(result.current.getErrorMessage(false, true)).toBe( + "The form is invalid. Please make sure that all fields are correct." + ); + }); - expect(resetForm).toBeCalledWith({ values: testValues }); - expect(onSubmit).toBeCalledWith({ - operations: [], - scheduleData: expectation[scheduleType], - scheduleType, - syncCatalog: { - streams: undefined, + it("should not show a validation error if the form is valid and dirty", async () => { + const { result } = renderHook(useConnectionFormService, { + wrapper: Wrapper, + initialProps: { + connection: mockConnection as WebBackendConnectionRead, + mode: "create", + refreshSchema, }, }); - expect(onAfterSubmit).toBeCalledWith(); - expect(result.current.errorMessage).toBe(null); - }); - }); - - it("should catch if onSubmit throws and generate an error message", async () => { - const errorMessage = "asdf"; - onSubmit.mockImplementation(async () => { - throw new Error(errorMessage); - }); - - const { result } = renderHook(useConnectionFormService, { - wrapper: Wrapper, - initialProps: { - connection: mockConnection as WebBackendConnectionRead, - mode: "create", - formId: Math.random().toString(), - onSubmit, - onAfterSubmit, - onCancel, - formDirty: false, - }, - }); - const resetForm = jest.fn(); - const testValues: any = {}; - await act(async () => { - await result.current.onFormSubmit(testValues, { resetForm } as any); + expect(result.current.getErrorMessage(true, true)).toBe(null); }); - expect(result.current.errorMessage).toBe(errorMessage); - expect(resetForm).not.toHaveBeenCalled(); - }); - - it("should catch if onSubmit throws but not generate an error if it's a ModalCancel error", async () => { - onSubmit.mockImplementation(async () => { - throw new ModalCancel(); - }); + it("should not show a validation error if the form is invalid and not dirty", async () => { + const { result } = renderHook(useConnectionFormService, { + wrapper: Wrapper, + initialProps: { + connection: mockConnection as WebBackendConnectionRead, + mode: "create", + refreshSchema, + }, + }); - const { result } = renderHook(useConnectionFormService, { - wrapper: Wrapper, - initialProps: { - connection: mockConnection as WebBackendConnectionRead, - mode: "create", - formId: Math.random().toString(), - onSubmit, - onAfterSubmit, - onCancel, - formDirty: false, - }, + expect(result.current.getErrorMessage(false, false)).toBe(null); }); - const resetForm = jest.fn(); - const testValues: any = {}; - await act(async () => { - await result.current.onFormSubmit(testValues, { resetForm } as any); - }); + it("should show a message when given a submit error", () => { + const { result } = renderHook(useConnectionFormService, { + wrapper: Wrapper, + initialProps: { + connection: mockConnection as WebBackendConnectionRead, + mode: "create", + refreshSchema, + }, + }); - expect(result.current.errorMessage).toBe(null); - expect(resetForm).not.toHaveBeenCalled(); - }); + const errMsg = "asdf"; + act(() => { + result.current.setSubmitError(new FormError(errMsg)); + }); - it("should render the generic form invalid error message if the form is dirty and there has not been a submit error", async () => { - const { result } = renderHook(useConnectionFormService, { - wrapper: Wrapper, - initialProps: { - connection: mockConnection as WebBackendConnectionRead, - mode: "create", - formId: Math.random().toString(), - onSubmit, - onAfterSubmit, - onCancel, - formDirty: true, - }, + expect(result.current.getErrorMessage(false, false)).toBe(errMsg); + expect(result.current.getErrorMessage(false, true)).toBe(errMsg); + expect(result.current.getErrorMessage(true, false)).toBe(errMsg); + expect(result.current.getErrorMessage(true, true)).toBe(errMsg); }); - - expect(result.current.errorMessage).toBe("The form is invalid. Please make sure that all fields are correct."); }); }); diff --git a/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.tsx b/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.tsx index 182a7d1543b1..670eecf66548 100644 --- a/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.tsx +++ b/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.tsx @@ -1,112 +1,82 @@ -import { FormikHelpers } from "formik"; -import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; +import React, { createContext, useCallback, useContext, useState } from "react"; import { useIntl } from "react-intl"; -import { ConnectionScheduleType, WebBackendConnectionRead } from "core/request/AirbyteClient"; +import { ConnectionScheduleType, OperationRead, WebBackendConnectionRead } from "core/request/AirbyteClient"; import { useGetDestinationDefinitionSpecification } from "services/connector/DestinationDefinitionSpecificationService"; -import { useCurrentWorkspaceId } from "services/workspaces/WorkspacesService"; -import { generateMessageFromError } from "utils/errorStatusMessage"; -import { ConnectionFormMode } from "views/Connection/ConnectionForm/ConnectionForm"; +import { FormError, generateMessageFromError } from "utils/errorStatusMessage"; import { ConnectionFormValues, connectionValidationSchema, FormikConnectionFormValues, mapFormPropsToOperation, - useFrequencyDropdownData, useInitialValues, } from "views/Connection/ConnectionForm/formConfig"; -import { useFormChangeTrackerService } from "../FormChangeTracker"; -import { ModalCancel } from "../Modal"; +import { useUniqueFormId } from "../FormChangeTracker"; import { ValuesProps } from "../useConnectionHook"; +import { SchemaError } from "../useSourceHook"; + +export type ConnectionFormMode = "create" | "edit" | "readonly"; export type ConnectionOrPartialConnection = | WebBackendConnectionRead | (Partial & Pick); -export interface ConnectionServiceProps { +interface ConnectionServiceProps { connection: ConnectionOrPartialConnection; mode: ConnectionFormMode; - formId: string; - onSubmit: (values: ValuesProps) => Promise; - onAfterSubmit?: () => void; - onCancel?: () => void; - formDirty: boolean; + schemaError?: SchemaError | null; + refreshSchema: () => Promise; } -const useConnectionForm = ({ - connection, - mode, - formId, - onSubmit, - onAfterSubmit, - onCancel, - formDirty, -}: ConnectionServiceProps) => { - const [submitError, setSubmitError] = useState(null); - const workspaceId = useCurrentWorkspaceId(); - const { clearFormChange } = useFormChangeTrackerService(); - const { formatMessage } = useIntl(); +export const tidyConnectionFormValues = ( + values: FormikConnectionFormValues, + workspaceId: string, + mode: ConnectionFormMode, + operations?: OperationRead[] +): ValuesProps => { + // TODO (https://github.com/airbytehq/airbyte/issues/17279): We should try to fix the types so we don't need the casting. + const formValues: ConnectionFormValues = connectionValidationSchema(mode).cast(values, { + context: { isRequest: true }, + }) as unknown as ConnectionFormValues; + + formValues.operations = mapFormPropsToOperation(values, operations, workspaceId); + + if (formValues.scheduleType === ConnectionScheduleType.manual) { + // Have to set this to undefined to override the existing scheduleData + formValues.scheduleData = undefined; + } + + return formValues; +}; +const useConnectionForm = ({ connection, mode, schemaError, refreshSchema }: ConnectionServiceProps) => { const destDefinition = useGetDestinationDefinitionSpecification(connection.destination.destinationDefinitionId); const initialValues = useInitialValues(connection, destDefinition, mode !== "create"); + const { formatMessage } = useIntl(); + const [submitError, setSubmitError] = useState(null); + const formId = useUniqueFormId(); - const onFormSubmit = useCallback( - async (values: FormikConnectionFormValues, formikHelpers: FormikHelpers) => { - // TODO: We should align these types - // With the PATCH-style endpoint available we might be able to forego this pattern - const formValues: ConnectionFormValues = connectionValidationSchema.cast(values, { - context: { isRequest: true }, - }) as unknown as ConnectionFormValues; - - formValues.operations = mapFormPropsToOperation(values, connection.operations, workspaceId); - - if (formValues.scheduleType === ConnectionScheduleType.manual) { - // Have to set this to undefined to override the existing scheduleData - formValues.scheduleData = undefined; - } - - setSubmitError(null); - try { - // This onSubmit comes from either ReplicationView.tsx (Connection Edit), or CreateConnectionContent.tsx (Connection Create). - await onSubmit(formValues); - - formikHelpers.resetForm({ values }); - // We need to clear the form changes otherwise the dirty form intercept service will prevent navigation - clearFormChange(formId); - - onAfterSubmit?.(); - } catch (e) { - if (!(e instanceof ModalCancel)) { - setSubmitError(e); - } - } - }, - [connection.operations, workspaceId, onSubmit, clearFormChange, formId, onAfterSubmit] - ); - - const errorMessage = useMemo( - () => + const getErrorMessage = useCallback( + (formValid: boolean, connectionDirty: boolean) => submitError ? generateMessageFromError(submitError) - : formDirty + : connectionDirty && !formValid ? formatMessage({ id: "connectionForm.validation.error" }) : null, - [formDirty, formatMessage, submitError] + [formatMessage, submitError] ); - const frequencies = useFrequencyDropdownData(connection.scheduleData); return { - initialValues, - destDefinition, connection, mode, - errorMessage, - frequencies, + destDefinition, + initialValues, + schemaError, formId, - onFormSubmit, - onAfterSubmit, - onCancel, + setSubmitError, + getErrorMessage, + refreshSchema, }; }; diff --git a/airbyte-webapp/src/hooks/services/useConnectionHook.tsx b/airbyte-webapp/src/hooks/services/useConnectionHook.tsx index f5261686ea04..838da75b5f31 100644 --- a/airbyte-webapp/src/hooks/services/useConnectionHook.tsx +++ b/airbyte-webapp/src/hooks/services/useConnectionHook.tsx @@ -57,14 +57,14 @@ interface CreateConnectionProps { sourceCatalogId: string | undefined; } -function useWebConnectionService() { +export const useWebConnectionService = () => { const config = useConfig(); const middlewares = useDefaultRequestMiddlewares(); return useInitService( () => new WebBackendConnectionService(config.apiUrl, middlewares), [config.apiUrl, middlewares] ); -} +}; export function useConnectionService() { const config = useConfig(); @@ -72,23 +72,6 @@ export function useConnectionService() { return useInitService(() => new ConnectionService(config.apiUrl, middlewares), [config.apiUrl, middlewares]); } -export const useConnectionLoad = ( - connectionId: string -): { - connection: WebBackendConnectionRead; - refreshConnectionCatalog: () => Promise; -} => { - const connection = useGetConnection(connectionId); - const connectionService = useWebConnectionService(); - - const refreshConnectionCatalog = async () => await connectionService.getConnection(connectionId, true); - - return { - connection, - refreshConnectionCatalog, - }; -}; - export const useSyncConnection = () => { const service = useConnectionService(); const analyticsService = useAnalyticsService(); diff --git a/airbyte-webapp/src/hooks/services/useSourceHook.tsx b/airbyte-webapp/src/hooks/services/useSourceHook.tsx index dc275c1c96fe..1261b16e0a5f 100644 --- a/airbyte-webapp/src/hooks/services/useSourceHook.tsx +++ b/airbyte-webapp/src/hooks/services/useSourceHook.tsx @@ -10,7 +10,7 @@ import { JobInfo } from "core/domain/job"; import { useInitService } from "services/useInitService"; import { isDefined } from "utils/common"; -import { SourceRead, SynchronousJobRead, WebBackendConnectionListItem } from "../../core/request/AirbyteClient"; +import { SourceRead, WebBackendConnectionListItem } from "../../core/request/AirbyteClient"; import { useSuspenseQuery } from "../../services/connector/useSuspenseQuery"; import { SCOPE_WORKSPACE } from "../../services/Scope"; import { useDefaultRequestMiddlewares } from "../../services/useDefaultRequestMiddlewares"; @@ -149,13 +149,15 @@ const useUpdateSource = () => { ); }; +export type SchemaError = (Error & { status: number; response: JobInfo }) | null; + const useDiscoverSchema = ( sourceId: string, disableCache?: boolean ): { isLoading: boolean; schema: SyncSchema; - schemaErrorStatus: { status: number; response: SynchronousJobRead } | null; + schemaErrorStatus: SchemaError; catalogId: string | undefined; onDiscoverSchema: () => Promise; } => { @@ -163,10 +165,7 @@ const useDiscoverSchema = ( const [schema, setSchema] = useState({ streams: [] }); const [catalogId, setCatalogId] = useState(""); const [isLoading, setIsLoading] = useState(false); - const [schemaErrorStatus, setSchemaErrorStatus] = useState<{ - status: number; - response: JobInfo; - } | null>(null); + const [schemaErrorStatus, setSchemaErrorStatus] = useState(null); const onDiscoverSchema = useCallback(async () => { setIsLoading(true); diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionItemPage.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionItemPage.tsx index 6228239a3e7d..fb4d5654f631 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionItemPage.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionItemPage.tsx @@ -1,14 +1,15 @@ -import React, { Suspense, useState } from "react"; +import React, { Suspense } from "react"; import { Navigate, Route, Routes, useParams } from "react-router-dom"; import { LoadingPage, MainPageWithScroll } from "components"; import HeadTitle from "components/HeadTitle"; -import { Action, Namespace } from "core/analytics"; -import { getFrequencyFromScheduleData } from "core/analytics/utils"; import { ConnectionStatus } from "core/request/AirbyteClient"; -import { useAnalyticsService, useTrackPage, PageTrackingCodes } from "hooks/services/Analytics"; -import { useGetConnection } from "hooks/services/useConnectionHook"; +import { useTrackPage, PageTrackingCodes } from "hooks/services/Analytics"; +import { + ConnectionEditServiceProvider, + useConnectionEditService, +} from "hooks/services/ConnectionEdit/ConnectionEditService"; import { ConnectionPageTitle } from "./ConnectionPageTitle"; import { ConnectionReplicationTab } from "./ConnectionReplicationTab"; @@ -17,30 +18,10 @@ import { ConnectionSettingsTab } from "./ConnectionSettingsTab"; import { ConnectionStatusTab } from "./ConnectionStatusTab"; import { ConnectionTransformationTab } from "./ConnectionTransformationTab"; -export const ConnectionItemPage: React.FC = () => { - const params = useParams<{ - connectionId: string; - "*": ConnectionSettingsRoutes; - }>(); - const connectionId = params.connectionId || ""; - const currentStep = params["*"] || ConnectionSettingsRoutes.STATUS; - const connection = useGetConnection(connectionId); - const [isStatusUpdating, setStatusUpdating] = useState(false); - const analyticsService = useAnalyticsService(); +export const ConnectionItemPageInner: React.FC = () => { + const { connection } = useConnectionEditService(); useTrackPage(PageTrackingCodes.CONNECTIONS_ITEM); - const { source, destination } = connection; - - const onAfterSaveSchema = () => { - analyticsService.track(Namespace.CONNECTION, Action.EDIT_SCHEMA, { - actionDescription: "Connection saved with catalog changes", - connector_source: source.sourceName, - connector_source_definition_id: source.sourceDefinitionId, - connector_destination: destination.destinationName, - connector_destination_definition_id: destination.destinationDefinitionId, - frequency: getFrequencyFromScheduleData(connection.scheduleData), - }); - }; const isConnectionDeleted = connection.status === ConnectionStatus.deprecated; @@ -53,33 +34,19 @@ export const ConnectionItemPage: React.FC = () => { { id: "connection.fromTo", values: { - source: source.name, - destination: destination.name, + source: connection.source.name, + destination: connection.destination.name, }, }, ]} /> } - pageTitle={ - - } + pageTitle={} > }> - } - /> - } - /> + } /> + } /> } @@ -96,3 +63,15 @@ export const ConnectionItemPage: React.FC = () => { ); }; + +export const ConnectionItemPage = () => { + const params = useParams<{ + connectionId: string; + }>(); + const connectionId = params.connectionId || ""; + return ( + + + + ); +}; diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionName.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionName.tsx index c9b49a515310..facd7196ae19 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionName.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionName.tsx @@ -5,26 +5,20 @@ import React, { ChangeEvent, useState } from "react"; import { Input } from "components/ui/Input"; import { Text } from "components/ui/Text"; -import { buildConnectionUpdate } from "core/domain/connection"; -import { WebBackendConnectionRead } from "core/request/AirbyteClient"; -import { useUpdateConnection } from "hooks/services/useConnectionHook"; +import { useConnectionEditService } from "hooks/services/ConnectionEdit/ConnectionEditService"; import withKeystrokeHandler from "utils/withKeystrokeHandler"; import styles from "./ConnectionName.module.scss"; -interface ConnectionNameProps { - connection: WebBackendConnectionRead; -} - const InputWithKeystroke = withKeystrokeHandler(Input); -export const ConnectionName: React.FC = ({ connection }) => { +export const ConnectionName: React.FC = () => { + const { connection, updateConnection } = useConnectionEditService(); const { name } = connection; const [editingState, setEditingState] = useState(false); const [loading, setLoading] = useState(false); const [connectionName, setConnectionName] = useState(connection.name); const [connectionNameBackup, setConnectionNameBackup] = useState(connectionName); - const { mutateAsync: updateConnection } = useUpdateConnection(); const inputChange = ({ currentTarget: { value } }: ChangeEvent) => setConnectionName(value); @@ -54,7 +48,10 @@ export const ConnectionName: React.FC = ({ connection }) => try { setLoading(true); - await updateConnection(buildConnectionUpdate(connection, { name: connectionNameTrimmed })); + await updateConnection({ + name: connectionNameTrimmed, + connectionId: connection.connectionId, + }); setConnectionName(connectionNameTrimmed); setConnectionNameBackup(connectionNameTrimmed); diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionPageTitle.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionPageTitle.tsx index ec474c2fa9f5..ba9715e39396 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionPageTitle.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionPageTitle.tsx @@ -1,35 +1,26 @@ import { faTrash } from "@fortawesome/free-solid-svg-icons"; import React, { useCallback, useMemo } from "react"; import { FormattedMessage } from "react-intl"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { InfoBox } from "components/ui/InfoBox"; import { StepsMenu } from "components/ui/StepsMenu"; import { Text } from "components/ui/Text"; -import { ConnectionStatus, DestinationRead, SourceRead, WebBackendConnectionRead } from "core/request/AirbyteClient"; +import { ConnectionStatus } from "core/request/AirbyteClient"; +import { useConnectionEditService } from "hooks/services/ConnectionEdit/ConnectionEditService"; import { ConnectionName } from "./ConnectionName"; import styles from "./ConnectionPageTitle.module.scss"; import { ConnectionSettingsRoutes } from "./ConnectionSettingsRoutes"; import { StatusMainInfo } from "./StatusMainInfo"; -interface ConnectionPageTitleProps { - source: SourceRead; - destination: DestinationRead; - connection: WebBackendConnectionRead; - currentStep: ConnectionSettingsRoutes; - onStatusUpdating?: (updating: boolean) => void; -} - -export const ConnectionPageTitle: React.FC = ({ - source, - destination, - connection, - currentStep, - onStatusUpdating, -}) => { +export const ConnectionPageTitle: React.FC = () => { + const params = useParams<{ id: string; "*": ConnectionSettingsRoutes }>(); const navigate = useNavigate(); + const currentStep = params["*"] || ConnectionSettingsRoutes.STATUS; + + const { connection } = useConnectionEditService(); const steps = useMemo(() => { const steps = [ @@ -77,14 +68,9 @@ export const ConnectionPageTitle: React.FC = ({ - +
- +
diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.module.scss b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.module.scss index 10360aaf922e..c437068665ea 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.module.scss +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.module.scss @@ -1,14 +1,13 @@ @use "../../../../scss/variables"; -.tryArrow { - margin: 0 variables.$spacing-md -1px 0; - - // used to control svg size - font-size: 14px; -} - .content { max-width: 1279px; margin: 0 auto; padding-bottom: variables.$spacing-md; + + > form { + display: flex; + flex-direction: column; + gap: variables.$spacing-md; + } } diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.test.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.test.tsx new file mode 100644 index 000000000000..87a355ee8f37 --- /dev/null +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.test.tsx @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import { render as tlr, act } from "@testing-library/react"; +import { Suspense } from "react"; +import mockConnection from "test-utils/mock-data/mockConnection.json"; +import mockDest from "test-utils/mock-data/mockDestinationDefinition.json"; +import { TestWrapper } from "test-utils/testutils"; + +import { WebBackendConnectionUpdate } from "core/request/AirbyteClient"; +import { ConnectionEditServiceProvider } from "hooks/services/ConnectionEdit/ConnectionEditService"; +import * as connectionHook from "hooks/services/useConnectionHook"; + +import { ConnectionReplicationTab } from "./ConnectionReplicationTab"; + +jest.mock("services/connector/DestinationDefinitionSpecificationService", () => ({ + useGetDestinationDefinitionSpecification: () => mockDest, +})); + +describe("ConnectionReplicationTab", () => { + const Wrapper: React.FC = ({ children }) => ( + I should not show up in a snapshot
}> + + + {children} + + + + ); + const render = async () => { + let renderResult: ReturnType; + await act(async () => { + renderResult = tlr( + + + + ); + }); + return renderResult!; + }; + + const setupSpies = (getConnection?: () => Promise) => { + const getConnectionImpl: any = { + getConnection: getConnection ?? (() => new Promise(() => null) as any), + }; + jest.spyOn(connectionHook, "useGetConnection").mockImplementation(() => mockConnection as any); + jest.spyOn(connectionHook, "useWebConnectionService").mockImplementation(() => getConnectionImpl); + jest.spyOn(connectionHook, "useUpdateConnection").mockImplementation( + () => + ({ + mutateAsync: async (connection: WebBackendConnectionUpdate) => connection, + isLoading: false, + } as any) + ); + }; + it("should render", async () => { + setupSpies(); + + const renderResult = await render(); + expect(renderResult.container).toMatchSnapshot(); + }); + + it("should show an error if there is a schemaError", async () => { + setupSpies(() => Promise.reject("Test Error")); + + const renderResult = await render(); + + await act(async () => { + renderResult.queryByText("Refresh source schema")?.click(); + }); + expect(renderResult.container).toMatchSnapshot(); + }); + + it("should show loading if the schema is refreshing", async () => { + setupSpies(); + + const renderResult = await render(); + await act(async () => { + renderResult.queryByText("Refresh source schema")?.click(); + }); + + await act(async () => { + expect(renderResult.findByText("We are fetching the schema of your data source.", { exact: false })).toBeTruthy(); + }); + }); +}); diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.tsx index 97cb8b508f6c..c30abf287aa2 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.tsx @@ -1,241 +1,173 @@ -import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React, { useMemo, useState } from "react"; +import { Form, Formik, FormikHelpers } from "formik"; +import React, { useCallback, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useAsyncFn, useUnmount } from "react-use"; -import { LabeledSwitch } from "components/LabeledSwitch"; +import { SchemaError } from "components/CreateConnection/SchemaError"; import LoadingSchema from "components/LoadingSchema"; -import { Button } from "components/ui/Button"; -import { ModalBody, ModalFooter } from "components/ui/Modal"; +import { Action, Namespace } from "core/analytics"; +import { getFrequencyFromScheduleData } from "core/analytics/utils"; import { toWebBackendConnectionUpdate } from "core/domain/connection"; -import { ConnectionStateType, ConnectionStatus } from "core/request/AirbyteClient"; -import { PageTrackingCodes, useTrackPage } from "hooks/services/Analytics"; -import { useConfirmationModalService } from "hooks/services/ConfirmationModal"; -import { ConnectionFormServiceProvider } from "hooks/services/ConnectionForm/ConnectionFormService"; -import { useChangedFormsById, useUniqueFormId } from "hooks/services/FormChangeTracker"; -import { ModalCancel, useModalService } from "hooks/services/Modal"; +import { PageTrackingCodes, useAnalyticsService, useTrackPage } from "hooks/services/Analytics"; +import { useConnectionEditService } from "hooks/services/ConnectionEdit/ConnectionEditService"; import { - useConnectionLoad, - useConnectionService, - useUpdateConnection, - ValuesProps, -} from "hooks/services/useConnectionHook"; + tidyConnectionFormValues, + useConnectionFormService, +} from "hooks/services/ConnectionForm/ConnectionFormService"; +import { useModalService } from "hooks/services/Modal"; +import { useConnectionService, ValuesProps } from "hooks/services/useConnectionHook"; +import { useCurrentWorkspaceId } from "services/workspaces/WorkspacesService"; import { equal, naturalComparatorBy } from "utils/objects"; -import { CatalogDiffModal } from "views/Connection/CatalogDiffModal/CatalogDiffModal"; -import { ConnectionForm } from "views/Connection/ConnectionForm"; +import { useConfirmCatalogDiff } from "views/Connection/CatalogDiffModal/useConfirmCatalogDiff"; +import EditControls from "views/Connection/ConnectionForm/components/EditControls"; +import { ConnectionFormFields } from "views/Connection/ConnectionForm/ConnectionFormFields"; +import { connectionValidationSchema, FormikConnectionFormValues } from "views/Connection/ConnectionForm/formConfig"; import styles from "./ConnectionReplicationTab.module.scss"; +import { ResetWarningModal } from "./ResetWarningModal"; -interface ConnectionReplicationTabProps { - onAfterSaveSchema: () => void; - connectionId: string; -} - -interface ResetWarningModalProps { - onClose: (withReset: boolean) => void; - onCancel: () => void; - stateType: ConnectionStateType; -} +export const ConnectionReplicationTab: React.FC = () => { + const analyticsService = useAnalyticsService(); + const connectionService = useConnectionService(); + const workspaceId = useCurrentWorkspaceId(); -const ResetWarningModal: React.FC = ({ onCancel, onClose, stateType }) => { const { formatMessage } = useIntl(); - const [withReset, setWithReset] = useState(true); - const requireFullReset = stateType === ConnectionStateType.legacy; - return ( - <> - - {/* - TODO: This should use proper text stylings once we have them available. - See https://github.com/airbytehq/airbyte/issues/14478 - */} - -

- setWithReset(ev.target.checked)} - label={formatMessage({ - id: requireFullReset ? "connection.saveWithFullReset" : "connection.saveWithReset", - })} - checkbox - data-testid="resetModal-reset-checkbox" - /> -

-
- - - - - - ); -}; + const { openModal } = useModalService(); -export const ConnectionReplicationTab: React.FC = ({ - onAfterSaveSchema, - connectionId, -}) => { - const { formatMessage } = useIntl(); - const { openModal, closeModal } = useModalService(); - const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService(); - const [activeUpdatingSchemaMode, setActiveUpdatingSchemaMode] = useState(false); const [saved, setSaved] = useState(false); - const connectionService = useConnectionService(); - useTrackPage(PageTrackingCodes.CONNECTIONS_ITEM_REPLICATION); - const { mutateAsync: updateConnection } = useUpdateConnection(); + const { connection, schemaRefreshing, schemaHasBeenRefreshed, updateConnection, setSchemaHasBeenRefreshed } = + useConnectionEditService(); + const { initialValues, mode, schemaError, getErrorMessage, setSubmitError } = useConnectionFormService(); - const { connection: initialConnection, refreshConnectionCatalog } = useConnectionLoad(connectionId); + useTrackPage(PageTrackingCodes.CONNECTIONS_ITEM_REPLICATION); + + const saveConnection = useCallback( + async ( + values: ValuesProps, + { skipReset, catalogHasChanged }: { skipReset: boolean; catalogHasChanged: boolean } + ) => { + const connectionAsUpdate = toWebBackendConnectionUpdate(connection); + + await updateConnection({ + ...connectionAsUpdate, + ...values, + connectionId: connection.connectionId, + skipReset, + }); - const [{ value: connectionWithRefreshCatalog, loading: isRefreshingCatalog }, refreshCatalog] = useAsyncFn( - refreshConnectionCatalog, - [connectionId] + if (catalogHasChanged) { + // TODO (https://github.com/airbytehq/airbyte/issues/17666): Move this into a useTrackChangedCatalog method (name pending) post Vlad's analytics hook work + analyticsService.track(Namespace.CONNECTION, Action.EDIT_SCHEMA, { + actionDescription: "Connection saved with catalog changes", + connector_source: connection.source.sourceName, + connector_source_definition_id: connection.source.sourceDefinitionId, + connector_destination: connection.destination.destinationName, + connector_destination_definition_id: connection.destination.destinationDefinitionId, + frequency: getFrequencyFromScheduleData(connection.scheduleData), + }); + } + }, + [analyticsService, connection, updateConnection] ); - const formId = useUniqueFormId(); - - const [changedFormsById] = useChangedFormsById(); - const formDirty = useMemo(() => !!changedFormsById?.[formId], [changedFormsById, formId]); - - useUnmount(() => { - closeModal(); - closeConfirmationModal(); - }); - - const connection = activeUpdatingSchemaMode ? connectionWithRefreshCatalog : initialConnection; - - const saveConnection = async (values: ValuesProps, { skipReset }: { skipReset: boolean }) => { - if (!connection) { - // onSubmit should only be called while the catalog isn't currently refreshing at the moment, - // which is the only case when `connection` would be `undefined`. - return; - } - const initialSyncSchema = connection.syncCatalog; - const connectionAsUpdate = toWebBackendConnectionUpdate(connection); - - await updateConnection({ - ...connectionAsUpdate, - ...values, - connectionId, - // Use the name and status from the initial connection because - // The status can be toggled and the name can be changed in-between refreshing the schema - name: initialConnection.name, - status: initialConnection.status || "", - skipReset, - }); - - setSaved(true); - if (!equal(values.syncCatalog, initialSyncSchema)) { - onAfterSaveSchema(); - } - - if (activeUpdatingSchemaMode) { - setActiveUpdatingSchemaMode(false); - } - }; - - const onSubmitForm = async (values: ValuesProps): Promise => { - // Detect whether the catalog has any differences in its enabled streams compared to the original one. - // This could be due to user changes (e.g. in the sync mode) or due to new/removed - // streams due to a "refreshed source schema". - const hasCatalogChanged = !equal( - values.syncCatalog.streams - .filter((s) => s.config?.selected) - .sort(naturalComparatorBy((syncStream) => syncStream.stream?.name ?? "")), - initialConnection.syncCatalog.streams - .filter((s) => s.config?.selected) - .sort(naturalComparatorBy((syncStream) => syncStream.stream?.name ?? "")) - ); - // Whenever the catalog changed show a warning to the user, that we're about to reset their data. - // Given them a choice to opt-out in which case we'll be sending skipRefresh: true to the update - // endpoint. - if (hasCatalogChanged) { - const stateType = await connectionService.getStateType(connectionId); - const result = await openModal({ - title: formatMessage({ id: "connection.resetModalTitle" }), - size: "md", - content: (props) => , - }); - if (result.type === "canceled") { - throw new ModalCancel(); + const onFormSubmit = useCallback( + async (values: FormikConnectionFormValues, _: FormikHelpers) => { + const formValues = tidyConnectionFormValues(values, workspaceId, mode, connection.operations); + + // Detect whether the catalog has any differences in its enabled streams compared to the original one. + // This could be due to user changes (e.g. in the sync mode) or due to new/removed + // streams due to a "refreshed source schema". + const catalogHasChanged = !equal( + formValues.syncCatalog.streams + .filter((s) => s.config?.selected) + .sort(naturalComparatorBy((syncStream) => syncStream.stream?.name ?? "")), + connection.syncCatalog.streams + .filter((s) => s.config?.selected) + .sort(naturalComparatorBy((syncStream) => syncStream.stream?.name ?? "")) + ); + + setSubmitError(null); + + // Whenever the catalog changed show a warning to the user, that we're about to reset their data. + // Given them a choice to opt-out in which case we'll be sending skipReset: true to the update + // endpoint. + try { + if (catalogHasChanged) { + const stateType = await connectionService.getStateType(connection.connectionId); + const result = await openModal({ + title: formatMessage({ id: "connection.resetModalTitle" }), + size: "md", + content: (props) => , + }); + if (result.type !== "canceled") { + // Save the connection taking into account the correct skipReset value from the dialog choice. + // We also want to skip the reset sync if the connection is not in an "active" status + await saveConnection(formValues, { + skipReset: !result.reason || connection.status !== "active", + catalogHasChanged, + }); + } else { + // We don't want to set saved to true or schema has been refreshed to false. + return; + } + } else { + // The catalog hasn't changed. We don't need to ask for any confirmation and can simply save. + await saveConnection(formValues, { skipReset: true, catalogHasChanged }); + } + + setSaved(true); + setSchemaHasBeenRefreshed(false); + } catch (e) { + setSubmitError(e); } - // Save the connection taking into account the correct skipRefresh value from the dialog choice. - await saveConnection(values, { skipReset: !result.reason }); - } else { - // The catalog hasn't changed. We don't need to ask for any confirmation and can simply save. - await saveConnection(values, { skipReset: true }); - } - }; - - // TODO: Move this into the service next - const refreshSourceSchema = async () => { - setSaved(false); - setActiveUpdatingSchemaMode(true); - const { catalogDiff, syncCatalog } = await refreshCatalog(); - if (catalogDiff?.transforms && catalogDiff.transforms.length > 0) { - await openModal({ - title: formatMessage({ id: "connection.updateSchema.completed" }), - preventCancel: true, - content: ({ onClose }) => ( - - ), - }); - } - }; - - const onRefreshSourceSchema = async () => { - if (formDirty) { - // The form is dirty so we show a warning before proceeding. - openConfirmationModal({ - title: "connection.updateSchema.formChanged.title", - text: "connection.updateSchema.formChanged.text", - submitButtonText: "connection.updateSchema.formChanged.confirm", - onSubmit: () => { - closeConfirmationModal(); - refreshSourceSchema(); - }, - }); - } else { - // The form is not dirty so we can directly refresh the source schema. - refreshSourceSchema(); - } - }; + }, + [ + connection.connectionId, + connection.operations, + connection.status, + connection.syncCatalog.streams, + connectionService, + formatMessage, + mode, + openModal, + saveConnection, + setSchemaHasBeenRefreshed, + setSubmitError, + workspaceId, + ] + ); - const onCancelConnectionFormEdit = () => { - setSaved(false); - setActiveUpdatingSchemaMode(false); - }; + useConfirmCatalogDiff(); return (
- {!isRefreshingCatalog && connection ? ( - + ) : !schemaRefreshing && connection ? ( + - } - canSubmitUntouchedForm={activeUpdatingSchemaMode} - additionalSchemaControl={ - - } - /> - + {({ values, isSubmitting, isValid, dirty, resetForm }) => ( +
+ + { + resetForm(); + setSchemaHasBeenRefreshed(false); + }} + successMessage={saved && !dirty && } + errorMessage={getErrorMessage(isValid, dirty)} + enableControls={schemaHasBeenRefreshed || dirty} + /> + + )} + ) : ( )} diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionStatusTab.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionStatusTab.tsx index fb88ac1e3f40..0efa2a215992 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionStatusTab.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionStatusTab.tsx @@ -39,7 +39,6 @@ interface ActiveJob { interface ConnectionStatusTabProps { connection: WebBackendConnectionRead; - isStatusUpdating?: boolean; } const getJobRunningOrPending = (jobs: JobWithAttemptsRead[]) => { diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionTransformationTab.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionTransformationTab.tsx index 9e0790cb71cf..170ef75a7e9c 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionTransformationTab.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionTransformationTab.tsx @@ -16,6 +16,7 @@ import { WebBackendConnectionRead, } from "core/request/AirbyteClient"; import { useTrackPage, PageTrackingCodes } from "hooks/services/Analytics"; +import { ConnectionFormMode } from "hooks/services/ConnectionForm/ConnectionFormService"; import { FeatureItem, useFeature } from "hooks/services/Feature"; import { useUpdateConnection } from "hooks/services/useConnectionHook"; import { useCurrentWorkspace } from "hooks/services/useWorkspace"; @@ -23,7 +24,6 @@ import { useGetDestinationDefinitionSpecification } from "services/connector/Des import { FormikOnSubmit } from "types/formik"; import { NormalizationField } from "views/Connection/ConnectionForm/components/NormalizationField"; import { TransformationField } from "views/Connection/ConnectionForm/components/TransformationField"; -import { ConnectionFormMode } from "views/Connection/ConnectionForm/ConnectionForm"; import { getInitialNormalization, getInitialTransformations, diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/EnabledControl.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/EnabledControl.tsx index 72518f55d7d4..54dfa72e8580 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/EnabledControl.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/EnabledControl.tsx @@ -1,16 +1,15 @@ import React from "react"; import { FormattedMessage } from "react-intl"; -import { useUpdateEffect } from "react-use"; +import { useAsyncFn } from "react-use"; import styled from "styled-components"; import { Switch } from "components/ui/Switch"; import { Action, Namespace } from "core/analytics"; import { getFrequencyFromScheduleData } from "core/analytics/utils"; -import { buildConnectionUpdate } from "core/domain/connection"; -import { ConnectionStatus, WebBackendConnectionRead } from "core/request/AirbyteClient"; +import { ConnectionStatus } from "core/request/AirbyteClient"; import { useAnalyticsService } from "hooks/services/Analytics"; -import { useUpdateConnection } from "hooks/services/useConnectionHook"; +import { useConnectionEditService } from "hooks/services/ConnectionEdit/ConnectionEditService"; const ToggleLabel = styled.label` text-transform: uppercase; @@ -30,21 +29,19 @@ const Content = styled.div` `; interface EnabledControlProps { - connection: WebBackendConnectionRead; disabled?: boolean; - onStatusUpdating?: (updating: boolean) => void; } -const EnabledControl: React.FC = ({ connection, disabled, onStatusUpdating }) => { - const { mutateAsync: updateConnection, isLoading } = useUpdateConnection(); +const EnabledControl: React.FC = ({ disabled }) => { const analyticsService = useAnalyticsService(); - const onChangeStatus = async () => { - await updateConnection( - buildConnectionUpdate(connection, { - status: connection.status === ConnectionStatus.active ? ConnectionStatus.inactive : ConnectionStatus.active, - }) - ); + const { connection, updateConnection, connectionUpdating } = useConnectionEditService(); + + const [{ loading }, onChangeStatus] = useAsyncFn(async () => { + await updateConnection({ + connectionId: connection.connectionId, + status: connection.status === ConnectionStatus.active ? ConnectionStatus.inactive : ConnectionStatus.active, + }); const trackableAction = connection.status === ConnectionStatus.active ? Action.DISABLE : Action.REENABLE; @@ -56,11 +53,16 @@ const EnabledControl: React.FC = ({ connection, disabled, o connector_destination_definition_id: connection.destination?.destinationDefinitionId, frequency: getFrequencyFromScheduleData(connection.scheduleData), }); - }; - - useUpdateEffect(() => { - onStatusUpdating?.(isLoading); - }, [isLoading]); + }, [ + analyticsService, + connection.connectionId, + connection.destination?.destinationDefinitionId, + connection.destination?.destinationName, + connection.source?.sourceDefinitionId, + connection.source?.sourceName, + connection.status, + updateConnection, + ]); return ( @@ -68,10 +70,10 @@ const EnabledControl: React.FC = ({ connection, disabled, o diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ResetWarningModal.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ResetWarningModal.tsx new file mode 100644 index 000000000000..7d3fac8cab85 --- /dev/null +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ResetWarningModal.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useUnmount } from "react-use"; + +import { LabeledSwitch } from "components"; +import { Button } from "components/ui/Button"; +import { ModalBody, ModalFooter } from "components/ui/Modal"; + +import { ConnectionStateType } from "core/request/AirbyteClient"; +import { useModalService } from "hooks/services/Modal"; + +interface ResetWarningModalProps { + onClose: (withReset: boolean) => void; + onCancel: () => void; + stateType: ConnectionStateType; +} + +export const ResetWarningModal: React.FC = ({ onCancel, onClose, stateType }) => { + const { closeModal } = useModalService(); + const { formatMessage } = useIntl(); + const [withReset, setWithReset] = useState(true); + const requireFullReset = stateType === ConnectionStateType.legacy; + + useUnmount(() => { + closeModal(); + }); + + return ( + <> + + {/* + TODO: This should use proper text stylings once we have them available. + See https://github.com/airbytehq/airbyte/issues/14478 + */} + +

+ setWithReset(ev.target.checked)} + label={formatMessage({ + id: requireFullReset ? "connection.saveWithFullReset" : "connection.saveWithReset", + })} + checkbox + data-testid="resetModal-reset-checkbox" + /> +

+
+ + + + + + ); +}; diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/StatusMainInfo.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/StatusMainInfo.tsx index 4d77cdec4249..846017f2bf75 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/StatusMainInfo.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/StatusMainInfo.tsx @@ -5,7 +5,8 @@ import { Link } from "react-router-dom"; import { ConnectorCard } from "components"; -import { ConnectionStatus, SourceRead, DestinationRead, WebBackendConnectionRead } from "core/request/AirbyteClient"; +import { ConnectionStatus } from "core/request/AirbyteClient"; +import { useConnectionEditService } from "hooks/services/ConnectionEdit/ConnectionEditService"; import { FeatureItem, useFeature } from "hooks/services/Feature"; import { RoutePaths } from "pages/routePaths"; import { useDestinationDefinition } from "services/connector/DestinationDefinitionService"; @@ -14,19 +15,10 @@ import { useSourceDefinition } from "services/connector/SourceDefinitionService" import EnabledControl from "./EnabledControl"; import styles from "./StatusMainInfo.module.scss"; -interface StatusMainInfoProps { - connection: WebBackendConnectionRead; - source: SourceRead; - destination: DestinationRead; - onStatusUpdating?: (updating: boolean) => void; -} - -export const StatusMainInfo: React.FC = ({ - onStatusUpdating, - connection, - source, - destination, -}) => { +export const StatusMainInfo: React.FC = () => { + const { + connection: { source, destination, status }, + } = useConnectionEditService(); const sourceDefinition = useSourceDefinition(source.sourceDefinitionId); const destinationDefinition = useDestinationDefinition(destination.destinationDefinitionId); @@ -56,9 +48,9 @@ export const StatusMainInfo: React.FC = ({ />
- {connection.status !== ConnectionStatus.deprecated && ( + {status !== ConnectionStatus.deprecated && (
- +
)}
diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/__snapshots__/ConnectionReplicationTab.test.tsx.snap b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/__snapshots__/ConnectionReplicationTab.test.tsx.snap new file mode 100644 index 000000000000..cbe0bbfc3187 --- /dev/null +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/__snapshots__/ConnectionReplicationTab.test.tsx.snap @@ -0,0 +1,836 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConnectionReplicationTab should render 1`] = ` +
+
+
+
+
+
+
+ Transfer +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+ Manual +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+ Streams +
+ +
+
+
+ +
+
+
+
+ + +
+
+
+
+
+ Mirror source structure +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ Activate the streams you want to sync +
+ +
+
+
+ +
+
+
+
+ +
+
+
+ Sync +
+
+ Source +
+
+
+ + + +
+
+
+
+
+
+ Sync mode +
+
+
+ + + +
+
+
+
+
+ Cursor field +
+
+
+ + + +
+
+
+
+
+ Primary key +
+
+
+ + + +
+
+
+
+
+ Destination +
+
+
+ + + +
+
+
+
+
+
+
+
+ Namespace +
+
+ Stream name +
+
+ Source | Destination +
+
+
+
+ Namespace +
+
+ Stream name +
+
+
+
+
+
+ +
+
+ + + +
+
+
+ +
+
+ + No namespace + +
+
+ pokemon +
+
+
+ + +
+
+
+
+
+ Full refresh +
+
+ | +
+
+ Append +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ '<source schema> +
+
+ pokemon +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+
+`; + +exports[`ConnectionReplicationTab should show an error if there is a schemaError 1`] = ` +
+
+
+
+
+ +
+

+ Failed to fetch schema. Please try again +

+ +
+
+
+
+`; diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/CreationFormPage/CreationFormPage.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/CreationFormPage/CreationFormPage.tsx index 69224657cb98..3bce72196493 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/CreationFormPage/CreationFormPage.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/CreationFormPage/CreationFormPage.tsx @@ -5,7 +5,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { LoadingPage } from "components"; import ConnectionBlock from "components/ConnectionBlock"; import { FormPageContent } from "components/ConnectorBlocks"; -import { CreateConnection } from "components/CreateConnection/CreateConnection"; +import { CreateConnectionForm } from "components/CreateConnection/CreateConnectionForm"; import HeadTitle from "components/HeadTitle"; import { PageHeader } from "components/ui/PageHeader"; import { StepsMenu } from "components/ui/StepsMenu"; @@ -168,7 +168,7 @@ export const CreationFormPage: React.FC = () => { return ; } - return ; + return ; }; const steps = diff --git a/airbyte-webapp/src/pages/OnboardingPage/components/ConnectionStep.tsx b/airbyte-webapp/src/pages/OnboardingPage/components/ConnectionStep.tsx index 7826992684d8..6b19c6a1cc92 100644 --- a/airbyte-webapp/src/pages/OnboardingPage/components/ConnectionStep.tsx +++ b/airbyte-webapp/src/pages/OnboardingPage/components/ConnectionStep.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { CreateConnection } from "components/CreateConnection/CreateConnection"; +import { CreateConnectionForm } from "components/CreateConnection/CreateConnectionForm"; import { useDestinationList } from "hooks/services/useDestinationHook"; import { useSourceList } from "hooks/services/useSourceHook"; @@ -14,7 +14,11 @@ const ConnectionStep: React.FC = ({ onNextStep: afterSubmit const { destinations } = useDestinationList(); return ( - + ); }; diff --git a/airbyte-webapp/src/test-utils/testutils.tsx b/airbyte-webapp/src/test-utils/testutils.tsx index 77172f9af3bb..0ca47361146b 100644 --- a/airbyte-webapp/src/test-utils/testutils.tsx +++ b/airbyte-webapp/src/test-utils/testutils.tsx @@ -14,7 +14,9 @@ import { WebBackendConnectionRead, } from "core/request/AirbyteClient"; import { ServicesProvider } from "core/servicesProvider"; +import { ConfirmationModalService } from "hooks/services/ConfirmationModal"; import { defaultFeatures, FeatureService } from "hooks/services/Feature"; +import { ModalServiceProvider } from "hooks/services/Modal"; import en from "locales/en.json"; import { AnalyticsProvider } from "views/common/AnalyticsProvider"; @@ -27,23 +29,9 @@ export async function render< Container extends Element | DocumentFragment = HTMLElement >(ui: React.ReactNode, renderOptions?: RenderOptions): Promise> { const Wrapper = ({ children }: WrapperProps) => { - const queryClient = new QueryClient(); - return ( - - - - - - - 'fallback content'
}>{children} - - - - - - + testutils render fallback content
}>{children} ); }; @@ -59,7 +47,21 @@ export async function render< export const TestWrapper: React.FC> = ({ children }) => ( null}> - {children} + + + + + + + + {children} + + + + + + + ); diff --git a/airbyte-webapp/src/views/Connection/CatalogDiffModal/CatalogDiffModal.test.tsx b/airbyte-webapp/src/views/Connection/CatalogDiffModal/CatalogDiffModal.test.tsx index 655deb86f594..3a99a63faab1 100644 --- a/airbyte-webapp/src/views/Connection/CatalogDiffModal/CatalogDiffModal.test.tsx +++ b/airbyte-webapp/src/views/Connection/CatalogDiffModal/CatalogDiffModal.test.tsx @@ -9,6 +9,7 @@ import { StreamTransform, SyncMode, } from "core/request/AirbyteClient"; +import { ModalServiceProvider } from "hooks/services/Modal"; import messages from "../../../locales/en.json"; import { CatalogDiffModal } from "./CatalogDiffModal"; @@ -151,13 +152,15 @@ describe("catalog diff modal", () => { render( - { - return null; - }} - /> + + { + return null; + }} + /> + ); @@ -198,13 +201,15 @@ describe("catalog diff modal", () => { render( - { - return null; - }} - /> + + { + return null; + }} + /> + ); @@ -217,13 +222,15 @@ describe("catalog diff modal", () => { render( - { - return null; - }} - /> + + { + return null; + }} + /> + ); @@ -236,13 +243,15 @@ describe("catalog diff modal", () => { render( - { - return null; - }} - /> + + { + return null; + }} + /> + ); diff --git a/airbyte-webapp/src/views/Connection/CatalogDiffModal/CatalogDiffModal.tsx b/airbyte-webapp/src/views/Connection/CatalogDiffModal/CatalogDiffModal.tsx index 0d01ad9770ba..b6f0e26c115f 100644 --- a/airbyte-webapp/src/views/Connection/CatalogDiffModal/CatalogDiffModal.tsx +++ b/airbyte-webapp/src/views/Connection/CatalogDiffModal/CatalogDiffModal.tsx @@ -1,10 +1,12 @@ import { useMemo } from "react"; import { FormattedMessage } from "react-intl"; +import { useUnmount } from "react-use"; import { Button } from "components/ui/Button"; import { ModalBody, ModalFooter } from "components/ui/Modal"; import { AirbyteCatalog, CatalogDiff } from "core/request/AirbyteClient"; +import { useModalService } from "hooks/services/Modal"; import styles from "./CatalogDiffModal.module.scss"; import { DiffSection } from "./components/DiffSection"; @@ -18,11 +20,16 @@ interface CatalogDiffModalProps { } export const CatalogDiffModal: React.FC = ({ catalogDiff, catalog, onClose }) => { + const { closeModal } = useModalService(); const { newItems, removedItems, changedItems } = useMemo( () => getSortedDiff(catalogDiff.transforms), [catalogDiff.transforms] ); + useUnmount(() => { + closeModal(); + }); + return ( <> diff --git a/airbyte-webapp/src/views/Connection/CatalogDiffModal/useConfirmCatalogDiff.tsx b/airbyte-webapp/src/views/Connection/CatalogDiffModal/useConfirmCatalogDiff.tsx new file mode 100644 index 000000000000..e21777def532 --- /dev/null +++ b/airbyte-webapp/src/views/Connection/CatalogDiffModal/useConfirmCatalogDiff.tsx @@ -0,0 +1,27 @@ +import { useEffect } from "react"; +import { useIntl } from "react-intl"; + +import { useConnectionEditService } from "hooks/services/ConnectionEdit/ConnectionEditService"; +import { useModalService } from "hooks/services/Modal"; + +import { CatalogDiffModal } from "./CatalogDiffModal"; + +export const useConfirmCatalogDiff = () => { + const { formatMessage } = useIntl(); + const { openModal } = useModalService(); + const { connection } = useConnectionEditService(); + + useEffect(() => { + // If we have a catalogDiff we always want to show the modal + const { catalogDiff, syncCatalog } = connection; + if (catalogDiff?.transforms && catalogDiff.transforms?.length > 0) { + openModal({ + title: formatMessage({ id: "connection.updateSchema.completed" }), + preventCancel: true, + content: ({ onClose }) => ( + + ), + }); + } + }, [connection, formatMessage, openModal]); +}; diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.test.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.test.tsx deleted file mode 100644 index 3c2f6b1a75d4..000000000000 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { render } from "test-utils/testutils"; - -import { - ConnectionStatus, - DestinationRead, - NamespaceDefinitionType, - SourceRead, - WebBackendConnectionRead, -} from "core/request/AirbyteClient"; -import { ConfirmationModalService } from "hooks/services/ConfirmationModal/ConfirmationModalService"; -import { ConnectionFormServiceProvider } from "hooks/services/ConnectionForm/ConnectionFormService"; - -import { ConnectionForm, ConnectionFormMode } from "./ConnectionForm"; - -const mockSource: SourceRead = { - sourceId: "test-source", - name: "test source", - sourceName: "test-source-name", - workspaceId: "test-workspace-id", - sourceDefinitionId: "test-source-definition-id", - connectionConfiguration: undefined, -}; - -const mockDestination: DestinationRead = { - destinationId: "test-destination", - name: "test destination", - destinationName: "test destination name", - workspaceId: "test-workspace-id", - destinationDefinitionId: "test-destination-definition-id", - connectionConfiguration: undefined, -}; - -const mockConnection: WebBackendConnectionRead = { - connectionId: "test-connection", - name: "test connection", - prefix: "test", - sourceId: "test-source", - destinationId: "test-destination", - status: ConnectionStatus.active, - scheduleType: "manual", - scheduleData: undefined, - syncCatalog: { - streams: [], - }, - namespaceDefinition: NamespaceDefinitionType.source, - namespaceFormat: "", - operationIds: [], - source: mockSource, - destination: mockDestination, - operations: [], - catalogId: "", - isSyncing: false, -}; - -jest.mock("services/connector/DestinationDefinitionSpecificationService", () => { - return { - useGetDestinationDefinitionSpecification: () => { - return "destinationDefinition"; - }, - }; -}); - -jest.mock("services/workspaces/WorkspacesService", () => { - return { - useCurrentWorkspace: () => { - return "currentWorkspace"; - }, - useCurrentWorkspaceId: () => { - return "currentWorkspace"; - }, - }; -}); - -const renderConnectionForm = (mode: ConnectionFormMode, connection = mockConnection) => - render( - - - - - - ); - -describe("", () => { - let container: HTMLElement; - describe("edit mode", () => { - beforeEach(async () => { - const renderResult = await renderConnectionForm("edit"); - - container = renderResult.container; - }); - it("renders relevant items", async () => { - const prefixInput = container.querySelector("input[data-testid='prefixInput']"); - expect(prefixInput).toBeInTheDocument(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - userEvent.type(prefixInput!, "{selectall}{del}prefix"); - await waitFor(() => userEvent.keyboard("{enter}")); - }); - it("pointer events are not turned off anywhere in the component", async () => { - expect(container.innerHTML).toContain("checkbox"); - }); - }); - describe("readonly mode", () => { - beforeEach(async () => { - const renderResult = await renderConnectionForm("readonly"); - - container = renderResult.container; - }); - it("renders only relevant items for the mode", async () => { - const prefixInput = container.querySelector("input[data-testid='prefixInput']"); - expect(prefixInput).toBeInTheDocument(); - }); - it("pointer events are turned off in the fieldset", async () => { - expect(container.innerHTML).not.toContain("checkbox"); - }); - }); -}); diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx deleted file mode 100644 index d574fc05ee36..000000000000 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import classNames from "classnames"; -import { Field, FieldProps, Form, Formik } from "formik"; -import React from "react"; -import { FormattedMessage, useIntl } from "react-intl"; -import { useToggle } from "react-use"; -import styled from "styled-components"; - -import { FormChangeTracker } from "components/FormChangeTracker"; -import { ControlLabels } from "components/LabeledControl"; -import { Input } from "components/ui/Input"; -import { Text } from "components/ui/Text"; - -import { NamespaceDefinitionType } from "core/request/AirbyteClient"; -import { useConnectionFormService } from "hooks/services/ConnectionForm/ConnectionFormService"; - -import CreateControls from "./components/CreateControls"; -import EditControls from "./components/EditControls"; -import { NamespaceDefinitionField } from "./components/NamespaceDefinitionField"; -import { OperationsSection } from "./components/OperationsSection"; -import ScheduleField from "./components/ScheduleField"; -import { Section } from "./components/Section"; -import SchemaField from "./components/SyncCatalogField"; -import styles from "./ConnectionForm.module.scss"; -import { connectionValidationSchema } from "./formConfig"; - -// This is removed in KC's main refactor PR. Removing it would require major scope creep for this PR. -const ConnectorLabel = styled(ControlLabels)` - max-width: 328px; - margin-right: 20px; - vertical-align: top; -`; - -export type ConnectionFormMode = "create" | "edit" | "readonly"; - -export interface ConnectionFormProps { - successMessage?: React.ReactNode; - - /** Should be passed when connection is updated with withRefreshCatalog flag */ - canSubmitUntouchedForm?: boolean; - additionalSchemaControl?: React.ReactNode; -} - -export const ConnectionForm: React.FC = ({ - successMessage, - canSubmitUntouchedForm, - additionalSchemaControl, -}) => { - const { initialValues, formId, mode, onFormSubmit, errorMessage, onCancel } = useConnectionFormService(); - - const [editingTransformation, toggleEditingTransformation] = useToggle(false); - const { formatMessage } = useIntl(); - - const readonlyClass = classNames({ - [styles.readonly]: mode === "readonly", - }); - - return ( - - {({ isSubmitting, isValid, dirty, resetForm, values }) => ( -
- - {mode === "create" && ( -
- - {({ field, meta }: FieldProps) => ( -
-
- - - - } - message={formatMessage({ - id: "form.connectionName.message", - })} - /> -
-
- -
-
- )} -
-
- )} -
}> - -
-
- - - - - - - {values.namespaceDefinition === NamespaceDefinitionType.customformat && ( - - {({ field, meta }: FieldProps) => ( -
-
- } - message={} - /> -
-
- -
-
- )} -
- )} - - {({ field }: FieldProps) => ( -
-
- -
-
- -
-
- )} -
-
-
- -
- {mode === "edit" && ( - { - resetForm(); - onCancel?.(); - }} - successMessage={successMessage} - errorMessage={!isValid && errorMessage} - enableControls={canSubmitUntouchedForm} - /> - )} - {mode === "create" && ( - <> - - - - )} - - )} -
- ); -}; diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionFormFields.module.scss b/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionFormFields.module.scss new file mode 100644 index 000000000000..98bdfdb34a7e --- /dev/null +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionFormFields.module.scss @@ -0,0 +1,44 @@ +@use "../../../scss/variables"; + +.formContainer { + display: flex; + flex-direction: column; + gap: variables.$spacing-md; +} + +.readonly { + pointer-events: none; +} + +.flexRow { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; + gap: variables.$spacing-md; +} + +.leftFieldCol { + flex: 1; + max-width: 640px; + padding-right: 30px; +} + +.rightFieldCol { + flex: 1; + max-width: 300px; +} + +.namespaceFormatLabel { + flex: 5 0 0; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.tryArrow { + margin: 0 variables.$spacing-md -1px 0; + + // used to control svg size + font-size: 14px; +} diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionFormFields.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionFormFields.tsx new file mode 100644 index 000000000000..67f194b4f9f7 --- /dev/null +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionFormFields.tsx @@ -0,0 +1,135 @@ +import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import classNames from "classnames"; +import { Field, FieldProps } from "formik"; +import React from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useUnmount } from "react-use"; + +import { ControlLabels } from "components"; +import { FormChangeTracker } from "components/FormChangeTracker"; +import { Button } from "components/ui/Button"; +import { Input } from "components/ui/Input"; +import { Text } from "components/ui/Text"; + +import { NamespaceDefinitionType } from "core/request/AirbyteClient"; +import { useConnectionFormService } from "hooks/services/ConnectionForm/ConnectionFormService"; +import { useFormChangeTrackerService } from "hooks/services/FormChangeTracker"; +import { ValuesProps } from "hooks/services/useConnectionHook"; + +import { NamespaceDefinitionField } from "./components/NamespaceDefinitionField"; +import { useRefreshSourceSchemaWithConfirmationOnDirty } from "./components/refreshSourceSchemaWithConfirmationOnDirty"; +import ScheduleField from "./components/ScheduleField"; +import { Section } from "./components/Section"; +import SchemaField from "./components/SyncCatalogField"; +import styles from "./ConnectionFormFields.module.scss"; +import { FormikConnectionFormValues } from "./formConfig"; + +interface ConnectionFormFieldsProps { + values: ValuesProps | FormikConnectionFormValues; + isSubmitting: boolean; + dirty: boolean; +} + +export const ConnectionFormFields: React.FC = ({ values, isSubmitting, dirty }) => { + const { mode, formId } = useConnectionFormService(); + const { formatMessage } = useIntl(); + const { clearFormChange } = useFormChangeTrackerService(); + + const readonlyClass = classNames({ + [styles.readonly]: mode === "readonly", + }); + + const refreshSchema = useRefreshSourceSchemaWithConfirmationOnDirty(dirty); + + useUnmount(() => { + clearFormChange(formId); + }); + + return ( + <> + {/* FormChangeTracker is here as it has access to everything it needs without being repeated */} + +
+
}> + +
+
+ + + + + + + {values.namespaceDefinition === NamespaceDefinitionType.customformat && ( + + {({ field, meta }: FieldProps) => ( +
+
+ } + message={} + /> +
+
+ +
+
+ )} +
+ )} + + {({ field }: FieldProps) => ( +
+
+ +
+
+ +
+
+ )} +
+
+
+ + + + + } + /> +
+
+ + ); +}; diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/__snapshots__/formConfig.test.ts.snap b/airbyte-webapp/src/views/Connection/ConnectionForm/__snapshots__/formConfig.test.ts.snap index c78c5f7076c9..171ce4d48e02 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/__snapshots__/formConfig.test.ts.snap +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/__snapshots__/formConfig.test.ts.snap @@ -1,66 +1,46 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`#useInitialValues should generate initial values w/ edit mode: false 1`] = ` +exports[`#useInitialValues should generate initial values w/ 'not create' mode: false 1`] = ` Object { - "all": Array [ - Object { - "name": "Scrafty <> Heroku Postgres", - "namespaceDefinition": "source", - "namespaceFormat": "\${SOURCE_NAMESPACE}", - "normalization": "basic", - "prefix": "", - "scheduleData": null, - "scheduleType": "manual", - "syncCatalog": Object { - "streams": Array [ - Object { - "config": Object { - "aliasName": "pokemon", - "cursorField": Array [], - "destinationSyncMode": "overwrite", - "primaryKey": Array [], - "selected": true, - "syncMode": "full_refresh", - }, - "id": "0", - "stream": Object { - "defaultCursorField": Array [], - "jsonSchema": Object { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": Object { - "abilities": Object { - "items": Object { + "name": "Scrafty <> Heroku Postgres", + "namespaceDefinition": "source", + "namespaceFormat": "\${SOURCE_NAMESPACE}", + "normalization": "basic", + "prefix": "", + "scheduleData": null, + "scheduleType": "manual", + "syncCatalog": Object { + "streams": Array [ + Object { + "config": Object { + "aliasName": "pokemon", + "cursorField": Array [], + "destinationSyncMode": "overwrite", + "primaryKey": Array [], + "selected": true, + "syncMode": "full_refresh", + }, + "id": "0", + "stream": Object { + "defaultCursorField": Array [], + "jsonSchema": Object { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": Object { + "abilities": Object { + "items": Object { + "properties": Object { + "ability": Object { "properties": Object { - "ability": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "is_hidden": Object { + "name": Object { "type": Array [ "null", - "boolean", + "string", ], }, - "slot": Object { + "url": Object { "type": Array [ "null", - "integer", + "string", ], }, }, @@ -69,19 +49,71 @@ Object { "object", ], }, - "type": Array [ - "null", - "array", - ], + "is_hidden": Object { + "type": Array [ + "null", + "boolean", + ], + }, + "slot": Object { + "type": Array [ + "null", + "integer", + ], + }, }, - "base_experience": Object { - "type": Array [ - "null", - "integer", - ], + "type": Array [ + "null", + "object", + ], + }, + "type": Array [ + "null", + "array", + ], + }, + "base_experience": Object { + "type": Array [ + "null", + "integer", + ], + }, + "forms": Object { + "items": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, }, - "forms": Object { - "items": Object { + "type": Array [ + "null", + "object", + ], + }, + "type": Array [ + "null", + "array", + ], + }, + "game_indices": Object { + "items": Object { + "properties": Object { + "game_index": Object { + "type": Array [ + "null", + "integer", + ], + }, + "version": Object { "properties": Object { "name": Object { "type": Array [ @@ -101,38 +133,38 @@ Object { "object", ], }, - "type": Array [ - "null", - "array", - ], }, - "game_indices": Object { - "items": Object { + "type": Array [ + "null", + "object", + ], + }, + "type": Array [ + "null", + "array", + ], + }, + "height": Object { + "type": Array [ + "null", + "integer", + ], + }, + "held_items": Object { + "items": Object { + "properties": Object { + "item": Object { "properties": Object { - "game_index": Object { + "name": Object { "type": Array [ "null", - "integer", + "string", ], }, - "version": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, + "url": Object { "type": Array [ "null", - "object", + "string", ], }, }, @@ -141,67 +173,27 @@ Object { "object", ], }, - "type": Array [ - "null", - "array", - ], - }, - "height": Object { - "type": Array [ - "null", - "integer", - ], - }, - "held_items": Object { - "items": Object { - "properties": Object { - "item": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, + "version_details": Object { + "items": Object { + "properties": Object { + "rarity": Object { + "type": Array [ + "null", + "integer", + ], }, - "type": Array [ - "null", - "object", - ], - }, - "version_details": Object { - "items": Object { + "version": Object { "properties": Object { - "rarity": Object { + "name": Object { "type": Array [ "null", - "integer", + "string", ], }, - "version": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, + "url": Object { "type": Array [ "null", - "object", + "string", ], }, }, @@ -210,110 +202,110 @@ Object { "object", ], }, - "type": Array [ - "null", - "array", - ], }, + "type": Array [ + "null", + "object", + ], }, "type": Array [ "null", - "object", + "array", ], }, - "type": Array [ - "null", - "array", - ], - }, - "id": Object { - "type": Array [ - "null", - "integer", - ], }, - "is_default ": Object { - "type": Array [ - "null", - "boolean", - ], - }, - "location_area_encounters": Object { - "type": Array [ - "null", - "string", - ], - }, - "moves": Object { - "items": Object { + "type": Array [ + "null", + "object", + ], + }, + "type": Array [ + "null", + "array", + ], + }, + "id": Object { + "type": Array [ + "null", + "integer", + ], + }, + "is_default ": Object { + "type": Array [ + "null", + "boolean", + ], + }, + "location_area_encounters": Object { + "type": Array [ + "null", + "string", + ], + }, + "moves": Object { + "items": Object { + "properties": Object { + "move": Object { "properties": Object { - "move": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { "type": Array [ "null", - "object", + "string", ], }, - "version_group_details": Object { - "items": Object { + }, + "type": Array [ + "null", + "object", + ], + }, + "version_group_details": Object { + "items": Object { + "properties": Object { + "level_learned_at": Object { + "type": Array [ + "null", + "integer", + ], + }, + "move_learn_method": Object { "properties": Object { - "level_learned_at": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { "type": Array [ "null", - "integer", + "string", ], }, - "move_learn_method": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, + }, + "type": Array [ + "null", + "object", + ], + }, + "version_group": Object { + "properties": Object { + "name": Object { "type": Array [ "null", - "object", + "string", ], }, - "version_group": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, + "url": Object { "type": Array [ "null", - "object", + "string", ], }, }, @@ -322,2275 +314,143 @@ Object { "object", ], }, - "type": Array [ - "null", - "array", - ], }, + "type": Array [ + "null", + "object", + ], }, "type": Array [ "null", - "object", + "array", ], }, + }, + "type": Array [ + "null", + "object", + ], + }, + "type": Array [ + "null", + "array", + ], + }, + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "order": Object { + "type": Array [ + "null", + "integer", + ], + }, + "species": Object { + "properties": Object { + "name": Object { "type": Array [ "null", - "array", + "string", ], }, - "name": Object { + "url": Object { "type": Array [ "null", "string", ], }, - "order": Object { + }, + "type": Array [ + "null", + "object", + ], + }, + "sprites": Object { + "properties": Object { + "back_default": Object { "type": Array [ "null", - "integer", + "string", ], }, - "species": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, + "back_female": Object { "type": Array [ "null", - "object", + "string", ], }, - "sprites": Object { - "properties": Object { - "back_default": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_shiny": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_shiny_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_default": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_shiny": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_shiny_female": Object { - "type": Array [ - "null", - "string", - ], - }, - }, + "back_shiny": Object { "type": Array [ "null", - "object", + "string", ], }, - "stats": Object { - "items": Object { - "properties": Object { - "base_stat": Object { - "type": Array [ - "null", - "integer", - ], - }, - "effort": Object { - "type": Array [ - "null", - "integer", - ], - }, - "stat": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, + "back_shiny_female": Object { "type": Array [ "null", - "array", + "string", ], }, - "types": Object { - "items": Object { - "properties": Object { - "slot": Object { - "type": Array [ - "null", - "integer", - ], - }, - "type": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "weight": Object { - "type": Array [ - "null", - "integer", - ], - }, - }, - "type": "object", - }, - "name": "pokemon", - "sourceDefinedPrimaryKey": Array [], - "supportedSyncModes": Array [ - "full_refresh", - ], - }, - }, - ], - }, - "transformations": Array [], - }, - ], - "current": Object { - "name": "Scrafty <> Heroku Postgres", - "namespaceDefinition": "source", - "namespaceFormat": "\${SOURCE_NAMESPACE}", - "normalization": "basic", - "prefix": "", - "scheduleData": null, - "scheduleType": "manual", - "syncCatalog": Object { - "streams": Array [ - Object { - "config": Object { - "aliasName": "pokemon", - "cursorField": Array [], - "destinationSyncMode": "overwrite", - "primaryKey": Array [], - "selected": true, - "syncMode": "full_refresh", - }, - "id": "0", - "stream": Object { - "defaultCursorField": Array [], - "jsonSchema": Object { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": Object { - "abilities": Object { - "items": Object { - "properties": Object { - "ability": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "is_hidden": Object { - "type": Array [ - "null", - "boolean", - ], - }, - "slot": Object { - "type": Array [ - "null", - "integer", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "base_experience": Object { - "type": Array [ - "null", - "integer", - ], - }, - "forms": Object { - "items": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "game_indices": Object { - "items": Object { - "properties": Object { - "game_index": Object { - "type": Array [ - "null", - "integer", - ], - }, - "version": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "height": Object { - "type": Array [ - "null", - "integer", - ], - }, - "held_items": Object { - "items": Object { - "properties": Object { - "item": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_details": Object { - "items": Object { - "properties": Object { - "rarity": Object { - "type": Array [ - "null", - "integer", - ], - }, - "version": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "id": Object { - "type": Array [ - "null", - "integer", - ], - }, - "is_default ": Object { - "type": Array [ - "null", - "boolean", - ], - }, - "location_area_encounters": Object { - "type": Array [ - "null", - "string", - ], - }, - "moves": Object { - "items": Object { - "properties": Object { - "move": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_group_details": Object { - "items": Object { - "properties": Object { - "level_learned_at": Object { - "type": Array [ - "null", - "integer", - ], - }, - "move_learn_method": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_group": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "order": Object { - "type": Array [ - "null", - "integer", - ], - }, - "species": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "sprites": Object { - "properties": Object { - "back_default": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_shiny": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_shiny_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_default": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_shiny": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_shiny_female": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "stats": Object { - "items": Object { - "properties": Object { - "base_stat": Object { - "type": Array [ - "null", - "integer", - ], - }, - "effort": Object { - "type": Array [ - "null", - "integer", - ], - }, - "stat": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "types": Object { - "items": Object { - "properties": Object { - "slot": Object { - "type": Array [ - "null", - "integer", - ], - }, - "type": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "weight": Object { - "type": Array [ - "null", - "integer", - ], - }, - }, - "type": "object", - }, - "name": "pokemon", - "sourceDefinedPrimaryKey": Array [], - "supportedSyncModes": Array [ - "full_refresh", - ], - }, - }, - ], - }, - "transformations": Array [], - }, - "error": undefined, -} -`; - -exports[`#useInitialValues should generate initial values w/ edit mode: true 1`] = ` -Object { - "all": Array [ - Object { - "name": "Scrafty <> Heroku Postgres", - "namespaceDefinition": "source", - "namespaceFormat": "\${SOURCE_NAMESPACE}", - "normalization": "basic", - "prefix": "", - "scheduleData": null, - "scheduleType": "manual", - "syncCatalog": Object { - "streams": Array [ - Object { - "config": Object { - "aliasName": "pokemon", - "cursorField": Array [], - "destinationSyncMode": "append", - "primaryKey": Array [], - "selected": true, - "syncMode": "full_refresh", - }, - "id": "0", - "stream": Object { - "defaultCursorField": Array [], - "jsonSchema": Object { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": Object { - "abilities": Object { - "items": Object { - "properties": Object { - "ability": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "is_hidden": Object { - "type": Array [ - "null", - "boolean", - ], - }, - "slot": Object { - "type": Array [ - "null", - "integer", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "base_experience": Object { - "type": Array [ - "null", - "integer", - ], - }, - "forms": Object { - "items": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "game_indices": Object { - "items": Object { - "properties": Object { - "game_index": Object { - "type": Array [ - "null", - "integer", - ], - }, - "version": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "height": Object { - "type": Array [ - "null", - "integer", - ], - }, - "held_items": Object { - "items": Object { - "properties": Object { - "item": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_details": Object { - "items": Object { - "properties": Object { - "rarity": Object { - "type": Array [ - "null", - "integer", - ], - }, - "version": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "id": Object { - "type": Array [ - "null", - "integer", - ], - }, - "is_default ": Object { - "type": Array [ - "null", - "boolean", - ], - }, - "location_area_encounters": Object { - "type": Array [ - "null", - "string", - ], - }, - "moves": Object { - "items": Object { - "properties": Object { - "move": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_group_details": Object { - "items": Object { - "properties": Object { - "level_learned_at": Object { - "type": Array [ - "null", - "integer", - ], - }, - "move_learn_method": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_group": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "order": Object { - "type": Array [ - "null", - "integer", - ], - }, - "species": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "sprites": Object { - "properties": Object { - "back_default": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_shiny": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_shiny_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_default": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_shiny": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_shiny_female": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "stats": Object { - "items": Object { - "properties": Object { - "base_stat": Object { - "type": Array [ - "null", - "integer", - ], - }, - "effort": Object { - "type": Array [ - "null", - "integer", - ], - }, - "stat": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "types": Object { - "items": Object { - "properties": Object { - "slot": Object { - "type": Array [ - "null", - "integer", - ], - }, - "type": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "weight": Object { - "type": Array [ - "null", - "integer", - ], - }, - }, - "type": "object", - }, - "name": "pokemon", - "sourceDefinedPrimaryKey": Array [], - "supportedSyncModes": Array [ - "full_refresh", - ], - }, - }, - ], - }, - "transformations": Array [], - }, - ], - "current": Object { - "name": "Scrafty <> Heroku Postgres", - "namespaceDefinition": "source", - "namespaceFormat": "\${SOURCE_NAMESPACE}", - "normalization": "basic", - "prefix": "", - "scheduleData": null, - "scheduleType": "manual", - "syncCatalog": Object { - "streams": Array [ - Object { - "config": Object { - "aliasName": "pokemon", - "cursorField": Array [], - "destinationSyncMode": "append", - "primaryKey": Array [], - "selected": true, - "syncMode": "full_refresh", - }, - "id": "0", - "stream": Object { - "defaultCursorField": Array [], - "jsonSchema": Object { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": Object { - "abilities": Object { - "items": Object { - "properties": Object { - "ability": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "is_hidden": Object { - "type": Array [ - "null", - "boolean", - ], - }, - "slot": Object { - "type": Array [ - "null", - "integer", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "base_experience": Object { - "type": Array [ - "null", - "integer", - ], - }, - "forms": Object { - "items": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "game_indices": Object { - "items": Object { - "properties": Object { - "game_index": Object { - "type": Array [ - "null", - "integer", - ], - }, - "version": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "height": Object { - "type": Array [ - "null", - "integer", - ], - }, - "held_items": Object { - "items": Object { - "properties": Object { - "item": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_details": Object { - "items": Object { - "properties": Object { - "rarity": Object { - "type": Array [ - "null", - "integer", - ], - }, - "version": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "id": Object { - "type": Array [ - "null", - "integer", - ], - }, - "is_default ": Object { - "type": Array [ - "null", - "boolean", - ], - }, - "location_area_encounters": Object { - "type": Array [ - "null", - "string", - ], - }, - "moves": Object { - "items": Object { - "properties": Object { - "move": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_group_details": Object { - "items": Object { - "properties": Object { - "level_learned_at": Object { - "type": Array [ - "null", - "integer", - ], - }, - "move_learn_method": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_group": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "order": Object { - "type": Array [ - "null", - "integer", - ], - }, - "species": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "sprites": Object { - "properties": Object { - "back_default": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_shiny": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_shiny_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_default": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_shiny": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_shiny_female": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "stats": Object { - "items": Object { - "properties": Object { - "base_stat": Object { - "type": Array [ - "null", - "integer", - ], - }, - "effort": Object { - "type": Array [ - "null", - "integer", - ], - }, - "stat": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "types": Object { - "items": Object { - "properties": Object { - "slot": Object { - "type": Array [ - "null", - "integer", - ], - }, - "type": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "weight": Object { - "type": Array [ - "null", - "integer", - ], - }, - }, - "type": "object", - }, - "name": "pokemon", - "sourceDefinedPrimaryKey": Array [], - "supportedSyncModes": Array [ - "full_refresh", - ], - }, - }, - ], - }, - "transformations": Array [], - }, - "error": undefined, -} -`; - -exports[`#useInitialValues should generate initial values w/ no edit mode 1`] = ` -Object { - "all": Array [ - Object { - "name": "Scrafty <> Heroku Postgres", - "namespaceDefinition": "source", - "namespaceFormat": "\${SOURCE_NAMESPACE}", - "normalization": "basic", - "prefix": "", - "scheduleData": null, - "scheduleType": "manual", - "syncCatalog": Object { - "streams": Array [ - Object { - "config": Object { - "aliasName": "pokemon", - "cursorField": Array [], - "destinationSyncMode": "overwrite", - "primaryKey": Array [], - "selected": true, - "syncMode": "full_refresh", - }, - "id": "0", - "stream": Object { - "defaultCursorField": Array [], - "jsonSchema": Object { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": Object { - "abilities": Object { - "items": Object { - "properties": Object { - "ability": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "is_hidden": Object { - "type": Array [ - "null", - "boolean", - ], - }, - "slot": Object { - "type": Array [ - "null", - "integer", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "base_experience": Object { - "type": Array [ - "null", - "integer", - ], - }, - "forms": Object { - "items": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "game_indices": Object { - "items": Object { - "properties": Object { - "game_index": Object { - "type": Array [ - "null", - "integer", - ], - }, - "version": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "height": Object { - "type": Array [ - "null", - "integer", - ], - }, - "held_items": Object { - "items": Object { - "properties": Object { - "item": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_details": Object { - "items": Object { - "properties": Object { - "rarity": Object { - "type": Array [ - "null", - "integer", - ], - }, - "version": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "id": Object { - "type": Array [ - "null", - "integer", - ], - }, - "is_default ": Object { - "type": Array [ - "null", - "boolean", - ], - }, - "location_area_encounters": Object { - "type": Array [ - "null", - "string", - ], - }, - "moves": Object { - "items": Object { - "properties": Object { - "move": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_group_details": Object { - "items": Object { - "properties": Object { - "level_learned_at": Object { - "type": Array [ - "null", - "integer", - ], - }, - "move_learn_method": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_group": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, - "name": Object { + "front_default": Object { "type": Array [ "null", "string", ], }, - "order": Object { + "front_female": Object { "type": Array [ "null", - "integer", + "string", ], }, - "species": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, + "front_shiny": Object { "type": Array [ "null", - "object", + "string", ], }, - "sprites": Object { - "properties": Object { - "back_default": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_shiny": Object { - "type": Array [ - "null", - "string", - ], - }, - "back_shiny_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_default": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_female": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_shiny": Object { - "type": Array [ - "null", - "string", - ], - }, - "front_shiny_female": Object { - "type": Array [ - "null", - "string", - ], - }, - }, + "front_shiny_female": Object { "type": Array [ "null", - "object", + "string", ], }, - "stats": Object { - "items": Object { + }, + "type": Array [ + "null", + "object", + ], + }, + "stats": Object { + "items": Object { + "properties": Object { + "base_stat": Object { + "type": Array [ + "null", + "integer", + ], + }, + "effort": Object { + "type": Array [ + "null", + "integer", + ], + }, + "stat": Object { "properties": Object { - "base_stat": Object { - "type": Array [ - "null", - "integer", - ], - }, - "effort": Object { + "name": Object { "type": Array [ "null", - "integer", + "string", ], }, - "stat": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, + "url": Object { "type": Array [ "null", - "object", + "string", ], }, }, @@ -2599,38 +459,38 @@ Object { "object", ], }, - "type": Array [ - "null", - "array", - ], }, - "types": Object { - "items": Object { + "type": Array [ + "null", + "object", + ], + }, + "type": Array [ + "null", + "array", + ], + }, + "types": Object { + "items": Object { + "properties": Object { + "slot": Object { + "type": Array [ + "null", + "integer", + ], + }, + "type": Object { "properties": Object { - "slot": Object { + "name": Object { "type": Array [ "null", - "integer", + "string", ], }, - "type": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, + "url": Object { "type": Array [ "null", - "object", + "string", ], }, }, @@ -2639,196 +499,238 @@ Object { "object", ], }, - "type": Array [ - "null", - "array", - ], - }, - "weight": Object { - "type": Array [ - "null", - "integer", - ], }, + "type": Array [ + "null", + "object", + ], }, - "type": "object", + "type": Array [ + "null", + "array", + ], + }, + "weight": Object { + "type": Array [ + "null", + "integer", + ], }, - "name": "pokemon", - "sourceDefinedPrimaryKey": Array [], - "supportedSyncModes": Array [ - "full_refresh", - ], }, + "type": "object", }, - ], + "name": "pokemon", + "sourceDefinedPrimaryKey": Array [], + "supportedSyncModes": Array [ + "full_refresh", + ], + }, }, - "transformations": Array [], - }, - ], - "current": Object { - "name": "Scrafty <> Heroku Postgres", - "namespaceDefinition": "source", - "namespaceFormat": "\${SOURCE_NAMESPACE}", - "normalization": "basic", - "prefix": "", - "scheduleData": null, - "scheduleType": "manual", - "syncCatalog": Object { - "streams": Array [ - Object { - "config": Object { - "aliasName": "pokemon", - "cursorField": Array [], - "destinationSyncMode": "overwrite", - "primaryKey": Array [], - "selected": true, - "syncMode": "full_refresh", - }, - "id": "0", - "stream": Object { - "defaultCursorField": Array [], - "jsonSchema": Object { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": Object { - "abilities": Object { - "items": Object { - "properties": Object { - "ability": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, + ], + }, + "transformations": Array [], +} +`; + +exports[`#useInitialValues should generate initial values w/ 'not create' mode: true 1`] = ` +Object { + "namespaceDefinition": "source", + "namespaceFormat": "\${SOURCE_NAMESPACE}", + "normalization": "basic", + "prefix": "", + "scheduleData": null, + "scheduleType": "manual", + "syncCatalog": Object { + "streams": Array [ + Object { + "config": Object { + "aliasName": "pokemon", + "cursorField": Array [], + "destinationSyncMode": "append", + "primaryKey": Array [], + "selected": true, + "syncMode": "full_refresh", + }, + "id": "0", + "stream": Object { + "defaultCursorField": Array [], + "jsonSchema": Object { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": Object { + "abilities": Object { + "items": Object { + "properties": Object { + "ability": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], }, - "type": Array [ - "null", - "object", - ], - }, - "is_hidden": Object { - "type": Array [ - "null", - "boolean", - ], - }, - "slot": Object { - "type": Array [ - "null", - "integer", - ], }, + "type": Array [ + "null", + "object", + ], + }, + "is_hidden": Object { + "type": Array [ + "null", + "boolean", + ], + }, + "slot": Object { + "type": Array [ + "null", + "integer", + ], }, - "type": Array [ - "null", - "object", - ], }, "type": Array [ "null", - "array", - ], - }, - "base_experience": Object { - "type": Array [ - "null", - "integer", + "object", ], }, - "forms": Object { - "items": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, + "type": Array [ + "null", + "array", + ], + }, + "base_experience": Object { + "type": Array [ + "null", + "integer", + ], + }, + "forms": Object { + "items": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], }, - "type": Array [ - "null", - "object", - ], }, "type": Array [ "null", - "array", + "object", ], }, - "game_indices": Object { - "items": Object { - "properties": Object { - "game_index": Object { - "type": Array [ - "null", - "integer", - ], - }, - "version": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, + "type": Array [ + "null", + "array", + ], + }, + "game_indices": Object { + "items": Object { + "properties": Object { + "game_index": Object { + "type": Array [ + "null", + "integer", + ], + }, + "version": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], }, - "type": Array [ - "null", - "object", - ], }, + "type": Array [ + "null", + "object", + ], }, - "type": Array [ - "null", - "object", - ], }, "type": Array [ "null", - "array", - ], - }, - "height": Object { - "type": Array [ - "null", - "integer", + "object", ], }, - "held_items": Object { - "items": Object { - "properties": Object { - "item": Object { + "type": Array [ + "null", + "array", + ], + }, + "height": Object { + "type": Array [ + "null", + "integer", + ], + }, + "held_items": Object { + "items": Object { + "properties": Object { + "item": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, + "type": Array [ + "null", + "object", + ], + }, + "version_details": Object { + "items": Object { "properties": Object { - "name": Object { + "rarity": Object { "type": Array [ "null", - "string", + "integer", ], }, - "url": Object { + "version": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, "type": Array [ "null", - "string", + "object", ], }, }, @@ -2837,193 +739,255 @@ Object { "object", ], }, - "version_details": Object { - "items": Object { - "properties": Object { - "rarity": Object { - "type": Array [ - "null", - "integer", - ], - }, - "version": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - }, - "type": Array [ - "null", - "object", - ], - }, - "type": Array [ - "null", - "array", - ], - }, + "type": Array [ + "null", + "array", + ], }, - "type": Array [ - "null", - "object", - ], }, "type": Array [ "null", - "array", - ], - }, - "id": Object { - "type": Array [ - "null", - "integer", - ], - }, - "is_default ": Object { - "type": Array [ - "null", - "boolean", - ], - }, - "location_area_encounters": Object { - "type": Array [ - "null", - "string", + "object", ], }, - "moves": Object { - "items": Object { - "properties": Object { - "move": Object { + "type": Array [ + "null", + "array", + ], + }, + "id": Object { + "type": Array [ + "null", + "integer", + ], + }, + "is_default ": Object { + "type": Array [ + "null", + "boolean", + ], + }, + "location_area_encounters": Object { + "type": Array [ + "null", + "string", + ], + }, + "moves": Object { + "items": Object { + "properties": Object { + "move": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, + "type": Array [ + "null", + "object", + ], + }, + "version_group_details": Object { + "items": Object { "properties": Object { - "name": Object { + "level_learned_at": Object { "type": Array [ "null", - "string", + "integer", ], }, - "url": Object { + "move_learn_method": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, "type": Array [ "null", - "string", + "object", ], }, - }, - "type": Array [ - "null", - "object", - ], - }, - "version_group_details": Object { - "items": Object { - "properties": Object { - "level_learned_at": Object { - "type": Array [ - "null", - "integer", - ], - }, - "move_learn_method": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, + "version_group": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], }, - "type": Array [ - "null", - "object", - ], - }, - "version_group": Object { - "properties": Object { - "name": Object { - "type": Array [ - "null", - "string", - ], - }, - "url": Object { - "type": Array [ - "null", - "string", - ], - }, + "url": Object { + "type": Array [ + "null", + "string", + ], }, - "type": Array [ - "null", - "object", - ], }, + "type": Array [ + "null", + "object", + ], }, - "type": Array [ - "null", - "object", - ], }, "type": Array [ "null", - "array", + "object", ], }, + "type": Array [ + "null", + "array", + ], }, - "type": Array [ - "null", - "object", - ], }, "type": Array [ "null", - "array", + "object", ], }, - "name": Object { - "type": Array [ - "null", - "string", - ], + "type": Array [ + "null", + "array", + ], + }, + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "order": Object { + "type": Array [ + "null", + "integer", + ], + }, + "species": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, }, - "order": Object { - "type": Array [ - "null", - "integer", - ], + "type": Array [ + "null", + "object", + ], + }, + "sprites": Object { + "properties": Object { + "back_default": Object { + "type": Array [ + "null", + "string", + ], + }, + "back_female": Object { + "type": Array [ + "null", + "string", + ], + }, + "back_shiny": Object { + "type": Array [ + "null", + "string", + ], + }, + "back_shiny_female": Object { + "type": Array [ + "null", + "string", + ], + }, + "front_default": Object { + "type": Array [ + "null", + "string", + ], + }, + "front_female": Object { + "type": Array [ + "null", + "string", + ], + }, + "front_shiny": Object { + "type": Array [ + "null", + "string", + ], + }, + "front_shiny_female": Object { + "type": Array [ + "null", + "string", + ], + }, }, - "species": Object { + "type": Array [ + "null", + "object", + ], + }, + "stats": Object { + "items": Object { "properties": Object { - "name": Object { + "base_stat": Object { "type": Array [ "null", - "string", + "integer", ], }, - "url": Object { + "effort": Object { "type": Array [ "null", - "string", + "integer", + ], + }, + "stat": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, + "type": Array [ + "null", + "object", ], }, }, @@ -3032,54 +996,204 @@ Object { "object", ], }, - "sprites": Object { + "type": Array [ + "null", + "array", + ], + }, + "types": Object { + "items": Object { "properties": Object { - "back_default": Object { + "slot": Object { "type": Array [ "null", - "string", + "integer", ], }, - "back_female": Object { + "type": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, "type": Array [ "null", - "string", + "object", + ], + }, + }, + "type": Array [ + "null", + "object", + ], + }, + "type": Array [ + "null", + "array", + ], + }, + "weight": Object { + "type": Array [ + "null", + "integer", + ], + }, + }, + "type": "object", + }, + "name": "pokemon", + "sourceDefinedPrimaryKey": Array [], + "supportedSyncModes": Array [ + "full_refresh", + ], + }, + }, + ], + }, + "transformations": Array [], +} +`; + +exports[`#useInitialValues should generate initial values w/ no 'not create' mode 1`] = ` +Object { + "name": "Scrafty <> Heroku Postgres", + "namespaceDefinition": "source", + "namespaceFormat": "\${SOURCE_NAMESPACE}", + "normalization": "basic", + "prefix": "", + "scheduleData": null, + "scheduleType": "manual", + "syncCatalog": Object { + "streams": Array [ + Object { + "config": Object { + "aliasName": "pokemon", + "cursorField": Array [], + "destinationSyncMode": "overwrite", + "primaryKey": Array [], + "selected": true, + "syncMode": "full_refresh", + }, + "id": "0", + "stream": Object { + "defaultCursorField": Array [], + "jsonSchema": Object { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": Object { + "abilities": Object { + "items": Object { + "properties": Object { + "ability": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, + "type": Array [ + "null", + "object", ], }, - "back_shiny": Object { + "is_hidden": Object { "type": Array [ "null", - "string", + "boolean", ], }, - "back_shiny_female": Object { + "slot": Object { "type": Array [ "null", - "string", + "integer", ], }, - "front_default": Object { + }, + "type": Array [ + "null", + "object", + ], + }, + "type": Array [ + "null", + "array", + ], + }, + "base_experience": Object { + "type": Array [ + "null", + "integer", + ], + }, + "forms": Object { + "items": Object { + "properties": Object { + "name": Object { "type": Array [ "null", "string", ], }, - "front_female": Object { + "url": Object { "type": Array [ "null", "string", ], }, - "front_shiny": Object { + }, + "type": Array [ + "null", + "object", + ], + }, + "type": Array [ + "null", + "array", + ], + }, + "game_indices": Object { + "items": Object { + "properties": Object { + "game_index": Object { "type": Array [ "null", - "string", + "integer", ], }, - "front_shiny_female": Object { + "version": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, "type": Array [ "null", - "string", + "object", ], }, }, @@ -3088,33 +1202,67 @@ Object { "object", ], }, - "stats": Object { - "items": Object { - "properties": Object { - "base_stat": Object { - "type": Array [ - "null", - "integer", - ], - }, - "effort": Object { - "type": Array [ - "null", - "integer", - ], + "type": Array [ + "null", + "array", + ], + }, + "height": Object { + "type": Array [ + "null", + "integer", + ], + }, + "held_items": Object { + "items": Object { + "properties": Object { + "item": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, }, - "stat": Object { + "type": Array [ + "null", + "object", + ], + }, + "version_details": Object { + "items": Object { "properties": Object { - "name": Object { + "rarity": Object { "type": Array [ "null", - "string", + "integer", ], }, - "url": Object { + "version": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, "type": Array [ "null", - "string", + "object", ], }, }, @@ -3123,38 +1271,110 @@ Object { "object", ], }, + "type": Array [ + "null", + "array", + ], }, - "type": Array [ - "null", - "object", - ], }, "type": Array [ "null", - "array", + "object", ], }, - "types": Object { - "items": Object { - "properties": Object { - "slot": Object { - "type": Array [ - "null", - "integer", - ], + "type": Array [ + "null", + "array", + ], + }, + "id": Object { + "type": Array [ + "null", + "integer", + ], + }, + "is_default ": Object { + "type": Array [ + "null", + "boolean", + ], + }, + "location_area_encounters": Object { + "type": Array [ + "null", + "string", + ], + }, + "moves": Object { + "items": Object { + "properties": Object { + "move": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, }, - "type": Object { + "type": Array [ + "null", + "object", + ], + }, + "version_group_details": Object { + "items": Object { "properties": Object { - "name": Object { + "level_learned_at": Object { + "type": Array [ + "null", + "integer", + ], + }, + "move_learn_method": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, "type": Array [ "null", - "string", + "object", ], }, - "url": Object { + "version_group": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, "type": Array [ "null", - "string", + "object", ], }, }, @@ -3163,37 +1383,214 @@ Object { "object", ], }, + "type": Array [ + "null", + "array", + ], }, + }, + "type": Array [ + "null", + "object", + ], + }, + "type": Array [ + "null", + "array", + ], + }, + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "order": Object { + "type": Array [ + "null", + "integer", + ], + }, + "species": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, + "type": Array [ + "null", + "object", + ], + }, + "sprites": Object { + "properties": Object { + "back_default": Object { + "type": Array [ + "null", + "string", + ], + }, + "back_female": Object { + "type": Array [ + "null", + "string", + ], + }, + "back_shiny": Object { + "type": Array [ + "null", + "string", + ], + }, + "back_shiny_female": Object { + "type": Array [ + "null", + "string", + ], + }, + "front_default": Object { + "type": Array [ + "null", + "string", + ], + }, + "front_female": Object { + "type": Array [ + "null", + "string", + ], + }, + "front_shiny": Object { + "type": Array [ + "null", + "string", + ], + }, + "front_shiny_female": Object { "type": Array [ "null", - "object", + "string", ], }, + }, + "type": Array [ + "null", + "object", + ], + }, + "stats": Object { + "items": Object { + "properties": Object { + "base_stat": Object { + "type": Array [ + "null", + "integer", + ], + }, + "effort": Object { + "type": Array [ + "null", + "integer", + ], + }, + "stat": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, + "type": Array [ + "null", + "object", + ], + }, + }, "type": Array [ "null", - "array", + "object", ], }, - "weight": Object { + "type": Array [ + "null", + "array", + ], + }, + "types": Object { + "items": Object { + "properties": Object { + "slot": Object { + "type": Array [ + "null", + "integer", + ], + }, + "type": Object { + "properties": Object { + "name": Object { + "type": Array [ + "null", + "string", + ], + }, + "url": Object { + "type": Array [ + "null", + "string", + ], + }, + }, + "type": Array [ + "null", + "object", + ], + }, + }, "type": Array [ "null", - "integer", + "object", ], }, + "type": Array [ + "null", + "array", + ], + }, + "weight": Object { + "type": Array [ + "null", + "integer", + ], }, - "type": "object", }, - "name": "pokemon", - "sourceDefinedPrimaryKey": Array [], - "supportedSyncModes": Array [ - "full_refresh", - ], + "type": "object", }, + "name": "pokemon", + "sourceDefinedPrimaryKey": Array [], + "supportedSyncModes": Array [ + "full_refresh", + ], }, - ], - }, - "transformations": Array [], + }, + ], }, - "error": undefined, + "transformations": Array [], } `; diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/calculateInitialCatalog.ts b/airbyte-webapp/src/views/Connection/ConnectionForm/calculateInitialCatalog.ts index ec7d881775c6..dcc2876025d9 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/calculateInitialCatalog.ts +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/calculateInitialCatalog.ts @@ -119,13 +119,13 @@ const getOptimalSyncMode = ( const calculateInitialCatalog = ( schema: SyncSchema, supportedDestinationSyncModes: DestinationSyncMode[], - isEditMode?: boolean + isNotCreateMode?: boolean ): SyncSchema => ({ streams: schema.streams.map((apiNode, id) => { const nodeWithId: SyncSchemaStream = { ...apiNode, id: id.toString() }; - const nodeStream = verifySourceDefinedProperties(verifySupportedSyncModes(nodeWithId), isEditMode || false); + const nodeStream = verifySourceDefinedProperties(verifySupportedSyncModes(nodeWithId), isNotCreateMode || false); - if (isEditMode) { + if (isNotCreateMode) { return nodeStream; } diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/components/NamespaceDefinitionField.module.scss b/airbyte-webapp/src/views/Connection/ConnectionForm/components/NamespaceDefinitionField.module.scss index 0a3d7654d1e2..7fd4485ab708 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/components/NamespaceDefinitionField.module.scss +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/components/NamespaceDefinitionField.module.scss @@ -1 +1 @@ -@forward "../ConnectionForm.module.scss"; +@forward "../ConnectionFormFields.module.scss"; diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/components/NamespaceDefinitionField.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/components/NamespaceDefinitionField.tsx index 0d304971a849..6b206c42351b 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/components/NamespaceDefinitionField.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/components/NamespaceDefinitionField.tsx @@ -7,6 +7,7 @@ import { DropDown } from "components/ui/DropDown"; import { NamespaceDefinitionType } from "../../../../core/request/AirbyteClient"; import styles from "./NamespaceDefinitionField.module.scss"; + export const StreamOptions = [ { value: NamespaceDefinitionType.source, diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/components/NormalizationField.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/components/NormalizationField.tsx index a2f1c63988f5..48df3c917852 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/components/NormalizationField.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/components/NormalizationField.tsx @@ -7,8 +7,7 @@ import { LabeledRadioButton, Link } from "components"; import { useConfig } from "config"; import { NormalizationType } from "core/domain/connection/operation"; - -import { ConnectionFormMode } from "../ConnectionForm"; +import { ConnectionFormMode } from "hooks/services/ConnectionForm/ConnectionFormService"; const Normalization = styled.div` margin: 16px 0; diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/components/SyncCatalogField.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/components/SyncCatalogField.tsx index e36263423c4e..4fa89e16d291 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/components/SyncCatalogField.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/components/SyncCatalogField.tsx @@ -14,12 +14,11 @@ import { useConfig } from "config"; import { SyncSchemaStream } from "core/domain/catalog"; import { DestinationSyncMode } from "core/request/AirbyteClient"; import { BatchEditProvider, useBulkEdit } from "hooks/services/BulkEdit/BulkEditService"; -import { useConnectionFormService } from "hooks/services/ConnectionForm/ConnectionFormService"; +import { ConnectionFormMode, useConnectionFormService } from "hooks/services/ConnectionForm/ConnectionFormService"; import { naturalComparatorBy } from "utils/objects"; import CatalogTree from "views/Connection/CatalogTree"; import { BulkHeader } from "../../CatalogTree/components/BulkHeader"; -import { ConnectionFormMode } from "../ConnectionForm"; import Search from "./Search"; import styles from "./SyncCatalogField.module.scss"; diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/components/TransformationField.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/components/TransformationField.tsx index 5460ca1b7a13..7d420dc72e96 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/components/TransformationField.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/components/TransformationField.tsx @@ -5,10 +5,10 @@ import { FormattedMessage } from "react-intl"; import ArrayOfObjectsEditor from "components/ArrayOfObjectsEditor"; import { OperationRead } from "core/request/AirbyteClient"; +import { ConnectionFormMode } from "hooks/services/ConnectionForm/ConnectionFormService"; import { isDefined } from "utils/common"; import TransformationForm from "views/Connection/TransformationForm"; -import { ConnectionFormMode } from "../ConnectionForm"; import { useDefaultTransformation } from "../formConfig"; interface TransformationFieldProps extends ArrayHelpers { @@ -44,7 +44,10 @@ const TransformationField: React.FC = ({ setEditableItem(idx); onStartEdit?.(); }} - onCancel={clearEditableItem} + onCancel={() => { + clearEditableItem(); + onEndEdit?.(); + }} mode={mode} editModalSize="xl" renderItemEditorForm={(editableItem) => ( diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/components/refreshSourceSchemaWithConfirmationOnDirty.ts b/airbyte-webapp/src/views/Connection/ConnectionForm/components/refreshSourceSchemaWithConfirmationOnDirty.ts new file mode 100644 index 000000000000..fd0945f1b1d0 --- /dev/null +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/components/refreshSourceSchemaWithConfirmationOnDirty.ts @@ -0,0 +1,33 @@ +import { useCallback } from "react"; +import { useUnmount } from "react-use"; + +import { useConfirmationModalService } from "hooks/services/ConfirmationModal"; +import { useConnectionFormService } from "hooks/services/ConnectionForm/ConnectionFormService"; +import { useFormChangeTrackerService } from "hooks/services/FormChangeTracker"; + +export const useRefreshSourceSchemaWithConfirmationOnDirty = (dirty: boolean) => { + const { clearFormChange } = useFormChangeTrackerService(); + const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService(); + const { formId, refreshSchema } = useConnectionFormService(); + + useUnmount(() => { + closeConfirmationModal(); + }); + + return useCallback(() => { + if (dirty) { + openConfirmationModal({ + title: "connection.updateSchema.formChanged.title", + text: "connection.updateSchema.formChanged.text", + submitButtonText: "connection.updateSchema.formChanged.confirm", + onSubmit: () => { + closeConfirmationModal(); + clearFormChange(formId); + refreshSchema(); + }, + }); + } else { + refreshSchema(); + } + }, [clearFormChange, closeConfirmationModal, dirty, formId, openConfirmationModal, refreshSchema]); +}; diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.test.ts b/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.test.ts index 145de75ab03a..a119935a6553 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.test.ts +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.test.ts @@ -170,17 +170,18 @@ describe("#mapFormPropsToOperation", () => { }); describe("#useInitialValues", () => { - it("should generate initial values w/ no edit mode", () => { + it("should generate initial values w/ no 'not create' mode", () => { const { result } = renderHook(() => useInitialValues( mockConnection as WebBackendConnectionRead, mockDestinationDefinition as DestinationDefinitionSpecificationRead ) ); - expect(result).toMatchSnapshot(); + expect(result.current).toMatchSnapshot(); + expect(result.current.name).toBeDefined(); }); - it("should generate initial values w/ edit mode: false", () => { + it("should generate initial values w/ 'not create' mode: false", () => { const { result } = renderHook(() => useInitialValues( mockConnection as WebBackendConnectionRead, @@ -188,10 +189,11 @@ describe("#useInitialValues", () => { false ) ); - expect(result).toMatchSnapshot(); + expect(result.current).toMatchSnapshot(); + expect(result.current.name).toBeDefined(); }); - it("should generate initial values w/ edit mode: true", () => { + it("should generate initial values w/ 'not create' mode: true", () => { const { result } = renderHook(() => useInitialValues( mockConnection as WebBackendConnectionRead, @@ -199,7 +201,8 @@ describe("#useInitialValues", () => { true ) ); - expect(result).toMatchSnapshot(); + expect(result.current).toMatchSnapshot(); + expect(result.current.name).toBeUndefined(); }); // This is a low-priority test diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx index a7b25acaf04b..a130831c81cc 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx @@ -24,7 +24,7 @@ import { SyncMode, WebBackendConnectionRead, } from "core/request/AirbyteClient"; -import { ConnectionOrPartialConnection } from "hooks/services/ConnectionForm/ConnectionFormService"; +import { ConnectionFormMode, ConnectionOrPartialConnection } from "hooks/services/ConnectionForm/ConnectionFormService"; import { ValuesProps } from "hooks/services/useConnectionHook"; import { useCurrentWorkspace } from "services/workspaces/WorkspacesService"; @@ -74,106 +74,108 @@ export function useDefaultTransformation(): OperationCreate { }; } -export const connectionValidationSchema = yup - .object({ - name: yup.string().required("form.empty.error"), - scheduleType: yup - .string() - .oneOf([ConnectionScheduleType.manual, ConnectionScheduleType.basic, ConnectionScheduleType.cron]), - scheduleData: yup.mixed().when("scheduleType", (scheduleType) => { - if (scheduleType === ConnectionScheduleType.basic) { +export const connectionValidationSchema = (mode: ConnectionFormMode) => + yup + .object({ + // The connection name during Editing is handled separately from the form + name: mode === "create" ? yup.string().required("form.empty.error") : yup.string().notRequired(), + scheduleType: yup + .string() + .oneOf([ConnectionScheduleType.manual, ConnectionScheduleType.basic, ConnectionScheduleType.cron]), + scheduleData: yup.mixed().when("scheduleType", (scheduleType) => { + if (scheduleType === ConnectionScheduleType.basic) { + return yup.object({ + basicSchedule: yup + .object({ + units: yup.number().required("form.empty.error"), + timeUnit: yup.string().required("form.empty.error"), + }) + .defined("form.empty.error"), + }); + } else if (scheduleType === ConnectionScheduleType.manual) { + return yup.mixed().notRequired(); + } + return yup.object({ - basicSchedule: yup + cron: yup .object({ - units: yup.number().required("form.empty.error"), - timeUnit: yup.string().required("form.empty.error"), + cronExpression: yup.string().required("form.empty.error"), + cronTimeZone: yup.string().required("form.empty.error"), }) .defined("form.empty.error"), }); - } else if (scheduleType === ConnectionScheduleType.manual) { - return yup.mixed().notRequired(); - } - - return yup.object({ - cron: yup - .object({ - cronExpression: yup.string().required("form.empty.error"), - cronTimeZone: yup.string().required("form.empty.error"), - }) - .defined("form.empty.error"), - }); - }), - namespaceDefinition: yup - .string() - .oneOf([ - NamespaceDefinitionType.source, - NamespaceDefinitionType.destination, - NamespaceDefinitionType.customformat, - ]) - .required("form.empty.error"), - namespaceFormat: yup.string().when("namespaceDefinition", { - is: NamespaceDefinitionType.customformat, - then: yup.string().required("form.empty.error"), - }), - prefix: yup.string(), - syncCatalog: yup.object({ - streams: yup.array().of( - yup.object({ - id: yup - .string() - // This is required to get rid of id fields we are using to detect stream for edition - .when("$isRequest", (isRequest: boolean, schema: yup.StringSchema) => - isRequest ? schema.strip(true) : schema - ), - stream: yup.object(), - config: yup - .object({ - selected: yup.boolean(), - syncMode: yup.string(), - destinationSyncMode: yup.string(), - primaryKey: yup.array().of(yup.array().of(yup.string())), - cursorField: yup.array().of(yup.string()).defined(), - }) - .test({ - name: "connectionSchema.config.validator", - // eslint-disable-next-line no-template-curly-in-string - message: "${path} is wrong", - test(value) { - if (!value.selected) { - return true; - } - if (DestinationSyncMode.append_dedup === value.destinationSyncMode) { - // it's possible that primaryKey array is always present - // however yup couldn't determine type correctly even with .required() call - if (value.primaryKey?.length === 0) { - return this.createError({ - message: "connectionForm.primaryKey.required", - path: `schema.streams[${this.parent.id}].config.primaryKey`, - }); + }), + namespaceDefinition: yup + .string() + .oneOf([ + NamespaceDefinitionType.source, + NamespaceDefinitionType.destination, + NamespaceDefinitionType.customformat, + ]) + .required("form.empty.error"), + namespaceFormat: yup.string().when("namespaceDefinition", { + is: NamespaceDefinitionType.customformat, + then: yup.string().required("form.empty.error"), + }), + prefix: yup.string(), + syncCatalog: yup.object({ + streams: yup.array().of( + yup.object({ + id: yup + .string() + // This is required to get rid of id fields we are using to detect stream for edition + .when("$isRequest", (isRequest: boolean, schema: yup.StringSchema) => + isRequest ? schema.strip(true) : schema + ), + stream: yup.object(), + config: yup + .object({ + selected: yup.boolean(), + syncMode: yup.string(), + destinationSyncMode: yup.string(), + primaryKey: yup.array().of(yup.array().of(yup.string())), + cursorField: yup.array().of(yup.string()).defined(), + }) + .test({ + name: "connectionSchema.config.validator", + // eslint-disable-next-line no-template-curly-in-string + message: "${path} is wrong", + test(value) { + if (!value.selected) { + return true; } - } - - if (SyncMode.incremental === value.syncMode) { - if ( - !this.parent.stream.sourceDefinedCursor && - // it's possible that cursorField array is always present + if (DestinationSyncMode.append_dedup === value.destinationSyncMode) { + // it's possible that primaryKey array is always present // however yup couldn't determine type correctly even with .required() call - value.cursorField?.length === 0 - ) { - return this.createError({ - message: "connectionForm.cursorField.required", - path: `schema.streams[${this.parent.id}].config.cursorField`, - }); + if (value.primaryKey?.length === 0) { + return this.createError({ + message: "connectionForm.primaryKey.required", + path: `schema.streams[${this.parent.id}].config.primaryKey`, + }); + } } - } - return true; - }, - }), - }) - ), - }), - }) - .noUnknown(); + + if (SyncMode.incremental === value.syncMode) { + if ( + !this.parent.stream.sourceDefinedCursor && + // it's possible that cursorField array is always present + // however yup couldn't determine type correctly even with .required() call + value.cursorField?.length === 0 + ) { + return this.createError({ + message: "connectionForm.cursorField.required", + path: `schema.streams[${this.parent.id}].config.cursorField`, + }); + } + } + return true; + }, + }), + }) + ), + }), + }) + .noUnknown(); /** * Returns {@link Operation}[] @@ -229,14 +231,14 @@ export const getInitialTransformations = (operations: OperationCreate[]): Operat export const getInitialNormalization = ( operations?: Array, - isEditMode?: boolean + isNotCreateMode?: boolean ): NormalizationType => { const initialNormalization = operations?.find(isNormalizationTransformation)?.operatorConfiguration?.normalization?.option; return initialNormalization ? NormalizationType[initialNormalization] - : isEditMode + : isNotCreateMode ? NormalizationType.raw : NormalizationType.basic; }; @@ -244,17 +246,20 @@ export const getInitialNormalization = ( export const useInitialValues = ( connection: ConnectionOrPartialConnection, destDefinition: DestinationDefinitionSpecificationRead, - isEditMode?: boolean + isNotCreateMode?: boolean ): FormikConnectionFormValues => { const initialSchema = useMemo( () => - calculateInitialCatalog(connection.syncCatalog, destDefinition?.supportedDestinationSyncModes || [], isEditMode), - [connection.syncCatalog, destDefinition, isEditMode] + calculateInitialCatalog( + connection.syncCatalog, + destDefinition?.supportedDestinationSyncModes || [], + isNotCreateMode + ), + [connection.syncCatalog, destDefinition, isNotCreateMode] ); return useMemo(() => { const initialValues: FormikConnectionFormValues = { - name: connection.name ?? `${connection.source.name} <> ${connection.destination.name}`, syncCatalog: initialSchema, scheduleType: connection.connectionId ? connection.scheduleType : ConnectionScheduleType.basic, scheduleData: connection.connectionId ? connection.scheduleData ?? null : DEFAULT_SCHEDULE, @@ -263,6 +268,11 @@ export const useInitialValues = ( namespaceFormat: connection.namespaceFormat ?? SOURCE_NAMESPACE_TAG, }; + // Is Create Mode + if (!isNotCreateMode) { + initialValues.name = connection.name ?? `${connection.source.name} <> ${connection.destination.name}`; + } + const operations = connection.operations ?? []; if (destDefinition.supportsDbt) { @@ -270,7 +280,7 @@ export const useInitialValues = ( } if (destDefinition.supportsNormalization) { - initialValues.normalization = getInitialNormalization(operations, isEditMode); + initialValues.normalization = getInitialNormalization(operations, isNotCreateMode); } return initialValues; @@ -288,7 +298,7 @@ export const useInitialValues = ( destDefinition.supportsDbt, destDefinition.supportsNormalization, initialSchema, - isEditMode, + isNotCreateMode, ]); }; diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/index.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/index.tsx deleted file mode 100644 index 518cc0429e56..000000000000 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "./ConnectionForm"; diff --git a/airbyte-webapp/src/views/Connection/FormCard.tsx b/airbyte-webapp/src/views/Connection/FormCard.tsx index 215ab247609f..9de05d1d3a9a 100644 --- a/airbyte-webapp/src/views/Connection/FormCard.tsx +++ b/airbyte-webapp/src/views/Connection/FormCard.tsx @@ -6,12 +6,11 @@ import styled from "styled-components"; import { FormChangeTracker } from "components/FormChangeTracker"; +import { ConnectionFormMode } from "hooks/services/ConnectionForm/ConnectionFormService"; import { generateMessageFromError } from "utils/errorStatusMessage"; import { CollapsibleCardProps, CollapsibleCard } from "views/Connection/CollapsibleCard"; import EditControls from "views/Connection/ConnectionForm/components/EditControls"; -import { ConnectionFormMode } from "./ConnectionForm/ConnectionForm"; - const FormContainer = styled(Form)` padding: 22px 27px 15px 24px; `;