[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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 2534 additions and 308 deletions

View File

@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 204 INVENTREE_API_VERSION = 205
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v205 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7284
- Added model_type and model_id fields to the "NotesImage" serializer
v204 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7393 v204 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7393
- Fixes previous API update which resulted in inconsistent ordering of currency codes - Fixes previous API update which resulted in inconsistent ordering of currency codes

View File

@ -1031,6 +1031,30 @@ class InvenTreeNotesMixin(models.Model):
abstract = True abstract = True
def delete(self):
"""Custom delete method for InvenTreeNotesMixin.
- Before deleting the object, check if there are any uploaded images associated with it.
- If so, delete the notes first
"""
from common.models import NotesImage
images = NotesImage.objects.filter(
model_type=self.__class__.__name__.lower(), model_id=self.pk
)
if images.exists():
logger.info(
'Deleting %s uploaded images associated with %s <%s>',
images.count(),
self.__class__.__name__,
self.pk,
)
images.delete()
super().delete()
notes = InvenTree.fields.InvenTreeNotesField( notes = InvenTree.fields.InvenTreeNotesField(
verbose_name=_('Notes'), help_text=_('Markdown notes (optional)') verbose_name=_('Notes'), help_text=_('Markdown notes (optional)')
) )

View File

@ -18,6 +18,7 @@ from djmoney.utils import MONEY_CLASSES, get_currency_field_name
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import empty from rest_framework.fields import empty
from rest_framework.mixins import ListModelMixin
from rest_framework.serializers import DecimalField from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
from taggit.serializers import TaggitSerializer from taggit.serializers import TaggitSerializer
@ -842,6 +843,23 @@ class DataFileExtractSerializer(serializers.Serializer):
pass pass
class NotesFieldMixin:
"""Serializer mixin for handling 'notes' fields.
The 'notes' field will be hidden in a LIST serializer,
but available in a DETAIL serializer.
"""
def __init__(self, *args, **kwargs):
"""Remove 'notes' field from list views."""
super().__init__(*args, **kwargs)
if hasattr(self, 'context'):
if view := self.context.get('view', None):
if issubclass(view.__class__, ListModelMixin):
self.fields.pop('notes', None)
class RemoteImageMixin(metaclass=serializers.SerializerMetaclass): class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
"""Mixin class which allows downloading an 'image' from a remote URL. """Mixin class which allows downloading an 'image' from a remote URL.

View File

@ -17,7 +17,7 @@ from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentS
from InvenTree.serializers import UserSerializer from InvenTree.serializers import UserSerializer
import InvenTree.helpers import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
from stock.status_codes import StockStatus from stock.status_codes import StockStatus
from stock.generators import generate_batch_code from stock.generators import generate_batch_code
@ -33,7 +33,7 @@ from users.serializers import OwnerSerializer
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
class BuildSerializer(InvenTreeModelSerializer): class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
"""Serializes a Build object.""" """Serializes a Build object."""
class Meta: class Meta:

View File

@ -346,6 +346,8 @@ onPanelLoad('notes', function() {
'build-notes', 'build-notes',
'{% url "api-build-detail" build.pk %}', '{% url "api-build-detail" build.pk %}',
{ {
model_type: 'build',
model_id: {{ build.pk }},
{% if roles.build.change %} {% if roles.build.change %}
editable: true, editable: true,
{% else %} {% else %}

View File

@ -479,6 +479,10 @@ class NotesImageList(ListCreateAPI):
serializer_class = common.serializers.NotesImageSerializer serializer_class = common.serializers.NotesImageSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
filter_backends = SEARCH_ORDER_FILTER
search_fields = ['user', 'model_type', 'model_id']
def perform_create(self, serializer): def perform_create(self, serializer):
"""Create (upload) a new notes image.""" """Create (upload) a new notes image."""
image = serializer.save() image = serializer.save()

View File

@ -52,11 +52,11 @@ def set_currencies(apps, schema_editor):
setting = InvenTreeSetting.objects.filter(key=key).first() setting = InvenTreeSetting.objects.filter(key=key).first()
if setting: if setting:
print(f"Updating existing setting for currency codes") print(f"- Updating existing setting for currency codes")
setting.value = value setting.value = value
setting.save() setting.save()
else: else:
print(f"Creating new setting for currency codes") print(f"- Creating new setting for currency codes")
setting = InvenTreeSetting(key=key, value=value) setting = InvenTreeSetting(key=key, value=value)
setting.save() setting.save()

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.12 on 2024-05-22 12:27
import common.validators
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0023_auto_20240602_1332'),
]
operations = [
migrations.AddField(
model_name='notesimage',
name='model_id',
field=models.IntegerField(blank=True, default=None, help_text='Target model ID for this image', null=True),
),
migrations.AddField(
model_name='notesimage',
name='model_type',
field=models.CharField(blank=True, null=True, help_text='Target model type for this image', max_length=100, validators=[common.validators.validate_notes_model_type]),
),
]

View File

@ -9,7 +9,6 @@ import hmac
import json import json
import logging import logging
import os import os
import re
import uuid import uuid
from datetime import timedelta, timezone from datetime import timedelta, timezone
from enum import Enum from enum import Enum
@ -35,7 +34,6 @@ from django.utils.translation import gettext_lazy as _
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.settings import CURRENCY_CHOICES
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
import build.validators import build.validators
@ -2955,7 +2953,7 @@ def rename_notes_image(instance, filename):
class NotesImage(models.Model): class NotesImage(models.Model):
"""Model for storing uploading images for the 'notes' fields of various models. """Model for storing uploading images for the 'notes' fields of various models.
Simply stores the image file, for use in the 'notes' field (of any models which support markdown) Simply stores the image file, for use in the 'notes' field (of any models which support markdown).
""" """
image = models.ImageField( image = models.ImageField(
@ -2966,6 +2964,21 @@ class NotesImage(models.Model):
date = models.DateTimeField(auto_now_add=True) date = models.DateTimeField(auto_now_add=True)
model_type = models.CharField(
max_length=100,
blank=True,
null=True,
validators=[common.validators.validate_notes_model_type],
help_text=_('Target model type for this image'),
)
model_id = models.IntegerField(
help_text=_('Target model ID for this image'),
blank=True,
null=True,
default=None,
)
class CustomUnit(models.Model): class CustomUnit(models.Model):
"""Model for storing custom physical unit definitions. """Model for storing custom physical unit definitions.

View File

@ -281,7 +281,7 @@ class NotesImageSerializer(InvenTreeModelSerializer):
"""Meta options for NotesImageSerializer.""" """Meta options for NotesImageSerializer."""
model = common_models.NotesImage model = common_models.NotesImage
fields = ['pk', 'image', 'user', 'date'] fields = ['pk', 'image', 'user', 'date', 'model_type', 'model_id']
read_only_fields = ['date', 'user'] read_only_fields = ['date', 'user']

View File

@ -1,8 +1,33 @@
"""Validation helpers for common models.""" """Validation helpers for common models."""
import re
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import InvenTree.helpers_model
def validate_notes_model_type(value):
"""Ensure that the provided model type is valid.
The provided value must map to a model which implements the 'InvenTreeNotesMixin'.
"""
import InvenTree.models
if not value:
# Empty values are allowed
return
model_types = list(
InvenTree.helpers_model.getModelsWithMixin(InvenTree.models.InvenTreeNotesMixin)
)
model_names = [model.__name__.lower() for model in model_types]
if value.lower() not in model_names:
raise ValidationError(f"Invalid model type '{value}'")
def validate_decimal_places_min(value): def validate_decimal_places_min(value):
"""Validator for PRICING_DECIMAL_PLACES_MIN setting.""" """Validator for PRICING_DECIMAL_PLACES_MIN setting."""

View File

@ -18,6 +18,7 @@ from InvenTree.serializers import (
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeMoneySerializer, InvenTreeMoneySerializer,
InvenTreeTagModelSerializer, InvenTreeTagModelSerializer,
NotesFieldMixin,
RemoteImageMixin, RemoteImageMixin,
) )
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
@ -102,7 +103,7 @@ class AddressBriefSerializer(InvenTreeModelSerializer):
] ]
class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer): class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSerializer):
"""Serializer for Company object (full detail).""" """Serializer for Company object (full detail)."""
class Meta: class Meta:

View File

@ -305,6 +305,8 @@
'{% url "api-company-detail" company.pk %}', '{% url "api-company-detail" company.pk %}',
{ {
editable: true, editable: true,
model_type: "company",
model_id: {{ company.pk }},
} }
); );
}); });

