Skip to content

Commit

Permalink
Add the ability to block navigation when forms have not been saved (#…
Browse files Browse the repository at this point in the history
…11831)

* Add navigation blocking when forms are dirty and not saved

* 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

* Add Confimration Modal and Service and update form blocking to use modal

* FormNavigationBlocker -> FormChangesTracker
useFormNavigationBlocking -> useDiscardFormChangesConfirmation
Update exports from default to module

* Add confirmation modal text to i18n file

* Used formatted title in ConfirmationModal component

* Move Form change tracking to a service pattern
Fix forms in replications and transformations not resetting the data
Add types to import sorting and add Formik helper type for onSubmit functions

* Clear all form data after submission in ConnectionForm component
Update ConnectionForm submission to handle post submission result from callback

* ConnectionForm now clears its own form tracking
Add hook to generate unique form id
Update form tracker component to use new hook if no id is specified

* Adjust margins on ConfirmationModal to more closely match design

* Update useUniqueFormId hook id generation to exclude trailing slash

* Cleanup code based on pull request review

* Remove uneeded dependency spread in useConfirmationModalService hook

* Remove data-id from confirmation modal component
Update FormChangeTracker to useEffect over useLayoutEffect
Memoize useConfirmationModalService hook
Remove memoization of ConfirmationModalService provider
Simplify useUniqueFormId

* Update FormTrackerService check from reduce to some

Co-authored-by: Tim Roes <tim@airbyte.io>

Co-authored-by: Tim Roes <tim@airbyte.io>
  • Loading branch information
edmundito and timroes authored Apr 20, 2022
1 parent a90be03 commit e1d0525
Show file tree
Hide file tree
Showing 25 changed files with 418 additions and 26 deletions.
2 changes: 1 addition & 1 deletion airbyte-webapp/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"group": "internal"
},
{
"pattern": "+(config|core|hooks|locales|packages|pages|services|utils|views){/**,}",
"pattern": "+(config|core|hooks|locales|packages|pages|services|types|utils|views){/**,}",
"group": "internal",
"position": "after"
}
Expand Down
8 changes: 7 additions & 1 deletion airbyte-webapp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { FeatureService } from "hooks/services/Feature";
import { ServicesProvider } from "core/servicesProvider";
import { ApiServices } from "core/ApiServices";
import { StoreProvider } from "views/common/StoreProvider";
import { ConfirmationModalService } from "hooks/services/ConfirmationModal";
import { FormChangeTrackerService } from "hooks/services/FormChangeTracker";

import en from "./locales/en.json";
import GlobalStyle from "./global-styles";
Expand Down Expand Up @@ -53,7 +55,11 @@ const Services: React.FC = ({ children }) => (
<WorkspaceServiceProvider>
<FeatureService>
<NotificationService>
<ApiServices>{children}</ApiServices>
<ConfirmationModalService>
<FormChangeTrackerService>
<ApiServices>{children}</ApiServices>
</FormChangeTrackerService>
</ConfirmationModalService>
</NotificationService>
</FeatureService>
</WorkspaceServiceProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from "react";
import styled from "styled-components";
import { FormattedMessage } from "react-intl";

import Modal from "components/Modal";
import { Button } from "components/base/Button";

const Content = styled.div`
width: 585px;
font-size: 14px;
line-height: 28px;
padding: 25px;
white-space: pre-line;
`;

const ButtonContent = styled.div`
margin-top: 26px;
display: flex;
justify-content: flex-end;
`;

const ButtonWithMargin = styled(Button)`
margin-right: 12px;
`;

export interface ConfirmationModalProps {
onClose: () => void;
title: string;
text: string;
submitButtonText: string;
onSubmit: () => void;
}

export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
onClose,
title,
text,
onSubmit,
submitButtonText,
}) => (
<Modal onClose={onClose} title={<FormattedMessage id={title} />}>
<Content>
<FormattedMessage id={text} />
<ButtonContent>
<ButtonWithMargin onClick={onClose} type="button" secondary>
<FormattedMessage id="form.cancel" />
</ButtonWithMargin>
<Button type="button" danger onClick={onSubmit}>
<FormattedMessage id={submitButtonText} />
</Button>
</ButtonContent>
</Content>
</Modal>
);
1 change: 1 addition & 0 deletions airbyte-webapp/src/components/ConfirmationModal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ConfirmationModal } from "./ConfirmationModal";
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ const CreateConnectionContent: React.FC<IProps> = ({
},
});

