2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 12:35:46 +00:00

[PUI] Notes editor (#7284)

* Install mdxeditor

* Setup basic toolbar

* Refactoring

* Add placeholder for image upload

* Add fields to link uploaded notes to model instances

* Add custom delete method for InvenTreeNotesMixin

* Refactor CUI notes editor

- Upload model type and model ID information

* Enable image uplaod for PUI editor

* Update <NotesEditor> component

* Fix import

* Add button to save notes

* Prepend the host name to relative image URLs

* Disable image resize

* Add notifications

* Add playwright tests

* Enable "read-only" mode for notes

* Typo fix

* Styling updates to the editor

* Update yarn.lock

* Bump API version

* Update migration

* Remove duplicated value

* Improve toggling between edit mode

* Fix migration

* Fix migration

* Unit test updates

- Click on the right buttons
- Add 'key' properties

* Remove extraneous key prop

* fix api version

* Add custom serializer mixin for 'notes' field

- Pop the field for 'list' endpoints
- Keep for detail

* Update to NotesEditor

* Add unit test
This commit is contained in:
Oliver
2024-06-04 21:53:44 +10:00
committed by GitHub
parent a5fa5f8ac3
commit 2b8e8e52a8
37 changed files with 2534 additions and 308 deletions

View File

@ -36,6 +36,8 @@
"@mantine/notifications": "^7.8.0",
"@mantine/spotlight": "^7.8.0",
"@mantine/vanilla-extract": "^7.8.0",
"@mdxeditor/editor": "^3.0.7",
"@naisutech/react-tree": "^3.1.0",
"@sentry/react": "^7.110.0",
"@tabler/icons-react": "^3.2.0",
"@tanstack/react-query": "^5.29.2",
@ -47,7 +49,6 @@
"clsx": "^2.1.0",
"codemirror": ">=6.0.0",
"dayjs": "^1.11.10",
"easymde": "^2.18.0",
"embla-carousel-react": "^8.0.2",
"html5-qrcode": "^2.3.8",
"mantine-datatable": "^7.8.1",
@ -58,8 +59,7 @@
"react-is": "^18.2.0",
"react-router-dom": "^6.22.3",
"react-select": "^5.8.0",
"react-simplemde-editor": "^5.2.0",
"recharts": "2",
"recharts": "^2.12.4",
"styled-components": "^6.1.8",
"zustand": "^4.5.2"
},

View File

