Merge commit '6865b0b6e6ed5464c25b4ccca7e3c523ce1876c5' into block-notes
This commit is contained in:
commit
0db3dd5512
|
|
@ -37,10 +37,7 @@ from build.validators import (
|
|||
validate_build_order_reference,
|
||||
)
|
||||
from common.models import ProjectCode
|
||||
from common.settings import (
|
||||
get_global_setting,
|
||||
prevent_build_output_complete_on_incompleted_tests,
|
||||
)
|
||||
from common.settings import get_global_setting
|
||||
from generic.enums import StringEnum
|
||||
from generic.states import StateTransitionMixin, StatusCodeMixin
|
||||
from plugin.events import trigger_event
|
||||
|
|
@ -1093,6 +1090,61 @@ class Build(
|
|||
},
|
||||
)
|
||||
|
||||
def can_complete_output(
|
||||
self,
|
||||
output: stock.models.StockItem,
|
||||
quantity: Optional[decimal.Decimal] = None,
|
||||
required_tests=None,
|
||||
) -> bool:
|
||||
"""Determine if the given build output can be completed.
|
||||
|
||||
Arguments:
|
||||
output: The StockItem instance (build output) to check
|
||||
quantity: The quantity to complete (defaults to entire output quantity)
|
||||
required_tests: Optional list of required tests to check against (defaults to the part's required tests)
|
||||
|
||||
Returns:
|
||||
True if the build output can be completed, False otherwise
|
||||
|
||||
Raises:
|
||||
ValidationError: If the build output cannot be completed, with an appropriate message
|
||||
"""
|
||||
prevent_incomplete = get_global_setting(
|
||||
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS'
|
||||
)
|
||||
|
||||
if prevent_incomplete and not output.passedAllRequiredTests(
|
||||
required_tests=required_tests
|
||||
):
|
||||
raise ValidationError(_('Build output has not passed all required tests'))
|
||||
|
||||
# Ensure that none of the allocated items are themselves still "in production"
|
||||
allocated_items = output.items_to_install.all().filter(
|
||||
stock_item__is_building=True
|
||||
)
|
||||
|
||||
if allocated_items.exists():
|
||||
raise ValidationError(_('Allocated stock items are still in production'))
|
||||
|
||||
if quantity is not None and quantity != output.quantity:
|
||||
# Cannot split a build output with allocated items
|
||||
if output.items_to_install.exists():
|
||||
raise ValidationError({
|
||||
'quantity': _(
|
||||
'Cannot partially complete a build output with allocated items'
|
||||
)
|
||||
})
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must be greater than zero')
|
||||
})
|
||||
|
||||
if quantity > output.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity cannot be greater than the output quantity')
|
||||
})
|
||||
|
||||
@transaction.atomic
|
||||
def complete_build_output(
|
||||
self,
|
||||
|
|
@ -1118,52 +1170,20 @@ class Build(
|
|||
notes = kwargs.get('notes', '')
|
||||
|
||||
required_tests = kwargs.get('required_tests', output.part.getRequiredTests())
|
||||
prevent_on_incomplete = kwargs.get(
|
||||
'prevent_on_incomplete',
|
||||
prevent_build_output_complete_on_incompleted_tests(),
|
||||
|
||||
self.can_complete_output(
|
||||
output, quantity=quantity, required_tests=required_tests
|
||||
)
|
||||
|
||||
if prevent_on_incomplete and not output.passedAllRequiredTests(
|
||||
required_tests=required_tests
|
||||
):
|
||||
msg = _('Build output has not passed all required tests')
|
||||
|
||||
if serial := output.serial:
|
||||
msg = _(f'Build output {serial} has not passed all required tests')
|
||||
|
||||
raise ValidationError(msg)
|
||||
|
||||
# List the allocated BuildItem objects for the given output
|
||||
allocated_items = output.items_to_install.all()
|
||||
|
||||
# Ensure that none of the allocated items are themselves still "in production"
|
||||
for build_item in allocated_items:
|
||||
if build_item.stock_item.is_building:
|
||||
raise ValidationError(
|
||||
_('Allocated stock items are still in production')
|
||||
)
|
||||
|
||||
# If a partial quantity is provided, split the stock output
|
||||
if quantity is not None and quantity != output.quantity:
|
||||
# Cannot split a build output with allocated items
|
||||
if allocated_items.count() > 0:
|
||||
raise ValidationError(
|
||||
_('Cannot partially complete a build output with allocated items')
|
||||
)
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must be greater than zero')
|
||||
})
|
||||
|
||||
if quantity > output.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity cannot be greater than the output quantity')
|
||||
})
|
||||
|
||||
# Split the stock item
|
||||
output = output.splitStock(quantity, user=user, allow_production=True)
|
||||
|
||||
allocated_items = output.items_to_install.all().select_related(
|
||||
'stock_item', 'stock_item__part'
|
||||
)
|
||||
|
||||
for build_item in allocated_items:
|
||||
# Complete the allocation of stock for that item
|
||||
build_item.complete_allocation(user=user)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""JSON serializers for Build API."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import models, transaction
|
||||
|
|
@ -22,7 +24,6 @@ from rest_framework import serializers
|
|||
from rest_framework.serializers import ValidationError
|
||||
|
||||
import common.filters
|
||||
import common.settings
|
||||
import company.serializers
|
||||
import InvenTree.helpers
|
||||
import part.filters
|
||||
|
|
@ -51,6 +52,7 @@ from users.serializers import OwnerSerializer, UserSerializer
|
|||
|
||||
from .models import Build, BuildItem, BuildLine
|
||||
from .status_codes import BuildStatus
|
||||
from .validators import check_build_output
|
||||
|
||||
|
||||
class BuildSerializer(
|
||||
|
|
@ -257,11 +259,19 @@ class BuildOutputSerializer(serializers.Serializer):
|
|||
class BuildOutputQuantitySerializer(BuildOutputSerializer):
|
||||
"""Build output with quantity field."""
|
||||
|
||||
# Optional callable to validate the output field, if required
|
||||
output_validator: Optional[Callable] = None
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
|
||||
fields = [*BuildOutputSerializer.Meta.fields, 'quantity']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the serializer."""
|
||||
self.output_validator = kwargs.pop('output_validator', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
|
|
@ -289,6 +299,10 @@ class BuildOutputQuantitySerializer(BuildOutputSerializer):
|
|||
'quantity': _('Quantity cannot be greater than the output quantity')
|
||||
})
|
||||
|
||||
if self.output_validator:
|
||||
# Call the parent serializer's output validator, if provided
|
||||
self.output_validator(output, quantity=quantity)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
|
@ -524,7 +538,9 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
|||
'notes',
|
||||
]
|
||||
|
||||
outputs = BuildOutputQuantitySerializer(many=True, required=True)
|
||||
outputs = BuildOutputQuantitySerializer(
|
||||
many=True, required=True, output_validator=check_build_output
|
||||
)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
|
|
@ -551,30 +567,6 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
|||
|
||||
outputs = data.get('outputs', [])
|
||||
|
||||
if common.settings.prevent_build_output_complete_on_incompleted_tests():
|
||||
errors = []
|
||||
for output in outputs:
|
||||
stock_item = output['output']
|
||||
if (
|
||||
stock_item.hasRequiredTests()
|
||||
and not stock_item.passedAllRequiredTests()
|
||||
):
|
||||
serial = stock_item.serial
|
||||
|
||||
if serial:
|
||||
errors.append(
|
||||
_(
|
||||
f'Build output {serial} has not passed all required tests'
|
||||
)
|
||||
)
|
||||
else:
|
||||
errors.append(
|
||||
_('Build output has not passed all required tests')
|
||||
)
|
||||
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
if len(outputs) == 0:
|
||||
raise ValidationError(_('A list of build outputs must be provided'))
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from build.models import Build, BuildItem, BuildLine
|
|||
from build.status_codes import BuildStatus
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from part.models import BomItem, BomItemSubstitute, Part
|
||||
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
|
||||
from stock.models import StockItem, StockLocation, StockSortOrder
|
||||
from stock.status_codes import StockStatus
|
||||
|
||||
|
|
@ -1531,10 +1531,15 @@ class BuildOutputScrapTest(BuildAPITest):
|
|||
'notes': 'Partial complete',
|
||||
}
|
||||
|
||||
# Ensure that an invalid quantity raises an error
|
||||
for q in [-4, 0, 999]:
|
||||
# Ensure that an invalid quantity raises an error, with the expected message
|
||||
for q, expected_message in [
|
||||
(-4, 'Ensure this value is greater than or equal to 0'),
|
||||
(0, 'Quantity must be greater than zero'),
|
||||
(999, 'Quantity cannot be greater than the output quantity'),
|
||||
]:
|
||||
data['outputs'][0]['quantity'] = q
|
||||
self.post(url, data, expected_code=400)
|
||||
response = self.post(url, data, expected_code=400)
|
||||
self.assertIn(expected_message, str(response.data))
|
||||
|
||||
# Partially complete the output (with a valid quantity)
|
||||
data['outputs'][0]['quantity'] = 4
|
||||
|
|
@ -1552,6 +1557,94 @@ class BuildOutputScrapTest(BuildAPITest):
|
|||
self.assertEqual(completed_output.status, StockStatus.OK)
|
||||
self.assertFalse(completed_output.is_building)
|
||||
|
||||
def test_complete_with_required_tests(self):
|
||||
"""Test that build output completion is blocked if required tests have not passed."""
|
||||
build = Build.objects.get(pk=1)
|
||||
output = build.create_build_output(1).first()
|
||||
|
||||
template = PartTestTemplate.objects.create(
|
||||
part=build.part, test_name='Required test', required=True
|
||||
)
|
||||
|
||||
set_global_setting(
|
||||
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None
|
||||
)
|
||||
|
||||
url = reverse('api-build-output-complete', kwargs={'pk': build.pk})
|
||||
|
||||
data = {'outputs': [{'output': output.pk}], 'location': 1}
|
||||
|
||||
response = self.post(url, data, expected_code=400)
|
||||
|
||||
self.assertIn(
|
||||
'Build output has not passed all required tests', str(response.data)
|
||||
)
|
||||
|
||||
# Add a passing test result - the output should now be able to be completed
|
||||
output.add_test_result(template=template, result=True)
|
||||
|
||||
self.post(url, data, expected_code=200)
|
||||
|
||||
def test_complete_still_in_production(self):
|
||||
"""Test that build output completion is blocked if an allocated item is still in production."""
|
||||
build = Build.objects.get(pk=1)
|
||||
output = build.create_build_output(1).first()
|
||||
|
||||
build.create_build_line_items()
|
||||
line = build.build_lines.first()
|
||||
|
||||
sub_build = Build.objects.create(
|
||||
part=line.bom_item.sub_part,
|
||||
quantity=1,
|
||||
title='Sub-build',
|
||||
reference='BO-9998',
|
||||
)
|
||||
|
||||
in_production = StockItem.objects.create(
|
||||
part=line.bom_item.sub_part, quantity=1, is_building=True, build=sub_build
|
||||
)
|
||||
|
||||
BuildItem.objects.create(
|
||||
build_line=line, stock_item=in_production, quantity=1, install_into=output
|
||||
)
|
||||
|
||||
url = reverse('api-build-output-complete', kwargs={'pk': build.pk})
|
||||
|
||||
response = self.post(
|
||||
url, {'outputs': [{'output': output.pk}], 'location': 1}, expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Allocated stock items are still in production', str(response.data)
|
||||
)
|
||||
|
||||
def test_partial_complete_with_allocated_items(self):
|
||||
"""Test that a build output with allocated items cannot be partially completed."""
|
||||
build = Build.objects.get(pk=1)
|
||||
output = build.create_build_output(10).first()
|
||||
|
||||
build.create_build_line_items()
|
||||
line = build.build_lines.first()
|
||||
|
||||
stock_item = StockItem.objects.create(part=line.bom_item.sub_part, quantity=10)
|
||||
|
||||
BuildItem.objects.create(
|
||||
build_line=line, stock_item=stock_item, quantity=1, install_into=output
|
||||
)
|
||||
|
||||
url = reverse('api-build-output-complete', kwargs={'pk': build.pk})
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
{'outputs': [{'output': output.pk, 'quantity': 4}], 'location': 1},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Cannot partially complete a build output with allocated items',
|
||||
str(response.data),
|
||||
)
|
||||
|
||||
|
||||
class BuildOutputCancelTest(BuildAPITest):
|
||||
"""Test cancellation of build outputs."""
|
||||
|
|
|
|||
|
|
@ -650,11 +650,15 @@ class BuildTest(BuildTestBase):
|
|||
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
with self.assertRaises(ValidationError) as exc:
|
||||
self.build_w_tests_trackable.complete_build_output(
|
||||
self.stockitem_with_required_test, None
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Build output has not passed all required tests', str(exc.exception)
|
||||
)
|
||||
|
||||
# let's complete the required test and see if it could be saved
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=self.stockitem_with_required_test,
|
||||
|
|
@ -671,6 +675,59 @@ class BuildTest(BuildTestBase):
|
|||
self.stockitem_wo_required_test, None
|
||||
)
|
||||
|
||||
def test_complete_output_still_in_production(self):
|
||||
"""Test that a build output cannot be completed if allocated stock is still in production."""
|
||||
# Create a stock item of the tracked sub-part, which is itself still "in production"
|
||||
sub_build = Build.objects.create(
|
||||
reference=generate_next_build_reference(),
|
||||
title='Building a sub-part',
|
||||
part=self.sub_part_3,
|
||||
quantity=2,
|
||||
issued_by=get_user_model().objects.get(pk=1),
|
||||
)
|
||||
|
||||
in_production = StockItem.objects.create(
|
||||
part=self.sub_part_3, quantity=2, is_building=True, build=sub_build
|
||||
)
|
||||
|
||||
self.allocate_stock(self.output_1, {in_production: 2})
|
||||
|
||||
with self.assertRaises(ValidationError) as exc:
|
||||
self.build.complete_build_output(self.output_1, None)
|
||||
|
||||
self.assertIn(
|
||||
'Allocated stock items are still in production', str(exc.exception)
|
||||
)
|
||||
|
||||
def test_partial_complete_with_allocated_items(self):
|
||||
"""Test that a build output with tracked allocations cannot be partially completed."""
|
||||
# Allocate tracked stock against output_1 (quantity=3)
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
|
||||
with self.assertRaises(ValidationError) as exc:
|
||||
self.build.complete_build_output(self.output_1, None, quantity=1)
|
||||
|
||||
self.assertIn(
|
||||
'Cannot partially complete a build output with allocated items',
|
||||
str(exc.exception),
|
||||
)
|
||||
|
||||
def test_complete_output_invalid_quantity(self):
|
||||
"""Test that invalid quantities are rejected when completing a build output directly."""
|
||||
with self.assertRaises(ValidationError) as exc:
|
||||
self.build.complete_build_output(self.output_1, None, quantity=0)
|
||||
|
||||
self.assertIn('Quantity must be greater than zero', str(exc.exception))
|
||||
|
||||
with self.assertRaises(ValidationError) as exc:
|
||||
self.build.complete_build_output(
|
||||
self.output_1, None, quantity=self.output_1.quantity + 1
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Quantity cannot be greater than the output quantity', str(exc.exception)
|
||||
)
|
||||
|
||||
def test_overdue_notification(self):
|
||||
"""Test sending of notifications when a build order is overdue."""
|
||||
self.ensurePluginsLoaded()
|
||||
|
|
|
|||
|
|
@ -21,3 +21,13 @@ def validate_build_order_reference(value):
|
|||
|
||||
# If we get to here, run the "default" validation routine
|
||||
Build.validate_reference_field(value)
|
||||
|
||||
|
||||
def check_build_output(output, quantity=None):
|
||||
"""Run a validation check against each output before accepting it for completion.
|
||||
|
||||
Arguments:
|
||||
output (StockItem): The build output to check
|
||||
quantity (Decimal, optional): The quantity to complete. If None, the full output quantity is assumed.
|
||||
"""
|
||||
output.build.can_complete_output(output, quantity=quantity)
|
||||
|
|
|
|||
|
|
@ -142,12 +142,3 @@ def stock_expiry_enabled():
|
|||
from common.models import InvenTreeSetting
|
||||
|
||||
return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY', False, create=False)
|
||||
|
||||
|
||||
def prevent_build_output_complete_on_incompleted_tests():
|
||||
"""Returns True if the completion of the build outputs is disabled until the required tests are passed."""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
return InvenTreeSetting.get_setting(
|
||||
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', False, create=False
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1769,7 +1769,7 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
|||
fields = ['items', 'notes', 'location']
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.filter(structural=False),
|
||||
queryset=StockLocation.objects.filter(),
|
||||
many=False,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
|
|
@ -1777,6 +1777,15 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
|||
help_text=_('Set stock location for counted items (optional)'),
|
||||
)
|
||||
|
||||
def validate_location(self, location):
|
||||
"""Validate the provided location."""
|
||||
if location and location.structural:
|
||||
raise ValidationError(
|
||||
_('Structural locations cannot be assigned stock items')
|
||||
)
|
||||
|
||||
return location
|
||||
|
||||
def save(self):
|
||||
"""Count stock."""
|
||||
request = self.context['request']
|
||||
|
|
@ -1873,7 +1882,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
|||
items = StockAdjustmentItemSerializer(many=True, require_non_zero=True)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.filter(structural=False),
|
||||
queryset=StockLocation.objects.filter(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
|
|
@ -1881,6 +1890,15 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
|||
help_text=_('Destination stock location'),
|
||||
)
|
||||
|
||||
def validate_location(self, location):
|
||||
"""Validate the provided location."""
|
||||
if location and location.structural:
|
||||
raise ValidationError(
|
||||
_('Structural locations cannot be assigned stock items')
|
||||
)
|
||||
|
||||
return location
|
||||
|
||||
def save(self):
|
||||
"""Transfer stock."""
|
||||
request = self.context['request']
|
||||
|
|
@ -1964,7 +1982,7 @@ class StockReturnSerializer(StockAdjustmentSerializer):
|
|||
)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.filter(structural=False),
|
||||
queryset=StockLocation.objects.filter(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
|
|
@ -1972,6 +1990,15 @@ class StockReturnSerializer(StockAdjustmentSerializer):
|
|||
help_text=_('Destination stock location'),
|
||||
)
|
||||
|
||||
def validate_location(self, location):
|
||||
"""Validate the provided location."""
|
||||
if location and location.structural:
|
||||
raise ValidationError(
|
||||
_('Structural locations cannot be assigned stock items')
|
||||
)
|
||||
|
||||
return location
|
||||
|
||||
merge = serializers.BooleanField(
|
||||
default=False,
|
||||
required=False,
|
||||
|
|
|
|||
|
|
@ -2435,7 +2435,10 @@ class StocktakeTest(StockAPITestCase):
|
|||
{'items': [{'pk': item_a.pk, 'quantity': 1}], 'location': structural.pk},
|
||||
expected_code=400,
|
||||
)
|
||||
self.assertIn('does not exist', str(response.data['location']))
|
||||
self.assertIn(
|
||||
'Structural locations cannot be assigned stock items',
|
||||
str(response.data['location']),
|
||||
)
|
||||
|
||||
|
||||
class StockTransferMergeTest(StockAPITestCase):
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export default defineConfig({
|
|||
INVENTREE_FRONTEND_API_HOST: 'http://localhost:8000',
|
||||
INVENTREE_CORS_ORIGIN_ALLOW_ALL: 'True',
|
||||
INVENTREE_COOKIE_SAMESITE: 'False',
|
||||
INVENTREE_LOGIN_ATTEMPTS: '100',
|
||||
INVENTREE_LOGIN_ATTEMPTS: '3',
|
||||
INVENTREE_PLUGINS_MANDATORY: 'samplelocate',
|
||||
INVENTREE_CUSTOM_SPLASH: 'img/playwright_custom_splash.png',
|
||||
INVENTREE_CUSTOM_LOGO: 'img/playwright_custom_logo.png'
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { useId } from '@mantine/hooks';
|
|||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { type Control, type FieldValues, useController } from 'react-hook-form';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { IconFileUpload } from '@tabler/icons-react';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
|
@ -19,6 +22,7 @@ import { RelatedModelField } from './RelatedModelField';
|
|||
import { TableField } from './TableField';
|
||||
import TagsField from './TagsField';
|
||||
import TextField from './TextField';
|
||||
import { TreeField } from './TreeField';
|
||||
|
||||
/**
|
||||
* Render an individual form field
|
||||
|
|
@ -121,14 +125,48 @@ export function ApiFormField({
|
|||
const fieldInstance = useMemo(() => {
|
||||
switch (fieldDefinition.field_type) {
|
||||
case 'related field':
|
||||
return (
|
||||
<RelatedModelField
|
||||
definition={fieldDefinition}
|
||||
controller={controller}
|
||||
fieldName={fieldName}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
if (
|
||||
fieldDefinition.api_url === apiUrl(ApiEndpoints.stock_location_list)
|
||||
) {
|
||||
// Redirect location fields to the appropriate tree field
|
||||
return (
|
||||
<TreeField
|
||||
controller={controller}
|
||||
definition={fieldDefinition}
|
||||
fieldName={fieldName}
|
||||
endpoint={ApiEndpoints.stock_location_tree}
|
||||
childIdentifier='sublocations'
|
||||
genericPlaceholder={t`Select location`}
|
||||
model={ModelType.stocklocation}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
fieldDefinition.api_url === apiUrl(ApiEndpoints.category_list)
|
||||
) {
|
||||
// Redirect category fields to the appropriate tree field
|
||||
return (
|
||||
<TreeField
|
||||
controller={controller}
|
||||
definition={fieldDefinition}
|
||||
fieldName={fieldName}
|
||||
endpoint={ApiEndpoints.category_tree}
|
||||
childIdentifier='subcategories'
|
||||
genericPlaceholder={t`Select category`}
|
||||
model={ModelType.partcategory}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<RelatedModelField
|
||||
definition={fieldDefinition}
|
||||
controller={controller}
|
||||
fieldName={fieldName}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'email':
|
||||
case 'url':
|
||||
case 'string':
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Container,
|
||||
Group,
|
||||
|
|
@ -9,7 +10,10 @@ import {
|
|||
Table,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import {
|
||||
IconCornerDownRight,
|
||||
IconExclamationCircle
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
type ReactNode,
|
||||
memo,
|
||||
|
|
@ -63,13 +67,33 @@ function TableFieldRow({
|
|||
);
|
||||
}
|
||||
|
||||
return modelRenderer({
|
||||
item: item,
|
||||
rowId: rowId,
|
||||
rowErrors: rowErrors,
|
||||
changeFn: changeFn,
|
||||
removeFn: removeFn
|
||||
});
|
||||
const nonFieldErrors = rowErrors?.non_field_errors;
|
||||
|
||||
return (
|
||||
<>
|
||||
{modelRenderer({
|
||||
item: item,
|
||||
rowId: rowId,
|
||||
rowErrors: rowErrors,
|
||||
changeFn: changeFn,
|
||||
removeFn: removeFn
|
||||
})}
|
||||
{nonFieldErrors && (
|
||||
<Table.Tr key={`table-row-${rowId}-non-field-errors`}>
|
||||
<Table.Td colSpan={columnCount}>
|
||||
<Group gap='xs'>
|
||||
<ActionIcon size='sm' variant='transparent' c='red'>
|
||||
<IconCornerDownRight />
|
||||
</ActionIcon>
|
||||
<Text size='xs' c='red'>
|
||||
{nonFieldErrors.message ?? nonFieldErrors}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Memoize each table field row, so that we don't re-render the entire table when a single row is updated
|
||||
|
|
|
|||
|
|
@ -0,0 +1,499 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Input,
|
||||
Text,
|
||||
type TreeNodeData,
|
||||
TreeSelect
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue, useId } from '@mantine/hooks';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconLink,
|
||||
IconX
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
import type { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelInformationDict } from '@lib/enums/ModelInformation';
|
||||
import type { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { cancelEvent } from '@lib/functions/Events';
|
||||
import { getDetailUrl, navigateToLink } from '@lib/index';
|
||||
import type { ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { useApi } from '../../../contexts/ApiContext';
|
||||
import {
|
||||
useGlobalSettingsState,
|
||||
useUserSettingsState
|
||||
} from '../../../states/SettingsStates';
|
||||
import { ScanButton } from '../../buttons/ScanButton';
|
||||
import { ApiIcon } from '../../items/ApiIcon';
|
||||
import Expand from '../../items/Expand';
|
||||
import { ModelHoverCard } from '../../render/ModelHoverCard';
|
||||
|
||||
/**
|
||||
* A form field that renders a hierarchical tree selector backed by a tree API
|
||||
* endpoint. Supports server-side search, lazy child loading, and (when a model
|
||||
* type is provided) barcode scanning and a hover-card navigate link.
|
||||
*/
|
||||
export function TreeField({
|
||||
controller,
|
||||
definition,
|
||||
fieldName,
|
||||
endpoint,
|
||||
childIdentifier,
|
||||
genericPlaceholder,
|
||||
model,
|
||||
navigate
|
||||
}: Readonly<{
|
||||
controller: UseControllerReturn<FieldValues, any>;
|
||||
definition: ApiFormFieldType;
|
||||
fieldName: string;
|
||||
endpoint: ApiEndpoints;
|
||||
childIdentifier: string;
|
||||
genericPlaceholder?: string;
|
||||
model?: ModelType;
|
||||
navigate?: NavigateFunction | null;
|
||||
}>) {
|
||||
const api = useApi();
|
||||
const inputId = useId();
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
const userSettings = useUserSettingsState();
|
||||
|
||||
const {
|
||||
field,
|
||||
fieldState: { error }
|
||||
} = controller;
|
||||
|
||||
// Keep the selected pk in sync with form state so we can always request
|
||||
// the selected node (and its ancestors) for label hydration.
|
||||
const selectedValue = useMemo(
|
||||
() => (field.value != null ? Number(field.value) : null),
|
||||
[field.value]
|
||||
);
|
||||
|
||||
// Track dropdown state to separate server-side search text from the
|
||||
// read-only display label shown when the field is closed.
|
||||
const dropdownOpen = useRef(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
// Track the pk whose ancestor path was last auto-expanded.
|
||||
const expandedForValue = useRef<number | null>(null);
|
||||
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
|
||||
const [expandedValues, setExpandedValues] = useState<string[]>([]);
|
||||
const [nodes, setNodes] = useState<any[]>([]);
|
||||
|
||||
const query = useQuery({
|
||||
enabled:
|
||||
!definition.disabled &&
|
||||
!definition.hidden &&
|
||||
(isDropdownOpen || selectedValue != null),
|
||||
queryKey: [
|
||||
'tree-field',
|
||||
fieldName,
|
||||
endpoint,
|
||||
isDropdownOpen,
|
||||
debouncedSearch,
|
||||
selectedValue
|
||||
],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get(apiUrl(endpoint), {
|
||||
params: {
|
||||
...definition.filters,
|
||||
ordering: 'level',
|
||||
search: debouncedSearch || undefined,
|
||||
// Include the selected node and its ancestors in the initial response
|
||||
// so the node label is available before the user interacts with the field.
|
||||
max_level: debouncedSearch ? undefined : 0,
|
||||
expand_to: debouncedSearch
|
||||
? undefined
|
||||
: (selectedValue ?? undefined)
|
||||
}
|
||||
})
|
||||
.then((res) => res.data ?? [])
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(query.data ?? []);
|
||||
}, [query.data]);
|
||||
|
||||
// O(1) lookup for raw node data inside renderNode
|
||||
const nodeMap = useMemo(() => {
|
||||
const map: Record<string, any> = {};
|
||||
for (const n of nodes) map[n.pk.toString()] = n;
|
||||
return map;
|
||||
}, [nodes]);
|
||||
|
||||
// Expand all returned nodes when a search is active so users can see all matches.
|
||||
// On the first browse-mode load, expand the ancestors of the initial value so
|
||||
// the tree shows the path to the currently-selected node.
|
||||
useEffect(() => {
|
||||
if (debouncedSearch) {
|
||||
setExpandedValues(nodes.map((n: any) => n.pk.toString()));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedValue != null &&
|
||||
expandedForValue.current !== selectedValue &&
|
||||
nodes.length > 0
|
||||
) {
|
||||
expandedForValue.current = selectedValue;
|
||||
const map: Record<number, any> = {};
|
||||
for (const n of nodes) map[n.pk] = n;
|
||||
const toExpand: string[] = [];
|
||||
let cur = map[selectedValue];
|
||||
while (cur?.parent) {
|
||||
toExpand.push(String(cur.parent));
|
||||
cur = map[cur.parent];
|
||||
}
|
||||
setExpandedValues(toExpand);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedValue == null) {
|
||||
expandedForValue.current = null;
|
||||
}
|
||||
}, [debouncedSearch, nodes, selectedValue]);
|
||||
|
||||
// Convert the flat API response (sorted by level) into the nested TreeNodeData structure.
|
||||
// `children` is intentionally left undefined on leaf nodes: Mantine's flatten logic uses
|
||||
// Array.isArray(node.children) to detect loaded children, so an empty [] would make every
|
||||
// node look like a parent. Instead we set node.hasChildren from the server-side count field
|
||||
// (childIdentifier) and only attach a children array when a child is actually encountered.
|
||||
const treeData: TreeNodeData[] = useMemo(() => {
|
||||
const map: Record<number, any> = {};
|
||||
const tree: TreeNodeData[] = [];
|
||||
|
||||
const sorted = [...nodes].sort((a, b) => a.level - b.level);
|
||||
|
||||
for (const raw of sorted) {
|
||||
const node: any = {
|
||||
value: raw.pk.toString(),
|
||||
label: raw.name as string,
|
||||
hasChildren: (raw[childIdentifier] ?? 0) > 0
|
||||
};
|
||||
|
||||
map[raw.pk] = node;
|
||||
|
||||
if (!raw.parent) {
|
||||
tree.push(node);
|
||||
} else if (map[raw.parent]) {
|
||||
if (!map[raw.parent].children) {
|
||||
map[raw.parent].children = [];
|
||||
}
|
||||
map[raw.parent].children.push(node);
|
||||
} else {
|
||||
// Keep orphaned nodes visible so selected labels can still resolve
|
||||
// if the API response omits an ancestor.
|
||||
tree.push(node);
|
||||
}
|
||||
}
|
||||
return tree;
|
||||
}, [nodes, childIdentifier]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(val: string | null) => {
|
||||
const pk = val ? Number.parseInt(val, 10) : null;
|
||||
const raw = val ? (nodeMap[val] ?? {}) : {};
|
||||
field.onChange(pk);
|
||||
definition.onValueChange?.(pk, raw);
|
||||
},
|
||||
[field, definition, nodeMap]
|
||||
);
|
||||
|
||||
const selectValue = useMemo(
|
||||
() => (field.value != null ? field.value.toString() : null),
|
||||
[field.value]
|
||||
);
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
if (selectValue == null) return '';
|
||||
const node = nodeMap[selectValue];
|
||||
return node?.pathstring ?? node?.name ?? selectValue;
|
||||
}, [nodeMap, selectValue]);
|
||||
|
||||
const inputSearchValue = isDropdownOpen ? searchValue : selectedLabel;
|
||||
|
||||
const refreshChildren = useCallback(
|
||||
async (nodeValue: string) => {
|
||||
const pk = Number.parseInt(nodeValue, 10);
|
||||
if (Number.isNaN(pk)) return;
|
||||
|
||||
const node = nodeMap[nodeValue];
|
||||
if (!node) return;
|
||||
|
||||
const response = await api.get(apiUrl(endpoint), {
|
||||
params: {
|
||||
...definition.filters,
|
||||
ordering: 'level',
|
||||
parent: pk,
|
||||
max_level: (node.level ?? 0) + 1
|
||||
}
|
||||
});
|
||||
|
||||
const children: any[] = response.data ?? [];
|
||||
|
||||
setNodes((prev) => {
|
||||
const base = prev.filter((n) => n.parent !== pk);
|
||||
const byPk = new Map<number, any>();
|
||||
|
||||
for (const n of base) {
|
||||
byPk.set(n.pk, n);
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
byPk.set(child.pk, child);
|
||||
}
|
||||
|
||||
if (children.length === 0) {
|
||||
const parentNode = byPk.get(pk);
|
||||
if (parentNode) {
|
||||
byPk.set(pk, { ...parentNode, [childIdentifier]: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byPk.values());
|
||||
});
|
||||
},
|
||||
[api, endpoint, nodeMap, childIdentifier]
|
||||
);
|
||||
|
||||
const toggleExpanded = useCallback(
|
||||
(nodeValue: string) => {
|
||||
setExpandedValues((prev) => {
|
||||
const isExpanded = prev.includes(nodeValue);
|
||||
|
||||
if (!isExpanded) {
|
||||
void refreshChildren(nodeValue);
|
||||
return [...prev, nodeValue];
|
||||
}
|
||||
|
||||
return prev.filter((v) => v !== nodeValue);
|
||||
});
|
||||
},
|
||||
[refreshChildren]
|
||||
);
|
||||
|
||||
// --- Navigate / hovercard ---
|
||||
|
||||
const detailUrl = useMemo(() => {
|
||||
if (!model || !selectedValue) return '';
|
||||
return getDetailUrl(model, selectedValue, true);
|
||||
}, [model, selectedValue]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(e: any) => {
|
||||
if (navigate && detailUrl) navigateToLink(detailUrl, navigate, e);
|
||||
},
|
||||
[navigate, detailUrl]
|
||||
);
|
||||
|
||||
// When a navigate model is present and a value is selected, swap out
|
||||
// Mantine's built-in clear button for a custom right section that holds
|
||||
// both a clear button and a link to the model detail page (with tooltip).
|
||||
const showNavigateSection = Boolean(model && selectedValue);
|
||||
|
||||
const navigateRightSection = showNavigateSection ? (
|
||||
<Group gap={2} wrap='nowrap' style={{ paddingRight: 4 }}>
|
||||
{!definition.required && selectValue && (
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
size='xs'
|
||||
color='dimmed'
|
||||
aria-label={t`Clear`}
|
||||
onClick={(e: any) => {
|
||||
cancelEvent(e);
|
||||
onChange(null);
|
||||
}}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ModelHoverCard model={model} pk={selectedValue} navigate={navigate}>
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
size='xs'
|
||||
component='a'
|
||||
href={detailUrl || '#'}
|
||||
target='_blank'
|
||||
aria-label={t`View details`}
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
<IconLink size={12} />
|
||||
</ActionIcon>
|
||||
</ModelHoverCard>
|
||||
</Group>
|
||||
) : undefined;
|
||||
|
||||
// --- Barcode scanning ---
|
||||
|
||||
const modelInfo = useMemo(
|
||||
() => (model ? ModelInformationDict[model] : null),
|
||||
[model]
|
||||
);
|
||||
|
||||
const addBarcodeField = useMemo(() => {
|
||||
if (!modelInfo?.supports_barcode) return false;
|
||||
if (!globalSettings.isSet('BARCODE_ENABLE')) return false;
|
||||
if (!userSettings.isSet('BARCODE_IN_FORM_FIELDS')) return false;
|
||||
return true;
|
||||
}, [modelInfo, globalSettings, userSettings]);
|
||||
|
||||
const onBarcodeScan = useCallback(
|
||||
(_barcode: string, response: any) => {
|
||||
if (!model) return;
|
||||
const modelData = response?.[model] ?? null;
|
||||
if (modelData?.pk) {
|
||||
field.onChange(modelData.pk);
|
||||
definition.onValueChange?.(modelData.pk, modelData);
|
||||
}
|
||||
},
|
||||
[model, field, definition]
|
||||
);
|
||||
|
||||
// --- Render ---
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
label={definition.label}
|
||||
description={definition.description}
|
||||
required={definition.required}
|
||||
error={definition.error ?? error?.message}
|
||||
id={inputId}
|
||||
>
|
||||
<Group wrap='nowrap' gap={3}>
|
||||
<Expand>
|
||||
<TreeSelect
|
||||
id={inputId}
|
||||
data={treeData}
|
||||
aria-label={`tree-field-${fieldName}`}
|
||||
value={selectValue}
|
||||
searchValue={inputSearchValue}
|
||||
onChange={onChange}
|
||||
onSearchChange={(val) => {
|
||||
if (dropdownOpen.current) setSearchValue(val);
|
||||
}}
|
||||
searchable
|
||||
filter={() => true}
|
||||
clearable={showNavigateSection ? false : !definition.required}
|
||||
rightSection={navigateRightSection}
|
||||
rightSectionPointerEvents={showNavigateSection ? 'all' : undefined}
|
||||
rightSectionWidth={
|
||||
showNavigateSection ? (definition.required ? 28 : 52) : undefined
|
||||
}
|
||||
expandedValues={expandedValues}
|
||||
onExpandedChange={setExpandedValues}
|
||||
onDropdownOpen={() => {
|
||||
dropdownOpen.current = true;
|
||||
setIsDropdownOpen(true);
|
||||
}}
|
||||
onDropdownClose={() => {
|
||||
dropdownOpen.current = false;
|
||||
setIsDropdownOpen(false);
|
||||
setSearchValue('');
|
||||
}}
|
||||
placeholder={
|
||||
isDropdownOpen && selectedLabel
|
||||
? selectedLabel
|
||||
: (definition.placeholder ?? genericPlaceholder ?? t`Select...`)
|
||||
}
|
||||
disabled={definition.disabled}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
maxDropdownHeight={300}
|
||||
nothingFoundMessage={
|
||||
query.isFetching ? t`Loading...` : t`No results found`
|
||||
}
|
||||
renderNode={({ node, expanded, hasChildren, selected }) => {
|
||||
const raw = nodeMap[node.value];
|
||||
return (
|
||||
<Group
|
||||
justify='space-between'
|
||||
gap='xs'
|
||||
wrap='nowrap'
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Group gap={4} wrap='nowrap'>
|
||||
{/* Chevron rendered manually so renderNode can coexist with
|
||||
expand behavior. stopPropagation prevents the
|
||||
Combobox.Option from selecting the node when the user
|
||||
clicks the expand toggle. */}
|
||||
<span
|
||||
role={hasChildren ? 'button' : undefined}
|
||||
tabIndex={hasChildren ? 0 : undefined}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
width: 16,
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
cursor: hasChildren ? 'pointer' : 'default'
|
||||
}}
|
||||
aria-label={expanded ? t`Collapse` : t`Expand`}
|
||||
onClick={
|
||||
hasChildren
|
||||
? (event: any) => {
|
||||
cancelEvent(event);
|
||||
toggleExpanded(node.value);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
hasChildren
|
||||
? (event: any) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
cancelEvent(event);
|
||||
toggleExpanded(node.value);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{hasChildren &&
|
||||
(expanded ? (
|
||||
<IconChevronDown size={14} />
|
||||
) : (
|
||||
<IconChevronRight size={14} />
|
||||
))}
|
||||
</span>
|
||||
{raw?.icon && <ApiIcon name={raw.icon} size={14} />}
|
||||
<Text
|
||||
size='sm'
|
||||
fw={selected ? 600 : undefined}
|
||||
fs={raw?.structural ? 'italic' : undefined}
|
||||
c={raw?.structural ? 'dimmed' : undefined}
|
||||
>
|
||||
{raw?.name ?? String(node.label)}
|
||||
</Text>
|
||||
</Group>
|
||||
{raw?.description && (
|
||||
<Text
|
||||
size='xs'
|
||||
c='dimmed'
|
||||
ta='right'
|
||||
truncate
|
||||
flex={1}
|
||||
maw='50%'
|
||||
>
|
||||
{raw.description}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Expand>
|
||||
{addBarcodeField && (
|
||||
<ScanButton modelType={model} onScanSuccess={onBarcodeScan} />
|
||||
)}
|
||||
</Group>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Group,
|
||||
HoverCard,
|
||||
Stack,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
import { ModelInformationDict } from '@lib/enums/ModelInformation';
|
||||
import type { ModelType } from '@lib/enums/ModelType';
|
||||
import { getDetailUrl, navigateToLink } from '@lib/index';
|
||||
import { IconLink } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* Wraps children in a HoverCard showing the model label, pk, and a "View
|
||||
* details" link for the given instance. Renders children directly (no card)
|
||||
* when model or pk is absent.
|
||||
*/
|
||||
export function ModelHoverCard({
|
||||
children,
|
||||
model,
|
||||
pk,
|
||||
navigate
|
||||
}: {
|
||||
children: ReactNode;
|
||||
model: ModelType | undefined;
|
||||
pk: number | null | undefined;
|
||||
navigate?: NavigateFunction | null;
|
||||
}) {
|
||||
const modelInfo = model ? ModelInformationDict[model] : undefined;
|
||||
|
||||
if (!modelInfo || !pk) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const detailUrl = getDetailUrl(model!, pk, true);
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
position='top-end'
|
||||
withinPortal
|
||||
openDelay={500}
|
||||
closeDelay={100}
|
||||
zIndex={99999}
|
||||
>
|
||||
<HoverCard.Target>
|
||||
<span>{children}</span>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Stack gap='xs'>
|
||||
<Group justify='space-between'>
|
||||
<Text size='sm' fw='bold'>
|
||||
{modelInfo.label()}
|
||||
</Text>
|
||||
<Text size='xs'>{`[${t`ID`}: ${pk}]`}</Text>
|
||||
</Group>
|
||||
{detailUrl && (
|
||||
<Anchor
|
||||
href={detailUrl}
|
||||
target='_blank'
|
||||
onClick={(event) => {
|
||||
if (navigate) navigateToLink(detailUrl, navigate, event);
|
||||
}}
|
||||
>
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<ActionIcon variant='transparent' size='xs'>
|
||||
<IconLink />
|
||||
</ActionIcon>
|
||||
<Text size='sm'>{t`View details`}</Text>
|
||||
</Group>
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -128,12 +128,7 @@ export function RenderPartCategory(
|
|||
<RenderInlineModel
|
||||
{...props}
|
||||
tooltip={instance.pathstring}
|
||||
prefix={
|
||||
<>
|
||||
{instance.level > 0 && `${'- '.repeat(instance.level)}`}
|
||||
{instance.icon && <ApiIcon name={instance.icon} />}
|
||||
</>
|
||||
}
|
||||
prefix={instance.icon && <ApiIcon name={instance.icon} />}
|
||||
primary={category}
|
||||
suffix={suffix}
|
||||
url={
|
||||
|
|
|
|||
|
|
@ -48,12 +48,7 @@ export function RenderStockLocation(
|
|||
<RenderInlineModel
|
||||
{...props}
|
||||
tooltip={instance.pathstring}
|
||||
prefix={
|
||||
<>
|
||||
{instance.level > 0 && `${'- '.repeat(instance.level)}`}
|
||||
{instance.icon && <ApiIcon name={instance.icon} />}
|
||||
</>
|
||||
}
|
||||
prefix={instance.icon && <ApiIcon name={instance.icon} />}
|
||||
primary={location}
|
||||
suffix={suffix}
|
||||
url={
|
||||
|
|
|
|||
|
|
@ -129,11 +129,24 @@ export async function doBasicLogin(
|
|||
});
|
||||
break;
|
||||
default:
|
||||
const data = err.response?.data ?? {};
|
||||
|
||||
let msg: string = t`Check your input and try again.`;
|
||||
|
||||
// Extract error message from response data
|
||||
if (data?.detail) {
|
||||
msg = data.detail;
|
||||
} else if (data?.message) {
|
||||
msg = data.message;
|
||||
} else if (data?.error) {
|
||||
msg = data.error;
|
||||
} else if (data?.errors && Array.isArray(data.errors)) {
|
||||
msg = data.errors[0]?.message ?? msg;
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: `${t`Login failed`} (${err.response.status})`,
|
||||
message:
|
||||
err.response?.data?.detail ??
|
||||
t`Check your input and try again.`,
|
||||
message: msg,
|
||||
id: 'auth-login-error',
|
||||
color: 'red'
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -227,8 +227,10 @@ test('Build Order - Calendar', async ({ browser }) => {
|
|||
await page
|
||||
.getByRole('option', { name: 'Part Category', exact: true })
|
||||
.click();
|
||||
await page.getByLabel('related-field-filter-category').click();
|
||||
await page.getByText('Part category, level 1').waitFor();
|
||||
await page.getByLabel('tree-field-filter-category').click();
|
||||
await page.getByText('Part category, level 1').click();
|
||||
await page.getByText('Filter by part category').waitFor();
|
||||
await page.getByText('Category 0').first().waitFor();
|
||||
|
||||
// Required because we downloaded a file
|
||||
await page.context().close();
|
||||
|
|
@ -324,9 +326,9 @@ test('Build Order - Build Outputs', async ({ browser }) => {
|
|||
.getByRole('img', { name: 'field-batch_code-accept-placeholder' })
|
||||
.click();
|
||||
|
||||
await page.getByLabel('related-field-location').click();
|
||||
await page.getByLabel('related-field-location').fill('Reel');
|
||||
await page.getByText('- Electronics Lab/Reel Storage').click();
|
||||
await page.getByLabel('tree-field-location').click();
|
||||
await page.getByLabel('tree-field-location').fill('Reel');
|
||||
await page.getByText('Storage for component reels').click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Should be an error as the number of serial numbers doesn't match the quantity
|
||||
|
|
@ -361,9 +363,10 @@ test('Build Order - Build Outputs', async ({ browser }) => {
|
|||
const row2 = await getRowFromCell(cell2);
|
||||
await row2.getByLabel(/row-action-menu-/i).click();
|
||||
await page.getByRole('menuitem', { name: 'Complete' }).click();
|
||||
await page.getByLabel('related-field-location').click();
|
||||
await page.getByLabel('tree-field-location').click();
|
||||
await page.getByLabel('tree-field-location').fill('Mechanical');
|
||||
await page.getByText('Mechanical Lab').click();
|
||||
await page.waitForTimeout(250);
|
||||
await page.waitForTimeout(100);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Build outputs have been completed').waitFor();
|
||||
|
||||
|
|
@ -385,10 +388,8 @@ test('Build Order - Build Outputs', async ({ browser }) => {
|
|||
|
||||
// Next, adjust the "location" field - and check that the "quantity" field does not change
|
||||
// Ref: https://github.com/inventree/InvenTree/pull/12081
|
||||
await page
|
||||
.getByRole('combobox', { name: 'related-field-location' })
|
||||
.fill('factory');
|
||||
await page.getByTitle('Factory/Mechanical Lab').click();
|
||||
await page.getByLabel('tree-field-location').fill('mechanical');
|
||||
await page.getByText('Mechanical Lab').click();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
// Check the 'quantity' value again - it should not have changed
|
||||
|
|
@ -539,11 +540,7 @@ test('Build Order - Auto Allocate Tracked', async ({ browser }) => {
|
|||
.click();
|
||||
|
||||
// Wait for auto-filled form field
|
||||
await page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Factory$/ })
|
||||
.first()
|
||||
.waitFor();
|
||||
await page.getByText('Factory').waitFor();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Wait for one of the required parts to be allocated
|
||||
|
|
|
|||
|
|
@ -495,6 +495,73 @@ test('Parts - Details', async ({ browser }) => {
|
|||
await page.getByText('Latest Serial Number').waitFor();
|
||||
});
|
||||
|
||||
test('Parts - Details - Upload image modal accepts pasted clipboard image', async ({
|
||||
browser
|
||||
}) => {
|
||||
const page = await doCachedLogin(browser, { url: 'part/113/details' });
|
||||
|
||||
await page
|
||||
.getByRole('tabpanel', { name: 'Part Details' })
|
||||
.locator('img')
|
||||
.hover();
|
||||
|
||||
const uploadButton = page
|
||||
.getByLabel('action-button-upload-new-image')
|
||||
.first();
|
||||
|
||||
await uploadButton.waitFor();
|
||||
await uploadButton.hover();
|
||||
await uploadButton.click();
|
||||
|
||||
const uploadModalTitle = page.getByText('Upload Image', { exact: true });
|
||||
await uploadModalTitle.waitFor();
|
||||
|
||||
await page.evaluate((pngBase64: string) => {
|
||||
const binary = atob(pngBase64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
const imageFile = new File([bytes], 'pasted.png', { type: 'image/png' });
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(imageFile);
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
// Firefox does not reliably honour `clipboardData` passed via the
|
||||
// ClipboardEvent constructor for synthetic paste events. Define it
|
||||
// directly so the app receives the same DataTransfer in all browsers.
|
||||
Object.defineProperty(pasteEvent, 'clipboardData', {
|
||||
value: dataTransfer
|
||||
});
|
||||
|
||||
const pasteTarget = document.activeElement ?? document.body;
|
||||
pasteTarget.dispatchEvent(pasteEvent);
|
||||
}, 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kb9Lw0AcxV9TtSItDmYQEQlYneyiIo61CkWoEGqFVh3Mj/6CJg1Jiouj4Fpw8Mdi1cHFWVcHV0EQ/AHiHyBOii5S4veSQosYD4778O7e4+4dwDUqimZ1xQFNt810MiFkc6tC6BU9GAGPCEYlxTLmRDEF3/F1jwBb72Isy//cnyOi5i0FCAjEccUwbeIN4plN22C8T8wrJUklPieeMOmCxI9Mlz1+Y1x0mWOZvJlJzxPzxEKxg+UOVkqmRjxNHFU1nfK5rMcq4y3GWqWmtO7JXhjO6yvLTKc5jCQWsQQRAmTUUEYFNmK06qRYSNN+wsc/5PpFcsnkKkMhxwKq0CC5frA/+N2tVZia9JLCCaD7xXE+xoDQLtCsO873seM0T4DgM3Clt/3VBjD7SXq9rUWPgP5t4OK6rcl7wOUOMPhkSKbkSkGaXKEAvJ/RN+WAgVugb83rrbWP0wcgQ12lboCDQ2C8SNnrPu/u7ezt3zOt/n4Aps9yu33ABq8AAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfqBh4UDgm4/dnOAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAABpJREFUKM9jrPivzkAKYGIgEYxqGNUwdDQAACVQAb74u92bAAAAAElFTkSuQmCC');
|
||||
|
||||
await expect(page.getByText('clipboard-image.png')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Image uploaded').waitFor();
|
||||
|
||||
// Now remove the associated image
|
||||
await page
|
||||
.getByRole('tabpanel', { name: 'Part Details' })
|
||||
.locator('img')
|
||||
.hover();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-delete-image' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
await page.getByText('The image has been removed successfully').waitFor();
|
||||
});
|
||||
|
||||
test('Parts - Requirements', async ({ browser }) => {
|
||||
// Navigate to the "Widget Assembly" part detail page
|
||||
// This part has multiple "variants"
|
||||
|
|
@ -973,7 +1040,7 @@ test('Parts - Notes', async ({ browser }) => {
|
|||
await page.keyboard.press('Control+E');
|
||||
await page.getByLabel('text-field-name', { exact: true }).waitFor();
|
||||
await page.getByLabel('text-field-description', { exact: true }).waitFor();
|
||||
await page.getByLabel('related-field-category').waitFor();
|
||||
await page.getByLabel('tree-field-category').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Enable notes editing
|
||||
|
|
@ -1024,10 +1091,8 @@ test('Parts - Bulk Edit', async ({ browser }) => {
|
|||
|
||||
await openDetailAction(page, 'part', 'set-category');
|
||||
|
||||
await page.getByLabel('related-field-category').fill('rnitu');
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await page.getByRole('option', { name: '- Furniture/Chairs' }).click();
|
||||
await page.getByLabel('tree-field-category').fill('rnitu');
|
||||
await page.getByText('Furniture and associated').click();
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
await page.getByText('Items Updated').waitFor();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -512,15 +512,8 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
|
|||
await page.getByLabel('action-button-receive-items').click();
|
||||
|
||||
// Check for display of individual locations
|
||||
await page
|
||||
.getByRole('cell', { name: /Choose Location/ })
|
||||
.getByText('Parts Bins')
|
||||
.waitFor();
|
||||
await page
|
||||
.getByRole('cell', { name: /Choose Location/ })
|
||||
.getByText('Room 101')
|
||||
.waitFor();
|
||||
|
||||
await page.getByText('Parts Bins').first().waitFor();
|
||||
await page.getByText('Room 101').first().waitFor();
|
||||
await page.getByText('Mechanical Lab').first().waitFor();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
|
@ -549,8 +542,8 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
|
|||
await page.getByRole('menuitem', { name: 'Receive line item' }).click();
|
||||
|
||||
// Select destination location
|
||||
await page.getByLabel('related-field-location').click();
|
||||
await page.getByRole('option', { name: 'Factory', exact: true }).click();
|
||||
await page.getByLabel('tree-field-location').fill('factory');
|
||||
await page.getByText('Factory', { exact: true }).click();
|
||||
|
||||
// Receive only a *single* item
|
||||
await page.getByLabel('number-field-quantity').fill('1');
|
||||
|
|
@ -611,10 +604,8 @@ test('Purchase Orders - Receive Virtual Items', async ({ browser }) => {
|
|||
.getByRole('button', { name: 'action-button-receive-items' })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('combobox', { name: 'related-field-location' })
|
||||
.fill('factory');
|
||||
await page.getByText('Factory/Storage Room A').click();
|
||||
await page.getByLabel('tree-field-location').fill('factory');
|
||||
await page.getByText('Factory', { exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ test('Barcode Scanning - Forms', async ({ browser }) => {
|
|||
await page
|
||||
.getByRole('button', { name: 'barcode-scan-button-stocklocation' })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('textbox', { name: 'barcode-scan-keyboard-input' })
|
||||
.fill('INV-SL37');
|
||||
|
|
|
|||
|
|
@ -499,7 +499,8 @@ test('Stock - Tracking', async ({ browser }) => {
|
|||
|
||||
// Navigate to the "stock tracking" tab
|
||||
await loadTab(page, 'Stock Tracking');
|
||||
await page.getByText('- - Factory/Office Block/Room').first().waitFor();
|
||||
|
||||
await page.getByText('Factory/Office Block/Room').first().waitFor();
|
||||
await page.getByRole('link', { name: 'Widget Assembly' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ test('Forms - Stock Item Validation', async ({ browser }) => {
|
|||
.waitFor();
|
||||
|
||||
// Set location
|
||||
await page.getByLabel('related-field-location').click();
|
||||
await page.getByLabel('tree-field-location').fill('production');
|
||||
await page.getByText('Electronics production facility').click();
|
||||
|
||||
// Create the stock item
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue