diff --git a/workspaces/orchestrator/.changeset/honor-schema-defaults.md b/workspaces/orchestrator/.changeset/honor-schema-defaults.md new file mode 100644 index 0000000000..fb8878f925 --- /dev/null +++ b/workspaces/orchestrator/.changeset/honor-schema-defaults.md @@ -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. diff --git a/workspaces/orchestrator/.changeset/scope-async-validation-to-step.md b/workspaces/orchestrator/.changeset/scope-async-validation-to-step.md new file mode 100644 index 0000000000..e83b4863b7 --- /dev/null +++ b/workspaces/orchestrator/.changeset/scope-async-validation-to-step.md @@ -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. diff --git a/workspaces/orchestrator/.changeset/tall-games-fail.md b/workspaces/orchestrator/.changeset/tall-games-fail.md new file mode 100644 index 0000000000..93f47baedb --- /dev/null +++ b/workspaces/orchestrator/.changeset/tall-games-fail.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator': patch +--- + +prepopulate Execute Workflow form from URL query params diff --git a/workspaces/orchestrator/.changeset/unlucky-poems-drum.md b/workspaces/orchestrator/.changeset/unlucky-poems-drum.md new file mode 100644 index 0000000000..22b2578493 --- /dev/null +++ b/workspaces/orchestrator/.changeset/unlucky-poems-drum.md @@ -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. diff --git a/workspaces/orchestrator/docs/user-interface.md b/workspaces/orchestrator/docs/user-interface.md index a67a54148e..e82e3deed8 100644 --- a/workspaces/orchestrator/docs/user-interface.md +++ b/workspaces/orchestrator/docs/user-interface.md @@ -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 diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx index 78a170682b..313d8b58ec 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/components/OrchestratorFormWrapper.tsx @@ -91,11 +91,22 @@ const FormComponent = (decoratorProps: FormDecoratorProps) => { let _extraErrors: ErrorSchema | 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( diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/extractStaticDefaults.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/extractStaticDefaults.test.ts new file mode 100644 index 0000000000..8f60d81958 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/extractStaticDefaults.test.ts @@ -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, + }, + }; + + 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', + }); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/extractStaticDefaults.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/extractStaticDefaults.ts index c05921beb3..ed4210dd8a 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/extractStaticDefaults.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/extractStaticDefaults.ts @@ -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 */ @@ -60,14 +67,26 @@ export function extractStaticDefaults( // Extract fetch:response:default from ui:props const uiProps = (curSchema as Record)['ui:props']; + let staticDefault: unknown; if (uiProps && typeof uiProps === 'object') { - const staticDefault = (uiProps as Record)[ + staticDefault = (uiProps as Record)[ '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); } } diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateUiSchema.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateUiSchema.test.ts index 94450459b5..eb4799f899 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateUiSchema.test.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateUiSchema.test.ts @@ -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', diff --git a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateUiSchema.ts b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateUiSchema.ts index dc4d5513ab..29599a8ed6 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateUiSchema.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-react/src/utils/generateUiSchema.ts @@ -106,6 +106,7 @@ function extractUiSchema(mixedSchema: JSONSchema7): UiSchema { 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 { diff --git a/workspaces/orchestrator/plugins/orchestrator/package.json b/workspaces/orchestrator/plugins/orchestrator/package.json index 0964ae64f8..e6367d0a86 100644 --- a/workspaces/orchestrator/plugins/orchestrator/package.json +++ b/workspaces/orchestrator/plugins/orchestrator/package.json @@ -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", diff --git a/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/ExecuteWorkflowPage.tsx b/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/ExecuteWorkflowPage.tsx index 483d8cf4c9..5fcca74bb0 100644 --- a/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/ExecuteWorkflowPage.tsx +++ b/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/ExecuteWorkflowPage.tsx @@ -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 = () => { @@ -67,6 +68,7 @@ export const ExecuteWorkflowPage = () => { const [isExecuting, setIsExecuting] = useState(false); const [updateError, setUpdateError] = useState(); const [instanceId] = useQueryParamState(QUERY_PARAM_INSTANCE_ID); + const navigate = useNavigate(); const instanceLink = useRouteRef(workflowInstanceRouteRef); const entityInstanceLink = useRouteRef(entityInstanceRouteRef); @@ -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, diff --git a/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/queryParamsToFormData.test.ts b/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/queryParamsToFormData.test.ts new file mode 100644 index 0000000000..670dd2f3d6 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/queryParamsToFormData.test.ts @@ -0,0 +1,650 @@ +/* + * 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 { mergeQueryParamsIntoFormData } from './queryParamsToFormData'; + +describe('mergeQueryParamsIntoFormData', () => { + it('returns base data when schema has no properties', () => { + const schema = { type: 'object' } as JSONSchema7; + const searchParams = new URLSearchParams('language=English'); + const baseData = { existing: 'value' }; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ existing: 'value' }); + }); + + it('merges query params matching flat schema properties', () => { + const schema = { + type: 'object', + properties: { + language: { type: 'string' }, + name: { type: 'string' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('language=English&name=John'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ language: 'English', name: 'John' }); + }); + + it('overrides base data with query param values', () => { + const schema = { + type: 'object', + properties: { + language: { type: 'string' }, + name: { type: 'string' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('language=Spanish'); + const baseData = { language: 'English', name: 'bob' }; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ language: 'Spanish', name: 'bob' }); + }); + + it('excludes reserved query params (targetEntity, instanceId)', () => { + const schema = { + type: 'object', + properties: { + language: { type: 'string' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams( + 'targetEntity=default:component:my-app&instanceId=123&language=English', + ); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ language: 'English' }); + expect(result).not.toHaveProperty('targetEntity'); + expect(result).not.toHaveProperty('instanceId'); + }); + + it('merges nested paths from query params', () => { + const schema = { + type: 'object', + properties: { + step1: { + type: 'object', + properties: { + language: { type: 'string' }, + name: { type: 'string' }, + }, + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams( + 'step1.language=Spanish&step1.name=carol', + ); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ + step1: { language: 'Spanish', name: 'carol' }, + }); + }); + + it('ignores query params that do not match schema paths', () => { + const schema = { + type: 'object', + properties: { + language: { type: 'string' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams( + 'language=English&unknownParam=ignored&other=alsoIgnored', + ); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ language: 'English' }); + }); + + it('merges dot-notation param key (firstStep.fooTheFirst=test)', () => { + const schema = { + type: 'object', + properties: { + firstStep: { + type: 'object', + properties: { + fooTheFirst: { type: 'string' }, + }, + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('firstStep.fooTheFirst=test'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ + firstStep: { fooTheFirst: 'test' }, + }); + }); + + it('returns base data unchanged when searchParams is empty', () => { + const schema = { + type: 'object', + properties: { + language: { type: 'string' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams(''); + const baseData = { language: 'English', name: 'bob' }; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ language: 'English', name: 'bob' }); + }); + + it('preserves base data when no query params match schema paths', () => { + const schema = { + type: 'object', + properties: { + language: { type: 'string' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('foo=bar&baz=qux'); + const baseData = { language: 'English' }; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ language: 'English' }); + }); + + it('merges query params into existing nested base data', () => { + const schema = { + type: 'object', + properties: { + step1: { + type: 'object', + properties: { + language: { type: 'string' }, + name: { type: 'string' }, + }, + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('step1.language=Spanish'); + const baseData = { step1: { language: 'English', name: 'alice' } }; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ + step1: { language: 'Spanish', name: 'alice' }, + }); + }); + + it('handles multiple nested levels (step1.step2.field)', () => { + const schema = { + type: 'object', + properties: { + step1: { + type: 'object', + properties: { + step2: { + type: 'object', + properties: { + deepField: { type: 'string' }, + }, + }, + }, + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('step1.step2.deepField=deepValue'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ + step1: { step2: { deepField: 'deepValue' } }, + }); + }); + + it('handles multiple steps with different nested params', () => { + const schema = { + type: 'object', + properties: { + chooseEntity: { + type: 'object', + properties: { + target_entity: { type: 'string' }, + }, + }, + provideInputs: { + type: 'object', + properties: { + language: { type: 'string' }, + name: { type: 'string' }, + }, + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams( + 'chooseEntity.target_entity=default:component:my-app&provideInputs.language=English&provideInputs.name=alice', + ); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ + chooseEntity: { target_entity: 'default:component:my-app' }, + provideInputs: { language: 'English', name: 'alice' }, + }); + }); + + it('handles URL-encoded values in query params', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('name=John%20Doe'); + + const result = mergeQueryParamsIntoFormData(schema, searchParams, {}); + + expect(result).toEqual({ name: 'John Doe' }); + }); + + it('works when baseFormData is omitted (uses default empty object)', () => { + const schema = { + type: 'object', + properties: { + language: { type: 'string' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('language=English'); + + const result = mergeQueryParamsIntoFormData(schema, searchParams); + + expect(result).toEqual({ language: 'English' }); + }); + + it('coerces enum values (case-insensitive match)', () => { + const schema = { + type: 'object', + properties: { + language: { + type: 'string', + enum: ['English', 'Spanish'], + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('language=english'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ language: 'English' }); + }); + + it('skips query param when value does not match enum', () => { + const schema = { + type: 'object', + properties: { + language: { + type: 'string', + enum: ['English', 'Spanish'], + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('language=French'); + const baseData = { language: 'English' }; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ language: 'English' }); + }); + + it('uses exact enum value when param matches exactly', () => { + const schema = { + type: 'object', + properties: { + language: { + type: 'string', + enum: ['English', 'Spanish'], + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('language=Spanish'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ language: 'Spanish' }); + }); + + it('coerces numeric enum values', () => { + const schema = { + type: 'object', + properties: { + priority: { + type: 'integer', + enum: [1, 2, 3], + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('priority=2'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ priority: 2 }); + }); + + it('skips numeric enum when value is not in enum', () => { + const schema = { + type: 'object', + properties: { + priority: { + type: 'integer', + enum: [1, 2, 3], + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('priority=5'); + const baseData = { priority: 1 }; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ priority: 1 }); + }); + + it('coerces boolean enum values', () => { + const schema = { + type: 'object', + properties: { + enabled: { + type: 'boolean', + enum: [true, false], + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('enabled=true'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ enabled: true }); + }); + + it('coerces boolean enum values (case-insensitive)', () => { + const schema = { + type: 'object', + properties: { + enabled: { + type: 'boolean', + enum: [true, false], + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('enabled=FALSE'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ enabled: false }); + }); + + it('coerces plain number field without enum', () => { + const schema = { + type: 'object', + properties: { + count: { type: 'number' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('count=42.5'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ count: 42.5 }); + }); + + it('coerces plain integer field without enum', () => { + const schema = { + type: 'object', + properties: { + count: { type: 'integer' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('count=42'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ count: 42 }); + }); + + it('skips integer field when value is not an integer', () => { + const schema = { + type: 'object', + properties: { + count: { type: 'integer' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('count=42.5'); + const baseData = { count: 10 }; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ count: 10 }); + }); + + it('coerces plain boolean field without enum', () => { + const schema = { + type: 'object', + properties: { + active: { type: 'boolean' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('active=true'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ active: true }); + }); + + it('prepopulates fields defined via $ref in $defs', () => { + const schema = { + type: 'object', + $defs: { + LanguageField: { + type: 'string', + enum: ['English', 'Spanish'], + }, + }, + properties: { + language: { $ref: '#/$defs/LanguageField' }, + name: { type: 'string' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('language=english&name=alice'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ language: 'English', name: 'alice' }); + }); + + it('prepopulates nested fields when step uses $ref', () => { + const schema = { + type: 'object', + $defs: { + InputsStep: { + type: 'object', + properties: { + language: { type: 'string', enum: ['English', 'Spanish'] }, + name: { type: 'string' }, + }, + }, + }, + properties: { + step1: { $ref: '#/$defs/InputsStep' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams( + 'step1.language=Spanish&step1.name=bob', + ); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ + step1: { language: 'Spanish', name: 'bob' }, + }); + }); + + it('prepopulates nested $ref with enum coercion', () => { + const schema = { + type: 'object', + $defs: { + LanguageEnum: { + type: 'string', + enum: ['English', 'Spanish', 'French'], + }, + }, + properties: { + step1: { + type: 'object', + properties: { + language: { $ref: '#/$defs/LanguageEnum' }, + }, + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('step1.language=french'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ step1: { language: 'French' } }); + }); + + it('prepopulates with #/definitions/ (legacy JSON Schema)', () => { + const schema = { + type: 'object', + definitions: { + NameField: { type: 'string' }, + }, + properties: { + name: { $ref: '#/definitions/NameField' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('name=charlie'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ name: 'charlie' }); + }); + + it('coerces array type from comma-separated query param', () => { + const schema = { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('tags=foo,bar,baz'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ tags: ['foo', 'bar', 'baz'] }); + }); + + it('coerces array type with single value', () => { + const schema = { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('tags=only-one'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ tags: ['only-one'] }); + }); + + it('prepopulates oneOf branch field (mode.alphaValue)', () => { + const schema = { + type: 'object', + properties: { + mode: { + oneOf: [ + { + type: 'object', + properties: { + alphaValue: { type: 'string' }, + }, + required: ['alphaValue'], + }, + { + type: 'object', + properties: { + betaValue: { type: 'string' }, + }, + required: ['betaValue'], + }, + ], + }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('mode.alphaValue=test'); + const baseData = {}; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ mode: { alphaValue: 'test' } }); + }); + + it('does not mutate base data', () => { + const schema = { + type: 'object', + properties: { + language: { type: 'string' }, + }, + } as JSONSchema7; + const searchParams = new URLSearchParams('language=Spanish'); + const baseData = { language: 'English' }; + + const result = mergeQueryParamsIntoFormData(schema, searchParams, baseData); + + expect(result).toEqual({ language: 'Spanish' }); + expect(baseData).toEqual({ language: 'English' }); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/queryParamsToFormData.ts b/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/queryParamsToFormData.ts new file mode 100644 index 0000000000..a8265a5dee --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator/src/components/ExecuteWorkflowPage/queryParamsToFormData.ts @@ -0,0 +1,303 @@ +/* + * 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 { JsonObject, JsonValue } from '@backstage/types'; + +import type { JSONSchema7 } from 'json-schema'; +import { Draft07 as JsonSchema } from 'json-schema-library'; +import cloneDeep from 'lodash/cloneDeep'; +import set from 'lodash/set'; + +/** Query param keys that are reserved for navigation/other purposes, not form fields */ +const RESERVED_QUERY_PARAMS = new Set(['targetEntity', 'instanceId']); + +/** + * Converts dot-notation path to JSON Pointer (e.g. "firstStep.language" -> "#/firstStep/language") + */ +function toJsonPointer(path: string): string { + if (!path) return '#'; + return `#/${path.replace(/\./g, '/')}`; +} + +/** + * Resolves $ref to the target schema (supports #/$defs and #/definitions). + */ +function resolveRef(root: JSONSchema7, ref: string): JSONSchema7 | undefined { + if (!ref.startsWith('#/')) return undefined; + const path = ref.slice(2).replace(/\//g, '.'); + const parts = path.split('.'); + let current: unknown = root; + for (const p of parts) { + if ( + current === null || + current === undefined || + typeof current !== 'object' + ) + return undefined; + current = (current as Record)[p]; + } + return current as JSONSchema7; +} + +/** + * Returns true if the path exists in the schema's properties (recursively). + * Handles $ref, oneOf, anyOf, allOf. Used to reject unknown query params. + */ +function pathExistsInSchema( + schema: JSONSchema7, + path: string, + root: JSONSchema7, +): boolean { + if (!path) return true; + let s: JSONSchema7 | undefined = schema; + const parts = path.split('.'); + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (!s || typeof s === 'boolean') return false; + if (s.$ref) { + s = resolveRef(root, s.$ref); + i--; + continue; + } + if (s.oneOf || s.anyOf) { + const remaining = parts.slice(i).join('.'); + return pathExistsInComposite(s, remaining, root); + } + const props = s.properties; + if (!props || typeof props !== 'object') return false; + if (!(part in props)) return false; + s = props[part] as JSONSchema7; + } + return true; +} + +/** + * Checks if path exists in any branch of oneOf/anyOf/allOf. + */ +function pathExistsInComposite( + schema: JSONSchema7, + path: string, + root: JSONSchema7, +): boolean { + const branches: JSONSchema7[] = []; + if (schema.oneOf) branches.push(...(schema.oneOf as JSONSchema7[])); + if (schema.anyOf) branches.push(...(schema.anyOf as JSONSchema7[])); + if (schema.allOf) { + for (const b of schema.allOf as JSONSchema7[]) { + const branch = + b && typeof b === 'object' && '$ref' in b + ? resolveRef(root, (b as JSONSchema7).$ref!) + : (b as JSONSchema7); + if (branch) branches.push(branch); + } + } + return branches.some(b => pathExistsInSchema(b as JSONSchema7, path, root)); +} + +const LEAF_TYPES = [ + 'string', + 'number', + 'integer', + 'boolean', + 'null', + 'array', +] as const; + +/** + * Returns true if the schema defines a concrete type for a settable value. + * Excludes only object schemas so we only merge when the path targets a real field + * (scalar or array), not a nested object placeholder. + */ +function isDefinedLeafSchema(s: JSONSchema7 | undefined): boolean { + if (!s || typeof s === 'boolean') return false; + if (s.enum !== undefined || s.const !== undefined) return true; + const t = s.type; + if (Array.isArray(t)) return t.some(v => LEAF_TYPES.includes(v as any)); + return t !== undefined && LEAF_TYPES.includes(t as any); +} + +/** + * Gets the schema definition for a dot-notation path using json-schema-library. + * Resolves $ref, oneOf, anyOf, allOf, and if/then/else when data context is provided. + * + * @param schema - The root JSON Schema + * @param path - Dot-notation path (e.g. "mode.alphaValue") + * @param data - The data object at that path; required for oneOf/anyOf/if resolution + */ +function getSchemaAtPath( + schema: JSONSchema7, + path: string, + data: JsonObject, +): JSONSchema7 | undefined { + if (!path) return undefined; + + try { + const parsedSchema = new JsonSchema(schema); + const pointer = toJsonPointer(path); + const resolved = parsedSchema.getSchema({ + pointer, + data, + }); + if (!resolved || typeof resolved === 'boolean') return undefined; + const schemaObj = resolved as JSONSchema7; + const isLeaf = isDefinedLeafSchema(schemaObj); + return isLeaf ? schemaObj : undefined; + } catch { + return undefined; + } +} + +/** + * Coerces a single string value for a scalar schema (used for array items). + */ +function coerceScalarValue( + strParam: string, + itemSchema: JSONSchema7 | undefined, +): JsonValue | undefined { + if (!itemSchema) return strParam; + + const enumValues = itemSchema.enum as + | (string | number | boolean)[] + | undefined; + const hasEnum = + enumValues && Array.isArray(enumValues) && enumValues.length > 0; + + if (hasEnum) { + if (enumValues!.includes(strParam)) return strParam; + for (const enumVal of enumValues!) { + if (typeof enumVal === 'boolean') { + const lower = strParam.toLowerCase(); + if ( + (lower === 'true' && enumVal === true) || + (lower === 'false' && enumVal === false) + ) { + return enumVal; + } + } else if (typeof enumVal === 'number') { + const parsed = Number(strParam); + if (!Number.isNaN(parsed) && parsed === enumVal) return parsed; + } else if (typeof enumVal === 'string') { + if (enumVal.toLowerCase() === strParam.toLowerCase()) return enumVal; + } + } + return undefined; + } + + if (itemSchema.type === 'boolean') { + const lower = strParam.toLowerCase(); + if (lower === 'true') return true; + if (lower === 'false') return false; + return undefined; + } + if (itemSchema.type === 'integer') { + const parsed = Number(strParam); + if (!Number.isNaN(parsed) && Number.isInteger(parsed)) return parsed; + return undefined; + } + if (itemSchema.type === 'number') { + const parsed = Number(strParam); + if (!Number.isNaN(parsed)) return parsed; + return undefined; + } + + // string or unspecified type + return strParam; +} + +/** + * Coerces a query param value to match schema constraints (type, enum). + * Returns the typed value to use, or undefined if the value is invalid and should be skipped. + * Supports string, number, integer, boolean, and array types including enum coercion. + */ +function coerceValueForSchema( + paramValue: string, + propSchema: JSONSchema7 | undefined, +): JsonValue | undefined { + if (!propSchema) return paramValue; + const strParam = paramValue.trim(); + + // Array type: parse comma-separated values + if (propSchema.type === 'array') { + const itemsSchema = + typeof propSchema.items === 'object' && + propSchema.items && + !Array.isArray(propSchema.items) + ? (propSchema.items as JSONSchema7) + : undefined; + + const parts = strParam + .split(',') + .map(s => s.trim()) + .filter(Boolean); + const coerced: JsonValue[] = []; + + for (const part of parts) { + const item = coerceScalarValue(part, itemsSchema); + if (item !== undefined) { + coerced.push(item); + } + } + + return coerced.length > 0 ? coerced : undefined; + } + + return coerceScalarValue(strParam, propSchema); +} + +/** + * Merges URL query parameters that match schema property paths into the base form data. + * Uses try-and-resolve: for each param, builds proposed data and asks json-schema-library + * for the schema at that path. If a schema is found, coerces and merges. This delegates + * full support for $ref, oneOf, anyOf, allOf, and if/then/else to the library. + * + * @param schema - The workflow input JSON Schema + * @param searchParams - URL search params from useSearchParams() + * @param baseFormData - Base form data from API (value?.data) + * @returns Form data with query param values merged in + */ +export function mergeQueryParamsIntoFormData( + schema: JSONSchema7, + searchParams: URLSearchParams, + baseFormData: JsonObject = {}, +): JsonObject { + const result = cloneDeep(baseFormData) as JsonObject; + + for (const [paramKey, paramValue] of searchParams.entries()) { + if (RESERVED_QUERY_PARAMS.has(paramKey)) { + continue; + } + if (paramValue === undefined || paramValue === null) { + continue; + } + + if (!pathExistsInSchema(schema, paramKey, schema)) continue; + + // Build proposed data with raw value so getSchema can resolve oneOf/anyOf/if-then-else + const proposedData = cloneDeep(result) as JsonObject; + set(proposedData, paramKey, paramValue); + + const propSchema = getSchemaAtPath(schema, paramKey, proposedData); + if (!propSchema) continue; + + const valueToSet = coerceValueForSchema(paramValue, propSchema); + if (valueToSet !== undefined) { + set(result, paramKey, valueToSet); + } + } + + return result; +} diff --git a/workspaces/orchestrator/yarn.lock b/workspaces/orchestrator/yarn.lock index dd636e619a..ebd60e383e 100644 --- a/workspaces/orchestrator/yarn.lock +++ b/workspaces/orchestrator/yarn.lock @@ -12629,6 +12629,7 @@ __metadata: "@types/uuid": ^9.0.0 axios: ^1.11.0 json-schema: ^0.4.0 + json-schema-library: ^9.0.0 lodash: ^4.17.21 luxon: ^3.7.2 prettier: 3.8.1