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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -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'");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,22 @@
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<string> => {
const evaluated = await evaluateTemplateString(props);
if (typeof evaluated !== 'string') {
throw new Error(

Check warning on line 138 in workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`new Error()` is too unspecific for a type check. Use `new TypeError()` instead.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ399vpUIjUZlKX6F31z&open=AZ399vpUIjUZlKX6F31z&pullRequest=3058
`Template evaluation for "${props.key}" must produce a string (JSONata expression), got ${typeof evaluated}`,
);
}
return evaluated;
};

export const evaluateTemplate = async (
props: evaluateTemplateProps,
): Promise<JsonValue> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
resolveDropdownDefault,
useProcessingState,
useClearOnRetrigger,
evaluateFetchResponseSelectorTemplate,
} from '../utils';
import { UiProps } from '../uiPropTypes';
import { ErrorText } from './ErrorText';
Expand Down Expand Up @@ -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) {
Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
useRetriggerEvaluate,
useProcessingState,
useClearOnRetrigger,
evaluateFetchResponseSelectorTemplate,
} from '../utils';
import { UiProps } from '../uiPropTypes';
import { ErrorText } from './ErrorText';
Expand Down Expand Up @@ -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,
);
Expand All @@ -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,
);
Expand All @@ -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 =
Expand Down Expand Up @@ -236,6 +267,9 @@ export const ActiveMultiSelect: Widget<
isChangedByUser,
skipInitialValue,
data,
formData,
uiProps,
templateUnitEvaluator,
props.id,
value,
onChange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
applySelectorString,
useProcessingState,
useClearOnRetrigger,
evaluateFetchResponseSelectorTemplate,
} from '../utils';
import { ErrorText } from './ErrorText';
import { UiProps } from '../uiPropTypes';
Expand Down Expand Up @@ -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 (
Expand All @@ -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);
}
Expand All @@ -169,6 +190,9 @@ export const ActiveTextInput: Widget<
defaultValueSelector,
autocompleteSelector,
data,
formData,
uiProps,
templateUnitEvaluator,
props.id,
value,
handleChange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
useFetch,
applySelectorObject,
useProcessingState,
evaluateFetchResponseSelectorTemplate,
} from '../utils';
import { ErrorText } from './ErrorText';
import { UiProps } from '../uiPropTypes';
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
Expand Down
Loading