mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-03 19:38:41 +00:00
[Refactor] Notes (#11971)
* Display note info
* Support user locale
* Add unit testing for HTML content
* Observe color mode
* Add link between Note and NotesImage
* Ensure image file is deleted when NotesImage is deleted
* Add support for image upload in editor
* Skeleton for data migration
* Updates
* Update data migration
- Find any NoteImage items which do not link to a model
- Try to associate them with an existing note
* Remove validator
* Updated API endpoints for NotesImage model
* Update server side sanitizing
* Specify max field length
* Remove old fields from NotesImage model
* Refactor clean_string
* Remove obsolete task
* Remove legacy "notes" field from older models
* Adjust search params when switching notes
* Remove NotesFieldMixin
* Change editor
* Resizable image support
* Support tables
* Add table style
* Adjust header actions
* Add data migration for SalesOrderShipment notes
* Adjust back-end sanitizing
* Adjust
* Use subtle editor variant
* Move undo/redo
* Enhance editing logic
* Add report tags for notes
* Add unit test for note image cleanup
* Render note to HTML
- Automatically replace images
* Fix migration order
* Adjust migration text
* Fix "dirty" trigger on notes
* Prevent navigate from dirty notes
* Add documentation
* Prevent image clicking if not in editing mode
* Update API
* Update migration files
* Fix migrations
* remove notes from test fixtures
* remove notes field that does not exsist anymore
* add missing ruleset
* fix assertation
* fix assertation
* Update docs/docs/concepts/notes.md
Co-authored-by: Matthias Mair <code@mjmair.com>
* Remove blocknote deps
* Move old helper functions
- Only used for this migration
- Will potentially be removed at some point in the future?
* Revert change
* Fix note image URL
* Fix migration conflicts
* Fix migrations
* Fix delete call
* Fix content mixin
* Fix note duplication
* Make save method atomic
* Fix double-save
* Add "template" field to Note model
* Adjust migrations
* Frontend updates
* Fix for NotesEditor
* Render Note instance in forms
* Fix button-within-button
* Fix migrations
* Fix missing import
* add docs
* docs for rendering notes in reports
* Restrict queryset based on user view permissions
* APi unit tests for note permissions
* Duplicate embedded images when copying notes
* Add unit test for note duplication
* Add CHANGELOG
* Add 'copy_note' option to duplicate serializer
* Add unit tests for data migrations
* implement note duplication serializers
* frontend UI elements
* Fix migration conflicts
* Use branch for playwrigh testing
* Implement duplicate action for stock item
* Fix import
* Updated playwright tests
* Bug fix for receiving stock items
* Add screenshot
* Fix api_version
* Update unit tests
* Fix docs
* Remove defunct tests
* Fix migration order
* Adjust import/export workflow
* Manual cleaning update
* Fix migrations
* Fix migration files
* Fix for note save
* Adjust save ordering
* Skip constraint checking in NoteSerializer
* Custom validate_constraints on Note model
* Revert "Skip constraint checking in NoteSerializer"
This reverts commit b42bc955c1.
* Fix for note search
* Fix for receive_line_items
* Shim model renderer for NoteTemplate
* Fix playwright tests
* Adjust frontend CI
* Fix import/export CI job
* Fix for data migration test
* Fix migration test
* Adjust unit test
* Fix conflicting migration
* Fix unit test
* Run migration tests in parallel
* Robustify migration test
* Disable parallel options
* Fix conflicting migration
* Remove extraneous unit test
* Fix conflicting migrations
* Additional regression tests
* Check permissions before deleting Note instance
* Updated docs
* Validate note model type
* Prevent discard of unsaved changes in note editor
* Clean up dead code
* Fix migration conflict
* Improved data migration
* Prefetch role groups
* UI refactoring
* Refactor permission checking code
* Further code refactoring
* use DuplicateField helper
* Refactoring
* Add prefetch
* Throw exception rather than assert
* Logic fix for notes editor
* reimplement old background task
* Adjust data migration
* Fix notes field when receiving items
* Fix existing report templates
* Fix save action for notes editor
* Refactoring: Add "instance-info" API endpoint for common model properties
* Fix indicator dots
* Tweak nav alert msg
* Adjust layout of buttons
* Sanitize notes during migration
* Fix for NotesImage delete cascade
* Fix caching
* Fix race condition in notes editor
* Fix distinct issue when searching notse
* Fix race condition when saving new note instance
* Fix improper error
* Refactor StockItem duplication
* Refactoring
* Increase query time
* Fix api_version.py
* Additional migration tests
* Fix CI workflow
---------
Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@@ -252,10 +252,12 @@ export enum ApiEndpoints {
|
||||
|
||||
// Miscellaneous API endpoints
|
||||
attachment_list = 'attachment/',
|
||||
instance_info = 'instance-info/',
|
||||
error_report_list = 'error-report/',
|
||||
project_code_list = 'project-code/',
|
||||
custom_unit_list = 'units/',
|
||||
notes_image_upload = 'notes-image-upload/',
|
||||
note_list = 'note/',
|
||||
notes_image_list = 'note/image/',
|
||||
email_list = 'admin/email/',
|
||||
email_test = 'admin/email/test/',
|
||||
scim_config = 'admin/scim/',
|
||||
|
||||
@@ -368,5 +368,12 @@ export const ModelInformationDict: ModelDict = {
|
||||
label_multiple: () => t`Tags`,
|
||||
api_endpoint: ApiEndpoints.tag_list,
|
||||
icon: 'tag'
|
||||
},
|
||||
notetemplate: {
|
||||
label: () => t`Note Template`,
|
||||
label_multiple: () => t`Note Templates`,
|
||||
url_overview: '/settings/admin/notes',
|
||||
api_endpoint: ApiEndpoints.note_list,
|
||||
icon: 'notes'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -39,7 +39,8 @@ export enum ModelType {
|
||||
selectionlist = 'selectionlist',
|
||||
selectionentry = 'selectionentry',
|
||||
error = 'error',
|
||||
tag = 'tag'
|
||||
tag = 'tag',
|
||||
notetemplate = 'notetemplate'
|
||||
}
|
||||
|
||||
export enum PluginPanelKey {
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.2.0",
|
||||
@@ -71,11 +72,20 @@
|
||||
"@mantine/modals": "^9.2.1",
|
||||
"@mantine/notifications": "^9.2.1",
|
||||
"@mantine/spotlight": "^9.2.1",
|
||||
"@mantine/tiptap": "^9.2.1",
|
||||
"@mantine/utils": "^6.0.22",
|
||||
"@mantine/vanilla-extract": "^9.2.1",
|
||||
"@messageformat/date-skeleton": "^1.1.0",
|
||||
"@sentry/react": "^10.57.0",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tiptap/core": "^3.23.6",
|
||||
"@tiptap/extension-image": "^3.23.6",
|
||||
"@tiptap/extension-link": "^3.23.6",
|
||||
"@tiptap/extension-table": "^3.23.6",
|
||||
"@tiptap/pm": "^3.23.6",
|
||||
"@tiptap/react": "^3.23.6",
|
||||
"@tiptap/starter-kit": "^3.23.6",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.8",
|
||||
"@uiw/react-codemirror": "^4.25.8",
|
||||
"@uiw/react-split": "^5.9.4",
|
||||
@@ -85,7 +95,6 @@
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.8",
|
||||
"easymde": "^2.21.0",
|
||||
"embla-carousel": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"fuse.js": "^7.4.2",
|
||||
@@ -100,10 +109,10 @@
|
||||
"react-is": "^19.2.7",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"react-select": "^5.10.2",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"react-window": "1.8.11",
|
||||
"recharts": "^3.8.1",
|
||||
"styled-components": "^6.4.2",
|
||||
"tiptap-extension-resizable-image": "^2.1.0",
|
||||
"undici": "^8.4.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
@@ -141,7 +150,10 @@
|
||||
"vite-plugin-externals": "^0.6.2",
|
||||
"vite-plugin-istanbul": "^9.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
},
|
||||
"resolutions": {
|
||||
"glob": "^13.0.0",
|
||||
"undici": "^6.24.0",
|
||||
"vite": "^7",
|
||||
"js-yaml": "^4",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/* Table styles for the Tiptap notes editor */
|
||||
.ProseMirror table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ProseMirror table td,
|
||||
.ProseMirror table th {
|
||||
border: 1px solid var(--mantine-color-gray-4);
|
||||
padding: 4px 8px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .ProseMirror table td,
|
||||
[data-mantine-color-scheme='dark'] .ProseMirror table th {
|
||||
border-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
.ProseMirror table th {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .ProseMirror table th {
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
}
|
||||
|
||||
/* Selected cell highlight */
|
||||
.ProseMirror table .selectedCell::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--mantine-color-blue-1);
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .ProseMirror table .selectedCell::after {
|
||||
background: var(--mantine-color-blue-9);
|
||||
}
|
||||
|
||||
/* Column resize handle */
|
||||
.ProseMirror table .column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background-color: var(--mantine-color-blue-5);
|
||||
pointer-events: none;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Scrollable wrapper for wide tables */
|
||||
.ProseMirror .tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Resize cursor while dragging */
|
||||
.ProseMirror.resize-cursor {
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Disable image interaction when not in editing mode */
|
||||
.mantine-RichTextEditor-root:not([data-editing]) .ProseMirror img {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mantine-RichTextEditor-root:not([data-editing]) .node-image.ProseMirror-selectednode .image-component {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mantine-RichTextEditor-root:not([data-editing]) .node-image.ProseMirror-selectednode .image-resizer {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,239 +1,760 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { RichTextEditor } from '@mantine/tiptap';
|
||||
import '@mantine/tiptap/styles.css';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { TableKit } from '@tiptap/extension-table';
|
||||
import { useEditor, useEditorState } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import DOMPurify from 'dompurify';
|
||||
import EasyMDE, { type default as SimpleMde } from 'easymde';
|
||||
import 'easymde/dist/easymde.min.css';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import SimpleMDE from 'react-simplemde-editor';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ResizableImage } from 'tiptap-extension-resizable-image';
|
||||
import 'tiptap-extension-resizable-image/styles.css';
|
||||
import './NotesEditor.css';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelInformationDict } from '@lib/enums/ModelInformation';
|
||||
import type { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
|
||||
/*
|
||||
* A text editor component for editing notes against a model type and instance.
|
||||
* Uses the react-simple-mde editor: https://github.com/RIP21/react-simplemde-editor
|
||||
*
|
||||
* TODO:
|
||||
* - Disable editing by default when the component is launched - user can click an "edit" button to enable
|
||||
* - Allow image resizing in the future (requires back-end validation changes))
|
||||
* - Allow user to configure the editor toolbar (i.e. hide some buttons if they don't want them)
|
||||
*/
|
||||
import { identifierString } from '@lib/functions/Conversion';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
FileButton,
|
||||
Flex,
|
||||
Group,
|
||||
HoverCard,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCirclePlus,
|
||||
IconColumnInsertLeft,
|
||||
IconColumnInsertRight,
|
||||
IconColumnRemove,
|
||||
IconDeviceFloppy,
|
||||
IconInfoCircle,
|
||||
IconPencil,
|
||||
IconPhoto,
|
||||
IconReload,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconRowRemove,
|
||||
IconStar,
|
||||
IconTableOff,
|
||||
IconTablePlus,
|
||||
IconTableRow
|
||||
} from '@tabler/icons-react';
|
||||
import { formatDate } from '../../defaults/formatters';
|
||||
import { useNoteFields, useNoteTemplateFields } from '../../forms/CommonForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
DeleteItemAction,
|
||||
EditItemAction,
|
||||
OptionsActionDropdown
|
||||
} from '../items/ActionDropdown';
|
||||
import { RenderUser } from '../render/User';
|
||||
|
||||
function NoteInfoHover({ note }: { note: any }) {
|
||||
if (!note?.pk) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard position='top-end'>
|
||||
<HoverCard.Target>
|
||||
<ActionIcon variant='transparent'>
|
||||
<IconInfoCircle />
|
||||
</ActionIcon>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Stack gap='xs'>
|
||||
{note.updated && (
|
||||
<Group gap='xs' justify='space-between'>
|
||||
<Text fw='bold'>{t`Updated`}</Text>
|
||||
<Text size='xs'>
|
||||
{formatDate(note.updated, { showTime: true })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{note.updated_by_detail && (
|
||||
<Group gap='xs' justify='space-between'>
|
||||
<Text fw='bold'>{t`Updated by`}</Text>
|
||||
<RenderUser instance={note.updated_by_detail} />
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotesEditor({
|
||||
modelType,
|
||||
modelId,
|
||||
editable,
|
||||
templateMode = false,
|
||||
setDirtyCallback
|
||||
}: Readonly<{
|
||||
modelType: ModelType;
|
||||
modelId: number;
|
||||
editable?: boolean;
|
||||
modelType?: ModelType;
|
||||
modelId?: number;
|
||||
templateMode?: 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<boolean>(false);
|
||||
const [localIsDirty, setLocalIsDirty] = useState<boolean>(false);
|
||||
const user = useUserState();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [markdown, setMarkdown] = useState<string>('');
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Initially disable editing mode on load
|
||||
setEditing(false);
|
||||
}, [editable, modelId, modelType]);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDirtyCallback?.(localIsDirty);
|
||||
}, [localIsDirty]);
|
||||
const [selectedNoteId, setSelectedNoteId] = useState<number | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const noteUrl: string = useMemo(() => {
|
||||
const modelInfo = ModelInformationDict[modelType];
|
||||
return apiUrl(modelInfo.api_endpoint, modelId);
|
||||
}, [modelType, modelId]);
|
||||
|
||||
// Image upload handler
|
||||
const imageUploadHandler = useCallback(
|
||||
(
|
||||
file: File,
|
||||
onSuccess: (url: string) => void,
|
||||
onError: (error: string) => void
|
||||
) => {
|
||||
// Callback to upload an image file against the currently selected note
|
||||
const uploadFile = useCallback(
|
||||
async (file: File): Promise<string> => {
|
||||
const formData = new FormData();
|
||||
formData.append('note', selectedNoteId?.toString() ?? '');
|
||||
formData.append('image', file);
|
||||
|
||||
formData.append('model_type', modelType);
|
||||
formData.append('model_id', modelId.toString());
|
||||
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.notes_image_upload), formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
return api
|
||||
.post(apiUrl(ApiEndpoints.notes_image_list), formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error.message);
|
||||
notifications.hide('notes');
|
||||
notifications.show({
|
||||
id: 'notes',
|
||||
title: t`Error`,
|
||||
message: t`Image upload failed`,
|
||||
color: 'red'
|
||||
});
|
||||
})
|
||||
.then((response: any) => {
|
||||
onSuccess(response.data.image);
|
||||
notifications.hide('notes');
|
||||
notifications.show({
|
||||
id: 'notes',
|
||||
title: t`Success`,
|
||||
message: t`Image uploaded successfully`,
|
||||
color: 'green'
|
||||
});
|
||||
});
|
||||
.then((response) => response.data.image);
|
||||
},
|
||||
[modelType, modelId]
|
||||
[selectedNoteId]
|
||||
);
|
||||
|
||||
const dataQuery = useQuery({
|
||||
queryKey: ['notes-editor', noteUrl, modelType, modelId],
|
||||
retry: 5,
|
||||
queryFn: () =>
|
||||
api.get(noteUrl).then((response) => response.data?.notes ?? ''),
|
||||
enabled: true
|
||||
// Ref so editorProps handlers always call the latest uploadFile without stale closure
|
||||
const uploadFileRef = useRef(uploadFile);
|
||||
useEffect(() => {
|
||||
uploadFileRef.current = uploadFile;
|
||||
}, [uploadFile]);
|
||||
|
||||
const editor = useEditor({
|
||||
editable: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
link: { openOnClick: false }
|
||||
}),
|
||||
ResizableImage.configure({
|
||||
// Paste and drop are handled by the extension's built-in plugin
|
||||
onUpload: async (file: File) => {
|
||||
const src = await uploadFileRef.current(file);
|
||||
return { src, 'data-keep-ratio': true };
|
||||
}
|
||||
}),
|
||||
TableKit.configure({
|
||||
table: { resizable: false, renderWrapper: true, cellMinWidth: 50 }
|
||||
})
|
||||
],
|
||||
content: '',
|
||||
onUpdate: () => setIsDirty(true)
|
||||
});
|
||||
|
||||
// Update internal markdown data when the query data changes
|
||||
useEffect(() => {
|
||||
setMarkdown(dataQuery.data ?? '');
|
||||
}, [dataQuery.data]);
|
||||
|
||||
// Callback to save notes to the server
|
||||
const saveNotes = useCallback(
|
||||
(markdown: string) => {
|
||||
if (!noteUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.patch(noteUrl, { notes: markdown })
|
||||
.then(() => {
|
||||
notifications.hide('notes');
|
||||
notifications.show({
|
||||
title: t`Success`,
|
||||
message: t`Notes saved successfully`,
|
||||
color: 'green',
|
||||
id: 'notes',
|
||||
autoClose: 2000
|
||||
});
|
||||
setLocalIsDirty(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
notifications.hide('notes');
|
||||
|
||||
const msg =
|
||||
error?.response?.data?.non_field_errors[0] ??
|
||||
t`Failed to save notes`;
|
||||
|
||||
notifications.show({
|
||||
title: t`Error Saving Notes`,
|
||||
message: msg,
|
||||
color: 'red',
|
||||
id: 'notes'
|
||||
});
|
||||
});
|
||||
},
|
||||
[api, noteUrl]
|
||||
const notesQueryKey = useMemo(
|
||||
() => ['notes', modelType, modelId, templateMode],
|
||||
[modelType, modelId, templateMode]
|
||||
);
|
||||
|
||||
const editorOptions: SimpleMde.Options = useMemo(() => {
|
||||
const icons: any[] = [];
|
||||
// Fetch the available notes for the given model type and ID (or all templates)
|
||||
const notesQuery = useQuery({
|
||||
queryKey: notesQueryKey,
|
||||
queryFn: async () => {
|
||||
const params: Record<string, any> = templateMode
|
||||
? { template: true }
|
||||
: { model_id: modelId, model_type: modelType };
|
||||
|
||||
if (editing) {
|
||||
icons.push({
|
||||
name: 'save-notes',
|
||||
action: (editor: SimpleMde) => {
|
||||
saveNotes(editor.value());
|
||||
},
|
||||
className: 'fa fa-save',
|
||||
title: t`Save Notes`
|
||||
});
|
||||
return api
|
||||
.get(apiUrl(ApiEndpoints.note_list), { params })
|
||||
.then((response) => response.data ?? []);
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true,
|
||||
enabled: templateMode ? true : !!modelId && !!modelType
|
||||
});
|
||||
|
||||
icons.push('|');
|
||||
const [selectedNote, setSelectedNote] = useState<any>(undefined);
|
||||
|
||||
icons.push('heading-1', 'heading-2', 'heading-3', '|'); // Headings
|
||||
icons.push('bold', 'italic', 'strikethrough', '|'); // Text styles
|
||||
icons.push('unordered-list', 'ordered-list', 'code', 'quote', '|'); // Text formatting
|
||||
icons.push('table', 'link', 'image', '|');
|
||||
icons.push('horizontal-rule', '|', 'guide'); // Misc
|
||||
// Push a note's content into the editor, discarding any local unsaved edits.
|
||||
// Used both for switching to a different note, and for the explicit "reset"
|
||||
// action, which intentionally discards unsaved changes.
|
||||
const applyNoteContent = useCallback(
|
||||
(note: any) => {
|
||||
if (editor && !editor.isDestroyed) {
|
||||
// Pass emitUpdate:false to avoid triggering dirty state when loading content
|
||||
editor.commands.setContent(
|
||||
note ? DOMPurify.sanitize(note.content ?? '') : '',
|
||||
{ emitUpdate: false }
|
||||
);
|
||||
}
|
||||
|
||||
icons.push('|', 'undo', 'redo'); // Undo/Redo
|
||||
setIsDirty(false);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
icons.push('|');
|
||||
|
||||
icons.push({
|
||||
name: 'edit-disabled',
|
||||
action: () => setEditing(false),
|
||||
className: 'fa fa-times',
|
||||
title: t`Close Editor`
|
||||
});
|
||||
} else if (editable) {
|
||||
icons.push({
|
||||
name: 'edit-enabled',
|
||||
action: () => setEditing(true),
|
||||
className: 'fa fa-edit',
|
||||
title: t`Enable Editing`
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
toolbar: icons,
|
||||
uploadImage: true,
|
||||
imagePathAbsolute: true,
|
||||
imageUploadFunction: imageUploadHandler,
|
||||
renderingConfig: {
|
||||
sanitizerFunction: (html: string) => {
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
},
|
||||
sideBySideFullscreen: false,
|
||||
shortcuts: {},
|
||||
spellChecker: false
|
||||
};
|
||||
}, [editable, editing]);
|
||||
|
||||
const [mdeInstance, setMdeInstance] = useState<SimpleMde | null>(null);
|
||||
// Track which note's content is currently loaded into the editor. A
|
||||
// background refetch of the notes list (e.g. after saving, editing a note's
|
||||
// metadata elsewhere, or another tab changing notes) must not silently
|
||||
// overwrite content the user is actively editing - only (re)load editor
|
||||
// content when the selected note itself changes, or when there are no
|
||||
// unsaved local edits to protect. Header metadata (title/description/etc.)
|
||||
// stays in sync regardless, since only the editor content is at risk.
|
||||
const loadedNoteIdRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (mdeInstance) {
|
||||
const previewMode = !(editable && editing);
|
||||
const noteId = selectedNoteId ?? -1;
|
||||
const note = notesQuery.data?.find((note: any) => note.pk === noteId);
|
||||
|
||||
mdeInstance.codemirror?.setOption('readOnly', previewMode);
|
||||
setSelectedNote(note);
|
||||
|
||||
// Ensure the preview mode is toggled if required
|
||||
if (mdeInstance.isPreviewActive() != previewMode) {
|
||||
const sibling =
|
||||
mdeInstance?.codemirror.getWrapperElement()?.nextSibling;
|
||||
|
||||
if (sibling != null && editable != false) {
|
||||
EasyMDE.togglePreview(mdeInstance);
|
||||
}
|
||||
}
|
||||
const switchingNote = loadedNoteIdRef.current !== noteId;
|
||||
if (switchingNote || !isDirty) {
|
||||
loadedNoteIdRef.current = noteId;
|
||||
applyNoteContent(note);
|
||||
}
|
||||
}, [mdeInstance, editable, editing]);
|
||||
}, [editor, selectedNoteId, notesQuery.data, isDirty, applyNoteContent]);
|
||||
|
||||
// Adjust the note selection
|
||||
useEffect(() => {
|
||||
if (!notesQuery.data) return;
|
||||
|
||||
const stillExists =
|
||||
selectedNoteId &&
|
||||
notesQuery.data.some((note: any) => note.pk === selectedNoteId);
|
||||
if (stillExists) return;
|
||||
|
||||
const paramSlug = searchParams.get('note');
|
||||
const fromParam =
|
||||
paramSlug &&
|
||||
notesQuery.data.find(
|
||||
(note: any) => identifierString(note.title ?? '') === paramSlug
|
||||
);
|
||||
|
||||
if (fromParam) {
|
||||
setSelectedNoteId(fromParam.pk);
|
||||
return;
|
||||
}
|
||||
|
||||
const primary = notesQuery.data.find((note: any) => note.primary);
|
||||
setSelectedNoteId((primary ?? notesQuery.data[0])?.pk ?? undefined);
|
||||
}, [notesQuery.data]);
|
||||
|
||||
// Templates are staff-only; regular notes follow the linked model's permissions
|
||||
const hasNotePermission = useCallback(
|
||||
(action: 'change' | 'delete'): boolean => {
|
||||
if (templateMode) {
|
||||
return user.isStaff();
|
||||
}
|
||||
if (!modelType) {
|
||||
return false;
|
||||
}
|
||||
return action === 'change'
|
||||
? user.hasChangePermission(modelType)
|
||||
: user.hasDeletePermission(modelType);
|
||||
},
|
||||
[user, modelType, templateMode]
|
||||
);
|
||||
|
||||
const canEdit: boolean = useMemo(
|
||||
() =>
|
||||
hasNotePermission('change') &&
|
||||
notesQuery.isFetched &&
|
||||
notesQuery.isSuccess &&
|
||||
!!notesQuery.data,
|
||||
[hasNotePermission, notesQuery]
|
||||
);
|
||||
|
||||
const isInTable = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: e }) => e?.isActive('table') ?? false
|
||||
});
|
||||
|
||||
// Propagate dirty state up to the panel system for navigation guards
|
||||
useEffect(() => {
|
||||
setDirtyCallback?.(isDirty);
|
||||
}, [isDirty, setDirtyCallback]);
|
||||
|
||||
// Sync editor editable state when permissions change.
|
||||
// Pass false for emitUpdate to avoid triggering onUpdate (which sets isDirty).
|
||||
useEffect(() => {
|
||||
editor?.setEditable(canEdit && isEditing, false);
|
||||
}, [editor, canEdit, isEditing]);
|
||||
|
||||
const hasNotes = useMemo(() => {
|
||||
return notesQuery.data && notesQuery.data.length > 0;
|
||||
}, [notesQuery.data]);
|
||||
|
||||
const noteFields = useNoteFields({
|
||||
modelType: modelType!,
|
||||
modelId: modelId!
|
||||
});
|
||||
const noteTemplateFields = useNoteTemplateFields();
|
||||
const activeFields = templateMode ? noteTemplateFields : noteFields;
|
||||
|
||||
const createNote = useCreateApiFormModal({
|
||||
title: templateMode ? t`Add Note Template` : t`Add Note`,
|
||||
fields: activeFields,
|
||||
url: apiUrl(ApiEndpoints.note_list),
|
||||
method: 'POST',
|
||||
successMessage: null,
|
||||
onFormSuccess: (response: any) => {
|
||||
notesQuery.refetch().then(() => {
|
||||
setSelectedNoteId(response.pk);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const deleteNote = useDeleteApiFormModal({
|
||||
title: templateMode ? t`Delete Note Template` : t`Delete Note`,
|
||||
url: apiUrl(ApiEndpoints.note_list),
|
||||
pk: selectedNoteId,
|
||||
onFormSuccess: () => {
|
||||
// Deleting the currently-open note can leave no note selected (if it was
|
||||
// the last one) - exit edit mode too, so the UI doesn't get stranded
|
||||
// with 'isEditing' stuck true and nothing left to edit/select.
|
||||
setIsEditing(false);
|
||||
setSelectedNoteId(undefined);
|
||||
notesQuery.refetch();
|
||||
}
|
||||
});
|
||||
|
||||
const editNote = useEditApiFormModal({
|
||||
title: templateMode ? t`Edit Note Template` : t`Edit Note`,
|
||||
fields: activeFields,
|
||||
url: apiUrl(ApiEndpoints.note_list),
|
||||
pk: selectedNoteId,
|
||||
onFormSuccess: (response: any) => {
|
||||
notesQuery.refetch().then(() => {
|
||||
setSelectedNoteId(response.pk);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const reloadNote = useCallback(() => {
|
||||
const note = notesQuery.data?.find(
|
||||
(note: any) => note.pk === (selectedNoteId ?? -1)
|
||||
);
|
||||
applyNoteContent(note);
|
||||
}, [selectedNoteId, notesQuery.data, applyNoteContent]);
|
||||
|
||||
const saveNote = useCallback(() => {
|
||||
// Guard against the global mod+s hotkey firing while there's nothing to
|
||||
// save - e.g. the user isn't currently editing this note, or lacks
|
||||
// permission to (in which case the editor was never made editable, so
|
||||
// there's nothing dirty to persist anyway).
|
||||
if (!canEdit || !isEditing || !selectedNoteId || !editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanHtml = DOMPurify.sanitize(editor.getHTML());
|
||||
|
||||
const url = apiUrl(ApiEndpoints.note_list, selectedNoteId);
|
||||
|
||||
notifications.hide('note-update-status');
|
||||
|
||||
api
|
||||
.patch(url, { content: cleanHtml })
|
||||
.then((response) => {
|
||||
// Merge the updated note directly into the cached notes list, rather
|
||||
// than refetching - a refetch is async, so the content-sync effect
|
||||
// (keyed on isDirty) can run against the *old* cached data in the gap
|
||||
// between setIsDirty(false) below and the refetch resolving, visibly
|
||||
// reverting the editor to the pre-save content until it lands.
|
||||
queryClient.setQueryData(notesQueryKey, (previous: any[] | undefined) =>
|
||||
previous?.map((note: any) =>
|
||||
note.pk === selectedNoteId ? (response.data ?? note) : note
|
||||
)
|
||||
);
|
||||
setIsDirty(false);
|
||||
notifications.show({
|
||||
title: t`Success`,
|
||||
message: t`Note updated`,
|
||||
color: 'green',
|
||||
id: 'note-update-status',
|
||||
autoClose: 2000
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
notifications.show({
|
||||
title: t`Error`,
|
||||
message: t`Failed to update note: ${error.message}`,
|
||||
color: 'red',
|
||||
id: 'note-update-status',
|
||||
autoClose: 2000
|
||||
});
|
||||
});
|
||||
}, [
|
||||
canEdit,
|
||||
isEditing,
|
||||
selectedNoteId,
|
||||
editor,
|
||||
queryClient,
|
||||
notesQueryKey,
|
||||
setIsDirty
|
||||
]);
|
||||
|
||||
useHotkeys([['mod+s', saveNote]]);
|
||||
|
||||
const handleImageUpload = useCallback(
|
||||
async (file: File | null) => {
|
||||
if (!file || !editor) return;
|
||||
try {
|
||||
const src = await uploadFile(file);
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setResizableImage({ src, 'data-keep-ratio': true })
|
||||
.run();
|
||||
} catch {
|
||||
notifications.show({
|
||||
title: t`Error`,
|
||||
message: t`Failed to upload image`,
|
||||
color: 'red',
|
||||
autoClose: 2000
|
||||
});
|
||||
}
|
||||
},
|
||||
[editor, uploadFile]
|
||||
);
|
||||
|
||||
return (
|
||||
<SimpleMDE
|
||||
autoFocus
|
||||
getMdeInstance={(instance: SimpleMde) => setMdeInstance(instance)}
|
||||
onChange={(value: string) => {
|
||||
setMarkdown(value);
|
||||
setLocalIsDirty(true);
|
||||
}}
|
||||
options={editorOptions}
|
||||
value={markdown}
|
||||
/>
|
||||
<>
|
||||
{createNote.modal}
|
||||
{deleteNote.modal}
|
||||
{editNote.modal}
|
||||
<Flex align='left' gap={5}>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Stack gap={5}>
|
||||
{selectedNote && (
|
||||
<Paper p='xs' shadow='sm' withBorder>
|
||||
<Group justify='space-between'>
|
||||
<Group justify='left' gap='lg'>
|
||||
<Text fw='bold'>{selectedNote?.title}</Text>
|
||||
<Text size='sm'>{selectedNote?.description}</Text>
|
||||
</Group>
|
||||
{canEdit && (
|
||||
<Group justify='right' gap='xs'>
|
||||
{!isEditing && (
|
||||
<Tooltip label={t`Edit note`} position='top-end'>
|
||||
<ActionIcon
|
||||
aria-label='edit-note'
|
||||
variant='transparent'
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<IconPencil />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isEditing && isDirty && (
|
||||
<Badge color='yellow'>{t`Unsaved Changes`}</Badge>
|
||||
)}
|
||||
{isEditing && isDirty && (
|
||||
<Tooltip label={t`Save note`} position='top-end'>
|
||||
<ActionIcon
|
||||
aria-label='save-note'
|
||||
variant='transparent'
|
||||
color={'green'}
|
||||
onClick={saveNote}
|
||||
disabled={!canEdit || !isDirty}
|
||||
>
|
||||
<IconDeviceFloppy />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isEditing && isDirty && (
|
||||
<Tooltip
|
||||
label={t`Reset note content`}
|
||||
position='top-end'
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label='reset-note'
|
||||
variant='transparent'
|
||||
onClick={reloadNote}
|
||||
disabled={!canEdit || !isDirty}
|
||||
>
|
||||
<IconReload />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isEditing && !isDirty && (
|
||||
<Tooltip label={t`Finish editing`} position='top-end'>
|
||||
<ActionIcon
|
||||
aria-label='finish-editing-note'
|
||||
variant='transparent'
|
||||
onClick={() => setIsEditing(false)}
|
||||
color='green'
|
||||
>
|
||||
<IconCheck />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<NoteInfoHover note={selectedNote} />
|
||||
<OptionsActionDropdown
|
||||
tooltip={t`Note Actions`}
|
||||
tooltipPosition='top-end'
|
||||
actions={[
|
||||
EditItemAction({
|
||||
hidden: !selectedNote || !canEdit,
|
||||
onClick: () => {
|
||||
editNote.open();
|
||||
}
|
||||
}),
|
||||
DeleteItemAction({
|
||||
hidden:
|
||||
!selectedNote ||
|
||||
isEditing ||
|
||||
!hasNotePermission('delete'),
|
||||
onClick: () => {
|
||||
deleteNote.open();
|
||||
}
|
||||
})
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
<Paper p='xs' shadow='sm' withBorder>
|
||||
{hasNotes ? (
|
||||
<RichTextEditor
|
||||
variant='subtle'
|
||||
editor={editor}
|
||||
style={{ minHeight: '400px' }}
|
||||
data-editing={isEditing || undefined}
|
||||
>
|
||||
{canEdit && isEditing && (
|
||||
<RichTextEditor.Toolbar sticky>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Bold />
|
||||
<RichTextEditor.Italic />
|
||||
<RichTextEditor.Underline />
|
||||
<RichTextEditor.Strikethrough />
|
||||
<RichTextEditor.ClearFormatting />
|
||||
<RichTextEditor.Code />
|
||||
<RichTextEditor.CodeBlock />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.H1 />
|
||||
<RichTextEditor.H2 />
|
||||
<RichTextEditor.H3 />
|
||||
<RichTextEditor.H4 />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Blockquote />
|
||||
<RichTextEditor.Hr />
|
||||
<RichTextEditor.BulletList />
|
||||
<RichTextEditor.OrderedList />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Link />
|
||||
<RichTextEditor.Unlink />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<FileButton
|
||||
onChange={handleImageUpload}
|
||||
accept='image/*'
|
||||
>
|
||||
{(props) => (
|
||||
<Tooltip label={t`Upload Image`}>
|
||||
<ActionIcon
|
||||
variant='default'
|
||||
size='sm'
|
||||
{...props}
|
||||
>
|
||||
<IconPhoto size='0.9rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FileButton>
|
||||
</RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Control
|
||||
onClick={() =>
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.insertTable({
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
withHeaderRow: true
|
||||
})
|
||||
.run()
|
||||
}
|
||||
aria-label={t`Insert table`}
|
||||
title={t`Insert table`}
|
||||
>
|
||||
<IconTablePlus size='0.9rem' />
|
||||
</RichTextEditor.Control>
|
||||
<RichTextEditor.Control
|
||||
disabled={!isInTable}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().addColumnBefore().run()
|
||||
}
|
||||
aria-label={t`Add column before`}
|
||||
title={t`Add column before`}
|
||||
>
|
||||
<IconColumnInsertLeft size='0.9rem' />
|
||||
</RichTextEditor.Control>
|
||||
<RichTextEditor.Control
|
||||
disabled={!isInTable}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().addColumnAfter().run()
|
||||
}
|
||||
aria-label={t`Add column after`}
|
||||
title={t`Add column after`}
|
||||
>
|
||||
<IconColumnInsertRight size='0.9rem' />
|
||||
</RichTextEditor.Control>
|
||||
<RichTextEditor.Control
|
||||
disabled={!isInTable}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().deleteColumn().run()
|
||||
}
|
||||
aria-label={t`Delete column`}
|
||||
title={t`Delete column`}
|
||||
>
|
||||
<IconColumnRemove size='0.9rem' />
|
||||
</RichTextEditor.Control>
|
||||
<RichTextEditor.Control
|
||||
disabled={!isInTable}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().addRowBefore().run()
|
||||
}
|
||||
aria-label={t`Add row before`}
|
||||
title={t`Add row before`}
|
||||
>
|
||||
<IconRowInsertTop size='0.9rem' />
|
||||
</RichTextEditor.Control>
|
||||
<RichTextEditor.Control
|
||||
disabled={!isInTable}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().addRowAfter().run()
|
||||
}
|
||||
aria-label={t`Add row after`}
|
||||
title={t`Add row after`}
|
||||
>
|
||||
<IconRowInsertBottom size='0.9rem' />
|
||||
</RichTextEditor.Control>
|
||||
<RichTextEditor.Control
|
||||
disabled={!isInTable}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().deleteRow().run()
|
||||
}
|
||||
aria-label={t`Delete row`}
|
||||
title={t`Delete row`}
|
||||
>
|
||||
<IconRowRemove size='0.9rem' />
|
||||
</RichTextEditor.Control>
|
||||
<RichTextEditor.Control
|
||||
disabled={!isInTable}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleHeaderRow().run()
|
||||
}
|
||||
aria-label={t`Toggle header row`}
|
||||
title={t`Toggle header row`}
|
||||
>
|
||||
<IconTableRow size='0.9rem' />
|
||||
</RichTextEditor.Control>
|
||||
<RichTextEditor.Control
|
||||
disabled={!isInTable}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().deleteTable().run()
|
||||
}
|
||||
aria-label={t`Delete table`}
|
||||
title={t`Delete table`}
|
||||
>
|
||||
<IconTableOff size='0.9rem' />
|
||||
</RichTextEditor.Control>
|
||||
</RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Undo />
|
||||
<RichTextEditor.Redo />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
</RichTextEditor.Toolbar>
|
||||
)}
|
||||
<RichTextEditor.Content />
|
||||
</RichTextEditor>
|
||||
) : (
|
||||
<Alert title={t`Notes`} icon={<IconInfoCircle />}>
|
||||
{t`There are no notes here yet.`}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Paper p='xs' shadow='sm' withBorder style={{ minWidth: '200px' }}>
|
||||
<Stack gap='xs'>
|
||||
{canEdit && (
|
||||
<Button
|
||||
color='green'
|
||||
leftSection={<IconCirclePlus />}
|
||||
onClick={createNote.open}
|
||||
disabled={isEditing}
|
||||
>
|
||||
{t`Add Note`}
|
||||
</Button>
|
||||
)}
|
||||
<Tabs
|
||||
orientation='vertical'
|
||||
placement='right'
|
||||
value={selectedNoteId?.toString()}
|
||||
>
|
||||
<Tabs.List style={{ width: '100%' }}>
|
||||
{notesQuery.data?.map((note: any) => (
|
||||
<Tabs.Tab
|
||||
key={note.pk}
|
||||
disabled={isEditing}
|
||||
value={note.pk?.toString()}
|
||||
onClick={() => {
|
||||
setSelectedNoteId(note.pk);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
prev.set('note', identifierString(note.title ?? ''));
|
||||
return prev;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Group gap='xs' wrap='nowrap' justify='space-between'>
|
||||
<Text size='sm'>{note.title}</Text>
|
||||
{note.primary && (
|
||||
<IconStar
|
||||
size={14}
|
||||
color='var(--mantine-color-yellow-6)'
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,16 +134,19 @@ export function ActionDropdown({
|
||||
export function OptionsActionDropdown({
|
||||
actions = [],
|
||||
tooltip = t`Options`,
|
||||
tooltipPosition = 'bottom',
|
||||
hidden = false
|
||||
}: Readonly<{
|
||||
actions: ActionDropdownItem[];
|
||||
tooltip?: string;
|
||||
tooltipPosition?: FloatingPosition;
|
||||
hidden?: boolean;
|
||||
}>) {
|
||||
return (
|
||||
<ActionDropdown
|
||||
icon={<IconDotsVertical />}
|
||||
tooltip={tooltip}
|
||||
tooltipPosition={tooltipPosition}
|
||||
actions={actions}
|
||||
hidden={hidden}
|
||||
noindicator
|
||||
|
||||
@@ -3,38 +3,24 @@ import { Skeleton } from '@mantine/core';
|
||||
import { IconPaperclip } from '@tabler/icons-react';
|
||||
|
||||
import type { ModelType } from '@lib/enums/ModelType';
|
||||
import { ApiEndpoints, apiUrl } from '@lib/index';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
import { api } from '../../App';
|
||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||
|
||||
export default function AttachmentPanel({
|
||||
model_type,
|
||||
model_id
|
||||
model_id,
|
||||
attachment_count
|
||||
}: {
|
||||
model_type: ModelType;
|
||||
model_id: number | undefined;
|
||||
attachment_count?: number;
|
||||
}): PanelType {
|
||||
return {
|
||||
name: 'attachments',
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
hotkey: 'mod+Shift+A',
|
||||
notification_dot: async () => {
|
||||
if (!model_type || !model_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return api
|
||||
.get(apiUrl(ApiEndpoints.attachment_list), {
|
||||
params: {
|
||||
model_type: model_type,
|
||||
model_id: model_id,
|
||||
limit: 1
|
||||
}
|
||||
})
|
||||
.then((response) => ((response.data?.count ?? 0) > 0 ? 'info' : null));
|
||||
},
|
||||
notification_dot: attachment_count ? 'info' : null,
|
||||
content:
|
||||
model_type && model_id ? (
|
||||
<AttachmentTable model_type={model_type} model_id={model_id} />
|
||||
|
||||
@@ -4,37 +4,30 @@ import { IconNotes } from '@tabler/icons-react';
|
||||
|
||||
import type { ModelType } from '@lib/enums/ModelType';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
import { lazy } from 'react';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import NotesEditor from '../editors/NotesEditor';
|
||||
|
||||
const NotesEditor = lazy(() => import('../editors/NotesEditor'));
|
||||
// const NotesEditor = lazy(() => import('../editors/NotesEditor'));
|
||||
|
||||
export default function NotesPanel({
|
||||
model_type,
|
||||
model_id,
|
||||
editable,
|
||||
has_note
|
||||
note_count
|
||||
}: {
|
||||
model_type: ModelType;
|
||||
model_id: number | undefined;
|
||||
editable?: boolean;
|
||||
has_note?: boolean;
|
||||
note_count?: number;
|
||||
}): PanelType {
|
||||
const user = useUserState.getState();
|
||||
|
||||
return {
|
||||
name: 'notes',
|
||||
label: t`Notes`,
|
||||
icon: <IconNotes />,
|
||||
hotkey: 'mod+Shift+N',
|
||||
notification_dot: has_note ? 'info' : null,
|
||||
notification_dot: note_count ? 'info' : null,
|
||||
content:
|
||||
model_type && model_id ? (
|
||||
<NotesEditor
|
||||
modelType={model_type}
|
||||
modelId={model_id}
|
||||
editable={editable ?? user.hasChangePermission(model_type)}
|
||||
/>
|
||||
<NotesEditor modelType={model_type} modelId={model_id} />
|
||||
) : (
|
||||
<Skeleton />
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Box,
|
||||
Divider,
|
||||
Group,
|
||||
@@ -10,10 +11,12 @@ import {
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
UnstyledButton
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconExclamationCircle,
|
||||
IconLayoutSidebarLeftCollapse,
|
||||
IconLayoutSidebarRightCollapse
|
||||
} from '@tabler/icons-react';
|
||||
@@ -51,6 +54,7 @@ import type {
|
||||
} from '@lib/types/Panel';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useDocumentVisibility, useWindowEvent } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { generateUrl } from '../../functions/urls';
|
||||
@@ -105,26 +109,34 @@ function PanelTabComponent({
|
||||
const visibility = useDocumentVisibility();
|
||||
const location = useLocation();
|
||||
|
||||
// Check if we should display an indicator dot for this panel
|
||||
const isDynamicDot = typeof panel.notification_dot === 'function';
|
||||
|
||||
// Check if we should display an indicator dot for this panel.
|
||||
// Only self-fetching (function) dots go through react-query, as they need
|
||||
// caching around their own async call. Static dot values are derived from
|
||||
// props the caller already re-renders on, so they are read directly below -
|
||||
// routing them through a query keyed only on panel.name would freeze the
|
||||
// first-seen value (e.g. `null` before data loads) and never update.
|
||||
const notificationDot = useQuery({
|
||||
enabled: panel.notification_dot !== undefined && visibility === 'visible',
|
||||
enabled: isDynamicDot && visibility === 'visible',
|
||||
queryKey: ['panel-notification', panel.name],
|
||||
queryFn: async () => {
|
||||
if (panel.notification_dot === undefined) {
|
||||
return null;
|
||||
} else if (typeof panel.notification_dot === 'function') {
|
||||
if (typeof panel.notification_dot === 'function') {
|
||||
return await panel.notification_dot();
|
||||
} else {
|
||||
return panel.notification_dot as PanelIndicatorType;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // cache for 5 minutes
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
||||
const indicatorValue: PanelIndicatorType | undefined = isDynamicDot
|
||||
? notificationDot.data
|
||||
: (panel.notification_dot as PanelIndicatorType | undefined);
|
||||
|
||||
const indicatorColor: MantineColor | undefined = useMemo(() => {
|
||||
switch (notificationDot.data) {
|
||||
switch (indicatorValue) {
|
||||
case 'info':
|
||||
return 'blue';
|
||||
case 'warning':
|
||||
@@ -134,7 +146,7 @@ function PanelTabComponent({
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [notificationDot.data]);
|
||||
}, [indicatorValue]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -291,21 +303,15 @@ function BasePanelGroup({
|
||||
[allPanels]
|
||||
);
|
||||
|
||||
// Callback when the active panel changes
|
||||
const handlePanelChange = useCallback(
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
useWindowEvent('beforeunload', (event) => {
|
||||
if (isDirty) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
const performPanelChange = 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}${location.search}`;
|
||||
navigateToLink(url, navigate, event);
|
||||
@@ -315,15 +321,43 @@ function BasePanelGroup({
|
||||
|
||||
localState.setLastUsedPanel(pageKey)(targetPanel);
|
||||
|
||||
// Optionally call external callback hook
|
||||
if (targetPanel && onPanelChange) {
|
||||
onPanelChange(targetPanel);
|
||||
}
|
||||
|
||||
// change dirty state
|
||||
setIsDirty(false);
|
||||
},
|
||||
[activePanels, navigate, location, onPanelChange]
|
||||
[navigate, location, pageKey, onPanelChange]
|
||||
);
|
||||
|
||||
// Callback when the active panel changes
|
||||
const handlePanelChange = useCallback(
|
||||
(targetPanel: string, event?: any) => {
|
||||
cancelEvent(event);
|
||||
|
||||
if (isDirty) {
|
||||
modals.openConfirmModal({
|
||||
title: <Title order={4}>{t`Unsaved Changes`}</Title>,
|
||||
children: (
|
||||
<>
|
||||
<Divider />
|
||||
<Alert
|
||||
color='red'
|
||||
icon={<IconExclamationCircle />}
|
||||
p='sm'
|
||||
>{t`You have unsaved changes. Are you sure you want to leave this panel?`}</Alert>
|
||||
</>
|
||||
),
|
||||
labels: { confirm: t`Leave`, cancel: t`Stay` },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: () => performPanelChange(targetPanel, event)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
performPanelChange(targetPanel, event);
|
||||
},
|
||||
[isDirty, performPanelChange]
|
||||
);
|
||||
|
||||
// if the selected panel state changes update the current panel
|
||||
@@ -358,13 +392,6 @@ function BasePanelGroup({
|
||||
}, [activePanels]);
|
||||
useInvenTreeHotkeys(hotkeys);
|
||||
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
useWindowEvent('beforeunload', (event) => {
|
||||
if (isDirty) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Boundary label={`PanelGroup-${pageKey}`}>
|
||||
<Paper p='sm' radius='xs' shadow='xs' aria-label={`${pageKey}`}>
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import type { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Skeleton } from '@mantine/core';
|
||||
import { IconListDetails } from '@tabler/icons-react';
|
||||
import { api } from '../../App';
|
||||
import { ParameterTable } from '../../tables/general/ParameterTable';
|
||||
|
||||
export default function ParametersPanel({
|
||||
model_type,
|
||||
model_id,
|
||||
hidden,
|
||||
allowEdit = true
|
||||
allowEdit = true,
|
||||
parameter_count
|
||||
}: {
|
||||
model_type: ModelType;
|
||||
model_id: number | undefined;
|
||||
hidden?: boolean;
|
||||
allowEdit?: boolean;
|
||||
parameter_count?: number;
|
||||
}): PanelType {
|
||||
return {
|
||||
name: 'parameters',
|
||||
@@ -25,21 +24,7 @@ export default function ParametersPanel({
|
||||
icon: <IconListDetails />,
|
||||
hotkey: 'mod+Shift+P',
|
||||
hidden: hidden ?? false,
|
||||
notification_dot: async () => {
|
||||
if (!model_type || !model_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return api
|
||||
.get(apiUrl(ApiEndpoints.parameter_list), {
|
||||
params: {
|
||||
model_type: model_type,
|
||||
model_id: model_id,
|
||||
limit: 1
|
||||
}
|
||||
})
|
||||
.then((response) => ((response.data?.count ?? 0) > 0 ? 'info' : null));
|
||||
},
|
||||
notification_dot: parameter_count ? 'info' : null,
|
||||
content:
|
||||
model_type && model_id ? (
|
||||
<ParameterTable
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
RenderSelectionList,
|
||||
RenderTag
|
||||
} from './Generic';
|
||||
import { RenderNoteTemplate } from './Note';
|
||||
import {
|
||||
RenderPurchaseOrder,
|
||||
RenderReturnOrder,
|
||||
@@ -48,6 +49,7 @@ registerModelRenderers({
|
||||
[ModelType.parameter]: RenderParameter,
|
||||
[ModelType.parametertemplate]: RenderParameterTemplate,
|
||||
[ModelType.manufacturerpart]: RenderManufacturerPart,
|
||||
[ModelType.notetemplate]: RenderNoteTemplate,
|
||||
[ModelType.owner]: RenderOwner,
|
||||
[ModelType.part]: RenderPart,
|
||||
[ModelType.partcategory]: RenderPartCategory,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { type InstanceRenderInterface, RenderInlineModel } from './Instance';
|
||||
|
||||
export function RenderNoteTemplate({
|
||||
instance
|
||||
}: Readonly<InstanceRenderInterface>): ReactNode {
|
||||
return (
|
||||
instance && (
|
||||
<RenderInlineModel
|
||||
primary={instance.title}
|
||||
suffix={instance.description}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -139,7 +139,8 @@ export function useBuildOrderFields({
|
||||
duplicate: DuplicateField({
|
||||
originalId: duplicateBuildId,
|
||||
extraFields: {
|
||||
copy_parameters: {}
|
||||
copy_parameters: {},
|
||||
copy_notes: {}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import type {
|
||||
StatusCodeInterface,
|
||||
StatusCodeListInterface
|
||||
@@ -305,6 +306,94 @@ export function useParameterFields({
|
||||
]);
|
||||
}
|
||||
|
||||
export function useNoteTemplateFields(): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
template: {
|
||||
hidden: true,
|
||||
value: true
|
||||
},
|
||||
model_type: {
|
||||
label: t`Model Type`,
|
||||
description: t`Limit this template to a specific model type, or leave blank for all models`,
|
||||
required: false
|
||||
},
|
||||
title: {},
|
||||
description: {}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useNoteFields({
|
||||
modelType,
|
||||
modelId
|
||||
}: {
|
||||
modelType: ModelType;
|
||||
modelId: number;
|
||||
}): ApiFormFieldSet {
|
||||
const api = useApi();
|
||||
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [description, setDescription] = useState<string>('');
|
||||
const [content, setContent] = useState<string>('');
|
||||
|
||||
const fetchTemplate = useCallback(
|
||||
(pk: number | null) => {
|
||||
if (!pk) return;
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.note_list, pk))
|
||||
.then((response) => {
|
||||
setTitle(response.data.title ?? '');
|
||||
setDescription(response.data.description ?? '');
|
||||
setContent(response.data.content ?? '');
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
[api]
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
model_type: {
|
||||
hidden: true,
|
||||
value: modelType
|
||||
},
|
||||
model_id: {
|
||||
hidden: true,
|
||||
value: modelId
|
||||
},
|
||||
template_source: {
|
||||
field_type: 'related field',
|
||||
label: t`From Template`,
|
||||
description: t`Optionally pre-fill this note from an existing template`,
|
||||
model: ModelType.notetemplate,
|
||||
api_url: apiUrl(ApiEndpoints.note_list),
|
||||
filters: {
|
||||
template: true,
|
||||
model_type: modelType
|
||||
},
|
||||
pk_field: 'pk',
|
||||
required: false,
|
||||
onValueChange: (value: any) => fetchTemplate(value),
|
||||
value: null
|
||||
},
|
||||
title: {
|
||||
value: title,
|
||||
onValueChange: (value: any) => setTitle(value)
|
||||
},
|
||||
description: {
|
||||
value: description,
|
||||
onValueChange: (value: any) => setDescription(value)
|
||||
},
|
||||
primary: {},
|
||||
content: {
|
||||
hidden: true,
|
||||
value: content
|
||||
}
|
||||
};
|
||||
}, [modelType, modelId, title, description, content, fetchTemplate]);
|
||||
}
|
||||
|
||||
export function selectionListFields(): ApiFormFieldSet {
|
||||
return {
|
||||
name: {},
|
||||
|
||||
@@ -101,7 +101,8 @@ export function useSupplierPartFields({
|
||||
duplicate: DuplicateField({
|
||||
originalId: duplicateSupplierPartId,
|
||||
extraFields: {
|
||||
copy_parameters: {}
|
||||
copy_parameters: {},
|
||||
copy_notes: {}
|
||||
}
|
||||
})
|
||||
};
|
||||
@@ -146,7 +147,8 @@ export function useManufacturerPartFields({
|
||||
duplicate: DuplicateField({
|
||||
originalId: duplicateManufacturerPartId,
|
||||
extraFields: {
|
||||
copy_parameters: {}
|
||||
copy_parameters: {},
|
||||
copy_notes: {}
|
||||
}
|
||||
})
|
||||
};
|
||||
@@ -191,7 +193,8 @@ export function companyFields({
|
||||
duplicate: DuplicateField({
|
||||
originalId: duplicateCompanyId,
|
||||
extraFields: {
|
||||
copy_parameters: {}
|
||||
copy_parameters: {},
|
||||
copy_notes: {}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -325,7 +325,8 @@ export function usePurchaseOrderFields({
|
||||
},
|
||||
copy_lines: {},
|
||||
copy_extra_lines: {},
|
||||
copy_parameters: {}
|
||||
copy_parameters: {},
|
||||
copy_notes: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,7 +89,8 @@ export function useReturnOrderFields({
|
||||
value: duplicateOrderId
|
||||
},
|
||||
copy_extra_lines: {},
|
||||
copy_parameters: {}
|
||||
copy_parameters: {},
|
||||
copy_notes: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,7 +103,8 @@ export function useSalesOrderFields({
|
||||
},
|
||||
copy_lines: {},
|
||||
copy_extra_lines: {},
|
||||
copy_parameters: {}
|
||||
copy_parameters: {},
|
||||
copy_notes: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconCoins,
|
||||
IconCopy,
|
||||
IconCurrencyDollar,
|
||||
IconLink,
|
||||
IconPackage,
|
||||
@@ -84,7 +85,7 @@ import {
|
||||
} from '../hooks/UseGenerator';
|
||||
import useStatusCodes from '../hooks/UseStatusCodes';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
import { TagsField } from './CommonFields';
|
||||
import { DuplicateField, TagsField } from './CommonFields';
|
||||
|
||||
/**
|
||||
* Construct a set of fields for creating / editing a StockItem instance
|
||||
@@ -96,7 +97,8 @@ export function useStockFields({
|
||||
create = false,
|
||||
supplierPartId,
|
||||
pricing,
|
||||
modalId
|
||||
modalId,
|
||||
duplicateStockItem
|
||||
}: {
|
||||
partId?: number;
|
||||
locationId?: number;
|
||||
@@ -105,6 +107,7 @@ export function useStockFields({
|
||||
create: boolean;
|
||||
supplierPartId?: number;
|
||||
pricing?: { [priceBreak: number]: [number, string] };
|
||||
duplicateStockItem?: any;
|
||||
}): ApiFormFieldSet {
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
@@ -331,6 +334,24 @@ export function useStockFields({
|
||||
delete fields.serial_numbers;
|
||||
}
|
||||
|
||||
// Additional fields for stock item duplication
|
||||
if (create && duplicateStockItem?.pk) {
|
||||
fields.duplicate = {
|
||||
icon: <IconCopy />,
|
||||
...DuplicateField({
|
||||
originalId: duplicateStockItem.pk,
|
||||
extraFields: {
|
||||
copy_notes: { value: true },
|
||||
copy_history: { value: false },
|
||||
copy_tests: {
|
||||
value: false,
|
||||
hidden: !duplicateStockItem?.part_detail?.testable
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [
|
||||
stockItem,
|
||||
@@ -346,6 +367,7 @@ export function useStockFields({
|
||||
purchasePriceCurrency,
|
||||
serialGenerator.result,
|
||||
batchGenerator.result,
|
||||
duplicateStockItem,
|
||||
create
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,8 @@ export function useTransferOrderFields({
|
||||
value: duplicateOrderId
|
||||
},
|
||||
copy_lines: {},
|
||||
copy_parameters: {}
|
||||
copy_parameters: {},
|
||||
copy_notes: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import type { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from '../contexts/ApiContext';
|
||||
|
||||
export interface InstanceInfo {
|
||||
attachment_count: number;
|
||||
note_count: number;
|
||||
parameter_count: number;
|
||||
}
|
||||
|
||||
const emptyInstanceInfo: InstanceInfo = {
|
||||
attachment_count: 0,
|
||||
note_count: 0,
|
||||
parameter_count: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch aggregated attachment/note/parameter counts for a single model instance.
|
||||
*
|
||||
* A single generic lookup which detail pages can use to drive their Attachments,
|
||||
* Notes and Parameters tab notification dots from one request, instead of each
|
||||
* tab independently querying its own list endpoint just to read a count.
|
||||
*/
|
||||
export function useInstanceInfo({
|
||||
modelType,
|
||||
modelId
|
||||
}: {
|
||||
modelType?: ModelType;
|
||||
modelId?: number;
|
||||
}) {
|
||||
const api = useApi();
|
||||
|
||||
const query = useQuery<InstanceInfo>({
|
||||
queryKey: ['instance-info', modelType, modelId],
|
||||
enabled: !!modelType && !!modelId,
|
||||
// These counts only drive tab notification dots (not displayed as numbers
|
||||
// anywhere), so - matching the staleTime PanelGroup already uses for the
|
||||
// dots themselves - a stale value for a few minutes is an acceptable
|
||||
// trade-off against refetching on every page navigation/remount.
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
return api
|
||||
.get(apiUrl(ApiEndpoints.instance_info), {
|
||||
params: { model_type: modelType, model_id: modelId }
|
||||
})
|
||||
.then((response) => response.data ?? emptyInstanceInfo);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
instanceInfo: query.data ?? emptyInstanceInfo,
|
||||
instanceInfoQuery: query
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
IconList,
|
||||
IconListDetails,
|
||||
IconMail,
|
||||
IconNotes,
|
||||
IconPackages,
|
||||
IconPhoto,
|
||||
IconPlugConnected,
|
||||
@@ -73,6 +74,8 @@ const MachineManagementPanel = Loadable(
|
||||
lazy(() => import('./MachineManagementPanel'))
|
||||
);
|
||||
|
||||
const NoteTemplatePanel = Loadable(lazy(() => import('./NoteTemplatePanel')));
|
||||
|
||||
const ScimManagementPanel = Loadable(
|
||||
lazy(() => import('./ScimManagementPanel'))
|
||||
);
|
||||
@@ -216,6 +219,13 @@ export default function AdminCenter() {
|
||||
content: <SelectionListTable />,
|
||||
hidden: !user.hasViewRole(UserRoles.part)
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
label: t`Note Templates`,
|
||||
icon: <IconNotes />,
|
||||
content: <NoteTemplatePanel />,
|
||||
hidden: !user.isStaff()
|
||||
},
|
||||
{
|
||||
name: 'category-parameters',
|
||||
label: t`Category Parameters`,
|
||||
@@ -316,6 +326,7 @@ export default function AdminCenter() {
|
||||
'selection-lists',
|
||||
'parameters',
|
||||
'category-parameters',
|
||||
'notes',
|
||||
'location-types',
|
||||
'stocktake'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert, Stack } from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import NotesEditor from '../../../../components/editors/NotesEditor';
|
||||
|
||||
export default function NoteTemplatePanel() {
|
||||
return (
|
||||
<Stack gap='xs'>
|
||||
<Alert color='blue' icon={<IconInfoCircle />} title={t`Note Templates`}>
|
||||
{t`Note templates can be used to create pre-defined notes which can be easily added to any model instance.`}
|
||||
</Alert>
|
||||
<NotesEditor templateMode />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@@ -231,6 +232,11 @@ export default function BuildDetail() {
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.build,
|
||||
modelId: build?.pk
|
||||
});
|
||||
|
||||
const buildPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -357,22 +363,25 @@ export default function BuildDetail() {
|
||||
},
|
||||
ParametersPanel({
|
||||
model_type: ModelType.build,
|
||||
model_id: build.pk
|
||||
model_id: build.pk,
|
||||
parameter_count: instanceInfo.parameter_count
|
||||
}),
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.build,
|
||||
model_id: build.pk
|
||||
model_id: build.pk,
|
||||
attachment_count: instanceInfo.attachment_count
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.build,
|
||||
model_id: build.pk,
|
||||
has_note: !!build.notes
|
||||
note_count: instanceInfo.note_count
|
||||
})
|
||||
];
|
||||
}, [
|
||||
build,
|
||||
id,
|
||||
user,
|
||||
instanceInfo,
|
||||
|
||||
buildStatus,
|
||||
globalSettings,
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { AddressTable } from '../../tables/company/AddressTable';
|
||||
import { ContactTable } from '../../tables/company/ContactTable';
|
||||
@@ -80,6 +81,11 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.company,
|
||||
modelId: company?.pk
|
||||
});
|
||||
|
||||
const detailsPanel = instanceQuery.isFetching ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
@@ -184,19 +190,21 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
},
|
||||
ParametersPanel({
|
||||
model_type: ModelType.company,
|
||||
model_id: company?.pk
|
||||
model_id: company?.pk,
|
||||
parameter_count: instanceInfo.parameter_count
|
||||
}),
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.company,
|
||||
model_id: company.pk
|
||||
model_id: company.pk,
|
||||
attachment_count: instanceInfo.attachment_count
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.company,
|
||||
model_id: company.pk,
|
||||
has_note: !!company.notes
|
||||
note_count: instanceInfo.note_count
|
||||
})
|
||||
];
|
||||
}, [id, company, user]);
|
||||
}, [id, company, user, instanceInfo]);
|
||||
|
||||
const editCompany = useEditApiFormModal({
|
||||
url: ApiEndpoints.company_list,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
@@ -58,6 +59,11 @@ export default function ManufacturerPartDetail() {
|
||||
}
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.manufacturerpart,
|
||||
modelId: manufacturerPart?.pk
|
||||
});
|
||||
|
||||
const panels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -102,19 +108,21 @@ export default function ManufacturerPartDetail() {
|
||||
},
|
||||
ParametersPanel({
|
||||
model_type: ModelType.manufacturerpart,
|
||||
model_id: manufacturerPart?.pk
|
||||
model_id: manufacturerPart?.pk,
|
||||
parameter_count: instanceInfo.parameter_count
|
||||
}),
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.manufacturerpart,
|
||||
model_id: manufacturerPart?.pk
|
||||
model_id: manufacturerPart?.pk,
|
||||
attachment_count: instanceInfo.attachment_count
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.manufacturerpart,
|
||||
model_id: manufacturerPart?.pk,
|
||||
has_note: !!manufacturerPart?.notes
|
||||
note_count: instanceInfo.note_count
|
||||
})
|
||||
];
|
||||
}, [user, manufacturerPart]);
|
||||
}, [user, manufacturerPart, instanceInfo]);
|
||||
|
||||
const editManufacturerPartFields = useManufacturerPartFields();
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
|
||||
import SupplierPriceBreakTable from '../../tables/purchasing/SupplierPriceBreakTable';
|
||||
@@ -66,6 +67,11 @@ export default function SupplierPartDetail() {
|
||||
}
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.supplierpart,
|
||||
modelId: supplierPart?.pk
|
||||
});
|
||||
|
||||
const panels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -119,19 +125,21 @@ export default function SupplierPartDetail() {
|
||||
},
|
||||
ParametersPanel({
|
||||
model_type: ModelType.supplierpart,
|
||||
model_id: supplierPart?.pk
|
||||
model_id: supplierPart?.pk,
|
||||
parameter_count: instanceInfo.parameter_count
|
||||
}),
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.supplierpart,
|
||||
model_id: supplierPart?.pk
|
||||
model_id: supplierPart?.pk,
|
||||
attachment_count: instanceInfo.attachment_count
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.supplierpart,
|
||||
model_id: supplierPart?.pk,
|
||||
has_note: !!supplierPart?.notes
|
||||
note_count: instanceInfo.note_count
|
||||
})
|
||||
];
|
||||
}, [supplierPart]);
|
||||
}, [supplierPart, instanceInfo]);
|
||||
|
||||
const supplierPartActions = useMemo(() => {
|
||||
return [
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||
import { useUserSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@@ -80,6 +81,11 @@ export default function CategoryDetail() {
|
||||
}
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.partcategory,
|
||||
modelId: category?.pk
|
||||
});
|
||||
|
||||
const stockOperationProps: StockOperationProps = useMemo(() => {
|
||||
return {
|
||||
refresh: refreshInstance,
|
||||
@@ -254,7 +260,8 @@ export default function CategoryDetail() {
|
||||
ParametersPanel({
|
||||
model_type: ModelType.partcategory,
|
||||
model_id: category?.pk,
|
||||
hidden: !id || !category.pk
|
||||
hidden: !id || !category.pk,
|
||||
parameter_count: instanceInfo.parameter_count
|
||||
}),
|
||||
{
|
||||
name: 'category_parameters',
|
||||
@@ -264,7 +271,7 @@ export default function CategoryDetail() {
|
||||
content: <PartCategoryTemplateTable categoryId={category?.pk} />
|
||||
}
|
||||
],
|
||||
[category, id, partsView]
|
||||
[category, id, partsView, instanceInfo]
|
||||
);
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||
import {
|
||||
useGlobalSettingsState,
|
||||
@@ -182,6 +183,11 @@ export default function PartDetail() {
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.part,
|
||||
modelId: part?.pk
|
||||
});
|
||||
|
||||
const { instance: partRequirements, instanceQuery: partRequirementsQuery } =
|
||||
useInstance({
|
||||
endpoint: ApiEndpoints.part_requirements,
|
||||
@@ -487,6 +493,7 @@ export default function PartDetail() {
|
||||
name: 'parameters',
|
||||
label: t`Parameters`,
|
||||
icon: <IconListDetails />,
|
||||
notification_dot: instanceInfo.parameter_count ? 'info' : null,
|
||||
content: (
|
||||
<>
|
||||
{lockingEnabled && part.locked && (
|
||||
@@ -509,12 +516,13 @@ export default function PartDetail() {
|
||||
},
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.part,
|
||||
model_id: part?.pk
|
||||
model_id: part?.pk,
|
||||
attachment_count: instanceInfo.attachment_count
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.part,
|
||||
model_id: part?.pk,
|
||||
has_note: !!part?.notes
|
||||
note_count: instanceInfo.note_count
|
||||
})
|
||||
];
|
||||
}, [
|
||||
@@ -525,7 +533,8 @@ export default function PartDetail() {
|
||||
userSettings,
|
||||
bomInformation,
|
||||
revisionSelector,
|
||||
refreshInstance
|
||||
refreshInstance,
|
||||
instanceInfo
|
||||
]);
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@@ -65,6 +66,11 @@ export default function PurchaseOrderDetail() {
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.purchaseorder,
|
||||
modelId: order?.pk
|
||||
});
|
||||
|
||||
const orderCurrency = useMemo(
|
||||
() =>
|
||||
order.order_currency ||
|
||||
@@ -198,16 +204,18 @@ export default function PurchaseOrderDetail() {
|
||||
},
|
||||
ParametersPanel({
|
||||
model_type: ModelType.purchaseorder,
|
||||
model_id: order.pk
|
||||
model_id: order.pk,
|
||||
parameter_count: instanceInfo.parameter_count
|
||||
}),
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.purchaseorder,
|
||||
model_id: order.pk
|
||||
model_id: order.pk,
|
||||
attachment_count: instanceInfo.attachment_count
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.purchaseorder,
|
||||
model_id: order.pk,
|
||||
has_note: !!order.notes,
|
||||
note_count: instanceInfo.note_count,
|
||||
// TODO @matmair - change API to include a "locked" attribute that we can check here
|
||||
editable:
|
||||
order.status == poStatus.COMPLETE &&
|
||||
@@ -216,7 +224,7 @@ export default function PurchaseOrderDetail() {
|
||||
: undefined
|
||||
})
|
||||
];
|
||||
}, [order, id, user]);
|
||||
}, [order, id, user, instanceInfo]);
|
||||
|
||||
const issueOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.purchase_order_issue, order.pk),
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@@ -64,6 +65,11 @@ export default function ReturnOrderDetail() {
|
||||
}
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.returnorder,
|
||||
modelId: order?.pk
|
||||
});
|
||||
|
||||
const roStatus = useStatusCodes({ modelType: ModelType.returnorder });
|
||||
|
||||
const orderOpen = useMemo(() => {
|
||||
@@ -150,19 +156,21 @@ export default function ReturnOrderDetail() {
|
||||
},
|
||||
ParametersPanel({
|
||||
model_type: ModelType.returnorder,
|
||||
model_id: order.pk
|
||||
model_id: order.pk,
|
||||
parameter_count: instanceInfo.parameter_count
|
||||
}),
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.returnorder,
|
||||
model_id: order.pk
|
||||
model_id: order.pk,
|
||||
attachment_count: instanceInfo.attachment_count
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.returnorder,
|
||||
model_id: order.pk,
|
||||
has_note: !!order.notes
|
||||
note_count: instanceInfo.note_count
|
||||
})
|
||||
];
|
||||
}, [order, id, user]);
|
||||
}, [order, id, user, instanceInfo]);
|
||||
|
||||
const orderBadges: ReactNode[] = useMemo(() => {
|
||||
return instanceQuery.isLoading
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@@ -73,6 +74,11 @@ export default function SalesOrderDetail() {
|
||||
}
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.salesorder,
|
||||
modelId: order?.pk
|
||||
});
|
||||
|
||||
const orderCurrency = useMemo(() => {
|
||||
return (
|
||||
order.order_currency ||
|
||||
@@ -223,16 +229,18 @@ export default function SalesOrderDetail() {
|
||||
},
|
||||
ParametersPanel({
|
||||
model_type: ModelType.salesorder,
|
||||
model_id: order.pk
|
||||
model_id: order.pk,
|
||||
parameter_count: instanceInfo.parameter_count
|
||||
}),
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.salesorder,
|
||||
model_id: order.pk
|
||||
model_id: order.pk,
|
||||
attachment_count: instanceInfo.attachment_count
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.salesorder,
|
||||
model_id: order.pk,
|
||||
has_note: !!order.notes,
|
||||
note_count: instanceInfo.note_count,
|
||||
// TODO @matmair - change API to include a "locked" attribute that we can check here
|
||||
editable:
|
||||
order.status == soStatus.COMPLETE &&
|
||||
@@ -241,7 +249,7 @@ export default function SalesOrderDetail() {
|
||||
: undefined
|
||||
})
|
||||
];
|
||||
}, [order, id, user, soStatus, user]);
|
||||
}, [order, id, user, soStatus, instanceInfo]);
|
||||
|
||||
const issueOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.sales_order_issue, order.pk),
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
|
||||
import { SalesOrderShipmentDetailsPanel } from './SalesOrderShipmentDetailsPanel';
|
||||
@@ -65,6 +66,11 @@ export default function SalesOrderShipmentDetail() {
|
||||
}
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.salesordershipment,
|
||||
modelId: shipment?.pk
|
||||
});
|
||||
|
||||
const isPending = useMemo(() => !shipment.shipment_date, [shipment]);
|
||||
const isChecked = useMemo(() => !!shipment.checked_by, [shipment]);
|
||||
|
||||
@@ -98,19 +104,21 @@ export default function SalesOrderShipmentDetail() {
|
||||
},
|
||||
ParametersPanel({
|
||||
model_type: ModelType.salesordershipment,
|
||||
model_id: shipment.pk
|
||||
model_id: shipment.pk,
|
||||
parameter_count: instanceInfo.parameter_count
|
||||
}),
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.salesordershipment,
|
||||
model_id: shipment.pk
|
||||
model_id: shipment.pk,
|
||||
attachment_count: instanceInfo.attachment_count
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.salesordershipment,
|
||||
model_id: shipment.pk,
|
||||
has_note: !!shipment.notes
|
||||
note_count: instanceInfo.note_count
|
||||
})
|
||||
];
|
||||
}, [isPending, shipment]);
|
||||
}, [isPending, shipment, instanceInfo]);
|
||||
|
||||
const editShipmentFields = useSalesOrderShipmentFields({
|
||||
pending: isPending,
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||
import { useUserSettingsState } from '../../states/SettingsStates';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
@@ -101,6 +102,11 @@ export default function Stock() {
|
||||
}
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.stocklocation,
|
||||
modelId: location?.pk
|
||||
});
|
||||
|
||||
const detailsPanel =
|
||||
id && instanceQuery.isFetching ? (
|
||||
<Skeleton />
|
||||
@@ -208,10 +214,11 @@ export default function Stock() {
|
||||
ParametersPanel({
|
||||
model_type: ModelType.stocklocation,
|
||||
model_id: location.pk,
|
||||
hidden: !location.pk
|
||||
hidden: !location.pk,
|
||||
parameter_count: instanceInfo.parameter_count
|
||||
})
|
||||
];
|
||||
}, [sublocationView, transferOrderView, location, id]);
|
||||
}, [sublocationView, transferOrderView, location, id, instanceInfo]);
|
||||
|
||||
const editLocation = useEditApiFormModal({
|
||||
url: ApiEndpoints.stock_location_list,
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@@ -112,6 +113,11 @@ export default function StockDetail() {
|
||||
defaultValue: {}
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.stockitem,
|
||||
modelId: stockitem?.pk
|
||||
});
|
||||
|
||||
const showBuildAllocations: boolean = useMemo(() => {
|
||||
// Determine if "build allocations" should be shown for this stock item
|
||||
return (
|
||||
@@ -307,12 +313,13 @@ export default function StockDetail() {
|
||||
},
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.stockitem,
|
||||
model_id: stockitem.pk
|
||||
model_id: stockitem.pk,
|
||||
attachment_count: instanceInfo.attachment_count
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.stockitem,
|
||||
model_id: stockitem.pk,
|
||||
has_note: !!stockitem.notes
|
||||
note_count: instanceInfo.note_count
|
||||
})
|
||||
];
|
||||
}, [
|
||||
@@ -321,7 +328,8 @@ export default function StockDetail() {
|
||||
showInstalledItems,
|
||||
stockitem,
|
||||
id,
|
||||
user
|
||||
user,
|
||||
instanceInfo
|
||||
]);
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
@@ -374,8 +382,9 @@ export default function StockDetail() {
|
||||
|
||||
const duplicateStockItemFields = useStockFields({
|
||||
create: true,
|
||||
locationId: stockitem.location,
|
||||
modalId: 'duplicate-stock-item'
|
||||
modalId: 'duplicate-stock-item',
|
||||
duplicateStockItem: stockitem,
|
||||
locationId: stockitem.location
|
||||
});
|
||||
|
||||
const duplicateStockData = useMemo(() => {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@@ -63,6 +64,11 @@ export default function TransferOrderDetail() {
|
||||
}
|
||||
});
|
||||
|
||||
const { instanceInfo } = useInstanceInfo({
|
||||
modelType: ModelType.transferorder,
|
||||
modelId: order?.pk
|
||||
});
|
||||
|
||||
const toStatus = useStatusCodes({ modelType: ModelType.transferorder });
|
||||
|
||||
const lineItemsEditable: boolean = useMemo(() => {
|
||||
@@ -174,18 +180,21 @@ export default function TransferOrderDetail() {
|
||||
},
|
||||
ParametersPanel({
|
||||
model_type: ModelType.transferorder,
|
||||
model_id: order.pk
|
||||
model_id: order.pk,
|
||||
parameter_count: instanceInfo.parameter_count
|
||||
}),
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.transferorder,
|
||||
model_id: order.pk
|
||||
model_id: order.pk,
|
||||
attachment_count: instanceInfo.attachment_count
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.transferorder,
|
||||
model_id: order.pk
|
||||
model_id: order.pk,
|
||||
note_count: instanceInfo.note_count
|
||||
})
|
||||
];
|
||||
}, [order, id, user]);
|
||||
}, [order, id, user, instanceInfo]);
|
||||
|
||||
const orderBadges: ReactNode[] = useMemo(() => {
|
||||
return instanceQuery.isLoading
|
||||
|
||||
@@ -1058,23 +1058,61 @@ test('Parts - Test Results', async ({ browser }) => {
|
||||
});
|
||||
|
||||
test('Parts - Notes', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'part/69/notes' });
|
||||
const page = await doCachedLogin(browser, { url: 'part/71/details' });
|
||||
|
||||
// Enable editing
|
||||
await page.getByLabel('Enable Editing').waitFor();
|
||||
await loadTab(page, 'Notes');
|
||||
|
||||
// Use keyboard shortcut to "edit" the part
|
||||
await page.keyboard.press('Control+E');
|
||||
await page.getByLabel('text-field-name', { exact: true }).waitFor();
|
||||
await page.getByLabel('text-field-description', { exact: true }).waitFor();
|
||||
await page.getByLabel('tree-field-category').waitFor();
|
||||
// Expect to see notes rendered for this part
|
||||
await page.getByRole('cell', { name: 'Red Widget' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'Blue Widget' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'Green Widget' }).waitFor();
|
||||
await page
|
||||
.getByRole('link', { name: 'Read more in the documentation' })
|
||||
.waitFor();
|
||||
|
||||
// Let's try to create a new note, but cancel before submitting
|
||||
await page.getByRole('button', { name: 'Add Note' }).click();
|
||||
await page.getByLabel('related-field-template').fill('instructions');
|
||||
await page
|
||||
.getByRole('option', { name: 'Manufacturing Instructions' })
|
||||
.click();
|
||||
await page.getByText('Manufacturing Instructions').waitFor();
|
||||
await page.getByText('How to build this part').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Enable notes editing
|
||||
await page.getByLabel('Enable Editing').click();
|
||||
// Enable editing for this note
|
||||
await page.getByRole('button', { name: 'edit-note' }).click();
|
||||
|
||||
await page.getByLabel('Save Notes').waitFor();
|
||||
await page.getByLabel('Close Editor').waitFor();
|
||||
await page.getByRole('button', { name: 'Bold' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Italic' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Underline' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Heading 1' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Heading 2' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Heading 3' }).waitFor();
|
||||
|
||||
await page.getByRole('button', { name: 'finish-editing-note' }).click();
|
||||
|
||||
// Duplicate this part - should show options for copying notes
|
||||
await page.getByRole('button', { name: 'action-menu-part-actions' }).click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'action-menu-part-actions-duplicate' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('switch', { name: 'boolean-field-duplicate.copy_notes' })
|
||||
.waitFor();
|
||||
|
||||
// Generate random IPN for copying
|
||||
const ipn = `IPN-${Math.floor(Math.random() * 100000)}`;
|
||||
await page.getByRole('textbox', { name: 'text-field-IPN' }).fill(ipn);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByText(`Part: ${ipn}`).waitFor();
|
||||
|
||||
// Check that the notes have been duplicated to this new part
|
||||
await loadTab(page, 'Notes');
|
||||
await page
|
||||
.getByRole('heading', { name: 'On Widgets (And Variants Thereof)' })
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
test('Parts - 404', async ({ browser }) => {
|
||||
|
||||
+988
-694
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user