mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
PUI form framework: dependent field (#7584)
* PUI form framework: implement the dependent field * fix: tests * fix: hook * fix: tests * --no-verify trigger: ci * --noVerify trigger: ci
This commit is contained in:
parent
1017ff0605
commit
9a6cfb4309
@ -98,6 +98,7 @@ export function PrintingActions({
|
||||
onClose: () => {
|
||||
setPluginKey('');
|
||||
},
|
||||
submitText: t`Print`,
|
||||
successMessage: t`Label printing completed successfully`,
|
||||
onFormSuccess: (response: any) => {
|
||||
setPluginKey('');
|
||||
@ -136,6 +137,7 @@ export function PrintingActions({
|
||||
value: items
|
||||
}
|
||||
},
|
||||
submitText: t`Generate`,
|
||||
successMessage: t`Report printing completed successfully`,
|
||||
onFormSuccess: (response: any) => {
|
||||
if (!response.complete) {
|
||||
|
@ -204,8 +204,11 @@ export function ApiForm({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return props.fields ?? {};
|
||||
const [fields, setFields] = useState<ApiFormFieldSet>(
|
||||
() => props.fields ?? {}
|
||||
);
|
||||
useEffect(() => {
|
||||
setFields(props.fields ?? {});
|
||||
}, [props.fields]);
|
||||
|
||||
const defaultValues: FieldValues = useMemo(() => {
|
||||
@ -543,6 +546,8 @@ export function ApiForm({
|
||||
fieldName={fieldName}
|
||||
definition={field}
|
||||
control={form.control}
|
||||
url={url}
|
||||
setFields={setFields}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
@ -17,6 +17,7 @@ import { ModelType } from '../../../enums/ModelType';
|
||||
import { isTrue } from '../../../functions/conversion';
|
||||
import { ChoiceField } from './ChoiceField';
|
||||
import DateField from './DateField';
|
||||
import { DependentField } from './DependentField';
|
||||
import { NestedObjectField } from './NestedObjectField';
|
||||
import { RelatedModelField } from './RelatedModelField';
|
||||
import { TableField } from './TableField';
|
||||
@ -74,12 +75,14 @@ export type ApiFormFieldType = {
|
||||
| 'choice'
|
||||
| 'file upload'
|
||||
| 'nested object'
|
||||
| 'dependent field'
|
||||
| 'table';
|
||||
api_url?: string;
|
||||
pk_field?: string;
|
||||
model?: ModelType;
|
||||
modelRenderer?: (instance: any) => ReactNode;
|
||||
filters?: any;
|
||||
child?: ApiFormFieldType;
|
||||
children?: { [key: string]: ApiFormFieldType };
|
||||
required?: boolean;
|
||||
choices?: any[];
|
||||
@ -94,6 +97,7 @@ export type ApiFormFieldType = {
|
||||
onValueChange?: (value: any, record?: any) => void;
|
||||
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
|
||||
headers?: string[];
|
||||
depends_on?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@ -103,12 +107,16 @@ export function ApiFormField({
|
||||
fieldName,
|
||||
definition,
|
||||
control,
|
||||
hideLabels
|
||||
hideLabels,
|
||||
url,
|
||||
setFields
|
||||
}: {
|
||||
fieldName: string;
|
||||
definition: ApiFormFieldType;
|
||||
control: Control<FieldValues, any>;
|
||||
hideLabels?: boolean;
|
||||
url?: string;
|
||||
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
|
||||
}) {
|
||||
const fieldId = useId();
|
||||
const controller = useController({
|
||||
@ -122,7 +130,11 @@ export function ApiFormField({
|
||||
const { value, ref } = field;
|
||||
|
||||
useEffect(() => {
|
||||
if (definition.field_type === 'nested object') return;
|
||||
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) {
|
||||
@ -291,6 +303,18 @@ export function ApiFormField({
|
||||
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':
|
||||
|
89
src/frontend/src/components/forms/fields/DependentField.tsx
Normal file
89
src/frontend/src/components/forms/fields/DependentField.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Control, FieldValues, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { api } from '../../../App';
|
||||
import {
|
||||
constructField,
|
||||
extractAvailableFields
|
||||
} from '../../../functions/forms';
|
||||
import {
|
||||
ApiFormField,
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType
|
||||
} from './ApiFormField';
|
||||
|
||||
export function DependentField({
|
||||
control,
|
||||
fieldName,
|
||||
definition,
|
||||
url,
|
||||
setFields
|
||||
}: {
|
||||
control: Control<FieldValues, any>;
|
||||
definition: ApiFormFieldType;
|
||||
fieldName: string;
|
||||
url?: string;
|
||||
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
|
||||
}) {
|
||||
const { watch, resetField } = useFormContext();
|
||||
|
||||
const mappedFieldNames = useMemo(
|
||||
() =>
|
||||
(definition.depends_on ?? []).map((f) =>
|
||||
[...fieldName.split('.').slice(0, -1), f].join('.')
|
||||
),
|
||||
[definition.depends_on]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = watch(async (values, { name }) => {
|
||||
// subscribe only to the fields that this field depends on
|
||||
if (!name || !mappedFieldNames.includes(name)) return;
|
||||
if (!url || !setFields) return;
|
||||
|
||||
const res = await api.options(url, {
|
||||
data: values // provide the current form state to the API
|
||||
});
|
||||
|
||||
const fields: Record<string, ApiFormFieldType> | null =
|
||||
extractAvailableFields(res, 'POST');
|
||||
|
||||
// update the fields in the form state with the new fields
|
||||
setFields((prevFields) => {
|
||||
const newFields: Record<string, ReturnType<typeof constructField>> = {};
|
||||
|
||||
for (const [k, v] of Object.entries(prevFields)) {
|
||||
newFields[k] = constructField({
|
||||
field: v,
|
||||
definition: fields?.[k]
|
||||
});
|
||||
}
|
||||
|
||||
return newFields;
|
||||
});
|
||||
|
||||
// reset the current field and all nested values with undefined
|
||||
resetField(fieldName, {
|
||||
defaultValue: undefined,
|
||||
keepDirty: true,
|
||||
keepTouched: true
|
||||
});
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [mappedFieldNames, url, setFields, resetField, fieldName]);
|
||||
|
||||
if (!definition.child) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ApiFormField
|
||||
control={control}
|
||||
fieldName={fieldName}
|
||||
definition={definition.child}
|
||||
url={url}
|
||||
setFields={setFields}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,16 +1,24 @@
|
||||
import { Accordion, Divider, Stack, Text } from '@mantine/core';
|
||||
import { Control, FieldValues } from 'react-hook-form';
|
||||
|
||||
import { ApiFormField, ApiFormFieldType } from './ApiFormField';
|
||||
import {
|
||||
ApiFormField,
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType
|
||||
} from './ApiFormField';
|
||||
|
||||
export function NestedObjectField({
|
||||
control,
|
||||
fieldName,
|
||||
definition
|
||||
definition,
|
||||
url,
|
||||
setFields
|
||||
}: {
|
||||
control: Control<FieldValues, any>;
|
||||
definition: ApiFormFieldType;
|
||||
fieldName: string;
|
||||
url?: string;
|
||||
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
|
||||
}) {
|
||||
return (
|
||||
<Accordion defaultValue={'OpenByDefault'} variant="contained">
|
||||
@ -28,6 +36,8 @@ export function NestedObjectField({
|
||||
fieldName={`${fieldName}.${childFieldName}`}
|
||||
definition={field}
|
||||
control={control}
|
||||
url={url}
|
||||
setFields={setFields}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
@ -64,32 +64,41 @@ export function extractAvailableFields(
|
||||
return null;
|
||||
}
|
||||
|
||||
const processField = (field: any, fieldName: string) => {
|
||||
const resField: ApiFormFieldType = {
|
||||
...field,
|
||||
name: fieldName,
|
||||
field_type: field.type,
|
||||
description: field.help_text,
|
||||
value: field.value ?? field.default,
|
||||
disabled: field.read_only ?? false
|
||||
};
|
||||
|
||||
// Remove the 'read_only' field - plays havoc with react components
|
||||
delete resField.read_only;
|
||||
|
||||
if (resField.field_type === 'nested object' && resField.children) {
|
||||
resField.children = processFields(resField.children, fieldName);
|
||||
}
|
||||
|
||||
if (resField.field_type === 'dependent field' && resField.child) {
|
||||
resField.child = processField(resField.child, fieldName);
|
||||
|
||||
// copy over the label from the dependent field to the child field
|
||||
if (!resField.child.label) {
|
||||
resField.child.label = resField.label;
|
||||
}
|
||||
}
|
||||
|
||||
return resField;
|
||||
};
|
||||
|
||||
const processFields = (fields: any, _path?: string) => {
|
||||
const _fields: ApiFormFieldSet = {};
|
||||
|
||||
for (const [fieldName, field] of Object.entries(fields) as any) {
|
||||
const path = _path ? `${_path}.${fieldName}` : fieldName;
|
||||
_fields[fieldName] = {
|
||||
...field,
|
||||
name: path,
|
||||
field_type: field.type,
|
||||
description: field.help_text,
|
||||
value: field.value ?? field.default,
|
||||
disabled: field.read_only ?? false
|
||||
};
|
||||
|
||||
// Remove the 'read_only' field - plays havoc with react components
|
||||
delete _fields[fieldName].read_only;
|
||||
|
||||
if (
|
||||
_fields[fieldName].field_type === 'nested object' &&
|
||||
_fields[fieldName].children
|
||||
) {
|
||||
_fields[fieldName].children = processFields(
|
||||
_fields[fieldName].children,
|
||||
path
|
||||
);
|
||||
}
|
||||
_fields[fieldName] = processField(field, path);
|
||||
}
|
||||
|
||||
return _fields;
|
||||
@ -153,6 +162,14 @@ export function constructField({
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'dependent field':
|
||||
if (!definition?.child) break;
|
||||
|
||||
def.child = constructField({
|
||||
// use the raw definition here as field, since a dependent field cannot be influenced by the frontend
|
||||
field: definition.child ?? {}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -36,9 +36,9 @@ test('PUI - Label Printing', async ({ page }) => {
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Submit the form (second time should result in success)
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
// Submit the print form (second time should result in success)
|
||||
await page.getByRole('button', { name: 'Print', exact: true }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Print', exact: true }).click();
|
||||
|
||||
await page.locator('#form-success').waitFor();
|
||||
await page.getByText('Label printing completed').waitFor();
|
||||
@ -71,9 +71,9 @@ test('PUI - Report Printing', async ({ page }) => {
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Submit the form (should result in success)
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
// Submit the print form (should result in success)
|
||||
await page.getByRole('button', { name: 'Generate', exact: true }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Generate', exact: true }).click();
|
||||
|
||||
await page.locator('#form-success').waitFor();
|
||||
await page.getByText('Report printing completed').waitFor();
|
||||
|
Loading…
Reference in New Issue
Block a user