mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +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:
		| @@ -384,21 +384,40 @@ export function ApiForm({ | ||||
|     let method = props.method?.toLowerCase() ?? 'get'; | ||||
|  | ||||
|     let hasFiles = false; | ||||
|     mapFields(fields, (_path, field) => { | ||||
|       if (field.field_type === 'file upload') { | ||||
|         hasFiles = true; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // Optionally pre-process the data before submitting it | ||||
|     if (props.processFormData) { | ||||
|       data = props.processFormData(data); | ||||
|     } | ||||
|  | ||||
|     let dataForm = new FormData(); | ||||
|  | ||||
|     Object.keys(data).forEach((key: string) => { | ||||
|       let value: any = data[key]; | ||||
|       let field_type = fields[key]?.field_type; | ||||
|  | ||||
|       if (field_type == 'file upload') { | ||||
|         hasFiles = true; | ||||
|       } | ||||
|  | ||||
|       // Stringify any JSON objects | ||||
|       if (typeof value === 'object') { | ||||
|         switch (field_type) { | ||||
|           case 'file upload': | ||||
|             break; | ||||
|           default: | ||||
|             value = JSON.stringify(value); | ||||
|             break; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       dataForm.append(key, value); | ||||
|     }); | ||||
|  | ||||
|     return api({ | ||||
|       method: method, | ||||
|       url: url, | ||||
|       data: data, | ||||
|       data: hasFiles ? dataForm : data, | ||||
|       timeout: props.timeout, | ||||
|       headers: { | ||||
|         'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json' | ||||
| @@ -462,7 +481,11 @@ export function ApiForm({ | ||||
|                 for (const [k, v] of Object.entries(errors)) { | ||||
|                   const path = _path ? `${_path}.${k}` : k; | ||||
|  | ||||
|                   if (k === 'non_field_errors' || k === '__all__') { | ||||
|                   // Determine if field "k" is valid (exists and is visible) | ||||
|                   let field = fields[k]; | ||||
|                   let valid = field && !field.hidden; | ||||
|  | ||||
|                   if (!valid || k === 'non_field_errors' || k === '__all__') { | ||||
|                     if (Array.isArray(v)) { | ||||
|                       _nonFieldErrors.push(...v); | ||||
|                     } | ||||
|   | ||||
| @@ -21,6 +21,7 @@ import { DependentField } from './DependentField'; | ||||
| import { NestedObjectField } from './NestedObjectField'; | ||||
| import { RelatedModelField } from './RelatedModelField'; | ||||
| import { TableField } from './TableField'; | ||||
| import TextField from './TextField'; | ||||
|  | ||||
| export type ApiFormData = UseFormReturnType<Record<string, unknown>>; | ||||
|  | ||||
| @@ -223,21 +224,11 @@ export function ApiFormField({ | ||||
|       case 'url': | ||||
|       case 'string': | ||||
|         return ( | ||||
|           <TextInput | ||||
|             {...reducedDefinition} | ||||
|             ref={field.ref} | ||||
|             id={fieldId} | ||||
|             aria-label={`text-field-${field.name}`} | ||||
|             type={definition.field_type} | ||||
|             value={value || ''} | ||||
|             error={error?.message} | ||||
|             radius="sm" | ||||
|             onChange={(event) => onChange(event.currentTarget.value)} | ||||
|             rightSection={ | ||||
|               value && !definition.required ? ( | ||||
|                 <IconX size="1rem" color="red" onClick={() => onChange('')} /> | ||||
|               ) : null | ||||
|             } | ||||
|           <TextField | ||||
|             definition={reducedDefinition} | ||||
|             controller={controller} | ||||
|             fieldName={fieldName} | ||||
|             onChange={onChange} | ||||
|           /> | ||||
|         ); | ||||
|       case 'boolean': | ||||
|   | ||||
							
								
								
									
										66
									
								
								src/frontend/src/components/forms/fields/TextField.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/frontend/src/components/forms/fields/TextField.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | ||||
| import { TextInput } from '@mantine/core'; | ||||
| import { useDebouncedValue } from '@mantine/hooks'; | ||||
| import { IconX } from '@tabler/icons-react'; | ||||
| import { useCallback, useEffect, useId, useState } from 'react'; | ||||
| import { FieldValues, UseControllerReturn } from 'react-hook-form'; | ||||
|  | ||||
| /* | ||||
|  * Custom implementation of the mantine <TextInput> component, | ||||
|  * used for rendering text input fields in forms. | ||||
|  * Uses a debounced value to prevent excessive re-renders. | ||||
|  */ | ||||
| export default function TextField({ | ||||
|   controller, | ||||
|   fieldName, | ||||
|   definition, | ||||
|   onChange | ||||
| }: { | ||||
|   controller: UseControllerReturn<FieldValues, any>; | ||||
|   definition: any; | ||||
|   fieldName: string; | ||||
|   onChange: (value: any) => void; | ||||
| }) { | ||||
|   const fieldId = useId(); | ||||
|   const { | ||||
|     field, | ||||
|     fieldState: { error } | ||||
|   } = controller; | ||||
|  | ||||
|   const { value } = field; | ||||
|  | ||||
|   const [rawText, setRawText] = useState(value); | ||||
|   const [debouncedText] = useDebouncedValue(rawText, 250); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setRawText(value); | ||||
|   }, [value]); | ||||
|  | ||||
|   const onTextChange = useCallback((value: any) => { | ||||
|     setRawText(value); | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (debouncedText !== value) { | ||||
|       onChange(debouncedText); | ||||
|     } | ||||
|   }, [debouncedText]); | ||||
|  | ||||
|   return ( | ||||
|     <TextInput | ||||
|       {...definition} | ||||
|       ref={field.ref} | ||||
|       id={fieldId} | ||||
|       aria-label={`text-field-${field.name}`} | ||||
|       type={definition.field_type} | ||||
|       value={rawText || ''} | ||||
|       error={error?.message} | ||||
|       radius="sm" | ||||
|       onChange={(event) => onTextChange(event.currentTarget.value)} | ||||
|       rightSection={ | ||||
|         value && !definition.required ? ( | ||||
|           <IconX size="1rem" color="red" onClick={() => onTextChange('')} /> | ||||
|         ) : null | ||||
|       } | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { Group, HoverCard, Stack, Text } from '@mantine/core'; | ||||
| import { Group, HoverCard, Paper, Space, Stack, Text } from '@mantine/core'; | ||||
| import { notifications } from '@mantine/notifications'; | ||||
| import { | ||||
|   IconArrowRight, | ||||
| @@ -26,6 +26,7 @@ import { RowDeleteAction, RowEditAction } from '../../tables/RowActions'; | ||||
| import { ActionButton } from '../buttons/ActionButton'; | ||||
| import { YesNoButton } from '../buttons/YesNoButton'; | ||||
| import { ApiFormFieldSet } from '../forms/fields/ApiFormField'; | ||||
| import { ProgressBar } from '../items/ProgressBar'; | ||||
| import { RenderRemoteInstance } from '../render/Instance'; | ||||
|  | ||||
| function ImporterDataCell({ | ||||
| @@ -178,6 +179,8 @@ export default function ImporterDataSelector({ | ||||
|           table.clearSelectedRecords(); | ||||
|           notifications.hide('importing-rows'); | ||||
|           table.refreshTable(); | ||||
|  | ||||
|           session.refreshSession(); | ||||
|         }); | ||||
|     }, | ||||
|     [session.sessionId, table.refreshTable] | ||||
| @@ -191,6 +194,7 @@ export default function ImporterDataSelector({ | ||||
|     title: t`Edit Data`, | ||||
|     fields: selectedFields, | ||||
|     initialData: selectedRow.data, | ||||
|     fetchInitialData: false, | ||||
|     processFormData: (data: any) => { | ||||
|       // Construct fields back into a single object | ||||
|       return { | ||||
| @@ -374,6 +378,18 @@ export default function ImporterDataSelector({ | ||||
|       {editRow.modal} | ||||
|       {deleteRow.modal} | ||||
|       <Stack gap="xs"> | ||||
|         <Paper shadow="xs" p="xs"> | ||||
|           <Group grow justify="apart"> | ||||
|             <Text size="lg">{t`Processing Data`}</Text> | ||||
|             <Space /> | ||||
|             <ProgressBar | ||||
|               maximum={session.rowCount} | ||||
|               value={session.completedRowCount} | ||||
|               progressLabel | ||||
|             /> | ||||
|             <Space /> | ||||
|           </Group> | ||||
|         </Paper> | ||||
|         <InvenTreeTable | ||||
|           tableState={table} | ||||
|           columns={columns} | ||||
| @@ -388,7 +404,10 @@ export default function ImporterDataSelector({ | ||||
|             enableColumnSwitching: true, | ||||
|             enableColumnCaching: false, | ||||
|             enableSelection: true, | ||||
|             enableBulkDelete: true | ||||
|             enableBulkDelete: true, | ||||
|             afterBulkDelete: () => { | ||||
|               session.refreshSession(); | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|       </Stack> | ||||
|   | ||||
| @@ -2,19 +2,23 @@ import { t } from '@lingui/macro'; | ||||
| import { | ||||
|   Alert, | ||||
|   Button, | ||||
|   Divider, | ||||
|   Group, | ||||
|   Paper, | ||||
|   Select, | ||||
|   SimpleGrid, | ||||
|   Space, | ||||
|   Stack, | ||||
|   Table, | ||||
|   Text | ||||
| } from '@mantine/core'; | ||||
| import { IconCheck } from '@tabler/icons-react'; | ||||
| import { useCallback, useEffect, useMemo, useState } from 'react'; | ||||
|  | ||||
| import { api } from '../../App'; | ||||
| import { ApiEndpoints } from '../../enums/ApiEndpoints'; | ||||
| import { ImportSessionState } from '../../hooks/UseImportSession'; | ||||
| import { apiUrl } from '../../states/ApiState'; | ||||
| import { StandaloneField } from '../forms/StandaloneField'; | ||||
| import { ApiFormFieldType } from '../forms/fields/ApiFormField'; | ||||
|  | ||||
| function ImporterColumn({ column, options }: { column: any; options: any[] }) { | ||||
|   const [errorMessage, setErrorMessage] = useState<string>(''); | ||||
| @@ -54,6 +58,7 @@ function ImporterColumn({ column, options }: { column: any; options: any[] }) { | ||||
|     <Select | ||||
|       error={errorMessage} | ||||
|       clearable | ||||
|       searchable | ||||
|       placeholder={t`Select column, or leave blank to ignore this field.`} | ||||
|       label={undefined} | ||||
|       data={options} | ||||
| @@ -63,6 +68,92 @@ function ImporterColumn({ column, options }: { column: any; options: any[] }) { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function ImporterDefaultField({ | ||||
|   fieldName, | ||||
|   session | ||||
| }: { | ||||
|   fieldName: string; | ||||
|   session: ImportSessionState; | ||||
| }) { | ||||
|   const onChange = useCallback( | ||||
|     (value: any) => { | ||||
|       // Update the default value for the field | ||||
|       let defaults = { | ||||
|         ...session.fieldDefaults, | ||||
|         [fieldName]: value | ||||
|       }; | ||||
|  | ||||
|       api | ||||
|         .patch(apiUrl(ApiEndpoints.import_session_list, session.sessionId), { | ||||
|           field_defaults: defaults | ||||
|         }) | ||||
|         .then((response: any) => { | ||||
|           session.setSessionData(response.data); | ||||
|         }) | ||||
|         .catch(() => { | ||||
|           // TODO: Error message? | ||||
|         }); | ||||
|     }, | ||||
|     [fieldName, session, session.fieldDefaults] | ||||
|   ); | ||||
|  | ||||
|   const fieldDef: ApiFormFieldType = useMemo(() => { | ||||
|     let def: any = session.availableFields[fieldName]; | ||||
|  | ||||
|     if (def) { | ||||
|       def = { | ||||
|         ...def, | ||||
|         value: session.fieldDefaults[fieldName], | ||||
|         field_type: def.type, | ||||
|         description: def.help_text, | ||||
|         onValueChange: onChange | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     return def; | ||||
|   }, [fieldName, session.availableFields, session.fieldDefaults]); | ||||
|  | ||||
|   return ( | ||||
|     fieldDef && <StandaloneField fieldDefinition={fieldDef} hideLabels={true} /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function ImporterColumnTableRow({ | ||||
|   session, | ||||
|   column, | ||||
|   options | ||||
| }: { | ||||
|   session: ImportSessionState; | ||||
|   column: any; | ||||
|   options: any; | ||||
| }) { | ||||
|   return ( | ||||
|     <Table.Tr key={column.label ?? column.field}> | ||||
|       <Table.Td> | ||||
|         <Group gap="xs"> | ||||
|           <Text fw={column.required ? 700 : undefined}> | ||||
|             {column.label ?? column.field} | ||||
|           </Text> | ||||
|           {column.required && ( | ||||
|             <Text c="red" fw={700}> | ||||
|               * | ||||
|             </Text> | ||||
|           )} | ||||
|         </Group> | ||||
|       </Table.Td> | ||||
|       <Table.Td> | ||||
|         <Text size="sm">{column.description}</Text> | ||||
|       </Table.Td> | ||||
|       <Table.Td> | ||||
|         <ImporterColumn column={column} options={options} /> | ||||
|       </Table.Td> | ||||
|       <Table.Td> | ||||
|         <ImporterDefaultField fieldName={column.field} session={session} /> | ||||
|       </Table.Td> | ||||
|     </Table.Tr> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default function ImporterColumnSelector({ | ||||
|   session | ||||
| }: { | ||||
| @@ -88,7 +179,7 @@ export default function ImporterColumnSelector({ | ||||
|  | ||||
|   const columnOptions: any[] = useMemo(() => { | ||||
|     return [ | ||||
|       { value: '', label: t`Select a column from the data file` }, | ||||
|       { value: '', label: t`Ignore this field` }, | ||||
|       ...session.availableColumns.map((column: any) => { | ||||
|         return { | ||||
|           value: column, | ||||
| @@ -100,45 +191,44 @@ export default function ImporterColumnSelector({ | ||||
|  | ||||
|   return ( | ||||
|     <Stack gap="xs"> | ||||
|       <Group justify="apart"> | ||||
|         <Text>{t`Map data columns to database fields`}</Text> | ||||
|         <Button | ||||
|           color="green" | ||||
|           variant="filled" | ||||
|           onClick={acceptMapping} | ||||
|         >{t`Accept Column Mapping`}</Button> | ||||
|       </Group> | ||||
|       <Paper shadow="xs" p="xs"> | ||||
|         <Group grow justify="apart"> | ||||
|           <Text size="lg">{t`Mapping data columns to database fields`}</Text> | ||||
|           <Space /> | ||||
|           <Button color="green" variant="filled" onClick={acceptMapping}> | ||||
|             <Group> | ||||
|               <IconCheck /> | ||||
|               {t`Accept Column Mapping`} | ||||
|             </Group> | ||||
|           </Button> | ||||
|         </Group> | ||||
|       </Paper> | ||||
|       {errorMessage && ( | ||||
|         <Alert color="red" title={t`Error`}> | ||||
|           <Text>{errorMessage}</Text> | ||||
|         </Alert> | ||||
|       )} | ||||
|       <SimpleGrid cols={3} spacing="xs"> | ||||
|         <Text fw={700}>{t`Database Field`}</Text> | ||||
|         <Text fw={700}>{t`Field Description`}</Text> | ||||
|         <Text fw={700}>{t`Imported Column Name`}</Text> | ||||
|         <Divider /> | ||||
|         <Divider /> | ||||
|         <Divider /> | ||||
|         {session.columnMappings.map((column: any) => { | ||||
|           return [ | ||||
|             <Group gap="xs"> | ||||
|               <Text fw={column.required ? 700 : undefined}> | ||||
|                 {column.label ?? column.field} | ||||
|               </Text> | ||||
|               {column.required && ( | ||||
|                 <Text c="red" fw={700}> | ||||
|                   * | ||||
|                 </Text> | ||||
|               )} | ||||
|             </Group>, | ||||
|             <Text size="sm" fs="italic"> | ||||
|               {column.description} | ||||
|             </Text>, | ||||
|             <ImporterColumn column={column} options={columnOptions} /> | ||||
|           ]; | ||||
|         })} | ||||
|       </SimpleGrid> | ||||
|       <Table> | ||||
|         <Table.Thead> | ||||
|           <Table.Tr> | ||||
|             <Table.Th>{t`Database Field`}</Table.Th> | ||||
|             <Table.Th>{t`Field Description`}</Table.Th> | ||||
|             <Table.Th>{t`Imported Column`}</Table.Th> | ||||
|             <Table.Th>{t`Default Value`}</Table.Th> | ||||
|           </Table.Tr> | ||||
|         </Table.Thead> | ||||
|         <Table.Tbody> | ||||
|           {session.columnMappings.map((column: any) => { | ||||
|             return ( | ||||
|               <ImporterColumnTableRow | ||||
|                 session={session} | ||||
|                 column={column} | ||||
|                 options={columnOptions} | ||||
|               /> | ||||
|             ); | ||||
|           })} | ||||
|         </Table.Tbody> | ||||
|       </Table> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,26 +1,26 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { | ||||
|   ActionIcon, | ||||
|   Alert, | ||||
|   Button, | ||||
|   Divider, | ||||
|   Drawer, | ||||
|   Group, | ||||
|   Loader, | ||||
|   LoadingOverlay, | ||||
|   Paper, | ||||
|   Space, | ||||
|   Stack, | ||||
|   Stepper, | ||||
|   Text, | ||||
|   Tooltip | ||||
|   Text | ||||
| } from '@mantine/core'; | ||||
| import { IconCircleX } from '@tabler/icons-react'; | ||||
| import { ReactNode, useCallback, useMemo, useState } from 'react'; | ||||
| import { IconCheck } from '@tabler/icons-react'; | ||||
| import { ReactNode, useMemo } from 'react'; | ||||
|  | ||||
| import { ModelType } from '../../enums/ModelType'; | ||||
| import { | ||||
|   ImportSessionStatus, | ||||
|   useImportSession | ||||
| } from '../../hooks/UseImportSession'; | ||||
| import { StylishText } from '../items/StylishText'; | ||||
| import { StatusRenderer } from '../render/StatusRenderer'; | ||||
| import ImporterDataSelector from './ImportDataSelector'; | ||||
| import ImporterColumnSelector from './ImporterColumnSelector'; | ||||
| import ImporterImportProgress from './ImporterImportProgress'; | ||||
| @@ -39,10 +39,12 @@ function ImportDrawerStepper({ currentStep }: { currentStep: number }) { | ||||
|       active={currentStep} | ||||
|       onStepClick={undefined} | ||||
|       allowNextStepsSelect={false} | ||||
|       iconSize={20} | ||||
|       size="xs" | ||||
|     > | ||||
|       <Stepper.Step label={t`Import Data`} /> | ||||
|       <Stepper.Step label={t`Upload File`} /> | ||||
|       <Stepper.Step label={t`Map Columns`} /> | ||||
|       <Stepper.Step label={t`Import Data`} /> | ||||
|       <Stepper.Step label={t`Process Data`} /> | ||||
|       <Stepper.Step label={t`Complete Import`} /> | ||||
|     </Stepper> | ||||
| @@ -60,7 +62,28 @@ export default function ImporterDrawer({ | ||||
| }) { | ||||
|   const session = useImportSession({ sessionId: sessionId }); | ||||
|  | ||||
|   // Map from import steps to stepper steps | ||||
|   const currentStep = useMemo(() => { | ||||
|     switch (session.status) { | ||||
|       default: | ||||
|       case ImportSessionStatus.INITIAL: | ||||
|         return 0; | ||||
|       case ImportSessionStatus.MAPPING: | ||||
|         return 1; | ||||
|       case ImportSessionStatus.IMPORTING: | ||||
|         return 2; | ||||
|       case ImportSessionStatus.PROCESSING: | ||||
|         return 3; | ||||
|       case ImportSessionStatus.COMPLETE: | ||||
|         return 4; | ||||
|     } | ||||
|   }, [session.status]); | ||||
|  | ||||
|   const widget = useMemo(() => { | ||||
|     if (session.sessionQuery.isLoading || session.sessionQuery.isFetching) { | ||||
|       return <Loader />; | ||||
|     } | ||||
|  | ||||
|     switch (session.status) { | ||||
|       case ImportSessionStatus.INITIAL: | ||||
|         return <Text>Initial : TODO</Text>; | ||||
| @@ -71,11 +94,29 @@ export default function ImporterDrawer({ | ||||
|       case ImportSessionStatus.PROCESSING: | ||||
|         return <ImporterDataSelector session={session} />; | ||||
|       case ImportSessionStatus.COMPLETE: | ||||
|         return <Text>Complete!</Text>; | ||||
|         return ( | ||||
|           <Stack gap="xs"> | ||||
|             <Alert | ||||
|               color="green" | ||||
|               title={t`Import Complete`} | ||||
|               icon={<IconCheck />} | ||||
|             > | ||||
|               {t`Data has been imported successfully`} | ||||
|             </Alert> | ||||
|             <Button color="blue" onClick={onClose}>{t`Close`}</Button> | ||||
|           </Stack> | ||||
|         ); | ||||
|       default: | ||||
|         return <Text>Unknown status code: {session?.status}</Text>; | ||||
|         return ( | ||||
|           <Stack gap="xs"> | ||||
|             <Alert color="red" title={t`Unknown Status`} icon={<IconCheck />}> | ||||
|               {t`Import session has unknown status`}: {session.status} | ||||
|             </Alert> | ||||
|             <Button color="red" onClick={onClose}>{t`Close`}</Button> | ||||
|           </Stack> | ||||
|         ); | ||||
|     } | ||||
|   }, [session.status]); | ||||
|   }, [session.status, session.sessionQuery]); | ||||
|  | ||||
|   const title: ReactNode = useMemo(() => { | ||||
|     return ( | ||||
| @@ -87,18 +128,11 @@ export default function ImporterDrawer({ | ||||
|           grow | ||||
|           preventGrowOverflow={false} | ||||
|         > | ||||
|           <StylishText> | ||||
|           <StylishText size="lg"> | ||||
|             {session.sessionData?.statusText ?? t`Importing Data`} | ||||
|           </StylishText> | ||||
|           {StatusRenderer({ | ||||
|             status: session.status, | ||||
|             type: ModelType.importsession | ||||
|           })} | ||||
|           <Tooltip label={t`Cancel import session`}> | ||||
|             <ActionIcon color="red" variant="transparent" onClick={onClose}> | ||||
|               <IconCircleX /> | ||||
|             </ActionIcon> | ||||
|           </Tooltip> | ||||
|           <ImportDrawerStepper currentStep={currentStep} /> | ||||
|           <Space /> | ||||
|         </Group> | ||||
|         <Divider /> | ||||
|       </Stack> | ||||
| @@ -112,7 +146,7 @@ export default function ImporterDrawer({ | ||||
|       title={title} | ||||
|       opened={opened} | ||||
|       onClose={onClose} | ||||
|       withCloseButton={false} | ||||
|       withCloseButton={true} | ||||
|       closeOnEscape={false} | ||||
|       closeOnClickOutside={false} | ||||
|       styles={{ | ||||
|   | ||||
| @@ -134,7 +134,11 @@ export function RenderRemoteInstance({ | ||||
|   } | ||||
|  | ||||
|   if (!data) { | ||||
|     return <Text>${pk}</Text>; | ||||
|     return ( | ||||
|       <Text> | ||||
|         {model}: {pk} | ||||
|       </Text> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return <RenderInstance model={model} instance={data} />; | ||||
|   | ||||
| @@ -4,8 +4,13 @@ export function dataImporterSessionFields(): ApiFormFieldSet { | ||||
|   return { | ||||
|     data_file: {}, | ||||
|     model_type: {}, | ||||
|     field_detauls: { | ||||
|       hidden: true | ||||
|     field_defaults: { | ||||
|       hidden: true, | ||||
|       value: {} | ||||
|     }, | ||||
|     field_overrides: { | ||||
|       hidden: true, | ||||
|       value: {} | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -21,6 +21,7 @@ export enum ImportSessionStatus { | ||||
| export type ImportSessionState = { | ||||
|   sessionId: number; | ||||
|   sessionData: any; | ||||
|   setSessionData: (data: any) => void; | ||||
|   refreshSession: () => void; | ||||
|   sessionQuery: any; | ||||
|   status: ImportSessionStatus; | ||||
| @@ -28,6 +29,10 @@ export type ImportSessionState = { | ||||
|   availableColumns: string[]; | ||||
|   mappedFields: any[]; | ||||
|   columnMappings: any[]; | ||||
|   fieldDefaults: any; | ||||
|   fieldOverrides: any; | ||||
|   rowCount: number; | ||||
|   completedRowCount: number; | ||||
| }; | ||||
|  | ||||
| export function useImportSession({ | ||||
| @@ -38,6 +43,7 @@ export function useImportSession({ | ||||
|   // Query manager for the import session | ||||
|   const { | ||||
|     instance: sessionData, | ||||
|     setInstance, | ||||
|     refreshInstance: refreshSession, | ||||
|     instanceQuery: sessionQuery | ||||
|   } = useInstance({ | ||||
| @@ -46,6 +52,12 @@ export function useImportSession({ | ||||
|     defaultValue: {} | ||||
|   }); | ||||
|  | ||||
|   const setSessionData = useCallback((data: any) => { | ||||
|     console.log('setting session data:'); | ||||
|     console.log(data); | ||||
|     setInstance(data); | ||||
|   }, []); | ||||
|  | ||||
|   // Current step of the import process | ||||
|   const status: ImportSessionStatus = useMemo(() => { | ||||
|     return sessionData?.status ?? ImportSessionStatus.INITIAL; | ||||
| @@ -93,8 +105,25 @@ export function useImportSession({ | ||||
|     ); | ||||
|   }, [sessionData]); | ||||
|  | ||||
|   const fieldDefaults: any = useMemo(() => { | ||||
|     return sessionData?.field_defaults ?? {}; | ||||
|   }, [sessionData]); | ||||
|  | ||||
|   const fieldOverrides: any = useMemo(() => { | ||||
|     return sessionData?.field_overrides ?? {}; | ||||
|   }, [sessionData]); | ||||
|  | ||||
|   const rowCount: number = useMemo(() => { | ||||
|     return sessionData?.row_count ?? 0; | ||||
|   }, [sessionData]); | ||||
|  | ||||
|   const completedRowCount: number = useMemo(() => { | ||||
|     return sessionData?.completed_row_count ?? 0; | ||||
|   }, [sessionData]); | ||||
|  | ||||
|   return { | ||||
|     sessionData, | ||||
|     setSessionData, | ||||
|     sessionId, | ||||
|     refreshSession, | ||||
|     sessionQuery, | ||||
| @@ -102,6 +131,10 @@ export function useImportSession({ | ||||
|     availableFields, | ||||
|     availableColumns, | ||||
|     columnMappings, | ||||
|     mappedFields | ||||
|     mappedFields, | ||||
|     fieldDefaults, | ||||
|     fieldOverrides, | ||||
|     rowCount, | ||||
|     completedRowCount | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -93,5 +93,11 @@ export function useInstance<T = any>({ | ||||
|     instanceQuery.refetch(); | ||||
|   }, []); | ||||
|  | ||||
|   return { instance, refreshInstance, instanceQuery, requestStatus }; | ||||
|   return { | ||||
|     instance, | ||||
|     setInstance, | ||||
|     refreshInstance, | ||||
|     instanceQuery, | ||||
|     requestStatus | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -103,6 +103,7 @@ export type InvenTreeTableProps<T = any> = { | ||||
|   enableColumnCaching?: boolean; | ||||
|   enableLabels?: boolean; | ||||
|   enableReports?: boolean; | ||||
|   afterBulkDelete?: () => void; | ||||
|   pageSize?: number; | ||||
|   barcodeActions?: any[]; | ||||
|   tableFilters?: TableFilter[]; | ||||
| @@ -547,6 +548,9 @@ export function InvenTreeTable<T = any>({ | ||||
|           }) | ||||
|           .finally(() => { | ||||
|             tableState.clearSelectedRecords(); | ||||
|             if (props.afterBulkDelete) { | ||||
|               props.afterBulkDelete(); | ||||
|             } | ||||
|           }); | ||||
|       } | ||||
|     }); | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { showNotification } from '@mantine/notifications'; | ||||
| import { | ||||
|   IconArrowRight, | ||||
|   IconCircleCheck, | ||||
|   IconFileArrowLeft, | ||||
|   IconLock, | ||||
|   IconSwitch3 | ||||
| } from '@tabler/icons-react'; | ||||
| @@ -15,11 +16,13 @@ import { ActionButton } from '../../components/buttons/ActionButton'; | ||||
| import { AddItemButton } from '../../components/buttons/AddItemButton'; | ||||
| import { YesNoButton } from '../../components/buttons/YesNoButton'; | ||||
| import { Thumbnail } from '../../components/images/Thumbnail'; | ||||
| import ImporterDrawer from '../../components/importer/ImporterDrawer'; | ||||
| import { formatDecimal, formatPriceRange } from '../../defaults/formatters'; | ||||
| import { ApiEndpoints } from '../../enums/ApiEndpoints'; | ||||
| import { ModelType } from '../../enums/ModelType'; | ||||
| import { UserRoles } from '../../enums/Roles'; | ||||
| import { bomItemFields } from '../../forms/BomForms'; | ||||
| import { dataImporterSessionFields } from '../../forms/ImporterForms'; | ||||
| import { | ||||
|   useApiFormModal, | ||||
|   useCreateApiFormModal, | ||||
| @@ -70,6 +73,12 @@ export function BomTable({ | ||||
|   const table = useTable('bom'); | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   const [importOpened, setImportOpened] = useState<boolean>(false); | ||||
|  | ||||
|   const [selectedSession, setSelectedSession] = useState<number | undefined>( | ||||
|     undefined | ||||
|   ); | ||||
|  | ||||
|   const tableColumns: TableColumn[] = useMemo(() => { | ||||
|     return [ | ||||
|       { | ||||
| @@ -345,6 +354,29 @@ export function BomTable({ | ||||
|  | ||||
|   const [selectedBomItem, setSelectedBomItem] = useState<number>(0); | ||||
|  | ||||
|   const importSessionFields = useMemo(() => { | ||||
|     let fields = dataImporterSessionFields(); | ||||
|  | ||||
|     fields.model_type.hidden = true; | ||||
|     fields.model_type.value = 'bomitem'; | ||||
|  | ||||
|     fields.field_overrides.value = { | ||||
|       part: partId | ||||
|     }; | ||||
|  | ||||
|     return fields; | ||||
|   }, [partId]); | ||||
|  | ||||
|   const importBomItem = useCreateApiFormModal({ | ||||
|     url: ApiEndpoints.import_session_list, | ||||
|     title: t`Import BOM Data`, | ||||
|     fields: importSessionFields, | ||||
|     onFormSuccess: (response: any) => { | ||||
|       setSelectedSession(response.pk); | ||||
|       setImportOpened(true); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   const newBomItem = useCreateApiFormModal({ | ||||
|     url: ApiEndpoints.bom_list, | ||||
|     title: t`Add BOM Item`, | ||||
| @@ -467,6 +499,12 @@ export function BomTable({ | ||||
|  | ||||
|   const tableActions = useMemo(() => { | ||||
|     return [ | ||||
|       <ActionButton | ||||
|         hidden={partLocked || !user.hasAddRole(UserRoles.part)} | ||||
|         tooltip={t`Import BOM Data`} | ||||
|         icon={<IconFileArrowLeft />} | ||||
|         onClick={() => importBomItem.open()} | ||||
|       />, | ||||
|       <ActionButton | ||||
|         hidden={partLocked || !user.hasChangeRole(UserRoles.part)} | ||||
|         tooltip={t`Validate BOM`} | ||||
| @@ -483,6 +521,7 @@ export function BomTable({ | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {importBomItem.modal} | ||||
|       {newBomItem.modal} | ||||
|       {editBomItem.modal} | ||||
|       {validateBom.modal} | ||||
| @@ -515,10 +554,20 @@ export function BomTable({ | ||||
|             modelField: 'sub_part', | ||||
|             rowActions: rowActions, | ||||
|             enableSelection: !partLocked, | ||||
|             enableBulkDelete: !partLocked | ||||
|             enableBulkDelete: !partLocked, | ||||
|             enableDownload: true | ||||
|           }} | ||||
|         /> | ||||
|       </Stack> | ||||
|       <ImporterDrawer | ||||
|         sessionId={selectedSession ?? -1} | ||||
|         opened={selectedSession !== undefined && importOpened} | ||||
|         onClose={() => { | ||||
|           setSelectedSession(undefined); | ||||
|           setImportOpened(false); | ||||
|           table.refreshTable(); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -180,6 +180,10 @@ test('PUI - Pages - Part - Attachments', async ({ page }) => { | ||||
|   await page.getByLabel('action-button-add-external-').click(); | ||||
|   await page.getByLabel('text-field-link').fill('https://www.google.com'); | ||||
|   await page.getByLabel('text-field-comment').fill('a sample comment'); | ||||
|  | ||||
|   // Note: Text field values are debounced for 250ms | ||||
|   await page.waitForTimeout(500); | ||||
|  | ||||
|   await page.getByRole('button', { name: 'Submit' }).click(); | ||||
|   await page.getByRole('cell', { name: 'a sample comment' }).first().waitFor(); | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user