mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-10 15:36:17 +00:00
[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:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user