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

This commit is contained in:
Debajyoti Halder
2021-02-03 21:22:08 +05:30
56 changed files with 1394 additions and 226 deletions
@@ -22,7 +22,7 @@ import {
ApiRegistry,
} from '@backstage/core';
import { catalogApiRef, EntityContext } from '@backstage/plugin-catalog-react';
import { renderInTestApp, withLogCollector } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
@@ -59,34 +59,11 @@ describe('EntityLayout', () => {
</ApiProvider>,
);
expect(rendered.getByText('my-entity')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
});
it('throws if any other component is a child of TabbedLayout', async () => {
const { error } = await withLogCollector(async () => {
await expect(
renderInTestApp(
<EntityLayout>
<EntityLayout.Route path="/" title="tabbed-test-title">
<div>tabbed-test-content</div>
</EntityLayout.Route>
<div>This will cause app to throw</div>
</EntityLayout>,
),
).rejects.toThrow(/Child of EntityLayout must be an EntityLayout.Route/);
});
expect(error).toEqual([
expect.stringMatching(
/Child of EntityLayout must be an EntityLayout.Route/,
),
expect.stringMatching(
/The above error occurred in the <EntityLayout> component/,
),
]);
});
it('navigates when user clicks different tab', async () => {
const rendered = await renderInTestApp(
<Routes>
@@ -16,12 +16,12 @@
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import {
attachComponentData,
Content,
Header,
HeaderLabel,
Page,
Progress,
TabbedLayout,
} from '@backstage/core';
import {
EntityContext,
@@ -29,12 +29,9 @@ import {
} from '@backstage/plugin-catalog-react';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, {
Children,
Fragment,
isValidElement,
import {
default as React,
PropsWithChildren,
ReactNode,
useContext,
useState,
} from 'react';
@@ -42,39 +39,6 @@ import { useNavigate } from 'react-router';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
import { TabbedLayout } from './TabbedLayout';
type SubRoute = {
path: string;
title: string;
children: JSX.Element;
};
const Route: (props: SubRoute) => null = () => null;
// This causes all mount points that are discovered within this route to use the path of the route itself
attachComponentData(Route, 'core.gatherMountPoints', true);
export function createSubRoutesFromChildren(
childrenNode: ReactNode,
): SubRoute[] {
return Children.toArray(childrenNode).flatMap(child => {
if (!isValidElement(child)) {
return [];
}
if (child.type === Fragment) {
return createSubRoutesFromChildren(child.props.children);
}
if (child.type !== Route) {
throw new Error('Child of EntityLayout must be an EntityLayout.Route');
}
const { path, title, children } = child.props;
return [{ path, title, children }];
});
}
const EntityLayoutTitle = ({
entity,
@@ -134,7 +98,6 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => {
const { kind, namespace, name } = useEntityCompoundName();
const { entity, loading, error } = useContext(EntityContext);
const routes = createSubRoutesFromChildren(children);
const { headerTitle, headerType } = headerProps(
kind,
namespace,
@@ -176,7 +139,7 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => {
{loading && <Progress />}
{entity && <TabbedLayout routes={routes} />}
{entity && <TabbedLayout>{children}</TabbedLayout>}
{error && (
<Content>
@@ -193,4 +156,4 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => {
);
};
EntityLayout.Route = Route;
EntityLayout.Route = TabbedLayout.Route;
@@ -1,150 +0,0 @@
/*
* 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 from 'react';
import { TabbedLayout } from './TabbedLayout';
import { renderInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { Routes, Route } from 'react-router';
const testRoute1 = {
path: '',
title: 'tabbed-test-title',
children: <div>tabbed-test-content</div>,
};
const testRoute2 = {
title: 'tabbed-test-title-2',
path: '/some-other-path',
children: <div>tabbed-test-content-2</div>,
};
describe('TabbedLayout', () => {
it('renders simplest case', async () => {
const rendered = await renderInTestApp(
<TabbedLayout routes={[testRoute1]} />,
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
});
it('navigates when user clicks different tab', async () => {
const rendered = await renderInTestApp(
<Routes>
<Route
path="/*"
element={<TabbedLayout routes={[testRoute1, testRoute2]} />}
/>
</Routes>,
);
const secondTab = rendered.queryAllByRole('tab')[1];
act(() => {
fireEvent.click(secondTab);
});
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
});
describe('correctly delegates nested links', () => {
const renderRoute = (route: string) =>
renderInTestApp(
<Routes>
<Route
path="/*"
element={
<TabbedLayout
routes={[
testRoute1,
{
...testRoute2,
children: (
<div>
tabbed-test-content-2
<Routes>
<Route
path="/nested"
element={<div>tabbed-test-nested-content-2</div>}
/>
</Routes>
</div>
),
},
]}
/>
}
/>
</Routes>,
{ routeEntries: [route] },
);
it('works for nested content', async () => {
const rendered = await renderRoute('/some-other-path/nested');
expect(
rendered.queryByText('tabbed-test-content'),
).not.toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
expect(
rendered.queryByText('tabbed-test-nested-content-2'),
).toBeInTheDocument();
});
it('works for non-nested content', async () => {
const rendered = await renderRoute('/some-other-path/');
expect(
rendered.queryByText('tabbed-test-content'),
).not.toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
expect(
rendered.queryByText('tabbed-test-nested-content-2'),
).not.toBeInTheDocument();
});
});
it('shows only one tab contents at a time', async () => {
const rendered = await renderInTestApp(
<TabbedLayout routes={[testRoute1, testRoute2]} />,
{ routeEntries: ['/some-other-path'] },
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
});
it('redirects to the top level when no route is matching the url', async () => {
const rendered = await renderInTestApp(
<TabbedLayout routes={[testRoute1, testRoute2]} />,
{ routeEntries: ['/non-existing-path'] },
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(
rendered.queryByText('tabbed-test-content-2'),
).not.toBeInTheDocument();
});
});
@@ -1,75 +0,0 @@
/*
* 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, { 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 { SubRoute } from './types';
export function useSelectedSubRoute(
subRoutes: SubRoute[],
): { index: number; route: SubRoute; element: JSX.Element } {
const params = useParams();
const routes = subRoutes.map(({ path, children }) => ({
caseSensitive: false,
path: `${path}/*`,
element: children,
}));
const element = useRoutes(routes) ?? subRoutes[0].children;
const [matchedRoute] = matchRoutes(routes, `/${params['*']}`) ?? [];
const foundIndex = matchedRoute
? subRoutes.findIndex(t => `${t.path}/*` === matchedRoute.route.path)
: 0;
return {
index: foundIndex === -1 ? 0 : foundIndex,
element,
route: subRoutes[foundIndex] ?? subRoutes[0],
};
}
export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => {
const navigate = useNavigate();
const { index, route, element } = useSelectedSubRoute(routes);
const headerTabs = useMemo(
() => routes.map(t => ({ id: t.path, label: t.title })),
[routes],
);
const onTabChange = (tabIndex: number) =>
// Remove trailing /*
// And remove leading / for relative navigation
// Note! route resolves relative to the position in the React tree,
// not relative to current location
navigate(routes[tabIndex].path.replace(/\/\*$/, '').replace(/^\//, ''));
return (
<>
<HeaderTabs
tabs={headerTabs}
selectedIndex={index}
onChange={onTabChange}
/>
<LayoutContent>
<Helmet title={route.title} />
{element}
</LayoutContent>
</>
);
};
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+4
View File
@@ -0,0 +1,4 @@
# explore-react
This package provides helpers to the `explore` plugin that can be imported by
any other plugin or app.
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@backstage/plugin-explore-react",
"version": "0.0.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/explore-react"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"clean": "backstage-cli clean",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack"
},
"dependencies": {
"@backstage/core": "^0.5.0"
},
"devDependencies": {
"@backstage/cli": "^0.5.0",
"@backstage/dev-utils": "^0.1.8",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
@@ -14,8 +14,4 @@
* limitations under the License.
*/
export type SubRoute = {
path: string;
title: string;
children: JSX.Element;
};
export * from './tools';
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
@@ -0,0 +1,23 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { exploreToolsConfigRef } from './api';
describe('api', () => {
// This is a dummy test to have at least one test in the package.
it('api ref exists', () => {
expect(exploreToolsConfigRef.id).toBe('plugin.explore.toolsconfig');
});
});
+36
View File
@@ -0,0 +1,36 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/core';
export const exploreToolsConfigRef = createApiRef<ExploreToolsConfig>({
id: 'plugin.explore.toolsconfig',
description: 'Used to configure tools displayed in the explore plugin',
});
export type ExploreTool = {
title: string;
description?: string;
url: string;
image: string;
tags?: string[];
lifecycle?: string;
newsTag?: string;
};
export interface ExploreToolsConfig {
getTools: () => Promise<ExploreTool[]>;
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { exploreToolsConfigRef } from './api';
export type { ExploreTool, ExploreToolsConfig } from './api';
+28 -3
View File
@@ -1,7 +1,32 @@
# Title
# explore
Welcome to the explore plugin!
This plugin helps to visualize the domains and tools in your ecosystem.
## Sub-section 1
## Getting started
## Sub-section 2
To install the plugin, include the following import your `plugins.ts`:
```typescript
export { explorePlugin } from '@backstage/plugin-explore';
```
Register the route in `App.tsx`:
```typescript
import { ExplorePage } from '@backstage/plugin-explore';
...
<Route path="/explore" element={<ExplorePage />} />
```
Add a link to the sidebar in `Root.tsx`:
```typescript
import LayersIcon from '@material-ui/icons/Layers';
...
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
```
+54 -2
View File
@@ -14,7 +14,59 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { exploreToolsConfigRef } from '@backstage/plugin-explore-react';
import React from 'react';
import { ExplorePage, explorePlugin } from '../src';
import { exampleTools } from '../src/util/examples';
createDevApp().registerPlugin(plugin).render();
createDevApp()
.registerPlugin(explorePlugin)
.registerApi({
api: exploreToolsConfigRef,
deps: {},
factory: () => ({
async getTools() {
return exampleTools;
},
}),
})
.registerApi({
api: catalogApiRef,
deps: {},
factory: () =>
({
async getEntities() {
const domainNames = [
'playback',
'artists',
'payments',
'analytics',
'songs',
'devops',
];
return {
items: domainNames.map(
(n, i) =>
({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Domain',
metadata: {
name: n,
description: `Everything about ${n}`,
tags: i % 2 === 0 ? [n] : undefined,
},
spec: {
owner: `${n}@example.com`,
},
} as Entity),
),
};
},
} as CatalogApi),
})
.addPage({ element: <ExplorePage />, title: 'Explore' })
.render();
+4 -1
View File
@@ -30,7 +30,10 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
"@backstage/plugin-catalog-react": "^0.0.1",
"@backstage/plugin-explore-react": "^0.0.1",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -38,7 +41,7 @@
"classnames": "^2.2.6",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3"
},
"devDependencies": {
@@ -0,0 +1,49 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DomainEntity } from '@backstage/catalog-model';
import { render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { DomainCard } from './DomainCard';
describe('<DomainCard />', () => {
it('renders a domain card', () => {
const entity: DomainEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Domain',
metadata: {
name: 'artists',
description: 'Everything about artists',
tags: ['a-tag'],
},
spec: {
owner: 'guest',
},
};
const { getByText } = render(<DomainCard entity={entity} />, {
wrapper: MemoryRouter,
});
expect(getByText('artists')).toBeInTheDocument();
expect(getByText('Everything about artists')).toBeInTheDocument();
expect(getByText('a-tag')).toBeInTheDocument();
expect(getByText('Explore').parentElement).toHaveAttribute(
'href',
'/catalog/default/domain/artists',
);
});
});
@@ -0,0 +1,41 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DomainEntity } from '@backstage/catalog-model';
import { ItemCard } from '@backstage/core';
import {
entityRoute,
entityRouteParams,
} from '@backstage/plugin-catalog-react';
import React from 'react';
import { generatePath } from 'react-router-dom';
type DomainCardProps = {
entity: DomainEntity;
};
export const DomainCard = ({ entity }: DomainCardProps) => (
<ItemCard
title={entity.metadata.name}
description={entity.metadata.description}
tags={entity.metadata.tags}
label="Explore"
// TODO: Use useRouteRef here to generate the path
href={generatePath(
`/catalog/${entityRoute.path}`,
entityRouteParams(entity),
)}
/>
);
@@ -0,0 +1,54 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DomainEntity } from '@backstage/catalog-model';
import { render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { DomainCardGrid } from './DomainCardGrid';
describe('<DomainCardGrid />', () => {
it('renders a grid of domain cards', () => {
const entities: DomainEntity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Domain',
metadata: {
name: 'playback',
},
spec: {
owner: 'guest',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Domain',
metadata: {
name: 'artists',
},
spec: {
owner: 'guest',
},
},
];
const { getByText } = render(<DomainCardGrid entities={entities} />, {
wrapper: MemoryRouter,
});
expect(getByText('artists')).toBeInTheDocument();
expect(getByText('playback')).toBeInTheDocument();
});
});
@@ -0,0 +1,33 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DomainEntity } from '@backstage/catalog-model';
import { Grid } from '@material-ui/core';
import React from 'react';
import { DomainCard } from '.';
type DomainCardGridProps = {
entities: DomainEntity[];
};
export const DomainCardGrid = ({ entities }: DomainCardGridProps) => (
<Grid container spacing={4}>
{entities.map((e, i) => (
<Grid item xs={12} md={3} key={i}>
<DomainCard entity={e} />
</Grid>
))}
</Grid>
);
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { DomainCard } from './DomainCard';
export { DomainCardGrid } from './DomainCardGrid';
@@ -0,0 +1,93 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DomainEntity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { DomainExplorerContent } from './DomainExplorerContent';
describe('<DomainExplorerContent />', () => {
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
addLocation: jest.fn(_a => new Promise(() => {})),
getEntities: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<MemoryRouter>
<ApiProvider apis={ApiRegistry.with(catalogApiRef, catalogApi)}>
{children}
</ApiProvider>
</MemoryRouter>
);
beforeEach(() => {
jest.resetAllMocks();
});
it('renders a grid of domains', async () => {
const entities: DomainEntity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Domain',
metadata: {
name: 'playback',
},
spec: {
owner: 'guest',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Domain',
metadata: {
name: 'artists',
},
spec: {
owner: 'guest',
},
},
];
catalogApi.getEntities.mockResolvedValue({ items: entities });
const { getByText } = render(<DomainExplorerContent />, {
wrapper: Wrapper,
});
await waitFor(() => {
expect(getByText('artists')).toBeInTheDocument();
expect(getByText('playback')).toBeInTheDocument();
});
});
it('renders empty state', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
const { getByText } = render(<DomainExplorerContent />, {
wrapper: Wrapper,
});
await waitFor(() =>
expect(getByText('No domains to display')).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,67 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DomainEntity } from '@backstage/catalog-model';
import {
Content,
ContentHeader,
EmptyState,
Progress,
SupportButton,
useApi,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Button } from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
import { DomainCardGrid } from '../DomainCard';
export const DomainExplorerContent = () => {
const catalogApi = useApi(catalogApiRef);
const { value: entities, loading } = useAsync(async () => {
const response = await catalogApi.getEntities({
filter: { kind: 'domain' },
});
return response.items as DomainEntity[];
}, [catalogApi]);
return (
<Content noPadding>
<ContentHeader title="Domains">
<SupportButton>Discover the domains in your ecosystem.</SupportButton>
</ContentHeader>
{loading && <Progress />}
{!loading && (!entities || entities.length === 0) && (
<EmptyState
missing="info"
title="No domains to display"
description={`You haven't added any domains yet.`}
action={
<Button
variant="contained"
color="primary"
href="https://backstage.io/docs/features/software-catalog/descriptor-format#kind-domain"
>
Read more
</Button>
}
/>
)}
{!loading && entities && <DomainCardGrid entities={entities} />}
</Content>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { DomainExplorerContent } from './DomainExplorerContent';
@@ -0,0 +1,34 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { configApiRef, Header, Page, useApi } from '@backstage/core';
import React from 'react';
import { ExploreTabs } from './ExploreTabs';
export const ExplorePage = () => {
const configApi = useApi(configApiRef);
const organizationName =
configApi.getOptionalString('organization.name') ?? 'Backstage';
return (
<Page themeId="home">
<Header
title={`Explore the ${organizationName} ecosystem`}
subtitle="Discover solutions available in your ecosystem"
/>
<ExploreTabs />
</Page>
);
};
@@ -0,0 +1,30 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TabbedLayout } from '@backstage/core';
import React from 'react';
import { DomainExplorerContent } from '../DomainExplorerContent';
import { ToolExplorerContent } from '../ToolExplorerContent';
export const ExploreTabs = () => (
<TabbedLayout>
<TabbedLayout.Route path="domains" title="Domains">
<DomainExplorerContent />
</TabbedLayout.Route>
<TabbedLayout.Route path="tools" title="Tools">
<ToolExplorerContent />
</TabbedLayout.Route>
</TabbedLayout>
);
@@ -0,0 +1,16 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ExplorePage } from './ExplorePage';
@@ -14,11 +14,10 @@
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import ExploreCard from './ExploreCard';
import { render } from '@testing-library/react';
import React from 'react';
import { ToolCard } from './ToolCard';
const minProps = {
card: {
@@ -30,37 +29,31 @@ const minProps = {
},
};
describe('<ExploreCard />', () => {
describe('<ToolCard />', () => {
it('renders without exploding', () => {
const { getByText } = render(wrapInTestApp(<ExploreCard {...minProps} />));
const { getByText } = render(wrapInTestApp(<ToolCard {...minProps} />));
expect(getByText('Explore')).toBeInTheDocument();
});
it('renders props correctly', () => {
const { getByText } = render(wrapInTestApp(<ExploreCard {...minProps} />));
const { getByText } = render(wrapInTestApp(<ToolCard {...minProps} />));
expect(getByText(minProps.card.title)).toBeInTheDocument();
expect(getByText(minProps.card.description)).toBeInTheDocument();
});
it('should link out', () => {
const rendered = render(wrapInTestApp(<ExploreCard {...minProps} />));
const rendered = render(wrapInTestApp(<ToolCard {...minProps} />));
const anchor = rendered.container.querySelector('a');
expect(anchor.href).toBe(minProps.card.url);
expect(anchor).toHaveAttribute('href', minProps.card.url);
});
it('renders default description when missing', () => {
const propsWithoutDescription = {
card: {
card: {
title: 'Title',
url: 'http://spotify.com/',
image: 'https://developer.spotify.com/assets/WebAPI_intro.png',
},
},
const card = {
title: 'Title',
url: 'http://spotify.com/',
image: 'https://developer.spotify.com/assets/WebAPI_intro.png',
};
const { getByText } = render(
wrapInTestApp(<ExploreCard {...propsWithoutDescription} />),
);
const { getByText } = render(wrapInTestApp(<ToolCard card={card} />));
expect(getByText('Description missing')).toBeInTheDocument();
});
@@ -74,13 +67,13 @@ describe('<ExploreCard />', () => {
},
};
const { queryByText } = render(
wrapInTestApp(<ExploreCard {...propsWithLifecycle} />),
wrapInTestApp(<ToolCard {...propsWithLifecycle} />),
);
expect(queryByText('GA')).not.toBeInTheDocument();
});
it('renders tags correctly', () => {
const { getByText } = render(wrapInTestApp(<ExploreCard {...minProps} />));
const { getByText } = render(wrapInTestApp(<ToolCard {...minProps} />));
expect(getByText(minProps.card.tags[0])).toBeInTheDocument();
expect(getByText(minProps.card.tags[1])).toBeInTheDocument();
});
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { ExploreTool } from '@backstage/plugin-explore-react';
import { BackstageTheme } from '@backstage/theme';
import {
Button,
Card,
@@ -23,10 +23,13 @@ import {
CardContent,
CardMedia,
Chip,
Typography,
makeStyles,
Typography,
} from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
import classNames from 'classnames';
import React from 'react';
// TODO: Align styling between Domain and ToolCard
const useStyles = makeStyles<BackstageTheme>(theme => ({
card: {
@@ -65,22 +68,12 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
},
}));
export type CardData = {
title: string;
description: string;
url: string;
image: string;
tags?: string[];
lifecycle?: string;
newsTag?: string;
};
type Props = {
card: CardData;
card: ExploreTool;
objectFit?: 'cover' | 'contain';
};
const ExploreCard = ({ card, objectFit }: Props) => {
export const ToolCard = ({ card, objectFit }: Props) => {
const classes = useStyles();
const { title, description, url, image, lifecycle, newsTag, tags } = card;
@@ -130,5 +123,3 @@ const ExploreCard = ({ card, objectFit }: Props) => {
</Card>
);
};
export default ExploreCard;
@@ -0,0 +1,46 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ExploreTool } from '@backstage/plugin-explore-react';
import { BackstageTheme } from '@backstage/theme';
import { makeStyles } from '@material-ui/core';
import React from 'react';
import { ToolCard } from './ToolCard';
const useStyles = makeStyles<BackstageTheme>(theme => ({
container: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, 296px)',
gridGap: theme.spacing(3),
marginBottom: theme.spacing(6),
},
}));
type ToolCardGridProps = {
tools: ExploreTool[];
};
export const ToolCardGrid = ({ tools }: ToolCardGridProps) => {
const classes = useStyles();
return (
<div className={classes.container}>
{tools.map((card: ExploreTool, ix: any) => (
<ToolCard card={card} key={ix} />
))}
</div>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ToolCard } from './ToolCard';
export { ToolCardGrid } from './ToolCardGrid';
@@ -0,0 +1,94 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiProvider, ApiRegistry } from '@backstage/core';
import {
ExploreTool,
exploreToolsConfigRef,
} from '@backstage/plugin-explore-react';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ToolExplorerContent } from './ToolExplorerContent';
describe('<ToolExplorerContent />', () => {
const exploreToolsConfigApi: jest.Mocked<typeof exploreToolsConfigRef.T> = {
getTools: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<ApiProvider
apis={ApiRegistry.with(exploreToolsConfigRef, exploreToolsConfigApi)}
>
{children}
</ApiProvider>
</MemoryRouter>
</ThemeProvider>
);
beforeEach(() => {
jest.resetAllMocks();
});
it('renders a grid of tools', async () => {
const tools: ExploreTool[] = [
{
title: 'Lighthouse',
description:
"Google's Lighthouse tool is a great resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your website.",
url: '/lighthouse',
image:
'https://raw.githubusercontent.com/GoogleChrome/lighthouse/8b3d7f052b2e64dd857e741d7395647f487697e7/assets/lighthouse-logo.png',
tags: ['web', 'seo', 'accessibility', 'performance'],
},
{
title: 'Tech Radar',
description:
'Tech Radar is a list of technologies, complemented by an assessment result, called ring assignment.',
url: '/tech-radar',
image:
'https://storage.googleapis.com/wf-blogs-engineering-media/2018/09/fe13bb32-wf-tech-radar-hero-1024x597.png',
tags: ['standards', 'landscape'],
},
];
exploreToolsConfigApi.getTools.mockResolvedValue(tools);
const { getByText } = render(<ToolExplorerContent />, {
wrapper: Wrapper,
});
await waitFor(() => {
expect(getByText('Lighthouse')).toBeInTheDocument();
expect(getByText('Tech Radar')).toBeInTheDocument();
});
});
it('renders empty state', async () => {
exploreToolsConfigApi.getTools.mockResolvedValue([]);
const { getByText } = render(<ToolExplorerContent />, {
wrapper: Wrapper,
});
await waitFor(() =>
expect(getByText('No tools to display')).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,52 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Content,
ContentHeader,
EmptyState,
Progress,
SupportButton,
useApi,
} from '@backstage/core';
import { exploreToolsConfigRef } from '@backstage/plugin-explore-react';
import React from 'react';
import { useAsync } from 'react-use';
import { ToolCardGrid } from '../ToolCard';
export const ToolExplorerContent = () => {
const exploreToolsConfigApi = useApi(exploreToolsConfigRef);
const { value: tools, loading } = useAsync(async () => {
return await exploreToolsConfigApi.getTools();
}, [exploreToolsConfigApi]);
return (
<Content noPadding>
<ContentHeader title="Tools">
<SupportButton>Discover the tools in your ecosystem.</SupportButton>
</ContentHeader>
{loading && <Progress />}
{!loading && (!tools || tools.length === 0) && (
<EmptyState
missing="info"
title="No tools to display"
description={`You haven't added any tools yet.`}
/>
)}
{!loading && tools && <ToolCardGrid tools={tools} />}
</Content>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ToolExplorerContent } from './ToolExplorerContent';
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRoutableExtension } from '@backstage/core';
import { explorePlugin } from './plugin';
import { exploreRouteRef } from './routes';
export const ExplorePage = explorePlugin.provide(
createRoutableExtension({
component: () =>
import('./components/ExplorePage').then(m => m.ExplorePage),
mountPoint: exploreRouteRef,
}),
);
+3 -2
View File
@@ -14,5 +14,6 @@
* limitations under the License.
*/
export { plugin } from './plugin';
export { Router } from './components/Router';
export * from './extensions';
export { explorePlugin } from './plugin';
export * from './routes';
+2 -2
View File
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { plugin } from './plugin';
import { explorePlugin } from './plugin';
describe('explore', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
expect(explorePlugin).toBeDefined();
});
});
+21 -3
View File
@@ -14,9 +14,27 @@
* limitations under the License.
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import { createApiFactory, createPlugin } from '@backstage/core';
import { exploreToolsConfigRef } from '@backstage/plugin-explore-react';
import { exploreRouteRef } from './routes';
import { exampleTools } from './util/examples';
export const rootRouteRef = createRouteRef({ path: '', title: 'Explore' });
export const plugin = createPlugin({
export const explorePlugin = createPlugin({
id: 'explore',
apis: [
// Register a default for exploreToolsConfigRef, you may want to override
// the API locally in your app.
createApiFactory({
api: exploreToolsConfigRef,
deps: {},
factory: () => ({
async getTools() {
return exampleTools;
},
}),
}),
],
routes: {
explore: exploreRouteRef,
},
});
@@ -14,13 +14,11 @@
* limitations under the License.
*/
import React from 'react';
import { Route, Routes } from 'react-router';
import { ExplorePluginPage } from './ExplorePluginPage';
import { rootRouteRef } from '../plugin';
import { createRouteRef } from '@backstage/core';
export const Router = () => (
<Routes>
<Route path={`/${rootRouteRef.path}`} element={<ExplorePluginPage />} />
</Routes>
);
const NoIcon = () => null;
export const exploreRouteRef = createRouteRef({
icon: NoIcon,
title: 'Explore',
});
@@ -14,28 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import { makeStyles, Typography } from '@material-ui/core';
import {
Content,
ContentHeader,
Header,
Page,
SupportButton,
} from '@backstage/core';
import ExploreCard, { CardData } from './ExploreCard';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles<BackstageTheme>(theme => ({
container: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, 296px)',
gridGap: theme.spacing(3),
marginBottom: theme.spacing(6),
},
}));
const toolsCards = [
export const exampleTools = [
{
title: 'New Relic',
description:
@@ -112,28 +91,3 @@ const toolsCards = [
tags: ['rollbar', 'monitoring', 'errors'],
},
];
export const ExplorePluginPage = () => {
const classes = useStyles();
return (
<Page themeId="home">
<Header
title="Explore"
subtitle="Tools and services available in Backstage"
/>
<Content>
<ContentHeader title="Tools">
<SupportButton>
<Typography>Explore tools available in Backstage</Typography>
</SupportButton>
</ContentHeader>
<div className={classes.container}>
{toolsCards.map((card: CardData, ix: any) => (
<ExploreCard card={card} key={ix} />
))}
</div>
</Content>
</Page>
);
};
@@ -56,6 +56,7 @@ describe('CookieCutter Templater', () => {
dockerClient: mockDocker,
});
expect(fs.ensureDir).toBeCalledWith(path.join('tempdir', 'intermediate'));
expect(fs.writeJson).toBeCalledWith(
path.join('tempdir', 'template', 'cookiecutter.json'),
expect.objectContaining(values),
@@ -44,6 +44,7 @@ export class CookieCutter implements TemplaterBase {
}: TemplaterRunOptions): Promise<void> {
const templateDir = path.join(workspacePath, 'template');
const intermediateDir = path.join(workspacePath, 'intermediate');
await fs.ensureDir(intermediateDir);
const resultDir = path.join(workspacePath, 'result');
// First lets grab the default cookiecutter.json file
@@ -25,13 +25,12 @@ import {
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Grid } from '@material-ui/core';
import React from 'react';
import { generatePath, useNavigate } from 'react-router-dom';
import { generatePath } from 'react-router-dom';
import { useAsync } from 'react-use';
import { rootDocsRouteRef } from '../../plugin';
export const TechDocsHome = () => {
const catalogApi = useApi(catalogApiRef);
const navigate = useNavigate();
const { value, loading, error } = useAsync(async () => {
const response = await catalogApi.getEntities();
@@ -80,15 +79,11 @@ export const TechDocsHome = () => {
? value.map((entity, index: number) => (
<Grid key={index} item xs={12} sm={6} md={3}>
<ItemCard
onClick={() =>
navigate(
generatePath(rootDocsRouteRef.path, {
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
}),
)
}
href={generatePath(rootDocsRouteRef.path, {
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
})}
title={entity.metadata.name}
label="Read Docs"
description={entity.metadata.description}