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

feat: LEAP-1198: Add labeling unsaved changes warning #6100

Merged
merged 14 commits into from
Aug 1, 2024
Merged
20 changes: 17 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,31 @@ 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 || "/",
// callback is an async way to confirm or decline going to another page in the context of routing. It accepts `true` or `false`
getUserConfirmation: (message, callback) => {
Gondragos marked this conversation as resolved.
Show resolved Hide resolved
// `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;
Gondragos marked this conversation as resolved.
Show resolved Hide resolved
} 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;
};
11 changes: 9 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 @@ -324,6 +326,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 @@ -422,6 +425,7 @@ const Configurator = ({
} else {
setError(res);
}
return res;
};

function completeAfter(cm, pred) {
Expand Down Expand Up @@ -459,9 +463,9 @@ const Configurator = ({
return (
<div className={configClass}>
<div className={configClass.elem("container")}>
<h1>Labeling Interface</h1>
<h1>Labeling Interface{hasChanges ? " *" : ""}</h1>
<header>
<button type="button" onClick={onBrowse}>
<button type="button" data-leave={true} onClick={onBrowse}>
Browse Templates
</button>
<ToggleItems items={{ code: "Code", visual: "Visual" }} active={configure} onSelect={onSelect} />
Expand Down Expand Up @@ -524,6 +528,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 @@ -547,6 +552,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 @@ -647,6 +653,7 @@ export const ConfigPage = ({
disableSaveButton={disableSaveButton}
onSaveClick={onSaveClick}
warning={warning}
hasChanges={hasChanges}
/>
)}
</div>
Expand Down
Loading
Loading