plugin-techdocs: add group filtering support to EntityListDocsGrid

Signed-off-by: Simon Ninon <sninon@pagerduty.com>
This commit is contained in:
Simon Ninon
2022-09-19 15:11:52 -07:00
parent 4a6143a3be
commit 5691baea69
5 changed files with 449 additions and 7 deletions
+25
View File
@@ -0,0 +1,25 @@
---
'@backstage/plugin-techdocs': minor
---
Add ability to configure filters when using EntityListDocsGrid
The following example will render two sections of cards grid:
- One section for documentations tagged as `recommended`
- One section for documentations tagged as `runbook`
```js
<EntityListDocsGrid groups={{[
{
title: "Recommended Documentation",
filterPredicate: entity =>
entity?.metadata?.tags?.includes('recommended') ?? false,
},
{
title: "RunBooks Documentation",
filterPredicate: entity =>
entity?.metadata?.tags?.includes('runbook') ?? false,
}
]}} />
```
+104 -2
View File
@@ -111,7 +111,7 @@ in Backstage. While a default table experience, similar to the one provided by
the Catalog plugin, is made available for ease-of-use, it's possible for you to
provide a completely custom experience, tailored to the needs of your
organization. For example, TechDocs comes with an alternative grid based layout
(`<EntityListDocsGrid>`).
(`<EntityListDocsGrid>`) and panel layout (`TechDocsCustomHome`).
This is done in your `app` package. By default, you might see something like
this in your `App.tsx`:
@@ -126,18 +126,120 @@ const AppRoutes = () => {
};
```
### Using TechDocsCustomHome
You can easily customize the TechDocs home page using TechDocs panel layout
(`<TechDocsCustomHome />`).
Modify your `App.tsx` as follows:
```tsx
import { TechDocsCustomHome } from '@backstage/plugin-techdocs';
//...
const techDocsTabsConfig = [
{
label: "Recommended Documentation",
panels: [
{
title: 'Golden Path',
description: 'Documentation about standards to follow',
panelType: 'DocsCardGrid',
filterPredicate: entity => entity?.metadata?.tags?.includes('recommended') ?? false,
}
]
}
]
const AppRoutes = () => {
<FlatRoutes>
<Route path="/docs" element={<TechDocsCustomHome tabsConfig={techDocsTabsConfig} />}>
</FlatRoutes>;
};
```
### Building a Custom home page
But you can replace `<DefaultTechDocsHome />` with any React component, which
will be rendered in its place. Most likely, you would want to create and
maintain such a component in a new directory at
`packages/app/src/components/techdocs`, and import and use it in `App.tsx`:
For example, you can define the following Custom home page component:
```tsx
import React from 'react';
import { Content } from '@backstage/core-components';
import {
CatalogFilterLayout,
EntityOwnerPicker,
EntityTagPicker,
UserListPicker,
EntityListProvider,
} from '@backstage/plugin-catalog-react';
import {
TechDocsPageWrapper,
TechDocsPicker,
} from '@backstage/plugin-techdocs';
import { Entity } from '@backstage/catalog-model';
import {
EntityListDocsGrid,
DocsGroupConfig,
} from '@backstage/plugin-techdocs';
export type CustomTechDocsHomeProps = {
groups?: Array<{
title: React.ReactNode;
filterPredicate: (entity: Entity) => boolean;
}>;
};
export const CustomTechDocsHome = ({ groups }: CustomTechDocsHomeProps) => {
return (
<TechDocsPageWrapper>
<Content>
<EntityListProvider>
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<TechDocsPicker />
<UserListPicker initialFilter="all" />
<EntityOwnerPicker />
<EntityTagPicker />
</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>
<EntityListDocsGrid groups={groups} />
</CatalogFilterLayout.Content>
</CatalogFilterLayout>
</EntityListProvider>
</Content>
</TechDocsPageWrapper>
);
};
```
Then you can add the following to your `App.tsx`:
```tsx
import { CustomTechDocsHome } from './components/techdocs/CustomTechDocsHome';
// ...
const AppRoutes = () => {
<FlatRoutes>
<Route path="/docs" element={<TechDocsIndexPage />}>
<CustomTechDocsHome />
<CustomTechDocsHome
groups={[
{
title: 'Recommended Documentation',
filterPredicate: entity =>
entity?.metadata?.tags?.includes('recommended') ?? false,
},
{
title: 'My Docs',
filterPredicate: 'ownedByUser',
},
]}
/>
</Route>
</FlatRoutes>;
};
+14 -1
View File
@@ -59,6 +59,12 @@ export type DocsCardGridProps = {
entities: Entity[] | undefined;
};
// @public
export type DocsGroupConfig = {
title: React_2.ReactNode;
filterPredicate: ((entity: Entity) => boolean) | string;
};
// @public
export const DocsTable: {
(props: DocsTableProps): JSX.Element | null;
@@ -112,7 +118,14 @@ export const EmbeddedDocsRouter: (
) => JSX.Element | null;
// @public
export const EntityListDocsGrid: () => JSX.Element;
export const EntityListDocsGrid: ({
groups,
}: EntityListDocsGridPageProps) => JSX.Element;
// @public
export type EntityListDocsGridPageProps = {
groups?: DocsGroupConfig[];
};
// @public
export const EntityListDocsTable: {
@@ -0,0 +1,203 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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, ConfigReader } from '@backstage/core-app-api';
import {
ConfigApi,
configApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import {
CatalogApi,
catalogApiRef,
starredEntitiesApiRef,
MockEntityListContextProvider,
MockStarredEntitiesApi,
} from '@backstage/plugin-catalog-react';
import {
MockStorageApi,
renderInTestApp,
TestApiRegistry,
} from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { rootDocsRouteRef } from '../../../routes';
import { EntityListDocsGrid } from './EntityListDocsGrid';
const entities = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Documentation #1',
namespace: 'default',
},
spec: {
type: 'documentation',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Documentation #2',
namespace: 'default',
},
spec: {
type: 'documentation',
},
},
];
const mockCatalogApi = {
getEntityByRef: () => Promise.resolve(),
getEntities: async () => ({
items: entities,
}),
} as Partial<CatalogApi>;
describe('Entity List Docs Grid', () => {
beforeEach(() => {
jest.resetAllMocks();
});
const configApi: ConfigApi = new ConfigReader({
organization: {
name: 'My Company',
},
});
const storageApi = MockStorageApi.create();
const apiRegistry = TestApiRegistry.from(
[catalogApiRef, mockCatalogApi],
[configApiRef, configApi],
[storageApiRef, storageApi],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
);
it('should render all entitites without filtering', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<MockEntityListContextProvider value={{ entities: entities }}>
<EntityListDocsGrid />
</MockEntityListContextProvider>
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(await screen.queryByText('All Documentation')).toBeInTheDocument();
expect(await screen.queryByText('Documentation #1')).toBeInTheDocument();
expect(await screen.queryByText('Documentation #2')).toBeInTheDocument();
expect(await screen.queryByTestId('doc-not-found')).not.toBeInTheDocument();
});
it('should render only filtered entities with filtering', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<MockEntityListContextProvider value={{ entities: entities }}>
<EntityListDocsGrid
groups={[
{
title: 'Curated Documentation',
filterPredicate: entity =>
entity.metadata.name === 'Documentation #1',
},
]}
/>
</MockEntityListContextProvider>
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(
await screen.queryByText('Curated Documentation'),
).toBeInTheDocument();
expect(await screen.queryByText('Documentation #1')).toBeInTheDocument();
expect(
await screen.queryByText('Documentation #2'),
).not.toBeInTheDocument();
expect(await screen.queryByTestId('doc-not-found')).not.toBeInTheDocument();
});
it('should render nothing with filtering yielding no result', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<MockEntityListContextProvider value={{ entities: entities }}>
<EntityListDocsGrid
groups={[
{
title: 'Curated Documentation',
filterPredicate: entity =>
entity.metadata.name === 'Documentation #3',
},
]}
/>
</MockEntityListContextProvider>
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(
await screen.queryByText('Curated Documentation'),
).not.toBeInTheDocument();
expect(
await screen.queryByText('Documentation #1'),
).not.toBeInTheDocument();
expect(
await screen.queryByText('Documentation #2'),
).not.toBeInTheDocument();
expect(await screen.queryByTestId('doc-not-found')).not.toBeInTheDocument();
});
it('should render an error without any documentation and without filtering', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<MockEntityListContextProvider value={{ entities: [] }}>
<EntityListDocsGrid />
</MockEntityListContextProvider>
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(
await screen.queryByText('All Documentation'),
).not.toBeInTheDocument();
expect(
await screen.queryByText('Documentation #1'),
).not.toBeInTheDocument();
expect(
await screen.queryByText('Documentation #2'),
).not.toBeInTheDocument();
expect(await screen.queryByTestId('doc-not-found')).toBeInTheDocument();
});
});
@@ -15,20 +15,95 @@
*/
import { DocsCardGrid } from './DocsCardGrid';
import { Entity } from '@backstage/catalog-model';
import {
CodeSnippet,
Content,
ContentHeader,
Link,
Progress,
WarningPanel,
} from '@backstage/core-components';
import { useEntityList } from '@backstage/plugin-catalog-react';
import {
useEntityList,
useEntityOwnership,
} from '@backstage/plugin-catalog-react';
import { Typography } from '@material-ui/core';
import React from 'react';
/**
* Props for {@link EntityListDocsGrid}
*
* @public
*/
export type DocsGroupConfig = {
title: React.ReactNode;
filterPredicate: ((entity: Entity) => boolean) | string;
};
/**
* Props for {@link EntityListDocsGrid}
*
* @public
*/
export type EntityListDocsGridPageProps = {
groups?: DocsGroupConfig[];
};
const allEntitiesGroup: DocsGroupConfig = {
title: 'All Documentation',
filterPredicate: () => true,
};
const EntityListDocsGridGroup = ({
entities,
group,
}: {
group: DocsGroupConfig;
entities: Entity[];
}) => {
const { loading: loadingOwnership, isOwnedEntity } = useEntityOwnership();
const shownEntities = entities.filter(entity => {
if (group.filterPredicate === 'ownedByUser') {
if (loadingOwnership) {
return false;
}
return isOwnedEntity(entity);
}
return (
typeof group.filterPredicate === 'function' &&
group.filterPredicate(entity)
);
});
const titleComponent: React.ReactNode = (() => {
return typeof group.title === 'string' ? (
<ContentHeader title={group.title} />
) : (
group.title
);
})();
if (shownEntities.length === 0) {
return null;
}
return (
<Content>
{titleComponent}
<DocsCardGrid entities={shownEntities} />
</Content>
);
};
/**
* Component responsible to get entities from entity list context and pass down to DocsCardGrid
*
* @public
*/
export const EntityListDocsGrid = () => {
export const EntityListDocsGrid = ({ groups }: EntityListDocsGridPageProps) => {
const { loading, error, entities } = useEntityList();
if (error) {
@@ -42,15 +117,39 @@ export const EntityListDocsGrid = () => {
);
}
if (loading || !entities) {
if (loading) {
return <Progress />;
}
if (entities.length === 0) {
return (
<div data-testid="doc-not-found">
<Typography variant="body2">
No documentation found that match your filter. Learn more about{' '}
<Link to="https://backstage.io/docs/features/techdocs/creating-and-publishing">
publishing documentation
</Link>
.
</Typography>
</div>
);
}
entities.sort((a, b) =>
(a.metadata.title ?? a.metadata.name).localeCompare(
b.metadata.title ?? b.metadata.name,
),
);
return <DocsCardGrid entities={entities} />;
return (
<Content>
{(groups || [allEntitiesGroup]).map((group, index: number) => (
<EntityListDocsGridGroup
entities={entities}
group={group}
key={`${group.title}-${index}`}
/>
))}
</Content>
);
};