diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f51bee40f..f535749b294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Added `popoverButton` and `popoverButtonBreakpoints` props to `EuiSelectableTemplateSitewide` for responsive capabilities ([#4008](https://github.com/elastic/eui/pull/4008)) - Added `isWithinMaxBreakpoint` service ([#4008](https://github.com/elastic/eui/pull/4008)) - Added horizontal line separator to `EuiContextMenu` ([#4018](https://github.com/elastic/eui/pull/4018)) +- Added controlled pagination props to `EuiInMemoryTablee` ([#4038](https://github.com/elastic/eui/pull/4038)) **Bug fixes** diff --git a/src-docs/src/views/tables/in_memory/in_memory_controlled_pagination.js b/src-docs/src/views/tables/in_memory/in_memory_controlled_pagination.js new file mode 100644 index 00000000000..b230097aaea --- /dev/null +++ b/src-docs/src/views/tables/in_memory/in_memory_controlled_pagination.js @@ -0,0 +1,119 @@ +import React, { useEffect, useState } from 'react'; +import { formatDate } from '../../../../../src/services/format'; +import { createDataStore } from '../data_store'; +import { + EuiInMemoryTable, + EuiLink, + EuiHealth, +} from '../../../../../src/components'; + +/* +Example user object: + +{ + id: '1', + firstName: 'john', + lastName: 'doe', + github: 'johndoe', + dateOfBirth: Date.now(), + nationality: 'NL', + online: true +} + +Example country object: + +{ + code: 'NL', + name: 'Netherlands', + flag: '🇳🇱' +} +*/ + +const store = createDataStore(); + +export const Table = () => { + const columns = [ + { + field: 'firstName', + name: 'First Name', + sortable: true, + truncateText: true, + }, + { + field: 'lastName', + name: 'Last Name', + truncateText: true, + }, + { + field: 'github', + name: 'Github', + render: username => ( + + {username} + + ), + }, + { + field: 'dateOfBirth', + name: 'Date of Birth', + dataType: 'date', + render: date => formatDate(date, 'dobLong'), + sortable: true, + }, + { + field: 'nationality', + name: 'Nationality', + render: countryCode => { + const country = store.getCountry(countryCode); + return `${country.flag} ${country.name}`; + }, + }, + { + field: 'online', + name: 'Online', + dataType: 'boolean', + render: online => { + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return {label}; + }, + sortable: true, + }, + ]; + + const sorting = { + sort: { + field: 'dateOfBirth', + direction: 'desc', + }, + }; + + const [users, setUsers] = useState(store.users); + + useEffect(() => { + const updateInterval = setInterval(() => { + setUsers(users => + // randomly toggle some of the online statuses + users.map(({ online, ...user }) => ({ + ...user, + online: Math.random() > 0.7 ? !online : online, + })) + ); + }, 1000); + return () => clearInterval(updateInterval); + }, []); + + const [pagination, setPagination] = useState({ pageIndex: 0 }); + + return ( + + setPagination({ pageIndex: index }) + } + items={users} + columns={columns} + pagination={pagination} + sorting={sorting} + /> + ); +}; diff --git a/src-docs/src/views/tables/in_memory/in_memory_controlled_pagination_section.js b/src-docs/src/views/tables/in_memory/in_memory_controlled_pagination_section.js new file mode 100644 index 00000000000..2867c236802 --- /dev/null +++ b/src-docs/src/views/tables/in_memory/in_memory_controlled_pagination_section.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { EuiCode } from '../../../../../src/components'; +import { GuideSectionTypes } from '../../../components'; +import { renderToHtml } from '../../../services'; + +import { Table } from './in_memory_controlled_pagination'; +import { propsInfo } from './props_info'; + +const source = require('!!raw-loader!./in_memory_controlled_pagination'); +const html = renderToHtml(Table); + +export const controlledPaginationSection = { + title: 'In-memory table with controlled pagination', + source: [ + { + type: GuideSectionTypes.JS, + code: source, + }, + { + type: GuideSectionTypes.HTML, + code: html, + }, + ], + text: ( +
+

+ By default EuiInMemoryTable resets its page index + when receiving a new EuiInMemoryTable array. To avoid + this behavior the pagination object optionally takes a + pageIndex value to control this yourself. + Additionally, pageSize can also be controlled the + same way. Both of these are provided to your app during the + onTableChange callback. +

+

+ The example below updates the array of users every second, randomly + toggling their online status. Pagination state is maintained by the app, + preventing it from being reset by the updates. +

+
+ ), + props: propsInfo, + demo: , +}; diff --git a/src-docs/src/views/tables/in_memory/index.js b/src-docs/src/views/tables/in_memory/index.js index f7b303a0e9b..a820ed11be7 100644 --- a/src-docs/src/views/tables/in_memory/index.js +++ b/src-docs/src/views/tables/in_memory/index.js @@ -4,3 +4,4 @@ export { searchSection } from './in_memory_search_section'; export { searchExternalSection } from './in_memory_search_external_section'; export { searchCallbackSection } from './in_memory_search_callback_section'; export { customSortingSection } from './in_memory_custom_sorting_section'; +export { controlledPaginationSection } from './in_memory_controlled_pagination_section'; diff --git a/src-docs/src/views/tables/in_memory/props_info.js b/src-docs/src/views/tables/in_memory/props_info.js index 8dc550818b2..6055eed75e8 100644 --- a/src-docs/src/views/tables/in_memory/props_info.js +++ b/src-docs/src/views/tables/in_memory/props_info.js @@ -85,6 +85,18 @@ export const propsInfo = { required: false, type: { name: 'number' }, }, + pageIndex: { + description: + "When present, controls the table's pagination index. You must listen to the onTableChange callback to respond to user actions. Ignores any initialPageIndex value", + required: false, + type: { name: 'number' }, + }, + pageSize: { + description: + "When present, controls the table's page size. You must listen to the onTableChange callback to respond to user actions. Ignores any initialPageSize value", + required: false, + type: { name: 'number' }, + }, pageSizeOptions: basicPropsInfo.Pagination.__docgenInfo.props.pageSizeOptions, hidePerPageOptions: diff --git a/src-docs/src/views/tables/tables_in_memory_example.js b/src-docs/src/views/tables/tables_in_memory_example.js index 204dfd4663e..d778b126e67 100644 --- a/src-docs/src/views/tables/tables_in_memory_example.js +++ b/src-docs/src/views/tables/tables_in_memory_example.js @@ -5,6 +5,7 @@ import { searchExternalSection as inMemorySearchExternalSection, searchCallbackSection as inMemorySearchCallbackSection, customSortingSection as inMemoryCustomSortingSection, + controlledPaginationSection as inMemoryControlledPaginationSection, } from './in_memory'; export const TableInMemoryExample = { @@ -16,5 +17,6 @@ export const TableInMemoryExample = { inMemorySearchCallbackSection, inMemorySearchExternalSection, inMemoryCustomSortingSection, + inMemoryControlledPaginationSection, ], }; diff --git a/src/components/basic_table/in_memory_table.test.tsx b/src/components/basic_table/in_memory_table.test.tsx index b6e8ed25082..00e90ce12ab 100644 --- a/src/components/basic_table/in_memory_table.test.tsx +++ b/src/components/basic_table/in_memory_table.test.tsx @@ -1001,4 +1001,219 @@ describe('EuiInMemoryTable', () => { }); }); }); + + describe('controlled pagination', () => { + it('respects pageIndex', () => { + const pagination = { + initialPageIndex: 2, + pageIndex: 1, + pageSizeOptions: [2], + }; + const items = [ + { index: 0 }, + { index: 1 }, + { index: 2 }, + { index: 3 }, + { index: 4 }, + { index: 5 }, + ]; + const columns = [ + { + field: 'index', + name: 'Index', + }, + ]; + const onTableChange = jest.fn(); + const component = mount( + + ); + + // ensure table is on 2nd page (pageIndex=1) + expect( + component.find('button[data-test-subj="pagination-button-1"][disabled]') + .length + ).toBe(1); + expect( + component + .find('td') + .at(0) + .text() + ).toBe('Index2'); + expect( + component + .find('td') + .at(1) + .text() + ).toBe('Index3'); + + // click the first pagination button + component + .find('EuiButtonEmpty[data-test-subj="pagination-button-0"]') + .simulate('click'); + expect(onTableChange).toHaveBeenCalledTimes(1); + expect(onTableChange).toHaveBeenCalledWith({ + sort: {}, + page: { + index: 0, + size: 2, + }, + }); + + // ensure table is still on the 2nd page (pageIndex=1) + expect( + component.find('button[data-test-subj="pagination-button-1"][disabled]') + .length + ).toBe(1); + expect( + component + .find('td') + .at(0) + .text() + ).toBe('Index2'); + expect( + component + .find('td') + .at(1) + .text() + ).toBe('Index3'); + + // re-render with an updated `pageIndex` value + pagination.pageIndex = 2; + component.setProps({ pagination }); + + // ensure table is on 3rd page (pageIndex=2) + expect( + component.find('button[data-test-subj="pagination-button-2"][disabled]') + .length + ).toBe(1); + expect( + component + .find('td') + .at(0) + .text() + ).toBe('Index4'); + expect( + component + .find('td') + .at(1) + .text() + ).toBe('Index5'); + }); + + it('respects pageSize', () => { + const pagination = { + pageSize: 2, + initialPageSize: 4, + pageSizeOptions: [1, 2, 4], + }; + const items = [ + { index: 0 }, + { index: 1 }, + { index: 2 }, + { index: 3 }, + { index: 4 }, + { index: 5 }, + ]; + const columns = [ + { + field: 'index', + name: 'Index', + }, + ]; + const onTableChange = jest.fn(); + const component = mount( + + ); + + // check that the first 2 items rendered + expect(component.find('td').length).toBe(2); + expect( + component + .find('td') + .at(0) + .text() + ).toBe('Index0'); + expect( + component + .find('td') + .at(1) + .text() + ).toBe('Index1'); + + // change the page size + component + .find('button[data-test-subj="tablePaginationPopoverButton"]') + .simulate('click'); + component.update(); + component + .find('button[data-test-subj="tablePagination-4-rows"]') + .simulate('click'); + + // check callback + expect(onTableChange).toHaveBeenCalledTimes(1); + expect(onTableChange).toHaveBeenCalledWith({ + sort: {}, + page: { + index: 0, + size: 4, + }, + }); + + // verify still only rendering the first 2 rows + expect(component.find('td').length).toBe(2); + expect( + component + .find('td') + .at(0) + .text() + ).toBe('Index0'); + expect( + component + .find('td') + .at(1) + .text() + ).toBe('Index1'); + + // update the controlled page size + pagination.pageSize = 4; + component.setProps({ pagination }); + + // verify it now renders 4 rows + expect(component.find('td').length).toBe(4); + expect( + component + .find('td') + .at(0) + .text() + ).toBe('Index0'); + expect( + component + .find('td') + .at(1) + .text() + ).toBe('Index1'); + expect( + component + .find('td') + .at(2) + .text() + ).toBe('Index2'); + expect( + component + .find('td') + .at(3) + .text() + ).toBe('Index3'); + }); + }); }); diff --git a/src/components/basic_table/in_memory_table.tsx b/src/components/basic_table/in_memory_table.tsx index 9a8e97b3b8e..2c7f7ab5d41 100644 --- a/src/components/basic_table/in_memory_table.tsx +++ b/src/components/basic_table/in_memory_table.tsx @@ -58,10 +58,12 @@ function isEuiSearchBarProps( type Search = boolean | EuiSearchBarProps; interface PaginationOptions { - initialPageIndex?: number; - initialPageSize?: number; pageSizeOptions?: number[]; hidePerPageOptions?: boolean; + initialPageIndex?: number; + initialPageSize?: number; + pageIndex?: number; + pageSize?: number; } type Pagination = boolean | PaginationOptions; @@ -149,12 +151,23 @@ const getInitialPagination = (pagination: Pagination | undefined) => { } const { - initialPageIndex = 0, - initialPageSize, pageSizeOptions = paginationBarDefaults.pageSizeOptions, hidePerPageOptions, } = pagination as PaginationOptions; + const defaultPageSize = pageSizeOptions + ? pageSizeOptions[0] + : paginationBarDefaults.pageSizeOptions[0]; + + const initialPageIndex = + pagination === true + ? 0 + : pagination.pageIndex || pagination.initialPageIndex || 0; + const initialPageSize = + pagination === true + ? defaultPageSize + : pagination.pageSize || pagination.initialPageSize || defaultPageSize; + if ( !hidePerPageOptions && initialPageSize && @@ -165,13 +178,9 @@ const getInitialPagination = (pagination: Pagination | undefined) => { ); } - const defaultPageSize = pageSizeOptions - ? pageSizeOptions[0] - : paginationBarDefaults.pageSizeOptions[0]; - return { pageIndex: initialPageIndex, - pageSize: initialPageSize || defaultPageSize, + pageSize: initialPageSize, pageSizeOptions, hidePerPageOptions, }; @@ -247,19 +256,52 @@ export class EuiInMemoryTable extends Component< prevState: State ) { let updatedPrevState = prevState; - let componentShouldUpdate = false; if (nextProps.items !== prevState.prevProps.items) { // We have new items because an external search has completed, so reset pagination state. - componentShouldUpdate = true; + + let nextPageIndex = 0; + if ( + nextProps.pagination != null && + typeof nextProps.pagination !== 'boolean' + ) { + nextPageIndex = nextProps.pagination.pageIndex || 0; + } + updatedPrevState = { ...updatedPrevState, prevProps: { ...updatedPrevState.prevProps, items: nextProps.items, }, - pageIndex: 0, + pageIndex: nextPageIndex, }; } + + // apply changes to controlled pagination + if ( + nextProps.pagination != null && + typeof nextProps.pagination !== 'boolean' + ) { + if ( + nextProps.pagination.pageSize != null && + nextProps.pagination.pageSize !== updatedPrevState.pageIndex + ) { + updatedPrevState = { + ...updatedPrevState, + pageSize: nextProps.pagination.pageSize, + }; + } + if ( + nextProps.pagination.pageIndex != null && + nextProps.pagination.pageIndex !== updatedPrevState.pageIndex + ) { + updatedPrevState = { + ...updatedPrevState, + pageIndex: nextProps.pagination.pageIndex, + }; + } + } + const { sortName, sortDirection } = getInitialSorting( nextProps.columns, nextProps.sorting @@ -268,7 +310,6 @@ export class EuiInMemoryTable extends Component< sortName !== prevState.prevProps.sortName || sortDirection !== prevState.prevProps.sortDirection ) { - componentShouldUpdate = true; updatedPrevState = { ...updatedPrevState, sortName, @@ -284,7 +325,6 @@ export class EuiInMemoryTable extends Component< : ''; if (nextQuery !== prevQuery) { - componentShouldUpdate = true; updatedPrevState = { ...updatedPrevState, prevProps: { @@ -294,7 +334,7 @@ export class EuiInMemoryTable extends Component< query: getQueryFromSearch(nextProps.search, false), }; } - if (componentShouldUpdate) { + if (updatedPrevState !== prevState) { return updatedPrevState; } return null; @@ -340,11 +380,19 @@ export class EuiInMemoryTable extends Component< } onTableChange = ({ page, sort }: Criteria) => { - const { index: pageIndex, size: pageSize } = (page || {}) as { + let { index: pageIndex, size: pageSize } = (page || {}) as { index: number; size: number; }; + // don't apply pagination changes that are otherwise controlled + // `page` is left unchanged as it goes to the consumer's `onTableChange` callback, allowing the app to respond + const { pagination } = this.props; + if (pagination != null && typeof pagination !== 'boolean') { + if (pagination.pageSize != null) pageSize = pagination.pageSize; + if (pagination.pageIndex != null) pageIndex = pagination.pageIndex; + } + let { field: sortName, direction: sortDirection } = (sort || {}) as { field: keyof T; direction: Direction;