Merge pull request #5054 from tragiclifestories/task-page-links
Support multiple links on TaskPage screen
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user