[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 <hana@MacBook-Air-M1.local>
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
Hana Lee
2026-07-31 19:18:35 +10:00
committed by GitHub
co-authored by Hana Oliver
parent 7461486317
commit 9177ebcb97
5 changed files with 237 additions and 92 deletions
@@ -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<string, any>;
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}
<ActionDropdown
key='add-parts-actions'
tooltip={t`Add Parts`}
position='bottom-start'
icon={<IconPlus />}
hidden={!user.hasAddRole(UserRoles.part)}
actions={[
{
name: t`Create Part`,
icon: <IconPlus />,
tooltip: t`Create a new part`,
onClick: () => newPart.open()
},
{
name: t`Import from File`,
icon: <IconFileUpload />,
tooltip: t`Import parts from a file`,
onClick: () => importParts.open(),
hidden: !enableImport
},
{
name: t`Import from Supplier`,
icon: <IconPackageImport />,
tooltip: t`Import parts from a supplier plugin`,
hidden: !enableImport || supplierPlugins.length === 0,
onClick: () => importPartWizard.openWizard()
}
]}
/>
</>
);
}
@@ -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<string, any>;
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,
@@ -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(
() => [
<PartCreationMenu
key='part-creation-menu'
categoryId={categoryId}
enableImport={enableImport}
refreshRef={tableRefreshRef}
/>
],
[categoryId, enableImport]
);
return (
<ParametricDataTable
modelType={ModelType.part}
@@ -48,6 +65,8 @@ export default function ParametricPartTable({
endpoint={ApiEndpoints.part_list}
customColumns={customColumns}
customFilters={customFilters}
customActions={tableActions}
refreshRef={tableRefreshRef}
queryParams={{
category: categoryId,
cascade: true,
+14 -88
View File
@@ -12,14 +12,10 @@ import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables';
import type { InvenTreeTableProps } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import {
IconFileUpload,
IconPackageImport,
IconPlus,
IconShoppingCart
} from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { IconShoppingCart } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import { PartCreationMenu } from '../../components/items/PartCreationMenu';
import {
BooleanColumn,
CategoryColumn,
@@ -32,11 +28,9 @@ import {
} from '../../components/tables/ColumnRenderers';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import { renderPartStockCell } from '../../components/tables/PartStockCell';
import ImportPartWizard from '../../components/wizards/ImportPartWizard';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatPriceRange } from '../../defaults/formatters';
import { DuplicateField } from '../../forms/CommonFields';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { usePartFields } from '../../forms/PartForms';
import { InvenTreeIcon } from '../../functions/icons';
import {
@@ -44,8 +38,6 @@ import {
useCreateApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { usePluginsWithMixin } from '../../hooks/UsePlugins';
import { useImporterState } from '../../states/ImporterState';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import { PartTableFilters } from './PartTableFilters';
@@ -151,52 +143,16 @@ export function PartListTable({
});
const user = useUserState();
const globalSettings = useGlobalSettingsState();
const openImporter = useImporterState((state) => 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<any>({});
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({
}
]}
/>,
<ActionDropdown
key='add-parts-actions'
tooltip={t`Add Parts`}
position='bottom-start'
icon={<IconPlus />}
hidden={!user.hasAddRole(UserRoles.part)}
actions={[
{
name: t`Create Part`,
icon: <IconPlus />,
tooltip: t`Create a new part`,
onClick: () => newPart.open()
},
{
name: t`Import from File`,
icon: <IconFileUpload />,
tooltip: t`Import parts from a file`,
onClick: () => importParts.open(),
hidden: !enableImport
},
{
name: t`Import from Supplier`,
icon: <IconPackageImport />,
tooltip: t`Import parts from a supplier plugin`,
hidden: !enableImport || supplierPlugins.length === 0,
onClick: () => importPartWizard.openWizard()
}
]}
<PartCreationMenu
key='part-creation-menu'
initialData={initialPartData}
basePartInstance={basePartInstance}
enableImport={enableImport}
refreshRef={refreshRef}
/>
];
}, [user, enableImport, table.hasSelectedRecords, supplierPlugins]);
}, [user, enableImport, table.hasSelectedRecords]);
return (
<>
{newPart.modal}
{duplicatePart.modal}
{editPart.modal}
{setCategory.modal}
{importParts.modal}
{orderPartsWizard.wizard}
{importPartWizard.wizard}
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_list)}
tableState={table}
+73
View File
@@ -1,5 +1,6 @@
import { expect } from '@playwright/test';
import { test } from '../baseFixtures';
import { readeruser } from '../defaults';
import {
clearTableFilters,
clickOnParamFilter,
@@ -1217,3 +1218,75 @@ test('Parts - Import supplier part', async ({ browser }) => {
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);
});