diff --git a/.changeset/heavy-penguins-burn.md b/.changeset/heavy-penguins-burn.md
new file mode 100644
index 0000000000..b8c243a99b
--- /dev/null
+++ b/.changeset/heavy-penguins-burn.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-scaffolder-common': minor
+'@backstage/plugin-scaffolder-react': minor
+---
+
+Add support for Markdown text blob outputs from templates
diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md
index 15e6a833cc..67bcb6c179 100644
--- a/docs/features/software-templates/writing-templates.md
+++ b/docs/features/software-templates/writing-templates.md
@@ -510,10 +510,8 @@ take a look at, or you can
Each individual step can output some variables that can be used in the
scaffolder frontend for after the job is finished. This is useful for things
-like linking to the entity that has been created with the backend, and also
-linking to the created repository.
-
-The main two that are used are the following:
+like linking to the entity that has been created with the backend, linking
+to the created repository, or showing Markdown text blobs.
```yaml
output:
@@ -523,6 +521,10 @@ output:
- title: Open in catalog
icon: catalog
entityRef: ${{ steps['register'].output.entityRef }} # link to the entity that has been ingested to the catalog
+ text:
+ - title: More information
+ content: |
+ **Entity URL:** `${{ steps['publish'].output.remoteUrl }}`
```
## The templating syntax
diff --git a/plugins/scaffolder-common/src/Template.v1beta3.schema.json b/plugins/scaffolder-common/src/Template.v1beta3.schema.json
index beb0ca5e31..1de68205b2 100644
--- a/plugins/scaffolder-common/src/Template.v1beta3.schema.json
+++ b/plugins/scaffolder-common/src/Template.v1beta3.schema.json
@@ -218,6 +218,33 @@
}
}
}
+ },
+ "text": {
+ "type": "array",
+ "description": "A list of Markdown text blobs, like output data from the template.",
+ "items": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "A user friendly display name for the text.",
+ "examples": ["Output Content"],
+ "minLength": 1
+ },
+ "icon": {
+ "type": "string",
+ "description": "A key representing a visual icon to be displayed in the UI.",
+ "examples": ["dashboard"],
+ "minLength": 1
+ },
+ "content": {
+ "type": "string",
+ "description": "The text blob to display in the UI, rendered as Markdown.",
+ "examples": ["**hey** _I'm_ Markdown"]
+ }
+ }
+ }
}
},
"additionalProperties": {
diff --git a/plugins/scaffolder-react/api-report.md b/plugins/scaffolder-react/api-report.md
index baf5afb312..1761c88edf 100644
--- a/plugins/scaffolder-react/api-report.md
+++ b/plugins/scaffolder-react/api-report.md
@@ -217,6 +217,13 @@ export type ScaffolderOutputLink = {
entityRef?: string;
};
+// @public (undocumented)
+export type ScaffolderOutputText = {
+ title?: string;
+ icon?: string;
+ content?: string;
+};
+
// @public
export interface ScaffolderScaffoldOptions {
// (undocumented)
@@ -261,6 +268,7 @@ export type ScaffolderTask = {
// @public (undocumented)
export type ScaffolderTaskOutput = {
links?: ScaffolderOutputLink[];
+ text?: ScaffolderOutputText[];
} & {
[key: string]: unknown;
};
diff --git a/plugins/scaffolder-react/src/api/types.ts b/plugins/scaffolder-react/src/api/types.ts
index b39555c14f..2adecdd0d2 100644
--- a/plugins/scaffolder-react/src/api/types.ts
+++ b/plugins/scaffolder-react/src/api/types.ts
@@ -84,9 +84,17 @@ export type ScaffolderOutputLink = {
entityRef?: string;
};
+/** @public */
+export type ScaffolderOutputText = {
+ title?: string;
+ icon?: string;
+ content?: string;
+};
+
/** @public */
export type ScaffolderTaskOutput = {
links?: ScaffolderOutputLink[];
+ text?: ScaffolderOutputText[];
} & {
[key: string]: unknown;
};
diff --git a/plugins/scaffolder-react/src/next/components/TemplateOutputs/DefaultTemplateOutputs.test.tsx b/plugins/scaffolder-react/src/next/components/TemplateOutputs/DefaultTemplateOutputs.test.tsx
new file mode 100644
index 0000000000..8366dcadf8
--- /dev/null
+++ b/plugins/scaffolder-react/src/next/components/TemplateOutputs/DefaultTemplateOutputs.test.tsx
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { entityRouteRef } from '@backstage/plugin-catalog-react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { fireEvent } from '@testing-library/react';
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { DefaultTemplateOutputs } from '.';
+
+describe('', () => {
+ it('should render template output', async () => {
+ const output = {
+ links: [{ title: 'Link 1', url: 'https://backstage.io/' }],
+ text: [{ title: 'Text 1', content: 'Hello, **world**!' }],
+ };
+
+ const { getByRole } = await renderInTestApp(
+ ,
+ {
+ mountedRoutes: {
+ '/catalog/:namespace/:kind/:name': entityRouteRef,
+ },
+ },
+ );
+
+ // test link outputs
+ for (const link of output.links ?? []) {
+ expect(
+ getByRole('button', { name: link.title }).closest('a'),
+ ).toHaveAttribute('href', link.url);
+ }
+
+ // test text outputs
+ for (const text of output.text ?? []) {
+ await act(async () => {
+ fireEvent.click(getByRole('button', { name: text.title }));
+ });
+
+ expect(getByRole('heading', { level: 2 }).innerHTML).toBe(text.title);
+ }
+ });
+});
diff --git a/plugins/scaffolder-react/src/next/components/TemplateOutputs/DefaultTemplateOutputs.tsx b/plugins/scaffolder-react/src/next/components/TemplateOutputs/DefaultTemplateOutputs.tsx
index 4d908461b0..a495e4ca9e 100644
--- a/plugins/scaffolder-react/src/next/components/TemplateOutputs/DefaultTemplateOutputs.tsx
+++ b/plugins/scaffolder-react/src/next/components/TemplateOutputs/DefaultTemplateOutputs.tsx
@@ -13,10 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import React from 'react';
-import { Box, Paper } from '@material-ui/core';
-import { LinkOutputs } from './LinkOutputs';
+import { InfoCard, MarkdownContent } from '@backstage/core-components';
import { ScaffolderTaskOutput } from '@backstage/plugin-scaffolder-react';
+import { Box, Paper } from '@material-ui/core';
+import React, { useMemo, useState } from 'react';
+import { LinkOutputs } from './LinkOutputs';
+import { TextOutputs } from './TextOutputs';
/**
* The DefaultOutputs renderer for the scaffolder task output
@@ -26,17 +28,47 @@ import { ScaffolderTaskOutput } from '@backstage/plugin-scaffolder-react';
export const DefaultTemplateOutputs = (props: {
output?: ScaffolderTaskOutput;
}) => {
- if (!props.output?.links) {
+ const [textOutputIndex, setTextOutputIndex] = useState();
+
+ const textOutput = useMemo(
+ () =>
+ textOutputIndex !== undefined
+ ? props.output?.text?.[textOutputIndex]
+ : null,
+ [props.output, textOutputIndex],
+ );
+
+ if (!props.output) {
return null;
}
return (
-
-
-
-
+ <>
+
+
+
+
+
+
+
+
+ {textOutput ? (
+
+
+
+
+
+
-
-
+ ) : null}
+ >
);
};
diff --git a/plugins/scaffolder-react/src/next/components/TemplateOutputs/TextOutputs.tsx b/plugins/scaffolder-react/src/next/components/TemplateOutputs/TextOutputs.tsx
new file mode 100644
index 0000000000..7e64603e92
--- /dev/null
+++ b/plugins/scaffolder-react/src/next/components/TemplateOutputs/TextOutputs.tsx
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { IconComponent, useApp } from '@backstage/core-plugin-api';
+import { Button } from '@material-ui/core';
+import WebIcon from '@material-ui/icons/Web';
+import React from 'react';
+import { ScaffolderTaskOutput } from '../../../api';
+
+export const TextOutputs = (props: {
+ output: ScaffolderTaskOutput;
+ index?: number;
+ setIndex?: (index: number | undefined) => void;
+}) => {
+ const {
+ output: { text = [] },
+ index,
+ setIndex,
+ } = props;
+
+ const app = useApp();
+
+ const iconResolver = (key?: string): IconComponent =>
+ app.getSystemIcon(key!) ?? WebIcon;
+
+ return (
+ <>
+ {text
+ .filter(({ content }) => content !== undefined)
+ .map(({ title, icon }, i) => {
+ const Icon = iconResolver(icon);
+ return (
+ }
+ component="div"
+ color="primary"
+ onClick={() => setIndex?.(index !== i ? i : undefined)}
+ variant={index === i ? 'outlined' : undefined}
+ >
+ {title}
+
+ );
+ })}
+ >
+ );
+};