diff --git a/airbyte-webapp/src/components/common/DeleteBlock/DeleteBlock.tsx b/airbyte-webapp/src/components/common/DeleteBlock/DeleteBlock.tsx index 548ad268ffaea..684d6dcbf92d4 100644 --- a/airbyte-webapp/src/components/common/DeleteBlock/DeleteBlock.tsx +++ b/airbyte-webapp/src/components/common/DeleteBlock/DeleteBlock.tsx @@ -1,12 +1,11 @@ -import React, { useCallback } from "react"; +import React from "react"; import { FormattedMessage } from "react-intl"; -import { useNavigate } from "react-router-dom"; import { H5 } from "components/base/Titles"; import { Button } from "components/ui/Button"; import { Card } from "components/ui/Card"; -import { useConfirmationModalService } from "hooks/services/ConfirmationModal"; +import { useDeleteModal } from "hooks/useDeleteModal"; import styles from "./DeleteBlock.module.scss"; @@ -16,22 +15,7 @@ interface IProps { } export const DeleteBlock: React.FC = ({ type, onDelete }) => { - const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService(); - const navigate = useNavigate(); - - const onDeleteButtonClick = useCallback(() => { - openConfirmationModal({ - text: `tables.${type}DeleteModalText`, - title: `tables.${type}DeleteConfirm`, - submitButtonText: "form.delete", - onSubmit: async () => { - await onDelete(); - closeConfirmationModal(); - navigate("../.."); - }, - submitButtonDataId: "delete", - }); - }, [closeConfirmationModal, onDelete, openConfirmationModal, navigate, type]); + const onDeleteButtonClick = useDeleteModal(type, onDelete); return ( diff --git a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.tsx b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.tsx index 0596304949d84..696e02fc6125f 100644 --- a/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/StreamTestingPanel/ConfigMenu.tsx @@ -1,11 +1,12 @@ import { faClose, faUser } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useMemo } from "react"; -import { FormattedMessage, useIntl } from "react-intl"; +import { FormattedMessage } from "react-intl"; import { useLocalStorage } from "react-use"; import { Button } from "components/ui/Button"; import { Callout } from "components/ui/Callout"; +import { FlexContainer, FlexItem } from "components/ui/Flex"; import { Modal, ModalBody } from "components/ui/Modal"; import { NumberBadge } from "components/ui/NumberBadge"; import { Tooltip } from "components/ui/Tooltip"; @@ -27,7 +28,6 @@ interface ConfigMenuProps { } export const ConfigMenu: React.FC = ({ className, testInputJsonErrors, isOpen, setIsOpen }) => { - const { formatMessage } = useIntl(); const { jsonManifest, editorView, setEditorView } = useConnectorBuilderFormState(); const { testInputJson, setTestInputJson } = useConnectorBuilderTestState(); @@ -112,20 +112,36 @@ export const ConfigMenu: React.FC = ({ className, testInputJson { setTestInputJson(values.connectionConfiguration as StreamReadRequestBodyConfig); setIsOpen(false); }} - onCancel={() => { - setIsOpen(false); - }} - onReset={() => { - setTestInputJson({}); - }} - submitLabel={formatMessage({ id: "connectorBuilder.saveInputsForm" })} + renderFooter={({ dirty, isSubmitting, resetConnectorForm }) => ( +
+ + + + + + + +
+ )} /> diff --git a/airbyte-webapp/src/hooks/useDeleteModal.tsx b/airbyte-webapp/src/hooks/useDeleteModal.tsx new file mode 100644 index 0000000000000..17f1d87c1e41b --- /dev/null +++ b/airbyte-webapp/src/hooks/useDeleteModal.tsx @@ -0,0 +1,23 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useConfirmationModalService } from "./services/ConfirmationModal"; + +export function useDeleteModal(type: "source" | "destination" | "connection", onDelete: () => Promise) { + const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService(); + const navigate = useNavigate(); + + return useCallback(() => { + openConfirmationModal({ + text: `tables.${type}DeleteModalText`, + title: `tables.${type}DeleteConfirm`, + submitButtonText: "form.delete", + onSubmit: async () => { + await onDelete(); + closeConfirmationModal(); + navigate("../.."); + }, + submitButtonDataId: "delete", + }); + }, [closeConfirmationModal, onDelete, openConfirmationModal, navigate, type]); +} diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json index d48e12c1b701f..4b7240a3f3566 100644 --- a/airbyte-webapp/src/locales/en.json +++ b/airbyte-webapp/src/locales/en.json @@ -62,9 +62,12 @@ "form.saveChanges": "Save changes", "form.openDatepicker": "Open datepicker", "form.datepickerTimeCaption": "Time (UTC)", - "form.saveChangesAndTest": "Save changes and test", - "form.sourceRetest": "Retest source", - "form.destinationRetest": "Retest destination", + "form.saveChangesAndTest": "Test and save", + "form.sourceRetest": "Retest saved source", + "form.destinationRetest": "Retest saved destination", + "form.test": "Test", + "form.sourceRetestTitle": "Test the source", + "form.destinationRetestTitle": "Test the destination", "form.discardChanges": "Discard changes", "form.discardChangesConfirmation": "There are unsaved changes. Are you sure you want to discard your changes?", "form.every.minutes": "Every {value, plural, one {minute} other {# minutes}}", diff --git a/airbyte-webapp/src/pages/SourcesPage/pages/SourceItemPage/components/SourceSettings.tsx b/airbyte-webapp/src/pages/SourcesPage/pages/SourceItemPage/components/SourceSettings.tsx index f1b21954adb9e..54f55047a132b 100644 --- a/airbyte-webapp/src/pages/SourcesPage/pages/SourceItemPage/components/SourceSettings.tsx +++ b/airbyte-webapp/src/pages/SourcesPage/pages/SourceItemPage/components/SourceSettings.tsx @@ -1,13 +1,12 @@ -import React, { useEffect } from "react"; +import React, { useCallback, useEffect } from "react"; import { FormattedMessage } from "react-intl"; -import { DeleteBlock } from "components/common/DeleteBlock"; - import { ConnectionConfiguration } from "core/domain/connection"; import { SourceRead, WebBackendConnectionListItem } from "core/request/AirbyteClient"; import { useTrackPage, PageTrackingCodes } from "hooks/services/Analytics"; import { useFormChangeTrackerService, useUniqueFormId } from "hooks/services/FormChangeTracker"; import { useDeleteSource, useUpdateSource } from "hooks/services/useSourceHook"; +import { useDeleteModal } from "hooks/useDeleteModal"; import { useSourceDefinition } from "services/connector/SourceDefinitionService"; import { useGetSourceDefinitionSpecification } from "services/connector/SourceDefinitionSpecificationService"; import { ConnectorCard } from "views/Connector/ConnectorCard"; @@ -49,10 +48,12 @@ const SourceSettings: React.FC = ({ currentSource, connecti }); }; - const onDelete = async () => { + const onDelete = useCallback(async () => { clearFormChange(formId); await deleteSource({ connectionsWithSource, source: currentSource }); - }; + }, [clearFormChange, connectionsWithSource, currentSource, deleteSource, formId]); + + const onDeleteClick = useDeleteModal("source", onDelete); return (
@@ -66,8 +67,8 @@ const SourceSettings: React.FC = ({ currentSource, connecti selectedConnectorDefinitionId={sourceDefinitionSpecification.sourceDefinitionId} connector={currentSource} onSubmit={onSubmit} + onDeleteClick={onDeleteClick} /> -
); }; diff --git a/airbyte-webapp/src/pages/destination/DestinationSettingsPage/DestinationSettingsPage.tsx b/airbyte-webapp/src/pages/destination/DestinationSettingsPage/DestinationSettingsPage.tsx index 33cd33acb4193..9e46d6bed34ee 100644 --- a/airbyte-webapp/src/pages/destination/DestinationSettingsPage/DestinationSettingsPage.tsx +++ b/airbyte-webapp/src/pages/destination/DestinationSettingsPage/DestinationSettingsPage.tsx @@ -1,14 +1,14 @@ -import React from "react"; +import React, { useCallback } from "react"; import { FormattedMessage } from "react-intl"; import { useParams } from "react-router-dom"; -import { DeleteBlock } from "components/common/DeleteBlock"; import { StepsTypes } from "components/ConnectorBlocks"; import { useTrackPage, PageTrackingCodes } from "hooks/services/Analytics"; import { useFormChangeTrackerService, useUniqueFormId } from "hooks/services/FormChangeTracker"; import { useConnectionList } from "hooks/services/useConnectionHook"; import { useDeleteDestination, useGetDestination, useUpdateDestination } from "hooks/services/useDestinationHook"; +import { useDeleteModal } from "hooks/useDeleteModal"; import { useDestinationDefinition } from "services/connector/DestinationDefinitionService"; import { useGetDestinationDefinitionSpecification } from "services/connector/DestinationDefinitionSpecificationService"; import { ConnectorCard } from "views/Connector/ConnectorCard"; @@ -36,13 +36,15 @@ export const DestinationSettingsPage: React.FC = () => { }); }; - const onDelete = async () => { + const onDelete = useCallback(async () => { clearFormChange(formId); await deleteDestination({ connectionsWithDestination, destination, }); - }; + }, [clearFormChange, connectionsWithDestination, deleteDestination, destination, formId]); + + const onDeleteClick = useDeleteModal("destination", onDelete); return (
@@ -56,8 +58,8 @@ export const DestinationSettingsPage: React.FC = () => { selectedConnectorDefinitionId={destinationSpecification.destinationDefinitionId} connector={destination} onSubmit={onSubmitForm} + onDeleteClick={onDeleteClick} /> -
); }; diff --git a/airbyte-webapp/src/views/Connector/ConnectorCard/ConnectorCard.module.scss b/airbyte-webapp/src/views/Connector/ConnectorCard/ConnectorCard.module.scss index 24d8851b643e0..ca3da58cf15f0 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorCard/ConnectorCard.module.scss +++ b/airbyte-webapp/src/views/Connector/ConnectorCard/ConnectorCard.module.scss @@ -1,9 +1,5 @@ @use "scss/variables"; -.cardForm { - padding: 22px 27px 23px 24px; -} - .connectorSelectControl { margin-bottom: variables.$spacing-xl; } @@ -19,7 +15,3 @@ margin-top: variables.$spacing-md; margin-left: variables.$spacing-lg; } - -.connectionTestLogs { - margin-top: variables.$spacing-lg; -} diff --git a/airbyte-webapp/src/views/Connector/ConnectorCard/ConnectorCard.tsx b/airbyte-webapp/src/views/Connector/ConnectorCard/ConnectorCard.tsx index c78951ad9525b..55a474a0c17d5 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorCard/ConnectorCard.tsx +++ b/airbyte-webapp/src/views/Connector/ConnectorCard/ConnectorCard.tsx @@ -1,11 +1,5 @@ -import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import React, { useEffect, useMemo, useState } from "react"; -import { FormattedMessage } from "react-intl"; -import { JobLogs } from "components/JobItem/components/JobLogs"; -import { Button } from "components/ui/Button"; -import { Card } from "components/ui/Card"; import { Spinner } from "components/ui/Spinner"; import { @@ -18,13 +12,9 @@ import { import { DestinationRead, SourceRead, SynchronousJobRead } from "core/request/AirbyteClient"; import { LogsRequestError } from "core/request/LogsRequestError"; import { generateMessageFromError } from "utils/errorStatusMessage"; -import { - ConnectorCardValues, - ConnectorForm, - ConnectorFormProps, - ConnectorFormValues, -} from "views/Connector/ConnectorForm"; +import { ConnectorCardValues, ConnectorForm, ConnectorFormValues } from "views/Connector/ConnectorForm"; +import { Controls } from "./components/Controls"; import ShowLoadingMessage from "./components/ShowLoadingMessage"; import styles from "./ConnectorCard.module.scss"; import { useAnalyticsTrackFunctions } from "./useAnalyticsTrackFunctions"; @@ -43,6 +33,7 @@ interface ConnectorCardBaseProps { jobInfo?: SynchronousJobRead | null; additionalSelectorComponent?: React.ReactNode; onSubmit: (values: ConnectorCardValues) => Promise | void; + onDeleteClick?: () => void; onConnectorDefinitionSelect?: (id: string) => void; availableConnectorDefinitions: ConnectorDefinition[]; @@ -75,20 +66,16 @@ const getConnectorId = (connectorRead: DestinationRead | SourceRead) => { }; export const ConnectorCard: React.FC = ({ - title, - description, - full, jobInfo, onSubmit, + onDeleteClick, additionalSelectorComponent, selectedConnectorDefinitionId, fetchingConnectorError, ...props }) => { - const [saved, setSaved] = useState(false); const [errorStatusRequest, setErrorStatusRequest] = useState(null); const [isFormSubmitting, setIsFormSubmitting] = useState(false); - const [logsVisible, setLogsVisible] = useState(false); const { setDocumentationUrl, setDocumentationPanelOpen } = useDocumentationPanelContext(); const { @@ -139,9 +126,25 @@ export const ConnectorCard: React.FC { + const testConnectorWithTracking = async (connectorCardValues?: ConnectorCardValues) => { + trackTestConnectorStarted(selectedConnectorDefinition); + try { + await testConnector(connectorCardValues); + trackTestConnectorSuccess(selectedConnectorDefinition); + } catch (e) { + trackTestConnectorFailure(selectedConnectorDefinition); + throw e; + } + }; + + const handleTestConnector = async (values?: ConnectorCardValues) => { setErrorStatusRequest(null); - return testConnector(v); + try { + await testConnectorWithTracking(values); + } catch (e) { + setErrorStatusRequest(e); + throw e; + } }; const onHandleSubmit = async (values: ConnectorFormValues) => { @@ -157,21 +160,9 @@ export const ConnectorCard: React.FC { - trackTestConnectorStarted(selectedConnectorDefinition); - try { - await testConnector(connectorCardValues); - trackTestConnectorSuccess(selectedConnectorDefinition); - } catch (e) { - trackTestConnectorFailure(selectedConnectorDefinition); - throw e; - } - }; - try { - await testConnectorWithTracking(); + await testConnectorWithTracking(connectorCardValues); onSubmit(connectorCardValues); - setSaved(true); } catch (e) { setErrorStatusRequest(e); setIsFormSubmitting(false); @@ -191,21 +182,21 @@ export const ConnectorCard: React.FC -
-
- -
- {additionalSelectorComponent} -
+ +
+ +
+ {additionalSelectorComponent} {props.isLoading && (
@@ -215,42 +206,48 @@ export const ConnectorCard: React.FC )} {fetchingConnectorError && } - {selectedConnectorDefinition && selectedConnectorDefinitionSpecification && ( - } - connectorId={isEditMode ? getConnectorId(props.connector) : undefined} - /> - )} - {job && ( -
- - {logsVisible && } -
- )} -
-
- + + } + // Causes the whole ConnectorForm to be unmounted and a new instance mounted whenever the connector type changes. + // That way we carry less state around inside it, preventing any state from one connector type from affecting another + // connector type's form in any way. + key={selectedConnectorDefinition && Connector.id(selectedConnectorDefinition)} + {...props} + selectedConnectorDefinition={selectedConnectorDefinition} + selectedConnectorDefinitionSpecification={selectedConnectorDefinitionSpecification} + isTestConnectionInProgress={isTestConnectionInProgress} + connectionTestSuccess={connectionTestSuccess} + onSubmit={onHandleSubmit} + formValues={formValues} + connectorId={isEditMode ? getConnectorId(props.connector) : undefined} + renderFooter={({ dirty, isSubmitting, isValid, resetConnectorForm, getValues }) => ( + { + if (!selectedConnectorDefinitionId) { + return; + } + handleTestConnector( + isEditMode ? undefined : { ...getValues(), serviceType: selectedConnectorDefinitionId } + ); + }} + onDeleteClick={onDeleteClick} + isValid={isValid} + dirty={dirty} + job={job ? job : undefined} + onCancelClick={() => { + resetConnectorForm(); + }} + connectionTestSuccess={connectionTestSuccess} + /> + )} + renderWithCard + /> ); }; diff --git a/airbyte-webapp/src/views/Connector/ConnectorCard/components/Controls.tsx b/airbyte-webapp/src/views/Connector/ConnectorCard/components/Controls.tsx new file mode 100644 index 0000000000000..b2a3e7527ba82 --- /dev/null +++ b/airbyte-webapp/src/views/Connector/ConnectorCard/components/Controls.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { FormattedMessage } from "react-intl"; + +import { Button } from "components/ui/Button"; +import { FlexContainer, FlexItem } from "components/ui/Flex"; + +import { SynchronousJobRead } from "core/request/AirbyteClient"; + +import { TestCard } from "./TestCard"; + +interface IProps { + formType: "source" | "destination"; + isSubmitting: boolean; + isValid: boolean; + dirty: boolean; + onCancelClick: () => void; + onDeleteClick?: () => void; + onRetestClick: () => void; + onCancelTesting: () => void; + isTestConnectionInProgress?: boolean; + errorMessage?: React.ReactNode; + job?: SynchronousJobRead; + connectionTestSuccess: boolean; + hasDefinition: boolean; + isEditMode: boolean; +} + +export const Controls: React.FC = ({ + isTestConnectionInProgress, + isSubmitting, + formType, + hasDefinition, + isEditMode, + dirty, + onDeleteClick, + onCancelClick, + ...restProps +}) => { + return ( + <> + {hasDefinition && ( + + )} + + + {isEditMode && ( + + )} + + {isEditMode && ( + + )} + + + + ); +}; diff --git a/airbyte-webapp/src/views/Connector/ConnectorCard/components/TestCard.module.scss b/airbyte-webapp/src/views/Connector/ConnectorCard/components/TestCard.module.scss new file mode 100644 index 0000000000000..9c08b01bf1573 --- /dev/null +++ b/airbyte-webapp/src/views/Connector/ConnectorCard/components/TestCard.module.scss @@ -0,0 +1,5 @@ +@use "scss/variables"; + +.cardTest { + padding: variables.$spacing-xl; +} diff --git a/airbyte-webapp/src/views/Connector/ConnectorCard/components/TestCard.tsx b/airbyte-webapp/src/views/Connector/ConnectorCard/components/TestCard.tsx new file mode 100644 index 0000000000000..75dac5a0e05bc --- /dev/null +++ b/airbyte-webapp/src/views/Connector/ConnectorCard/components/TestCard.tsx @@ -0,0 +1,120 @@ +import { faChevronDown, faChevronRight, faClose, faRefresh } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import React, { useState } from "react"; +import { FormattedMessage } from "react-intl"; + +import { JobLogs } from "components/JobItem/components/JobLogs"; +import { Button } from "components/ui/Button"; +import { Card } from "components/ui/Card"; +import { FlexContainer, FlexItem } from "components/ui/Flex"; +import { ProgressBar } from "components/ui/ProgressBar"; +import { Text } from "components/ui/Text"; + +import { SynchronousJobRead } from "core/request/AirbyteClient"; + +import styles from "./TestCard.module.scss"; +import TestingConnectionSuccess from "./TestingConnectionSuccess"; +import { TestingConnectionError } from "../../ConnectorForm/components/TestingConnectionError"; + +interface IProps { + formType: "source" | "destination"; + isValid: boolean; + onRetestClick: () => void; + onCancelTesting: () => void; + isTestConnectionInProgress?: boolean; + successMessage?: React.ReactNode; + errorMessage?: React.ReactNode; + job?: SynchronousJobRead; + isEditMode?: boolean; + dirty: boolean; + connectionTestSuccess: boolean; +} + +const PROGRESS_BAR_TIME = 60 * 2; + +export const TestCard: React.FC = ({ + isTestConnectionInProgress, + isValid, + formType, + onRetestClick, + connectionTestSuccess, + errorMessage, + onCancelTesting, + job, + isEditMode, + dirty, +}) => { + const [logsVisible, setLogsVisible] = useState(false); + + const renderStatusMessage = () => { + if (errorMessage) { + return ( + + + {job && ( +
+ + {logsVisible && } +
+ )} +
+ ); + } + if (connectionTestSuccess) { + return ; + } + return null; + }; + + return ( + + + + + + + + + {isTestConnectionInProgress ? ( + + ) : ( + + )} + + {isTestConnectionInProgress ? ( + + + + ) : ( + renderStatusMessage() + )} + + + ); +}; diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/components/TestingConnectionSpinner.module.scss b/airbyte-webapp/src/views/Connector/ConnectorCard/components/TestingConnectionSpinner.module.scss similarity index 100% rename from airbyte-webapp/src/views/Connector/ConnectorForm/components/TestingConnectionSpinner.module.scss rename to airbyte-webapp/src/views/Connector/ConnectorCard/components/TestingConnectionSpinner.module.scss diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/components/TestingConnectionSpinner.tsx b/airbyte-webapp/src/views/Connector/ConnectorCard/components/TestingConnectionSpinner.tsx similarity index 100% rename from airbyte-webapp/src/views/Connector/ConnectorForm/components/TestingConnectionSpinner.tsx rename to airbyte-webapp/src/views/Connector/ConnectorCard/components/TestingConnectionSpinner.tsx diff --git a/airbyte-webapp/src/views/Connector/ConnectorCard/components/TestingConnectionSuccess.tsx b/airbyte-webapp/src/views/Connector/ConnectorCard/components/TestingConnectionSuccess.tsx new file mode 100644 index 0000000000000..2f364342f160e --- /dev/null +++ b/airbyte-webapp/src/views/Connector/ConnectorCard/components/TestingConnectionSuccess.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { FormattedMessage } from "react-intl"; + +import { FlexContainer } from "components/ui/Flex"; +import { StatusIcon } from "components/ui/StatusIcon"; +import { Text } from "components/ui/Text"; + +const TestingConnectionSuccess: React.FC = () => ( + + + + + + +); + +export default TestingConnectionSuccess; diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/ConnectorForm.test.tsx b/airbyte-webapp/src/views/Connector/ConnectorForm/ConnectorForm.test.tsx index 6ce7e80830635..6858c7a266406 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/ConnectorForm.test.tsx +++ b/airbyte-webapp/src/views/Connector/ConnectorForm/ConnectorForm.test.tsx @@ -8,7 +8,7 @@ import { render, useMockIntersectionObserver } from "test-utils/testutils"; import { ConnectorDefinition } from "core/domain/connector"; import { AirbyteJSONSchema } from "core/jsonSchema/types"; import { DestinationDefinitionSpecificationRead } from "core/request/AirbyteClient"; -import { ConnectorForm, ConnectorFormProps } from "views/Connector/ConnectorForm"; +import { ConnectorForm } from "views/Connector/ConnectorForm"; import { ConnectorFormValues } from "./types"; import { DocumentationPanelContext } from "../ConnectorDocumentationLayout/DocumentationPanelContext"; @@ -179,6 +179,7 @@ describe("Service Form", () => { formType="source" onSubmit={handleSubmit} selectedConnectorDefinition={connectorDefinition} + renderFooter={() => } selectedConnectorDefinitionSpecification={ // @ts-expect-error Partial objects for testing { @@ -260,6 +261,7 @@ describe("Service Form", () => { onSubmit={async (values) => { result = values; }} + renderFooter={() => } selectedConnectorDefinition={connectorDefinition} selectedConnectorDefinitionSpecification={ // @ts-expect-error Partial objects for testing @@ -390,42 +392,4 @@ describe("Service Form", () => { ]); }); }); - - describe("conditionally render form submit button", () => { - const renderConnectorForm = (props: ConnectorFormProps) => - render(); - // eslint-disable-next-line @typescript-eslint/no-empty-function - const onSubmitClb = async () => {}; - const connectorDefSpec = { - connectionSpecification: schema, - sourceDefinitionId: "test-service-type", - documentationUrl: "", - }; - - it("should render if connector is selected", async () => { - const { getByText } = await renderConnectorForm({ - selectedConnectorDefinition: connectorDefinition, - selectedConnectorDefinitionSpecification: - // @ts-expect-error Partial objects for testing - connectorDefSpec as DestinationDefinitionSpecificationRead, - formType: "destination", - onSubmit: onSubmitClb, - }); - expect(getByText(/Set up destination/)).toBeInTheDocument(); - }); - - it("should render if connector is selected", async () => { - const { getByText } = await renderConnectorForm({ - selectedConnectorDefinition: connectorDefinition, - selectedConnectorDefinitionSpecification: - // @ts-expect-error Partial objects for testing - connectorDefSpec as DestinationDefinitionSpecificationRead, - formType: "destination", - onSubmit: onSubmitClb, - isEditMode: true, - }); - - expect(getByText(/Save changes and test/)).toBeInTheDocument(); - }); - }); }); diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/ConnectorForm.tsx b/airbyte-webapp/src/views/Connector/ConnectorForm/ConnectorForm.tsx index f193a3f41e8c4..a5c14439c9972 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/ConnectorForm.tsx +++ b/airbyte-webapp/src/views/Connector/ConnectorForm/ConnectorForm.tsx @@ -1,5 +1,5 @@ import { Formik } from "formik"; -import React, { useCallback } from "react"; +import React, { useCallback, useMemo } from "react"; import { FormChangeTracker } from "components/common/FormChangeTracker"; @@ -9,46 +9,40 @@ import { SourceDefinitionSpecificationDraft, } from "core/domain/connector"; import { FormikPatch } from "core/form/FormikPatch"; -import { CheckConnectionRead } from "core/request/AirbyteClient"; import { useFormChangeTrackerService, useUniqueFormId } from "hooks/services/FormChangeTracker"; import { ConnectorFormContextProvider } from "./connectorFormContext"; -import { FormRoot } from "./FormRoot"; -import { ConnectorCardValues, ConnectorFormValues } from "./types"; +import { BaseFormRootProps, FormRoot } from "./FormRoot"; +import { ConnectorFormValues } from "./types"; import { useBuildForm } from "./useBuildForm"; -export interface ConnectorFormProps { +interface BaseConnectorFormProps extends Omit { formType: "source" | "destination"; formId?: string; /** * Definition of the connector might not be available if it's not released but only exists in frontend heap */ selectedConnectorDefinition?: ConnectorDefinition; - selectedConnectorDefinitionSpecification: ConnectorDefinitionSpecification | SourceDefinitionSpecificationDraft; + selectedConnectorDefinitionSpecification?: ConnectorDefinitionSpecification | SourceDefinitionSpecificationDraft; onSubmit: (values: ConnectorFormValues) => Promise; isEditMode?: boolean; formValues?: Partial; - connectionTestSuccess?: boolean; - errorMessage?: React.ReactNode; - successMessage?: React.ReactNode; connectorId?: string; - footerClassName?: string; - bodyClassName?: string; - submitLabel?: string; - /** - * Called in case the user cancels the form - if not provided, no cancel button is rendered - */ - onCancel?: () => void; - /** - * Called in case the user reset the form - if not provided, no reset button is rendered - */ - onReset?: () => void; +} - isTestConnectionInProgress?: boolean; - onStopTesting?: () => void; - testConnector?: (v?: ConnectorCardValues) => Promise; +interface CardConnectorFormProps extends BaseConnectorFormProps { + renderWithCard: true; + title?: React.ReactNode; + description?: React.ReactNode; + full?: boolean; } +interface BareConnectorFormProps extends BaseConnectorFormProps { + renderWithCard?: false; +} + +export type ConnectorFormProps = CardConnectorFormProps | BareConnectorFormProps; + export const ConnectorForm: React.FC = (props) => { const formId = useUniqueFormId(props.formId); const { clearFormChange } = useFormChangeTrackerService(); @@ -58,13 +52,9 @@ export const ConnectorForm: React.FC = (props) => { formValues, onSubmit, isEditMode, - onStopTesting, - testConnector, selectedConnectorDefinition, selectedConnectorDefinitionSpecification, - errorMessage, connectorId, - onReset, } = props; const { formFields, initialValues, validationSchema } = useBuildForm( @@ -74,7 +64,7 @@ export const ConnectorForm: React.FC = (props) => { formValues ); - const getValues = useCallback( + const castValues = useCallback( (values: ConnectorFormValues) => validationSchema.cast(values, { stripUnknown: true, @@ -84,11 +74,16 @@ export const ConnectorForm: React.FC = (props) => { const onFormSubmit = useCallback( async (values: ConnectorFormValues) => { - const valuesToSend = getValues(values); + const valuesToSend = castValues(values); await onSubmit(valuesToSend); clearFormChange(formId); }, - [clearFormChange, formId, getValues, onSubmit] + [clearFormChange, formId, castValues, onSubmit] + ); + + const isInitialValid = useMemo( + () => Boolean(validationSchema.isValidSync(initialValues)), + [initialValues, validationSchema] ); return ( @@ -96,14 +91,15 @@ export const ConnectorForm: React.FC = (props) => { validateOnBlur validateOnChange initialValues={initialValues} + isInitialValid={isInitialValid} validationSchema={validationSchema} onSubmit={onFormSubmit} enableReinitialize > - {({ dirty, resetForm }) => ( + {({ dirty }) => ( = (props) => { > - { - onReset?.(); - resetForm(); - }) - } - onStopTestingConnector={onStopTesting ? () => onStopTesting() : undefined} - onRetest={testConnector ? async () => await testConnector() : undefined} - /> + )} diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/FormRoot.module.scss b/airbyte-webapp/src/views/Connector/ConnectorForm/FormRoot.module.scss new file mode 100644 index 0000000000000..dbb8d75cb9858 --- /dev/null +++ b/airbyte-webapp/src/views/Connector/ConnectorForm/FormRoot.module.scss @@ -0,0 +1,5 @@ +@use "scss/variables"; + +.cardForm { + padding: variables.$spacing-xl; +} diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/FormRoot.tsx b/airbyte-webapp/src/views/Connector/ConnectorForm/FormRoot.tsx index fbc00004eafeb..8d5a2b8c0c8fb 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/FormRoot.tsx +++ b/airbyte-webapp/src/views/Connector/ConnectorForm/FormRoot.tsx @@ -1,87 +1,87 @@ import { Form, useFormikContext } from "formik"; -import React from "react"; +import React, { ReactNode } from "react"; + +import { Card } from "components/ui/Card"; +import { FlexContainer } from "components/ui/Flex"; import { FormBlock } from "core/form/types"; -import CreateControls from "./components/CreateControls"; -import EditControls from "./components/EditControls"; import { FormSection } from "./components/Sections/FormSection"; import { useConnectorForm } from "./connectorFormContext"; +import styles from "./FormRoot.module.scss"; import { ConnectorFormValues } from "./types"; -interface FormRootProps { +export interface BaseFormRootProps { formFields: FormBlock; connectionTestSuccess?: boolean; isTestConnectionInProgress?: boolean; - errorMessage?: React.ReactNode; - successMessage?: React.ReactNode; - onRetest?: () => void; - onStopTestingConnector?: () => void; - submitLabel?: string; - footerClassName?: string; bodyClassName?: string; - /** - * Called in case the user cancels the form - if not provided, no cancel button is rendered - */ - onCancel?: () => void; - /** - * Called in case the user reset the form - if not provided, no reset button is rendered - */ - onReset?: () => void; + headerBlock?: ReactNode; + castValues: (values: ConnectorFormValues) => ConnectorFormValues; + renderFooter?: (formProps: { + dirty: boolean; + isSubmitting: boolean; + isValid: boolean; + resetConnectorForm: () => void; + isEditMode?: boolean; + formType: "source" | "destination"; + getValues: () => ConnectorFormValues; + }) => ReactNode; +} + +interface CardFormRootProps extends BaseFormRootProps { + renderWithCard: true; + title?: React.ReactNode; + description?: React.ReactNode; + full?: boolean; } -export const FormRoot: React.FC = ({ +interface BareFormRootProps extends BaseFormRootProps { + renderWithCard?: false; +} + +export const FormRoot: React.FC = ({ isTestConnectionInProgress = false, - onRetest, formFields, - successMessage, - errorMessage, - connectionTestSuccess, - onStopTestingConnector, - submitLabel, - footerClassName, bodyClassName, - onCancel, - onReset, + headerBlock, + renderFooter, + castValues, + ...props }) => { - const { dirty, isSubmitting, isValid } = useFormikContext(); + const { dirty, isSubmitting, isValid, values } = useFormikContext(); const { resetConnectorForm, isEditMode, formType } = useConnectorForm(); - return ( -
+ const formBody = ( + <> + {headerBlock}
-
- {isEditMode ? ( - { - resetConnectorForm(); - }} - successMessage={successMessage} - /> + + ); + + return ( + + + {props.renderWithCard ? ( + +
{formBody}
+
) : ( - + formBody )} -
+ {renderFooter && + renderFooter({ + dirty, + isSubmitting, + isValid, + resetConnectorForm, + isEditMode, + formType, + getValues: () => castValues(values), + })} +
); }; diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.module.scss b/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.module.scss deleted file mode 100644 index d93a861682412..0000000000000 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.module.scss +++ /dev/null @@ -1,19 +0,0 @@ -@use "scss/variables"; - -.controlContainer { - margin-top: 34px; - display: flex; - align-items: center; - justify-content: flex-end; -} - -.buttonContainer { - display: flex; - flex: 0 0 auto; - align-self: flex-end; - gap: variables.$spacing-sm; -} - -.deleteButtonContainer { - flex-grow: 1; -} diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.tsx b/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.tsx deleted file mode 100644 index 425157a83cc77..0000000000000 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/components/CreateControls.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from "react"; -import { FormattedMessage } from "react-intl"; - -import { Button } from "components/ui/Button"; - -import styles from "./CreateControls.module.scss"; -import { TestingConnectionError } from "./TestingConnectionError"; -import { TestingConnectionSpinner } from "./TestingConnectionSpinner"; -import TestingConnectionSuccess from "./TestingConnectionSuccess"; - -interface CreateControlProps { - formType: "source" | "destination"; - /** - * Called in case the user cancels the form - if not provided, no cancel button is rendered - */ - onCancel?: () => void; - /** - * Called in case the user reset the form - if not provided, no reset button is rendered - */ - onReset?: () => void; - submitLabel?: string; - isSubmitting: boolean; - errorMessage?: React.ReactNode; - connectionTestSuccess?: boolean; - - isTestConnectionInProgress: boolean; - onCancelTesting?: () => void; -} - -const CreateControls: React.FC = ({ - isTestConnectionInProgress, - isSubmitting, - formType, - connectionTestSuccess, - errorMessage, - onCancelTesting, - onCancel, - onReset, - submitLabel, -}) => { - if (isSubmitting) { - return ; - } - - if (connectionTestSuccess) { - return ; - } - - return ( -
- {errorMessage && } - {onReset && ( -
- -
- )} -
- {onCancel && ( - - )} - -
-
- ); -}; - -export default CreateControls; diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/components/EditControls.module.scss b/airbyte-webapp/src/views/Connector/ConnectorForm/components/EditControls.module.scss deleted file mode 100644 index 0976e17e7de45..0000000000000 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/components/EditControls.module.scss +++ /dev/null @@ -1,16 +0,0 @@ -@use "scss/variables"; - -.controlsContainer { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: variables.$spacing-lg; -} - -.buttonsContainer { - display: flex; -} - -.cancelButton { - margin-left: 10px; -} diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/components/EditControls.tsx b/airbyte-webapp/src/views/Connector/ConnectorForm/components/EditControls.tsx deleted file mode 100644 index ba6bd02460893..0000000000000 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/components/EditControls.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from "react"; -import { FormattedMessage } from "react-intl"; - -import { Button } from "components/ui/Button"; - -import styles from "./EditControls.module.scss"; -import { TestingConnectionError } from "./TestingConnectionError"; -import { TestingConnectionSpinner } from "./TestingConnectionSpinner"; -import TestingConnectionSuccess from "./TestingConnectionSuccess"; - -interface IProps { - formType: "source" | "destination"; - isSubmitting: boolean; - isValid: boolean; - dirty: boolean; - onCancelClick: () => void; - onRetestClick?: () => void; - onCancelTesting?: () => void; - isTestConnectionInProgress?: boolean; - successMessage?: React.ReactNode; - errorMessage?: React.ReactNode; -} - -const EditControls: React.FC = ({ - isSubmitting, - isTestConnectionInProgress, - isValid, - dirty, - onCancelClick, - formType, - onRetestClick, - successMessage, - errorMessage, - onCancelTesting, -}) => { - if (isSubmitting) { - return ; - } - - const renderStatusMessage = () => { - if (errorMessage) { - return ; - } - if (successMessage) { - return ; - } - return null; - }; - - return ( - <> -
-
- - -
- {onRetestClick && ( - - )} -
- {renderStatusMessage()} - - ); -}; - -export default EditControls; diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/components/Sections/auth/AuthSection.tsx b/airbyte-webapp/src/views/Connector/ConnectorForm/components/Sections/auth/AuthSection.tsx index c00cadf3ae6a9..50852a0c002eb 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/components/Sections/auth/AuthSection.tsx +++ b/airbyte-webapp/src/views/Connector/ConnectorForm/components/Sections/auth/AuthSection.tsx @@ -9,7 +9,10 @@ import { SectionContainer } from "../SectionContainer"; export const AuthSection: React.FC = () => { const { selectedConnectorDefinitionSpecification } = useConnectorForm(); - if (isSourceDefinitionSpecificationDraft(selectedConnectorDefinitionSpecification)) { + if ( + !selectedConnectorDefinitionSpecification || + isSourceDefinitionSpecificationDraft(selectedConnectorDefinitionSpecification) + ) { return null; } return ( diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/components/TestingConnectionError.tsx b/airbyte-webapp/src/views/Connector/ConnectorForm/components/TestingConnectionError.tsx index f88f3e9ef6026..e9538929578e8 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/components/TestingConnectionError.tsx +++ b/airbyte-webapp/src/views/Connector/ConnectorForm/components/TestingConnectionError.tsx @@ -1,44 +1,24 @@ import React from "react"; import { FormattedMessage } from "react-intl"; -import styled from "styled-components"; +import { Callout } from "components/ui/Callout"; +import { FlexContainer } from "components/ui/Flex"; import { StatusIcon } from "components/ui/StatusIcon"; - -const Error = styled(StatusIcon)` - padding-left: 1px; - width: 26px; - min-width: 26px; - height: 26px; - padding-top: 5px; - font-size: 17px; -`; - -const ErrorBlock = styled.div` - display: flex; - align-items: center; - font-weight: 600; - font-size: 12px; - line-height: 18px; - color: ${({ theme }) => theme.darkPrimaryColor}; -`; - -const ErrorText = styled.div` - font-weight: normal; - color: ${({ theme }) => theme.dangerColor}; - max-width: 400px; -`; +import { Text } from "components/ui/Text"; const ErrorSection: React.FC<{ errorTitle: React.ReactNode; errorMessage: React.ReactNode; }> = ({ errorMessage, errorTitle }) => ( - - -
- {errorTitle} - {errorMessage} -
-
+ + + + + {errorTitle} + {errorMessage} + + + ); const TestingConnectionError: React.FC<{ errorMessage: React.ReactNode }> = ({ errorMessage }) => ( diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/components/TestingConnectionSuccess.tsx b/airbyte-webapp/src/views/Connector/ConnectorForm/components/TestingConnectionSuccess.tsx deleted file mode 100644 index ec07619a3590e..0000000000000 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/components/TestingConnectionSuccess.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; -import { FormattedMessage } from "react-intl"; -import styled from "styled-components"; - -import { StatusIcon } from "components/ui/StatusIcon"; - -const LoadingContainer = styled.div` - font-weight: 600; - font-size: 14px; - line-height: 17px; - color: ${({ theme }) => theme.darkPrimaryColor}; - margin-top: 34px; - display: flex; - align-items: center; - justify-content: center; -`; - -const Success = styled(StatusIcon)` - width: 26px; - min-width: 26px; - height: 26px; - padding-top: 5px; - font-size: 17px; -`; - -const TestingConnectionSuccess: React.FC = () => ( - - - - -); - -export default TestingConnectionSuccess; diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/connectorFormContext.tsx b/airbyte-webapp/src/views/Connector/ConnectorForm/connectorFormContext.tsx index a222e14c59c9b..4389e49fcf74a 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/connectorFormContext.tsx +++ b/airbyte-webapp/src/views/Connector/ConnectorForm/connectorFormContext.tsx @@ -15,7 +15,7 @@ interface ConnectorFormContext { getValues: (values: ConnectorFormValues) => ConnectorFormValues; resetConnectorForm: () => void; selectedConnectorDefinition?: ConnectorDefinition; - selectedConnectorDefinitionSpecification: ConnectorDefinitionSpecification | SourceDefinitionSpecificationDraft; + selectedConnectorDefinitionSpecification?: ConnectorDefinitionSpecification | SourceDefinitionSpecificationDraft; isEditMode?: boolean; validationSchema: AnySchema; connectorId?: string; @@ -36,7 +36,7 @@ interface ConnectorFormContextProviderProps { formType: "source" | "destination"; isEditMode?: boolean; getValues: (values: ConnectorFormValues) => ConnectorFormValues; - selectedConnectorDefinitionSpecification: ConnectorDefinitionSpecification | SourceDefinitionSpecificationDraft; + selectedConnectorDefinitionSpecification?: ConnectorDefinitionSpecification | SourceDefinitionSpecificationDraft; validationSchema: AnySchema; connectorId?: string; } diff --git a/airbyte-webapp/src/views/Connector/ConnectorForm/useBuildForm.tsx b/airbyte-webapp/src/views/Connector/ConnectorForm/useBuildForm.tsx index cb4e76b6bde3f..6a009380795ea 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorForm/useBuildForm.tsx +++ b/airbyte-webapp/src/views/Connector/ConnectorForm/useBuildForm.tsx @@ -61,14 +61,26 @@ export function setDefaultValues( export function useBuildForm( isEditMode: boolean, formType: "source" | "destination", - selectedConnectorDefinitionSpecification: ConnectorDefinitionSpecification | SourceDefinitionSpecificationDraft, + selectedConnectorDefinitionSpecification: + | ConnectorDefinitionSpecification + | SourceDefinitionSpecificationDraft + | undefined, initialValues?: Partial ): BuildFormHook { const { formatMessage } = useIntl(); - const isDraft = isSourceDefinitionSpecificationDraft(selectedConnectorDefinitionSpecification); + + const isDraft = + selectedConnectorDefinitionSpecification && + isSourceDefinitionSpecificationDraft(selectedConnectorDefinitionSpecification); try { const jsonSchema: JSONSchema7 = useMemo(() => { + if (!selectedConnectorDefinitionSpecification) { + return { + type: "object", + properties: {}, + }; + } const schema: JSONSchema7 = { type: "object", properties: { @@ -89,7 +101,7 @@ export function useBuildForm( }; schema.required = ["name"]; return schema; - }, [formType, formatMessage, isDraft, selectedConnectorDefinitionSpecification.connectionSpecification]); + }, [formType, formatMessage, isDraft, selectedConnectorDefinitionSpecification]); const formFields = useMemo(() => jsonSchemaToFormBlock(jsonSchema), [jsonSchema]); @@ -119,7 +131,7 @@ export function useBuildForm( return baseValues; } - setDefaultValues(formFields, baseValues as Record, { respectExistingValues: isDraft }); + setDefaultValues(formFields, baseValues as Record, { respectExistingValues: Boolean(isDraft) }); return baseValues; }, [formFields, initialValues, isDraft, isEditMode, validationSchema]); @@ -134,7 +146,9 @@ export function useBuildForm( if (isFormBuildError(e)) { throw new FormBuildError( e.message, - isDraft ? undefined : ConnectorSpecification.id(selectedConnectorDefinitionSpecification) + isDraft || !selectedConnectorDefinitionSpecification + ? undefined + : ConnectorSpecification.id(selectedConnectorDefinitionSpecification) ); } throw e;