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:
Lukas 2024-07-09 00:14:26 +02:00 committed by GitHub
parent 1017ff0605
commit 9a6cfb4309
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 180 additions and 33 deletions

View File

@ -98,6 +98,7 @@ export function PrintingActions({
onClose: () => { onClose: () => {
setPluginKey(''); setPluginKey('');
}, },
submitText: t`Print`,
successMessage: t`Label printing completed successfully`, successMessage: t`Label printing completed successfully`,
onFormSuccess: (response: any) => { onFormSuccess: (response: any) => {
setPluginKey(''); setPluginKey('');
@ -136,6 +137,7 @@ export function PrintingActions({
value: items value: items
} }
}, },
submitText: t`Generate`,
successMessage: t`Report printing completed successfully`, successMessage: t`Report printing completed successfully`,
onFormSuccess: (response: any) => { onFormSuccess: (response: any) => {
if (!response.complete) { if (!response.complete) {

View File

@ -204,8 +204,11 @@ export function ApiForm({
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const fields: ApiFormFieldSet = useMemo(() => { const [fields, setFields] = useState<ApiFormFieldSet>(
return props.fields ?? {}; () => props.fields ?? {}
);
useEffect(() => {
setFields(props.fields ?? {});
}, [props.fields]); }, [props.fields]);
const defaultValues: FieldValues = useMemo(() => { const defaultValues: FieldValues = useMemo(() => {
@ -543,6 +546,8 @@ export function ApiForm({
fieldName={fieldName} fieldName={fieldName}
definition={field} definition={field}
control={form.control} control={form.control}
url={url}
setFields={setFields}
/> />
))} ))}
</Stack> </Stack>

View File

@ -17,6 +17,7 @@ import { ModelType } from '../../../enums/ModelType';
import { isTrue } from '../../../functions/conversion'; import { isTrue } from '../../../functions/conversion';
import { ChoiceField } from './ChoiceField'; import { ChoiceField } from './ChoiceField';
import DateField from './DateField'; import DateField from './DateField';
import { DependentField } from './DependentField';
import { NestedObjectField } from './NestedObjectField'; import { NestedObjectField } from './NestedObjectField';
import { RelatedModelField } from './RelatedModelField'; import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField'; import { TableField } from './TableField';
@ -74,12 +75,14 @@ export type ApiFormFieldType = {
| 'choice' | 'choice'
| 'file upload' | 'file upload'
| 'nested object' | 'nested object'
| 'dependent field'
| 'table'; | 'table';
api_url?: string; api_url?: string;
pk_field?: string; pk_field?: string;
model?: ModelType; model?: ModelType;
modelRenderer?: (instance: any) => ReactNode; modelRenderer?: (instance: any) => ReactNode;
filters?: any; filters?: any;
child?: ApiFormFieldType;
children?: { [key: string]: ApiFormFieldType }; children?: { [key: string]: ApiFormFieldType };
required?: boolean; required?: boolean;
choices?: any[]; choices?: any[];
@ -94,6 +97,7 @@ export type ApiFormFieldType = {
onValueChange?: (value: any, record?: any) => void; onValueChange?: (value: any, record?: any) => void;
adjustFilters?: (value: ApiFormAdjustFilterType) => any; adjustFilters?: (value: ApiFormAdjustFilterType) => any;
headers?: string[]; headers?: string[];
depends_on?: string[];
}; };
/** /**
@ -103,12 +107,16 @@ export function ApiFormField({
fieldName, fieldName,
definition, definition,
control, control,
hideLabels hideLabels,
url,
setFields
}: { }: {
fieldName: string; fieldName: string;
definition: ApiFormFieldType; definition: ApiFormFieldType;
control: Control<FieldValues, any>; control: Control<FieldValues, any>;
hideLabels?: boolean; hideLabels?: boolean;
url?: string;
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
}) { }) {
const fieldId = useId(); const fieldId = useId();
const controller = useController({ const controller = useController({
@ -122,7 +130,11 @@ export function ApiFormField({
const { value, ref } = field; const { value, ref } = field;
useEffect(() => { 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 // hook up the value state to the input field
if (definition.value !== undefined) { if (definition.value !== undefined) {
@ -291,6 +303,18 @@ export function ApiFormField({
definition={fieldDefinition} definition={fieldDefinition}
fieldName={fieldName} fieldName={fieldName}
control={control} control={control}
url={url}
setFields={setFields}
/>
);
case 'dependent field':
return (
<DependentField
definition={fieldDefinition}
fieldName={fieldName}
control={control}
url={url}
setFields={setFields}
/> />
); );
case 'table': case 'table':

View 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}
/>
);
}

View File

@ -1,16 +1,24 @@
import { Accordion, Divider, Stack, Text } from '@mantine/core'; import { Accordion, Divider, Stack, Text } from '@mantine/core';
import { Control, FieldValues } from 'react-hook-form'; import { Control, FieldValues } from 'react-hook-form';
import { ApiFormField, ApiFormFieldType } from './ApiFormField'; import {
ApiFormField,
ApiFormFieldSet,
ApiFormFieldType
} from './ApiFormField';
export function NestedObjectField({ export function NestedObjectField({
control, control,
fieldName, fieldName,
definition definition,
url,
setFields
}: { }: {
control: Control<FieldValues, any>; control: Control<FieldValues, any>;
definition: ApiFormFieldType; definition: ApiFormFieldType;
fieldName: string; fieldName: string;
url?: string;
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
}) { }) {
return ( return (
<Accordion defaultValue={'OpenByDefault'} variant="contained"> <Accordion defaultValue={'OpenByDefault'} variant="contained">
@ -28,6 +36,8 @@ export function NestedObjectField({
fieldName={`${fieldName}.${childFieldName}`} fieldName={`${fieldName}.${childFieldName}`}
definition={field} definition={field}
control={control} control={control}
url={url}
setFields={setFields}
/> />
) )
)} )}

View File

@ -64,14 +64,10 @@ export function extractAvailableFields(
return null; return null;
} }
const processFields = (fields: any, _path?: string) => { const processField = (field: any, fieldName: string) => {
const _fields: ApiFormFieldSet = {}; const resField: ApiFormFieldType = {
for (const [fieldName, field] of Object.entries(fields) as any) {
const path = _path ? `${_path}.${fieldName}` : fieldName;
_fields[fieldName] = {
...field, ...field,
name: path, name: fieldName,
field_type: field.type, field_type: field.type,
description: field.help_text, description: field.help_text,
value: field.value ?? field.default, value: field.value ?? field.default,
@ -79,17 +75,30 @@ export function extractAvailableFields(
}; };
// Remove the 'read_only' field - plays havoc with react components // Remove the 'read_only' field - plays havoc with react components
delete _fields[fieldName].read_only; delete resField.read_only;
if ( if (resField.field_type === 'nested object' && resField.children) {
_fields[fieldName].field_type === 'nested object' && resField.children = processFields(resField.children, fieldName);
_fields[fieldName].children
) {
_fields[fieldName].children = processFields(
_fields[fieldName].children,
path
);
} }
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] = processField(field, path);
} }
return _fields; return _fields;
@ -153,6 +162,14 @@ export function constructField({
}); });
} }
break; 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: default:
break; break;
} }

View File

@ -36,9 +36,9 @@ test('PUI - Label Printing', async ({ page }) => {
await page.waitForTimeout(100); await page.waitForTimeout(100);
// Submit the form (second time should result in success) // Submit the print form (second time should result in success)
await page.getByRole('button', { name: 'Submit' }).isEnabled(); await page.getByRole('button', { name: 'Print', exact: true }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Print', exact: true }).click();
await page.locator('#form-success').waitFor(); await page.locator('#form-success').waitFor();
await page.getByText('Label printing completed').waitFor(); await page.getByText('Label printing completed').waitFor();
@ -71,9 +71,9 @@ test('PUI - Report Printing', async ({ page }) => {
await page.waitForTimeout(100); await page.waitForTimeout(100);
// Submit the form (should result in success) // Submit the print form (should result in success)
await page.getByRole('button', { name: 'Submit' }).isEnabled(); await page.getByRole('button', { name: 'Generate', exact: true }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Generate', exact: true }).click();
await page.locator('#form-success').waitFor(); await page.locator('#form-success').waitFor();
await page.getByText('Report printing completed').waitFor(); await page.getByText('Report printing completed').waitFor();