diff --git a/.changeset/four-shrimps-flash.md b/.changeset/four-shrimps-flash.md new file mode 100644 index 0000000000..fb155bafd7 --- /dev/null +++ b/.changeset/four-shrimps-flash.md @@ -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. diff --git a/.changeset/thirty-cobras-tickle.md b/.changeset/thirty-cobras-tickle.md new file mode 100644 index 0000000000..cf43318658 --- /dev/null +++ b/.changeset/thirty-cobras-tickle.md @@ -0,0 +1,15 @@ +--- +'@backstage/core': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-explore': patch +--- + +Introduce `TabbedLayout` for creating tabs that are routed. + +```typescript + + +
This is rendered under /example/anything-here route
+
+
+``` diff --git a/.changeset/two-foxes-exercise.md b/.changeset/two-foxes-exercise.md new file mode 100644 index 0000000000..953eb83138 --- /dev/null +++ b/.changeset/two-foxes-exercise.md @@ -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. diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 1b76773979..00696736d1 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -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={} /> } /> + } /> } diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 52dd397418..80706fe459 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -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<{}>) => ( + {/* End global nav */} diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index f07be07ec1..70dd56df0d 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -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'; diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx similarity index 92% rename from plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx rename to packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx index 065265cea4..549aa55eff 100644 --- a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx +++ b/packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx @@ -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:
tabbed-test-content-2
, }; -describe('TabbedLayout', () => { +describe('RoutedTabs', () => { it('renders simplest case', async () => { const rendered = await renderInTestApp( - , + , ); expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); @@ -46,7 +46,7 @@ describe('TabbedLayout', () => { } + element={} /> , ); @@ -70,7 +70,7 @@ describe('TabbedLayout', () => { { it('shows only one tab contents at a time', async () => { const rendered = await renderInTestApp( - , + , { 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( - , + , { routeEntries: ['/non-existing-path'] }, ); diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx similarity index 89% rename from plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx rename to packages/core/src/components/TabbedLayout/RoutedTabs.tsx index a2209ba492..ffeeeb26ad 100644 --- a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx +++ b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx @@ -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} /> - + {element} - + ); }; diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx new file mode 100644 index 0000000000..bf6175a9a2 --- /dev/null +++ b/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx @@ -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<{}>) => ( + + + {children}} /> + + +); + +export const Default = () => ( + + + +
tabbed-test-content
+
+ +
tabbed-test-content-2
+
+
+
+); diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx new file mode 100644 index 0000000000..77230ab6cd --- /dev/null +++ b/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx @@ -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( + + +
tabbed-test-content
+
+
, + ); + + 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( + + +
tabbed-test-content
+
+
This will cause app to throw
+
, + ), + ).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 component/, + ), + ]); + }); + + it('navigates when user clicks different tab', async () => { + const { getByText, queryByText, queryAllByRole } = await renderInTestApp( + + + +
tabbed-test-content
+
+ +
tabbed-test-content-2
+
+
+ } + /> + , + ); + + 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(); + }); +}); diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx new file mode 100644 index 0000000000..b455df4d79 --- /dev/null +++ b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx @@ -0,0 +1,87 @@ +/* + * 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(children: 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 = ( + +
+ + ).type; + + return Children.toArray(children).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 + * + * + *
This is rendered under /example/anything-here route
+ *
+ *
+ * ``` + */ +export const TabbedLayout = ({ children }: PropsWithChildren<{}>) => { + const routes = createSubRoutesFromChildren(children); + + return ; +}; + +TabbedLayout.Route = Route; diff --git a/packages/core/src/components/TabbedLayout/index.ts b/packages/core/src/components/TabbedLayout/index.ts new file mode 100644 index 0000000000..744b56959e --- /dev/null +++ b/packages/core/src/components/TabbedLayout/index.ts @@ -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'; diff --git a/plugins/catalog/src/components/EntityLayout/types.ts b/packages/core/src/components/TabbedLayout/types.ts similarity index 100% rename from plugins/catalog/src/components/EntityLayout/types.ts rename to packages/core/src/components/TabbedLayout/types.ts diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 85ebba7a58..77eba9759a 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -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'; diff --git a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx index 75031a0765..b53fa9eb7a 100644 --- a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx @@ -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" /> + + + ); + +export const Link = () => ( + + + + + + + + + + +); diff --git a/packages/core/src/layout/ItemCard/ItemCard.tsx b/packages/core/src/layout/ItemCard/ItemCard.tsx index 991a05708f..4242cd6381 100644 --- a/packages/core/src/layout/ItemCard/ItemCard.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.tsx @@ -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) => ( ))} - + 0 && classes.withTags, + )} + > {description}
- + {!href && ( + + )} + {href && ( + + )}
diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 0d8ae56262..7356ccc27b 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -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', () => { , ); + 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( - - -
tabbed-test-content
-
-
This will cause app to throw
-
, - ), - ).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 component/, - ), - ]); - }); - it('navigates when user clicks different tab', async () => { const rendered = await renderInTestApp( diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 4f7cb09cb6..c4cd4c5c71 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -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,37 +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(children: ReactNode): SubRoute[] { - return Children.toArray(children).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, @@ -132,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, @@ -174,7 +139,7 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => { {loading && } - {entity && } + {entity && {children}} {error && ( @@ -191,4 +156,4 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => { ); }; -EntityLayout.Route = Route; +EntityLayout.Route = TabbedLayout.Route; diff --git a/plugins/explore-react/.eslintrc.js b/plugins/explore-react/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/explore-react/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/explore-react/README.md b/plugins/explore-react/README.md new file mode 100644 index 0000000000..4a881955df --- /dev/null +++ b/plugins/explore-react/README.md @@ -0,0 +1,4 @@ +# explore-react + +This package provides helpers to the `explore` plugin that can be imported by +any other plugin or app. diff --git a/plugins/explore-react/package.json b/plugins/explore-react/package.json new file mode 100644 index 0000000000..80d3d9d21e --- /dev/null +++ b/plugins/explore-react/package.json @@ -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" + ] +} diff --git a/plugins/explore-react/src/index.ts b/plugins/explore-react/src/index.ts new file mode 100644 index 0000000000..d46f5d4f27 --- /dev/null +++ b/plugins/explore-react/src/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 * from './tools'; diff --git a/plugins/explore-react/src/setupTests.ts b/plugins/explore-react/src/setupTests.ts new file mode 100644 index 0000000000..825bcd4115 --- /dev/null +++ b/plugins/explore-react/src/setupTests.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. + */ + +import '@testing-library/jest-dom'; diff --git a/plugins/explore-react/src/tools/api.test.ts b/plugins/explore-react/src/tools/api.test.ts new file mode 100644 index 0000000000..386d66ead2 --- /dev/null +++ b/plugins/explore-react/src/tools/api.test.ts @@ -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'); + }); +}); diff --git a/plugins/explore-react/src/tools/api.ts b/plugins/explore-react/src/tools/api.ts new file mode 100644 index 0000000000..5d96033546 --- /dev/null +++ b/plugins/explore-react/src/tools/api.ts @@ -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({ + 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; +} diff --git a/plugins/explore-react/src/tools/index.ts b/plugins/explore-react/src/tools/index.ts new file mode 100644 index 0000000000..7f727881ca --- /dev/null +++ b/plugins/explore-react/src/tools/index.ts @@ -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'; diff --git a/plugins/explore/README.md b/plugins/explore/README.md index d5453e594c..ec00cb4dee 100644 --- a/plugins/explore/README.md +++ b/plugins/explore/README.md @@ -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'; + +... + +} /> +``` + +Add a link to the sidebar in `Root.tsx`: + +```typescript +import LayersIcon from '@material-ui/icons/Layers'; + +... + + +``` diff --git a/plugins/explore/dev/index.tsx b/plugins/explore/dev/index.tsx index 812a5585d4..9a7b66a7b5 100644 --- a/plugins/explore/dev/index.tsx +++ b/plugins/explore/dev/index.tsx @@ -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: , title: 'Explore' }) + .render(); diff --git a/plugins/explore/package.json b/plugins/explore/package.json index c6668c9c4d..2afa686355 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -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": { diff --git a/plugins/explore/src/components/DomainCard/DomainCard.test.tsx b/plugins/explore/src/components/DomainCard/DomainCard.test.tsx new file mode 100644 index 0000000000..9d409051f6 --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCard.test.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 { 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('', () => { + 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(, { + 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', + ); + }); +}); diff --git a/plugins/explore/src/components/DomainCard/DomainCard.tsx b/plugins/explore/src/components/DomainCard/DomainCard.tsx new file mode 100644 index 0000000000..2c0126b94a --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCard.tsx @@ -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) => ( + +); diff --git a/plugins/explore/src/components/DomainCard/DomainCardGrid.test.tsx b/plugins/explore/src/components/DomainCard/DomainCardGrid.test.tsx new file mode 100644 index 0000000000..bad3ca581b --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCardGrid.test.tsx @@ -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('', () => { + 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(, { + wrapper: MemoryRouter, + }); + + expect(getByText('artists')).toBeInTheDocument(); + expect(getByText('playback')).toBeInTheDocument(); + }); +}); diff --git a/plugins/explore/src/components/DomainCard/DomainCardGrid.tsx b/plugins/explore/src/components/DomainCard/DomainCardGrid.tsx new file mode 100644 index 0000000000..b4a13aa39f --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCardGrid.tsx @@ -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) => ( + + {entities.map((e, i) => ( + + + + ))} + +); diff --git a/plugins/explore/src/components/DomainCard/index.ts b/plugins/explore/src/components/DomainCard/index.ts new file mode 100644 index 0000000000..6acf10800a --- /dev/null +++ b/plugins/explore/src/components/DomainCard/index.ts @@ -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'; diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx new file mode 100644 index 0000000000..4846565bdd --- /dev/null +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -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('', () => { + const catalogApi: jest.Mocked = { + 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 }) => ( + + + {children} + + + ); + + 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(, { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(getByText('artists')).toBeInTheDocument(); + expect(getByText('playback')).toBeInTheDocument(); + }); + }); + + it('renders empty state', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + + const { getByText } = render(, { + wrapper: Wrapper, + }); + + await waitFor(() => + expect(getByText('No domains to display')).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx new file mode 100644 index 0000000000..ca00c525c9 --- /dev/null +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx @@ -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 ( + + + Discover the domains in your ecosystem. + + + {loading && } + {!loading && (!entities || entities.length === 0) && ( + + Read more + + } + /> + )} + {!loading && entities && } + + ); +}; diff --git a/plugins/explore/src/components/DomainExplorerContent/index.ts b/plugins/explore/src/components/DomainExplorerContent/index.ts new file mode 100644 index 0000000000..4012332006 --- /dev/null +++ b/plugins/explore/src/components/DomainExplorerContent/index.ts @@ -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'; diff --git a/plugins/explore/src/components/ExplorePage/ExplorePage.tsx b/plugins/explore/src/components/ExplorePage/ExplorePage.tsx new file mode 100644 index 0000000000..3f9394a1ab --- /dev/null +++ b/plugins/explore/src/components/ExplorePage/ExplorePage.tsx @@ -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 ( + +
+ + + + ); +}; diff --git a/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx b/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx new file mode 100644 index 0000000000..0cf28f9f1c --- /dev/null +++ b/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx @@ -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 = () => ( + + + + + + + + +); diff --git a/plugins/explore/src/components/ExplorePage/index.ts b/plugins/explore/src/components/ExplorePage/index.ts new file mode 100644 index 0000000000..b075c410c3 --- /dev/null +++ b/plugins/explore/src/components/ExplorePage/index.ts @@ -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'; diff --git a/plugins/explore/src/components/ExploreCard.test.js b/plugins/explore/src/components/ToolCard/ToolCard.test.tsx similarity index 70% rename from plugins/explore/src/components/ExploreCard.test.js rename to plugins/explore/src/components/ToolCard/ToolCard.test.tsx index 45652015a5..b3c1737ae6 100644 --- a/plugins/explore/src/components/ExploreCard.test.js +++ b/plugins/explore/src/components/ToolCard/ToolCard.test.tsx @@ -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('', () => { +describe('', () => { it('renders without exploding', () => { - const { getByText } = render(wrapInTestApp()); + const { getByText } = render(wrapInTestApp()); expect(getByText('Explore')).toBeInTheDocument(); }); it('renders props correctly', () => { - const { getByText } = render(wrapInTestApp()); + const { getByText } = render(wrapInTestApp()); expect(getByText(minProps.card.title)).toBeInTheDocument(); expect(getByText(minProps.card.description)).toBeInTheDocument(); }); it('should link out', () => { - const rendered = render(wrapInTestApp()); + const rendered = render(wrapInTestApp()); 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(), - ); + const { getByText } = render(wrapInTestApp()); expect(getByText('Description missing')).toBeInTheDocument(); }); @@ -74,13 +67,13 @@ describe('', () => { }, }; const { queryByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); expect(queryByText('GA')).not.toBeInTheDocument(); }); it('renders tags correctly', () => { - const { getByText } = render(wrapInTestApp()); + const { getByText } = render(wrapInTestApp()); expect(getByText(minProps.card.tags[0])).toBeInTheDocument(); expect(getByText(minProps.card.tags[1])).toBeInTheDocument(); }); diff --git a/plugins/explore/src/components/ExploreCard.tsx b/plugins/explore/src/components/ToolCard/ToolCard.tsx similarity index 91% rename from plugins/explore/src/components/ExploreCard.tsx rename to plugins/explore/src/components/ToolCard/ToolCard.tsx index 8d1348c116..c6d812b098 100644 --- a/plugins/explore/src/components/ExploreCard.tsx +++ b/plugins/explore/src/components/ToolCard/ToolCard.tsx @@ -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(theme => ({ card: { @@ -65,22 +68,12 @@ const useStyles = makeStyles(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) => { ); }; - -export default ExploreCard; diff --git a/plugins/explore/src/components/ToolCard/ToolCardGrid.tsx b/plugins/explore/src/components/ToolCard/ToolCardGrid.tsx new file mode 100644 index 0000000000..5b6fbd1834 --- /dev/null +++ b/plugins/explore/src/components/ToolCard/ToolCardGrid.tsx @@ -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(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 ( +
+ {tools.map((card: ExploreTool, ix: any) => ( + + ))} +
+ ); +}; diff --git a/plugins/explore/src/components/ToolCard/index.ts b/plugins/explore/src/components/ToolCard/index.ts new file mode 100644 index 0000000000..84599aa163 --- /dev/null +++ b/plugins/explore/src/components/ToolCard/index.ts @@ -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'; diff --git a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx new file mode 100644 index 0000000000..b0e1330ff3 --- /dev/null +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx @@ -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('', () => { + const exploreToolsConfigApi: jest.Mocked = { + getTools: jest.fn(), + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + + + {children} + + + + ); + + 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(, { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(getByText('Lighthouse')).toBeInTheDocument(); + expect(getByText('Tech Radar')).toBeInTheDocument(); + }); + }); + + it('renders empty state', async () => { + exploreToolsConfigApi.getTools.mockResolvedValue([]); + + const { getByText } = render(, { + wrapper: Wrapper, + }); + + await waitFor(() => + expect(getByText('No tools to display')).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx new file mode 100644 index 0000000000..8b8b3f0a61 --- /dev/null +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx @@ -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 ( + + + Discover the tools in your ecosystem. + + + {loading && } + {!loading && (!tools || tools.length === 0) && ( + + )} + {!loading && tools && } + + ); +}; diff --git a/plugins/explore/src/components/ToolExplorerContent/index.ts b/plugins/explore/src/components/ToolExplorerContent/index.ts new file mode 100644 index 0000000000..8fb3072d46 --- /dev/null +++ b/plugins/explore/src/components/ToolExplorerContent/index.ts @@ -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'; diff --git a/plugins/explore/src/extensions.tsx b/plugins/explore/src/extensions.tsx new file mode 100644 index 0000000000..cdf43d3035 --- /dev/null +++ b/plugins/explore/src/extensions.tsx @@ -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, + }), +); diff --git a/plugins/explore/src/index.ts b/plugins/explore/src/index.ts index ff7857cacd..70a00f5bbb 100644 --- a/plugins/explore/src/index.ts +++ b/plugins/explore/src/index.ts @@ -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'; diff --git a/plugins/explore/src/plugin.test.ts b/plugins/explore/src/plugin.test.ts index d6503c038b..652a0590dc 100644 --- a/plugins/explore/src/plugin.test.ts +++ b/plugins/explore/src/plugin.test.ts @@ -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(); }); }); diff --git a/plugins/explore/src/plugin.ts b/plugins/explore/src/plugin.ts index 75ea892242..27573eaeb9 100644 --- a/plugins/explore/src/plugin.ts +++ b/plugins/explore/src/plugin.ts @@ -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, + }, }); diff --git a/plugins/explore/src/components/Router.tsx b/plugins/explore/src/routes.ts similarity index 66% rename from plugins/explore/src/components/Router.tsx rename to plugins/explore/src/routes.ts index becb2522d0..c41a53128f 100644 --- a/plugins/explore/src/components/Router.tsx +++ b/plugins/explore/src/routes.ts @@ -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 = () => ( - - } /> - -); +const NoIcon = () => null; + +export const exploreRouteRef = createRouteRef({ + icon: NoIcon, + title: 'Explore', +}); diff --git a/plugins/explore/src/components/ExplorePluginPage.tsx b/plugins/explore/src/util/examples.ts similarity index 75% rename from plugins/explore/src/components/ExplorePluginPage.tsx rename to plugins/explore/src/util/examples.ts index f3e75e0011..d0a4cdd1f4 100644 --- a/plugins/explore/src/components/ExplorePluginPage.tsx +++ b/plugins/explore/src/util/examples.ts @@ -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(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 ( - -
- - - - Explore tools available in Backstage - - -
- {toolsCards.map((card: CardData, ix: any) => ( - - ))} -
-
- - ); -}; diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index 2d8b5c7dcc..f064c35517 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -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) => ( - 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}