Merge pull request #27694 from hyb175/feature/add-pagination-to-tech-docs-table

[Feature] Add Pagination to Tech Docs Table
This commit is contained in:
John Philip
2024-12-10 14:02:38 -05:00
committed by GitHub
15 changed files with 577 additions and 43 deletions
@@ -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 (
<TechDocsPageWrapper>
<Content>
@@ -55,7 +61,7 @@ export const DefaultTechDocsHome = (props: TechDocsIndexPageProps) => {
Discover documentation in your ecosystem.
</SupportButton>
</ContentHeader>
<EntityListProvider>
<EntityListProvider pagination={pagination}>
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<TechDocsPicker />
@@ -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<EntityListContextProps<DefaultEntityFilters>>,
) => {
return (
<MockEntityListContextProvider value={value}>
{node}
</MockEntityListContextProvider>
);
};
it('should display all the items', async () => {
await renderInTestApp(
wrapInContext(<CursorPaginatedDocsTable data={data} columns={columns} />),
);
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(
<CursorPaginatedDocsTable
data={data}
columns={columns}
next={undefined}
/>,
),
);
expect(
screen.queryAllByRole('button', { name: 'Next Page' })[0],
).toBeDisabled();
const fn = jest.fn();
rerender(
wrapInContext(
<CursorPaginatedDocsTable data={data} columns={columns} next={fn} />,
),
);
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(
<CursorPaginatedDocsTable
data={data}
columns={columns}
prev={undefined}
/>,
),
);
expect(
screen.queryAllByRole('button', { name: 'Next Page' })[0],
).toBeDisabled();
const fn = jest.fn();
rerender(
wrapInContext(
<CursorPaginatedDocsTable data={data} columns={columns} prev={fn} />,
),
);
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(
<MockEntityListContextProvider
value={{
entities: data.map(e => e.entity),
totalItems: data.length,
filters: {
kind: new EntityKindFilter('techdocs'),
},
}}
>
<CursorPaginatedDocsTable
data={data}
columns={columns}
next={undefined}
title="My title"
/>
</MockEntityListContextProvider>,
);
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();
});
});
});
@@ -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<DocsTableRow>;
/**
* @internal
*/
export function CursorPaginatedDocsTable(props: PaginatedDocsTableProps) {
const {
actions,
columns,
data,
next,
prev,
title,
isLoading,
options,
...restProps
} = props;
return (
<Table
title={isLoading ? '' : title}
columns={columns}
data={data}
options={{
paginationPosition: 'both',
...options,
// These settings are configured to force server side pagination
pageSizeOptions: [],
showFirstLastPageButtons: false,
pageSize: Number.MAX_SAFE_INTEGER,
emptyRowsWhenPaging: false,
actionsColumnIndex: -1,
}}
onPageChange={page => {
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}
/>
);
}
@@ -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<DocsTableRow>;
};
const defaultColumns: TableColumn<DocsTableRow>[] = [
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<DocsTableRow>['actions'] = [
actionFactories.createCopyDocsUrlAction(copyToClipboard),
@@ -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 (
<CursorPaginatedDocsTable
columns={columns || defaultColumns}
isLoading={loading}
title={title}
actions={actions || defaultActions}
options={options}
data={documents}
next={pageInfo?.next}
prev={pageInfo?.prev}
/>
);
} else if (paginationMode === 'offset') {
return (
<OffsetPaginatedDocsTable
columns={columns || defaultColumns}
isLoading={loading}
title={title}
actions={actions || defaultActions}
options={options}
data={documents}
/>
);
}
if (error) {
return (
<WarningPanel
@@ -0,0 +1,111 @@
/*
* 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 } from '@testing-library/react';
import { DocsTableRow } from './types';
import { renderInTestApp } from '@backstage/test-utils';
import {
DefaultEntityFilters,
EntityListContextProps,
} from '@backstage/plugin-catalog-react';
import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils';
import { OffsetPaginatedDocsTable } from './OffsetPaginatedDocsTable';
describe('OffsetPaginatedDocsTable', () => {
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<EntityListContextProps<DefaultEntityFilters>>,
) => {
return (
<MockEntityListContextProvider value={value}>
{node}
</MockEntityListContextProvider>
);
};
it('should display all the items', async () => {
await renderInTestApp(
wrapInContext(
<OffsetPaginatedDocsTable data={data} columns={columns} />,
{
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(
<OffsetPaginatedDocsTable data={data} columns={columns} />,
{ 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);
});
});
@@ -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<DocsTableRow>) {
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 (
<Table<DocsTableRow>
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}
/>
);
}
@@ -85,3 +85,11 @@ export const columnFactories = {
};
},
};
export const defaultColumns: TableColumn<DocsTableRow>[] = [
columnFactories.createTitleColumn({ hidden: true }),
columnFactories.createNameColumn(),
columnFactories.createOwnerColumn(),
columnFactories.createKindColumn(),
columnFactories.createTypeColumn(),
];
@@ -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(', '),
},
};
});
}
@@ -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 = [
@@ -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}
</ContentHeader>
<div className={classes.panelContainer}>
<Panel data-testid="techdocs-custom-panel" entities={shownEntities} />
<EntityListProvider>
<Panel data-testid="techdocs-custom-panel" entities={shownEntities} />
</EntityListProvider>
</div>
</>
);
@@ -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<DocsTableRow>[];
actions?: TableProps<DocsTableRow>['actions'];
ownerPickerMode?: EntityOwnerPickerProps['mode'];
pagination?: EntityListPagination;
};
export const TechDocsIndexPage = (props: TechDocsIndexPageProps) => {