2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-15 07:48:51 +00:00

Selection lists updates (#11705)

* Add search capability to selection list entry endpoint

* Use API lookup for selection entries

* Add renderer func

* Allow API filtering

* Fetch selectionentry data related to the selected data item

* remove now unneeded entry

* add missing modelinfo

* fix ref

* add api bump

* Provide optional single fetch function to API forms

- Useful if we need to perform a custom API call for initial data

* django-admin support for SelectionList

* Docstring improvements

* Apply 'active' filter

* Tweak api version entry

* Playwright tests

* Tweak docs wording

* Fix incorrect docstring

* Adjust playwright tests

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2026-04-10 09:22:12 +10:00
committed by GitHub
parent 6701f4085d
commit 9965ebcfa1
22 changed files with 325 additions and 84 deletions

View File

@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 475
INVENTREE_API_VERSION = 476
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v476 -> 2026-04-09 : https://github.com/inventree/InvenTree/pull/11705
- Adds sorting / filtering / searching functionality to the SelectionListEntry API endpoint
v475 -> 2026-04-09 : https://github.com/inventree/InvenTree/pull/11702
- Adds "updated" and "updated_by" fields to the LabelTemplate and ReportTemplate API endpoints

View File

@@ -32,6 +32,24 @@ class ParameterAdmin(admin.ModelAdmin):
search_fields = ('template__name', 'data', 'note')
class SelectionListEntryInlineAdmin(admin.StackedInline):
"""Inline admin class for the SelectionListEntry model."""
model = common.models.SelectionListEntry
extra = 0
@admin.register(common.models.SelectionList)
class SelectionListAdmin(admin.ModelAdmin):
"""Admin interface for SelectionList objects."""
list_display = ('name', 'description')
search_fields = ('name', 'description')
list_filter = ('active', 'locked')
inlines = [SelectionListEntryInlineAdmin]
@admin.register(common.models.Attachment)
class AttachmentAdmin(admin.ModelAdmin):
"""Admin interface for Attachment objects."""

View File

@@ -1166,6 +1166,14 @@ class EntryMixin:
class SelectionEntryList(EntryMixin, ListCreateAPI):
"""List view for SelectionEntry objects."""
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['list', 'label', 'active']
search_fields = ['label', 'description']
filterset_fields = ['active', 'value', 'list']
class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI):
"""Detail view for a SelectionEntry object."""

View File

