Skip to content

Commit acfcb4c

Browse files
fix(orchestrator-form-widgets): evaluate templates in fetch response selectors (#3058)
Evaluate placeholder templates in fetch:response:value, fetch:response:autocomplete, fetch:response:label, and dropdown fetch:response:value before JSONata, consistent with other fetch fields. Treat undefined array selector results as empty options for ActiveDropdown and ActiveTextInput autocomplete (same as ActiveMultiSelect). Add changeset for patch release. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fc38238 commit acfcb4c

7 files changed

Lines changed: 181 additions & 13 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch
3+
---
4+
5+
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.

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
*/
1616

1717
import { JsonValue } from '@backstage/types';
18-
import { evaluateTemplate, evaluateTemplateProps } from './evaluateTemplate';
18+
import {
19+
evaluateFetchResponseSelectorTemplate,
20+
evaluateTemplate,
21+
evaluateTemplateProps,
22+
} from './evaluateTemplate';
1923
import get from 'lodash/get';
2024

2125
const unitEvaluator: evaluateTemplateProps['unitEvaluator'] = async (
@@ -30,6 +34,21 @@ const unitEvaluator: evaluateTemplateProps['unitEvaluator'] = async (
3034
return Promise.resolve(get(formData, unit));
3135
};
3236

37+
/** Matches useTemplateUnitEvaluator: `current.*` uses the path after the first segment. */
38+
const unitEvaluatorAsInWidgets: evaluateTemplateProps['unitEvaluator'] = async (
39+
unit,
40+
formData,
41+
) => {
42+
if (!unit) {
43+
throw new Error('Template unit can not be empty');
44+
}
45+
const dot = unit.indexOf('.');
46+
if (dot > 0 && unit.substring(0, dot) === 'current') {
47+
return get(formData, unit.substring(dot + 1));
48+
}
49+
return get(formData, unit);
50+
};
51+
3352
describe('evaluate template', () => {
3453
const props = {
3554
unitEvaluator,
@@ -240,4 +259,23 @@ describe('evaluate template', () => {
240259
),
241260
);
242261
});
262+
263+
it('evaluateFetchResponseSelectorTemplate interpolates $${{current…}} into a JSONata string', async () => {
264+
await expect(
265+
evaluateFetchResponseSelectorTemplate({
266+
unitEvaluator: unitEvaluatorAsInWidgets,
267+
key: 'fetch:response:value',
268+
formData: {
269+
step: {
270+
xParams: {
271+
selectedEnvironment: 'a',
272+
provisionedEnvironments: 'a,b',
273+
},
274+
},
275+
},
276+
template:
277+
"$${{current.step.xParams.selectedEnvironment}} in $split($${{current.step.xParams.provisionedEnvironments}}, ',') ? 'update' : 'create'",
278+
}),
279+
).resolves.toBe("a in $split(a,b, ',') ? 'update' : 'create'");
280+
});
243281
});

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,22 @@ export const evaluateTemplateString = async (
126126
return evaluated;
127127
};
128128

129+
/**
130+
* Substitutes `$${{…}}` in a fetch response selector (JSONata string) using the same rules as
131+
* `fetch:body` / `fetch:url`, then returns a plain string for JSONata evaluation against the response.
132+
*/
133+
export const evaluateFetchResponseSelectorTemplate = async (
134+
props: evaluateTemplateStringProps,
135+
): Promise<string> => {
136+
const evaluated = await evaluateTemplateString(props);
137+
if (typeof evaluated !== 'string') {
138+
throw new Error(
139+
`Template evaluation for "${props.key}" must produce a string (JSONata expression), got ${typeof evaluated}`,
140+
);
141+
}
142+
return evaluated;
143+
};
144+
129145
export const evaluateTemplate = async (
130146
props: evaluateTemplateProps,
131147
): Promise<JsonValue> => {

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
resolveDropdownDefault,
3636
useProcessingState,
3737
useClearOnRetrigger,
38+
evaluateFetchResponseSelectorTemplate,
3839
} from '../utils';
3940
import { UiProps } from '../uiPropTypes';
4041
import { ErrorText } from './ErrorText';
@@ -126,13 +127,36 @@ export const ActiveDropdown: Widget<
126127

127128
const doItAsync = async () => {
128129
await wrapProcessing(async () => {
130+
const fd = formData ?? {};
131+
const resolvedLabelSelector =
132+
await evaluateFetchResponseSelectorTemplate({
133+
template: labelSelector,
134+
key: 'fetch:response:label',
135+
unitEvaluator: templateUnitEvaluator,
136+
formData: fd,
137+
responseData: data,
138+
uiProps,
139+
});
140+
const resolvedValueSelector =
141+
await evaluateFetchResponseSelectorTemplate({
142+
template: valueSelector,
143+
key: 'fetch:response:value',
144+
unitEvaluator: templateUnitEvaluator,
145+
formData: fd,
146+
responseData: data,
147+
uiProps,
148+
});
129149
const selectedLabels = await applySelectorArray(
130-
getSelectorContext(labelSelector),
131-
labelSelector,
150+
getSelectorContext(resolvedLabelSelector),
151+
resolvedLabelSelector,
152+
true,
153+
true,
132154
);
133155
const selectedValues = await applySelectorArray(
134-
getSelectorContext(valueSelector),
135-
valueSelector,
156+
getSelectorContext(resolvedValueSelector),
157+
resolvedValueSelector,
158+
true,
159+
true,
136160
);
137161

138162
if (selectedLabels.length !== selectedValues.length) {
@@ -148,7 +172,16 @@ export const ActiveDropdown: Widget<
148172
};
149173

150174
doItAsync();
151-
}, [labelSelector, valueSelector, data, formData, props.id, wrapProcessing]);
175+
}, [
176+
labelSelector,
177+
valueSelector,
178+
data,
179+
formData,
180+
uiProps,
181+
templateUnitEvaluator,
182+
props.id,
183+
wrapProcessing,
184+
]);
152185

