mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'upstream/master' into barcode-generation
This commit is contained in:
commit
508258b362
@ -67,7 +67,7 @@ If you need to process your queue with background workers, run the `worker` task
|
||||
You can either only run InvenTree or use the integrated debugger for debugging. Goto the `Run and debug` side panel make sure `InvenTree Server` is selected. Click on the play button on the left.
|
||||
|
||||
!!! tip "Debug with 3rd party"
|
||||
Sometimes you need to debug also some 3rd party packages. Just select `InvenTree Servre - 3rd party`
|
||||
Sometimes you need to debug also some 3rd party packages. Just select `InvenTree Server - 3rd party`
|
||||
|
||||
You can now set breakpoints and vscode will automatically pause execution if that point is hit. You can see all variables available in that context and evaluate some code with the debugger console at the bottom. Use the play or step buttons to continue execution.
|
||||
|
||||
|
@ -6,6 +6,10 @@ title: Stock
|
||||
|
||||
A stock location represents a physical real-world location where *Stock Items* are stored. Locations are arranged in a cascading manner and each location may contain multiple sub-locations, or stock, or both.
|
||||
|
||||
## Stock Location Type
|
||||
|
||||
A stock location type represents a specific type of location (e.g. one specific size of drawer, shelf, ... or box) which can be assigned to multiple stock locations. In the first place, it is used to specify an icon and having the icon in sync for all locations that use this location type, but it also serves as a data field to quickly see what type of location this is. It is planned to add e.g. drawer dimension information to the location type to add a "find a matching, empty stock location" tool.
|
||||
|
||||
## Stock Item
|
||||
|
||||
A *Stock Item* is an actual instance of a [*Part*](../part/part.md) item. It represents a physical quantity of the *Part* in a specific location.
|
||||
|
@ -1,12 +1,20 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 220
|
||||
INVENTREE_API_VERSION = 222
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
v222 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7635
|
||||
- Adjust the BomItem API endpoint to improve data import process
|
||||
|
||||
v221 - 2024-07-13 : https://github.com/inventree/InvenTree/pull/7636
|
||||
- Adds missing fields from StockItemBriefSerializer
|
||||
- Adds missing fields from PartBriefSerializer
|
||||
- Adds extra exportable fields to BuildItemSerializer
|
||||
|
||||
v220 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7585
|
||||
- Adds "revision_of" field to Part serializer
|
||||
- Adds new API filters for "revision" status
|
||||
|
@ -1072,6 +1072,8 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
'part_name',
|
||||
'part_ipn',
|
||||
'available_quantity',
|
||||
'item_batch_code',
|
||||
'item_serial',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@ -1103,6 +1105,8 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
'part_name',
|
||||
'part_ipn',
|
||||
'available_quantity',
|
||||
'item_batch_code',
|
||||
'item_serial_number',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -1138,6 +1142,9 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
part_name = serializers.CharField(source='stock_item.part.name', label=_('Part Name'), read_only=True)
|
||||
part_ipn = serializers.CharField(source='stock_item.part.IPN', label=_('Part IPN'), read_only=True)
|
||||
|
||||
item_batch_code = serializers.CharField(source='stock_item.batch', label=_('Batch Code'), read_only=True)
|
||||
item_serial_number = serializers.CharField(source='stock_item.serial', label=_('Serial Number'), read_only=True)
|
||||
|
||||
# Annotated fields
|
||||
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
|
||||
|
||||
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.2.14 on 2024-07-12 03:35
|
||||
|
||||
from django.db import migrations, models
|
||||
import importer.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('importer', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='dataimportsession',
|
||||
name='field_overrides',
|
||||
field=models.JSONField(blank=True, null=True, validators=[importer.validators.validate_field_defaults], verbose_name='Field Overrides'),
|
||||
),
|
||||
]
|
@ -1,5 +1,6 @@
|
||||
"""Model definitions for the 'importer' app."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
@ -32,6 +33,7 @@ class DataImportSession(models.Model):
|
||||
status: IntegerField for the status of the import session
|
||||
user: ForeignKey to the User who initiated the import
|
||||
field_defaults: JSONField for field default values
|
||||
field_overrides: JSONField for field override values
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@ -92,6 +94,13 @@ class DataImportSession(models.Model):
|
||||
validators=[importer.validators.validate_field_defaults],
|
||||
)
|
||||
|
||||
field_overrides = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Field Overrides'),
|
||||
validators=[importer.validators.validate_field_defaults],
|
||||
)
|
||||
|
||||
@property
|
||||
def field_mapping(self):
|
||||
"""Construct a dict of field mappings for this import session.
|
||||
@ -132,8 +141,15 @@ class DataImportSession(models.Model):
|
||||
|
||||
matched_columns = set()
|
||||
|
||||
field_overrides = self.field_overrides or {}
|
||||
|
||||
# Create a default mapping for each available field in the database
|
||||
for field, field_def in serializer_fields.items():
|
||||
# If an override value is provided for the field,
|
||||
# skip creating a mapping for this field
|
||||
if field in field_overrides:
|
||||
continue
|
||||
|
||||
# Generate a list of possible column names for this field
|
||||
field_options = [
|
||||
field,
|
||||
@ -181,10 +197,15 @@ class DataImportSession(models.Model):
|
||||
required_fields = self.required_fields()
|
||||
|
||||
field_defaults = self.field_defaults or {}
|
||||
field_overrides = self.field_overrides or {}
|
||||
|
||||
missing_fields = []
|
||||
|
||||
for field in required_fields.keys():
|
||||
# An override value exists
|
||||
if field in field_overrides:
|
||||
continue
|
||||
|
||||
# A default value exists
|
||||
if field in field_defaults and field_defaults[field]:
|
||||
continue
|
||||
@ -265,6 +286,18 @@ class DataImportSession(models.Model):
|
||||
self.status = DataImportStatusCode.PROCESSING.value
|
||||
self.save()
|
||||
|
||||
def check_complete(self) -> bool:
|
||||
"""Check if the import session is complete."""
|
||||
if self.completed_row_count < self.row_count:
|
||||
return False
|
||||
|
||||
# Update the status of this session
|
||||
if self.status != DataImportStatusCode.COMPLETE.value:
|
||||
self.status = DataImportStatusCode.COMPLETE.value
|
||||
self.save()
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def row_count(self):
|
||||
"""Return the number of rows in the import session."""
|
||||
@ -467,6 +500,34 @@ class DataImportRow(models.Model):
|
||||
|
||||
complete = models.BooleanField(default=False, verbose_name=_('Complete'))
|
||||
|
||||
@property
|
||||
def default_values(self) -> dict:
|
||||
"""Return a dict object of the 'default' values for this row."""
|
||||
defaults = self.session.field_defaults or {}
|
||||
|
||||
if type(defaults) is not dict:
|
||||
try:
|
||||
defaults = json.loads(str(defaults))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning('Failed to parse default values for import row')
|
||||
defaults = {}
|
||||
|
||||
return defaults
|
||||
|
||||
@property
|
||||
def override_values(self) -> dict:
|
||||
"""Return a dict object of the 'override' values for this row."""
|
||||
overrides = self.session.field_overrides or {}
|
||||
|
||||
if type(overrides) is not dict:
|
||||
try:
|
||||
overrides = json.loads(str(overrides))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning('Failed to parse override values for import row')
|
||||
overrides = {}
|
||||
|
||||
return overrides
|
||||
|
||||
def extract_data(
|
||||
self, available_fields: dict = None, field_mapping: dict = None, commit=True
|
||||
):
|
||||
@ -477,14 +538,24 @@ class DataImportRow(models.Model):
|
||||
if not available_fields:
|
||||
available_fields = self.session.available_fields()
|
||||
|
||||
default_values = self.session.field_defaults or {}
|
||||
overrride_values = self.override_values
|
||||
default_values = self.default_values
|
||||
|
||||
data = {}
|
||||
|
||||
# We have mapped column (file) to field (serializer) already
|
||||
for field, col in field_mapping.items():
|
||||
# Data override (force value and skip any further checks)
|
||||
if field in overrride_values:
|
||||
data[field] = overrride_values[field]
|
||||
continue
|
||||
|
||||
# Default value (if provided)
|
||||
if field in default_values:
|
||||
data[field] = default_values[field]
|
||||
|
||||
# If this field is *not* mapped to any column, skip
|
||||
if not col:
|
||||
if not col or col not in self.row_data:
|
||||
continue
|
||||
|
||||
# Extract field type
|
||||
@ -516,11 +587,14 @@ class DataImportRow(models.Model):
|
||||
- If available, we use the "default" values provided by the import session
|
||||
- If available, we use the "override" values provided by the import session
|
||||
"""
|
||||
data = self.session.field_defaults or {}
|
||||
data = self.default_values
|
||||
|
||||
if self.data:
|
||||
data.update(self.data)
|
||||
|
||||
# Override values take priority, if present
|
||||
data.update(self.override_values)
|
||||
|
||||
return data
|
||||
|
||||
def construct_serializer(self):
|
||||
@ -568,6 +642,8 @@ class DataImportRow(models.Model):
|
||||
self.complete = True
|
||||
self.save()
|
||||
|
||||
self.session.check_complete()
|
||||
|
||||
except Exception as e:
|
||||
self.errors = {'non_field_errors': str(e)}
|
||||
result = False
|
||||
|
@ -1,5 +1,7 @@
|
||||
"""API serializers for the importer app."""
|
||||
|
||||
import json
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -47,6 +49,7 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
|
||||
'columns',
|
||||
'column_mappings',
|
||||
'field_defaults',
|
||||
'field_overrides',
|
||||
'row_count',
|
||||
'completed_row_count',
|
||||
]
|
||||
@ -75,6 +78,32 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
|
||||
|
||||
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||
|
||||
def validate_field_defaults(self, defaults):
|
||||
"""De-stringify the field defaults."""
|
||||
if defaults is None:
|
||||
return None
|
||||
|
||||
if type(defaults) is not dict:
|
||||
try:
|
||||
defaults = json.loads(str(defaults))
|
||||
except:
|
||||
raise ValidationError(_('Invalid field defaults'))
|
||||
|
||||
return defaults
|
||||
|
||||
def validate_field_overrides(self, overrides):
|
||||
"""De-stringify the field overrides."""
|
||||
if overrides is None:
|
||||
return None
|
||||
|
||||
if type(overrides) is not dict:
|
||||
try:
|
||||
overrides = json.loads(str(overrides))
|
||||
except:
|
||||
raise ValidationError(_('Invalid field overrides'))
|
||||
|
||||
return overrides
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Override create method for this serializer.
|
||||
|
||||
@ -167,4 +196,7 @@ class DataImportAcceptRowSerializer(serializers.Serializer):
|
||||
for row in rows:
|
||||
row.validate(commit=True)
|
||||
|
||||
if session := self.context.get('session', None):
|
||||
session.check_complete()
|
||||
|
||||
return rows
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""Custom validation routines for the 'importer' app."""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -46,4 +46,8 @@ def validate_field_defaults(value):
|
||||
return
|
||||
|
||||
if type(value) is not dict:
|
||||
raise ValidationError(_('Value must be a valid dictionary object'))
|
||||
# OK if we can parse it as JSON
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
raise ValidationError(_('Value must be a valid dictionary object'))
|
||||
|
@ -309,7 +309,9 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'image',
|
||||
'thumbnail',
|
||||
'active',
|
||||
'locked',
|
||||
'assembly',
|
||||
'component',
|
||||
'is_template',
|
||||
'purchaseable',
|
||||
'salable',
|
||||
@ -1478,28 +1480,30 @@ class BomItemSerializer(
|
||||
):
|
||||
"""Serializer for BomItem object."""
|
||||
|
||||
import_exclude_fields = ['validated', 'substitutes']
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields."""
|
||||
|
||||
model = BomItem
|
||||
fields = [
|
||||
'part',
|
||||
'sub_part',
|
||||
'reference',
|
||||
'quantity',
|
||||
'overage',
|
||||
'allow_variants',
|
||||
'inherited',
|
||||
'note',
|
||||
'optional',
|
||||
'consumable',
|
||||
'overage',
|
||||
'note',
|
||||
'pk',
|
||||
'part',
|
||||
'part_detail',
|
||||
'pricing_min',
|
||||
'pricing_max',
|
||||
'pricing_min_total',
|
||||
'pricing_max_total',
|
||||
'pricing_updated',
|
||||
'quantity',
|
||||
'reference',
|
||||
'sub_part',
|
||||
'sub_part_detail',
|
||||
'substitutes',
|
||||
'validated',
|
||||
|
@ -78,21 +78,21 @@ def report_page_size_default():
|
||||
return page_size
|
||||
|
||||
|
||||
def encode_image_base64(image, format: str = 'PNG'):
|
||||
def encode_image_base64(image, img_format: str = 'PNG'):
|
||||
"""Return a base-64 encoded image which can be rendered in an <img> tag.
|
||||
|
||||
Arguments:
|
||||
image: {Image} -- Image to encode
|
||||
format: {str} -- Image format (default = 'PNG')
|
||||
img_format: {str} -- Image format (default = 'PNG')
|
||||
|
||||
Returns:
|
||||
str -- Base64 encoded image data e.g. 'data:image/png;base64,xxxxxxxxx'
|
||||
"""
|
||||
fmt = format.lower()
|
||||
img_format = str(img_format).lower()
|
||||
|
||||
buffered = io.BytesIO()
|
||||
image.save(buffered, fmt)
|
||||
image.save(buffered, img_format)
|
||||
|
||||
img_str = base64.b64encode(buffered.getvalue())
|
||||
|
||||
return f'data:image/{fmt};charset=utf-8;base64,' + img_str.decode()
|
||||
return f'data:image/{img_format};charset=utf-8;base64,' + img_str.decode()
|
||||
|
@ -306,6 +306,7 @@ class StockItemSerializerBrief(
|
||||
'location',
|
||||
'quantity',
|
||||
'serial',
|
||||
'batch',
|
||||
'supplier_part',
|
||||
'barcode_hash',
|
||||
]
|
||||
|
@ -384,21 +384,40 @@ export function ApiForm({
|
||||
let method = props.method?.toLowerCase() ?? 'get';
|
||||
|
||||
let hasFiles = false;
|
||||
mapFields(fields, (_path, field) => {
|
||||
if (field.field_type === 'file upload') {
|
||||
hasFiles = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Optionally pre-process the data before submitting it
|
||||
if (props.processFormData) {
|
||||
data = props.processFormData(data);
|
||||
}
|
||||
|
||||
let dataForm = new FormData();
|
||||
|
||||
Object.keys(data).forEach((key: string) => {
|
||||
let value: any = data[key];
|
||||
let field_type = fields[key]?.field_type;
|
||||
|
||||
if (field_type == 'file upload') {
|
||||
hasFiles = true;
|
||||
}
|
||||
|
||||
// Stringify any JSON objects
|
||||
if (typeof value === 'object') {
|
||||
switch (field_type) {
|
||||
case 'file upload':
|
||||
break;
|
||||
default:
|
||||
value = JSON.stringify(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dataForm.append(key, value);
|
||||
});
|
||||
|
||||
return api({
|
||||
method: method,
|
||||
url: url,
|
||||
data: data,
|
||||
data: hasFiles ? dataForm : data,
|
||||
timeout: props.timeout,
|
||||
headers: {
|
||||
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
|
||||
@ -462,7 +481,11 @@ export function ApiForm({
|
||||
for (const [k, v] of Object.entries(errors)) {
|
||||
const path = _path ? `${_path}.${k}` : k;
|
||||
|
||||
if (k === 'non_field_errors' || k === '__all__') {
|
||||
// Determine if field "k" is valid (exists and is visible)
|
||||
let field = fields[k];
|
||||
let valid = field && !field.hidden;
|
||||
|
||||
if (!valid || k === 'non_field_errors' || k === '__all__') {
|
||||
if (Array.isArray(v)) {
|
||||
_nonFieldErrors.push(...v);
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ import { DependentField } from './DependentField';
|
||||
import { NestedObjectField } from './NestedObjectField';
|
||||
import { RelatedModelField } from './RelatedModelField';
|
||||
import { TableField } from './TableField';
|
||||
import TextField from './TextField';
|
||||
|
||||
export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
|
||||
|
||||
@ -223,21 +224,11 @@ export function ApiFormField({
|
||||
case 'url':
|
||||
case 'string':
|
||||
return (
|
||||
<TextInput
|
||||
{...reducedDefinition}
|
||||
ref={field.ref}
|
||||
id={fieldId}
|
||||
aria-label={`text-field-${field.name}`}
|
||||
type={definition.field_type}
|
||||
value={value || ''}
|
||||
error={error?.message}
|
||||
radius="sm"
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
rightSection={
|
||||
value && !definition.required ? (
|
||||
<IconX size="1rem" color="red" onClick={() => onChange('')} />
|
||||
) : null
|
||||
}
|
||||
<TextField
|
||||
definition={reducedDefinition}
|
||||
controller={controller}
|
||||
fieldName={fieldName}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'boolean':
|
||||
|
66
src/frontend/src/components/forms/fields/TextField.tsx
Normal file
66
src/frontend/src/components/forms/fields/TextField.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { TextInput } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useId, useState } from 'react';
|
||||
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
/*
|
||||
* Custom implementation of the mantine <TextInput> component,
|
||||
* used for rendering text input fields in forms.
|
||||
* Uses a debounced value to prevent excessive re-renders.
|
||||
*/
|
||||
export default function TextField({
|
||||
controller,
|
||||
fieldName,
|
||||
definition,
|
||||
onChange
|
||||
}: {
|
||||
controller: UseControllerReturn<FieldValues, any>;
|
||||
definition: any;
|
||||
fieldName: string;
|
||||
onChange: (value: any) => void;
|
||||
}) {
|
||||
const fieldId = useId();
|
||||
const {
|
||||
field,
|
||||
fieldState: { error }
|
||||
} = controller;
|
||||
|
||||
const { value } = field;
|
||||
|
||||
const [rawText, setRawText] = useState(value);
|
||||
const [debouncedText] = useDebouncedValue(rawText, 250);
|
||||
|
||||
useEffect(() => {
|
||||
setRawText(value);
|
||||
}, [value]);
|
||||
|
||||
const onTextChange = useCallback((value: any) => {
|
||||
setRawText(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedText !== value) {
|
||||
onChange(debouncedText);
|
||||
}
|
||||
}, [debouncedText]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
{...definition}
|
||||
ref={field.ref}
|
||||
id={fieldId}
|
||||
aria-label={`text-field-${field.name}`}
|
||||
type={definition.field_type}
|
||||
value={rawText || ''}
|
||||
error={error?.message}
|
||||
radius="sm"
|
||||
onChange={(event) => onTextChange(event.currentTarget.value)}
|
||||
rightSection={
|
||||
value && !definition.required ? (
|
||||
<IconX size="1rem" color="red" onClick={() => onTextChange('')} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Group, HoverCard, Stack, Text } from '@mantine/core';
|
||||
import { Group, HoverCard, Paper, Space, Stack, Text } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconArrowRight,
|
||||
@ -26,6 +26,7 @@ import { RowDeleteAction, RowEditAction } from '../../tables/RowActions';
|
||||
import { ActionButton } from '../buttons/ActionButton';
|
||||
import { YesNoButton } from '../buttons/YesNoButton';
|
||||
import { ApiFormFieldSet } from '../forms/fields/ApiFormField';
|
||||
import { ProgressBar } from '../items/ProgressBar';
|
||||
import { RenderRemoteInstance } from '../render/Instance';
|
||||
|
||||
function ImporterDataCell({
|
||||
@ -178,6 +179,8 @@ export default function ImporterDataSelector({
|
||||
table.clearSelectedRecords();
|
||||
notifications.hide('importing-rows');
|
||||
table.refreshTable();
|
||||
|
||||
session.refreshSession();
|
||||
});
|
||||
},
|
||||
[session.sessionId, table.refreshTable]
|
||||
@ -191,6 +194,7 @@ export default function ImporterDataSelector({
|
||||
title: t`Edit Data`,
|
||||
fields: selectedFields,
|
||||
initialData: selectedRow.data,
|
||||
fetchInitialData: false,
|
||||
processFormData: (data: any) => {
|
||||
// Construct fields back into a single object
|
||||
return {
|
||||
@ -374,6 +378,18 @@ export default function ImporterDataSelector({
|
||||
{editRow.modal}
|
||||
{deleteRow.modal}
|
||||
<Stack gap="xs">
|
||||
<Paper shadow="xs" p="xs">
|
||||
<Group grow justify="apart">
|
||||
<Text size="lg">{t`Processing Data`}</Text>
|
||||
<Space />
|
||||
<ProgressBar
|
||||
maximum={session.rowCount}
|
||||
value={session.completedRowCount}
|
||||
progressLabel
|
||||
/>
|
||||
<Space />
|
||||
</Group>
|
||||
</Paper>
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
columns={columns}
|
||||
@ -388,7 +404,10 @@ export default function ImporterDataSelector({
|
||||
enableColumnSwitching: true,
|
||||
enableColumnCaching: false,
|
||||
enableSelection: true,
|
||||
enableBulkDelete: true
|
||||
enableBulkDelete: true,
|
||||
afterBulkDelete: () => {
|
||||
session.refreshSession();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
@ -2,19 +2,23 @@ import { t } from '@lingui/macro';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Space,
|
||||
Stack,
|
||||
Table,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ImportSessionState } from '../../hooks/UseImportSession';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { StandaloneField } from '../forms/StandaloneField';
|
||||
import { ApiFormFieldType } from '../forms/fields/ApiFormField';
|
||||
|
||||
function ImporterColumn({ column, options }: { column: any; options: any[] }) {
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
@ -54,6 +58,7 @@ function ImporterColumn({ column, options }: { column: any; options: any[] }) {
|
||||
<Select
|
||||
error={errorMessage}
|
||||
clearable
|
||||
searchable
|
||||
placeholder={t`Select column, or leave blank to ignore this field.`}
|
||||
label={undefined}
|
||||
data={options}
|
||||
@ -63,6 +68,92 @@ function ImporterColumn({ column, options }: { column: any; options: any[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ImporterDefaultField({
|
||||
fieldName,
|
||||
session
|
||||
}: {
|
||||
fieldName: string;
|
||||
session: ImportSessionState;
|
||||
}) {
|
||||
const onChange = useCallback(
|
||||
(value: any) => {
|
||||
// Update the default value for the field
|
||||
let defaults = {
|
||||
...session.fieldDefaults,
|
||||
[fieldName]: value
|
||||
};
|
||||
|
||||
api
|
||||
.patch(apiUrl(ApiEndpoints.import_session_list, session.sessionId), {
|
||||
field_defaults: defaults
|
||||
})
|
||||
.then((response: any) => {
|
||||
session.setSessionData(response.data);
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: Error message?
|
||||
});
|
||||
},
|
||||
[fieldName, session, session.fieldDefaults]
|
||||
);
|
||||
|
||||
const fieldDef: ApiFormFieldType = useMemo(() => {
|
||||
let def: any = session.availableFields[fieldName];
|
||||
|
||||
if (def) {
|
||||
def = {
|
||||
...def,
|
||||
value: session.fieldDefaults[fieldName],
|
||||
field_type: def.type,
|
||||
description: def.help_text,
|
||||
onValueChange: onChange
|
||||
};
|
||||
}
|
||||
|
||||
return def;
|
||||
}, [fieldName, session.availableFields, session.fieldDefaults]);
|
||||
|
||||
return (
|
||||
fieldDef && <StandaloneField fieldDefinition={fieldDef} hideLabels={true} />
|
||||
);
|
||||
}
|
||||
|
||||
function ImporterColumnTableRow({
|
||||
session,
|
||||
column,
|
||||
options
|
||||
}: {
|
||||
session: ImportSessionState;
|
||||
column: any;
|
||||
options: any;
|
||||
}) {
|
||||
return (
|
||||
<Table.Tr key={column.label ?? column.field}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Text fw={column.required ? 700 : undefined}>
|
||||
{column.label ?? column.field}
|
||||
</Text>
|
||||
{column.required && (
|
||||
<Text c="red" fw={700}>
|
||||
*
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{column.description}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ImporterColumn column={column} options={options} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ImporterDefaultField fieldName={column.field} session={session} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImporterColumnSelector({
|
||||
session
|
||||
}: {
|
||||
@ -88,7 +179,7 @@ export default function ImporterColumnSelector({
|
||||
|
||||
const columnOptions: any[] = useMemo(() => {
|
||||
return [
|
||||
{ value: '', label: t`Select a column from the data file` },
|
||||
{ value: '', label: t`Ignore this field` },
|
||||
...session.availableColumns.map((column: any) => {
|
||||
return {
|
||||
value: column,
|
||||
@ -100,45 +191,44 @@ export default function ImporterColumnSelector({
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group justify="apart">
|
||||
<Text>{t`Map data columns to database fields`}</Text>
|
||||
<Button
|
||||
color="green"
|
||||
variant="filled"
|
||||
onClick={acceptMapping}
|
||||
>{t`Accept Column Mapping`}</Button>
|
||||
</Group>
|
||||
<Paper shadow="xs" p="xs">
|
||||
<Group grow justify="apart">
|
||||
<Text size="lg">{t`Mapping data columns to database fields`}</Text>
|
||||
<Space />
|
||||
<Button color="green" variant="filled" onClick={acceptMapping}>
|
||||
<Group>
|
||||
<IconCheck />
|
||||
{t`Accept Column Mapping`}
|
||||
</Group>
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
{errorMessage && (
|
||||
<Alert color="red" title={t`Error`}>
|
||||
<Text>{errorMessage}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
<Text fw={700}>{t`Database Field`}</Text>
|
||||
<Text fw={700}>{t`Field Description`}</Text>
|
||||
<Text fw={700}>{t`Imported Column Name`}</Text>
|
||||
<Divider />
|
||||
<Divider />
|
||||
<Divider />
|
||||
{session.columnMappings.map((column: any) => {
|
||||
return [
|
||||
<Group gap="xs">
|
||||
<Text fw={column.required ? 700 : undefined}>
|
||||
{column.label ?? column.field}
|
||||
</Text>
|
||||
{column.required && (
|
||||
<Text c="red" fw={700}>
|
||||
*
|
||||
</Text>
|
||||
)}
|
||||
</Group>,
|
||||
<Text size="sm" fs="italic">
|
||||
{column.description}
|
||||
</Text>,
|
||||
<ImporterColumn column={column} options={columnOptions} />
|
||||
];
|
||||
})}
|
||||
</SimpleGrid>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t`Database Field`}</Table.Th>
|
||||
<Table.Th>{t`Field Description`}</Table.Th>
|
||||
<Table.Th>{t`Imported Column`}</Table.Th>
|
||||
<Table.Th>{t`Default Value`}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{session.columnMappings.map((column: any) => {
|
||||
return (
|
||||
<ImporterColumnTableRow
|
||||
session={session}
|
||||
column={column}
|
||||
options={columnOptions}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@ -1,26 +1,26 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
Space,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
Tooltip
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { IconCircleX } from '@tabler/icons-react';
|
||||
import { ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import {
|
||||
ImportSessionStatus,
|
||||
useImportSession
|
||||
} from '../../hooks/UseImportSession';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
import { StatusRenderer } from '../render/StatusRenderer';
|
||||
import ImporterDataSelector from './ImportDataSelector';
|
||||
import ImporterColumnSelector from './ImporterColumnSelector';
|
||||
import ImporterImportProgress from './ImporterImportProgress';
|
||||
@ -39,10 +39,12 @@ function ImportDrawerStepper({ currentStep }: { currentStep: number }) {
|
||||
active={currentStep}
|
||||
onStepClick={undefined}
|
||||
allowNextStepsSelect={false}
|
||||
iconSize={20}
|
||||
size="xs"
|
||||
>
|
||||
<Stepper.Step label={t`Import Data`} />
|
||||
<Stepper.Step label={t`Upload File`} />
|
||||
<Stepper.Step label={t`Map Columns`} />
|
||||
<Stepper.Step label={t`Import Data`} />
|
||||
<Stepper.Step label={t`Process Data`} />
|
||||
<Stepper.Step label={t`Complete Import`} />
|
||||
</Stepper>
|
||||
@ -60,7 +62,28 @@ export default function ImporterDrawer({
|
||||
}) {
|
||||
const session = useImportSession({ sessionId: sessionId });
|
||||
|
||||
// Map from import steps to stepper steps
|
||||
const currentStep = useMemo(() => {
|
||||
switch (session.status) {
|
||||
default:
|
||||
case ImportSessionStatus.INITIAL:
|
||||
return 0;
|
||||
case ImportSessionStatus.MAPPING:
|
||||
return 1;
|
||||
case ImportSessionStatus.IMPORTING:
|
||||
return 2;
|
||||
case ImportSessionStatus.PROCESSING:
|
||||
return 3;
|
||||
case ImportSessionStatus.COMPLETE:
|
||||
return 4;
|
||||
}
|
||||
}, [session.status]);
|
||||
|
||||
const widget = useMemo(() => {
|
||||
if (session.sessionQuery.isLoading || session.sessionQuery.isFetching) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
switch (session.status) {
|
||||
case ImportSessionStatus.INITIAL:
|
||||
return <Text>Initial : TODO</Text>;
|
||||
@ -71,11 +94,29 @@ export default function ImporterDrawer({
|
||||
case ImportSessionStatus.PROCESSING:
|
||||
return <ImporterDataSelector session={session} />;
|
||||
case ImportSessionStatus.COMPLETE:
|
||||
return <Text>Complete!</Text>;
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Alert
|
||||
color="green"
|
||||
title={t`Import Complete`}
|
||||
icon={<IconCheck />}
|
||||
>
|
||||
{t`Data has been imported successfully`}
|
||||
</Alert>
|
||||
<Button color="blue" onClick={onClose}>{t`Close`}</Button>
|
||||
</Stack>
|
||||
);
|
||||
default:
|
||||
return <Text>Unknown status code: {session?.status}</Text>;
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Alert color="red" title={t`Unknown Status`} icon={<IconCheck />}>
|
||||
{t`Import session has unknown status`}: {session.status}
|
||||
</Alert>
|
||||
<Button color="red" onClick={onClose}>{t`Close`}</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}, [session.status]);
|
||||
}, [session.status, session.sessionQuery]);
|
||||
|
||||
const title: ReactNode = useMemo(() => {
|
||||
return (
|
||||
@ -87,18 +128,11 @@ export default function ImporterDrawer({
|
||||
grow
|
||||
preventGrowOverflow={false}
|
||||
>
|
||||
<StylishText>
|
||||
<StylishText size="lg">
|
||||
{session.sessionData?.statusText ?? t`Importing Data`}
|
||||
</StylishText>
|
||||
{StatusRenderer({
|
||||
status: session.status,
|
||||
type: ModelType.importsession
|
||||
})}
|
||||
<Tooltip label={t`Cancel import session`}>
|
||||
<ActionIcon color="red" variant="transparent" onClick={onClose}>
|
||||
<IconCircleX />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ImportDrawerStepper currentStep={currentStep} />
|
||||
<Space />
|
||||
</Group>
|
||||
<Divider />
|
||||
</Stack>
|
||||
@ -112,7 +146,7 @@ export default function ImporterDrawer({
|
||||
title={title}
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton={false}
|
||||
withCloseButton={true}
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside={false}
|
||||
styles={{
|
||||
|
@ -134,7 +134,11 @@ export function RenderRemoteInstance({
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Text>${pk}</Text>;
|
||||
return (
|
||||
<Text>
|
||||
{model}: {pk}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <RenderInstance model={model} instance={data} />;
|
||||
|
@ -4,8 +4,13 @@ export function dataImporterSessionFields(): ApiFormFieldSet {
|
||||
return {
|
||||
data_file: {},
|
||||
model_type: {},
|
||||
field_detauls: {
|
||||
hidden: true
|
||||
field_defaults: {
|
||||
hidden: true,
|
||||
value: {}
|
||||
},
|
||||
field_overrides: {
|
||||
hidden: true,
|
||||
value: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ export enum ImportSessionStatus {
|
||||
export type ImportSessionState = {
|
||||
sessionId: number;
|
||||
sessionData: any;
|
||||
setSessionData: (data: any) => void;
|
||||
refreshSession: () => void;
|
||||
sessionQuery: any;
|
||||
status: ImportSessionStatus;
|
||||
@ -28,6 +29,10 @@ export type ImportSessionState = {
|
||||
availableColumns: string[];
|
||||
mappedFields: any[];
|
||||
columnMappings: any[];
|
||||
fieldDefaults: any;
|
||||
fieldOverrides: any;
|
||||
rowCount: number;
|
||||
completedRowCount: number;
|
||||
};
|
||||
|
||||
export function useImportSession({
|
||||
@ -38,6 +43,7 @@ export function useImportSession({
|
||||
// Query manager for the import session
|
||||
const {
|
||||
instance: sessionData,
|
||||
setInstance,
|
||||
refreshInstance: refreshSession,
|
||||
instanceQuery: sessionQuery
|
||||
} = useInstance({
|
||||
@ -46,6 +52,12 @@ export function useImportSession({
|
||||
defaultValue: {}
|
||||
});
|
||||
|
||||
const setSessionData = useCallback((data: any) => {
|
||||
console.log('setting session data:');
|
||||
console.log(data);
|
||||
setInstance(data);
|
||||
}, []);
|
||||
|
||||
// Current step of the import process
|
||||
const status: ImportSessionStatus = useMemo(() => {
|
||||
return sessionData?.status ?? ImportSessionStatus.INITIAL;
|
||||
@ -93,8 +105,25 @@ export function useImportSession({
|
||||
);
|
||||
}, [sessionData]);
|
||||
|
||||
const fieldDefaults: any = useMemo(() => {
|
||||
return sessionData?.field_defaults ?? {};
|
||||
}, [sessionData]);
|
||||
|
||||
const fieldOverrides: any = useMemo(() => {
|
||||
return sessionData?.field_overrides ?? {};
|
||||
}, [sessionData]);
|
||||
|
||||
const rowCount: number = useMemo(() => {
|
||||
return sessionData?.row_count ?? 0;
|
||||
}, [sessionData]);
|
||||
|
||||
const completedRowCount: number = useMemo(() => {
|
||||
return sessionData?.completed_row_count ?? 0;
|
||||
}, [sessionData]);
|
||||
|
||||
return {
|
||||
sessionData,
|
||||
setSessionData,
|
||||
sessionId,
|
||||
refreshSession,
|
||||
sessionQuery,
|
||||
@ -102,6 +131,10 @@ export function useImportSession({
|
||||
availableFields,
|
||||
availableColumns,
|
||||
columnMappings,
|
||||
mappedFields
|
||||
mappedFields,
|
||||
fieldDefaults,
|
||||
fieldOverrides,
|
||||
rowCount,
|
||||
completedRowCount
|
||||
};
|
||||
}
|
||||
|
@ -93,5 +93,11 @@ export function useInstance<T = any>({
|
||||
instanceQuery.refetch();
|
||||
}, []);
|
||||
|
||||
return { instance, refreshInstance, instanceQuery, requestStatus };
|
||||
return {
|
||||
instance,
|
||||
setInstance,
|
||||
refreshInstance,
|
||||
instanceQuery,
|
||||
requestStatus
|
||||
};
|
||||
}
|
||||
|
@ -57,7 +57,6 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||
import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
|
||||
|
@ -103,6 +103,7 @@ export type InvenTreeTableProps<T = any> = {
|
||||
enableColumnCaching?: boolean;
|
||||
enableLabels?: boolean;
|
||||
enableReports?: boolean;
|
||||
afterBulkDelete?: () => void;
|
||||
pageSize?: number;
|
||||
barcodeActions?: React.ReactNode[];
|
||||
tableFilters?: TableFilter[];
|
||||
@ -547,6 +548,9 @@ export function InvenTreeTable<T = any>({
|
||||
})
|
||||
.finally(() => {
|
||||
tableState.clearSelectedRecords();
|
||||
if (props.afterBulkDelete) {
|
||||
props.afterBulkDelete();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -4,6 +4,7 @@ import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCircleCheck,
|
||||
IconFileArrowLeft,
|
||||
IconLock,
|
||||
IconSwitch3
|
||||
} from '@tabler/icons-react';
|
||||
@ -15,11 +16,13 @@ import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||
import ImporterDrawer from '../../components/importer/ImporterDrawer';
|
||||
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { bomItemFields } from '../../forms/BomForms';
|
||||
import { dataImporterSessionFields } from '../../forms/ImporterForms';
|
||||
import {
|
||||
useApiFormModal,
|
||||
useCreateApiFormModal,
|
||||
@ -70,6 +73,12 @@ export function BomTable({
|
||||
const table = useTable('bom');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [importOpened, setImportOpened] = useState<boolean>(false);
|
||||
|
||||
const [selectedSession, setSelectedSession] = useState<number | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@ -345,6 +354,29 @@ export function BomTable({
|
||||
|
||||
const [selectedBomItem, setSelectedBomItem] = useState<number>(0);
|
||||
|
||||
const importSessionFields = useMemo(() => {
|
||||
let fields = dataImporterSessionFields();
|
||||
|
||||
fields.model_type.hidden = true;
|
||||
fields.model_type.value = 'bomitem';
|
||||
|
||||
fields.field_overrides.value = {
|
||||
part: partId
|
||||
};
|
||||
|
||||
return fields;
|
||||
}, [partId]);
|
||||
|
||||
const importBomItem = useCreateApiFormModal({
|
||||
url: ApiEndpoints.import_session_list,
|
||||
title: t`Import BOM Data`,
|
||||
fields: importSessionFields,
|
||||
onFormSuccess: (response: any) => {
|
||||
setSelectedSession(response.pk);
|
||||
setImportOpened(true);
|
||||
}
|
||||
});
|
||||
|
||||
const newBomItem = useCreateApiFormModal({
|
||||
url: ApiEndpoints.bom_list,
|
||||
title: t`Add BOM Item`,
|
||||
@ -467,6 +499,12 @@ export function BomTable({
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<ActionButton
|
||||
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
|
||||
tooltip={t`Import BOM Data`}
|
||||
icon={<IconFileArrowLeft />}
|
||||
onClick={() => importBomItem.open()}
|
||||
/>,
|
||||
<ActionButton
|
||||
hidden={partLocked || !user.hasChangeRole(UserRoles.part)}
|
||||
tooltip={t`Validate BOM`}
|
||||
@ -483,6 +521,7 @@ export function BomTable({
|
||||
|
||||
return (
|
||||
<>
|
||||
{importBomItem.modal}
|
||||
{newBomItem.modal}
|
||||
{editBomItem.modal}
|
||||
{validateBom.modal}
|
||||
@ -515,10 +554,20 @@ export function BomTable({
|
||||
modelField: 'sub_part',
|
||||
rowActions: rowActions,
|
||||
enableSelection: !partLocked,
|
||||
enableBulkDelete: !partLocked
|
||||
enableBulkDelete: !partLocked,
|
||||
enableDownload: true
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<ImporterDrawer
|
||||
sessionId={selectedSession ?? -1}
|
||||
opened={selectedSession !== undefined && importOpened}
|
||||
onClose={() => {
|
||||
setSelectedSession(undefined);
|
||||
setImportOpened(false);
|
||||
table.refreshTable();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { t } from '@lingui/macro';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import {
|
||||
useDeleteApiFormModal,
|
||||
@ -58,6 +59,13 @@ export default function BuildAllocatedStockTable({
|
||||
sortable: true,
|
||||
switchable: false
|
||||
},
|
||||
{
|
||||
accessor: 'serial',
|
||||
title: t`Serial Number`,
|
||||
sortable: false,
|
||||
switchable: true,
|
||||
render: (record: any) => record?.stock_item_detail?.serial
|
||||
},
|
||||
{
|
||||
accessor: 'batch',
|
||||
title: t`Batch Code`,
|
||||
@ -150,7 +158,9 @@ export default function BuildAllocatedStockTable({
|
||||
enableDownload: true,
|
||||
enableSelection: true,
|
||||
rowActions: rowActions,
|
||||
tableFilters: tableFilters
|
||||
tableFilters: tableFilters,
|
||||
modelField: 'stock_item',
|
||||
modelType: ModelType.stockitem
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -19,10 +19,10 @@ test('PUI - Pages - Build Order', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: 'Allocated Stock' }).click();
|
||||
|
||||
// Check for expected text in the table
|
||||
await page.getByText('R_10R_0402_1%').click();
|
||||
await page.getByText('R_10R_0402_1%').waitFor();
|
||||
await page
|
||||
.getByRole('cell', { name: 'R38, R39, R40, R41, R42, R43' })
|
||||
.click();
|
||||
.waitFor();
|
||||
|
||||
// Click through to the "parent" build
|
||||
await page.getByRole('tab', { name: 'Build Details' }).click();
|
||||
|
@ -180,6 +180,10 @@ test('PUI - Pages - Part - Attachments', async ({ page }) => {
|
||||
await page.getByLabel('action-button-add-external-').click();
|
||||
await page.getByLabel('text-field-link').fill('https://www.google.com');
|
||||
await page.getByLabel('text-field-comment').fill('a sample comment');
|
||||
|
||||
// Note: Text field values are debounced for 250ms
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('cell', { name: 'a sample comment' }).first().waitFor();
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user