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
6 changes: 6 additions & 0 deletions workspaces/orchestrator/.changeset/honor-schema-defaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': patch
---

Include JSON Schema `default` values when computing initial form data so
template expressions can resolve defaults before widgets render.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': patch
---

Scope async validate:url calls to the active step in multi-step forms.
5 changes: 5 additions & 0 deletions workspaces/orchestrator/.changeset/tall-games-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-orchestrator': patch
---

prepopulate Execute Workflow form from URL query params
5 changes: 5 additions & 0 deletions workspaces/orchestrator/.changeset/unlucky-poems-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-react': patch
---

Fix `extractUiSchema` so `ui:*` directives on object schemas that define `properties` (for example `ui:hidden` on a `workflowParams` object) are copied into the generated UI schema.
46 changes: 46 additions & 0 deletions workspaces/orchestrator/docs/user-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,52 @@ Workflows can also be invoked from Backstage software templates using the `orche
- Monitor workflow execution status
- View workflow results and outputs

### Execute Workflow Form Prepopulation

The Execute Workflow page supports prepopulating form fields from URL query parameters. When the workflow schema defines input fields, any query parameter whose name matches a schema property path will be used to prepopulate the corresponding form field.

**Path format**

- For flat schemas, use the property name directly: `?language=English&name=John`
- For nested (multi-step) schemas, use dot notation: `?firstStep.fooTheFirst=test` or `?provideInputs.language=English`
- For fields inside `oneOf` or `anyOf` branches, use the same dot notation: `?mode.alphaValue=test`

**Schema support**

The prepopulation logic supports the full JSON Schema draft-07 spec, including:

- Fields defined via `$ref` in `$defs` or `definitions`
- `oneOf` and `anyOf` — the correct branch is resolved from the provided data
- Array fields — use comma-separated values: `?tags=foo,bar,baz`
- Type coercion for numbers, integers, and booleans

**Schema constraints**

For fields with `enum` constraints in the schema, the query param value must match one of the allowed values. Case-insensitive matching is supported (e.g. `?language=english` maps to `English` when the enum is `['English', 'Spanish']`). Values that do not match any enum option are ignored and will not prepopulate the field.

Query parameters that do not match any schema property path are ignored and will not be merged into the form.

**Reserved parameters**

The following query parameters are reserved for navigation and are not used for form prepopulation:

- `targetEntity` — Used to associate the workflow run with a catalog entity
- `instanceId` — Used when re-running or viewing a specific workflow instance

**Examples**

```
/orchestrator/workflows/yamlgreet/execute?targetEntity=default:component:my-app&language=English&name=alice
```

In this example, `targetEntity` is excluded (reserved), while `language` and `name` prepopulate the form when those fields exist in the workflow schema.

```
/orchestrator/workflows/my-workflow/execute?language=English&mode.alphaValue=prefilled&tags=a,b,c
```

This example prepopulates a flat field (`language`), a nested field inside an `oneOf` branch (`mode.alphaValue`), and an array field (`tags`).

### Entity Integration

- Workflow tabs on entity pages
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,22 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => {
let _extraErrors: ErrorSchema<JsonObject> | undefined = undefined;
let _validationError: Error | undefined = undefined;
const activeKey = getActiveKey();
const shouldScopeExtraErrors =
Boolean(activeKey) && Boolean(uiSchema?.[activeKey as string]);
const extraErrorsFormData = (_formData ?? formData) as JsonObject;
const extraErrorsUiSchema = shouldScopeExtraErrors
? ({
[activeKey as string]: uiSchema?.[activeKey as string],
} as OrchestratorFormContextProps['uiSchema'])
: uiSchema;

if (decoratorProps.getExtraErrors) {
try {
handleValidateStarted();
_extraErrors = await decoratorProps.getExtraErrors(formData, uiSchema);
_extraErrors = await decoratorProps.getExtraErrors(
extraErrorsFormData,
extraErrorsUiSchema,
);

if (activeKey) {
setExtraErrors(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* 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 type { JSONSchema7 } from 'json-schema';

import extractStaticDefaults from './extractStaticDefaults';

describe('extractStaticDefaults', () => {
it('applies schema defaults when no fetch default', () => {
const schema: JSONSchema7 = {
type: 'object',
properties: {
name: { type: 'string', default: 'app' },
},
};

expect(extractStaticDefaults(schema)).toEqual({ name: 'app' });
});

it('prefers fetch:response:default over schema default', () => {
const schema: JSONSchema7 = {
type: 'object',
properties: {
name: {
type: 'string',
default: 'schema',
'ui:props': { 'fetch:response:default': 'fetch' },
} as JSONSchema7 & Record<string, unknown>,
},
};

expect(extractStaticDefaults(schema)).toEqual({ name: 'fetch' });
});

it('does not overwrite existing form data values', () => {
const schema: JSONSchema7 = {
type: 'object',
properties: {
name: { type: 'string', default: 'schema' },
},
};

expect(extractStaticDefaults(schema, { name: 'existing' })).toEqual({
name: 'existing',
});
});

it('preserves falsy defaults', () => {
const schema: JSONSchema7 = {
type: 'object',
properties: {
enabled: { type: 'boolean', default: false },
retries: { type: 'number', default: 0 },
note: { type: 'string', default: '' },
},
};

expect(extractStaticDefaults(schema)).toEqual({
enabled: false,
retries: 0,
note: '',
});
});

it('applies defaults from composed schemas', () => {
const schema: JSONSchema7 = {
type: 'object',
allOf: [
{
type: 'object',
properties: {
name: { type: 'string', default: 'composed' },
},
},
],
};

expect(extractStaticDefaults(schema)).toEqual({ name: 'composed' });
});

it('applies root default objects without creating empty key', () => {
const schema: JSONSchema7 = {
type: 'object',
default: { foo: 'bar' },
properties: {
foo: { type: 'string' },
},
};

expect(extractStaticDefaults(schema)).toEqual({ foo: 'bar' });
});

it('does not override existing data with root defaults', () => {
const schema: JSONSchema7 = {
type: 'object',
default: { foo: 'bar' },
properties: {
foo: { type: 'string' },
},
};

expect(extractStaticDefaults(schema, { foo: 'existing' })).toEqual({
foo: 'existing',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,19 @@ import type { JSONSchema7, JSONSchema7Definition } from 'json-schema';
import get from 'lodash/get';
import set from 'lodash/set';

const isPlainObject = (value: unknown): value is JsonObject => {
return value !== null && typeof value === 'object' && !Array.isArray(value);
};

/**
* Extracts static default values from fetch:response:default properties in the schema.
* Extracts static default values from the schema in priority order:
* 1) ui:props.fetch:response:default
* 2) JSON Schema default
*
* These values are applied to formData before widgets render, ensuring defaults
* are available immediately without waiting for fetch operations.
*
* @param schema - The JSON Schema containing ui:props with fetch:response:default
* @param schema - The JSON Schema containing ui:props and/or default values
* @param existingFormData - Existing form data to preserve (won't be overwritten)
* @returns An object containing the extracted default values merged with existing data
*/
Expand Down Expand Up @@ -60,14 +67,26 @@ export function extractStaticDefaults(

// Extract fetch:response:default from ui:props
const uiProps = (curSchema as Record<string, unknown>)['ui:props'];
let staticDefault: unknown;
if (uiProps && typeof uiProps === 'object') {
const staticDefault = (uiProps as Record<string, unknown>)[
staticDefault = (uiProps as Record<string, unknown>)[
'fetch:response:default'
];
if (staticDefault !== undefined) {
// Only set if not already in existing form data
const existingValue = get(existingFormData, path);
if (existingValue === undefined || existingValue === null) {
}

if (staticDefault === undefined && 'default' in curSchema) {
staticDefault = (curSchema as JSONSchema7).default;
}

if (staticDefault !== undefined) {
// Only set if not already in existing form data
const existingValue = get(existingFormData, path);
if (existingValue === undefined || existingValue === null) {
if (path === '') {
if (isPlainObject(staticDefault)) {
Object.assign(defaults, staticDefault);
}
} else {
set(defaults, path, staticDefault);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,29 @@ describe('extract ui schema', () => {
expect(uiSchema).toEqual({ name: { 'ui:autofocus': true } });
});

it('extracts ui:hidden and other ui:* on object nodes that also have properties', () => {
const mixedSchema: JSONSchema7 = {
type: 'object',
properties: {
visibleField: {
type: 'string',
title: 'Visible',
},
workflowParams: {
type: 'object',
'ui:hidden': true,
properties: {
solutionName: { type: 'string', default: 'a' },
solutionVersion: { type: 'string', default: '1.0' },
},
} as JSONSchema7,
},
};
const uiSchema = generateUiSchema(mixedSchema, false);
expect(uiSchema.workflowParams).toEqual({ 'ui:hidden': true });
expect(uiSchema.visibleField).toMatchObject({ 'ui:autofocus': true });
});

it('should extract from array', () => {
const mixedSchema = {
title: 'A list of tasks',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ function extractUiSchema(mixedSchema: JSONSchema7): UiSchema<JsonObject> {
processObject(getSchemaDefinition(curSchema.$ref, rootSchema), path);
} else if (curSchema.properties) {
processObjectProperties(curSchema.properties, path);
processLeafSchema(curSchema, path);
} else if (curSchema.items) {
processArraySchema(curSchema, path);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"@red-hat-developer-hub/backstage-plugin-orchestrator-form-react": "workspace:^",
"axios": "^1.11.0",
"json-schema": "^0.4.0",
"json-schema-library": "^9.0.0",
"lodash": "^4.17.21",
"luxon": "^3.7.2",
"react-use": "^17.4.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
import { getErrorObject } from '../../utils/ErrorUtils';
import { BaseOrchestratorPage } from '../ui/BaseOrchestratorPage';
import MissingSchemaNotice from './MissingSchemaNotice';
import { mergeQueryParamsIntoFormData } from './queryParamsToFormData';
import { getSchemaUpdater } from './schemaUpdater';

export const ExecuteWorkflowPage = () => {
Expand All @@ -67,6 +68,7 @@ export const ExecuteWorkflowPage = () => {
const [isExecuting, setIsExecuting] = useState(false);
const [updateError, setUpdateError] = useState<Error>();
const [instanceId] = useQueryParamState<string>(QUERY_PARAM_INSTANCE_ID);

const navigate = useNavigate();
const instanceLink = useRouteRef(workflowInstanceRouteRef);
const entityInstanceLink = useRouteRef(entityInstanceRouteRef);
Expand Down Expand Up @@ -96,7 +98,13 @@ export const ExecuteWorkflowPage = () => {
[schema],
);

const initialFormData = value?.data ?? {};
const initialFormData = useMemo(() => {
const baseData = value?.data ?? {};
if (!schema) {
return baseData;
}
return mergeQueryParamsIntoFormData(schema, searchParams, baseData);
}, [schema, value?.data, searchParams]);
const {
value: workflowName,
loading: workflowNameLoading,
Expand Down
Loading
Loading