diff --git a/airbyte-webapp/src/components/CreateConnectionContent/CreateConnectionContent.tsx b/airbyte-webapp/src/components/CreateConnectionContent/CreateConnectionContent.tsx index 83221396a6f1..6c2108711a77 100644 --- a/airbyte-webapp/src/components/CreateConnectionContent/CreateConnectionContent.tsx +++ b/airbyte-webapp/src/components/CreateConnectionContent/CreateConnectionContent.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useMemo, useState } from "react"; +import React, { Suspense, useMemo } from "react"; import styled from "styled-components"; import { ContentCard } from "components"; @@ -12,7 +12,6 @@ import { useAnalyticsService } from "hooks/services/Analytics"; import { useCreateConnection, ValuesProps } from "hooks/services/useConnectionHook"; import ConnectionForm from "views/Connection/ConnectionForm"; import { ConnectionFormProps } from "views/Connection/ConnectionForm/ConnectionForm"; -import { FormikConnectionFormValues } from "views/Connection/ConnectionForm/formConfig"; import { DestinationRead, SourceRead, WebBackendConnectionRead } from "../../core/request/AirbyteClient"; import { useDiscoverSchema } from "../../hooks/services/useSourceHook"; @@ -45,21 +44,14 @@ const CreateConnectionContent: React.FC = ({ const { schema, isLoading, schemaErrorStatus, catalogId, onDiscoverSchema } = useDiscoverSchema(source.sourceId); - const [connectionFormValues, setConnectionFormValues] = useState(); - const connection = useMemo( () => ({ - name: connectionFormValues?.name ?? "", - namespaceDefinition: connectionFormValues?.namespaceDefinition, - namespaceFormat: connectionFormValues?.namespaceFormat, - prefix: connectionFormValues?.prefix, - schedule: connectionFormValues?.schedule ?? undefined, syncCatalog: schema, destination, source, catalogId, }), - [connectionFormValues, schema, destination, source, catalogId] + [schema, destination, source, catalogId] ); const onSubmitConnectionStep = async (values: ValuesProps) => { @@ -124,7 +116,6 @@ const CreateConnectionContent: React.FC = ({ additionBottomControls={additionBottomControls} onDropDownSelect={onSelectFrequency} onSubmit={onSubmitConnectionStep} - onChangeValues={setConnectionFormValues} /> ); diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json index a544ac98307c..eb5e4b1192d7 100644 --- a/airbyte-webapp/src/locales/en.json +++ b/airbyte-webapp/src/locales/en.json @@ -330,6 +330,9 @@ "connection.sourceTestAgain": "Test source connection again", "connection.resetData": "Reset your data", "connection.updateSchema": "Refresh source schema", + "connection.updateSchema.formChanged.title": "Unsaved changes", + "connection.updateSchema.formChanged.text": "Your replication settings have unsaved changes. Those will be lost when refreshing the source schema. Save your changes before refreshing the source schema to not lose them.", + "connection.updateSchema.formChanged.confirm": "Discard changes and refresh schema", "connection.updateSchema.completed": "Refreshed source schema", "connection.updateSchema.confirm": "Confirm", "connection.updateSchema.new": "{value} new {item}", 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 0fd5827bb330..367433ef0ece 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/ReplicationView.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/components/ReplicationView.tsx @@ -1,6 +1,6 @@ import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React, { useMemo, useState } from "react"; +import React, { useCallback, useRef, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useAsyncFn, useUnmount } from "react-use"; import styled from "styled-components"; @@ -10,6 +10,7 @@ import LoadingSchema from "components/LoadingSchema"; import { toWebBackendConnectionUpdate } from "core/domain/connection"; import { ConnectionStateType, ConnectionStatus } from "core/request/AirbyteClient"; +import { useConfirmationModalService } from "hooks/services/ConfirmationModal"; import { useModalService } from "hooks/services/Modal"; import { useConnectionLoad, @@ -21,7 +22,6 @@ import { equal } from "utils/objects"; import { CatalogDiffModal } from "views/Connection/CatalogDiffModal/CatalogDiffModal"; import ConnectionForm from "views/Connection/ConnectionForm"; import { ConnectionFormSubmitResult } from "views/Connection/ConnectionForm/ConnectionForm"; -import { FormikConnectionFormValues } from "views/Connection/ConnectionForm/formConfig"; interface ReplicationViewProps { onAfterSaveSchema: () => void; @@ -84,9 +84,10 @@ const TryArrow = styled(FontAwesomeIcon)` export const ReplicationView: React.FC = ({ onAfterSaveSchema, connectionId }) => { const { formatMessage } = useIntl(); const { openModal, closeModal } = useModalService(); + const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService(); + const connectionFormDirtyRef = useRef(false); const [activeUpdatingSchemaMode, setActiveUpdatingSchemaMode] = useState(false); const [saved, setSaved] = useState(false); - const [connectionFormValues, setConnectionFormValues] = useState(); const connectionService = useConnectionService(); const { mutateAsync: updateConnection } = useUpdateConnection(); @@ -97,28 +98,19 @@ 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: - // 1. if there is a namespace definition, format, prefix, or schedule in connectionFormValues, - // use those and fill in the rest from the database - // 2. otherwise, use the values from the database - // 3. if none of the above, use the default values. - return { - ...connectionWithRefreshCatalog, - namespaceDefinition: - connectionFormValues?.namespaceDefinition ?? connectionWithRefreshCatalog.namespaceDefinition, - namespaceFormat: connectionFormValues?.namespaceFormat ?? connectionWithRefreshCatalog.namespaceFormat, - prefix: connectionFormValues?.prefix ?? connectionWithRefreshCatalog.prefix, - schedule: connectionFormValues?.schedule ?? connectionWithRefreshCatalog.schedule, - }; - } - return initialConnection; - }, [activeUpdatingSchemaMode, connectionWithRefreshCatalog, initialConnection, connectionFormValues]); + 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); @@ -174,7 +166,7 @@ export const ReplicationView: React.FC = ({ onAfterSaveSch } }; - const onRefreshSourceSchema = async () => { + const refreshSourceSchema = async () => { setSaved(false); setActiveUpdatingSchemaMode(true); const { catalogDiff, syncCatalog } = await refreshCatalog(); @@ -189,11 +181,33 @@ export const ReplicationView: React.FC = ({ onAfterSaveSch } }; + const onRefreshSourceSchema = async () => { + if (connectionFormDirtyRef.current) { + // 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(); + } + }; + const onCancelConnectionFormEdit = () => { setSaved(false); setActiveUpdatingSchemaMode(false); }; + const onDirtyChanges = useCallback((dirty: boolean) => { + connectionFormDirtyRef.current = dirty; + }, []); + return ( {!isRefreshingCatalog && connection ? ( @@ -210,7 +224,7 @@ export const ReplicationView: React.FC = ({ onAfterSaveSch } - onChangeValues={setConnectionFormValues} + onFormDirtyChanges={onDirtyChanges} /> ) : ( diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx index d8ecdc15d004..9fa2eee3e5a5 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx @@ -1,8 +1,7 @@ -import { Field, FieldProps, Form, Formik, FormikHelpers, useFormikContext } from "formik"; -import React, { useCallback, useState } from "react"; +import { Field, FieldProps, Form, Formik, FormikHelpers } from "formik"; +import React, { useCallback, useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useToggle } from "react-use"; -import { useDebounce } from "react-use"; import styled from "styled-components"; import { Card, ControlLabels, DropDown, DropDownRow, H5, Input } from "components"; @@ -102,20 +101,18 @@ export interface ConnectionFormSubmitResult { export type ConnectionFormMode = "create" | "edit" | "readonly"; -// eslint-disable-next-line react/function-component-definition -function FormValuesChangeTracker({ onChangeValues }: { onChangeValues?: (values: T) => void }) { - // Grab values from context - const { values } = useFormikContext(); - useDebounce( - () => { - onChangeValues?.(values); - }, - 200, - [values, onChangeValues] - ); - return null; +interface DirtyChangeTrackerProps { + dirty: boolean; + onChanges: (dirty: boolean) => void; } +const DirtyChangeTracker: React.FC = ({ dirty, onChanges }) => { + useEffect(() => { + onChanges(dirty); + }, [dirty, onChanges]); + return null; +}; + interface ConnectionFormProps { onSubmit: (values: ConnectionFormValues) => Promise; className?: string; @@ -123,7 +120,7 @@ interface ConnectionFormProps { successMessage?: React.ReactNode; onDropDownSelect?: (item: DropDownRow.IDataItem) => void; onCancel?: () => void; - onChangeValues?: (values: FormikConnectionFormValues) => void; + onFormDirtyChanges?: (dirty: boolean) => void; /** Should be passed when connection is updated with withRefreshCatalog flag */ canSubmitUntouchedForm?: boolean; @@ -146,7 +143,7 @@ const ConnectionForm: React.FC = ({ canSubmitUntouchedForm, additionalSchemaControl, connection, - onChangeValues, + onFormDirtyChanges, }) => { const destDefinition = useGetDestinationDefinitionSpecification(connection.destination.destinationDefinitionId); const { clearFormChange } = useFormChangeTrackerService(); @@ -200,7 +197,7 @@ const ConnectionForm: React.FC = ({ {({ isSubmitting, setFieldValue, isValid, dirty, resetForm, values }) => ( - + {onFormDirtyChanges && } {!isEditMode && (
diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx index 2852f086004e..2e6f6e3e568f 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx @@ -234,7 +234,7 @@ const useInitialValues = ( const initialValues: FormikConnectionFormValues = { name: connection.name ?? `${connection.source.name} <> ${connection.destination.name}`, syncCatalog: initialSchema, - schedule: connection.connectionId || connection.schedule ? connection.schedule ?? null : DEFAULT_SCHEDULE, + schedule: connection.connectionId ? connection.schedule ?? null : DEFAULT_SCHEDULE, prefix: connection.prefix || "", namespaceDefinition: connection.namespaceDefinition || NamespaceDefinitionType.source, namespaceFormat: connection.namespaceFormat ?? SOURCE_NAMESPACE_TAG,