-
Notifications
You must be signed in to change notification settings - Fork 4.1k
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
Conversation
82a370d
to
fba94d8
Compare
@airbytehq/frontend I'm pretty happy where this is at and it's ready for review. |
e59af21
to
86acc12
Compare
@@ -0,0 +1 @@ | |||
export { ConfirmationModal } from "./ConfirmationModal"; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
confirmationModalService.closeConfirmationModal(); | ||
} | ||
}; | ||
// eslint-disable-next-line |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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:
if (!!changedFormsById?.[id] !== changed) { | |
if (Boolean(changedFormsById?.[id]) !== changed) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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"; |
There was a problem hiding this comment.
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; | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
if (formikHelpers) { | ||
formikHelpers.resetForm({ values }); | ||
} |
There was a problem hiding this comment.
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
if (formikHelpers) { | |
formikHelpers.resetForm({ values }); | |
} | |
formikHelpers?.resetForm({ values }); |
@@ -0,0 +1,3 @@ | |||
import { FormikConfig } from "formik"; | |||
|
|||
export type FormikOnSubmit<T> = FormikConfig<T>["onSubmit"]; |
There was a problem hiding this comment.
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. 🤷
There was a problem hiding this comment.
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
86acc12
to
53fdc84
Compare
<ButtonWithMargin onClick={onClose} type="button" secondary> | ||
<FormattedMessage id="form.cancel" /> | ||
</ButtonWithMargin> | ||
<Button type="button" danger onClick={onSubmit} data-id="delete"> |
There was a problem hiding this comment.
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.
return { | ||
openConfirmationModal: confirmationModalService.openConfirmationModal, | ||
closeConfirmationModal: confirmationModalService.closeConfirmationModal, | ||
}; |
There was a problem hiding this comment.
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 }); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
- Form is initialized with loaded values
- User edits Form, and the initial values no longer match the current values so the form is "dirty"
- User submits form and request is successful
- Form is reset with updated values, thus longer "dirty"
|
||
const { trackFormChange } = useFormChangeTrackerService(); | ||
|
||
useLayoutEffect(() => { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
const nextFormValues: typeof values = {}; | ||
if (values.transformations) { | ||
nextFormValues.transformations = getInitialTransformations(operations); | ||
} | ||
if (values.normalization) { | ||
nextFormValues.normalization = getInitialNormalization(operations, true); | ||
} | ||
|
||
resetForm({ values: nextFormValues }); |
There was a problem hiding this comment.
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_")}`, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this 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.
airbyte-webapp/src/hooks/services/FormChangeTracker/FormChangeTrackerService.tsx
Outdated
Show resolved
Hide resolved
Co-authored-by: Tim Roes <[email protected]>
…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 <[email protected]> Co-authored-by: Tim Roes <[email protected]>
What
Closes #11164 Display a confirmation modal when the user navigates away from the Replication or Transformation pages with unsaved changes.
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.
This bridges the gap between
Formik
andreact-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