From c6166d7c4a38e750a0464883db9c5679ea22151c Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 9 Jul 2025 23:32:00 +1000 Subject: [PATCH] Import fix 2 (#9992) * Fix return types * Add getStatusCodeLabel func * Fix logic for import session drawer - Properly re-fetch session data - Rendering improvements * Fix icon * API permission fixes * Enhanced playwright testing * Fix playwright tests * Tweak playwright tests * Remove unused var * Tweak playwright tests --- src/backend/InvenTree/importer/api.py | 6 +-- .../components/importer/ImporterDrawer.tsx | 33 ++++-------- .../importer/ImporterImportProgress.tsx | 45 ----------------- .../components/importer/ImporterStatus.tsx | 42 ++++++++++++++++ .../src/components/render/StatusRenderer.tsx | 23 +++++++++ src/frontend/src/hooks/UseImportSession.tsx | 22 +++++--- src/frontend/src/hooks/UseInstance.tsx | 8 ++- src/frontend/tests/pages/pui_part.spec.ts | 13 +++-- src/frontend/tests/pui_importing.spec.ts | 50 +++++++++++++++++-- src/frontend/vite.config.ts | 1 - 10 files changed, 153 insertions(+), 90 deletions(-) delete mode 100644 src/frontend/src/components/importer/ImporterImportProgress.tsx create mode 100644 src/frontend/src/components/importer/ImporterStatus.tsx diff --git a/src/backend/InvenTree/importer/api.py b/src/backend/InvenTree/importer/api.py index cc00b7635c..bae964131a 100644 --- a/src/backend/InvenTree/importer/api.py +++ b/src/backend/InvenTree/importer/api.py @@ -135,7 +135,7 @@ class DataImportSessionAcceptFields(APIView): return Response(importer.serializers.DataImportSessionSerializer(session).data) -class DataImportSessionAcceptRows(DataImporterPermission, CreateAPI): +class DataImportSessionAcceptRows(DataImporterPermissionMixin, CreateAPI): """API endpoint to accept the rows for a DataImportSession.""" queryset = importer.models.DataImportSession.objects.all() @@ -174,7 +174,7 @@ class DataImportColumnMappingDetail(DataImporterPermissionMixin, RetrieveUpdateA serializer_class = importer.serializers.DataImportColumnMapSerializer -class DataImportRowList(DataImporterPermission, BulkDeleteMixin, ListAPI): +class DataImportRowList(DataImporterPermissionMixin, BulkDeleteMixin, ListAPI): """API endpoint for accessing a list of DataImportRow objects.""" queryset = importer.models.DataImportRow.objects.all() @@ -189,7 +189,7 @@ class DataImportRowList(DataImporterPermission, BulkDeleteMixin, ListAPI): ordering = 'row_index' -class DataImportRowDetail(DataImporterPermission, RetrieveUpdateDestroyAPI): +class DataImportRowDetail(DataImporterPermissionMixin, RetrieveUpdateDestroyAPI): """Detail endpoint for a single DataImportRow object.""" queryset = importer.models.DataImportRow.objects.all() diff --git a/src/frontend/src/components/importer/ImporterDrawer.tsx b/src/frontend/src/components/importer/ImporterDrawer.tsx index 7213fac500..2ed836627c 100644 --- a/src/frontend/src/components/importer/ImporterDrawer.tsx +++ b/src/frontend/src/components/importer/ImporterDrawer.tsx @@ -5,15 +5,12 @@ import { Divider, Drawer, Group, - Loader, - LoadingOverlay, Paper, Space, Stack, - Stepper, - Text + Stepper } from '@mantine/core'; -import { IconCheck } from '@tabler/icons-react'; +import { IconCheck, IconExclamationCircle } from '@tabler/icons-react'; import { type ReactNode, useMemo } from 'react'; import { ModelType } from '@lib/enums/ModelType'; @@ -22,7 +19,7 @@ import useStatusCodes from '../../hooks/UseStatusCodes'; import { StylishText } from '../items/StylishText'; import ImporterDataSelector from './ImportDataSelector'; import ImporterColumnSelector from './ImporterColumnSelector'; -import ImporterImportProgress from './ImporterImportProgress'; +import ImporterStatus from './ImporterStatus'; /* * Stepper component showing the current step of the data import process. @@ -86,17 +83,17 @@ export default function ImporterDrawer({ }, [session.status]); const widget = useMemo(() => { - if (session.sessionQuery.isLoading || session.sessionQuery.isFetching) { - return ; + if (session.sessionQuery.isError) { + return ( + }> + {t`Failed to fetch import session data`} + + ); } switch (session.status) { - case importSessionStatus.INITIAL: - return Initial : TODO; case importSessionStatus.MAPPING: return ; - case importSessionStatus.IMPORTING: - return ; case importSessionStatus.PROCESSING: return ; case importSessionStatus.COMPLETE: @@ -113,14 +110,7 @@ export default function ImporterDrawer({ ); default: - return ( - - }> - {t`Import session has unknown status`}: {session.status} - - - - ); + return ; } }, [session.status, session.sessionQuery]); @@ -165,8 +155,7 @@ export default function ImporterDrawer({ }} > - - {session.sessionQuery.isFetching || widget} + {widget} ); diff --git a/src/frontend/src/components/importer/ImporterImportProgress.tsx b/src/frontend/src/components/importer/ImporterImportProgress.tsx deleted file mode 100644 index 3ee15a1a28..0000000000 --- a/src/frontend/src/components/importer/ImporterImportProgress.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { t } from '@lingui/core/macro'; -import { Center, Container, Loader, Stack, Text } from '@mantine/core'; -import { useInterval } from '@mantine/hooks'; -import { useEffect } from 'react'; - -import { ModelType } from '@lib/enums/ModelType'; -import type { ImportSessionState } from '../../hooks/UseImportSession'; -import useStatusCodes from '../../hooks/UseStatusCodes'; -import { StylishText } from '../items/StylishText'; - -export default function ImporterImportProgress({ - session -}: Readonly<{ - session: ImportSessionState; -}>) { - const importSessionStatus = useStatusCodes({ - modelType: ModelType.importsession - }); - - // Periodically refresh the import session data - const interval = useInterval(() => { - if (session.status == importSessionStatus.IMPORTING) { - session.refreshSession(); - } - }, 1000); - - useEffect(() => { - interval.start(); - return interval.stop; - }, []); - - return ( -
- - - {t`Importing Records`} - - - {t`Imported Rows`}: {session.sessionData.row_count} - - - -
- ); -} diff --git a/src/frontend/src/components/importer/ImporterStatus.tsx b/src/frontend/src/components/importer/ImporterStatus.tsx new file mode 100644 index 0000000000..9dbf5d9218 --- /dev/null +++ b/src/frontend/src/components/importer/ImporterStatus.tsx @@ -0,0 +1,42 @@ +import { t } from '@lingui/core/macro'; +import { Center, Loader, Stack } from '@mantine/core'; +import { useInterval } from '@mantine/hooks'; +import { useMemo } from 'react'; + +import { ModelType } from '@lib/enums/ModelType'; +import type { ImportSessionState } from '../../hooks/UseImportSession'; +import { StylishText } from '../items/StylishText'; +import { getStatusCodeLabel } from '../render/StatusRenderer'; + +export default function ImporterStatus({ + session +}: Readonly<{ + session: ImportSessionState; +}>) { + const statusText = useMemo(() => { + return ( + getStatusCodeLabel(ModelType.importsession, session.status) || + t`Unknown Status` + ); + }, [session.status]); + + // Periodically refresh the import session data + const _interval = useInterval( + () => { + session.refreshSession(); + }, + 1000, + { + autoInvoke: true + } + ); + + return ( +
+ + {statusText} + + +
+ ); +} diff --git a/src/frontend/src/components/render/StatusRenderer.tsx b/src/frontend/src/components/render/StatusRenderer.tsx index bb54da9e6e..d7dc8a3599 100644 --- a/src/frontend/src/components/render/StatusRenderer.tsx +++ b/src/frontend/src/components/render/StatusRenderer.tsx @@ -132,6 +132,29 @@ export function getStatusCodeName( return null; } +/* + * Return the human-readable label for a status code + */ +export function getStatusCodeLabel( + type: ModelType | string, + key: string | number +): string | null { + const statusCodes = getStatusCodes(type); + + if (!statusCodes) { + return null; + } + + for (const name in statusCodes.values) { + const entry: StatusCodeInterface = statusCodes.values[name]; + + if (entry.key == key) { + return entry.label; + } + } + return null; +} + /* * Render the status for a object. * Uses the values specified in "status_codes.py" diff --git a/src/frontend/src/hooks/UseImportSession.tsx b/src/frontend/src/hooks/UseImportSession.tsx index dde146f019..7503228c03 100644 --- a/src/frontend/src/hooks/UseImportSession.tsx +++ b/src/frontend/src/hooks/UseImportSession.tsx @@ -1,7 +1,8 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; +import type { UseQueryResult } from '@tanstack/react-query'; import { useInstance } from './UseInstance'; import useStatusCodes from './UseStatusCodes'; @@ -14,7 +15,7 @@ export type ImportSessionState = { sessionData: any; setSessionData: (data: any) => void; refreshSession: () => void; - sessionQuery: any; + sessionQuery: UseQueryResult; status: number; availableFields: Record; availableColumns: string[]; @@ -52,10 +53,19 @@ export function useImportSession({ modelType: ModelType.importsession }); - // Current step of the import process - const status: number = useMemo(() => { - return sessionData?.status ?? importSessionStatus.INITIAL; - }, [sessionData, importSessionStatus]); + // Session status (we update whenever the session data changes) + const [status, setStatus] = useState(0); + + // Reset the status when the sessionId changes + useEffect(() => { + setStatus(0); + }, [sessionId]); + + useEffect(() => { + if (!!sessionData.status && sessionData.status !== status) { + setStatus(sessionData.status); + } + }, [sessionData?.status]); // List of available writeable database field definitions const availableFields: any[] = useMemo(() => { diff --git a/src/frontend/src/hooks/UseInstance.tsx b/src/frontend/src/hooks/UseInstance.tsx index 2bdf564f1d..34a766f921 100644 --- a/src/frontend/src/hooks/UseInstance.tsx +++ b/src/frontend/src/hooks/UseInstance.tsx @@ -1,4 +1,8 @@ -import { type QueryObserverResult, useQuery } from '@tanstack/react-query'; +import { + type QueryObserverResult, + type UseQueryResult, + useQuery +} from '@tanstack/react-query'; import { useCallback, useMemo, useState } from 'react'; import type { ApiEndpoints } from '@lib/enums/ApiEndpoints'; @@ -11,7 +15,7 @@ export interface UseInstanceResult { setInstance: (instance: any) => void; refreshInstance: () => void; refreshInstancePromise: () => Promise>; - instanceQuery: any; + instanceQuery: UseQueryResult; isLoaded: boolean; } diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 4a3f037994..994ef8f574 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -1,6 +1,7 @@ import { test } from '../baseFixtures'; import { clearTableFilters, + clickOnRowMenu, getRowFromCell, loadTab, navigate @@ -89,17 +90,15 @@ test('Parts - BOM', async ({ browser }) => { name: 'Small plastic enclosure, black', exact: true }); - await cell.click({ button: 'right' }); + + await clickOnRowMenu(cell); // Check for expected context menu actions - await page.getByRole('button', { name: 'Edit', exact: true }).waitFor(); - await page.getByRole('button', { name: 'Delete', exact: true }).waitFor(); - await page - .getByRole('button', { name: 'View details', exact: true }) - .waitFor(); + await page.getByRole('menuitem', { name: 'Edit', exact: true }).waitFor(); + await page.getByRole('menuitem', { name: 'Delete', exact: true }).waitFor(); await page - .getByRole('button', { name: 'Edit Substitutes', exact: true }) + .getByRole('menuitem', { name: 'Edit Substitutes', exact: true }) .click(); await page.getByText('Edit BOM Substitutes').waitFor(); await page.getByText('1551ACLR').first().waitFor(); diff --git a/src/frontend/tests/pui_importing.spec.ts b/src/frontend/tests/pui_importing.spec.ts index 6cbe55e69e..4b38ba862f 100644 --- a/src/frontend/tests/pui_importing.spec.ts +++ b/src/frontend/tests/pui_importing.spec.ts @@ -50,13 +50,55 @@ test('Importing - BOM', async ({ browser }) => { await page.getByText('Mapping data columns to database fields').waitFor(); await page.getByRole('button', { name: 'Accept Column Mapping' }).click(); + await page.waitForTimeout(500); - await page.getByText('Processing Data').waitFor(); + await page.getByText('Importing Data').waitFor(); await page.getByText('0 / 4').waitFor(); + + await page.getByText('Torx head screw, M3 thread, 10.0mm').first().waitFor(); + await page.getByText('Small plastic enclosure, black').first().waitFor(); + + // Select some rows await page - .getByLabel('Importing DataUpload FileMap') - .getByText('002.01-PCBA | Widget Board') - .waitFor(); + .getByRole('row', { name: 'Select record 1 0 Thumbnail' }) + .getByLabel('Select record') + .click(); + await page + .getByRole('row', { name: 'Select record 2 1 Thumbnail' }) + .getByLabel('Select record') + .click(); + + // Delete selected rows + await page + .getByRole('dialog', { name: 'Importing Data Upload File 2' }) + .getByLabel('action-button-delete-selected') + .click(); + await page.getByRole('button', { name: 'Delete', exact: true }).click(); + + await page.getByText('Success', { exact: true }).waitFor(); + await page.getByText('Items deleted', { exact: true }).waitFor(); + + // Edit a row + await page + .getByRole('row', { name: 'Select record 1 2 Thumbnail' }) + .getByLabel('row-action-menu-') + .click(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('12'); + + await page.waitForTimeout(250); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.waitForTimeout(250); + + await page.getByText('0 / 2', { exact: true }).waitFor(); + + // Submit a row + await page + .getByRole('row', { name: 'Select record 1 2 Thumbnail' }) + .getByLabel('row-action-menu-') + .click(); + await page.getByRole('menuitem', { name: 'Accept' }).click(); + await page.getByText('1 / 2', { exact: true }).waitFor(); }); test('Importing - Purchase Order', async ({ browser }) => { diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index 888cc0b727..610d55a667 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -23,7 +23,6 @@ const OUTPUT_DIR = '../../src/backend/InvenTree/web/static/web'; export default defineConfig(({ command, mode }) => { // In 'build' mode, we want to use an empty base URL (for static file generation) const baseUrl: string | undefined = command === 'build' ? '' : undefined; - console.log(`Running Vite in '${command}' mode -> base URL: ${baseUrl}`); return { plugins: [