2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 04:25:42 +00:00

[PUI] Build test results (#7777)

* Skeleton for "test results" panel on build detail page

* Generate table columns based on test templates

* Fill out test result table in build panel

* Fix for form submission with files attached

- Better determination of "hasFiles"
- Ignore undefined values

* Add modal form to create a new test result

* Add button for creating a new test result

* Fix for build output table

* Add extra API filtering options to BuildLine API endpoint

* Improve table rendering

* Adjust form fields

* Account for multiple test results

* Add "location" column

* Docs updates

* playwright tests
This commit is contained in:
Oliver
2024-08-01 14:58:26 +10:00
committed by GitHub
parent 3cbfcc11cb
commit 97bef77d56
16 changed files with 357 additions and 34 deletions

View File

@ -359,6 +359,8 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
'unit_quantity',
'available_stock',
'trackable',
'allow_variants',
'inherited',
]
ordering_field_aliases = {
@ -368,6 +370,8 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
'consumable': 'bom_item__consumable',
'optional': 'bom_item__optional',
'trackable': 'bom_item__sub_part__trackable',
'allow_variants': 'bom_item__allow_variants',
'inherited': 'bom_item__inherited',
}
search_fields = [

View File

@ -122,11 +122,11 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
case 200:
return response.data;
default:
return undefined;
return {};
}
})
.catch(() => {
return undefined;
return {};
});
}
});

View File

