diff --git a/.changeset/blue-crabs-rule.md b/.changeset/blue-crabs-rule.md new file mode 100644 index 00000000000..ee61b0dae0d --- /dev/null +++ b/.changeset/blue-crabs-rule.md @@ -0,0 +1,5 @@ +--- +"@razorpay/blade": minor +--- + +chore: add server-side pagination with `totalItemCount` & `paginationType` diff --git a/packages/blade/src/components/Table/Table.web.tsx b/packages/blade/src/components/Table/Table.web.tsx index 637a1dc5994..98e4b705ffd 100644 --- a/packages/blade/src/components/Table/Table.web.tsx +++ b/packages/blade/src/components/Table/Table.web.tsx @@ -17,7 +17,7 @@ import { tableBackgroundColor, tablePagination, } from './tokens'; -import type { TableProps, TableNode, Identifier } from './types'; +import type { TableProps, TableNode, Identifier, TablePaginationType } from './types'; import { makeBorderSize, makeMotionTime } from '~utils'; import { getComponentId, isValidAllowedChildren } from '~utils/isValidAllowedChildren'; import { throwBladeError } from '~utils/logger'; @@ -131,6 +131,9 @@ const _Table = ({ const [selectedRows, setSelectedRows] = React.useState['id'][]>([]); const [disabledRows, setDisabledRows] = React.useState['id'][]>([]); const [totalItems, setTotalItems] = React.useState(data.nodes.length || 0); + const [paginationType, setPaginationType] = React.useState>( + 'client', + ); // Need to make header is sticky if first column is sticky otherwise the first header cell will not be sticky const shouldHeaderBeSticky = isHeaderSticky ?? isFirstColumnSticky; const backgroundColor = tableBackgroundColor; @@ -318,12 +321,18 @@ const _Table = ({ const hasPagination = Boolean(pagination); - const paginationConfig = usePagination(data, { - state: { - page: 0, - size: tablePagination.defaultPageSize, + const paginationConfig = usePagination( + data, + { + state: { + page: 0, + size: tablePagination.defaultPageSize, + }, }, - }); + { + isServer: paginationType === 'server', + }, + ); const currentPaginationState = useMemo(() => { return hasPagination @@ -376,6 +385,8 @@ const _Table = ({ showStripedRows, disabledRows, setDisabledRows, + paginationType, + setPaginationType, backgroundColor, }), [ @@ -394,6 +405,8 @@ const _Table = ({ showStripedRows, disabledRows, setDisabledRows, + paginationType, + setPaginationType, backgroundColor, ], ); diff --git a/packages/blade/src/components/Table/TableContext.tsx b/packages/blade/src/components/Table/TableContext.tsx index 06bee340e87..deb87ed2a2c 100644 --- a/packages/blade/src/components/Table/TableContext.tsx +++ b/packages/blade/src/components/Table/TableContext.tsx @@ -2,7 +2,7 @@ // eslint-disable-next-line @typescript-eslint/no-empty-function import React from 'react'; import type { TableNode } from '@table-library/react-table-library/table'; -import type { TableBackgroundColors, TableProps } from './types'; +import type { TableBackgroundColors, TableProps, TablePaginationType } from './types'; export type TableContextType = { selectionType?: TableProps['selectionType']; @@ -27,6 +27,8 @@ export type TableContextType = { showStripedRows?: boolean; disabledRows: TableNode['id'][]; setDisabledRows: React.Dispatch>; + paginationType: NonNullable; + setPaginationType: React.Dispatch>>; backgroundColor: TableBackgroundColors; }; @@ -47,6 +49,8 @@ const TableContext = React.createContext({ setPaginationRowSize: () => {}, disabledRows: [], setDisabledRows: () => {}, + paginationType: 'client', + setPaginationType: () => {}, backgroundColor: 'surface.background.gray.intense', }); diff --git a/packages/blade/src/components/Table/TablePagination.web.tsx b/packages/blade/src/components/Table/TablePagination.web.tsx index 8d175e1f6cb..007a595e93a 100644 --- a/packages/blade/src/components/Table/TablePagination.web.tsx +++ b/packages/blade/src/components/Table/TablePagination.web.tsx @@ -3,7 +3,7 @@ import styled from 'styled-components'; import { useTableContext } from './TableContext'; import { ComponentIds } from './componentIds'; import { tablePagination } from './tokens'; -import type { TablePaginationProps } from './types'; +import type { TablePaginationCommonProps, TablePaginationProps } from './types'; import isUndefined from '~utils/lodashButBetter/isUndefined'; import getIn from '~utils/lodashButBetter/get'; import BaseBox from '~components/Box/BaseBox'; @@ -23,9 +23,10 @@ import { Button } from '~components/Button'; import { makeAccessible } from '~utils/makeAccessible'; import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; import { useTheme } from '~components/BladeProvider'; +import { throwBladeError } from '~utils/logger'; import { getFocusRingStyles } from '~utils/getFocusRingStyles'; -const pageSizeOptions: NonNullable[] = [10, 25, 50]; +const pageSizeOptions: NonNullable[] = [10, 25, 50]; const PageSelectionButton = styled.button<{ isSelected?: boolean }>(({ theme, isSelected }) => ({ backgroundColor: isSelected @@ -145,12 +146,15 @@ const _TablePagination = ({ showPageNumberSelector = false, showLabel, label, + totalItemCount, + paginationType = 'client', }: TablePaginationProps): React.ReactElement => { const { setPaginationPage, currentPaginationState, totalItems, setPaginationRowSize, + setPaginationType, } = useTableContext(); const [currentPageSize, setCurrentPageSize] = React.useState(defaultPageSize); const [currentPage, setCurrentPage] = React.useState( @@ -170,6 +174,7 @@ const _TablePagination = ({ const onMobile = platform === 'onMobile'; useEffect(() => { setPaginationRowSize(currentPageSize); + setPaginationType(paginationType); }, []); useEffect(() => { @@ -178,7 +183,9 @@ const _TablePagination = ({ } }, [currentPage, currentPaginationState?.page, setPaginationPage]); - const totalPages = Math.ceil(totalItems / currentPageSize); + const totalPages = isUndefined(totalItemCount) + ? Math.ceil(totalItems / currentPageSize) + : Math.ceil(totalItemCount / currentPageSize); const handlePageChange = useCallback( (page: number): void => { @@ -206,6 +213,16 @@ const _TablePagination = ({ } }, [controlledCurrentPage, currentPage, handlePageChange, onPageChange]); + if (__DEV__) { + if (paginationType === 'server' && (isUndefined(totalItemCount) || isUndefined(onPageChange))) { + throwBladeError({ + message: + '`onPageChange` and `totalItemCount` props are required when paginationType is server.', + moduleName: 'TablePagination', + }); + } + } + const handlePageSizeChange = (pageSize: number): void => { onPageSizeChange?.({ pageSize }); setPaginationRowSize(pageSize); diff --git a/packages/blade/src/components/Table/__tests__/Table.web.test.tsx b/packages/blade/src/components/Table/__tests__/Table.web.test.tsx index 2d6e61a7f43..a088dd888bc 100644 --- a/packages/blade/src/components/Table/__tests__/Table.web.test.tsx +++ b/packages/blade/src/components/Table/__tests__/Table.web.test.tsx @@ -1,4 +1,5 @@ import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import { fireEvent, waitFor } from '@testing-library/react'; import { Table } from '../Table'; import { TableBody, TableCell, TableRow } from '../TableBody'; @@ -752,7 +753,7 @@ describe('', () => { expect(onSelectionChange).toHaveBeenCalledWith({ values: [] }); }); - it('should render table with pagination', async () => { + it('should render table with client side pagination', async () => { const onPageChange = jest.fn(); const onPageSizeChange = jest.fn(); const user = userEvent.setup(); @@ -840,4 +841,124 @@ describe('
', () => { fireEvent.click(goBack5PagesButton); expect(onPageChange).toHaveBeenLastCalledWith({ page: 0 }); }, 10000); + + it('should render table with server side pagination', () => { + const ServerPaginatedTable = (): React.ReactElement => { + const [apiData, setAPIData] = useState({ nodes: nodes.slice(0, 10) }); + const onPageChange = ({ page }: { page: number }): void => { + setAPIData({ nodes: nodes.slice(page * 10, page * 10 + 10) }); + }; + + return ( +
+ } + > + {(tableData) => ( + <> + + + Payment ID + Amount + Status + Type + Method + Name + + + + {tableData.map((tableItem, index) => ( + + {tableItem.paymentId} + {tableItem.amount} + {tableItem.status} + {tableItem.type} + {tableItem.method} + {tableItem.name} + + ))} + + + )} +
+ ); + }; + const { getByLabelText, queryByText } = renderWithTheme(); + const nextPageButton = getByLabelText('Next Page'); + const previousPageButton = getByLabelText('Previous Page'); + // Check if pagination buttons work + expect(nextPageButton).toBeInTheDocument(); + expect(previousPageButton).toBeInTheDocument(); + expect(queryByText('rzp01')).toBeInTheDocument(); + // Go to next page + fireEvent.click(nextPageButton); + expect(queryByText('rzp01')).not.toBeInTheDocument(); + expect(queryByText('rzp11')).toBeInTheDocument(); + // Go to previous page + fireEvent.click(previousPageButton); + expect(queryByText('rzp01')).toBeInTheDocument(); + }); + + beforeAll(() => jest.spyOn(console, 'error').mockImplementation()); + afterAll(() => jest.restoreAllMocks()); + + it('should throw error for missing props in server side pagination', () => { + const ServerPaginatedTable = (): React.ReactElement => { + const [apiData] = useState({ nodes: nodes.slice(0, 10) }); + + return ( + + } + > + {(tableData) => ( + <> + + + Payment ID + Amount + Status + Type + Method + Name + + + + {tableData.map((tableItem, index) => ( + + {tableItem.paymentId} + {tableItem.amount} + {tableItem.status} + {tableItem.type} + {tableItem.method} + {tableItem.name} + + ))} + + + )} +
+ ); + }; + try { + renderWithTheme(); + } catch (error: unknown) { + if (error instanceof Error) { + expect(error.message).toEqual( + '[Blade: TablePagination]: `onPageChange` and `totalItemCount` props are required when paginationType is server.', + ); + } + } + }); }); diff --git a/packages/blade/src/components/Table/docs/TableExamples.stories.tsx b/packages/blade/src/components/Table/docs/TableExamples.stories.tsx index 9a407a0a0d5..3b54b4a7271 100644 --- a/packages/blade/src/components/Table/docs/TableExamples.stories.tsx +++ b/packages/blade/src/components/Table/docs/TableExamples.stories.tsx @@ -10,7 +10,6 @@ import { MultiSelectableWithZebraStripesStory, TableWithStickyHeaderAndFooterStory, TableWithStickyFirstColumnStory, - TableWithPaginationStory, TableWithDisabledRowsStory, TableWithBackgroundColorStory, TableWithIsLoadingStory, @@ -101,14 +100,6 @@ export const MultiSelectableWithZebraStripes = (): React.ReactElement => { ); }; -export const TableWithPagination = (): React.ReactElement => { - return ( - - {TableWithPaginationStory} - - ); -}; - export const TableWithDisabledRows = (): React.ReactElement => { return ( diff --git a/packages/blade/src/components/Table/docs/TablePaginationExamples.stories.tsx b/packages/blade/src/components/Table/docs/TablePaginationExamples.stories.tsx new file mode 100644 index 00000000000..1d630901a78 --- /dev/null +++ b/packages/blade/src/components/Table/docs/TablePaginationExamples.stories.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import type { Meta } from '@storybook/react'; +import { Table } from '../Table'; +import { TableWithClientSidePaginationStory, TableWithServerSidePaginationStory } from './stories'; +import { Sandbox } from '~utils/storybook/Sandbox'; + +const TableMeta: Meta = { + title: 'Components/Table/Examples/Pagination', + component: Table, + parameters: { + viewMode: 'story', + options: { + showPanel: false, + }, + previewTabs: { + 'storybook/docs/panel': { + hidden: true, + }, + }, + chromatic: { disableSnapshot: true }, + }, +}; + +export const TableWithClientSidePagination = (): React.ReactElement => { + return ( + + {TableWithClientSidePaginationStory} + + ); +}; + +export const TableWithServerSidePagination = (): React.ReactElement => { + return ( + + {TableWithServerSidePaginationStory} + + ); +}; + +export default TableMeta; diff --git a/packages/blade/src/components/Table/docs/stories.ts b/packages/blade/src/components/Table/docs/stories.ts index 7e40cc96d2e..0e0cb09a3af 100644 --- a/packages/blade/src/components/Table/docs/stories.ts +++ b/packages/blade/src/components/Table/docs/stories.ts @@ -1069,7 +1069,7 @@ function App(): React.ReactElement { export default App; `; -const TableWithPaginationStory = ` +const TableWithClientSidePaginationStory = ` import { Table, Code, @@ -1817,6 +1817,128 @@ function App(): React.ReactElement { export default App; `; +const TableWithServerSidePaginationStory = ` +import { + Table, + Box, + TableHeader, + TableHeaderRow, + TableHeaderCell, + TableBody, + TableRow, + TableCell, + TablePagination, +} from '@razorpay/blade/components'; +import React, { useEffect, useState } from 'react'; + +type APIResult = { + info: { + count: number; + pages: number; + }; + results: { + id: number; + name: string; + species: string; + status: string; + origin: { name: string }; + }[]; +}; + +const fetchData = async ({ page }: { page: number }): Promise => { + const response = await fetch( + \`https://rickandmortyapi.com/api/character?page=\${page}\`, + { + method: 'GET', + redirect: 'follow', + } + ); + const result = await response.json(); + return result as APIResult; +}; + +function App(): React.ReactElement { + const [apiData, setApiData] = useState<{ nodes: APIResult['results'] }>({ + nodes: [], + }); + const [dataCount, setDataCount] = useState(0); + const [isRefreshing, setIsRefreshing] = useState(false); + + useEffect(() => { + if (apiData.nodes.length === 0) { + fetchData({ page: 1 }).then((res) => { + // rick & morty api returns 20 items and we cannot change that. Hence limiting to show only first 10 items from the result of this API. Ideally an API should have \`limit\` & \`offset\` params that help us define the response we get. + const firstTenItems = res.results.slice(0, 10); + setApiData({ nodes: firstTenItems }); + setDataCount(res.info.count / 2); // rick & morty api returns 20 items and we cannot change that. Hence dividing by 2 to get the actual count. + }); + } + }, []); + + const handlePageChange = ({ page }: { page: number }) => { + setIsRefreshing(true); + fetchData({ page: page + 1 }).then((res) => { + // rick & morty api returns 20 items and we cannot change that. Hence limiting to show only first 10 items from the result of this API. Ideally an API should have \`limit\` & \`offset\` params that help us define the response we get. + const firstTenItems = res.results.slice(0, 10); + // When paginationType is server, we are assuming that pagination is taken care of on the server and we are receiving and displaying per page data. + // Instead of appending the new data to existing data like we do for client side pagination, we replace the existing data with the new data for server side pagination. + // All of the data will be rendered when paginationType is server. So on every page change, we fetch new data for the specific page and replace the existing data with the new data. + setApiData({ nodes: firstTenItems }); + setDataCount(res.info.count / 2); // rick & morty api returns 20 items and we cannot change that. Hence dividing by 2 to get the actual count. + setIsRefreshing(false); + }); + }; + + return ( + + + } + > + {(tableData) => ( + <> + + + Name + Origin + Species + Status + + + + {tableData.map((tableItem, index) => ( + + {tableItem.name} + {tableItem.origin.name} + {tableItem.species} + {tableItem.status} + + ))} + + + )} +
+
+ ); +} + +export default App; +`; + export { BasicTableStory, TableWithCustomCellComponentsStory, @@ -1826,9 +1948,10 @@ export { MultiSelectableWithZebraStripesStory, TableWithStickyHeaderAndFooterStory, TableWithStickyFirstColumnStory, - TableWithPaginationStory, TableWithDisabledRowsStory, TableWithBackgroundColorStory, TableWithIsLoadingStory, TableWithIsRefreshingStory, + TableWithClientSidePaginationStory, + TableWithServerSidePaginationStory, }; diff --git a/packages/blade/src/components/Table/types.ts b/packages/blade/src/components/Table/types.ts index 50965363e4d..cf5f9f6c16b 100644 --- a/packages/blade/src/components/Table/types.ts +++ b/packages/blade/src/components/Table/types.ts @@ -252,7 +252,7 @@ type TableFooterCellProps = { children: string | React.ReactNode; }; -type TablePaginationProps = { +type TablePaginationCommonProps = { /** * The default page size. * Page size controls how rows are shown per page. @@ -263,10 +263,7 @@ type TablePaginationProps = { * The current page. Passing this prop will make the component controlled and will not update the page on its own. **/ currentPage?: number; - /** - * Callback function that is called when the page is changed - */ - onPageChange?: ({ page }: { page: number }) => void; + /** * Callback function that is called when the page size is changed */ @@ -295,6 +292,49 @@ type TablePaginationProps = { showLabel?: boolean; }; +type TablePaginationType = 'client' | 'server'; + +type TablePaginationServerProps = TablePaginationCommonProps & { + /** + * Whether the pagination is happening on client or server. + * If the pagination is happening on `client`, the Table component will **divide the data into pages** and show the pages based on the page size. + * If the pagination is happening on `server`, the Table component will **not divide the data into pages and will show all the data**. You will have to fetch data for each page as the page changes and pass it to the Table component. + * When paginationType is `server`, the `onPageChange` & `totalItemCount` props are required. + * @default 'client' + * */ + paginationType?: Extract; + /** + * The total number of possible items in the table. This is used to calculate the total number of pages when pagination is happening on server and not all the data is fetched at once. + */ + totalItemCount: number; + /** + * Callback function that is called when the page is changed + */ + onPageChange: ({ page }: { page: number }) => void; +}; + +type TablePaginationClientProps = TablePaginationCommonProps & { + /** + * Whether the pagination is happening on client or server. + * If the pagination is happening on `client`, the Table component will **divide the data into pages** and show the pages based on the page size. + * If the pagination is happening on `server`, the Table component will **not divide the data into pages and will show all the data**. You will have to fetch data for each page as the page changes and pass it to the Table component. + * When paginationType is `server`, the `onPageChange` & `totalItemCount` props are required. + * @default 'client' + * */ + paginationType?: Extract; + /** + * The total number of possible items in the table. This is used to calculate the total number of pages when pagination is happening on server and not all the data is fetched at once. + */ + totalItemCount?: number; + /** + * Callback function that is called when the page is changed + */ + onPageChange?: ({ page }: { page: number }) => void; +}; + +type TablePaginationProps = TablePaginationCommonProps & + (TablePaginationServerProps | TablePaginationClientProps); + type TableToolbarProps = { /** * The children of TableToolbar should be TableToolbarActions @@ -334,4 +374,6 @@ export type { TableToolbarProps, TableToolbarActionsProps, TableBackgroundColors, + TablePaginationType, + TablePaginationCommonProps, };