Merge pull request #17641 from voximity/scaffolder-text-output

feat(scaffolder/next): Markdown text output
This commit is contained in:
Ben Lambert
2023-05-09 11:02:18 +02:00
committed by GitHub
8 changed files with 212 additions and 14 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-common': minor
'@backstage/plugin-scaffolder-react': minor
---
Add support for Markdown text blob outputs from templates
@@ -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
@@ -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": {
+8
View File
@@ -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;
};
@@ -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;
};
@@ -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('<DefaultTemplateOutputs />', () => {
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(
<DefaultTemplateOutputs output={output} />,
{
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);
}
});
});
@@ -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<number | undefined>();
const textOutput = useMemo(
() =>
textOutputIndex !== undefined
? props.output?.text?.[textOutputIndex]
: null,
[props.output, textOutputIndex],
);
if (!props.output) {
return null;
}
return (
<Box paddingBottom={2}>
<Paper>
<Box padding={2} justifyContent="center" display="flex" gridGap={16}>
<LinkOutputs output={props.output} />
<>
<Box paddingBottom={2}>
<Paper>
<Box padding={2} justifyContent="center" display="flex" gridGap={16}>
<TextOutputs
output={props.output}
index={textOutputIndex}
setIndex={setTextOutputIndex}
/>
<LinkOutputs output={props.output} />
</Box>
</Paper>
</Box>
{textOutput ? (
<Box paddingBottom={2}>
<InfoCard
title={textOutput.title ?? 'Text Output'}
noPadding
titleTypographyProps={{ component: 'h2' }}
>
<Box padding={2} height="100%">
<MarkdownContent content={textOutput.content ?? ''} />
</Box>
</InfoCard>
</Box>
</Paper>
</Box>
) : null}
</>
);
};
@@ -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 (
<Button
key={i}
startIcon={<Icon />}
component="div"
color="primary"
onClick={() => setIndex?.(index !== i ? i : undefined)}
variant={index === i ? 'outlined' : undefined}
>
{title}
</Button>
);
})}
</>
);
};