@ -398,7 +398,7 @@ export function ApiForm({
let value: any = data[key];
let field_type = fields[key]?.field_type;
if (field_type == 'file upload') {
if (field_type == 'file upload' && !!value) {
hasFiles = true;
}
@ -413,7 +413,9 @@ export function ApiForm({
}
}
dataForm.append(key, value);
if (value != undefined) {
dataForm.append(key, value);
}
});
return api({

View File

@ -28,6 +28,7 @@ import {
useSerialNumberGenerator
} from '../hooks/UseGenerator';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
/**
* Construct a set of fields for creating / editing a StockItem instance
@ -932,6 +933,13 @@ export function useTestResultFields({
// Field type for the "value" input
const [fieldType, setFieldType] = useState<'string' | 'choice'>('string');
const settings = useGlobalSettingsState.getState();
const includeTestStation = useMemo(
() => settings.isSet('TEST_STATION_DATA'),
[settings]
);
return useMemo(() => {
return {
stock_item: {
@ -972,8 +980,15 @@ export function useTestResultFields({
},
attachment: {},
notes: {},
started_datetime: {},
finished_datetime: {}
started_datetime: {
hidden: !includeTestStation
},
finished_datetime: {
hidden: !includeTestStation
},
test_station: {
hidden: !includeTestStation
}
};
}, [choices, fieldType, partId, itemId]);
}, [choices, fieldType, partId, itemId, includeTestStation]);
}

View File

@ -1,6 +1,7 @@
import { t } from '@lingui/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconChecklist,
IconClipboardCheck,
IconClipboardList,
IconDots,
@ -51,6 +52,7 @@ import { useUserState } from '../../states/UserState';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
import BuildLineTable from '../../tables/build/BuildLineTable';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import BuildOrderTestTable from '../../tables/build/BuildOrderTestTable';
import BuildOutputTable from '../../tables/build/BuildOutputTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
@ -307,6 +309,17 @@ export default function BuildDetail() {
<Skeleton />
)
},
{
name: 'test-results',
label: t`Test Results`,
icon: <IconChecklist />,
hidden: !build.part_detail?.trackable,
content: build.pk ? (
<BuildOrderTestTable buildId={build.pk} partId={build.part} />
) : (
<Skeleton />
)
},
{
name: 'test-statistics',
label: t`Test Statistics`,

View File

@ -0,0 +1,256 @@
import { t } from '@lingui/macro';
import { ActionIcon, Badge, Group, Text, Tooltip } from '@mantine/core';
import { IconCirclePlus } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { PassFailButton } from '../../components/buttons/YesNoButton';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { RenderUser } from '../../components/render/User';
import { formatDate } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { useTestResultFields } from '../../forms/StockForms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { LocationColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
/**
* A table which displays all "test results" for the outputs generated by a build order.
*/
export default function BuildOrderTestTable({
buildId,
partId
}: {
buildId: number;
partId: number;
}) {
const table = useTable('build-tests');
const user = useUserState();
// Fetch the test templates required for this build order
const { data: testTemplates } = useQuery({
queryKey: ['build-test-templates', partId, buildId],
queryFn: async () => {
if (!partId) {
return [];
}
return api
.get(apiUrl(ApiEndpoints.part_test_template_list), {
params: {
part: partId,
include_inherited: true,
enabled: true,
required: true
}
})
.then((res) => res.data)
.catch((err) => []);
}
});
// Reload the table data whenever the set of templates changes
useEffect(() => {
table.refreshTable();
}, [testTemplates]);
const [selectedOutput, setSelectedOutput] = useState<number>(0);
const [selectedTemplate, setSelectedTemplate] = useState<number>(0);
const testResultFields: ApiFormFieldSet = useTestResultFields({
partId: partId,
itemId: selectedOutput
});
const createTestResult = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.stock_test_result_list),
title: t`Add Test Result`,
fields: testResultFields,
initialData: {
template: selectedTemplate,
result: true
},
onFormSuccess: () => table.refreshTable(),
successMessage: t`Test result added`
});
// Generate a table column for each test template
const testColumns: TableColumn[] = useMemo(() => {
if (!testTemplates || testTemplates.length == 0) {
return [];
}
return testTemplates.map((template: any) => {
return {
accessor: `test_${template.pk}`,
title: template.test_name,
sortable: false,
switchable: true,
render: (record: any) => {
let tests = record.tests || [];
// Find the most recent test result (highest primary key)
let test = tests
.filter((test: any) => test.template == template.pk)
.sort((a: any, b: any) => b.pk - a.pk)
.shift();
// No test result recorded
if (!test || test.result === undefined) {
return (
<Group gap="xs" wrap="nowrap" justify="space-between">
<Badge color="lightblue" variant="filled">{t`No Result`}</Badge>
<Tooltip label={t`Add Test Result`}>
<ActionIcon
size="xs"
color="green"
variant="transparent"
onClick={() => {
setSelectedOutput(record.pk);
setSelectedTemplate(template.pk);
createTestResult.open();
}}
>
<IconCirclePlus />
</ActionIcon>
</Tooltip>
</Group>
);
}
let extra: ReactNode[] = [];
if (test.value) {
extra.push(
<Text key="value" size="sm">
{t`Value`}: {test.value}
</Text>
);
}
if (test.notes) {
extra.push(
<Text key="notes" size="sm">
{t`Notes`}: {test.notes}
</Text>
);
}
if (test.date) {
extra.push(
<Text key="date" size="sm">
{t`Date`}: {formatDate(test.date)}
</Text>
);
}
if (test.user_detail) {
extra.push(<RenderUser key="user" instance={test.user_detail} />);
}
return (
<TableHoverCard
value={<PassFailButton value={test.result} />}
title={template.test_name}
extra={extra}
/>
);
}
};
});
}, [testTemplates]);
const tableColumns: TableColumn[] = useMemo(() => {
// Fixed columns
let columns: TableColumn[] = [
{
accessor: 'stock',
title: t`Build Output`,
sortable: true,
switchable: false,
render: (record: any) => {
if (record.serial) {
return `# ${record.serial}`;
} else {
let extra: ReactNode[] = [];
if (record.batch) {
extra.push(
<Text key="batch" size="sm">
{t`Batch Code`}: {record.batch}
</Text>
);
}
return (
<TableHoverCard
value={
<Text>
{t`Quantity`}: {record.quantity}
</Text>
}
title={t`Build Output`}
extra={extra}
/>
);
}
}
},
LocationColumn({
accessor: 'location_detail'
})
];
return [...columns, ...testColumns];
}, [testColumns]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'is_building',
label: t`In Production`,
description: t`Show build outputs currently in production`
}
];
}, []);
const tableActions = useMemo(() => {
return [];
}, []);
const rowActions = useCallback(
(record: any) => {
return [];
},
[user]
);
return (
<>
{createTestResult.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.stock_item_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
part_detail: true,
location_detail: true,
tests: true,
build: buildId
},
rowActions: rowActions,
tableFilters: tableFilters,
tableActions: tableActions
}}
/>
</>
);
}

View File

@ -47,9 +47,9 @@ export default function BuildOutputTable({ build }: { build: any }) {
// Fetch the test templates associated with the partId
const { data: testTemplates } = useQuery({
queryKey: ['buildoutputtests', build.part],
queryKey: ['buildoutputtests', partId],
queryFn: async () => {
if (!partId) {
if (!partId || partId < 0) {
return [];
}
@ -322,7 +322,7 @@ export default function BuildOutputTable({ build }: { build: any }) {
}
}
];
}, [buildId, partId]);
}, [buildId, partId, testTemplates]);
return (
<>

View File

@ -24,6 +24,16 @@ test('PUI - Pages - Build Order', async ({ page }) => {
.getByRole('cell', { name: 'R38, R39, R40, R41, R42, R43' })
.waitFor();
// Check "test results"
await page.getByRole('tab', { name: 'Test Results' }).click();
await page.getByText('Quantity: 25').waitFor();
await page.getByText('Continuity Checks').waitFor();
await page
.getByRole('row', { name: 'Quantity: 16 No location set' })
.getByRole('button')
.hover();
await page.getByText('Add Test Result').waitFor();
// Click through to the "parent" build
await page.getByRole('tab', { name: 'Build Details' }).click();
await page.getByRole('link', { name: 'BO0010' }).click();