feat(explore): customizable explore page
Signed-off-by: Andrew Thauer <athauer@wealthsimple.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
---
|
||||
'@backstage/plugin-explore': patch
|
||||
---
|
||||
|
||||
Refactors the explore plugin to be more customizable. This includes the following non-breaking changes:
|
||||
|
||||
- Introduce new `ExploreLayout` page which can be used to create a custom `ExplorePage`
|
||||
- Refactor `ExplorePage` to use a new `ExploreLayout` component
|
||||
- Exports existing `DomainExplorerContent`, `GroupsExplorerContent`, & `ToolExplorerContent` components
|
||||
- Allows `title` props to be customized
|
||||
|
||||
Create a custom explore page in `packages/app/src/components/explore/ExplorePage.tsx`.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
DomainExplorerContent,
|
||||
ExploreLayout,
|
||||
} from '@backstage/plugin-explore';
|
||||
import React from 'react';
|
||||
import { InnserSourceExplorerContent } from './InnserSourceExplorerContent';
|
||||
|
||||
export const ExplorePage = () => {
|
||||
return (
|
||||
<ExploreLayout
|
||||
title="Explore the ACME corp ecosystem"
|
||||
subtitle="Browse our ecosystem"
|
||||
>
|
||||
<ExploreLayout.Route path="domains" title="Domains">
|
||||
<DomainExplorerContent />
|
||||
</ExploreLayout.Route>
|
||||
<ExploreLayout.Route path="inner-source" title="InnerSource">
|
||||
<AcmeInnserSourceExplorerContent />
|
||||
</ExploreLayout.Route>
|
||||
</ExploreLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const explorePage = <ExplorePage />;
|
||||
```
|
||||
|
||||
Now register the new explore page in `packages/app/src/App.tsx`.
|
||||
|
||||
```diff
|
||||
+ import { explorePage } from './components/explore/ExplorePage';
|
||||
|
||||
const routes = (
|
||||
<FlatRoutes>
|
||||
- <Route path="/explore" element={<ExplorePage />} />
|
||||
+ <Route path="/explore" element={<ExplorePage />}>
|
||||
+ {explorePage}
|
||||
+ </Route>
|
||||
</FlatRoutes>
|
||||
);
|
||||
```
|
||||
@@ -33,3 +33,49 @@ import LayersIcon from '@material-ui/icons/Layers';
|
||||
|
||||
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
Create a custom explore page in `packages/app/src/components/explore/ExplorePage.tsx`.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
DomainExplorerContent,
|
||||
ExploreLayout,
|
||||
} from '@backstage/plugin-explore';
|
||||
import React from 'react';
|
||||
import { InnserSourceExplorerContent } from './InnserSourceExplorerContent';
|
||||
|
||||
export const ExplorePage = () => {
|
||||
return (
|
||||
<ExploreLayout
|
||||
title="Explore the ACME corp ecosystem"
|
||||
subtitle="Browse our ecosystem"
|
||||
>
|
||||
<ExploreLayout.Route path="domains" title="Domains">
|
||||
<DomainExplorerContent />
|
||||
</ExploreLayout.Route>
|
||||
<ExploreLayout.Route path="inner-source" title="InnerSource">
|
||||
<AcmeInnserSourceExplorerContent />
|
||||
</ExploreLayout.Route>
|
||||
</ExploreLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const explorePage = <ExplorePage />;
|
||||
```
|
||||
|
||||
Now register the new explore page in `packages/app/src/App.tsx`.
|
||||
|
||||
```diff
|
||||
+ import { explorePage } from './components/explore/ExplorePage';
|
||||
|
||||
const routes = (
|
||||
<FlatRoutes>
|
||||
- <Route path="/explore" element={<ExplorePage />} />
|
||||
+ <Route path="/explore" element={<ExplorePage />}>
|
||||
+ {explorePage}
|
||||
+ </Route>
|
||||
</FlatRoutes>
|
||||
);
|
||||
```
|
||||
|
||||
@@ -38,9 +38,11 @@
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@types/react": "^16.9",
|
||||
"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": "^17.2.4"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor, getByText } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { DefaultExplorePage } from './DefaultExplorePage';
|
||||
|
||||
describe('<DefaultExplorePage />', () => {
|
||||
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
|
||||
addLocation: jest.fn(_a => new Promise(() => {})),
|
||||
getEntities: jest.fn(),
|
||||
getOriginLocationByEntity: jest.fn(),
|
||||
getLocationByEntity: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
removeLocationById: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
};
|
||||
|
||||
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
|
||||
<ApiProvider apis={ApiRegistry.with(catalogApiRef, catalogApi)}>
|
||||
{children}
|
||||
</ApiProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('renders the default explore page', async () => {
|
||||
catalogApi.getEntities.mockResolvedValue({ items: [] });
|
||||
|
||||
const { getAllByRole } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<DefaultExplorePage />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const elements = getAllByRole('tab');
|
||||
expect(elements.length).toBe(3);
|
||||
expect(getByText(elements[0], 'Domains')).toBeInTheDocument();
|
||||
expect(getByText(elements[1], 'Groups')).toBeInTheDocument();
|
||||
expect(getByText(elements[2], 'Tools')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import { configApiRef, useApi } from '@backstage/core';
|
||||
import { DomainExplorerContent } from '../DomainExplorerContent';
|
||||
import { ExploreLayout } from '../ExploreLayout';
|
||||
import { GroupsExplorerContent } from '../GroupsExplorerContent';
|
||||
import { ToolExplorerContent } from '../ToolExplorerContent';
|
||||
|
||||
export const DefaultExplorePage = () => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const organizationName =
|
||||
configApi.getOptionalString('organization.name') ?? 'Backstage';
|
||||
|
||||
return (
|
||||
<ExploreLayout
|
||||
title={`Explore the ${organizationName} ecosystem`}
|
||||
subtitle="Discover solutions available in your ecosystem"
|
||||
>
|
||||
<ExploreLayout.Route path="domains" title="Domains">
|
||||
<DomainExplorerContent />
|
||||
</ExploreLayout.Route>
|
||||
<ExploreLayout.Route path="groups" title="Groups">
|
||||
<GroupsExplorerContent />
|
||||
</ExploreLayout.Route>
|
||||
<ExploreLayout.Route path="tools" title="Tools">
|
||||
<ToolExplorerContent />
|
||||
</ExploreLayout.Route>
|
||||
</ExploreLayout>
|
||||
);
|
||||
};
|
||||
@@ -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 { DefaultExplorePage } from './DefaultExplorePage';
|
||||
+22
-15
@@ -41,6 +41,12 @@ describe('<DomainExplorerContent />', () => {
|
||||
</ApiProvider>
|
||||
);
|
||||
|
||||
const mountedRoutes = {
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
@@ -74,11 +80,7 @@ describe('<DomainExplorerContent />', () => {
|
||||
<Wrapper>
|
||||
<DomainExplorerContent />
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
|
||||
},
|
||||
},
|
||||
mountedRoutes,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -87,6 +89,19 @@ describe('<DomainExplorerContent />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a custom title', async () => {
|
||||
catalogApi.getEntities.mockResolvedValue({ items: [] });
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<DomainExplorerContent title="Our Areas" />
|
||||
</Wrapper>,
|
||||
mountedRoutes,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getByText('Our Areas')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders empty state', async () => {
|
||||
catalogApi.getEntities.mockResolvedValue({ items: [] });
|
||||
|
||||
@@ -94,11 +109,7 @@ describe('<DomainExplorerContent />', () => {
|
||||
<Wrapper>
|
||||
<DomainExplorerContent />
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
|
||||
},
|
||||
},
|
||||
mountedRoutes,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -114,11 +125,7 @@ describe('<DomainExplorerContent />', () => {
|
||||
<Wrapper>
|
||||
<DomainExplorerContent />
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
|
||||
},
|
||||
},
|
||||
mountedRoutes,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -79,10 +79,16 @@ const Body = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const DomainExplorerContent = () => {
|
||||
type DomainExplorerContentProps = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export const DomainExplorerContent = ({
|
||||
title,
|
||||
}: DomainExplorerContentProps) => {
|
||||
return (
|
||||
<Content noPadding>
|
||||
<ContentHeader title="Domains">
|
||||
<ContentHeader title={title ?? 'Domains'}>
|
||||
<SupportButton>Discover the domains in your ecosystem.</SupportButton>
|
||||
</ContentHeader>
|
||||
<Body />
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { ExploreLayout } from './ExploreLayout';
|
||||
|
||||
describe('<ExploreLayout />', () => {
|
||||
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('renders an explore tabbed layout page with defaults', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<ExploreLayout>
|
||||
<ExploreLayout.Route path="/tools" title="Tools">
|
||||
<div>Tools Content</div>
|
||||
</ExploreLayout.Route>
|
||||
</ExploreLayout>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Explore our ecosystem')).toBeInTheDocument();
|
||||
expect(
|
||||
getByText('Discover solutions available in our ecosystem'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a custom page title', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<ExploreLayout title="Explore our universe">
|
||||
<ExploreLayout.Route path="/tools" title="Tools">
|
||||
<div>Tools Content</div>
|
||||
</ExploreLayout.Route>
|
||||
</ExploreLayout>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByText('Explore our universe')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a custom page subtitle', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<ExploreLayout subtitle="Browse the ACME Corp ecosystem">
|
||||
<ExploreLayout.Route path="/tools" title="Tools">
|
||||
<div>Tools Content</div>
|
||||
</ExploreLayout.Route>
|
||||
</ExploreLayout>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByText('Browse the ACME Corp ecosystem')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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, Header, Page, RoutedTabs } from '@backstage/core';
|
||||
import { TabProps } from '@material-ui/core';
|
||||
import { Children, default as React, Fragment, isValidElement } from 'react';
|
||||
|
||||
// TODO: This layout could be a shared based component if it was possible to create custom TabbedLayouts
|
||||
// A generalized version of createSubRoutesFromChildren, etc. would be required
|
||||
|
||||
type SubRoute = {
|
||||
path: string;
|
||||
title: string;
|
||||
children: JSX.Element;
|
||||
tabProps?: TabProps<React.ElementType, { component?: React.ElementType }>;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
function createSubRoutesFromChildren(
|
||||
childrenProps: React.ReactNode,
|
||||
): SubRoute[] {
|
||||
// Directly comparing child.type with Route will not work with in
|
||||
// combination with react-hot-loader in storybook
|
||||
// https://github.com/gaearon/react-hot-loader/issues/304
|
||||
const routeType = (
|
||||
<Route path="" title="">
|
||||
<div />
|
||||
</Route>
|
||||
).type;
|
||||
|
||||
return Children.toArray(childrenProps).flatMap(child => {
|
||||
if (!isValidElement(child)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (child.type === Fragment) {
|
||||
return createSubRoutesFromChildren(child.props.children);
|
||||
}
|
||||
|
||||
if (child.type !== routeType) {
|
||||
throw new Error('Child of ExploreLayout must be an ExploreLayout.Route');
|
||||
}
|
||||
|
||||
const { path, title, children, tabProps } = child.props;
|
||||
return [{ path, title, children, tabProps }];
|
||||
});
|
||||
}
|
||||
|
||||
type ExploreLayoutProps = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Explore is a compound component, which allows you to define a custom layout
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ExploreLayout title="Explore ACME's ecosystem">
|
||||
* <ExploreLayout.Route path="/example" title="Example tab">
|
||||
* <div>This is rendered under /example/anything-here route</div>
|
||||
* </ExploreLayout.Route>
|
||||
* </ExploreLayout>
|
||||
* ```
|
||||
*/
|
||||
export const ExploreLayout = ({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: ExploreLayoutProps) => {
|
||||
const routes = createSubRoutesFromChildren(children);
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
title={title ?? 'Explore our ecosystem'}
|
||||
subtitle={subtitle ?? 'Discover solutions available in our ecosystem'}
|
||||
/>
|
||||
<RoutedTabs routes={routes} />
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
ExploreLayout.Route = Route;
|
||||
@@ -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 { ExploreLayout } from './ExploreLayout';
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { useOutlet } from 'react-router';
|
||||
import { ExplorePage } from './ExplorePage';
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useLocation: jest.fn().mockReturnValue({
|
||||
search: '',
|
||||
}),
|
||||
useOutlet: jest.fn().mockReturnValue('Route Children'),
|
||||
}));
|
||||
|
||||
jest.mock('../DefaultExplorePage', () => ({
|
||||
...jest.requireActual('../DefaultExplorePage'),
|
||||
DefaultExplorePage: jest.fn().mockReturnValue('DefaultExplorePageMock'),
|
||||
}));
|
||||
|
||||
describe('ExplorePage', () => {
|
||||
it('renders provided router element', async () => {
|
||||
const { getByText } = await renderInTestApp(<ExplorePage />);
|
||||
|
||||
expect(getByText('Route Children')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default explorer page when no router children are provided', async () => {
|
||||
(useOutlet as jest.Mock).mockReturnValueOnce(null);
|
||||
const { getByText } = await renderInTestApp(<ExplorePage />);
|
||||
|
||||
expect(getByText('DefaultExplorePageMock')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -13,22 +13,13 @@
|
||||
* 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';
|
||||
import { useOutlet } from 'react-router';
|
||||
import { DefaultExplorePage } from '../DefaultExplorePage';
|
||||
|
||||
export const ExplorePage = () => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const organizationName =
|
||||
configApi.getOptionalString('organization.name') ?? 'Backstage';
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
title={`Explore the ${organizationName} ecosystem`}
|
||||
subtitle="Discover solutions available in your ecosystem"
|
||||
/>
|
||||
const outlet = useOutlet();
|
||||
|
||||
<ExploreTabs />
|
||||
</Page>
|
||||
);
|
||||
return <>{outlet || <DefaultExplorePage />}</>;
|
||||
};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* 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 { GroupsExplorerContent } from '../GroupsExplorerContent';
|
||||
import { ToolExplorerContent } from '../ToolExplorerContent';
|
||||
|
||||
export const ExploreTabs = () => (
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="domains" title="Domains">
|
||||
<DomainExplorerContent />
|
||||
</TabbedLayout.Route>
|
||||
<TabbedLayout.Route path="groups" title="Groups">
|
||||
<GroupsExplorerContent />
|
||||
</TabbedLayout.Route>
|
||||
<TabbedLayout.Route path="tools" title="Tools">
|
||||
<ToolExplorerContent />
|
||||
</TabbedLayout.Route>
|
||||
</TabbedLayout>
|
||||
);
|
||||
+21
-10
@@ -40,6 +40,12 @@ describe('<GroupsExplorerContent />', () => {
|
||||
</ApiProvider>
|
||||
);
|
||||
|
||||
const mountedRoutes = {
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
@@ -69,11 +75,7 @@ describe('<GroupsExplorerContent />', () => {
|
||||
<Wrapper>
|
||||
<GroupsExplorerContent />
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
mountedRoutes,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -81,6 +83,19 @@ describe('<GroupsExplorerContent />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a custom title', async () => {
|
||||
catalogApi.getEntities.mockResolvedValue({ items: [] });
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<GroupsExplorerContent title="Our Teams" />
|
||||
</Wrapper>,
|
||||
mountedRoutes,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getByText('Our Teams')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders a friendly error if it cannot collect domains', async () => {
|
||||
const catalogError = new Error('Network timeout');
|
||||
catalogApi.getEntities.mockRejectedValueOnce(catalogError);
|
||||
@@ -89,11 +104,7 @@ describe('<GroupsExplorerContent />', () => {
|
||||
<Wrapper>
|
||||
<GroupsExplorerContent />
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
mountedRoutes,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -13,14 +13,21 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Content, ContentHeader, SupportButton } from '@backstage/core';
|
||||
import React from 'react';
|
||||
import { GroupsDiagram } from './GroupsDiagram';
|
||||
|
||||
export const GroupsExplorerContent = () => {
|
||||
type GroupsExplorerContentProps = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export const GroupsExplorerContent = ({
|
||||
title,
|
||||
}: GroupsExplorerContentProps) => {
|
||||
return (
|
||||
<Content noPadding>
|
||||
<ContentHeader title="Groups">
|
||||
<ContentHeader title={title ?? 'Groups'}>
|
||||
<SupportButton>Explore your groups.</SupportButton>
|
||||
</ContentHeader>
|
||||
|
||||
|
||||
@@ -80,6 +80,18 @@ describe('<ToolExplorerContent />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a custom title', async () => {
|
||||
exploreToolsConfigApi.getTools.mockResolvedValue([]);
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<ToolExplorerContent title="Our Tools" />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getByText('Our Tools')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders empty state', async () => {
|
||||
exploreToolsConfigApi.getTools.mockResolvedValue([]);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
@@ -61,9 +62,13 @@ const Body = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolExplorerContent = () => (
|
||||
type ToolExplorerContentProps = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export const ToolExplorerContent = ({ title }: ToolExplorerContentProps) => (
|
||||
<Content noPadding>
|
||||
<ContentHeader title="Tools">
|
||||
<ContentHeader title={title ?? 'Tools'}>
|
||||
<SupportButton>Discover the tools in your ecosystem.</SupportButton>
|
||||
</ContentHeader>
|
||||
<Body />
|
||||
|
||||
@@ -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 { ExploreLayout } from './ExploreLayout';
|
||||
@@ -14,7 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createRoutableExtension } from '@backstage/core';
|
||||
import {
|
||||
createComponentExtension,
|
||||
createRoutableExtension,
|
||||
} from '@backstage/core';
|
||||
import { explorePlugin } from './plugin';
|
||||
import { exploreRouteRef } from './routes';
|
||||
|
||||
@@ -25,3 +28,36 @@ export const ExplorePage = explorePlugin.provide(
|
||||
mountPoint: exploreRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
export const DomainExplorerContent = explorePlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/DomainExplorerContent').then(
|
||||
m => m.DomainExplorerContent,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const GroupsExplorerContent = explorePlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/GroupsExplorerContent').then(
|
||||
m => m.GroupsExplorerContent,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const ToolExplorerContent = explorePlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/ToolExplorerContent').then(
|
||||
m => m.ToolExplorerContent,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ExploreLayout } from './components';
|
||||
export * from './extensions';
|
||||
export { explorePlugin } from './plugin';
|
||||
export { explorePlugin, explorePlugin as plugin } from './plugin';
|
||||
export * from './routes';
|
||||
|
||||
Reference in New Issue
Block a user