@ -0,0 +1,255 @@
import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications';
import {
AdmonitionDirectiveDescriptor,
BlockTypeSelect,
BoldItalicUnderlineToggles,
ButtonWithTooltip,
CodeToggle,
CreateLink,
InsertAdmonition,
InsertImage,
InsertTable,
ListsToggle,
MDXEditor,
type MDXEditorMethods,
Separator,
UndoRedo,
directivesPlugin,
headingsPlugin,
imagePlugin,
linkDialogPlugin,
linkPlugin,
listsPlugin,
markdownShortcutPlugin,
quotePlugin,
tablePlugin,
toolbarPlugin
} from '@mdxeditor/editor';
import '@mdxeditor/editor/style.css';
import { IconDeviceFloppy, IconEdit, IconEye } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import React from 'react';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { apiUrl } from '../../states/ApiState';
import { useLocalState } from '../../states/LocalState';
import { ModelInformationDict } from '../render/ModelType';
/*
* Upload an drag-n-dropped image to the server against a model type and instance.
*/
async function uploadNotesImage(
image: File,
modelType: ModelType,
modelId: number
): Promise<string> {
const formData = new FormData();
formData.append('image', image);
formData.append('model_type', modelType);
formData.append('model_id', modelId.toString());
const response = await api
.post(apiUrl(ApiEndpoints.notes_image_upload), formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.catch(() => {
notifications.hide('notes');
notifications.show({
title: t`Error`,
message: t`Image upload failed`,
color: 'red',
id: 'notes'
});
});
return response?.data?.image ?? '';
}
/*
* A text editor component for editing notes against a model type and instance.
* Uses the MDXEditor component - https://mdxeditor.dev/
*
* 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)
*/
export default function NotesEditor({
modelType,
modelId,
editable
}: {
modelType: ModelType;
modelId: number;
editable?: boolean;
}) {
const ref = React.useRef<MDXEditorMethods>(null);
const { host } = useLocalState();
// In addition to the editable prop, we also need to check if the user has "enabled" editing
const [editing, setEditing] = useState<boolean>(false);
useEffect(() => {
// Initially disable editing mode on load
setEditing(false);
}, [editable, modelId, modelType]);
const noteUrl: string = useMemo(() => {
const modelInfo = ModelInformationDict[modelType];
return apiUrl(modelInfo.api_endpoint, modelId);
}, [modelType, modelId]);
const imageUploadHandler = useCallback(
(image: File): Promise<string> => {
return uploadNotesImage(image, modelType, modelId);
},
[modelType, modelId]
);
const imagePreviewHandler = useCallback(
async (image: string): Promise<string> => {
// If the image is a relative URL, then we need to prepend the base URL
if (image.startsWith('/media/')) {
image = host + image;
}
return image;
},
[host]
);
const dataQuery = useQuery({
queryKey: [noteUrl],
queryFn: () =>
api
.get(noteUrl)
.then((response) => response.data?.notes ?? '')
.catch(() => ''),
enabled: true
});
useEffect(() => {
ref.current?.setMarkdown(dataQuery.data ?? '');
}, [dataQuery.data, ref.current]);
// Callback to save notes to the server
const saveNotes = useCallback(() => {
const markdown = ref.current?.getMarkdown() ?? '';
api
.patch(noteUrl, { notes: markdown })
.then(() => {
notifications.hide('notes');
notifications.show({
title: t`Success`,
message: t`Notes saved successfully`,
color: 'green',
id: 'notes'
});
})
.catch(() => {
notifications.hide('notes');
notifications.show({
title: t`Error`,
message: t`Failed to save notes`,
color: 'red',
id: 'notes'
});
});
}, [noteUrl, ref.current]);
const plugins: any[] = useMemo(() => {
let plg = [
directivesPlugin({
directiveDescriptors: [AdmonitionDirectiveDescriptor]
}),
headingsPlugin(),
imagePlugin({
imageUploadHandler: imageUploadHandler,
imagePreviewHandler: imagePreviewHandler,
disableImageResize: true // Note: To enable image resize, we must allow HTML tags in the server
}),
linkPlugin(),
linkDialogPlugin(),
listsPlugin(),
markdownShortcutPlugin(),
quotePlugin(),
tablePlugin()
];
let toolbar: ReactNode[] = [];
if (editable) {
toolbar = [
<ButtonWithTooltip
key="toggle-editing"
aria-label="toggle-notes-editing"
title={editing ? t`Preview Notes` : t`Edit Notes`}
onClick={() => setEditing(!editing)}
>
{editing ? <IconEye /> : <IconEdit />}
</ButtonWithTooltip>
];
if (editing) {
toolbar = [
...toolbar,
<ButtonWithTooltip
key="save-notes"
aria-label="save-notes"
onClick={() => saveNotes()}
title={t`Save Notes`}
disabled={false}
>
<IconDeviceFloppy />
</ButtonWithTooltip>,
<Separator key="separator-1" />,
<UndoRedo key="undo-redo" />,
<Separator key="separator-2" />,
<BoldItalicUnderlineToggles key="bold-italic-underline" />,
<CodeToggle key="code-toggle" />,
<ListsToggle key="lists-toggle" />,
<Separator key="separator-3" />,
<BlockTypeSelect key="block-type" />,
<Separator key="separator-4" />,
<CreateLink key="create-link" />,
<InsertTable key="insert-table" />,
<InsertAdmonition key="insert-admonition" />
];
}
}
// If the user is allowed to edit, then add the toolbar
if (editable) {
plg.push(
toolbarPlugin({
toolbarContents: () => (
<>
{toolbar.map((item, index) => item)}
{editing && <InsertImage />}
</>
)
})
);
}
return plg;
}, [
dataQuery.data,
editable,
editing,
imageUploadHandler,
imagePreviewHandler,
saveNotes
]);
return (
<MDXEditor ref={ref} markdown={''} readOnly={!editable} plugins={plugins} />
);
}

View File

@ -1,164 +0,0 @@
import { t } from '@lingui/macro';
import { showNotification } from '@mantine/notifications';
import EasyMDE from 'easymde';
import 'easymde/dist/easymde.min.css';
import { ReactNode, useCallback, useMemo, useState } from 'react';
import SimpleMDE from 'react-simplemde-editor';
import { api } from '../../App';
/**
* Markdon editor component. Uses react-simplemde-editor
*/
export function MarkdownEditor({
data,
allowEdit,
saveValue
}: {
data?: string;
allowEdit?: boolean;
saveValue?: (value: string) => void;
}): ReactNode {
const [value, setValue] = useState(data);
// Construct markdown editor options
const options = useMemo(() => {
// Custom set of toolbar icons for the editor
let icons: any[] = ['preview', 'side-by-side'];
if (allowEdit) {
icons.push(
'|',
// Heading icons
'heading-1',
'heading-2',
'heading-3',
'|',
// Font styles
'bold',
'italic',
'strikethrough',
'|',
// Text formatting
'unordered-list',
'ordered-list',
'code',
'quote',
'|',
// Link and image icons
'table',
'link',
'image'
);
}
if (allowEdit) {
icons.push(
'|',
// Save button
{
name: 'save',
action: (editor: EasyMDE) => {
if (saveValue) {
saveValue(editor.value());
}
},
className: 'fa fa-save',
title: t`Save`
}
);
}
return {
minHeight: '400px',
toolbar: icons,
sideBySideFullscreen: false,
uploadImage: allowEdit,
imagePathAbsolute: true,
imageUploadFunction: (
file: File,
onSuccess: (url: string) => void,
onError: (error: string) => void
) => {
api
.post(
'/notes-image-upload/',
{
image: file
},
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
)
.then((response) => {
if (response.data?.image) {
onSuccess(response.data.image);
}
})
.catch((error) => {
showNotification({
title: t`Error`,
message: t`Failed to upload image`,
color: 'red'
});
onError(error);
});
}
};
}, [allowEdit]);
return (
<SimpleMDE
value={value}
options={options}
onChange={(v: string) => setValue(v)}
/>
);
}
/**
* Custom implementation of the MarkdownEditor widget for editing notes.
* Includes a callback hook for saving the notes to the server.
*/
export function NotesEditor({
url,
data,
allowEdit
}: {
url: string;
data?: string;
allowEdit?: boolean;
}): ReactNode {
// Callback function to upload data to the server
const uploadData = useCallback((value: string) => {
api
.patch(url, { notes: value })
.then((response) => {
showNotification({
title: t`Success`,
message: t`Notes saved`,
color: 'green'
});
return response;
})
.catch((error) => {
showNotification({
title: t`Error`,
message: t`Failed to save notes`,
color: 'red'
});
return error;
});
}, []);
return (
<MarkdownEditor data={data} allowEdit={allowEdit} saveValue={uploadData} />
);
}

View File

@ -158,5 +158,6 @@ export enum ApiEndpoints {
error_report_list = 'error-report/',
project_code_list = 'project-code/',
custom_unit_list = 'units/',
ui_preference = 'web/ui_preference/'
ui_preference = 'web/ui_preference/',
notes_image_upload = 'notes-image-upload/'
}

View File

@ -20,6 +20,7 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
ActionDropdown,
CancelItemAction,
@ -32,7 +33,6 @@ import {
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
@ -308,14 +308,14 @@ export default function BuildDetail() {
icon: <IconNotes />,
content: (
<NotesEditor
url={apiUrl(ApiEndpoints.build_order_list, build.pk)}
data={build.notes ?? ''}
allowEdit={true}
modelType={ModelType.build}
modelId={build.pk}
editable={user.hasChangeRole(UserRoles.build)}
/>
)
}
];
}, [build, id]);
}, [build, id, user]);
const buildOrderFields = useBuildOrderFields({ create: false });

