2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 19:46:46 +00:00

Notes editor (#7980)

* Support for thematic breaks

- Use the '-' character

* Improve "read only" mode

* Refactor markdown notes editor

- Revert back to simplemde
- Remove package dependencies for mdxeditor
- Fix up buttons / preview / save sequencing

* Update playwright tests

* Cleanup toolbar buttons

* Enable "side by side" mode

* Update UI text
This commit is contained in:
Oliver 2024-09-03 18:39:13 +10:00 committed by GitHub
parent 355b4937da
commit f144158cf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 247 additions and 2143 deletions

View File

@ -37,7 +37,6 @@
"@mantine/notifications": "^7.12.2", "@mantine/notifications": "^7.12.2",
"@mantine/spotlight": "^7.12.2", "@mantine/spotlight": "^7.12.2",
"@mantine/vanilla-extract": "^7.12.2", "@mantine/vanilla-extract": "^7.12.2",
"@mdxeditor/editor": "^3.11.3",
"@sentry/react": "^8.27.0", "@sentry/react": "^8.27.0",
"@tabler/icons-react": "^3.14.0", "@tabler/icons-react": "^3.14.0",
"@tanstack/react-query": "^5.53.3", "@tanstack/react-query": "^5.53.3",
@ -49,6 +48,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"easymde": "^2.18.0",
"embla-carousel-react": "^8.2.0", "embla-carousel-react": "^8.2.0",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
@ -61,6 +61,7 @@
"react-is": "^18.3.1", "react-is": "^18.3.1",
"react-router-dom": "^6.26.1", "react-router-dom": "^6.26.1",
"react-select": "^5.8.0", "react-select": "^5.8.0",
"react-simplemde-editor": "^5.2.0",
"react-window": "^1.8.10", "react-window": "^1.8.10",
"recharts": "^2.12.7", "recharts": "^2.12.7",
"styled-components": "^6.1.13", "styled-components": "^6.1.13",

View File

@ -1,42 +1,17 @@
// import SimpleMDE from "react-simplemde-editor";
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useMantineColorScheme } from '@mantine/core';
import { notifications } from '@mantine/notifications'; 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 { useQuery } from '@tanstack/react-query';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import EasyMDE, { default as SimpleMde } from 'easymde';
import React from 'react'; import 'easymde/dist/easymde.min.css';
import { useCallback, useEffect, useMemo, useState } from 'react';
import SimpleMDE from 'react-simplemde-editor';
import { api } from '../../App'; import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useLocalState } from '../../states/LocalState';
import { ModelInformationDict } from '../render/ModelType'; import { ModelInformationDict } from '../render/ModelType';
/* /*
@ -74,7 +49,7 @@ async function uploadNotesImage(
/* /*
* A text editor component for editing notes against a model type and instance. * A text editor component for editing notes against a model type and instance.
* Uses the MDXEditor component - https://mdxeditor.dev/ * Uses the react-simple-mde editor: https://github.com/RIP21/react-simplemde-editor
* *
* TODO: * TODO:
* - Disable editing by default when the component is launched - user can click an "edit" button to enable * - Disable editing by default when the component is launched - user can click an "edit" button to enable
@ -90,13 +65,13 @@ export default function NotesEditor({
modelId: number; modelId: number;
editable?: boolean; editable?: boolean;
}) { }) {
const ref = React.useRef<MDXEditorMethods>(null); const { colorScheme } = useMantineColorScheme();
const { host } = useLocalState();
// In addition to the editable prop, we also need to check if the user has "enabled" editing // In addition to the editable prop, we also need to check if the user has "enabled" editing
const [editing, setEditing] = useState<boolean>(false); const [editing, setEditing] = useState<boolean>(false);
const [markdown, setMarkdown] = useState<string>('');
useEffect(() => { useEffect(() => {
// Initially disable editing mode on load // Initially disable editing mode on load
setEditing(false); setEditing(false);
@ -107,27 +82,51 @@ export default function NotesEditor({
return apiUrl(modelInfo.api_endpoint, modelId); return apiUrl(modelInfo.api_endpoint, modelId);
}, [modelType, modelId]); }, [modelType, modelId]);
// Image upload handler
const imageUploadHandler = useCallback( const imageUploadHandler = useCallback(
(image: File): Promise<string> => { (
return uploadNotesImage(image, modelType, modelId); file: File,
onSuccess: (url: string) => void,
onError: (error: string) => void
) => {
const formData = new FormData();
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'
}
})
.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'
});
});
}, },
[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({ const dataQuery = useQuery({
queryKey: [noteUrl], queryKey: ['notes-editor', noteUrl, modelType, modelId],
queryFn: () => queryFn: () =>
api api
.get(noteUrl) .get(noteUrl)
@ -137,124 +136,119 @@ export default function NotesEditor({
}); });
useEffect(() => { useEffect(() => {
ref.current?.setMarkdown(dataQuery.data ?? ''); setMarkdown(dataQuery.data ?? '');
}, [dataQuery.data, ref.current]); }, [dataQuery.data]);
// Callback to save notes to the server // Callback to save notes to the server
const saveNotes = useCallback(() => { const saveNotes = useCallback(
const markdown = ref.current?.getMarkdown(); (markdown: string) => {
if (!noteUrl) {
return;
}
if (!noteUrl || markdown === undefined) { api
return; .patch(noteUrl, { notes: markdown })
} .then(() => {
notifications.hide('notes');
api notifications.show({
.patch(noteUrl, { notes: markdown }) title: t`Success`,
.then(() => { message: t`Notes saved successfully`,
notifications.hide('notes'); color: 'green',
notifications.show({ id: 'notes'
title: t`Success`, });
message: t`Notes saved successfully`, })
color: 'green', .catch(() => {
id: 'notes' notifications.hide('notes');
notifications.show({
title: t`Error`,
message: t`Failed to save notes`,
color: 'red',
id: 'notes'
});
}); });
}) },
.catch(() => { [api, noteUrl]
notifications.hide('notes'); );
notifications.show({
title: t`Error`,
message: t`Failed to save notes`,
color: 'red',
id: 'notes'
});
});
}, [api, noteUrl, ref.current]);
const plugins: any[] = useMemo(() => { const editorOptions: SimpleMde.Options = useMemo(() => {
let plg = [ let icons: any[] = [];
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) { 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) { if (editing) {
toolbar = [ icons.push({
...toolbar, name: 'edit-disabled',
<ButtonWithTooltip action: () => setEditing(false),
key="save-notes" className: 'fa fa-eye',
aria-label="save-notes" title: t`Disable Editing`
onClick={() => saveNotes()} });
title={t`Save Notes`}
disabled={false} icons.push('|', 'side-by-side', '|');
> } else {
<IconDeviceFloppy /> icons.push({
</ButtonWithTooltip>, name: 'edit-enabled',
<Separator key="separator-1" />, action: () => setEditing(true),
<UndoRedo key="undo-redo" />, className: 'fa fa-edit',
<Separator key="separator-2" />, title: t`Enable Editing`
<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 (editing) {
if (editable) { icons.push('heading-1', 'heading-2', 'heading-3', '|'); // Headings
plg.push( icons.push('bold', 'italic', 'strikethrough', '|'); // Text styles
toolbarPlugin({ icons.push('unordered-list', 'ordered-list', 'code', 'quote', '|'); // Text formatting
toolbarContents: () => ( icons.push('table', 'link', 'image', '|');
<> icons.push('horizontal-rule', '|', 'guide'); // Misc
{toolbar.map((item, index) => item)}
{editing && <InsertImage />} icons.push('|', 'undo', 'redo'); // Undo/Redo
</> icons.push('|');
)
}) icons.push({
); name: 'save-notes',
action: (editor: SimpleMde) => {
saveNotes(editor.value());
},
className: 'fa fa-save',
title: t`Save Notes`
});
} }
return plg; return {
}, [ toolbar: icons,
dataQuery.data, uploadImage: true,
editable, imagePathAbsolute: true,
editing, imageUploadFunction: imageUploadHandler,
imageUploadHandler, sideBySideFullscreen: false,
imagePreviewHandler, shortcuts: {},
saveNotes spellChecker: false
]); };
}, [editable, editing]);
const [mdeInstance, setMdeInstance] = useState<SimpleMde | null>(null);
useEffect(() => {
if (mdeInstance) {
let previewMode = !(editable && editing);
mdeInstance.codemirror?.setOption('readOnly', previewMode);
// Ensure the preview mode is toggled if required
if (mdeInstance.isPreviewActive() != previewMode) {
let sibling = mdeInstance?.codemirror.getWrapperElement()?.nextSibling;
if (sibling != null) {
EasyMDE.togglePreview(mdeInstance);
}
}
}
}, [mdeInstance, editable, editing]);
return ( return (
<MDXEditor ref={ref} markdown={''} readOnly={!editable} plugins={plugins} /> <SimpleMDE
value={markdown}
onChange={setMarkdown}
options={editorOptions}
getMdeInstance={(instance: SimpleMde) => setMdeInstance(instance)}
/>
); );
} }

View File

@ -226,7 +226,7 @@ test('PUI - Pages - Part - Notes', async ({ page }) => {
await page.goto(`${baseUrl}/part/69/notes`); await page.goto(`${baseUrl}/part/69/notes`);
// Enable editing // Enable editing
await page.getByLabel('toggle-notes-editing').waitFor(); await page.getByLabel('Enable Editing').waitFor();
// Use keyboard shortcut to "edit" the part // Use keyboard shortcut to "edit" the part
await page.keyboard.press('Control+E'); await page.keyboard.press('Control+E');
@ -235,36 +235,10 @@ test('PUI - Pages - Part - Notes', async ({ page }) => {
await page.getByLabel('related-field-category').waitFor(); await page.getByLabel('related-field-category').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click(); await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByLabel('toggle-notes-editing').click(); // Enable notes editing
await page.getByLabel('Enable Editing').click();
// Enter some text await page.getByLabel('Disable Editing').waitFor();
await page await page.getByLabel('Save Notes').waitFor();
.getByRole('textbox')
.getByRole('paragraph')
.fill('This is some data\n');
// Save
await page.waitForTimeout(1000);
await page.getByLabel('save-notes').click();
/*
* Note: 2024-07-16
* Ref: https://github.com/inventree/InvenTree/pull/7649
* The following tests have been disabled as they are unreliable...
* For some reasons, the axios request fails, with "x-unknown" status.
* Commenting out for now as the failed tests are eating a *lot* of time.
*/
// 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();
}); });
test('PUI - Pages - Part - 404', async ({ page }) => { test('PUI - Pages - Part - 404', async ({ page }) => {

View File

@ -48,6 +48,13 @@ export default defineConfig({
sourcemap: is_coverage sourcemap: is_coverage
}, },
server: { server: {
proxy: {
'/media': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: true
}
},
watch: { watch: {
// use polling only for WSL as the file system doesn't trigger notifications for Linux apps // use polling only for WSL as the file system doesn't trigger notifications for Linux apps
// ref: https://github.com/vitejs/vite/issues/1153#issuecomment-785467271 // ref: https://github.com/vitejs/vite/issues/1153#issuecomment-785467271

File diff suppressed because it is too large Load Diff