✨(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:
5
.changeset/tall-glasses-attack.md
Normal file
5
.changeset/tall-glasses-attack.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@openfun/cunningham-react": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
add sorting on custom columns
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { faker } from "@faker-js/faker";
|
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 { expect } from "vitest";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { expectPaginationList } from ":/components/Pagination/utils";
|
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 () => {
|
it("should render a grid with non-sortable columns", async () => {
|
||||||
const rows = Array.from(Array(23))
|
const rows = Array.from(Array(23))
|
||||||
.map(() => ({
|
.map(() => ({
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ export const FullServerSide = () => {
|
|||||||
lastName: faker.person.lastName(),
|
lastName: faker.person.lastName(),
|
||||||
email: faker.internet.email(),
|
email: faker.internet.email(),
|
||||||
address: faker.location.streetAddress(),
|
address: faker.location.streetAddress(),
|
||||||
|
country: faker.location.countryCode(),
|
||||||
})),
|
})),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -261,6 +262,24 @@ export const FullServerSide = () => {
|
|||||||
field: "address",
|
field: "address",
|
||||||
headerName: "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",
|
id: "actions",
|
||||||
renderCell: () => (
|
renderCell: () => (
|
||||||
|
|||||||
@@ -30,12 +30,19 @@ export interface ColumnField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ColumnCustomCell<T extends Row = Row> {
|
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;
|
id: string;
|
||||||
|
field?: never;
|
||||||
renderCell: (params: { row: T }) => React.ReactNode;
|
renderCell: (params: { row: T }) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Column<T extends Row = Row> = (
|
export type Column<T extends Row = Row> = (
|
||||||
| ColumnCustomCell<T>
|
| ColumnCustomCell<T>
|
||||||
|
| ColumnDisplayCell<T>
|
||||||
| ColumnField
|
| ColumnField
|
||||||
) & {
|
) & {
|
||||||
headerName?: string;
|
headerName?: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
CellContext,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
PaginationState,
|
PaginationState,
|
||||||
@@ -23,23 +24,23 @@ export const useHeadlessColumns = <T extends Row>({
|
|||||||
const { t } = useCunningham();
|
const { t } = useCunningham();
|
||||||
const columnHelper = createColumnHelper<T>();
|
const columnHelper = createColumnHelper<T>();
|
||||||
let headlessColumns = columns.map((column) => {
|
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 = {
|
const opts = {
|
||||||
id: column.renderCell ? column.id : column.id ?? column.field,
|
id,
|
||||||
enableSorting: column.enableSorting,
|
enableSorting: column.enableSorting,
|
||||||
header: column.headerName,
|
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) {
|
if (column.field) {
|
||||||
// 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.
|
|
||||||
return columnHelper.accessor(column.field as any, opts);
|
return columnHelper.accessor(column.field as any, opts);
|
||||||
}
|
}
|
||||||
|
return columnHelper.display(opts);
|
||||||
return columnHelper.display({
|
|
||||||
...opts,
|
|
||||||
cell: (info) => {
|
|
||||||
return column.renderCell({ row: info.row.original });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
if (enableRowSelection) {
|
if (enableRowSelection) {
|
||||||
headlessColumns = [
|
headlessColumns = [
|
||||||
|
|||||||
Reference in New Issue
Block a user