Skip to content

Commit 4aec634

Browse files
fix(orchestrator): preserve nested extraErrors and async validation errors (#2818)
1 parent dc43ffa commit 4aec634

8 files changed

Lines changed: 308 additions & 16 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch
3+
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': patch
4+
---
5+
6+
Fix multi-step workflow forms dropping or misplacing async validation errors and deep nested field paths.
7+
8+
- @red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets: Fix safeSet deep paths and sequential async validation in getExtraErrors.
9+
10+
- @red-hat-developer-hub/backstage-plugin-orchestrator-form-react: Wrap extraErrors with the active step key so RJSF matches the root schema.

workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import { useTranslation } from '../hooks/useTranslation';
3636
import { getActiveStepKey } from '../utils/getSortedStepEntries';
3737
import { useStepperContext } from '../utils/StepperContext';
38+
import { toRootExtraErrors } from '../utils/toRootExtraErrors';
3839
import useValidator from '../utils/useValidator';
3940
import { AuthRequester } from './AuthRequester';
4041
import HiddenObjectFieldTemplate from './HiddenObjectFieldTemplate';
@@ -102,15 +103,7 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => {
102103
extraErrorsUiSchema,
103104
);
104105

105-
if (activeKey) {
106-
setExtraErrors(
107-
_extraErrors?.[activeKey]
108-
? (_extraErrors[activeKey] as ErrorSchema<JsonObject>)
109-
: undefined,
110-
);
111-
} else {
112-
setExtraErrors(_extraErrors);
113-
}
106+
setExtraErrors(toRootExtraErrors(activeKey, _extraErrors));
114107
} catch (err) {
115108
_validationError = err as Error;
116109
} finally {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { ERRORS_KEY } from '@rjsf/utils';
18+
19+
import { toRootExtraErrors } from './toRootExtraErrors';
20+
21+
describe('toRootExtraErrors', () => {
22+
it('returns undefined when extraErrors is undefined', () => {
23+
expect(toRootExtraErrors('my-step', undefined)).toBeUndefined();
24+
});
25+
26+
it('returns extraErrors unchanged when activeKey is undefined', () => {
27+
const tree = { a: { [ERRORS_KEY]: ['x'] } };
28+
expect(toRootExtraErrors(undefined, tree)).toBe(tree);
29+
});
30+
31+
it('wraps the active step slice so RJSF receives root-shaped errors', () => {
32+
const inner = {
33+
xParams: {
34+
fieldA: { [ERRORS_KEY]: ['invalid A'] },
35+
fieldB: { [ERRORS_KEY]: ['invalid B'] },
36+
},
37+
};
38+
const fromGetExtraErrors = {
39+
'my-solution': inner,
40+
};
41+
expect(toRootExtraErrors('my-solution', fromGetExtraErrors)).toEqual({
42+
'my-solution': inner,
43+
});
44+
});
45+
46+
it('returns undefined when the active step has no errors in the tree', () => {
47+
expect(
48+
toRootExtraErrors('missing-step', {
49+
'other-step': { [ERRORS_KEY]: ['e'] },
50+
}),
51+
).toBeUndefined();
52+
});
53+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { JsonObject } from '@backstage/types';
18+
19+
import { ErrorSchema } from '@rjsf/utils';
20+
21+
/**
22+
* RJSF `extraErrors` must match the root `schema.properties` shape. For
23+
* multi-step forms, `getExtraErrors` returns a tree keyed by step name; pass
24+
* `activeKey` so the slice is wrapped again for the root form.
25+
*/
26+
export function toRootExtraErrors(
27+
activeKey: string | undefined,
28+
extraErrors: ErrorSchema<JsonObject> | undefined,
29+
): ErrorSchema<JsonObject> | undefined {
30+
if (!extraErrors) {
31+
return undefined;
32+
}
33+
if (!activeKey) {
34+
return extraErrors;
35+
}
36+
const slice = extraErrors[activeKey];
37+
if (!slice) {
38+
return undefined;
39+
}
40+
return { [activeKey]: slice } as ErrorSchema<JsonObject>;
41+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { ERRORS_KEY } from '@rjsf/utils';
18+
19+
import { safeSet } from './safeSet';
20+
import { JsonObject } from '@backstage/types/index';
21+
22+
describe('safeSet', () => {
23+
it('splits only on the first dot so deep paths (e.g. step.x.y) are not truncated', () => {
24+
// `String.prototype.split(".", 2)` truncates remainder (e.g. "a.b.c" -> ["a","b"]);
25+
// paths with three or more segments must recurse on `a` + `b.c`, not `a` + `b` only.
26+
const errors: JsonObject = {};
27+
safeSet(errors, 'a.b.c.d', { [ERRORS_KEY]: ['deep'] });
28+
expect(errors).toEqual({
29+
a: {
30+
b: {
31+
c: {
32+
d: {
33+
[ERRORS_KEY]: ['deep'],
34+
},
35+
},
36+
},
37+
},
38+
});
39+
});
40+
41+
it('sets a single-segment path', () => {
42+
const errors: JsonObject = {};
43+
safeSet(errors, 'onlyKey', { [ERRORS_KEY]: ['msg'] });
44+
expect(errors).toEqual({ onlyKey: { [ERRORS_KEY]: ['msg'] } });
45+
});
46+
47+
it('merges sibling leaf paths under a shared prefix when applied sequentially', () => {
48+
const errors: JsonObject = {};
49+
safeSet(errors, 'my-solution.xParams.fieldA', {
50+
[ERRORS_KEY]: ['a'],
51+
});
52+
safeSet(errors, 'my-solution.xParams.fieldB', {
53+
[ERRORS_KEY]: ['b'],
54+
});
55+
safeSet(errors, 'my-solution.xParams.fieldC', {
56+
[ERRORS_KEY]: ['c'],
57+
});
58+
expect(errors).toEqual({
59+
'my-solution': {
60+
xParams: {
61+
fieldA: { [ERRORS_KEY]: ['a'] },
62+
fieldB: { [ERRORS_KEY]: ['b'] },
63+
fieldC: { [ERRORS_KEY]: ['c'] },
64+
},
65+
},
66+
});
67+
});
68+
});

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/safeSet.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ export const safeSet: (
2121
path: string,
2222
value: JsonValue,
2323
) => void = (errors, path, value) => {
24-
const steps = path.split('.', 2);
25-
if (steps.length === 1) {
26-
errors[steps[0]] = value;
24+
const dot = path.indexOf('.');
25+
if (dot === -1) {
26+
errors[path] = value;
2727
} else {
28-
const safeObject = (errors[steps[0]] ?? {}) as JsonObject;
29-
errors[steps[0]] = safeObject;
30-
safeSet(safeObject, steps[1], value);
28+
const head = path.slice(0, dot);
29+
const rest = path.slice(dot + 1);
30+
const safeObject = (errors[head] ?? {}) as JsonObject;
31+
errors[head] = safeObject;
32+
safeSet(safeObject, rest, value);
3133
}
3234
};
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS-IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { renderHook } from '@testing-library/react';
18+
19+
import { JsonObject } from '@backstage/types';
20+
import { ERRORS_KEY } from '@rjsf/utils';
21+
22+
import { useGetExtraErrors } from './useGetExtraErrors';
23+
24+
jest.mock('./evaluateTemplate', () => ({
25+
evaluateTemplateString: jest
26+
.fn()
27+
.mockResolvedValue('https://example.com/validate'),
28+
}));
29+
30+
jest.mock('./useRequestInit', () => ({
31+
getRequestInit: jest.fn().mockResolvedValue({}),
32+
}));
33+
34+
jest.mock('./useTemplateUnitEvaluator', () => ({
35+
useTemplateUnitEvaluator: jest.fn(() => async () => undefined),
36+
}));
37+
38+
const mockFetch = jest.fn();
39+
40+
jest.mock('@backstage/core-plugin-api', () => {
41+
const actual = jest.requireActual('@backstage/core-plugin-api');
42+
return {
43+
...actual,
44+
useApi: (ref: unknown) => {
45+
if (ref === actual.fetchApiRef) {
46+
return { fetch: mockFetch };
47+
}
48+
throw new Error(
49+
`useGetExtraErrors.test: unmocked api ref: ${String(ref)}`,
50+
);
51+
},
52+
};
53+
});
54+
55+
describe('useGetExtraErrors', () => {
56+
beforeEach(() => {
57+
mockFetch.mockReset();
58+
});
59+
60+
it('aggregates async validation errors for all nested fields under a shared path prefix', async () => {
61+
let fetchCall = 0;
62+
mockFetch.mockImplementation(async () => {
63+
const n = ++fetchCall;
64+
return {
65+
status: 422,
66+
json: async () => ({ issues: [`validation failed for request ${n}`] }),
67+
};
68+
});
69+
70+
const { result } = renderHook(() => useGetExtraErrors());
71+
72+
const uiSchema: JsonObject = {
73+
'my-solution': {
74+
xParams: {
75+
fieldA: {
76+
'ui:widget': 'ActiveTextInput',
77+
'ui:props': { 'validate:url': 'https://example.com/validate' },
78+
},
79+
fieldB: {
80+
'ui:widget': 'ActiveTextInput',
81+
'ui:props': { 'validate:url': 'https://example.com/validate' },
82+
},
83+
fieldC: {
84+
'ui:widget': 'ActiveTextInput',
85+
'ui:props': { 'validate:url': 'https://example.com/validate' },
86+
},
87+
},
88+
},
89+
};
90+
91+
const formData: JsonObject = {
92+
'my-solution': {
93+
xParams: {
94+
fieldA: '',
95+
fieldB: '',
96+
fieldC: '',
97+
},
98+
},
99+
};
100+
101+
const errors = await result.current(formData, uiSchema);
102+
103+
expect(mockFetch).toHaveBeenCalledTimes(3);
104+
expect(errors).toEqual({
105+
'my-solution': {
106+
xParams: {
107+
fieldA: {
108+
[ERRORS_KEY]: ['validation failed for request 1'],
109+
},
110+
fieldB: {
111+
[ERRORS_KEY]: ['validation failed for request 2'],
112+
},
113+
fieldC: {
114+
[ERRORS_KEY]: ['validation failed for request 3'],
115+
},
116+
},
117+
},
118+
});
119+
});
120+
});

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useGetExtraErrors.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,12 @@ export const useGetExtraErrors = () => {
143143
'',
144144
);
145145

146-
await Promise.all(promises);
146+
// Run sequentially so concurrent async callbacks cannot interleave
147+
// `safeSet` mutations on the shared `errors` object (lost updates under
148+
// the same path prefix, e.g. `step.xParams.fieldA` vs `fieldB`).
149+
for (const p of promises) {
150+
await p;
151+
}
147152
return errors;
148153
};
149154
};

0 commit comments

Comments
 (0)