2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-25 12:33:33 +00:00

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
This commit is contained in:
Matthias Mair
2026-04-22 01:11:27 +02:00
committed by GitHub
parent d837c8a910
commit 6cb0cfbfcc
5 changed files with 71 additions and 3 deletions
+2
View File
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 ### Changed
### Removed ### Removed
@@ -25,15 +25,18 @@ import { useApi } from '../../contexts/ApiContext';
export default function NotesEditor({ export default function NotesEditor({
modelType, modelType,
modelId, modelId,
editable editable,
setDirtyCallback
}: Readonly<{ }: Readonly<{
modelType: ModelType; modelType: ModelType;
modelId: number; modelId: number;
editable?: boolean; editable?: boolean;
setDirtyCallback?: (dirty: boolean) => void;
}>) { }>) {
const api = useApi(); const api = useApi();
// In addition to the editable prop, we also need to check if the user has "enabled" editing // In addition to the editable prop, we also need to check if the user has "enabled" editing
const [editing, setEditing] = useState<boolean>(false); const [editing, setEditing] = useState<boolean>(false);
const [localIsDirty, setLocalIsDirty] = useState<boolean>(false);
const [markdown, setMarkdown] = useState<string>(''); const [markdown, setMarkdown] = useState<string>('');
@@ -42,6 +45,10 @@ export default function NotesEditor({
setEditing(false); setEditing(false);
}, [editable, modelId, modelType]); }, [editable, modelId, modelType]);
useEffect(() => {
setDirtyCallback?.(localIsDirty);
}, [localIsDirty]);
const noteUrl: string = useMemo(() => { const noteUrl: string = useMemo(() => {
const modelInfo = ModelInformationDict[modelType]; const modelInfo = ModelInformationDict[modelType];
return apiUrl(modelInfo.api_endpoint, modelId); return apiUrl(modelInfo.api_endpoint, modelId);
@@ -121,6 +128,7 @@ export default function NotesEditor({
id: 'notes', id: 'notes',
autoClose: 2000 autoClose: 2000
}); });
setLocalIsDirty(false);
}) })
.catch((error) => { .catch((error) => {
notifications.hide('notes'); notifications.hide('notes');
@@ -222,6 +230,7 @@ export default function NotesEditor({
getMdeInstance={(instance: SimpleMde) => setMdeInstance(instance)} getMdeInstance={(instance: SimpleMde) => setMdeInstance(instance)}
onChange={(value: string) => { onChange={(value: string) => {
setMarkdown(value); setMarkdown(value);
setLocalIsDirty(true);
}} }}
options={editorOptions} options={editorOptions}
value={markdown} value={markdown}
@@ -34,6 +34,7 @@ export default function NotesPanel({
/> />
) : ( ) : (
<Skeleton /> <Skeleton />
) ),
supportsDirty: true
}; };
} }
@@ -13,6 +13,7 @@ export type PanelType = {
hidden?: boolean; hidden?: boolean;
disabled?: boolean; disabled?: boolean;
showHeadline?: boolean; showHeadline?: boolean;
supportsDirty?: boolean;
}; };
export type PanelGroupType = { export type PanelGroupType = {
@@ -39,6 +39,7 @@ import { cancelEvent } from '@lib/functions/Events';
import { eventModified, getBaseUrl } from '@lib/functions/Navigation'; import { eventModified, getBaseUrl } from '@lib/functions/Navigation';
import { navigateToLink } from '@lib/functions/Navigation'; import { navigateToLink } from '@lib/functions/Navigation';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useWindowEvent } from '@mantine/hooks';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { generateUrl } from '../../functions/urls'; import { generateUrl } from '../../functions/urls';
import { usePluginPanels } from '../../hooks/UsePluginPanels'; import { usePluginPanels } from '../../hooks/UsePluginPanels';
@@ -173,6 +174,17 @@ function BasePanelGroup({
const handlePanelChange = useCallback( const handlePanelChange = useCallback(
(targetPanel: string, event?: any) => { (targetPanel: string, event?: any) => {
cancelEvent(event); 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)) { if (event && eventModified(event)) {
const url = `${location.pathname}/../${targetPanel}`; const url = `${location.pathname}/../${targetPanel}`;
navigateToLink(url, navigate, event); navigateToLink(url, navigate, event);
@@ -186,6 +198,9 @@ function BasePanelGroup({
if (targetPanel && onPanelChange) { if (targetPanel && onPanelChange) {
onPanelChange(targetPanel); onPanelChange(targetPanel);
} }
// change dirty state
setIsDirty(false);
}, },
[activePanels, navigate, location, onPanelChange] [activePanels, navigate, location, onPanelChange]
); );
@@ -206,6 +221,13 @@ function BasePanelGroup({
} }
}, [activePanels, panel]); }, [activePanels, panel]);
const [isDirty, setIsDirty] = useState(false);
useWindowEvent('beforeunload', (event) => {
if (isDirty) {
event.preventDefault();
}
});
return ( return (
<Boundary label={`PanelGroup-${pageKey}`}> <Boundary label={`PanelGroup-${pageKey}`}>
<Paper p='sm' radius='xs' shadow='xs' aria-label={`${pageKey}`}> <Paper p='sm' radius='xs' shadow='xs' aria-label={`${pageKey}`}>
@@ -341,7 +363,7 @@ function BasePanelGroup({
</> </>
)} )}
<Boundary label={`PanelContent-${panel.name}`}> <Boundary label={`PanelContent-${panel.name}`}>
{panel.content} {getPanelContent(panel.content, panel, setIsDirty)}
</Boundary> </Boundary>
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>
@@ -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({ function IndexPanelComponent({
pageKey, pageKey,
selectedPanel, selectedPanel,