View File

@ -23,6 +23,7 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
ActionDropdown,
DeleteItemAction,
@ -31,7 +32,6 @@ import {
import { Breadcrumb } from '../../components/nav/BreadcrumbList';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
@ -268,14 +268,18 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
icon: <IconNotes />,
content: (
<NotesEditor
url={apiUrl(ApiEndpoints.company_list, company.pk)}
data={company?.notes ?? ''}
allowEdit={true}
modelType={ModelType.company}
modelId={company.pk}
editable={
user.hasChangeRole(UserRoles.purchase_order) ||
user.hasChangeRole(UserRoles.sales_order) ||
user.hasChangeRole(UserRoles.return_order)
}
/>
)
}
];
}, [id, company]);
}, [id, company, user]);
const editCompany = useEditApiFormModal({
url: ApiEndpoints.company_list,

View File

@ -41,6 +41,7 @@ import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { PartIcons } from '../../components/details/PartIcons';
import NotesEditor from '../../components/editors/NotesEditor';
import { Thumbnail } from '../../components/images/Thumbnail';
import {
ActionDropdown,
@ -55,7 +56,6 @@ import {
import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
@ -626,14 +626,14 @@ export default function PartDetail() {
icon: <IconNotes />,
content: (
<NotesEditor
url={apiUrl(ApiEndpoints.part_list, part.pk)}
data={part.notes ?? ''}
allowEdit={true}
modelType={ModelType.part}
modelId={part.pk}
editable={user.hasChangeRole(UserRoles.part)}
/>
)
}
];
}, [id, part]);
}, [id, part, user]);
const breadcrumbs = useMemo(
() => [

View File

@ -16,6 +16,7 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
ActionDropdown,
BarcodeActionDropdown,
@ -29,7 +30,6 @@ import {
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
@ -291,14 +291,14 @@ export default function PurchaseOrderDetail() {
icon: <IconNotes />,
content: (
<NotesEditor
url={apiUrl(ApiEndpoints.purchase_order_list, id)}
data={order.notes ?? ''}
allowEdit={true}
modelType={ModelType.purchaseorder}
modelId={order.pk}
editable={user.hasChangeRole(UserRoles.purchase_order)}
/>
)
}
];
}, [order, id]);
}, [order, id, user]);
const poActions = useMemo(() => {
return [

View File

@ -15,6 +15,7 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
ActionDropdown,
CancelItemAction,
@ -24,7 +25,6 @@ import {
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
@ -240,14 +240,14 @@ export default function ReturnOrderDetail() {
icon: <IconNotes />,
content: (
<NotesEditor
url={apiUrl(ApiEndpoints.return_order_list, id)}
data={order.notes ?? ''}
allowEdit={true}
modelType={ModelType.returnorder}
modelId={order.pk}
editable={user.hasChangeRole(UserRoles.return_order)}
/>
)
}
];
}, [order, id]);
}, [order, id, user]);
const orderBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading

