[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:
Oliver
2026-08-31 16:46:33 +10:00
committed by GitHub
co-authored by Matthias Mair
parent d39d0262e6
commit 02da01dfec
108 changed files with 6902 additions and 1643 deletions
+3 -1
View File
@@ -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'
}
};
+2 -1
View File
@@ -39,7 +39,8 @@ export enum ModelType {
selectionlist = 'selectionlist',
selectionentry = 'selectionentry',
error = 'error',
tag = 'tag'
tag = 'tag',
notetemplate = 'notetemplate'
}
export enum PluginPanelKey {
+14 -2
View File
@@ -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}
/>
)
);
}
+2 -1
View File
@@ -139,7 +139,8 @@ export function useBuildOrderFields({
duplicate: DuplicateField({
originalId: duplicateBuildId,
extraFields: {
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
})
};
+89
View File
@@ -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: {},
+6 -3
View File
@@ -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: {}
}
};
}
+2 -1
View File
@@ -89,7 +89,8 @@ export function useReturnOrderFields({
value: duplicateOrderId
},
copy_extra_lines: {},
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
};
}
+2 -1
View File
@@ -103,7 +103,8 @@ export function useSalesOrderFields({
},
copy_lines: {},
copy_extra_lines: {},
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
};
}
+24 -2
View File
@@ -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>
);
}
+12 -3
View File
@@ -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(
+12 -3
View File
@@ -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,
+14 -5
View File
@@ -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
+50 -12
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff