Skip to content

Commit dd649ef

Browse files
committed
fix(ui): ensure oneOf choices inherit type and properties for forms (#3508)
Problem: The Kaoto form engine (uniforms via `@kaoto/forms`) relies on each choice in a `oneOf` JSON schema array having `type: 'object'` and its own `properties` structure. When a `oneOf` schema choice only defines constraints (e.g. `{"required": ["data"]}`) on properties defined in the parent object, the form engine cannot render an appropriate choice option for it, leading to visual bugs and empty form sections. Solution: Created a `normalizeOneOfChoices` utility that intercepts JSON schemas before they are handed to `KaotoForm`. When traversing the schema, if a `oneOf` choice contains constraints but no type/properties, it now implicitly assumes `type: 'object'` and inherits the corresponding property definitions (and titles) from the parent schema. Fixes #3508
1 parent 042e967 commit dd649ef

4 files changed

Lines changed: 122 additions & 2 deletions

File tree

packages/ui/src/components/Visualization/Canvas/Form/CanvasFormBody.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { FunctionComponent, useCallback, useContext, useMemo, useRef } from 'rea
33

44
import { IVisualizationNode } from '../../../../models';
55
import { EntitiesContext } from '../../../../providers/entities.provider';
6-
import { setValue } from '../../../../utils';
6+
import { normalizeOneOfChoices, setValue } from '../../../../utils';
77
import { UnknownNode } from '../../Custom/UnknownNode';
88
import { customFieldsFactoryfactory } from './fields/custom-fields-factory';
99
import { SuggestionRegistrar } from './suggestions/SuggestionsProvider';
@@ -15,7 +15,7 @@ interface CanvasFormTabsProps {
1515
export const CanvasFormBody: FunctionComponent<CanvasFormTabsProps> = ({ vizNode }) => {
1616
const entitiesContext = useContext(EntitiesContext);
1717
const omitFields = useRef(vizNode.getOmitFormFields() ?? []);
18-
const schema = useMemo(() => vizNode.getNodeSchema(), [vizNode]);
18+
const schema = useMemo(() => normalizeOneOfChoices(vizNode.getNodeSchema()), [vizNode]);
1919

2020
const isUnknownComponent = useMemo(() => {
2121
return !isDefined(schema) || Object.keys(schema).length === 0;

packages/ui/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ export * from './set-value';
2323
export * from './update-kamelet-from-custom-schema';
2424
export * from './xml-comments';
2525
export * from './yaml-comments';
26+
export * from './normalize-oneof-choices';
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { normalizeOneOfChoices } from './normalize-oneof-choices';
2+
3+
describe('normalizeOneOfChoices', () => {
4+
it('should not throw on primitives or null', () => {
5+
expect(normalizeOneOfChoices(null)).toBeNull();
6+
expect(normalizeOneOfChoices(undefined)).toBeUndefined();
7+
expect(normalizeOneOfChoices('string')).toBe('string');
8+
});
9+
10+
it('should normalize oneOf choices missing properties or type', () => {
11+
const schema = {
12+
type: 'object',
13+
properties: {
14+
data: {
15+
type: 'string',
16+
title: 'Data',
17+
},
18+
resource: {
19+
type: 'string',
20+
title: 'Resource',
21+
},
22+
},
23+
anyOf: [
24+
{
25+
oneOf: [
26+
{ required: ['data'] },
27+
{
28+
type: 'object',
29+
properties: {
30+
resource: {
31+
type: 'object',
32+
},
33+
},
34+
},
35+
],
36+
},
37+
],
38+
};
39+
40+
const normalized = normalizeOneOfChoices(schema);
41+
42+
// The first oneOf choice should have type 'object' and inherited properties
43+
const choice1 = normalized.anyOf[0].oneOf[0];
44+
expect(choice1.type).toBe('object');
45+
expect(choice1.properties).toBeDefined();
46+
expect(choice1.properties.data).toBeDefined();
47+
expect(choice1.properties.data.type).toBe('string');
48+
expect(choice1.properties.data.title).toBe('Data');
49+
expect(choice1.title).toBe('Data'); // Inherited title
50+
51+
// The second choice should remain untouched
52+
const choice2 = normalized.anyOf[0].oneOf[1];
53+
expect(choice2.type).toBe('object');
54+
expect(choice2.properties.resource.type).toBe('object');
55+
});
56+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import cloneDeep from 'lodash/cloneDeep';
2+
3+
export function normalizeOneOfChoices(schemaRaw: any, parentProperties?: any) {
4+
if (!schemaRaw || typeof schemaRaw !== 'object') {
5+
return schemaRaw;
6+
}
7+
8+
const schema = parentProperties === undefined ? cloneDeep(schemaRaw) : schemaRaw;
9+
10+
let currentProperties = parentProperties;
11+
if (schema.properties) {
12+
currentProperties = schema.properties;
13+
}
14+
15+
if (Array.isArray(schema.oneOf)) {
16+
schema.oneOf.forEach((choice: any) => {
17+
if (typeof choice === 'object' && choice !== null) {
18+
if (!choice.type && !choice.$ref) {
19+
// If it has 'required' or 'properties', or 'not', assume it's an object
20+
if (choice.required || choice.properties || choice.not) {
21+
if (!choice.not) {
22+
choice.type = 'object';
23+
}
24+
// If it has 'required' but no 'properties', copy from parent
25+
if (choice.required && !choice.properties && currentProperties) {
26+
choice.properties = {};
27+
for (const req of choice.required) {
28+
if (currentProperties[req]) {
29+
choice.properties[req] = currentProperties[req];
30+
if (!choice.title && currentProperties[req].title) {
31+
choice.title = currentProperties[req].title;
32+
}
33+
}
34+
}
35+
// If title is still missing and there's exactly one required property, use the required property name as title
36+
if (!choice.title && choice.required.length === 1) {
37+
choice.title = choice.required[0];
38+
}
39+
}
40+
}
41+
}
42+
}
43+
});
44+
}
45+
46+
// Recursively process children
47+
for (const key of Object.keys(schema)) {
48+
// We do not pass down `currentProperties` unconditionally because
49+
// properties of parent are only valid for that specific level.
50+
// However, properties of schema.properties.x are for the next level.
51+
if (key === 'properties') {
52+
Object.values(schema.properties).forEach((item: any) => normalizeOneOfChoices(item, undefined));
53+
} else if (key === 'oneOf' || key === 'anyOf' || key === 'allOf') {
54+
schema[key].forEach((item: any) => normalizeOneOfChoices(item, currentProperties));
55+
} else if (typeof schema[key] === 'object' && schema[key] !== null) {
56+
// Don't pass parent properties into completely different structures like 'items' etc.
57+
// unless it's inside anyOf/oneOf/allOf.
58+
normalizeOneOfChoices(schema[key], undefined);
59+
}
60+
}
61+
62+
return schema;
63+
}

0 commit comments

Comments
 (0)