2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-05 06:32:55 +00:00

[UI] Table field refactor (#12274)

* Use callback funcs

* Don't use idx to identify rows

* Add debug function for finding why a component re-rendered

* Do not pass 'control' through to each row

* Prevent unnecessary re-rendering of table rows

* Adjust order of operations for hooks

* Keep props hidden

* Use lightweight NumberInput

* Use NumberInput elsewhere

* Add comment

* use rowId instead of idx

* Generic row memos

* Compare errors too

* Fix for BomItemSubstituteRow

* Adjust more forms

* memoize quantity

* Memoize build lines

* Fix re-rendering issues for build allocation

* Fix for useConsumeBuildLinesForm

* Fix for transfer order table

* Fix useReceiveLineItems

* Remove memoized pattern

* Fix row keys

* Cleanup

* Create useStockItems hook for memoizing items

* Refactoring

* More refactoring

* Remove obj reference

- preventing shallow comparison from working

* Add error message to useWhyDidYouUpdate

* Cleanup

* Cleanup dead code

* Adjust modal width

* Change attr name

* Remove autoFillFilters prop

* Adjustments for serialized stock

* Fix typing

* Bump frontend version

* Adjustments for playwright testing

* Fix ref issue

* Remove debug entry

* Update CHANGELOG.md

* Reintroduce index to table header

* Refactor common component
This commit is contained in:
Oliver
2026-06-30 18:10:40 +10:00
committed by GitHub
parent 414aac0224
commit 6111aace1f
26 changed files with 724 additions and 388 deletions
-2
View File
@@ -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;
};
+1 -1
View File
@@ -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",
@@ -247,7 +247,9 @@ export function ApiFormField({
<TableField
definition={fieldDefinition}
fieldName={fieldName}
control={controller}
value={value}
onChange={field.onChange}
error={error}
/>
);
case 'tags':
@@ -419,6 +419,7 @@ export function RelatedModelField({
...definition,
addCreateFields: undefined,
autoFill: undefined,
autoFillFilters: undefined,
modelRenderer: undefined,
onValueChange: undefined,
adjustFilters: undefined,
@@ -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<FieldValues, any>;
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<FieldValues, any>;
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 (
<Table.Tr key='table-row-no-renderer'>
<Table.Td colSpan={definition.headers?.length}>
<Table.Td colSpan={columnCount}>
<Alert color='red' title={t`Error`} icon={<IconExclamationCircle />}>
{t`modelRenderer entry required for tables`}
</Alert>
@@ -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<FieldValues, any>;
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<string | number, number>());
const generatedRowIdsRef = useRef(new WeakMap<object, string>());
const generatedRowIdCounterRef = useRef(0);
const onRowFieldChange = (idx: number, key: string, value: any) => {
const val = field.value;
val[idx][key] = value;
const getRowIdentifier = useCallback(
(item: any, idx: number): string | number => {
if (item && typeof item === 'object') {
const intrinsicId = item.pk ?? item.item ?? item.id ?? item.uuid;
field.onChange(val);
};
if (intrinsicId !== undefined && intrinsicId !== null) {
return intrinsicId;
}
const removeRow = (idx: number) => {
const val = field.value;
val.splice(idx, 1);
field.onChange(val);
};
const existingGeneratedId = generatedRowIdsRef.current.get(item);
if (existingGeneratedId) {
return existingGeneratedId;
}
generatedRowIdCounterRef.current += 1;
const generatedId = `table-row-generated-${generatedRowIdCounterRef.current}`;
generatedRowIdsRef.current.set(item, generatedId);
return generatedId;
}
return item ?? idx;
},
[]
);
// Keep refs in sync with latest values without introducing them as deps
valueRef.current = value;
onChangeRef.current = onChange;
useEffect(() => {
const nextRowIndexById = new Map<string | number, number>();
value?.forEach((item: any, idx: number) => {
nextRowIndexById.set(getRowIdentifier(item, idx), idx);
});
rowIndexByIdRef.current = nextRowIndexById;
}, [value, getRowIdentifier]);
const resolveRowIndex = useCallback((identifier: number | string) => {
const mappedIndex = rowIndexByIdRef.current.get(identifier);
if (mappedIndex !== undefined) {
return mappedIndex;
}
if (typeof identifier === 'number' && identifier >= 0) {
return identifier;
}
return undefined;
}, []);
const onRowFieldChange = useCallback(
(identifier: number | string, key: string, rowValue: any) => {
const idx = resolveRowIndex(identifier);
if (idx === undefined) {
return;
}
const currentValue = valueRef.current;
if (!Array.isArray(currentValue) || currentValue[idx] === undefined) {
return;
}
const nextValue = [...currentValue];
const currentRow = nextValue[idx];
if (currentRow && typeof currentRow === 'object') {
nextValue[idx] = {
...currentRow,
[key]: rowValue
};
}
valueRef.current = nextValue;
onChangeRef.current(nextValue);
},
[resolveRowIndex]
);
const removeRow = useCallback(
(identifier: number | string) => {
const idx = resolveRowIndex(identifier);
if (idx === undefined) {
return;
}
const currentValue = valueRef.current;
if (!Array.isArray(currentValue)) {
return;
}
const nextValue = [...currentValue];
nextValue.splice(idx, 1);
onChangeRef.current(nextValue);
},
[resolveRowIndex]
);
// Extract errors associated with the current row
const rowErrors: any = useCallback(
@@ -125,7 +280,7 @@ export function TableField({
<Table
highlightOnHover
striped
aria-label={`table-field-${field.name}`}
aria-label={`table-field-${fieldName}`}
style={{ width: '100%' }}
>
<Table.Thead>
@@ -146,14 +301,16 @@ export function TableField({
<Table.Tbody>
{(value?.length ?? 0) > 0 ? (
value.map((item: any, idx: number) => {
const rowId = getRowIdentifier(item, idx);
return (
<TableFieldRow
key={`table-row-${idx}`}
<MemoizedTableFieldRow
key={`table-row-${rowId}`}
item={item}
idx={idx}
errors={rowErrors(idx)}
control={control}
definition={definition}
rowId={rowId}
rowErrors={rowErrors(idx)}
modelRenderer={definition.modelRenderer}
columnCount={definition.headers?.length}
changeFn={onRowFieldChange}
removeFn={removeRow}
/>
@@ -189,9 +346,7 @@ export function TableField({
if (definition.addRow === undefined) return;
const ret = definition.addRow();
if (ret) {
const val = field.value;
val.push(ret);
field.onChange(val);
onChange([...(value ?? []), ret]);
}
}}
/>
@@ -203,6 +358,19 @@ export function TableField({
);
}
export const TableField = memo(
TableFieldComponent,
(previousProps, nextProps) => {
return (
previousProps.definition === nextProps.definition &&
previousProps.fieldName === nextProps.fieldName &&
areShallowEqual(previousProps.value, nextProps.value) &&
areShallowEqual(previousProps.error, nextProps.error) &&
previousProps.onChange === nextProps.onChange
);
}
);
/*
* Display an "extra" row below the main table row, for additional information.
* - Each "row" can display an extra row of information below the main row
@@ -224,10 +392,16 @@ export function TableFieldExtraRow({
emptyValue?: any;
onValueChange: (value: any) => void;
}) {
const hasMounted = useRef(false);
// Callback whenever the visibility of the sub-field changes
// Skip the initial mount — the value was never set, nothing to reset
useEffect(() => {
if (!hasMounted.current) {
hasMounted.current = true;
return;
}
if (!visible) {
// If the sub-field is hidden, reset the value to the "empty" value
onValueChange(emptyValue);
}
}, [visible]);
@@ -262,3 +436,37 @@ export function TableFieldExtraRow({
)
);
}
export function TableFieldQuantityInput({
value,
onChange,
error,
min
}: {
value: number | '';
onChange: (value: number | '') => void;
error?: string;
min?: number;
}) {
return (
<NumberInput
radius='sm'
aria-label='number-field-quantity'
min={min}
step={1}
decimalScale={10}
value={value}
onChange={(v: number | string) => {
if (typeof v === 'number') {
onChange(Number.isFinite(v) ? v : '');
} else if (v.trim() !== '') {
const parsed = Number.parseFloat(v);
onChange(Number.isFinite(parsed) ? parsed : '');
} else {
onChange('');
}
}}
error={error}
/>
);
}
@@ -172,15 +172,6 @@ export default function ImporterDataSelector({
description: fieldDef.help_text,
filters: filters
};
console.log('Defined Field:', field);
console.log({
...fieldDef,
...customField,
field_type: fieldDef.type,
description: fieldDef.help_text,
filters: filters
});
}
}
+2 -2
View File
@@ -75,7 +75,7 @@ function BomItemSubstituteRow({
api
.delete(apiUrl(ApiEndpoints.bom_substitute_list, record.pk))
.then(() => {
props.removeFn(props.idx);
props.removeFn(props.rowId);
})
.catch((err) => {
showApiErrorMessage({
@@ -116,7 +116,7 @@ export function useEditBomSubstitutesForm(props: BomItemSubstituteFormProps) {
modelRenderer: (row: TableFieldRowProps) => {
const record = substitutes.find((r) => r.pk == row.item.pk);
return record ? (
<BomItemSubstituteRow props={row} record={record} />
<BomItemSubstituteRow key={row.rowId} props={row} record={record} />
) : null;
},
headers: [
+51 -45
View File
@@ -21,6 +21,7 @@ import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import {
TableFieldErrorWrapper,
TableFieldQuantityInput,
type TableFieldRowProps
} from '../components/forms/fields/TableField';
import { StatusRenderer } from '../components/render/StatusRenderer';
@@ -272,15 +273,10 @@ function BuildOutputFormRow({
// Non-serialized output - quantity can be changed
return (
<StandaloneField
fieldName='quantity'
fieldDefinition={{
field_type: 'number',
required: true,
value: props.item.quantity,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
<TableFieldQuantityInput
value={props.item.quantity ?? ''}
onChange={(value) => {
props.changeFn(props.rowId, 'quantity', value);
}}
error={props.rowErrors?.quantity?.message}
/>
@@ -310,7 +306,7 @@ function BuildOutputFormRow({
/>{' '}
</Table.Td>
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td>
</Table.Tr>
</>
@@ -344,6 +340,7 @@ export function useCompleteBuildOutputsForm({
const buildOutputs = useMemo(() => {
return outputs.map((output: any) => {
return {
id: output.pk,
output: output.pk,
quantity: output.quantity
};
@@ -358,7 +355,7 @@ export function useCompleteBuildOutputsForm({
modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output);
return (
<BuildOutputFormRow props={row} record={record} key={record.pk} />
<BuildOutputFormRow props={row} record={record} key={row.rowId} />
);
},
headers: [
@@ -426,6 +423,7 @@ export function useScrapBuildOutputsForm({
const buildOutputs = useMemo(() => {
return outputs.map((output: any) => {
return {
id: output.pk,
output: output.pk,
quantity: output.quantity
};
@@ -440,7 +438,7 @@ export function useScrapBuildOutputsForm({
modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output);
return (
<BuildOutputFormRow props={row} record={record} key={record.pk} />
<BuildOutputFormRow props={row} record={record} key={row.rowId} />
);
},
headers: [
@@ -497,6 +495,7 @@ export function useCancelBuildOutputsForm({
const buildOutputs = useMemo(() => {
return outputs.map((output: any) => {
return {
id: output.pk,
output: output.pk
};
});
@@ -562,6 +561,8 @@ function BuildAllocateLineRow({
record: any;
sourceLocation: number | undefined;
}>) {
const [quantity, setQuantity] = useState<number>(props.item?.quantity ?? 0);
const stockField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'related field',
@@ -582,7 +583,7 @@ function BuildAllocateLineRow({
value: props.item.stock_item,
name: 'stock_item',
onValueChange: (value: any, instance: any) => {
props.changeFn(props.idx, 'stock_item', value);
props.changeFn(props.rowId, 'stock_item', value);
// Update the allocated quantity based on the selected stock item
if (instance) {
@@ -590,7 +591,7 @@ function BuildAllocateLineRow({
if (available < props.item.quantity) {
props.changeFn(
props.idx,
props.rowId,
'quantity',
Math.min(props.item.quantity, available)
);
@@ -600,18 +601,6 @@ function BuildAllocateLineRow({
};
}, [record, props]);
const quantityField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'number',
name: 'quantity',
required: true,
value: props.item.quantity,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
};
}, [props]);
return (
<Table.Tr key={`table-row-${record.pk}`}>
<Table.Td>
@@ -636,14 +625,18 @@ function BuildAllocateLineRow({
/>
</Table.Td>
<Table.Td>
<StandaloneField
fieldName='quantity'
fieldDefinition={quantityField}
<TableFieldQuantityInput
min={0}
value={quantity}
onChange={(value) => {
setQuantity(value === '' ? 0 : value);
props.changeFn(props.rowId, 'quantity', value);
}}
error={props.rowErrors?.quantity?.message}
/>
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td>
</Table.Tr>
);
@@ -671,6 +664,16 @@ export function useAllocateStockToBuildForm({
undefined
);
// Memoize the line items once to avoid re-rendering
const buildLines = useMemo(() => {
return lineItems.map((item) => {
return {
id: item.pk,
...item
};
});
}, [lineItems]);
const buildAllocateFields: ApiFormFieldSet = useMemo(() => {
const fields: ApiFormFieldSet = {
items: {
@@ -687,10 +690,10 @@ export function useAllocateStockToBuildForm({
modelRenderer: (row: TableFieldRowProps) => {
// Find the matching record from the passed 'lineItems'
const record =
lineItems.find((item) => item.pk == row.item.build_line) ?? {};
buildLines.find((item) => item.pk == row.item.build_line) ?? {};
return (
<BuildAllocateLineRow
key={row.idx}
key={row.rowId}
output={output}
props={row}
record={record}
@@ -767,6 +770,7 @@ export function useAllocateStockToBuildForm({
})
.map((item) => {
return {
id: item.pk,
build_line: item.pk,
stock_item: undefined,
quantity: Math.max(
@@ -788,6 +792,8 @@ function BuildConsumeItemRow({
props: TableFieldRowProps;
record: any;
}) {
const [quantity, setQuantity] = useState<number>(props.item?.quantity ?? 0);
return (
<Table.Tr key={`table-row-${record.pk}`}>
<Table.Td>
@@ -803,21 +809,18 @@ function BuildConsumeItemRow({
</Table.Td>
<Table.Td>{record.quantity}</Table.Td>
<Table.Td>
<StandaloneField
fieldName='quantity'
fieldDefinition={{
field_type: 'number',
required: true,
value: props.item.quantity,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
<TableFieldQuantityInput
min={0}
value={quantity}
onChange={(value) => {
setQuantity(value === '' ? 0 : value);
props.changeFn(props.rowId, 'quantity', value);
}}
error={props.rowErrors?.quantity?.message}
/>
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td>
</Table.Tr>
);
@@ -853,7 +856,7 @@ export function useConsumeBuildItemsForm({
);
return (
<BuildConsumeItemRow key={row.idx} props={row} record={record} />
<BuildConsumeItemRow key={row.rowId} props={row} record={record} />
);
}
},
@@ -872,6 +875,7 @@ export function useConsumeBuildItemsForm({
initialData: {
items: allocatedItems.map((item) => {
return {
id: item.pk,
build_item: item.pk,
quantity: item.quantity
};
@@ -916,7 +920,7 @@ function BuildConsumeLineRow({
/>
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td>
</Table.Tr>
);
@@ -954,7 +958,7 @@ export function useConsumeBuildLinesForm({
);
return (
<BuildConsumeLineRow key={row.idx} props={row} record={record} />
<BuildConsumeLineRow key={row.rowId} props={row} record={record} />
);
}
},
@@ -969,9 +973,11 @@ export function useConsumeBuildLinesForm({
successMessage: null,
onFormSuccess: onFormSuccess,
fields: consumeFields,
size: '80%',
initialData: {
lines: filteredLines.map((item) => {
return {
id: item.pk,
build_line: item.pk
};
})
+56 -42
View File
@@ -45,6 +45,7 @@ import type {
} from '@lib/types/Forms';
import {
TableFieldExtraRow,
TableFieldQuantityInput,
type TableFieldRowProps
} from '../components/forms/fields/TableField';
import { Thumbnail } from '../components/images/Thumbnail';
@@ -348,11 +349,11 @@ function LineItemFormRow({
}>) {
// Barcode Modal state
const [opened, { open, close }] = useDisclosure(false, {
onClose: () => props.changeFn(props.idx, 'barcode', undefined)
onClose: () => props.changeFn(props.rowId, 'barcode', undefined)
});
const [locationOpen, locationHandlers] = useDisclosure(false, {
onClose: () => props.changeFn(props.idx, 'location', undefined)
onClose: () => props.changeFn(props.rowId, 'location', undefined)
});
// Is this a trackable part?
@@ -365,7 +366,7 @@ function LineItemFormRow({
useEffect(() => {
if (!!record.destination) {
props.changeFn(props.idx, 'location', record.destination);
props.changeFn(props.rowId, 'location', record.destination);
locationHandlers.open();
}
}, [record.destination]);
@@ -375,7 +376,7 @@ function LineItemFormRow({
isEnabled: () => batchOpen,
onGenerate: (value: any) => {
if (value) {
props.changeFn(props.idx, 'batch_code', value);
props.changeFn(props.rowId, 'batch_code', value);
}
}
});
@@ -387,19 +388,19 @@ function LineItemFormRow({
const [packagingOpen, packagingHandlers] = useDisclosure(false, {
onClose: () => {
props.changeFn(props.idx, 'packaging', undefined);
props.changeFn(props.rowId, 'packaging', undefined);
}
});
const [noteOpen, noteHandlers] = useDisclosure(false, {
onClose: () => {
props.changeFn(props.idx, 'note', undefined);
props.changeFn(props.rowId, 'note', undefined);
}
});
const [batchOpen, batchHandlers] = useDisclosure(false, {
onClose: () => {
props.changeFn(props.idx, 'batch_code', undefined);
props.changeFn(props.rowId, 'batch_code', undefined);
},
onOpen: () => {
// Generate a new batch code
@@ -412,7 +413,7 @@ function LineItemFormRow({
const [serialOpen, serialHandlers] = useDisclosure(false, {
onClose: () => {
props.changeFn(props.idx, 'serial_numbers', undefined);
props.changeFn(props.rowId, 'serial_numbers', undefined);
},
onOpen: () => {
// Generate new serial numbers
@@ -422,7 +423,7 @@ function LineItemFormRow({
quantity: props.item.quantity
});
} else {
props.changeFn(props.idx, 'serial_numbers', undefined);
props.changeFn(props.rowId, 'serial_numbers', undefined);
}
}
});
@@ -433,20 +434,20 @@ function LineItemFormRow({
const defaultExpiry = record.part_detail?.default_expiry;
if (defaultExpiry !== undefined && defaultExpiry > 0) {
props.changeFn(
props.idx,
props.rowId,
'expiry_date',
dayjs().add(defaultExpiry, 'day').format('YYYY-MM-DD')
);
}
},
onClose: () => {
props.changeFn(props.idx, 'expiry_date', undefined);
props.changeFn(props.rowId, 'expiry_date', undefined);
}
});
// Status value
const [statusOpen, statusHandlers] = useDisclosure(false, {
onClose: () => props.changeFn(props.idx, 'status', undefined)
onClose: () => props.changeFn(props.rowId, 'status', undefined)
});
// Barcode value
@@ -455,7 +456,7 @@ function LineItemFormRow({
// Change form value when state is altered
useEffect(() => {
props.changeFn(props.idx, 'barcode', barcode);
props.changeFn(props.rowId, 'barcode', barcode);
}, [barcode]);
// Update location field description on state change
@@ -574,15 +575,14 @@ function LineItemFormRow({
/>
</Table.Td>
<Table.Td style={{ whiteSpace: 'nowrap' }}>
<StandaloneField
fieldName='quantity'
fieldDefinition={{
field_type: 'number',
value: props.item.quantity,
onValueChange: (value) => {
props.changeFn(props.idx, 'quantity', value);
serialNumberGenerator.update({ quantity: value });
}
<TableFieldQuantityInput
min={0}
value={props.item.quantity ?? ''}
onChange={(value) => {
props.changeFn(props.rowId, 'quantity', value);
serialNumberGenerator.update({
quantity: value === '' ? undefined : value
});
}}
error={props.rowErrors?.quantity?.message}
/>
@@ -678,7 +678,7 @@ function LineItemFormRow({
</Flex>
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td>
</Table.Tr>
{locationOpen && (
@@ -697,7 +697,7 @@ function LineItemFormRow({
structural: false
},
onValueChange: (value) => {
props.changeFn(props.idx, 'location', value);
props.changeFn(props.rowId, 'location', value);
},
description: locationDescription,
value: props.item.location,
@@ -719,7 +719,7 @@ function LineItemFormRow({
tooltip={t`Store at default location`}
onClick={() =>
props.changeFn(
props.idx,
props.rowId,
'location',
record.part_detail?.default_location ??
record.part_detail?.category_default_location
@@ -733,7 +733,11 @@ function LineItemFormRow({
icon={<InvenTreeIcon icon='destination' />}
tooltip={t`Store at line item destination `}
onClick={() =>
props.changeFn(props.idx, 'location', record.destination)
props.changeFn(
props.rowId,
'location',
record.destination
)
}
tooltipAlignment='top'
/>
@@ -746,7 +750,7 @@ function LineItemFormRow({
tooltip={t`Store with already received stock`}
onClick={() =>
props.changeFn(
props.idx,
props.rowId,
'location',
record.destination_detail.pk
)
@@ -762,7 +766,7 @@ function LineItemFormRow({
<TableFieldExtraRow
visible={batchOpen}
onValueChange={(value) => {
props.changeFn(props.idx, 'batch_code', value);
props.changeFn(props.rowId, 'batch_code', value);
}}
fieldName='batch_code'
fieldDefinition={{
@@ -776,7 +780,7 @@ function LineItemFormRow({
<TableFieldExtraRow
visible={serialOpen}
onValueChange={(value) =>
props.changeFn(props.idx, 'serial_numbers', value)
props.changeFn(props.rowId, 'serial_numbers', value)
}
fieldName='serial_numbers'
fieldDefinition={{
@@ -794,7 +798,7 @@ function LineItemFormRow({
<TableFieldExtraRow
visible={expiryDateOpen}
onValueChange={(value) =>
props.changeFn(props.idx, 'expiry_date', value)
props.changeFn(props.rowId, 'expiry_date', value)
}
fieldName='expiry_date'
fieldDefinition={{
@@ -808,7 +812,9 @@ function LineItemFormRow({
)}
<TableFieldExtraRow
visible={packagingOpen}
onValueChange={(value) => props.changeFn(props.idx, 'packaging', value)}
onValueChange={(value) =>
props.changeFn(props.rowId, 'packaging', value)
}
fieldName='packaging'
fieldDefinition={{
field_type: 'string',
@@ -821,7 +827,7 @@ function LineItemFormRow({
visible={statusOpen}
defaultValue={10}
fieldName='status'
onValueChange={(value) => props.changeFn(props.idx, 'status', value)}
onValueChange={(value) => props.changeFn(props.rowId, 'status', value)}
fieldDefinition={{
field_type: 'choice',
api_url: apiUrl(ApiEndpoints.stock_status),
@@ -833,7 +839,7 @@ function LineItemFormRow({
<TableFieldExtraRow
visible={noteOpen}
fieldName='note'
onValueChange={(value) => props.changeFn(props.idx, 'note', value)}
onValueChange={(value) => props.changeFn(props.rowId, 'note', value)}
fieldDefinition={{
field_type: 'string',
label: t`Note`
@@ -862,13 +868,20 @@ export function useReceiveLineItems(props: LineItemsForm) {
[]
);
const records = Object.fromEntries(
props.items.map((item) => [item.pk, item])
);
const records = useMemo(() => {
return Object.fromEntries(props.items.map((item) => [item.pk, item]));
}, [props.items]);
const filteredItems = props.items.filter(
(elem) => elem.quantity !== elem.received
);
const filteredItems = useMemo(() => {
return props.items
.filter((elem) => elem.quantity !== elem.received)
.map((elem) => {
return {
id: elem.pk,
...elem
};
});
}, [props.items]);
const fields: ApiFormFieldSet = useMemo(() => {
return {
@@ -878,8 +891,9 @@ export function useReceiveLineItems(props: LineItemsForm) {
},
items: {
field_type: 'table',
value: filteredItems.map((elem, idx) => {
value: filteredItems.map((elem) => {
return {
id: elem.pk,
line_item: elem.pk,
location: elem.destination ?? elem.destination_detail?.pk ?? null,
quantity: elem.quantity - elem.received,
@@ -902,7 +916,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
props={row}
record={record}
statuses={stockStatusCodes}
key={record.pk}
key={row.rowId}
/>
);
},
@@ -921,7 +935,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
}
}
};
}, [filteredItems, props, stockStatusCodes]);
}, [filteredItems, records, props, stockStatusCodes]);
return useCreateApiFormModal({
...props.formProps,
+4 -3
View File
@@ -199,7 +199,7 @@ function ReturnOrderLineItemFormRow({
label: t`Status`,
choices: statusOptions,
onValueChange: (value) => {
props.changeFn(props.idx, 'status', value);
props.changeFn(props.rowId, 'status', value);
}
}}
defaultValue={record.item_detail?.status}
@@ -207,7 +207,7 @@ function ReturnOrderLineItemFormRow({
/>
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td>
</Table.Tr>
</>
@@ -226,6 +226,7 @@ export function useReceiveReturnOrderLineItems(
field_type: 'table',
value: props.items.map((item: any) => {
return {
id: item.pk,
item: item.pk
};
}),
@@ -236,7 +237,7 @@ export function useReceiveReturnOrderLineItems(
<ReturnOrderLineItemFormRow
props={row}
record={record}
key={record.pk}
key={row.rowId}
/>
);
},
+25 -25
View File
@@ -25,7 +25,10 @@ import type {
ApiFormFieldType
} from '@lib/types/Forms';
import dayjs from 'dayjs';
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
import {
TableFieldQuantityInput,
type TableFieldRowProps
} from '../components/forms/fields/TableField';
import useBackgroundTask from '../hooks/UseBackgroundTask';
import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates';
@@ -328,41 +331,33 @@ function SalesOrderAllocateLineRow({
value: props.item.stock_item,
name: 'stock_item',
onValueChange: (value: any, instance: any) => {
props.changeFn(props.idx, 'stock_item', value);
props.changeFn(props.rowId, 'stock_item', value);
// Update the allocated quantity based on the selected stock item
if (instance) {
const available = instance.quantity - instance.allocated;
const required = record.quantity - record.allocated;
let quantity = props.item?.quantity ?? 0;
let q = props.item?.quantity ?? 0;
quantity = Math.max(quantity, required);
quantity = Math.min(quantity, available);
q = Math.max(q, required);
q = Math.min(q, available);
if (quantity != props.item.quantity) {
props.changeFn(props.idx, 'quantity', quantity);
if (q != props.item?.quantity) {
setQuantity(q);
props.changeFn(props.rowId, 'quantity', q);
}
}
}
};
}, [sourceLocation, record, props]);
// Statically defined field for selecting the allocation quantity
const quantityField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'number',
name: 'quantity',
required: true,
value: props.item.quantity,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
};
}, [props]);
const [quantity, setQuantity] = useState<number | ''>(
props.item?.quantity ?? ''
);
return (
<Table.Tr key={`table-row-${props.idx}-${record.pk}`}>
<Table.Tr key={`table-row-${props.rowId}-${record.pk}`}>
<Table.Td>
<RenderPartColumn part={record.part_detail} />
</Table.Td>
@@ -381,14 +376,18 @@ function SalesOrderAllocateLineRow({
/>
</Table.Td>
<Table.Td>
<StandaloneField
fieldName='quantity'
fieldDefinition={quantityField}
<TableFieldQuantityInput
min={0}
value={quantity}
onChange={(value) => {
setQuantity(value);
props.changeFn(props.rowId, 'quantity', value);
}}
error={props.rowErrors?.quantity?.message}
/>
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td>
</Table.Tr>
);
@@ -443,7 +442,7 @@ export function useAllocateToSalesOrderForm({
return (
<SalesOrderAllocateLineRow
key={`table-row-${row.idx}-${record.pk}`}
key={row.rowId}
props={row}
record={record}
sourceLocation={sourceLocation}
@@ -474,6 +473,7 @@ export function useAllocateToSalesOrderForm({
initialData: {
items: lineItems.map((item) => {
return {
id: item.pk,
line_item: item.pk,
quantity: 0,
stock_item: null
+237 -161
View File
@@ -41,15 +41,16 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { useFormContext } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { api } from '../App';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import {
TableFieldExtraRow,
TableFieldQuantityInput,
type TableFieldRowProps
} from '../components/forms/fields/TableField';
import { Thumbnail } from '../components/images/Thumbnail';
@@ -553,19 +554,106 @@ function moveToDefault(
});
}
type StockAdjustmentItemWithRecord = {
obj: any;
} & StockAdjustmentItem;
/*
* Memoize a list of stock items for use in a stock operations modal.
* These items may be provided directly, or fetched from the API
*
* @param opened - Is the underlying modal opened or closed?
* @param items - Optional list of stock items to use directly
* @param category - Optional category ID to filter stock items
* @param location - Optional location ID to filter stock items
* @param part - Optional part ID to filter stock items
* @param filters - Optional additional filters to apply to the stock item query
*/
function useStockItems({
opened,
items,
filters
}: Readonly<{
opened: boolean;
items?: any[] | any;
filters?: { [key: string]: any };
}>) {
const query = useQuery({
enabled: opened,
queryKey: ['stockItems', filters],
queryFn: async () => {
if (items !== undefined) {
return Array.isArray(items) ? items : [items];
}
type TableFieldRefreshFn = (idx: number) => void;
type TableFieldChangeFn = (idx: number, key: string, value: any) => void;
if (!opened) {
return [];
}
type StockRow = {
item: StockAdjustmentItemWithRecord;
idx: number;
changeFn: TableFieldChangeFn;
removeFn: TableFieldRefreshFn;
};
// Fetch via the API
const url = apiUrl(ApiEndpoints.stock_item_list);
return api
.get(url, {
params: {
...filters,
part_detail: true,
location_detail: true,
cascade: false
}
})
.then((response) => response.data ?? []);
}
});
return useMemo(() => {
if (!opened) {
return [];
}
return query.data ?? [];
}, [opened, query.data]);
}
function ReturnStockMoveButton({
record,
quantity,
onRemove,
returnStock
}: {
record: any;
quantity: StockItemQuantity;
onRemove: () => void;
returnStock: boolean;
}) {
const form = useFormContext();
return (
<ActionButton
onClick={() =>
moveToDefault(
record,
quantity,
onRemove,
returnStock
? {
title: t`Confirm Stock Return`,
onConfirm: (location: number) => {
form.setValue('location', location, {
shouldDirty: true,
shouldValidate: true
});
}
}
: undefined
)
}
icon={<InvenTreeIcon icon='default_location' />}
tooltip={t`Move to default location`}
tooltipAlignment='top'
disabled={
!record.part_detail?.default_location &&
!record.part_detail?.category_default_location
}
/>
);
}
function StockOperationsRow({
props,
@@ -588,7 +676,7 @@ function StockOperationsRow({
returnStock?: boolean;
record?: any;
}) {
const form = useFormContext();
const rowId = props.rowId;
const statusOptions: ApiFormFieldChoice[] = useMemo(() => {
return (
@@ -607,37 +695,54 @@ function StockOperationsRow({
const [status, setStatus] = useState<number | undefined>(undefined);
const removeAndRefresh = () => {
props.removeFn(props.idx);
};
const [packagingOpen, packagingHandlers] = useDisclosure(false);
const [statusOpen, statusHandlers] = useDisclosure(false);
const hasMountedPackagingRef = useRef(false);
const hasMountedStatusRef = useRef(false);
const callChangeFn = (idx: number, key: string, value: any) => {
setTimeout(() => props.changeFn(idx, key, value), 0);
};
const [packagingOpen, packagingHandlers] = useDisclosure(false, {
onOpen: () => {
if (transfer) {
callChangeFn(props.idx, 'packaging', record?.packaging || undefined);
}
},
onClose: () => {
if (transfer) {
callChangeFn(props.idx, 'packaging', undefined);
}
useEffect(() => {
if (!transfer) {
return;
}
});
const [statusOpen, statusHandlers] = useDisclosure(false, {
onOpen: () => {
if (!hasMountedPackagingRef.current) {
hasMountedPackagingRef.current = true;
return;
}
props.changeFn(
rowId,
'packaging',
packagingOpen ? record?.packaging || undefined : undefined
);
}, [transfer, packagingOpen, rowId, record?.packaging, props.changeFn]);
useEffect(() => {
if (!changeStatus) {
return;
}
if (!hasMountedStatusRef.current) {
hasMountedStatusRef.current = true;
return;
}
if (statusOpen) {
setStatus(record?.status_custom_key || record?.status || undefined);
props.changeFn(props.idx, 'status', record?.status || undefined);
},
onClose: () => {
setStatus(undefined);
callChangeFn(props.idx, 'status', undefined);
props.changeFn(rowId, 'status', record?.status || undefined);
return;
}
});
setStatus(undefined);
props.changeFn(rowId, 'status', undefined);
}, [
changeStatus,
statusOpen,
rowId,
record?.status,
record?.status_custom_key,
props.changeFn
]);
const stockString: string = useMemo(() => {
if (!record) {
@@ -652,10 +757,15 @@ function StockOperationsRow({
}, [record]);
return !record ? (
<div>{t`Loading...`}</div>
<Table.Tr>
<Table.Td colSpan={6}>{t`Loading...`}</Table.Td>
</Table.Tr>
) : (
<>
<Table.Tr>
<Table.Tr
aria-label={`stock-op-row-${rowId}`}
key={`stock-op-row-${rowId}`}
>
<Table.Td>
<Stack gap='xs'>
<Flex gap='sm' align='center'>
@@ -689,15 +799,12 @@ function StockOperationsRow({
</Table.Td>
{!merge && (
<Table.Td>
<StandaloneField
fieldName='quantity'
fieldDefinition={{
field_type: 'number',
value: quantity,
onValueChange: (value: any) => {
setQuantity(value);
props.changeFn(props.idx, 'quantity', value);
}
<TableFieldQuantityInput
min={0}
value={quantity ?? ''}
onChange={(value) => {
setQuantity(value);
props.changeFn(rowId, 'quantity', value);
}}
error={props.rowErrors?.quantity?.message}
/>
@@ -705,35 +812,30 @@ function StockOperationsRow({
)}
<Table.Td>
<Flex gap='3px'>
{transfer && (
<ActionButton
onClick={() =>
moveToDefault(
record,
props.item.quantity,
removeAndRefresh,
returnStock
? {
title: t`Confirm Stock Return`,
onConfirm: (location: number) => {
form.setValue('location', location, {
shouldDirty: true,
shouldValidate: true
});
}
}
: undefined
)
}
icon={<InvenTreeIcon icon='default_location' />}
tooltip={t`Move to default location`}
tooltipAlignment='top'
disabled={
!record.part_detail?.default_location &&
!record.part_detail?.category_default_location
}
/>
)}
{transfer &&
(returnStock ? (
<ReturnStockMoveButton
record={record}
quantity={props.item.quantity}
onRemove={() => props.removeFn(rowId)}
returnStock={returnStock}
/>
) : (
<ActionButton
onClick={() =>
moveToDefault(record, props.item.quantity, () =>
props.removeFn(rowId)
)
}
icon={<InvenTreeIcon icon='default_location' />}
tooltip={t`Move to default location`}
tooltipAlignment='top'
disabled={
!record.part_detail?.default_location &&
!record.part_detail?.category_default_location
}
/>
))}
{changeStatus && (
<ActionButton
size='sm'
@@ -758,12 +860,12 @@ function StockOperationsRow({
icon={<InvenTreeIcon icon='merge' />}
tooltip={t`Merge into existing stock`}
onClick={() =>
callChangeFn(props.idx, 'merge', !props.item?.merge)
props.changeFn(rowId, 'merge', !props.item?.merge)
}
variant={props.item?.merge ? 'filled' : 'transparent'}
/>
)}
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
<RemoveRowButton onClick={() => props.removeFn(rowId)} />
</Flex>
</Table.Td>
</Table.Tr>
@@ -772,7 +874,7 @@ function StockOperationsRow({
visible={statusOpen}
onValueChange={(value: any) => {
setStatus(value);
props.changeFn(props.idx, 'status', value || undefined);
props.changeFn(rowId, 'status', value || undefined);
}}
fieldName='status'
fieldDefinition={{
@@ -788,7 +890,7 @@ function StockOperationsRow({
<TableFieldExtraRow
visible={transfer && packagingOpen}
onValueChange={(value: any) => {
props.changeFn(props.idx, 'packaging', value || undefined);
props.changeFn(rowId, 'packaging', value || undefined);
}}
fieldName='packaging'
fieldDefinition={{
@@ -814,15 +916,15 @@ type StockAdjustmentItem = {
};
function mapAdjustmentItems(items: any[], mergeDefault?: boolean) {
const mappedItems: StockAdjustmentItemWithRecord[] = items.map((elem) => {
const mappedItems: StockAdjustmentItem[] = items.map((elem) => {
return {
id: elem.pk,
pk: elem.pk,
quantity: elem.quantity,
batch: elem.batch || undefined,
status: elem.status || undefined,
packaging: elem.packaging || undefined,
merge: elem.merge ?? mergeDefault ?? false,
obj: elem
merge: elem.merge ?? mergeDefault ?? false
};
});
@@ -856,7 +958,7 @@ function stockTransferFields(
changeStatus
setMax
transferMerge
key={record.pk}
key={row.rowId}
record={record}
/>
);
@@ -901,7 +1003,7 @@ function stockReturnFields(items: any[]): ApiFormFieldSet {
return (
<StockOperationsRow
props={row}
key={record.pk}
key={row.rowId}
record={record}
transfer
returnStock
@@ -948,9 +1050,12 @@ function stockRemoveFields(items: any[]): ApiFormFieldSet {
return {};
}
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
// Only include items which are not serialized (serial number field is empty)
const validItems = items.filter((item) => !item.serial && item.quantity > 0);
const initialValue = mapAdjustmentItems(items).map((elem) => {
const records = Object.fromEntries(validItems.map((item) => [item.pk, item]));
const initialValue = mapAdjustmentItems(validItems).map((elem) => {
return {
...elem,
quantity: 0
@@ -970,7 +1075,7 @@ function stockRemoveFields(items: any[]): ApiFormFieldSet {
setMax
changeStatus
add
key={record.pk}
key={row.rowId}
record={record}
/>
);
@@ -995,9 +1100,12 @@ function stockAddFields(items: any[]): ApiFormFieldSet {
return {};
}
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
// Only include items which are not serialized (serial number field is empty)
const validItems = items.filter((item) => !item.serial);
const initialValue = mapAdjustmentItems(items).map((elem) => {
const records = Object.fromEntries(validItems.map((item) => [item.pk, item]));
const initialValue = mapAdjustmentItems(validItems).map((elem) => {
return {
...elem,
quantity: 0
@@ -1016,7 +1124,7 @@ function stockAddFields(items: any[]): ApiFormFieldSet {
changeStatus
props={row}
add
key={record.pk}
key={row.rowId}
record={record}
/>
);
@@ -1037,16 +1145,14 @@ function stockAddFields(items: any[]): ApiFormFieldSet {
}
function stockCountFields(items: any[]): ApiFormFieldSet {
if (!items) {
return {};
}
const records = Object.fromEntries(
items?.map((item) => [item.pk, item]) ?? []
);
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
const initialValue = mapAdjustmentItems(items);
const initialValue = items ? mapAdjustmentItems(items) : [];
// Extract all location values from the items
const locations = [...new Set(items.map((item) => item.location))];
const locations = [...new Set(items?.map((item) => item.location))];
const fields: ApiFormFieldSet = {
items: {
@@ -1057,8 +1163,8 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
<StockOperationsRow
props={row}
changeStatus
key={row.item.pk}
record={records[row.item.pk]}
key={row.rowId}
record={records[row.item?.pk]}
/>
);
},
@@ -1105,7 +1211,7 @@ function stockChangeStatusFields(items: any[]): ApiFormFieldSet {
return (
<StockOperationsRow
props={row}
key={row.item}
key={row.rowId}
merge
record={records[row.item]}
/>
@@ -1133,19 +1239,22 @@ function stockMergeFields(items: any[]): ApiFormFieldSet {
return {};
}
const records = Object.fromEntries(items.map((item) => [item.pk, item]));
// Only include items which are not serialized (serial number field is empty)
const validItems = items.filter((item) => !item.serial);
const records = Object.fromEntries(validItems.map((item) => [item.pk, item]));
// Extract all non-null location values from the items
const locationValues = [
...new Set(
items.filter((item) => item.location).map((item) => item.location)
validItems.filter((item) => item.location).map((item) => item.location)
)
];
// Extract all non-null default location values from the items
const defaultLocationValues = [
...new Set(
items
validItems
.filter((item) => item.part_detail?.default_location)
.map((item) => item.part_detail?.default_location)
)
@@ -1162,17 +1271,16 @@ function stockMergeFields(items: any[]): ApiFormFieldSet {
const fields: ApiFormFieldSet = {
items: {
field_type: 'table',
value: items.map((elem) => {
value: validItems.map((elem) => {
return {
item: elem.pk,
obj: elem
item: elem.pk
};
}),
modelRenderer: (row: TableFieldRowProps) => {
return (
<StockOperationsRow
props={row}
key={row.item.item}
key={row.rowId}
merge
changeStatus
record={records[row.item.item]}
@@ -1213,15 +1321,14 @@ function stockAssignFields(items: any[]): ApiFormFieldSet {
field_type: 'table',
value: items.map((elem) => {
return {
item: elem.pk,
obj: elem
item: elem.pk
};
}),
modelRenderer: (row: TableFieldRowProps) => {
return (
<StockOperationsRow
props={row}
key={row.item.item}
key={row.rowId}
merge
record={records[row.item.item]}
/>
@@ -1265,7 +1372,7 @@ function stockDeleteFields(items: any[]): ApiFormFieldSet {
return (
<StockOperationsRow
props={row}
key={record.pk}
key={row.rowId}
merge
record={record}
/>
@@ -1293,8 +1400,6 @@ type apiModalFunc = (props: ApiFormModalProps) => {
function useStockOperationModal({
items,
pk,
model,
refresh,
fieldGenerator,
endpoint,
@@ -1305,9 +1410,7 @@ function useStockOperationModal({
modalFunc = useCreateApiFormModal
}: {
items?: object;
pk?: number;
filters?: any;
model: ModelType | string;
refresh: () => void;
fieldGenerator: (items: any[]) => ApiFormFieldSet;
endpoint: ApiEndpoints;
@@ -1316,51 +1419,19 @@ function useStockOperationModal({
successMessage?: string;
modalFunc?: apiModalFunc;
}) {
const baseParams: any = {
part_detail: true,
location_detail: true,
cascade: false
};
const params = useMemo(() => {
const query_params: any = {
...baseParams,
...(filters ?? {})
};
query_params[model] =
pk === undefined && model === 'location' ? 'null' : pk;
return query_params;
}, [baseParams, filters, model, pk]);
const [opened, setOpened] = useState<boolean>(false);
const { data } = useQuery({
queryKey: ['stockitems', opened, model, pk, items, params],
queryFn: async () => {
if (items) {
// If a list of items is provided, use that directly
return Array.isArray(items) ? items : [items];
}
if (!pk || !opened) {
return [];
}
const url = apiUrl(ApiEndpoints.stock_item_list);
return api
.get(url, {
params: params
})
.then((response) => response.data ?? []);
}
const stockItems = useStockItems({
opened: opened,
items: items,
filters: filters
});
const fields = useMemo(() => {
return fieldGenerator(data);
}, [data]);
// Rebuild the "fields" object
const fields = useMemo(
() => fieldGenerator(stockItems),
[fieldGenerator, stockItems]
);
return modalFunc({
url: endpoint,
@@ -1447,9 +1518,14 @@ export function useReturnStockItem(props: StockOperationProps) {
}
export function useCountStockItem(props: StockOperationProps) {
const fieldGenerator = useCallback(
(items: any[]) => stockCountFields(items),
[]
);
return useStockOperationModal({
...props,
fieldGenerator: stockCountFields,
fieldGenerator: fieldGenerator,
endpoint: ApiEndpoints.stock_count,
title: t`Count Stock`,
successMessage: t`Stock counted`,
+37 -29
View File
@@ -6,7 +6,10 @@ import { IconCalendar, IconUsers } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
import {
TableFieldQuantityInput,
type TableFieldRowProps
} from '../components/forms/fields/TableField';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { RenderPartColumn } from '../tables/ColumnRenderers';
@@ -110,6 +113,10 @@ function TransferOrderAllocateLineRow({
record: any;
sourceLocation?: number | null;
}>) {
const [quantity, setQuantity] = useState<number | ''>(
props.item?.quantity ?? ''
);
// Statically defined field for selecting the stock item
const stockItemField: ApiFormFieldType = useMemo(() => {
return {
@@ -128,41 +135,27 @@ function TransferOrderAllocateLineRow({
value: props.item.stock_item,
name: 'stock_item',
onValueChange: (value: any, instance: any) => {
props.changeFn(props.idx, 'stock_item', value);
props.changeFn(props.rowId, 'stock_item', value);
// Update the allocated quantity based on the selected stock item
if (instance) {
const available = instance.quantity - instance.allocated;
const required = record.quantity - record.allocated;
let quantity = props.item?.quantity ?? 0;
let q = props.item?.quantity ?? 0;
quantity = Math.max(quantity, required);
quantity = Math.min(quantity, available);
q = Math.max(q, required);
q = Math.min(q, available);
if (quantity != props.item.quantity) {
props.changeFn(props.idx, 'quantity', quantity);
}
setQuantity(q);
props.changeFn(props.rowId, 'quantity', q);
}
}
};
}, [sourceLocation, record, props]);
// Statically defined field for selecting the allocation quantity
const quantityField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'number',
name: 'quantity',
required: true,
value: props.item.quantity,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'quantity', value);
}
};
}, [props]);
}, [sourceLocation, record, quantity, props.changeFn]);
return (
<Table.Tr key={`table-row-${props.idx}-${record.pk}`}>
<Table.Tr key={`table-row-${props.rowId}`}>
<Table.Td>
<RenderPartColumn part={record.part_detail} />
</Table.Td>
@@ -181,14 +174,18 @@ function TransferOrderAllocateLineRow({
/>
</Table.Td>
<Table.Td>
<StandaloneField
fieldName='quantity'
fieldDefinition={quantityField}
<TableFieldQuantityInput
min={0}
value={quantity}
onChange={(value) => {
setQuantity(value === '' ? 0 : value);
props.changeFn(props.rowId, 'quantity', value);
}}
error={props.rowErrors?.quantity?.message}
/>
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
<RemoveRowButton onClick={() => props.removeFn(props.rowId)} />
</Table.Td>
</Table.Tr>
);
@@ -209,6 +206,16 @@ export function useAllocateToTransferOrderForm({
sourceLocationId || null
);
// Memoize the line items to prevent re-rendering
const lines = useMemo(() => {
return lineItems.map((item) => {
return {
id: item.pk,
...item
};
});
}, [lineItems]);
const fields: ApiFormFieldSet = useMemo(() => {
return {
// Non-submitted field to select the source location
@@ -237,11 +244,11 @@ export function useAllocateToTransferOrderForm({
],
modelRenderer: (row: TableFieldRowProps) => {
const record =
lineItems.find((item) => item.pk == row.item.line_item) ?? {};
lines.find((item) => item.pk == row.item.line_item) ?? {};
return (
<TransferOrderAllocateLineRow
key={`table-row-${row.idx}-${record.pk}`}
key={row.rowId}
props={row}
record={record}
sourceLocation={sourceLocation}
@@ -263,6 +270,7 @@ export function useAllocateToTransferOrderForm({
initialData: {
items: lineItems.map((item) => {
return {
id: item.pk,
line_item: item.pk,
quantity: 0,
stock_item: null
+36
View File
@@ -0,0 +1,36 @@
/** Various debugging helper functions for development */
import { useEffect, useRef } from 'react';
/**
* A custom hook that logs the previous and current props of a component whenever it updates.
*/
export function useWhyDidYouUpdate(name: string, props: any) {
const previousProps = useRef({});
useEffect(() => {
console.error(
'useWhyDidYouUpdate should not be used in production code. It is intended for debugging purposes only.'
);
if (previousProps.current) {
const allKeys = Object.keys({ ...previousProps.current, ...props });
const changedProps: any = {};
allKeys.forEach((key) => {
if ((previousProps as any).current[key] !== props[key]) {
(changedProps as any)[key] = {
from: (previousProps as any).current[key],
to: props[key]
};
}
});
if (Object.keys(changedProps).length > 0) {
console.log(`[${name}] Changed props:`, changedProps);
}
}
previousProps.current = props;
});
}
+1 -2
View File
@@ -1064,10 +1064,9 @@ export default function PartDetail() {
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
pk: part.pk,
model: ModelType.part,
refresh: refreshInstance,
filters: {
part: part.pk,
in_stock: true
}
};
@@ -349,10 +349,9 @@ export default function Stock() {
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
pk: location.pk,
model: 'location',
refresh: refreshInstance,
filters: {
location: location.pk,
in_stock: true
}
};
+2 -1
View File
@@ -805,7 +805,6 @@ export default function StockDetail() {
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
items: [stockitem],
model: ModelType.stockitem,
refresh: () => {
const location = stockitem?.location;
refreshInstancePromise().then((response) => {
@@ -830,6 +829,8 @@ export default function StockDetail() {
formProps: stockOperationProps,
delete: false,
changeBatch: false,
add: !stockitem.serial,
remove: !stockitem.serial,
assign: !!stockitem.in_stock && stockitem.part_detail?.salable,
return: !!stockitem.consumed_by || !!stockitem.customer,
merge: false
+1 -1
View File
@@ -181,6 +181,6 @@ export function patchUser(key: 'language' | 'theme' | 'widgets', val: any) {
if (uid) {
api.patch(apiUrl(ApiEndpoints.user_me_profile), { [key]: val });
} else {
console.log('user not logged in, not patching');
console.warn('user not logged in, not patching');
}
}
@@ -233,7 +233,6 @@ export default function BuildAllocatedStockTable({
return {
items: stockItems,
model: ModelType.stockitem,
refresh: table.refreshTable
};
}, [table.selectedRecords, table.refreshTable]);
@@ -309,7 +309,10 @@ export default function BuildOutputTable({
allocated += allocation.quantity;
});
if (allocated >= item.bom_item_detail.quantity) {
if (
item.bom_item_detail?.quantity &&
allocated >= item.bom_item_detail.quantity
) {
fullyAllocatedCount += 1;
}
});
@@ -510,7 +513,6 @@ export default function BuildOutputTable({
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
items: table.selectedRecords,
model: ModelType.stockitem,
refresh: table.refreshTable,
filters: {}
};
@@ -285,7 +285,6 @@ export default function SalesOrderAllocationTable({
return {
items: stockItems,
model: ModelType.stockitem,
refresh: table.refreshTable
};
}, [table.selectedRecords, table.refreshTable]);
@@ -105,14 +105,12 @@ function SelectionListEntriesTable({
return [
RowEditAction({
onClick: () => {
console.log('record:', record);
setSelectedEntry(record.id);
editEntry.open();
}
}),
RowDeleteAction({
onClick: () => {
console.log('record:', record);
setSelectedEntry(record.id);
deleteEntry.open();
}
@@ -399,7 +399,6 @@ export function StockItemTable({
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
items: table.selectedRecords,
model: ModelType.stockitem,
refresh: () => {
table.clearSelectedRecords();
table.refreshTable();
@@ -224,7 +224,6 @@ export default function TransferOrderAllocationTable({
return {
items: stockItems,
model: ModelType.stockitem,
refresh: table.refreshTable
};
}, [table.selectedRecords, table.refreshTable]);
@@ -159,8 +159,6 @@ test('Dashboard - Preserve widget sizes', async ({ browser }) => {
for (const [bp, items] of Object.entries(await readLayouts(page))) {
const entry = (items as any[]).find((i) => i?.i === 'ovr-so');
console.log('entry:', bp, entry);
expect(entry?.w, `${bp}: ovr-so missing or wrong w`).toBe(TARGET_W);
expect(entry?.h, `${bp}: ovr-so missing or wrong h`).toBe(TARGET_H);
}