mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[PUI] Add BOM import tool (#7635)
* Add "field_overrides" field to DataImportSession model * Adjust logic for extracting field value * Add import drawer to BOM table * Enable download of BOM data * Improve support for hidden errors in forms * Improve form submission on front-end - Handle a mix of files and JSON fields - Stringify any objects * Update backend validation for data import session - Accept override values if provided - Ensure correct data format - Update fields for BomItem serializer * Add completion check for data import session * Improvements to importer drawer * Render column selection as a table * Add debouncing to text form fields - Significantly reduces rendering calls * Fix for TextField * Allow instance data to be updated manually * Allow specification of per-field default values when importing data * Improve rendering of import * Improve UI for data import drawer * Bump API version * Add callback after bulk delete * Update playwright test * Fix for editRow function
This commit is contained in:
parent
750e6d81fa
commit
76f8a2ee9e
@ -1,12 +1,15 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 221
|
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
|
v221 - 2024-07-13 : https://github.com/inventree/InvenTree/pull/7636
|
||||||
- Adds missing fields from StockItemBriefSerializer
|
- Adds missing fields from StockItemBriefSerializer
|
||||||
- Adds missing fields from PartBriefSerializer
|
- Adds missing fields from PartBriefSerializer
|
||||||
|
@ -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'))
|
||||||
|
@ -1480,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',
|
||||||
|
@ -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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -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?: any[];
|
barcodeActions?: any[];
|
||||||
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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user