[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:
Oliver 2024-07-14 22:00:29 +10:00 committed by GitHub
parent 750e6d81fa
commit 76f8a2ee9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 565 additions and 101 deletions

View File

@ -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

View File

@ -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'),
),
]

View File

@ -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

View File

@ -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

View File

@ -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:
# 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')) raise ValidationError(_('Value must be a valid dictionary object'))

View File

@ -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',

View File

@ -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);
} }

View File

@ -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':

View 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
}
/>
);
}

View File

@ -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>

View File

@ -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 />
{t`Accept Column Mapping`}
</Group> </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>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{session.columnMappings.map((column: any) => { {session.columnMappings.map((column: any) => {
return [ return (
<Group gap="xs"> <ImporterColumnTableRow
<Text fw={column.required ? 700 : undefined}> session={session}
{column.label ?? column.field} column={column}
</Text> options={columnOptions}
{column.required && ( />
<Text c="red" fw={700}> );
*
</Text>
)}
</Group>,
<Text size="sm" fs="italic">
{column.description}
</Text>,
<ImporterColumn column={column} options={columnOptions} />
];
})} })}
</SimpleGrid> </Table.Tbody>
</Table>
</Stack> </Stack>
); );
} }

View File

@ -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={{

View File

@ -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} />;

View File

@ -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: {}
} }
}; };
} }

View File

@ -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
}; };
} }

View File

@ -93,5 +93,11 @@ export function useInstance<T = any>({
instanceQuery.refetch(); instanceQuery.refetch();
}, []); }, []);
return { instance, refreshInstance, instanceQuery, requestStatus }; return {
instance,
setInstance,
refreshInstance,
instanceQuery,
requestStatus
};
} }

View File

@ -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();
}
}); });
} }
}); });

View File

@ -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();
}}
/>
</> </>
); );
} }

View File

@ -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();