feat(catalog): add entity links card component

This commit is contained in:
Andrew Thauer
2021-02-10 06:12:32 -05:00
parent 0bc14f2943
commit 1df75733e9
14 changed files with 498 additions and 5 deletions
+20
View File
@@ -0,0 +1,20 @@
---
'@backstage/plugin-catalog': patch
---
Adds an `EntityLinksCard` component to display `entity.metadata.links` on entity pages. The new component is a companion for the new [Entity Links](https://backstage.io/docs/features/software-catalog/descriptor-format#links-optional) catalog model addition.
Here is an example usage within an `EntityPage.tsx`.
```tsx
// in packages/app/src/components/catalog/EntityPage.tsx
const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
<Grid container spacing={3} alignItems="stretch">
<Grid item md={4} sm={6}>
<EntityLinksCard entity={entity} />
// or ...
<EntityLinksCard entity={entity} cols={{ md: 2, lg: 3, xl: 4 }} />
</Grid>
</Grid>
);
```
@@ -28,7 +28,11 @@ import {
ProvidedApisCard,
ProvidingComponentsCard,
} from '@backstage/plugin-api-docs';
import { AboutCard, EntityPageLayout } from '@backstage/plugin-catalog';
import {
AboutCard,
EntityLinksCard,
EntityPageLayout,
} from '@backstage/plugin-catalog';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
isPluginApplicableToEntity as isCircleCIAvailable,
@@ -177,6 +181,9 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
<PagerDutyCard entity={entity} />
</Grid>
)}
<Grid item md={4} sm={6}>
<EntityLinksCard entity={entity} />
</Grid>
<RecentCICDRunsSwitcher entity={entity} />
{isGitHubAvailable(entity) && (
<>
@@ -7,11 +7,24 @@ metadata:
- java
- data
links:
- url: https://example.com/apm/artists-lookup
title: APM
- url: https://example.com/user
title: Examples Users
icon: user
- url: https://example.com/group
title: Example Group
icon: group
- url: https://example.com/cloud
title: Link with Cloud Icon
icon: cloud
- url: https://example.com/dashboard
title: Dashboard
icon: dashboard
- url: https://example.com/logs/artists-lookup
title: Logs
- url: https://example.com/help
title: Support
icon: help
- url: https://example.com/web
title: Website
icon: web
spec:
type: service
lifecycle: experimental
@@ -0,0 +1,81 @@
/*
* 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 { Entity, EntityLink } from '@backstage/catalog-model';
import { EntityContext } from '@backstage/plugin-catalog-react';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import React from 'react';
import { EntityLinksCard } from './EntityLinksCard';
describe('EntityLinksCard', () => {
const createEntity = (links: EntityLink[] = []): Entity =>
({
metadata: {
links,
},
} as Entity);
const createLink = ({
url = 'https://dashboard.dashexample.com',
title = 'admin dashboard',
icon = undefined,
}: Partial<EntityLink> = {}): EntityLink => ({
url,
title,
icon,
});
it('should render a link', async () => {
const links: EntityLink[] = [createLink()];
const entityContextValue = {
entity: createEntity(links),
loading: false,
error: undefined,
};
const { queryByText } = await renderWithEffects(
wrapInTestApp(
<EntityContext.Provider value={entityContextValue}>
<EntityLinksCard />
</EntityContext.Provider>,
),
);
expect(queryByText('admin dashboard')).toBeInTheDocument();
expect(queryByText('derp')).not.toBeInTheDocument();
});
it('should show empty state', async () => {
const entityContextValue = {
entity: createEntity([]),
loading: false,
error: undefined,
};
const { queryByText } = await renderWithEffects(
wrapInTestApp(
<EntityContext.Provider value={entityContextValue}>
<EntityLinksCard />
</EntityContext.Provider>,
),
);
expect(
queryByText(/.*No links defined for this entity.*/),
).toBeInTheDocument();
expect(queryByText('admin dashboard')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { IconComponent, InfoCard, useApp } from '@backstage/core';
import { useEntity } from '@backstage/plugin-catalog-react';
import LanguageIcon from '@material-ui/icons/Language';
import React from 'react';
import { EntityLinksEmptyState } from './EntityLinksEmptyState';
import { LinksGridList } from './LinksGridList';
import { ColumnBreakpoints } from './types';
type Props = {
/** @deprecated The entity is now grabbed from context instead */
entity?: Entity;
cols?: ColumnBreakpoints | number;
};
export const EntityLinksCard = ({ cols = undefined }: Props) => {
const { entity } = useEntity();
const app = useApp();
// TODO: Refactor App.icons & App.getSystemIcon to support custom icons
const iconResolver = (key: string | undefined): IconComponent => {
return app.getSystemIcon(key as any) ?? LanguageIcon;
};
const links = entity?.metadata?.links;
return (
<InfoCard title="Links">
{!links || links.length === 0 ? (
<EntityLinksEmptyState />
) : (
<LinksGridList
cols={cols}
items={links.map(({ url, title, icon }) => ({
text: title ?? url,
href: url,
Icon: iconResolver(icon),
}))}
/>
)}
</InfoCard>
);
};
@@ -0,0 +1,65 @@
/*
* 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 { CodeSnippet } from '@backstage/core';
import { BackstageTheme } from '@backstage/theme';
import { Button, makeStyles, Typography } from '@material-ui/core';
import React from 'react';
const ENTITY_YAML = `metadata:
name: example
links:
- url: https://dashboard.example.com
title: My Dashboard
icon: dashboard`;
const useStyles = makeStyles<BackstageTheme>(theme => ({
code: {
borderRadius: 6,
margin: `${theme.spacing(2)}px 0px`,
background: theme.palette.type === 'dark' ? '#444' : '#fff',
},
}));
export const EntityLinksEmptyState = () => {
const classes = useStyles();
return (
<>
<Typography variant="body1">
No links defined for this entity. You can add links to your entity YAML
as shown in the highlighted example below:
</Typography>
<div className={classes.code}>
<CodeSnippet
text={ENTITY_YAML}
language="yaml"
showLineNumbers
highlightedNumbers={[3, 4, 5, 6]}
customStyle={{ background: 'inherit', fontSize: '115%' }}
/>
</div>
<Button
variant="contained"
color="primary"
target="_blank"
href="https://backstage.io/docs/features/software-catalog/descriptor-format#links-optional"
>
Read more
</Button>
</>
);
};
@@ -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 { Link, IconComponent } from '@backstage/core';
import { Grid, makeStyles, Typography } from '@material-ui/core';
import LanguageIcon from '@material-ui/icons/Language';
import React from 'react';
const useStyles = makeStyles({
svgIcon: {
display: 'inline-block',
'& svg': {
display: 'inline-block',
fontSize: 'inherit',
verticalAlign: 'baseline',
},
},
});
export const IconLink = ({
href,
text,
Icon,
}: {
href: string;
text?: string;
Icon?: IconComponent;
}) => {
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} target="_blank" rel="noopener">
{text || href}
</Link>
</Grid>
</Grid>
);
};
@@ -0,0 +1,56 @@
/*
* 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 { IconComponent } from '@backstage/core';
import { Grid, GridList, GridListTile } from '@material-ui/core';
import React from 'react';
import { IconLink } from './IconLink';
import { ColumnBreakpoints } from './types';
import { useDynamicColumns } from './useDynamicColumns';
export type LinksGridListItem = {
href: string;
text?: string;
Icon?: IconComponent;
};
type Props = {
items: LinksGridListItem[];
cols?: ColumnBreakpoints | number;
};
export const LinksGridList = ({ items, cols = undefined }: Props) => {
const numOfCols = useDynamicColumns(cols);
return (
<Grid
container
item
direction="column"
justify="space-evenly"
alignItems="flex-start"
spacing={0}
>
<GridList cellHeight="auto" cols={numOfCols}>
{items.map(({ text, href, Icon }, i) => (
<GridListTile key={i}>
<IconLink href={href} text={text ?? href} Icon={Icon} />
</GridListTile>
))}
</GridList>
</Grid>
);
};
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { EntityLinksCard } from './EntityLinksCard';
@@ -0,0 +1,19 @@
/*
* 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.
*/
export type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type ColumnBreakpoints = Record<Breakpoint, number>;
@@ -0,0 +1,49 @@
/*
* 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 { Theme, useMediaQuery } from '@material-ui/core';
import { Breakpoint, ColumnBreakpoints } from './types';
const colDefaults: ColumnBreakpoints = {
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 3,
};
export function useDynamicColumns(
cols: ColumnBreakpoints | number | undefined,
): number {
const matches: (Breakpoint | null)[] = [
useMediaQuery((theme: Theme) => theme.breakpoints.up('xl')) ? 'xl' : null,
useMediaQuery((theme: Theme) => theme.breakpoints.up('lg')) ? 'lg' : null,
useMediaQuery((theme: Theme) => theme.breakpoints.up('md')) ? 'md' : null,
useMediaQuery((theme: Theme) => theme.breakpoints.up('sm')) ? 'sm' : null,
useMediaQuery((theme: Theme) => theme.breakpoints.up('xs')) ? 'xs' : null,
];
let numOfCols = 1;
if (typeof cols === 'number') {
numOfCols = cols;
} else {
const breakpoint = matches.find(k => k !== null) ?? 'xs';
numOfCols = cols?.[breakpoint] ?? colDefaults[breakpoint];
}
return numOfCols;
}
+1
View File
@@ -24,4 +24,5 @@ export {
catalogPlugin as plugin,
CatalogIndexPage,
CatalogEntityPage,
EntityLinksCard,
} from './plugin';
+10
View File
@@ -19,6 +19,7 @@ import {
createApiFactory,
createPlugin,
discoveryApiRef,
createComponentExtension,
createRoutableExtension,
identityApiRef,
} from '@backstage/core';
@@ -65,3 +66,12 @@ export const CatalogEntityPage = catalogPlugin.provide(
mountPoint: entityRouteRef,
}),
);
export const EntityLinksCard = catalogPlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/EntityLinksCard').then(m => m.EntityLinksCard),
},
}),
);