diff --git a/packages/crayons-core/src/components.d.ts b/packages/crayons-core/src/components.d.ts index a33787f5e..fdb4f16d8 100644 --- a/packages/crayons-core/src/components.d.ts +++ b/packages/crayons-core/src/components.d.ts @@ -1401,10 +1401,18 @@ export namespace Components { * Aria Label to be used for the button group. */ "buttonGroupLabel": string; + /** + * hides page numbers in standard pagination variant. Defaults to false. Works only with `standard` variant. + */ + "hidePageNumbers": boolean; /** * Indicates if the records in current page are being fetched. */ "isLoading": boolean; + /** + * represents the number of pages to be shown on both the margins. Defaults to 1. Works only with `standard` variant. + */ + "marginPagesDisplayed": number; /** * Aria Label to be used for next button. */ @@ -1417,10 +1425,18 @@ export namespace Components { * The current page number. */ "page": number; + /** + * represents the range of pages to be shown. Defaults to 4. Works only with `standard` variant. + */ + "pageRangeDisplayed": number; /** * The number of records to be shown per page. Defaults to 10. */ "perPage": number; + /** + * Specify the perPage options to be shown. Works only with `standard` variant. + */ + "perPageOptions": number[]; /** * Aria Label to be used for previous button. */ @@ -1433,6 +1449,10 @@ export namespace Components { * The total number of records. This is a mandatory parameter. */ "total": number; + /** + * The variant of pagination to be displayed. Mini variant displays only previous and next buttons along with pagination information. Standard variant displays list of page numbers which can be selected along with previous and next buttons Defaults to 'mini'. + */ + "variant": 'mini' | 'standard'; } interface FwPill { /** @@ -4293,26 +4313,46 @@ declare namespace LocalJSX { * Aria Label to be used for the button group. */ "buttonGroupLabel"?: string; + /** + * hides page numbers in standard pagination variant. Defaults to false. Works only with `standard` variant. + */ + "hidePageNumbers"?: boolean; /** * Indicates if the records in current page are being fetched. */ "isLoading"?: boolean; + /** + * represents the number of pages to be shown on both the margins. Defaults to 1. Works only with `standard` variant. + */ + "marginPagesDisplayed"?: number; /** * Aria Label to be used for next button. */ "nextButtonLabel"?: string; /** - * Triggered when either previous or next button is clicked. + * Triggered when previous, next or page button is clicked. */ "onFwChange"?: (event: FwPaginationCustomEvent) => void; + /** + * Triggered when per page is changed from the dropdown. Works only with `standard` variant. + */ + "onFwPerPageChange"?: (event: FwPaginationCustomEvent) => void; /** * The current page number. */ "page"?: number; + /** + * represents the range of pages to be shown. Defaults to 4. Works only with `standard` variant. + */ + "pageRangeDisplayed"?: number; /** * The number of records to be shown per page. Defaults to 10. */ "perPage"?: number; + /** + * Specify the perPage options to be shown. Works only with `standard` variant. + */ + "perPageOptions"?: number[]; /** * Aria Label to be used for previous button. */ @@ -4321,6 +4361,10 @@ declare namespace LocalJSX { * The total number of records. This is a mandatory parameter. */ "total"?: number; + /** + * The variant of pagination to be displayed. Mini variant displays only previous and next buttons along with pagination information. Standard variant displays list of page numbers which can be selected along with previous and next buttons Defaults to 'mini'. + */ + "variant"?: 'mini' | 'standard'; } interface FwPill { /** diff --git a/packages/crayons-core/src/components/options-list/readme.md b/packages/crayons-core/src/components/options-list/readme.md index 6bb6115f5..6346a8c5c 100644 --- a/packages/crayons-core/src/components/options-list/readme.md +++ b/packages/crayons-core/src/components/options-list/readme.md @@ -429,6 +429,7 @@ Type: `Promise` ### Used by - [fw-kebab-menu](../kebab-menu) + - [fw-pagination](../pagination) - [fw-select](../select) ### Depends on @@ -447,6 +448,7 @@ graph TD; fw-checkbox --> fw-icon fw-input --> fw-icon fw-kebab-menu --> fw-list-options + fw-pagination --> fw-list-options fw-select --> fw-list-options style fw-list-options fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/packages/crayons-core/src/components/pagination/pagination.e2e.ts b/packages/crayons-core/src/components/pagination/pagination.e2e.ts index 8fa6c80fb..b9c621483 100644 --- a/packages/crayons-core/src/components/pagination/pagination.e2e.ts +++ b/packages/crayons-core/src/components/pagination/pagination.e2e.ts @@ -8,89 +8,785 @@ describe('fw-pagination', () => { const element = await page.find('fw-pagination'); expect(element).toHaveClass('hydrated'); }); - it('should set start to 1 if page is not passed in', async () => { - const page = await newE2EPage(); - await page.setContent( - '' - ); - const element = await page.findAll('fw-pagination >>> .record'); - console.log(element); - expect(element[0].textContent).toEqual('1'); - }); - it('should set start based on the passed in value for page', async () => { - const page = await newE2EPage(); + describe('if variant is mini', () => { + it('should set start to 1 if page is not passed in', async () => { + const page = await newE2EPage(); - await page.setContent( - '' - ); - const element = await page.findAll('fw-pagination >>> .record'); - console.log(element); - expect(element[0].textContent).toEqual('11'); - }); - it('should set end to 10 when per-page is passed in and total records is greater', async () => { - const page = await newE2EPage(); + await page.setContent( + '' + ); + const element = await page.findAll('fw-pagination >>> .record'); + console.log(element); + expect(element[0].textContent).toEqual('1'); + }); + it('should set start based on the passed in value for page', async () => { + const page = await newE2EPage(); - await page.setContent( - '' - ); - const element = await page.findAll('fw-pagination >>> .record'); - expect(element[1].textContent).toEqual('10'); - }); - it('should set end to 10 when per-page is not passed in', async () => { - const page = await newE2EPage(); + await page.setContent( + '' + ); + const element = await page.findAll('fw-pagination >>> .record'); + console.log(element); + expect(element[0].textContent).toEqual('11'); + }); + it('should set end to 10 when per-page is passed in and total records is greater', async () => { + const page = await newE2EPage(); - await page.setContent(''); - const element = await page.findAll('fw-pagination >>> .record'); - expect(element[1].textContent).toEqual('10'); - }); - it('Clicking on previous button should set start and end to next set', async () => { - const page = await newE2EPage(); + await page.setContent( + '' + ); + const element = await page.findAll('fw-pagination >>> .record'); + expect(element[1].textContent).toEqual('10'); + }); + it('should set end to 10 when per-page is not passed in', async () => { + const page = await newE2EPage(); - await page.setContent( - '' - ); - const nextButton = await page.find( - 'fw-pagination >>> fw-button[aria-label="Next"]' - ); - const previousButton = await page.find( - 'fw-pagination >>> fw-button[aria-label="Previous"]' - ); - const element = await page.findAll('fw-pagination >>> .record'); - await nextButton.click(); - await previousButton.click(); - expect(element[0].textContent).toEqual('1'); - expect(element[1].textContent).toEqual('10'); - }); - it('Clicking on next button should set start and end to next set', async () => { - const page = await newE2EPage(); + await page.setContent(''); + const element = await page.findAll('fw-pagination >>> .record'); + expect(element[1].textContent).toEqual('10'); + }); + it('Clicking on previous button should set start and end to next set', async () => { + const page = await newE2EPage(); - await page.setContent( - '' - ); - const button = await page.find( - 'fw-pagination >>> fw-button[aria-label="Next"]' - ); - await button.click(); - await page.waitForChanges(); - const element = await page.findAll('fw-pagination >>> .record'); - expect(element[0].textContent).toEqual('11'); - expect(element[1].textContent).toEqual('20'); + await page.setContent( + '' + ); + const nextButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Next"]' + ); + const previousButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Previous"]' + ); + const element = await page.findAll('fw-pagination >>> .record'); + await nextButton.click(); + await previousButton.click(); + expect(element[0].textContent).toEqual('1'); + expect(element[1].textContent).toEqual('10'); + }); + it('Clicking on next button should set start and end to next set', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const button = await page.find( + 'fw-pagination >>> fw-button[aria-label="Next"]' + ); + await button.click(); + await page.waitForChanges(); + const element = await page.findAll('fw-pagination >>> .record'); + expect(element[0].textContent).toEqual('11'); + expect(element[1].textContent).toEqual('20'); + }); + it('next and previous buttons are disabled when limit is reached', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const nextButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Next"]' + ); + const previousButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Previous"]' + ); + + expect(nextButton).toHaveAttribute('disabled'); + expect(previousButton).toHaveAttribute('disabled'); + }); + + it('renders mini pagination by default if variant is not provided', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const miniPagination = await page.find( + 'fw-pagination >>> .mini-pagination' + ); + expect(miniPagination).toBeTruthy(); + }); }); - it('next and previous buttons are disabled when limit is reached', async () => { - const page = await newE2EPage(); - await page.setContent( - '' - ); - const nextButton = await page.find( - 'fw-pagination >>> fw-button[aria-label="Next"]' - ); - const previousButton = await page.find( - 'fw-pagination >>> fw-button[aria-label="Previous"]' - ); - - expect(nextButton).toHaveAttribute('disabled'); - expect(previousButton).toHaveAttribute('disabled'); + describe('if variant is standard', () => { + it('renders standard pagination', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const standardPagination = await page.find( + 'fw-pagination >>> .standard-pagination' + ); + expect(standardPagination).toBeTruthy(); + }); + + it('renders previous, next, page pills and per page dropdown', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const standardPagination = await page.find( + 'fw-pagination >>> .standard-pagination' + ); + expect(standardPagination).toBeTruthy(); + const nextButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Next"]' + ); + const previousButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Previous"]' + ); + + expect(nextButton).toBeTruthy(); + expect(previousButton).toBeTruthy(); + expect(previousButton).toHaveAttribute('disabled'); + const activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('1'); + const pagePills = await page.findAll('fw-pagination >>> .page-pill'); + expect(pagePills.length).toBe(5); + const expectedPages = ['1', '2', '3', '4', '100']; + await pagePills.forEach((page, index) => { + expect(page.innerText).toBe(expectedPages[index]); + }); + const ellipsis = await page.findAll('fw-pagination >>> .ellipsis'); + expect(ellipsis.length).toBe(1); + expect(ellipsis[0].innerText).toBe('...'); + const perPageDropdown = await page.find( + 'fw-pagination >>> .per-page-dropdown' + ); + expect(perPageDropdown).toBeTruthy(); + expect(perPageDropdown.innerText).toBe('Showing 10 / page'); + }); + + it('renders per page dropdown with the default options', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const standardPagination = await page.find( + 'fw-pagination >>> .standard-pagination' + ); + expect(standardPagination).toBeTruthy(); + const perPageDropdown = await page.find( + 'fw-pagination >>> .per-page-list' + ); + expect(perPageDropdown).toBeTruthy(); + const options = await perPageDropdown.getProperty('options'); + expect(options.length).toBe(5); + const expectedOptions = [ + { + text: '10 / page', + value: 10, + }, + { + text: '20 / page', + value: 20, + }, + { + text: '30 / page', + value: 30, + }, + { + text: '40 / page', + value: 40, + }, + { + text: '50 / page', + value: 50, + }, + ]; + expect(options).toEqual(expectedOptions); + const perPageSelected = await page.find( + 'fw-pagination >>> .per-page-dropdown' + ); + expect(perPageSelected).toBeTruthy(); + expect(perPageSelected.innerText).toBe('Showing 20 / page'); + }); + + it('renders per page dropdown with the new options provided', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const standardPagination = await page.find( + 'fw-pagination >>> .standard-pagination' + ); + expect(standardPagination).toBeTruthy(); + const perPageDropdown = await page.find( + 'fw-pagination >>> .per-page-list' + ); + expect(perPageDropdown).toBeTruthy(); + + await page.$eval('fw-pagination', (elm: any) => { + elm.perPageOptions = [20, 40, 60, 80, 100]; + }); + await page.waitForChanges(); + const expectedOptions = [ + { + text: '20 / page', + value: 20, + }, + { + text: '40 / page', + value: 40, + }, + { + text: '60 / page', + value: 60, + }, + { + text: '80 / page', + value: 80, + }, + { + text: '100 / page', + value: 100, + }, + ]; + const options = await perPageDropdown.getProperty('options'); + expect(options.length).toBe(5); + expect(options).toEqual(expectedOptions); + const perPageSelected = await page.find( + 'fw-pagination >>> .per-page-dropdown' + ); + expect(perPageSelected).toBeTruthy(); + expect(perPageSelected.innerText).toBe('Showing 60 / page'); + }); + + it('should be able to select per page option from the dropdown provided', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const fwPerPageChange = await page.spyOnEvent('fwPerPageChange'); + + const standardPagination = await page.find( + 'fw-pagination >>> .standard-pagination' + ); + expect(standardPagination).toBeTruthy(); + const perPageDropdownBtn = await page.find( + 'fw-pagination >>> .per-page-dropdown fw-button' + ); + expect(perPageDropdownBtn).toBeTruthy(); + await perPageDropdownBtn.click(); + await page.waitForChanges(); + const dropdown = await page.find('fw-pagination >>> .per-page-dropdown'); + const options = await dropdown.findAll( + '.per-page-list >>> fw-select-option' + ); + expect(options.length).toBe(5); + await options[2].click(); + await page.waitForChanges(); + expect(fwPerPageChange).toHaveReceivedEventDetail({ + perPage: 30, + }); + const perPageSelected = await page.find( + 'fw-pagination >>> .per-page-dropdown ' + ); + expect(perPageSelected).toBeTruthy(); + expect(perPageSelected.innerText).toBe('Showing 30 / page'); + }); + + it('should change the page to the last page if the current page exceeds the last page when per page is changed', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const fwPerPageChange = await page.spyOnEvent('fwPerPageChange'); + + const standardPagination = await page.find( + 'fw-pagination >>> .standard-pagination' + ); + expect(standardPagination).toBeTruthy(); + let activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('50'); + const perPageDropdownBtn = await page.find( + 'fw-pagination >>> .per-page-dropdown fw-button' + ); + expect(perPageDropdownBtn).toBeTruthy(); + await perPageDropdownBtn.click(); + await page.waitForChanges(); + const dropdown = await page.find('fw-pagination >>> .per-page-dropdown'); + const options = await dropdown.findAll( + '.per-page-list >>> fw-select-option' + ); + expect(options.length).toBe(5); + await options[4].click(); + await page.waitForChanges(); + await page.waitForChanges(); + expect(fwPerPageChange).toHaveReceivedEventDetail({ + perPage: 50, + }); + const perPageSelected = await page.find( + 'fw-pagination >>> .per-page-dropdown ' + ); + expect(perPageSelected).toBeTruthy(); + expect(perPageSelected.innerText).toBe('Showing 50 / page'); + activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('20'); + }); + + it('render only active page item', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('1'); + const pagePills = await page.findAll('fw-pagination >>> .page-pill'); + expect(pagePills.length).toBe(1); + const nextButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Next"]' + ); + const previousButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Previous"]' + ); + + expect(nextButton).toBeTruthy(); + expect(previousButton).toBeTruthy(); + expect(previousButton).toHaveAttribute('disabled'); + }); + + it('disabled previous button when page is 1', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const nextButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Next"]' + ); + const previousButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Previous"]' + ); + + expect(nextButton).toBeTruthy(); + expect(nextButton).not.toHaveAttribute('disabled'); + expect(previousButton).toBeTruthy(); + expect(previousButton).toHaveAttribute('disabled'); + }); + + it('disabled next button when current page is the last page', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const nextButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Next"]' + ); + const previousButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Previous"]' + ); + + expect(nextButton).toBeTruthy(); + expect(nextButton).toHaveAttribute('disabled'); + expect(previousButton).toBeTruthy(); + expect(previousButton).not.toHaveAttribute('disabled'); + }); + + it('should render only Previous / Next if total is zero (default)', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const nextButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Next"]' + ); + const previousButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Previous"]' + ); + + expect(nextButton).toBeTruthy(); + expect(nextButton).not.toHaveAttribute('disabled'); + expect(previousButton).toBeTruthy(); + expect(previousButton).not.toHaveAttribute('disabled'); + const pagePills = await page.findAll('fw-pagination >>> .page-pill'); + expect(pagePills.length).toBe(0); + }); + + it('should render only previous, next and single divider and should not render page pills when hidePageNumbers is true', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const nextButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Next"]' + ); + const previousButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Previous"]' + ); + + expect(nextButton).toBeTruthy(); + expect(nextButton).not.toHaveAttribute('disabled'); + expect(previousButton).toBeTruthy(); + expect(previousButton).toHaveAttribute('disabled'); + const pagePills = await page.findAll('fw-pagination >>> .page-pill'); + expect(pagePills.length).toBe(0); + const dividers = await page.findAll('fw-pagination >>> .divider'); + expect(dividers.length).toBe(1); + }); + + describe('Test clicks', () => { + it('test clicks on previous and next buttons', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const fwChange = await page.spyOnEvent('fwChange'); + const element = await page.find('fw-pagination'); + const nextButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Next"]' + ); + const previousButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Previous"]' + ); + let activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('1'); + await nextButton.click(); + await page.waitForChanges(); + element.waitForEvent('fwChange'); + expect(fwChange).toHaveReceivedEventDetail({ + page: 2, + }); + activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('2'); + await previousButton.click(); + expect(fwChange).toHaveReceivedEventDetail({ + page: 1, + }); + activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('1'); + }); + + it('test click on a page button', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const fwChange = await page.spyOnEvent('fwChange'); + let activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('1'); + const pagePills = await page.findAll('fw-pagination >>> .page-pill'); + await pagePills[3].click(); + await page.waitForChanges(); + await page.waitForChanges(); + expect(fwChange).toHaveReceivedEventDetail({ + page: 4, + }); + activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('4'); + }); + + it('test multiple clicks on previous and next buttons', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + const fwChange = await page.spyOnEvent('fwChange'); + const element = await page.find('fw-pagination'); + const nextButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Next"]' + ); + const previousButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Previous"]' + ); + let activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('1'); + for (let i = 1; i <= 3; i++) { + await nextButton.click(); + await page.waitForChanges(); + element.waitForEvent('fwChange'); + expect(fwChange).toHaveReceivedEventDetail({ + page: i + 1, + }); + activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe((i + 1).toString()); + } + await page.waitForChanges(); + await previousButton.click(); + await page.waitForChanges(); + expect(fwChange).toHaveReceivedEventDetail({ + page: 3, + }); + activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('3'); + }); + }); + + describe('test pagination behaviour', () => { + it('should display 2 elements to the left, 1 ellipsis and 2 elements to the right', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + + const items = await page.findAll('fw-pagination >>> li'); + // previous + 2 left elements + ellipsis + 1 right element + next + const actualResult = []; + const expectedResult = ['Previous', '1', '2', '...', '100', 'Next']; + await items.forEach((item) => { + actualResult.push(item.innerText); + }); + expect(actualResult).toEqual(expectedResult); + }); + + it('should display 5 elements to the left, 1 ellipsis and 2 elements to the right', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + + const items = await page.findAll('fw-pagination >>> li'); + // previous + 5 left elements + ellipsis + 2 right elements + next + const actualResult = []; + const expectedResult = [ + 'Previous', + '1', + '2', + '3', + '4', + '5', + '...', + '19', + '20', + 'Next', + ]; + await items.forEach((item) => { + actualResult.push(item.innerText); + }); + expect(actualResult).toEqual(expectedResult); + }); + + it('should display 7 elements to the left, 1 ellipsis and 2 elements to the right', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + + const items = await page.findAll('fw-pagination >>> li'); + // previous + 7 left elements + ellipsis + 2 right elements + next + const actualResult = []; + const expectedResult = [ + 'Previous', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '...', + '19', + '20', + 'Next', + ]; + await items.forEach((item) => { + actualResult.push(item.innerText); + }); + expect(actualResult).toEqual(expectedResult); + }); + + it('should display 2 elements to the left, 5 elements in the middle, 2 elements to the right and 2 ellipsis', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + + const items = await page.findAll('fw-pagination >>> li'); + // previous + 2 left elements + ellipsis + 5 middle elements + ellipsis + 2 right elements + next + const actualResult = []; + const expectedResult = [ + 'Previous', + '1', + '2', + '...', + '6', + '7', + '8', + '9', + '10', + '...', + '19', + '20', + 'Next', + ]; + await items.forEach((item) => { + actualResult.push(item.innerText); + }); + expect(actualResult).toEqual(expectedResult); + }); + + it('should display 2 elements to the left, 1 ellipsis and 7 elements to the right', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + + const items = await page.findAll('fw-pagination >>> li'); + // previous + 2 left elements + ellipsis + 7 right elements + next + const actualResult = []; + const expectedResult = [ + 'Previous', + '1', + '2', + '...', + '14', + '15', + '16', + '17', + '18', + '19', + '20', + 'Next', + ]; + await items.forEach((item) => { + actualResult.push(item.innerText); + }); + expect(actualResult).toEqual(expectedResult); + }); + + it('should display 2 elements to the left, 1 ellipsis and 6 elements to the right', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + + const items = await page.findAll('fw-pagination >>> li'); + // previous + 2 left elements + ellipsis + 6 right elements + next + const actualResult = []; + const expectedResult = [ + 'Previous', + '1', + '2', + '...', + '15', + '16', + '17', + '18', + '19', + '20', + 'Next', + ]; + await items.forEach((item) => { + actualResult.push(item.innerText); + }); + expect(actualResult).toEqual(expectedResult); + }); + + it('should not display a ellipsis containing only one page', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + + const items = await page.findAll('fw-pagination >>> li'); + // previous + 10 elements + next + const actualResult = []; + const expectedResult = [ + 'Previous', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + 'Next', + ]; + await items.forEach((item) => { + actualResult.push(item.innerText); + }); + expect(actualResult).toEqual(expectedResult); + }); + }); + + it('render active page based on page prop', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + + const activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('6'); + }); + + it('should disable all elements when isLoading is true', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + + const nextButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Next"]' + ); + const previousButton = await page.find( + 'fw-pagination >>> fw-button[aria-label="Previous"]' + ); + + expect(nextButton).toBeTruthy(); + expect(nextButton).toHaveAttribute('disabled'); + expect(previousButton).toBeTruthy(); + expect(previousButton).toHaveAttribute('disabled'); + const pagePills = await page.findAll('fw-pagination >>> .page-pill'); + await pagePills.forEach((page) => { + expect(page).toHaveClass('disabled'); + }); + const perPageDropdownBtn = await page.find( + 'fw-pagination >>> .per-page-dropdown fw-button' + ); + expect(perPageDropdownBtn).toHaveAttribute('disabled'); + }); + + it('does not display ellipsis', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + + const pagePills = await page.findAll('fw-pagination >>> .page-pill'); + expect(pagePills.length).toBe(1); + expect(pagePills[0].innerText).toBe('1'); + const activePage = await page.find('fw-pagination >>> .active'); + expect(activePage.innerText).toBe('1'); + const ellipsis = await page.findAll('fw-pagination >>> .ellipsis'); + expect(ellipsis.length).toBe(0); + }); + + it('hides page numbers when hidePageNumbers is true', async () => { + const page = await newE2EPage(); + + await page.setContent( + '' + ); + + const pagePills = await page.findAll('fw-pagination >>> .page-pill'); + expect(pagePills.length).toBe(0); + }); }); }); diff --git a/packages/crayons-core/src/components/pagination/pagination.scss b/packages/crayons-core/src/components/pagination/pagination.scss index 2ec6cf2bd..7e1540903 100644 --- a/packages/crayons-core/src/components/pagination/pagination.scss +++ b/packages/crayons-core/src/components/pagination/pagination.scss @@ -1,8 +1,122 @@ -:host { +.mini-pagination { display: flex; align-items: center; } +.standard-pagination { + display: flex; + justify-content: space-between; + + .per-page-verbiage { + color: $app-icon-color; + font-size: $font-size-12; + font-weight: $font-weight-400; + + .per-page-text { + font-weight: $font-weight-600; + } + } + + .disabled { + cursor: not-allowed !important; + } + + .pagination-list { + display: flex; + overflow: hidden; + -webkit-box-align: center; + align-items: center; + padding: 0px; + margin: 0px; + list-style: none; + + .nav-label { + color: $app-icon-color; + font-weight: $font-weight-600; + font-size: $font-size-14; + } + + .ellipsis { + display: inline-flex; + -webkit-box-align: center; + align-items: center; + -webkit-box-pack: center; + justify-content: center; + position: relative; + -webkit-tap-highlight-color: transparent; + background-color: transparent; + user-select: none; + vertical-align: middle; + appearance: none; + text-decoration: none; + font-weight: $font-weight-400; + font-size: $font-size-14; + text-align: center; + box-sizing: border-box; + padding-block: 2px; + padding-inline: 7px; + margin-block: 0px; + margin-inline: 8px; + border-radius: 4px; + min-height: 24px; + min-width: 24px; + } + + .page-pill { + display: inline-flex; + -webkit-box-align: center; + align-items: center; + -webkit-box-pack: center; + justify-content: center; + position: relative; + -webkit-tap-highlight-color: transparent; + background-color: transparent; + outline: 0px; + border: 0px; + cursor: pointer; + user-select: none; + vertical-align: middle; + appearance: none; + text-decoration: none; + font-weight: $font-weight-400; + font-size: $font-size-14; + text-align: center; + box-sizing: border-box; + padding-block: 2px; + padding-inline: 7px; + margin-block: 0px; + margin-inline: 8px; + border-radius: 4px; + min-height: 24px; + min-width: 24px; + + &:hover:not([disabled], .active), + &:focus:not([disabled], .active) { + background-color: $color-smoke-50; + } + + &.active { + background-color: $app-link; + color: $app-text-light; + font-weight: $font-weight-600; + + &.disabled { + opacity: 0.5; + } + } + } + + .divider { + border: 1px solid $color-smoke-100; + height: 24px; + } + + .nav-btn { + margin-inline: 8px; + } + } +} + .current-record { color: $app-info-color; margin-inline-start: 10px; diff --git a/packages/crayons-core/src/components/pagination/pagination.tsx b/packages/crayons-core/src/components/pagination/pagination.tsx index f9a07216b..d2b0bbbef 100644 --- a/packages/crayons-core/src/components/pagination/pagination.tsx +++ b/packages/crayons-core/src/components/pagination/pagination.tsx @@ -7,8 +7,10 @@ import { EventEmitter, Method, Watch, + State, } from '@stencil/core'; import { TranslationController } from '../../global/Translation'; +import memoize from 'lodash/memoize'; @Component({ tag: 'fw-pagination', styleUrl: 'pagination.scss', @@ -17,6 +19,8 @@ import { TranslationController } from '../../global/Translation'; export class Pagination { private end; private start; + private lastPage; + private popoverRef; /** * The current page number. @@ -29,7 +33,11 @@ export class Pagination { /** *The number of records to be shown per page. Defaults to 10. */ - @Prop() perPage = 10; + @Prop({ mutable: true }) perPage = 10; + /** + * Specify the perPage options to be shown. Works only with `standard` variant. + */ + @Prop() perPageOptions = [10, 20, 30, 40, 50]; /** * Aria Label to be used for the button group. @@ -56,12 +64,47 @@ export class Pagination { * Indicates if the records in current page are being fetched. */ @Prop() isLoading = false; + + /** + * The variant of pagination to be displayed. + * Mini variant displays only previous and next buttons along with pagination information. + * Standard variant displays list of page numbers which can be selected along with previous and next buttons + * Defaults to 'mini'. + */ + @Prop() variant: 'mini' | 'standard' = 'mini'; + /** - * Triggered when either previous or next button is clicked. + * hides page numbers in standard pagination variant. Defaults to false. Works only with `standard` variant. + */ + @Prop() hidePageNumbers = false; + + /** + * represents the range of pages to be shown. Defaults to 4. Works only with `standard` variant. + */ + @Prop({ mutable: true }) pageRangeDisplayed = 4; + + /** + * represents the number of pages to be shown on both the margins. Defaults to 1. Works only with `standard` variant. + */ + @Prop({ mutable: true }) marginPagesDisplayed = 1; + + /** + * Formatted per page options + */ + @State() formattedPerPageOptions = []; + + /** + * Triggered when previous, next or page button is clicked. */ @Event() fwChange: EventEmitter; + /** + * Triggered when per page is changed from the dropdown. Works only with `standard` variant. + */ + @Event() + fwPerPageChange: EventEmitter; + /** * Navigates to previous set of records if available. */ @@ -92,22 +135,318 @@ export class Pagination { @Watch('page') handlePage(page) { - if (page > this.getLastPage()) return; + if (page > this.lastPage) return; this.start = this.getStartRecord(); this.end = this.getEndRecord(); } + @Watch('perPage') + handlePerPage() { + this.lastPage = this.getLastPage(); + // if the current page is greater than the last page, set current page as the last page + if (this.page > this.lastPage) { + this.goToPage(this.lastPage); + } + } + @Watch('total') handleTotal() { this.end = this.getEndRecord(); + this.lastPage = this.getLastPage(); } + @Watch('perPageOptions') + handlePerPageOptions() { + this.formatPerPageOptions(); + if ( + this.perPageOptions.length && + !this.perPageOptions.includes(this.perPage) + ) { + this.perPage = this.perPageOptions[0]; + } + } + + range = (start, end) => { + const length = end - start + 1; + /* + Create an array of certain length and set the elements within it from + start value to end value. + */ + return Array.from({ length }, (_, idx) => idx + start); + }; + + getPaginationRange = memoize( + (total = 0, perPage, pageRangeDisplayed, marginPagesDisplayed, page) => { + const currentPageIndex = page - 1; + const totalPageCount = Math.ceil(total / perPage); + const items = []; + + if (totalPageCount <= pageRangeDisplayed) { + for (let index = 0; index < totalPageCount; index++) { + items.push(index + 1); + } + } else { + let leftSide = pageRangeDisplayed / 2; + let rightSide = pageRangeDisplayed - leftSide; + + // If the selected page index is on the default right side of the pagination, + // we consider that the new right side is made up of it (= only one break element). + // If the selected page index is on the default left side of the pagination, + // we consider that the new left side is made up of it (= only one break element). + if (currentPageIndex > totalPageCount - pageRangeDisplayed / 2) { + rightSide = totalPageCount - currentPageIndex; + leftSide = pageRangeDisplayed - rightSide; + } else if (currentPageIndex < pageRangeDisplayed / 2) { + leftSide = currentPageIndex; + rightSide = pageRangeDisplayed - leftSide; + } + + let index; + + // First pass: process the pages or breaks to display (or not). + const pagesBreaking = []; + for (index = 0; index < totalPageCount; index++) { + const page = index + 1; + + // If the page index is lower than the margin defined, + // the page has to be displayed on the left side of + // the pagination. + if (page <= marginPagesDisplayed) { + pagesBreaking.push(index + 1); + continue; + } + + // If the page index is greater than the page count + // minus the margin defined, the page has to be + // displayed on the right side of the pagination. + if (page > totalPageCount - marginPagesDisplayed) { + pagesBreaking.push(index + 1); + continue; + } + + // If it is the first element of the array the rightSide need to be adjusted, + // otherwise an extra element will be rendered + const adjustedRightSide = + currentPageIndex === 0 && pageRangeDisplayed > 1 + ? rightSide - 1 + : rightSide; + + // If the page index is near the selected page index + // and inside the defined range (pageRangeDisplayed) + // we have to display it (it will create the center + // part of the pagination). + if ( + index >= currentPageIndex - leftSide && + index <= currentPageIndex + adjustedRightSide + ) { + pagesBreaking.push(index + 1); + continue; + } + + // If the page index doesn't meet any of the conditions above, + // we check if the last item of the current "items" array + // is a break element. If not, we add a break element, else, + // we do nothing (because we don't want to display the page). + if ( + pagesBreaking.length > 0 && + pagesBreaking[pagesBreaking.length - 1] !== 'ellipsis' && + // We do not show break if only one active page is displayed. + (pageRangeDisplayed > 0 || marginPagesDisplayed > 0) + ) { + pagesBreaking.push('ellipsis'); + } + } + // Second pass: we remove breaks containing one page to the actual page. + pagesBreaking.forEach((pageElement, i) => { + let actualPageElement = pageElement; + // 1 2 3 4 5 6 7 ... 9 10 + // | + // 1 2 ... 4 5 6 7 8 9 10 + // | + // The break should be replaced by the page. + if ( + pageElement === 'ellipsis' && + pagesBreaking[i - 1] && + pagesBreaking[i - 1] !== 'ellipsis' && + pagesBreaking[i + 1] && + pagesBreaking[i + 1] !== 'ellipsis' && + pagesBreaking[i + 1] - 1 - pagesBreaking[i - 1] - 1 <= 2 + ) { + actualPageElement = pageElement; + } + // We add the displayed elements in the same pass, to avoid another iteration. + items.push(actualPageElement); + }); + } + + return items; + }, + (total, perPage, pageRangeDisplayed, marginPagesDisplayed, page) => + JSON.stringify([ + total, + perPage, + pageRangeDisplayed, + marginPagesDisplayed, + page, + ]) + ); + + renderPrevious = () => { + return ( +
  • + this.goToPrevious()} + > + + + {TranslationController.t('pagination.previousButtonLabel')} + + +
  • + ); + }; + + renderNext = () => { + return ( +
  • + this.goToNext()} + > + + {TranslationController.t('pagination.nextButtonLabel')} + + + +
  • + ); + }; + + renderPill = (page) => { + return ( +
  • + +
  • + ); + }; + + renderEllipsis = () =>
  • ...
  • ; + + renderDivider = () => { + return
    ; + }; + + renderSizeChanger = () => { + return ( + (this.popoverRef = ref)} + trigger='manual' + hoist + autoFocusOnContent + > + + + + + + ); + }; + + onPerPageChange = (ev) => { + ev.stopPropagation(); + if (ev?.detail?.value) { + this.perPage = ev.detail.value; + this.fwPerPageChange.emit({ + perPage: this.perPage, + }); + this.popoverRef.hide(); + } + }; + + handlePerPageDropdownClick = () => { + if (!this.isLoading) { + this.popoverRef.show(); + } + }; + + formatPerPageOptions = () => { + if (this.variant === 'standard') { + this.formattedPerPageOptions = this.perPageOptions.map((value) => ({ + text: TranslationController.t('pagination.perPageOptionLabel', { + perPage: value, + }), + value, + })); + } + }; + componentWillLoad() { - this.page = Math.min(this.page, this.getLastPage()); + this.lastPage = this.getLastPage(); + this.page = Math.min(this.page, this.lastPage); this.start = this.getStartRecord(); this.end = this.getEndRecord(); } + componentDidLoad() { + this.formatPerPageOptions(); + } + private goToPrevious() { this.page = Math.max(1, this.page - 1); this.fwChange.emit({ @@ -116,57 +455,96 @@ export class Pagination { } private goToNext() { - this.page = Math.min(this.getLastPage(), this.page + 1); + this.page = Math.min(this.lastPage, this.page + 1); this.fwChange.emit({ page: this.page, }); } + private goToPage(page) { + if (this.page !== page && page >= 1 && page <= this.lastPage) { + this.page = page; + this.fwChange.emit({ + page: this.page, + }); + } + } + render() { return ( -
    - {/* {this.start} to{' '} + {this.variant === 'standard' ? ( +
    +
      + {this.renderPrevious()} + {this.renderDivider()} + {!this.hidePageNumbers && + this.getPaginationRange( + this.total, + this.perPage, + this.pageRangeDisplayed, + this.marginPagesDisplayed, + this.page + ).map((item) => { + if (item === 'ellipsis') { + return this.renderEllipsis(); + } else { + return this.renderPill(item); + } + })} + {!this.hidePageNumbers && this.total + ? this.renderDivider() + : null} + {this.renderNext()} +
    + {this.renderSizeChanger()} +
    + ) : ( +
    +
    + {/* {this.start} to{' '} {this.end} of {this.total} */} -
    - - this.goToPrevious()} - > - - - this.goToNext()} - > - - - +
    + + this.goToPrevious()} + > + + + this.goToNext()} + > + + + +
    + )}
    ); } diff --git a/packages/crayons-core/src/components/pagination/readme.md b/packages/crayons-core/src/components/pagination/readme.md index 94cc1d6da..895da7c5c 100644 --- a/packages/crayons-core/src/components/pagination/readme.md +++ b/packages/crayons-core/src/components/pagination/readme.md @@ -1,8 +1,8 @@ # Pagination (fw-pagination) -fw-pagination displays pagination. The component displays starting and ending record numbers against total number of records. +fw-pagination displays pagination. The component displays starting and ending record numbers against total number of records. This component has two variants namely, `mini` and `standard`. -## Demo +## Demo - Mini variant ```html live
    @@ -10,7 +10,7 @@ fw-pagination displays pagination. The component displays starting and ending re ``` -## Usage +## Usage - Mini variant @@ -43,27 +43,173 @@ function App() { +## Demo - Standard variant + +`hidePageNumbers` property can be used to hide the page numbers in standard variant. + +`pageRangeDisplayed` property can be set to the range of pages to be shown in the UI. + +`marginPagesDisplayed` property can be set to the number of pages to be shown on either margins. + +`perPageOptions` property is used to modify the options provided in the per page dropdown. + +`fwChange` event is triggered when a page is changed by clicking previous or next or the desired page buttons. + +`fwPerPageChange` event is triggered when per page is changed using the dropdown provided in the UI. + +```html live +

    +
    +
    +
    + +

    +
    + +

    +
    + +

    + + + + +``` + +## Usage - Standard variant + + + +```html +

    +
    +
    +
    + +

    +
    + +

    +
    + +

    + + + +``` +
    + + +```jsx +import React from "react"; +import ReactDOM from "react-dom"; +import { FwPagination } from "@freshworks/crayons/react"; +function App() { + return (
    + +
    + +
    + + // hide page numbers + + // modifying pageRangeDisplayed and marginPagesDisplayed + + // modifying perPageOptions + +
    ) +} +``` +
    +
    + ## Properties -| Property | Attribute | Description | Type | Default | -| --------------------- | ----------------------- | ----------------------------------------------------------- | --------- | ----------- | -| `buttonGroupLabel` | `button-group-label` | Aria Label to be used for the button group. | `string` | `''` | -| `isLoading` | `is-loading` | Indicates if the records in current page are being fetched. | `boolean` | `false` | -| `nextButtonLabel` | `next-button-label` | Aria Label to be used for next button. | `string` | `''` | -| `page` | `page` | The current page number. | `number` | `1` | -| `perPage` | `per-page` | The number of records to be shown per page. Defaults to 10. | `number` | `10` | -| `previousButtonLabel` | `previous-button-label` | Aria Label to be used for previous button. | `string` | `''` | -| `total` | `total` | The total number of records. This is a mandatory parameter. | `number` | `undefined` | +| Property | Attribute | Description | Type | Default | +| ---------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------- | +| `buttonGroupLabel` | `button-group-label` | Aria Label to be used for the button group. | `string` | `''` | +| `hidePageNumbers` | `hide-page-numbers` | hides page numbers in standard pagination variant. Defaults to false. Works only with `standard` variant. | `boolean` | `false` | +| `isLoading` | `is-loading` | Indicates if the records in current page are being fetched. | `boolean` | `false` | +| `marginPagesDisplayed` | `margin-pages-displayed` | represents the number of pages to be shown on both the margins. Defaults to 1. Works only with `standard` variant. | `number` | `1` | +| `nextButtonLabel` | `next-button-label` | Aria Label to be used for next button. | `string` | `''` | +| `page` | `page` | The current page number. | `number` | `1` | +| `pageRangeDisplayed` | `page-range-displayed` | represents the range of pages to be shown. Defaults to 4. Works only with `standard` variant. | `number` | `4` | +| `perPage` | `per-page` | The number of records to be shown per page. Defaults to 10. | `number` | `10` | +| `perPageOptions` | -- | Specify the perPage options to be shown. Works only with `standard` variant. | `number[]` | `[10, 20, 30, 40, 50]` | +| `previousButtonLabel` | `previous-button-label` | Aria Label to be used for previous button. | `string` | `''` | +| `total` | `total` | The total number of records. This is a mandatory parameter. | `number` | `undefined` | +| `variant` | `variant` | The variant of pagination to be displayed. Mini variant displays only previous and next buttons along with pagination information. Standard variant displays list of page numbers which can be selected along with previous and next buttons Defaults to 'mini'. | `"mini" \| "standard"` | `'mini'` | ## Events -| Event | Description | Type | -| ---------- | --------------------------------------------------------- | ------------------ | -| `fwChange` | Triggered when either previous or next button is clicked. | `CustomEvent` | +| Event | Description | Type | +| ----------------- | ----------------------------------------------------------------------------------------- | ------------------ | +| `fwChange` | Triggered when previous, next or page button is clicked. | `CustomEvent` | +| `fwPerPageChange` | Triggered when per page is changed from the dropdown. Works only with `standard` variant. | `CustomEvent` | ## Methods @@ -93,18 +239,29 @@ Type: `Promise` ### Depends on -- [fw-button-group](../button-group) - [fw-button](../button) - [fw-icon](../icon) +- [fw-popover](../popover) +- [fw-list-options](../options-list) +- [fw-button-group](../button-group) ### Graph ```mermaid graph TD; - fw-pagination --> fw-button-group fw-pagination --> fw-button fw-pagination --> fw-icon + fw-pagination --> fw-popover + fw-pagination --> fw-list-options + fw-pagination --> fw-button-group fw-button --> fw-spinner fw-button --> fw-icon + fw-list-options --> fw-select-option + fw-list-options --> fw-input + fw-select-option --> fw-icon + fw-select-option --> fw-checkbox + fw-select-option --> fw-avatar + fw-checkbox --> fw-icon + fw-input --> fw-icon style fw-pagination fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/packages/crayons-core/src/components/popover/readme.md b/packages/crayons-core/src/components/popover/readme.md index eb1d70e81..032c166da 100644 --- a/packages/crayons-core/src/components/popover/readme.md +++ b/packages/crayons-core/src/components/popover/readme.md @@ -648,6 +648,7 @@ Type: `Promise` - [fw-datepicker](../datepicker) - [fw-kebab-menu](../kebab-menu) + - [fw-pagination](../pagination) - [fw-select](../select) - [fw-tooltip](../tooltip) @@ -656,6 +657,7 @@ Type: `Promise` graph TD; fw-datepicker --> fw-popover fw-kebab-menu --> fw-popover + fw-pagination --> fw-popover fw-select --> fw-popover fw-tooltip --> fw-popover style fw-popover fill:#f9f,stroke:#333,stroke-width:4px diff --git a/packages/crayons-i18n/i18n/en-US.json b/packages/crayons-i18n/i18n/en-US.json index 4a2668316..530f0a38c 100644 --- a/packages/crayons-i18n/i18n/en-US.json +++ b/packages/crayons-i18n/i18n/en-US.json @@ -58,7 +58,10 @@ "buttonGroupLabel": "Pagination controls", "previousButtonLabel": "Previous", "nextButtonLabel": "Next", - "content": "{{start}} to {{end}} of {{total}}" + "content": "{{start}} to {{end}} of {{total}}", + "pagePillLabel": "Go to page {{page}}", + "perPageLabel": "Showing {{perPage}} / page", + "perPageOptionLabel": "{{perPage}} / page" }, "datatable": { "chooseColumns": "Choose columns",