Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/eui/src/components/pagination/pagination.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Side Public License, v 1.
*/

import React, { FunctionComponent, useEffect, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';

import { enableFunctionToggleControls } from '../../../.storybook/utils';
Expand All @@ -31,4 +32,28 @@ export const Playground: Story = {
activePage: 5,
pageCount: 10,
},
render: (args) => <StatefulPagination {...args} />,
};

const StatefulPagination: FunctionComponent<EuiPaginationProps> = ({
activePage: _activePage,
onPageClick,
...args
}) => {
const [activePage, setActivePage] = useState(_activePage);

useEffect(() => {
setActivePage(_activePage);
}, [_activePage]);

return (
<EuiPagination
{...args}
activePage={activePage}
onPageClick={(page) => {
setActivePage(page);
onPageClick?.(page);
}}
/>
);
};
1 change: 1 addition & 0 deletions packages/test-helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ their own version at runtime.
| `EuiAccordionObject` | [src/components/accordion/README.md](src/components/accordion/README.md) |
| `EuiContextMenuObject` | [src/components/context_menu/README.md](src/components/context_menu/README.md) |
| `EuiModalObject` | [src/components/modal/README.md](src/components/modal/README.md) |
| `EuiPaginationObject` | [src/components/pagination/README.md](src/components/pagination/README.md) |
| `EuiBasicTableObject` | [src/components/basic_table/README.md](src/components/basic_table/README.md) |
| `EuiColorPickerObject` | [src/components/color_picker/README.md](src/components/color_picker/README.md) |
| `EuiTreeViewObject` | [src/components/tree_view/README.md](src/components/tree_view/README.md) |
Expand Down
1 change: 1 addition & 0 deletions packages/test-helpers/changelogs/upcoming/10214.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added `EuiPaginationObject` with `getActivePage()`, `getPageCount()`, `goToPage(index)`, `next()` and `previous()`
31 changes: 31 additions & 0 deletions packages/test-helpers/src/components/pagination/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# EuiPaginationObject

