Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -130,10 +134,14 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => {

const onChange = (
e: IChangeEvent<JsonObject, JSONSchema7, OrchestratorFormContextProps>,
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);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonObject>}
registry={{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<JsonObject>;

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',
);
});
});
Original file line number Diff line number Diff line change
@@ -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, '.');

Check warning on line 37 in workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/clearExtraErrorAtPath.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ7uKyIsTKPW_fjH0kS_&open=AZ7uKyIsTKPW_fjH0kS_&pullRequest=3522
}

function pruneEmptyErrorBranches(
node: ErrorSchema<JsonObject> | undefined,
): ErrorSchema<JsonObject> | undefined {
if (!node || typeof node !== 'object') {
return undefined;
}

if (Array.isArray((node as JsonObject)[ERRORS_KEY])) {
return node;
}

const pruned: ErrorSchema<JsonObject> = {};
for (const [key, value] of Object.entries(node)) {
if (key === ERRORS_KEY) {
continue;
}
const child = pruneEmptyErrorBranches(value as ErrorSchema<JsonObject>);
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<JsonObject> | undefined,
fieldPath: string | undefined,
): ErrorSchema<JsonObject> | undefined {
if (!extraErrors) {
return undefined;
}

if (!fieldPath) {
return extraErrors;
}

const next = cloneDeep(extraErrors);
unset(next, fieldPath);
return pruneEmptyErrorBranches(next);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@
import { getRequestInit } from './useRequestInit';
import { safeSet } from './safeSet';

const parseValidationErrorBody = async (
response: Response,
): Promise<JsonObject | undefined> => {
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: (
Expand Down Expand Up @@ -74,7 +94,7 @@
): Promise<ErrorSchema<JsonObject>> => {
// Asynchronous validation on wizard step transition or submit
const errors: ErrorSchema<JsonObject> = {};
const callback = async (path: string, uiSchemaProperty: JsonObject) => {

Check failure on line 97 in workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useGetExtraErrors.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ7uKyBrTKPW_fjH0kS-&open=AZ7uKyBrTKPW_fjH0kS-&pullRequest=3522
const uiProps = (uiSchemaProperty?.['ui:props'] ?? {}) as JsonObject;
const validateUrl = uiProps['validate:url']?.toString();

Expand Down Expand Up @@ -115,7 +135,13 @@
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
Expand Down
Loading