diff --git a/.changeset/trait-form-portal-annotations.md b/.changeset/trait-form-portal-annotations.md new file mode 100644 index 000000000..8e7fbaff7 --- /dev/null +++ b/.changeset/trait-form-portal-annotations.md @@ -0,0 +1,5 @@ +--- +'@openchoreo/backstage-plugin': minor +--- + +Render trait configuration forms using `x-openchoreo-backstage-portal` schema annotations. The Add/Edit trait dialogs now fold these vendor extensions (e.g. `ui:order`, `ui:widget`, `ui:title`, `ui:placeholder`) into the RJSF uiSchema, letting trait authors control form rendering. Traits without annotations are unaffected. diff --git a/plugins/openchoreo/src/components/Traits/AddTraitDialog.tsx b/plugins/openchoreo/src/components/Traits/AddTraitDialog.tsx index 5386c41f0..9c59b7cca 100644 --- a/plugins/openchoreo/src/components/Traits/AddTraitDialog.tsx +++ b/plugins/openchoreo/src/components/Traits/AddTraitDialog.tsx @@ -25,14 +25,12 @@ import { JSONSchema7 } from 'json-schema'; import validator from '@rjsf/validator-ajv8'; import { TraitConfigToggle } from '@openchoreo/backstage-plugin-react'; import { useTraitsStyles } from './styles'; +import { generateUiSchemaWithTitles } from './uiSchema'; import { ComponentTrait } from '../../api/OpenChoreoClientApi'; import { ResponseError } from '@backstage/errors'; import { isForbiddenError, getErrorMessage } from '../../utils/errorUtils'; import { extractEntityMetadata } from '../../utils/entityUtils'; -import { - sanitizeLabel, - CHOREO_ANNOTATIONS, -} from '@openchoreo/backstage-plugin-common'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; interface AddTraitDialogProps { open: boolean; @@ -78,77 +76,6 @@ function hasEmptyRequiredFields( return false; } -/** - * Recursively generates a UI Schema with sanitized titles for all fields - * that don't already have a title in the JSON Schema. - */ -function generateUiSchemaWithTitles( - schema: any, - hideErrors: boolean = false, -): any { - if (!schema || typeof schema !== 'object') { - return {}; - } - - const uiSchema: any = {}; - - // Handle object properties - if (schema.properties) { - Object.entries(schema.properties).forEach( - ([key, propSchema]: [string, any]) => { - if (!propSchema || typeof propSchema !== 'object') { - return; - } - - // If the property doesn't have a title, add one in the UI schema - const fieldUiSchema: any = {}; - - if (!propSchema.title) { - fieldUiSchema['ui:title'] = sanitizeLabel(key); - } - - // Hide errors if requested - if (hideErrors) { - fieldUiSchema['ui:options'] = { - ...fieldUiSchema['ui:options'], - hideError: true, - }; - } - - uiSchema[key] = fieldUiSchema; - - // Recursively handle nested objects - if (propSchema.type === 'object' && propSchema.properties) { - const nestedUiSchema = generateUiSchemaWithTitles( - propSchema, - hideErrors, - ); - uiSchema[key] = { - ...uiSchema[key], - ...nestedUiSchema, - }; - } - - // Handle array items - if (propSchema.type === 'array' && propSchema.items) { - const itemsUiSchema = generateUiSchemaWithTitles( - propSchema.items, - hideErrors, - ); - if (Object.keys(itemsUiSchema).length > 0) { - uiSchema[key] = { - ...uiSchema[key], - items: itemsUiSchema, - }; - } - } - }, - ); - } - - return uiSchema; -} - const buildTraitKey = (trait: TraitListItem): string => `${trait.kind}:${trait.name}`; diff --git a/plugins/openchoreo/src/components/Traits/EditTraitDialog.tsx b/plugins/openchoreo/src/components/Traits/EditTraitDialog.tsx index 70ecbb2f4..58f819e7c 100644 --- a/plugins/openchoreo/src/components/Traits/EditTraitDialog.tsx +++ b/plugins/openchoreo/src/components/Traits/EditTraitDialog.tsx @@ -21,11 +21,11 @@ import { JSONSchema7 } from 'json-schema'; import validator from '@rjsf/validator-ajv8'; import { TraitConfigToggle } from '@openchoreo/backstage-plugin-react'; import { useTraitsStyles } from './styles'; +import { generateUiSchemaWithTitles } from './uiSchema'; import { ComponentTrait } from '../../api/OpenChoreoClientApi'; import { ResponseError } from '@backstage/errors'; import { extractEntityMetadata } from '../../utils/entityUtils'; import { isForbiddenError, getErrorMessage } from '../../utils/errorUtils'; -import { sanitizeLabel } from '@openchoreo/backstage-plugin-common'; interface EditTraitDialogProps { open: boolean; @@ -63,77 +63,6 @@ function hasEmptyRequiredFields( return false; } -/** - * Recursively generates a UI Schema with sanitized titles for all fields - * that don't already have a title in the JSON Schema. - */ -function generateUiSchemaWithTitles( - schema: any, - hideErrors: boolean = false, -): any { - if (!schema || typeof schema !== 'object') { - return {}; - } - - const uiSchema: any = {}; - - // Handle object properties - if (schema.properties) { - Object.entries(schema.properties).forEach( - ([key, propSchema]: [string, any]) => { - if (!propSchema || typeof propSchema !== 'object') { - return; - } - - // If the property doesn't have a title, add one in the UI schema - const fieldUiSchema: any = {}; - - if (!propSchema.title) { - fieldUiSchema['ui:title'] = sanitizeLabel(key); - } - - // Hide errors if requested - if (hideErrors) { - fieldUiSchema['ui:options'] = { - ...fieldUiSchema['ui:options'], - hideError: true, - }; - } - - uiSchema[key] = fieldUiSchema; - - // Recursively handle nested objects - if (propSchema.type === 'object' && propSchema.properties) { - const nestedUiSchema = generateUiSchemaWithTitles( - propSchema, - hideErrors, - ); - uiSchema[key] = { - ...uiSchema[key], - ...nestedUiSchema, - }; - } - - // Handle array items - if (propSchema.type === 'array' && propSchema.items) { - const itemsUiSchema = generateUiSchemaWithTitles( - propSchema.items, - hideErrors, - ); - if (Object.keys(itemsUiSchema).length > 0) { - uiSchema[key] = { - ...uiSchema[key], - items: itemsUiSchema, - }; - } - } - }, - ); - } - - return uiSchema; -} - export const EditTraitDialog: React.FC = ({ open, onClose, diff --git a/plugins/openchoreo/src/components/Traits/uiSchema.test.ts b/plugins/openchoreo/src/components/Traits/uiSchema.test.ts new file mode 100644 index 000000000..674b99632 --- /dev/null +++ b/plugins/openchoreo/src/components/Traits/uiSchema.test.ts @@ -0,0 +1,146 @@ +import { sanitizeLabel } from '@openchoreo/backstage-plugin-common'; +import { generateUiSchemaWithTitles } from './uiSchema'; + +describe('generateUiSchemaWithTitles', () => { + it('returns an empty object for non-object schemas', () => { + expect(generateUiSchemaWithTitles(null)).toEqual({}); + expect(generateUiSchemaWithTitles(undefined)).toEqual({}); + expect(generateUiSchemaWithTitles('nope' as any)).toEqual({}); + }); + + it('adds a sanitized ui:title only for properties without their own title', () => { + const schema = { + type: 'object', + properties: { + minReplicas: { type: 'integer' }, + maxReplicas: { type: 'integer', title: 'Max Replicas' }, + }, + }; + + const ui = generateUiSchemaWithTitles(schema); + + expect(ui.minReplicas['ui:title']).toBe(sanitizeLabel('minReplicas')); + expect(ui.maxReplicas['ui:title']).toBeUndefined(); + }); + + it('folds a root-level portal fragment into the root uiSchema', () => { + const schema = { + type: 'object', + 'x-openchoreo-backstage-portal': { + 'ui:order': ['maxReplicas', 'minReplicas'], + }, + properties: { + minReplicas: { type: 'integer' }, + maxReplicas: { type: 'integer' }, + }, + }; + + const ui = generateUiSchemaWithTitles(schema); + + expect(ui['ui:order']).toEqual(['maxReplicas', 'minReplicas']); + expect(ui.minReplicas['ui:title']).toBe(sanitizeLabel('minReplicas')); + }); + + it('folds a leaf property portal fragment into that field', () => { + const schema = { + type: 'object', + properties: { + targetCPUUtilizationPercentage: { + type: 'integer', + 'x-openchoreo-backstage-portal': { 'ui:widget': 'range' }, + }, + }, + }; + + const ui = generateUiSchemaWithTitles(schema); + + expect(ui.targetCPUUtilizationPercentage['ui:widget']).toBe('range'); + // generated title survives alongside the folded fragment + expect(ui.targetCPUUtilizationPercentage['ui:title']).toBe( + sanitizeLabel('targetCPUUtilizationPercentage'), + ); + }); + + it('lets a portal ui:title override the generated title', () => { + const schema = { + type: 'object', + properties: { + minReplicas: { + type: 'integer', + 'x-openchoreo-backstage-portal': { 'ui:title': 'Minimum Replicas' }, + }, + }, + }; + + const ui = generateUiSchemaWithTitles(schema); + + expect(ui.minReplicas['ui:title']).toBe('Minimum Replicas'); + }); + + it('merges ui:options so generated options are not dropped', () => { + const schema = { + type: 'object', + properties: { + enabled: { + type: 'boolean', + 'x-openchoreo-backstage-portal': { + 'ui:options': { widget: 'radio' }, + }, + }, + }, + }; + + const ui = generateUiSchemaWithTitles(schema, true); + + expect(ui.enabled['ui:options']).toEqual({ + hideError: true, + widget: 'radio', + }); + }); + + it('folds a nested object portal fragment into the nested field', () => { + const schema = { + type: 'object', + properties: { + source: { + type: 'object', + 'x-openchoreo-backstage-portal': { + 'ui:order': ['type', 'query'], + }, + properties: { + type: { type: 'string' }, + query: { type: 'string' }, + }, + }, + }, + }; + + const ui = generateUiSchemaWithTitles(schema); + + expect(ui.source['ui:order']).toEqual(['type', 'query']); + expect(ui.source.type['ui:title']).toBe(sanitizeLabel('type')); + }); + + it('folds an array node portal fragment and keeps item titles under items', () => { + const schema = { + type: 'object', + properties: { + channels: { + type: 'array', + 'x-openchoreo-backstage-portal': { 'ui:field': 'ChannelPicker' }, + items: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + }; + + const ui = generateUiSchemaWithTitles(schema); + + expect(ui.channels['ui:field']).toBe('ChannelPicker'); + expect(ui.channels.items.name['ui:title']).toBe(sanitizeLabel('name')); + }); +}); diff --git a/plugins/openchoreo/src/components/Traits/uiSchema.ts b/plugins/openchoreo/src/components/Traits/uiSchema.ts new file mode 100644 index 000000000..64eede562 --- /dev/null +++ b/plugins/openchoreo/src/components/Traits/uiSchema.ts @@ -0,0 +1,96 @@ +import { sanitizeLabel } from '@openchoreo/backstage-plugin-common'; + +// Vendor extension carrying an RJSF uiSchema fragment for a schema node. +// Ref - https://github.com/openchoreo/openchoreo/blob/main/docs/templating/openapiv3-schema.md#vendor-extensions-x- +const PORTAL_UI_EXTENSION = 'x-openchoreo-backstage-portal'; + +// Portal values win; ui:options is merged so generated options (hideError) survive. +function mergePortalUiSchema(base: any, portal: any): any { + const merged = { ...base, ...portal }; + if (base['ui:options'] || portal['ui:options']) { + merged['ui:options'] = { + ...base['ui:options'], + ...portal['ui:options'], + }; + } + return merged; +} + +// Builds an RJSF uiSchema: sanitized titles plus any x-openchoreo-backstage-portal +// fragments folded in (which take precedence over the generated defaults). +export function generateUiSchemaWithTitles( + schema: any, + hideErrors: boolean = false, +): any { + if (!schema || typeof schema !== 'object') { + return {}; + } + + const uiSchema: any = {}; + + // Fragment on this object node (e.g. ui:order). + if ( + schema[PORTAL_UI_EXTENSION] && + typeof schema[PORTAL_UI_EXTENSION] === 'object' + ) { + Object.assign(uiSchema, schema[PORTAL_UI_EXTENSION]); + } + + if (schema.properties) { + Object.entries(schema.properties).forEach( + ([key, propSchema]: [string, any]) => { + if (!propSchema || typeof propSchema !== 'object') { + return; + } + + const fieldUiSchema: any = {}; + + if (!propSchema.title) { + fieldUiSchema['ui:title'] = sanitizeLabel(key); + } + + if (hideErrors) { + fieldUiSchema['ui:options'] = { + ...fieldUiSchema['ui:options'], + hideError: true, + }; + } + + uiSchema[key] = fieldUiSchema; + + // Objects fold their own fragment via recursion; leaves/arrays fold theirs here. + if (propSchema.type === 'object' && propSchema.properties) { + const nestedUiSchema = generateUiSchemaWithTitles( + propSchema, + hideErrors, + ); + uiSchema[key] = { + ...uiSchema[key], + ...nestedUiSchema, + }; + } else { + const portal = propSchema[PORTAL_UI_EXTENSION]; + if (portal && typeof portal === 'object') { + uiSchema[key] = mergePortalUiSchema(uiSchema[key], portal); + } + } + + // Array items keep their generated uiSchema under `items`. + if (propSchema.type === 'array' && propSchema.items) { + const itemsUiSchema = generateUiSchemaWithTitles( + propSchema.items, + hideErrors, + ); + if (Object.keys(itemsUiSchema).length > 0) { + uiSchema[key] = { + ...uiSchema[key], + items: itemsUiSchema, + }; + } + } + }, + ); + } + + return uiSchema; +}