plugin-techdocs: add group filtering support to EntityListDocsGrid
Signed-off-by: Simon Ninon <sninon@pagerduty.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
]}} />
|
||||
```
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user