diff --git a/CHANGELOG.md b/CHANGELOG.md index edd2c2078..35467a070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,22 @@ This project adheres to [Semantic Versioning](http://semver.org/). [#597](https://github.com/plotly/dash-table/issues/597) - Add `is blank` unary operator. Returns true for `undefined`, `null` and `''`. +[#299](https://github.com/plotly/dash-table/issues/299) +- New prop `page_count` that sets the maximum number of pages that are + accessible via the pagination menu when using backend pagination. + ### Changed [#598](https://github.com/plotly/dash-table/issues/598) - Allow values with whitespaces in column filters +[#580](https://github.com/plotly/dash-table/issues/580) +- Change pagination menu button UI to use arrow icons instead of plain + buttons +- Move pagination menu to bottom-right of table +- Include go-to-first and go-to-last buttons +- Include current-page and total-pages display in pagination menu +- Include input box for user to navigate directly to a page + ### Fixed [#460](https://github.com/plotly/dash-table/issues/460) - The `datestartswith` relational operator now supports number comparison diff --git a/package.json b/package.json index 95f3131bd..734e7c465 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "private::host_dash8083": "python tests/cypress/dash/v_fe_page.py", "private::host_dash8084": "python tests/cypress/dash/v_data_loading.py", "private::host_dash8085": "python tests/cypress/dash/v_default.py", + "private::host_dash8086": "python tests/cypress/dash/v_pagination.py", "private::host_js": "http-server ./dash_table -c-1 --silent", "private::lint:ts": "tslint --project tsconfig.json --config tslint.json", "private::lint:py": "flake8 --exclude=DataTable.py,__init__.py,_imports_.py dash_table", @@ -35,6 +36,7 @@ "private::wait_dash8083": "wait-on http://localhost:8083", "private::wait_dash8084": "wait-on http://localhost:8084", "private::wait_dash8085": "wait-on http://localhost:8085", + "private::wait_dash8086": "wait-on http://localhost:8086", "private::wait_js": "wait-on http://localhost:8080", "private::opentests": "cypress open", "private::test.python": "python -m unittest tests/unit/format_test.py", diff --git a/src/dash-table/components/ControlledTable/index.tsx b/src/dash-table/components/ControlledTable/index.tsx index e7f5d924d..e2634bcab 100644 --- a/src/dash-table/components/ControlledTable/index.tsx +++ b/src/dash-table/components/ControlledTable/index.tsx @@ -38,6 +38,8 @@ import queryLexicon from 'dash-table/syntax-tree/lexicon/query'; import dataLoading from 'dash-table/derived/table/data_loading'; import reconcile from 'dash-table/type/reconcile'; +import PageNavigation from 'dash-table/components/PageNavigation'; + const DEFAULT_STYLE = { width: '100%' }; @@ -629,18 +631,6 @@ export default class ControlledTable extends PureComponent ) || page_action === TableAction.Custom; } - loadNext = () => { - const { paginator } = this.props; - - paginator.loadNext(); - } - - loadPrevious = () => { - const { paginator } = this.props; - - paginator.loadPrevious(); - } - applyStyle = () => { const { fixed_columns, @@ -807,7 +797,7 @@ export default class ControlledTable extends PureComponent tooltip_duration ); - const { export_columns, export_format, export_headers, virtual, merge_duplicate_headers } = this.props; + const { export_columns, export_format, export_headers, virtual, merge_duplicate_headers, paginator, page_current, page_count } = this.props; const buttonProps = { export_columns, export_format, @@ -820,6 +810,7 @@ export default class ControlledTable extends PureComponent return (
{!this.displayPagination ? null : ( -
- - -
+ )} ); } diff --git a/src/dash-table/components/PageNavigation/index.tsx b/src/dash-table/components/PageNavigation/index.tsx new file mode 100644 index 000000000..14b1743df --- /dev/null +++ b/src/dash-table/components/PageNavigation/index.tsx @@ -0,0 +1,80 @@ +import React, { Component } from 'react'; +import { IPageNavigationProps } from 'dash-table/components/PageNavigation/props'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { KEY_CODES } from 'dash-table/utils/unicode'; + +export default class PageNavigation extends Component { + + constructor(props: IPageNavigationProps) { + super(props); + } + + goToPage = (page_number: string) => { + const { paginator } = this.props; + + let page = parseInt(page_number, 10); + + if (isNaN(page)) { + return; + } + + paginator.loadPage(page - 1); + } + + render() { + const { + paginator, + page_current + } = this.props; + + return (paginator.lastPage !== undefined && paginator.lastPage <= 0) ? null : ( +
+ + + + +
+ { this.goToPage(event.target.value); event.target.value = ''; }} + onKeyDown={event => { if (event.keyCode === KEY_CODES.ENTER) { event.currentTarget.blur(); } }} + placeholder={(page_current + 1).toString()} + defaultValue='' + > + + + {paginator.lastPage !== undefined ? ' / ' : ''} + + {paginator.lastPage !== undefined ?
+ {paginator.lastPage + 1} +
: ''} +
+ + + + +
+ ); + } +} diff --git a/src/dash-table/components/PageNavigation/props.ts b/src/dash-table/components/PageNavigation/props.ts new file mode 100644 index 000000000..7b4c2dadd --- /dev/null +++ b/src/dash-table/components/PageNavigation/props.ts @@ -0,0 +1,7 @@ +import { IPaginator } from 'dash-table/derived/paginator'; + +export interface IPageNavigationProps { + paginator: IPaginator; + page_current: number; + page_count: number | undefined; +} diff --git a/src/dash-table/components/Table/Table.less b/src/dash-table/components/Table/Table.less index de89a2e70..fe3422494 100644 --- a/src/dash-table/components/Table/Table.less +++ b/src/dash-table/components/Table/Table.less @@ -140,409 +140,475 @@ } } -.dash-spreadsheet-container { - .reset-css(); - display: flex; - flex-direction: row; - position: relative; - - // This overrides Chrome's default `font-size: medium;` which is causing performance issues - // with AutoInputResize sub-component in react-select - // https://github.com/JedWatson/react-input-autosize/blob/05b0f86a7f8b16de99c2b31296ff0d3307f15957/src/AutosizeInput.js#L58 - table { - font-size: inherit; +.dash-table-container { + + .previous-next-container { + display: inline-block; + float: right; + padding: 5px 0px; + + + .page-number { + font-family: monospace; + display: inline-block; + .last-page { + min-width: 30px; + display: inline-block; + text-align: center; + margin: 0.5em; + } + } + + input.current-page { + display: inline-block; + border-bottom: solid lightgrey 1px !important; + color: black; + border: none; + width: 30px; + text-align: center; + font-family: monospace; + font-size: 10pt; + margin: 0.5em; + + &::placeholder { + color: black; + } + + &:focus { + outline: none; + + &::placeholder { + opacity: 0; + } + } + } + + button.previous-page, button.next-page, button.first-page, button.last-page { + transition-duration: 400ms; + padding: 5px; + border: none; + display: inline-block; + margin-left: 5px; + margin-right: 5px; + + &:hover { + color: hotpink; + + &:disabled { + color: graytext + } + } + + &:focus { + outline: none; + } + } } - - .dash-spreadsheet-inner { - box-sizing: border-box; - display: flex; - flex-direction: column; - - *, - *:after, - *:before { - box-sizing: inherit; - } - - .Select { - overflow: hidden; - position: static; - } - - .Select, - .Select-control { - background-color: inherit; - } - - .Select-value { + + .dash-spreadsheet-container { + .reset-css(); + display: flex; + flex-direction: row; + position: relative; + + // This overrides Chrome's default `font-size: medium;` which is causing performance issues + // with AutoInputResize sub-component in react-select + // https://github.com/JedWatson/react-input-autosize/blob/05b0f86a7f8b16de99c2b31296ff0d3307f15957/src/AutosizeInput.js#L58 + table { + font-size: inherit; + } + + .dash-spreadsheet-inner { + box-sizing: border-box; display: flex; flex-direction: column; - justify-content: center; - margin-top: -2px; - } - .marker-row { - tr { - visibility: hidden !important; + *, + *:after, + *:before { + box-sizing: inherit; } - td, th { - height: 0 !important; - padding: 0 !important; - margin: 0 !important; + .Select { + overflow: hidden; + position: static; } - } - .dash-filter { - input::placeholder { - color: inherit; - font-size: 0.8em; - padding-right: 5px; + .Select, + .Select-control { + background-color: inherit; } - & + .dash-filter { - &:not(:hover):not(:focus-within) { - input::placeholder { - color: transparent; - } - } + .Select-value { + display: flex; + flex-direction: column; + justify-content: center; + margin-top: -2px; } - &.invalid { - background-color: pink; - } - } + .marker-row { + tr { + visibility: hidden !important; + } - &:not(.dash-empty-11) { - .row-0 { - tr:last-of-type { - td, th { - border-bottom: none !important; - } - } + td, th { + height: 0 !important; + padding: 0 !important; + margin: 0 !important; + } } - } - &:not(.dash-empty-01) { - .cell-0-0, - .cell-1-0 { - tr { - td:last-of-type, - th:last-of-type { - border-right: none !important; + .dash-filter { + input::placeholder { + color: inherit; + font-size: 0.8em; + padding-right: 5px; + } + + & + .dash-filter { + &:not(:hover):not(:focus-within) { + input::placeholder { + color: transparent; + } } - } - } - } + } - &.dash-freeze-left, - &.dash-freeze-top, - &.dash-virtualized { - overflow: hidden !important; - - .row-0 { - display: flex; - flex: 0 0 auto; - flex-direction: row; + &.invalid { + background-color: pink; + } } - .row-1 { - display: flex; - flex-direction: row; - overflow: auto; + &:not(.dash-empty-11) { + .row-0 { + tr:last-of-type { + td, th { + border-bottom: none !important; + } + } + } } - .cell-0-0, - .cell-1-0 { - flex: 0 0 auto; - left: 0; - position: sticky; - position:-webkit-sticky; - z-index: 400; + &:not(.dash-empty-01) { + .cell-0-0, + .cell-1-0 { + tr { + td:last-of-type, + th:last-of-type { + border-right: none !important; + } + } + } } - .cell-0-1 { - z-index: 300; - flex: 0 0 auto; + &.dash-freeze-left, + &.dash-freeze-top, + &.dash-virtualized { + overflow: hidden !important; + + .row-0 { + display: flex; + flex: 0 0 auto; + flex-direction: row; + } + + .row-1 { + display: flex; + flex-direction: row; + overflow: auto; + } + + .cell-0-0, + .cell-1-0 { + flex: 0 0 auto; + left: 0; + position: sticky; + position:-webkit-sticky; + z-index: 400; + } + + .cell-0-1 { + z-index: 300; + flex: 0 0 auto; + } + + .cell-1-1 { + flex: 0 0 auto; + } } - .cell-1-1 { - flex: 0 0 auto; - } - } + &.dash-fill-width { + .cell-0-1, + .cell-1-1 { + flex: 1 0 auto; + } - &.dash-fill-width { - .cell-0-1, - .cell-1-1 { - flex: 1 0 auto; + table { + width: 100%; + } } - table { - width: 100%; + td { + background-color: inherit; + + &.focused { + margin: -1px; + z-index: 200; + } + + .dash-cell-value-container { + width: 100%; + height: 100%; + } + + .dash-input-cell-value-container { + position: relative; + } + + .dash-cell-value { + height: 100%; + width: 100%; + } + + input.dash-cell-value { + position: absolute; + left: 0; + top: 0; + &.unfocused::selection { + background-color: transparent; + } + &.unfocused { + caret-color: transparent; + } + } + + .cell-value-shadow { + margin: auto 0; + opacity: 0; + } + + .input-cell-value-shadow { + display: inline-block; + height: initial; + width: initial; + } + + .dropdown-cell-value-shadow { + display: block; + height: 0px; + padding: 0 42px 0 10px; + } } - } - td { - background-color: inherit; + th.dash-filter { + position: relative; - &.focused { - margin: -1px; - z-index: 200; + & input { + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 100%; + } } - .dash-cell-value-container { - width: 100%; - height: 100%; + th { + white-space: nowrap; + + .column-header--clear, + .column-header--delete, + .column-header--edit, + .column-header--hide + .column-header--sort { + .not-selectable(); + cursor: pointer; + } } - .dash-input-cell-value-container { - position: relative; + // cell content styling + td, th { + background-clip: padding-box; + padding: 2px; + overflow-x: hidden; + white-space: nowrap; + + height: 30px; + + text-align: right; + + div.dash-cell-value { + display: inline; + vertical-align: middle; + white-space: inherit; + overflow: inherit; + text-overflow: inherit; + } } + } + + .dash-spreadsheet-inner textarea { + white-space: pre; + } + + .dash-spreadsheet-inner table { + border-collapse: collapse; + + font-family: monospace; + --accent: hotpink; + --border: lightgrey; + --text-color: rgb(60, 60, 60); + --hover: rgb(253, 253, 253); + --background-color-ellipses: rgb(253, 253, 253); + --faded-text: rgb(250, 250, 250); + --faded-text-header: rgb(180, 180, 180); + --selected-background: rgba(255, 65, 54, 0.2); + --faded-dropdown: rgb(240, 240, 240); + --muted: rgb(200, 200, 200); + } + + /* focus happens after copying to clipboard */ + .dash-spreadsheet-inner table:focus { + outline: none; + } + + .dash-spreadsheet-inner thead { + display: table-row-group; + } + + .elip { + text-align: center; + width: 100%; + background-color: var(--background-color-ellipses); + } + + .dash-spreadsheet-inner td.dropdown { + /* + * To view the dropdown's contents, we need + * overflow-y: visible. + * Unfortunately, overflow-x: hidden and overflow-y: visible + * can't both be set at the same time. + * So, we have to make both overflow-x: visible and overflow-y: visble + * + * See https://stackoverflow.com/questions/6421966/ + * + * There might be another solution with parent divs, but I haven't + * tried it. + */ + overflow-x: visible; + } + + .dash-spreadsheet-inner :not(.cell--selected) tr:hover, + tr:hover input :not(.cell--selected) { + background-color: var(--hover); + } + + .dash-spreadsheet-inner th { + background-color: rgb(250, 250, 250); + } + + .dash-spreadsheet-inner td { + background-color: white; + } + + .expanded-row--empty-cell { + background-color: transparent; + } + + .expanded-row { + text-align: center; + } + + .dash-spreadsheet-inner input:not([type=radio]):not([type=checkbox]) { + padding: 0px; + margin: 0px; + height: calc(100% - 1px); + line-height: 30px; + border: none; + font-family: inherit; + text-align: right; + box-sizing: border-box; + color: var(--text-color); + background-color: transparent; /* so as to not overlay the box shadow */ + + /* browser's default text-shadow is `$color 0px 0px 0px;` + * for `input`, which makes it look a little bit heavier than dropdowns + * or bare `td` + */ + text-shadow: none; + } + + .dash-spreadsheet-inner input.unfocused { + color: transparent; + text-shadow: 0 0 0 var(--text-color); + cursor: default; + } + + .dash-spreadsheet-inner input.unfocused:focus { + outline: none; + } + + .toggle-row { + border: none; + box-shadow: none; + width: 10px; + padding-left: 10px; + padding-right: 10px; + cursor: pointer; + color: var(--faded-text); + } - .dash-cell-value { - height: 100%; - width: 100%; - } + .toggle-row--expanded { + color: var(--accent); + } - input.dash-cell-value { - position: absolute; - left: 0; - top: 0; - &.unfocused::selection { - background-color: transparent; - } - &.unfocused { - caret-color: transparent; - } - } + .dash-spreadsheet-inner tr:hover .toggle-row { + color: var(--accent); + } - .cell-value-shadow { - margin: auto 0; - opacity: 0; - } + .dash-spreadsheet-inner .dash-delete-cell, + .dash-spreadsheet-inner .dash-delete-header { + .not-selectable(); - .input-cell-value-shadow { - display: inline-block; - height: initial; - width: initial; + font-size: 1.3rem; + text-align: center; + cursor: pointer; + color: var(--muted); + } + .dash-spreadsheet-inner .dash-delete-cell:hover, + .dash-spreadsheet-inner .dash-delete-header:hover { + color: var(--accent); + } + + .dash-spreadsheet-inner { + [class^='column-header--'] { + cursor: pointer; + float: left; } - .dropdown-cell-value-shadow { - display: block; - height: 0px; - padding: 0 42px 0 10px; + .column-header--select { + height: auto; } - } - - th.dash-filter { - position: relative; - & input { - position: absolute; - left: 0; - top: 0; - height: 100%; - width: 100%; + .column-header--select, + .column-header--sort { + color: var(--faded-text-header); } - } - th { - white-space: nowrap; .column-header--clear, .column-header--delete, .column-header--edit, - .column-header--hide - .column-header--sort { - .not-selectable(); - cursor: pointer; - } - } - - // cell content styling - td, th { - background-clip: padding-box; - padding: 2px; - overflow-x: hidden; - white-space: nowrap; - - height: 30px; - - text-align: right; - - div.dash-cell-value { - display: inline; - vertical-align: middle; - white-space: inherit; - overflow: inherit; - text-overflow: inherit; + .column-header--hide { + opacity: 0.1; + padding-left: 2px; + padding-right: 2px; } - } - } - - .dash-spreadsheet-inner textarea { - white-space: pre; - } - - .dash-spreadsheet-inner table { - border-collapse: collapse; - - font-family: monospace; - --accent: hotpink; - --border: lightgrey; - --text-color: rgb(60, 60, 60); - --hover: rgb(253, 253, 253); - --background-color-ellipses: rgb(253, 253, 253); - --faded-text: rgb(250, 250, 250); - --faded-text-header: rgb(180, 180, 180); - --selected-background: rgba(255, 65, 54, 0.2); - --faded-dropdown: rgb(240, 240, 240); - --muted: rgb(200, 200, 200); - } - - /* focus happens after copying to clipboard */ - .dash-spreadsheet-inner table:focus { - outline: none; - } - - .dash-spreadsheet-inner thead { - display: table-row-group; - } - - .elip { - text-align: center; - width: 100%; - background-color: var(--background-color-ellipses); - } - - .dash-spreadsheet-inner td.dropdown { - /* - * To view the dropdown's contents, we need - * overflow-y: visible. - * Unfortunately, overflow-x: hidden and overflow-y: visible - * can't both be set at the same time. - * So, we have to make both overflow-x: visible and overflow-y: visble - * - * See https://stackoverflow.com/questions/6421966/ - * - * There might be another solution with parent divs, but I haven't - * tried it. - */ - overflow-x: visible; - } - - .dash-spreadsheet-inner :not(.cell--selected) tr:hover, - tr:hover input :not(.cell--selected) { - background-color: var(--hover); - } - - .dash-spreadsheet-inner th { - background-color: rgb(250, 250, 250); - } - - .dash-spreadsheet-inner td { - background-color: white; - } - - .expanded-row--empty-cell { - background-color: transparent; - } - - .expanded-row { - text-align: center; - } - .dash-spreadsheet-inner input:not([type=radio]):not([type=checkbox]) { - padding: 0px; - margin: 0px; - height: calc(100% - 1px); - line-height: 30px; - border: none; - font-family: inherit; - text-align: right; - box-sizing: border-box; - color: var(--text-color); - background-color: transparent; /* so as to not overlay the box shadow */ - - /* browser's default text-shadow is `$color 0px 0px 0px;` - * for `input`, which makes it look a little bit heavier than dropdowns - * or bare `td` - */ - text-shadow: none; - } - - .dash-spreadsheet-inner input.unfocused { - color: transparent; - text-shadow: 0 0 0 var(--text-color); - cursor: default; - } - - .dash-spreadsheet-inner input.unfocused:focus { - outline: none; - } - - .toggle-row { - border: none; - box-shadow: none; - width: 10px; - padding-left: 10px; - padding-right: 10px; - cursor: pointer; - color: var(--faded-text); - } - - .toggle-row--expanded { - color: var(--accent); - } - - .dash-spreadsheet-inner tr:hover .toggle-row { - color: var(--accent); - } - - .dash-spreadsheet-inner .dash-delete-cell, - .dash-spreadsheet-inner .dash-delete-header { - .not-selectable(); - - font-size: 1.3rem; - text-align: center; - cursor: pointer; - color: var(--muted); - } - .dash-spreadsheet-inner .dash-delete-cell:hover, - .dash-spreadsheet-inner .dash-delete-header:hover { - color: var(--accent); - } - - .dash-spreadsheet-inner { - [class^='column-header--'] { - cursor: pointer; - float: left; - } - - .column-header--select { - height: auto; - } - - .column-header--select, - .column-header--sort { - color: var(--faded-text-header); - } - - - .column-header--clear, - .column-header--delete, - .column-header--edit, - .column-header--hide { - opacity: 0.1; - padding-left: 2px; - padding-right: 2px; - } - - th:hover { - [class^='column-header--']:not(.disabled) { - color: var(--accent); - opacity: 1; + th:hover { + [class^='column-header--']:not(.disabled) { + color: var(--accent); + opacity: 1; + } } - } + } } } diff --git a/src/dash-table/components/Table/controlledPropsHelper.ts b/src/dash-table/components/Table/controlledPropsHelper.ts index 4193e300a..806777e82 100644 --- a/src/dash-table/components/Table/controlledPropsHelper.ts +++ b/src/dash-table/components/Table/controlledPropsHelper.ts @@ -37,6 +37,7 @@ export default () => { page_action, page_current, page_size, + page_count, selected_columns, selected_rows, sort_action, @@ -92,6 +93,7 @@ export default () => { page_action, page_current, page_size, + page_count, setProps, virtual.data ); diff --git a/src/dash-table/components/Table/props.ts b/src/dash-table/components/Table/props.ts index 28c0a6884..fe380e40a 100644 --- a/src/dash-table/components/Table/props.ts +++ b/src/dash-table/components/Table/props.ts @@ -318,6 +318,7 @@ export interface IProps { style_as_list_view?: boolean; page_action?: TableAction; page_current?: number; + page_count?: number; page_size: number; style_data?: Style; diff --git a/src/dash-table/components/Table/style.ts b/src/dash-table/components/Table/style.ts index 1b5eca4e1..c7b174c99 100644 --- a/src/dash-table/components/Table/style.ts +++ b/src/dash-table/components/Table/style.ts @@ -1,6 +1,7 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faEyeSlash, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; import { faEraser, faPencilAlt, faSort, faSortDown, faSortUp } from '@fortawesome/free-solid-svg-icons'; +import { faAngleLeft, faAngleRight, faAngleDoubleLeft, faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons'; library.add( faEraser, @@ -9,5 +10,9 @@ library.add( faSort, faSortDown, faSortUp, - faTrashAlt + faTrashAlt, + faAngleLeft, + faAngleRight, + faAngleDoubleLeft, + faAngleDoubleRight ); diff --git a/src/dash-table/dash/DataTable.js b/src/dash-table/dash/DataTable.js index fe38db9bc..ac282b37e 100644 --- a/src/dash-table/dash/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -731,6 +731,14 @@ export const propTypes = { */ page_current: PropTypes.number, + /** + * `page_count` represents the number of the pages in the + * paginated table. This is really only useful when performing + * backend pagination, since the front end is able to use the + * full size of the table to calculate the number of pages. + */ + page_count: PropTypes.number, + /** * `page_size` represents the number of rows that will be * displayed on a particular page when `page_action` is `'custom'` or `'native'` diff --git a/src/dash-table/derived/paginator.ts b/src/dash-table/derived/paginator.ts index 8f3671eb6..76ac2c3f4 100644 --- a/src/dash-table/derived/paginator.ts +++ b/src/dash-table/derived/paginator.ts @@ -11,84 +11,120 @@ import { export interface IPaginator { loadNext(): void; loadPrevious(): void; + loadFirst(): void; + loadLast(): void; + loadPage(page: number): void; + hasPrevious(): boolean; + hasNext(): boolean; + isLast(): boolean; + lastPage: number | undefined; } -export function lastPage(data: Data, page_size: number) { - return Math.max(Math.ceil(data.length / page_size) - 1, 0); +export interface IPaginatorParams { + setProps: SetProps; + page_current: number; + page_count: number | undefined; } -function getBackEndPagination( - page_current: number, - setProps: SetProps +export function lastPage( + data: Data, + page_size: number +) { + return Math.ceil(data.length / page_size); +} + +function makePaginator( + params: IPaginatorParams | null ): IPaginator { - return { - loadNext: () => { - page_current++; - setProps({ page_current, ...clearSelection }); - }, - loadPrevious: () => { - if (page_current <= 0) { - return; - } - page_current--; - setProps({ page_current, ...clearSelection }); - } - }; -} + if (params === null) { + return { + loadNext() { }, + loadPrevious() { }, + loadFirst() { }, + loadLast() { }, + loadPage() { }, + hasPrevious() { return true; }, + hasNext() { return true; }, + isLast() { return false; }, + lastPage: undefined + }; + } + + let { + setProps, + page_current, + page_count + } = params; + + function updatePage() { + setProps({ + page_current, + ...clearSelection + }); + } + + function loadPage(page: number) { + + page = Math.max(0, page); + page = page_count ? Math.min(page_count - 1, page) : page; + + page_current = page; + updatePage(); + } -function getFrontEndPagination( - page_current: number, - page_size: number, - setProps: SetProps, - data: Data -) { return { - loadNext: () => { - const maxPageIndex = lastPage(data, page_size); - if (page_current >= maxPageIndex) { - return; - } + loadNext: () => loadPage(page_current + 1), + + loadPrevious: () => loadPage(page_current - 1), + + loadFirst: () => loadPage(0), - page_current++; - setProps({ page_current, ...clearSelection }); + loadPage, + + loadLast: () => { + if (page_count) { loadPage(page_count - 1); } }, - loadPrevious: () => { - if (page_current <= 0) { - return; - } - page_current--; - setProps({ page_current, ...clearSelection }); - } - }; -} + hasPrevious: () => { + return page_current !== 0; + }, -function getNoPagination() { - return { - loadNext: () => { }, - loadPrevious: () => { } + hasNext: () => { + return page_count ? page_current !== page_count - 1 : true; + }, + + isLast: () => { + return page_count ? page_current === page_count - 1 : false; + }, + + lastPage: page_count ? Math.max(0, page_count - 1) : undefined }; } const getter = ( - page_action: TableAction, + table_action: TableAction, page_current: number, page_size: number, + page_count: number | undefined, setProps: SetProps, data: Data ): IPaginator => { - switch (page_action) { - case TableAction.None: - return getNoPagination(); - case TableAction.Native: - return getFrontEndPagination(page_current, page_size, setProps, data); - case TableAction.Custom: - return getBackEndPagination(page_current, setProps); - default: - throw new Error(`Unknown pagination mode: '${page_action}'`); + + if (table_action === TableAction.Native) { + page_count = lastPage(data, page_size); } + + if (page_count) { page_count = Math.max(page_count, 1); } + + return makePaginator( + table_action === TableAction.None ? null : { + setProps, + page_current, + page_count + } + ); }; export default memoizeOneFactory(getter); diff --git a/tests/cypress/dash/v_pagination.py b/tests/cypress/dash/v_pagination.py new file mode 100644 index 000000000..7a8bc89e6 --- /dev/null +++ b/tests/cypress/dash/v_pagination.py @@ -0,0 +1,64 @@ +import dash +from dash.dependencies import Input, Output +from dash.exceptions import PreventUpdate +import dash_table +import dash_core_components as dcc +import dash_html_components as html +import pandas as pd + + +df = pd.read_csv('https://raw.githubusercontent.com/plotly/datasets/master/gapminder2007.csv') + +df[' index'] = range(1, len(df) + 1) + +app = dash.Dash(__name__) + +PAGE_SIZE = 5 + +app.layout = html.Div([ + dcc.Location(id='url'), + dash_table.DataTable( + id='table', + columns=[ + {'name': i, 'id': i} for i in sorted(df.columns) + ], + page_current=0, + page_size=PAGE_SIZE, + page_count=None + ) +]) + + +@app.callback( + [Output('table', 'data'), + Output('table', 'page_action'), + Output('table', 'page_count')], + [Input('table', 'page_current'), + Input('table', 'page_size'), + Input('url', 'search')] +) +def update_table(page_current, page_size, pagination_mode): + + if not pagination_mode: + raise PreventUpdate + + mode = { + param.split('=')[0]: param.split('=')[1] + for param in pagination_mode.strip('?').split('&') + } + + data = None + + if mode['page_action'] == 'native': + data = df.to_dict('records') + else: + data = df.iloc[ + page_current * page_size: (page_current + 1) * page_size + ].to_dict('records') + + return data, mode.get('page_action', 'native'), \ + int(mode.get('page_count')) if mode.get('page_count') else None + + +if __name__ == '__main__': + app.run_server(port=8086, debug=True) diff --git a/tests/cypress/tests/server/pagination_test.ts b/tests/cypress/tests/server/pagination_test.ts new file mode 100644 index 000000000..e3e691722 --- /dev/null +++ b/tests/cypress/tests/server/pagination_test.ts @@ -0,0 +1,254 @@ +import DashTable from 'cypress/DashTable'; +import DOM from 'cypress/DOM'; +import Key from 'cypress/Key'; + +const pagination_modes = ['native', 'custom'] + +describe('table pagination', () => { + + pagination_modes.forEach(mode => { + + describe(`can change pages with ${mode} page_action`, () => { + + before(() => { + cy.visit(`http://localhost:8086?page_action=${mode}&page_count=29`); + + // initial state: first page, previous/first buttons disabled + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '1')); + cy.get('button.first-page').should('be.disabled'); + cy.get('button.previous-page').should('be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '1'); + cy.get('.last-page').should('have.html', '29'); + cy.get('button.next-page').should('not.be.disabled'); + cy.get('button.last-page').should('not.be.disabled'); + + }); + + describe('with the navigation buttons', () => { + + it('with the next-page navigation button', () => { + + // go forward by five pages + for (let i = 0; i < 5; i++) { + cy.get('button.next-page').click(); + } + + cy.get('button.first-page').should('not.be.disabled'); + cy.get('button.previous-page').should('not.be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '6'); + cy.get('.last-page').should('have.html', '29'); + cy.get('button.next-page').should('not.be.disabled'); + cy.get('button.last-page').should('not.be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '26')); + + }); + + it('with the previous-page navigation button', () => { + // go back by three pages + for (let i = 0; i < 3; i++) { + cy.get('button.previous-page').click(); + } + + cy.get('button.first-page').should('not.be.disabled'); + cy.get('button.previous-page').should('not.be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '3'); + cy.get('.last-page').should('have.html', '29'); + cy.get('button.next-page').should('not.be.disabled'); + cy.get('button.last-page').should('not.be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '11')); + + }); + + it('with the first page button', () => { + cy.get('button.first-page').click(); + + cy.get('button.first-page').should('be.disabled'); + cy.get('button.previous-page').should('be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '1'); + cy.get('.last-page').should('have.html', '29'); + cy.get('button.next-page').should('not.be.disabled'); + cy.get('button.last-page').should('not.be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '1')); + + }); + + it('with the last page button', () => { + cy.get('button.last-page').click(); + + cy.get('button.first-page').should('not.be.disabled'); + cy.get('button.previous-page').should('not.be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '29'); + cy.get('.last-page').should('have.html', '29'); + cy.get('button.next-page').should('be.disabled'); + cy.get('button.last-page').should('be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '141')); + + }); + }); + + describe('with the text input box', () => { + describe('correctly navigates to the desired page', () => { + it('with unfocus', () => { + cy.get('input.current-page').click(); + DOM.focused.type(`14`); + cy.get('input.current-page').blur(); + + cy.get('button.first-page').should('not.be.disabled'); + cy.get('button.previous-page').should('not.be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '14'); + cy.get('.last-page').should('have.html', '29'); + cy.get('button.next-page').should('not.be.disabled'); + cy.get('button.last-page').should('not.be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '66')); + }), + it('with enter', () => { + cy.get('input.current-page').click(); + DOM.focused.type(`18${Key.Enter}`); + + cy.get('button.first-page').should('not.be.disabled'); + cy.get('button.previous-page').should('not.be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '18'); + cy.get('input.current-page').should('not.be.focused'); + cy.get('.last-page').should('have.html', '29'); + cy.get('button.next-page').should('not.be.disabled'); + cy.get('button.last-page').should('not.be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '86')); + }) + }); + + describe('can handle invalid page numbers', () => { + it('zero value', () => { + cy.get('input.current-page').click(); + DOM.focused.type('0'); + cy.get('input.current-page').blur(); + + cy.get('button.first-page').should('be.disabled'); + cy.get('button.previous-page').should('be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '1'); + cy.get('.last-page').should('have.html', '29'); + cy.get('button.next-page').should('not.be.disabled'); + cy.get('button.last-page').should('not.be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '1')); + + }); + + it('value higher than last page', () => { + cy.get('input.current-page').click(); + DOM.focused.type('100'); + cy.get('input.current-page').blur(); + cy.get('button.first-page').should('not.be.disabled'); + cy.get('button.previous-page').should('not.be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '29'); + cy.get('.last-page').should('have.html', '29'); + cy.get('button.next-page').should('be.disabled'); + cy.get('button.last-page').should('be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '141')); + }); + + it('negative value', () => { + + cy.get('input.current-page').click(); + DOM.focused.type('-1'); + cy.get('input.current-page').blur(); + + cy.get('button.first-page').should('be.disabled'); + cy.get('button.previous-page').should('be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '1'); + cy.get('.last-page').should('have.html', '29'); + cy.get('button.next-page').should('not.be.disabled'); + cy.get('button.last-page').should('not.be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '1')); + + }); + + it('non-numerical value', () => { + cy.get('input.current-page').click(); + DOM.focused.type('10'); + cy.get('input.current-page').blur(); + + cy.get('button.first-page').should('not.be.disabled'); + cy.get('button.previous-page').should('not.be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '10'); + cy.get('.last-page').should('have.html', '29'); + cy.get('button.next-page').should('not.be.disabled'); + cy.get('button.last-page').should('not.be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '46')); + + cy.get('input.current-page').click(); + DOM.focused.type('hello'); + cy.get('input.current-page').blur(); + + cy.get('button.first-page').should('not.be.disabled'); + cy.get('button.previous-page').should('not.be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '10'); + cy.get('.last-page').should('have.html', '29'); + cy.get('button.next-page').should('not.be.disabled'); + cy.get('button.last-page').should('not.be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '46')); + + }); + }); + }); + }); + }); + + describe('handles other page_count values', () => { + + describe('hides pagination', () => { + it('on single page', () => { + cy.visit(`http://localhost:8086?page_action=custom&page_count=1`); + + cy.get('.previous-next-container').should('not.exist'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '1')); + + }); + + it('on negative/zero values', () => { + cy.visit(`http://localhost:8086?page_action=custom&page_count=-1`); + + cy.get('.previous-next-container').should('not.exist'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '1')); + }); + }); + + it('undefined/none', () => { + + cy.visit(`http://localhost:8086?page_action=custom`); + + cy.get('.page-number').children().should('have.length', 1); + cy.get('.current-page').should('exist'); + cy.get('button.next-page').should('not.be.disabled'); + cy.get('button.last-page').should('be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '1')); + + }); + + it('limits pages', () => { + cy.visit(`http://localhost:8086?page_action=custom&page_count=10`); + cy.get('button.last-page').click(); + + cy.get('button.first-page').should('not.be.disabled'); + cy.get('button.previous-page').should('not.be.disabled'); + cy.get('input.current-page').should('have.attr', 'placeholder', '10'); + cy.get('.last-page').should('have.html', '10'); + cy.get('button.next-page').should('be.disabled'); + cy.get('button.last-page').should('be.disabled'); + + DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.html', '46')); + }); + }); +});