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