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; +};