From 6cb0cfbfcc0d7caed9576abbd07bc4c54469325e Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 22 Apr 2026 01:11:27 +0200 Subject: [PATCH] feat(frontend): warn if notes are dirty (#11772) * feat(frontend): warn if notes are dirty closes https://github.com/invenhost/InvenTree/issues/301 * fix type * fix small style issues * add changelog entry * stop closing tab --- CHANGELOG.md | 2 + .../src/components/editors/NotesEditor.tsx | 11 +++- .../src/components/panels/NotesPanel.tsx | 3 +- src/frontend/src/components/panels/Panel.tsx | 1 + .../src/components/panels/PanelGroup.tsx | 57 ++++++++++++++++++- 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74c25341c4..1fc1bf862a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#11772](https://github.com/inventree/InvenTree/pull/11772) the UI now warns if you navigate away from a note panel with unsaved changes + ### Changed ### Removed diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index 667cdd1ae5..afdb3d2c05 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -25,15 +25,18 @@ import { useApi } from '../../contexts/ApiContext'; export default function NotesEditor({ modelType, modelId, - editable + editable, + setDirtyCallback }: Readonly<{ modelType: ModelType; modelId: number; editable?: boolean; + setDirtyCallback?: (dirty: boolean) => void; }>) { const api = useApi(); // In addition to the editable prop, we also need to check if the user has "enabled" editing const [editing, setEditing] = useState(false); + const [localIsDirty, setLocalIsDirty] = useState(false); const [markdown, setMarkdown] = useState(''); @@ -42,6 +45,10 @@ export default function NotesEditor({ setEditing(false); }, [editable, modelId, modelType]); + useEffect(() => { + setDirtyCallback?.(localIsDirty); + }, [localIsDirty]); + const noteUrl: string = useMemo(() => { const modelInfo = ModelInformationDict[modelType]; return apiUrl(modelInfo.api_endpoint, modelId); @@ -121,6 +128,7 @@ export default function NotesEditor({ id: 'notes', autoClose: 2000 }); + setLocalIsDirty(false); }) .catch((error) => { notifications.hide('notes'); @@ -222,6 +230,7 @@ export default function NotesEditor({ getMdeInstance={(instance: SimpleMde) => setMdeInstance(instance)} onChange={(value: string) => { setMarkdown(value); + setLocalIsDirty(true); }} options={editorOptions} value={markdown} diff --git a/src/frontend/src/components/panels/NotesPanel.tsx b/src/frontend/src/components/panels/NotesPanel.tsx index a200a706d4..3de9d6f011 100644 --- a/src/frontend/src/components/panels/NotesPanel.tsx +++ b/src/frontend/src/components/panels/NotesPanel.tsx @@ -34,6 +34,7 @@ export default function NotesPanel({ /> ) : ( - ) + ), + supportsDirty: true }; } diff --git a/src/frontend/src/components/panels/Panel.tsx b/src/frontend/src/components/panels/Panel.tsx index 2fafd2283b..548dc4bac5 100644 --- a/src/frontend/src/components/panels/Panel.tsx +++ b/src/frontend/src/components/panels/Panel.tsx @@ -13,6 +13,7 @@ export type PanelType = { hidden?: boolean; disabled?: boolean; showHeadline?: boolean; + supportsDirty?: boolean; }; export type PanelGroupType = { diff --git a/src/frontend/src/components/panels/PanelGroup.tsx b/src/frontend/src/components/panels/PanelGroup.tsx index c6d7015ec6..80b13c80fb 100644 --- a/src/frontend/src/components/panels/PanelGroup.tsx +++ b/src/frontend/src/components/panels/PanelGroup.tsx @@ -39,6 +39,7 @@ import { cancelEvent } from '@lib/functions/Events'; import { eventModified, getBaseUrl } from '@lib/functions/Navigation'; import { navigateToLink } from '@lib/functions/Navigation'; import { t } from '@lingui/core/macro'; +import { useWindowEvent } from '@mantine/hooks'; import { useShallow } from 'zustand/react/shallow'; import { generateUrl } from '../../functions/urls'; import { usePluginPanels } from '../../hooks/UsePluginPanels'; @@ -173,6 +174,17 @@ function BasePanelGroup({ const handlePanelChange = useCallback( (targetPanel: string, event?: any) => { cancelEvent(event); + + // check if we are currently on a dirty panel, if so prompt the user to confirm navigation + if (isDirty) { + const confirm = globalThis.confirm( + t`You have unsaved changes, are you sure you want to navigate away from this panel?` + ); + if (!confirm) { + return; + } + } + if (event && eventModified(event)) { const url = `${location.pathname}/../${targetPanel}`; navigateToLink(url, navigate, event); @@ -186,6 +198,9 @@ function BasePanelGroup({ if (targetPanel && onPanelChange) { onPanelChange(targetPanel); } + + // change dirty state + setIsDirty(false); }, [activePanels, navigate, location, onPanelChange] ); @@ -206,6 +221,13 @@ function BasePanelGroup({ } }, [activePanels, panel]); + const [isDirty, setIsDirty] = useState(false); + useWindowEvent('beforeunload', (event) => { + if (isDirty) { + event.preventDefault(); + } + }); + return ( @@ -341,7 +363,7 @@ function BasePanelGroup({ )} - {panel.content} + {getPanelContent(panel.content, panel, setIsDirty)} @@ -353,6 +375,39 @@ function BasePanelGroup({ ); } +/* + * Helper function to inject the setIsDirty callback into panel content if supported + * This allows panels to mark themselves as dirty when changes are made, which will trigger a confirmation prompt when navigating away from the panel + */ +function getPanelContent( + content: ReactNode, + panel: PanelType, + setIsDirty?: (dirty: boolean) => void +): ReactNode { + if (content === null) { + return null; + } + + // pass setIsDirty callback to content if supported + if ( + panel.supportsDirty && + typeof content === 'object' && + 'props' in content && + setIsDirty + ) { + return { + ...content, + props: { + ...(content.props || {}), + setDirtyCallback: setIsDirty + } + }; + } + + // normal content, just return as is + return content; +} + function IndexPanelComponent({ pageKey, selectedPanel,