Merge pull request #5054 from tragiclifestories/task-page-links

Support multiple links on TaskPage screen
This commit is contained in:
Patrik Oldsberg
2021-04-06 20:45:33 +02:00
committed by GitHub
10 changed files with 324 additions and 44 deletions
@@ -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(
<ThemeProvider theme={lightTheme}>
<IconLink
href="https://example.com"
text="I am Link"
Icon={CloudIcon}
/>
</ThemeProvider>,
);
expect(rendered.queryByText('I am Link')).toBeInTheDocument();
});
});
@@ -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 (
<Grid container direction="row" spacing={1}>
<Grid item>
<Typography component="div" className={classes.svgIcon}>
{Icon ? <Icon /> : <LanguageIcon />}
</Typography>
</Grid>
<Grid item>
<Link to={href} {...linkProps}>
{text || href}
</Link>
</Grid>
</Grid>
);
};
@@ -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 (
<Page themeId="home">
<Header
@@ -291,38 +290,8 @@ export const TaskPage = () => {
currentStepId={currentStepId}
onUserStepChange={setUserSelectedStepId}
/>
{(entityRef || remoteUrl) && (
<Box
px={3}
pb={3}
display="flex"
flex={1}
justifyContent="space-between"
flexDirection="row"
>
{entityRef && (
<Button
size="small"
variant="outlined"
component={Link}
to={generatePath(
`/catalog/${entityRoute.path}`,
parseEntityName(entityRef),
)}
>
Open in catalog
</Button>
)}
{remoteUrl && (
<Button
size="small"
variant="outlined"
href={remoteUrl}
>
Repo
</Button>
)}
</Box>
{output && hasLinks(output) && (
<TaskPageLinks output={output} />
)}
</Paper>
</Grid>
@@ -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(
<TaskPageLinks output={output} />,
{
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(
<TaskPageLinks output={output} />,
{
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(
<TaskPageLinks output={output} />,
{
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');
});
});
@@ -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 (
<Box px={3} pb={3}>
{links.map(({ url, title, icon }, i) => (
<IconLink
key={`output-link-${i}`}
href={url}
text={title ?? url}
Icon={iconResolver(icon)}
target="_blank"
/>
))}
</Box>
);
};
@@ -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;
+15
View File
@@ -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;
};