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');