diff --git a/.changeset/dull-oranges-tap.md b/.changeset/dull-oranges-tap.md new file mode 100644 index 0000000000..946656f610 --- /dev/null +++ b/.changeset/dull-oranges-tap.md @@ -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 + + entity?.metadata?.tags?.includes('recommended') ?? false, + }, + { + title: "RunBooks Documentation", + filterPredicate: entity => + entity?.metadata?.tags?.includes('runbook') ?? false, + } +]}} /> +``` diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 4f2dc6c348..67b25c2f86 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -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 -(``). +(``) 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 +(``). + +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 = () => { + + }> + ; +}; +``` + +### Building a Custom home page + But you can replace `` 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 ( + + + + + + + + + + + + + + + + + + ); +}; +``` + +Then you can add the following to your `App.tsx`: + ```tsx import { CustomTechDocsHome } from './components/techdocs/CustomTechDocsHome'; // ... const AppRoutes = () => { }> - + + entity?.metadata?.tags?.includes('recommended') ?? false, + }, + { + title: 'My Docs', + filterPredicate: 'ownedByUser', + }, + ]} + /> ; }; diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index b2f0331a9b..d1c05f5b8e 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -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: { diff --git a/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.test.tsx b/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.test.tsx new file mode 100644 index 0000000000..effd8fe0c5 --- /dev/null +++ b/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.test.tsx @@ -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; + +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( + + + + + , + { + 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( + + + + entity.metadata.name === 'Documentation #1', + }, + ]} + /> + + , + { + 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( + + + + entity.metadata.name === 'Documentation #3', + }, + ]} + /> + + , + { + 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( + + + + + , + { + 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(); + }); +}); diff --git a/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.tsx b/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.tsx index 348104f5cc..d6b2bf3391 100644 --- a/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.tsx +++ b/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.tsx @@ -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' ? ( + + ) : ( + group.title + ); + })(); + + if (shownEntities.length === 0) { + return null; + } + + return ( + + {titleComponent} + + + ); +}; + /** * 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 ; } + if (entities.length === 0) { + return ( +
+ + No documentation found that match your filter. Learn more about{' '} + + publishing documentation + + . + +
+ ); + } + entities.sort((a, b) => (a.metadata.title ?? a.metadata.name).localeCompare( b.metadata.title ?? b.metadata.name, ), ); - return ; + return ( + + {(groups || [allEntitiesGroup]).map((group, index: number) => ( + + ))} + + ); };