diff --git a/.changeset/curly-beans-brake.md b/.changeset/curly-beans-brake.md new file mode 100644 index 0000000000..4afb11578d --- /dev/null +++ b/.changeset/curly-beans-brake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': minor +--- + +Add pagination support to TechDocs Index Page and make it the default diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index ccf06b17bd..7e914bdc9b 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -164,7 +164,10 @@ const routes = ( /> } /> - } /> + } + /> } diff --git a/plugins/techdocs/report.api.md b/plugins/techdocs/report.api.md index 2cc3934f62..c577de5eac 100644 --- a/plugins/techdocs/report.api.md +++ b/plugins/techdocs/report.api.md @@ -12,6 +12,7 @@ import { Config } from '@backstage/config'; import { CSSProperties } from '@material-ui/styles/withStyles'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import { EntityListPagination } from '@backstage/plugin-catalog-react'; import { EntityOwnerPickerProps } from '@backstage/plugin-catalog-react'; import { FetchApi } from '@backstage/core-plugin-api'; import { IdentityApi } from '@backstage/core-plugin-api'; @@ -309,6 +310,7 @@ export type TechDocsIndexPageProps = { columns?: TableColumn[]; actions?: TableProps['actions']; ownerPickerMode?: EntityOwnerPickerProps['mode']; + pagination?: EntityListPagination; }; // @public @deprecated (undocumented) diff --git a/plugins/techdocs/src/home/components/DefaultTechDocsHome.tsx b/plugins/techdocs/src/home/components/DefaultTechDocsHome.tsx index 2fbd5bb50a..f9757f29cc 100644 --- a/plugins/techdocs/src/home/components/DefaultTechDocsHome.tsx +++ b/plugins/techdocs/src/home/components/DefaultTechDocsHome.tsx @@ -46,7 +46,13 @@ export type DefaultTechDocsHomeProps = TechDocsIndexPageProps; * @public */ export const DefaultTechDocsHome = (props: TechDocsIndexPageProps) => { - const { initialFilter = 'owned', columns, actions, ownerPickerMode } = props; + const { + initialFilter = 'owned', + columns, + actions, + ownerPickerMode, + pagination, + } = props; return ( @@ -55,7 +61,7 @@ export const DefaultTechDocsHome = (props: TechDocsIndexPageProps) => { Discover documentation in your ecosystem. - + diff --git a/plugins/techdocs/src/home/components/Tables/CursorPaginatedDocsTable.test.tsx b/plugins/techdocs/src/home/components/Tables/CursorPaginatedDocsTable.test.tsx new file mode 100644 index 0000000000..d7ab3827e1 --- /dev/null +++ b/plugins/techdocs/src/home/components/Tables/CursorPaginatedDocsTable.test.tsx @@ -0,0 +1,168 @@ +/* + * Copyright 2024 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 React, { ReactNode } from 'react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { CursorPaginatedDocsTable } from './CursorPaginatedDocsTable'; +import { DocsTableRow } from './types'; +import { renderInTestApp } from '@backstage/test-utils'; +import { + DefaultEntityFilters, + EntityKindFilter, + EntityListContextProps, +} from '@backstage/plugin-catalog-react'; +import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; + +describe('CursorPaginatedDocsTable', () => { + const data = new Array(100).fill(0).map((_, index) => { + const name = `techdocs-${index}`; + return { + entity: { + apiVersion: '1', + kind: 'TestKind', + metadata: { + name, + }, + }, + resolved: { + docsUrl: 'https://example.com', + ownedByRelationsTitle: 'owned', + ownedByRelations: [], + }, + } as DocsTableRow; + }); + + const columns = [ + { + title: 'Title', + field: 'entity.metadata.name', + searchable: true, + }, + ]; + + const wrapInContext = ( + node: ReactNode, + value?: Partial>, + ) => { + return ( + + {node} + + ); + }; + + it('should display all the items', async () => { + await renderInTestApp( + wrapInContext(), + ); + + for (const item of data) { + expect(screen.queryByText(item.entity.metadata.name)).toBeInTheDocument(); + } + }); + + it('should display and invoke the next button', async () => { + const { rerender } = await renderInTestApp( + wrapInContext( + , + ), + ); + + expect( + screen.queryAllByRole('button', { name: 'Next Page' })[0], + ).toBeDisabled(); + + const fn = jest.fn(); + + rerender( + wrapInContext( + , + ), + ); + + const nextButton = screen.queryAllByRole('button', { + name: 'Next Page', + })[0]; + expect(nextButton).toBeEnabled(); + + fireEvent.click(nextButton); + expect(fn).toHaveBeenCalled(); + }); + + it('should display and invoke the prev button', async () => { + const { rerender } = await renderInTestApp( + wrapInContext( + , + ), + ); + + expect( + screen.queryAllByRole('button', { name: 'Next Page' })[0], + ).toBeDisabled(); + + const fn = jest.fn(); + + rerender( + wrapInContext( + , + ), + ); + + const prevButton = screen.queryAllByRole('button', { + name: 'Previous Page', + })[0]; + expect(prevButton).toBeEnabled(); + + fireEvent.click(prevButton); + expect(fn).toHaveBeenCalled(); + }); + + it('should display entity names when loading has finished and no error occurred', async () => { + await renderInTestApp( + e.entity), + totalItems: data.length, + filters: { + kind: new EntityKindFilter('techdocs'), + }, + }} + > + + , + ); + + expect(screen.getByText(/techdocs-0/)).toBeInTheDocument(); + expect(screen.getByText(/techdocs-50/)).toBeInTheDocument(); + expect(screen.getByText(/techdocs-99/)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(/My title/)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/techdocs/src/home/components/Tables/CursorPaginatedDocsTable.tsx b/plugins/techdocs/src/home/components/Tables/CursorPaginatedDocsTable.tsx new file mode 100644 index 0000000000..48a1ecddce --- /dev/null +++ b/plugins/techdocs/src/home/components/Tables/CursorPaginatedDocsTable.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2024 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 React from 'react'; + +import { Table, TableProps } from '@backstage/core-components'; +import { DocsTableRow } from './types'; + +type PaginatedDocsTableProps = { + prev?(): void; + next?(): void; +} & TableProps; + +/** + * @internal + */ + +export function CursorPaginatedDocsTable(props: PaginatedDocsTableProps) { + const { + actions, + columns, + data, + next, + prev, + title, + isLoading, + options, + ...restProps + } = props; + + return ( + { + if (page > 0) { + next?.(); + } else { + prev?.(); + } + }} + /* this will enable the prev button accordingly */ + page={prev ? 1 : 0} + /* this will enable the next button accordingly */ + totalCount={next ? Number.MAX_VALUE : Number.MAX_SAFE_INTEGER} + localization={{ pagination: { labelDisplayedRows: '' } }} + isLoading={isLoading} + {...restProps} + /> + ); +} diff --git a/plugins/techdocs/src/home/components/Tables/DocsTable.tsx b/plugins/techdocs/src/home/components/Tables/DocsTable.tsx index b1537affdb..29dbb3fd4f 100644 --- a/plugins/techdocs/src/home/components/Tables/DocsTable.tsx +++ b/plugins/techdocs/src/home/components/Tables/DocsTable.tsx @@ -18,11 +18,7 @@ import React from 'react'; import useCopyToClipboard from 'react-use/esm/useCopyToClipboard'; import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; -import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; -import { - getEntityRelations, - humanizeEntityRef, -} from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; import { rootDocsRouteRef } from '../../../routes'; import { EmptyState, @@ -33,9 +29,9 @@ import { TableProps, } from '@backstage/core-components'; import { actionFactories } from './actions'; -import { columnFactories } from './columns'; -import { toLowerMaybe } from '../../../helpers'; +import { columnFactories, defaultColumns } from './columns'; import { DocsTableRow } from './types'; +import { entitiesToDocsMapper } from './helpers'; /** * Props for {@link DocsTable}. @@ -51,14 +47,6 @@ export type DocsTableProps = { options?: TableOptions; }; -const defaultColumns: TableColumn[] = [ - columnFactories.createTitleColumn({ hidden: true }), - columnFactories.createNameColumn(), - columnFactories.createOwnerColumn(), - columnFactories.createKindColumn(), - columnFactories.createTypeColumn(), -]; - /** * Component which renders a table documents * @@ -71,26 +59,11 @@ export const DocsTable = (props: DocsTableProps) => { const config = useApi(configApiRef); if (!entities) return null; - const documents = entities.map(entity => { - const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); - return { - entity, - resolved: { - docsUrl: getRouteToReaderPageFor({ - namespace: toLowerMaybe( - entity.metadata.namespace ?? 'default', - config, - ), - kind: toLowerMaybe(entity.kind, config), - name: toLowerMaybe(entity.metadata.name, config), - }), - ownedByRelations, - ownedByRelationsTitle: ownedByRelations - .map(r => humanizeEntityRef(r, { defaultKind: 'group' })) - .join(', '), - }, - }; - }); + const documents = entitiesToDocsMapper( + entities, + getRouteToReaderPageFor, + config, + ); const defaultActions: TableProps['actions'] = [ actionFactories.createCopyDocsUrlAction(copyToClipboard), diff --git a/plugins/techdocs/src/home/components/Tables/EntityListDocsTable.tsx b/plugins/techdocs/src/home/components/Tables/EntityListDocsTable.tsx index 563bec4d22..08702d2fde 100644 --- a/plugins/techdocs/src/home/components/Tables/EntityListDocsTable.tsx +++ b/plugins/techdocs/src/home/components/Tables/EntityListDocsTable.tsx @@ -24,14 +24,19 @@ import { TableProps, WarningPanel, } from '@backstage/core-components'; +import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; import { useEntityList, useStarredEntities, } from '@backstage/plugin-catalog-react'; import { DocsTable } from './DocsTable'; +import { OffsetPaginatedDocsTable } from './OffsetPaginatedDocsTable'; +import { CursorPaginatedDocsTable } from './CursorPaginatedDocsTable'; import { actionFactories } from './actions'; -import { columnFactories } from './columns'; +import { columnFactories, defaultColumns } from './columns'; import { DocsTableRow } from './types'; +import { rootDocsRouteRef } from '../../../routes'; +import { entitiesToDocsMapper } from './helpers'; /** * Props for {@link EntityListDocsTable}. @@ -51,9 +56,12 @@ export type EntityListDocsTableProps = { */ export const EntityListDocsTable = (props: EntityListDocsTableProps) => { const { columns, actions, options } = props; - const { loading, error, entities, filters } = useEntityList(); + const { loading, error, entities, filters, paginationMode, pageInfo } = + useEntityList(); const { isStarredEntity, toggleStarredEntity } = useStarredEntities(); const [, copyToClipboard] = useCopyToClipboard(); + const getRouteToReaderPageFor = useRouteRef(rootDocsRouteRef); + const config = useApi(configApiRef); const title = capitalize(filters.user?.value ?? 'all'); @@ -65,6 +73,38 @@ export const EntityListDocsTable = (props: EntityListDocsTableProps) => { ), ]; + const documents = entitiesToDocsMapper( + entities, + getRouteToReaderPageFor, + config, + ); + + if (paginationMode === 'cursor') { + return ( + + ); + } else if (paginationMode === 'offset') { + return ( + + ); + } + if (error) { return ( { + const data = new Array(100).fill(0).map((_, index) => { + const name = `tectdocs-${index}`; + return { + entity: { + apiVersion: '1', + kind: 'TestKind', + metadata: { + name, + }, + }, + resolved: { + docsUrl: 'https://example.com', + ownedByRelationsTitle: 'owned', + ownedByRelations: [], + }, + } as DocsTableRow; + }); + + const columns = [ + { + title: 'Title', + field: 'entity.metadata.name', + searchable: true, + }, + ]; + + const wrapInContext = ( + node: ReactNode, + value?: Partial>, + ) => { + return ( + + {node} + + ); + }; + + it('should display all the items', async () => { + await renderInTestApp( + wrapInContext( + , + { + setOffset: jest.fn(), + limit: Number.MAX_SAFE_INTEGER, + offset: 0, + totalItems: data.length, + }, + ), + ); + + for (const item of data) { + expect(screen.queryByText(item.entity.metadata.name)).toBeInTheDocument(); + } + }); + + it('should display and invoke the next and previous buttons', async () => { + const offsetFn = jest.fn(); + + await renderInTestApp( + wrapInContext( + , + { setOffset: offsetFn, limit: 10, totalItems: data.length, offset: 0 }, + ), + ); + + expect(offsetFn).toHaveBeenNthCalledWith(1, 0); + const nextButton = screen.queryAllByRole('button', { + name: 'Next Page', + })[0]; + expect(nextButton).toBeEnabled(); + + fireEvent.click(nextButton); + expect(offsetFn).toHaveBeenNthCalledWith(2, 10); + + const prevButton = screen.queryAllByRole('button', { + name: 'Previous Page', + })[0]; + expect(prevButton).toBeEnabled(); + + fireEvent.click(prevButton); + expect(offsetFn).toHaveBeenNthCalledWith(3, 0); + }); +}); diff --git a/plugins/techdocs/src/home/components/Tables/OffsetPaginatedDocsTable.tsx b/plugins/techdocs/src/home/components/Tables/OffsetPaginatedDocsTable.tsx new file mode 100644 index 0000000000..9f4572117d --- /dev/null +++ b/plugins/techdocs/src/home/components/Tables/OffsetPaginatedDocsTable.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2024 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 React, { useEffect } from 'react'; + +import { Table, TableProps } from '@backstage/core-components'; +import { DocsTableRow } from './types'; +import { + EntityTextFilter, + useEntityList, +} from '@backstage/plugin-catalog-react'; + +/** + * @internal + */ +export function OffsetPaginatedDocsTable(props: TableProps) { + const { actions, columns, data, isLoading, options } = props; + const { updateFilters, setLimit, setOffset, limit, totalItems, offset } = + useEntityList(); + const [page, setPage] = React.useState( + offset && limit ? Math.floor(offset / limit) : 0, + ); + + useEffect(() => { + if (totalItems && page * limit >= totalItems) { + setOffset!(Math.max(0, totalItems - limit)); + } else { + setOffset!(Math.max(0, page * limit)); + } + }, [setOffset, page, limit, totalItems]); + + return ( + + columns={columns} + data={data} + options={{ + paginationPosition: 'both', + pageSizeOptions: [5, 10, 20, 50, 100], + pageSize: limit, + emptyRowsWhenPaging: false, + actionsColumnIndex: -1, + ...options, + }} + actions={actions} + onSearchChange={(searchText: string) => + updateFilters({ + text: searchText ? new EntityTextFilter(searchText) : undefined, + }) + } + page={page} + onPageChange={newPage => { + setPage(newPage); + }} + onRowsPerPageChange={pageSize => { + setLimit(pageSize); + }} + totalCount={totalItems} + localization={{ pagination: { labelDisplayedRows: '' } }} + isLoading={isLoading} + /> + ); +} diff --git a/plugins/techdocs/src/home/components/Tables/columns.tsx b/plugins/techdocs/src/home/components/Tables/columns.tsx index ef22f6d288..9947e1de9e 100644 --- a/plugins/techdocs/src/home/components/Tables/columns.tsx +++ b/plugins/techdocs/src/home/components/Tables/columns.tsx @@ -85,3 +85,11 @@ export const columnFactories = { }; }, }; + +export const defaultColumns: TableColumn[] = [ + columnFactories.createTitleColumn({ hidden: true }), + columnFactories.createNameColumn(), + columnFactories.createOwnerColumn(), + columnFactories.createKindColumn(), + columnFactories.createTypeColumn(), +]; diff --git a/plugins/techdocs/src/home/components/Tables/helpers.ts b/plugins/techdocs/src/home/components/Tables/helpers.ts new file mode 100644 index 0000000000..4c6383c35e --- /dev/null +++ b/plugins/techdocs/src/home/components/Tables/helpers.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2024 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 { RELATION_OWNED_BY, Entity } from '@backstage/catalog-model'; +import { + getEntityRelations, + humanizeEntityRef, +} from '@backstage/plugin-catalog-react'; +import { toLowerMaybe } from '../../../helpers'; +import { ConfigApi, RouteFunc } from '@backstage/core-plugin-api'; + +type getRouteFunc = RouteFunc<{ + namespace: string; + kind: string; + name: string; +}>; + +export function entitiesToDocsMapper( + entities: Entity[], + getRouteToReaderPageFor: getRouteFunc, + config: ConfigApi, +) { + return entities.map(entity => { + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + return { + entity, + resolved: { + docsUrl: getRouteToReaderPageFor({ + namespace: toLowerMaybe( + entity.metadata.namespace ?? 'default', + config, + ), + kind: toLowerMaybe(entity.kind, config), + name: toLowerMaybe(entity.metadata.name, config), + }), + ownedByRelations, + ownedByRelationsTitle: ownedByRelations + .map(r => humanizeEntityRef(r, { defaultKind: 'group' })) + .join(', '), + }, + }; + }); +} diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx index 409d68a593..585248c6c9 100644 --- a/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx @@ -14,7 +14,11 @@ * limitations under the License. */ -import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { + catalogApiRef, + starredEntitiesApiRef, + MockStarredEntitiesApi, +} from '@backstage/plugin-catalog-react'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; @@ -37,7 +41,10 @@ const mockCatalogApi = catalogApiMock({ }); describe('TechDocsCustomHome', () => { - const apiRegistry = TestApiRegistry.from([catalogApiRef, mockCatalogApi]); + const apiRegistry = TestApiRegistry.from( + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, new MockStarredEntitiesApi()], + ); it('should render a TechDocs home page', async () => { const tabsConfig = [ diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx index 56d086acfc..d89bc5ee1b 100644 --- a/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx @@ -23,6 +23,7 @@ import { catalogApiRef, CatalogApi, useEntityOwnership, + EntityListProvider, } from '@backstage/plugin-catalog-react'; import { Entity } from '@backstage/catalog-model'; import { DocsTable } from './Tables'; @@ -127,7 +128,9 @@ const CustomPanel = ({ ) : null}
- + + +
); diff --git a/plugins/techdocs/src/home/components/TechDocsIndexPage.tsx b/plugins/techdocs/src/home/components/TechDocsIndexPage.tsx index adbb68d3e9..cc89d22c08 100644 --- a/plugins/techdocs/src/home/components/TechDocsIndexPage.tsx +++ b/plugins/techdocs/src/home/components/TechDocsIndexPage.tsx @@ -18,6 +18,7 @@ import React from 'react'; import { useOutlet } from 'react-router-dom'; import { TableColumn, TableProps } from '@backstage/core-components'; import { + EntityListPagination, EntityOwnerPickerProps, UserListFilterKind, } from '@backstage/plugin-catalog-react'; @@ -34,6 +35,7 @@ export type TechDocsIndexPageProps = { columns?: TableColumn[]; actions?: TableProps['actions']; ownerPickerMode?: EntityOwnerPickerProps['mode']; + pagination?: EntityListPagination; }; export const TechDocsIndexPage = (props: TechDocsIndexPageProps) => {