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

Implement generic "parameter" table

This commit is contained in:
Oliver Walters
2025-11-24 09:20:10 +00:00
parent 3258cf268a
commit e9b5dde992
9 changed files with 316 additions and 274 deletions

View File

@@ -37,7 +37,11 @@ from data_exporter.mixins import DataExportViewMixin
from generic.states.api import urlpattern as generic_states_api_urls
from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
from InvenTree.filters import (
ORDER_FILTER,
SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS,
)
from InvenTree.helpers import inheritors, str2bool
from InvenTree.helpers_email import send_email
from InvenTree.mixins import (
@@ -837,6 +841,10 @@ class ParameterFilter(FilterSet):
model = common.models.Parameter
fields = ['model_type', 'model_id', 'template', 'updated_by']
enabled = rest_filters.BooleanFilter(
label='Template Enabled', field_name='template__enabled'
)
class ParameterMixin:
"""Mixin class for Parameter views."""
@@ -856,7 +864,7 @@ class ParameterList(
"""List API endpoint for Parameter objects."""
filterset_class = ParameterFilter
filter_backends = SEARCH_ORDER_FILTER
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['name', 'data', 'units', 'template', 'updated', 'updated_by']

View File

@@ -33,6 +33,11 @@ export const ModelInformationDict: ModelDict = {
admin_url: '/part/part/',
icon: 'part'
},
parametertemplate: {
label: () => t`Parameter Template`,
label_multiple: () => t`Parameter Templates`,
api_endpoint: ApiEndpoints.parameter_template_list
},
partparametertemplate: {
label: () => t`Part Parameter Template`,
label_multiple: () => t`Part Parameter Templates`,

View File

@@ -17,6 +17,7 @@ export enum ModelType {
buildline = 'buildline',
builditem = 'builditem',
company = 'company',
parametertemplate = 'parametertemplate',
purchaseorder = 'purchaseorder',
purchaseorderlineitem = 'purchaseorderlineitem',
salesorder = 'salesorder',

View File

@@ -2,6 +2,18 @@ import type { ReactNode } from 'react';
import { type InstanceRenderInterface, RenderInlineModel } from './Instance';
export function RenderParameterTemplate({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
suffix={instance.units}
/>
);
}
export function RenderProjectCode({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {

View File

@@ -36,6 +36,7 @@ import {
RenderContentType,
RenderError,
RenderImportSession,
RenderParameterTemplate,
RenderProjectCode,
RenderSelectionList
} from './Generic';
@@ -71,6 +72,7 @@ export const RendererLookup: ModelRendererDict = {
[ModelType.builditem]: RenderBuildItem,
[ModelType.company]: RenderCompany,
[ModelType.contact]: RenderContact,
[ModelType.parametertemplate]: RenderParameterTemplate,
[ModelType.manufacturerpart]: RenderManufacturerPart,
[ModelType.owner]: RenderOwner,
[ModelType.part]: RenderPart,

View File

@@ -1,12 +1,16 @@
import { IconUsers } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import type {
StatusCodeInterface,
StatusCodeListInterface
} from '../components/render/StatusRenderer';
import { useApi } from '../contexts/ApiContext';
import { useGlobalStatusState } from '../states/GlobalStatusState';
export function projectCodeFields(): ApiFormFieldSet {
@@ -91,3 +95,117 @@ export function extraLineItemFields(): ApiFormFieldSet {
link: {}
};
}
export function useParameterFields({
modelType,
modelId
}: {
modelType: ModelType;
modelId: number;
}): ApiFormFieldSet {
const api = useApi();
// Valid field choices
const [choices, setChoices] = useState<any[]>([]);
// Field type for "data" input
const [fieldType, setFieldType] = useState<'string' | 'boolean' | 'choice'>(
'string'
);
const [data, setData] = useState<string>('');
// Reset the field type and choices when the model changes
useEffect(() => {
setFieldType('string');
setChoices([]);
setData('');
}, [modelType, modelId]);
return useMemo(() => {
return {
model_type: {
hidden: true,
value: modelType
},
model_id: {
hidden: true,
value: modelId
},
template: {
filters: {
model_type: modelType,
enabled: true
},
onValueChange: (value: any, record: any) => {
// Adjust the type of the "data" field based on the selected template
if (record?.checkbox) {
// This is a "checkbox" field
setChoices([]);
setFieldType('boolean');
} else if (record?.choices) {
const _choices: string[] = record.choices.split(',');
if (_choices.length > 0) {
setChoices(
_choices.map((choice) => {
return {
display_name: choice.trim(),
value: choice.trim()
};
})
);
setFieldType('choice');
} else {
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');
}
}
},
data: {
value: data,
onValueChange: (value: any) => {
setData(value);
},
type: fieldType,
field_type: fieldType,
choices: fieldType === 'choice' ? choices : undefined,
default: fieldType === 'boolean' ? false : undefined,
adjustValue: (value: any) => {
// Coerce boolean value into a string (required by backend)
let v: string = value.toString().trim();
if (fieldType === 'boolean') {
if (v.toLowerCase() !== 'true') {
v = 'false';
}
}
return v;
}
},
note: {}
};
}, [data, modelType, fieldType, choices, modelId]);
}

View File

@@ -97,7 +97,7 @@ import { useUserState } from '../../states/UserState';
import { BomTable } from '../../tables/bom/BomTable';
import { UsedInTable } from '../../tables/bom/UsedInTable';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import { PartParameterTable } from '../../tables/part/PartParameterTable';
import { ParameterTable } from '../../tables/general/ParameterTable';
import PartPurchaseOrdersTable from '../../tables/part/PartPurchaseOrdersTable';
import PartTestResultTable from '../../tables/part/PartTestResultTable';
import PartTestTemplateTable from '../../tables/part/PartTestTemplateTable';
@@ -792,11 +792,10 @@ export default function PartDetail() {
name: 'parameters',
label: t`Parameters`,
icon: <IconList />,
content: (
<PartParameterTable
partId={id ?? -1}
partLocked={part?.locked == true}
/>
content: part?.pk ? (
<ParameterTable modelType={ModelType.part} modelId={part?.pk} />
) : (
<Skeleton />
)
},
{

View File

@@ -1,10 +1,34 @@
import { ApiEndpoints, type ModelType, apiUrl } from '@lib/index';
import {
AddItemButton,
ApiEndpoints,
type ModelType,
RowDeleteAction,
RowEditAction,
YesNoButton,
apiUrl,
formatDecimal
} from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import { useCallback, useMemo } from 'react';
import { t } from '@lingui/core/macro';
import { useCallback, useMemo, useState } from 'react';
import { useParameterFields } from '../../forms/CommonForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import {
DateColumn,
DescriptionColumn,
NoteColumn,
UserColumn
} from '../ColumnRenderers';
import { UserFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
/**
* Construct a table listing parameters
@@ -20,32 +44,159 @@ export function ParameterTable({
const user = useUserState();
const tableColumns: TableColumn[] = useMemo(() => {
// TODO
return [];
return [
{
accessor: 'template_detail.name',
switchable: false,
sortable: true,
ordering: 'name'
},
DescriptionColumn({
accessor: 'template_detail.description'
}),
{
accessor: 'data',
switchable: false,
sortable: true,
render: (record) => {
const template = record.template_detail;
if (template?.checkbox) {
return <YesNoButton value={record.data} />;
}
const extra: any[] = [];
if (
template.units &&
record.data_numeric &&
record.data_numeric != record.data
) {
const numeric = formatDecimal(record.data_numeric, { digits: 15 });
extra.push(`${numeric} [${template.units}]`);
}
return (
<TableHoverCard
value={record.data}
extra={extra}
title={t`Internal Units`}
/>
);
}
},
{
accessor: 'template_detail.units',
ordering: 'units',
sortable: true
},
NoteColumn({}),
DateColumn({
accessor: 'updated',
title: t`Last Updated`,
sortable: true,
switchable: true
}),
UserColumn({
accessor: 'updated_by_detail',
ordering: 'updated_by',
title: t`Updated By`
})
];
}, [user]);
const tableFilters: TableFilter[] = useMemo(() => {
// TODO
return [];
return [
{
name: 'enabled',
label: 'Enabled',
description: t`Show parameters for enabled templates`,
type: 'boolean'
},
UserFilter({
name: 'updated_by',
label: t`Updated By`,
description: t`Filter by user who last updated the parameter`
})
];
}, []);
const [selectedParameter, setSelectedParameter] = useState<any | undefined>(
undefined
);
const newParameter = useCreateApiFormModal({
url: ApiEndpoints.parameter_list,
title: t`Add Parameter`,
fields: useParameterFields({ modelType, modelId }),
table: table
});
const editParameter = useEditApiFormModal({
url: ApiEndpoints.parameter_list,
pk: selectedParameter?.pk,
title: t`Edit Parameter`,
fields: useParameterFields({ modelType, modelId }),
table: table
});
const deleteParameter = useDeleteApiFormModal({
url: ApiEndpoints.parameter_list,
pk: selectedParameter?.pk,
title: t`Delete Parameter`,
table: table
});
const tableActions = useMemo(() => {
// TODO
return [];
return [
<AddItemButton
key='add-parameter'
hidden={!user.hasAddPermission(modelType)}
onClick={() => {
setSelectedParameter(undefined);
newParameter.open();
}}
/>
];
}, [user]);
const rowActions = useCallback(() => {
return [];
}, [user]);
const rowActions = useCallback(
(record: any) => {
return [
RowEditAction({
tooltip: t`Edit Parameter`,
onClick: () => {
setSelectedParameter(record);
editParameter.open();
},
hidden: !user.hasChangePermission(modelType)
}),
RowDeleteAction({
tooltip: t`Delete Parameter`,
onClick: () => {
setSelectedParameter(record);
deleteParameter.open();
},
hidden: !user.hasDeletePermission(modelType)
})
];
},
[user]
);
return (
<>
{newParameter.modal}
{editParameter.modal}
{deleteParameter.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.parameter_list)}
tableState={table}
columns={tableColumns}
props={{
enableDownload: true,
enableBulkDelete: true,
enableSelection: true,
rowActions: rowActions,
tableActions: tableActions,
tableFilters: tableFilters,

View File

@@ -1,254 +0,0 @@
import { t } from '@lingui/core/macro';
import { Alert, Stack, Text } from '@mantine/core';
import { IconLock } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '@lib/components/AddItemButton';
import {
type RowAction,
RowDeleteAction,
RowEditAction
} from '@lib/components/RowActions';
import { YesNoButton } from '@lib/components/YesNoButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables';
import { formatDecimal } from '../../defaults/formatters';
import { usePartParameterFields } from '../../forms/PartForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import {
DateColumn,
DescriptionColumn,
NoteColumn,
PartColumn,
UserColumn
} from '../ColumnRenderers';
import { IncludeVariantsFilter, UserFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
/**
* Construct a table listing parameters for a given part
*/
export function PartParameterTable({
partId,
partLocked
}: Readonly<{
partId: any;
partLocked?: boolean;
}>) {
const table = useTable('part-parameters');
const user = useUserState();
const tableColumns: TableColumn[] = useMemo(() => {
return [
PartColumn({
part: 'part_detail'
}),
{
accessor: 'part_detail.IPN',
sortable: false,
switchable: true,
defaultVisible: false
},
{
accessor: 'template_detail.name',
switchable: false,
sortable: true,
ordering: 'name',
render: (record) => {
const variant = String(partId) != String(record.part);
return (
<Text style={{ fontStyle: variant ? 'italic' : 'inherit' }}>
{record.template_detail?.name}
</Text>
);
}
},
DescriptionColumn({
accessor: 'template_detail.description'
}),
{
accessor: 'data',
switchable: false,
sortable: true,
render: (record) => {
const template = record.template_detail;
if (template?.checkbox) {
return <YesNoButton value={record.data} />;
}
const extra: any[] = [];
if (
template.units &&
record.data_numeric &&
record.data_numeric != record.data
) {
const numeric = formatDecimal(record.data_numeric, { digits: 15 });
extra.push(`${numeric} [${template.units}]`);
}
return (
<TableHoverCard
value={record.data}
extra={extra}
title={t`Internal Units`}
/>
);
}
},
{
accessor: 'template_detail.units',
ordering: 'units',
sortable: true
},
NoteColumn({}),
DateColumn({
accessor: 'updated',
title: t`Last Updated`,
sortable: true,
switchable: true
}),
UserColumn({
accessor: 'updated_by_detail',
ordering: 'updated_by',
title: t`Updated By`
})
];
}, [partId]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
IncludeVariantsFilter(),
UserFilter({
name: 'updated_by',
label: t`Updated By`,
description: t`Filter by user who last updated the parameter`
})
];
}, []);
const partParameterFields: ApiFormFieldSet = usePartParameterFields({});
const newParameter = useCreateApiFormModal({
url: ApiEndpoints.part_parameter_list,
title: t`New Part Parameter`,
fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]),
focus: 'template',
initialData: {
part: partId
},
table: table
});
const [selectedParameter, setSelectedParameter] = useState<
number | undefined
>(undefined);
const editParameter = useEditApiFormModal({
url: ApiEndpoints.part_parameter_list,
pk: selectedParameter,
title: t`Edit Part Parameter`,
focus: 'data',
fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]),
table: table
});
const deleteParameter = useDeleteApiFormModal({
url: ApiEndpoints.part_parameter_list,
pk: selectedParameter,
title: t`Delete Part Parameter`,
table: table
});
// Callback for row actions
const rowActions = useCallback(
(record: any): RowAction[] => {
// Actions not allowed for "variant" rows
if (String(partId) != String(record.part)) {
return [];
}
return [
RowEditAction({
tooltip: t`Edit Part Parameter`,
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
onClick: () => {
setSelectedParameter(record.pk);
editParameter.open();
}
}),
RowDeleteAction({
tooltip: t`Delete Part Parameter`,
hidden: partLocked || !user.hasDeleteRole(UserRoles.part),
onClick: () => {
setSelectedParameter(record.pk);
deleteParameter.open();
}
})
];
},
[partId, partLocked, user]
);
// Custom table actions
const tableActions = useMemo(() => {
return [
<AddItemButton
key='add-parameter'
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
tooltip={t`Add parameter`}
onClick={() => newParameter.open()}
/>
];
}, [partLocked, user]);
return (
<>
{newParameter.modal}
{editParameter.modal}
{deleteParameter.modal}
<Stack gap='xs'>
{partLocked && (
<Alert
title={t`Part is Locked`}
color='orange'
icon={<IconLock />}
p='xs'
>
<Text>{t`Part parameters cannot be edited, as the part is locked`}</Text>
</Alert>
)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_parameter_list)}
tableState={table}
columns={tableColumns}
props={{
rowActions: rowActions,
enableDownload: true,
tableActions: tableActions,
tableFilters: tableFilters,
params: {
part: partId,
template_detail: true,
part_detail: true
}
}}
/>
</Stack>
</>
);
}