Skip to content

Commit 07a9aad

Browse files
authored
feat(ui): add validation for form fields (#1934)
1 parent 41c9dd4 commit 07a9aad

File tree

16 files changed

+176
-58
lines changed

16 files changed

+176
-58
lines changed

apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/schemas.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import z from 'zod';
88
export const baseFieldSchema = z.object({
99
id: z.string().nonempty(),
1010
label: z.string().nonempty(),
11-
required: z.boolean(),
11+
required: z.boolean().nullish(),
1212
col_span: z.int().min(1).max(4).nullish(),
1313
});
1414

@@ -30,36 +30,27 @@ export const fileFieldSchema = baseFieldSchema.extend({
3030
accept: z.array(z.string()),
3131
});
3232

33+
export const selectFieldOptionSchema = z.object({
34+
id: z.string().nonempty(),
35+
label: z.string().nonempty(),
36+
});
37+
3338
export const singleSelectFieldSchema = baseFieldSchema.extend({
3439
type: z.literal('singleselect'),
35-
options: z
36-
.array(
37-
z.object({
38-
id: z.string().nonempty(),
39-
label: z.string().nonempty(),
40-
}),
41-
)
42-
.nonempty(),
40+
options: z.array(selectFieldOptionSchema).nonempty(),
4341
default_value: z.string().nullish(),
4442
});
4543

4644
export const multiSelectFieldSchema = baseFieldSchema.extend({
4745
type: z.literal('multiselect'),
48-
options: z
49-
.array(
50-
z.object({
51-
id: z.string().nonempty(),
52-
label: z.string().nonempty(),
53-
}),
54-
)
55-
.nonempty(),
46+
options: z.array(selectFieldOptionSchema).nonempty(),
5647
default_value: z.array(z.string()).nullish(),
5748
});
5849

5950
export const checkboxFieldSchema = baseFieldSchema.extend({
6051
type: z.literal('checkbox'),
6152
content: z.string(),
62-
default_value: z.boolean(),
53+
default_value: z.boolean().nullish(),
6354
});
6455

6556
export const formFieldSchema = z.discriminatedUnion('type', [
@@ -119,11 +110,11 @@ export const formFieldValueSchema = z.discriminatedUnion('type', [
119110
]);
120111

121112
export const formRenderSchema = z.object({
113+
fields: z.array(formFieldSchema).nonempty(),
122114
title: z.string().nullish(),
123115
description: z.string().nullish(),
124116
columns: z.int().min(1).max(4).nullish(),
125117
submit_label: z.string().nullish(),
126-
fields: z.array(formFieldSchema).nonempty(),
127118
});
128119

129120
export const formValuesSchema = z.record(z.string(), formFieldValueSchema);

apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
formValuesSchema,
2020
multiSelectFieldSchema,
2121
multiSelectFieldValueSchema,
22+
selectFieldOptionSchema,
2223
singleSelectFieldSchema,
2324
singleSelectFieldValueSchema,
2425
textFieldSchema,
@@ -28,6 +29,7 @@ import type {
2829
export type TextField = z.infer<typeof textFieldSchema>;
2930
export type DateField = z.infer<typeof dateFieldSchema>;
3031
export type FileField = z.infer<typeof fileFieldSchema>;
32+
export type SelectFieldOption = z.infer<typeof selectFieldOptionSchema>;
3133
export type SingleSelectField = z.infer<typeof singleSelectFieldSchema>;
3234
export type MultiSelectField = z.infer<typeof multiSelectFieldSchema>;
3335
export type CheckboxField = z.infer<typeof checkboxFieldSchema>;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import type { PropsWithChildren } from 'react';
7+
8+
export function FormRequirement({ children }: PropsWithChildren) {
9+
return <div className="cds--form-requirement">{children}</div>;
10+
}

apps/agentstack-ui/src/components/TextAreaAutoHeight/TextAreaAutoHeight.module.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ $padding-inline: $spacing-05;
6868
border-radius: $border-radius;
6969
}
7070

71+
&[data-invalid] {
72+
&::after,
73+
> textarea {
74+
padding-inline-end: $spacing-08;
75+
}
76+
}
77+
7178
&.lg {
7279
--padding-top: #{rem(14px)};
7380
--padding-bottom: #{rem(14px)};

apps/agentstack-ui/src/components/TextAreaAutoHeight/TextAreaAutoHeight.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import { WarningFilled } from '@carbon/icons-react';
67
import clsx from 'clsx';
78
import type { ChangeEvent, CSSProperties, TextareaHTMLAttributes } from 'react';
89
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
@@ -15,10 +16,11 @@ type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value'> & {
1516
maxRows?: number;
1617
resizable?: boolean;
1718
size?: 'lg';
19+
invalid?: boolean;
1820
};
1921

2022
export const TextAreaAutoHeight = forwardRef<HTMLTextAreaElement, Props>(function TextAreaAutoHeight(
21-
{ className, resizable, maxRows, size, onChange, ...rest },
23+
{ className, resizable, maxRows, size, invalid, onChange, ...rest },
2224
ref,
2325
) {
2426
const [value, setValue] = useState(rest.defaultValue ?? '');
@@ -68,10 +70,18 @@ export const TextAreaAutoHeight = forwardRef<HTMLTextAreaElement, Props>(functio
6870
className={clsx(classes.root, className, sizeClassName, {
6971
[classes.resized]: Boolean(manualHeight),
7072
})}
73+
data-invalid={invalid}
7174
data-replicated-value={value}
7275
style={maxRows ? ({ '--max-rows': `${maxRows}` } as CSSProperties) : undefined}
7376
>
74-
<textarea ref={mergeRefs([ref, textareaRef])} {...rest} onChange={handleChange} />
77+
<textarea
78+
ref={mergeRefs([ref, textareaRef])}
79+
{...rest}
80+
className={clsx({ 'cds--text-input--invalid': invalid })}
81+
onChange={handleChange}
82+
/>
83+
84+
{invalid && <WarningFilled className="cds--text-input__invalid-icon" />}
7585
</div>
7686
);
7787

apps/agentstack-ui/src/modules/form/components/FormActionBar.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ import { useRunSettingsDialog } from '#modules/runs/settings/useRunSettingsDialo
1313
import classes from './FormActionBar.module.scss';
1414

1515
interface Props {
16-
showSubmitButton?: boolean;
1716
submitLabel: string;
17+
showSubmit?: boolean;
1818
showRunSettings?: boolean;
1919
}
2020

21-
export function FormActionBar({ showSubmitButton = true, submitLabel, showRunSettings }: Props) {
21+
export function FormActionBar({ submitLabel, showSubmit = true, showRunSettings }: Props) {
2222
const modelsDialog = useRunSettingsDialog({ blockOffset: 8 });
2323
const settingsDialog = useRunSettingsDialog({ blockOffset: 8 });
2424
const formRefs = useMergeRefs([modelsDialog.refs.setPositionReference, settingsDialog.refs.setPositionReference]);
@@ -32,7 +32,8 @@ export function FormActionBar({ showSubmitButton = true, submitLabel, showRunSet
3232
<RunModels dialog={modelsDialog} iconOnly={false} />
3333
</div>
3434
)}
35-
{showSubmitButton && (
35+
36+
{showSubmit && (
3637
<div className={classes.buttons}>
3738
<Button type="submit" size="md">
3839
{submitLabel}

apps/agentstack-ui/src/modules/form/components/FormRenderer.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,18 @@ export function FormRenderer({
3737

3838
const defaultValues = getDefaultValues(fields);
3939

40-
const form = useForm<RunFormValues>({ defaultValues });
40+
const form = useForm<RunFormValues>({
41+
mode: 'onChange',
42+
defaultValues,
43+
});
44+
const { handleSubmit } = form;
4145

4246
const showHeading = showHeadingProp && isNotNull(heading);
4347
const showHeader = showHeading || Boolean(description);
4448

4549
return (
4650
<FormProvider {...form}>
47-
<form onSubmit={form.handleSubmit(onSubmit)}>
51+
<form onSubmit={handleSubmit(onSubmit)}>
4852
<fieldset disabled={isDisabled} className={classes.root}>
4953
{showHeader && (
5054
<AgentRunHeader heading={showHeading ? heading : undefined}>
@@ -56,8 +60,8 @@ export function FormRenderer({
5660

5761
<FormActionBar
5862
submitLabel={submit_label ?? 'Submit'}
63+
showSubmit={!isDisabled}
5964
showRunSettings={showRunSettings}
60-
showSubmitButton={!isDisabled}
6165
/>
6266
</fieldset>
6367
</form>

apps/agentstack-ui/src/modules/form/components/fields/CheckboxField.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,25 @@ import { Checkbox, FormGroup } from '@carbon/react';
77
import type { CheckboxField } from 'agentstack-sdk';
88
import { useFormContext } from 'react-hook-form';
99

10+
import { useFormFieldValidation } from '#modules/form/hooks/useFormFieldValidation.ts';
1011
import type { ValuesOfField } from '#modules/form/types.ts';
12+
import { getFieldName } from '#modules/form/utils.ts';
1113

1214
interface Props {
1315
field: CheckboxField;
1416
}
1517

1618
export function CheckboxField({ field }: Props) {
17-
const { id, label, content, required } = field;
19+
const { id, label, content } = field;
1820

19-
const { register } = useFormContext<ValuesOfField<CheckboxField>>();
21+
const { register, formState } = useFormContext<ValuesOfField<CheckboxField>>();
22+
const { rules, invalid, invalidText } = useFormFieldValidation({ field, formState });
23+
24+
const inputProps = register(getFieldName(field), rules);
2025

2126
return (
2227
<FormGroup legendText={label}>
23-
<Checkbox id={id} labelText={content} {...register(`${id}.value`, { required: Boolean(required) })} />
28+
<Checkbox id={id} labelText={content} invalid={invalid} invalidText={invalidText} {...inputProps} />
2429
</FormGroup>
2530
);
2631
}

apps/agentstack-ui/src/modules/form/components/fields/DateField.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { DatePicker, DatePickerInput } from '@carbon/react';
77
import type { DateField } from 'agentstack-sdk';
88
import { Controller, useFormContext } from 'react-hook-form';
99

10+
import { useFormFieldValidation } from '#modules/form/hooks/useFormFieldValidation.ts';
1011
import type { ValuesOfField } from '#modules/form/types.ts';
12+
import { getFieldName } from '#modules/form/utils.ts';
1113

1214
interface Props {
1315
field: DateField;
@@ -16,20 +18,29 @@ interface Props {
1618
export function DateField({ field }: Props) {
1719
const { id, label, placeholder } = field;
1820

19-
const { control } = useFormContext<ValuesOfField<DateField>>();
21+
const { control, formState } = useFormContext<ValuesOfField<DateField>>();
22+
const { rules, invalid, invalidText } = useFormFieldValidation({ field, formState });
2023

2124
return (
2225
<Controller
23-
name={`${id}.value`}
26+
name={getFieldName(field)}
2427
control={control}
28+
rules={rules}
2529
render={({ field: { value, onChange } }) => (
2630
<DatePicker
2731
datePickerType="single"
2832
value={value ?? undefined}
2933
onChange={(_, currentDateString) => onChange(currentDateString)}
3034
allowInput
35+
invalid={invalid}
3136
>
32-
<DatePickerInput id={id} size="lg" labelText={label} placeholder={placeholder ?? undefined} />
37+
<DatePickerInput
38+
id={id}
39+
size="lg"
40+
labelText={label}
41+
placeholder={placeholder ?? undefined}
42+
invalidText={invalidText}
43+
/>
3344
</DatePicker>
3445
)}
3546
/>

apps/agentstack-ui/src/modules/form/components/fields/FileField.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ import type { FileField } from 'agentstack-sdk';
99
import { useEffect } from 'react';
1010
import { useController, useFormContext } from 'react-hook-form';
1111

12+
import { FormRequirement } from '#components/FormRequirement/FormRequirement.tsx';
13+
import { usePrevious } from '#hooks/usePrevious.ts';
1214
import { FileCard } from '#modules/files/components/FileCard.tsx';
1315
import { FileCardsList } from '#modules/files/components/FileCardsList.tsx';
1416
import { FileUploadProvider } from '#modules/files/contexts/FileUploadProvider.tsx';
1517
import { useFileUpload } from '#modules/files/contexts/index.ts';
18+
import { useFormFieldValidation } from '#modules/form/hooks/useFormFieldValidation.ts';
1619
import type { ValuesOfField } from '#modules/form/types.ts';
17-
import { convertFileToFileFieldValue } from '#modules/form/utils.ts';
20+
import { convertFileToFileFieldValue, getFieldName } from '#modules/form/utils.ts';
1821
import { isNotNull } from '#utils/helpers.ts';
1922

2023
import classes from './FileField.module.scss';
@@ -32,29 +35,41 @@ export function FileField({ field }: Props) {
3235
}
3336

3437
export function FileFieldComponent({ field }: Props) {
35-
const { id, label } = field;
38+
const { label } = field;
3639

37-
const { dropzone, isDisabled, files, removeFile } = useFileUpload();
38-
const { control } = useFormContext<ValuesOfField<FileField>>();
40+
const { dropzone, isDisabled, isPending, files, removeFile } = useFileUpload();
41+
const { control, formState } = useFormContext<ValuesOfField<FileField>>();
42+
const { rules, invalid: invalidState, invalidText } = useFormFieldValidation({ field, formState });
3943
const {
4044
field: { onChange },
41-
} = useController({ control, name: `${id}.value` });
45+
} = useController({ control, name: getFieldName(field), rules });
46+
47+
const invalid = invalidState && !isPending;
4248

4349
const hasFiles = files.length > 0;
50+
const filesKey = files
51+
.map(({ uploadFile }) => (uploadFile ? uploadFile.id : null))
52+
.filter(isNotNull)
53+
.join('|');
54+
const prevFilesKey = usePrevious(filesKey);
4455

4556
useEffect(() => {
57+
if (prevFilesKey === filesKey) {
58+
return;
59+
}
60+
4661
const newValue = files.map(convertFileToFileFieldValue).filter(isNotNull);
4762

4863
onChange(newValue);
49-
}, [files, onChange]);
64+
}, [filesKey, prevFilesKey, files, onChange]);
5065

5166
if (!dropzone) {
5267
return null;
5368
}
5469

5570
return (
5671
<FormGroup {...dropzone.getRootProps()} legendText={label}>
57-
<input type="file" {...dropzone.getInputProps()} />
72+
<input type="file" {...dropzone.getInputProps()} data-invalid={invalid} />
5873

5974
{hasFiles ? (
6075
<FileCardsList className={classes.files}>
@@ -76,6 +91,8 @@ export function FileFieldComponent({ field }: Props) {
7691
Upload
7792
</Button>
7893
)}
94+
95+
{invalid && <FormRequirement>{invalidText}</FormRequirement>}
7996
</FormGroup>
8097
);
8198
}

0 commit comments

Comments
 (0)