diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index 5c7fe2e542..d247e69ba2 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -102,6 +102,7 @@ export enum ApiEndpoints { bom_list = 'bom/', bom_item_validate = 'bom/:id/validate/', bom_validate = 'part/:id/bom-validate/', + bom_substitute_list = 'bom/substitute/', // Part API endpoints part_list = 'part/', diff --git a/src/frontend/src/forms/BomForms.tsx b/src/frontend/src/forms/BomForms.tsx index 7487303d13..8852cb5fa2 100644 --- a/src/frontend/src/forms/BomForms.tsx +++ b/src/frontend/src/forms/BomForms.tsx @@ -1,4 +1,17 @@ +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { apiUrl } from '@lib/functions/Api'; +import { UserRoles } from '@lib/index'; import type { ApiFormFieldSet } from '@lib/types/Forms'; +import { t } from '@lingui/core/macro'; +import { Table } from '@mantine/core'; +import { useEffect, useMemo, useState } from 'react'; +import { api } from '../App'; +import RemoveRowButton from '../components/buttons/RemoveRowButton'; +import type { TableFieldRowProps } from '../components/forms/fields/TableField'; +import { RenderPart } from '../components/render/Part'; +import { showApiErrorMessage } from '../functions/notifications'; +import { useCreateApiFormModal } from '../hooks/UseForm'; +import { useUserState } from '../states/UserState'; /** * Field set for BomItem form @@ -24,3 +37,111 @@ export function bomItemFields(): ApiFormFieldSet { optional: {} }; } + +function BomItemSubstituteRow({ + props, + record +}: Readonly<{ + props: TableFieldRowProps; + record: any; +}>) { + const user = useUserState(); + + return ( + + + {record.part_detail && } + + + {user.hasDeleteRole(UserRoles.part) && ( + { + api + .delete(apiUrl(ApiEndpoints.bom_substitute_list, record.pk)) + .then(() => { + props.removeFn(props.idx); + }) + .catch((err) => { + showApiErrorMessage({ + error: err, + title: t`Error` + }); + }); + }} + /> + )} + + + ); +} + +type BomItemSubstituteFormProps = { + bomItemId: number; + substitutes: any[]; + onClose?: () => void; +}; + +/** + * Edit substitutes for a BOM item + */ +export function useEditBomSubstitutesForm(props: BomItemSubstituteFormProps) { + const [substitutes, setSubstitutes] = useState([]); + + useEffect(() => { + setSubstitutes(props.substitutes); + }, [props.substitutes]); + + const formFields: ApiFormFieldSet = useMemo(() => { + return { + substitutes: { + field_type: 'table', + value: substitutes, + ignore: true, + modelRenderer: (row: TableFieldRowProps) => { + const record = substitutes.find((r) => r.pk == row.item.pk); + return record ? ( + + ) : null; + }, + headers: [ + { title: t`Substitute Part`, style: { width: '100%' } }, + { title: '', style: { width: '50px' } } + ] + }, + bom_item: { + hidden: true, + value: props.bomItemId + }, + part: { + filters: { + component: true + } + } + }; + }, [props, substitutes]); + + return useCreateApiFormModal({ + title: t`Edit BOM Substitutes`, + url: apiUrl(ApiEndpoints.bom_substitute_list), + fields: formFields, + initialData: { + substitutes: substitutes + }, + cancelText: t`Close`, + submitText: t`Add Substitute`, + successMessage: t`Substitute added`, + onClose: () => { + props.onClose?.(); + }, + checkClose: (response, form) => { + // Keep the form open + return false; + }, + onFormSuccess(data, form) { + // Add the new substitute to the list + setSubstitutes((old) => [...old, data]); + form.setValue('part', null); + return false; + } + }); +} diff --git a/src/frontend/src/tables/Column.tsx b/src/frontend/src/tables/Column.tsx index 04fb40cf63..cdd1dcdcb3 100644 --- a/src/frontend/src/tables/Column.tsx +++ b/src/frontend/src/tables/Column.tsx @@ -1,24 +1,47 @@ import type { ApiFormFieldType } from '@lib/types/Forms'; +/** + * Table column properties + * + * @param T - The type of the record + * @param accessor - The key in the record to access + * @param title - The title of the column - Note: this may be supplied by the API, and is not required, but it can be overridden if required + * @param ordering - The key in the record to sort by (defaults to accessor) + * @param sortable - Whether the column is sortable + * @param switchable - Whether the column is switchable + * @param hidden - Whether the column is hidden + * @param editable - Whether the value of this column can be edited + * @param definition - Optional field definition for the column + * @param render - A custom render function + * @param filter - A custom filter function + * @param filtering - Whether the column is filterable + * @param width - The width of the column + * @param noWrap - Whether the column should wrap + * @param ellipsis - Whether the column should be ellipsized + * @param textAlign - The text alignment of the column + * @param cellsStyle - The style of the cells in the column + * @param extra - Extra data to pass to the render function + * @param noContext - Disable context menu for this column + */ export type TableColumnProps = { - accessor?: string; // The key in the record to access - title?: string; // The title of the column - Note: this may be supplied by the API, and is not required, but it can be overridden if required - ordering?: string; // The key in the record to sort by (defaults to accessor) - sortable?: boolean; // Whether the column is sortable - switchable?: boolean; // Whether the column is switchable - hidden?: boolean; // Whether the column is hidden - editable?: boolean; // Whether the value of this column can be edited - definition?: ApiFormFieldType; // Optional field definition for the column - render?: (record: T, index?: number) => any; // A custom render function - filter?: any; // A custom filter function - filtering?: boolean; // Whether the column is filterable - width?: number; // The width of the column - noWrap?: boolean; // Whether the column should wrap - ellipsis?: boolean; // Whether the column should be ellipsized - textAlign?: 'left' | 'center' | 'right'; // The text alignment of the column - cellsStyle?: any; // The style of the cells in the column - extra?: any; // Extra data to pass to the render function - noContext?: boolean; // Disable context menu for this column + accessor?: string; + title?: string; + ordering?: string; + sortable?: boolean; + switchable?: boolean; + hidden?: boolean; + editable?: boolean; + definition?: ApiFormFieldType; + render?: (record: T, index?: number) => any; + filter?: any; + filtering?: boolean; + width?: number; + noWrap?: boolean; + ellipsis?: boolean; + textAlign?: 'left' | 'center' | 'right'; + cellsStyle?: any; + extra?: any; + noContext?: boolean; }; /** diff --git a/src/frontend/src/tables/bom/BomTable.tsx b/src/frontend/src/tables/bom/BomTable.tsx index b41488d80d..b7e4c43898 100644 --- a/src/frontend/src/tables/bom/BomTable.tsx +++ b/src/frontend/src/tables/bom/BomTable.tsx @@ -22,11 +22,11 @@ import { AddItemButton } from '../../components/buttons/AddItemButton'; import { YesNoButton } from '../../components/buttons/YesNoButton'; import { Thumbnail } from '../../components/images/Thumbnail'; import ImporterDrawer from '../../components/importer/ImporterDrawer'; +import { RenderPart } from '../../components/render/Part'; import { useApi } from '../../contexts/ApiContext'; import { formatDecimal, formatPriceRange } from '../../defaults/formatters'; -import { bomItemFields } from '../../forms/BomForms'; +import { bomItemFields, useEditBomSubstitutesForm } from '../../forms/BomForms'; import { dataImporterSessionFields } from '../../forms/ImporterForms'; -import { notYetImplemented } from '../../functions/notifications'; import { useApiFormModal, useCreateApiFormModal, @@ -145,12 +145,17 @@ export function BomTable({ }, { accessor: 'substitutes', - // TODO: Show hovercard with list of substitutes render: (row) => { const substitutes = row.substitutes ?? []; return substitutes.length > 0 ? ( - row.length + {substitutes.length}} + title={t`Substitutes`} + extra={substitutes.map((sub: any) => ( + + ))} + /> ) : ( ); @@ -363,7 +368,7 @@ export function BomTable({ ]; }, [partId, params]); - const [selectedBomItem, setSelectedBomItem] = useState(0); + const [selectedBomItem, setSelectedBomItem] = useState({}); const importSessionFields = useMemo(() => { const fields = dataImporterSessionFields(); @@ -401,7 +406,7 @@ export function BomTable({ const editBomItem = useEditApiFormModal({ url: ApiEndpoints.bom_list, - pk: selectedBomItem, + pk: selectedBomItem.pk, title: t`Edit BOM Item`, fields: bomItemFields(), successMessage: t`BOM item updated`, @@ -410,12 +415,20 @@ export function BomTable({ const deleteBomItem = useDeleteApiFormModal({ url: ApiEndpoints.bom_list, - pk: selectedBomItem, + pk: selectedBomItem.pk, title: t`Delete BOM Item`, successMessage: t`BOM item deleted`, table: table }); + const editSubstitues = useEditBomSubstitutesForm({ + bomItemId: selectedBomItem.pk, + substitutes: selectedBomItem?.substitutes ?? [], + onClose: () => { + table.refreshTable(); + } + }); + const validateBom = useApiFormModal({ url: ApiEndpoints.bom_validate, method: 'PUT', @@ -488,21 +501,24 @@ export function BomTable({ RowEditAction({ hidden: partLocked || !user.hasChangeRole(UserRoles.part), onClick: () => { - setSelectedBomItem(record.pk); + setSelectedBomItem(record); editBomItem.open(); } }), { title: t`Edit Substitutes`, color: 'blue', - hidden: partLocked || !user.hasChangeRole(UserRoles.part), + hidden: partLocked || !user.hasAddRole(UserRoles.part), icon: , - onClick: notYetImplemented + onClick: () => { + setSelectedBomItem(record); + editSubstitues.open(); + } }, RowDeleteAction({ hidden: partLocked || !user.hasDeleteRole(UserRoles.part), onClick: () => { - setSelectedBomItem(record.pk); + setSelectedBomItem(record); deleteBomItem.open(); } }) @@ -543,6 +559,7 @@ export function BomTable({ {editBomItem.modal} {validateBom.modal} {deleteBomItem.modal} + {editSubstitues.modal} {partLocked && ( { await page.getByText('DIG-84670-SJI - R_550R_0805_1%').waitFor(); }); +test('Parts - BOM', async ({ browser }) => { + const page = await doCachedLogin(browser, { url: 'part/87/bom' }); + + await loadTab(page, 'Bill of Materials'); + await page.waitForLoadState('networkidle'); + + const cell = await page.getByRole('cell', { + name: 'Small plastic enclosure, black', + exact: true + }); + await cell.click({ button: 'right' }); + + // 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('button', { name: 'Edit Substitutes', exact: true }) + .click(); + await page.getByText('Edit BOM Substitutes').waitFor(); + await page.getByText('1551ACLR').first().waitFor(); + await page.getByText('1551AGY').first().waitFor(); + + await page.getByLabel('related-field-part').fill('enclosure'); + await page.getByText('1591BTBU').click(); + + await page.getByRole('button', { name: 'Add Substitute' }).waitFor(); + await page.getByRole('button', { name: 'Close' }).click(); +}); + test('Parts - Locking', async ({ browser }) => { const page = await doCachedLogin(browser, { url: 'part/104/bom' }); await loadTab(page, 'Bill of Materials');