diff --git a/workspaces/orchestrator/.changeset/active-textinput-missing-selector.md b/workspaces/orchestrator/.changeset/active-textinput-missing-selector.md new file mode 100644 index 0000000000..cba63efe43 --- /dev/null +++ b/workspaces/orchestrator/.changeset/active-textinput-missing-selector.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch +--- + +Make fetch-driven form widgets resilient to invalid or partial JSONata and dynamic selector input so the UI no longer crashes while users edit fields. diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/applySelector.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/applySelector.test.ts index acca77ec0d..1866b8bb34 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/applySelector.test.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/applySelector.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { applySelectorArray } from './applySelector'; +import { applySelectorArray, applySelectorString } from './applySelector'; import { JsonObject } from '@backstage/types'; describe('applySelectorArray', () => { @@ -258,6 +258,79 @@ describe('applySelectorArray', () => { }); }); +describe('applySelectorString', () => { + const data: JsonObject = { status: 'UP', nested: { name: 'x' } }; + + it('returns string when selector evaluates to a string', async () => { + await expect(applySelectorString(data, 'status')).resolves.toBe('UP'); + }); + + it('throws when selector is missing and emptyStringWhenMissing is false', async () => { + await expect(applySelectorString(data, 'doesNotExist')).rejects.toThrow( + 'Unexpected result of "doesNotExist" selector, expected string type', + ); + }); + + it('returns empty string when selector is missing and emptyStringWhenMissing is true', async () => { + await expect(applySelectorString(data, 'doesNotExist', true)).resolves.toBe( + '', + ); + }); + + it('returns empty string when JSONata yields null and emptyStringWhenMissing is true', async () => { + const withNull: JsonObject = { absent: null }; + await expect(applySelectorString(withNull, 'absent', true)).resolves.toBe( + '', + ); + }); + + it('returns empty string when JSONata yields a number and emptyStringWhenMissing is true', async () => { + const withNum: JsonObject = { n: 42 }; + await expect(applySelectorString(withNum, 'n', true)).resolves.toBe(''); + }); + + it('returns empty string for invalid JSONata syntax when emptyStringWhenMissing is true', async () => { + await expect( + applySelectorString({} as JsonObject, '.', true), + ).resolves.toBe(''); + await expect( + applySelectorString({} as JsonObject, '/', true), + ).resolves.toBe(''); + }); + + it('throws a clear error for invalid JSONata syntax when strict', async () => { + await expect(applySelectorString({} as JsonObject, '.')).rejects.toThrow( + 'Invalid JSONata', + ); + }); +}); + +describe('applySelectorArray invalid JSONata', () => { + const data: JsonObject = { args: { tag: ['a'] } }; + + it('returns empty array for invalid syntax when emptyArrayIfNeeded is true', async () => { + await expect( + applySelectorArray(data, '.', true, true), + ).resolves.toStrictEqual([]); + await expect( + applySelectorArray(data, '/', true, true), + ).resolves.toStrictEqual([]); + }); + + it('throws for invalid syntax when not lenient', async () => { + await expect(applySelectorArray(data, '.')).rejects.toThrow( + 'Invalid JSONata', + ); + }); + + it('returns empty array when JSONata yields a number and emptyArrayIfNeeded is true', async () => { + const withNum: JsonObject = { n: 7 }; + await expect( + applySelectorArray(withNum, 'n', true, true), + ).resolves.toStrictEqual([]); + }); +}); + describe('applySelectorArray - complex queries', () => { it('handles complex queries', async () => { const data = [ diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/applySelector.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/applySelector.ts index dcff1d0c3a..b6769cb224 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/applySelector.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/applySelector.ts @@ -17,14 +17,46 @@ import jsonata from 'jsonata'; import { JsonArray, JsonObject } from '@backstage/types'; import { isJsonObject } from '@red-hat-developer-hub/backstage-plugin-orchestrator-common'; +const jsonataReason = (reason: unknown): string => + reason instanceof Error ? reason.message : String(reason); + +const compileJsonata = (selector: string) => { + try { + return jsonata(selector); + } catch (reason) { + throw new Error( + `Invalid JSONata selector ${JSON.stringify(selector)}: ${jsonataReason(reason)}`, + ); + } +}; + export const applySelectorArray = async ( data: JsonObject | JsonArray, selector: string, createArrayIfNeeded: boolean = false, emptyArrayIfNeeded: boolean = false, ): Promise => { - const expression = jsonata(selector); - const value = await expression.evaluate(data); + let expression; + try { + expression = compileJsonata(selector); + } catch (reason) { + if (emptyArrayIfNeeded) { + return []; + } + throw reason; + } + + let value; + try { + value = await expression.evaluate(data); + } catch (reason) { + if (emptyArrayIfNeeded) { + return []; + } + throw new Error( + `JSONata evaluation failed for ${JSON.stringify(selector)}: ${jsonataReason(reason)}`, + ); + } if (emptyArrayIfNeeded && !value) { return []; @@ -38,6 +70,11 @@ export const applySelectorArray = async ( return [...value]; } + if (emptyArrayIfNeeded) { + // Dynamic selectors while typing may yield numbers, objects, or mixed arrays. + return []; + } + throw new Error( `Unexpected result of "${selector}" selector, expected string[] type. Value ${JSON.stringify(value)}`, ); @@ -46,14 +83,39 @@ export const applySelectorArray = async ( export const applySelectorString = async ( data: JsonObject, selector: string, + emptyStringWhenMissing: boolean = false, ): Promise => { - const expression = jsonata(selector); - const value = await expression.evaluate(data); + let expression; + try { + expression = compileJsonata(selector); + } catch (reason) { + if (emptyStringWhenMissing) { + return ''; + } + throw reason; + } + + let value; + try { + value = await expression.evaluate(data); + } catch (reason) { + if (emptyStringWhenMissing) { + return ''; + } + throw new Error( + `JSONata evaluation failed for ${JSON.stringify(selector)}: ${jsonataReason(reason)}`, + ); + } if (typeof value === 'string') { return value; } + if (emptyStringWhenMissing) { + // Missing path, numbers/booleans from literals (e.g. selector "1"), objects, etc. + return ''; + } + throw new Error( `Unexpected result of "${selector}" selector, expected string type. Value "${JSON.stringify(value)}"`, ); @@ -63,8 +125,15 @@ export const applySelectorObject = async ( data: JsonObject, selector: string, ): Promise => { - const expression = jsonata(selector); - const value = await expression.evaluate(data); + const expression = compileJsonata(selector); + let value; + try { + value = await expression.evaluate(data); + } catch (reason) { + throw new Error( + `JSONata evaluation failed for ${JSON.stringify(selector)}: ${jsonataReason(reason)}`, + ); + } if (isJsonObject(value)) { return value; 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 754e1de0ff..cb5f75d45c 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 @@ -278,4 +278,48 @@ describe('evaluate template', () => { }), ).resolves.toBe("a in $split(a,b, ',') ? 'update' : 'create'"); }); + + it('evaluateFetchResponseSelectorTemplate stringifies numeric template results for JSONata', async () => { + await expect( + evaluateFetchResponseSelectorTemplate({ + unitEvaluator: unitEvaluatorAsInWidgets, + key: 'fetch:response:value', + formData: { step: { sel: 1 } }, + template: '$${{current.step.sel}}', + }), + ).resolves.toBe('1'); + }); + + it('evaluateFetchResponseSelectorTemplate returns empty string for null template expansion', async () => { + await expect( + evaluateFetchResponseSelectorTemplate({ + unitEvaluator: async () => null, + key: 'fetch:response:value', + formData: {}, + template: '$${{x}}', + }), + ).resolves.toBe(''); + }); + + it('evaluateFetchResponseSelectorTemplate returns empty string for solo object expansion', async () => { + await expect( + evaluateFetchResponseSelectorTemplate({ + unitEvaluator: async () => ({ a: 1 }), + key: 'fetch:response:value', + formData: {}, + template: '$${{x}}', + }), + ).resolves.toBe(''); + }); + + it('evaluateFetchResponseSelectorTemplate returns empty string on malformed template', async () => { + await expect( + evaluateFetchResponseSelectorTemplate({ + unitEvaluator: unitEvaluatorAsInWidgets, + key: 'fetch:response:value', + formData: {}, + template: '$${{foo', + }), + ).resolves.toBe(''); + }); }); 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 cb897116be..de0c51ecf7 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.ts @@ -133,13 +133,25 @@ export const evaluateTemplateString = async ( 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}`, - ); + let evaluated: JsonValue; + try { + evaluated = await evaluateTemplateString(props); + } catch { + // Malformed `$${{…}}` or evaluator errors while editing should not break fetch widgets. + return ''; } - return evaluated; + if (typeof evaluated === 'string') { + return evaluated; + } + if (evaluated === undefined || evaluated === null) { + return ''; + } + if (typeof evaluated === 'number' || typeof evaluated === 'boolean') { + // Form fields may be numeric/boolean while the selector must be JSONata text. + return String(evaluated); + } + // Solo object/array from template expansion is not a usable JSONata selector string. + return ''; }; export const evaluateTemplate = async ( diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useRequestInit.test.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useRequestInit.test.ts index 1d4c4339d7..318d39f903 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useRequestInit.test.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useRequestInit.test.ts @@ -95,4 +95,26 @@ describe('getRequestInit', () => { name: 'Zara', }); }); + + it('omits fetch:body fields when jsonata: expression is invalid syntax', async () => { + const uiProps: JsonObject = { + 'fetch:method': 'POST', + 'fetch:body': { + ok: 'literal-only', + badCompile: 'jsonata:.', + badSlash: 'jsonata:/', + }, + }; + const formData: JsonObject = {}; + + const requestInit = await getRequestInit( + uiProps, + 'fetch', + unitEvaluator, + formData, + ); + expect(JSON.parse(requestInit.body as string)).toEqual({ + ok: 'literal-only', + }); + }); }); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useRequestInit.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useRequestInit.ts index 9eec19df6c..f9cc167554 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useRequestInit.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useRequestInit.ts @@ -53,9 +53,16 @@ const evaluateJsonataInValue = async ( if (!expression) { return value; } - const compiled = jsonata(expression); - const evaluated = await compiled.evaluate(formData); - return evaluated === undefined ? UNDEFINED_VALUE : (evaluated as JsonValue); + try { + const compiled = jsonata(expression); + const evaluated = await compiled.evaluate(formData); + return evaluated === undefined + ? UNDEFINED_VALUE + : (evaluated as JsonValue); + } catch { + // Invalid or failing JSONata (e.g. user-typed fragments) must not break fetch body evaluation. + return UNDEFINED_VALUE; + } } if (Array.isArray(value)) { const evaluatedArray = await Promise.all( 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 cbc7197b0f..04bc568d78 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx @@ -153,6 +153,7 @@ export const ActiveTextInput: Widget< const fetchedValue = await applySelectorString( data, resolvedSelector, + true, ); if ( 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 ba5895d366..64f71d1497 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/SchemaUpdater.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/SchemaUpdater.tsx @@ -95,10 +95,19 @@ export const SchemaUpdater: Widget< responseData: data, uiProps, }); - typedData = (await applySelectorObject( - data, - resolvedSelector, - )) as unknown as SchemaChunksResponse; + if (resolvedSelector.trim()) { + try { + typedData = (await applySelectorObject( + data, + resolvedSelector, + )) as unknown as SchemaChunksResponse; + } catch (reason) { + setLocalError( + reason instanceof Error ? reason.message : String(reason), + ); + return; + } + } } // validate received response before updating