Implement explorer page

This commit is contained in:
Oliver Sand
2021-01-27 17:52:19 +01:00
parent 806929fe2f
commit 207024e2e0
22 changed files with 311 additions and 132 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# explore
Welcome to the explore plugin!
This plugin helps to visualize the domains and in your ecosystem.
This plugin helps to visualize the domains and tools in your ecosystem.
## Getting started
+3 -2
View File
@@ -17,10 +17,10 @@
import { Entity } from '@backstage/catalog-model';
import { createDevApp } from '@backstage/dev-utils';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { plugin } from '../src/plugin';
import React from 'react';
import { ExplorePage } from '../src/extensions';
createDevApp()
.registerPlugin(plugin)
.registerApi({
api: catalogApiRef,
deps: {},
@@ -56,4 +56,5 @@ createDevApp()
},
} as CatalogApi),
})
.addPage({ element: <ExplorePage />, title: 'Explore' })
.render();
+2 -1
View File
@@ -30,8 +30,9 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.5.0",
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
"@backstage/plugin-catalog": "^0.2.12",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -0,0 +1,54 @@
/*
* 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 { DomainEntity } from '@backstage/catalog-model';
import { render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { DomainCardGrid } from './DomainCardGrid';
describe('<DomainCardGrid />', () => {
it('renders a grid of domain cards', () => {
const entities: DomainEntity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Domain',
metadata: {
name: 'playback',
},
spec: {
owner: 'guest',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Domain',
metadata: {
name: 'artists',
},
spec: {
owner: 'guest',
},
},
];
const { getByText } = render(<DomainCardGrid entities={entities} />, {
wrapper: MemoryRouter,
});
expect(getByText('artists')).toBeInTheDocument();
expect(getByText('playback')).toBeInTheDocument();
});
});
@@ -16,13 +16,13 @@
import { DomainEntity } from '@backstage/catalog-model';
import { Grid } from '@material-ui/core';
import React from 'react';
import { DomainCard } from '../DomainCard';
import { DomainCard } from '.';
type DomainExplorerProps = {
type DomainCardGridProps = {
entities: DomainEntity[];
};
export const DomainExplorer = ({ entities }: DomainExplorerProps) => (
export const DomainCardGrid = ({ entities }: DomainCardGridProps) => (
<Grid container spacing={4}>
{entities.map((e, i) => (
<Grid item xs={12} md={3} key={i}>
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { DomainCard } from './DomainCard';
export { DomainCardGrid } from './DomainCardGrid';
@@ -0,0 +1,93 @@
/*
* 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 { DomainEntity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { DomainExplorerContent } from './DomainExplorerContent';
describe('<DomainExplorerContent />', () => {
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
addLocation: jest.fn(_a => new Promise(() => {})),
getEntities: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<MemoryRouter>
<ApiProvider apis={ApiRegistry.with(catalogApiRef, catalogApi)}>
{children}
</ApiProvider>
</MemoryRouter>
);
beforeEach(() => {
jest.resetAllMocks();
});
it('renders a grid of domains', async () => {
const entities: DomainEntity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Domain',
metadata: {
name: 'playback',
},
spec: {
owner: 'guest',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Domain',
metadata: {
name: 'artists',
},
spec: {
owner: 'guest',
},
},
];
catalogApi.getEntities.mockResolvedValue({ items: entities });
const { getByText } = render(<DomainExplorerContent />, {
wrapper: Wrapper,
});
await waitFor(() => {
expect(getByText('artists')).toBeInTheDocument();
expect(getByText('playback')).toBeInTheDocument();
});
});
it('renders empty state', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
const { getByText } = render(<DomainExplorerContent />, {
wrapper: Wrapper,
});
await waitFor(() =>
expect(getByText('No domains to display')).toBeInTheDocument(),
);
});
});
@@ -26,7 +26,7 @@ import { catalogApiRef } from '@backstage/plugin-catalog';
import { Button } from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
import { DomainExplorer } from '../DomainExplorer/DomainExplorer';
import { DomainCardGrid } from '../DomainCard';
export const DomainExplorerContent = () => {
const catalogApi = useApi(catalogApiRef);
@@ -39,7 +39,7 @@ export const DomainExplorerContent = () => {
}, [catalogApi]);
return (
<Content>
<Content noPadding>
<ContentHeader title="Domains">
<SupportButton>Discover the domains in your ecosystem.</SupportButton>
</ContentHeader>
@@ -60,7 +60,7 @@ export const DomainExplorerContent = () => {
}
/>
)}
{!loading && entities && <DomainExplorer entities={entities} />}
{!loading && entities && <DomainCardGrid entities={entities} />}
</Content>
);
};
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { DomainExplorerPage } from './DomainExplorerPage';
export { DomainExplorerContent } from './DomainExplorerContent';
@@ -25,7 +25,7 @@ export const ExplorePage = () => {
<Page themeId="home">
<Header
title={`Explore the ${organizationName} ecosystem`}
subtitle="Discover available solutions in your ecosystem"
subtitle="Discover solutions available in your ecosystem"
/>
<ExploreTabs />
@@ -14,22 +14,36 @@
* limitations under the License.
*/
import { Tabs } from '@backstage/core';
import { makeStyles } from '@material-ui/core';
import React from 'react';
import { DomainExplorerContent } from '../DomainExplorerPage/DomainExplorerContent';
import { DomainExplorerContent } from '../DomainExplorerContent';
import { ToolExplorerContent } from '../ToolExplorerContent';
// TODO: Support sub routes for these tabs in the future
const useStyles = makeStyles({
layout: {
gridArea: 'pageContent',
},
});
export const ExploreTabs = () => {
const classes = useStyles();
return (
<Tabs
tabs={[
{
label: `Domains`,
content: <DomainExplorerContent />,
},
{
label: `Tools`,
content: <DomainExplorerContent />,
},
]}
/>
<div className={classes.layout}>
<Tabs
tabs={[
{
label: `Domains`,
content: <DomainExplorerContent />,
},
{
label: `Tools`,
content: <ToolExplorerContent />,
},
]}
/>
</div>
);
};
@@ -14,11 +14,10 @@
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import ExploreCard from './ExploreCard';
import { render } from '@testing-library/react';
import React from 'react';
import { ToolCard } from './ToolCard';
const minProps = {
card: {
@@ -30,20 +29,20 @@ const minProps = {
},
};
describe('<ExploreCard />', () => {
describe('<ToolCard />', () => {
it('renders without exploding', () => {
const { getByText } = render(wrapInTestApp(<ExploreCard {...minProps} />));
const { getByText } = render(wrapInTestApp(<ToolCard {...minProps} />));
expect(getByText('Explore')).toBeInTheDocument();
});
it('renders props correctly', () => {
const { getByText } = render(wrapInTestApp(<ExploreCard {...minProps} />));
const { getByText } = render(wrapInTestApp(<ToolCard {...minProps} />));
expect(getByText(minProps.card.title)).toBeInTheDocument();
expect(getByText(minProps.card.description)).toBeInTheDocument();
});
it('should link out', () => {
const rendered = render(wrapInTestApp(<ExploreCard {...minProps} />));
const rendered = render(wrapInTestApp(<ToolCard {...minProps} />));
const anchor = rendered.container.querySelector('a');
expect(anchor.href).toBe(minProps.card.url);
});
@@ -59,7 +58,7 @@ describe('<ExploreCard />', () => {
},
};
const { getByText } = render(
wrapInTestApp(<ExploreCard {...propsWithoutDescription} />),
wrapInTestApp(<ToolCard {...propsWithoutDescription} />),
);
expect(getByText('Description missing')).toBeInTheDocument();
});
@@ -74,13 +73,13 @@ describe('<ExploreCard />', () => {
},
};
const { queryByText } = render(
wrapInTestApp(<ExploreCard {...propsWithLifecycle} />),
wrapInTestApp(<ToolCard {...propsWithLifecycle} />),
);
expect(queryByText('GA')).not.toBeInTheDocument();
});
it('renders tags correctly', () => {
const { getByText } = render(wrapInTestApp(<ExploreCard {...minProps} />));
const { getByText } = render(wrapInTestApp(<ToolCard {...minProps} />));
expect(getByText(minProps.card.tags[0])).toBeInTheDocument();
expect(getByText(minProps.card.tags[1])).toBeInTheDocument();
});
@@ -14,8 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { BackstageTheme } from '@backstage/theme';
import {
Button,
Card,
@@ -23,10 +22,14 @@ import {
CardContent,
CardMedia,
Chip,
Typography,
makeStyles,
Typography,
} from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
import classNames from 'classnames';
import React from 'react';
import { CardData } from './types';
// TODO: Align styling between Domain and ToolCard
const useStyles = makeStyles<BackstageTheme>(theme => ({
card: {
@@ -65,22 +68,12 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
},
}));
export type CardData = {
title: string;
description: string;
url: string;
image: string;
tags?: string[];
lifecycle?: string;
newsTag?: string;
};
type Props = {
card: CardData;
objectFit?: 'cover' | 'contain';
};
const ExploreCard = ({ card, objectFit }: Props) => {
export const ToolCard = ({ card, objectFit }: Props) => {
const classes = useStyles();
const { title, description, url, image, lifecycle, newsTag, tags } = card;
@@ -130,5 +123,3 @@ const ExploreCard = ({ card, objectFit }: Props) => {
</Card>
);
};
export default ExploreCard;
@@ -0,0 +1,46 @@
/*
* 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 { BackstageTheme } from '@backstage/theme';
import { makeStyles } from '@material-ui/core';
import React from 'react';
import { ToolCard } from './ToolCard';
import { CardData } from './types';
const useStyles = makeStyles<BackstageTheme>(theme => ({
container: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, 296px)',
gridGap: theme.spacing(3),
marginBottom: theme.spacing(6),
},
}));
type ToolCardGridProps = {
tools: CardData[];
};
export const ToolCardGrid = ({ tools }: ToolCardGridProps) => {
const classes = useStyles();
return (
<div className={classes.container}>
{tools.map((card: CardData, ix: any) => (
<ToolCard card={card} key={ix} />
))}
</div>
);
};
@@ -13,22 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { configApiRef, Header, Page, useApi } from '@backstage/core';
import React from 'react';
import { DomainExplorerContent } from './DomainExplorerContent';
export const DomainExplorerPage = () => {
const configApi = useApi(configApiRef);
const organizationName =
configApi.getOptionalString('organization.name') ?? 'Backstage';
return (
<Page themeId="home">
<Header
title={`Explore the ${organizationName} ecosystem`}
subtitle="Discover the domains in your ecosystem"
/>
<DomainExplorerContent />
</Page>
);
};
export { ToolCard } from './ToolCard';
export { ToolCardGrid } from './ToolCardGrid';
export type { CardData } from './types';
@@ -0,0 +1,25 @@
/*
* 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 CardData = {
title: string;
description: string;
url: string;
image: string;
tags?: string[];
lifecycle?: string;
newsTag?: string;
};
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Spotify AB
* 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.
@@ -13,27 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Content, ContentHeader, SupportButton } from '@backstage/core';
import React from 'react';
import { makeStyles, Typography } from '@material-ui/core';
import {
Content,
ContentHeader,
Header,
Page,
SupportButton,
} from '@backstage/core';
import ExploreCard, { CardData } from './ExploreCard';
import { BackstageTheme } from '@backstage/theme';
import { ToolCardGrid } from '../ToolCard';
const useStyles = makeStyles<BackstageTheme>(theme => ({
container: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, 296px)',
gridGap: theme.spacing(3),
marginBottom: theme.spacing(6),
},
}));
// TODO: Provide API to configure the toolsCards
const toolsCards = [
{
@@ -113,27 +97,13 @@ const toolsCards = [
},
];
export const ExplorePluginPage = () => {
const classes = useStyles();
export const ToolExplorerContent = () => {
return (
<Page themeId="home">
<Header
title="Explore"
subtitle="Tools and services available in Backstage"
/>
<Content>
<ContentHeader title="Tools">
<SupportButton>
<Typography>Explore tools available in Backstage</Typography>
</SupportButton>
</ContentHeader>
<div className={classes.container}>
{toolsCards.map((card: CardData, ix: any) => (
<ExploreCard card={card} key={ix} />
))}
</div>
</Content>
</Page>
<Content noPadding>
<ContentHeader title="Tools">
<SupportButton>Discover the tools in your ecosystem.</SupportButton>
</ContentHeader>
<ToolCardGrid tools={toolsCards} />
</Content>
);
};
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { DomainExplorer } from './DomainExplorer';
export { ToolExplorerContent } from './ToolExplorerContent';
+2 -2
View File
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { plugin } from './plugin';
import { explorePlugin } from './plugin';
describe('explore', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
expect(explorePlugin).toBeDefined();
});
});
-1
View File
@@ -20,6 +20,5 @@ const NoIcon = () => null;
export const exploreRouteRef = createRouteRef({
icon: NoIcon,
path: '',
title: 'Explore',
});