From e9b5dde9920371d6602697b491b4006fee602c94 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 24 Nov 2025 09:20:10 +0000 Subject: [PATCH] Implement generic "parameter" table --- src/backend/InvenTree/common/api.py | 12 +- src/frontend/lib/enums/ModelInformation.tsx | 5 + src/frontend/lib/enums/ModelType.tsx | 1 + .../src/components/render/Generic.tsx | 12 + .../src/components/render/Instance.tsx | 2 + src/frontend/src/forms/CommonForms.tsx | 120 ++++++++- src/frontend/src/pages/part/PartDetail.tsx | 11 +- .../src/tables/general/ParameterTable.tsx | 173 +++++++++++- .../src/tables/part/PartParameterTable.tsx | 254 ------------------ 9 files changed, 316 insertions(+), 274 deletions(-) delete mode 100644 src/frontend/src/tables/part/PartParameterTable.tsx diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 9263b10582..cc4d3164df 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -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'] diff --git a/src/frontend/lib/enums/ModelInformation.tsx b/src/frontend/lib/enums/ModelInformation.tsx index ba73e5ddf3..30bb2185aa 100644 --- a/src/frontend/lib/enums/ModelInformation.tsx +++ b/src/frontend/lib/enums/ModelInformation.tsx @@ -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`, diff --git a/src/frontend/lib/enums/ModelType.tsx b/src/frontend/lib/enums/ModelType.tsx index 84bf7ca60b..c3ea04893d 100644 --- a/src/frontend/lib/enums/ModelType.tsx +++ b/src/frontend/lib/enums/ModelType.tsx @@ -17,6 +17,7 @@ export enum ModelType { buildline = 'buildline', builditem = 'builditem', company = 'company', + parametertemplate = 'parametertemplate', purchaseorder = 'purchaseorder', purchaseorderlineitem = 'purchaseorderlineitem', salesorder = 'salesorder', diff --git a/src/frontend/src/components/render/Generic.tsx b/src/frontend/src/components/render/Generic.tsx index b828c8b00a..e9d9942d1a 100644 --- a/src/frontend/src/components/render/Generic.tsx +++ b/src/frontend/src/components/render/Generic.tsx @@ -2,6 +2,18 @@ import type { ReactNode } from 'react'; import { type InstanceRenderInterface, RenderInlineModel } from './Instance'; +export function RenderParameterTemplate({ + instance +}: Readonly): ReactNode { + return ( + + ); +} + export function RenderProjectCode({ instance }: Readonly): ReactNode { diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index 09a7c33e2d..13f27f3a09 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -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, diff --git a/src/frontend/src/forms/CommonForms.tsx b/src/frontend/src/forms/CommonForms.tsx index e8992746f3..e3838457b1 100644 --- a/src/frontend/src/forms/CommonForms.tsx +++ b/src/frontend/src/forms/CommonForms.tsx @@ -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([]); + + // Field type for "data" input + const [fieldType, setFieldType] = useState<'string' | 'boolean' | 'choice'>( + 'string' + ); + + const [data, setData] = useState(''); + + // 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]); +} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index a539b51078..1e26b5a74c 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -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: , - content: ( - + content: part?.pk ? ( + + ) : ( + ) }, { diff --git a/src/frontend/src/tables/general/ParameterTable.tsx b/src/frontend/src/tables/general/ParameterTable.tsx index 72dcbfd981..728633a17b 100644 --- a/src/frontend/src/tables/general/ParameterTable.tsx +++ b/src/frontend/src/tables/general/ParameterTable.tsx @@ -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 ; + } + + 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 ( + + ); + } + }, + { + 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( + 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 [ +