Skip to content

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 14 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,894 changes: 1 addition & 4,893 deletions label_studio/feature_flags.json

Large diffs are not rendered by default.

19 changes: 16 additions & 3 deletions web/apps/labelstudio/src/app/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createBrowserHistory } from "history";
import React from "react";
import { render } from "react-dom";
import { Router } from "react-router-dom";
import { LEAVE_BLOCKER_KEY, leaveBlockerCallback } from "../components/LeaveBlocker/LeaveBlocker";
import { initSentry } from "../config/Sentry";
import { ApiProvider } from "../providers/ApiProvider";
import { AppStoreProvider } from "../providers/AppStoreProvider";
Expand All @@ -17,18 +18,30 @@ import "./App.styl";
import { AsyncPage } from "./AsyncPage/AsyncPage";
import ErrorBoundary from "./ErrorBoundary";
import { RootPage } from "./RootPage";
import { FF_OPTIC_2, isFF } from "../utils/feature-flags";
import { FF_OPTIC_2, FF_UNSAVED_CHANGES, isFF } from "../utils/feature-flags";
import { ToastProvider, ToastViewport } from "../components/Toast/Toast";

const baseURL = new URL(APP_SETTINGS.hostname || location.origin);
export const UNBLOCK_HISTORY_MESSAGE = "UNBLOCK_HISTORY";

const browserHistory = createBrowserHistory({
basename: baseURL.pathname || "/",
getUserConfirmation: (message, callback) => {
// `history.block` doesn't block events, so in the case of listeners,
// we need to have some flag that can be checked for preventing related actions
// `isBlocking` flag is used for this purpose
browserHistory.isBlocking = true;
const callbackWrapper = (result) => {
browserHistory.isBlocking = false;
callback(result);
isFF(FF_UNSAVED_CHANGES) && window.postMessage({ source: "label-studio", payload: UNBLOCK_HISTORY_MESSAGE });
};
if (isFF(FF_OPTIC_2) && message === DRAFT_GUARD_KEY) {
draftGuardCallback.current = callback;
draftGuardCallback.current = callbackWrapper;
} else if (isFF(FF_UNSAVED_CHANGES) && message === LEAVE_BLOCKER_KEY) {
leaveBlockerCallback.current = callbackWrapper;
} else {
callback(window.confirm(message));
callbackWrapper(window.confirm(message));
}
},
});
Expand Down
14 changes: 14 additions & 0 deletions web/apps/labelstudio/src/app/AsyncPage/AsyncPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { useHistory } from "react-router";
import { ErrorWrapper } from "../../components/Error/Error";
import { modal } from "../../components/Modal/Modal";
import { ConfigContext } from "../../providers/ConfigProvider";
import { FF_UNSAVED_CHANGES, isFF } from "../../utils/feature-flags";
import { absoluteURL, removePrefix } from "../../utils/helpers";
import { clearScriptsCache, isScriptValid, reInsertScripts, replaceScript } from "../../utils/scripts";
import { UNBLOCK_HISTORY_MESSAGE } from "../App";

const pageCache = new Map();

Expand Down Expand Up @@ -234,6 +236,8 @@ export const AsyncPage = ({ children }) => {
}, []);

const onPopState = useCallback(() => {
// Prevent false positive triggers in case of blocking page transitions
if (isFF(FF_UNSAVED_CHANGES) && history.isBlocking) return;
const newLocation = locationWithoutHash();
const isSameLocation = newLocation === currentLocation;

Expand All @@ -243,14 +247,24 @@ export const AsyncPage = ({ children }) => {
}
}, []);

// Fallback in case of blocked transitions
const onMessage = useCallback((event) => {
if (event.origin !== window.origin) return;
if (event.data?.source !== "label-studio") return;
if (event.data?.payload !== UNBLOCK_HISTORY_MESSAGE) return;
onPopState();
}, []);

// useEffect(onPopState, [location]);

useEffect(() => {
document.addEventListener("click", onLinkClick, { capture: true });
window.addEventListener("popstate", onPopState);
isFF(FF_UNSAVED_CHANGES) && window.addEventListener("message", onMessage);
return () => {
document.removeEventListener("click", onLinkClick, { capture: true });
window.removeEventListener("popstate", onPopState);
isFF(FF_UNSAVED_CHANGES) && window.removeEventListener("message", onMessage);
};
}, []);

Expand Down
128 changes: 128 additions & 0 deletions web/apps/labelstudio/src/components/LeaveBlocker/LeaveBlocker.tsx
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;
};
13 changes: 11 additions & 2 deletions web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Form } from "../../../components/Form";
import { useAPI } from "../../../providers/ApiProvider";
import { Block, cn, Elem } from "../../../utils/bem";
import { Palette } from "../../../utils/colors";
import { FF_UNSAVED_CHANGES, isFF } from "../../../utils/feature-flags";
import { colorNames } from "./colors";
import "./Config.styl";
import { Preview } from "./Preview";
Expand All @@ -20,6 +21,7 @@ import { TemplatesList } from "./TemplatesList";
import "./codemirror.css";
import "./config-hint";
import tags from "./schema.json";
import { UnsavedChanges } from "./UnsavedChanges";

const wizardClass = cn("wizard");
const configClass = cn("configure");
Expand Down Expand Up @@ -323,6 +325,7 @@ const Configurator = ({
onValidate,
disableSaveButton,
warning,
hasChanges,
}) => {
const [configure, setConfigure] = React.useState(isEmptyConfig(config) ? "code" : "visual");
const [visualLoaded, loadVisual] = React.useState(configure === "visual");
Expand Down Expand Up @@ -421,6 +424,7 @@ const Configurator = ({
} else {
setError(res);
}
return res;
};

function completeAfter(cm, pred) {
Expand Down Expand Up @@ -458,9 +462,11 @@ const Configurator = ({
return (
<div className={configClass}>
<div className={configClass.elem("container")}>
<h1>Labeling Interface</h1>
<h1>Labeling Interface{hasChanges ? " *" : ""}</h1>
<header>
<button onClick={onBrowse}>Browse Templates</button>
<button data-leave={true} onClick={onBrowse}>
Browse Templates
</button>
<ToggleItems items={{ code: "Code", visual: "Visual" }} active={configure} onSelect={onSelect} />
</header>
<div className={configClass.elem("editor")}>
Expand Down Expand Up @@ -521,6 +527,7 @@ const Configurator = ({
<Button look="primary" size="compact" style={{ width: 120 }} onClick={onSave} waiting={waiting}>
{waiting ? "Saving..." : "Save"}
</Button>
{isFF(FF_UNSAVED_CHANGES) && <UnsavedChanges hasChanges={hasChanges} onSave={onSave} />}
</Form.Actions>
)}
</div>
Expand All @@ -544,6 +551,7 @@ export const ConfigPage = ({
onValidate,
disableSaveButton,
show = true,
hasChanges,
}) => {
const [config, _setConfig] = React.useState("");
const [mode, setMode] = React.useState("list"); // view | list
Expand Down Expand Up @@ -644,6 +652,7 @@ export const ConfigPage = ({
disableSaveButton={disableSaveButton}
onSaveClick={onSaveClick}
warning={warning}
hasChanges={hasChanges}
/>
)}
</div>
Expand Down
113 changes: 113 additions & 0 deletions web/apps/labelstudio/src/pages/CreateProject/Config/UnsavedChanges.tsx
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} />;
};
Loading