From 3ef6078b7047e544d4631763d12593f230183b3a Mon Sep 17 00:00:00 2001 From: Dmitry Gusev Date: Tue, 14 Apr 2026 15:29:04 +0300 Subject: [PATCH] scaffolder: Support conditional `if` on output links and text (#33332) * Add sample template for conditional output demo Add a scaffolder template that exercises conditional `if` on output links and text items, for testing issue #24805. Signed-off-by: Dmitry Gusev * Support conditional `if` on scaffolder output links and text Add `if` property to output link and text items in scaffolder templates, allowing template authors to conditionally show/hide output items based on parameters or step results. Items with a falsy `if` condition are filtered out before being sent to the frontend. Signed-off-by: Dmitry Gusev * Add changesets for conditional output feature Signed-off-by: Dmitry Gusev * Document conditional if on output links and text Signed-off-by: Dmitry Gusev * Guard against non-object items in output arrays Signed-off-by: Dmitry Gusev * Skip array items in output if-filtering destructuring Add Array.isArray guard to prevent corrupting array items into plain objects during the rest-destructuring step. Signed-off-by: Dmitry Gusev * Extract filterConditionalItems helper to deduplicate logic Signed-off-by: Dmitry Gusev * Align output if schema descriptions with step if semantics Signed-off-by: Dmitry Gusev * Fix docs quality check: replace 'falsy' wording Signed-off-by: Dmitry Gusev * Refactor filterConditionalItems to use flatMap and generics Replace .filter().map() with a single flatMap call and make the function generic to eliminate JsonArray casts at call sites. Signed-off-by: Dmitry Gusev --------- Signed-off-by: Dmitry Gusev --- .changeset/metal-parts-doubt.md | 5 + .changeset/silver-mangos-think.md | 5 + .../software-templates/writing-templates.md | 20 +++ .../sample-templates/all-templates.yaml | 1 + .../conditional-output-demo/template.yaml | 62 ++++++++ .../tasks/NunjucksWorkflowRunner.test.ts | 132 +++++++++++++++++- .../tasks/NunjucksWorkflowRunner.ts | 15 +- .../src/scaffolder/tasks/helper.ts | 21 ++- plugins/scaffolder-common/report.api.md | 2 + .../src/Template.v1beta3.schema.json | 8 ++ plugins/scaffolder-common/src/api.ts | 2 + 11 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 .changeset/metal-parts-doubt.md create mode 100644 .changeset/silver-mangos-think.md create mode 100644 plugins/scaffolder-backend/sample-templates/conditional-output-demo/template.yaml diff --git a/.changeset/metal-parts-doubt.md b/.changeset/metal-parts-doubt.md new file mode 100644 index 0000000000..0211b4672a --- /dev/null +++ b/.changeset/metal-parts-doubt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-common': patch +--- + +Added optional `if` property to `ScaffolderOutputLink` and `ScaffolderOutputText` types, allowing template authors to conditionally include output links and text items. diff --git a/.changeset/silver-mangos-think.md b/.changeset/silver-mangos-think.md new file mode 100644 index 0000000000..3cd569bee6 --- /dev/null +++ b/.changeset/silver-mangos-think.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Added support for conditional `if` filtering on output `links` and `text` items. Items where the `if` condition evaluates to false are now excluded from the task output. diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 23b376caf5..fe80235c54 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -767,6 +767,26 @@ output: **Entity URL:** `${{ steps['publish'].output.remoteUrl }}` ``` +Output `links` and `text` items support an optional `if` condition, using the same syntax as step conditions. Items where the condition evaluates to false are excluded from the output: + +```yaml +output: + links: + - title: Repository + url: ${{ steps['publish'].output.remoteUrl }} + - if: ${{ parameters.enableCI === "Yes" }} + title: CI Dashboard + url: https://ci.example.com/${{ parameters.name }} + text: + - title: Summary + content: | + **Component:** `${{ parameters.name }}` + - if: ${{ parameters.showDetails }} + title: Details + content: | + **CI enabled:** ${{ parameters.enableCI }} +``` + ## The templating syntax You might have noticed expressions wrapped in `${{ }}` in the examples. These are diff --git a/plugins/scaffolder-backend/sample-templates/all-templates.yaml b/plugins/scaffolder-backend/sample-templates/all-templates.yaml index 1e1fb5baf1..859bd717aa 100644 --- a/plugins/scaffolder-backend/sample-templates/all-templates.yaml +++ b/plugins/scaffolder-backend/sample-templates/all-templates.yaml @@ -7,6 +7,7 @@ spec: targets: - ./remote-templates.yaml - ./notifications-demo/template.yaml + - ./conditional-output-demo/template.yaml # For local development of a template, you can reference your local templates here. # Examples: # diff --git a/plugins/scaffolder-backend/sample-templates/conditional-output-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/conditional-output-demo/template.yaml new file mode 100644 index 0000000000..5ee5df76db --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/conditional-output-demo/template.yaml @@ -0,0 +1,62 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: conditional-output-demo + title: Conditional Output Demo + description: Demonstrates conditional if on output links and text +spec: + owner: backstage/techdocs-core + type: service + + parameters: + - title: Options + required: + - name + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + enableCI: + title: Enable CI + type: string + enum: + - 'Yes' + - 'No' + default: 'Yes' + showDetails: + title: Show Details + type: boolean + default: true + + steps: + - id: log + name: Log + action: debug:log + input: + message: 'Creating ${{ parameters.name }}' + + output: + links: + - title: Backstage Homepage + url: https://backstage.io + - if: ${{ parameters.enableCI === "Yes" }} + title: CI Dashboard + icon: dashboard + url: https://ci.example.com/${{ parameters.name }} + - if: ${{ parameters.enableCI === "Yes" }} + title: CI Docs + url: https://ci.example.com/docs + text: + - title: Summary + content: | + **Component:** `${{ parameters.name }}` + - if: ${{ parameters.showDetails }} + title: CI Details + content: | + **CI enabled:** ${{ parameters.enableCI }} + - if: ${{ parameters.enableCI === "Yes" }} + title: CI Setup Instructions + content: | + Run `ci-setup --name ${{ parameters.name }}` to configure CI. diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 6b07936e57..8187070ffa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -20,7 +20,7 @@ import { TemplateActionRegistry, } from '../actions'; import { ScmIntegrations } from '@backstage/integration'; -import { JsonObject } from '@backstage/types'; +import { JsonArray, JsonObject } from '@backstage/types'; import { ConfigReader } from '@backstage/config'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; import { @@ -519,6 +519,136 @@ describe('NunjucksWorkflowRunner', () => { }); }); + describe('conditional output items', () => { + it('should include output links without an if condition', async () => { + const task = createMockTaskWithSpec({ + steps: [{ id: 'test', name: 'test', action: 'jest-mock-action' }], + output: { + links: [{ title: 'Always', url: 'https://example.com' }], + }, + }); + + const { output } = await runner.execute(task); + expect(output.links).toEqual([ + { title: 'Always', url: 'https://example.com' }, + ]); + }); + + it('should filter out output links where if is false', async () => { + const task = createMockTaskWithSpec({ + steps: [{ id: 'test', name: 'test', action: 'jest-mock-action' }], + output: { + links: [ + { title: 'Always', url: 'https://example.com' }, + { if: false, title: 'Hidden', url: 'https://hidden.com' }, + ], + }, + }); + + const { output } = await runner.execute(task); + expect(output.links).toEqual([ + { title: 'Always', url: 'https://example.com' }, + ]); + }); + + it('should include output links where if is true', async () => { + const task = createMockTaskWithSpec({ + steps: [{ id: 'test', name: 'test', action: 'jest-mock-action' }], + output: { + links: [{ if: true, title: 'Visible', url: 'https://visible.com' }], + }, + }); + + const { output } = await runner.execute(task); + expect(output.links).toEqual([ + { title: 'Visible', url: 'https://visible.com' }, + ]); + }); + + it('should filter output links based on templated if condition', async () => { + const task = createMockTaskWithSpec({ + steps: [{ id: 'test', name: 'test', action: 'output-action' }], + output: { + links: [ + { + if: '${{ parameters.enableCI === "Yes" }}', + title: 'CI', + url: 'https://ci.example.com', + }, + { + if: '${{ parameters.enableCI === "Yes" }}', + title: 'CI Docs', + url: 'https://ci.example.com/docs', + }, + ], + }, + parameters: { enableCI: 'No' }, + }); + + const { output } = await runner.execute(task); + expect(output.links).toEqual([]); + }); + + it('should include output links when templated if condition is truthy', async () => { + const task = createMockTaskWithSpec({ + steps: [{ id: 'test', name: 'test', action: 'output-action' }], + output: { + links: [ + { + if: '${{ parameters.enableCI === "Yes" }}', + title: 'CI', + url: 'https://ci.example.com', + }, + ], + }, + parameters: { enableCI: 'Yes' }, + }); + + const { output } = await runner.execute(task); + expect(output.links).toEqual([ + { title: 'CI', url: 'https://ci.example.com' }, + ]); + }); + + it('should filter output text items based on if condition', async () => { + const task = createMockTaskWithSpec({ + steps: [{ id: 'test', name: 'test', action: 'jest-mock-action' }], + output: { + text: [ + { title: 'Always', content: 'visible' }, + { if: false, title: 'Hidden', content: 'hidden' }, + { + if: '${{ parameters.show }}', + title: 'Conditional', + content: 'conditional', + }, + ], + }, + parameters: { show: true }, + }); + + const { output } = await runner.execute(task); + expect(output.text).toEqual([ + { title: 'Always', content: 'visible' }, + { title: 'Conditional', content: 'conditional' }, + ]); + }); + + it('should strip the if field from output items that pass the condition', async () => { + const task = createMockTaskWithSpec({ + steps: [{ id: 'test', name: 'test', action: 'jest-mock-action' }], + output: { + links: [{ if: true, title: 'Link', url: 'https://example.com' }], + text: [{ if: true, title: 'Text', content: 'content' }], + }, + }); + + const { output } = await runner.execute(task); + expect((output.links as JsonArray)[0]).not.toHaveProperty('if'); + expect((output.text as JsonArray)[0]).not.toHaveProperty('if'); + }); + }); + describe('templating', () => { it('should template the input to an action', async () => { const task = createMockTaskWithSpec({ diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index a6a2eb2483..dbf0ed58b1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -33,7 +33,11 @@ import { SecureTemplateRenderer, } from '../../lib/templating/SecureTemplater'; import { TemplateActionRegistry } from '../actions/TemplateActionRegistry'; -import { generateExampleOutput, isTruthy } from './helper'; +import { + filterConditionalItems, + generateExampleOutput, + isTruthy, +} from './helper'; import { TaskTrackType, WorkflowResponse, WorkflowRunner } from './types'; import type { @@ -690,6 +694,15 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { } const output = this.render(task.spec.output, context, renderTemplate); + + // Filter output links and text items based on their `if` condition + if (Array.isArray(output?.links)) { + output.links = filterConditionalItems(output.links); + } + if (Array.isArray(output?.text)) { + output.text = filterConditionalItems(output.text); + } + await taskTrack.markSuccessful(); await task.cleanWorkspace?.(); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts index da47bfd563..1b14fe6798 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts @@ -15,7 +15,7 @@ */ import { Config, readDurationFromConfig } from '@backstage/config'; -import { HumanDuration } from '@backstage/types'; +import { HumanDuration, JsonObject, JsonValue } from '@backstage/types'; import { isArray } from 'lodash'; import { Schema } from 'jsonschema'; @@ -29,6 +29,25 @@ export function isTruthy(value: any): boolean { return isArray(value) ? value.length > 0 : !!value; } +function isPlainObject(item: JsonValue): item is JsonObject { + return typeof item === 'object' && item !== null && !Array.isArray(item); +} + +/** + * Filters an output array by evaluating the `if` condition on each item, + * and strips the `if` field from items that pass. Non-plain-object items + * are passed through unchanged. + */ +export function filterConditionalItems(items: readonly T[]): T[] { + return items.flatMap(item => { + if (!isPlainObject(item as JsonValue)) return [item]; + const obj = item as JsonObject; + if ('if' in obj && !isTruthy(obj.if)) return []; + const { if: _if, ...rest } = obj; + return [rest as T]; + }); +} + export function generateExampleOutput(schema: Schema): unknown { const { examples } = schema as { examples?: unknown }; if (examples && Array.isArray(examples)) { diff --git a/plugins/scaffolder-common/report.api.md b/plugins/scaffolder-common/report.api.md index 5b82272955..2c6c928920 100644 --- a/plugins/scaffolder-common/report.api.md +++ b/plugins/scaffolder-common/report.api.md @@ -270,6 +270,7 @@ export interface ScaffolderGetIntegrationsListResponse { // @public (undocumented) export type ScaffolderOutputLink = { + if?: string | boolean; title?: string; icon?: string; url?: string; @@ -278,6 +279,7 @@ export type ScaffolderOutputLink = { // @public (undocumented) export type ScaffolderOutputText = { + if?: string | boolean; title?: string; icon?: string; content?: string; diff --git a/plugins/scaffolder-common/src/Template.v1beta3.schema.json b/plugins/scaffolder-common/src/Template.v1beta3.schema.json index 5eb61a0f6d..683115bf78 100644 --- a/plugins/scaffolder-common/src/Template.v1beta3.schema.json +++ b/plugins/scaffolder-common/src/Template.v1beta3.schema.json @@ -261,6 +261,10 @@ "type": "object", "required": [], "properties": { + "if": { + "type": ["string", "boolean"], + "description": "A templated condition that excludes this link when evaluated to false. If the condition is true or not defined, the link is included. The condition is true, if the input is not `false`, `undefined`, `null`, `\"\"`, `0`, or `[]`." + }, "url": { "type": "string", "description": "A url in a standard uri format.", @@ -295,6 +299,10 @@ "type": "object", "required": [], "properties": { + "if": { + "type": ["string", "boolean"], + "description": "A templated condition that excludes this text when evaluated to false. If the condition is true or not defined, the text is included. The condition is true, if the input is not `false`, `undefined`, `null`, `\"\"`, `0`, or `[]`." + }, "title": { "type": "string", "description": "A user friendly display name for the text.", diff --git a/plugins/scaffolder-common/src/api.ts b/plugins/scaffolder-common/src/api.ts index 5e61af5bb4..7a3fe5afbf 100644 --- a/plugins/scaffolder-common/src/api.ts +++ b/plugins/scaffolder-common/src/api.ts @@ -136,6 +136,7 @@ export type ListTemplatingExtensionsResponse = { /** @public */ export type ScaffolderOutputLink = { + if?: string | boolean; title?: string; icon?: string; url?: string; @@ -144,6 +145,7 @@ export type ScaffolderOutputLink = { /** @public */ export type ScaffolderOutputText = { + if?: string | boolean; title?: string; icon?: string; content?: string;