diff --git a/packages/react/.eslintrc.json b/packages/react/.eslintrc.json index 8b99351..77b5a20 100644 --- a/packages/react/.eslintrc.json +++ b/packages/react/.eslintrc.json @@ -7,6 +7,6 @@ "project": "./tsconfig.eslint.json" }, "rules": { - "import/no-extraneous-dependencies": ["error", {"devDependencies": ["vite.config.ts", "cunningham.ts","**/*.stories.tsx", "**/*.spec.tsx"]}] + "import/no-extraneous-dependencies": ["error", {"devDependencies": ["vite.config.ts", "cunningham.ts","**/*.stories.tsx", "**/*.spec.tsx", "src/tests/*"]}] } } \ No newline at end of file diff --git a/packages/react/.storybook/main.cjs b/packages/react/.storybook/main.cjs index b45b1cf..858fdf5 100644 --- a/packages/react/.storybook/main.cjs +++ b/packages/react/.storybook/main.cjs @@ -12,6 +12,7 @@ module.exports = { '@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/addon-a11y', + '@storybook/preset-scss' ], 'framework': '@storybook/react', 'core': { diff --git a/packages/react/package.json b/packages/react/package.json index 8f15b89..58fdf6b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -41,6 +41,8 @@ "@fontsource/material-icons": "4.5.4", "@fontsource/roboto": "4.5.8", "@openfun/cunningham-tokens": "*", + "@tanstack/react-table": "8.7.9", + "classnames": "2.3.2", "react": "18.2.0", "react-dom": "18.2.0" }, @@ -49,6 +51,7 @@ }, "devDependencies": { "@babel/core": "7.20.7", + "@faker-js/faker": "7.6.0", "@openfun/cunningham-tokens": "*", "@openfun/typescript-configs": "*", "@storybook/addon-a11y": "6.5.16", @@ -59,7 +62,7 @@ "@storybook/builder-vite": "0.2.6", "@storybook/preset-scss": "1.0.3", "@storybook/react": "6.5.15", - "@storybook/storybook-deployer": "^2.8.16", + "@storybook/storybook-deployer": "2.8.16", "@storybook/testing-library": "0.0.13", "@testing-library/dom": "8.19.1", "@testing-library/react": "13.4.0", @@ -80,6 +83,7 @@ "vite": "4.0.3", "vite-plugin-dts": "1.7.1", "vite-tsconfig-paths": "4.0.3", - "vitest": "0.26.2" + "vitest": "0.26.2", + "vitest-fetch-mock": "0.2.2" } } diff --git a/packages/react/src/components/Accessibility/index.scss b/packages/react/src/components/Accessibility/index.scss new file mode 100644 index 0000000..9f51320 --- /dev/null +++ b/packages/react/src/components/Accessibility/index.scss @@ -0,0 +1,12 @@ +.offscreen { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} \ No newline at end of file diff --git a/packages/react/src/components/DataGrid/SimpleDataGrid.spec.tsx b/packages/react/src/components/DataGrid/SimpleDataGrid.spec.tsx new file mode 100644 index 0000000..8be6197 --- /dev/null +++ b/packages/react/src/components/DataGrid/SimpleDataGrid.spec.tsx @@ -0,0 +1,543 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import React, { useState } from "react"; +import { faker } from "@faker-js/faker"; +import { getAllByRole, getByRole } from "@testing-library/dom"; +import { expect } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { expectPaginationList } from "components/Pagination/utils"; +import { CunninghamProvider } from "components/Provider"; +import { SimpleDataGrid } from "components/DataGrid/SimpleDataGrid"; +import { sleep } from "utils"; +import { Row } from "components/DataGrid/index"; + +describe("", () => { + it("should render a grid without pagination", async () => { + const rows = Array.from(Array(23)).map(() => ({ + id: faker.datatype.uuid(), + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + email: faker.internet.email(), + address: faker.address.streetAddress(), + })); + render( + + + + ); + + const table = screen.getByRole("table"); + const ths = getAllByRole(table, "columnheader"); + expect(ths.length).toBe(4); + expect(ths[0].textContent).toEqual("First name"); + expect(ths[1].textContent).toEqual("Last name"); + expect(ths[2].textContent).toEqual("Email"); + expect(ths[3].textContent).toEqual("Address"); + rows.forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + // Make sure the pagination is not rendered ( because it is disabled by default ) + expect(document.querySelector(".c__pagination")).toBeNull(); + }); + it("should render a grid with working pagination", async () => { + const rows = Array.from(Array(23)) + .map(() => ({ + id: faker.datatype.uuid(), + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + email: faker.internet.email(), + address: faker.address.streetAddress(), + })) + .sort((a, b) => a.firstName.localeCompare(b.firstName)); + render( + + + + ); + + // Verify first page rows. + expect(screen.getAllByRole("row").length).toBe(11); + rows.slice(0, 10).forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + + // Expect pagination to be rendered. + expectPaginationList([ + { text: "navigate_before", name: "Go to previous page" }, + { text: "1", name: "You are currently on page 1" }, + { text: "2", name: "Go to page 2" }, + { text: "3", name: "Go to page 3" }, + { text: "navigate_next", name: "Go to next page" }, + ]); + + // Go to page 2. + const nextButton = screen.getByRole("button", { + name: "Go to next page", + }); + const user = userEvent.setup(); + user.click(nextButton); + + // Verify second page rows. + expect(screen.getAllByRole("row").length).toBe(11); + await waitFor(() => { + rows.slice(10, 20).forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + }); + + expectPaginationList([ + { text: "navigate_before", name: "Go to previous page" }, + { text: "1", name: "Go to page 1" }, + { text: "2", name: "You are currently on page 2" }, + { text: "3", name: "Go to page 3" }, + { text: "navigate_next", name: "Go to next page" }, + ]); + + // Go to page 3. + user.click(nextButton); + + // Verify third page rows. + expect(screen.getAllByRole("row").length).toBe(11); + await waitFor(() => { + rows.slice(20, 23).forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + }); + + expectPaginationList([ + { text: "navigate_before", name: "Go to previous page" }, + { text: "1", name: "Go to page 1" }, + { text: "2", name: "Go to page 2" }, + { text: "3", name: "You are currently on page 3" }, + { text: "navigate_next", name: "Go to next page" }, + ]); + }); + it("should render a grid with working rows selection", async () => { + const rows = Array.from(Array(23)) + .map(() => ({ + id: faker.datatype.uuid(), + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + email: faker.internet.email(), + address: faker.address.streetAddress(), + })) + .sort((a, b) => a.firstName.localeCompare(b.firstName)); + + let lastRowSelection: Record; + + const Wrapper = () => { + const [rowSelection, setRowSelection] = useState({}); + lastRowSelection = rowSelection; + return ( + + + + ); + }; + + render(); + + const user = userEvent.setup(); + const rowsToSelect = [rows[0], rows[10]]; + + // Check first row. + let element = screen.getByTestId(rowsToSelect[0].id); + let checkbox: HTMLInputElement = getByRole(element, "checkbox"); + user.click(checkbox); + await waitFor(() => { + expect(lastRowSelection[rowsToSelect[0].id]).toBe(true); + }); + + // Check second row. + element = screen.getByTestId(rowsToSelect[1].id); + checkbox = getByRole(element, "checkbox"); + user.click(checkbox); + await waitFor(() => { + expect(lastRowSelection[rowsToSelect[0].id]).toBe(true); + expect(lastRowSelection[rowsToSelect[1].id]).toBe(true); + }); + + // Uncheck first row. + element = screen.getByTestId(rowsToSelect[0].id); + checkbox = getByRole(element, "checkbox"); + user.click(checkbox); + await waitFor(() => { + expect(lastRowSelection[rowsToSelect[0].id]).toBe(undefined); + expect(lastRowSelection[rowsToSelect[1].id]).toBe(true); + }); + }); + it("should render a grid with working sortable columns", async () => { + const rows = Array.from(Array(23)) + .map(() => ({ + id: faker.datatype.uuid(), + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + email: faker.internet.email(), + address: faker.address.streetAddress(), + })) + .sort((a, b) => a.firstName.localeCompare(b.firstName)); + render( + + + + ); + + // Verify first page rows are sorted by firstName ASC. + expect(screen.getAllByRole("row").length).toBe(11); + rows.slice(0, 10).forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + + // Verify last page rows are sorted by firstName ASC. + const page3Button = screen.getByRole("button", { name: "Go to page 3" }); + const user = userEvent.setup(); + user.click(page3Button); + + await waitFor(() => { + rows.slice(20, 23).forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + }); + + // Go to page 1 and sort by firstName DESC. + const page1Button = screen.getByRole("button", { name: "Go to page 1" }); + user.click(page1Button); + + const table = screen.getByRole("table"); + const ths = getAllByRole(table, "columnheader"); + expect(ths.length).toBe(4); + expect(ths[0].textContent).toContain("First name"); + expect(ths[0].textContent).toContain("arrow_drop_up"); + user.click(ths[0].querySelector("div")!); + + // Verify first page rows are sorted by firstName DESC. + await waitFor(() => { + expect(ths[0].textContent).toContain("arrow_drop_down"); + [...rows] + .reverse() + .slice(0, 10) + .forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + }); + + // Verify last page rows are sorted by firstName DESC. + user.click(page3Button); + await waitFor(() => { + [...rows] + .reverse() + .slice(20, 23) + .forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + }); + + // Click again on sort button to disable sorting. + user.click(ths[0].querySelector("div")!); + await waitFor(() => { + // Make sure there are no arrow_drop_down nor arrow_drop_up. + expect(ths[0].textContent).toEqual("First name"); + }); + + // Click on email header to make it the new sort column. + expect(ths[2].textContent).toEqual("Email"); + user.click(ths[2].querySelector("div")!); + await waitFor(() => { + expect(ths[2].textContent).toContain("arrow_drop_up"); + expect(ths[0].textContent).toEqual("First name"); + }); + + // Verify first page rows are sorted by email ASC. + user.click(page1Button); + await waitFor(() => { + [...rows] + .sort((a, b) => a.email.localeCompare(b.email)) + .slice(0, 10) + .forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + }); + }); + it("should render a grid with non-sortable columns", async () => { + const rows = Array.from(Array(23)) + .map(() => ({ + id: faker.datatype.uuid(), + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + email: faker.internet.email(), + address: faker.address.streetAddress(), + })) + .sort((a, b) => a.firstName.localeCompare(b.firstName)); + render( + + + + ); + + // Make sure the sort is enabled on the first name column. + const table = screen.getByRole("table"); + let ths = getAllByRole(table, "columnheader"); + expect(ths.length).toBe(4); + expect(ths[0].textContent).toContain("First name"); + expect(ths[0].textContent).toContain("arrow_drop_up"); + expect(ths[2].textContent).toEqual("Email"); + + // Click on the email header to make sure it is not sortable. + const user = userEvent.setup(); + user.click(ths[2].querySelector("div")!); + // Make sure the sort is never enabled on the email column. + await sleep(100); + ths = getAllByRole(table, "columnheader"); + expect(ths[0].textContent).toContain("First name"); + expect(ths[0].textContent).toContain("arrow_drop_up"); + expect(ths[2].textContent).toEqual("Email"); + + // Make sure the first page is still sorted by firstName ASC. + rows.slice(0, 10).forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + }); + it("should render an empty grid", async () => { + const rows: Row[] = []; + render( + + + + ); + + screen.getByRole("img", { name: /illustration of an empty table/i }); + screen.getByText(/this table is empty/i); + }); + it("should render a loading grid even if rows are empty", async () => { + const rows: Row[] = []; + render( + + + + ); + + // Verify that the empty state is not rendered. + expect( + screen.queryByRole("img", { name: /illustration of an empty table/i }) + ).toBeNull(); + expect(screen.queryByText(/this table is empty/i)).toBeNull(); + + // Verify the loading state. + screen.getByRole("status", { + name: "Loading data", + }); + }); +}); diff --git a/packages/react/src/components/DataGrid/SimpleDataGrid.tsx b/packages/react/src/components/DataGrid/SimpleDataGrid.tsx new file mode 100644 index 0000000..bae1f67 --- /dev/null +++ b/packages/react/src/components/DataGrid/SimpleDataGrid.tsx @@ -0,0 +1,67 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { usePagination } from "components/Pagination"; +import { BaseProps, DataGrid, Row, SortModel } from "components/DataGrid/index"; + +/** + * Handles sorting, pagination. + */ +export const SimpleDataGrid = ({ + rows, + defaultPaginationParams, + defaultSortModel = [], + ...props +}: BaseProps & { + /** Pagination default props, should never change. */ + defaultPaginationParams?: Parameters[0] | boolean; + /** Pagination default props, should never change. */ + defaultSortModel?: SortModel; +}) => { + const [realRows, setRealRows] = useState([]); + const [sortModel, setSortModel] = useState(defaultSortModel); + const realPaginationParams = useMemo(() => { + if (typeof defaultPaginationParams === "boolean") { + return {}; + } + return defaultPaginationParams; + }, [defaultPaginationParams]); + + const pagination = realPaginationParams + ? usePagination(realPaginationParams) + : undefined; + + useEffect(() => { + pagination?.setPagesCount(Math.ceil(rows.length / pagination.pageSize)); + }, [rows]); + + useEffect(() => { + const sortKey = sortModel.length > 0 ? sortModel[0].field : "id"; + const sortPolarity = + sortModel.length > 0 && sortModel[0].sort === "asc" ? 1 : -1; + const sortedRows = [...rows].sort((a, b) => { + if (a[sortKey] < b[sortKey]) return -sortPolarity; + if (a[sortKey] > b[sortKey]) return sortPolarity; + return 0; + }); + + if (pagination) { + setRealRows( + sortedRows.slice( + (pagination.page - 1) * pagination.pageSize, + pagination.page * pagination.pageSize + ) + ); + } else { + setRealRows(sortedRows); + } + }, [pagination?.page, sortModel, rows]); + + return ( + + ); +}; diff --git a/packages/react/src/components/DataGrid/empty.svg b/packages/react/src/components/DataGrid/empty.svg new file mode 100644 index 0000000..d6a2187 --- /dev/null +++ b/packages/react/src/components/DataGrid/empty.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/react/src/components/DataGrid/index.scss b/packages/react/src/components/DataGrid/index.scss new file mode 100644 index 0000000..50212c7 --- /dev/null +++ b/packages/react/src/components/DataGrid/index.scss @@ -0,0 +1,114 @@ +.c__datagrid { + + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--c--theme--spacings--s); + position: relative; + + &--empty { + min-height: 400px; + background-color: var(--c--theme--colors--greyscale-000); + border: 1px var(--c--theme--colors--greyscale-300) solid; + display: flex; + align-items: center; + justify-content: center; + } + + &__loader { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: wait; + + &__background { + position: absolute; + inset: 0; + background-color: var(--c--theme--colors--greyscale-100); + opacity: 0.5; + animation: pulse 1s infinite var(--c--theme--transitions--ease-out); + + @keyframes pulse { + 0% { opacity: 0.5; } + 50% { opacity: 0.7; } + 100% { opacity: 0.5; } + } + } + } + + .c__datagrid__empty-placeholder { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--c--theme--spacings--s); + + img { + max-width: 400px; + width: 80%; + } + } + + > table { + border-collapse: collapse; + font-weight: var(--c--theme--font--weights--regular); + width: 100%; + + th, td { + text-align: left; + padding: 0 var(--c--theme--spacings--s); + white-space: nowrap; + font-size: var(--c--theme--font--sizes--m); + height: 3rem; + } + + th { + color: var(--c--theme--colors--greyscale-800); + font-weight: var(--c--theme--font--weights--bold); + font-size: var(--c--theme--font--sizes--h5); + text-transform: uppercase; + + .c__datagrid__header { + display: flex; + align-items: center; + + .material-icons { + color: var(--c--theme--colors--greyscale-500); + } + + &--sortable { + cursor: pointer; + } + + &__icon-placeholder { + width: 24px; + height: 24px; + } + } + } + + td { + color: var(--c--theme--colors--greyscale-700); + } + + tbody tr { + border: 1px var(--c--theme--colors--greyscale-300) solid; + } + + .c__datagrid__row { + &__cell { + &--highlight { + color: var(--c--theme--colors--greyscale-800); + font-weight: var(--c--theme--font--weights--medium); + } + } + } + + tbody { + background-color: var(--c--theme--colors--greyscale-000); + } + + } + +} diff --git a/packages/react/src/components/DataGrid/index.spec.tsx b/packages/react/src/components/DataGrid/index.spec.tsx new file mode 100644 index 0000000..89298d6 --- /dev/null +++ b/packages/react/src/components/DataGrid/index.spec.tsx @@ -0,0 +1,326 @@ +import React, { useEffect, useState } from "react"; +import { faker } from "@faker-js/faker"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { getAllByRole, getByRole } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; +import { usePagination } from "components/Pagination"; +import { DataGrid, SortModel } from "components/DataGrid/index"; +import { CunninghamProvider } from "components/Provider"; +import { Deferred } from "tests/deferred"; +import { expectPaginationList } from "components/Pagination/utils"; +import { Button } from "components/Button"; + +describe("", () => { + afterEach(() => {}); + + it("should render a grid with server-side loading", async () => { + const database = Array.from(Array(23)).map(() => ({ + id: faker.datatype.uuid(), + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + email: faker.internet.email(), + address: faker.address.streetAddress(), + })); + + const Component = () => { + const [isLoading, setIsLoading] = useState(true); + const pagination = usePagination({}); + const [sortModel, setSortModel] = useState([ + { + field: "lastName", + sort: "desc", + }, + ]); + const [rows, setRows] = useState([]); + + useEffect(() => { + setIsLoading(true); + + const fetchData = async () => { + const query: any = { + page: pagination.page, + }; + if (sortModel.length > 0) { + query.sort = sortModel[0].field; + query.sortOrder = sortModel[0].sort; + } + + // Simulate HTTP request. + // eslint-disable-next-line compat/compat + const queryString = new URLSearchParams(query); + const response = await fetch("https://example.com?" + queryString); + const data = await response.json(); + + // Set the pagination length. + pagination.setPagesCount(Math.ceil(data.count / pagination.pageSize)); + // Select the rows to display on the current page. + setRows(data.rows); + setIsLoading(false); + }; + fetchData(); + }, [pagination.page, sortModel]); + + return ( + + + + ); + }; + + let deferred = new Deferred(); + + fetchMock.mockIf( + "https://example.com/?page=1&sort=lastName&sortOrder=desc", + () => deferred.promise + ); + + render(); + + // Make sure it is loading. + expect(screen.queryAllByRole("row").length).toBe(0); + screen.getByRole("status", { + name: "Loading data", + }); + + // Resolve request. + database.sort((a, b) => a.firstName.localeCompare(b.firstName)); + await act(() => + deferred.resolve( + JSON.stringify({ + rows: database.slice(0, 10), + count: database.length, + }) + ) + ); + + // Make sure the loader disappears. + await waitFor(() => + expect( + screen.queryByRole("status", { + name: "Loading data", + }) + ).toBeNull() + ); + + // Make sure the rows are rendered. + await waitFor(() => { + expect(screen.getAllByRole("row").length).toBe(11); + database.slice(0, 10).forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + }); + + expectPaginationList([ + { text: "navigate_before", name: "Go to previous page" }, + { text: "1", name: "You are currently on page 1" }, + { text: "2", name: "Go to page 2" }, + { text: "3", name: "Go to page 3" }, + { text: "navigate_next", name: "Go to next page" }, + ]); + + // Mock page 2 fetch. + deferred = new Deferred(); + fetchMock.mockIf( + "https://example.com/?page=2&sort=lastName&sortOrder=desc", + () => deferred.promise + ); + + // Go to page 2. + const nextButton = screen.getByRole("button", { + name: "Go to next page", + }); + const user = userEvent.setup(); + user.click(nextButton); + + // While loading it still shows the previous page. + await waitFor(() => { + screen.getByRole("status", { + name: "Loading data", + }); + expect(screen.getAllByRole("row").length).toBe(11); + database.slice(0, 10).forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + }); + + // Resolve page 2 mock. + await act(() => + deferred.resolve( + JSON.stringify({ + rows: database.slice(10, 20), + count: database.length, + }) + ) + ); + + // Make sure the loader disappears. + await waitFor(() => + expect( + screen.queryByRole("status", { + name: "Loading data", + }) + ).toBeNull() + ); + + // Make sure the rows are rendered. + await waitFor(() => { + expect(screen.getAllByRole("row").length).toBe(11); + database.slice(10, 20).forEach((row) => { + const element = screen.getByTestId(row.id); + const tds = getAllByRole(element, "cell"); + expect(tds.length).toBe(4); + expect(tds[0].textContent).toEqual(row.firstName); + expect(tds[1].textContent).toEqual(row.lastName); + expect(tds[2].textContent).toEqual(row.email); + expect(tds[3].textContent).toEqual(row.address); + }); + }); + }); + it("should render custom cells", async () => { + const database = Array.from(Array(10)).map(() => ({ + id: faker.datatype.uuid(), + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + email: faker.internet.email(), + address: faker.address.streetAddress(), + })); + + const Component = () => { + return ( + + ( +