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: () => {
|
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) {
|
||||||
|
@ -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>
|
||||||
|
@ -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':
|
||||||
|
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 { 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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
Loading…
Reference in New Issue
Block a user