Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue11460
This commit is contained in:
commit
4b4a698d33
|
|
@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation.
|
||||
- [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created.
|
||||
- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules.
|
||||
- [#11920](https://github.com/inventree/InvenTree/pull/11920) adds support for renaming attachments after they have been uploaded. This includes both backend and frontend changes, allowing users to rename attachments via the API or through the UI.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ Remove parts from a stock item record - for example taking parts from stock for
|
|||
|
||||
Count stock items (stocktake) to record the number of items in stock at a given point of time. The quantity for each part is pre-filled with the current quantity based on stock item history.
|
||||
|
||||
An optional **Location** field allows all counted items to be moved to a new location in the same operation. If left blank, each item retains its current location.
|
||||
|
||||
{{ image("stock/stock_count.png", "Stock Count") }}
|
||||
|
||||
### Merge Stock
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 496
|
||||
INVENTREE_API_VERSION = 497
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v497 -> 2026-05-27 : https://github.com/inventree/InvenTree/pull/12019
|
||||
- Adds "location" field to StockCount API endpoint
|
||||
|
||||
v496 -> 2026-05-26 : https://github.com/inventree/InvenTree/pull/12011
|
||||
- Add "creation_date" field to the StockItem API endpoint
|
||||
|
||||
|
|
|
|||
|
|
@ -550,29 +550,31 @@ def increment_serial_number(serial, part=None):
|
|||
incremented value, or None if incrementing could not be performed.
|
||||
"""
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.ready import isReadOnlyCommand
|
||||
from plugin import PluginMixinEnum, registry
|
||||
|
||||
# Ensure we start with a string value
|
||||
if serial is not None:
|
||||
serial = str(serial).strip()
|
||||
|
||||
# First, let any plugins attempt to increment the serial number
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
try:
|
||||
if not hasattr(plugin, 'increment_serial_number'):
|
||||
continue
|
||||
if not isReadOnlyCommand():
|
||||
# First, let any plugins attempt to increment the serial number
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
try:
|
||||
if not hasattr(plugin, 'increment_serial_number'):
|
||||
continue
|
||||
|
||||
signature = inspect.signature(plugin.increment_serial_number)
|
||||
signature = inspect.signature(plugin.increment_serial_number)
|
||||
|
||||
# Note: 2024-08-21 - The 'part' parameter has been added to the signature
|
||||
if 'part' in signature.parameters:
|
||||
result = plugin.increment_serial_number(serial, part=part)
|
||||
else:
|
||||
result = plugin.increment_serial_number(serial)
|
||||
if result is not None:
|
||||
return str(result)
|
||||
except Exception:
|
||||
log_error('increment_serial_number', plugin=plugin.slug)
|
||||
# Note: 2024-08-21 - The 'part' parameter has been added to the signature
|
||||
if 'part' in signature.parameters:
|
||||
result = plugin.increment_serial_number(serial, part=part)
|
||||
else:
|
||||
result = plugin.increment_serial_number(serial)
|
||||
if result is not None:
|
||||
return str(result)
|
||||
except Exception:
|
||||
log_error('increment_serial_number', plugin=plugin.slug)
|
||||
|
||||
# If we get to here, no plugins were able to "increment" the provided serial value
|
||||
# Attempt to perform increment according to some basic rules
|
||||
|
|
|
|||
|
|
@ -92,10 +92,23 @@ class PluginValidationMixin(DiffMixin):
|
|||
Any model class which inherits from this mixin will be exposed to the plugin validation system.
|
||||
"""
|
||||
|
||||
def should_plugin_validate(self):
|
||||
"""Return True if this model instance should be validated by plugins.
|
||||
|
||||
The default implementation returns True, but this can be overridden in the implementing class if required.
|
||||
"""
|
||||
from InvenTree.ready import isReadOnlyCommand
|
||||
|
||||
# Prevent plugin validation when importing or exporting data
|
||||
return not isReadOnlyCommand()
|
||||
|
||||
def run_plugin_validation(self):
|
||||
"""Throw this model against the plugin validation interface."""
|
||||
from plugin import PluginMixinEnum, registry
|
||||
|
||||
if not self.should_plugin_validate():
|
||||
return
|
||||
|
||||
deltas = self.get_field_deltas()
|
||||
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
|
|
@ -139,15 +152,16 @@ class PluginValidationMixin(DiffMixin):
|
|||
from InvenTree.exceptions import log_error
|
||||
from plugin import PluginMixinEnum, registry
|
||||
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
try:
|
||||
plugin.validate_model_deletion(self)
|
||||
except ValidationError as e:
|
||||
# Plugin might raise a ValidationError to prevent deletion
|
||||
raise e
|
||||
except Exception:
|
||||
log_error('validate_model_deletion', plugin=plugin.slug)
|
||||
continue
|
||||
if self.should_plugin_validate():
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
try:
|
||||
plugin.validate_model_deletion(self)
|
||||
except ValidationError as e:
|
||||
# Plugin might raise a ValidationError to prevent deletion
|
||||
raise e
|
||||
except Exception:
|
||||
log_error('validate_model_deletion', plugin=plugin.slug)
|
||||
continue
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -2875,6 +2875,10 @@ class Parameter(
|
|||
except ValidationError as e:
|
||||
raise ValidationError({'data': e.message})
|
||||
|
||||
if InvenTree.ready.isReadOnlyCommand():
|
||||
# Skip plugin validation checks during read-only management commands
|
||||
return
|
||||
|
||||
# Finally, run custom validation checks (via plugins)
|
||||
from plugin import PluginMixinEnum, registry
|
||||
|
||||
|
|
|
|||
|
|
@ -724,19 +724,21 @@ class Part(
|
|||
"""
|
||||
from plugin import PluginMixinEnum, registry
|
||||
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
# Run the name through each custom validator
|
||||
# If the plugin returns 'True' we will skip any subsequent validation
|
||||
# Skip plugin validation checks during read-only management commands
|
||||
if not InvenTree.ready.isReadOnlyCommand():
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
# Run the name through each custom validator
|
||||
# If the plugin returns 'True' we will skip any subsequent validation
|
||||
|
||||
try:
|
||||
result = plugin.validate_part_name(self.name, self)
|
||||
if result:
|
||||
return
|
||||
except ValidationError as exc:
|
||||
if raise_error:
|
||||
raise ValidationError({'name': exc.message})
|
||||
except Exception:
|
||||
log_error('validate_part_name', plugin=plugin.slug)
|
||||
try:
|
||||
result = plugin.validate_part_name(self.name, self)
|
||||
if result:
|
||||
return
|
||||
except ValidationError as exc:
|
||||
if raise_error:
|
||||
raise ValidationError({'name': exc.message})
|
||||
except Exception:
|
||||
log_error('validate_part_name', plugin=plugin.slug)
|
||||
|
||||
def validate_ipn(self, raise_error=True):
|
||||
"""Ensure that the IPN (internal part number) is valid for this Part".
|
||||
|
|
@ -746,18 +748,20 @@ class Part(
|
|||
"""
|
||||
from plugin import PluginMixinEnum, registry
|
||||
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
try:
|
||||
result = plugin.validate_part_ipn(self.IPN, self)
|
||||
# Skip plugin validation checks during read-only management commands
|
||||
if not InvenTree.ready.isReadOnlyCommand():
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
try:
|
||||
result = plugin.validate_part_ipn(self.IPN, self)
|
||||
|
||||
if result:
|
||||
# A "true" result force skips any subsequent checks
|
||||
break
|
||||
except ValidationError as exc:
|
||||
if raise_error:
|
||||
raise ValidationError({'IPN': exc.message})
|
||||
except Exception:
|
||||
log_error('validate_part_ipn', plugin=plugin.slug)
|
||||
if result:
|
||||
# A "true" result force skips any subsequent checks
|
||||
break
|
||||
except ValidationError as exc:
|
||||
if raise_error:
|
||||
raise ValidationError({'IPN': exc.message})
|
||||
except Exception:
|
||||
log_error('validate_part_ipn', plugin=plugin.slug)
|
||||
|
||||
# If we get to here, none of the plugins have raised an error
|
||||
pattern = get_global_setting('PART_IPN_REGEX', '', create=False).strip()
|
||||
|
|
@ -835,40 +839,41 @@ class Part(
|
|||
Raises:
|
||||
ValidationError if serial number is invalid and raise_error = True
|
||||
"""
|
||||
serial = str(serial).strip()
|
||||
|
||||
# First, throw the serial number against each of the loaded validation plugins
|
||||
from plugin import PluginMixinEnum, registry
|
||||
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
# Run the serial number through each custom validator
|
||||
# If the plugin returns 'True' we will skip any subsequent validation
|
||||
serial = str(serial).strip()
|
||||
|
||||
try:
|
||||
result = False
|
||||
if not InvenTree.ready.isReadOnlyCommand():
|
||||
# First, throw the serial number against each of the loaded validation plugins
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
# Run the serial number through each custom validator
|
||||
# If the plugin returns 'True' we will skip any subsequent validation
|
||||
|
||||
if hasattr(plugin, 'validate_serial_number'):
|
||||
signature = inspect.signature(plugin.validate_serial_number)
|
||||
try:
|
||||
result = False
|
||||
|
||||
if 'stock_item' in signature.parameters:
|
||||
# 2024-08-21: New method signature accepts a 'stock_item' parameter
|
||||
result = plugin.validate_serial_number(
|
||||
serial, self, stock_item=stock_item
|
||||
)
|
||||
if hasattr(plugin, 'validate_serial_number'):
|
||||
signature = inspect.signature(plugin.validate_serial_number)
|
||||
|
||||
if 'stock_item' in signature.parameters:
|
||||
# 2024-08-21: New method signature accepts a 'stock_item' parameter
|
||||
result = plugin.validate_serial_number(
|
||||
serial, self, stock_item=stock_item
|
||||
)
|
||||
else:
|
||||
# Old method signature - does not accept a 'stock_item' parameter
|
||||
result = plugin.validate_serial_number(serial, self)
|
||||
|
||||
if result is True:
|
||||
return True
|
||||
except ValidationError as exc:
|
||||
if raise_error:
|
||||
# Re-throw the error
|
||||
raise exc
|
||||
else:
|
||||
# Old method signature - does not accept a 'stock_item' parameter
|
||||
result = plugin.validate_serial_number(serial, self)
|
||||
|
||||
if result is True:
|
||||
return True
|
||||
except ValidationError as exc:
|
||||
if raise_error:
|
||||
# Re-throw the error
|
||||
raise exc
|
||||
else:
|
||||
return False
|
||||
except Exception:
|
||||
log_error('validate_serial_number', plugin=plugin.slug)
|
||||
return False
|
||||
except Exception:
|
||||
log_error('validate_serial_number', plugin=plugin.slug)
|
||||
|
||||
"""
|
||||
If we are here, none of the loaded plugins (if any) threw an error or exited early
|
||||
|
|
@ -960,7 +965,7 @@ class Part(
|
|||
"""
|
||||
from plugin import PluginMixinEnum, registry
|
||||
|
||||
if allow_plugins:
|
||||
if allow_plugins and not InvenTree.ready.isReadOnlyCommand():
|
||||
# Check with plugin system
|
||||
# If any plugin returns a non-null result, that takes priority
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
|
|
|
|||
|
|
@ -779,24 +779,24 @@ class StockItem(
|
|||
|
||||
# First, let any plugins convert this serial number to an integer value
|
||||
# If a non-null value is returned (by any plugin) we will use that
|
||||
if not InvenTree.ready.isReadOnlyCommand():
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
try:
|
||||
serial_int = plugin.convert_serial_to_int(serial)
|
||||
except Exception:
|
||||
InvenTree.exceptions.log_error(
|
||||
'convert_serial_to_int', plugin=plugin.slug
|
||||
)
|
||||
serial_int = None
|
||||
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
try:
|
||||
serial_int = plugin.convert_serial_to_int(serial)
|
||||
except Exception:
|
||||
InvenTree.exceptions.log_error(
|
||||
'convert_serial_to_int', plugin=plugin.slug
|
||||
)
|
||||
serial_int = None
|
||||
|
||||
# Save the first returned result
|
||||
if serial_int is not None:
|
||||
# Ensure that it is clipped within a range allowed in the database schema
|
||||
clip = 0x7FFFFFFF
|
||||
serial_int = abs(serial_int)
|
||||
serial_int = min(serial_int, clip)
|
||||
# Return the first non-null value
|
||||
return serial_int
|
||||
# Save the first returned result
|
||||
if serial_int is not None:
|
||||
# Ensure that it is clipped within a range allowed in the database schema
|
||||
clip = 0x7FFFFFFF
|
||||
serial_int = abs(serial_int)
|
||||
serial_int = min(serial_int, clip)
|
||||
# Return the first non-null value
|
||||
return serial_int
|
||||
|
||||
# None of the plugins provided a valid integer value
|
||||
if serial not in [None, '']:
|
||||
|
|
@ -922,15 +922,16 @@ class StockItem(
|
|||
"""
|
||||
from plugin import PluginMixinEnum, registry
|
||||
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
try:
|
||||
plugin.validate_batch_code(self.batch, self)
|
||||
except ValidationError as exc:
|
||||
raise ValidationError({'batch': exc.message})
|
||||
except Exception:
|
||||
InvenTree.exceptions.log_error(
|
||||
'validate_batch_code', plugin=plugin.slug
|
||||
)
|
||||
if not InvenTree.ready.isReadOnlyCommand():
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||
try:
|
||||
plugin.validate_batch_code(self.batch, self)
|
||||
except ValidationError as exc:
|
||||
raise ValidationError({'batch': exc.message})
|
||||
except Exception:
|
||||
InvenTree.exceptions.log_error(
|
||||
'validate_batch_code', plugin=plugin.slug
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
"""Validate the StockItem object (separate to field validation).
|
||||
|
|
@ -2623,6 +2624,7 @@ class StockItem(
|
|||
Keyword Arguments:
|
||||
notes: Optional notes for the stocktake
|
||||
status: Optionally adjust the stock status
|
||||
location: Optionally set the stock location
|
||||
"""
|
||||
try:
|
||||
count = Decimal(count)
|
||||
|
|
@ -2634,6 +2636,14 @@ class StockItem(
|
|||
|
||||
tracking_info = {}
|
||||
|
||||
location = kwargs.pop('location', None)
|
||||
|
||||
if location and location != self.location:
|
||||
old_location = self.location
|
||||
self.location = location
|
||||
tracking_info['location'] = location.pk
|
||||
tracking_info['old_location'] = old_location.pk if old_location else None
|
||||
|
||||
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||
|
||||
if status and not self.compare_status(status):
|
||||
|
|
|
|||
|
|
@ -1744,6 +1744,20 @@ class StockAdjustmentSerializer(serializers.Serializer):
|
|||
class StockCountSerializer(StockAdjustmentSerializer):
|
||||
"""Serializer for counting stock items."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['items', 'notes', 'location']
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.filter(structural=False),
|
||||
many=False,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
label=_('Location'),
|
||||
help_text=_('Set stock location for counted items (optional)'),
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Count stock."""
|
||||
request = self.context['request']
|
||||
|
|
@ -1751,6 +1765,7 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
|||
data = self.validated_data
|
||||
items = data['items']
|
||||
notes = data.get('notes', '')
|
||||
location = data.get('location', None)
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
|
|
@ -1764,6 +1779,9 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
|||
if field_value := item.get(field_name, None):
|
||||
extra[field_name] = field_value
|
||||
|
||||
if location is not None:
|
||||
extra['location'] = location
|
||||
|
||||
stock_item.stocktake(quantity, request.user, notes=notes, **extra)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2253,6 +2253,88 @@ class StocktakeTest(StockAPITestCase):
|
|||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def test_count_with_location(self):
|
||||
"""Test that the stock count endpoint correctly handles the optional location field."""
|
||||
url = reverse('api-stock-count')
|
||||
|
||||
# Stock item pk=1234 starts at location 5; pk=1 starts at location 3
|
||||
item_a = StockItem.objects.get(pk=1234)
|
||||
item_b = StockItem.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(item_a.location.pk, 5)
|
||||
self.assertEqual(item_b.location.pk, 3)
|
||||
|
||||
# --- location is updated when provided (single item) ---
|
||||
response = self.post(
|
||||
url,
|
||||
{'items': [{'pk': item_a.pk, 'quantity': 10}], 'location': 1},
|
||||
expected_code=201,
|
||||
)
|
||||
self.assertEqual(response.data['items'][0]['pk'], item_a.pk)
|
||||
|
||||
item_a.refresh_from_db()
|
||||
self.assertEqual(item_a.location.pk, 1)
|
||||
|
||||
# Tracking entry records the location change
|
||||
entry = StockItemTracking.objects.filter(
|
||||
item=item_a, tracking_type=StockHistoryCode.STOCK_COUNT
|
||||
).latest('date')
|
||||
self.assertEqual(entry.deltas.get('location'), 1)
|
||||
self.assertEqual(entry.deltas.get('old_location'), 5)
|
||||
|
||||
# --- location is updated for multiple items simultaneously ---
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'items': [
|
||||
{'pk': item_a.pk, 'quantity': 5},
|
||||
{'pk': item_b.pk, 'quantity': 20},
|
||||
],
|
||||
'location': 2,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
self.assertEqual(len(response.data['items']), 2)
|
||||
|
||||
item_a.refresh_from_db()
|
||||
item_b.refresh_from_db()
|
||||
self.assertEqual(item_a.location.pk, 2)
|
||||
self.assertEqual(item_b.location.pk, 2)
|
||||
|
||||
# Both items have a tracking entry with the new location
|
||||
for item, old_loc in [(item_a, 1), (item_b, 3)]:
|
||||
entry = StockItemTracking.objects.filter(
|
||||
item=item, tracking_type=StockHistoryCode.STOCK_COUNT
|
||||
).latest('date')
|
||||
self.assertEqual(entry.deltas.get('location'), 2)
|
||||
self.assertEqual(entry.deltas.get('old_location'), old_loc)
|
||||
|
||||
# --- location is unchanged when not provided ---
|
||||
response = self.post(
|
||||
url, {'items': [{'pk': item_a.pk, 'quantity': 7}]}, expected_code=201
|
||||
)
|
||||
|
||||
item_a.refresh_from_db()
|
||||
# Location should still be 2 (unchanged from the previous count)
|
||||
self.assertEqual(item_a.location.pk, 2)
|
||||
|
||||
# Tracking entry has no location delta when location was not provided
|
||||
entry = StockItemTracking.objects.filter(
|
||||
item=item_a, tracking_type=StockHistoryCode.STOCK_COUNT
|
||||
).latest('date')
|
||||
self.assertNotIn('location', entry.deltas)
|
||||
self.assertNotIn('old_location', entry.deltas)
|
||||
|
||||
# --- structural location is rejected ---
|
||||
structural = StockLocation.objects.create(name='Structural', structural=True)
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
{'items': [{'pk': item_a.pk, 'quantity': 1}], 'location': structural.pk},
|
||||
expected_code=400,
|
||||
)
|
||||
self.assertIn('does not exist', str(response.data['location']))
|
||||
|
||||
|
||||
class StockItemDeletionTest(StockAPITestCase):
|
||||
"""Tests for stock item deletion via the API."""
|
||||
|
|
|
|||
|
|
@ -967,6 +967,9 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
|
|||
|
||||
const initialValue = mapAdjustmentItems(items);
|
||||
|
||||
// Extract all location values from the items
|
||||
const locations = [...new Set(items.map((item) => item.location))];
|
||||
|
||||
const fields: ApiFormFieldSet = {
|
||||
items: {
|
||||
field_type: 'table',
|
||||
|
|
@ -990,6 +993,12 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
|
|||
{ title: t`Actions` }
|
||||
]
|
||||
},
|
||||
location: {
|
||||
value: locations.length === 1 ? locations[0] : undefined,
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
},
|
||||
notes: {}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -814,9 +814,9 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
|||
}, [tableProps.onCellClick, tableProps.onRowClick, tableProps.modelType]);
|
||||
|
||||
// When sticky headers are enabled, we adjust the maximum viewport height,
|
||||
// based on the number of records being displayed (up to a maximum of 60vh)
|
||||
// based on the number of records being displayed (up to a maximum of 80vh)
|
||||
const autoHeight = useMemo(() => {
|
||||
const rows = Math.max(10, Math.min(tableState.records.length, 60));
|
||||
const rows = Math.min(80, 6 * Math.max(tableState.records.length, 3));
|
||||
|
||||
return `${rows}vh`;
|
||||
}, [tableState.records]);
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ export default function PluginListTable() {
|
|||
const [pluginPackage, setPluginPackage] = useState<string>('');
|
||||
|
||||
const activatePluginModal = useEditApiFormModal({
|
||||
title: t`Activate Plugin`,
|
||||
title: activate ? t`Activate Plugin` : t`Deactivate Plugin`,
|
||||
url: ApiEndpoints.plugin_activate,
|
||||
pathParams: { key: selectedPluginKey },
|
||||
preFormContent: activateModalContent,
|
||||
|
|
|
|||
|
|
@ -290,8 +290,6 @@ test('Parts - BOM Validation', async ({ browser }) => {
|
|||
// Edit line item, to ensure BOM is not valid
|
||||
const cell = await page.getByRole('cell', { name: 'paint', exact: true });
|
||||
|
||||
// await cell.click({ button: 'right' });
|
||||
// await page.getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
await clickOnRowMenu(cell);
|
||||
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue