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
|
||||
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."""
|
||||
|
||||
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
|
||||
- Fixes previous API update which resulted in inconsistent ordering of currency codes
|
||||
|
||||
|
@ -1031,6 +1031,30 @@ class InvenTreeNotesMixin(models.Model):
|
||||
|
||||
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(
|
||||
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.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.fields import empty
|
||||
from rest_framework.mixins import ListModelMixin
|
||||
from rest_framework.serializers import DecimalField
|
||||
from rest_framework.utils import model_meta
|
||||
from taggit.serializers import TaggitSerializer
|
||||
@ -842,6 +843,23 @@ class DataFileExtractSerializer(serializers.Serializer):
|
||||
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):
|
||||
"""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
|
||||
|
||||
import InvenTree.helpers
|
||||
from InvenTree.serializers import InvenTreeDecimalField
|
||||
from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
|
||||
from stock.status_codes import StockStatus
|
||||
|
||||
from stock.generators import generate_batch_code
|
||||
@ -33,7 +33,7 @@ from users.serializers import OwnerSerializer
|
||||
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
|
||||
|
||||
|
||||
class BuildSerializer(InvenTreeModelSerializer):
|
||||
class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
|
||||
"""Serializes a Build object."""
|
||||
|
||||
class Meta:
|
||||
|
@ -346,6 +346,8 @@ onPanelLoad('notes', function() {
|
||||
'build-notes',
|
||||
'{% url "api-build-detail" build.pk %}',
|
||||
{
|
||||
model_type: 'build',
|
||||
model_id: {{ build.pk }},
|
||||
{% if roles.build.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
|
@ -479,6 +479,10 @@ class NotesImageList(ListCreateAPI):
|
||||
serializer_class = common.serializers.NotesImageSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
search_fields = ['user', 'model_type', 'model_id']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create (upload) a new notes image."""
|
||||
image = serializer.save()
|
||||
|
@ -52,11 +52,11 @@ def set_currencies(apps, schema_editor):
|
||||
setting = InvenTreeSetting.objects.filter(key=key).first()
|
||||
|
||||
if setting:
|
||||
print(f"Updating existing setting for currency codes")
|
||||
print(f"- Updating existing setting for currency codes")
|
||||
setting.value = value
|
||||
setting.save()
|
||||
else:
|
||||
print(f"Creating new setting for currency codes")
|
||||
print(f"- Creating new setting for currency codes")
|
||||
setting = InvenTreeSetting(key=key, value=value)
|
||||
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 logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import timedelta, timezone
|
||||
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.models import convert_money
|
||||
from djmoney.settings import CURRENCY_CHOICES
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
import build.validators
|
||||
@ -2955,7 +2953,7 @@ def rename_notes_image(instance, filename):
|
||||
class NotesImage(models.Model):
|
||||
"""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(
|
||||
@ -2966,6 +2964,21 @@ class NotesImage(models.Model):
|
||||
|
||||
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):
|
||||
"""Model for storing custom physical unit definitions.
|
||||
|
@ -281,7 +281,7 @@ class NotesImageSerializer(InvenTreeModelSerializer):
|
||||
"""Meta options for NotesImageSerializer."""
|
||||
|
||||
model = common_models.NotesImage
|
||||
fields = ['pk', 'image', 'user', 'date']
|
||||
fields = ['pk', 'image', 'user', 'date', 'model_type', 'model_id']
|
||||
|
||||
read_only_fields = ['date', 'user']
|
||||
|
||||
|
@ -1,8 +1,33 @@
|
||||
"""Validation helpers for common models."""
|
||||
|
||||
import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
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):
|
||||
"""Validator for PRICING_DECIMAL_PLACES_MIN setting."""
|
||||
|
@ -18,6 +18,7 @@ from InvenTree.serializers import (
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeMoneySerializer,
|
||||
InvenTreeTagModelSerializer,
|
||||
NotesFieldMixin,
|
||||
RemoteImageMixin,
|
||||
)
|
||||
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)."""
|
||||
|
||||
class Meta:
|
||||
|
@ -305,6 +305,8 @@
|
||||
'{% url "api-company-detail" company.pk %}',
|
||||
{
|
||||
editable: true,
|
||||
model_type: "company",
|
||||
model_id: {{ company.pk }},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -47,6 +47,7 @@ from InvenTree.serializers import (
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeMoneySerializer,
|
||||
NotesFieldMixin,
|
||||
)
|
||||
from order.status_codes import (
|
||||
PurchaseOrderStatusGroups,
|
||||
@ -198,7 +199,7 @@ class AbstractExtraLineMeta:
|
||||
|
||||
|
||||
class PurchaseOrderSerializer(
|
||||
TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
|
||||
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
|
||||
):
|
||||
"""Serializer for a PurchaseOrder object."""
|
||||
|
||||
@ -768,7 +769,7 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
|
||||
|
||||
class SalesOrderSerializer(
|
||||
TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
|
||||
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
|
||||
):
|
||||
"""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."""
|
||||
|
||||
class Meta:
|
||||
@ -1536,7 +1537,7 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
|
||||
|
||||
class ReturnOrderSerializer(
|
||||
AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer
|
||||
NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer
|
||||
):
|
||||
"""Serializer for the ReturnOrder model class."""
|
||||
|
||||
|
@ -120,6 +120,8 @@
|
||||
'order-notes',
|
||||
'{% url "api-po-detail" order.pk %}',
|
||||
{
|
||||
model_type: "purchaseorder",
|
||||
model_id: {{ order.pk }},
|
||||
{% if roles.purchase_order.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
|
@ -175,6 +175,8 @@ onPanelLoad('order-notes', function() {
|
||||
'order-notes',
|
||||
'{% url "api-return-order-detail" order.pk %}',
|
||||
{
|
||||
model_type: 'returnorder',
|
||||
model_id: {{ order.pk }},
|
||||
{% if roles.purchase_order.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
|
@ -190,6 +190,8 @@
|
||||
'order-notes',
|
||||
'{% url "api-so-detail" order.pk %}',
|
||||
{
|
||||
model_type: "salesorder",
|
||||
model_id: {{ order.pk }},
|
||||
{% if roles.purchase_order.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
|
@ -1179,7 +1179,6 @@ class PartMixin:
|
||||
queryset = Part.objects.all()
|
||||
|
||||
starred_parts = None
|
||||
|
||||
is_create = False
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
@ -580,6 +580,7 @@ class InitialSupplierSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class PartSerializer(
|
||||
InvenTree.serializers.NotesFieldMixin,
|
||||
InvenTree.serializers.RemoteImageMixin,
|
||||
InvenTree.serializers.InvenTreeTagModelSerializer,
|
||||
):
|
||||
|
@ -404,6 +404,8 @@
|
||||
'part-notes',
|
||||
'{% url "api-part-detail" part.pk %}',
|
||||
{
|
||||
model_type: "part",
|
||||
model_id: {{ part.pk }},
|
||||
editable: {% js_bool roles.part.change %},
|
||||
}
|
||||
);
|
||||
|
@ -1149,6 +1149,23 @@ class PartAPITest(PartAPITestBase):
|
||||
date = datetime.fromisoformat(item['creation_date'])
|
||||
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):
|
||||
"""Tests for creating new Part instances via the API."""
|
||||
|
@ -283,7 +283,10 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
||||
return data
|
||||
|
||||
|
||||
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
class StockItemSerializerBrief(
|
||||
InvenTree.serializers.NotesFieldMixin,
|
||||
InvenTree.serializers.InvenTreeModelSerializer,
|
||||
):
|
||||
"""Brief serializers for a StockItem."""
|
||||
|
||||
class Meta:
|
||||
|
@ -208,6 +208,8 @@
|
||||
'stock-notes',
|
||||
'{% url "api-stock-detail" item.pk %}',
|
||||
{
|
||||
model_type: 'stockitem',
|
||||
model_id: {{ item.pk }},
|
||||
{% if roles.stock.change and user_owns_item %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
|
@ -482,6 +482,10 @@ function setupNotesField(element, url, options={}) {
|
||||
|
||||
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, {
|
||||
success: function(response) {
|
||||
onSuccess(response.image);
|
||||
|
@ -36,6 +36,8 @@
|
||||
"@mantine/notifications": "^7.8.0",
|
||||
"@mantine/spotlight": "^7.8.0",
|
||||
"@mantine/vanilla-extract": "^7.8.0",
|
||||
"@mdxeditor/editor": "^3.0.7",
|
||||
"@naisutech/react-tree": "^3.1.0",
|
||||
"@sentry/react": "^7.110.0",
|
||||
"@tabler/icons-react": "^3.2.0",
|
||||
"@tanstack/react-query": "^5.29.2",
|
||||
@ -47,7 +49,6 @@
|
||||
"clsx": "^2.1.0",
|
||||
"codemirror": ">=6.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"easymde": "^2.18.0",
|
||||
"embla-carousel-react": "^8.0.2",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"mantine-datatable": "^7.8.1",
|
||||
@ -58,8 +59,7 @@
|
||||
"react-is": "^18.2.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-select": "^5.8.0",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"recharts": "2",
|
||||
"recharts": "^2.12.4",
|
||||
"styled-components": "^6.1.8",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
|
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/',
|
||||
project_code_list = 'project-code/',
|
||||
custom_unit_list = 'units/',
|
||||
ui_preference = 'web/ui_preference/'
|
||||
ui_preference = 'web/ui_preference/',
|
||||
notes_image_upload = 'notes-image-upload/'
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
ActionDropdown,
|
||||
CancelItemAction,
|
||||
@ -32,7 +33,6 @@ import {
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -308,14 +308,14 @@ export default function BuildDetail() {
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
url={apiUrl(ApiEndpoints.build_order_list, build.pk)}
|
||||
data={build.notes ?? ''}
|
||||
allowEdit={true}
|
||||
modelType={ModelType.build}
|
||||
modelId={build.pk}
|
||||
editable={user.hasChangeRole(UserRoles.build)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [build, id]);
|
||||
}, [build, id, user]);
|
||||
|
||||
const buildOrderFields = useBuildOrderFields({ create: false });
|
||||
|
||||
|
@ -23,6 +23,7 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import DetailsBadge from '../../components/details/DetailsBadge';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
ActionDropdown,
|
||||
DeleteItemAction,
|
||||
@ -31,7 +32,6 @@ import {
|
||||
import { Breadcrumb } from '../../components/nav/BreadcrumbList';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -268,14 +268,18 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
url={apiUrl(ApiEndpoints.company_list, company.pk)}
|
||||
data={company?.notes ?? ''}
|
||||
allowEdit={true}
|
||||
modelType={ModelType.company}
|
||||
modelId={company.pk}
|
||||
editable={
|
||||
user.hasChangeRole(UserRoles.purchase_order) ||
|
||||
user.hasChangeRole(UserRoles.sales_order) ||
|
||||
user.hasChangeRole(UserRoles.return_order)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [id, company]);
|
||||
}, [id, company, user]);
|
||||
|
||||
const editCompany = useEditApiFormModal({
|
||||
url: ApiEndpoints.company_list,
|
||||
|
@ -41,6 +41,7 @@ import DetailsBadge from '../../components/details/DetailsBadge';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import { PartIcons } from '../../components/details/PartIcons';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||
import {
|
||||
ActionDropdown,
|
||||
@ -55,7 +56,6 @@ import {
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||
import { formatPriceRange } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -626,14 +626,14 @@ export default function PartDetail() {
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
url={apiUrl(ApiEndpoints.part_list, part.pk)}
|
||||
data={part.notes ?? ''}
|
||||
allowEdit={true}
|
||||
modelType={ModelType.part}
|
||||
modelId={part.pk}
|
||||
editable={user.hasChangeRole(UserRoles.part)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [id, part]);
|
||||
}, [id, part, user]);
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
() => [
|
||||
|
@ -16,6 +16,7 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
ActionDropdown,
|
||||
BarcodeActionDropdown,
|
||||
@ -29,7 +30,6 @@ import {
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -291,14 +291,14 @@ export default function PurchaseOrderDetail() {
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
url={apiUrl(ApiEndpoints.purchase_order_list, id)}
|
||||
data={order.notes ?? ''}
|
||||
allowEdit={true}
|
||||
modelType={ModelType.purchaseorder}
|
||||
modelId={order.pk}
|
||||
editable={user.hasChangeRole(UserRoles.purchase_order)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [order, id]);
|
||||
}, [order, id, user]);
|
||||
|
||||
const poActions = useMemo(() => {
|
||||
return [
|
||||
|
@ -15,6 +15,7 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
ActionDropdown,
|
||||
CancelItemAction,
|
||||
@ -24,7 +25,6 @@ import {
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -240,14 +240,14 @@ export default function ReturnOrderDetail() {
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
url={apiUrl(ApiEndpoints.return_order_list, id)}
|
||||
data={order.notes ?? ''}
|
||||
allowEdit={true}
|
||||
modelType={ModelType.returnorder}
|
||||
modelId={order.pk}
|
||||
editable={user.hasChangeRole(UserRoles.return_order)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [order, id]);
|
||||
}, [order, id, user]);
|
||||
|
||||
const orderBadges: ReactNode[] = useMemo(() => {
|
||||
return instanceQuery.isLoading
|
||||
|
@ -18,6 +18,7 @@ import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
ActionDropdown,
|
||||
CancelItemAction,
|
||||
@ -27,7 +28,6 @@ import {
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -288,14 +288,14 @@ export default function SalesOrderDetail() {
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
url={apiUrl(ApiEndpoints.sales_order_list, id)}
|
||||
data={order.notes ?? ''}
|
||||
allowEdit={true}
|
||||
modelType={ModelType.salesorder}
|
||||
modelId={order.pk}
|
||||
editable={user.hasChangeRole(UserRoles.sales_order)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [order, id]);
|
||||
}, [order, id, user]);
|
||||
|
||||
const soActions = useMemo(() => {
|
||||
return [
|
||||
|
@ -21,6 +21,7 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import DetailsBadge from '../../components/details/DetailsBadge';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import {
|
||||
ActionDropdown,
|
||||
BarcodeActionDropdown,
|
||||
@ -35,7 +36,6 @@ import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -338,14 +338,14 @@ export default function StockDetail() {
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
url={apiUrl(ApiEndpoints.stock_item_list, id)}
|
||||
data={stockitem.notes ?? ''}
|
||||
allowEdit={true}
|
||||
modelType={ModelType.stockitem}
|
||||
modelId={stockitem.pk}
|
||||
editable={user.hasChangeRole(UserRoles.stock)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [stockitem, id]);
|
||||
}, [stockitem, id, user]);
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
() => [
|
||||
|
@ -196,3 +196,31 @@ test('PUI - Pages - Part - Parameters', async ({ page }) => {
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('PUI - Pages - Part - Notes', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/69/notes`);
|
||||
|
||||
// Enable editing
|
||||
await page.getByLabel('toggle-notes-editing').click();
|
||||
|
||||
// Enter some text
|
||||
await page
|
||||
.getByRole('textbox')
|
||||
.getByRole('paragraph')
|
||||
.fill('This is some data\n');
|
||||
|
||||
// Save
|
||||
await page.getByLabel('save-notes').click();
|
||||
await page.getByText('Notes saved successfully').waitFor();
|
||||
|
||||
// Navigate away from the page, and then back
|
||||
await page.goto(`${baseUrl}/stock/location/index/`);
|
||||
await page.waitForURL('**/platform/stock/location/**');
|
||||
await page.getByRole('tab', { name: 'Location Details' }).waitFor();
|
||||
await page.goto(`${baseUrl}/part/69/notes`);
|
||||
|
||||
// Check that the original notes are still present
|
||||
await page.getByText('This is some data').waitFor();
|
||||
});
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user