View File

@ -47,6 +47,7 @@ from InvenTree.serializers import (
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeMoneySerializer, InvenTreeMoneySerializer,
NotesFieldMixin,
) )
from order.status_codes import ( from order.status_codes import (
PurchaseOrderStatusGroups, PurchaseOrderStatusGroups,
@ -198,7 +199,7 @@ class AbstractExtraLineMeta:
class PurchaseOrderSerializer( class PurchaseOrderSerializer(
TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
): ):
"""Serializer for a PurchaseOrder object.""" """Serializer for a PurchaseOrder object."""
@ -768,7 +769,7 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
class SalesOrderSerializer( class SalesOrderSerializer(
TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
): ):
"""Serializer for the SalesOrder model class.""" """Serializer for the SalesOrder model class."""
@ -1075,7 +1076,7 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
) )
class SalesOrderShipmentSerializer(InvenTreeModelSerializer): class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
"""Serializer for the SalesOrderShipment class.""" """Serializer for the SalesOrderShipment class."""
class Meta: class Meta:
@ -1536,7 +1537,7 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
class ReturnOrderSerializer( class ReturnOrderSerializer(
AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer
): ):
"""Serializer for the ReturnOrder model class.""" """Serializer for the ReturnOrder model class."""

View File

@ -120,6 +120,8 @@
'order-notes', 'order-notes',
'{% url "api-po-detail" order.pk %}', '{% url "api-po-detail" order.pk %}',
{ {
model_type: "purchaseorder",
model_id: {{ order.pk }},
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}
editable: true, editable: true,
{% else %} {% else %}

View File

@ -175,6 +175,8 @@ onPanelLoad('order-notes', function() {
'order-notes', 'order-notes',
'{% url "api-return-order-detail" order.pk %}', '{% url "api-return-order-detail" order.pk %}',
{ {
model_type: 'returnorder',
model_id: {{ order.pk }},
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}
editable: true, editable: true,
{% else %} {% else %}

View File

@ -190,6 +190,8 @@
'order-notes', 'order-notes',
'{% url "api-so-detail" order.pk %}', '{% url "api-so-detail" order.pk %}',
{ {
model_type: "salesorder",
model_id: {{ order.pk }},
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}
editable: true, editable: true,
{% else %} {% else %}

View File

@ -1179,7 +1179,6 @@ class PartMixin:
queryset = Part.objects.all() queryset = Part.objects.all()
starred_parts = None starred_parts = None
is_create = False is_create = False
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):

View File

@ -580,6 +580,7 @@ class InitialSupplierSerializer(serializers.Serializer):
class PartSerializer( class PartSerializer(
InvenTree.serializers.NotesFieldMixin,
InvenTree.serializers.RemoteImageMixin, InvenTree.serializers.RemoteImageMixin,
InvenTree.serializers.InvenTreeTagModelSerializer, InvenTree.serializers.InvenTreeTagModelSerializer,
): ):

View File

@ -404,6 +404,8 @@
'part-notes', 'part-notes',
'{% url "api-part-detail" part.pk %}', '{% url "api-part-detail" part.pk %}',
{ {
model_type: "part",
model_id: {{ part.pk }},
editable: {% js_bool roles.part.change %}, editable: {% js_bool roles.part.change %},
} }
); );

View File

@ -1149,6 +1149,23 @@ class PartAPITest(PartAPITestBase):
date = datetime.fromisoformat(item['creation_date']) date = datetime.fromisoformat(item['creation_date'])
self.assertGreaterEqual(date, date_compare) self.assertGreaterEqual(date, date_compare)
def test_part_notes(self):
"""Test the 'notes' field."""
# First test the 'LIST' endpoint - no notes information provided
url = reverse('api-part-list')
response = self.get(url, {'limit': 1}, expected_code=200)
data = response.data['results'][0]
self.assertNotIn('notes', data)
# Second, test the 'DETAIL' endpoint - notes information provided
url = reverse('api-part-detail', kwargs={'pk': data['pk']})
response = self.get(url, expected_code=200)
self.assertIn('notes', response.data)
class PartCreationTests(PartAPITestBase): class PartCreationTests(PartAPITestBase):
"""Tests for creating new Part instances via the API.""" """Tests for creating new Part instances via the API."""

View File

@ -283,7 +283,10 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
return data return data
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): class StockItemSerializerBrief(
InvenTree.serializers.NotesFieldMixin,
InvenTree.serializers.InvenTreeModelSerializer,
):
"""Brief serializers for a StockItem.""" """Brief serializers for a StockItem."""
class Meta: class Meta:

View File

@ -208,6 +208,8 @@
'stock-notes', 'stock-notes',
'{% url "api-stock-detail" item.pk %}', '{% url "api-stock-detail" item.pk %}',
{ {
model_type: 'stockitem',
model_id: {{ item.pk }},
{% if roles.stock.change and user_owns_item %} {% if roles.stock.change and user_owns_item %}
editable: true, editable: true,
{% else %} {% else %}

View File

@ -482,6 +482,10 @@ function setupNotesField(element, url, options={}) {
form_data.append('image', imageFile); form_data.append('image', imageFile);
// Add model type and ID to the form data
form_data.append('model_type', options.model_type);
form_data.append('model_id', options.model_id);
inventreeFormDataUpload('{% url "api-notes-image-list" %}', form_data, { inventreeFormDataUpload('{% url "api-notes-image-list" %}', form_data, {
success: function(response) { success: function(response) {
onSuccess(response.image); onSuccess(response.image);

View File

@ -36,6 +36,8 @@
"@mantine/notifications": "^7.8.0", "@mantine/notifications": "^7.8.0",
"@mantine/spotlight": "^7.8.0", "@mantine/spotlight": "^7.8.0",
"@mantine/vanilla-extract": "^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", "@sentry/react": "^7.110.0",
"@tabler/icons-react": "^3.2.0", "@tabler/icons-react": "^3.2.0",
"@tanstack/react-query": "^5.29.2", "@tanstack/react-query": "^5.29.2",
@ -47,7 +49,6 @@
"clsx": "^2.1.0", "clsx": "^2.1.0",
"codemirror": ">=6.0.0", "codemirror": ">=6.0.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"easymde": "^2.18.0",
"embla-carousel-react": "^8.0.2", "embla-carousel-react": "^8.0.2",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"mantine-datatable": "^7.8.1", "mantine-datatable": "^7.8.1",
@ -58,8 +59,7 @@
"react-is": "^18.2.0", "react-is": "^18.2.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-select": "^5.8.0", "react-select": "^5.8.0",
"react-simplemde-editor": "^5.2.0", "recharts": "^2.12.4",
"recharts": "2",
"styled-components": "^6.1.8", "styled-components": "^6.1.8",
"zustand": "^4.5.2" "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/', error_report_list = 'error-report/',
project_code_list = 'project-code/', project_code_list = 'project-code/',
custom_unit_list = 'units/', 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 { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage'; import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import { import {
ActionDropdown, ActionDropdown,
CancelItemAction, CancelItemAction,
@ -32,7 +33,6 @@ import {
import { PageDetail } from '../../components/nav/PageDetail'; import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer'; import { StatusRenderer } from '../../components/render/StatusRenderer';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
@ -308,14 +308,14 @@ export default function BuildDetail() {
icon: <IconNotes />, icon: <IconNotes />,
content: ( content: (
<NotesEditor <NotesEditor
url={apiUrl(ApiEndpoints.build_order_list, build.pk)} modelType={ModelType.build}
data={build.notes ?? ''} modelId={build.pk}
allowEdit={true} editable={user.hasChangeRole(UserRoles.build)}
/> />
) )
} }
]; ];
}, [build, id]); }, [build, id, user]);
const buildOrderFields = useBuildOrderFields({ create: false }); 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 DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage'; import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import NotesEditor from '../../components/editors/NotesEditor';
import { import {
ActionDropdown, ActionDropdown,
DeleteItemAction, DeleteItemAction,
@ -31,7 +32,6 @@ import {
import { Breadcrumb } from '../../components/nav/BreadcrumbList'; import { Breadcrumb } from '../../components/nav/BreadcrumbList';
import { PageDetail } from '../../components/nav/PageDetail'; import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
@ -268,14 +268,18 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
icon: <IconNotes />, icon: <IconNotes />,
content: ( content: (
<NotesEditor <NotesEditor
url={apiUrl(ApiEndpoints.company_list, company.pk)} modelType={ModelType.company}
data={company?.notes ?? ''} modelId={company.pk}
allowEdit={true} editable={
user.hasChangeRole(UserRoles.purchase_order) ||
user.hasChangeRole(UserRoles.sales_order) ||
user.hasChangeRole(UserRoles.return_order)
}
/> />
) )
} }
]; ];
}, [id, company]); }, [id, company, user]);
const editCompany = useEditApiFormModal({ const editCompany = useEditApiFormModal({
url: ApiEndpoints.company_list, url: ApiEndpoints.company_list,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -196,3 +196,31 @@ test('PUI - Pages - Part - Parameters', async ({ page }) => {
await page.getByRole('button', { name: 'Cancel' }).click(); 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