diff --git a/src/frontend/src/components/buttons/PrintingActions.tsx b/src/frontend/src/components/buttons/PrintingActions.tsx index 3f14e49eeb..ecfa98b0cb 100644 --- a/src/frontend/src/components/buttons/PrintingActions.tsx +++ b/src/frontend/src/components/buttons/PrintingActions.tsx @@ -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) { diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index a17b2c7aa2..f58ca69cda 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -204,8 +204,11 @@ export function ApiForm({ }) { const navigate = useNavigate(); - const fields: ApiFormFieldSet = useMemo(() => { - return props.fields ?? {}; + const [fields, setFields] = useState( + () => 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} /> ))} diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index cec43ec526..736d8cda8c 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -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; hideLabels?: boolean; + url?: string; + setFields?: React.Dispatch>; }) { 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 ( + ); case 'table': diff --git a/src/frontend/src/components/forms/fields/DependentField.tsx b/src/frontend/src/components/forms/fields/DependentField.tsx new file mode 100644 index 0000000000..216275d6b7 --- /dev/null +++ b/src/frontend/src/components/forms/fields/DependentField.tsx @@ -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; + definition: ApiFormFieldType; + fieldName: string; + url?: string; + setFields?: React.Dispatch>; +}) { + 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 | null = + extractAvailableFields(res, 'POST'); + + // update the fields in the form state with the new fields + setFields((prevFields) => { + const newFields: Record> = {}; + + 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 ( + + ); +} diff --git a/src/frontend/src/components/forms/fields/NestedObjectField.tsx b/src/frontend/src/components/forms/fields/NestedObjectField.tsx index d3b6164eb2..77a8693c69 100644 --- a/src/frontend/src/components/forms/fields/NestedObjectField.tsx +++ b/src/frontend/src/components/forms/fields/NestedObjectField.tsx @@ -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; definition: ApiFormFieldType; fieldName: string; + url?: string; + setFields?: React.Dispatch>; }) { return ( @@ -28,6 +36,8 @@ export function NestedObjectField({ fieldName={`${fieldName}.${childFieldName}`} definition={field} control={control} + url={url} + setFields={setFields} /> ) )} diff --git a/src/frontend/src/functions/forms.tsx b/src/frontend/src/functions/forms.tsx index 62bb4c731c..07bd8bbade 100644 --- a/src/frontend/src/functions/forms.tsx +++ b/src/frontend/src/functions/forms.tsx @@ -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; } diff --git a/src/frontend/tests/pui_printing.spec.ts b/src/frontend/tests/pui_printing.spec.ts index d8b146b7bc..dcd46b28f8 100644 --- a/src/frontend/tests/pui_printing.spec.ts +++ b/src/frontend/tests/pui_printing.spec.ts @@ -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();