153186
const handleChange = useCallback(
154187
(changed: string, isByUser: boolean) => {

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
useRetriggerEvaluate,
4545
useProcessingState,
4646
useClearOnRetrigger,
47+
evaluateFetchResponseSelectorTemplate,
4748
} from '../utils';
4849
import { UiProps } from '../uiPropTypes';
4950
import { ErrorText } from './ErrorText';
@@ -171,10 +172,20 @@ export const ActiveMultiSelect: Widget<
171172

172173
const doItAsync = async () => {
173174
await wrapProcessing(async () => {
175+
const fd = formData ?? {};
174176
if (autocompleteSelector) {
177+
const resolvedAutocomplete =
178+
await evaluateFetchResponseSelectorTemplate({
179+
template: autocompleteSelector,
180+
key: 'fetch:response:autocomplete',
181+
unitEvaluator: templateUnitEvaluator,
182+
formData: fd,
183+
responseData: data,
184+
uiProps,
185+
});
175186
const autocompleteValues = await applySelectorArray(
176187
data,
177-
autocompleteSelector,
188+
resolvedAutocomplete,
178189
true,
179190
true,
180191
);
@@ -195,9 +206,19 @@ export const ActiveMultiSelect: Widget<
195206
if (!skipInitialValue && !isChangedByUser) {
196207
// set this just once, when the user has not touched the field
197208
if (defaultValueSelector) {
209+
const resolvedDefault = await evaluateFetchResponseSelectorTemplate(
210+
{
211+
template: defaultValueSelector,
212+
key: 'fetch:response:value',
213+
unitEvaluator: templateUnitEvaluator,
214+
formData: fd,
215+
responseData: data,
216+
uiProps,
217+
},
218+
);
198219
defaults = await applySelectorArray(
199220
data,
200-
defaultValueSelector,
221+
resolvedDefault,
201222
true,
202223
true,
203224
);
@@ -207,7 +228,17 @@ export const ActiveMultiSelect: Widget<
207228

208229
let mandatory: string[] = [];
209230
if (mandatorySelector) {
210-
mandatory = await applySelectorArray(data, mandatorySelector, true);
231+
const resolvedMandatory = await evaluateFetchResponseSelectorTemplate(
232+
{
233+
template: mandatorySelector,
234+
key: 'fetch:response:mandatory',
235+
unitEvaluator: templateUnitEvaluator,
236+
formData: fd,
237+
responseData: data,
238+
uiProps,
239+
},
240+
);
241+
mandatory = await applySelectorArray(data, resolvedMandatory, true);
211242

212243
// Only update if arrays differ (by item or count).
213244
const arraysAreEqual =
@@ -249,6 +280,9 @@ export const ActiveMultiSelect: Widget<
249280
isChangedByUser,
250281
skipInitialValue,
251282
data,
283+
formData,
284+
uiProps,
285+
templateUnitEvaluator,
252286
props.id,
253287
value,
254288
clearOnRetrigger,

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
applySelectorString,
3939
useProcessingState,
4040
useClearOnRetrigger,
41+
evaluateFetchResponseSelectorTemplate,
4142
} from '../utils';
4243
import { ErrorText } from './ErrorText';
4344
import { UiProps } from '../uiPropTypes';
@@ -138,11 +139,20 @@ export const ActiveTextInput: Widget<
138139

139140
const doItAsync = async () => {
140141
await wrapProcessing(async () => {
142+
const fd = formData ?? {};
141143
// Only apply fetched value if user hasn't changed the field
142144
if (!skipInitialValue && !isChangedByUser && defaultValueSelector) {
145+
const resolvedSelector = await evaluateFetchResponseSelectorTemplate({
146+
template: defaultValueSelector,
147+
key: 'fetch:response:value',
148+
unitEvaluator: templateUnitEvaluator,
149+
formData: fd,
150+
responseData: data,
151+
uiProps,
152+
});
143153
const fetchedValue = await applySelectorString(
144154
data,
145-
defaultValueSelector,
155+
resolvedSelector,
146156
);
147157

148158
if (
@@ -155,9 +165,20 @@ export const ActiveTextInput: Widget<
155165
}
156166

157167
if (autocompleteSelector) {
168+
const resolvedAutocomplete =
169+
await evaluateFetchResponseSelectorTemplate({
170+
template: autocompleteSelector,
171+
key: 'fetch:response:autocomplete',
172+
unitEvaluator: templateUnitEvaluator,
173+
formData: fd,
174+
responseData: data,
175+
uiProps,
176+
});
158177
const autocompleteValues = await applySelectorArray(
159178
data,
160-
autocompleteSelector,
179+
resolvedAutocomplete,
180+
true,
181+
true,
161182
);
162183
setAutocompleteOptions(autocompleteValues);
163184
}
@@ -169,6 +190,9 @@ export const ActiveTextInput: Widget<
169190
defaultValueSelector,
170191
autocompleteSelector,
171192
data,
193+
formData,
194+
uiProps,
195+
templateUnitEvaluator,
172196
props.id,
173197
value,
174198
handleChange,

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/SchemaUpdater.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
useFetch,
3030
applySelectorObject,
3131
useProcessingState,
32+
evaluateFetchResponseSelectorTemplate,
3233
} from '../utils';
3334
import { ErrorText } from './ErrorText';
3435
import { UiProps } from '../uiPropTypes';
@@ -86,9 +87,17 @@ export const SchemaUpdater: Widget<
8687
let typedData: SchemaChunksResponse =
8788
data as unknown as SchemaChunksResponse;
8889
if (valueSelector) {
90+
const resolvedSelector = await evaluateFetchResponseSelectorTemplate({
91+
template: valueSelector,
92+
key: 'fetch:response:value',
93+
unitEvaluator: templateUnitEvaluator,
94+
formData: formData ?? {},
95+
responseData: data,
96+
uiProps,
97+
});
8998
typedData = (await applySelectorObject(
9099
data,
91-
valueSelector,
100+
resolvedSelector,
92101
)) as unknown as SchemaChunksResponse;
93102
}
94103

@@ -115,7 +124,16 @@ export const SchemaUpdater: Widget<
115124
});
116125
};
117126
doItAsync();
118-
}, [data, props.id, updateSchema, valueSelector, wrapProcessing]);
127+
}, [
128+
data,
129+
formData,
130+
props.id,
131+
updateSchema,
132+
valueSelector,
133+
uiProps,
134+
templateUnitEvaluator,
135+
wrapProcessing,
136+
]);
119137

120138
const shouldShowFetchError = uiProps['fetch:error:silent'] !== true;
121139
const displayError = localError ?? (shouldShowFetchError ? error : undefined);

0 commit comments

Comments
 (0)