2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 12:05:53 +00:00

[Refactor] Custom states (#8438)

* Enhancements for "custom state" form

- More intuitive form actions

* Improve back-end validation

* Improve table rendering

* Fix lookup for useStatusCodes

* Fix status display for SockDetail page

* Fix SalesOrder status display

* Refactor get_custom_classes

- Add StatusCode.custom_values method

* Fix for status table filters

* Cleanup (and note to self)

* Include custom state values in specific API endpoints

* Add serializer class definition

* Use same serializer for AllStatusView

* Fix API to match existing frontend type StatusCodeListInterface

* Enable filtering by reference status type

* Add option to duplicate an existing custom state

* Improved validation for the InvenTreeCustomUserStateModel class

* Code cleanup

* Fix default value in StockOperationsRow

* Use custom status values in stock operations

* Allow custom values

* Fix migration

* Bump API version

* Fix filtering of stock items by "status"

* Enhance status filter for orders

* Fix status code rendering

* Build Order API filter

* Update playwright tests for build filters

* Additional playwright tests for stock table filters

* Add 'custom' attribute

* Fix unit tests

* Add custom state field validation

* Implement StatusCodeMixin for setting status code values

* Clear out 'custom key' if the base key does not match

* Updated playwright testing

* Remove timeout

* Refactor detail pages which display status

* Update old migrations - add field validator

* Remove dead code

* Simplify API query filtering

* Revert "Simplify API query filtering"

This reverts commit 06c858ae7c.

* Fix save method

* Unit test fixes

* Fix for ReturnOrderLineItem

* Reorganize code

* Adjust unit test
This commit is contained in:
Oliver
2024-12-29 08:45:23 +11:00
committed by GitHub
parent c582ca0afd
commit 964984ccac
42 changed files with 916 additions and 262 deletions

View File

@ -1,19 +1,22 @@
import { Badge, Center, type MantineSize } from '@mantine/core';
import { colorMap } from '../../defaults/backendMappings';
import { statusColorMap } from '../../defaults/backendMappings';
import type { ModelType } from '../../enums/ModelType';
import { resolveItem } from '../../functions/conversion';
import { useGlobalStatusState } from '../../states/StatusState';
interface StatusCodeInterface {
key: string;
export interface StatusCodeInterface {
key: number;
label: string;
name: string;
color: string;
}
export interface StatusCodeListInterface {
[key: string]: StatusCodeInterface;
status_class: string;
values: {
[key: string]: StatusCodeInterface;
};
}
interface RenderStatusLabelOptionsInterface {
@ -33,10 +36,10 @@ function renderStatusLabel(
let color = null;
// Find the entry which matches the provided key
for (const name in codes) {
const entry = codes[name];
for (const name in codes.values) {
const entry: StatusCodeInterface = codes.values[name];
if (entry.key == key) {
if (entry?.key == key) {
text = entry.label;
color = entry.color;
break;
@ -51,7 +54,7 @@ function renderStatusLabel(
// Fallbacks
if (color == null) color = 'default';
color = colorMap[color] || colorMap['default'];
color = statusColorMap[color] || statusColorMap['default'];
const size = options.size || 'xs';
if (!text) {
@ -65,7 +68,9 @@ function renderStatusLabel(
);
}
export function getStatusCodes(type: ModelType | string) {
export function getStatusCodes(
type: ModelType | string
): StatusCodeListInterface | null {
const statusCodeList = useGlobalStatusState.getState().status;
if (statusCodeList === undefined) {
@ -97,7 +102,7 @@ export function getStatusCodeName(
}
for (const name in statusCodes) {
const entry = statusCodes[name];
const entry: StatusCodeInterface = statusCodes.values[name];
if (entry.key == key) {
return entry.name;

View File

@ -20,7 +20,7 @@ export const statusCodeList: Record<string, ModelType> = {
/*
* Map the colors used in the backend to the colors used in the frontend
*/
export const colorMap: { [key: string]: string } = {
export const statusColorMap: { [key: string]: string } = {
dark: 'dark',
warning: 'yellow',
success: 'green',

View File

@ -1,6 +1,12 @@
import { IconUsers } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
import type {
StatusCodeInterface,
StatusCodeListInterface
} from '../components/render/StatusRenderer';
import { useGlobalStatusState } from '../states/StatusState';
export function projectCodeFields(): ApiFormFieldSet {
return {
@ -12,16 +18,51 @@ export function projectCodeFields(): ApiFormFieldSet {
};
}
export function customStateFields(): ApiFormFieldSet {
return {
key: {},
name: {},
label: {},
color: {},
logical_key: {},
model: {},
reference_status: {}
};
export function useCustomStateFields(): ApiFormFieldSet {
// Status codes
const statusCodes = useGlobalStatusState();
// Selected base status class
const [statusClass, setStatusClass] = useState<string>('');
// Construct a list of status options based on the selected status class
const statusOptions: any[] = useMemo(() => {
const options: any[] = [];
const valuesList = Object.values(statusCodes.status ?? {}).find(
(value: StatusCodeListInterface) => value.status_class === statusClass
);
Object.values(valuesList?.values ?? {}).forEach(
(value: StatusCodeInterface) => {
options.push({
value: value.key,
display_name: value.label
});
}
);
return options;
}, [statusCodes, statusClass]);
return useMemo(() => {
return {
reference_status: {
onValueChange(value) {
setStatusClass(value);
}
},
logical_key: {
field_type: 'choice',
choices: statusOptions
},
key: {},
name: {},
label: {},
color: {},
model: {}
};
}, [statusOptions]);
}
export function customUnitsFields(): ApiFormFieldSet {

View File

@ -482,7 +482,7 @@ function StockOperationsRow({
const [statusOpen, statusHandlers] = useDisclosure(false, {
onOpen: () => {
setStatus(record?.status || undefined);
setStatus(record?.status_custom_key || record?.status || undefined);
props.changeFn(props.idx, 'status', record?.status || undefined);
},
onClose: () => {

View File

@ -31,14 +31,18 @@ export default function useStatusCodes({
const statusCodeList = useGlobalStatusState.getState().status;
const codes = useMemo(() => {
const statusCodes = getStatusCodes(modelType) || {};
const statusCodes = getStatusCodes(modelType) || null;
const codesMap: Record<any, any> = {};
for (const name in statusCodes) {
codesMap[name] = statusCodes[name].key;
if (!statusCodes) {
return codesMap;
}
Object.keys(statusCodes.values).forEach((name) => {
codesMap[name] = statusCodes.values[name].key;
});
return codesMap;
}, [modelType, statusCodeList]);

View File

@ -107,6 +107,15 @@ export default function BuildDetail() {
label: t`Status`,
model: ModelType.build
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.build,
icon: 'status',
hidden:
!build.status_custom_key || build.status_custom_key == build.status
},
{
type: 'text',
name: 'reference',

View File

@ -144,6 +144,15 @@ export default function PurchaseOrderDetail() {
name: 'status',
label: t`Status`,
model: ModelType.purchaseorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.purchaseorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];

View File

@ -115,6 +115,15 @@ export default function ReturnOrderDetail() {
name: 'status',
label: t`Status`,
model: ModelType.returnorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.returnorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];

View File

@ -124,6 +124,15 @@ export default function SalesOrderDetail() {
name: 'status',
label: t`Status`,
model: ModelType.salesorder
},
{
type: 'status',
name: 'status_custom_key',
label: t`Custom Status`,
model: ModelType.salesorder,
icon: 'status',
hidden:
!order.status_custom_key || order.status_custom_key == order.status
}
];

View File

@ -133,11 +133,21 @@ export default function StockDetail() {
icon: 'part',
hidden: !part.IPN
},
{
name: 'status',
type: 'status',
label: t`Status`,
model: ModelType.stockitem
},
{
name: 'status_custom_key',
type: 'status',
label: t`Stock Status`,
model: ModelType.stockitem
label: t`Custom Status`,
model: ModelType.stockitem,
icon: 'status',
hidden:
!stockitem.status_custom_key ||
stockitem.status_custom_key == stockitem.status
},
{
type: 'text',
@ -845,11 +855,10 @@ export default function StockDetail() {
key='batch'
/>,
<StatusRenderer
status={stockitem.status_custom_key}
status={stockitem.status_custom_key || stockitem.status}
type={ModelType.stockitem}
options={{
size: 'lg',
hidden: !!stockitem.status_custom_key
size: 'lg'
}}
key='status'
/>,

View File

@ -9,7 +9,7 @@ import type { ModelType } from '../enums/ModelType';
import { apiUrl } from './ApiState';
import { useUserState } from './UserState';
type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
export type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
interface ServerStateProps {
status?: StatusLookup;
@ -35,8 +35,10 @@ export const useGlobalStatusState = create<ServerStateProps>()(
.then((response) => {
const newStatusLookup: StatusLookup = {} as StatusLookup;
for (const key in response.data) {
newStatusLookup[statusCodeList[key] || key] =
response.data[key].values;
newStatusLookup[statusCodeList[key] || key] = {
status_class: key,
values: response.data[key].values
};
}
set({ status: newStatusLookup });
})

View File

@ -1,7 +1,11 @@
import { t } from '@lingui/macro';
import type {
StatusCodeInterface,
StatusCodeListInterface
} from '../components/render/StatusRenderer';
import type { ModelType } from '../enums/ModelType';
import { useGlobalStatusState } from '../states/StatusState';
import { type StatusLookup, useGlobalStatusState } from '../states/StatusState';
/**
* Interface for the table filter choice
@ -71,17 +75,18 @@ export function StatusFilterOptions(
model: ModelType
): () => TableFilterChoice[] {
return () => {
const statusCodeList = useGlobalStatusState.getState().status;
const statusCodeList: StatusLookup | undefined =
useGlobalStatusState.getState().status;
if (!statusCodeList) {
return [];
}
const codes = statusCodeList[model];
const codes: StatusCodeListInterface | undefined = statusCodeList[model];
if (codes) {
return Object.keys(codes).map((key) => {
const entry = codes[key];
return Object.keys(codes.values).map((key) => {
const entry: StatusCodeInterface = codes.values[key];
return {
value: entry.key.toString(),
label: entry.label?.toString() ?? entry.key.toString()

View File

@ -1,10 +1,16 @@
import { t } from '@lingui/macro';
import { Badge } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import type {
StatusCodeInterface,
StatusCodeListInterface
} from '../../components/render/StatusRenderer';
import { statusColorMap } from '../../defaults/backendMappings';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
import { customStateFields } from '../../forms/CommonForms';
import { useCustomStateFields } from '../../forms/CommonForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@ -12,10 +18,17 @@ import {
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useGlobalStatusState } from '../../states/StatusState';
import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column';
import type { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
import {
type RowAction,
RowDeleteAction,
RowDuplicateAction,
RowEditAction
} from '../RowActions';
/**
* Table for displaying list of custom states
@ -23,12 +36,64 @@ import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
export default function CustomStateTable() {
const table = useTable('customstates');
const statusCodes = useGlobalStatusState();
// Find the associated logical state key
const getLogicalState = useCallback(
(group: string, key: number) => {
const valuesList = Object.values(statusCodes.status ?? {}).find(
(value: StatusCodeListInterface) => value.status_class === group
);
const value = Object.values(valuesList?.values ?? {}).find(
(value: StatusCodeInterface) => value.key === key
);
return value?.label ?? value?.name ?? '';
},
[statusCodes]
);
const user = useUserState();
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'reference_status',
label: t`Status Group`,
field_type: 'choice',
choices: Object.values(statusCodes.status ?? {}).map(
(value: StatusCodeListInterface) => ({
label: value.status_class,
value: value.status_class
})
)
}
];
}, [statusCodes]);
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'reference_status',
title: t`Status`,
sortable: true
},
{
accessor: 'logical_key',
title: t`Logical State`,
sortable: true,
render: (record: any) => {
const stateText = getLogicalState(
record.reference_status,
record.logical_key
);
return stateText ? stateText : record.logical_key;
}
},
{
accessor: 'name',
title: t`Identifier`,
sortable: true
},
{
@ -36,34 +101,45 @@ export default function CustomStateTable() {
title: t`Display Name`,
sortable: true
},
{
accessor: 'color'
},
{
accessor: 'key',
sortable: true
},
{
accessor: 'logical_key',
sortable: true
},
{
accessor: 'model_name',
title: t`Model`,
sortable: true
},
{
accessor: 'reference_status',
title: t`Status`,
sortable: true
accessor: 'color',
render: (record: any) => {
return (
<Badge
color={statusColorMap[record.color] || statusColorMap['default']}
variant='filled'
size='xs'
>
{record.color}
</Badge>
);
}
}
];
}, []);
}, [getLogicalState]);
const newCustomStateFields = useCustomStateFields();
const duplicateCustomStateFields = useCustomStateFields();
const editCustomStateFields = useCustomStateFields();
const [initialStateData, setInitialStateData] = useState<any>({});
const newCustomState = useCreateApiFormModal({
url: ApiEndpoints.custom_state_list,
title: t`Add State`,
fields: customStateFields(),
fields: newCustomStateFields,
table: table
});
const duplicateCustomState = useCreateApiFormModal({
url: ApiEndpoints.custom_state_list,
title: t`Add State`,
fields: duplicateCustomStateFields,
initialData: initialStateData,
table: table
});
@ -75,7 +151,7 @@ export default function CustomStateTable() {
url: ApiEndpoints.custom_state_list,
pk: selectedCustomState,
title: t`Edit State`,
fields: customStateFields(),
fields: editCustomStateFields,
table: table
});
@ -96,6 +172,13 @@ export default function CustomStateTable() {
editCustomState.open();
}
}),
RowDuplicateAction({
hidden: !user.hasAddRole(UserRoles.admin),
onClick: () => {
setInitialStateData(record);
duplicateCustomState.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.admin),
onClick: () => {
@ -112,7 +195,10 @@ export default function CustomStateTable() {
return [
<AddItemButton
key={'add'}
onClick={() => newCustomState.open()}
onClick={() => {
setInitialStateData({});
newCustomState.open();
}}
tooltip={t`Add State`}
/>
];
@ -122,6 +208,7 @@ export default function CustomStateTable() {
<>
{newCustomState.modal}
{editCustomState.modal}
{duplicateCustomState.modal}
{deleteCustomState.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.custom_state_list)}
@ -130,6 +217,7 @@ export default function CustomStateTable() {
props={{
rowActions: rowActions,
tableActions: tableActions,
tableFilters: tableFilters,
enableDownload: true
}}
/>

View File

@ -34,7 +34,7 @@ export const clickButtonIfVisible = async (page, name, timeout = 500) => {
export const clearTableFilters = async (page) => {
await openFilterDrawer(page);
await clickButtonIfVisible(page, 'Clear Filters');
await page.getByLabel('filter-drawer-close').click();
await closeFilterDrawer(page);
};
export const setTableChoiceFilter = async (page, filter, value) => {
@ -42,7 +42,9 @@ export const setTableChoiceFilter = async (page, filter, value) => {
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByPlaceholder('Select filter').fill(filter);
await page.getByRole('option', { name: 'Status' }).click();
await page.getByPlaceholder('Select filter').click();
await page.getByRole('option', { name: filter }).click();
await page.getByPlaceholder('Select filter value').click();
await page.getByRole('option', { name: value }).click();

View File

@ -1,9 +1,9 @@
import { test } from '../baseFixtures.ts';
import { baseUrl } from '../defaults.ts';
import {
clickButtonIfVisible,
clearTableFilters,
getRowFromCell,
openFilterDrawer
setTableChoiceFilter
} from '../helpers.ts';
import { doQuickLogin } from '../login.ts';
@ -266,6 +266,24 @@ test('Build Order - Filters', async ({ page }) => {
await page.goto(`${baseUrl}/manufacturing/index/buildorders`);
await openFilterDrawer(page);
await clickButtonIfVisible(page, 'Clear Filters');
await clearTableFilters(page);
await page.getByText('1 - 24 / 24').waitFor();
// Toggle 'Outstanding' filter
await setTableChoiceFilter(page, 'Outstanding', 'Yes');
await page.getByText('1 - 18 / 18').waitFor();
await clearTableFilters(page);
await setTableChoiceFilter(page, 'Outstanding', 'No');
await page.getByText('1 - 6 / 6').waitFor();
await clearTableFilters(page);
// Filter by custom status code
await setTableChoiceFilter(page, 'Status', 'Pending Approval');
// Single result - navigate through to the build order
await page.getByText('1 - 1 / 1').waitFor();
await page.getByRole('cell', { name: 'BO0023' }).click();
await page.getByText('On Hold').first().waitFor();
await page.getByText('Pending Approval').first().waitFor();
});

View File

@ -1,6 +1,11 @@
import { test } from '../baseFixtures.js';
import { baseUrl } from '../defaults.js';
import { clickButtonIfVisible, openFilterDrawer } from '../helpers.js';
import {
clearTableFilters,
clickButtonIfVisible,
openFilterDrawer,
setTableChoiceFilter
} from '../helpers.js';
import { doQuickLogin } from '../login.js';
test('Stock - Basic Tests', async ({ page }) => {
@ -84,9 +89,15 @@ test('Stock - Filters', async ({ page }) => {
.getByRole('cell', { name: 'A round table - with blue paint' })
.waitFor();
// Clear filters (ready for next set of tests)
await openFilterDrawer(page);
await clickButtonIfVisible(page, 'Clear Filters');
// Filter by custom status code
await clearTableFilters(page);
await setTableChoiceFilter(page, 'Status', 'Incoming goods inspection');
await page.getByText('1 - 8 / 8').waitFor();
await page.getByRole('cell', { name: '1551AGY' }).first().waitFor();
await page.getByRole('cell', { name: 'widget.blue' }).first().waitFor();
await page.getByRole('cell', { name: '002.01-PCBA' }).first().waitFor();
await clearTableFilters(page);
});
test('Stock - Serial Numbers', async ({ page }) => {
@ -158,47 +169,58 @@ test('Stock - Serial Numbers', async ({ page }) => {
test('Stock - Stock Actions', async ({ page }) => {
await doQuickLogin(page);
// Find an in-stock, untracked item
await page.goto(
`${baseUrl}/stock/location/index/stock-items?in_stock=1&serialized=0`
);
await page.getByText('530470210').first().click();
await page.goto(`${baseUrl}/stock/item/1225/details`);
// Helper function to launch a stock action
const launchStockAction = async (action: string) => {
await page.getByLabel('action-menu-stock-operations').click();
await page.getByLabel(`action-menu-stock-operations-${action}`).click();
};
const setStockStatus = async (status: string) => {
await page.getByLabel('action-button-change-status').click();
await page.getByLabel('choice-field-status').click();
await page.getByRole('option', { name: status }).click();
};
// Check for required values
await page.getByText('Status', { exact: true }).waitFor();
await page.getByText('Custom Status', { exact: true }).waitFor();
await page.getByText('Attention needed').waitFor();
await page
.locator('div')
.filter({ hasText: /^Quantity: 270$/ })
.first()
.getByLabel('Stock Details')
.getByText('Incoming goods inspection')
.waitFor();
await page.getByText('123').first().waitFor();
// Check for expected action sections
await page.getByLabel('action-menu-barcode-actions').click();
await page.getByLabel('action-menu-barcode-actions-link-barcode').click();
await page.getByRole('banner').getByRole('button').click();
await page.getByLabel('action-menu-printing-actions').click();
await page.getByLabel('action-menu-printing-actions-print-labels').click();
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByLabel('action-menu-stock-operations').click();
await page.getByLabel('action-menu-stock-operations-count').waitFor();
await page.getByLabel('action-menu-stock-operations-add').waitFor();
await page.getByLabel('action-menu-stock-operations-remove').waitFor();
await page.getByLabel('action-menu-stock-operations-transfer').click();
await page.getByLabel('text-field-notes').fill('test notes');
// Add stock, and change status
await launchStockAction('add');
await page.getByLabel('number-field-quantity').fill('12');
await setStockStatus('Lost');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('This field is required.').first().waitFor();
// Set the status field
await page.getByLabel('action-button-change-status').click();
await page.getByLabel('choice-field-status').click();
await page.getByText('Attention needed').click();
await page.getByText('Lost').first().waitFor();
await page.getByText('Unavailable').first().waitFor();
await page.getByText('135').first().waitFor();
// Set the packaging field
await page.getByLabel('action-button-adjust-packaging').click();
await page.getByLabel('text-field-packaging').fill('test packaging');
// Remove stock, and change status
await launchStockAction('remove');
await page.getByLabel('number-field-quantity').fill('99');
await setStockStatus('Damaged');
await page.getByRole('button', { name: 'Submit' }).click();
// Close the dialog
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByText('36').first().waitFor();
await page.getByText('Damaged').first().waitFor();
// Count stock and change status (reverting to original value)
await launchStockAction('count');
await page.getByLabel('number-field-quantity').fill('123');
await setStockStatus('Incoming goods inspection');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('123').first().waitFor();
await page.getByText('Custom Status').first().waitFor();
await page.getByText('Incoming goods inspection').first().waitFor();
// Find an item which has been sent to a customer
await page.goto(`${baseUrl}/stock/item/1014/details`);
@ -220,7 +242,4 @@ test('Stock - Tracking', async ({ page }) => {
await page.getByText('- - Factory/Office Block/Room').first().waitFor();
await page.getByRole('link', { name: 'Widget Assembly' }).waitFor();
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
await page.waitForTimeout(1500);
return;
});

View File

@ -153,7 +153,7 @@ test('Settings - Admin - Barcode History', async ({ page, request }) => {
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
await page.getByRole('tab', { name: 'Barcode Scans' }).click();
await page.waitForTimeout(2000);
await page.waitForTimeout(500);
// Barcode history is displayed in table
barcodes.forEach(async (barcode) => {