From 9177ebcb9715a8a3585d0da62a6f2d4dd74d0e1e Mon Sep 17 00:00:00 2001 From: Hana Lee <47701469+hanielee@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:18:35 -0700 Subject: [PATCH] [UI] Keep Add Parts button visible in Parametric View (#12392) * [UI] Keep Add Parts button visible in Parametric View * [UI] Add PartCreationMenu component for shared part creation actions * [UI] Fix unused imports and import ordering in PartTable and ParametricDataTable * [UI] Fix ambiguous text locator in parametric view test * [UI] Fix import formatting in PartTable --------- Co-authored-by: Hana Co-authored-by: Oliver --- .../src/components/items/PartCreationMenu.tsx | 109 ++++++++++++++++++ .../tables/general/ParametricDataTable.tsx | 22 +++- .../src/tables/part/ParametricPartTable.tsx | 23 +++- src/frontend/src/tables/part/PartTable.tsx | 102 +++------------- src/frontend/tests/pages/pui_part.spec.ts | 73 ++++++++++++ 5 files changed, 237 insertions(+), 92 deletions(-) create mode 100644 src/frontend/src/components/items/PartCreationMenu.tsx diff --git a/src/frontend/src/components/items/PartCreationMenu.tsx b/src/frontend/src/components/items/PartCreationMenu.tsx new file mode 100644 index 0000000000..232a53e930 --- /dev/null +++ b/src/frontend/src/components/items/PartCreationMenu.tsx @@ -0,0 +1,109 @@ +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { ModelType } from '@lib/enums/ModelType'; +import { UserRoles } from '@lib/enums/Roles'; +import { t } from '@lingui/core/macro'; +import { + IconFileUpload, + IconPackageImport, + IconPlus +} from '@tabler/icons-react'; +import { type RefObject, useMemo } from 'react'; +import { dataImporterSessionFields } from '../../forms/ImporterForms'; +import { usePartFields } from '../../forms/PartForms'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; +import { usePluginsWithMixin } from '../../hooks/UsePlugins'; +import { openGlobalImporter } from '../../states/ImporterState'; +import { useUserState } from '../../states/UserState'; +import ImportPartWizard from '../wizards/ImportPartWizard'; +import { ActionDropdown } from './ActionDropdown'; + +export function PartCreationMenu({ + categoryId, + initialData, + basePartInstance, + enableImport = true, + refreshRef +}: Readonly<{ + categoryId?: any; + initialData?: Record; + basePartInstance?: any; + enableImport?: boolean; + refreshRef?: RefObject<() => void>; +}>) { + const user = useUserState(); + + const importSessionFields = useMemo(() => { + const fields = dataImporterSessionFields({ modelType: ModelType.part }); + fields.field_defaults.value = initialData ?? { category: categoryId }; + return fields; + }, [categoryId, initialData]); + + const importParts = useCreateApiFormModal({ + url: ApiEndpoints.import_session_list, + title: t`Import Parts`, + fields: importSessionFields, + onFormSuccess: (response: any) => { + openGlobalImporter(response.pk, { onClose: refreshRef?.current }); + } + }); + + const partInitialData = useMemo( + () => initialData ?? { category: categoryId }, + [categoryId, initialData] + ); + + const newPartFields = usePartFields({ + create: true, + duplicatePartInstance: basePartInstance + }); + + const newPart = useCreateApiFormModal({ + url: ApiEndpoints.part_list, + title: t`Add Part`, + fields: newPartFields, + initialData: partInitialData, + follow: true, + modelType: ModelType.part, + keepOpenOption: true + }); + + const supplierPlugins = usePluginsWithMixin('supplier'); + const importPartWizard = ImportPartWizard({ categoryId }); + + return ( + <> + {newPart.modal} + {importParts.modal} + {importPartWizard.wizard} + } + hidden={!user.hasAddRole(UserRoles.part)} + actions={[ + { + name: t`Create Part`, + icon: , + tooltip: t`Create a new part`, + onClick: () => newPart.open() + }, + { + name: t`Import from File`, + icon: , + tooltip: t`Import parts from a file`, + onClick: () => importParts.open(), + hidden: !enableImport + }, + { + name: t`Import from Supplier`, + icon: , + tooltip: t`Import parts from a supplier plugin`, + hidden: !enableImport || supplierPlugins.length === 0, + onClick: () => importPartWizard.openWizard() + } + ]} + /> + + ); +} diff --git a/src/frontend/src/tables/general/ParametricDataTable.tsx b/src/frontend/src/tables/general/ParametricDataTable.tsx index ca8b77fd3b..c56a87c482 100644 --- a/src/frontend/src/tables/general/ParametricDataTable.tsx +++ b/src/frontend/src/tables/general/ParametricDataTable.tsx @@ -21,7 +21,14 @@ import { Divider, Group, Text } from '@mantine/core'; import { useHover } from '@mantine/hooks'; import { IconCirclePlus } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; -import { type ReactNode, useCallback, useMemo, useState } from 'react'; +import { + type ReactNode, + type RefObject, + useCallback, + useEffect, + useMemo, + useState +} from 'react'; import { useNavigate } from 'react-router-dom'; import { InvenTreeTable } from '../../components/tables/InvenTreeTable'; import { TableHoverCard } from '../../components/tables/TableHoverCard'; @@ -143,7 +150,9 @@ export default function ParametricDataTable({ endpoint, queryParams, customFilters, - customColumns + customColumns, + customActions, + refreshRef }: { modelType: ModelType; modelId?: number; @@ -153,12 +162,20 @@ export default function ParametricDataTable({ queryParams?: Record; customFilters?: TableFilter[]; customColumns?: TableColumn[]; + customActions?: ReactNode[]; + refreshRef?: RefObject<() => void>; }) { const api = useApi(); const table = useTable(`parametric-data-${modelType}`); const user = useUserState(); const navigate = useNavigate(); + useEffect(() => { + if (refreshRef) { + refreshRef.current = table.refreshTable; + } + }, [table.refreshTable]); + // Fetch all active parameter templates for the given model type const parameterTemplates = useQuery({ queryKey: ['parameter-templates', modelType], @@ -461,6 +478,7 @@ export default function ParametricDataTable({ props={{ enableDownload: true, rowActions: rowActions, + tableActions: customActions, tableFilters: tableFilters, params: { ...queryParams, diff --git a/src/frontend/src/tables/part/ParametricPartTable.tsx b/src/frontend/src/tables/part/ParametricPartTable.tsx index 32477cc389..4d47e87480 100644 --- a/src/frontend/src/tables/part/ParametricPartTable.tsx +++ b/src/frontend/src/tables/part/ParametricPartTable.tsx @@ -2,7 +2,8 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import type { TableFilter } from '@lib/types/Filters'; import type { TableColumn } from '@lib/types/Tables'; -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; +import { PartCreationMenu } from '../../components/items/PartCreationMenu'; import { DescriptionColumn, PartColumn @@ -12,10 +13,14 @@ import ParametricDataTable from '../general/ParametricDataTable'; import { PartTableFilters } from './PartTableFilters'; export default function ParametricPartTable({ - categoryId + categoryId, + enableImport = true }: Readonly<{ categoryId?: any; + enableImport?: boolean; }>) { + const tableRefreshRef = useRef<() => void>(null!); + const customFilters: TableFilter[] = useMemo(() => PartTableFilters(), []); const customColumns: TableColumn[] = useMemo(() => { @@ -40,6 +45,18 @@ export default function ParametricPartTable({ ]; }, []); + const tableActions = useMemo( + () => [ + + ], + [categoryId, enableImport] + ); + return ( state.openImporter); + const refreshRef = useRef<() => void>(null!); - const importSessionFields = useMemo(() => { - const fields = dataImporterSessionFields({ - modelType: ModelType.part - }); - - // Override default field values with provided fields - fields.field_defaults.value = { - ...props?.params, - ...defaultPartData - }; - - return fields; - }, [defaultPartData, props?.params]); - - const importParts = useCreateApiFormModal({ - url: ApiEndpoints.import_session_list, - title: t`Import Parts`, - fields: importSessionFields, - onFormSuccess: (response: any) => { - openImporter(response.pk, { - onClose: table.refreshTable - }); - } - }); + useEffect(() => { + refreshRef.current = table.refreshTable; + }, [table.refreshTable]); const initialPartData = useMemo(() => { return defaultPartData ?? props?.params ?? {}; }, [defaultPartData, props?.params]); - const newPartFields = usePartFields({ - create: true, - duplicatePartInstance: basePartInstance - }); - - const newPart = useCreateApiFormModal({ - url: ApiEndpoints.part_list, - title: t`Add Part`, - fields: newPartFields, - initialData: initialPartData, - follow: true, - modelType: ModelType.part, - keepOpenOption: true - }); - const [selectedPart, setSelectedPart] = useState({}); const editPart = useEditApiFormModal({ @@ -264,11 +220,6 @@ export function PartListTable({ const orderPartsWizard = OrderPartsWizard({ parts: table.selectedRecords }); - const supplierPlugins = usePluginsWithMixin('supplier'); - const importPartWizard = ImportPartWizard({ - categoryId: initialPartData.category - }); - const rowActions = useCallback( (record: any): RowAction[] => { const can_edit = user.hasChangePermission(ModelType.part); @@ -323,47 +274,22 @@ export function PartListTable({ } ]} />, - } - hidden={!user.hasAddRole(UserRoles.part)} - actions={[ - { - name: t`Create Part`, - icon: , - tooltip: t`Create a new part`, - onClick: () => newPart.open() - }, - { - name: t`Import from File`, - icon: , - tooltip: t`Import parts from a file`, - onClick: () => importParts.open(), - hidden: !enableImport - }, - { - name: t`Import from Supplier`, - icon: , - tooltip: t`Import parts from a supplier plugin`, - hidden: !enableImport || supplierPlugins.length === 0, - onClick: () => importPartWizard.openWizard() - } - ]} + ]; - }, [user, enableImport, table.hasSelectedRecords, supplierPlugins]); + }, [user, enableImport, table.hasSelectedRecords]); return ( <> - {newPart.modal} {duplicatePart.modal} {editPart.modal} {setCategory.modal} - {importParts.modal} {orderPartsWizard.wizard} - {importPartWizard.wizard} { await deletePart('BOLT-Steel-M5-5'); await deletePart('BOLT-M5-5'); }); + +test('Parts - Add button visible in Parametric View (admin)', async ({ + browser +}) => { + const page = await doCachedLogin(browser, { url: 'part/category/4/parts' }); + + await showParametricView(page); + + await expect( + page.getByRole('button', { name: 'action-menu-add-parts' }) + ).toBeVisible(); +}); + +test('Parts - Add button opens Create Part form in Parametric View', async ({ + browser +}) => { + const page = await doCachedLogin(browser, { url: 'part/category/4/parts' }); + + await showParametricView(page); + + await page.getByRole('button', { name: 'action-menu-add-parts' }).click(); + await page + .getByRole('menuitem', { name: 'action-menu-add-parts-create-part' }) + .click(); + + await expect(page.getByText('Add Part', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); +}); + +test('Parts - Add button hidden in Parametric View (reader)', async ({ + browser +}) => { + const page = await doCachedLogin(browser, { + url: 'part/category/4/parts', + user: readeruser + }); + + await showParametricView(page); + + await expect( + page.getByRole('button', { name: 'action-menu-add-parts' }) + ).toHaveCount(0); +}); + +test('Parts - Create part via Parametric View submits to API', async ({ + browser +}) => { + const testPartName = 'TEST-PARAMETRIC-ADD-PART'; + + await deletePart(testPartName); + + const page = await doCachedLogin(browser, { url: 'part/category/4/parts' }); + + await showParametricView(page); + + await page.getByRole('button', { name: 'action-menu-add-parts' }).click(); + await page + .getByRole('menuitem', { name: 'action-menu-add-parts-create-part' }) + .click(); + + await page.getByLabel('text-field-name', { exact: true }).fill(testPartName); + await page + .getByLabel('text-field-description', { exact: true }) + .fill('Created from Parametric View integration test'); + + await page.getByRole('button', { name: 'Submit' }).click(); + await page.waitForLoadState('networkidle'); + + await page.getByText(testPartName).first().waitFor(); + + await deletePart(testPartName); +});