From 4f06918c36a28e614b2b0e4a9c0a2604a7795863 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 27 Sep 2024 00:35:30 +1000 Subject: [PATCH] [PUI] Fix Build Output Forms (#8184) * Enhancements for stock item form * Edit stock item from "build output" table * Rearrange menu items * Fix build order line complete action * Fix for other modals * Cleanup dead code * Reload build details after output state change * Logic fix for plugin table * Bump API version * Adds hook for generating placeholder serial numbers * Add playwright tests * Remove unused imports * Cleanup playwright tests --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/build/serializers.py | 8 +- .../src/components/forms/fields/TextField.tsx | 2 +- src/frontend/src/forms/BuildForms.tsx | 256 ++++++------------ src/frontend/src/forms/StockForms.tsx | 49 +++- src/frontend/src/hooks/UsePlaceholder.tsx | 66 +++++ src/frontend/src/pages/build/BuildDetail.tsx | 6 +- .../pages/part/pricing/BomPricingPanel.tsx | 2 +- .../part/pricing/VariantPricingPanel.tsx | 2 +- src/frontend/src/pages/stock/StockDetail.tsx | 5 +- src/frontend/src/tables/ColumnRenderers.tsx | 8 +- src/frontend/src/tables/bom/UsedInTable.tsx | 4 +- .../tables/build/BuildAllocatedStockTable.tsx | 2 +- .../src/tables/build/BuildLineTable.tsx | 2 +- .../src/tables/build/BuildOrderTable.tsx | 2 +- .../src/tables/build/BuildOutputTable.tsx | 55 +++- .../src/tables/part/ParametricPartTable.tsx | 2 +- .../src/tables/part/PartParameterTable.tsx | 2 +- src/frontend/src/tables/part/PartTable.tsx | 2 +- .../src/tables/plugin/PluginListTable.tsx | 10 +- .../purchasing/ManufacturerPartTable.tsx | 2 +- .../purchasing/PurchaseOrderLineItemTable.tsx | 2 +- .../tables/purchasing/SupplierPartTable.tsx | 2 +- .../tables/sales/ReturnOrderLineItemTable.tsx | 2 +- .../sales/SalesOrderAllocationTable.tsx | 2 +- .../tables/sales/SalesOrderLineItemTable.tsx | 2 +- .../src/tables/stock/InstalledItemsTable.tsx | 2 +- .../src/tables/stock/StockItemTable.tsx | 2 +- src/frontend/tests/pages/pui_build.spec.ts | 76 ++++++ 29 files changed, 354 insertions(+), 228 deletions(-) create mode 100644 src/frontend/src/hooks/UsePlaceholder.tsx diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index e247bbfcb8..90bd51dfd7 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 260 +INVENTREE_API_VERSION = 261 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +261 - 2024-09;26 : https://github.com/inventree/InvenTree/pull/8184 + - Fixes for BuildOrder API serializers + v260 - 2024-09-26 : https://github.com/inventree/InvenTree/pull/8190 - Adds facility for server-side context data to be passed to client-side plugins diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 4471fe0297..186d4eabcb 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -555,7 +555,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer): fields = [ 'outputs', 'location', - 'status', + 'status_custom_key', 'accept_incomplete_allocation', 'notes', ] @@ -573,7 +573,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer): help_text=_("Location for completed build outputs"), ) - status = serializers.ChoiceField( + status_custom_key = serializers.ChoiceField( choices=StockStatus.items(), default=StockStatus.OK.value, label=_("Status"), @@ -621,8 +621,8 @@ class BuildOutputCompleteSerializer(serializers.Serializer): data = self.validated_data - location = data['location'] - status = data['status'] + location = data.get('location', None) + status = data.get('status_custom_key', StockStatus.OK.value) notes = data.get('notes', '') outputs = data.get('outputs', []) diff --git a/src/frontend/src/components/forms/fields/TextField.tsx b/src/frontend/src/components/forms/fields/TextField.tsx index 1f5cabf13e..b12cec7178 100644 --- a/src/frontend/src/components/forms/fields/TextField.tsx +++ b/src/frontend/src/components/forms/fields/TextField.tsx @@ -30,7 +30,7 @@ export default function TextField({ const [rawText, setRawText] = useState(value || ''); - const [debouncedText] = useDebouncedValue(rawText, 250); + const [debouncedText] = useDebouncedValue(rawText, 100); useEffect(() => { setRawText(value || ''); diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index 5ce8aacedb..ff68c0eeda 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/macro'; -import { Alert, Stack, Table, Text } from '@mantine/core'; +import { Stack, Table } from '@mantine/core'; import { IconCalendar, IconLink, @@ -9,11 +9,8 @@ import { IconUser, IconUsersGroup } from '@tabler/icons-react'; -import { DataTable } from 'mantine-datatable'; import { useEffect, useMemo, useState } from 'react'; -import { api } from '../App'; -import { ActionButton } from '../components/buttons/ActionButton'; import RemoveRowButton from '../components/buttons/RemoveRowButton'; import { StandaloneField } from '../components/forms/StandaloneField'; import { @@ -22,15 +19,15 @@ import { } from '../components/forms/fields/ApiFormField'; import { TableFieldRowProps } from '../components/forms/fields/TableField'; import { ProgressBar } from '../components/items/ProgressBar'; +import { StatusRenderer } from '../components/render/StatusRenderer'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ModelType } from '../enums/ModelType'; -import { InvenTreeIcon } from '../functions/icons'; import { useCreateApiFormModal } from '../hooks/UseForm'; import { useBatchCodeGenerator } from '../hooks/UseGenerator'; -import { useSelectedRows } from '../hooks/UseSelectedRows'; +import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder'; import { apiUrl } from '../states/ApiState'; import { useGlobalSettingsState } from '../states/SettingsState'; -import { PartColumn, StatusColumn } from '../tables/ColumnRenderers'; +import { PartColumn } from '../tables/ColumnRenderers'; /** * Field set for BuildOrder forms @@ -160,32 +157,11 @@ export function useBuildOrderOutputFields({ setQuantity(Math.max(0, build_quantity - build_complete)); }, [build]); - const [serialPlaceholder, setSerialPlaceholder] = useState(''); - - useEffect(() => { - if (trackable) { - api - .get(apiUrl(ApiEndpoints.part_serial_numbers, build.part_detail.pk)) - .then((response: any) => { - if (response.data?.next) { - setSerialPlaceholder( - t`Next serial number` + ' - ' + response.data.next - ); - } else if (response.data?.latest) { - setSerialPlaceholder( - t`Latest serial number` + ' - ' + response.data.latest - ); - } else { - setSerialPlaceholder(''); - } - }) - .catch(() => { - setSerialPlaceholder(''); - }); - } else { - setSerialPlaceholder(''); - } - }, [build, trackable]); + const serialPlaceholder = useSerialNumberPlaceholder({ + partId: build.part_detail?.pk, + key: 'build-output', + enabled: build.part_detail?.trackable + }); return useMemo(() => { return { @@ -213,48 +189,37 @@ export function useBuildOrderOutputFields({ }, [quantity, serialPlaceholder, trackable]); } -/* - * Construct a table of build outputs, for displaying at the top of a form - */ -function buildOutputFormTable(outputs: any[], onRemove: (output: any) => void) { +function BuildOutputFormRow({ + props, + record +}: Readonly<{ + props: TableFieldRowProps; + record: any; +}>) { + const serial = useMemo(() => { + if (record.serial) { + return `# ${record.serial}`; + } else { + return t`Quantity` + `: ${record.quantity}`; + } + }, [record]); + return ( - PartColumn(record.part_detail) - }, - { - accessor: 'quantity', - title: t`Quantity`, - render: (record: any) => { - if (record.serial) { - return `# ${record.serial}`; - } else { - return record.quantity; - } - } - }, - StatusColumn({ model: ModelType.stockitem, sortable: false }), - { - accessor: 'actions', - title: '', - render: (record: any) => ( - } - color="red" - onClick={() => onRemove(record.pk)} - disabled={outputs.length <= 1} - /> - ) - } - ]} - /> + <> + + + + + {serial} + {record.batch} + + {' '} + + + props.removeFn(props.idx)} /> + + + ); } @@ -269,10 +234,6 @@ export function useCompleteBuildOutputsForm({ }) { const [location, setLocation] = useState(null); - const { selectedRows, removeRow } = useSelectedRows({ - rows: outputs - }); - useEffect(() => { if (location) { return; @@ -283,19 +244,22 @@ export function useCompleteBuildOutputsForm({ ); }, [location, build.destination, build.part_detail]); - const preFormContent = useMemo(() => { - return buildOutputFormTable(selectedRows, removeRow); - }, [selectedRows, removeRow]); - const buildOutputCompleteFields: ApiFormFieldSet = useMemo(() => { return { outputs: { - hidden: true, - value: selectedRows.map((output: any) => { + field_type: 'table', + value: outputs.map((output: any) => { return { output: output.pk }; - }) + }), + modelRenderer: (row: TableFieldRowProps) => { + const record = outputs.find((output) => output.pk == row.item.output); + return ( + + ); + }, + headers: [t`Part`, t`Stock Item`, t`Batch`, t`Status`] }, status_custom_key: {}, location: { @@ -303,14 +267,14 @@ export function useCompleteBuildOutputsForm({ structural: false }, value: location, - onValueChange: (value) => { + onValueChange: (value: any) => { setLocation(value); } }, notes: {}, accept_incomplete_allocation: {} }; - }, [selectedRows, location]); + }, [location, outputs]); return useCreateApiFormModal({ url: apiUrl(ApiEndpoints.build_output_complete, build.pk), @@ -318,8 +282,8 @@ export function useCompleteBuildOutputsForm({ title: t`Complete Build Outputs`, fields: buildOutputCompleteFields, onFormSuccess: onFormSuccess, - preFormContent: preFormContent, - successMessage: t`Build outputs have been completed` + successMessage: t`Build outputs have been completed`, + size: '80%' }); } @@ -337,10 +301,6 @@ export function useScrapBuildOutputsForm({ }) { const [location, setLocation] = useState(null); - const { selectedRows, removeRow } = useSelectedRows({ - rows: outputs - }); - useEffect(() => { if (location) { return; @@ -351,20 +311,23 @@ export function useScrapBuildOutputsForm({ ); }, [location, build.destination, build.part_detail]); - const preFormContent = useMemo(() => { - return buildOutputFormTable(selectedRows, removeRow); - }, [selectedRows, removeRow]); - const buildOutputScrapFields: ApiFormFieldSet = useMemo(() => { return { outputs: { - hidden: true, - value: selectedRows.map((output: any) => { + field_type: 'table', + value: outputs.map((output: any) => { return { output: output.pk, quantity: output.quantity }; - }) + }), + modelRenderer: (row: TableFieldRowProps) => { + const record = outputs.find((output) => output.pk == row.item.output); + return ( + + ); + }, + headers: [t`Part`, t`Stock Item`, t`Batch`, t`Status`] }, location: { value: location, @@ -375,7 +338,7 @@ export function useScrapBuildOutputsForm({ notes: {}, discard_allocations: {} }; - }, [location, selectedRows]); + }, [location, outputs]); return useCreateApiFormModal({ url: apiUrl(ApiEndpoints.build_output_scrap, build.pk), @@ -383,8 +346,8 @@ export function useScrapBuildOutputsForm({ title: t`Scrap Build Outputs`, fields: buildOutputScrapFields, onFormSuccess: onFormSuccess, - preFormContent: preFormContent, - successMessage: t`Build outputs have been scrapped` + successMessage: t`Build outputs have been scrapped`, + size: '80%' }); } @@ -397,89 +360,37 @@ export function useCancelBuildOutputsForm({ outputs: any[]; onFormSuccess: (response: any) => void; }) { - const { selectedRows, removeRow } = useSelectedRows({ - rows: outputs - }); - - const preFormContent = useMemo(() => { - return ( - - - {t`Selected build outputs will be deleted`} - - {buildOutputFormTable(selectedRows, removeRow)} - - ); - }, [selectedRows, removeRow]); - const buildOutputCancelFields: ApiFormFieldSet = useMemo(() => { return { outputs: { - hidden: true, - value: selectedRows.map((output: any) => { + field_type: 'table', + value: outputs.map((output: any) => { return { output: output.pk }; - }) + }), + modelRenderer: (row: TableFieldRowProps) => { + const record = outputs.find((output) => output.pk == row.item.output); + return ( + + ); + }, + headers: [t`Part`, t`Stock Item`, t`Batch`, t`Status`] } }; - }, [selectedRows]); + }, [outputs]); return useCreateApiFormModal({ url: apiUrl(ApiEndpoints.build_output_delete, build.pk), method: 'POST', title: t`Cancel Build Outputs`, fields: buildOutputCancelFields, - preFormContent: preFormContent, onFormSuccess: onFormSuccess, - successMessage: t`Build outputs have been cancelled` + successMessage: t`Build outputs have been cancelled`, + size: '80%' }); } -function buildAllocationFormTable( - outputs: any[], - onRemove: (output: any) => void -) { - return ( - PartColumn(record.part_detail) - }, - { - accessor: 'allocated', - title: t`Allocated`, - render: (record: any) => ( - - ) - }, - { - accessor: 'actions', - title: '', - render: (record: any) => ( - } - color="red" - onClick={() => onRemove(record.pk)} - disabled={outputs.length <= 1} - /> - ) - } - ]} - /> - ); -} - // Construct a single row in the 'allocate stock to build' table function BuildAllocateLineRow({ props, @@ -534,14 +445,11 @@ function BuildAllocateLineRow({ }; }, [props]); - const partDetail = useMemo( - () => PartColumn(record.part_detail), - [record.part_detail] - ); - return ( - {partDetail} + + + (null); const [supplierPart, setSupplierPart] = useState(null); @@ -86,7 +100,7 @@ export function useStockFields({ } }, supplier_part: { - // TODO: icon + hidden: part_detail?.purchaseable == false, value: supplierPart, onValueChange: (value) => { setSupplierPart(value); @@ -109,6 +123,7 @@ export function useStockFields({ description: t`Add given quantity as packs instead of individual items` }, location: { + // Cannot adjust location for existing stock items hidden: !create, onValueChange: (value) => { batchGenerator.update({ location: value }); @@ -135,11 +150,12 @@ export function useStockFields({ onValueChange: (value) => setSerialNumbers(value) }, serial: { - hidden: create - // TODO: icon + hidden: + create || + part_detail?.trackable == false || + (!item_detail?.quantity != undefined && item_detail?.quantity != 1) }, batch: { - // TODO: icon value: batchCode, onValueChange: (value) => setBatchCode(value) }, @@ -147,22 +163,23 @@ export function useStockFields({ label: t`Stock Status` }, expiry_date: { - // TODO: icon + icon: , + hidden: !globalSettings.isSet('STOCK_ENABLE_EXPIRY') }, purchase_price: { - // TODO: icon + icon: }, purchase_price_currency: { - // TODO: icon + icon: }, packaging: { - // TODO: icon, + icon: }, link: { - // TODO: icon + icon: }, owner: { - // TODO: icon + icon: }, delete_on_deplete: {} }; @@ -171,7 +188,17 @@ export function useStockFields({ // TODO: refer to stock.py in original codebase return fields; - }, [part, supplierPart, batchCode, serialNumbers, trackable, create]); + }, [ + item_detail, + part_detail, + part, + globalSettings, + supplierPart, + batchCode, + serialNumbers, + trackable, + create + ]); } /** diff --git a/src/frontend/src/hooks/UsePlaceholder.tsx b/src/frontend/src/hooks/UsePlaceholder.tsx new file mode 100644 index 0000000000..d67839e240 --- /dev/null +++ b/src/frontend/src/hooks/UsePlaceholder.tsx @@ -0,0 +1,66 @@ +import { t } from '@lingui/macro'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; + +import { api } from '../App'; +import { ApiEndpoints } from '../enums/ApiEndpoints'; +import { apiUrl } from '../states/ApiState'; + +/** + * Hook for generating a placeholder text for a serial number input + * + * This hook fetches the latest serial number information for a given part and generates a placeholder string. + * + * @param partId The ID of the part to fetch serial number information for + * @param key A unique key to identify the query + * @param enabled Whether the query should be enabled + */ +export function useSerialNumberPlaceholder({ + partId, + key, + enabled = true +}: { + partId: number; + key: string; + enabled?: boolean; +}): string | undefined { + // Fetch serial number information (if available) + const snQuery = useQuery({ + queryKey: ['serial-placeholder', key, partId], + enabled: enabled ?? true, + queryFn: async () => { + if (!partId) { + return null; + } + + const url = apiUrl(ApiEndpoints.part_serial_numbers, partId); + + return api + .get(url) + .then((response) => { + if (response.status === 200) { + return response.data; + } else { + return null; + } + }) + .catch(() => { + return null; + }); + } + }); + + const placeholder = useMemo(() => { + if (!enabled) { + return undefined; + } else if (snQuery.data?.next) { + return t`Next serial number` + `: ${snQuery.data.next}`; + } else if (snQuery.data?.latest) { + return t`Latest serial number` + `: ${snQuery.data.latest}`; + } else { + return undefined; + } + }, [enabled, snQuery.data]); + + return placeholder; +} diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 10ebab4e8c..2c967b57df 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -262,7 +262,11 @@ export default function BuildDetail() { name: 'incomplete-outputs', label: t`Incomplete Outputs`, icon: , - content: build.pk ? : + content: build.pk ? ( + + ) : ( + + ) // TODO: Hide if build is complete }, { diff --git a/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx b/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx index b3c1a01c2f..98bface11e 100644 --- a/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx +++ b/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx @@ -141,7 +141,7 @@ export default function BomPricingPanel({ title: t`Component`, sortable: true, switchable: false, - render: (record: any) => PartColumn(record.sub_part_detail) + render: (record: any) => PartColumn({ part: record.sub_part_detail }) }, { accessor: 'quantity', diff --git a/src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx b/src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx index 687f351423..884c1c67ea 100644 --- a/src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx +++ b/src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx @@ -30,7 +30,7 @@ export default function VariantPricingPanel({ title: t`Variant Part`, sortable: true, switchable: false, - render: (record: any) => PartColumn(record, true) + render: (record: any) => PartColumn({ part: record, full_name: true }) }, { accessor: 'pricing_min', diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index b6a4142400..6d18482ac9 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -422,7 +422,10 @@ export default function StockDetail() { [stockitem] ); - const editStockItemFields = useStockFields({ create: false }); + const editStockItemFields = useStockFields({ + create: false, + part_detail: stockitem.part_detail + }); const editStockItem = useEditApiFormModal({ url: ApiEndpoints.stock_item_list, diff --git a/src/frontend/src/tables/ColumnRenderers.tsx b/src/frontend/src/tables/ColumnRenderers.tsx index a9e4ad568b..32681aecd4 100644 --- a/src/frontend/src/tables/ColumnRenderers.tsx +++ b/src/frontend/src/tables/ColumnRenderers.tsx @@ -18,7 +18,13 @@ import { TableColumn, TableColumnProps } from './Column'; import { ProjectCodeHoverCard } from './TableHoverCard'; // Render a Part instance within a table -export function PartColumn(part: any, full_name?: boolean) { +export function PartColumn({ + part, + full_name +}: { + part: any; + full_name?: boolean; +}) { return part ? ( PartColumn(record.part_detail) + render: (record: any) => PartColumn({ part: record.part_detail }) }, { accessor: 'part_detail.IPN', @@ -47,7 +47,7 @@ export function UsedInTable({ accessor: 'sub_part', sortable: true, title: t`Component`, - render: (record: any) => PartColumn(record.sub_part_detail) + render: (record: any) => PartColumn({ part: record.sub_part_detail }) }, { accessor: 'quantity', diff --git a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx index 2db9b50e6d..c6057fa37d 100644 --- a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx +++ b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx @@ -82,7 +82,7 @@ export default function BuildAllocatedStockTable({ title: t`Part`, sortable: true, switchable: false, - render: (record: any) => PartColumn(record.part_detail) + render: (record: any) => PartColumn({ part: record.part_detail }) }, { hidden: !showPartInfo, diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index 6568e507b0..b9b385482e 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -175,7 +175,7 @@ export default function BuildLineTable({ ordering: 'part', sortable: true, switchable: false, - render: (record: any) => PartColumn(record.part_detail) + render: (record: any) => PartColumn({ part: record.part_detail }) }, { accessor: 'part_detail.IPN', diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx index c5f4d67d81..553ad44f3b 100644 --- a/src/frontend/src/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTable.tsx @@ -45,7 +45,7 @@ export function BuildOrderTable({ accessor: 'part', sortable: true, switchable: false, - render: (record: any) => PartColumn(record.part_detail) + render: (record: any) => PartColumn({ part: record.part_detail }) }, { accessor: 'part_detail.IPN', diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index 18df389a60..8331e1dce3 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -17,16 +17,20 @@ import { useCompleteBuildOutputsForm, useScrapBuildOutputsForm } from '../../forms/BuildForms'; +import { useStockFields } from '../../forms/StockForms'; import { InvenTreeIcon } from '../../functions/icons'; import { notYetImplemented } from '../../functions/notifications'; -import { useCreateApiFormModal } from '../../hooks/UseForm'; +import { + useCreateApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; import { TableColumn } from '../Column'; import { LocationColumn, PartColumn, StatusColumn } from '../ColumnRenderers'; import { InvenTreeTable } from '../InvenTreeTable'; -import { RowAction } from '../RowActions'; +import { RowAction, RowEditAction } from '../RowActions'; import { TableHoverCard } from '../TableHoverCard'; type TestResultOverview = { @@ -34,7 +38,10 @@ type TestResultOverview = { result: boolean; }; -export default function BuildOutputTable({ build }: Readonly<{ build: any }>) { +export default function BuildOutputTable({ + build, + refreshBuild +}: Readonly<{ build: any; refreshBuild: () => void }>) { const user = useUserState(); const table = useTable('build-outputs'); @@ -186,6 +193,7 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) { outputs: selectedOutputs, onFormSuccess: () => { table.refreshTable(); + refreshBuild(); } }); @@ -194,6 +202,7 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) { outputs: selectedOutputs, onFormSuccess: () => { table.refreshTable(); + refreshBuild(); } }); @@ -202,9 +211,24 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) { outputs: selectedOutputs, onFormSuccess: () => { table.refreshTable(); + refreshBuild(); } }); + const editStockItemFields = useStockFields({ + create: false, + item_detail: selectedOutputs[0], + part_detail: selectedOutputs[0]?.part_detail + }); + + const editBuildOutput = useEditApiFormModal({ + url: ApiEndpoints.stock_item_list, + pk: selectedOutputs[0]?.pk, + title: t`Edit Build Output`, + fields: editStockItemFields, + table: table + }); + const tableActions = useMemo(() => { return [ ) { completeBuildOutputsForm.open(); } }, + RowEditAction({ + tooltip: t`Edit build output`, + onClick: () => { + setSelectedOutputs([record]); + editBuildOutput.open(); + } + }), { title: t`Scrap`, tooltip: t`Scrap build output`, @@ -306,7 +337,7 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) { { accessor: 'part', sortable: true, - render: (record: any) => PartColumn(record?.part_detail) + render: (record: any) => PartColumn({ part: record?.part_detail }) }, { accessor: 'quantity', @@ -321,18 +352,13 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) { text = `# ${record.serial}`; } - return ( - - {text} - {record.batch && ( - - {t`Batch`}: {record.batch} - - )} - - ); + return text; } }, + { + accessor: 'batch', + sortable: true + }, StatusColumn({ accessor: 'status', sortable: true, @@ -410,6 +436,7 @@ export default function BuildOutputTable({ build }: Readonly<{ build: any }>) { {addBuildOutput.modal} {completeBuildOutputsForm.modal} {scrapBuildOutputsForm.modal} + {editBuildOutput.modal} {cancelBuildOutputsForm.modal} PartColumn(record) + render: (record: any) => PartColumn({ part: record }) }, DescriptionColumn({}), { diff --git a/src/frontend/src/tables/part/PartParameterTable.tsx b/src/frontend/src/tables/part/PartParameterTable.tsx index 9a51a9bbca..341864ec33 100644 --- a/src/frontend/src/tables/part/PartParameterTable.tsx +++ b/src/frontend/src/tables/part/PartParameterTable.tsx @@ -43,7 +43,7 @@ export function PartParameterTable({ { accessor: 'part', sortable: true, - render: (record: any) => PartColumn(record?.part_detail) + render: (record: any) => PartColumn({ part: record?.part_detail }) }, { accessor: 'part_detail.IPN', diff --git a/src/frontend/src/tables/part/PartTable.tsx b/src/frontend/src/tables/part/PartTable.tsx index 3a409e4d17..575ced442d 100644 --- a/src/frontend/src/tables/part/PartTable.tsx +++ b/src/frontend/src/tables/part/PartTable.tsx @@ -28,7 +28,7 @@ function partTableColumns(): TableColumn[] { title: t`Part`, sortable: true, noWrap: true, - render: (record: any) => PartColumn(record) + render: (record: any) => PartColumn({ part: record }) }, { accessor: 'IPN', diff --git a/src/frontend/src/tables/plugin/PluginListTable.tsx b/src/frontend/src/tables/plugin/PluginListTable.tsx index 05fd0ce493..3fc1a3e746 100644 --- a/src/frontend/src/tables/plugin/PluginListTable.tsx +++ b/src/frontend/src/tables/plugin/PluginListTable.tsx @@ -336,7 +336,10 @@ export default function PluginListTable() { return [ { - hidden: record.is_builtin != false || record.is_installed != false, + hidden: + record.is_builtin != false || + record.is_installed != true || + record.active != true, title: t`Deactivate`, color: 'red', icon: , @@ -347,7 +350,10 @@ export default function PluginListTable() { } }, { - hidden: record.is_builtin != false || record.is_installed != true, + hidden: + record.is_builtin != false || + record.is_installed != true || + record.active != false, title: t`Activate`, color: 'green', icon: , diff --git a/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx b/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx index 2f526b4a9b..e6ad551c50 100644 --- a/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx +++ b/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx @@ -37,7 +37,7 @@ export function ManufacturerPartTable({ accessor: 'part', switchable: 'part' in params, sortable: true, - render: (record: any) => PartColumn(record?.part_detail) + render: (record: any) => PartColumn({ part: record?.part_detail }) }, { accessor: 'manufacturer', diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx index f4a421837d..7b355a6a10 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx @@ -128,7 +128,7 @@ export function PurchaseOrderLineItemTable({ title: t`Internal Part`, sortable: true, switchable: false, - render: (record: any) => PartColumn(record.part_detail) + render: (record: any) => PartColumn({ part: record.part_detail }) }, { accessor: 'description', diff --git a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx index 5c8df1639d..a478018211 100644 --- a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx +++ b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx @@ -47,7 +47,7 @@ export function SupplierPartTable({ accessor: 'part', switchable: 'part' in params, sortable: true, - render: (record: any) => PartColumn(record?.part_detail) + render: (record: any) => PartColumn({ part: record?.part_detail }) }, { accessor: 'supplier', diff --git a/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx b/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx index eca6bd2ef7..dc1b1bcf09 100644 --- a/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx @@ -99,7 +99,7 @@ export default function ReturnOrderLineItemTable({ accessor: 'part', title: t`Part`, switchable: false, - render: (record: any) => PartColumn(record?.part_detail) + render: (record: any) => PartColumn({ part: record?.part_detail }) }, { accessor: 'item_detail.serial', diff --git a/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx b/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx index 30298bc063..70ddb594c6 100644 --- a/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx @@ -68,7 +68,7 @@ export default function SalesOrderAllocationTable({ title: t`Part`, sortable: true, switchable: false, - render: (record: any) => PartColumn(record.part_detail) + render: (record: any) => PartColumn({ part: record.part_detail }) }, { accessor: 'quantity', diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index a9bbb6cae0..89616fcf0e 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -59,7 +59,7 @@ export default function SalesOrderLineItemTable({ accessor: 'part', sortable: true, switchable: false, - render: (record: any) => PartColumn(record?.part_detail) + render: (record: any) => PartColumn({ part: record?.part_detail }) }, { accessor: 'part_detail.IPN', diff --git a/src/frontend/src/tables/stock/InstalledItemsTable.tsx b/src/frontend/src/tables/stock/InstalledItemsTable.tsx index de90b54117..ce20f72c60 100644 --- a/src/frontend/src/tables/stock/InstalledItemsTable.tsx +++ b/src/frontend/src/tables/stock/InstalledItemsTable.tsx @@ -22,7 +22,7 @@ export default function InstalledItemsTable({ { accessor: 'part', switchable: false, - render: (record: any) => PartColumn(record?.part_detail) + render: (record: any) => PartColumn({ part: record?.part_detail }) }, { accessor: 'quantity', diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 2aa326ff84..c642af474e 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -47,7 +47,7 @@ function stockItemTableColumns(): TableColumn[] { { accessor: 'part', sortable: true, - render: (record: any) => PartColumn(record?.part_detail) + render: (record: any) => PartColumn({ part: record?.part_detail }) }, { accessor: 'part_detail.IPN', diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index f36f740049..59a6397d5b 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -81,3 +81,79 @@ test('PUI - Pages - Build Order', async ({ page }) => { .getByText('Making a high level assembly') .waitFor(); }); + +test('PUI - Pages - Build Order - Build Outputs', async ({ page }) => { + await doQuickLogin(page); + + await page.goto(`${baseUrl}/part/`); + + // Navigate to the correct build order + await page.getByRole('tab', { name: 'Build', exact: true }).click(); + + // We have now loaded the "Build Order" table. Check for some expected texts + await page.getByText('On Hold').first().waitFor(); + await page.getByText('Pending').first().waitFor(); + + await page.getByRole('cell', { name: 'BO0011' }).click(); + await page.getByRole('tab', { name: 'Incomplete Outputs' }).click(); + + // Create a new build output + await page.getByLabel('action-button-add-build-output').click(); + await page.getByLabel('number-field-quantity').fill('5'); + + const placeholder = await page + .getByLabel('text-field-serial_numbers') + .getAttribute('placeholder'); + + let sn = 1; + + if (!!placeholder && placeholder.includes('Next serial number')) { + sn = parseInt(placeholder.split(':')[1].trim()); + } + + // Generate some new serial numbers + await page.getByLabel('text-field-serial_numbers').fill(`${sn}, ${sn + 1}`); + + await page.getByLabel('text-field-batch_code').fill('BATCH12345'); + await page.getByLabel('related-field-location').click(); + await page.getByText('Reel Storage').click(); + await page.getByRole('button', { name: 'Submit' }).click(); + + // Should be an error as the number of serial numbers doesn't match the quantity + await page.getByText('Errors exist for one or more').waitFor(); + await page.getByText('Number of unique serial').waitFor(); + + // Fix the quantity + await page.getByLabel('number-field-quantity').fill('2'); + await page.waitForTimeout(250); + await page.getByRole('button', { name: 'Submit' }).click(); + + // Check that new serial numbers have been created + await page + .getByRole('cell', { name: `# ${sn}` }) + .first() + .waitFor(); + await page + .getByRole('cell', { name: `# ${sn + 1}` }) + .first() + .waitFor(); + + // Cancel one of the newly created outputs + const cell = await page.getByRole('cell', { name: `# ${sn}` }); + const row = await cell.locator('xpath=ancestor::tr').first(); + await row.getByLabel(/row-action-menu-/i).click(); + await page.getByRole('menuitem', { name: 'Cancel' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Build outputs have been cancelled').waitFor(); + + // Complete the other output + const cell2 = await page.getByRole('cell', { name: `# ${sn + 1}` }); + const row2 = await cell2.locator('xpath=ancestor::tr').first(); + await row2.getByLabel(/row-action-menu-/i).click(); + await page.getByRole('menuitem', { name: 'Complete' }).click(); + await page.getByLabel('related-field-location').click(); + await page.getByText('Mechanical Lab').click(); + await page.waitForTimeout(250); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Build outputs have been completed').waitFor(); +});