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 <gusevda90@gmail.com>

* 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 <gusevda90@gmail.com>

* Add changesets for conditional output feature

Signed-off-by: Dmitry Gusev <gusevda90@gmail.com>

* Document conditional if on output links and text

Signed-off-by: Dmitry Gusev <gusevda90@gmail.com>

* Guard against non-object items in output arrays

Signed-off-by: Dmitry Gusev <gusevda90@gmail.com>

* 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 <gusevda90@gmail.com>

* Extract filterConditionalItems helper to deduplicate logic

Signed-off-by: Dmitry Gusev <gusevda90@gmail.com>

* Align output if schema descriptions with step if semantics

Signed-off-by: Dmitry Gusev <gusevda90@gmail.com>

* Fix docs quality check: replace 'falsy' wording

Signed-off-by: Dmitry Gusev <gusevda90@gmail.com>

* 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 <gusevda90@gmail.com>

---------

Signed-off-by: Dmitry Gusev <gusevda90@gmail.com>
This commit is contained in:
Dmitry Gusev
2026-04-14 15:29:04 +03:00
committed by GitHub
parent aa08b7f135
commit 3ef6078b70
11 changed files with 270 additions and 3 deletions
@@ -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:
#
@@ -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.
@@ -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({
@@ -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?.();
@@ -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<T>(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)) {