fix(base): prompt unsaved changes when discarding dirty rename

This commit is contained in:
Philipinho
2026-04-18 20:58:59 +01:00
parent 097b1c76d4
commit 64dafe5ac0
@@ -50,9 +50,12 @@ export function PropertyMenuContent({
const renameInputRef = useRef<HTMLInputElement>(null); const renameInputRef = useRef<HTMLInputElement>(null);
const [optionsDirty, setOptionsDirty] = useState(false); const [optionsDirty, setOptionsDirty] = useState(false);
const pendingActionRef = useRef<"back" | "close" | null>(null); const pendingActionRef = useRef<"back" | "close" | null>(null);
const sourcePanelRef = useRef<"rename" | "options" | null>(null);
const [closeRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number]; const [closeRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number];
const closeRequestRef = useRef(closeRequest); const closeRequestRef = useRef(closeRequest);
const renameDirty = renameValue !== property.name;
const updatePropertyMutation = useUpdatePropertyMutation(); const updatePropertyMutation = useUpdatePropertyMutation();
const deletePropertyMutation = useDeletePropertyMutation(); const deletePropertyMutation = useDeletePropertyMutation();
@@ -70,13 +73,21 @@ export function PropertyMenuContent({
} }
}, [panel]); }, [panel]);
const handleOptionsDirtyChange = useCallback( const handleOptionsDirtyChange = useCallback((dirty: boolean) => {
(dirty: boolean) => { setOptionsDirty(dirty);
setOptionsDirty(dirty); }, []);
onDirtyChange?.(dirty);
}, // Single dirty signal to the outside — reflects whichever panel is
[onDirtyChange], // currently accumulating unsaved work. Keeps rename and options in
); // lockstep with the `propertyMenuDirtyAtom` so the grid-container's
// outside-click handler and the header's ESC handler both prompt
// "Unsaved changes" consistently.
useEffect(() => {
const dirty =
(panel === "rename" && renameDirty) ||
(panel === "options" && optionsDirty);
onDirtyChange?.(dirty);
}, [panel, renameDirty, optionsDirty, onDirtyChange]);
const commitRename = useCallback(() => { const commitRename = useCallback(() => {
const trimmed = renameValue.trim(); const trimmed = renameValue.trim();
@@ -94,6 +105,20 @@ export function PropertyMenuContent({
onClose(); onClose();
}, [commitRename, onClose]); }, [commitRename, onClose]);
const requestClose = useCallback(() => {
if (panel === "rename" && renameDirty) {
sourcePanelRef.current = "rename";
pendingActionRef.current = "close";
setPanel("confirmDiscard");
} else if (panel === "options" && optionsDirty) {
sourcePanelRef.current = "options";
pendingActionRef.current = "close";
setPanel("confirmDiscard");
} else {
onClose();
}
}, [panel, renameDirty, optionsDirty, onClose]);
const handleRenameKeyDown = useCallback( const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -103,10 +128,10 @@ export function PropertyMenuContent({
} }
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
onClose(); requestClose();
} }
}, },
[handleRenameAndClose, onClose], [handleRenameAndClose, requestClose],
); );
const handleOptionsUpdate = useCallback( const handleOptionsUpdate = useCallback(
@@ -131,6 +156,7 @@ export function PropertyMenuContent({
const handleOptionsBack = useCallback(() => { const handleOptionsBack = useCallback(() => {
if (optionsDirty) { if (optionsDirty) {
sourcePanelRef.current = "options";
pendingActionRef.current = "back"; pendingActionRef.current = "back";
setPanel("confirmDiscard"); setPanel("confirmDiscard");
} else { } else {
@@ -138,15 +164,6 @@ export function PropertyMenuContent({
} }
}, [optionsDirty]); }, [optionsDirty]);
const requestClose = useCallback(() => {
if (panel === "options" && optionsDirty) {
pendingActionRef.current = "close";
setPanel("confirmDiscard");
} else {
onClose();
}
}, [panel, optionsDirty, onClose]);
useEffect(() => { useEffect(() => {
if (closeRequest !== closeRequestRef.current) { if (closeRequest !== closeRequestRef.current) {
closeRequestRef.current = closeRequest; closeRequestRef.current = closeRequest;
@@ -158,19 +175,22 @@ export function PropertyMenuContent({
const handleConfirmDiscard = useCallback(() => { const handleConfirmDiscard = useCallback(() => {
setOptionsDirty(false); setOptionsDirty(false);
onDirtyChange?.(false); setRenameValue(property.name);
const action = pendingActionRef.current; const action = pendingActionRef.current;
pendingActionRef.current = null; pendingActionRef.current = null;
sourcePanelRef.current = null;
if (action === "back") { if (action === "back") {
setPanel("main"); setPanel("main");
} else { } else {
onClose(); onClose();
} }
}, [onClose, onDirtyChange]); }, [property.name, onClose]);
const handleCancelDiscard = useCallback(() => { const handleCancelDiscard = useCallback(() => {
const source = sourcePanelRef.current ?? "options";
pendingActionRef.current = null; pendingActionRef.current = null;
setPanel("options"); sourcePanelRef.current = null;
setPanel(source);
}, []); }, []);
return ( return (
@@ -197,7 +217,7 @@ export function PropertyMenuContent({
/> />
<Divider /> <Divider />
<Group justify="flex-end" gap="xs"> <Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={onClose}> <Button variant="default" size="xs" onClick={requestClose}>
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button <Button