Playwright Component Object for [EuiPagination](https://eui.elastic.co/docs/components/navigation/pagination/). Covers the numbered and the compressed variant.

## Usage

```ts
import { EuiPaginationObject } from '@elastic/eui-test-helpers';

const pagination = new EuiPaginationObject(page, 'myPagination');
await pagination.goToPage(2);
expect(await pagination.getActivePage()).toBe(2);
```

Set `data-test-subj` on `<EuiPagination>`. EUI spreads it onto the `<nav>`, which the component-type guard verifies. Page indexes are 0-based, matching EUI's `activePage` prop and the `pagination-button-<index>` test subjs.

## API

| Member | Description |
|---|---|
| `getActivePage()` | The 0-based index of the active page. |
| `getPageCount()` | The total number of pages. |
| `goToPage(index)` | Go to a page and wait for it to become active. Clicks the page button when rendered, otherwise steps through next or previous. No-op if already active, throws if out of range. |
| `next()` | Click the next arrow and wait for the page to advance. |
| `previous()` | Click the previous arrow and wait for the page to go back. |

The numbered variant is read from the `aria-current="page"` button and the last page button. The compressed variant is read from its "page of total" text.

## Deliberately excluded

`pageCount={0}` (unknown total, "page X of collection") is not covered, `getPageCount` and `goToPage` have nothing to read there. The first and last arrows exist only in the compressed variant, `goToPage(0)` and `goToPage(count - 1)` cover the same need.
34 changes: 34 additions & 0 deletions packages/test-helpers/src/components/pagination/selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

/**
* Stable selectors for
* {@link https://eui.elastic.co/docs/components/navigation/pagination/|EuiPagination}.
* `*_SELECTOR` values are CSS, `*_TEST_SUBJ` values are `data-test-subj` names EUI sets itself.
*/
export const EuiPaginationSelectors = {
/** The `<nav>` root carrying the consumer's `data-test-subj`. */
ROOT_SELECTOR: '.euiPagination',

/** A numbered page button. Not rendered in the compressed variant. */
PAGE_BUTTON_SELECTOR: '.euiPaginationButton',

/** The numbered page button of the active page. */
ACTIVE_PAGE_BUTTON_SELECTOR: '.euiPaginationButton[aria-current="page"]',

/** The "page of total" text of the compressed variant, with one `<span>` per number. */
COMPRESSED_TEXT_SELECTOR: '.euiPagination__compressedText',

/** Prefix of a page button's test subj, followed by the 0-based page index. */
PAGE_BUTTON_TEST_SUBJ_PREFIX: 'pagination-button-',

FIRST_BUTTON_TEST_SUBJ: 'pagination-button-first',
PREVIOUS_BUTTON_TEST_SUBJ: 'pagination-button-previous',
NEXT_BUTTON_TEST_SUBJ: 'pagination-button-next',
LAST_BUTTON_TEST_SUBJ: 'pagination-button-last',
};
1 change: 1 addition & 0 deletions packages/test-helpers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export { EuiFlyoutObject } from './playwright/components/flyout/object';
export { EuiAccordionObject } from './playwright/components/accordion/object';
export { EuiContextMenuObject } from './playwright/components/context_menu/object';
export { EuiModalObject } from './playwright/components/modal/object';
export { EuiPaginationObject } from './playwright/components/pagination/object';
export { EuiBasicTableObject } from './playwright/components/basic_table/object';
export { EuiColorPickerObject } from './playwright/components/color_picker/object';
export { EuiTreeViewObject } from './playwright/components/tree_view/object';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { test, expect } from '@playwright/test';

import { EuiPaginationObject } from './object';
import { storyUrl } from '../../../storybook';

/**
* Validates `EuiPaginationObject` against the compressed variant, which
* renders "page of total" text and arrows instead of numbered buttons.
*/

const TEST_SUBJ = 'testPagination';

test.describe('EuiPaginationObject (compressed)', () => {
let pagination: EuiPaginationObject;

test.beforeEach(async ({ page }) => {
await page.goto(
storyUrl(
'navigation-euipagination-euipagination--playground',
`data-test-subj:${TEST_SUBJ};compressed:!true`
)
);
pagination = new EuiPaginationObject(page, TEST_SUBJ);
await pagination.locator.waitFor({ state: 'visible' });
});

test('reads the active page and page count from the text', async () => {
expect(await pagination.getActivePage()).toBe(5);
expect(await pagination.getPageCount()).toBe(10);
});

test('goToPage steps through the arrows', async () => {
await pagination.goToPage(8);

expect(await pagination.getActivePage()).toBe(8);
});

test('next and previous step one page at a time', async () => {
await pagination.next();
expect(await pagination.getActivePage()).toBe(6);

await pagination.previous();
expect(await pagination.getActivePage()).toBe(5);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { test, expect } from '@playwright/test';

import { EuiPaginationObject } from './object';
import { storyUrl } from '../../../storybook';

/**
* Validates `EuiPaginationObject` against the live component in EUI
* Storybook. The Playground story is stateful, starts on page index 5 of 10
* and renders the numbered variant.
*/

const TEST_SUBJ = 'testPagination';

const PLAYGROUND_URL = storyUrl(
'navigation-euipagination-euipagination--playground',
`data-test-subj:${TEST_SUBJ}`
);

test.describe('EuiPaginationObject', () => {
let pagination: EuiPaginationObject;

test.beforeEach(async ({ page }) => {
await page.goto(PLAYGROUND_URL);
pagination = new EuiPaginationObject(page, TEST_SUBJ);
await pagination.locator.waitFor({ state: 'visible' });
});

test.describe('getActivePage', () => {
test('reads the 0-based active page', async () => {
expect(await pagination.getActivePage()).toBe(5);
});
});

test.describe('getPageCount', () => {
test('reads the total page count', async () => {
expect(await pagination.getPageCount()).toBe(10);
});
});

test.describe('goToPage', () => {
test('clicks a rendered page button', async () => {
await pagination.goToPage(6);

expect(await pagination.getActivePage()).toBe(6);
});

test('reaches a page whose button is not rendered', async () => {
await pagination.goToPage(0);
// From page 0 EUI renders pages 0 to 4 plus the last page, so page 7 has no button.
await pagination.goToPage(7);

expect(await pagination.getActivePage()).toBe(7);
});

test('is a no-op on the active page', async () => {
await pagination.goToPage(5);

expect(await pagination.getActivePage()).toBe(5);
});

test('rejects an index out of range', async () => {
await expect(pagination.goToPage(10)).rejects.toThrow(/out of range/);
});
});

test.describe('next and previous', () => {
test('step one page at a time', async () => {
await pagination.next();
expect(await pagination.getActivePage()).toBe(6);

await pagination.previous();
expect(await pagination.getActivePage()).toBe(5);
});
});
});
122 changes: 122 additions & 0 deletions packages/test-helpers/src/playwright/components/pagination/object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { expect, type Locator } from '@playwright/test';

import { BaseObject, type ObjectScope } from '../../base_object';
import { EuiPaginationSelectors } from '../../../components/pagination/selectors';

/**
* Playwright Component Object for {@link
* https://eui.elastic.co/docs/components/navigation/pagination/ EuiPagination}.
*
* `testSubj` must be set on `EuiPagination` itself, EUI spreads it onto the
* `<nav>`. Page indexes are 0-based, matching EUI's `activePage` and the
* `pagination-button-<index>` test subjs. Covers the numbered and the
* compressed variant.
*/
export class EuiPaginationObject extends BaseObject {
constructor(scope: ObjectScope, testSubj: string) {
super(scope, testSubj, EuiPaginationSelectors.ROOT_SELECTOR);
}

/** The 0-based index of the active page. */
async getActivePage(): Promise<number> {
if (await this.isCompressed()) {
const text = await this.compressedNumbers().first().innerText();
return Number(text) - 1;
Comment thread
infra-vault-gh-plugin-prod[bot] marked this conversation as resolved.
Outdated
}
const text = await this.root
.locator(EuiPaginationSelectors.ACTIVE_PAGE_BUTTON_SELECTOR)
.innerText();
return Number(text) - 1;
}

/** The total number of pages. */
async getPageCount(): Promise<number> {
if (await this.isCompressed()) {
return Number(await this.compressedNumbers().last().innerText());
}
// EUI always renders the last page's button, so its label is the count.
return Number(
await this.root
.locator(EuiPaginationSelectors.PAGE_BUTTON_SELECTOR)
.last()
.innerText()
);
}

/**
* Go to the page at the 0-based `index` and wait for it to become active.
* Clicks the page button when EUI renders it, otherwise steps through
* next or previous until it does. A no-op if already active.
*/
async goToPage(index: number): Promise<void> {
const pageCount = await this.getPageCount();
if (index < 0 || index >= pageCount) {
throw new Error(
Comment thread
infra-vault-gh-plugin-prod[bot] marked this conversation as resolved.
Outdated
`EuiPagination "${this.testSubj}" has ${pageCount} pages, page index ${index} is out of range.`
);
}

for (let step = 0; step < pageCount; step++) {
const active = await this.getActivePage();
if (active === index) {
return;
}
const button = this.pageButton(index);
if ((await button.count()) > 0) {
await button.click();
} else if (index > active) {
await this.arrow(EuiPaginationSelectors.NEXT_BUTTON_TEST_SUBJ).click();
} else {
await this.arrow(
EuiPaginationSelectors.PREVIOUS_BUTTON_TEST_SUBJ
).click();
}
await expect.poll(() => this.getActivePage()).not.toBe(active);
}
}

Comment thread
infra-vault-gh-plugin-prod[bot] marked this conversation as resolved.
/** Click the next arrow and wait for the active page to advance. */
async next(): Promise<void> {
const active = await this.getActivePage();
await this.arrow(EuiPaginationSelectors.NEXT_BUTTON_TEST_SUBJ).click();
await expect.poll(() => this.getActivePage()).toBe(active + 1);
}

/** Click the previous arrow and wait for the active page to go back. */
async previous(): Promise<void> {
const active = await this.getActivePage();
await this.arrow(EuiPaginationSelectors.PREVIOUS_BUTTON_TEST_SUBJ).click();
await expect.poll(() => this.getActivePage()).toBe(active - 1);
}

private pageButton(index: number): Locator {
return this.root.getByTestId(
`${EuiPaginationSelectors.PAGE_BUTTON_TEST_SUBJ_PREFIX}${index}`
);
}

private arrow(testSubj: string): Locator {
return this.root.getByTestId(testSubj);
}

private async isCompressed(): Promise<boolean> {
return (
(await this.root
.locator(EuiPaginationSelectors.COMPRESSED_TEXT_SELECTOR)
.count()) > 0
Comment thread
infra-vault-gh-plugin-prod[bot] marked this conversation as resolved.
);
}

private compressedNumbers(): Locator {
return this.root
.locator(EuiPaginationSelectors.COMPRESSED_TEXT_SELECTOR)
.locator('span');
}
}
2 changes: 2 additions & 0 deletions packages/test-helpers/src/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { EuiDraggableSelectors } from './components/drag_and_drop/selectors';
import { EuiFilterButtonSelectors } from './components/filter_button/selectors';
import { EuiFlyoutSelectors } from './components/flyout/selectors';
import { EuiModalSelectors } from './components/modal/selectors';
import { EuiPaginationSelectors } from './components/pagination/selectors';
import { EuiPopoverSelectors } from './components/popover/selectors';
import { EuiRangeSelectors } from './components/form/range/selectors';
import { EuiSelectableSelectors } from './components/selectable/selectors';
Expand All @@ -41,6 +42,7 @@ export const selectors = {
filterButton: EuiFilterButtonSelectors,
flyout: EuiFlyoutSelectors,
modal: EuiModalSelectors,
pagination: EuiPaginationSelectors,
popover: EuiPopoverSelectors,
range: EuiRangeSelectors,
selectable: EuiSelectableSelectors,
Expand Down