diff --git a/airbyte-webapp-e2e-tests/cypress/integration/connection.spec.ts b/airbyte-webapp-e2e-tests/cypress/integration/connection.spec.ts index d4a938505ff8..81a0aaa95841 100644 --- a/airbyte-webapp-e2e-tests/cypress/integration/connection.spec.ts +++ b/airbyte-webapp-e2e-tests/cypress/integration/connection.spec.ts @@ -17,7 +17,7 @@ describe("Connection main actions", () => { }); it("Update connection", () => { - cy.intercept("/api/v1/web_backend/connections/update").as("updateConnection"); + cy.intercept("/api/v1/web_backend/connections/updateNew").as("updateConnection"); createTestConnection("Test update connection source cypress", "Test update connection destination cypress"); diff --git a/airbyte-webapp/src/App.tsx b/airbyte-webapp/src/App.tsx index f444e697ed8f..3413daa3759a 100644 --- a/airbyte-webapp/src/App.tsx +++ b/airbyte-webapp/src/App.tsx @@ -8,6 +8,7 @@ import { ServicesProvider } from "core/servicesProvider"; import { ConfirmationModalService } from "hooks/services/ConfirmationModal"; import { defaultFeatures, FeatureService } from "hooks/services/Feature"; import { FormChangeTrackerService } from "hooks/services/FormChangeTracker"; +import { ModalServiceProvider } from "hooks/services/Modal"; import NotificationService from "hooks/services/Notification"; import { AnalyticsProvider } from "views/common/AnalyticsProvider"; import { StoreProvider } from "views/common/StoreProvider"; @@ -44,9 +45,11 @@ const Services: React.FC = ({ children }) => ( - - {children} - + + + {children} + + diff --git a/airbyte-webapp/src/components/Modal/Modal.tsx b/airbyte-webapp/src/components/Modal/Modal.tsx index 705544d1a626..3fc3dfc33084 100644 --- a/airbyte-webapp/src/components/Modal/Modal.tsx +++ b/airbyte-webapp/src/components/Modal/Modal.tsx @@ -22,10 +22,10 @@ const cardStyleBySize = { }; const Modal: React.FC = ({ children, title, onClose, clear, closeOnBackground, size }) => { - const handleUserKeyPress = useCallback((event, closeModal) => { - const { keyCode } = event; + const handleUserKeyPress = useCallback((event: KeyboardEvent, closeModal: () => void) => { + const { key } = event; // Escape key - if (keyCode === 27) { + if (key === "Escape") { closeModal(); } }, []); diff --git a/airbyte-webapp/src/components/Modal/ModalBody.module.scss b/airbyte-webapp/src/components/Modal/ModalBody.module.scss index 51f977f9a19d..1cb9b8f8c520 100644 --- a/airbyte-webapp/src/components/Modal/ModalBody.module.scss +++ b/airbyte-webapp/src/components/Modal/ModalBody.module.scss @@ -2,6 +2,6 @@ .modalBody { padding: variables.$spacing-lg variables.$spacing-xl; - overflow: scroll; + overflow: auto; max-width: 100%; } diff --git a/airbyte-webapp/src/core/domain/connection/ConnectionService.ts b/airbyte-webapp/src/core/domain/connection/ConnectionService.ts index a4616be27e56..5a3ae970e69f 100644 --- a/airbyte-webapp/src/core/domain/connection/ConnectionService.ts +++ b/airbyte-webapp/src/core/domain/connection/ConnectionService.ts @@ -1,4 +1,4 @@ -import { deleteConnection, resetConnection, syncConnection, getState } from "../../request/AirbyteClient"; +import { deleteConnection, resetConnection, syncConnection, getState, getStateType } from "../../request/AirbyteClient"; import { AirbyteRequestService } from "../../request/AirbyteRequestService"; export class ConnectionService extends AirbyteRequestService { @@ -17,4 +17,8 @@ export class ConnectionService extends AirbyteRequestService { public getState(connectionId: string) { return getState({ connectionId }, this.requestOptions); } + + public getStateType(connectionId: string) { + return getStateType({ connectionId }, this.requestOptions); + } } diff --git a/airbyte-webapp/src/core/domain/connection/WebBackendConnectionService.ts b/airbyte-webapp/src/core/domain/connection/WebBackendConnectionService.ts index bfd3f6c9b050..a8702ce251a7 100644 --- a/airbyte-webapp/src/core/domain/connection/WebBackendConnectionService.ts +++ b/airbyte-webapp/src/core/domain/connection/WebBackendConnectionService.ts @@ -4,7 +4,7 @@ import { webBackendCreateConnection, webBackendGetConnection, webBackendListConnectionsForWorkspace, - webBackendUpdateConnection, + webBackendUpdateConnectionNew as webBackendUpdateConnection, } from "../../request/AirbyteClient"; import { AirbyteRequestService } from "../../request/AirbyteRequestService"; diff --git a/airbyte-webapp/src/core/domain/connection/utils.ts b/airbyte-webapp/src/core/domain/connection/utils.ts index 15e242ed11b9..29bc15ababc4 100644 --- a/airbyte-webapp/src/core/domain/connection/utils.ts +++ b/airbyte-webapp/src/core/domain/connection/utils.ts @@ -19,6 +19,7 @@ export const buildConnectionUpdate = ( connection: WebBackendConnectionRead, connectionUpdate: Partial ): WebBackendConnectionUpdate => ({ + skipReset: true, ...toWebBackendConnectionUpdate(connection), ...connectionUpdate, }); diff --git a/airbyte-webapp/src/hooks/services/Modal/ModalService.test.tsx b/airbyte-webapp/src/hooks/services/Modal/ModalService.test.tsx new file mode 100644 index 000000000000..fc3f82776b35 --- /dev/null +++ b/airbyte-webapp/src/hooks/services/Modal/ModalService.test.tsx @@ -0,0 +1,87 @@ +import { render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useEffectOnce } from "react-use"; + +import { ModalServiceProvider, useModalService } from "./ModalService"; +import { ModalResult } from "./types"; + +const TestComponent: React.FC<{ onModalResult?: (result: ModalResult) => void }> = ({ onModalResult }) => { + const { openModal } = useModalService(); + useEffectOnce(() => { + openModal({ + title: "Test Modal Title", + content: ({ onCancel, onClose }) => ( +
+ + + +
+ ), + }).then(onModalResult); + }); + return null; +}; + +const renderModal = (resultCallback?: (reason: unknown) => void) => { + return render( + + + + ); +}; + +describe("ModalService", () => { + it("should open a modal on openModal", () => { + const rendered = renderModal(); + + expect(rendered.getByText("Test Modal Title")).toBeTruthy(); + expect(rendered.getByTestId("testModalContent")).toBeTruthy(); + }); + + it("should close the modal with escape and emit a cancel result", async () => { + const resultCallback = jest.fn(); + + const rendered = renderModal(resultCallback); + + await waitFor(() => userEvent.keyboard("{Escape}")); + + expect(rendered.queryByTestId("testModalContent")).toBeFalsy(); + expect(resultCallback).toHaveBeenCalledWith({ type: "canceled" }); + }); + + it("should allow cancelling the modal from inside", async () => { + const resultCallback = jest.fn(); + + const rendered = renderModal(resultCallback); + + await waitFor(() => userEvent.click(rendered.getByTestId("cancel"))); + + expect(rendered.queryByTestId("testModalContent")).toBeFalsy(); + expect(resultCallback).toHaveBeenCalledWith({ type: "canceled" }); + }); + + it("should allow closing the button with a reason and return that reason", async () => { + const resultCallback = jest.fn(); + + let rendered = renderModal(resultCallback); + + await waitFor(() => userEvent.click(rendered.getByTestId("close-reason1"))); + + expect(rendered.queryByTestId("testModalContent")).toBeFalsy(); + expect(resultCallback).toHaveBeenCalledWith({ type: "closed", reason: "reason1" }); + + resultCallback.mockReset(); + rendered = renderModal(resultCallback); + + await waitFor(() => userEvent.click(rendered.getByTestId("close-reason2"))); + + expect(rendered.queryByTestId("testModalContent")).toBeFalsy(); + expect(resultCallback).toHaveBeenCalledWith({ type: "closed", reason: "reason2" }); + }); +}); diff --git a/airbyte-webapp/src/hooks/services/Modal/ModalService.tsx b/airbyte-webapp/src/hooks/services/Modal/ModalService.tsx new file mode 100644 index 000000000000..33f347b35d84 --- /dev/null +++ b/airbyte-webapp/src/hooks/services/Modal/ModalService.tsx @@ -0,0 +1,64 @@ +import React, { useContext, useMemo, useRef, useState } from "react"; +import { firstValueFrom, Subject } from "rxjs"; + +import { Modal } from "components"; + +import { ModalOptions, ModalResult, ModalServiceContext } from "./types"; + +const modalServiceContext = React.createContext(undefined); + +export const ModalServiceProvider: React.FC = ({ children }) => { + // The any here is due to the fact, that every call to open a modal might come in with + // a different type, thus we can't type this with unknown or a generic. + // The consuming code of this service though is properly typed, so that this `any` stays + // encapsulated within this component. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [modalOptions, setModalOptions] = useState>(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resultSubjectRef = useRef>>(); + + const service: ModalServiceContext = useMemo( + () => ({ + openModal: (options) => { + resultSubjectRef.current = new Subject(); + setModalOptions(options); + + return firstValueFrom(resultSubjectRef.current).then((reason) => { + setModalOptions(undefined); + resultSubjectRef.current = undefined; + return reason; + }); + }, + closeModal: () => { + resultSubjectRef.current?.next({ type: "canceled" }); + }, + }), + [] + ); + + return ( + + {children} + {modalOptions && ( + resultSubjectRef.current?.next({ type: "canceled" })} + > + resultSubjectRef.current?.next({ type: "canceled" })} + onClose={(reason) => resultSubjectRef.current?.next({ type: "closed", reason })} + /> + + )} + + ); +}; + +export const useModalService = () => { + const context = useContext(modalServiceContext); + if (!context) { + throw new Error("Can't use ModalService outside ModalServiceProvider"); + } + return context; +}; diff --git a/airbyte-webapp/src/hooks/services/Modal/index.ts b/airbyte-webapp/src/hooks/services/Modal/index.ts new file mode 100644 index 000000000000..2a39bbce1986 --- /dev/null +++ b/airbyte-webapp/src/hooks/services/Modal/index.ts @@ -0,0 +1 @@ +export * from "./ModalService"; diff --git a/airbyte-webapp/src/hooks/services/Modal/types.ts b/airbyte-webapp/src/hooks/services/Modal/types.ts new file mode 100644 index 000000000000..fe2592fc02f5 --- /dev/null +++ b/airbyte-webapp/src/hooks/services/Modal/types.ts @@ -0,0 +1,21 @@ +import React from "react"; + +import { ModalProps } from "components/Modal/Modal"; + +export interface ModalOptions { + title: ModalProps["title"]; + size?: ModalProps["size"]; + content: React.ComponentType>; +} + +export type ModalResult = { type: "canceled" } | { type: "closed"; reason: T }; + +interface ModalContentProps { + onClose: (reason: T) => void; + onCancel: () => void; +} + +export interface ModalServiceContext { + openModal: (options: ModalOptions) => Promise>; + closeModal: () => void; +} diff --git a/airbyte-webapp/src/hooks/services/useConnectionHook.tsx b/airbyte-webapp/src/hooks/services/useConnectionHook.tsx index 749a4fb98774..ae2708354b84 100644 --- a/airbyte-webapp/src/hooks/services/useConnectionHook.tsx +++ b/airbyte-webapp/src/hooks/services/useConnectionHook.tsx @@ -63,7 +63,7 @@ function useWebConnectionService() { ); } -function useConnectionService() { +export function useConnectionService() { const config = useConfig(); const middlewares = useDefaultRequestMiddlewares(); return useInitService(() => new ConnectionService(config.apiUrl, middlewares), [config.apiUrl, middlewares]); @@ -190,20 +190,11 @@ const useUpdateConnection = () => { const service = useWebConnectionService(); const queryClient = useQueryClient(); - return useMutation( - (connectionUpdate: WebBackendConnectionUpdate) => { - const withRefreshedCatalogCleaned = connectionUpdate.withRefreshedCatalog - ? { withRefreshedCatalog: connectionUpdate.withRefreshedCatalog } - : null; - - return service.update({ ...connectionUpdate, ...withRefreshedCatalogCleaned }); + return useMutation((connectionUpdate: WebBackendConnectionUpdate) => service.update(connectionUpdate), { + onSuccess: (connection) => { + queryClient.setQueryData(connectionsKeys.detail(connection.connectionId), connection); }, - { - onSuccess: (connection) => { - queryClient.setQueryData(connectionsKeys.detail(connection.connectionId), connection); - }, - } - ); + }); }; const useConnectionList = (): ListConnection => { diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json index 768a0e12874f..8dcf26305a3b 100644 --- a/airbyte-webapp/src/locales/en.json +++ b/airbyte-webapp/src/locales/en.json @@ -88,7 +88,6 @@ "form.noNeed": "No need!", "form.reset": "Reset", "form.resetData": "Reset your data", - "form.changedColumns": "You have changed which column is used to detect new records for a stream. You may want to reset the data in your stream.", "form.resetDataText": "Resetting your data will delete all the data for this connection in your destination and start syncs from scratch. Are you sure you want to do this?", "form.dockerError": "Could not find docker image", "form.edit": "Edit", @@ -106,8 +105,6 @@ "form.destinationConnector": "Destination connector", "form.addItems": "Add", "form.items": "Items", - "form.toSaveSchema": "To save the new schema, click on Save changes.", - "form.noteStartSync": "Note that it will delete all the data in your destination and start syncing from scratch. ", "form.pkSelected": "{count, plural, =0 { } one {{items}} other {# keys selected}}", "form.url.error": "field must be a valid URL", "form.setupGuide": "Setup Guide", @@ -317,7 +314,12 @@ "syncMode.full_refresh": "Full refresh", "syncMode.incremental": "Incremental", - "connection.warningUpdateSchema": "WARNING! Updating the schema will delete all the data for this connection in your destination and start syncing from scratch.", + "connection.resetModalTitle": "Stream configuration changed", + "connection.streamResetHint": "Due to changes in the stream configuration, we recommend a data reset. A reset will delete data in the destination of the affected streams and then re-sync that data. Skipping the reset is discouraged and might lead to unexpected behavior.", + "connection.streamFullResetHint": "Due to changes in the stream configuration, we recommend a data reset. A reset will delete data in the destination of the affected streams and then re-sync that data. Skipping the reset is discouraged and might lead to unexpected behavior.", + "connection.saveWithReset": "Reset affected streams (recommended)", + "connection.saveWithFullReset": "Reset all streams (recommended)", + "connection.save": "Save connection", "connection.title": "Connection", "connection.description": "Connections link Sources to Destinations.", "connection.fromTo": "{source} → {destination}", @@ -326,12 +328,10 @@ "connection.testsFailed": "The connection tests failed.", "connection.destinationCheckSettings": "Check destination settings", "connection.sourceCheckSettings": "Check source settings", - "connection.saveAndReset": "Save changes & reset data", "connection.destinationTestAgain": "Test destination connection again", "connection.sourceTestAgain": "Test source connection again", "connection.resetData": "Reset your data", "connection.updateSchema": "Refresh source schema", - "connection.updateSchemaText": "WARNING! Updating the schema will delete all the data for this connection in your destination and start syncing from scratch. Are you sure you want to do this?", "connection.newConnection": "+ New connection", "connection.newConnectionTitle": "New connection", "connection.noConnections": "Connection list is empty", diff --git a/airbyte-webapp/src/packages/cloud/App.tsx b/airbyte-webapp/src/packages/cloud/App.tsx index 353db8ebbe26..0cbc03e551f9 100644 --- a/airbyte-webapp/src/packages/cloud/App.tsx +++ b/airbyte-webapp/src/packages/cloud/App.tsx @@ -10,6 +10,7 @@ import { I18nProvider } from "core/i18n"; import { ConfirmationModalService } from "hooks/services/ConfirmationModal"; import { FeatureItem, FeatureService } from "hooks/services/Feature"; import { FormChangeTrackerService } from "hooks/services/FormChangeTracker"; +import { ModalServiceProvider } from "hooks/services/Modal"; import NotificationServiceProvider from "hooks/services/Notification"; import en from "locales/en.json"; import { Routing } from "packages/cloud/cloudRoutes"; @@ -37,17 +38,19 @@ const Services: React.FC = ({ children }) => ( - - - - - {children} - - - - + + + + + + {children} + + + + + diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/ReplicationView.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/ReplicationView.tsx index 6eb75031cc3f..99d0a1a540de 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/ReplicationView.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/ReplicationView.tsx @@ -1,25 +1,25 @@ import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { FormikHelpers } from "formik"; import React, { useMemo, useState } from "react"; -import { FormattedMessage } from "react-intl"; -import { useAsyncFn } from "react-use"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useAsyncFn, useUnmount } from "react-use"; import styled from "styled-components"; -import { Button, Card } from "components"; +import { Button, Card, LabeledSwitch, ModalBody, ModalFooter } from "components"; import LoadingSchema from "components/LoadingSchema"; import { toWebBackendConnectionUpdate } from "core/domain/connection"; -import { ConnectionStatus } from "core/request/AirbyteClient"; -import { useConfirmationModalService } from "hooks/services/ConfirmationModal"; +import { ConnectionStateType, ConnectionStatus } from "core/request/AirbyteClient"; +import { useModalService } from "hooks/services/Modal"; import { useConnectionLoad, - useResetConnection, + useConnectionService, useUpdateConnection, ValuesProps, } from "hooks/services/useConnectionHook"; import { equal } from "utils/objects"; import ConnectionForm from "views/Connection/ConnectionForm"; +import { ConnectionFormSubmitResult } from "views/Connection/ConnectionForm/ConnectionForm"; import { FormikConnectionFormValues } from "views/Connection/ConnectionForm/formConfig"; interface ReplicationViewProps { @@ -27,6 +27,48 @@ interface ReplicationViewProps { connectionId: string; } +interface ResetWarningModalProps { + onClose: (withReset: boolean) => void; + onCancel: () => void; + stateType: ConnectionStateType; +} + +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 Content = styled.div` max-width: 1279px; margin: 0 auto; @@ -38,27 +80,15 @@ const TryArrow = styled(FontAwesomeIcon)` font-size: 14px; `; -const Message = styled.div` - font-weight: 500; - font-size: 12px; - line-height: 15px; - color: ${({ theme }) => theme.greyColor40}; -`; - -const Note = styled.span` - color: ${({ theme }) => theme.dangerColor}; -`; - export const ReplicationView: React.FC = ({ onAfterSaveSchema, connectionId }) => { - const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService(); + const { formatMessage } = useIntl(); + const { openModal, closeModal } = useModalService(); const [activeUpdatingSchemaMode, setActiveUpdatingSchemaMode] = useState(false); const [saved, setSaved] = useState(false); const [connectionFormValues, setConnectionFormValues] = useState(); + const connectionService = useConnectionService(); const { mutateAsync: updateConnection } = useUpdateConnection(); - const { mutateAsync: resetConnection } = useResetConnection(); - - const onReset = () => resetConnection(connectionId); const { connection: initialConnection, refreshConnectionCatalog } = useConnectionLoad(connectionId); @@ -67,6 +97,8 @@ export const ReplicationView: React.FC = ({ onAfterSaveSch [connectionId] ); + useUnmount(() => closeModal()); + const connection = useMemo(() => { if (activeUpdatingSchemaMode && connectionWithRefreshCatalog) { // merge connectionFormValues (unsaved previous form state) with the refreshed connection data: @@ -86,12 +118,7 @@ export const ReplicationView: React.FC = ({ onAfterSaveSch return initialConnection; }, [activeUpdatingSchemaMode, connectionWithRefreshCatalog, initialConnection, connectionFormValues]); - const onSubmit = async (values: ValuesProps, formikHelpers?: FormikHelpers) => { - if (!connection) { - // onSubmit should only be called when a connection object exists. - return; - } - + const saveConnection = async (values: ValuesProps, { skipReset }: { skipReset: boolean }) => { const initialSyncSchema = connection.syncCatalog; const connectionAsUpdate = toWebBackendConnectionUpdate(connection); @@ -103,7 +130,7 @@ export const ReplicationView: React.FC = ({ onAfterSaveSch // The status can be toggled and the name can be changed in-between refreshing the schema name: initialConnection.name, status: initialConnection.status || "", - withRefreshedCatalog: activeUpdatingSchemaMode, + skipReset, }); setSaved(true); @@ -114,59 +141,50 @@ export const ReplicationView: React.FC = ({ onAfterSaveSch if (activeUpdatingSchemaMode) { setActiveUpdatingSchemaMode(false); } - - formikHelpers?.resetForm({ values }); - }; - - const openResetDataModal = (values: ValuesProps) => { - openConfirmationModal({ - title: "connection.updateSchema", - text: "connection.updateSchemaText", - submitButtonText: "connection.updateSchema", - submitButtonDataId: "refresh", - onSubmit: async () => { - await onSubmit(values); - closeConfirmationModal(); - }, - }); }; - const onSubmitForm = async (values: ValuesProps) => { - if (activeUpdatingSchemaMode) { - openResetDataModal(values); + 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), + initialConnection.syncCatalog.streams.filter((s) => s.config?.selected) + ); + // 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") { + return { + submitCancelled: true, + }; + } + // Save the connection taking into account the correct skipRefresh value from the dialog choice. + await saveConnection(values, { skipReset: !result.reason }); } else { - await onSubmit(values); + // The catalog hasn't changed. We don't need to ask for any confirmation and can simply save. + await saveConnection(values, { skipReset: true }); } }; - const onEnterRefreshCatalogMode = async () => { + const onRefreshSourceSchema = async () => { + setSaved(false); setActiveUpdatingSchemaMode(true); await refreshCatalog(); }; const onCancelConnectionFormEdit = () => { + setSaved(false); setActiveUpdatingSchemaMode(false); }; - const renderUpdateSchemaButton = () => { - if (!activeUpdatingSchemaMode) { - return ( - - ); - } - return ( - - {" "} - - - - - ); - }; - return ( @@ -175,11 +193,15 @@ export const ReplicationView: React.FC = ({ onAfterSaveSch mode={connection?.status !== ConnectionStatus.deprecated ? "edit" : "readonly"} connection={connection} onSubmit={onSubmitForm} - onReset={onReset} successMessage={saved && } onCancel={onCancelConnectionFormEdit} - editSchemeMode={activeUpdatingSchemaMode} - additionalSchemaControl={renderUpdateSchemaButton()} + canSubmitUntouchedForm={activeUpdatingSchemaMode} + additionalSchemaControl={ + + } onChangeValues={setConnectionFormValues} /> ) : ( diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx index 0c11955e3599..c96280f5d817 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx @@ -9,12 +9,10 @@ import { ControlLabels, DropDown, DropDownRow, H5, Input, Label } from "componen import { FormChangeTracker } from "components/FormChangeTracker"; import { ConnectionSchedule, NamespaceDefinitionType, WebBackendConnectionRead } from "core/request/AirbyteClient"; -import { useConfirmationModalService } from "hooks/services/ConfirmationModal"; import { useFormChangeTrackerService, useUniqueFormId } from "hooks/services/FormChangeTracker"; import { useGetDestinationDefinitionSpecification } from "services/connector/DestinationDefinitionSpecificationService"; import { useCurrentWorkspace } from "services/workspaces/WorkspacesService"; import { createFormErrorMessage } from "utils/errorStatusMessage"; -import { equal } from "utils/objects"; import CreateControls from "./components/CreateControls"; import EditControls from "./components/EditControls"; @@ -88,8 +86,9 @@ const FormContainer = styled(Form)` } `; -interface ConnectionFormSubmitResult { - onSubmitComplete: () => void; +export interface ConnectionFormSubmitResult { + onSubmitComplete?: () => void; + submitCancelled?: boolean; } export type ConnectionFormMode = "create" | "edit" | "readonly"; @@ -113,13 +112,12 @@ interface ConnectionFormProps { className?: string; additionBottomControls?: React.ReactNode; successMessage?: React.ReactNode; - onReset?: (connectionId?: string) => void; onDropDownSelect?: (item: DropDownRow.IDataItem) => void; onCancel?: () => void; onChangeValues?: (values: FormikConnectionFormValues) => void; /** Should be passed when connection is updated with withRefreshCatalog flag */ - editSchemeMode?: boolean; + canSubmitUntouchedForm?: boolean; mode: ConnectionFormMode; additionalSchemaControl?: React.ReactNode; @@ -130,19 +128,17 @@ interface ConnectionFormProps { const ConnectionForm: React.FC = ({ onSubmit, - onReset, onCancel, className, onDropDownSelect, mode, successMessage, additionBottomControls, - editSchemeMode, + canSubmitUntouchedForm, additionalSchemaControl, connection, onChangeValues, }) => { - const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService(); const destDefinition = useGetDestinationDefinitionSpecification(connection.destination.destinationDefinitionId); const { clearFormChange } = useFormChangeTrackerService(); const formId = useUniqueFormId(); @@ -155,19 +151,6 @@ const ConnectionForm: React.FC = ({ const initialValues = useInitialValues(connection, destDefinition, isEditMode); const workspace = useCurrentWorkspace(); - const openResetDataModal = useCallback(() => { - openConfirmationModal({ - title: "form.resetData", - text: "form.changedColumns", - submitButtonText: "form.reset", - cancelButtonText: "form.noNeed", - onSubmit: async () => { - await onReset?.(); - closeConfirmationModal(); - }, - }); - }, [closeConfirmationModal, onReset, openConfirmationModal]); - const onFormSubmit = useCallback( async (values: FormikConnectionFormValues, formikHelpers: FormikHelpers) => { const formValues: ConnectionFormValues = connectionValidationSchema.cast(values, { @@ -180,32 +163,19 @@ const ConnectionForm: React.FC = ({ try { const result = await onSubmit(formValues); + if (result?.submitCancelled) { + return; + } + formikHelpers.resetForm({ values }); clearFormChange(formId); - const requiresReset = - mode === "edit" && !equal(initialValues.syncCatalog, values.syncCatalog) && !editSchemeMode; - - if (requiresReset) { - openResetDataModal(); - } - result?.onSubmitComplete?.(); } catch (e) { setSubmitError(e); } }, - [ - connection.operations, - workspace.workspaceId, - onSubmit, - clearFormChange, - formId, - mode, - initialValues.syncCatalog, - editSchemeMode, - openResetDataModal, - ] + [connection.operations, workspace.workspaceId, onSubmit, clearFormChange, formId] ); const errorMessage = submitError ? createFormErrorMessage(submitError) : null; @@ -362,7 +332,7 @@ const ConnectionForm: React.FC = ({ errorMessage={ errorMessage || !isValid ? formatMessage({ id: "connectionForm.validation.error" }) : null } - editSchemeMode={editSchemeMode} + enableControls={canSubmitUntouchedForm} /> )} {mode === "create" && ( diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/components/EditControls.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/components/EditControls.tsx index c8366c3d3dc9..8fb8b4d83eb9 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/components/EditControls.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/components/EditControls.tsx @@ -11,16 +11,10 @@ interface EditControlProps { resetForm: () => void; successMessage?: React.ReactNode; errorMessage?: React.ReactNode; - editSchemeMode?: boolean; + enableControls?: boolean; withLine?: boolean; } -const Warning = styled.div` - margin-bottom: 10px; - font-size: 12px; - font-weight: bold; -`; - const Buttons = styled.div` display: flex; justify-content: space-between; @@ -57,7 +51,7 @@ const EditControls: React.FC = ({ resetForm, successMessage, errorMessage, - editSchemeMode, + enableControls, withLine, }) => { const showStatusMessage = () => { @@ -72,28 +66,19 @@ const EditControls: React.FC = ({ return ( <> - {editSchemeMode && ( - - - - )} {withLine && }
{showStatusMessage()}
- - {editSchemeMode ? ( - - ) : ( - - )} +