2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 12:35:46 +00:00

[WIP] Test result table (#6430)

* Add basic table for stock item test results

* Improve custom data formatter callback

* Custom data formatter for returned results

* Update YesNoButton functionality

- Add PassFailButton with custom text

* Enhancements for stock item test result table

- Render all data

* Add placeholder row actions

* Fix table link

* Add option to filter parttesttemplate table by "inherited"

* Navigate through to parent part

* Update PartTestTemplate model

- Save 'key' value to database
- Update whenever model is saved
- Custom data migration

* Custom migration step in tasks.py

- Add custom management command
- Wraps migration step in maintenance mode

* Improve uniqueness validation for PartTestTemplate

* Add 'template' field to StockItemTestResult

- Links to a PartTestTemplate instance
- Add migrations to link existing PartTestTemplates

* Add "results" count to PartTestTemplate API

- Include in rendered tables

* Add 'results' column to test result table

- Allow filtering too

* Update serializer for StockItemTestResult

- Include template information
- Update CUI and PUI tables

* Control template_detail field with query params

* Update ref in api_version.py

* Update data migration

- Ensure new template is created for top level assembly

* Fix admin integration

* Update StockItemTestResult table

- Remove 'test' field
- Make 'template' field non-nullable
- Previous data migrations should have accounted for this

* Implement "legacy" API support

- Create test result by providing test name
- Lookup existing template

* PUI: Cleanup table

* Update tasks.py

- Exclude temporary settings when exporting data

* Fix unique validation check

* Remove duplicate code

* CUI: Fix data rendering

* More refactoring of PUI table

* More fixes for PUI table

* Get row expansion working (kinda)

* Improve rendering of subtable

* More PUI updates:

- Edit existing results
- Add new results

* allow delete of test result

* Fix typo

* Updates for admin integration

* Unit tests for stock migrations

* Added migration test for PartTestTemplate

* Fix for AttachmentTable

- Rebuild actions when permissions are recalculated

* Update test fixtures

* Add ModelType information

* Fix TableState

* Fix dataFormatter type def

* Improve table rendering

* Correctly filter "edit" and "delete" buttons

* Loosen requirements for dataFormatter

* Fixtures for report tests

* Better API filtering for StocokItemTestResult list

- Add Filter class
- Add option for filtering against legacy "name" data

* Cleanup API filter

* Fix unit tests

* Further unit test fixes

* Include test results for installed stock items

* Improve rendering of test result table

* Fix filtering for getTestResults

* More unit test fixes

* Fix more unit tests

* FIx part unit test

* More fixes

* More unit test fixes

* Rebuild stock item trees when merging

* Helper function for adding a test result to a stock item

* Set init fix

* Code cleanup

* Cleanup unused variables

* Add docs and more unit tests

* Update build unit test
This commit is contained in:
Oliver
2024-02-18 23:26:01 +11:00
committed by GitHub
parent ad1c1ae604
commit 0f51127adf
50 changed files with 1505 additions and 243 deletions

View File

@ -21,7 +21,7 @@ export function setApiDefaults() {
const token = useSessionState.getState().token;
api.defaults.baseURL = host;
api.defaults.timeout = 1000;
api.defaults.timeout = 2500;
if (!!token) {
api.defaults.headers.common['Authorization'] = `Token ${token}`;

View File

@ -3,8 +3,18 @@ import { Badge } from '@mantine/core';
import { isTrue } from '../../functions/conversion';
export function YesNoButton({ value }: { value: any }) {
export function PassFailButton({
value,
passText,
failText
}: {
value: any;
passText?: string;
failText?: string;
}) {
const v = isTrue(value);
const pass = passText || t`Pass`;
const fail = failText || t`Fail`;
return (
<Badge
@ -13,7 +23,11 @@ export function YesNoButton({ value }: { value: any }) {
radius="lg"
size="sm"
>
{v ? t`Yes` : t`No`}
{v ? pass : fail}
</Badge>
);
}
export function YesNoButton({ value }: { value: any }) {
return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />;
}

View File

@ -23,7 +23,8 @@ import {
import {
RenderPart,
RenderPartCategory,
RenderPartParameterTemplate
RenderPartParameterTemplate,
RenderPartTestTemplate
} from './Part';
import { RenderStockItem, RenderStockLocation } from './Stock';
import { RenderOwner, RenderUser } from './User';
@ -48,6 +49,7 @@ const RendererLookup: EnumDictionary<
[ModelType.part]: RenderPart,
[ModelType.partcategory]: RenderPartCategory,
[ModelType.partparametertemplate]: RenderPartParameterTemplate,
[ModelType.parttesttemplate]: RenderPartTestTemplate,
[ModelType.projectcode]: RenderProjectCode,
[ModelType.purchaseorder]: RenderPurchaseOrder,
[ModelType.purchaseorderline]: RenderPurchaseOrder,

View File

@ -32,6 +32,13 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/partparametertemplate/:pk/',
api_endpoint: ApiEndpoints.part_parameter_template_list
},
parttesttemplate: {
label: t`Part Test Template`,
label_multiple: t`Part Test Templates`,
url_overview: '/parttesttemplate',
url_detail: '/parttesttemplate/:pk/',
api_endpoint: ApiEndpoints.part_test_template_list
},
supplierpart: {
label: t`Supplier Part`,
label_multiple: t`Supplier Parts`,

View File

@ -51,3 +51,16 @@ export function RenderPartParameterTemplate({
/>
);
}
export function RenderPartTestTemplate({
instance
}: {
instance: any;
}): ReactNode {
return (
<RenderInlineModel
primary={instance.test_name}
secondary={instance.description}
/>
);
}

View File

@ -80,6 +80,7 @@ export enum ApiEndpoints {
stock_location_list = 'stock/location/',
stock_location_tree = 'stock/location/tree/',
stock_attachment_list = 'stock/attachment/',
stock_test_result_list = 'stock/test/',
// Order API endpoints
purchase_order_list = 'order/po/',

View File

@ -7,6 +7,7 @@ export enum ModelType {
manufacturerpart = 'manufacturerpart',
partcategory = 'partcategory',
partparametertemplate = 'partparametertemplate',
parttesttemplate = 'parttesttemplate',
projectcode = 'projectcode',
stockitem = 'stockitem',
stocklocation = 'stocklocation',

View File

@ -51,6 +51,7 @@ export function useInstance<T = any>({
return api
.get(url, {
timeout: 10000,
params: params
})
.then((response) => {

View File

@ -19,6 +19,8 @@ export type TableState = {
activeFilters: TableFilter[];
setActiveFilters: (filters: TableFilter[]) => void;
clearActiveFilters: () => void;
expandedRecords: any[];
setExpandedRecords: (records: any[]) => void;
selectedRecords: any[];
setSelectedRecords: (records: any[]) => void;
clearSelectedRecords: () => void;
@ -59,6 +61,9 @@ export function useTable(tableName: string): TableState {
setActiveFilters([]);
}, []);
// Array of expanded records
const [expandedRecords, setExpandedRecords] = useState<any[]>([]);
// Array of selected records
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
@ -81,6 +86,8 @@ export function useTable(tableName: string): TableState {
activeFilters,
setActiveFilters,
clearActiveFilters,
expandedRecords,
setExpandedRecords,
selectedRecords,
setSelectedRecords,
clearSelectedRecords,

View File

@ -41,6 +41,7 @@ import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
export default function StockDetail() {
const { id } = useParams();
@ -89,7 +90,15 @@ export default function StockDetail() {
name: 'testdata',
label: t`Test Data`,
icon: <IconChecklist />,
hidden: !stockitem?.part_detail?.trackable
hidden: !stockitem?.part_detail?.trackable,
content: stockitem?.pk ? (
<StockItemTestResultTable
itemId={stockitem.pk}
partId={stockitem.part}
/>
) : (
<Skeleton />
)
},
{
name: 'installed_items',

View File

@ -74,7 +74,9 @@ export function ReferenceColumn(): TableColumn {
export function NoteColumn(): TableColumn {
return {
accessor: 'note',
sortable: false
sortable: false,
title: t`Note`,
render: (record: any) => record.note ?? record.notes
};
}
@ -119,6 +121,15 @@ export function ResponsibleColumn(): TableColumn {
};
}
export function DateColumn(): TableColumn {
return {
accessor: 'date',
sortable: true,
title: t`Date`,
render: (record: any) => renderDate(record.date)
};
}
export function TargetDateColumn(): TableColumn {
return {
accessor: 'target_date',

View File

@ -74,8 +74,9 @@ export type InvenTreeTableProps<T = any> = {
tableFilters?: TableFilter[];
tableActions?: React.ReactNode[];
printingActions?: any[];
rowExpansion?: any;
idAccessor?: string;
dataFormatter?: (data: T) => any;
dataFormatter?: (data: any) => any;
rowActions?: (record: T) => RowAction[];
onRowClick?: (record: T, index: number, event: any) => void;
};
@ -223,14 +224,12 @@ export function InvenTreeTable<T = any>({
hidden: false,
switchable: false,
width: 50,
render: function (record: any) {
return (
<RowActions
actions={tableProps.rowActions?.(record) ?? []}
disabled={tableState.selectedRecords.length > 0}
/>
);
}
render: (record: any) => (
<RowActions
actions={tableProps.rowActions?.(record) ?? []}
disabled={tableState.selectedRecords.length > 0}
/>
)
});
}
@ -373,14 +372,11 @@ export function InvenTreeTable<T = any>({
tableProps.noRecordsText ?? t`No records found`
);
let results = [];
let results = response.data?.results ?? response.data ?? [];
if (props.dataFormatter) {
// Custom data formatter provided
results = props.dataFormatter(response.data);
} else {
// Extract returned data (accounting for pagination) and ensure it is a list
results = response.data?.results ?? response.data ?? [];
results = props.dataFormatter(results);
}
if (!Array.isArray(results)) {
@ -611,6 +607,7 @@ export function InvenTreeTable<T = any>({
onSelectedRecordsChange={
tableProps.enableSelection ? onSelectedRecordsChange : undefined
}
rowExpansion={tableProps.rowExpansion}
fetching={isFetching}
noRecordsText={missingRecordsText}
records={data}

View File

@ -1,10 +1,14 @@
import { t } from '@lingui/macro';
import { Trans, t } from '@lingui/macro';
import { Alert, Badge, Text } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { getDetailUrl } from '../../functions/urls';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@ -22,6 +26,7 @@ import { RowDeleteAction, RowEditAction } from '../RowActions';
export default function PartTestTemplateTable({ partId }: { partId: number }) {
const table = useTable('part-test-template');
const user = useUserState();
const navigate = useNavigate();
const tableColumns: TableColumn[] = useMemo(() => {
return [
@ -30,6 +35,15 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
switchable: false,
sortable: true
},
{
accessor: 'results',
switchable: true,
sortable: true,
title: t`Results`,
render: (record: any) => {
return record.results || <Badge color="blue">{t`No Results`}</Badge>;
}
},
DescriptionColumn({
switchable: false
}),
@ -43,7 +57,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
accessor: 'requires_attachment'
})
];
}, []);
}, [partId]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
@ -58,6 +72,11 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
{
name: 'requires_attachment',
description: t`Show tests that require an attachment`
},
{
name: 'include_inherited',
label: t`Include Inherited`,
description: t`Show tests from inherited templates`
}
];
}, []);
@ -99,13 +118,27 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
url: ApiEndpoints.part_test_template_list,
pk: selectedTest,
title: t`Delete Test Template`,
preFormContent: (
<Alert color="red" title={t`This action cannot be reversed`}>
<Text>
<Trans>
Any tests results associated with this template will be deleted
</Trans>
</Text>
</Alert>
),
onFormSuccess: table.refreshTable
});
const rowActions = useCallback(
(record: any) => {
let can_edit = user.hasChangeRole(UserRoles.part);
let can_delete = user.hasDeleteRole(UserRoles.part);
const can_edit = user.hasChangeRole(UserRoles.part);
const can_delete = user.hasDeleteRole(UserRoles.part);
if (record.part != partId) {
// No actions, as this test is defined for a parent part
return [];
}
return [
RowEditAction({
@ -124,7 +157,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
})
];
},
[user]
[user, partId]
);
const tableActions = useMemo(() => {
@ -150,11 +183,18 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
columns={tableColumns}
props={{
params: {
part: partId
part: partId,
part_detail: true
},
tableFilters: tableFilters,
tableActions: tableActions,
rowActions: rowActions
rowActions: rowActions,
onRowClick: (row) => {
if (row.part && row.part != partId) {
// This test is defined for a different part
navigate(getDetailUrl(ModelType.part, row.part));
}
}
}}
/>
</>

View File

@ -67,8 +67,8 @@ export default function CurrencyTable() {
columns={columns}
props={{
tableActions: tableActions,
dataFormatter: (data) => {
let rates = data?.exchange_rates ?? {};
dataFormatter: (data: any) => {
let rates = data.exchange_rates ?? {};
return Object.entries(rates).map(([currency, rate]) => {
return {

View File

@ -338,7 +338,7 @@ export function StockItemTable({ params = {} }: { params?: any }) {
columns={tableColumns}
props={{
enableDownload: true,
enableSelection: true,
enableSelection: false,
tableFilters: tableFilters,
onRowClick: (record) =>
navigate(getDetailUrl(ModelType.stockitem, record.pk)),

View File

@ -0,0 +1,428 @@
import { t } from '@lingui/macro';
import { Badge, Group, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import {
IconCircleCheck,
IconCirclePlus,
IconInfoCircle
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { DataTable } from 'mantine-datatable';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { AttachmentLink } from '../../components/items/AttachmentLink';
import { PassFailButton } from '../../components/items/YesNoButton';
import { RenderUser } from '../../components/render/User';
import { renderDate } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { DescriptionColumn, NoteColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowActions, RowDeleteAction, RowEditAction } from '../RowActions';
export default function StockItemTestResultTable({
partId,
itemId
}: {
partId: number;
itemId: number;
}) {
const user = useUserState();
const table = useTable('stocktests');
// Fetch the test templates required for this stock item
const { data: testTemplates } = useQuery({
queryKey: ['stocktesttemplates', partId, itemId],
queryFn: async () => {
if (!partId) {
return [];
}
return api
.get(apiUrl(ApiEndpoints.part_test_template_list), {
params: {
part: partId,
include_inherited: true
}
})
.then((response) => response.data)
.catch((_error) => []);
}
});
useEffect(() => {
table.refreshTable();
}, [testTemplates]);
// Format the test results based on the returned data
const formatRecords = useCallback(
(records: any[]): any[] => {
// Construct a list of test templates
let results = testTemplates.map((template: any) => {
return {
...template,
templateId: template.pk,
results: []
};
});
// If any of the tests results point to templates which we do not have, add them in
records.forEach((record) => {
if (!results.find((r: any) => r.templateId == record.template)) {
results.push({
...record.template_detail,
templateId: record.template,
results: []
});
}
});
// Iterate through the returned records
// Note that the results are sorted by oldest first,
// to ensure that the most recent result is displayed "on top"
records
.sort((a: any, b: any) => {
return a.pk > b.pk ? 1 : -1;
})
.forEach((record) => {
// Find matching template
let idx = results.findIndex(
(r: any) => r.templateId == record.template
);
if (idx >= 0) {
results[idx] = {
...results[idx],
...record
};
results[idx].results.push(record);
}
});
return results;
},
[partId, itemId, testTemplates]
);
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'test',
title: t`Test`,
switchable: false,
sortable: true,
render: (record: any) => {
let required = record.required ?? record.template_detail?.required;
let installed =
record.stock_item != undefined && record.stock_item != itemId;
return (
<Group position="apart">
<Text italic={installed} fw={required && 700}>
{!record.templateId && '- '}
{record.test_name ?? record.template_detail?.test_name}
</Text>
<Group position="right">
{record.results && record.results.length > 1 && (
<Tooltip label={t`Test Results`}>
<Badge color="lightblue" variant="filled">
{record.results.length}
</Badge>
</Tooltip>
)}
{installed && (
<Tooltip label={t`Test result for installed stock item`}>
<IconInfoCircle size={16} color="blue" />
</Tooltip>
)}
</Group>
</Group>
);
}
},
{
accessor: 'result',
title: t`Result`,
switchable: false,
sortable: true,
render: (record: any) => {
if (record.result === undefined) {
return (
<Badge color="lightblue" variant="filled">{t`No Result`}</Badge>
);
} else {
return <PassFailButton value={record.result} />;
}
}
},
DescriptionColumn({
accessor: 'description'
}),
{
accessor: 'value',
title: t`Value`
},
{
accessor: 'attachment',
title: t`Attachment`,
render: (record: any) =>
record.attachment && <AttachmentLink attachment={record.attachment} />
},
NoteColumn(),
{
accessor: 'date',
sortable: true,
title: t`Date`,
render: (record: any) => {
return (
<Group position="apart">
{renderDate(record.date)}
{record.user_detail && (
<RenderUser instance={record.user_detail} />
)}
</Group>
);
}
}
];
}, [itemId]);
const resultFields: ApiFormFieldSet = useMemo(() => {
return {
template: {
filters: {
include_inherited: true,
part: partId
}
},
result: {},
value: {},
attachment: {},
notes: {},
stock_item: {
value: itemId,
hidden: true
}
};
}, [partId, itemId]);
const [selectedTemplate, setSelectedTemplate] = useState<number | undefined>(
undefined
);
const newTestModal = useCreateApiFormModal({
url: ApiEndpoints.stock_test_result_list,
fields: resultFields,
initialData: {
template: selectedTemplate,
result: true
},
title: t`Add Test Result`,
onFormSuccess: () => table.refreshTable(),
successMessage: t`Test result added`
});
const [selectedTest, setSelectedTest] = useState<number | undefined>(
undefined
);
const editTestModal = useEditApiFormModal({
url: ApiEndpoints.stock_test_result_list,
pk: selectedTest,
fields: resultFields,
title: t`Edit Test Result`,
onFormSuccess: () => table.refreshTable(),
successMessage: t`Test result updated`
});
const deleteTestModal = useDeleteApiFormModal({
url: ApiEndpoints.stock_test_result_list,
pk: selectedTest,
title: t`Delete Test Result`,
onFormSuccess: () => table.refreshTable(),
successMessage: t`Test result deleted`
});
const passTest = useCallback(
(templateId: number) => {
api
.post(apiUrl(ApiEndpoints.stock_test_result_list), {
template: templateId,
stock_item: itemId,
result: true
})
.then(() => {
table.refreshTable();
showNotification({
title: t`Test Passed`,
message: t`Test result has been recorded`,
color: 'green'
});
});
},
[itemId]
);
const rowActions = useCallback(
(record: any) => {
if (record.stock_item != undefined && record.stock_item != itemId) {
// Test results for other stock items cannot be edited
return [];
}
return [
{
title: t`Pass Test`,
color: 'green',
icon: <IconCircleCheck />,
hidden:
!record.templateId ||
record?.requires_attachment ||
record?.requires_value ||
record.result,
onClick: () => passTest(record.templateId)
},
{
title: t`Add`,
tooltip: t`Add Test Result`,
color: 'green',
icon: <IconCirclePlus />,
hidden: !user.hasAddRole(UserRoles.stock) || !record.templateId,
onClick: () => {
setSelectedTemplate(record.templateId);
newTestModal.open();
}
},
RowEditAction({
tooltip: t`Edit Test Result`,
hidden:
!user.hasChangeRole(UserRoles.stock) || !record.template_detail,
onClick: () => {
setSelectedTest(record.pk);
editTestModal.open();
}
}),
RowDeleteAction({
tooltip: t`Delete Test Result`,
hidden:
!user.hasDeleteRole(UserRoles.stock) || !record.template_detail,
onClick: () => {
setSelectedTest(record.pk);
deleteTestModal.open();
}
})
];
},
[user, itemId]
);
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'required',
label: t`Required`,
description: t`Show results for required tests`
},
{
name: 'include_installed',
label: t`Include Installed`,
description: t`Show results for installed stock items`
},
{
name: 'result',
label: t`Passed`,
description: t`Show only passed tests`
}
];
}, []);
const tableActions = useMemo(() => {
return [
<AddItemButton
tooltip={t`Add Test Result`}
onClick={() => {
setSelectedTemplate(undefined);
newTestModal.open();
}}
hidden={!user.hasAddRole(UserRoles.stock)}
/>
];
}, [user]);
// Row expansion controller
const rowExpansion: any = useMemo(() => {
const cols: any = [
...tableColumns,
{
accessor: 'actions',
title: ' ',
hidden: false,
switchable: false,
width: 50,
render: (record: any) => (
<RowActions actions={rowActions(record) ?? []} />
)
}
];
return {
allowMultiple: true,
content: ({ record }: { record: any }) => {
if (!record || !record.results || record.results.length < 2) {
return null;
}
const results = record?.results ?? [];
return (
<DataTable
key={record.pk}
noHeader
columns={cols}
records={results.slice(0, -1)}
/>
);
}
};
}, []);
return (
<>
{newTestModal.modal}
{editTestModal.modal}
{deleteTestModal.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.stock_test_result_list)}
tableState={table}
columns={tableColumns}
props={{
dataFormatter: formatRecords,
enablePagination: false,
tableActions: tableActions,
tableFilters: tableFilters,
rowActions: rowActions,
rowExpansion: rowExpansion,
params: {
stock_item: itemId,
user_detail: true,
attachment_detail: true,
template_detail: true
}
}}
/>
</>
);
}