@@ -2320,11 +2320,39 @@ class SelectionList(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeMo
"""Return the API URL associated with the SelectionList model."""
return reverse('api-selectionlist-list')
def get_choices(self):
"""Return the choices for the selection list."""
choices = self.entries.filter(active=True)
def get_choices(self, active: Optional[bool] = True):
"""Return the choices for the selection list.
Arguments:
active: If specified, filter choices by active status
Returns:
List of choice values for this selection list
"""
choices = self.entries.all()
if active is not None:
choices = choices.filter(active=active)
return [c.value for c in choices]
def has_choice(self, value: str, active: Optional[bool] = None):
"""Check if the selection list has a particular choice.
Arguments:
value: The value to check for
active: If specified, filter choices by active status
Returns:
True if the choice exists in the selection list, False otherwise
"""
choices = self.entries.all()
if active is not None:
choices = choices.filter(active=active)
return choices.filter(value=value).exists()
class SelectionListEntry(models.Model):
"""Class which represents a single entry in a SelectionList.

View File

@@ -64,7 +64,7 @@ export enum ApiEndpoints {
content_type_list = 'contenttype/',
icons = 'icons/',
selectionlist_list = 'selection/',
selectionlist_detail = 'selection/:id/',
selectionentry_list = 'selection/:id/entry/',
// Barcode API endpoints
barcode = 'barcode/',

View File

@@ -287,6 +287,13 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.selectionlist_list,
icon: 'list_details'
},
selectionentry: {
label: () => t`Selection Entry`,
label_multiple: () => t`Selection Entries`,
url_overview: '/settings/admin/part-parameters',
api_endpoint: ApiEndpoints.selectionentry_list,
icon: 'list_details'
},
error: {
label: () => t`Error`,
label_multiple: () => t`Errors`,

View File

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

View File

@@ -68,6 +68,7 @@ export type ApiFormFieldHeader = {
* @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
* @param singleFetchFunction : Optional function to fetch a single value for this field (used for fetching the initial value when editing an existing object)
*/
export type ApiFormFieldType = {
label?: string;
@@ -126,6 +127,7 @@ export type ApiFormFieldType = {
addRow?: () => any;
headers?: ApiFormFieldHeader[];
depends_on?: string[];
singleFetchFunction?: (value: any) => Promise<any> | null;
};
export type ApiFormFieldSet = Record<string, ApiFormFieldType>;

View File

@@ -75,42 +75,55 @@ export function RelatedModelField({
const [value, setValue] = useState<string>('');
const [searchText] = useDebouncedValue(value, 250);
// Response to fetching a single instance
const fetchSingleCallback = useCallback(
(instance: any) => {
const pk_field = definition.pk_field ?? 'pk';
if (instance?.[pk_field]) {
// Convert the response into the format expected by the select field
const value = {
value: instance[pk_field],
data: instance
};
// Run custom callback for this field (if provided)
if (definition.onValueChange) {
definition.onValueChange(instance[pk_field], instance);
}
setInitialData(value);
dataRef.current = [value];
setPk(instance[pk_field]);
}
},
[definition.pk_field, definition.onValueChange, setInitialData, setPk]
);
// Fetch a single field by primary key, using the provided API filters
const fetchSingleField = useCallback(
(pk: number) => {
if (!definition?.api_url) {
return;
}
const params = definition?.filters ?? {};
const url = `${definition.api_url}${pk}/`;
api
.get(url, {
params: params
})
.then((response) => {
const pk_field = definition.pk_field ?? 'pk';
if (response.data?.[pk_field]) {
const value = {
value: response.data[pk_field],
data: response.data
};
// Run custom callback for this field (if provided)
if (definition.onValueChange) {
definition.onValueChange(response.data[pk_field], response.data);
}
setInitialData(value);
dataRef.current = [value];
setPk(response.data[pk_field]);
}
(pk: number | string) => {
if (definition.singleFetchFunction) {
definition.singleFetchFunction(pk)?.then((instance: any) => {
fetchSingleCallback(instance);
});
} else if (!!definition.api_url) {
const params = definition?.filters ?? {};
const url = `${definition.api_url}${pk}/`;
api.get(url, { params: params }).then((response) => {
const instance = response.data;
fetchSingleCallback(instance);
});
} else {
console.error(
`No API URL provided for related field ${fieldName}, cannot fetch data`
);
}
},
[
definition.api_url,
definition.filters,
definition.singleFetchFunction,
definition.onValueChange,
definition.pk_field,
setValue,

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import { Group, Text } from '@mantine/core';
import { type InstanceRenderInterface, RenderInlineModel } from './Instance';
export function RenderParameterTemplate({
@@ -8,8 +9,12 @@ export function RenderParameterTemplate({
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
suffix={instance.units}
suffix={
<Group gap='xs'>
<Text size='xs'>{instance.description}</Text>
{instance.units && <Text size='xs'>[{instance.units}]</Text>}
</Group>
}
/>
);
}
@@ -66,7 +71,20 @@ export function RenderSelectionList({
instance && (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
suffix={instance.description}
/>
)
);
}
export function RenderSelectionEntry({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return (
instance && (
<RenderInlineModel
primary={instance.label}
suffix={instance.description}
/>
)
);

View File

@@ -39,6 +39,7 @@ import {
RenderParameter,
RenderParameterTemplate,
RenderProjectCode,
RenderSelectionEntry,
RenderSelectionList
} from './Generic';
import {
@@ -95,6 +96,7 @@ export const RendererLookup: ModelRendererDict = {
[ModelType.pluginconfig]: RenderPlugin,
[ModelType.contenttype]: RenderContentType,
[ModelType.selectionlist]: RenderSelectionList,
[ModelType.selectionentry]: RenderSelectionEntry,
[ModelType.error]: RenderError
};

View File

@@ -1,8 +1,8 @@
import { IconUsers } from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
@@ -106,18 +106,45 @@ export function useParameterFields({
}): ApiFormFieldSet {
const api = useApi();
const [selectionListId, setSelectionListId] = useState<number | null>(null);
// Valid field choices
const [choices, setChoices] = useState<any[]>([]);
// Field type for "data" input
const [fieldType, setFieldType] = useState<'string' | 'boolean' | 'choice'>(
'string'
);
const [fieldType, setFieldType] = useState<
'string' | 'boolean' | 'choice' | 'related field'
>('string');
// Memoized value for the "data" field
const [data, setData] = useState<string>('');
const fetchSelectionEntry = useCallback(
(value: any) => {
if (!value || !selectionListId) {
return null;
}
return api
.get(apiUrl(ApiEndpoints.selectionentry_list, selectionListId), {
params: {
value: value
}
})
.then((response) => {
if (response.data && response.data.length == 1) {
return response.data[0];
} else {
return null;
}
});
},
[selectionListId]
);
// Reset the field type and choices when the model changes
useEffect(() => {
setSelectionListId(null);
setFieldType('string');
setChoices([]);
setData('');
@@ -139,6 +166,8 @@ export function useParameterFields({
enabled: true
},
onValueChange: (value: any, record: any) => {
setSelectionListId(record?.selectionlist || null);
// Adjust the type of the "data" field based on the selected template
if (record?.checkbox) {
// This is a "checkbox" field
@@ -162,36 +191,42 @@ export function useParameterFields({
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');
setFieldType('related field');
}
}
},
data: {
value: data,
onValueChange: (value: any) => {
setData(value);
onValueChange: (value: any, record: any) => {
if (fieldType === 'related field' && selectionListId) {
// For related fields, we need to store the selected primary key value (not the string representation)
setData(record?.value ?? value);
} else {
setData(value);
}
},
type: fieldType,
field_type: fieldType,
choices: fieldType === 'choice' ? choices : undefined,
default: fieldType === 'boolean' ? false : undefined,
pk_field:
fieldType === 'related field' && selectionListId
? 'value'
: undefined,
model:
fieldType === 'related field' && selectionListId
? ModelType.selectionentry
: undefined,
api_url:
fieldType === 'related field' && selectionListId
? apiUrl(ApiEndpoints.selectionentry_list, selectionListId)
: undefined,
filters:
fieldType === 'related field'
? {
active: true
}
: undefined,
adjustValue: (value: any) => {
// Coerce boolean value into a string (required by backend)
@@ -204,9 +239,10 @@ export function useParameterFields({
}
return v;
}
},
singleFetchFunction: fetchSelectionEntry
},
note: {}
};
}, [data, modelType, fieldType, choices, modelId]);
}, [data, modelType, fieldType, choices, modelId, selectionListId]);
}

View File

@@ -38,7 +38,11 @@ export default function ParameterTemplateTable() {
model_type: {},
choices: {},
checkbox: {},
selectionlist: {},
selectionlist: {
filters: {
active: true
}
},
enabled: {}
};
}, []);

View File

@@ -144,7 +144,7 @@ export default function ParametricDataTable({
refetchOnMount: true
});
/* Store filters against selected part parameters.
/* Store filters against selected parameters.
* These are stored in the format:
* {
* parameter_1: {

View File

@@ -667,7 +667,37 @@ test('Parts - Parameters by Category', async ({ browser }) => {
});
test('Parts - Parameters', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/69/parameters' });
const page = await doCachedLogin(browser, { url: 'part/915/parameters' });
// Edit parameter defined with a SelectionListEntry
const animalCell = await page.getByRole('cell', {
name: 'Animal',
exact: true
});
await clickOnRowMenu(animalCell);
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Check the data field, which should be populated with the options defined in the "Animal" parameter template
// If both values are present, we know that the correct SelectionListEntry has been loaded
await page
.getByText('Data *Parameter')
.getByText(/Armadillo/i)
.waitFor();
await page
.getByText('Data *Parameter')
.getByText(/A mammal known for/i)
.waitFor();
// Check for other values
await page
.getByRole('combobox', { name: 'related-field-data' })
.fill('horse');
await page.getByText('The offspring of a male donkey').waitFor();
await page.getByText('A small marine fish with a head').waitFor();
await page.getByText('Zebra').click();
// Close the edit dialog
await page.getByRole('button', { name: 'Cancel' }).click();
// check that "is polarized" parameter is not already present - if it is, delete it before proceeding with the rest of the test
await page

View File

@@ -82,6 +82,52 @@ test('Purchasing - Index', async ({ browser }) => {
.waitFor();
});
test('Purchasing - Parameters', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'purchasing/purchase-order/11/parameters'
});
// Create a new parameter against this purchase order
// We will use a "SelectionList" to choose the value here
await page
.getByRole('button', { name: 'action-menu-add-parameters' })
.click();
await page
.getByRole('menuitem', {
name: 'action-menu-add-parameters-create-parameter'
})
.click();
// Select the template
await page
.getByRole('combobox', { name: 'related-field-template' })
.fill('animal');
await page.getByRole('option', { name: 'Animal Select an animal' }).click();
// Select an animal
await page.getByRole('combobox', { name: 'related-field-data' }).fill('cat');
await page.getByRole('option', { name: 'Caracal' }).click();
// Add a note and submit the form
await page
.getByRole('textbox', { name: 'text-field-note' })
.fill('The caracal is an interesting beast');
await page.getByRole('button', { name: 'Submit' }).click();
// Let's edit this parameter to ensure the "edit" form works as expected
await clickOnRowMenu(await page.getByRole('cell', { name: 'Caracal' }));
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByRole('combobox', { name: 'related-field-data' }).fill('ox');
await page.getByRole('option', { name: 'Fennec Fox' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
// Finally, delete the parameter
await clickOnRowMenu(await page.getByRole('cell', { name: 'Fennec Fox' }));
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete', exact: true }).click();
});
test('Purchasing - Manufacturer Parts', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'purchasing/index/manufacturer-parts'

View File

@@ -402,12 +402,12 @@ test('Settings - Admin - Parameter', async ({ browser }) => {
await loadTab(page, 'Parameters', true);
await page.waitForTimeout(1000);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Clean old template data if exists
await page
.getByRole('cell', { name: 'my custom parameter' })
.getByRole('cell', { name: 'my custom parameter', exact: true })
.waitFor({ timeout: 500 })
.then(async (cell) => {
await page
@@ -420,11 +420,14 @@ test('Settings - Admin - Parameter', async ({ browser }) => {
})
.catch(() => {});
await page.getByRole('button', { name: 'Selection Lists' }).click();
// Allow time for the table to load
await page.waitForTimeout(1000);
await page.getByRole('button', { name: 'Selection Lists' }).click();
await page.waitForLoadState('networkidle');
// Check for expected entry
await page.getByRole('cell', { name: 'Animals', exact: true }).waitFor();
await page.getByText('Various animals and descriptions thereof').waitFor();
// Clean old list data if exists
await page
.getByRole('cell', { name: 'some list' })
@@ -445,6 +448,19 @@ test('Settings - Admin - Parameter', async ({ browser }) => {
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');
// Add an entry to the selection list
await page.getByRole('button', { name: 'action-button-add-new-row' }).click();
await page.getByRole('textbox', { name: 'text-field-value' }).fill('HW');
await page
.getByRole('textbox', { name: 'text-field-label' })
.fill('Hardwood');
await page
.getByRole('row', { name: 'boolean-field-active action-' })
.getByLabel('text-field-description')
.fill('Hardwood materials');
await page.getByRole('cell', { name: 'boolean-field-active' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('cell', { name: 'some list' }).waitFor();
@@ -494,9 +510,18 @@ test('Settings - Admin - Parameter', async ({ browser }) => {
.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');
// Finally, select value from the SelectionList data
await page.getByRole('combobox', { name: 'related-field-data' }).fill('wood');
await page
.getByRole('option', { name: 'Hardwood Hardwood materials' })
.click();
await page.getByRole('button', { name: 'Submit' }).click();
// Check for the expected value
await page.getByRole('cell', { name: 'HW', exact: true }).waitFor();
});
test('Settings - Admin - Unauthorized', async ({ browser }) => {