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 (
+
+ );
+};
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
-
-
-