diff --git a/.changeset/old-glasses-tickle.md b/.changeset/old-glasses-tickle.md
new file mode 100644
index 0000000000..55595b0609
--- /dev/null
+++ b/.changeset/old-glasses-tickle.md
@@ -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 }) => (
+
+
+
+ // or ...
+
+
+
+);
+```
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index 5474afa72c..e849667c19 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -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 }) => (
)}
+
+
+
{isGitHubAvailable(entity) && (
<>
diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml
index fbf28d7fcb..29c892274d 100644
--- a/packages/catalog-model/examples/components/artist-lookup-component.yaml
+++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml
@@ -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
diff --git a/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx
new file mode 100644
index 0000000000..d54eac788c
--- /dev/null
+++ b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx
@@ -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 => ({
+ 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(
+
+
+ ,
+ ),
+ );
+
+ 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(
+
+
+ ,
+ ),
+ );
+
+ expect(
+ queryByText(/.*No links defined for this entity.*/),
+ ).toBeInTheDocument();
+ expect(queryByText('admin dashboard')).not.toBeInTheDocument();
+ });
+});
diff --git a/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx
new file mode 100644
index 0000000000..60fb9c3cf5
--- /dev/null
+++ b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx
@@ -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 (
+
+ {!links || links.length === 0 ? (
+
+ ) : (
+ ({
+ text: title ?? url,
+ href: url,
+ Icon: iconResolver(icon),
+ }))}
+ />
+ )}
+
+ );
+};
diff --git a/plugins/catalog/src/components/EntityLinksCard/EntityLinksEmptyState.tsx b/plugins/catalog/src/components/EntityLinksCard/EntityLinksEmptyState.tsx
new file mode 100644
index 0000000000..3ced50decc
--- /dev/null
+++ b/plugins/catalog/src/components/EntityLinksCard/EntityLinksEmptyState.tsx
@@ -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(theme => ({
+ code: {
+ borderRadius: 6,
+ margin: `${theme.spacing(2)}px 0px`,
+ background: theme.palette.type === 'dark' ? '#444' : '#fff',
+ },
+}));
+
+export const EntityLinksEmptyState = () => {
+ const classes = useStyles();
+
+ return (
+ <>
+
+ No links defined for this entity. You can add links to your entity YAML
+ as shown in the highlighted example below:
+
+
+
+
+
+ >
+ );
+};
diff --git a/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx b/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx
new file mode 100644
index 0000000000..8caf37e70d
--- /dev/null
+++ b/plugins/catalog/src/components/EntityLinksCard/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/catalog/src/components/EntityLinksCard/IconLink.tsx b/plugins/catalog/src/components/EntityLinksCard/IconLink.tsx
new file mode 100644
index 0000000000..32ea085889
--- /dev/null
+++ b/plugins/catalog/src/components/EntityLinksCard/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 { 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 (
+
+
+
+ {Icon ? : }
+
+
+
+
+ {text || href}
+
+
+
+ );
+};
diff --git a/plugins/catalog/src/components/EntityLinksCard/LinksGridList.tsx b/plugins/catalog/src/components/EntityLinksCard/LinksGridList.tsx
new file mode 100644
index 0000000000..6d58c20700
--- /dev/null
+++ b/plugins/catalog/src/components/EntityLinksCard/LinksGridList.tsx
@@ -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 (
+
+
+ {items.map(({ text, href, Icon }, i) => (
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/plugins/catalog/src/components/EntityLinksCard/index.ts b/plugins/catalog/src/components/EntityLinksCard/index.ts
new file mode 100644
index 0000000000..d893d3a233
--- /dev/null
+++ b/plugins/catalog/src/components/EntityLinksCard/index.ts
@@ -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';
diff --git a/plugins/catalog/src/components/EntityLinksCard/types.ts b/plugins/catalog/src/components/EntityLinksCard/types.ts
new file mode 100644
index 0000000000..d7729fc216
--- /dev/null
+++ b/plugins/catalog/src/components/EntityLinksCard/types.ts
@@ -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;
diff --git a/plugins/catalog/src/components/EntityLinksCard/useDynamicColumns.tsx b/plugins/catalog/src/components/EntityLinksCard/useDynamicColumns.tsx
new file mode 100644
index 0000000000..d2b4168e0b
--- /dev/null
+++ b/plugins/catalog/src/components/EntityLinksCard/useDynamicColumns.tsx
@@ -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;
+}
diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts
index 3ef79680c4..1bc11f6880 100644
--- a/plugins/catalog/src/index.ts
+++ b/plugins/catalog/src/index.ts
@@ -24,4 +24,5 @@ export {
catalogPlugin as plugin,
CatalogIndexPage,
CatalogEntityPage,
+ EntityLinksCard,
} from './plugin';
diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts
index f4f121f0e6..c6944a318a 100644
--- a/plugins/catalog/src/plugin.ts
+++ b/plugins/catalog/src/plugin.ts
@@ -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),
+ },
+ }),
+);