-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat: LEAP-1198: Add labeling unsaved changes warning #6100
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
Merged
Merged
Changes from 10 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
b1d489c
feat: LEAP-1198: Add labeling unsaved changes warning
Gondragos de6dc52
Add missed change and biome fixes.
Gondragos cfe90e7
Fix saving config from modal
Gondragos e95f28a
Missed import
Gondragos caf9fac
Update API provider
Gondragos 12307cb
Add FFs, comments, micro fixes
Gondragos 7d006f6
Fallback for AsyncPage popstate reaction on blocking history
Gondragos 38e1be7
Use ff for new changes
Gondragos ebbce80
Fix ff key
Gondragos d48bc24
Fix ff key
Gondragos 93d379e
Merge branch 'refs/heads/develop' into fb-leap-1198/unsaved-change
Gondragos 466cf3b
Add comment
Gondragos 2337dd8
Merge branch 'develop' into 'fb-leap-1198/unsaved-change'
Gondragos abd203f
Avoid modal re-rendering as it may cause unmounting and losing control
Gondragos File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
128 changes: 128 additions & 0 deletions
128
web/apps/labelstudio/src/components/LeaveBlocker/LeaveBlocker.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
import { useCallback, useEffect, useRef } from "react"; | ||
import { useHistory } from "react-router"; | ||
|
||
/** | ||
* @param continueCallback - callback to call when the user wants to leave the page | ||
* @param cancelCallback - callback to call when the user wants to stay on the page | ||
*/ | ||
export type LeaveBlockerCallbacks = { | ||
continueCallback?: () => void; | ||
cancelCallback?: () => void; | ||
}; | ||
|
||
/** | ||
* @param active - should the blocker be active or not. Set false to disable the blocker | ||
* @param onBeforeBlock - callback to check if we should block the page. If there is a need for a predicate to block the page | ||
* @param onBlock - callback to call when we should block the page. It Allows using custom modals to ask the user if they want to leave the page | ||
*/ | ||
export type LeaveBlockerProps = { | ||
active: boolean; | ||
onBeforeBlock?: () => boolean; | ||
onBlock?: (callbacks: LeaveBlockerCallbacks) => void; | ||
}; | ||
|
||
// Use `data-leave` attribute to mark the button that should be used to leave the current view (without changing url) to be able to block this action | ||
const LEAVE_BUTTON_SELECTOR = "[data-leave]"; | ||
export const LEAVE_BLOCKER_KEY: string = "LEAVE_BLOCKER"; | ||
|
||
type LeaveBlockerCallback = { | ||
current?: (shouldLeave: boolean) => void; | ||
}; | ||
// This is used to avoid problems with blocking the page API in react-router v5 | ||
// Callback is stored in a ref and called when the user decides to leave the page (this will unblock history.block for the current transition) | ||
export const leaveBlockerCallback: LeaveBlockerCallback = { | ||
current: undefined, | ||
}; | ||
/** | ||
* Block leaving the page if there is a reason to do so. | ||
* It includes | ||
* - blocking the action of a tab/window closing, | ||
* - blocking going through the browser history, | ||
* - blocking clicking on the button with `data-leave` attribute, which is supposed to lead to leave the current view | ||
*/ | ||
export const LeaveBlocker = ({ active = true, onBeforeBlock, onBlock }: LeaveBlockerProps) => { | ||
// This will make active value available in the callbacks without the need to update the callback every time the active value changes | ||
const isActive = useRef(active); | ||
isActive.current = active; | ||
const history = useHistory(); | ||
// This is a way to block the page on a tab/window closing | ||
// It will be done with browser standard API and confirm dialog | ||
const beforeUnloadHandler = useCallback( | ||
(e: BeforeUnloadEvent) => { | ||
if (!isActive.current) return; | ||
const shouldBlock = onBeforeBlock ? onBeforeBlock() : true; | ||
if (!shouldBlock) return true; | ||
e.preventDefault(); | ||
e.returnValue = false; | ||
return false; | ||
}, | ||
[onBeforeBlock], | ||
); | ||
const shouldSkipClickChecks = useRef(false); | ||
// This is a way to block the view (but not a page) change by clicking on the button | ||
// It obligates us to use `data-leave` attribute on the button that should be used to leave the current view | ||
const beforeLeaveClickHandler = useCallback( | ||
(e: MouseEvent) => { | ||
if (!isActive.current) return; | ||
// It allows to skip the check if the user chooses to leave the page | ||
if (shouldSkipClickChecks.current) return; | ||
const eventTarget = e.target as HTMLElement; | ||
const target = eventTarget?.matches?.(LEAVE_BUTTON_SELECTOR) | ||
? e.target | ||
: eventTarget?.closest(LEAVE_BUTTON_SELECTOR); | ||
|
||
if (target) { | ||
const shouldBlock = onBeforeBlock ? onBeforeBlock() : true; | ||
if (!shouldBlock) return; | ||
e.preventDefault(); | ||
e.stopPropagation(); | ||
if (onBlock) { | ||
onBlock({ | ||
continueCallback() { | ||
shouldSkipClickChecks.current = true; | ||
eventTarget.click(); | ||
shouldSkipClickChecks.current = false; | ||
}, | ||
}); | ||
} | ||
return false; | ||
} | ||
}, | ||
[onBeforeBlock, onBlock], | ||
); | ||
|
||
useEffect(() => { | ||
let unsubcribe: Function | null = null; | ||
|
||
window.addEventListener("beforeunload", beforeUnloadHandler); | ||
window.addEventListener("click", beforeLeaveClickHandler, { capture: true }); | ||
unsubcribe = history.block(() => { | ||
if (!isActive.current) return; | ||
const shouldBlock = onBeforeBlock ? onBeforeBlock() : true; | ||
if (!shouldBlock) { | ||
return; | ||
} | ||
|
||
onBlock?.({ | ||
continueCallback: () => { | ||
leaveBlockerCallback.current?.(true); | ||
leaveBlockerCallback.current = undefined; | ||
unsubcribe?.(); | ||
}, | ||
cancelCallback: () => { | ||
leaveBlockerCallback.current?.(false); | ||
leaveBlockerCallback.current = undefined; | ||
}, | ||
}); | ||
// workaround for react-router v5 | ||
// see `getUserConfirmation` on the history object | ||
return LEAVE_BLOCKER_KEY; | ||
}); | ||
return () => { | ||
window.removeEventListener("beforeunload", beforeUnloadHandler); | ||
window.removeEventListener("click", beforeLeaveClickHandler, { capture: true }); | ||
if (unsubcribe) unsubcribe(); | ||
}; | ||
}, [onBeforeBlock, onBlock, beforeUnloadHandler, beforeLeaveClickHandler]); | ||
return null; | ||
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
113 changes: 113 additions & 0 deletions
113
web/apps/labelstudio/src/pages/CreateProject/Config/UnsavedChanges.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import React, { useCallback, useRef } from "react"; | ||
import { Button } from "../../../components"; | ||
import { LeaveBlocker, type LeaveBlockerCallbacks } from "../../../components/LeaveBlocker/LeaveBlocker"; | ||
import { modal } from "../../../components/Modal/Modal"; | ||
import { Space } from "../../../components/Space/Space"; | ||
|
||
type UnsavedChangesModalProps = { | ||
onSave: () => void; | ||
onCancel?: () => void; | ||
onDiscard?: () => void; | ||
cancelText?: string; | ||
discardText?: string; | ||
okText?: string; | ||
title?: string; | ||
body?: string; | ||
}; | ||
|
||
export const unsavedChangesModal = ({ | ||
onSave, | ||
onCancel, | ||
onDiscard, | ||
cancelText, | ||
discardText, | ||
okText, | ||
title = "You have unsaved changes.", | ||
body = "Would you like to save them before leaving?", | ||
...props | ||
}: UnsavedChangesModalProps) => { | ||
let modalInstance: any = undefined; | ||
const saveAndLeave = async () => { | ||
modalInstance?.update({ footer: getFooter(true) }); | ||
await onSave?.(); | ||
modalInstance?.close(); | ||
}; | ||
// It must be a function to be able to rerender the modal correctly | ||
const getFooter = (waiting: boolean) => { | ||
return ( | ||
<Space align="end"> | ||
<Button | ||
onClick={() => { | ||
onCancel?.(); | ||
modalInstance?.close(); | ||
}} | ||
size="compact" | ||
autoFocus | ||
> | ||
{cancelText ?? "Cancel"} | ||
</Button> | ||
|
||
{onDiscard && ( | ||
<Button | ||
onClick={() => { | ||
onDiscard?.(); | ||
modalInstance?.close(); | ||
}} | ||
size="compact" | ||
look="danger" | ||
> | ||
{discardText ?? "Discard and leave"} | ||
</Button> | ||
)} | ||
|
||
<Button waiting={waiting} onClick={saveAndLeave} size="compact" look={"primary"}> | ||
{okText ?? "Save and leave"} | ||
</Button> | ||
</Space> | ||
); | ||
}; | ||
modalInstance = modal({ | ||
...props, | ||
title, | ||
body, | ||
allowClose: true, | ||
footer: getFooter(false), | ||
style: { width: 512 }, | ||
unique: "UNSAVED_CHANGES_MODAL", | ||
}); | ||
}; | ||
|
||
type UnsavedChangesProps = { | ||
hasChanges: boolean; | ||
onSave: () => any; | ||
}; | ||
|
||
/** | ||
* Component that blocks navigation if there are unsaved changes | ||
* @param hasChanges - flag that indicates if there are unsaved changes | ||
* @param onSave - function that should be called to save changes | ||
*/ | ||
export const UnsavedChanges = ({ hasChanges, onSave }: UnsavedChangesProps) => { | ||
const saveHandlerRef = useRef(onSave); | ||
saveHandlerRef.current = onSave; | ||
const blockHandler = useCallback(async ({ continueCallback, cancelCallback }: LeaveBlockerCallbacks) => { | ||
const wrappedOnSave = async () => { | ||
const result = await saveHandlerRef.current?.(); | ||
if (result === true) { | ||
continueCallback && setTimeout(continueCallback, 0); | ||
} else { | ||
// We consider that user tries to save changes, but as long as there are some errors, | ||
// we just close the modal to allow user to see and fix them | ||
cancelCallback?.(); | ||
} | ||
}; | ||
|
||
unsavedChangesModal({ | ||
onSave: wrappedOnSave, | ||
onCancel: cancelCallback, | ||
onDiscard: continueCallback, | ||
}); | ||
}, []); | ||
|
||
return <LeaveBlocker active={hasChanges} onBlock={blockHandler} />; | ||
}; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.