if (afterSubmitConnection) {
afterSubmitConnection(connection);
}
return {
onSubmitComplete: () => {
afterSubmitConnection?.(connection);
},
};
};

const onSelectFrequency = (item: IDataItem | null) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useEffect } from "react";
import { usePrevious } from "react-use";

import { useFormChangeTrackerService, useUniqueFormId } from "hooks/services/FormChangeTracker";

interface Props {
changed: boolean;
formId?: string;
}

export const FormChangeTracker: React.FC<Props> = ({ changed, formId }) => {
const id = useUniqueFormId(formId);
const prevChanged = usePrevious(changed);

const { trackFormChange } = useFormChangeTrackerService();

useEffect(() => {
if (changed !== prevChanged) {
trackFormChange(id, changed);
}
}, [id, changed, trackFormChange, prevChanged]);

return null;
};
1 change: 1 addition & 0 deletions airbyte-webapp/src/components/FormChangeTracker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FormChangeTracker } from "./FormChangeTracker";
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, block = true) => {
const { navigator } = useContext(NavigationContext) as NavigationContextWithBlock;

useEffect(() => {
if (!block) {
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, block]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { useContext, useEffect, useMemo } from "react";

import { ConfirmationModal } from "components/ConfirmationModal";

import useTypesafeReducer from "hooks/useTypesafeReducer";

import { ConfirmationModalOptions, ConfirmationModalServiceApi, ConfirmationModalState } from "./types";
import { actions, initialState, confirmationModalServiceReducer } from "./reducer";

const ConfirmationModalServiceContext = React.createContext<ConfirmationModalServiceApi | undefined>(undefined);

export const useConfirmationModalService: (confirmationModal?: ConfirmationModalOptions) => {
openConfirmationModal: (confirmationModal: ConfirmationModalOptions) => void;
closeConfirmationModal: () => void;
} = (confirmationModal) => {
const confirmationModalService = useContext(ConfirmationModalServiceContext);
if (!confirmationModalService) {
throw new Error("useConfirmationModalService must be used within a ConfirmationModalService.");
}

useEffect(() => {
if (confirmationModal) {
confirmationModalService.openConfirmationModal(confirmationModal);
}
return () => {
if (confirmationModal) {
confirmationModalService.closeConfirmationModal();
}
};
}, [confirmationModal, confirmationModalService]);

return useMemo(
() => ({
openConfirmationModal: confirmationModalService.openConfirmationModal,
closeConfirmationModal: confirmationModalService.closeConfirmationModal,
}),
[confirmationModalService]
);
};

export const ConfirmationModalService = ({ children }: { children: React.ReactNode }) => {
const [state, { openConfirmationModal, closeConfirmationModal }] = useTypesafeReducer<
ConfirmationModalState,
typeof actions
>(confirmationModalServiceReducer, initialState, actions);

const confirmationModalService: ConfirmationModalServiceApi = useMemo(
() => ({
openConfirmationModal,
closeConfirmationModal,
}),
[closeConfirmationModal, openConfirmationModal]
);

return (
<>
<ConfirmationModalServiceContext.Provider value={confirmationModalService}>
{children}
</ConfirmationModalServiceContext.Provider>
{state.isOpen && state.confirmationModal ? (
<ConfirmationModal
onClose={closeConfirmationModal}
title={state.confirmationModal.title}
text={state.confirmationModal.text}
onSubmit={state.confirmationModal.onSubmit}
submitButtonText={state.confirmationModal.submitButtonText}
/>
) : null}
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./ConfirmationModalService";
31 changes: 31 additions & 0 deletions airbyte-webapp/src/hooks/services/ConfirmationModal/reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ActionType, createAction, createReducer } from "typesafe-actions";

import { ConfirmationModalOptions, ConfirmationModalState } from "./types";

export const actions = {
openConfirmationModal: createAction("OPEN_CONFIRMATION_MODAL")<ConfirmationModalOptions>(),
closeConfirmationModal: createAction("CLOSE_CONFIRMATION_MODAL")(),
};

type Actions = ActionType<typeof actions>;

export const initialState: ConfirmationModalState = {
isOpen: false,
confirmationModal: null,
};

export const confirmationModalServiceReducer = createReducer<ConfirmationModalState, Actions>(initialState)
.handleAction(actions.openConfirmationModal, (state, action): ConfirmationModalState => {
return {
...state,
isOpen: true,
confirmationModal: action.payload,
};
})
.handleAction(actions.closeConfirmationModal, (state): ConfirmationModalState => {
return {
...state,
isOpen: false,
confirmationModal: null,
};
});
13 changes: 13 additions & 0 deletions airbyte-webapp/src/hooks/services/ConfirmationModal/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ConfirmationModalProps } from "components/ConfirmationModal/ConfirmationModal";

export type ConfirmationModalOptions = Omit<ConfirmationModalProps, "onClose">;

export interface ConfirmationModalServiceApi {
openConfirmationModal: (confirmationModal: ConfirmationModalOptions) => void;
closeConfirmationModal: () => void;
}

export interface ConfirmationModalState {
isOpen: boolean;
confirmationModal: ConfirmationModalOptions | null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Transition } from "history";

import React, { useCallback, useMemo } from "react";

import { useBlocker } from "hooks/router/useBlocker";

import { useConfirmationModalService } from "../ConfirmationModal";
import { useChangedFormsById } from "./hooks";

export const FormChangeTrackerService: React.FC = ({ children }) => {
const [changedFormsById, setChangedFormsById] = useChangedFormsById();
const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService();

const blocker = useCallback(
(tx: Transition) => {
openConfirmationModal({
title: "form.discardChanges",
text: "form.discardChangesConfirmation",
submitButtonText: "form.discardChanges",
onSubmit: () => {
setChangedFormsById({});
closeConfirmationModal();
tx.retry();
},
});
},
[closeConfirmationModal, openConfirmationModal, setChangedFormsById]
);

const formsChanged = useMemo(
() => Object.values(changedFormsById ?? {}).some((formChanged) => formChanged),
[changedFormsById]
);

useBlocker(blocker, formsChanged);

return <>{children}</>;
};
39 changes: 39 additions & 0 deletions airbyte-webapp/src/hooks/services/FormChangeTracker/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useCallback, useMemo } from "react";
import { createGlobalState } from "react-use";
import { uniqueId } from "lodash";

import { FormChangeTrackerServiceApi } from "./types";

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

export const useUniqueFormId = (formId?: string) => useMemo(() => formId ?? uniqueId("form_"), [formId]);

export const useFormChangeTrackerService = (): FormChangeTrackerServiceApi => {
const [changedFormsById, setChangedFormsById] = useChangedFormsById();

const clearAllFormChanges = useCallback(() => {
setChangedFormsById({});
}, [setChangedFormsById]);

const clearFormChange = useCallback(
(id: string) => {
setChangedFormsById({ ...changedFormsById, [id]: false });
},
[changedFormsById, setChangedFormsById]
);

const trackFormChange = useCallback(
(id: string, changed: boolean) => {
if (Boolean(changedFormsById?.[id]) !== changed) {
setChangedFormsById({ ...changedFormsById, [id]: changed });
}
},
[changedFormsById, setChangedFormsById]
);

return {
trackFormChange,
clearFormChange,
clearAllFormChanges,
};
};
2 changes: 2 additions & 0 deletions airbyte-webapp/src/hooks/services/FormChangeTracker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./FormChangeTrackerService";
export * from "./hooks";
5 changes: 5 additions & 0 deletions airbyte-webapp/src/hooks/services/FormChangeTracker/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface FormChangeTrackerServiceApi {
trackFormChange: (id: string, changed: boolean) => void;
clearFormChange: (id: string) => void;
clearAllFormChanges: () => void;
}
1 change: 1 addition & 0 deletions airbyte-webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"form.sourceRetest": "Retest source",
"form.destinationRetest": "Retest destination",
"form.discardChanges": "Discard changes",
"form.discardChangesConfirmation": "There are unsaved changes. Are you sure you want to discard your changes?",
"form.every": "Every {value}",
"form.testingConnection": "Testing connection...",
"form.successTests": "All connection tests passed!",
Expand Down
Loading

0 comments on commit e1d0525

Please sign in to comment.