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
@@ -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>
);
};