Skip to content

Commit

Permalink
Add navigation blocking when forms are dirty and not saved
Browse files Browse the repository at this point in the history
* Add hooks to block and display prompt on navigation
* Add components to indicate which form is blocking
* Update connection replication and transformation components to block on edit
* Update main page component to show prompt
  • Loading branch information
edmundito committed Apr 11, 2022
1 parent 06c902c commit a2db11c
Show file tree
Hide file tree
Showing 9 changed files with 143 additions and 2 deletions.
25 changes: 25 additions & 0 deletions airbyte-webapp/src/components/FormNavigationBlocker/index.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ 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;
40 changes: 40 additions & 0 deletions airbyte-webapp/src/hooks/router/useBlocker.ts
Original file line number Diff line number Diff line change
@@ -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<typeof NavigationContext> & { 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]);
};
35 changes: 35 additions & 0 deletions airbyte-webapp/src/hooks/router/usePrompt.ts
Original file line number Diff line number Diff line change
@@ -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);
};
23 changes: 23 additions & 0 deletions airbyte-webapp/src/hooks/useFormNavigationBlocking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useCallback, useMemo } from "react";
import { createGlobalState } from "react-use";

import { usePrompt } from "./router/usePrompt";

export const useBlockingFormsById = createGlobalState<Record<string, boolean>>({});

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;
3 changes: 3 additions & 0 deletions airbyte-webapp/src/pages/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -53,6 +54,8 @@ const useAddAnalyticsContextForWorkspace = (workspace: Workspace): void => {
};

const MainViewRoutes: React.FC<{ workspace: Workspace }> = ({ workspace }) => {
useFormNavigationBlocking();

return (
<MainView>
<TrackPageAnalytics />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -153,6 +154,7 @@ const ConnectionForm: React.FC<ConnectionFormProps> = ({
>
{({ isSubmitting, setFieldValue, isValid, dirty, resetForm, values }) => (
<FormContainer className={className}>
<FormNavigationBlocker block={dirty} />
<Section title={<FormattedMessage id="connection.transfer" />}>
<Field name="schedule">
{({ field, meta }: FieldProps<ScheduleProperties>) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const TransformationField: React.FC<
{(editableItem) => (
<TransformationForm
transformation={editableItem ?? defaultTransformation}
isNewTransformation={!editableItem}
onCancel={() => setEditableItem(null)}
onDone={(transformation) => {
if (isDefined(editableItemIdx)) {
Expand Down
3 changes: 3 additions & 0 deletions airbyte-webapp/src/views/Connection/FormCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -32,6 +34,7 @@ export const FormCard: React.FC<
{({ resetForm, isSubmitting, dirty, isValid }) => (
<CollapsibleCard {...props}>
<FormContainer>
<FormNavigationBlocker block={dirty} />
{children}
<div>
<EditControls
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import React from "react";
import styled from "styled-components";
import { FormattedMessage, useIntl } from "react-intl";
import * as yup from "yup";
import { getIn, useFormik } from "formik";
import { getIn, useFormik, useFormikContext } from "formik";

import { Button, ControlLabels, DropDown, Input } from "components";
import FormNavigationBlocker from "components/FormNavigationBlocker";

import { equal } from "utils/objects";
import { Transformation } from "core/domain/connection/operation";
Expand Down Expand Up @@ -44,6 +45,7 @@ interface TransformationProps {
transformation: Transformation;
onCancel: () => void;
onDone: (tr: Transformation) => void;
isNewTransformation?: boolean;
}

const validationSchema = yup.object({
Expand Down Expand Up @@ -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<TransformationProps> = ({ transformation, onCancel, onDone }) => {
const TransformationForm: React.FC<TransformationProps> = ({
transformation,
onCancel,
onDone,
isNewTransformation,
}) => {
const formatMessage = useIntl().formatMessage;
const operationService = useGetService<OperationService>("OperationService");

Expand All @@ -89,9 +96,11 @@ const TransformationForm: React.FC<TransformationProps> = ({ transformation, onC
onDone(values);
},
});
const { dirty } = useFormikContext();

return (
<>
<FormNavigationBlocker block={isNewTransformation || dirty} />
<Content>
<Column>
<Label
Expand Down

0 comments on commit a2db11c

Please sign in to comment.