diff --git a/workspaces/orchestrator/.changeset/fetch-response-selector-templates.md b/workspaces/orchestrator/.changeset/fetch-response-selector-templates.md new file mode 100644 index 0000000000..7289f7c01e --- /dev/null +++ b/workspaces/orchestrator/.changeset/fetch-response-selector-templates.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch +--- + +Evaluate `$${{…}}` placeholders in `fetch:response:value`, `fetch:response:autocomplete`, `fetch:response:label`, and `fetch:response:value` (dropdown) before applying JSONata to the fetch response, consistent with other fetch template fields. Align `ActiveDropdown` and `ActiveTextInput` autocomplete with `ActiveMultiSelect` by treating undefined selector results as empty string arrays when building options, so invalid paths while editing do not surface as hard errors. diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.test.ts index 9b6e55eb15..754e1de0ff 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.test.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.test.ts @@ -15,7 +15,11 @@ */ import { JsonValue } from '@backstage/types'; -import { evaluateTemplate, evaluateTemplateProps } from './evaluateTemplate'; +import { + evaluateFetchResponseSelectorTemplate, + evaluateTemplate, + evaluateTemplateProps, +} from './evaluateTemplate'; import get from 'lodash/get'; const unitEvaluator: evaluateTemplateProps['unitEvaluator'] = async ( @@ -30,6 +34,21 @@ const unitEvaluator: evaluateTemplateProps['unitEvaluator'] = async ( return Promise.resolve(get(formData, unit)); }; +/** Matches useTemplateUnitEvaluator: `current.*` uses the path after the first segment. */ +const unitEvaluatorAsInWidgets: evaluateTemplateProps['unitEvaluator'] = async ( + unit, + formData, +) => { + if (!unit) { + throw new Error('Template unit can not be empty'); + } + const dot = unit.indexOf('.'); + if (dot > 0 && unit.substring(0, dot) === 'current') { + return get(formData, unit.substring(dot + 1)); + } + return get(formData, unit); +}; + describe('evaluate template', () => { const props = { unitEvaluator, @@ -240,4 +259,23 @@ describe('evaluate template', () => { ), ); }); + + it('evaluateFetchResponseSelectorTemplate interpolates $${{current…}} into a JSONata string', async () => { + await expect( + evaluateFetchResponseSelectorTemplate({ + unitEvaluator: unitEvaluatorAsInWidgets, + key: 'fetch:response:value', + formData: { + step: { + xParams: { + selectedEnvironment: 'a', + provisionedEnvironments: 'a,b', + }, + }, + }, + template: + "$${{current.step.xParams.selectedEnvironment}} in $split($${{current.step.xParams.provisionedEnvironments}}, ',') ? 'update' : 'create'", + }), + ).resolves.toBe("a in $split(a,b, ',') ? 'update' : 'create'"); + }); }); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.ts index 9e3e609a4b..cb897116be 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.ts @@ -126,6 +126,22 @@ export const evaluateTemplateString = async ( return evaluated; }; +/** + * Substitutes `$${{…}}` in a fetch response selector (JSONata string) using the same rules as + * `fetch:body` / `fetch:url`, then returns a plain string for JSONata evaluation against the response. + */ +export const evaluateFetchResponseSelectorTemplate = async ( + props: evaluateTemplateStringProps, +): Promise => { + const evaluated = await evaluateTemplateString(props); + if (typeof evaluated !== 'string') { + throw new Error( + `Template evaluation for "${props.key}" must produce a string (JSONata expression), got ${typeof evaluated}`, + ); + } + return evaluated; +}; + export const evaluateTemplate = async ( props: evaluateTemplateProps, ): Promise => { diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx index 200c02e259..df9db62c81 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx @@ -35,6 +35,7 @@ import { resolveDropdownDefault, useProcessingState, useClearOnRetrigger, + evaluateFetchResponseSelectorTemplate, } from '../utils'; import { UiProps } from '../uiPropTypes'; import { ErrorText } from './ErrorText'; @@ -126,13 +127,36 @@ export const ActiveDropdown: Widget< const doItAsync = async () => { await wrapProcessing(async () => { + const fd = formData ?? {}; + const resolvedLabelSelector = + await evaluateFetchResponseSelectorTemplate({ + template: labelSelector, + key: 'fetch:response:label', + unitEvaluator: templateUnitEvaluator, + formData: fd, + responseData: data, + uiProps, + }); + const resolvedValueSelector = + await evaluateFetchResponseSelectorTemplate({ + template: valueSelector, + key: 'fetch:response:value', + unitEvaluator: templateUnitEvaluator, + formData: fd, + responseData: data, + uiProps, + }); const selectedLabels = await applySelectorArray( - getSelectorContext(labelSelector), - labelSelector, + getSelectorContext(resolvedLabelSelector), + resolvedLabelSelector, + true, + true, ); const selectedValues = await applySelectorArray( - getSelectorContext(valueSelector), - valueSelector, + getSelectorContext(resolvedValueSelector), + resolvedValueSelector, + true, + true, ); if (selectedLabels.length !== selectedValues.length) { @@ -148,7 +172,16 @@ export const ActiveDropdown: Widget< }; doItAsync(); - }, [labelSelector, valueSelector, data, formData, props.id, wrapProcessing]); + }, [ + labelSelector, + valueSelector, + data, + formData, + uiProps, + templateUnitEvaluator, + props.id, + wrapProcessing, + ]); const handleChange = useCallback( (changed: string, isByUser: boolean) => { diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx index 5f505a4e26..6860e26900 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx @@ -43,6 +43,7 @@ import { useRetriggerEvaluate, useProcessingState, useClearOnRetrigger, + evaluateFetchResponseSelectorTemplate, } from '../utils'; import { UiProps } from '../uiPropTypes'; import { ErrorText } from './ErrorText'; @@ -168,10 +169,20 @@ export const ActiveMultiSelect: Widget< const doItAsync = async () => { await wrapProcessing(async () => { + const fd = formData ?? {}; if (autocompleteSelector) { + const resolvedAutocomplete = + await evaluateFetchResponseSelectorTemplate({ + template: autocompleteSelector, + key: 'fetch:response:autocomplete', + unitEvaluator: templateUnitEvaluator, + formData: fd, + responseData: data, + uiProps, + }); const autocompleteValues = await applySelectorArray( data, - autocompleteSelector, + resolvedAutocomplete, true, true, ); @@ -192,9 +203,19 @@ export const ActiveMultiSelect: Widget< if (!skipInitialValue && !isChangedByUser) { // set this just once, when the user has not touched the field if (defaultValueSelector) { + const resolvedDefault = await evaluateFetchResponseSelectorTemplate( + { + template: defaultValueSelector, + key: 'fetch:response:value', + unitEvaluator: templateUnitEvaluator, + formData: fd, + responseData: data, + uiProps, + }, + ); defaults = await applySelectorArray( data, - defaultValueSelector, + resolvedDefault, true, true, ); @@ -204,7 +225,17 @@ export const ActiveMultiSelect: Widget< let mandatory: string[] = []; if (mandatorySelector) { - mandatory = await applySelectorArray(data, mandatorySelector, true); + const resolvedMandatory = await evaluateFetchResponseSelectorTemplate( + { + template: mandatorySelector, + key: 'fetch:response:mandatory', + unitEvaluator: templateUnitEvaluator, + formData: fd, + responseData: data, + uiProps, + }, + ); + mandatory = await applySelectorArray(data, resolvedMandatory, true); // Only update if arrays differ (by item or count). const arraysAreEqual = @@ -236,6 +267,9 @@ export const ActiveMultiSelect: Widget< isChangedByUser, skipInitialValue, data, + formData, + uiProps, + templateUnitEvaluator, props.id, value, onChange, diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx index 8fab03bfa3..cbc7197b0f 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx @@ -38,6 +38,7 @@ import { applySelectorString, useProcessingState, useClearOnRetrigger, + evaluateFetchResponseSelectorTemplate, } from '../utils'; import { ErrorText } from './ErrorText'; import { UiProps } from '../uiPropTypes'; @@ -138,11 +139,20 @@ export const ActiveTextInput: Widget< const doItAsync = async () => { await wrapProcessing(async () => { + const fd = formData ?? {}; // Only apply fetched value if user hasn't changed the field if (!skipInitialValue && !isChangedByUser && defaultValueSelector) { + const resolvedSelector = await evaluateFetchResponseSelectorTemplate({ + template: defaultValueSelector, + key: 'fetch:response:value', + unitEvaluator: templateUnitEvaluator, + formData: fd, + responseData: data, + uiProps, + }); const fetchedValue = await applySelectorString( data, - defaultValueSelector, + resolvedSelector, ); if ( @@ -155,9 +165,20 @@ export const ActiveTextInput: Widget< } if (autocompleteSelector) { + const resolvedAutocomplete = + await evaluateFetchResponseSelectorTemplate({ + template: autocompleteSelector, + key: 'fetch:response:autocomplete', + unitEvaluator: templateUnitEvaluator, + formData: fd, + responseData: data, + uiProps, + }); const autocompleteValues = await applySelectorArray( data, - autocompleteSelector, + resolvedAutocomplete, + true, + true, ); setAutocompleteOptions(autocompleteValues); } @@ -169,6 +190,9 @@ export const ActiveTextInput: Widget< defaultValueSelector, autocompleteSelector, data, + formData, + uiProps, + templateUnitEvaluator, props.id, value, handleChange, diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/SchemaUpdater.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/SchemaUpdater.tsx index 003f367829..ba5895d366 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/SchemaUpdater.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/SchemaUpdater.tsx @@ -29,6 +29,7 @@ import { useFetch, applySelectorObject, useProcessingState, + evaluateFetchResponseSelectorTemplate, } from '../utils'; import { ErrorText } from './ErrorText'; import { UiProps } from '../uiPropTypes'; @@ -86,9 +87,17 @@ export const SchemaUpdater: Widget< let typedData: SchemaChunksResponse = data as unknown as SchemaChunksResponse; if (valueSelector) { + const resolvedSelector = await evaluateFetchResponseSelectorTemplate({ + template: valueSelector, + key: 'fetch:response:value', + unitEvaluator: templateUnitEvaluator, + formData: formData ?? {}, + responseData: data, + uiProps, + }); typedData = (await applySelectorObject( data, - valueSelector, + resolvedSelector, )) as unknown as SchemaChunksResponse; } @@ -115,7 +124,16 @@ export const SchemaUpdater: Widget< }); }; doItAsync(); - }, [data, props.id, updateSchema, valueSelector, wrapProcessing]); + }, [ + data, + formData, + props.id, + updateSchema, + valueSelector, + uiProps, + templateUnitEvaluator, + wrapProcessing, + ]); const shouldShowFetchError = uiProps['fetch:error:silent'] !== true; const displayError = localError ?? (shouldShowFetchError ? error : undefined);