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

Add SelectionList concept (#8054)

* Add SelectionList model, APIs and simple tests

* Add managment entries

* Add field to serializer

* add more tests for parameters

* Add support for SelectionList to CUI

* Add selection option to PUI

* fix display

* add PUI admin entries

* remove get_api_url

* fix modeldict

* Add models for meta

* Add test for inactive lists

* Add locking and testing for locking

* ignore unneeded section

* Add PUI testing for adding parameter

* Add selectionList admin

* also allow creating entries

* extend tests

* force click

* and more testing

* adapt test?

* more assurance?

* make test more robust

* more retries but shorter runs

* Update playwright.config.ts

* Add docs

* Add note regarding administration

* Adapt to https://github.com/inventree/InvenTree/pull/8093

* make help text more descriptive

* fix migration

* remove unneeded UI entries

* add lables and describtions to TableFields

* factor out selectionList forms

* add key to button

* cleanup imports

* add editable fields

* Add function to add row

* fix render warning

* remove dead parameter

* fix migrations

* fix migrations

* fix format

* autofix

* fix migrations

* fix create / update loop

* fix addition of empty lists

* extend tests

* adjust changelog entry

* fix updating loop

* update test name

* merge migrations

* simplify request

* - Add entry count to list
- Move parameter table to default accordion

* fix test

* fix test clearing section

---------

Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
Matthias Mair
2024-11-27 03:30:39 +01:00
committed by GitHub
parent 20fb1250f8
commit af39189e7e
28 changed files with 1278 additions and 11 deletions

View File

@ -5,8 +5,8 @@ export default defineConfig({
fullyParallel: true,
timeout: 90000,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 3 : undefined,
reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
/* Configure projects for major browsers */

View File

@ -49,6 +49,7 @@ export type ApiFormAdjustFilterType = {
* @param onValueChange : Callback function to call when the field value changes
* @param adjustFilters : Callback function to adjust the filters for a related field before a query is made
* @param adjustValue : Callback function to adjust the value of the field before it is sent to the API
* @param addRow : Callback function to add a new row to a table field
* @param onKeyDown : Callback function to get which key was pressed in the form to handle submission on enter
*/
export type ApiFormFieldType = {
@ -94,6 +95,7 @@ export type ApiFormFieldType = {
adjustValue?: (value: any) => any;
onValueChange?: (value: any, record?: any) => void;
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
addRow?: () => any;
headers?: string[];
depends_on?: string[];
};

View File

@ -6,6 +6,7 @@ import type { FieldValues, UseControllerReturn } from 'react-hook-form';
import { identifierString } from '../../../functions/conversion';
import { InvenTreeIcon } from '../../../functions/icons';
import { AddItemButton } from '../../buttons/AddItemButton';
import { StandaloneField } from '../StandaloneField';
import type { ApiFormFieldType } from './ApiFormField';
@ -109,6 +110,17 @@ export function TableField({
field.onChange(val);
};
const fieldDefinition = useMemo(() => {
return {
...definition,
modelRenderer: undefined,
onValueChange: undefined,
adjustFilters: undefined,
read_only: undefined,
addRow: undefined
};
}, [definition]);
// Extract errors associated with the current row
const rowErrors: any = useCallback(
(idx: number) => {
@ -134,6 +146,7 @@ export function TableField({
})}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{value.length > 0 ? (
value.map((item: any, idx: number) => {
@ -170,6 +183,26 @@ export function TableField({
</Table.Tr>
)}
</Table.Tbody>
{definition.addRow && (
<Table.Tfoot>
<Table.Tr>
<Table.Td colSpan={definition.headers?.length}>
<AddItemButton
tooltip={t`Add new row`}
onClick={() => {
if (definition.addRow === undefined) return;
const ret = definition.addRow();
if (ret) {
const val = field.value;
val.push(ret);
field.onChange(val);
}
}}
/>
</Table.Td>
</Table.Tr>
</Table.Tfoot>
)}
</Table>
);
}

View File

@ -34,3 +34,16 @@ export function RenderImportSession({
}): ReactNode {
return instance && <RenderInlineModel primary={instance.data_file} />;
}
export function RenderSelectionList({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return (
instance && (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
/>
)
);
}

View File

@ -20,7 +20,8 @@ import {
RenderContentType,
RenderError,
RenderImportSession,
RenderProjectCode
RenderProjectCode,
RenderSelectionList
} from './Generic';
import { ModelInformationDict } from './ModelType';
import {
@ -94,6 +95,7 @@ const RendererLookup: EnumDictionary<
[ModelType.labeltemplate]: RenderLabelTemplate,
[ModelType.pluginconfig]: RenderPlugin,
[ModelType.contenttype]: RenderContentType,
[ModelType.selectionlist]: RenderSelectionList,
[ModelType.error]: RenderError
};

View File

@ -286,6 +286,12 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.content_type_list,
icon: 'list_details'
},
selectionlist: {
label: () => t`Selection List`,
label_multiple: () => t`Selection Lists`,
api_endpoint: ApiEndpoints.selectionlist_list,
icon: 'list_details'
},
error: {
label: () => t`Error`,
label_multiple: () => t`Errors`,

View File

@ -49,6 +49,8 @@ export enum ApiEndpoints {
owner_list = 'user/owner/',
content_type_list = 'contenttype/',
icons = 'icons/',
selectionlist_list = 'selection/',
selectionlist_detail = 'selection/:id/',
// Barcode API endpoints
barcode = 'barcode/',

View File

@ -33,5 +33,6 @@ export enum ModelType {
labeltemplate = 'labeltemplate',
pluginconfig = 'pluginconfig',
contenttype = 'contenttype',
selectionlist = 'selectionlist',
error = 'error'
}

View File

@ -2,7 +2,10 @@ import { t } from '@lingui/macro';
import { IconPackages } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { api } from '../App';
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
/**
@ -204,7 +207,7 @@ export function usePartParameterFields({
setChoices(
_choices.map((choice) => {
return {
label: choice.trim(),
display_name: choice.trim(),
value: choice.trim()
};
})
@ -214,6 +217,22 @@ export function usePartParameterFields({
setChoices([]);
setFieldType('string');
}
} else if (record?.selectionlist) {
api
.get(
apiUrl(ApiEndpoints.selectionlist_detail, record.selectionlist)
)
.then((res) => {
setChoices(
res.data.choices.map((item: any) => {
return {
value: item.value,
display_name: item.label
};
})
);
setFieldType('choice');
});
} else {
setChoices([]);
setFieldType('string');

View File

@ -0,0 +1,117 @@
import { t } from '@lingui/macro';
import { Table } from '@mantine/core';
import { useMemo } from 'react';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import type {
ApiFormFieldSet,
ApiFormFieldType
} from '../components/forms/fields/ApiFormField';
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
function BuildAllocateLineRow({
props
}: Readonly<{
props: TableFieldRowProps;
}>) {
const valueField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'string',
name: 'value',
required: true,
value: props.item.value,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'value', value);
}
};
}, [props]);
const labelField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'string',
name: 'label',
required: true,
value: props.item.label,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'label', value);
}
};
}, [props]);
const descriptionField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'string',
name: 'description',
required: true,
value: props.item.description,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'description', value);
}
};
}, [props]);
const activeField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'boolean',
name: 'active',
required: true,
value: props.item.active,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'active', value);
}
};
}, [props]);
return (
<Table.Tr key={`table-row-${props.item.pk}`}>
<Table.Td>
<StandaloneField fieldName='value' fieldDefinition={valueField} />
</Table.Td>
<Table.Td>
<StandaloneField fieldName='label' fieldDefinition={labelField} />
</Table.Td>
<Table.Td>
<StandaloneField
fieldName='description'
fieldDefinition={descriptionField}
/>
</Table.Td>
<Table.Td>
<StandaloneField fieldName='active' fieldDefinition={activeField} />
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
</Table.Td>
</Table.Tr>
);
}
export function selectionListFields(): ApiFormFieldSet {
return {
name: {},
description: {},
active: {},
locked: {},
source_plugin: {},
source_string: {},
choices: {
label: t`Entries`,
description: t`List of entries to choose from`,
field_type: 'table',
value: [],
headers: [t`Value`, t`Label`, t`Description`, t`Active`],
modelRenderer: (row: TableFieldRowProps) => (
<BuildAllocateLineRow props={row} />
),
addRow: () => {
return {
value: '',
label: '',
description: '',
active: true
};
}
}
};
}

View File

@ -66,6 +66,8 @@ const MachineManagementPanel = Loadable(
lazy(() => import('./MachineManagementPanel'))
);
const PartParameterPanel = Loadable(lazy(() => import('./PartParameterPanel')));
const ErrorReportTable = Loadable(
lazy(() => import('../../../../tables/settings/ErrorTable'))
);
@ -86,6 +88,10 @@ const CustomStateTable = Loadable(
lazy(() => import('../../../../tables/settings/CustomStateTable'))
);
const CustomUnitsTable = Loadable(
lazy(() => import('../../../../tables/settings/CustomUnitsTable'))
);
const PartParameterTemplateTable = Loadable(
lazy(() => import('../../../../tables/part/PartParameterTemplateTable'))
);
@ -169,7 +175,7 @@ export default function AdminCenter() {
name: 'part-parameters',
label: t`Part Parameters`,
icon: <IconList />,
content: <PartParameterTemplateTable />
content: <PartParameterPanel />
},
{
name: 'category-parameters',

View File

@ -0,0 +1,29 @@
import { t } from '@lingui/macro';
import { Accordion } from '@mantine/core';
import { StylishText } from '../../../../components/items/StylishText';
import PartParameterTemplateTable from '../../../../tables/part/PartParameterTemplateTable';
import SelectionListTable from '../../../../tables/part/SelectionListTable';
export default function PartParameterPanel() {
return (
<Accordion defaultValue='parametertemplate'>
<Accordion.Item value='parametertemplate' key='parametertemplate'>
<Accordion.Control>
<StylishText size='lg'>{t`Part Parameter Template`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<PartParameterTemplateTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='selectionlist' key='selectionlist'>
<Accordion.Control>
<StylishText size='lg'>{t`Selection Lists`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<SelectionListTable />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View File

@ -76,7 +76,8 @@ export default function PartParameterTemplateTable() {
description: {},
units: {},
choices: {},
checkbox: {}
checkbox: {},
selectionlist: {}
};
}, []);

View File

@ -0,0 +1,134 @@
import { t } from '@lingui/macro';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
import { selectionListFields } from '../../forms/selectionListFields';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
/**
* Table for displaying list of selectionlist items
*/
export default function SelectionListTable() {
const table = useTable('selectionlist');
const user = useUserState();
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'name',
sortable: true
},
{
accessor: 'description',
sortable: true
},
BooleanColumn({
accessor: 'active'
}),
BooleanColumn({
accessor: 'locked'
}),
{
accessor: 'source_plugin',
sortable: true
},
{
accessor: 'source_string',
sortable: true
},
{
accessor: 'entry_count'
}
];
}, []);
const newSelectionList = useCreateApiFormModal({
url: ApiEndpoints.selectionlist_list,
title: t`Add Selection List`,
fields: selectionListFields(),
table: table
});
const [selectedSelectionList, setSelectedSelectionList] = useState<
number | undefined
>(undefined);
const editSelectionList = useEditApiFormModal({
url: ApiEndpoints.selectionlist_list,
pk: selectedSelectionList,
title: t`Edit Selection List`,
fields: selectionListFields(),
table: table
});
const deleteSelectionList = useDeleteApiFormModal({
url: ApiEndpoints.selectionlist_list,
pk: selectedSelectionList,
title: t`Delete Selection List`,
table: table
});
const rowActions = useCallback(
(record: any): RowAction[] => {
return [
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.admin),
onClick: () => {
setSelectedSelectionList(record.pk);
editSelectionList.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.admin),
onClick: () => {
setSelectedSelectionList(record.pk);
deleteSelectionList.open();
}
})
];
},
[user]
);
const tableActions = useMemo(() => {
return [
<AddItemButton
key='add-selection-list'
onClick={() => newSelectionList.open()}
tooltip={t`Add Selection List`}
/>
];
}, []);
return (
<>
{newSelectionList.modal}
{editSelectionList.modal}
{deleteSelectionList.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.selectionlist_list)}
tableState={table}
columns={columns}
props={{
rowActions: rowActions,
tableActions: tableActions,
enableDownload: true
}}
/>
</>
);
}

View File

@ -0,0 +1,100 @@
import { test } from '../baseFixtures';
import { baseUrl } from '../defaults';
import { doQuickLogin } from '../login';
test('PUI - Admin - Parameter', async ({ page }) => {
await doQuickLogin(page, 'admin', 'inventree');
await page.getByRole('button', { name: 'admin' }).click();
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
await page.getByRole('tab', { name: 'Part Parameters' }).click();
await page.getByRole('button', { name: 'Selection Lists' }).click();
await page.waitForLoadState('networkidle');
// clean old data if exists
await page
.getByRole('cell', { name: 'some list' })
.waitFor({ timeout: 200 })
.then(async (cell) => {
await page
.getByRole('cell', { name: 'some list' })
.locator('..')
.getByLabel('row-action-menu-')
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
})
.catch(() => {});
// clean old data if exists
await page.getByRole('button', { name: 'Part Parameter Template' }).click();
await page.waitForLoadState('networkidle');
await page
.getByRole('cell', { name: 'my custom parameter' })
.waitFor({ timeout: 200 })
.then(async (cell) => {
await page
.getByRole('cell', { name: 'my custom parameter' })
.locator('..')
.getByLabel('row-action-menu-')
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
})
.catch(() => {});
// Add selection list
await page.getByRole('button', { name: 'Selection Lists' }).click();
await page.waitForLoadState('networkidle');
await page.getByLabel('action-button-add-selection-').waitFor();
await page.getByLabel('action-button-add-selection-').click();
await page.getByLabel('text-field-name').fill('some list');
await page.getByLabel('text-field-description').fill('Listdescription');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('cell', { name: 'some list' }).waitFor();
await page.waitForTimeout(200);
// Add parameter
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'Part Parameter Template' }).click();
await page.getByLabel('action-button-add-parameter').waitFor();
await page.getByLabel('action-button-add-parameter').click();
await page.getByLabel('text-field-name').fill('my custom parameter');
await page.getByLabel('text-field-description').fill('description');
await page
.locator('div')
.filter({ hasText: /^Search\.\.\.$/ })
.nth(2)
.click();
await page
.getByRole('option', { name: 'some list' })
.locator('div')
.first()
.click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('cell', { name: 'my custom parameter' }).click();
// Fill parameter
await page.goto(`${baseUrl}/part/104/parameters/`);
await page.getByLabel('Parameters').getByText('Parameters').waitFor();
await page.waitForLoadState('networkidle');
await page.getByLabel('action-button-add-parameter').waitFor();
await page.getByLabel('action-button-add-parameter').click();
await page.waitForTimeout(200);
await page.getByText('New Part Parameter').waitFor();
await page
.getByText('Template *Parameter')
.locator('div')
.filter({ hasText: /^Search\.\.\.$/ })
.nth(2)
.click();
await page
.getByText('Template *Parameter')
.locator('div')
.filter({ hasText: /^Search\.\.\.$/ })
.locator('input')
.fill('my custom parameter');
await page.getByRole('option', { name: 'my custom parameter' }).click();
await page.getByLabel('choice-field-data').fill('2');
await page.getByRole('button', { name: 'Submit' }).click();
});