diff --git a/.changeset/sharp-rings-march.md b/.changeset/sharp-rings-march.md
new file mode 100644
index 0000000000..81ea9aa9df
--- /dev/null
+++ b/.changeset/sharp-rings-march.md
@@ -0,0 +1,8 @@
+---
+'@backstage/catalog-model': patch
+'@backstage/core': patch
+'@backstage/plugin-catalog': patch
+'@backstage/plugin-scaffolder': patch
+---
+
+Add support for multiple links to post-scaffold task summary page
diff --git a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json
index bd9999f8bd..ba3ccb9d1d 100644
--- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json
+++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json
@@ -135,6 +135,36 @@
"output": {
"type": "object",
"description": "A templated object describing the outputs of the scaffolding task.",
+ "properties": {
+ "links": {
+ "type": "array",
+ "description": "A list of external hyperlinks, typically pointing to resources created or updated by the template",
+ "items": {
+ "type": "object",
+ "required": ["url"],
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "A url in a standard uri format.",
+ "examples": ["https://github.com/my-org/my-new-repo"],
+ "minLength": 1
+ },
+ "title": {
+ "type": "string",
+ "description": "A user friendly display name for the link.",
+ "examples": ["View new repo"],
+ "minLength": 1
+ },
+ "icon": {
+ "type": "string",
+ "description": "A key representing a visual icon to be displayed in the UI.",
+ "examples": ["dashboard"],
+ "minLength": 1
+ }
+ }
+ }
+ }
+ },
"additionalProperties": {
"type": "string"
}
diff --git a/packages/core-api/src/icons/icons.tsx b/packages/core-api/src/icons/icons.tsx
index e638131a28..fd3d0dcdab 100644
--- a/packages/core-api/src/icons/icons.tsx
+++ b/packages/core-api/src/icons/icons.tsx
@@ -15,6 +15,7 @@
*/
import { SvgIconProps } from '@material-ui/core';
+import MuiMenuBookIcon from '@material-ui/icons/MenuBook';
import MuiBrokenImageIcon from '@material-ui/icons/BrokenImage';
import MuiChatIcon from '@material-ui/icons/Chat';
import MuiDashboardIcon from '@material-ui/icons/Dashboard';
@@ -32,6 +33,8 @@ import { IconComponent, IconComponentMap, SystemIconKey } from './types';
export const defaultSystemIcons: IconComponentMap = {
brokenImage: MuiBrokenImageIcon,
+ // To be confirmed: see https://github.com/backstage/backstage/issues/4970
+ catalog: MuiMenuBookIcon,
chat: MuiChatIcon,
dashboard: MuiDashboardIcon,
email: MuiEmailIcon,
diff --git a/plugins/scaffolder/src/components/TaskPage/IconLink.test.tsx b/plugins/scaffolder/src/components/TaskPage/IconLink.test.tsx
new file mode 100644
index 0000000000..8caf37e70d
--- /dev/null
+++ b/plugins/scaffolder/src/components/TaskPage/IconLink.test.tsx
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { lightTheme } from '@backstage/theme';
+import { ThemeProvider } from '@material-ui/core';
+import CloudIcon from '@material-ui/icons/Cloud';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { IconLink } from './IconLink';
+
+describe('IconLink', () => {
+ it('should render an icon link', () => {
+ const rendered = render(
+
+
+ ,
+ );
+
+ expect(rendered.queryByText('I am Link')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/scaffolder/src/components/TaskPage/IconLink.tsx b/plugins/scaffolder/src/components/TaskPage/IconLink.tsx
new file mode 100644
index 0000000000..9bede214c6
--- /dev/null
+++ b/plugins/scaffolder/src/components/TaskPage/IconLink.tsx
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 React from 'react';
+import { IconComponent, Link } from '@backstage/core';
+import { Grid, LinkProps, makeStyles, Typography } from '@material-ui/core';
+import LanguageIcon from '@material-ui/icons/Language';
+
+const useStyles = makeStyles({
+ svgIcon: {
+ display: 'inline-block',
+ '& svg': {
+ display: 'inline-block',
+ fontSize: 'inherit',
+ verticalAlign: 'baseline',
+ },
+ },
+});
+
+export const IconLink = (
+ props: {
+ href: string;
+ text?: string;
+ Icon?: IconComponent;
+ } & LinkProps,
+) => {
+ const { href, text, Icon, ...linkProps } = props;
+
+ const classes = useStyles();
+
+ return (
+
+
+
+ {Icon ? : }
+
+
+
+
+ {text || href}
+
+
+
+ );
+};
diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx
index 96a857dbb5..cc1149ee61 100644
--- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx
+++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx
@@ -22,28 +22,24 @@ import Step from '@material-ui/core/Step';
import StepLabel from '@material-ui/core/StepLabel';
import Grid from '@material-ui/core/Grid';
import Typography from '@material-ui/core/Typography';
-import { generatePath, useParams } from 'react-router';
+import { useParams } from 'react-router';
import { useTaskEventStream } from '../hooks/useEventStream';
import LazyLog from 'react-lazylog/build/LazyLog';
-import { Link } from 'react-router-dom';
import {
- Box,
- Button,
CircularProgress,
Paper,
StepButton,
StepIconProps,
} from '@material-ui/core';
-import { Status } from '../../types';
+import { Status, TaskOutput } from '../../types';
import { DateTime, Interval } from 'luxon';
import { useInterval } from 'react-use';
import Check from '@material-ui/icons/Check';
import Cancel from '@material-ui/icons/Cancel';
import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord';
-import { entityRoute } from '@backstage/plugin-catalog-react';
-import { parseEntityName } from '@backstage/catalog-model';
import classNames from 'classnames';
import { BackstageTheme } from '@backstage/theme';
+import { TaskPageLinks } from './TaskPageLinks';
// typings are wrong for this library, so fallback to not parsing types.
const humanizeDuration = require('humanize-duration');
@@ -211,6 +207,9 @@ const TaskLogger = memo(({ log }: { log: string }) => {
);
});
+const hasLinks = ({ entityRef, remoteUrl, links = [] }: TaskOutput): boolean =>
+ !!(entityRef || remoteUrl || links.length > 0);
+
export const TaskPage = () => {
const [userSelectedStepId, setUserSelectedStepId] = useState<
string | undefined
@@ -261,8 +260,8 @@ export const TaskPage = () => {
taskStream.loading === false &&
!taskStream.task;
- const entityRef = taskStream.output?.entityRef;
- const remoteUrl = taskStream.output?.remoteUrl;
+ const { output } = taskStream;
+
return (
{
currentStepId={currentStepId}
onUserStepChange={setUserSelectedStepId}
/>
- {(entityRef || remoteUrl) && (
-
- {entityRef && (
-
- )}
- {remoteUrl && (
-
- )}
-
+ {output && hasLinks(output) && (
+
)}
diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.test.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.test.tsx
new file mode 100644
index 0000000000..cd51488242
--- /dev/null
+++ b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.test.tsx
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 React from 'react';
+import { TaskPageLinks } from './TaskPageLinks';
+import { renderInTestApp } from '@backstage/test-utils';
+import { entityRouteRef } from '@backstage/plugin-catalog-react';
+
+describe('TaskPageLinks', () => {
+ beforeEach(() => {});
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders the entityRef link', async () => {
+ const output = { entityRef: 'Component:default/my-app' };
+ const { findByText } = await renderInTestApp(
+ ,
+ {
+ mountedRoutes: {
+ '/catalog/:namespace/:kind/:name/*': entityRouteRef,
+ },
+ },
+ );
+
+ const element = await findByText('Open in catalog');
+
+ expect(element).toBeInTheDocument();
+ expect(element).toHaveAttribute(
+ 'href',
+ '/catalog/default/Component/my-app',
+ );
+ });
+
+ it('renders the remoteUrl link', async () => {
+ const output = { remoteUrl: 'https://remote.url' };
+ const { findByText } = await renderInTestApp(
+ ,
+ {
+ mountedRoutes: {
+ '/catalog/:namespace/:kind/:name/*': entityRouteRef,
+ },
+ },
+ );
+
+ const element = await findByText('Repo');
+
+ expect(element).toBeInTheDocument();
+ expect(element).toHaveAttribute('href', 'https://remote.url');
+ });
+
+ it('renders further links', async () => {
+ const output = {
+ links: [
+ { url: 'https://first.url', title: 'Cool link 1' },
+ { url: 'https://second.url', title: 'Cool link 2' },
+ ],
+ };
+ const { findByText } = await renderInTestApp(
+ ,
+ {
+ mountedRoutes: {
+ '/catalog/:namespace/:kind/:name/*': entityRouteRef,
+ },
+ },
+ );
+
+ let element = await findByText('Cool link 1');
+
+ expect(element).toBeInTheDocument();
+ expect(element).toHaveAttribute('href', 'https://first.url');
+
+ element = await findByText('Cool link 2');
+
+ expect(element).toBeInTheDocument();
+ expect(element).toHaveAttribute('href', 'https://second.url');
+ });
+});
diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx
new file mode 100644
index 0000000000..c0b55193c2
--- /dev/null
+++ b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * 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 React from 'react';
+import { parseEntityName } from '@backstage/catalog-model';
+import { IconComponent, IconKey, useApp, useRouteRef } from '@backstage/core';
+import { IconLink } from './IconLink';
+import { entityRouteRef } from '@backstage/plugin-catalog-react';
+import { Box } from '@material-ui/core';
+import LanguageIcon from '@material-ui/icons/Language';
+import { TaskOutput } from '../../types';
+
+type TaskPageLinksProps = {
+ output: TaskOutput;
+};
+
+export const TaskPageLinks = ({ output }: TaskPageLinksProps) => {
+ const { entityRef, remoteUrl } = output;
+ let { links = [] } = output;
+ const app = useApp();
+ const entityRoute = useRouteRef(entityRouteRef);
+
+ const iconResolver = (key: IconKey | undefined): IconComponent =>
+ key ? app.getSystemIcon(key) ?? LanguageIcon : LanguageIcon;
+
+ if (remoteUrl) {
+ links = [{ url: remoteUrl, title: 'Repo' }, ...links];
+ }
+
+ if (entityRef) {
+ const entityName = parseEntityName(entityRef);
+ const target = entityRoute(entityName);
+ links = [
+ {
+ url: target,
+ title: 'Open in catalog',
+ icon: 'catalog',
+ },
+ ...links,
+ ];
+ }
+
+ return (
+
+ {links.map(({ url, title, icon }, i) => (
+
+ ))}
+
+ );
+};
diff --git a/plugins/scaffolder/src/components/hooks/useEventStream.ts b/plugins/scaffolder/src/components/hooks/useEventStream.ts
index 210fd8cb9b..4d28fabd34 100644
--- a/plugins/scaffolder/src/components/hooks/useEventStream.ts
+++ b/plugins/scaffolder/src/components/hooks/useEventStream.ts
@@ -16,7 +16,7 @@
import { useImmerReducer } from 'use-immer';
import { useEffect } from 'react';
import { scaffolderApiRef, LogEvent } from '../../api';
-import { ScaffolderTask, Status } from '../../types';
+import { ScaffolderTask, Status, TaskOutput } from '../../types';
import { Subscription, useApi } from '@backstage/core';
type Step = {
@@ -26,8 +26,6 @@ type Step = {
startedAt?: string;
};
-type TaskOutput = { entityRef?: string } & { [key in string]: string };
-
export type TaskStream = {
loading: boolean;
error?: Error;
diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts
index 6a2c2eede4..9ba480a441 100644
--- a/plugins/scaffolder/src/types.ts
+++ b/plugins/scaffolder/src/types.ts
@@ -64,3 +64,18 @@ export type ListActionsResponse = Array<{
output?: JSONSchema;
};
}>;
+
+type OutputLink = {
+ url: string;
+ title?: string;
+ icon?: string;
+};
+
+export type TaskOutput = {
+ entityRef?: string;
+ /** @deprecated use the `links` property to link out to relevant resources */
+ remoteUrl?: string;
+ links?: OutputLink[];
+} & {
+ [key: string]: unknown;
+};