mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-16 20:15:44 +00:00
Merge remote-tracking branch 'upstream/master' into barcode-generation
This commit is contained in:
@ -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.
|
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"
|
!!! 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.
|
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.
|
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
|
## 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.
|
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 information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
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
|
v220 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7585
|
||||||
- Adds "revision_of" field to Part serializer
|
- Adds "revision_of" field to Part serializer
|
||||||
- Adds new API filters for "revision" status
|
- Adds new API filters for "revision" status
|
||||||
|
@ -1072,6 +1072,8 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
'part_name',
|
'part_name',
|
||||||
'part_ipn',
|
'part_ipn',
|
||||||
'available_quantity',
|
'available_quantity',
|
||||||
|
'item_batch_code',
|
||||||
|
'item_serial',
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -1103,6 +1105,8 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
'part_name',
|
'part_name',
|
||||||
'part_ipn',
|
'part_ipn',
|
||||||
'available_quantity',
|
'available_quantity',
|
||||||
|
'item_batch_code',
|
||||||
|
'item_serial_number',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
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_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)
|
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
|
# Annotated fields
|
||||||
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
|
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."""
|
"""Model definitions for the 'importer' app."""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@ -32,6 +33,7 @@ class DataImportSession(models.Model):
|
|||||||
status: IntegerField for the status of the import session
|
status: IntegerField for the status of the import session
|
||||||
user: ForeignKey to the User who initiated the import
|
user: ForeignKey to the User who initiated the import
|
||||||
field_defaults: JSONField for field default values
|
field_defaults: JSONField for field default values
|
||||||
|
field_overrides: JSONField for field override values
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -92,6 +94,13 @@ class DataImportSession(models.Model):
|
|||||||
validators=[importer.validators.validate_field_defaults],
|
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
|
@property
|
||||||
def field_mapping(self):
|
def field_mapping(self):
|
||||||
"""Construct a dict of field mappings for this import session.
|
"""Construct a dict of field mappings for this import session.
|
||||||
@ -132,8 +141,15 @@ class DataImportSession(models.Model):
|
|||||||
|
|
||||||
matched_columns = set()
|
matched_columns = set()
|
||||||
|
|
||||||
|
field_overrides = self.field_overrides or {}
|
||||||
|
|
||||||
# Create a default mapping for each available field in the database
|
# Create a default mapping for each available field in the database
|
||||||
for field, field_def in serializer_fields.items():
|
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
|
# Generate a list of possible column names for this field
|
||||||
field_options = [
|
field_options = [
|
||||||
field,
|
field,
|
||||||
@ -181,10 +197,15 @@ class DataImportSession(models.Model):
|
|||||||
required_fields = self.required_fields()
|
required_fields = self.required_fields()
|
||||||
|
|
||||||
field_defaults = self.field_defaults or {}
|
field_defaults = self.field_defaults or {}
|
||||||
|
field_overrides = self.field_overrides or {}
|
||||||
|
|
||||||
missing_fields = []
|
missing_fields = []
|
||||||
|
|
||||||
for field in required_fields.keys():
|
for field in required_fields.keys():
|
||||||
|
# An override value exists
|
||||||
|
if field in field_overrides:
|
||||||
|
continue
|
||||||
|
|
||||||
# A default value exists
|
# A default value exists
|
||||||
if field in field_defaults and field_defaults[field]:
|
if field in field_defaults and field_defaults[field]:
|
||||||
continue
|
continue
|
||||||
@ -265,6 +286,18 @@ class DataImportSession(models.Model):
|
|||||||
self.status = DataImportStatusCode.PROCESSING.value
|
self.status = DataImportStatusCode.PROCESSING.value
|
||||||
self.save()
|
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
|
@property
|
||||||
def row_count(self):
|
def row_count(self):
|
||||||
"""Return the number of rows in the import session."""
|
"""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'))
|
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(
|
def extract_data(
|
||||||
self, available_fields: dict = None, field_mapping: dict = None, commit=True
|
self, available_fields: dict = None, field_mapping: dict = None, commit=True
|
||||||
):
|
):
|
||||||
@ -477,14 +538,24 @@ class DataImportRow(models.Model):
|
|||||||
if not available_fields:
|
if not available_fields:
|
||||||
available_fields = self.session.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 = {}
|
data = {}
|
||||||
|
|
||||||
# We have mapped column (file) to field (serializer) already
|
# We have mapped column (file) to field (serializer) already
|
||||||
for field, col in field_mapping.items():
|
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 this field is *not* mapped to any column, skip
|
||||||
if not col:
|
if not col or col not in self.row_data:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Extract field type
|
# 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 "default" values provided by the import session
|
||||||
- If available, we use the "override" 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:
|
if self.data:
|
||||||
data.update(self.data)
|
data.update(self.data)
|
||||||
|
|
||||||
|
# Override values take priority, if present
|
||||||
|
data.update(self.override_values)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def construct_serializer(self):
|
def construct_serializer(self):
|
||||||
@ -568,6 +642,8 @@ class DataImportRow(models.Model):
|
|||||||
self.complete = True
|
self.complete = True
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
self.session.check_complete()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.errors = {'non_field_errors': str(e)}
|
self.errors = {'non_field_errors': str(e)}
|
||||||
result = False
|
result = False
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"""API serializers for the importer app."""
|
"""API serializers for the importer app."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@ -47,6 +49,7 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
|
|||||||
'columns',
|
'columns',
|
||||||
'column_mappings',
|
'column_mappings',
|
||||||
'field_defaults',
|
'field_defaults',
|
||||||
|
'field_overrides',
|
||||||
'row_count',
|
'row_count',
|
||||||
'completed_row_count',
|
'completed_row_count',
|
||||||
]
|
]
|
||||||
@ -75,6 +78,32 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
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):
|
def create(self, validated_data):
|
||||||
"""Override create method for this serializer.
|
"""Override create method for this serializer.
|
||||||
|
|
||||||
@ -167,4 +196,7 @@ class DataImportAcceptRowSerializer(serializers.Serializer):
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
row.validate(commit=True)
|
row.validate(commit=True)
|
||||||
|
|
||||||
|
if session := self.context.get('session', None):
|
||||||
|
session.check_complete()
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
"""Custom validation routines for the 'importer' app."""
|
"""Custom validation routines for the 'importer' app."""
|
||||||
|
|
||||||
import os
|
import json
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -46,4 +46,8 @@ def validate_field_defaults(value):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if type(value) is not dict:
|
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',
|
'image',
|
||||||
'thumbnail',
|
'thumbnail',
|
||||||
'active',
|
'active',
|
||||||
|
'locked',
|
||||||
'assembly',
|
'assembly',
|
||||||
|
'component',
|
||||||
'is_template',
|
'is_template',
|
||||||
'purchaseable',
|
'purchaseable',
|
||||||
'salable',
|
'salable',
|
||||||
@ -1478,28 +1480,30 @@ class BomItemSerializer(
|
|||||||
):
|
):
|
||||||
"""Serializer for BomItem object."""
|
"""Serializer for BomItem object."""
|
||||||
|
|
||||||
|
import_exclude_fields = ['validated', 'substitutes']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Metaclass defining serializer fields."""
|
"""Metaclass defining serializer fields."""
|
||||||
|
|
||||||
model = BomItem
|
model = BomItem
|
||||||
fields = [
|
fields = [
|
||||||
|
'part',
|
||||||
|
'sub_part',
|
||||||
|
'reference',
|
||||||
|
'quantity',
|
||||||
|
'overage',
|
||||||
'allow_variants',
|
'allow_variants',
|
||||||
'inherited',
|
'inherited',
|
||||||
'note',
|
|
||||||
'optional',
|
'optional',
|
||||||
'consumable',
|
'consumable',
|
||||||
'overage',
|
'note',
|
||||||
'pk',
|
'pk',
|
||||||
'part',
|
|
||||||
'part_detail',
|
'part_detail',
|
||||||
'pricing_min',
|
'pricing_min',
|
||||||
'pricing_max',
|
'pricing_max',
|
||||||
'pricing_min_total',
|
'pricing_min_total',
|
||||||
'pricing_max_total',
|
'pricing_max_total',
|
||||||
'pricing_updated',
|
'pricing_updated',
|
||||||
'quantity',
|
|
||||||
'reference',
|
|
||||||
'sub_part',
|
|
||||||
'sub_part_detail',
|
'sub_part_detail',
|
||||||
'substitutes',
|
'substitutes',
|
||||||
'validated',
|
'validated',
|
||||||
|
@ -78,21 +78,21 @@ def report_page_size_default():
|
|||||||
return page_size
|
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.
|
"""Return a base-64 encoded image which can be rendered in an <img> tag.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
image: {Image} -- Image to encode
|
image: {Image} -- Image to encode
|
||||||
format: {str} -- Image format (default = 'PNG')
|
img_format: {str} -- Image format (default = 'PNG')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str -- Base64 encoded image data e.g. 'data:image/png;base64,xxxxxxxxx'
|
str -- Base64 encoded image data e.g. 'data:image/png;base64,xxxxxxxxx'
|
||||||
"""
|
"""
|
||||||
fmt = format.lower()
|
img_format = str(img_format).lower()
|
||||||
|
|
||||||
buffered = io.BytesIO()
|
buffered = io.BytesIO()
|
||||||
image.save(buffered, fmt)
|
image.save(buffered, img_format)
|
||||||
|
|
||||||
img_str = base64.b64encode(buffered.getvalue())
|
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',
|
'location',
|
||||||
'quantity',
|
'quantity',
|
||||||
'serial',
|
'serial',
|
||||||
|
'batch',
|
||||||
'supplier_part',
|
'supplier_part',
|
||||||
'barcode_hash',
|
'barcode_hash',
|
||||||
]
|
]
|
||||||
|
@ -384,21 +384,40 @@ export function ApiForm({
|
|||||||
let method = props.method?.toLowerCase() ?? 'get';
|
let method = props.method?.toLowerCase() ?? 'get';
|
||||||
|
|
||||||
let hasFiles = false;
|
let hasFiles = false;
|
||||||
mapFields(fields, (_path, field) => {
|
|
||||||
if (field.field_type === 'file upload') {
|
|
||||||
hasFiles = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optionally pre-process the data before submitting it
|
// Optionally pre-process the data before submitting it
|
||||||
if (props.processFormData) {
|
if (props.processFormData) {
|
||||||
data = props.processFormData(data);
|
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({
|
return api({
|
||||||
method: method,
|
method: method,
|
||||||
url: url,
|
url: url,
|
||||||
data: data,
|
data: hasFiles ? dataForm : data,
|
||||||
timeout: props.timeout,
|
timeout: props.timeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
|
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
|
||||||
@ -462,7 +481,11 @@ export function ApiForm({
|
|||||||
for (const [k, v] of Object.entries(errors)) {
|
for (const [k, v] of Object.entries(errors)) {
|
||||||
const path = _path ? `${_path}.${k}` : k;
|
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)) {
|
if (Array.isArray(v)) {
|
||||||
_nonFieldErrors.push(...v);
|
_nonFieldErrors.push(...v);
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ import { DependentField } from './DependentField';
|
|||||||
import { NestedObjectField } from './NestedObjectField';
|
import { NestedObjectField } from './NestedObjectField';
|
||||||
import { RelatedModelField } from './RelatedModelField';
|
import { RelatedModelField } from './RelatedModelField';
|
||||||
import { TableField } from './TableField';
|
import { TableField } from './TableField';
|
||||||
|
import TextField from './TextField';
|
||||||
|
|
||||||
export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
|
export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
|
||||||
|
|
||||||
@ -223,21 +224,11 @@ export function ApiFormField({
|
|||||||
case 'url':
|
case 'url':
|
||||||
case 'string':
|
case 'string':
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextField
|
||||||
{...reducedDefinition}
|
definition={reducedDefinition}
|
||||||
ref={field.ref}
|
controller={controller}
|
||||||
id={fieldId}
|
fieldName={fieldName}
|
||||||
aria-label={`text-field-${field.name}`}
|
onChange={onChange}
|
||||||
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
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'boolean':
|
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 { 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 { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
@ -26,6 +26,7 @@ import { RowDeleteAction, RowEditAction } from '../../tables/RowActions';
|
|||||||
import { ActionButton } from '../buttons/ActionButton';
|
import { ActionButton } from '../buttons/ActionButton';
|
||||||
import { YesNoButton } from '../buttons/YesNoButton';
|
import { YesNoButton } from '../buttons/YesNoButton';
|
||||||
import { ApiFormFieldSet } from '../forms/fields/ApiFormField';
|
import { ApiFormFieldSet } from '../forms/fields/ApiFormField';
|
||||||
|
import { ProgressBar } from '../items/ProgressBar';
|
||||||
import { RenderRemoteInstance } from '../render/Instance';
|
import { RenderRemoteInstance } from '../render/Instance';
|
||||||
|
|
||||||
function ImporterDataCell({
|
function ImporterDataCell({
|
||||||
@ -178,6 +179,8 @@ export default function ImporterDataSelector({
|
|||||||
table.clearSelectedRecords();
|
table.clearSelectedRecords();
|
||||||
notifications.hide('importing-rows');
|
notifications.hide('importing-rows');
|
||||||
table.refreshTable();
|
table.refreshTable();
|
||||||
|
|
||||||
|
session.refreshSession();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[session.sessionId, table.refreshTable]
|
[session.sessionId, table.refreshTable]
|
||||||
@ -191,6 +194,7 @@ export default function ImporterDataSelector({
|
|||||||
title: t`Edit Data`,
|
title: t`Edit Data`,
|
||||||
fields: selectedFields,
|
fields: selectedFields,
|
||||||
initialData: selectedRow.data,
|
initialData: selectedRow.data,
|
||||||
|
fetchInitialData: false,
|
||||||
processFormData: (data: any) => {
|
processFormData: (data: any) => {
|
||||||
// Construct fields back into a single object
|
// Construct fields back into a single object
|
||||||
return {
|
return {
|
||||||
@ -374,6 +378,18 @@ export default function ImporterDataSelector({
|
|||||||
{editRow.modal}
|
{editRow.modal}
|
||||||
{deleteRow.modal}
|
{deleteRow.modal}
|
||||||
<Stack gap="xs">
|
<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
|
<InvenTreeTable
|
||||||
tableState={table}
|
tableState={table}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -388,7 +404,10 @@ export default function ImporterDataSelector({
|
|||||||
enableColumnSwitching: true,
|
enableColumnSwitching: true,
|
||||||
enableColumnCaching: false,
|
enableColumnCaching: false,
|
||||||
enableSelection: true,
|
enableSelection: true,
|
||||||
enableBulkDelete: true
|
enableBulkDelete: true,
|
||||||
|
afterBulkDelete: () => {
|
||||||
|
session.refreshSession();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -2,19 +2,23 @@ import { t } from '@lingui/macro';
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
|
||||||
Group,
|
Group,
|
||||||
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
Space,
|
||||||
Stack,
|
Stack,
|
||||||
|
Table,
|
||||||
Text
|
Text
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
import { IconCheck } from '@tabler/icons-react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '../../App';
|
import { api } from '../../App';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { ImportSessionState } from '../../hooks/UseImportSession';
|
import { ImportSessionState } from '../../hooks/UseImportSession';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
|
import { StandaloneField } from '../forms/StandaloneField';
|
||||||
|
import { ApiFormFieldType } from '../forms/fields/ApiFormField';
|
||||||
|
|
||||||
function ImporterColumn({ column, options }: { column: any; options: any[] }) {
|
function ImporterColumn({ column, options }: { column: any; options: any[] }) {
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
@ -54,6 +58,7 @@ function ImporterColumn({ column, options }: { column: any; options: any[] }) {
|
|||||||
<Select
|
<Select
|
||||||
error={errorMessage}
|
error={errorMessage}
|
||||||
clearable
|
clearable
|
||||||
|
searchable
|
||||||
placeholder={t`Select column, or leave blank to ignore this field.`}
|
placeholder={t`Select column, or leave blank to ignore this field.`}
|
||||||
label={undefined}
|
label={undefined}
|
||||||
data={options}
|
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({
|
export default function ImporterColumnSelector({
|
||||||
session
|
session
|
||||||
}: {
|
}: {
|
||||||
@ -88,7 +179,7 @@ export default function ImporterColumnSelector({
|
|||||||
|
|
||||||
const columnOptions: any[] = useMemo(() => {
|
const columnOptions: any[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{ value: '', label: t`Select a column from the data file` },
|
{ value: '', label: t`Ignore this field` },
|
||||||
...session.availableColumns.map((column: any) => {
|
...session.availableColumns.map((column: any) => {
|
||||||
return {
|
return {
|
||||||
value: column,
|
value: column,
|
||||||
@ -100,45 +191,44 @@ export default function ImporterColumnSelector({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group justify="apart">
|
<Paper shadow="xs" p="xs">
|
||||||
<Text>{t`Map data columns to database fields`}</Text>
|
<Group grow justify="apart">
|
||||||
<Button
|
<Text size="lg">{t`Mapping data columns to database fields`}</Text>
|
||||||
color="green"
|
<Space />
|
||||||
variant="filled"
|
<Button color="green" variant="filled" onClick={acceptMapping}>
|
||||||
onClick={acceptMapping}
|
<Group>
|
||||||
>{t`Accept Column Mapping`}</Button>
|
<IconCheck />
|
||||||
</Group>
|
{t`Accept Column Mapping`}
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<Alert color="red" title={t`Error`}>
|
<Alert color="red" title={t`Error`}>
|
||||||
<Text>{errorMessage}</Text>
|
<Text>{errorMessage}</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<SimpleGrid cols={3} spacing="xs">
|
<Table>
|
||||||
<Text fw={700}>{t`Database Field`}</Text>
|
<Table.Thead>
|
||||||
<Text fw={700}>{t`Field Description`}</Text>
|
<Table.Tr>
|
||||||
<Text fw={700}>{t`Imported Column Name`}</Text>
|
<Table.Th>{t`Database Field`}</Table.Th>
|
||||||
<Divider />
|
<Table.Th>{t`Field Description`}</Table.Th>
|
||||||
<Divider />
|
<Table.Th>{t`Imported Column`}</Table.Th>
|
||||||
<Divider />
|
<Table.Th>{t`Default Value`}</Table.Th>
|
||||||
{session.columnMappings.map((column: any) => {
|
</Table.Tr>
|
||||||
return [
|
</Table.Thead>
|
||||||
<Group gap="xs">
|
<Table.Tbody>
|
||||||
<Text fw={column.required ? 700 : undefined}>
|
{session.columnMappings.map((column: any) => {
|
||||||
{column.label ?? column.field}
|
return (
|
||||||
</Text>
|
<ImporterColumnTableRow
|
||||||
{column.required && (
|
session={session}
|
||||||
<Text c="red" fw={700}>
|
column={column}
|
||||||
*
|
options={columnOptions}
|
||||||
</Text>
|
/>
|
||||||
)}
|
);
|
||||||
</Group>,
|
})}
|
||||||
<Text size="sm" fs="italic">
|
</Table.Tbody>
|
||||||
{column.description}
|
</Table>
|
||||||
</Text>,
|
|
||||||
<ImporterColumn column={column} options={columnOptions} />
|
|
||||||
];
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,26 +1,26 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
Alert,
|
||||||
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
Drawer,
|
Drawer,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
LoadingOverlay,
|
LoadingOverlay,
|
||||||
Paper,
|
Paper,
|
||||||
|
Space,
|
||||||
Stack,
|
Stack,
|
||||||
Stepper,
|
Stepper,
|
||||||
Text,
|
Text
|
||||||
Tooltip
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconCircleX } from '@tabler/icons-react';
|
import { IconCheck } from '@tabler/icons-react';
|
||||||
import { ReactNode, useCallback, useMemo, useState } from 'react';
|
import { ReactNode, useMemo } from 'react';
|
||||||
|
|
||||||
import { ModelType } from '../../enums/ModelType';
|
|
||||||
import {
|
import {
|
||||||
ImportSessionStatus,
|
ImportSessionStatus,
|
||||||
useImportSession
|
useImportSession
|
||||||
} from '../../hooks/UseImportSession';
|
} from '../../hooks/UseImportSession';
|
||||||
import { StylishText } from '../items/StylishText';
|
import { StylishText } from '../items/StylishText';
|
||||||
import { StatusRenderer } from '../render/StatusRenderer';
|
|
||||||
import ImporterDataSelector from './ImportDataSelector';
|
import ImporterDataSelector from './ImportDataSelector';
|
||||||
import ImporterColumnSelector from './ImporterColumnSelector';
|
import ImporterColumnSelector from './ImporterColumnSelector';
|
||||||
import ImporterImportProgress from './ImporterImportProgress';
|
import ImporterImportProgress from './ImporterImportProgress';
|
||||||
@ -39,10 +39,12 @@ function ImportDrawerStepper({ currentStep }: { currentStep: number }) {
|
|||||||
active={currentStep}
|
active={currentStep}
|
||||||
onStepClick={undefined}
|
onStepClick={undefined}
|
||||||
allowNextStepsSelect={false}
|
allowNextStepsSelect={false}
|
||||||
|
iconSize={20}
|
||||||
size="xs"
|
size="xs"
|
||||||
>
|
>
|
||||||
<Stepper.Step label={t`Import Data`} />
|
<Stepper.Step label={t`Upload File`} />
|
||||||
<Stepper.Step label={t`Map Columns`} />
|
<Stepper.Step label={t`Map Columns`} />
|
||||||
|
<Stepper.Step label={t`Import Data`} />
|
||||||
<Stepper.Step label={t`Process Data`} />
|
<Stepper.Step label={t`Process Data`} />
|
||||||
<Stepper.Step label={t`Complete Import`} />
|
<Stepper.Step label={t`Complete Import`} />
|
||||||
</Stepper>
|
</Stepper>
|
||||||
@ -60,7 +62,28 @@ export default function ImporterDrawer({
|
|||||||
}) {
|
}) {
|
||||||
const session = useImportSession({ sessionId: sessionId });
|
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(() => {
|
const widget = useMemo(() => {
|
||||||
|
if (session.sessionQuery.isLoading || session.sessionQuery.isFetching) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
switch (session.status) {
|
switch (session.status) {
|
||||||
case ImportSessionStatus.INITIAL:
|
case ImportSessionStatus.INITIAL:
|
||||||
return <Text>Initial : TODO</Text>;
|
return <Text>Initial : TODO</Text>;
|
||||||
@ -71,11 +94,29 @@ export default function ImporterDrawer({
|
|||||||
case ImportSessionStatus.PROCESSING:
|
case ImportSessionStatus.PROCESSING:
|
||||||
return <ImporterDataSelector session={session} />;
|
return <ImporterDataSelector session={session} />;
|
||||||
case ImportSessionStatus.COMPLETE:
|
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:
|
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(() => {
|
const title: ReactNode = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@ -87,18 +128,11 @@ export default function ImporterDrawer({
|
|||||||
grow
|
grow
|
||||||
preventGrowOverflow={false}
|
preventGrowOverflow={false}
|
||||||
>
|
>
|
||||||
<StylishText>
|
<StylishText size="lg">
|
||||||
{session.sessionData?.statusText ?? t`Importing Data`}
|
{session.sessionData?.statusText ?? t`Importing Data`}
|
||||||
</StylishText>
|
</StylishText>
|
||||||
{StatusRenderer({
|
<ImportDrawerStepper currentStep={currentStep} />
|
||||||
status: session.status,
|
<Space />
|
||||||
type: ModelType.importsession
|
|
||||||
})}
|
|
||||||
<Tooltip label={t`Cancel import session`}>
|
|
||||||
<ActionIcon color="red" variant="transparent" onClick={onClose}>
|
|
||||||
<IconCircleX />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
</Stack>
|
</Stack>
|
||||||
@ -112,7 +146,7 @@ export default function ImporterDrawer({
|
|||||||
title={title}
|
title={title}
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
withCloseButton={false}
|
withCloseButton={true}
|
||||||
closeOnEscape={false}
|
closeOnEscape={false}
|
||||||
closeOnClickOutside={false}
|
closeOnClickOutside={false}
|
||||||
styles={{
|
styles={{
|
||||||
|
@ -134,7 +134,11 @@ export function RenderRemoteInstance({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <Text>${pk}</Text>;
|
return (
|
||||||
|
<Text>
|
||||||
|
{model}: {pk}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RenderInstance model={model} instance={data} />;
|
return <RenderInstance model={model} instance={data} />;
|
||||||
|
@ -4,8 +4,13 @@ export function dataImporterSessionFields(): ApiFormFieldSet {
|
|||||||
return {
|
return {
|
||||||
data_file: {},
|
data_file: {},
|
||||||
model_type: {},
|
model_type: {},
|
||||||
field_detauls: {
|
field_defaults: {
|
||||||
hidden: true
|
hidden: true,
|
||||||
|
value: {}
|
||||||
|
},
|
||||||
|
field_overrides: {
|
||||||
|
hidden: true,
|
||||||
|
value: {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ export enum ImportSessionStatus {
|
|||||||
export type ImportSessionState = {
|
export type ImportSessionState = {
|
||||||
sessionId: number;
|
sessionId: number;
|
||||||
sessionData: any;
|
sessionData: any;
|
||||||
|
setSessionData: (data: any) => void;
|
||||||
refreshSession: () => void;
|
refreshSession: () => void;
|
||||||
sessionQuery: any;
|
sessionQuery: any;
|
||||||
status: ImportSessionStatus;
|
status: ImportSessionStatus;
|
||||||
@ -28,6 +29,10 @@ export type ImportSessionState = {
|
|||||||
availableColumns: string[];
|
availableColumns: string[];
|
||||||
mappedFields: any[];
|
mappedFields: any[];
|
||||||
columnMappings: any[];
|
columnMappings: any[];
|
||||||
|
fieldDefaults: any;
|
||||||
|
fieldOverrides: any;
|
||||||
|
rowCount: number;
|
||||||
|
completedRowCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useImportSession({
|
export function useImportSession({
|
||||||
@ -38,6 +43,7 @@ export function useImportSession({
|
|||||||
// Query manager for the import session
|
// Query manager for the import session
|
||||||
const {
|
const {
|
||||||
instance: sessionData,
|
instance: sessionData,
|
||||||
|
setInstance,
|
||||||
refreshInstance: refreshSession,
|
refreshInstance: refreshSession,
|
||||||
instanceQuery: sessionQuery
|
instanceQuery: sessionQuery
|
||||||
} = useInstance({
|
} = useInstance({
|
||||||
@ -46,6 +52,12 @@ export function useImportSession({
|
|||||||
defaultValue: {}
|
defaultValue: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setSessionData = useCallback((data: any) => {
|
||||||
|
console.log('setting session data:');
|
||||||
|
console.log(data);
|
||||||
|
setInstance(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Current step of the import process
|
// Current step of the import process
|
||||||
const status: ImportSessionStatus = useMemo(() => {
|
const status: ImportSessionStatus = useMemo(() => {
|
||||||
return sessionData?.status ?? ImportSessionStatus.INITIAL;
|
return sessionData?.status ?? ImportSessionStatus.INITIAL;
|
||||||
@ -93,8 +105,25 @@ export function useImportSession({
|
|||||||
);
|
);
|
||||||
}, [sessionData]);
|
}, [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 {
|
return {
|
||||||
sessionData,
|
sessionData,
|
||||||
|
setSessionData,
|
||||||
sessionId,
|
sessionId,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
sessionQuery,
|
sessionQuery,
|
||||||
@ -102,6 +131,10 @@ export function useImportSession({
|
|||||||
availableFields,
|
availableFields,
|
||||||
availableColumns,
|
availableColumns,
|
||||||
columnMappings,
|
columnMappings,
|
||||||
mappedFields
|
mappedFields,
|
||||||
|
fieldDefaults,
|
||||||
|
fieldOverrides,
|
||||||
|
rowCount,
|
||||||
|
completedRowCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -93,5 +93,11 @@ export function useInstance<T = any>({
|
|||||||
instanceQuery.refetch();
|
instanceQuery.refetch();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { instance, refreshInstance, instanceQuery, requestStatus };
|
return {
|
||||||
|
instance,
|
||||||
|
setInstance,
|
||||||
|
refreshInstance,
|
||||||
|
instanceQuery,
|
||||||
|
requestStatus
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -57,7 +57,6 @@ import {
|
|||||||
useEditApiFormModal
|
useEditApiFormModal
|
||||||
} from '../../hooks/UseForm';
|
} from '../../hooks/UseForm';
|
||||||
import { useInstance } from '../../hooks/UseInstance';
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||||
import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
|
import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
|
||||||
|
@ -103,6 +103,7 @@ export type InvenTreeTableProps<T = any> = {
|
|||||||
enableColumnCaching?: boolean;
|
enableColumnCaching?: boolean;
|
||||||
enableLabels?: boolean;
|
enableLabels?: boolean;
|
||||||
enableReports?: boolean;
|
enableReports?: boolean;
|
||||||
|
afterBulkDelete?: () => void;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
barcodeActions?: React.ReactNode[];
|
barcodeActions?: React.ReactNode[];
|
||||||
tableFilters?: TableFilter[];
|
tableFilters?: TableFilter[];
|
||||||
@ -547,6 +548,9 @@ export function InvenTreeTable<T = any>({
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
tableState.clearSelectedRecords();
|
tableState.clearSelectedRecords();
|
||||||
|
if (props.afterBulkDelete) {
|
||||||
|
props.afterBulkDelete();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -4,6 +4,7 @@ import { showNotification } from '@mantine/notifications';
|
|||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconCircleCheck,
|
IconCircleCheck,
|
||||||
|
IconFileArrowLeft,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconSwitch3
|
IconSwitch3
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
@ -15,11 +16,13 @@ import { ActionButton } from '../../components/buttons/ActionButton';
|
|||||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
||||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||||
|
import ImporterDrawer from '../../components/importer/ImporterDrawer';
|
||||||
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
|
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { ModelType } from '../../enums/ModelType';
|
import { ModelType } from '../../enums/ModelType';
|
||||||
import { UserRoles } from '../../enums/Roles';
|
import { UserRoles } from '../../enums/Roles';
|
||||||
import { bomItemFields } from '../../forms/BomForms';
|
import { bomItemFields } from '../../forms/BomForms';
|
||||||
|
import { dataImporterSessionFields } from '../../forms/ImporterForms';
|
||||||
import {
|
import {
|
||||||
useApiFormModal,
|
useApiFormModal,
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
@ -70,6 +73,12 @@ export function BomTable({
|
|||||||
const table = useTable('bom');
|
const table = useTable('bom');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [importOpened, setImportOpened] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [selectedSession, setSelectedSession] = useState<number | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
const tableColumns: TableColumn[] = useMemo(() => {
|
const tableColumns: TableColumn[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -345,6 +354,29 @@ export function BomTable({
|
|||||||
|
|
||||||
const [selectedBomItem, setSelectedBomItem] = useState<number>(0);
|
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({
|
const newBomItem = useCreateApiFormModal({
|
||||||
url: ApiEndpoints.bom_list,
|
url: ApiEndpoints.bom_list,
|
||||||
title: t`Add BOM Item`,
|
title: t`Add BOM Item`,
|
||||||
@ -467,6 +499,12 @@ export function BomTable({
|
|||||||
|
|
||||||
const tableActions = useMemo(() => {
|
const tableActions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
<ActionButton
|
||||||
|
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
|
||||||
|
tooltip={t`Import BOM Data`}
|
||||||
|
icon={<IconFileArrowLeft />}
|
||||||
|
onClick={() => importBomItem.open()}
|
||||||
|
/>,
|
||||||
<ActionButton
|
<ActionButton
|
||||||
hidden={partLocked || !user.hasChangeRole(UserRoles.part)}
|
hidden={partLocked || !user.hasChangeRole(UserRoles.part)}
|
||||||
tooltip={t`Validate BOM`}
|
tooltip={t`Validate BOM`}
|
||||||
@ -483,6 +521,7 @@ export function BomTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{importBomItem.modal}
|
||||||
{newBomItem.modal}
|
{newBomItem.modal}
|
||||||
{editBomItem.modal}
|
{editBomItem.modal}
|
||||||
{validateBom.modal}
|
{validateBom.modal}
|
||||||
@ -515,10 +554,20 @@ export function BomTable({
|
|||||||
modelField: 'sub_part',
|
modelField: 'sub_part',
|
||||||
rowActions: rowActions,
|
rowActions: rowActions,
|
||||||
enableSelection: !partLocked,
|
enableSelection: !partLocked,
|
||||||
enableBulkDelete: !partLocked
|
enableBulkDelete: !partLocked,
|
||||||
|
enableDownload: true
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</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 { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
|
import { ModelType } from '../../enums/ModelType';
|
||||||
import { UserRoles } from '../../enums/Roles';
|
import { UserRoles } from '../../enums/Roles';
|
||||||
import {
|
import {
|
||||||
useDeleteApiFormModal,
|
useDeleteApiFormModal,
|
||||||
@ -58,6 +59,13 @@ export default function BuildAllocatedStockTable({
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
switchable: false
|
switchable: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessor: 'serial',
|
||||||
|
title: t`Serial Number`,
|
||||||
|
sortable: false,
|
||||||
|
switchable: true,
|
||||||
|
render: (record: any) => record?.stock_item_detail?.serial
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessor: 'batch',
|
accessor: 'batch',
|
||||||
title: t`Batch Code`,
|
title: t`Batch Code`,
|
||||||
@ -150,7 +158,9 @@ export default function BuildAllocatedStockTable({
|
|||||||
enableDownload: true,
|
enableDownload: true,
|
||||||
enableSelection: true,
|
enableSelection: true,
|
||||||
rowActions: rowActions,
|
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();
|
await page.getByRole('tab', { name: 'Allocated Stock' }).click();
|
||||||
|
|
||||||
// Check for expected text in the table
|
// 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
|
await page
|
||||||
.getByRole('cell', { name: 'R38, R39, R40, R41, R42, R43' })
|
.getByRole('cell', { name: 'R38, R39, R40, R41, R42, R43' })
|
||||||
.click();
|
.waitFor();
|
||||||
|
|
||||||
// Click through to the "parent" build
|
// Click through to the "parent" build
|
||||||
await page.getByRole('tab', { name: 'Build Details' }).click();
|
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('action-button-add-external-').click();
|
||||||
await page.getByLabel('text-field-link').fill('https://www.google.com');
|
await page.getByLabel('text-field-link').fill('https://www.google.com');
|
||||||
await page.getByLabel('text-field-comment').fill('a sample comment');
|
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('button', { name: 'Submit' }).click();
|
||||||
await page.getByRole('cell', { name: 'a sample comment' }).first().waitFor();
|
await page.getByRole('cell', { name: 'a sample comment' }).first().waitFor();
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user