diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4bea76d17c..5bb687ad6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- [#12274](https://github.com/inventree/InvenTree/pull/12274) fixes a bug in rendering the "table field" component in frontend forms. Any plugins which make use of the "table field" component in their forms may be affected by this change, and will need to update their form definitions accordingly.
+
### Removed
## 1.4.0 - 2026-06-24
diff --git a/src/frontend/lib/types/Forms.tsx b/src/frontend/lib/types/Forms.tsx
index 03acfb0ad9..48323dd6a0 100644
--- a/src/frontend/lib/types/Forms.tsx
+++ b/src/frontend/lib/types/Forms.tsx
@@ -221,8 +221,6 @@ export interface BulkEditApiFormModalProps extends ApiFormModalProps {
export type StockOperationProps = {
items?: any[];
- pk?: number;
filters?: any;
- model: ModelType.stockitem | 'location' | ModelType.part;
refresh: () => void;
};
diff --git a/src/frontend/package.json b/src/frontend/package.json
index 8c1bf2e93e..4ceefd58c5 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "@inventreedb/ui",
"description": "UI components for the InvenTree project",
- "version": "1.4.6",
+ "version": "1.5.0",
"private": false,
"type": "module",
"license": "MIT",
diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx
index 85e128a1fd..d9adc749f3 100644
--- a/src/frontend/src/components/forms/fields/ApiFormField.tsx
+++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx
@@ -247,7 +247,9 @@ export function ApiFormField({
);
case 'tags':
diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx
index bb88471c0c..a744f3f4ac 100644
--- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx
+++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx
@@ -419,6 +419,7 @@ export function RelatedModelField({
...definition,
addCreateFields: undefined,
autoFill: undefined,
+ autoFillFilters: undefined,
modelRenderer: undefined,
onValueChange: undefined,
adjustFilters: undefined,
diff --git a/src/frontend/src/components/forms/fields/TableField.tsx b/src/frontend/src/components/forms/fields/TableField.tsx
index 5d209b1fee..d0792a2172 100644
--- a/src/frontend/src/components/forms/fields/TableField.tsx
+++ b/src/frontend/src/components/forms/fields/TableField.tsx
@@ -1,9 +1,23 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
-import { Alert, Container, Group, Stack, Table, Text } from '@mantine/core';
+import {
+ Alert,
+ Container,
+ Group,
+ NumberInput,
+ Stack,
+ Table,
+ Text
+} from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
-import { type ReactNode, useCallback, useEffect, useMemo } from 'react';
-import type { FieldValues, UseControllerReturn } from 'react-hook-form';
+import {
+ type ReactNode,
+ memo,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef
+} from 'react';
import { AddItemButton } from '@lib/components/AddItemButton';
import { identifierString } from '@lib/functions/Conversion';
@@ -13,35 +27,34 @@ import { StandaloneField } from '../StandaloneField';
export interface TableFieldRowProps {
item: any;
- idx: number;
+ rowId: string | number;
rowErrors: any;
- control: UseControllerReturn;
- changeFn: (idx: number, key: string, value: any) => void;
- removeFn: (idx: number) => void;
+ changeFn: (rowId: number | string, key: string, value: any) => void;
+ removeFn: (rowId: number | string) => void;
}
function TableFieldRow({
item,
- idx,
- errors,
- definition,
- control,
+ rowId,
+ rowErrors,
+ modelRenderer,
+ columnCount,
changeFn,
removeFn
}: Readonly<{
item: any;
- idx: number;
- errors: any;
- definition: ApiFormFieldType;
- control: UseControllerReturn;
- changeFn: (idx: number, key: string, value: any) => void;
- removeFn: (idx: number) => void;
+ rowId: string | number;
+ rowErrors: any;
+ modelRenderer?: ApiFormFieldType['modelRenderer'];
+ columnCount?: number;
+ changeFn: (rowId: number | string, key: string, value: any) => void;
+ removeFn: (rowId: number | string) => void;
}>) {
// Table fields require render function
- if (!definition.modelRenderer) {
+ if (!modelRenderer) {
return (
-
+
}>
{t`modelRenderer entry required for tables`}
@@ -50,16 +63,60 @@ function TableFieldRow({
);
}
- return definition.modelRenderer({
+ return modelRenderer({
item: item,
- idx: idx,
- rowErrors: errors,
- control: control,
+ rowId: rowId,
+ rowErrors: rowErrors,
changeFn: changeFn,
removeFn: removeFn
});
}
+// Memoize each table field row, so that we don't re-render the entire table when a single row is updated
+function areShallowEqual(previousValue: any, nextValue: any): boolean {
+ if (previousValue === nextValue) {
+ return true;
+ }
+
+ if (!previousValue || !nextValue) {
+ return false;
+ }
+
+ if (typeof previousValue !== 'object' || typeof nextValue !== 'object') {
+ return previousValue === nextValue;
+ }
+
+ const previousKeys = Object.keys(previousValue);
+ const nextKeys = Object.keys(nextValue);
+
+ if (previousKeys.length !== nextKeys.length) {
+ return false;
+ }
+
+ for (const key of previousKeys) {
+ if (previousValue[key] !== nextValue[key]) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+const MemoizedTableFieldRow = memo(
+ TableFieldRow,
+ (previousProps, nextProps) => {
+ return (
+ previousProps.rowId === nextProps.rowId &&
+ areShallowEqual(previousProps.item, nextProps.item) &&
+ areShallowEqual(previousProps.rowErrors, nextProps.rowErrors) &&
+ previousProps.modelRenderer === nextProps.modelRenderer &&
+ previousProps.changeFn === nextProps.changeFn &&
+ previousProps.removeFn === nextProps.removeFn &&
+ previousProps.columnCount === nextProps.columnCount
+ );
+ }
+);
+
export function TableFieldErrorWrapper({
props,
errorKey,
@@ -83,33 +140,131 @@ export function TableFieldErrorWrapper({
);
}
-export function TableField({
+function TableFieldComponent({
definition,
fieldName,
- control
+ value,
+ onChange,
+ error
}: Readonly<{
definition: ApiFormFieldType;
fieldName: string;
- control: UseControllerReturn;
+ value: any;
+ onChange: (value: any) => void;
+ error?: any;
}>) {
- const {
- field,
- fieldState: { error }
- } = control;
- const { value } = field;
+ const valueRef = useRef(value);
+ const onChangeRef = useRef(onChange);
+ const rowIndexByIdRef = useRef(new Map());
+ const generatedRowIdsRef = useRef(new WeakMap