[UI] Form fix (#12348)
* Refactor API form fields - Prevent reconstruction of all fields when single value changes - Memoize components more intelligently - Fix enter key callback ref * memoize other field types too * More memo * Prevent duplicate API calls for related model field * Add aria-label for IconField * add playwright tests for form field coverage
This commit is contained in:
parent
9fd074b202
commit
a38c3963f2
|
|
@ -41,6 +41,7 @@ import type {
|
|||
ApiFormProps
|
||||
} from '@lib/types/Forms';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import { isEquivalent } from '../../functions/comparison';
|
||||
import { constructField, extractAvailableFields } from '../../functions/forms';
|
||||
import { KeepFormOpenSwitch } from './KeepFormOpenSwitch';
|
||||
import { ApiFormField } from './fields/ApiFormField';
|
||||
|
|
@ -122,15 +123,23 @@ export function OptionsApiForm({
|
|||
}
|
||||
}, [opened]);
|
||||
|
||||
// Preserve the previous reference for any field whose constructed
|
||||
// definition did not actually change, so unrelated fields don't force a
|
||||
// fields-object replacement (and cascade a re-render to every field)
|
||||
// whenever this recomputes for an unrelated reason (e.g. options data or
|
||||
// the caller's own fields hook re-running with equivalent output)
|
||||
const previousConstructedFieldsRef = useRef<ApiFormFieldSet>({});
|
||||
|
||||
const formProps: ApiFormProps = useMemo(() => {
|
||||
const _props = { ...props };
|
||||
|
||||
if (!_props.fields) return _props;
|
||||
|
||||
_props.fields = { ..._props.fields };
|
||||
const prevFields = previousConstructedFieldsRef.current;
|
||||
const nextFields: ApiFormFieldSet = {};
|
||||
|
||||
for (const [k, v] of Object.entries(_props.fields)) {
|
||||
_props.fields[k] = constructField({
|
||||
const constructed = constructField({
|
||||
field: v,
|
||||
definition: optionsQuery?.data?.[k]
|
||||
});
|
||||
|
|
@ -139,10 +148,17 @@ export function OptionsApiForm({
|
|||
const value = _props?.initialData?.[k];
|
||||
|
||||
if (value) {
|
||||
_props.fields[k].value = value;
|
||||
constructed.value = value;
|
||||
}
|
||||
|
||||
const prev = prevFields[k];
|
||||
nextFields[k] =
|
||||
prev && isEquivalent(prev, constructed) ? prev : constructed;
|
||||
}
|
||||
|
||||
previousConstructedFieldsRef.current = nextFields;
|
||||
_props.fields = nextFields;
|
||||
|
||||
return _props;
|
||||
}, [optionsQuery.data, props]);
|
||||
|
||||
|
|
@ -314,7 +330,21 @@ export function ApiForm({
|
|||
}
|
||||
}
|
||||
|
||||
setFields(_fields);
|
||||
// Preserve the previous reference for any field whose definition did not
|
||||
// actually change, so unrelated fields don't re-render just because the
|
||||
// fields hook rebuilt its entire output (e.g. in response to some other
|
||||
// field's onValueChange updating local state elsewhere in that hook)
|
||||
setFields((prevFields) => {
|
||||
const nextFields: ApiFormFieldSet = {};
|
||||
|
||||
for (const k of Object.keys(_fields)) {
|
||||
const prev = prevFields[k];
|
||||
const next = _fields[k];
|
||||
nextFields[k] = prev && isEquivalent(prev, next) ? prev : next;
|
||||
}
|
||||
|
||||
return nextFields;
|
||||
});
|
||||
}, [props.fields, props.initialData, defaultValues, initialDataQuery.data]);
|
||||
|
||||
// Fetch initial data on form load
|
||||
|
|
@ -600,6 +630,24 @@ export function ApiForm({
|
|||
[props.onFormError]
|
||||
);
|
||||
|
||||
// Submit the form when "Enter" is pressed in a field.
|
||||
// Routed through a ref so that the callback identity passed down to every
|
||||
// field stays stable across renders - isValid / isDirty change on every
|
||||
// keystroke anywhere in the form, and a fresh onKeyDown reference here
|
||||
// would force every field (not just the one being edited) to re-render.
|
||||
const submitOnEnterRef = useRef<() => void>(() => {});
|
||||
submitOnEnterRef.current = () => {
|
||||
if (!isLoading && (!props.fetchInitialData || isDirty)) {
|
||||
form.handleSubmit(submitForm, onFormError)();
|
||||
}
|
||||
};
|
||||
|
||||
const onFieldKeyDown = useCallback((value: any) => {
|
||||
if (value === 'Enter') {
|
||||
submitOnEnterRef.current();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Submit the form with Ctrl+Enter (Cmd+Enter on Mac) while it is open.
|
||||
// An empty tagsToIgnore list allows the hotkey to fire from within form inputs.
|
||||
useInvenTreeHotkeys(
|
||||
|
|
@ -685,15 +733,7 @@ export function ApiForm({
|
|||
url={url}
|
||||
navigate={navigate}
|
||||
setFields={setFields}
|
||||
onKeyDown={(value) => {
|
||||
if (
|
||||
value == 'Enter' &&
|
||||
!isLoading &&
|
||||
(!props.fetchInitialData || isDirty)
|
||||
) {
|
||||
form.handleSubmit(submitForm, onFormError)();
|
||||
}
|
||||
}}
|
||||
onKeyDown={onFieldKeyDown}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import { t } from '@lingui/core/macro';
|
|||
import { DateTimePicker } from '@mantine/dates';
|
||||
import dayjs from 'dayjs';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
import { useCallback, useId, useMemo } from 'react';
|
||||
import { memo, useCallback, useId, useMemo } from 'react';
|
||||
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
export default function DateTimeField({
|
||||
function DateTimeField({
|
||||
controller,
|
||||
definition
|
||||
}: Readonly<{
|
||||
|
|
@ -73,3 +73,5 @@ export default function DateTimeField({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DateTimeField);
|
||||
|
|
|
|||
|
|
@ -121,6 +121,15 @@ export function ApiFormField({
|
|||
[fieldName, definition]
|
||||
);
|
||||
|
||||
// Stable wrapper so the identity passed to leaf field components does not
|
||||
// change unless onKeyDown itself changes (onKeyDown may be undefined)
|
||||
const safeOnKeyDown = useCallback(
|
||||
(value: any) => {
|
||||
onKeyDown?.(value);
|
||||
},
|
||||
[onKeyDown]
|
||||
);
|
||||
|
||||
// Construct the individual field
|
||||
const fieldInstance = useMemo(() => {
|
||||
switch (fieldDefinition.field_type) {
|
||||
|
|
@ -177,9 +186,7 @@ export function ApiFormField({
|
|||
controller={controller}
|
||||
fieldName={fieldName}
|
||||
onChange={onChange}
|
||||
onKeyDown={(value) => {
|
||||
onKeyDown?.(value);
|
||||
}}
|
||||
onKeyDown={safeOnKeyDown}
|
||||
/>
|
||||
);
|
||||
case 'password':
|
||||
|
|
@ -189,9 +196,7 @@ export function ApiFormField({
|
|||
controller={controller}
|
||||
fieldName={fieldName}
|
||||
onChange={onChange}
|
||||
onKeyDown={(value) => {
|
||||
onKeyDown?.(value);
|
||||
}}
|
||||
onKeyDown={safeOnKeyDown}
|
||||
/>
|
||||
);
|
||||
case 'icon':
|
||||
|
|
@ -204,9 +209,7 @@ export function ApiFormField({
|
|||
controller={controller}
|
||||
definition={reducedDefinition}
|
||||
fieldName={fieldName}
|
||||
onChange={(value: boolean) => {
|
||||
onChange(value);
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
|
|
@ -231,9 +234,7 @@ export function ApiFormField({
|
|||
fieldDefinition.placeholderWarningCompare ?? undefined
|
||||
}
|
||||
placeholderWarning={fieldDefinition.placeholderWarning ?? undefined}
|
||||
onChange={(value: any) => {
|
||||
onChange(value);
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'choice':
|
||||
|
|
@ -311,7 +312,7 @@ export function ApiFormField({
|
|||
fieldName,
|
||||
fieldDefinition,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
safeOnKeyDown,
|
||||
reducedDefinition,
|
||||
ref,
|
||||
setFields,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { isTrue } from '@lib/functions/Conversion';
|
|||
import type { ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { Switch } from '@mantine/core';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react';
|
||||
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
export function BooleanField({
|
||||
function BooleanFieldComponent({
|
||||
controller,
|
||||
definition,
|
||||
fieldName,
|
||||
|
|
@ -37,6 +37,11 @@ export function BooleanField({
|
|||
return isTrue(value ?? definition.default ?? false);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: any) => onChange(event.currentTarget.checked || false),
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
{...definition}
|
||||
|
|
@ -47,7 +52,9 @@ export function BooleanField({
|
|||
radius='lg'
|
||||
size='sm'
|
||||
error={definition.error ?? error?.message}
|
||||
onChange={(event: any) => onChange(event.currentTarget.checked || false)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const BooleanField = memo(BooleanFieldComponent);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import type { ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { Select } from '@mantine/core';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
/**
|
||||
* Render a 'select' field for selecting from a list of choices
|
||||
*/
|
||||
export function ChoiceField({
|
||||
function ChoiceFieldComponent({
|
||||
controller,
|
||||
definition,
|
||||
fieldName
|
||||
|
|
@ -64,6 +64,8 @@ export function ChoiceField({
|
|||
}
|
||||
}, [value]);
|
||||
|
||||
const onDropdownOpen = useCallback(() => inputRef?.current?.select(), []);
|
||||
|
||||
return (
|
||||
<Select
|
||||
id={fieldId}
|
||||
|
|
@ -72,7 +74,7 @@ export function ChoiceField({
|
|||
radius='sm'
|
||||
{...field}
|
||||
ref={inputRef}
|
||||
onDropdownOpen={() => inputRef?.current?.select()}
|
||||
onDropdownOpen={onDropdownOpen}
|
||||
onChange={onChange}
|
||||
data={choices}
|
||||
value={choiceValue}
|
||||
|
|
@ -88,3 +90,5 @@ export function ChoiceField({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChoiceField = memo(ChoiceFieldComponent);
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import { t } from '@lingui/core/macro';
|
|||
import { DateInput } from '@mantine/dates';
|
||||
import dayjs from 'dayjs';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
import { useCallback, useId, useMemo } from 'react';
|
||||
import { memo, useCallback, useId, useMemo } from 'react';
|
||||
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
export default function DateField({
|
||||
function DateField({
|
||||
controller,
|
||||
definition
|
||||
}: Readonly<{
|
||||
|
|
@ -76,3 +76,5 @@ export default function DateField({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DateField);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo } from 'react';
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
type Control,
|
||||
type FieldValues,
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from '../../../functions/forms';
|
||||
import { ApiFormField } from './ApiFormField';
|
||||
|
||||
export function DependentField({
|
||||
function DependentFieldComponent({
|
||||
control,
|
||||
fieldName,
|
||||
definition,
|
||||
|
|
@ -51,14 +51,15 @@ export function DependentField({
|
|||
extractAvailableFields(res, 'POST');
|
||||
|
||||
// update the fields in the form state with the new fields
|
||||
// (preserve the reference for any field that did not actually change,
|
||||
// so unrelated fields don't re-render on every dependent-field update)
|
||||
setFields((prevFields) => {
|
||||
const newFields: Record<string, ReturnType<typeof constructField>> = {};
|
||||
const newFields: ApiFormFieldSet = {};
|
||||
|
||||
for (const [k, v] of Object.entries(prevFields)) {
|
||||
newFields[k] = constructField({
|
||||
field: v,
|
||||
definition: fields?.[k]
|
||||
});
|
||||
newFields[k] = fields?.[k]
|
||||
? constructField({ field: v, definition: fields[k] })
|
||||
: v;
|
||||
}
|
||||
|
||||
return newFields;
|
||||
|
|
@ -89,3 +90,5 @@ export function DependentField({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const DependentField = memo(DependentFieldComponent);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,14 @@ import {
|
|||
import { useDebouncedValue, useElementSize } from '@mantine/hooks';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import Fuse from 'fuse.js';
|
||||
import { startTransition, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
memo,
|
||||
startTransition,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
import { FixedSizeGrid as Grid } from 'react-window';
|
||||
|
||||
|
|
@ -26,7 +33,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||
import { useIconState } from '../../../states/IconState';
|
||||
import { ApiIcon } from '../../items/ApiIcon';
|
||||
|
||||
export default function IconField({
|
||||
function IconField({
|
||||
controller,
|
||||
definition
|
||||
}: Readonly<{
|
||||
|
|
@ -53,6 +60,7 @@ export default function IconField({
|
|||
description={definition.description}
|
||||
required={definition.required}
|
||||
error={definition.error ?? error?.message}
|
||||
aria-label={`icon-field-${field.name}`}
|
||||
ref={field.ref}
|
||||
component='button'
|
||||
type='button'
|
||||
|
|
@ -99,6 +107,8 @@ export default function IconField({
|
|||
);
|
||||
}
|
||||
|
||||
export default memo(IconField);
|
||||
|
||||
type RenderIconType = {
|
||||
package: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { Accordion, Divider, Stack, Text } from '@mantine/core';
|
||||
import { memo } from 'react';
|
||||
import type { Control, FieldValues } from 'react-hook-form';
|
||||
import { ApiFormField } from './ApiFormField';
|
||||
|
||||
export function NestedObjectField({
|
||||
function NestedObjectFieldComponent({
|
||||
control,
|
||||
fieldName,
|
||||
definition,
|
||||
|
|
@ -43,3 +44,5 @@ export function NestedObjectField({
|
|||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
export const NestedObjectField = memo(NestedObjectFieldComponent);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NumberInput } from '@mantine/core';
|
||||
import { useId, useMemo } from 'react';
|
||||
import { memo, useCallback, useId, useMemo } from 'react';
|
||||
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
import AutoFillRightSection, { AutoFillWarning } from './AutoFillRightSection';
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ import AutoFillRightSection, { AutoFillWarning } from './AutoFillRightSection';
|
|||
* Custom implementation of the mantine <NumberInput> component,
|
||||
* used for rendering numerical input fields in forms.
|
||||
*/
|
||||
export default function NumberField({
|
||||
function NumberField({
|
||||
controller,
|
||||
fieldName,
|
||||
definition,
|
||||
|
|
@ -97,6 +97,17 @@ export default function NumberField({
|
|||
onChange
|
||||
]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: number | string | null) => {
|
||||
if (value != null && value.toString().trim() === '') {
|
||||
onChange(null);
|
||||
} else {
|
||||
onChange(value);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<NumberInput
|
||||
{...definition}
|
||||
|
|
@ -108,14 +119,10 @@ export default function NumberField({
|
|||
value={numericalValue === null ? '' : numericalValue}
|
||||
decimalScale={definition.field_type == 'integer' ? 0 : 10}
|
||||
step={1}
|
||||
onChange={(value: number | string | null) => {
|
||||
if (value != null && value.toString().trim() === '') {
|
||||
onChange(null);
|
||||
} else {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
onChange={handleChange}
|
||||
rightSection={rightSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(NumberField);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useDebouncedValue, useId } from '@mantine/hooks';
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
type ReactNode,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
|
|
@ -47,7 +48,7 @@ import { RenderInstance } from '../../render/Instance';
|
|||
/**
|
||||
* Render a 'select' field for searching the database against a particular model type
|
||||
*/
|
||||
export function RelatedModelField({
|
||||
function RelatedModelFieldComponent({
|
||||
controller,
|
||||
fieldName,
|
||||
definition,
|
||||
|
|
@ -285,6 +286,13 @@ export function RelatedModelField({
|
|||
field.value
|
||||
]);
|
||||
|
||||
// Track which id we've already requested (or are currently requesting),
|
||||
// so that if this effect re-fires for an unrelated reason - e.g. a new
|
||||
// `definition.filters`/`definition.api_url` reference - before the
|
||||
// in-flight request for the same id resolves and updates `pk`, it doesn't
|
||||
// issue a duplicate API call for that same id.
|
||||
const requestedIdRef = useRef<number | string | null>(null);
|
||||
|
||||
// If an initial value is provided, load from the API
|
||||
useEffect(() => {
|
||||
// If the value is unchanged, do nothing
|
||||
|
|
@ -293,8 +301,11 @@ export function RelatedModelField({
|
|||
const id = pk || field.value;
|
||||
|
||||
if (id !== null && id !== undefined && id !== '') {
|
||||
if (requestedIdRef.current === id) return;
|
||||
requestedIdRef.current = id;
|
||||
fetchSingleField(id);
|
||||
} else {
|
||||
requestedIdRef.current = null;
|
||||
setPk(null);
|
||||
}
|
||||
}, [
|
||||
|
|
@ -571,6 +582,8 @@ export function RelatedModelField({
|
|||
);
|
||||
}
|
||||
|
||||
export const RelatedModelField = memo(RelatedModelFieldComponent);
|
||||
|
||||
function InlineCreateButton({
|
||||
definition,
|
||||
modelInfo,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
import { identifierString } from '@lib/functions/Conversion';
|
||||
import type { ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { isEquivalent } from '../../../functions/comparison';
|
||||
import { InvenTreeIcon } from '../../../functions/icons';
|
||||
import { StandaloneField } from '../StandaloneField';
|
||||
|
||||
|
|
@ -97,42 +98,13 @@ function TableFieldRow({
|
|||
}
|
||||
|
||||
// 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) &&
|
||||
isEquivalent(previousProps.item, nextProps.item) &&
|
||||
isEquivalent(previousProps.rowErrors, nextProps.rowErrors) &&
|
||||
previousProps.modelRenderer === nextProps.modelRenderer &&
|
||||
previousProps.changeFn === nextProps.changeFn &&
|
||||
previousProps.removeFn === nextProps.removeFn &&
|
||||
|
|
@ -388,8 +360,8 @@ export const TableField = memo(
|
|||
return (
|
||||
previousProps.definition === nextProps.definition &&
|
||||
previousProps.fieldName === nextProps.fieldName &&
|
||||
areShallowEqual(previousProps.value, nextProps.value) &&
|
||||
areShallowEqual(previousProps.error, nextProps.error) &&
|
||||
isEquivalent(previousProps.value, nextProps.value) &&
|
||||
isEquivalent(previousProps.error, nextProps.error) &&
|
||||
previousProps.onChange === nextProps.onChange
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { TagsInput } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
|
|
@ -9,7 +9,7 @@ import { apiUrl } from '@lib/functions/Api';
|
|||
import type { ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { api } from '../../../App';
|
||||
|
||||
export default function TagsField({
|
||||
function TagsField({
|
||||
controller,
|
||||
definition
|
||||
}: Readonly<{
|
||||
|
|
@ -79,3 +79,5 @@ export default function TagsField({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TagsField);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { TextInput } from '@mantine/core';
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react';
|
||||
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
import AutoFillRightSection from './AutoFillRightSection';
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ import AutoFillRightSection from './AutoFillRightSection';
|
|||
* used for rendering text input fields in forms.
|
||||
* Uses a debounced value to prevent excessive re-renders.
|
||||
*/
|
||||
export default function TextField({
|
||||
function TextField({
|
||||
controller,
|
||||
fieldName,
|
||||
definition,
|
||||
|
|
@ -29,7 +29,7 @@ export default function TextField({
|
|||
fieldState: { error }
|
||||
} = controller;
|
||||
|
||||
const { value } = useMemo(() => field, [field]);
|
||||
const { value } = field;
|
||||
|
||||
const [textValue, setTextValue] = useState<string>(value || '');
|
||||
|
||||
|
|
@ -91,3 +91,5 @@ export default function TextField({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TextField);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
IconX
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ import { ModelHoverCard } from '../../render/ModelHoverCard';
|
|||
* endpoint. Supports server-side search, lazy child loading, and (when a model
|
||||
* type is provided) barcode scanning and a hover-card navigate link.
|
||||
*/
|
||||
export function TreeField({
|
||||
function TreeFieldComponent({
|
||||
controller,
|
||||
definition,
|
||||
fieldName,
|
||||
|
|
@ -497,3 +497,5 @@ export function TreeField({
|
|||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const TreeField = memo(TreeFieldComponent);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
/** Generic value-comparison helpers, used to keep memoized props/state stable */
|
||||
|
||||
function isReactElement(value: any): boolean {
|
||||
return (
|
||||
value != null &&
|
||||
typeof value === 'object' &&
|
||||
(value.$$typeof === Symbol.for('react.element') ||
|
||||
value.$$typeof === Symbol.for('react.transitional.element'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively compare two values for structural equivalence.
|
||||
*
|
||||
* Useful when comparing generated data (e.g. field definitions rebuilt by a
|
||||
* `useMemo`, or table row data) that may get a fresh object/array/JSX-element
|
||||
* reference on every recompute even though nothing meaningful changed -
|
||||
* without this, every consumer downstream would be treated as "changed" and
|
||||
* re-render unnecessarily.
|
||||
*
|
||||
* Functions are deliberately compared by reference only - unlike a JSX icon,
|
||||
* a callback's behavior can depend on values it closes over, so we can't
|
||||
* assume two function instances are interchangeable.
|
||||
*/
|
||||
export function isEquivalent(a: any, b: any): boolean {
|
||||
if (Object.is(a, b)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isReactElement(a) && isReactElement(b)) {
|
||||
return a.type === b.type && isEquivalent(a.props, b.props);
|
||||
}
|
||||
|
||||
if (typeof a === 'function' || typeof b === 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
return a.every((item, idx) => isEquivalent(item, b[idx]));
|
||||
}
|
||||
|
||||
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) {
|
||||
return false;
|
||||
}
|
||||
return aKeys.every((key) => isEquivalent(a[key], b[key]));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
@ -516,6 +516,26 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
|
|||
await page.getByText('Room 101').first().waitFor();
|
||||
await page.getByText('Mechanical Lab').first().waitFor();
|
||||
|
||||
// Editing the quantity for one row should not affect any other row
|
||||
// (regression test for per-row TableField memoization)
|
||||
const quantityInputs = page.getByRole('textbox', {
|
||||
name: 'number-field-quantity'
|
||||
});
|
||||
|
||||
// .count() does not auto-wait, so explicitly wait for the second row's
|
||||
// input to be ready before relying on the total count being stable
|
||||
await expect(quantityInputs.nth(1)).toBeVisible();
|
||||
|
||||
const rowCount = await quantityInputs.count();
|
||||
expect(rowCount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await quantityInputs.nth(0).fill('11');
|
||||
await quantityInputs.nth(1).fill('22');
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await expect(quantityInputs.nth(0)).toHaveValue('11');
|
||||
await expect(quantityInputs.nth(1)).toHaveValue('22');
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Let's actually receive an item (with custom values)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createApi } from './api';
|
|||
/** Unit tests for form validation, rendering, etc */
|
||||
import { expect, test } from './baseFixtures';
|
||||
import { stevenuser } from './defaults';
|
||||
import { navigate, openDetailAction } from './helpers';
|
||||
import { clickOnRowMenu, loadTab, navigate, openDetailAction } from './helpers';
|
||||
import { doCachedLogin } from './login';
|
||||
|
||||
// Test hover form action in related fields
|
||||
|
|
@ -202,3 +202,116 @@ test('Forms - Keep form open option', async ({ browser }) => {
|
|||
await page.getByText('Item Created').waitFor();
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Forms - DateTime Field', async ({ browser }) => {
|
||||
// The "started_datetime" / "finished_datetime" test-result fields are only
|
||||
// shown when the "TEST_STATION_DATA" global setting is enabled
|
||||
const api = await createApi({});
|
||||
const settingUrl = 'settings/global/TEST_STATION_DATA/';
|
||||
|
||||
const enableResponse = await api.patch(settingUrl, {
|
||||
data: { value: 'true' }
|
||||
});
|
||||
expect(enableResponse.status()).toBe(200);
|
||||
|
||||
const page = await doCachedLogin(browser, {
|
||||
user: stevenuser,
|
||||
url: 'stock/item/1194/test_results'
|
||||
});
|
||||
await page.waitForURL('**/web/stock/**');
|
||||
await loadTab(page, 'Test Results');
|
||||
|
||||
const cell = page.getByText('395c6d5586e5fb656901d047be27e1f7');
|
||||
await clickOnRowMenu(cell);
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
// Set a value via the Mantine DateTimePicker popup - a button which opens a
|
||||
// calendar (for the date) plus separate hour/minute/second spinbuttons
|
||||
const setDateTime = async (
|
||||
fieldLabel: string,
|
||||
day: string,
|
||||
hour: string,
|
||||
minute: string
|
||||
) => {
|
||||
const field = page.getByLabel(fieldLabel);
|
||||
await expect(field).toBeVisible();
|
||||
await field.click();
|
||||
await page.getByRole('button', { name: day }).click();
|
||||
|
||||
const spinbuttons = page.getByRole('spinbutton');
|
||||
await spinbuttons.nth(0).fill(hour);
|
||||
await spinbuttons.nth(1).fill(minute);
|
||||
await spinbuttons.nth(2).fill('00');
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
};
|
||||
|
||||
await setDateTime(
|
||||
'date-time-field-started_datetime',
|
||||
'10 March 2024',
|
||||
'10',
|
||||
'30'
|
||||
);
|
||||
await setDateTime(
|
||||
'date-time-field-finished_datetime',
|
||||
'11 March 2024',
|
||||
'11',
|
||||
'45'
|
||||
);
|
||||
|
||||
await expect(page.getByLabel('date-time-field-started_datetime')).toHaveText(
|
||||
'2024-03-10 10:30:00'
|
||||
);
|
||||
await expect(page.getByLabel('date-time-field-finished_datetime')).toHaveText(
|
||||
'2024-03-11 11:45:00'
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Test result updated').waitFor();
|
||||
|
||||
// Restore the global setting to its default value
|
||||
const disableResponse = await api.patch(settingUrl, {
|
||||
data: { value: 'false' }
|
||||
});
|
||||
expect(disableResponse.status()).toBe(200);
|
||||
});
|
||||
|
||||
// Test the "nested object" field type, via the "Initial Supplier" section
|
||||
// of the "Create Part" form
|
||||
test('Forms - Nested Object Field', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
user: stevenuser,
|
||||
url: 'part/category/index/parts'
|
||||
});
|
||||
await page.waitForURL('**/part/category/index/**');
|
||||
|
||||
await page.getByRole('button', { name: 'action-menu-add-parts' }).click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'action-menu-add-parts-create-part' })
|
||||
.click();
|
||||
|
||||
// Generate a unique part name
|
||||
const partName = `Test Part ${new Date().getTime()}`;
|
||||
await page.getByLabel('text-field-name', { exact: true }).fill(partName);
|
||||
|
||||
// The "Initial Supplier" nested-object field should be visible and expanded by default
|
||||
await page.getByText('Supplier Information').waitFor();
|
||||
|
||||
const supplierField = page.getByLabel(
|
||||
'related-field-initial_supplier.supplier'
|
||||
);
|
||||
await expect(supplierField).toBeVisible();
|
||||
await supplierField.fill('Mouser');
|
||||
await page.getByText('Mouser Electronics').first().click();
|
||||
|
||||
const skuValue = `SKU-${new Date().getTime()}`;
|
||||
await page
|
||||
.getByLabel('text-field-initial_supplier.sku', { exact: true })
|
||||
.fill(skuValue);
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// Confirm the part was created with the expected name
|
||||
await page.getByText(partName).first().waitFor();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue