Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the ability to block navigation when forms have not been saved #11831

Merged
merged 14 commits into from
Apr 20, 2022

Conversation

edmundito
Copy link
Contributor

@edmundito edmundito commented Apr 8, 2022

What

Closes #11164 Display a confirmation modal when the user navigates away from the Replication or Transformation pages with unsaved changes.

image

How

The component uses a global state in a hook to track each form that may need to confirm changes. It's designed to also track multiple forms on the same page but only provide one confirmation.

<MyForm>
  <FormChangesTracker changed={dirty || new} />
</MyForm>

This bridges the gap between Formik and react-router in that introduces a component to track which forms have changed so that it can display the confirmation.

react-router used to provide ways to display blockers but these were removed in 6.x (remix-run/react-router#8139). This is using hooks inspired by the changes that existed before (remix-run/react-router#8139 (comment)).

Recommended reading order

Top to bottom

🚨 User Impact 🚨

Are there any breaking changes? What is the end result perceived by the user? If yes, please merge this PR with the 🚨🚨 emoji so changelog authors can further highlight this if needed.

Tests

Webapp E2E tests pass

┆Issue is synchronized with this Monday item by Unito

@CLAassistant
Copy link

CLAassistant commented Apr 8, 2022

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions bot added area/frontend area/platform issues related to the platform labels Apr 8, 2022
@edmundito edmundito marked this pull request as ready for review April 13, 2022 20:01
@edmundito edmundito requested a review from a team as a code owner April 13, 2022 20:01
@edmundito
Copy link
Contributor Author

@airbytehq/frontend I'm pretty happy where this is at and it's ready for review.

@@ -0,0 +1 @@
export { ConfirmationModal } from "./ConfirmationModal";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tim has specifically called out to stop using this pattern. We should be exporting thing and then import { thing } where needed.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the confusion I apparently created there. I don't think this is an anti pattern. I was mainly calling out default imports, but named re-exporting from a file and having index.ts files, would be my idea how we should export.

@@ -0,0 +1,3 @@
import FormChangeTracker from "./FormChangeTracker";

export default FormChangeTracker;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See last comment.

const { navigator } = useContext(NavigationContext) as NavigationContextWithBlock;

useEffect(() => {
if (!when) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this could be named a little more specifically.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe so. this is how it was originally designed in react-router but I'll do another pass to give it a better name.

airbyte-webapp/src/hooks/router/useBlocker.ts Show resolved Hide resolved
confirmationModalService.closeConfirmationModal();
}
};
// eslint-disable-next-line
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is needed.


const trackFormChange = useCallback(
(id: string, changed: boolean) => {
if (!!changedFormsById?.[id] !== changed) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick, this feels slightly clearer:

Suggested change
if (!!changedFormsById?.[id] !== changed) {
if (Boolean(changedFormsById?.[id]) !== changed) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This casts the value correctly?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I specified it was a nitpick as !!changedFormsById?.[id] !== changed looks a little strange in an if-statement.
I know it functions correctly but, and perhaps this is a personal thing, I could see this reading a little confusingly as !!(changedFormsById?.[id] !== changed)

@@ -0,0 +1,2 @@
export * from "./FormChangeTrackerService";
export * from "./hooks";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See other export comments

trackFormChange: (id: string, changed: boolean) => void;
clearFormChange: (id: string) => void;
clearAllFormChanges: () => void;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to get @timroes opinion on this pattern.
I'm not the biggest fan and would prefer to place in the component or service, but content to fall either way.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have anything against this pattern. No strong feelings in either direction. Advantage of pulling it into a separate type file like this (as long as we need to export the type), is that we'd potentially prevent some cyclic dependency further down the road, in which case we'd then need to extract it into a separate type file.

Comment on lines 96 to 98
if (formikHelpers) {
formikHelpers.resetForm({ values });
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick since you do this elsewhere

Suggested change
if (formikHelpers) {
formikHelpers.resetForm({ values });
}
formikHelpers?.resetForm({ values });

@@ -0,0 +1,3 @@
import { FormikConfig } from "formik";

export type FormikOnSubmit<T> = FormikConfig<T>["onSubmit"];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be tempted to just put this into TransformationView.tsx since that's the only place it's used, just to cut down on files. 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty generic and may be needed later. I rather make it available to all now than to hide it inside a component.

* 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
useFormNavigationBlocking -> useDiscardFormChangesConfirmation
Update exports from default to module
Fix forms in replications and transformations not resetting the data
Add types to import sorting and add Formik helper type for onSubmit functions
Update ConnectionForm submission to handle post submission result from callback
Add hook to generate unique form id
Update form tracker component to use new hook if no id is specified
<ButtonWithMargin onClick={onClose} type="button" secondary>
<FormattedMessage id="form.cancel" />
</ButtonWithMargin>
<Button type="button" danger onClick={onSubmit} data-id="delete">
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the data-id for something specific or just for testing purposes? If the latter, I'd suggest we name it data-testid to stay aligned with what our testing frameworks expect.

Comment on lines 32 to 35
return {
openConfirmationModal: confirmationModalService.openConfirmationModal,
closeConfirmationModal: confirmationModalService.closeConfirmationModal,
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should memoize this object. While confirmationModalService.openConfirmModal and confirmationModalService.closeConfirmationModal are stable references and should not change, we're creating a new object here every time, causing the return value of useConfirmationModalService to not be stable, and thus if we're using it (without destructuring) it might cause other places to suddenly to become unstable too, e.g.:

const confirmationModal = useConfirmationModalService();

const fn = useCallback(() => {
  /* do something with a confirmation modal */
}, [confirmationModal]);

This will now cause the useCallback to create a new return value every render, while just changing the code slightly:

const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService();

const fn = useCallback(() => {
  /* do something with a confirmation modal */
}, [openConfirmationModal, closeConfirmationModal]);

This would now make the useCallback to always stay the same reference. I think that behavior might be confusing, thus I'd recommend we're also memoizing the whole object we return here from useConfirmationModalService.

@@ -91,6 +92,8 @@ const ReplicationView: React.FC<IProps> = ({ onAfterSaveSchema, connectionId })
if (activeUpdatingSchemaMode) {
setActiveUpdatingSchemaMode(false);
}

formikHelpers?.resetForm({ values });
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Could you clarify why we're needing to reset the form here now?

Copy link
Contributor Author

@edmundito edmundito Apr 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This resetForm({ values }) updates the Formik form with the new base values so that the form is no longer marked as "dirty" during an update. The confirmation modal works based on the dirtiness of the Formik form.

Here's the sequence of events:

  1. Form is initialized with loaded values
  2. User edits Form, and the initial values no longer match the current values so the form is "dirty"
  3. User submits form and request is successful
  4. Form is reset with updated values, thus longer "dirty"


const { trackFormChange } = useFormChangeTrackerService();

useLayoutEffect(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Is there a reason for using useLayoutEffect instead useEffect here? If so (since React also recommends using useEffect whenever possible), could we leave an inline comment why we're needing to use useLayoutEffect here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will change to useEffect. In earlier iterations I was testing different things and I forgot to change it back.

Comment on lines +131 to +139
const nextFormValues: typeof values = {};
if (values.transformations) {
nextFormValues.transformations = getInitialTransformations(operations);
}
if (values.normalization) {
nextFormValues.normalization = getInitialNormalization(operations, true);
}

resetForm({ values: nextFormValues });
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question here as above: Why are we resetting this form after updating? Maybe we could leave an inline comment why this is needed.

export const useUniqueFormId = (formId?: string) => {
const location = useLocation();
return useMemo(
() => formId ?? `${location.pathname.toLowerCase().substring(1).replace(/\//gi, "_")}__${uniqueId("form_")}`,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: do we need all that (in my personal opinion a bit harder to read) location logic? Since we're anyway generating unique ids here, is there still a use-case of prefixing them with the location path?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need it, it was just easier to debug the form data this way. However, now that it's working I can remove it.

Update FormChangeTracker to useEffect over useLayoutEffect
Memoize useConfirmationModalService hook
Remove memoization of ConfirmationModalService provider
Simplify useUniqueFormId
@edmundito edmundito requested a review from timroes April 19, 2022 18:06
Copy link
Collaborator

@timroes timroes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested locally. Seems to work and block correctly as expected. Reviewed code, LGTM. Minor code style comment left.

Co-authored-by: Tim Roes <tim@airbyte.io>
@edmundito edmundito merged commit e1d0525 into master Apr 20, 2022
@edmundito edmundito deleted the form-nav-blocker branch April 20, 2022 13:36
suhomud pushed a commit that referenced this pull request May 23, 2022
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area/platform issues related to the platform
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Display popup about loosing changes when user navigates between tabs in Connection Form
4 participants