Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import { useIsGraderOrInstructor, useIsInstructor } from "@/hooks/useClassProfiles";
import { Box, Button, Flex, Heading, HStack, VStack } from "@chakra-ui/react";
import { Select } from "chakra-react-select";
import { hasRubricUnsavedChangesFlag, RUBRIC_UNSAVED_CHANGES_WARNING_MESSAGE } from "@/lib/rubricUnsavedChanges";
import NextLink from "next/link";
import { useParams, usePathname, useRouter } from "next/navigation";
import React, { useState } from "react";
import React, { useCallback, useState } from "react";
import {
FaCalendar,
FaChartBar,
Expand Down Expand Up @@ -106,6 +107,15 @@ export function ManageAssignmentNav({
const pathname = usePathname();
const [selectedPage, setSelectedPage] = useState<string>("");
const router = useRouter();
const confirmRubricNavigation = useCallback(
(nextHref: string) => {
if (!pathname.includes("/rubric")) return true;
if (pathname === nextHref) return true;
if (!hasRubricUnsavedChangesFlag(String(assignment_id))) return true;
return window.confirm(RUBRIC_UNSAVED_CHANGES_WARNING_MESSAGE);
},
[assignment_id, pathname]
);

return (
<>
Expand Down Expand Up @@ -156,6 +166,7 @@ export function ManageAssignmentNav({
<Select
onChange={(e) => {
if (e) {
if (!confirmRubricNavigation(e.value)) return;
setSelectedPage(e.value);
router.replace(e.value);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ import {
import { useCreate, useDataProvider, useDelete, useInvalidate, useUpdate } from "@refinedev/core";
import { configureMonacoYaml } from "monaco-yaml";
import dynamic from "next/dynamic";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
clearRubricUnsavedChangesFlag,
RUBRIC_UNSAVED_CHANGES_WARNING_MESSAGE,
setRubricUnsavedChangesFlag
} from "@/lib/rubricUnsavedChanges";

// Dynamic import of Monaco Editor to reduce build memory usage
const Editor = dynamic(() => import("@monaco-editor/react").then((mod) => mod.default), {
Expand Down Expand Up @@ -532,6 +537,23 @@ function InnerRubricPage() {
{} as Record<string, boolean>
)
);
const hasAnyUnsavedChanges = useMemo(
() => hasUnsavedChanges || Object.values(unsavedStatusPerTab).some(Boolean),
[hasUnsavedChanges, unsavedStatusPerTab]
);
const hasAnyUnsavedChangesRef = useRef(hasAnyUnsavedChanges);
const shouldSkipNextPopStateWarningRef = useRef(false);
const rubricPageRootRef = useRef<HTMLDivElement>(null);
const isRubricPageInstanceVisible = useCallback(() => {
const rootElement = rubricPageRootRef.current;
if (!rootElement) return true;
return rootElement.getClientRects().length > 0;
}, []);

useLayoutEffect(() => {
hasAnyUnsavedChangesRef.current = hasAnyUnsavedChanges;
}, [hasAnyUnsavedChanges]);

const [stashedEditorStates, setStashedEditorStates] = useState<
Record<
string,
Expand Down Expand Up @@ -876,6 +898,81 @@ function InnerRubricPage() {
}
}, [value, initialActiveRubricSnapshot, activeReviewRound, assignment_id]);

useLayoutEffect(() => {
if (!assignment_id) return;
if (!isRubricPageInstanceVisible()) return;

setRubricUnsavedChangesFlag(assignment_id, hasAnyUnsavedChanges);
return () => {
clearRubricUnsavedChangesFlag(assignment_id);
};
}, [assignment_id, hasAnyUnsavedChanges, isRubricPageInstanceVisible]);

useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isRubricPageInstanceVisible()) return;
if (!hasAnyUnsavedChangesRef.current) return;
event.preventDefault();
event.returnValue = "";
};

const handlePopState = () => {
if (!isRubricPageInstanceVisible()) return;
if (shouldSkipNextPopStateWarningRef.current) {
shouldSkipNextPopStateWarningRef.current = false;
return;
}
if (!hasAnyUnsavedChangesRef.current) return;

const shouldLeave = window.confirm(RUBRIC_UNSAVED_CHANGES_WARNING_MESSAGE);
if (shouldLeave) return;

shouldSkipNextPopStateWarningRef.current = true;
window.history.go(1);
};

const handleDocumentClick = (event: MouseEvent) => {
if (!isRubricPageInstanceVisible()) return;
if (!hasAnyUnsavedChangesRef.current || event.defaultPrevented) return;
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;

const target = event.target;
if (!(target instanceof Element)) return;

const anchor = target.closest("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) return;
if (anchor.target === "_blank" || anchor.hasAttribute("download")) return;

const href = anchor.getAttribute("href");
if (!href) return;

const currentUrl = new URL(window.location.href);
const nextUrl = new URL(href, window.location.href);
const isSameDocumentLocation =
currentUrl.origin === nextUrl.origin &&
currentUrl.pathname === nextUrl.pathname &&
currentUrl.search === nextUrl.search;

if (isSameDocumentLocation) return;

const shouldLeave = window.confirm(RUBRIC_UNSAVED_CHANGES_WARNING_MESSAGE);
if (shouldLeave) return;

event.preventDefault();
event.stopPropagation();
};

window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("popstate", handlePopState);
document.addEventListener("click", handleDocumentClick, true);

return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("popstate", handlePopState);
document.removeEventListener("click", handleDocumentClick, true);
};
}, [isRubricPageInstanceVisible]);

const updatePartIfChanged = useCallback(
async (part: HydratedRubricPart, existingPart: HydratedRubricPart) => {
if (part.id !== existingPart.id) {
Expand Down Expand Up @@ -1302,7 +1399,7 @@ function InnerRubricPage() {
}

return (
<Flex w="100%" minW="0" direction="column">
<Flex ref={rubricPageRootRef} w="100%" minW="0" direction="column">
<HStack w="100%" mt={2} mb={2} justifyContent="space-between" pr={2}>
<Toaster />
<VStack align="start">
Expand Down
36 changes: 36 additions & 0 deletions lib/rubricUnsavedChanges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export const RUBRIC_UNSAVED_CHANGES_WARNING_MESSAGE =
"You have unsaved rubric changes. Leave this page without saving?";

const STORAGE_KEY_PREFIX = "pawtograder:rubric-unsaved-changes";

function getStorageKeyOrNull(assignmentId: string | number): string | null {
const normalizedAssignmentId = String(assignmentId).trim();
if (!normalizedAssignmentId) return null;
return `${STORAGE_KEY_PREFIX}:${normalizedAssignmentId}`;
}

export function setRubricUnsavedChangesFlag(assignmentId: string | number, hasUnsavedChanges: boolean): void {
if (typeof window === "undefined") return;
const storageKey = getStorageKeyOrNull(assignmentId);
if (!storageKey) return;

if (hasUnsavedChanges) {
window.sessionStorage.setItem(storageKey, "true");
return;
}
window.sessionStorage.removeItem(storageKey);
}

export function hasRubricUnsavedChangesFlag(assignmentId: string | number): boolean {
if (typeof window === "undefined") return false;
const storageKey = getStorageKeyOrNull(assignmentId);
if (!storageKey) return false;
return window.sessionStorage.getItem(storageKey) === "true";
}

export function clearRubricUnsavedChangesFlag(assignmentId: string | number): void {
if (typeof window === "undefined") return;
const storageKey = getStorageKeyOrNull(assignmentId);
if (!storageKey) return;
window.sessionStorage.removeItem(storageKey);
}
35 changes: 35 additions & 0 deletions tests/unit/rubric-unsaved-changes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
clearRubricUnsavedChangesFlag,
hasRubricUnsavedChangesFlag,
setRubricUnsavedChangesFlag
} from "@/lib/rubricUnsavedChanges";

describe("rubric unsaved changes session state", () => {
const assignmentId = "123";

beforeEach(() => {
window.sessionStorage.clear();
});

it("sets and reads unsaved state", () => {
setRubricUnsavedChangesFlag(assignmentId, true);
expect(hasRubricUnsavedChangesFlag(assignmentId)).toBe(true);
});

it("clears unsaved state when set false", () => {
setRubricUnsavedChangesFlag(assignmentId, true);
setRubricUnsavedChangesFlag(assignmentId, false);
expect(hasRubricUnsavedChangesFlag(assignmentId)).toBe(false);
});

it("clears unsaved state explicitly", () => {
setRubricUnsavedChangesFlag(assignmentId, true);
clearRubricUnsavedChangesFlag(assignmentId);
expect(hasRubricUnsavedChangesFlag(assignmentId)).toBe(false);
});

it("ignores empty assignment ids", () => {
setRubricUnsavedChangesFlag("", true);
expect(hasRubricUnsavedChangesFlag("")).toBe(false);
});
});
Loading