mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
a5fa5f8ac3
commit
2b8e8e52a8
@ -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
|
||||||
|
|
||||||
|
@ -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)')
|
||||||
)
|
)
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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 %}
|
||||||
|
@ -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()
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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]),
|
||||||
|
),
|
||||||
|
]
|
@ -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.
|
||||||
|
@ -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']
|
||||||
|
|
||||||
|
@ -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."""
|
||||||
|
@ -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:
|
||||||
|
@ -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 }},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
@ -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):
|
||||||
|
@ -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,
|
||||||
):
|
):
|
||||||
|
@ -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 %},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -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."""
|
||||||
|
@ -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:
|
||||||
|
@ -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 %}
|
||||||
|
@ -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);
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
|
255
src/frontend/src/components/editors/NotesEditor.tsx
Normal file
255
src/frontend/src/components/editors/NotesEditor.tsx
Normal 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} />
|
||||||
|
);
|
||||||
|
}
|
@ -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} />
|
|
||||||
);
|
|
||||||
}
|
|
@ -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/'
|
||||||
}
|
}
|
||||||
|
@ -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 });
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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(
|
||||||
() => [
|
() => [
|
||||||
|
@ -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 [
|
||||||
|
@ -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
|
||||||
|
@ -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 [
|
||||||
|
@ -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(
|
||||||
() => [
|
() => [
|
||||||
|
@ -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
Loading…
Reference in New Issue
Block a user