diff --git a/workspaces/orchestrator/.changeset/clear-validation-errors-on-edit.md b/workspaces/orchestrator/.changeset/clear-validation-errors-on-edit.md new file mode 100644 index 0000000000..6504e186f2 --- /dev/null +++ b/workspaces/orchestrator/.changeset/clear-validation-errors-on-edit.md @@ -0,0 +1,6 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': patch +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch +--- + +Clear async validation errors when the user edits a workflow form field after clicking Next. Only the changed field's error is removed; other field errors remain until that field is edited or the step is validated again. Also handle empty or non-JSON validation responses without breaking the form. 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 91d2ef9396..db403be924 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx @@ -33,6 +33,10 @@ import { } from '@red-hat-developer-hub/backstage-plugin-orchestrator-form-api'; import { useTranslation } from '../hooks/useTranslation'; +import { + clearExtraErrorAtPath, + rjsfIdToFieldPath, +} from '../utils/clearExtraErrorAtPath'; import { getActiveStepKey } from '../utils/getSortedStepEntries'; import { useStepperContext } from '../utils/StepperContext'; import { toRootExtraErrors } from '../utils/toRootExtraErrors'; @@ -130,10 +134,14 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => { const onChange = ( e: IChangeEvent, + id?: string, ) => { + const fieldPath = rjsfIdToFieldPath(id); + setExtraErrors(prev => clearExtraErrorAtPath(prev, fieldPath)); + setValidationError(undefined); setFormData(e.formData || {}); if (decoratorProps.onChange) { - decoratorProps.onChange(e); + decoratorProps.onChange(e, id); } }; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/StepperObjectField.tsx b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/StepperObjectField.tsx index 2ac629c9d8..c837018231 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/StepperObjectField.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/StepperObjectField.tsx @@ -64,8 +64,8 @@ const StepperObjectField = ({ schema={{ ...subSchema, title: '' }} // the title is in the step uiSchema={uiSchema?.[key] || {}} formData={(formData?.[key] as JsonObject) || {}} - onChange={data => { - onChange({ ...formData, [key]: data }); + onChange={(data, newErrorSchema, id) => { + onChange({ ...formData, [key]: data }, newErrorSchema, id); }} idSchema={idSchema[key] as IdSchema} registry={{ diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/clearExtraErrorAtPath.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/clearExtraErrorAtPath.test.ts new file mode 100644 index 0000000000..a25376f4b0 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/clearExtraErrorAtPath.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { ERRORS_KEY, ErrorSchema } from '@rjsf/utils'; + +import { + clearExtraErrorAtPath, + rjsfIdToFieldPath, +} from './clearExtraErrorAtPath'; + +describe('clearExtraErrorAtPath', () => { + const extraErrors = { + 'managed-app-ci': { + xParams: { + fossaTeamName: { [ERRORS_KEY]: ['required'] }, + sonarProjectKey: { [ERRORS_KEY]: ['required'] }, + }, + }, + } as ErrorSchema; + + it('preserves all errors when fieldPath is undefined', () => { + expect(clearExtraErrorAtPath(extraErrors, undefined)).toBe(extraErrors); + }); + + it('clears only the targeted field', () => { + expect( + clearExtraErrorAtPath( + extraErrors, + 'managed-app-ci.xParams.fossaTeamName', + ), + ).toEqual({ + 'managed-app-ci': { + xParams: { + sonarProjectKey: { [ERRORS_KEY]: ['required'] }, + }, + }, + }); + }); + + it('maps rjsf ids with hyphens to dotted paths', () => { + expect(rjsfIdToFieldPath('root_managed-app-ci_xParams_fossaTeamName')).toBe( + 'managed-app-ci.xParams.fossaTeamName', + ); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/clearExtraErrorAtPath.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/clearExtraErrorAtPath.ts new file mode 100644 index 0000000000..0e1ae0cbfb --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/clearExtraErrorAtPath.ts @@ -0,0 +1,84 @@ +/* + * 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 { ERRORS_KEY, ErrorSchema } from '@rjsf/utils'; +import cloneDeep from 'lodash/cloneDeep'; +import unset from 'lodash/unset'; + +/** + * Maps an RJSF field id (e.g. `root_stepOne_xParams_name`) to a dotted form + * path used in extraErrors (e.g. `stepOne.xParams.name`). + */ +export function rjsfIdToFieldPath(id?: string): string | undefined { + if (!id || id === 'root') { + return undefined; + } + + const withoutRoot = id.startsWith('root_') ? id.slice('root_'.length) : id; + if (!withoutRoot) { + return undefined; + } + + return withoutRoot.replace(/_/g, '.'); +} + +function pruneEmptyErrorBranches( + node: ErrorSchema | undefined, +): ErrorSchema | undefined { + if (!node || typeof node !== 'object') { + return undefined; + } + + if (Array.isArray((node as JsonObject)[ERRORS_KEY])) { + return node; + } + + const pruned: ErrorSchema = {}; + for (const [key, value] of Object.entries(node)) { + if (key === ERRORS_KEY) { + continue; + } + const child = pruneEmptyErrorBranches(value as ErrorSchema); + if (child !== undefined) { + pruned[key] = child; + } + } + + return Object.keys(pruned).length > 0 ? pruned : undefined; +} + +/** + * Removes async validation errors for a single field. When `fieldPath` is + * omitted, existing errors are preserved (nested step onChange often has no id). + */ +export function clearExtraErrorAtPath( + extraErrors: ErrorSchema | undefined, + fieldPath: string | undefined, +): ErrorSchema | undefined { + if (!extraErrors) { + return undefined; + } + + if (!fieldPath) { + return extraErrors; + } + + const next = cloneDeep(extraErrors); + unset(next, fieldPath); + return pruneEmptyErrorBranches(next); +} diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useGetExtraErrors.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useGetExtraErrors.ts index 2ed6ca88b9..59b1caae64 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useGetExtraErrors.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useGetExtraErrors.ts @@ -27,6 +27,26 @@ import { evaluateTemplateString } from './evaluateTemplate'; import { getRequestInit } from './useRequestInit'; import { safeSet } from './safeSet'; +const parseValidationErrorBody = async ( + response: Response, +): Promise => { + try { + if (typeof response.text === 'function') { + const text = await response.text(); + if (!text) { + return undefined; + } + return JSON.parse(text) as JsonObject; + } + if (typeof response.json === 'function') { + return (await response.json()) as JsonObject; + } + } catch { + return undefined; + } + return undefined; +}; + // Walks through the uiSchema and calls the "callback" for every field which is backed by the dynamic ui:widget. // The callback is provided with the uiSchema path, content of the uiSchema part and the corresponding entered formData value. const walkThrough: ( @@ -115,7 +135,13 @@ export const useGetExtraErrors = () => { evaluatedRequestInit, ); if (response.status !== 200) { - const data = (await response.json()) as JsonObject; + const data = await parseValidationErrorBody(response); + if (!data || Object.keys(data).length === 0) { + safeSet(errors, path, { + [ERRORS_KEY]: `Validation request failed with status ${response.status}`, + }); + return; + } Object.keys(data).forEach(key => { // @ts-ignore