Files
InvenTree/src/frontend/src/components/forms/fields/ApiFormField.tsx
T
Oliver 3383e865cb [UI] Fixes for "tags" field (#13233)
- Fetch appropriate 'tags' detail in the form request
- Additional playwright tests
2026-11-09 23:42:12 +11:00

345 lines
9.7 KiB
TypeScript

import { t } from '@lingui/core/macro';
import { Alert, FileInput, Stack } from '@mantine/core';
import { useId } from '@mantine/hooks';
import { useCallback, useEffect, useMemo } from 'react';
import { type Control, type FieldValues, useController } from 'react-hook-form';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { IconFileUpload } from '@tabler/icons-react';
import type { NavigateFunction } from 'react-router-dom';
import DateTimeField from '../DateTimeField';
import { BooleanField } from './BooleanField';
import { ChoiceField } from './ChoiceField';
import DateField from './DateField';
import { DependentField } from './DependentField';
import IconField from './IconField';
import { JsonField } from './JsonField';
import { NestedObjectField } from './NestedObjectField';
import NumberField from './NumberField';
import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField';
import TagsField from './TagsField';
import TextField from './TextField';
import { TreeField } from './TreeField';
/**
* Render an individual form field
*/
export function ApiFormField({
fieldName,
definition,
control,
hideLabels,
navigate,
url,
setFields,
onKeyDown
}: Readonly<{
fieldName: string;
definition: ApiFormFieldType;
control: Control<FieldValues, any>;
hideLabels?: boolean;
navigate?: NavigateFunction | null;
url?: string;
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
onKeyDown?: (value: any) => void;
}>) {
const fieldId = useId();
const controller = useController({
name: fieldName,
control
});
const {
field,
fieldState: { error }
} = controller;
const { value, ref } = field;
useEffect(() => {
if (
definition.field_type === 'nested object' ||
definition.field_type === 'dependent field'
)
return;
// hook up the value state to the input field
if (definition.value !== undefined) {
field.onChange(
definition.adjustValue
? definition.adjustValue(definition.value)
: definition.value
);
}
}, [definition.value, definition.field_type]);
const fieldDefinition: ApiFormFieldType = useMemo(() => {
return {
...definition,
label: hideLabels ? undefined : definition.label,
description: hideLabels ? undefined : definition.description
};
}, [hideLabels, definition]);
// pull out onValueChange as this can cause strange errors when passing the
// definition to the input components via spread syntax
const reducedDefinition = useMemo(() => {
return {
...fieldDefinition,
autoFill: undefined,
placeholderAutofill: undefined,
placeholderWarning: undefined,
placeholderWarningCompare: undefined,
singleFetchFunction: undefined,
fetchParams: undefined,
autoFillFilters: undefined,
onValueChange: undefined,
adjustFilters: undefined,
adjustValue: undefined,
allow_blank: undefined,
allow_null: undefined,
read_only: undefined,
children: undefined,
exclude: undefined
};
}, [fieldDefinition]);
// Callback helper when form value changes
const onChange = useCallback(
(value: any) => {
let rtnValue: any = value;
// Allow for custom value adjustments (per field)
if (definition.adjustValue) {
rtnValue = definition.adjustValue(value);
}
field.onChange(rtnValue);
// Run custom callback for this field
definition.onValueChange?.(rtnValue);
},
[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) {
case 'related field':
if (
fieldDefinition.api_url === apiUrl(ApiEndpoints.stock_location_list)
) {
// Redirect location fields to the appropriate tree field
return (
<TreeField
controller={controller}
definition={fieldDefinition}
fieldName={fieldName}
endpoint={ApiEndpoints.stock_location_tree}
childIdentifier='sublocations'
genericPlaceholder={t`Select location`}
model={ModelType.stocklocation}
navigate={navigate}
/>
);
} else if (
fieldDefinition.api_url === apiUrl(ApiEndpoints.category_list)
) {
// Redirect category fields to the appropriate tree field
return (
<TreeField
controller={controller}
definition={fieldDefinition}
fieldName={fieldName}
endpoint={ApiEndpoints.category_tree}
childIdentifier='subcategories'
genericPlaceholder={t`Select category`}
model={ModelType.partcategory}
navigate={navigate}
/>
);
} else {
return (
<RelatedModelField
definition={fieldDefinition}
controller={controller}
fieldName={fieldName}
navigate={navigate}
/>
);
}
case 'email':
case 'url':
case 'string':
return (
<TextField
definition={reducedDefinition}
placeholderAutofill={fieldDefinition.placeholderAutofill ?? false}
controller={controller}
fieldName={fieldName}
onChange={onChange}
onKeyDown={safeOnKeyDown}
/>
);
case 'password':
return (
<TextField
definition={{ ...reducedDefinition, type: 'password' }}
controller={controller}
fieldName={fieldName}
onChange={onChange}
onKeyDown={safeOnKeyDown}
/>
);
case 'icon':
return (
<IconField definition={fieldDefinition} controller={controller} />
);
case 'boolean':
return (
<BooleanField
controller={controller}
definition={reducedDefinition}
fieldName={fieldName}
onChange={onChange}
/>
);
case 'date':
return (
<DateField controller={controller} definition={fieldDefinition} />
);
case 'datetime':
return (
<DateTimeField controller={controller} definition={fieldDefinition} />
);
case 'integer':
case 'decimal':
case 'float':
case 'number':
return (
<NumberField
controller={controller}
fieldName={fieldName}
definition={reducedDefinition}
placeholderAutofill={fieldDefinition.placeholderAutofill ?? false}
placeholderWarningCompare={
fieldDefinition.placeholderWarningCompare ?? undefined
}
placeholderWarning={fieldDefinition.placeholderWarning ?? undefined}
onChange={onChange}
/>
);
case 'choice':
return (
<ChoiceField
controller={controller}
fieldName={fieldName}
definition={fieldDefinition}
/>
);
case 'file upload':
return (
<FileInput
{...reducedDefinition}
clearable={!definition.required}
aria-label={`file-field-${fieldName}`}
placeholder={definition.placeholder ?? t`Select file to upload`}
leftSection={<IconFileUpload />}
id={fieldId}
ref={field.ref}
radius='sm'
value={value}
error={definition.error ?? error?.message}
onChange={(payload: File | null) => onChange(payload)}
/>
);
case 'nested object':
return (
<NestedObjectField
definition={fieldDefinition}
fieldName={fieldName}
control={control}
url={url}
setFields={setFields}
/>
);
case 'dependent field':
return (
<DependentField
definition={fieldDefinition}
fieldName={fieldName}
control={control}
url={url}
setFields={setFields}
/>
);
case 'table':
return (
<TableField
definition={fieldDefinition}
fieldName={fieldName}
value={value}
onChange={field.onChange}
error={error}
/>
);
case 'tags':
return (
<TagsField controller={controller} definition={fieldDefinition} />
);
case 'json':
return (
<JsonField
controller={controller}
definition={fieldDefinition}
fieldName={fieldName}
onChange={onChange}
/>
);
default:
return (
<Alert color='red' title={t`Error`}>
Invalid field type for field '{fieldName}': '
{fieldDefinition.field_type}'
</Alert>
);
}
}, [
control,
controller,
definition,
field,
fieldId,
fieldName,
fieldDefinition,
onChange,
safeOnKeyDown,
reducedDefinition,
ref,
setFields,
value
]);
if (fieldDefinition.hidden) {
return null;
}
return (
<Stack>
{definition.preFieldContent}
{fieldInstance}
{definition.postFieldContent}
</Stack>
);
}