From d69cb57d7f0eb1e705f31d2ad1fdf0e8ede66c56 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Tue, 4 Aug 2026 13:38:20 +0530 Subject: [PATCH 1/7] feat(orchestrator-form): add field-level validation via ui:validateOn and ui:validateGroup Enable per-field async validation triggered on blur/change without waiting for Next/Submit. Fields annotated with ui:validateOn fire their validate:url immediately (blur) or after a 1s debounce (change). Fields sharing a ui:validateGroup name are validated together once all group members have values. Co-Authored-By: Claude Opus 4.6 --- .../.changeset/field-level-validation.md | 7 + .../docs/orchestratorFormWidgets.md | 129 +++++++++++- .../plugins/orchestrator-form-api/src/api.ts | 6 + .../components/OrchestratorFormWrapper.tsx | 66 ++++-- .../src/utils/fieldValidationConfig.test.ts | 193 ++++++++++++++++++ .../src/utils/fieldValidationConfig.ts | 95 +++++++++ .../src/utils/mergeExtraErrors.test.ts | 81 ++++++++ .../src/utils/mergeExtraErrors.ts | 41 ++++ .../src/utils/useFieldValidation.ts | 150 ++++++++++++++ .../src/FormDecoratorContent.tsx | 4 +- .../src/FormWidgetsApi.test.tsx | 8 + .../src/utils/index.ts | 2 + .../src/utils/useGetExtraErrorsForField.ts | 42 ++++ .../src/utils/validateSingleField.test.ts | 152 ++++++++++++++ .../src/utils/validateSingleField.ts | 118 +++++++++++ .../src/widgets/ActiveDropdown.tsx | 3 +- .../src/widgets/ActiveMultiSelect.tsx | 11 +- .../src/widgets/ActiveTextInput.tsx | 4 +- 18 files changed, 1087 insertions(+), 25 deletions(-) create mode 100644 workspaces/orchestrator/.changeset/field-level-validation.md create mode 100644 workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/fieldValidationConfig.test.ts create mode 100644 workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/fieldValidationConfig.ts create mode 100644 workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/mergeExtraErrors.test.ts create mode 100644 workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/mergeExtraErrors.ts create mode 100644 workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts create mode 100644 workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useGetExtraErrorsForField.ts create mode 100644 workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.test.ts create mode 100644 workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.ts diff --git a/workspaces/orchestrator/.changeset/field-level-validation.md b/workspaces/orchestrator/.changeset/field-level-validation.md new file mode 100644 index 00000000000..6db5bfe24ce --- /dev/null +++ b/workspaces/orchestrator/.changeset/field-level-validation.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-api': minor +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': minor +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': minor +--- + +Add field-level validation support via `ui:validateOn` and `ui:validateGroup` schema annotations. Fields can now trigger async validation on blur, change, or both without waiting for Next/Submit. Dependent fields sharing a `ui:validateGroup` are validated together once all group members have values. diff --git a/workspaces/orchestrator/docs/orchestratorFormWidgets.md b/workspaces/orchestrator/docs/orchestratorFormWidgets.md index b9042b37c33..8c732fcce3a 100644 --- a/workspaces/orchestrator/docs/orchestratorFormWidgets.md +++ b/workspaces/orchestrator/docs/orchestratorFormWidgets.md @@ -45,6 +45,11 @@ Implementation of the HTTP endpoints is out of the scope of this library, they a - [ActiveText Data Fetching](#activetext-data-fetching) - [Dynamic Text Templating](#dynamic-text-templating) - [ActiveText widget ui:props](#activetext-widget-uiprops) + - [Field-Level Validation](#field-level-validation) + - [Validation on Blur](#validation-on-blur) + - [Validation on Change](#validation-on-change) + - [Validation on Both Blur and Change](#validation-on-both-blur-and-change) + - [Dependent Field Group Validation](#dependent-field-group-validation) - [Content of `ui:props`](#content-of-uiprops) - [List of widget properties](#list-of-widget-properties) - [Specifics for templates in fetch:body, validate:body, fetch:headers or validate:headers](#specifics-for-templates-in-fetchbody-validatebody-fetchheaders-or-validateheaders) @@ -254,7 +259,7 @@ If you want to keep the field empty until the user interacts with it, set `fetch In addition to the AJV validation handled by the RJSF form, an external service can be utilized through the `validate:*` properties via HTTP requests. -If specified, external validation is triggered both upon form submission and when moving to the next step. +If specified, external validation is triggered both upon form submission and when moving to the next step. Additionally, validation can be triggered on blur or change using the `ui:validateOn` annotation (see [Field-Level Validation](#field-level-validation)). The validation is considered successful if an HTTP 200 response is received. @@ -543,6 +548,126 @@ The widget supports the following `ui:props` (for detailed information on each, - `fetch:retry:backoff`: Backoff multiplier applied to the delay - `fetch:retry:statusCodes`: Optional list of status codes to retry +## Field-Level Validation + +By default, validation (both AJV schema validation and async `validate:url` HTTP validation) runs only when the user clicks **Next** or **Submit**. The `ui:validateOn` annotation enables immediate per-field validation triggered by user interaction, without waiting for form submission. + +This feature is supported by the `ActiveTextInput`, `ActiveDropdown`, and `ActiveMultiSelect` widgets. + +### Validation on Blur + +Trigger validation when the user leaves a field (tabs out or clicks another field): + +```json +{ + "userId": { + "type": "string", + "title": "User ID", + "ui:widget": "ActiveTextInput", + "ui:validateOn": "blur", + "ui:props": { + "validate:url": "$${{backend.baseUrl}}/api/proxy/myservice/validate/user/$${{current.userId}}" + } + } +} +``` + +### Validation on Change + +Trigger validation while the user types. The validation is **debounced** (1 second delay) to avoid excessive network calls: + +```json +{ + "email": { + "type": "string", + "title": "Email", + "ui:widget": "ActiveTextInput", + "ui:validateOn": "change", + "ui:props": { + "validate:url": "$${{backend.baseUrl}}/api/proxy/myservice/validate/email", + "validate:method": "POST", + "validate:body": { + "email": "$${{current.email}}" + } + } + } +} +``` + +### Validation on Both Blur and Change + +Use a comma-separated value to trigger on both events: + +```json +{ + "hostname": { + "type": "string", + "title": "Hostname", + "ui:widget": "ActiveTextInput", + "ui:validateOn": "blur,change", + "ui:props": { + "validate:url": "$${{backend.baseUrl}}/api/proxy/myservice/validate/hostname/$${{current.hostname}}" + } + } +} +``` + +### Dependent Field Group Validation + +Use `ui:validateGroup` to link dependent fields that should be validated together. When all fields in a group have values and any member triggers validation, all other group members are validated automatically. + +This is useful for fields like **namespace + cluster** where the validity of one depends on the value of the other. + +```json +{ + "namespace": { + "type": "string", + "title": "Namespace", + "ui:widget": "ActiveTextInput", + "ui:validateOn": "blur", + "ui:validateGroup": "ns-cluster", + "ui:props": { + "validate:url": "$${{backend.baseUrl}}/api/proxy/myservice/validate/namespace", + "validate:method": "POST", + "validate:body": { + "namespace": "$${{current.namespace}}", + "cluster": "$${{current.cluster}}" + } + } + }, + "cluster": { + "type": "string", + "title": "Cluster", + "ui:widget": "ActiveTextInput", + "ui:validateOn": "blur", + "ui:validateGroup": "ns-cluster", + "ui:props": { + "validate:url": "$${{backend.baseUrl}}/api/proxy/myservice/validate/cluster", + "validate:method": "POST", + "validate:body": { + "namespace": "$${{current.namespace}}", + "cluster": "$${{current.cluster}}" + } + } + } +} +``` + +**How group validation works:** + +1. The user fills in `namespace` and blurs the field — only `namespace` is validated (because `cluster` is still empty). +2. The user fills in `cluster` and blurs the field — both `cluster` **and** `namespace` are validated, because all group members now have values. +3. The group name (`"ns-cluster"` in this example) is arbitrary — it just needs to match across all fields in the group. + +**Key points:** + +| Property | Location | Values | Description | +| ------------------ | ------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `ui:validateOn` | Field schema | `"blur"`, `"change"`, `"blur,change"` | When to trigger field-level validation. If omitted, validation runs only on Next/Submit. | +| `ui:validateGroup` | Field schema | Any string (group name) | Links fields for group validation. All fields with the same group name are validated together when all have values. | + +> **Note:** Fields without `ui:validateOn` continue to validate only on Next/Submit — this feature is fully backward compatible. + ## Content of `ui:props` A list of particular widgets supported by each widget can be found in its description above. @@ -574,7 +699,7 @@ Various selectors (like `fetch:response:*`) are processed by the [jsonata](https | fetch:response:label | Special (well-known) case of the fetch:response:\[YOUR_KEY\] . Used i.e. by the ActiveDropdown to label the items. | | | fetch:response:value | Like fetch:response:label, but gives i.e. ActiveDropdown item values (not visible to the user but actually used as the field value) | | | fetch:response:autocomplete | Special (well-known) case of the fetch:response:\[YOUR_KEY\] . Used for selecting list of strings for autocomplete feature (ActiveTextInput) | | -| validate:url | Like fetch:url but triggered for validation on form submit, form page transition | | +| validate:url | Like fetch:url but triggered for validation on form submit, form page transition. Can also be triggered on blur or change when `ui:validateOn` is set on the field (see [Field-Level Validation](#field-level-validation)). | | | validate:method | Similar to fetch:method | | | validate:retrigger | An array similar to fetch:retrigger. Force revalidation of the field if a dependency is changed. In the most simple case when just the value of the particular field is listed (sort of \[“current.myField”\], the validation is triggered “on input”, i.e. when the user types a character in ActiveInputBox. The network calls are throttled. No matter if validate:retrigger is used, the validation happens at least on submit or transition to the next page. | | | validate:body | Similar to fetch:body | | diff --git a/workspaces/orchestrator/plugins/orchestrator-form-api/src/api.ts b/workspaces/orchestrator/plugins/orchestrator-form-api/src/api.ts index 5091ce73fec..ea2f6ef8af1 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-api/src/api.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-api/src/api.ts @@ -47,6 +47,7 @@ export type OrchestratorFormContextProps = { handleFetchStarted?: () => void; handleFetchEnded?: () => void; onSamlSsoError?: (error: Error) => void; + validatingFields?: ReadonlySet; }; /** @@ -84,6 +85,11 @@ export type FormDecoratorProps = Pick< formData: JsonObject, uiSchema: OrchestratorFormContextProps['uiSchema'], ) => Promise> | undefined; + getExtraErrorsForField?: ( + formData: JsonObject, + fieldPath: string, + uiSchemaProperty: JsonObject, + ) => Promise>; }; /** diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx index 5d24277e241..d5c651f07a5 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ErrorPanel } from '@backstage/core-components'; import { JsonObject } from '@backstage/types'; @@ -41,6 +41,7 @@ import { getActiveStepKey } from '../utils/getSortedStepEntries'; import { normalizeErrorSchema } from '../utils/resolveStepErrorSchema'; import { useStepperContext } from '../utils/StepperContext'; import { toRootExtraErrors } from '../utils/toRootExtraErrors'; +import { useFieldValidation } from '../utils/useFieldValidation'; import useValidator from '../utils/useValidator'; import { AuthRequester } from './AuthRequester'; import HiddenObjectFieldTemplate from './HiddenObjectFieldTemplate'; @@ -70,6 +71,23 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => { const validator = useValidator(isMultiStep); const { t } = useTranslation(); + const formDataRef = useRef(formContext?.formData ?? {}); + useEffect(() => { + formDataRef.current = formContext?.formData ?? {}; + }, [formContext?.formData]); + + const { validatingFields, triggerFieldValidation } = useFieldValidation({ + uiSchema: formContext?.uiSchema ?? {}, + getExtraErrorsForField: decoratorProps.getExtraErrorsForField, + setExtraErrors, + formDataRef, + }); + + const enhancedFormContext = useMemo( + () => (formContext ? { ...formContext, validatingFields } : formContext), + [formContext, validatingFields], + ); + useEffect(() => { clearFormErrorsRef.current = () => { setExtraErrors(undefined); @@ -84,21 +102,18 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => { return
{t('formDecorator.error')}
; } - const { - uiSchema, - schema, - onSubmit: _onSubmit, - children, - formData, - setFormData, - } = formContext; + const { onSubmit: _onSubmit, children, setFormData } = formContext; const getActiveKey = () => { if (!isMultiStep) { return undefined; } - return getActiveStepKey(schema, activeStep, formData); + return getActiveStepKey( + formContext.schema, + activeStep, + formContext.formData, + ); }; const onSubmit = async (_formData: JsonObject) => { @@ -106,14 +121,16 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => { let _extraErrors: ErrorSchema | undefined = undefined; let _validationError: Error | undefined = undefined; const activeKey = getActiveKey(); + const { uiSchema: currentUiSchema } = formContext; const shouldScopeExtraErrors = - Boolean(activeKey) && Boolean(uiSchema?.[activeKey as string]); - const extraErrorsFormData = (_formData ?? formData) as JsonObject; + Boolean(activeKey) && Boolean(currentUiSchema?.[activeKey as string]); + const extraErrorsFormData = (_formData ?? + formContext.formData) as JsonObject; const extraErrorsUiSchema = shouldScopeExtraErrors ? ({ - [activeKey as string]: uiSchema?.[activeKey as string], + [activeKey as string]: currentUiSchema?.[activeKey as string], } as OrchestratorFormContextProps['uiSchema']) - : uiSchema; + : currentUiSchema; if (decoratorProps.getExtraErrors) { try { @@ -150,6 +167,13 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => { } }; + const onBlur = (id: string, _value: unknown) => { + const fieldPath = rjsfIdToFieldPath(id); + if (fieldPath) { + triggerFieldValidation(fieldPath, 'blur'); + } + }; + const onChange = ( e: IChangeEvent, id?: string, @@ -161,6 +185,9 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => { if (decoratorProps.onChange) { decoratorProps.onChange(e, id); } + if (fieldPath) { + triggerFieldValidation(fieldPath, 'change'); + } }; return ( @@ -172,21 +199,22 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => { )} onSubmit(e.formData || {})} onChange={onChange} + onBlur={onBlur} > {children} diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/fieldValidationConfig.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/fieldValidationConfig.test.ts new file mode 100644 index 00000000000..ed86204467c --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/fieldValidationConfig.test.ts @@ -0,0 +1,193 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + areAllGroupFieldsPopulated, + getFieldValidationConfig, + getGroupMembers, + parseValidateOn, +} from './fieldValidationConfig'; + +describe('parseValidateOn', () => { + it('parses "blur"', () => { + expect(parseValidateOn('blur')).toEqual(['blur']); + }); + + it('parses "change"', () => { + expect(parseValidateOn('change')).toEqual(['change']); + }); + + it('parses "blur,change"', () => { + expect(parseValidateOn('blur,change')).toEqual(['blur', 'change']); + }); + + it('parses "change,blur" with spaces', () => { + expect(parseValidateOn(' change , blur ')).toEqual(['change', 'blur']); + }); + + it('returns empty for undefined', () => { + expect(parseValidateOn(undefined)).toEqual([]); + }); + + it('returns empty for non-string', () => { + expect(parseValidateOn(42)).toEqual([]); + }); + + it('filters out invalid values', () => { + expect(parseValidateOn('blur,invalid,change')).toEqual(['blur', 'change']); + }); +}); + +describe('getFieldValidationConfig', () => { + it('returns config when ui:validateOn is set', () => { + const uiSchema = { + userId: { + 'ui:validateOn': 'blur', + }, + }; + expect(getFieldValidationConfig(uiSchema, 'userId')).toEqual({ + validateOn: ['blur'], + validateGroup: undefined, + }); + }); + + it('returns config with validateGroup', () => { + const uiSchema = { + namespace: { + 'ui:validateOn': 'blur', + 'ui:validateGroup': 'ns-cluster', + }, + }; + expect(getFieldValidationConfig(uiSchema, 'namespace')).toEqual({ + validateOn: ['blur'], + validateGroup: 'ns-cluster', + }); + }); + + it('returns undefined when no ui:validateOn', () => { + const uiSchema = { + userId: { + 'ui:widget': 'ActiveTextInput', + }, + }; + expect(getFieldValidationConfig(uiSchema, 'userId')).toBeUndefined(); + }); + + it('returns undefined for unknown field path', () => { + const uiSchema = {}; + expect(getFieldValidationConfig(uiSchema, 'unknown')).toBeUndefined(); + }); + + it('works with nested paths', () => { + const uiSchema = { + stepOne: { + userId: { + 'ui:validateOn': 'change', + }, + }, + }; + expect(getFieldValidationConfig(uiSchema, 'stepOne.userId')).toEqual({ + validateOn: ['change'], + validateGroup: undefined, + }); + }); +}); + +describe('getGroupMembers', () => { + it('finds all members of a group', () => { + const uiSchema = { + namespace: { + 'ui:validateOn': 'blur', + 'ui:validateGroup': 'ns-cluster', + }, + cluster: { + 'ui:validateOn': 'blur', + 'ui:validateGroup': 'ns-cluster', + }, + userId: { + 'ui:validateOn': 'blur', + }, + }; + const members = getGroupMembers(uiSchema, 'ns-cluster'); + expect(members).toContain('namespace'); + expect(members).toContain('cluster'); + expect(members).not.toContain('userId'); + }); + + it('finds members in nested schema', () => { + const uiSchema = { + stepOne: { + namespace: { + 'ui:validateGroup': 'ns-cluster', + }, + cluster: { + 'ui:validateGroup': 'ns-cluster', + }, + }, + }; + const members = getGroupMembers(uiSchema, 'ns-cluster'); + expect(members).toContain('stepOne.namespace'); + expect(members).toContain('stepOne.cluster'); + }); + + it('returns empty array when no members found', () => { + const uiSchema = { + userId: { 'ui:validateOn': 'blur' }, + }; + expect(getGroupMembers(uiSchema, 'nonexistent')).toEqual([]); + }); +}); + +describe('areAllGroupFieldsPopulated', () => { + it('returns true when all members have values', () => { + const formData = { namespace: 'ns1', cluster: 'cl1' }; + expect(areAllGroupFieldsPopulated(['namespace', 'cluster'], formData)).toBe( + true, + ); + }); + + it('returns false when a member is empty string', () => { + const formData = { namespace: 'ns1', cluster: '' }; + expect(areAllGroupFieldsPopulated(['namespace', 'cluster'], formData)).toBe( + false, + ); + }); + + it('returns false when a member is undefined', () => { + const formData = { namespace: 'ns1' }; + expect(areAllGroupFieldsPopulated(['namespace', 'cluster'], formData)).toBe( + false, + ); + }); + + it('returns false when a member is null', () => { + const formData = { namespace: 'ns1', cluster: null }; + expect(areAllGroupFieldsPopulated(['namespace', 'cluster'], formData)).toBe( + false, + ); + }); + + it('returns false when a member is an empty array', () => { + const formData = { namespace: 'ns1', items: [] }; + expect(areAllGroupFieldsPopulated(['namespace', 'items'], formData)).toBe( + false, + ); + }); + + it('returns true for empty members list', () => { + expect(areAllGroupFieldsPopulated([], {})).toBe(true); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/fieldValidationConfig.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/fieldValidationConfig.ts new file mode 100644 index 00000000000..3c0bf81c2ab --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/fieldValidationConfig.ts @@ -0,0 +1,95 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonObject } from '@backstage/types'; + +import { UiSchema } from '@rjsf/utils'; +import type { JSONSchema7 } from 'json-schema'; +import get from 'lodash/get'; + +export type ValidateOnMode = 'blur' | 'change'; + +export interface FieldValidationConfig { + validateOn: ValidateOnMode[]; + validateGroup?: string; +} + +export function parseValidateOn(raw: unknown): ValidateOnMode[] { + if (typeof raw !== 'string') return []; + return raw + .split(',') + .map(s => s.trim().toLowerCase()) + .filter((s): s is ValidateOnMode => s === 'blur' || s === 'change'); +} + +export function getFieldValidationConfig( + uiSchema: UiSchema, + fieldPath: string, +): FieldValidationConfig | undefined { + const fieldUiSchema = get(uiSchema, fieldPath) as JsonObject | undefined; + if (!fieldUiSchema) return undefined; + + const validateOn = parseValidateOn(fieldUiSchema['ui:validateOn']); + if (validateOn.length === 0) return undefined; + + const validateGroup = + typeof fieldUiSchema['ui:validateGroup'] === 'string' + ? (fieldUiSchema['ui:validateGroup'] as string) + : undefined; + + return { validateOn, validateGroup }; +} + +export function getGroupMembers( + uiSchema: UiSchema, + groupName: string, + prefix = '', +): string[] { + const members: string[] = []; + const dottedPrefix = prefix ? `${prefix}.` : ''; + + for (const [key, value] of Object.entries(uiSchema)) { + if (key.startsWith('ui:')) continue; + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const fieldGroup = (value as JsonObject)['ui:validateGroup']; + if (fieldGroup === groupName) { + members.push(`${dottedPrefix}${key}`); + } + members.push( + ...getGroupMembers( + value as UiSchema, + groupName, + `${dottedPrefix}${key}`, + ), + ); + } + } + + return members; +} + +export function areAllGroupFieldsPopulated( + members: string[], + formData: JsonObject, +): boolean { + return members.every(path => { + const value = get(formData, path); + if (value === undefined || value === null || value === '') return false; + if (Array.isArray(value) && value.length === 0) return false; + return true; + }); +} diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/mergeExtraErrors.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/mergeExtraErrors.test.ts new file mode 100644 index 00000000000..1ea6de9a8fa --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/mergeExtraErrors.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ERRORS_KEY } from '@rjsf/utils'; + +import { mergeExtraErrors } from './mergeExtraErrors'; + +describe('mergeExtraErrors', () => { + it('merges errors into empty state', () => { + const fieldErrors = { + userId: { [ERRORS_KEY]: ['User ID is invalid'] }, + }; + const result = mergeExtraErrors(undefined, fieldErrors, 'userId'); + expect(result).toEqual({ + userId: { [ERRORS_KEY]: ['User ID is invalid'] }, + }); + }); + + it('replaces existing errors at the field path', () => { + const existing = { + userId: { [ERRORS_KEY]: ['Old error'] }, + }; + const fieldErrors = { + userId: { [ERRORS_KEY]: ['New error'] }, + }; + const result = mergeExtraErrors(existing, fieldErrors, 'userId'); + expect(result).toEqual({ + userId: { [ERRORS_KEY]: ['New error'] }, + }); + }); + + it('preserves errors from other fields', () => { + const existing = { + userId: { [ERRORS_KEY]: ['User error'] }, + email: { [ERRORS_KEY]: ['Email error'] }, + }; + const fieldErrors = { + userId: { [ERRORS_KEY]: ['Updated user error'] }, + }; + const result = mergeExtraErrors(existing, fieldErrors, 'userId'); + expect(result).toEqual({ + userId: { [ERRORS_KEY]: ['Updated user error'] }, + email: { [ERRORS_KEY]: ['Email error'] }, + }); + }); + + it('clears errors when validation passes', () => { + const existing = { + userId: { [ERRORS_KEY]: ['Old error'] }, + }; + const result = mergeExtraErrors(existing, {}, 'userId'); + expect(result).toBeUndefined(); + }); + + it('returns undefined when result is empty', () => { + const result = mergeExtraErrors(undefined, {}, 'userId'); + expect(result).toBeUndefined(); + }); + + it('does not mutate the existing error schema', () => { + const existing = { + userId: { [ERRORS_KEY]: ['Old error'] }, + }; + const copy = JSON.parse(JSON.stringify(existing)); + mergeExtraErrors(existing, {}, 'userId'); + expect(existing).toEqual(copy); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/mergeExtraErrors.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/mergeExtraErrors.ts new file mode 100644 index 00000000000..9c0966df528 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/mergeExtraErrors.ts @@ -0,0 +1,41 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonObject } from '@backstage/types'; + +import { ErrorSchema } from '@rjsf/utils'; +import cloneDeep from 'lodash/cloneDeep'; +import get from 'lodash/get'; +import set from 'lodash/set'; +import unset from 'lodash/unset'; + +export function mergeExtraErrors( + existing: ErrorSchema | undefined, + fieldErrors: ErrorSchema, + fieldPath: string, +): ErrorSchema | undefined { + const result = existing ? cloneDeep(existing) : {}; + + unset(result, fieldPath); + + const newFieldError = get(fieldErrors, fieldPath); + if (newFieldError && typeof newFieldError === 'object') { + set(result, fieldPath, newFieldError); + } + + if (Object.keys(result).length === 0) return undefined; + return result as ErrorSchema; +} diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts new file mode 100644 index 00000000000..1756bc41c31 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts @@ -0,0 +1,150 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + MutableRefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import { JsonObject } from '@backstage/types'; + +import { ErrorSchema } from '@rjsf/utils'; +import get from 'lodash/get'; + +import { + areAllGroupFieldsPopulated, + getFieldValidationConfig, + getGroupMembers, + ValidateOnMode, +} from './fieldValidationConfig'; +import { mergeExtraErrors } from './mergeExtraErrors'; + +const FIELD_VALIDATION_DEBOUNCE_MS = 1000; + +export interface UseFieldValidationParams { + uiSchema: JsonObject; + getExtraErrorsForField?: ( + formData: JsonObject, + fieldPath: string, + uiSchemaProperty: JsonObject, + ) => Promise>; + setExtraErrors: React.Dispatch< + React.SetStateAction | undefined> + >; + formDataRef: MutableRefObject; +} + +export function useFieldValidation({ + uiSchema, + getExtraErrorsForField, + setExtraErrors, + formDataRef, +}: UseFieldValidationParams) { + const [validatingFields, setValidatingFields] = useState>( + new Set(), + ); + const debounceTimers = useRef>>( + new Map(), + ); + const requestIdRef = useRef>(new Map()); + + const validateField = useCallback( + async (fieldPath: string) => { + const fieldUiSchema = get(uiSchema, fieldPath) as JsonObject | undefined; + if (!fieldUiSchema) return; + + const currentId = (requestIdRef.current.get(fieldPath) ?? 0) + 1; + requestIdRef.current.set(fieldPath, currentId); + + setValidatingFields(prev => new Set(prev).add(fieldPath)); + + try { + const formData = formDataRef.current; + + // Run async validation if the field has a validate:url + let asyncErrors: ErrorSchema = {}; + if (getExtraErrorsForField) { + asyncErrors = await getExtraErrorsForField( + formData, + fieldPath, + fieldUiSchema, + ); + } + + if (requestIdRef.current.get(fieldPath) !== currentId) return; + + setExtraErrors(prev => mergeExtraErrors(prev, asyncErrors, fieldPath)); + } finally { + if (requestIdRef.current.get(fieldPath) === currentId) { + setValidatingFields(prev => { + const next = new Set(prev); + next.delete(fieldPath); + return next; + }); + } + } + }, + [uiSchema, getExtraErrorsForField, setExtraErrors, formDataRef], + ); + + const triggerFieldValidation = useCallback( + (fieldPath: string, mode: ValidateOnMode) => { + const config = getFieldValidationConfig(uiSchema, fieldPath); + if (!config || !config.validateOn.includes(mode)) return; + + if (mode === 'change') { + const existing = debounceTimers.current.get(fieldPath); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => { + debounceTimers.current.delete(fieldPath); + validateField(fieldPath); + }, FIELD_VALIDATION_DEBOUNCE_MS); + debounceTimers.current.set(fieldPath, timer); + } else { + validateField(fieldPath); + } + + if (config.validateGroup) { + const members = getGroupMembers(uiSchema, config.validateGroup); + if (areAllGroupFieldsPopulated(members, formDataRef.current)) { + for (const memberPath of members) { + if (memberPath !== fieldPath) { + validateField(memberPath); + } + } + } + } + }, + [uiSchema, validateField, formDataRef], + ); + + const cleanupTimers = useCallback(() => { + for (const timer of debounceTimers.current.values()) { + clearTimeout(timer); + } + debounceTimers.current.clear(); + }, []); + + useEffect(() => cleanupTimers, [cleanupTimers]); + + return { + validatingFields, + triggerFieldValidation, + }; +} diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormDecoratorContent.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormDecoratorContent.tsx index ce8a6b1c13e..9d55019faa9 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormDecoratorContent.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormDecoratorContent.tsx @@ -29,7 +29,7 @@ import { ActiveDropdown, ActiveMultiSelect, } from './widgets'; -import { useGetExtraErrors } from './utils'; +import { useGetExtraErrors, useGetExtraErrorsForField } from './utils'; const customValidate = ( _formData: JsonObject | undefined, @@ -53,6 +53,7 @@ const FormDecoratorContent = ({ FormComponent: ComponentType; } & OrchestratorFormContextProps) => { const getExtraErrors = useGetExtraErrors(); + const getExtraErrorsForField = useGetExtraErrorsForField(); return ( ); }; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormWidgetsApi.test.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormWidgetsApi.test.tsx index 571bbcdb73f..d83fe1b0c86 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormWidgetsApi.test.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormWidgetsApi.test.tsx @@ -23,15 +23,20 @@ jest.mock('./utils', () => { return { ...actual, useGetExtraErrors: jest.fn(), + useGetExtraErrorsForField: jest.fn(), }; }); const mockedUseGetExtraErrors = utils.useGetExtraErrors as jest.Mock; +const mockedUseGetExtraErrorsForField = + utils.useGetExtraErrorsForField as jest.Mock; describe('FormWidgetsApi', () => { beforeEach(() => { mockedUseGetExtraErrors.mockReset(); mockedUseGetExtraErrors.mockReturnValue(jest.fn()); + mockedUseGetExtraErrorsForField.mockReset(); + mockedUseGetExtraErrorsForField.mockReturnValue(jest.fn()); }); it('returns undefined review component by default', () => { @@ -80,6 +85,9 @@ describe('FormWidgetsApi', () => { ); expect(receivedProps[0].customValidate).toEqual(expect.any(Function)); expect(receivedProps[0].getExtraErrors).toEqual(expect.any(Function)); + expect(receivedProps[0].getExtraErrorsForField).toEqual( + expect.any(Function), + ); expect(receivedProps[0].formContext).toEqual( expect.objectContaining({ formData: expect.any(Object), diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts index 1984e8ee0b8..f382d645daf 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts @@ -25,3 +25,5 @@ export * from './applySelector'; export * from './useProcessingState'; export * from './resolveDropdownDefault'; export * from './useClearOnRetrigger'; +export * from './validateSingleField'; +export * from './useGetExtraErrorsForField'; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useGetExtraErrorsForField.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useGetExtraErrorsForField.ts new file mode 100644 index 00000000000..feba587d542 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useGetExtraErrorsForField.ts @@ -0,0 +1,42 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fetchApiRef, useApi } from '@backstage/core-plugin-api'; +import { JsonObject } from '@backstage/types'; + +import { ErrorSchema } from '@rjsf/utils'; + +import { useTemplateUnitEvaluator } from './useTemplateUnitEvaluator'; +import { validateSingleField } from './validateSingleField'; + +export const useGetExtraErrorsForField = () => { + const fetchApi = useApi(fetchApiRef); + const templateUnitEvaluator = useTemplateUnitEvaluator(); + + return async ( + formData: JsonObject, + fieldPath: string, + uiSchemaProperty: JsonObject, + ): Promise> => { + return validateSingleField({ + formData, + fieldPath, + uiSchemaProperty, + unitEvaluator: templateUnitEvaluator, + fetchApi, + }); + }; +}; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.test.ts new file mode 100644 index 00000000000..d072b5d2dcb --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ERRORS_KEY } from '@rjsf/utils'; + +import { validateSingleField } from './validateSingleField'; + +jest.mock('./evaluateTemplate', () => ({ + evaluateTemplateString: jest.fn(), +})); + +jest.mock('./useRequestInit', () => ({ + getRequestInit: jest.fn().mockResolvedValue({}), +})); + +const { evaluateTemplateString } = jest.requireMock('./evaluateTemplate'); + +describe('validateSingleField', () => { + const mockUnitEvaluator = jest.fn().mockResolvedValue(undefined); + const mockFetch = jest.fn(); + const mockFetchApi = { fetch: mockFetch }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns empty errors when field has no validate:url', async () => { + const result = await validateSingleField({ + formData: { userId: 'test' }, + fieldPath: 'userId', + uiSchemaProperty: { + 'ui:widget': 'ActiveTextInput', + 'ui:props': {}, + }, + unitEvaluator: mockUnitEvaluator, + fetchApi: mockFetchApi, + }); + + expect(result).toEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns empty errors when widget type is not in allowed list', async () => { + const result = await validateSingleField({ + formData: { userId: 'test' }, + fieldPath: 'userId', + uiSchemaProperty: { + 'ui:widget': 'SomeOtherWidget', + 'ui:props': { 'validate:url': 'http://example.com/validate' }, + }, + unitEvaluator: mockUnitEvaluator, + fetchApi: mockFetchApi, + }); + + expect(result).toEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns empty errors when field value is undefined', async () => { + const result = await validateSingleField({ + formData: {}, + fieldPath: 'userId', + uiSchemaProperty: { + 'ui:widget': 'ActiveTextInput', + 'ui:props': { 'validate:url': 'http://example.com/validate' }, + }, + unitEvaluator: mockUnitEvaluator, + fetchApi: mockFetchApi, + }); + + expect(result).toEqual({}); + }); + + it('returns errors when validate endpoint returns non-200', async () => { + evaluateTemplateString.mockResolvedValue( + 'http://example.com/validate/test', + ); + mockFetch.mockResolvedValue({ + status: 400, + json: async () => ({}), + text: async () => '', + }); + + const result = await validateSingleField({ + formData: { userId: 'test' }, + fieldPath: 'userId', + uiSchemaProperty: { + 'ui:widget': 'ActiveTextInput', + 'ui:props': { 'validate:url': 'http://example.com/validate/${userId}' }, + }, + unitEvaluator: mockUnitEvaluator, + fetchApi: mockFetchApi, + }); + + expect(result.userId).toBeDefined(); + expect(result.userId?.[ERRORS_KEY]).toBeDefined(); + }); + + it('returns empty errors when validate endpoint returns 200', async () => { + evaluateTemplateString.mockResolvedValue( + 'http://example.com/validate/test', + ); + mockFetch.mockResolvedValue({ status: 200 }); + + const result = await validateSingleField({ + formData: { userId: 'test' }, + fieldPath: 'userId', + uiSchemaProperty: { + 'ui:widget': 'ActiveTextInput', + 'ui:props': { 'validate:url': 'http://example.com/validate/${userId}' }, + }, + unitEvaluator: mockUnitEvaluator, + fetchApi: mockFetchApi, + }); + + expect(result).toEqual({}); + }); + + it('returns error when validate:url fails to evaluate to a string', async () => { + evaluateTemplateString.mockResolvedValue(42); + + const result = await validateSingleField({ + formData: { userId: 'test' }, + fieldPath: 'userId', + uiSchemaProperty: { + 'ui:widget': 'ActiveTextInput', + 'ui:props': { 'validate:url': '${invalid}' }, + }, + unitEvaluator: mockUnitEvaluator, + fetchApi: mockFetchApi, + }); + + expect(result.userId?.[ERRORS_KEY]).toEqual( + expect.arrayContaining([ + expect.stringContaining('not evaluated to a string'), + ]), + ); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.ts new file mode 100644 index 00000000000..bc82a8f5253 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.ts @@ -0,0 +1,118 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonObject, JsonValue } from '@backstage/types'; + +import { ERRORS_KEY, ErrorSchema } from '@rjsf/utils'; +import { get } from 'lodash'; + +import { UiProps } from '../uiPropTypes'; +import { evaluateTemplateString } from './evaluateTemplate'; +import { parseValidationErrorBody } from './parseValidationErrorBody'; +import { safeSet } from './safeSet'; +import { getRequestInit } from './useRequestInit'; + +const VALIDATABLE_WIDGETS = [ + 'ActiveTextInput', + 'ActiveDropdown', + 'ActiveMultiSelect', +]; + +export async function validateSingleField(params: { + formData: JsonObject; + fieldPath: string; + uiSchemaProperty: JsonObject; + unitEvaluator: ( + unit: string, + formData: JsonObject, + responseData?: JsonObject, + uiProps?: UiProps, + ) => Promise; + fetchApi: { fetch: typeof fetch }; +}): Promise> { + const { formData, fieldPath, uiSchemaProperty, unitEvaluator, fetchApi } = + params; + const errors: ErrorSchema = {}; + + const uiProps = (uiSchemaProperty?.['ui:props'] ?? {}) as JsonObject; + const validateUrl = uiProps['validate:url']?.toString(); + + if ( + !validateUrl || + !VALIDATABLE_WIDGETS.includes( + uiSchemaProperty?.['ui:widget']?.toString() ?? '', + ) + ) { + return errors; + } + + const value = get(formData, fieldPath); + if (value === undefined) { + return errors; + } + + const evaluatedValidateUrl = await evaluateTemplateString({ + template: validateUrl, + key: 'validate:url', + unitEvaluator, + formData, + }); + + if (typeof evaluatedValidateUrl !== 'string') { + safeSet(errors, fieldPath, { + [ERRORS_KEY]: [ + `The validate:url is not evaluated to a string: "${validateUrl}"`, + ], + }); + return errors; + } + + const evaluatedRequestInit = await getRequestInit( + uiProps, + 'validate', + unitEvaluator, + formData, + ); + + const response = await fetchApi.fetch( + evaluatedValidateUrl, + evaluatedRequestInit, + ); + if (response.status !== 200) { + const data = await parseValidationErrorBody(response); + if (!data || Object.keys(data).length === 0) { + safeSet(errors, fieldPath, { + [ERRORS_KEY]: [ + `Validation request failed with status ${response.status}`, + ], + }); + return errors; + } + + Object.keys(data).forEach(key => { + // @ts-ignore + const issues = data[key]; + if (issues) { + const array = (Array.isArray(issues) ? issues : [issues]) as string[]; + safeSet(errors, fieldPath, { + [ERRORS_KEY]: array.map(e => e?.toString()), + }); + } + }); + } + + return errors; +} diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx index 7f053db938c..0633414dc3b 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx @@ -62,7 +62,7 @@ export const ActiveDropdown: Widget< const { classes } = useStyles(); const templateUnitEvaluator = useTemplateUnitEvaluator(); - const { id, label, value, onChange, formContext } = props; + const { id, label, value, onChange, onBlur, formContext } = props; const formData = formContext?.formData; const isChangedByUser = !!formContext?.getIsChangedByUser(id); const setIsChangedByUser = formContext?.setIsChangedByUser; @@ -308,6 +308,7 @@ export const ActiveDropdown: Widget< label={label} disabled={isReadOnly} onChange={event => handleChange(event.target.value as string, true)} + onBlur={() => onBlur(id, value)} MenuProps={{ PaperProps: { sx: { maxHeight: '20rem' } }, }} diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx index 1e8ee12ec66..7fc977ba93a 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx @@ -70,7 +70,15 @@ export const ActiveMultiSelect: Widget< > = props => { const { classes } = useStyles(); const templateUnitEvaluator = useTemplateUnitEvaluator(); - const { id, name, label, value: rawValue, onChange, formContext } = props; + const { + id, + name, + label, + value: rawValue, + onChange, + onBlur, + formContext, + } = props; const value = rawValue as string[]; const formData = formContext?.formData; const isChangedByUser = !!formContext?.getIsChangedByUser(id); @@ -349,6 +357,7 @@ export const ActiveMultiSelect: Widget< freeSolo={allowNewItems} data-testid={`${id}-autocomplete`} disabled={isReadOnly} + onBlur={() => onBlur(id, value)} options={allOptions} isOptionEqualToValue={(option, selected) => option === selected} value={value} diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx index e5e28722569..d71e08b2b48 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx @@ -57,7 +57,7 @@ export const ActiveTextInput: Widget< const { classes } = useStyles(); const templateUnitEvaluator = useTemplateUnitEvaluator(); - const { id, label, value, onChange, formContext } = props; + const { id, label, value, onChange, onBlur, formContext } = props; const formData = formContext?.formData; const isChangedByUser = !!formContext?.getIsChangedByUser(id); const setIsChangedByUser = formContext?.setIsChangedByUser; @@ -227,6 +227,7 @@ export const ActiveTextInput: Widget< {...params} data-testid={`${id}-textfield`} onChange={event => handleChange(event.target.value, true)} + onBlur={() => onBlur(id, value)} label={label} disabled={isReadOnly} /> @@ -267,6 +268,7 @@ export const ActiveTextInput: Widget< value={value ?? ''} data-testid={`${id}-textfield`} onChange={event => handleChange(event.target.value, true)} + onBlur={() => onBlur(id, value)} label={label} disabled={isReadOnly} /> From b71c2ec32f2183daa08505c174860dca4bbd83fe Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Tue, 4 Aug 2026 13:47:34 +0530 Subject: [PATCH 2/7] chore: update API report for orchestrator-form-api Co-Authored-By: Claude Opus 4.6 --- .../plugins/orchestrator-form-api/report.api.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-api/report.api.md b/workspaces/orchestrator/plugins/orchestrator-form-api/report.api.md index 603d0d773cc..487c8048c6e 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-api/report.api.md +++ b/workspaces/orchestrator/plugins/orchestrator-form-api/report.api.md @@ -25,6 +25,11 @@ export type FormDecoratorProps = Pick< formData: JsonObject, uiSchema: OrchestratorFormContextProps['uiSchema'], ) => Promise> | undefined; + getExtraErrorsForField?: ( + formData: JsonObject, + fieldPath: string, + uiSchemaProperty: JsonObject, + ) => Promise>; }; // @public @@ -54,6 +59,7 @@ export type OrchestratorFormContextProps = { handleFetchStarted?: () => void; handleFetchEnded?: () => void; onSamlSsoError?: (error: Error) => void; + validatingFields?: ReadonlySet; }; // @public @@ -91,7 +97,7 @@ export const useOrchestratorFormApiOrDefault: () => OrchestratorFormApi; // Warnings were encountered during analysis: // -// src/api.d.ts:132:22 - (ae-undocumented) Missing documentation for "useOrchestratorFormApiOrDefault". +// src/api.d.ts:134:22 - (ae-undocumented) Missing documentation for "useOrchestratorFormApiOrDefault". // (No @packageDocumentation comment for this package) ``` From 5905ad444102fb790ab3e8895762bbd0e47b0cc7 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Tue, 4 Aug 2026 13:55:00 +0530 Subject: [PATCH 3/7] fix: address code review feedback for field-level validation - Add try/catch around getExtraErrorsForField to handle network failures - Guard onBlur with optional chaining in ActiveTextInput, ActiveDropdown, ActiveMultiSelect - Update formDataRef synchronously in onChange to prevent stale ref reads Co-Authored-By: Claude Opus 4.6 --- .../src/components/OrchestratorFormWrapper.tsx | 4 +++- .../src/utils/useFieldValidation.ts | 15 +++++++++------ .../src/widgets/ActiveDropdown.tsx | 2 +- .../src/widgets/ActiveMultiSelect.tsx | 2 +- .../src/widgets/ActiveTextInput.tsx | 4 ++-- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx index d5c651f07a5..cfc31190f84 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx @@ -181,7 +181,9 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => { const fieldPath = rjsfIdToFieldPath(id); setExtraErrors(prev => clearExtraErrorAtPath(prev, fieldPath)); setValidationError(undefined); - setFormData(e.formData || {}); + const latestFormData = e.formData || {}; + formDataRef.current = latestFormData; + setFormData(latestFormData); if (decoratorProps.onChange) { decoratorProps.onChange(e, id); } diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts index 1756bc41c31..44482a73cac 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts @@ -77,14 +77,17 @@ export function useFieldValidation({ try { const formData = formDataRef.current; - // Run async validation if the field has a validate:url let asyncErrors: ErrorSchema = {}; if (getExtraErrorsForField) { - asyncErrors = await getExtraErrorsForField( - formData, - fieldPath, - fieldUiSchema, - ); + try { + asyncErrors = await getExtraErrorsForField( + formData, + fieldPath, + fieldUiSchema, + ); + } catch { + // Network or evaluation failure — treat as no extra errors + } } if (requestIdRef.current.get(fieldPath) !== currentId) return; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx index 0633414dc3b..88e1e52c6df 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx @@ -308,7 +308,7 @@ export const ActiveDropdown: Widget< label={label} disabled={isReadOnly} onChange={event => handleChange(event.target.value as string, true)} - onBlur={() => onBlur(id, value)} + onBlur={() => onBlur?.(id, value)} MenuProps={{ PaperProps: { sx: { maxHeight: '20rem' } }, }} diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx index 7fc977ba93a..63106587f47 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx @@ -357,7 +357,7 @@ export const ActiveMultiSelect: Widget< freeSolo={allowNewItems} data-testid={`${id}-autocomplete`} disabled={isReadOnly} - onBlur={() => onBlur(id, value)} + onBlur={() => onBlur?.(id, value)} options={allOptions} isOptionEqualToValue={(option, selected) => option === selected} value={value} diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx index d71e08b2b48..aff429bb588 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx @@ -227,7 +227,7 @@ export const ActiveTextInput: Widget< {...params} data-testid={`${id}-textfield`} onChange={event => handleChange(event.target.value, true)} - onBlur={() => onBlur(id, value)} + onBlur={() => onBlur?.(id, value)} label={label} disabled={isReadOnly} /> @@ -268,7 +268,7 @@ export const ActiveTextInput: Widget< value={value ?? ''} data-testid={`${id}-textfield`} onChange={event => handleChange(event.target.value, true)} - onBlur={() => onBlur(id, value)} + onBlur={() => onBlur?.(id, value)} label={label} disabled={isReadOnly} /> From 157bf6f9981652ed471769054522c2280a36193d Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Tue, 4 Aug 2026 14:47:03 +0530 Subject: [PATCH 4/7] fix: address SonarCloud code smells - Use optional chaining in triggerFieldValidation - Convert VALIDATABLE_WIDGETS array to Set with .has() lookup Co-Authored-By: Claude Opus 4.6 --- .../src/utils/useFieldValidation.ts | 2 +- .../src/utils/validateSingleField.ts | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts index 44482a73cac..e9e071bf0a4 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts @@ -109,7 +109,7 @@ export function useFieldValidation({ const triggerFieldValidation = useCallback( (fieldPath: string, mode: ValidateOnMode) => { const config = getFieldValidationConfig(uiSchema, fieldPath); - if (!config || !config.validateOn.includes(mode)) return; + if (!config?.validateOn.includes(mode)) return; if (mode === 'change') { const existing = debounceTimers.current.get(fieldPath); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.ts index bc82a8f5253..d993d898793 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.ts @@ -25,11 +25,11 @@ import { parseValidationErrorBody } from './parseValidationErrorBody'; import { safeSet } from './safeSet'; import { getRequestInit } from './useRequestInit'; -const VALIDATABLE_WIDGETS = [ +const VALIDATABLE_WIDGETS = new Set([ 'ActiveTextInput', 'ActiveDropdown', 'ActiveMultiSelect', -]; +]); export async function validateSingleField(params: { formData: JsonObject; @@ -52,9 +52,7 @@ export async function validateSingleField(params: { if ( !validateUrl || - !VALIDATABLE_WIDGETS.includes( - uiSchemaProperty?.['ui:widget']?.toString() ?? '', - ) + !VALIDATABLE_WIDGETS.has(uiSchemaProperty?.['ui:widget']?.toString() ?? '') ) { return errors; } From c9da443fab61354170f3e2eaf582790066554591 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Tue, 4 Aug 2026 21:07:46 +0530 Subject: [PATCH 5/7] fix: avoid error flicker on change-validated fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip clearing errors immediately on keystroke when the field has ui:validateOn: "change" — let the debounced validation replace them instead of clearing and re-showing. Co-Authored-By: Claude Opus 4.6 --- .../src/components/OrchestratorFormWrapper.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx index cfc31190f84..ad7de4c675d 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx @@ -37,6 +37,7 @@ import { clearExtraErrorAtPath, rjsfIdToFieldPath, } from '../utils/clearExtraErrorAtPath'; +import { getFieldValidationConfig } from '../utils/fieldValidationConfig'; import { getActiveStepKey } from '../utils/getSortedStepEntries'; import { normalizeErrorSchema } from '../utils/resolveStepErrorSchema'; import { useStepperContext } from '../utils/StepperContext'; @@ -179,7 +180,15 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => { id?: string, ) => { const fieldPath = rjsfIdToFieldPath(id); - setExtraErrors(prev => clearExtraErrorAtPath(prev, fieldPath)); + const hasChangeValidation = + fieldPath && + getFieldValidationConfig( + formContext.uiSchema, + fieldPath, + )?.validateOn.includes('change'); + if (!hasChangeValidation) { + setExtraErrors(prev => clearExtraErrorAtPath(prev, fieldPath)); + } setValidationError(undefined); const latestFormData = e.formData || {}; formDataRef.current = latestFormData; From 783826768e32d3d26997c049d2691db0948d826e Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Wed, 5 Aug 2026 12:58:58 +0530 Subject: [PATCH 6/7] fix: address review feedback - debounce group validation, error handling - Debounce group member validation when triggered via change mode - Add try/catch around fetch with user-facing error message - Collect all error messages from response before setting errors - Add tests for multi-key errors and network failure Co-Authored-By: Claude Opus 4.6 --- .../src/utils/useFieldValidation.ts | 12 ++++- .../src/utils/validateSingleField.test.ts | 54 +++++++++++++++++++ .../src/utils/validateSingleField.ts | 22 +++++--- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts index e9e071bf0a4..584e6c45699 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts @@ -128,7 +128,17 @@ export function useFieldValidation({ if (areAllGroupFieldsPopulated(members, formDataRef.current)) { for (const memberPath of members) { if (memberPath !== fieldPath) { - validateField(memberPath); + if (mode === 'change') { + const existing = debounceTimers.current.get(memberPath); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => { + debounceTimers.current.delete(memberPath); + validateField(memberPath); + }, FIELD_VALIDATION_DEBOUNCE_MS); + debounceTimers.current.set(memberPath, timer); + } else { + validateField(memberPath); + } } } } diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.test.ts index d072b5d2dcb..70a4021cd6a 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.test.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.test.ts @@ -129,6 +129,60 @@ describe('validateSingleField', () => { expect(result).toEqual({}); }); + it('collects all error messages from multiple keys in response body', async () => { + evaluateTemplateString.mockResolvedValue( + 'http://example.com/validate/test', + ); + const body = { + name: ['Name is required', 'Name must be alphanumeric'], + format: 'Invalid format', + }; + mockFetch.mockResolvedValue({ + status: 422, + json: async () => body, + text: async () => JSON.stringify(body), + }); + + const result = await validateSingleField({ + formData: { userId: 'test' }, + fieldPath: 'userId', + uiSchemaProperty: { + 'ui:widget': 'ActiveTextInput', + 'ui:props': { 'validate:url': 'http://example.com/validate/${userId}' }, + }, + unitEvaluator: mockUnitEvaluator, + fetchApi: mockFetchApi, + }); + + expect(result.userId?.[ERRORS_KEY]).toEqual([ + 'Name is required', + 'Name must be alphanumeric', + 'Invalid format', + ]); + }); + + it('returns network error when fetch throws', async () => { + evaluateTemplateString.mockResolvedValue( + 'http://example.com/validate/test', + ); + mockFetch.mockRejectedValue(new TypeError('Failed to fetch')); + + const result = await validateSingleField({ + formData: { userId: 'test' }, + fieldPath: 'userId', + uiSchemaProperty: { + 'ui:widget': 'ActiveTextInput', + 'ui:props': { 'validate:url': 'http://example.com/validate/${userId}' }, + }, + unitEvaluator: mockUnitEvaluator, + fetchApi: mockFetchApi, + }); + + expect(result.userId?.[ERRORS_KEY]).toEqual([ + 'Validation request failed: unable to reach the server', + ]); + }); + it('returns error when validate:url fails to evaluate to a string', async () => { evaluateTemplateString.mockResolvedValue(42); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.ts index d993d898793..e6e60da8243 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/validateSingleField.ts @@ -85,10 +85,16 @@ export async function validateSingleField(params: { formData, ); - const response = await fetchApi.fetch( - evaluatedValidateUrl, - evaluatedRequestInit, - ); + let response: Response; + try { + response = await fetchApi.fetch(evaluatedValidateUrl, evaluatedRequestInit); + } catch { + safeSet(errors, fieldPath, { + [ERRORS_KEY]: ['Validation request failed: unable to reach the server'], + }); + return errors; + } + if (response.status !== 200) { const data = await parseValidationErrorBody(response); if (!data || Object.keys(data).length === 0) { @@ -100,16 +106,18 @@ export async function validateSingleField(params: { return errors; } + const allMessages: string[] = []; Object.keys(data).forEach(key => { // @ts-ignore const issues = data[key]; if (issues) { const array = (Array.isArray(issues) ? issues : [issues]) as string[]; - safeSet(errors, fieldPath, { - [ERRORS_KEY]: array.map(e => e?.toString()), - }); + allMessages.push(...array.map(e => e?.toString())); } }); + if (allMessages.length > 0) { + safeSet(errors, fieldPath, { [ERRORS_KEY]: allMessages }); + } } return errors; From edc56dc25e54cbd79f33b3d03690ee4b0a6006cd Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Wed, 5 Aug 2026 13:06:42 +0530 Subject: [PATCH 7/7] refactor: extract scheduleValidation to reduce cognitive complexity Extract debounce-or-immediate logic into scheduleValidation helper, eliminating duplicated nested branches in triggerFieldValidation. Co-Authored-By: Claude Opus 4.6 --- .../src/utils/useFieldValidation.ts | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts index 584e6c45699..e922eaf2518 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/useFieldValidation.ts @@ -106,45 +106,42 @@ export function useFieldValidation({ [uiSchema, getExtraErrorsForField, setExtraErrors, formDataRef], ); - const triggerFieldValidation = useCallback( - (fieldPath: string, mode: ValidateOnMode) => { - const config = getFieldValidationConfig(uiSchema, fieldPath); - if (!config?.validateOn.includes(mode)) return; - + const scheduleValidation = useCallback( + (path: string, mode: ValidateOnMode) => { if (mode === 'change') { - const existing = debounceTimers.current.get(fieldPath); + const existing = debounceTimers.current.get(path); if (existing) clearTimeout(existing); const timer = setTimeout(() => { - debounceTimers.current.delete(fieldPath); - validateField(fieldPath); + debounceTimers.current.delete(path); + validateField(path); }, FIELD_VALIDATION_DEBOUNCE_MS); - debounceTimers.current.set(fieldPath, timer); + debounceTimers.current.set(path, timer); } else { - validateField(fieldPath); + validateField(path); } + }, + [validateField], + ); + + const triggerFieldValidation = useCallback( + (fieldPath: string, mode: ValidateOnMode) => { + const config = getFieldValidationConfig(uiSchema, fieldPath); + if (!config?.validateOn.includes(mode)) return; + + scheduleValidation(fieldPath, mode); if (config.validateGroup) { const members = getGroupMembers(uiSchema, config.validateGroup); if (areAllGroupFieldsPopulated(members, formDataRef.current)) { for (const memberPath of members) { if (memberPath !== fieldPath) { - if (mode === 'change') { - const existing = debounceTimers.current.get(memberPath); - if (existing) clearTimeout(existing); - const timer = setTimeout(() => { - debounceTimers.current.delete(memberPath); - validateField(memberPath); - }, FIELD_VALIDATION_DEBOUNCE_MS); - debounceTimers.current.set(memberPath, timer); - } else { - validateField(memberPath); - } + scheduleValidation(memberPath, mode); } } } } }, - [uiSchema, validateField, formDataRef], + [uiSchema, scheduleValidation, formDataRef], ); const cleanupTimers = useCallback(() => {