View File

@ -18,6 +18,7 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
ActionDropdown,
CancelItemAction,
@ -27,7 +28,6 @@ import {
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
@ -288,14 +288,14 @@ export default function SalesOrderDetail() {
icon: <IconNotes />,
content: (
<NotesEditor
url={apiUrl(ApiEndpoints.sales_order_list, id)}
data={order.notes ?? ''}
allowEdit={true}
modelType={ModelType.salesorder}
modelId={order.pk}
editable={user.hasChangeRole(UserRoles.sales_order)}
/>
)
}
];
}, [order, id]);
}, [order, id, user]);
const soActions = useMemo(() => {
return [

View File

@ -21,6 +21,7 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import {
ActionDropdown,
BarcodeActionDropdown,
@ -35,7 +36,6 @@ import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
@ -338,14 +338,14 @@ export default function StockDetail() {
icon: <IconNotes />,
content: (
<NotesEditor
url={apiUrl(ApiEndpoints.stock_item_list, id)}
data={stockitem.notes ?? ''}
allowEdit={true}
modelType={ModelType.stockitem}
modelId={stockitem.pk}
editable={user.hasChangeRole(UserRoles.stock)}
/>
)
}
];
}, [stockitem, id]);
}, [stockitem, id, user]);
const breadcrumbs = useMemo(
() => [

View File

@ -196,3 +196,31 @@ test('PUI - Pages - Part - Parameters', async ({ page }) => {
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('PUI - Pages - Part - Notes', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/69/notes`);
// Enable editing
await page.getByLabel('toggle-notes-editing').click();
// Enter some text
await page
.getByRole('textbox')
.getByRole('paragraph')
.fill('This is some data\n');
// Save
await page.getByLabel('save-notes').click();
await page.getByText('Notes saved successfully').waitFor();
// Navigate away from the page, and then back
await page.goto(`${baseUrl}/stock/location/index/`);
await page.waitForURL('**/platform/stock/location/**');
await page.getByRole('tab', { name: 'Location Details' }).waitFor();
await page.goto(`${baseUrl}/part/69/notes`);
// Check that the original notes are still present
await page.getByText('This is some data').waitFor();
});

File diff suppressed because it is too large Load Diff