2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-12 06:18:48 +00:00

[UI] Pass custom fields through to the importer session (#11688)

* [UI] Pass custom fields through to the importer session

* Support custom model rendering within the data import wizard

* Update CHANGELOG.md

* Update UI version
This commit is contained in:
Oliver
2026-04-08 23:50:16 +10:00
committed by GitHub
parent b9a66da833
commit cc77d1d5e6
9 changed files with 96 additions and 18 deletions

View File

@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- [#11685](https://github.com/inventree/InvenTree/pull/11685) exposes the data importer wizard to the plugin interface, allowing plugins to trigger the data importer wizard and perform custom data imports from the UI.
- [#11692](https://github.com/inventree/InvenTree/pull/11692) adds line item numbering for external orders (purchase, sales and return orders). This allows users to specify a line number for each line item on the order, which can be used for reference purposes. The line number is optional, and can be left blank if not required. The line number is stored as a string, to allow for more flexible formatting (e.g. "1", "1.1", "A", etc). - [#11692](https://github.com/inventree/InvenTree/pull/11692) adds line item numbering for external orders (purchase, sales and return orders). This allows users to specify a line number for each line item on the order, which can be used for reference purposes. The line number is optional, and can be left blank if not required. The line number is stored as a string, to allow for more flexible formatting (e.g. "1", "1.1", "A", etc).
- [#11641](https://github.com/inventree/InvenTree/pull/11641) adds support for custom parameters against the SalesOrderShipment model. - [#11641](https://github.com/inventree/InvenTree/pull/11641) adds support for custom parameters against the SalesOrderShipment model.
- [#11527](https://github.com/inventree/InvenTree/pull/11527) adds a new API endpoint for monitoring the status of a particular background task. This endpoint allows clients to check the status of a background task and receive updates when the task is complete. This is useful for long-running tasks that may take some time to complete, allowing clients to provide feedback to users about the progress of the task. - [#11527](https://github.com/inventree/InvenTree/pull/11527) adds a new API endpoint for monitoring the status of a particular background task. This endpoint allows clients to check the status of a background task and receive updates when the task is complete. This is useful for long-running tasks that may take some time to complete, allowing clients to provide feedback to users about the progress of the task.

View File

@@ -2,6 +2,10 @@
This file contains historical changelog information for the InvenTree UI components library. This file contains historical changelog information for the InvenTree UI components library.
### 0.10.1 - April 2026
Allows plugins to specify custom model rendering functions within the data import wizard, allowing import of data models not defined in the core InvenTree codebase.
### 0.10.0 - April 2026 ### 0.10.0 - April 2026
Exposes the `importer` object to the plugin context, allow plugins to initialize a data import session using the data importer wizard. Exposes the `importer` object to the plugin context, allow plugins to initialize a data import session using the data importer wizard.

View File

@@ -1,7 +1,7 @@
{ {
"name": "@inventreedb/ui", "name": "@inventreedb/ui",
"description": "UI components for the InvenTree project", "description": "UI components for the InvenTree project",
"version": "0.10.0", "version": "0.10.1",
"private": false, "private": false,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View File

@@ -4,6 +4,7 @@ import ImporterDrawer from './ImporterDrawer';
export default function GlobalImporterDrawer() { export default function GlobalImporterDrawer() {
const isOpen = useImporterState((state) => state.isOpen); const isOpen = useImporterState((state) => state.isOpen);
const sessionId = useImporterState((state) => state.sessionId); const sessionId = useImporterState((state) => state.sessionId);
const customFields = useImporterState((state) => state.customFields);
const closeImporter = useImporterState((state) => state.closeImporter); const closeImporter = useImporterState((state) => state.closeImporter);
if (!isOpen || sessionId === null) { if (!isOpen || sessionId === null) {
@@ -15,6 +16,7 @@ export default function GlobalImporterDrawer() {
sessionId={sessionId} sessionId={sessionId}
opened={isOpen} opened={isOpen}
onClose={closeImporter} onClose={closeImporter}
customFields={customFields}
/> />
); );
} }

View File

@@ -36,10 +36,12 @@ import { RenderRemoteInstance } from '../render/Instance';
function ImporterDataCell({ function ImporterDataCell({
session, session,
column, column,
fieldDef,
row, row,
onEdit onEdit
}: Readonly<{ }: Readonly<{
session: ImportSessionState; session: ImportSessionState;
fieldDef: any;
column: any; column: any;
row: any; row: any;
onEdit?: () => void; onEdit?: () => void;
@@ -63,22 +65,24 @@ function ImporterDataCell({
}, [row.errors, column.field]); }, [row.errors, column.field]);
const cellValue: ReactNode = useMemo(() => { const cellValue: ReactNode = useMemo(() => {
const field_def = session.availableFields[column.field]; // const field_def = session.availableFields[column.field];
if (!row?.data) { if (!row?.data) {
return '-'; return '-';
} }
switch (field_def?.type) { switch (fieldDef?.type) {
case 'boolean': case 'boolean':
return ( return (
<YesNoButton value={row.data ? row.data[column.field] : false} /> <YesNoButton value={row.data ? row.data[column.field] : false} />
); );
case 'related field': case 'related field':
if (field_def.model && row.data[column.field]) { if (fieldDef.model && row.data[column.field]) {
return ( return (
<RenderRemoteInstance <RenderRemoteInstance
model={field_def.model} model={fieldDef.model}
modelRenderer={fieldDef.modelRenderer}
modelUrl={fieldDef.api_url}
pk={row.data[column.field]} pk={row.data[column.field]}
/> />
); );
@@ -95,7 +99,7 @@ function ImporterDataCell({
} }
return value; return value;
}, [row.data, column.field, session.availableFields]); }, [fieldDef, row.data, column.field, session.availableFields]);
const cellValid: boolean = useMemo( const cellValid: boolean = useMemo(
() => cellErrors.length == 0, () => cellErrors.length == 0,
@@ -127,9 +131,11 @@ function ImporterDataCell({
} }
export default function ImporterDataSelector({ export default function ImporterDataSelector({
session session,
customFields
}: Readonly<{ }: Readonly<{
session: ImportSessionState; session: ImportSessionState;
customFields?: ApiFormFieldSet | null;
}>) { }>) {
const api = useApi(); const api = useApi();
const table = useTable('dataimporter'); const table = useTable('dataimporter');
@@ -142,9 +148,11 @@ export default function ImporterDataSelector({
for (const field of selectedFieldNames) { for (const field of selectedFieldNames) {
// Find the field definition in session.availableFields // Find the field definition in session.availableFields
const fieldDef = session.availableFields[field]; const fieldDef = session.availableFields[field];
if (fieldDef) { const customField = customFields?.[field] ?? null;
if (fieldDef || customField) {
// Construct field filters based on session field filters // Construct field filters based on session field filters
let filters = fieldDef.filters ?? {}; let filters = fieldDef?.filters ?? {};
if (session.fieldFilters[field]) { if (session.fieldFilters[field]) {
filters = { filters = {
@@ -159,15 +167,30 @@ export default function ImporterDataSelector({
fields[field] = { fields[field] = {
...fieldDef, ...fieldDef,
...customField,
field_type: fieldDef.type, field_type: fieldDef.type,
description: fieldDef.help_text, description: fieldDef.help_text,
filters: filters filters: filters
}; };
console.log('Defined Field:', field);
console.log({
...fieldDef,
...customField,
field_type: fieldDef.type,
description: fieldDef.help_text,
filters: filters
});
} }
} }
return fields; return fields;
}, [selectedFieldNames, session.availableFields, session.fieldFilters]); }, [
customFields,
selectedFieldNames,
session.availableFields,
session.fieldFilters
]);
const importData = useCallback( const importData = useCallback(
(rows: number[]) => { (rows: number[]) => {
@@ -322,6 +345,10 @@ export default function ImporterDataSelector({
column={column} column={column}
row={row} row={row}
onEdit={() => editCell(row, column)} onEdit={() => editCell(row, column)}
fieldDef={{
...session.availableFields[column.field],
...customFields?.[column.field]
}}
/> />
); );
} }
@@ -330,7 +357,7 @@ export default function ImporterDataSelector({
]; ];
return columns; return columns;
}, [session]); }, [session, customFields]);
const rowActions = useCallback( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {

View File

@@ -15,7 +15,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldType } from '@lib/types/Forms'; import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { useDebouncedValue } from '@mantine/hooks'; import { useDebouncedValue } from '@mantine/hooks';
import { useApi } from '../../contexts/ApiContext'; import { useApi } from '../../contexts/ApiContext';
import type { ImportSessionState } from '../../hooks/UseImportSession'; import type { ImportSessionState } from '../../hooks/UseImportSession';
@@ -77,9 +77,11 @@ function ImporterColumn({
function ImporterDefaultField({ function ImporterDefaultField({
fieldName, fieldName,
customField,
session session
}: { }: {
fieldName: string; fieldName: string;
customField?: ApiFormFieldType | null;
session: ImportSessionState; session: ImportSessionState;
}) { }) {
const api = useApi(); const api = useApi();
@@ -162,8 +164,15 @@ function ImporterDefaultField({
}; };
} }
if (customField) {
def = {
...def,
...customField
};
}
return def; return def;
}, [fieldName, session.availableFields, session.fieldDefaults]); }, [fieldName, session.availableFields, session.fieldDefaults, customField]);
return ( return (
fieldDef && <StandaloneField fieldDefinition={fieldDef} hideLabels={true} /> fieldDef && <StandaloneField fieldDefinition={fieldDef} hideLabels={true} />
@@ -173,10 +182,12 @@ function ImporterDefaultField({
function ImporterColumnTableRow({ function ImporterColumnTableRow({
session, session,
column, column,
customField,
options options
}: Readonly<{ }: Readonly<{
session: ImportSessionState; session: ImportSessionState;
column: any; column: any;
customField?: ApiFormFieldType | null;
options: any; options: any;
}>) { }>) {
return ( return (
@@ -200,16 +211,22 @@ function ImporterColumnTableRow({
<ImporterColumn column={column} options={options} /> <ImporterColumn column={column} options={options} />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<ImporterDefaultField fieldName={column.field} session={session} /> <ImporterDefaultField
fieldName={column.field}
session={session}
customField={customField}
/>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
); );
} }
export default function ImporterColumnSelector({ export default function ImporterColumnSelector({
session session,
customFields
}: Readonly<{ }: Readonly<{
session: ImportSessionState; session: ImportSessionState;
customFields?: ApiFormFieldSet | null;
}>) { }>) {
const api = useApi(); const api = useApi();
@@ -279,6 +296,7 @@ export default function ImporterColumnSelector({
session={session} session={session}
column={column} column={column}
options={columnOptions} options={columnOptions}
customField={customFields?.[column.field] || null}
/> />
); );
})} })}

View File

@@ -14,6 +14,7 @@ import { IconCheck, IconExclamationCircle } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react'; import { type ReactNode, useMemo } from 'react';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import type { ApiFormFieldSet } from '@lib/index';
import { useImportSession } from '../../hooks/UseImportSession'; import { useImportSession } from '../../hooks/UseImportSession';
import useStatusCodes from '../../hooks/UseStatusCodes'; import useStatusCodes from '../../hooks/UseStatusCodes';
import { StylishText } from '../items/StylishText'; import { StylishText } from '../items/StylishText';
@@ -51,10 +52,12 @@ function ImportDrawerStepper({
export default function ImporterDrawer({ export default function ImporterDrawer({
sessionId, sessionId,
customFields,
opened, opened,
onClose onClose
}: Readonly<{ }: Readonly<{
sessionId: number; sessionId: number;
customFields?: ApiFormFieldSet | null;
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
}>) { }>) {
@@ -93,9 +96,16 @@ export default function ImporterDrawer({
switch (session.status) { switch (session.status) {
case importSessionStatus.MAPPING: case importSessionStatus.MAPPING:
return <ImporterColumnSelector session={session} />; return (
<ImporterColumnSelector
session={session}
customFields={customFields}
/>
);
case importSessionStatus.PROCESSING: case importSessionStatus.PROCESSING:
return <ImporterDataSelector session={session} />; return (
<ImporterDataSelector session={session} customFields={customFields} />
);
case importSessionStatus.COMPLETE: case importSessionStatus.COMPLETE:
return ( return (
<Stack gap='xs'> <Stack gap='xs'>

View File

@@ -119,9 +119,13 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
export function RenderRemoteInstance({ export function RenderRemoteInstance({
model, model,
modelUrl,
modelRenderer,
pk pk
}: Readonly<{ }: Readonly<{
model: ModelType; model: ModelType;
modelUrl?: string;
modelRenderer?: (instance: any) => ReactNode;
pk: number; pk: number;
}>): ReactNode { }>): ReactNode {
const api = useApi(); const api = useApi();
@@ -129,7 +133,9 @@ export function RenderRemoteInstance({
const { data, isLoading, isFetching } = useQuery({ const { data, isLoading, isFetching } = useQuery({
queryKey: ['model', model, pk], queryKey: ['model', model, pk],
queryFn: async () => { queryFn: async () => {
const url = apiUrl(ModelInformationDict[model].api_endpoint, pk); const url = modelUrl
? apiUrl(modelUrl, pk)
: apiUrl(ModelInformationDict[model].api_endpoint, pk);
return api.get(url).then((response) => response.data); return api.get(url).then((response) => response.data);
} }
@@ -147,6 +153,10 @@ export function RenderRemoteInstance({
); );
} }
if (!!modelRenderer) {
return modelRenderer({ instance: data });
}
return <RenderInstance model={model} instance={data} />; return <RenderInstance model={model} instance={data} />;
} }

View File

@@ -6,15 +6,18 @@
* The `useImporterState` hook can be used to access and manipulate the importer state from any component, while the `openGlobalImporter` and `closeGlobalImporter` functions provide convenient ways to control the importer from outside of React components. * The `useImporterState` hook can be used to access and manipulate the importer state from any component, while the `openGlobalImporter` and `closeGlobalImporter` functions provide convenient ways to control the importer from outside of React components.
*/ */
import type { ApiFormFieldSet } from '@lib/index';
import { create } from 'zustand'; import { create } from 'zustand';
export interface ImporterOpenOptions { export interface ImporterOpenOptions {
fields?: ApiFormFieldSet | null;
onClose?: () => void; onClose?: () => void;
} }
interface ImporterStateProps { interface ImporterStateProps {
isOpen: boolean; isOpen: boolean;
sessionId: number | null; sessionId: number | null;
customFields?: ApiFormFieldSet | null;
onCloseCallback?: () => void; onCloseCallback?: () => void;
openImporter: (sessionId: number, options?: ImporterOpenOptions) => void; openImporter: (sessionId: number, options?: ImporterOpenOptions) => void;
closeImporter: () => void; closeImporter: () => void;
@@ -23,12 +26,14 @@ interface ImporterStateProps {
export const useImporterState = create<ImporterStateProps>()((set, get) => ({ export const useImporterState = create<ImporterStateProps>()((set, get) => ({
isOpen: false, isOpen: false,
sessionId: null, sessionId: null,
customFields: null,
onCloseCallback: undefined, onCloseCallback: undefined,
openImporter: (sessionId: number, options?: ImporterOpenOptions) => { openImporter: (sessionId: number, options?: ImporterOpenOptions) => {
set({ set({
sessionId, sessionId,
isOpen: true, isOpen: true,
customFields: options?.fields ?? null,
onCloseCallback: options?.onClose onCloseCallback: options?.onClose
}); });
}, },
@@ -39,6 +44,7 @@ export const useImporterState = create<ImporterStateProps>()((set, get) => ({
set({ set({
sessionId: null, sessionId: null,
isOpen: false, isOpen: false,
customFields: null,
onCloseCallback: undefined onCloseCallback: undefined
}); });