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
+5
View File
@@ -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.
+5
View File
@@ -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.
@@ -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
@@ -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)) {
+2
View File
@@ -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;
@@ -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.",
+2
View File
@@ -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;