Merge branch 'master' of https://github.com/spotify/backstage into lintMod

This commit is contained in:
Debajyoti Halder
2021-02-03 21:22:08 +05:30
56 changed files with 1394 additions and 226 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/core': patch
'@backstage/plugin-techdocs': patch
---
Add `href` in addition to `onClick` to `ItemCard`. Ensure that the height of a
`ItemCard` with and without tags is equal.
+15
View File
@@ -0,0 +1,15 @@
---
'@backstage/core': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-explore': patch
---
Introduce `TabbedLayout` for creating tabs that are routed.
```typescript
<TabbedLayout>
<TabbedLayout.Route path="/example" title="Example tab">
<div>This is rendered under /example/anything-here route</div>
</TabbedLayout.Route>
</TabbedLayout>
```
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-explore': patch
---
Rework the explore plugin to allow the user to explore things in the ecosystem,
including tools and domains.
+15 -14
View File
@@ -15,30 +15,30 @@
*/
import {
createApp,
AlertDisplay,
OAuthRequestDialog,
SignInPage,
createApp,
createRouteRef,
FlatRoutes,
OAuthRequestDialog,
SignInPage,
} from '@backstage/core';
import React from 'react';
import Root from './components/Root';
import * as plugins from './plugins';
import { apis } from './apis';
import { hot } from 'react-hot-loader/root';
import { providers } from './identityProviders';
import { Router as CatalogRouter } from '@backstage/plugin-catalog';
import { Router as DocsRouter } from '@backstage/plugin-techdocs';
import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
import { ExplorePage } from '@backstage/plugin-explore';
import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql';
import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse';
import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component';
import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
import { Router as DocsRouter } from '@backstage/plugin-techdocs';
import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
import { Route, Navigate } from 'react-router';
import React from 'react';
import { hot } from 'react-hot-loader/root';
import { Navigate, Route } from 'react-router';
import { apis } from './apis';
import { EntityPage } from './components/catalog/EntityPage';
import Root from './components/Root';
import { providers } from './identityProviders';
import * as plugins from './plugins';
const app = createApp({
apis,
@@ -78,6 +78,7 @@ const routes = (
element={<CatalogRouter EntityPage={EntityPage} />}
/>
<Route path="/docs" element={<DocsRouter />} />
<Route path="/explore" element={<ExplorePage />} />
<Route
path="/tech-radar"
element={<TechRadarRouter width={1500} height={800} />}
@@ -20,6 +20,7 @@ import HomeIcon from '@material-ui/icons/Home';
import ExtensionIcon from '@material-ui/icons/Extension';
import RuleIcon from '@material-ui/icons/AssignmentTurnedIn';
import MapIcon from '@material-ui/icons/MyLocation';
import LayersIcon from '@material-ui/icons/Layers';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import MoneyIcon from '@material-ui/icons/MonetizationOn';
@@ -82,6 +83,7 @@ const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarItem icon={HomeIcon} to="/catalog" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
+1 -1
View File
@@ -18,7 +18,7 @@ export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';
export { plugin as CatalogPlugin } from '@backstage/plugin-catalog';
export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder';
export { plugin as TechRadar } from '@backstage/plugin-tech-radar';
export { plugin as Explore } from '@backstage/plugin-explore';
export { explorePlugin } from '@backstage/plugin-explore';
export { plugin as Circleci } from '@backstage/plugin-circleci';
export { plugin as RegisterComponent } from '@backstage/plugin-register-component';
export { plugin as Sentry } from '@backstage/plugin-sentry';
@@ -13,12 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { TabbedLayout } from './TabbedLayout';
import { renderInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Routes, Route } from 'react-router';
import { Route, Routes } from 'react-router';
import { RoutedTabs } from './RoutedTabs';
const testRoute1 = {
path: '',
@@ -31,10 +31,10 @@ const testRoute2 = {
children: <div>tabbed-test-content-2</div>,
};
describe('TabbedLayout', () => {
describe('RoutedTabs', () => {
it('renders simplest case', async () => {
const rendered = await renderInTestApp(
<TabbedLayout routes={[testRoute1]} />,
<RoutedTabs routes={[testRoute1]} />,
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
@@ -46,7 +46,7 @@ describe('TabbedLayout', () => {
<Routes>
<Route
path="/*"
element={<TabbedLayout routes={[testRoute1, testRoute2]} />}
element={<RoutedTabs routes={[testRoute1, testRoute2]} />}
/>
</Routes>,
);
@@ -70,7 +70,7 @@ describe('TabbedLayout', () => {
<Route
path="/*"
element={
<TabbedLayout
<RoutedTabs
routes={[
testRoute1,
{
@@ -122,7 +122,7 @@ describe('TabbedLayout', () => {
it('shows only one tab contents at a time', async () => {
const rendered = await renderInTestApp(
<TabbedLayout routes={[testRoute1, testRoute2]} />,
<RoutedTabs routes={[testRoute1, testRoute2]} />,
{ routeEntries: ['/some-other-path'] },
);
@@ -135,7 +135,7 @@ describe('TabbedLayout', () => {
it('redirects to the top level when no route is matching the url', async () => {
const rendered = await renderInTestApp(
<TabbedLayout routes={[testRoute1, testRoute2]} />,
<RoutedTabs routes={[testRoute1, testRoute2]} />,
{ routeEntries: ['/non-existing-path'] },
);
@@ -14,9 +14,9 @@
* limitations under the License.
*/
import React, { useMemo } from 'react';
import { useParams, useNavigate, matchRoutes, useRoutes } from 'react-router';
import { HeaderTabs, Content as LayoutContent } from '@backstage/core';
import { Helmet } from 'react-helmet';
import { matchRoutes, useNavigate, useParams, useRoutes } from 'react-router';
import { Content, HeaderTabs } from '../../layout';
import { SubRoute } from './types';
export function useSelectedSubRoute(
@@ -44,7 +44,7 @@ export function useSelectedSubRoute(
};
}
export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => {
export const RoutedTabs = ({ routes }: { routes: SubRoute[] }) => {
const navigate = useNavigate();
const { index, route, element } = useSelectedSubRoute(routes);
const headerTabs = useMemo(
@@ -66,10 +66,10 @@ export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => {
selectedIndex={index}
onChange={onTabChange}
/>
<LayoutContent>
<Content>
<Helmet title={route.title} />
{element}
</LayoutContent>
</Content>
</>
);
};
@@ -0,0 +1,44 @@
/*
* 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, { PropsWithChildren } from 'react';
import { MemoryRouter, Route, Routes } from 'react-router';
import { TabbedLayout } from './TabbedLayout';
export default {
title: 'Navigation/TabbedLayout',
component: TabbedLayout,
};
const Wrapper = ({ children }: PropsWithChildren<{}>) => (
<MemoryRouter>
<Routes>
<Route path="/*" element={<>{children}</>} />
</Routes>
</MemoryRouter>
);
export const Default = () => (
<Wrapper>
<TabbedLayout>
<TabbedLayout.Route path="/" title="tabbed-test-title">
<div>tabbed-test-content</div>
</TabbedLayout.Route>
<TabbedLayout.Route path="/some-other-path" title="tabbed-test-title-2">
<div>tabbed-test-content-2</div>
</TabbedLayout.Route>
</TabbedLayout>
</Wrapper>
);
@@ -0,0 +1,94 @@
/*
* 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 { renderInTestApp, withLogCollector } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Route, Routes } from 'react-router';
import { TabbedLayout } from './TabbedLayout';
describe('TabbedLayout', () => {
it('renders simplest case', async () => {
const { getByText } = await renderInTestApp(
<TabbedLayout>
<TabbedLayout.Route path="/" title="tabbed-test-title">
<div>tabbed-test-content</div>
</TabbedLayout.Route>
</TabbedLayout>,
);
expect(getByText('tabbed-test-title')).toBeInTheDocument();
expect(getByText('tabbed-test-content')).toBeInTheDocument();
});
it('throws if any other component is a child of TabbedLayout', async () => {
const { error } = await withLogCollector(async () => {
await expect(
renderInTestApp(
<TabbedLayout>
<TabbedLayout.Route path="/" title="tabbed-test-title">
<div>tabbed-test-content</div>
</TabbedLayout.Route>
<div>This will cause app to throw</div>
</TabbedLayout>,
),
).rejects.toThrow(/Child of TabbedLayout must be an TabbedLayout.Route/);
});
expect(error).toEqual([
expect.stringMatching(
/Child of TabbedLayout must be an TabbedLayout.Route/,
),
expect.stringMatching(
/The above error occurred in the <TabbedLayout> component/,
),
]);
});
it('navigates when user clicks different tab', async () => {
const { getByText, queryByText, queryAllByRole } = await renderInTestApp(
<Routes>
<Route
path="/*"
element={
<TabbedLayout>
<TabbedLayout.Route path="/" title="tabbed-test-title">
<div>tabbed-test-content</div>
</TabbedLayout.Route>
<TabbedLayout.Route
path="/some-other-path"
title="tabbed-test-title-2"
>
<div>tabbed-test-content-2</div>
</TabbedLayout.Route>
</TabbedLayout>
}
/>
</Routes>,
);
const secondTab = queryAllByRole('tab')[1];
act(() => {
fireEvent.click(secondTab);
});
expect(getByText('tabbed-test-title')).toBeInTheDocument();
expect(queryByText('tabbed-test-content')).not.toBeInTheDocument();
expect(getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(queryByText('tabbed-test-content-2')).toBeInTheDocument();
});
});
@@ -0,0 +1,89 @@
/*
* 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 { attachComponentData } from '@backstage/core-api';
import React, {
Children,
Fragment,
isValidElement,
PropsWithChildren,
ReactNode,
} from 'react';
import { RoutedTabs } from './RoutedTabs';
type SubRoute = {
path: string;
title: string;
children: JSX.Element;
};
const Route: (props: SubRoute) => null = () => null;
// This causes all mount points that are discovered within this route to use the path of the route itself
attachComponentData(Route, 'core.gatherMountPoints', true);
export function createSubRoutesFromChildren(
childrenProps: ReactNode,
): SubRoute[] {
// Directly comparing child.type with Route will not work with in
// combination with react-hot-loader in storybook
// https://github.com/gaearon/react-hot-loader/issues/304
const routeType = (
<Route path="" title="">
<div />
</Route>
).type;
return Children.toArray(childrenProps).flatMap(child => {
if (!isValidElement(child)) {
return [];
}
if (child.type === Fragment) {
return createSubRoutesFromChildren(child.props.children);
}
if (child.type !== routeType) {
throw new Error('Child of TabbedLayout must be an TabbedLayout.Route');
}
const { path, title, children } = child.props;
return [{ path, title, children }];
});
}
/**
* TabbedLayout is a compound component, which allows you to define a layout for
* pages using a sub-navigation mechanism.
*
* Consists of two parts: TabbedLayout and TabbedLayout.Route
*
* @example
* ```jsx
* <TabbedLayout>
* <TabbedLayout.Route path="/example" title="Example tab">
* <div>This is rendered under /example/anything-here route</div>
* </TabbedLayout.Route>
* </TabbedLayout>
* ```
*/
export const TabbedLayout = ({ children }: PropsWithChildren<{}>) => {
const routes = createSubRoutesFromChildren(children);
return <RoutedTabs routes={routes} />;
};
TabbedLayout.Route = Route;
@@ -0,0 +1,16 @@
/*
* 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 { TabbedLayout } from './TabbedLayout';
+4 -3
View File
@@ -21,10 +21,13 @@ export * from './CodeSnippet';
export * from './CopyTextButton';
export * from './DependencyGraph';
export * from './DismissableBanner';
export * from './EmptyState';
export * from './FeatureDiscovery';
export * from './HeaderIconLinkRow';
export * from './HorizontalScrollGrid';
export * from './Lifecycle';
export * from './Link';
export * from './MarkdownContent';
export * from './OAuthRequestDialog';
export * from './Progress';
export * from './ProgressBars';
@@ -32,10 +35,8 @@ export * from './SimpleStepper';
export * from './Status';
export * from './StructuredMetadataTable';
export * from './SupportButton';
export * from './TabbedLayout';
export * from './Table';
export * from './Tabs';
export * from './TrendLine';
export * from './WarningPanel';
export * from './EmptyState';
export * from './MarkdownContent';
export * from './HeaderIconLinkRow';
@@ -13,9 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { ItemCard } from '.';
import { Grid } from '@material-ui/core';
import React from 'react';
import { MemoryRouter } from 'react-router';
import { ItemCard } from '.';
export default {
title: 'Layout/Item Card',
@@ -63,5 +64,35 @@ export const Tags = () => (
label="Button"
/>
</Grid>
<Grid item xs={12} md={3}>
<ItemCard
title="Item Card"
description="This is the description of an Item Card without Tags"
label="Button"
/>
</Grid>
</Grid>
);
export const Link = () => (
<MemoryRouter>
<Grid container spacing={4}>
<Grid item xs={12} md={3}>
<ItemCard
title="Backstage"
description="This is the description of an Item Card with link"
label="Read More"
href="https://backstage.io"
/>
</Grid>
<Grid item xs={12} md={3}>
<ItemCard
title="Backstage @ GitHub"
description="This is the description of an Item Card with link"
label="Read More"
href="https://github.com/backstage/backstage"
/>
</Grid>
</Grid>
</MemoryRouter>
);
+27 -5
View File
@@ -13,8 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button, Card, Chip, makeStyles, Typography } from '@material-ui/core';
import clsx from 'clsx';
import React from 'react';
import { Button, Card, Chip, Typography, makeStyles } from '@material-ui/core';
import { Link } from '../../components';
const useStyles = makeStyles(theme => ({
header: {
@@ -30,6 +32,9 @@ const useStyles = makeStyles(theme => ({
overflow: 'hidden',
textOverflow: 'ellipsis',
},
withTags: {
height: 'calc(175px - 32px - 8px)',
},
footer: {
display: 'flex',
flexDirection: 'row-reverse',
@@ -43,7 +48,9 @@ type ItemCardProps = {
type?: string;
label: string;
onClick?: () => void;
href?: string;
};
export const ItemCard = ({
description,
tags,
@@ -51,6 +58,7 @@ export const ItemCard = ({
type,
label,
onClick,
href,
}: ItemCardProps) => {
const classes = useStyles();
@@ -64,13 +72,27 @@ export const ItemCard = ({
{tags?.map((tag, i) => (
<Chip label={tag} key={`tag-${i}`} />
))}
<Typography variant="body2" paragraph className={classes.description}>
<Typography
variant="body2"
paragraph
className={clsx(
classes.description,
tags && tags.length > 0 && classes.withTags,
)}
>
{description}
</Typography>
<div className={classes.footer}>
<Button onClick={onClick} color="primary">
{label}
</Button>
{!href && (
<Button onClick={onClick} color="primary">
{label}
</Button>
)}
{href && (
<Button component={Link} to={href} color="primary">
{label}
</Button>
)}
</div>
</div>
</Card>
@@ -22,7 +22,7 @@ import {
ApiRegistry,
} from '@backstage/core';
import { catalogApiRef, EntityContext } from '@backstage/plugin-catalog-react';
import { renderInTestApp, withLogCollector } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
@@ -59,34 +59,11 @@ describe('EntityLayout', () => {
</ApiProvider>,
);
expect(rendered.getByText('my-entity')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
});
it('throws if any other component is a child of TabbedLayout', async () => {
const { error } = await withLogCollector(async () => {
await expect(
renderInTestApp(
<EntityLayout>
<EntityLayout.Route path="/" title="tabbed-test-title">
<div>tabbed-test-content</div>
</EntityLayout.Route>
<div>This will cause app to throw</div>
</EntityLayout>,
),
).rejects.toThrow(/Child of EntityLayout must be an EntityLayout.Route/);
});
expect(error).toEqual([
expect.stringMatching(
/Child of EntityLayout must be an EntityLayout.Route/,
),
expect.stringMatching(
/The above error occurred in the <EntityLayout> component/,
),
]);
});
it('navigates when user clicks different tab', async () => {
const rendered = await renderInTestApp(
<Routes>
@@ -16,12 +16,12 @@
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import {
attachComponentData,
Content,
Header,
HeaderLabel,
Page,
Progress,
TabbedLayout,
} from '@backstage/core';
import {
EntityContext,
@@ -29,12 +29,9 @@ import {
} from '@backstage/plugin-catalog-react';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, {
Children,
Fragment,
isValidElement,
import {
default as React,
PropsWithChildren,
ReactNode,
useContext,
useState,
} from 'react';
@@ -42,39 +39,6 @@ import { useNavigate } from 'react-router';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
import { TabbedLayout } from './TabbedLayout';
type SubRoute = {
path: string;
title: string;
children: JSX.Element;
};
const Route: (props: SubRoute) => null = () => null;
// This causes all mount points that are discovered within this route to use the path of the route itself
attachComponentData(Route, 'core.gatherMountPoints', true);
export function createSubRoutesFromChildren(
childrenNode: ReactNode,
): SubRoute[] {
return Children.toArray(childrenNode).flatMap(child => {
if (!isValidElement(child)) {
return [];
}
if (child.type === Fragment) {
return createSubRoutesFromChildren(child.props.children);
}
if (child.type !== Route) {
throw new Error('Child of EntityLayout must be an EntityLayout.Route');
}
const { path, title, children } = child.props;
return [{ path, title, children }];
});
}
const EntityLayoutTitle = ({
entity,
@@ -134,7 +98,6 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => {
const { kind, namespace, name } = useEntityCompoundName();
const { entity, loading, error } = useContext(EntityContext);
const routes = createSubRoutesFromChildren(children);
const { headerTitle, headerType } = headerProps(
kind,
namespace,
@@ -176,7 +139,7 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => {
{loading && <Progress />}
{entity && <TabbedLayout routes={routes} />}
{entity && <TabbedLayout>{children}</TabbedLayout>}
{error && (
<Content>
@@ -193,4 +156,4 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => {
);
};
EntityLayout.Route = Route;
EntityLayout.Route = TabbedLayout.Route;
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+4
View File
@@ -0,0 +1,4 @@
# explore-react
This package provides helpers to the `explore` plugin that can be imported by
any other plugin or app.
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@backstage/plugin-explore-react",
"version": "0.0.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/explore-react"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"clean": "backstage-cli clean",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack"
},
"dependencies": {
"@backstage/core": "^0.5.0"
},
"devDependencies": {
"@backstage/cli": "^0.5.0",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
+17
View File
@@ -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 * from './tools';
+17
View File
@@ -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.
*/
import '@testing-library/jest-dom';
@@ -0,0 +1,23 @@
/*
* 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 { exploreToolsConfigRef } from './api';
describe('api', () => {
// This is a dummy test to have at least one test in the package.
it('api ref exists', () => {
expect(exploreToolsConfigRef.id).toBe('plugin.explore.toolsconfig');
});
});
+36
View File
@@ -0,0 +1,36 @@
/*
* 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 { createApiRef } from '@backstage/core';
export const exploreToolsConfigRef = createApiRef<ExploreToolsConfig>({
id: 'plugin.explore.toolsconfig',
description: 'Used to configure tools displayed in the explore plugin',
});
export type ExploreTool = {
title: string;
description?: string;
url: string;
image: string;
tags?: string[];
lifecycle?: string;
newsTag?: string;
};
export interface ExploreToolsConfig {
getTools: () => Promise<ExploreTool[]>;
}
+18
View File
@@ -0,0 +1,18 @@
/*
* 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 { exploreToolsConfigRef } from './api';
export type { ExploreTool, ExploreToolsConfig } from './api';
+28 -3
View File
@@ -1,7 +1,32 @@
# Title
# explore
Welcome to the explore plugin!
This plugin helps to visualize the domains and tools in your ecosystem.
## Sub-section 1
## Getting started
## Sub-section 2
To install the plugin, include the following import your `plugins.ts`:
```typescript
export { explorePlugin } from '@backstage/plugin-explore';
```
Register the route in `App.tsx`:
```typescript
import { ExplorePage } from '@backstage/plugin-explore';
...
<Route path="/explore" element={<ExplorePage />} />
```
Add a link to the sidebar in `Root.tsx`:
```typescript
import LayersIcon from '@material-ui/icons/Layers';
...
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
```
+54 -2
View File
@@ -14,7 +14,59 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { exploreToolsConfigRef } from '@backstage/plugin-explore-react';
import React from 'react';
import { ExplorePage, explorePlugin } from '../src';
import { exampleTools } from '../src/util/examples';
createDevApp().registerPlugin(plugin).render();
createDevApp()
.registerPlugin(explorePlugin)
.registerApi({
api: exploreToolsConfigRef,
deps: {},
factory: () => ({
async getTools() {
return exampleTools;
},
}),
})
.registerApi({
api: catalogApiRef,
deps: {},
factory: () =>
({
async getEntities() {
const domainNames = [
'playback',
'artists',
'payments',
'analytics',
'songs',
'devops',
];
return {
items: domainNames.map(
(n, i) =>
({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Domain',
metadata: {
name: n,
description: `Everything about ${n}`,
tags: i % 2 === 0 ? [n] : undefined,
},
spec: {
owner: `${n}@example.com`,
},
} as Entity),
),
};
},
} as CatalogApi),
})
.addPage({ element: <ExplorePage />, title: 'Explore' })
.render();
+4 -1
View File
@@ -30,7 +30,10 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
"@backstage/plugin-catalog-react": "^0.0.1",
"@backstage/plugin-explore-react": "^0.0.1",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -38,7 +41,7 @@
"classnames": "^2.2.6",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3"
},
"devDependencies": {
@@ -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 { DomainEntity } from '@backstage/catalog-model';
import { render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { DomainCard } from './DomainCard';
describe('<DomainCard />', () => {
it('renders a domain card', () => {
const entity: DomainEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Domain',
metadata: {
name: 'artists',
description: 'Everything about artists',
tags: ['a-tag'],
},
spec: {
owner: 'guest',
},
};
const { getByText } = render(<DomainCard entity={entity} />, {
wrapper: MemoryRouter,
});
expect(getByText('artists')).toBeInTheDocument();
expect(getByText('Everything about artists')).toBeInTheDocument();
expect(getByText('a-tag')).toBeInTheDocument();
expect(getByText('Explore').parentElement).toHaveAttribute(
'href',
'/catalog/default/domain/artists',
);
});
});
@@ -0,0 +1,41 @@
/*
* 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 { DomainEntity } from '@backstage/catalog-model';
import { ItemCard } from '@backstage/core';
import {
entityRoute,
entityRouteParams,
} from '@backstage/plugin-catalog-react';
import React from 'react';
import { generatePath } from 'react-router-dom';
type DomainCardProps = {
entity: DomainEntity;
};
export const DomainCard = ({ entity }: DomainCardProps) => (
<ItemCard
title={entity.metadata.name}
description={entity.metadata.description}
tags={entity.metadata.tags}
label="Explore"
// TODO: Use useRouteRef here to generate the path
href={generatePath(
`/catalog/${entityRoute.path}`,
entityRouteParams(entity),
)}
/>
);
@@ -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();
});
});
@@ -0,0 +1,33 @@
/*
* 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 { DomainEntity } from '@backstage/catalog-model';
import { Grid } from '@material-ui/core';
import React from 'react';
import { DomainCard } from '.';
type DomainCardGridProps = {
entities: DomainEntity[];
};
export const DomainCardGrid = ({ entities }: DomainCardGridProps) => (
<Grid container spacing={4}>
{entities.map((e, i) => (
<Grid item xs={12} md={3} key={i}>
<DomainCard entity={e} />
</Grid>
))}
</Grid>
);
@@ -0,0 +1,17 @@
/*
* 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.
*/
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-react';
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(),
);
});
});
@@ -0,0 +1,67 @@
/*
* 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 { DomainEntity } from '@backstage/catalog-model';
import {
Content,
ContentHeader,
EmptyState,
Progress,
SupportButton,
useApi,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Button } from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
import { DomainCardGrid } from '../DomainCard';
export const DomainExplorerContent = () => {
const catalogApi = useApi(catalogApiRef);
const { value: entities, loading } = useAsync(async () => {
const response = await catalogApi.getEntities({
filter: { kind: 'domain' },
});
return response.items as DomainEntity[];
}, [catalogApi]);
return (
<Content noPadding>
<ContentHeader title="Domains">
<SupportButton>Discover the domains in your ecosystem.</SupportButton>
</ContentHeader>
{loading && <Progress />}
{!loading && (!entities || entities.length === 0) && (
<EmptyState
missing="info"
title="No domains to display"
description={`You haven't added any domains yet.`}
action={
<Button
variant="contained"
color="primary"
href="https://backstage.io/docs/features/software-catalog/descriptor-format#kind-domain"
>
Read more
</Button>
}
/>
)}
{!loading && entities && <DomainCardGrid entities={entities} />}
</Content>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { DomainExplorerContent } from './DomainExplorerContent';
@@ -0,0 +1,34 @@
/*
* 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 { configApiRef, Header, Page, useApi } from '@backstage/core';
import React from 'react';
import { ExploreTabs } from './ExploreTabs';
export const ExplorePage = () => {
const configApi = useApi(configApiRef);
const organizationName =
configApi.getOptionalString('organization.name') ?? 'Backstage';
return (
<Page themeId="home">
<Header
title={`Explore the ${organizationName} ecosystem`}
subtitle="Discover solutions available in your ecosystem"
/>
<ExploreTabs />
</Page>
);
};
@@ -0,0 +1,30 @@
/*
* 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 { TabbedLayout } from '@backstage/core';
import React from 'react';
import { DomainExplorerContent } from '../DomainExplorerContent';
import { ToolExplorerContent } from '../ToolExplorerContent';
export const ExploreTabs = () => (
<TabbedLayout>
<TabbedLayout.Route path="domains" title="Domains">
<DomainExplorerContent />
</TabbedLayout.Route>
<TabbedLayout.Route path="tools" title="Tools">
<ToolExplorerContent />
</TabbedLayout.Route>
</TabbedLayout>
);
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { ExplorePage } from './ExplorePage';
@@ -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,37 +29,31 @@ 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);
expect(anchor).toHaveAttribute('href', minProps.card.url);
});
it('renders default description when missing', () => {
const propsWithoutDescription = {
card: {
card: {
title: 'Title',
url: 'http://spotify.com/',
image: 'https://developer.spotify.com/assets/WebAPI_intro.png',
},
},
const card = {
title: 'Title',
url: 'http://spotify.com/',
image: 'https://developer.spotify.com/assets/WebAPI_intro.png',
};
const { getByText } = render(
wrapInTestApp(<ExploreCard {...propsWithoutDescription} />),
);
const { getByText } = render(wrapInTestApp(<ToolCard card={card} />));
expect(getByText('Description missing')).toBeInTheDocument();
});
@@ -74,13 +67,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,8 @@
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { ExploreTool } from '@backstage/plugin-explore-react';
import { BackstageTheme } from '@backstage/theme';
import {
Button,
Card,
@@ -23,10 +23,13 @@ import {
CardContent,
CardMedia,
Chip,
Typography,
makeStyles,
Typography,
} from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
import classNames from 'classnames';
import React from 'react';
// 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;
card: ExploreTool;
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 { ExploreTool } from '@backstage/plugin-explore-react';
import { BackstageTheme } from '@backstage/theme';
import { makeStyles } from '@material-ui/core';
import React from 'react';
import { ToolCard } from './ToolCard';
const useStyles = makeStyles<BackstageTheme>(theme => ({
container: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, 296px)',
gridGap: theme.spacing(3),
marginBottom: theme.spacing(6),
},
}));
type ToolCardGridProps = {
tools: ExploreTool[];
};
export const ToolCardGrid = ({ tools }: ToolCardGridProps) => {
const classes = useStyles();
return (
<div className={classes.container}>
{tools.map((card: ExploreTool, ix: any) => (
<ToolCard card={card} key={ix} />
))}
</div>
);
};
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { ToolCard } from './ToolCard';
export { ToolCardGrid } from './ToolCardGrid';
@@ -0,0 +1,94 @@
/*
* 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 { ApiProvider, ApiRegistry } from '@backstage/core';
import {
ExploreTool,
exploreToolsConfigRef,
} from '@backstage/plugin-explore-react';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ToolExplorerContent } from './ToolExplorerContent';
describe('<ToolExplorerContent />', () => {
const exploreToolsConfigApi: jest.Mocked<typeof exploreToolsConfigRef.T> = {
getTools: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<ApiProvider
apis={ApiRegistry.with(exploreToolsConfigRef, exploreToolsConfigApi)}
>
{children}
</ApiProvider>
</MemoryRouter>
</ThemeProvider>
);
beforeEach(() => {
jest.resetAllMocks();
});
it('renders a grid of tools', async () => {
const tools: ExploreTool[] = [
{
title: 'Lighthouse',
description:
"Google's Lighthouse tool is a great resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your website.",
url: '/lighthouse',
image:
'https://raw.githubusercontent.com/GoogleChrome/lighthouse/8b3d7f052b2e64dd857e741d7395647f487697e7/assets/lighthouse-logo.png',
tags: ['web', 'seo', 'accessibility', 'performance'],
},
{
title: 'Tech Radar',
description:
'Tech Radar is a list of technologies, complemented by an assessment result, called ring assignment.',
url: '/tech-radar',
image:
'https://storage.googleapis.com/wf-blogs-engineering-media/2018/09/fe13bb32-wf-tech-radar-hero-1024x597.png',
tags: ['standards', 'landscape'],
},
];
exploreToolsConfigApi.getTools.mockResolvedValue(tools);
const { getByText } = render(<ToolExplorerContent />, {
wrapper: Wrapper,
});
await waitFor(() => {
expect(getByText('Lighthouse')).toBeInTheDocument();
expect(getByText('Tech Radar')).toBeInTheDocument();
});
});
it('renders empty state', async () => {
exploreToolsConfigApi.getTools.mockResolvedValue([]);
const { getByText } = render(<ToolExplorerContent />, {
wrapper: Wrapper,
});
await waitFor(() =>
expect(getByText('No tools to display')).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,52 @@
/*
* 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 {
Content,
ContentHeader,
EmptyState,
Progress,
SupportButton,
useApi,
} from '@backstage/core';
import { exploreToolsConfigRef } from '@backstage/plugin-explore-react';
import React from 'react';
import { useAsync } from 'react-use';
import { ToolCardGrid } from '../ToolCard';
export const ToolExplorerContent = () => {
const exploreToolsConfigApi = useApi(exploreToolsConfigRef);
const { value: tools, loading } = useAsync(async () => {
return await exploreToolsConfigApi.getTools();
}, [exploreToolsConfigApi]);
return (
<Content noPadding>
<ContentHeader title="Tools">
<SupportButton>Discover the tools in your ecosystem.</SupportButton>
</ContentHeader>
{loading && <Progress />}
{!loading && (!tools || tools.length === 0) && (
<EmptyState
missing="info"
title="No tools to display"
description={`You haven't added any tools yet.`}
/>
)}
{!loading && tools && <ToolCardGrid tools={tools} />}
</Content>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { ToolExplorerContent } from './ToolExplorerContent';
+27
View File
@@ -0,0 +1,27 @@
/*
* 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 { createRoutableExtension } from '@backstage/core';
import { explorePlugin } from './plugin';
import { exploreRouteRef } from './routes';
export const ExplorePage = explorePlugin.provide(
createRoutableExtension({
component: () =>
import('./components/ExplorePage').then(m => m.ExplorePage),
mountPoint: exploreRouteRef,
}),
);
+3 -2
View File
@@ -14,5 +14,6 @@
* limitations under the License.
*/
export { plugin } from './plugin';
export { Router } from './components/Router';
export * from './extensions';
export { explorePlugin } from './plugin';
export * from './routes';
+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();
});
});
+21 -3
View File
@@ -14,9 +14,27 @@
* limitations under the License.
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import { createApiFactory, createPlugin } from '@backstage/core';
import { exploreToolsConfigRef } from '@backstage/plugin-explore-react';
import { exploreRouteRef } from './routes';
import { exampleTools } from './util/examples';
export const rootRouteRef = createRouteRef({ path: '', title: 'Explore' });
export const plugin = createPlugin({
export const explorePlugin = createPlugin({
id: 'explore',
apis: [
// Register a default for exploreToolsConfigRef, you may want to override
// the API locally in your app.
createApiFactory({
api: exploreToolsConfigRef,
deps: {},
factory: () => ({
async getTools() {
return exampleTools;
},
}),
}),
],
routes: {
explore: exploreRouteRef,
},
});
@@ -14,13 +14,11 @@
* limitations under the License.
*/
import React from 'react';
import { Route, Routes } from 'react-router';
import { ExplorePluginPage } from './ExplorePluginPage';
import { rootRouteRef } from '../plugin';
import { createRouteRef } from '@backstage/core';
export const Router = () => (
<Routes>
<Route path={`/${rootRouteRef.path}`} element={<ExplorePluginPage />} />
</Routes>
);
const NoIcon = () => null;
export const exploreRouteRef = createRouteRef({
icon: NoIcon,
title: 'Explore',
});
@@ -14,28 +14,7 @@
* limitations under the License.
*/
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';
const useStyles = makeStyles<BackstageTheme>(theme => ({
container: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, 296px)',
gridGap: theme.spacing(3),
marginBottom: theme.spacing(6),
},
}));
const toolsCards = [
export const exampleTools = [
{
title: 'New Relic',
description:
@@ -112,28 +91,3 @@ const toolsCards = [
tags: ['rollbar', 'monitoring', 'errors'],
},
];
export const ExplorePluginPage = () => {
const classes = useStyles();
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>
);
};
@@ -56,6 +56,7 @@ describe('CookieCutter Templater', () => {
dockerClient: mockDocker,
});
expect(fs.ensureDir).toBeCalledWith(path.join('tempdir', 'intermediate'));
expect(fs.writeJson).toBeCalledWith(
path.join('tempdir', 'template', 'cookiecutter.json'),
expect.objectContaining(values),
@@ -44,6 +44,7 @@ export class CookieCutter implements TemplaterBase {
}: TemplaterRunOptions): Promise<void> {
const templateDir = path.join(workspacePath, 'template');
const intermediateDir = path.join(workspacePath, 'intermediate');
await fs.ensureDir(intermediateDir);
const resultDir = path.join(workspacePath, 'result');
// First lets grab the default cookiecutter.json file
@@ -25,13 +25,12 @@ import {
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Grid } from '@material-ui/core';
import React from 'react';
import { generatePath, useNavigate } from 'react-router-dom';
import { generatePath } from 'react-router-dom';
import { useAsync } from 'react-use';
import { rootDocsRouteRef } from '../../plugin';
export const TechDocsHome = () => {
const catalogApi = useApi(catalogApiRef);
const navigate = useNavigate();
const { value, loading, error } = useAsync(async () => {
const response = await catalogApi.getEntities();
@@ -80,15 +79,11 @@ export const TechDocsHome = () => {
? value.map((entity, index: number) => (
<Grid key={index} item xs={12} sm={6} md={3}>
<ItemCard
onClick={() =>
navigate(
generatePath(rootDocsRouteRef.path, {
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
}),
)
}
href={generatePath(rootDocsRouteRef.path, {
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
})}
title={entity.metadata.name}
label="Read Docs"
description={entity.metadata.description}