Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/trait-form-portal-annotations.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 2 additions & 75 deletions plugins/openchoreo/src/components/Traits/AddTraitDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}`;

Expand Down
73 changes: 1 addition & 72 deletions plugins/openchoreo/src/components/Traits/EditTraitDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<EditTraitDialogProps> = ({
open,
onClose,
Expand Down
146 changes: 146 additions & 0 deletions plugins/openchoreo/src/components/Traits/uiSchema.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});
Loading
Loading