mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +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:
parent
355b4937da
commit
f144158cf1
@ -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",
|
||||||
|
@ -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)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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 }) => {
|
||||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user