diff --git a/airbyte-webapp/src/components/FormNavigationBlocker/index.tsx b/airbyte-webapp/src/components/FormNavigationBlocker/index.tsx new file mode 100644 index 000000000000..e0f5714555c5 --- /dev/null +++ b/airbyte-webapp/src/components/FormNavigationBlocker/index.tsx @@ -0,0 +1,25 @@ +import { useLayoutEffect, useMemo } from "react"; +import { uniqueId } from "lodash"; +import { useLocation } from "react-router-dom"; + +import { useBlockingFormsById } from "hooks/useFormNavigationBlocking"; + +interface Props { + block: boolean; +} + +const FormNavigationBlocker: React.FC = ({ block }) => { + const location = useLocation(); + const [blockingFormsById, setBlockingFormsById] = useBlockingFormsById(); + const id = useMemo(() => `${location.pathname}__${uniqueId("form_")}`, [location.pathname]); + + useLayoutEffect(() => { + if (!!blockingFormsById?.[id] !== block) { + setBlockingFormsById({ ...blockingFormsById, [id]: block }); + } + }, [id, block, setBlockingFormsById, blockingFormsById]); + + return null; +}; + +export default FormNavigationBlocker; diff --git a/airbyte-webapp/src/hooks/router/useBlocker.ts b/airbyte-webapp/src/hooks/router/useBlocker.ts new file mode 100644 index 000000000000..f17d1741db64 --- /dev/null +++ b/airbyte-webapp/src/hooks/router/useBlocker.ts @@ -0,0 +1,40 @@ +import type { Blocker, History, Transition } from "history"; + +import { ContextType, useContext, useEffect } from "react"; +import { Navigator as BaseNavigator, UNSAFE_NavigationContext as NavigationContext } from "react-router-dom"; + +interface Navigator extends BaseNavigator { + block: History["block"]; +} + +type NavigationContextWithBlock = ContextType & { navigator: Navigator }; + +/** + * @source https://github.com/remix-run/react-router/commit/256cad70d3fd4500b1abcfea66f3ee622fb90874 + */ +export const useBlocker = (blocker: Blocker, when = true) => { + const { navigator } = useContext(NavigationContext) as NavigationContextWithBlock; + + useEffect(() => { + if (!when) { + return; + } + + const unblock = navigator.block((tx: Transition) => { + const autoUnblockingTx = { + ...tx, + retry() { + // Automatically unblock the transition so it can play all the way + // through before retrying it. TODO: Figure out how to re-enable + // this block if the transition is cancelled for some reason. + unblock(); + tx.retry(); + }, + }; + + blocker(autoUnblockingTx); + }); + + return unblock; + }, [navigator, blocker, when]); +}; diff --git a/airbyte-webapp/src/hooks/router/usePrompt.ts b/airbyte-webapp/src/hooks/router/usePrompt.ts new file mode 100644 index 000000000000..e2640dc8336b --- /dev/null +++ b/airbyte-webapp/src/hooks/router/usePrompt.ts @@ -0,0 +1,35 @@ +import type { Transition } from "history"; + +import { useCallback } from "react"; + +import { useBlocker } from "./useBlocker"; + +/** + * @source https://github.com/remix-run/react-router/issues/8139#issuecomment-1021457943 + */ +export const usePrompt = ( + message: string | ((location: Transition["location"], action: Transition["action"]) => string), + when = true, + onConfirm?: () => void +) => { + const blocker = useCallback( + (tx: Transition) => { + let response; + if (typeof message === "function") { + response = message(tx.location, tx.action); + if (typeof response === "string") { + response = window.confirm(response); + } + } else { + response = window.confirm(message); + } + if (response) { + onConfirm?.(); + tx.retry(); + } + }, + [message, onConfirm] + ); + + return useBlocker(blocker, when); +}; diff --git a/airbyte-webapp/src/hooks/useFormNavigationBlocking.ts b/airbyte-webapp/src/hooks/useFormNavigationBlocking.ts new file mode 100644 index 000000000000..233457b7f2ab --- /dev/null +++ b/airbyte-webapp/src/hooks/useFormNavigationBlocking.ts @@ -0,0 +1,23 @@ +import { useCallback, useMemo } from "react"; +import { createGlobalState } from "react-use"; + +import { usePrompt } from "./router/usePrompt"; + +export const useBlockingFormsById = createGlobalState>({}); + +const useFormNavigationBlockingPrompt = () => { + const [blockingFormsById, setBlockingFormsById] = useBlockingFormsById(); + + const isFormBlocking = useMemo( + () => Object.values(blockingFormsById ?? {}).reduce((acc, value) => acc || value, false), + [blockingFormsById] + ); + + const onConfirm = useCallback(() => { + setBlockingFormsById({}); + }, [setBlockingFormsById]); + + usePrompt("Navigate to another page? Changes you made will not be saved.", isFormBlocking, onConfirm); +}; + +export default useFormNavigationBlockingPrompt; diff --git a/airbyte-webapp/src/pages/routes.tsx b/airbyte-webapp/src/pages/routes.tsx index dca0409b9b1b..0a69f98b248a 100644 --- a/airbyte-webapp/src/pages/routes.tsx +++ b/airbyte-webapp/src/pages/routes.tsx @@ -3,6 +3,7 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { useIntl } from "react-intl"; import { useEffectOnce } from "react-use"; +import useFormNavigationBlocking from "hooks/useFormNavigationBlocking"; import { useConfig } from "config"; import MainView from "views/layout/MainView"; import { CompleteOauthRequest } from "views/CompleteOauthRequest"; @@ -53,6 +54,8 @@ const useAddAnalyticsContextForWorkspace = (workspace: Workspace): void => { }; const MainViewRoutes: React.FC<{ workspace: Workspace }> = ({ workspace }) => { + useFormNavigationBlocking(); + return ( diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx index 0e0d63b1c6a1..9b9f061de4f1 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/ConnectionForm.tsx @@ -6,6 +6,7 @@ import { Field, FieldProps, Form, Formik } from "formik"; import { ControlLabels, DropDown, DropDownRow, H5, Input, Label } from "components"; import ResetDataModal from "components/ResetDataModal"; import { ModalTypes } from "components/ResetDataModal/types"; +import FormNavigationBlocker from "components/FormNavigationBlocker"; import { equal } from "utils/objects"; import { useCurrentWorkspace } from "services/workspaces/WorkspacesService"; @@ -153,6 +154,7 @@ const ConnectionForm: React.FC = ({ > {({ isSubmitting, setFieldValue, isValid, dirty, resetForm, values }) => ( +
}> {({ field, meta }: FieldProps) => ( diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/components/TransformationField.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/components/TransformationField.tsx index 1c3c512a1366..f3e1546cf23d 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/components/TransformationField.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/components/TransformationField.tsx @@ -29,6 +29,7 @@ const TransformationField: React.FC< {(editableItem) => ( setEditableItem(null)} onDone={(transformation) => { if (isDefined(editableItemIdx)) { diff --git a/airbyte-webapp/src/views/Connection/FormCard.tsx b/airbyte-webapp/src/views/Connection/FormCard.tsx index 95f3595fcf46..da723783e147 100644 --- a/airbyte-webapp/src/views/Connection/FormCard.tsx +++ b/airbyte-webapp/src/views/Connection/FormCard.tsx @@ -4,6 +4,8 @@ import { useMutation } from "react-query"; import { useIntl } from "react-intl"; import styled from "styled-components"; +import FormNavigationBlocker from "components/FormNavigationBlocker"; + import EditControls from "views/Connection/ConnectionForm/components/EditControls"; import { CollapsibleCardProps, CollapsibleCard } from "views/Connection/CollapsibleCard"; import { createFormErrorMessage } from "utils/errorStatusMessage"; @@ -32,6 +34,7 @@ export const FormCard: React.FC< {({ resetForm, isSubmitting, dirty, isValid }) => ( + {children}
void; onDone: (tr: Transformation) => void; + isNewTransformation?: boolean; } const validationSchema = yup.object({ @@ -77,7 +79,12 @@ function prepareLabelFields( // enum with only one value for the moment const TransformationTypes = [{ value: "custom", label: "Custom DBT" }]; -const TransformationForm: React.FC = ({ transformation, onCancel, onDone }) => { +const TransformationForm: React.FC = ({ + transformation, + onCancel, + onDone, + isNewTransformation, +}) => { const formatMessage = useIntl().formatMessage; const operationService = useGetService("OperationService"); @@ -89,9 +96,11 @@ const TransformationForm: React.FC = ({ transformation, onC onDone(values); }, }); + const { dirty } = useFormikContext(); return ( <> +