(react) add sorting on custom cells

Previously sorting on custom cells was not possible because they were
considered as "display cells" by React-Table, which is made for actions
columns, which aren't sortable.

Closes #296 #100
This commit is contained in:
Nathan Vasse
2024-03-11 15:17:18 +01:00
committed by NathanVss
parent 91a7b2369f
commit 317cab4bad
5 changed files with 137 additions and 12 deletions

View File

@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": minor
---
add sorting on custom columns

View File

@@ -1,7 +1,7 @@
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 { getAllByRole, getByRole, within } from "@testing-library/dom";
import { expect } from "vitest";
import userEvent from "@testing-library/user-event";
import { expectPaginationList } from ":/components/Pagination/utils";
@@ -418,6 +418,99 @@ describe("<SimpleDataGrid/>", () => {
});
});
});
it("should render a grid with working sortable custom columns", async () => {
const rows = Array.from(Array(23))
.map(() => ({
id: faker.string.uuid(),
firstName: faker.person.firstName(),
country: faker.location.country(),
}))
.sort((a, b) => a.firstName.localeCompare(b.firstName));
render(
<CunninghamProvider>
<SimpleDataGrid
columns={[
{
field: "firstName",
headerName: "First name",
},
{
field: "country",
headerName: "Country",
renderCell: ({ row }) => <h1>{row.country}</h1>,
},
]}
rows={rows}
defaultPaginationParams={{
pageSize: 10,
}}
defaultSortModel={[
{
field: "firstName",
sort: "asc",
},
]}
/>
</CunninghamProvider>,
);
const user = userEvent.setup();
// 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(2);
expect(tds[0].textContent).toEqual(row.firstName);
});
// Sort by country ASC.
const table = screen.getByRole("table");
const ths = getAllByRole(table, "columnheader");
expect(ths.length).toBe(2);
expect(ths[1].textContent).toContain("Country");
expect(ths[1].textContent).not.toContain("arrow_drop_up");
expect(ths[1].textContent).not.toContain("arrow_drop_down");
await user.click(ths[1].querySelector("div")!);
// Verify first page rows are sorted by country ASC.
await waitFor(() => {
expect(ths[1].textContent).toContain("arrow_drop_up");
[...rows]
.sort((a, b) => a.country.localeCompare(b.country))
.slice(0, 10)
.forEach((row) => {
const element = screen.getByTestId(row.id);
const tds = getAllByRole(element, "cell");
expect(tds.length).toBe(2);
expect(tds[0].textContent).toEqual(row.firstName);
within(tds[1]).getByRole("heading", { name: row.country, level: 1 });
});
});
// Sort by country DESC.
expect(ths.length).toBe(2);
expect(ths[1].textContent).toContain("Country");
expect(ths[1].textContent).toContain("arrow_drop_up");
expect(ths[1].textContent).not.toContain("arrow_drop_down");
await user.click(ths[1].querySelector("div")!);
await waitFor(() => {
expect(ths[1].textContent).toContain("arrow_drop_down");
[...rows]
.sort((a, b) => a.country.localeCompare(b.country))
.reverse()
.slice(0, 10)
.forEach((row) => {
const element = screen.getByTestId(row.id);
const tds = getAllByRole(element, "cell");
expect(tds.length).toBe(2);
expect(tds[0].textContent).toEqual(row.firstName);
within(tds[1]).getByRole("heading", { name: row.country, level: 1 });
});
});
});
it("should render a grid with non-sortable columns", async () => {
const rows = Array.from(Array(23))
.map(() => ({

View File

@@ -195,6 +195,7 @@ export const FullServerSide = () => {
lastName: faker.person.lastName(),
email: faker.internet.email(),
address: faker.location.streetAddress(),
country: faker.location.countryCode(),
})),
[],
);
@@ -261,6 +262,24 @@ export const FullServerSide = () => {
field: "address",
headerName: "Address",
},
{
headerName: "Country",
field: "country",
renderCell: (context) => {
return (
<span
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>
<img
style={{ height: "24px" }}
src={`https://flagsapi.com/${context.row.country}/shiny/64.png`}
alt="Flag"
/>
{context.row.country}
</span>
);
},
},
{
id: "actions",
renderCell: () => (

View File

@@ -30,12 +30,19 @@ export interface ColumnField {
}
export interface ColumnCustomCell<T extends Row = Row> {
id?: string;
field: string;
renderCell: (params: { row: T }) => React.ReactNode;
}
export interface ColumnDisplayCell<T extends Row = Row> {
id: string;
field?: never;
renderCell: (params: { row: T }) => React.ReactNode;
}
export type Column<T extends Row = Row> = (
| ColumnCustomCell<T>
| ColumnDisplayCell<T>
| ColumnField
) & {
headerName?: string;

View File

@@ -1,4 +1,5 @@
import {
CellContext,
ColumnDef,
createColumnHelper,
PaginationState,
@@ -23,23 +24,23 @@ export const useHeadlessColumns = <T extends Row>({
const { t } = useCunningham();
const columnHelper = createColumnHelper<T>();
let headlessColumns = columns.map((column) => {
// Based on types we can assume that at least one of both is defined.
const id = (column.id ?? column.field) as string;
const opts = {
id: column.renderCell ? column.id : column.id ?? column.field,
id,
enableSorting: column.enableSorting,
header: column.headerName,
cell: (info: CellContext<any, any>) => {
if (column.renderCell) {
return column.renderCell({ row: info.row.original });
}
return info.cell.getValue();
},
};
if (!column.renderCell) {
// The any cast is needed because the type of the accessor is hard-defined on react-table.
// On our side we only use string as type for simplicity purpose.
if (column.field) {
return columnHelper.accessor(column.field as any, opts);
}
return columnHelper.display({
...opts,
cell: (info) => {
return column.renderCell({ row: info.row.original });
},
});
return columnHelper.display(opts);
});
if (enableRowSelection) {
headlessColumns = [