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:
@@ -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.
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user