Skip to content

Commit 82b5200

Browse files
authored
WEB-109 Add unsaved changes navigation warning (#127)
2 parents 817deb3 + 23a84c7 commit 82b5200

7 files changed

Lines changed: 185 additions & 52 deletions

File tree

src/app/editor/[id]/[slug]/SaveButton.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { createUsePuck } from "@puckeditor/core";
44
import { useTransition } from "react";
55
import { toast } from "sonner";
6-
import { useDocumentContext } from "./client";
6+
import { useDocumentContext } from "./document-context";
77
import { saveVersionAction } from "../../../../lib/documents/actions";
88
import { getEditorUrl, getPreviewUrl } from "../../../../lib/editor-url";
99
import { runAction } from "../../runAction";
@@ -13,7 +13,7 @@ const usePuck = createUsePuck();
1313

1414
export function SaveButton() {
1515
const data = usePuck((s) => s.appState.data);
16-
const { documentId, documentName, isArchived, addVersion } = useDocumentContext();
16+
const { documentId, documentName, isArchived, isDirty, addVersion } = useDocumentContext();
1717
const { alert } = useDialogs();
1818

1919
const [isSaving, startTransition] = useTransition();
@@ -28,7 +28,7 @@ export function SaveButton() {
2828
}
2929

3030
addVersion(result.data.version);
31-
window.history.replaceState(null, "", getEditorUrl(documentId, documentName, result.data.version.id));
31+
window.history.replaceState(window.history.state, "", getEditorUrl(documentId, documentName, result.data.version.id));
3232

3333
const previewUrl = getPreviewUrl(documentId, documentName, result.data.version.id);
3434
toast.success("Saved", {
@@ -46,7 +46,7 @@ export function SaveButton() {
4646
return (
4747
<button
4848
onClick={handleSave}
49-
disabled={isSaving || isArchived}
49+
disabled={isSaving || isArchived || !isDirty}
5050
className="px-3 py-1.5 bg-gray-600 text-white text-sm rounded hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
5151
>
5252
{isArchived ? "Archived" : isSaving ? "Saving..." : "Save"}

src/app/editor/[id]/[slug]/VersionPluginContainer.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import { useTransition } from "react";
44
import { useRouter } from "next/navigation";
5-
import { useDocumentContext } from "./client";
5+
import { useDocumentContext } from "./document-context";
6+
import { useUnsavedChangesContext } from "./unsaved-changes-context";
67
import { publishVersionAction } from "../../../../lib/documents/actions";
78
import { getEditorUrl, getPreviewUrl } from "../../../../lib/editor-url";
89
import { runAction } from "../../runAction";
@@ -13,6 +14,7 @@ import { toast } from "sonner";
1314
export function VersionPluginContainer() {
1415
const router = useRouter();
1516
const { documentId, documentName, versionId, publishedVersionId, versions, isArchived, setPublishedVersionId } = useDocumentContext();
17+
const { confirmDiscardChanges } = useUnsavedChangesContext();
1618
const [isPublishing, startTransition] = useTransition();
1719
const { alert } = useDialogs();
1820

@@ -30,7 +32,17 @@ export function VersionPluginContainer() {
3032
});
3133
};
3234

33-
const handleLoadVersion = (versionIdToLoad: number) => {
35+
const handleLoadVersion = async (versionIdToLoad: number) => {
36+
if (versionIdToLoad === versionId) {
37+
return;
38+
}
39+
40+
const shouldLeave = await confirmDiscardChanges();
41+
42+
if (!shouldLeave) {
43+
return;
44+
}
45+
3446
router.replace(getEditorUrl(documentId, documentName, versionIdToLoad));
3547
};
3648

Lines changed: 29 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,17 @@
11
"use client";
22

33
import type { Data } from "@puckeditor/core";
4-
import { Puck } from "@puckeditor/core";
4+
import { Puck, blocksPlugin, outlinePlugin } from "@puckeditor/core";
55
import config from "../../../../puck.config";
6-
import { useState, createContext, useContext, useCallback } from "react";
6+
import { useState, useCallback } from "react";
77
import { VersionPlugin } from "./VersionPlugin";
8-
import { blocksPlugin, outlinePlugin } from "@puckeditor/core";
98
import { ActionBarOverride } from "./ActionBarOverride";
109
import { SaveButton } from "./SaveButton";
1110
import { MediaProvider, type MediaWithUrl } from "@/components/puck/media-context";
1211
import type { Version } from "../../../../lib/types";
13-
14-
type DocumentContextType = {
15-
documentId: number;
16-
documentName: string;
17-
versionId?: number;
18-
publishedVersionId?: number;
19-
versions: Version[];
20-
isArchived: boolean;
21-
addVersion: (version: Version) => void;
22-
setPublishedVersionId: (id: number) => void;
23-
};
24-
25-
const DocumentContext = createContext<DocumentContextType>({
26-
documentId: 0,
27-
documentName: "",
28-
versions: [],
29-
isArchived: false,
30-
addVersion: () => {},
31-
setPublishedVersionId: () => {},
32-
});
33-
34-
export function useDocumentContext() {
35-
return useContext(DocumentContext);
36-
}
12+
import { useUnsavedChangesGuard } from "./useUnsavedChangesGuard";
13+
import { DocumentContext } from "./document-context";
14+
import { UnsavedChangesContext } from "./unsaved-changes-context";
3715

3816
export function Client({
3917
documentId,
@@ -57,32 +35,38 @@ export function Client({
5735
const [versions, setVersions] = useState(initialVersions);
5836
const [versionId, setVersionId] = useState(initialVersionId);
5937
const [publishedVersionId, setPublishedVersionId] = useState(initialPublishedVersionId);
38+
const [isDirty, setIsDirty] = useState(false);
39+
const { confirmDiscardChanges } = useUnsavedChangesGuard(isDirty);
6040

6141
const addVersion = useCallback((version: Version) => {
6242
setVersions(prev => [version, ...prev]);
6343
setVersionId(version.id);
44+
setIsDirty(false);
6445
}, []);
6546

6647
return (
6748
<MediaProvider media={media}>
68-
<DocumentContext.Provider
69-
value={{ documentId, documentName, versionId, publishedVersionId, versions, isArchived, addVersion, setPublishedVersionId }}
70-
>
71-
<Puck
72-
config={config}
73-
data={data}
74-
ui={{plugin: {current: "version-plugin"}}}
75-
plugins={[VersionPlugin, blocksPlugin(), outlinePlugin()]}
76-
permissions={isArchived
77-
? { drag: false, duplicate: false, delete: false, edit: false, insert: false }
78-
: { duplicate: false } // We replace this with our own, to avoid an icon collision
79-
}
80-
overrides={{
81-
actionBar: ActionBarOverride,
82-
headerActions: SaveButton
83-
}}
84-
/>
85-
</DocumentContext.Provider>
49+
<DocumentContext.Provider
50+
value={{ documentId, documentName, versionId, publishedVersionId, versions, isArchived, isDirty, addVersion, setPublishedVersionId }}
51+
>
52+
<UnsavedChangesContext.Provider value={{ confirmDiscardChanges }}>
53+
<Puck
54+
config={config}
55+
data={data}
56+
onChange={() => setIsDirty(true)}
57+
ui={{ plugin: { current: "version-plugin" } }}
58+
plugins={[VersionPlugin, blocksPlugin(), outlinePlugin()]}
59+
permissions={isArchived
60+
? { drag: false, duplicate: false, delete: false, edit: false, insert: false }
61+
: { duplicate: false } // We replace this with our own, to avoid an icon collision
62+
}
63+
overrides={{
64+
actionBar: ActionBarOverride,
65+
headerActions: SaveButton
66+
}}
67+
/>
68+
</UnsavedChangesContext.Provider>
69+
</DocumentContext.Provider>
8670
</MediaProvider>
8771
);
8872
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"use client";
2+
3+
import { createContext, useContext } from "react";
4+
import type { Version } from "../../../../lib/types";
5+
6+
type DocumentContextType = {
7+
documentId: number;
8+
documentName: string;
9+
versionId?: number;
10+
publishedVersionId?: number;
11+
versions: Version[];
12+
isArchived: boolean;
13+
isDirty: boolean;
14+
addVersion: (version: Version) => void;
15+
setPublishedVersionId: (id: number) => void;
16+
};
17+
18+
const DocumentContext = createContext<DocumentContextType>({
19+
documentId: 0,
20+
documentName: "",
21+
versions: [],
22+
isArchived: false,
23+
isDirty: false,
24+
addVersion: () => {},
25+
setPublishedVersionId: () => {},
26+
});
27+
28+
export function useDocumentContext() {
29+
return useContext(DocumentContext);
30+
}
31+
32+
export { DocumentContext };
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"use client";
2+
3+
import { createContext, useContext } from "react";
4+
5+
type UnsavedChangesContextType = {
6+
confirmDiscardChanges: () => Promise<boolean>;
7+
};
8+
9+
const UnsavedChangesContext = createContext<UnsavedChangesContextType>({
10+
confirmDiscardChanges: async () => true,
11+
});
12+
13+
export function useUnsavedChangesContext() {
14+
return useContext(UnsavedChangesContext);
15+
}
16+
17+
export { UnsavedChangesContext };
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"use client";
2+
3+
import { useCallback, useEffect, useRef } from "react";
4+
import { useDialogs } from "@/components/ui/dialog-provider";
5+
6+
const LEAVE_MESSAGE = "You have unsaved changes. Leave this page without saving?";
7+
8+
// Next.js internally sets an auto-incrementing `idx` on history.state to track
9+
// navigation position. This is an undocumented internal — not a public API — so
10+
// it may disappear or change in any Next.js release. We use it to determine
11+
// whether the user navigated forward or back so we can revert the navigation
12+
// when they cancel. If `idx` is absent we degrade gracefully (warn, but can't
13+
// revert). See: https://github.com/vercel/next.js/discussions/34980
14+
function getCurrentHistoryIndex() {
15+
const historyState = window.history.state as { idx?: number } | null;
16+
return typeof historyState?.idx === "number" ? historyState.idx : null;
17+
}
18+
19+
export function useUnsavedChangesGuard(isDirty: boolean) {
20+
const { confirm } = useDialogs();
21+
const historyIndexRef = useRef<number | null>(null);
22+
const isRevertingNavigationRef = useRef(false);
23+
24+
const confirmDiscardChanges = useCallback(async () => {
25+
if (!isDirty) {
26+
return true;
27+
}
28+
29+
return await confirm({
30+
message: LEAVE_MESSAGE,
31+
actionLabel: "Leave",
32+
destructive: true,
33+
});
34+
}, [confirm, isDirty]);
35+
36+
useEffect(() => {
37+
if (!isDirty) {
38+
return;
39+
}
40+
41+
historyIndexRef.current = getCurrentHistoryIndex();
42+
isRevertingNavigationRef.current = false;
43+
44+
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
45+
event.preventDefault();
46+
// Needed for Chrome version <=119 to show the confirmation dialog
47+
event.returnValue = true;
48+
};
49+
50+
const handlePopState = () => {
51+
const nextHistoryIndex = getCurrentHistoryIndex();
52+
53+
if (!isRevertingNavigationRef.current) {
54+
const shouldLeave = window.confirm(LEAVE_MESSAGE);
55+
56+
if (!shouldLeave) {
57+
if (
58+
historyIndexRef.current !== null &&
59+
nextHistoryIndex !== null &&
60+
historyIndexRef.current !== nextHistoryIndex
61+
) {
62+
isRevertingNavigationRef.current = true;
63+
window.history.go(historyIndexRef.current > nextHistoryIndex ? 1 : -1);
64+
return;
65+
}
66+
67+
console.warn("Unable to determine history direction for unsaved-changes guard");
68+
}
69+
}
70+
71+
isRevertingNavigationRef.current = false;
72+
historyIndexRef.current = nextHistoryIndex;
73+
};
74+
75+
window.addEventListener("beforeunload", handleBeforeUnload);
76+
window.addEventListener("popstate", handlePopState);
77+
78+
return () => {
79+
isRevertingNavigationRef.current = false;
80+
window.removeEventListener("beforeunload", handleBeforeUnload);
81+
window.removeEventListener("popstate", handlePopState);
82+
};
83+
}, [isDirty]);
84+
85+
return {
86+
confirmDiscardChanges,
87+
};
88+
}

src/components/ui/alert-dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ function AlertDialogDescription({
133133
<AlertDialogPrimitive.Description
134134
data-slot="alert-dialog-description"
135135
className={cn(
136-
"text-sm break-all text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
136+
"text-sm break-normal wrap-anywhere text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
137137
className
138138
)}
139139
{...props}

0 commit comments

Comments
 (0)