(react) add DataGrid, SimpleDataGrid components

The DataGrid component can be considered as the core one, which provides
a full controlled component, but more complicated than SimpleDataGrid
which is based on DataGrid. SimpleDataGrid is intended to give a simple
ready-to-use data grid for client side data for example.
This commit is contained in:
Nathan Vasse
2023-03-07 17:06:50 +01:00
committed by NathanVss
parent 8e078c87c2
commit 994d42578e
39 changed files with 3105 additions and 7 deletions

View File

@@ -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/*"]}]
}
}

View File

@@ -12,6 +12,7 @@ module.exports = {
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
'@storybook/preset-scss'
],
'framework': '@storybook/react',
'core': {

View File

@@ -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"
}
}

View File

@@ -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;
}

View File

@@ -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("<SimpleDataGrid/>", () => {
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(
<CunninghamProvider>
<SimpleDataGrid
columns={[
{
field: "firstName",
headerName: "First name",
},
{
field: "lastName",
headerName: "Last name",
},
{
field: "email",
headerName: "Email",
},
{
field: "address",
headerName: "Address",
},
]}
rows={rows}
/>
</CunninghamProvider>
);
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(
<CunninghamProvider>
<SimpleDataGrid
columns={[
{
field: "firstName",
headerName: "First name",
},
{
field: "lastName",
headerName: "Last name",
},
{
field: "email",
headerName: "Email",
},
{
field: "address",
headerName: "Address",
},
]}
rows={rows}
defaultPaginationParams={{
pageSize: 10,
}}
defaultSortModel={[
{
field: "firstName",
sort: "asc",
},
]}
/>
</CunninghamProvider>
);
// 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<string, boolean>;
const Wrapper = () => {
const [rowSelection, setRowSelection] = useState({});
lastRowSelection = rowSelection;
return (
<CunninghamProvider>
<SimpleDataGrid
columns={[
{
field: "firstName",
headerName: "First name",
},
{
field: "lastName",
headerName: "Last name",
},
{
field: "email",
headerName: "Email",
},
{
field: "address",
headerName: "Address",
},
]}
rows={rows}
defaultSortModel={[
{
field: "firstName",
sort: "asc",
},
]}
enableRowSelection={true}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
/>
</CunninghamProvider>
);
};
render(<Wrapper />);
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(
<CunninghamProvider>
<SimpleDataGrid
columns={[
{
field: "firstName",
headerName: "First name",
},
{
field: "lastName",
headerName: "Last name",
},
{
field: "email",
headerName: "Email",
},
{
field: "address",
headerName: "Address",
},
]}
rows={rows}
defaultPaginationParams={{
pageSize: 10,
}}
defaultSortModel={[
{
field: "firstName",
sort: "asc",
},
]}
/>
</CunninghamProvider>
);
// 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(
<CunninghamProvider>
<SimpleDataGrid
columns={[
{
field: "firstName",
headerName: "First name",
},
{
field: "lastName",
headerName: "Last name",
},
{
field: "email",
headerName: "Email",
enableSorting: false,
},
{
field: "address",
headerName: "Address",
},
]}
rows={rows}
defaultPaginationParams={{
pageSize: 10,
}}
defaultSortModel={[
{
field: "firstName",
sort: "asc",
},
]}
/>
</CunninghamProvider>
);
// 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(
<CunninghamProvider>
<SimpleDataGrid
columns={[
{
field: "firstName",
headerName: "First name",
},
]}
rows={rows}
/>
</CunninghamProvider>
);
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(
<CunninghamProvider>
<SimpleDataGrid
columns={[
{
field: "firstName",
headerName: "First name",
},
]}
rows={rows}
isLoading={true}
/>
</CunninghamProvider>
);
// 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",
});
});
});

View File

@@ -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<typeof usePagination>[0] | boolean;
/** Pagination default props, should never change. */
defaultSortModel?: SortModel;
}) => {
const [realRows, setRealRows] = useState<Row[]>([]);
const [sortModel, setSortModel] = useState<SortModel>(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 (
<DataGrid
{...props}
pagination={pagination}
rows={realRows}
sortModel={sortModel}
onSortModelChange={setSortModel}
/>
);
};

View File

@@ -0,0 +1,85 @@
<svg width="411" height="185" viewBox="0 0 411 185" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="409.009" height="15.3105" transform="translate(1)" fill="white"/>
<rect x="7.12402" y="4.59326" width="85.7389" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="108.174" y="4.59326" width="76.2184" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="199.702" y="4.59326" width="76.2184" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="291.231" y="4.59326" width="76.2184" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="382.761" y="4.59326" width="15" height="6" rx="0.765526" fill="#C2C6CA"/>
<rect x="0.808618" y="15.1192" width="409.392" height="15.6933" fill="white"/>
<rect x="7.12402" y="19.9038" width="85.7389" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="108.174" y="19.9038" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="199.702" y="19.9038" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="291.231" y="19.9038" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="382.761" y="19.9038" width="15" height="6" rx="0.765526" fill="#E7E8EA"/>
<rect x="0.808618" y="15.1192" width="409.392" height="15.6933" stroke="#E8E9EB" stroke-width="0.382763"/>
<rect x="0.808618" y="30.4297" width="409.392" height="15.6933" fill="white"/>
<rect x="7.12402" y="35.2144" width="85.7389" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="108.174" y="35.2144" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="199.702" y="35.2144" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="291.231" y="35.2144" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="382.761" y="35.2144" width="15" height="6" rx="0.765526" fill="#E7E8EA"/>
<rect x="0.808618" y="30.4297" width="409.392" height="15.6933" stroke="#E8E9EB" stroke-width="0.382763"/>
<rect x="0.808618" y="45.7403" width="409.392" height="15.6933" fill="white"/>
<rect x="7.12402" y="50.5249" width="85.7389" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="108.174" y="50.5249" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="199.702" y="50.5249" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="291.231" y="50.5249" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="382.761" y="50.5249" width="15" height="6" rx="0.765526" fill="#E7E8EA"/>
<rect x="0.808618" y="45.7403" width="409.392" height="15.6933" stroke="#E8E9EB" stroke-width="0.382763"/>
<rect x="0.808618" y="61.0508" width="409.392" height="15.6933" fill="white"/>
<rect x="7.12402" y="65.8354" width="85.7389" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="108.174" y="65.8354" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="199.702" y="65.8354" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="291.231" y="65.8354" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="382.761" y="65.8354" width="15" height="6" rx="0.765526" fill="#E7E8EA"/>
<rect x="0.808618" y="61.0508" width="409.392" height="15.6933" stroke="#E8E9EB" stroke-width="0.382763"/>
<rect x="0.808618" y="76.3614" width="409.392" height="15.6933" fill="white"/>
<rect x="7.12402" y="81.146" width="85.7389" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="108.174" y="81.146" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="199.702" y="81.146" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="291.231" y="81.146" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="382.761" y="81.146" width="15" height="6" rx="0.765526" fill="#E7E8EA"/>
<rect x="0.808618" y="76.3614" width="409.392" height="15.6933" stroke="#E8E9EB" stroke-width="0.382763"/>
<rect x="0.808618" y="91.6719" width="409.392" height="15.6933" fill="white"/>
<rect x="7.12402" y="96.4565" width="85.7389" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="108.174" y="96.4565" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="199.702" y="96.4565" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="291.231" y="96.4565" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="382.761" y="96.4565" width="15" height="6" rx="0.765526" fill="#E7E8EA"/>
<rect x="0.808618" y="91.6719" width="409.392" height="15.6933" stroke="#E8E9EB" stroke-width="0.382763"/>
<rect x="0.808618" y="106.982" width="409.392" height="15.6933" fill="white"/>
<rect x="7.12402" y="111.767" width="85.7389" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="108.174" y="111.767" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="199.702" y="111.767" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="291.231" y="111.767" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="382.761" y="111.767" width="15" height="6" rx="0.765526" fill="#E7E8EA"/>
<rect x="0.808618" y="106.982" width="409.392" height="15.6933" stroke="#E8E9EB" stroke-width="0.382763"/>
<rect x="0.808618" y="122.293" width="409.392" height="15.6933" fill="white"/>
<rect x="7.12402" y="127.078" width="85.7389" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="108.174" y="127.078" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="199.702" y="127.078" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="291.231" y="127.078" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="382.761" y="127.078" width="15" height="6" rx="0.765526" fill="#E7E8EA"/>
<rect x="0.808618" y="122.293" width="409.392" height="15.6933" stroke="#E8E9EB" stroke-width="0.382763"/>
<rect x="0.808618" y="137.604" width="409.392" height="15.6933" fill="white"/>
<rect x="7.12402" y="142.388" width="85.7389" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="108.174" y="142.388" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="199.702" y="142.388" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="291.231" y="142.388" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="382.761" y="142.388" width="15" height="6" rx="0.765526" fill="#E7E8EA"/>
<rect x="0.808618" y="137.604" width="409.392" height="15.6933" stroke="#E8E9EB" stroke-width="0.382763"/>
<rect x="0.808618" y="152.914" width="409.392" height="15.6933" fill="white"/>
<rect x="7.12402" y="157.699" width="85.7389" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="108.174" y="157.699" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="199.702" y="157.699" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="291.231" y="157.699" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="382.761" y="157.699" width="15" height="6" rx="0.765526" fill="#E7E8EA"/>
<rect x="0.808618" y="152.914" width="409.392" height="15.6933" stroke="#E8E9EB" stroke-width="0.382763"/>
<rect x="0.808618" y="168.224" width="409.392" height="15.6933" fill="white"/>
<rect x="7.12402" y="173.009" width="85.7389" height="6.12421" rx="0.765526" fill="#C2C6CA"/>
<rect x="108.174" y="173.009" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="199.702" y="173.009" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="291.231" y="173.009" width="76.2184" height="6.12421" rx="0.765526" fill="#E7E8EA"/>
<rect x="382.761" y="173.009" width="15" height="6" rx="0.765526" fill="#E7E8EA"/>
<rect x="0.808618" y="168.224" width="409.392" height="15.6933" stroke="#E8E9EB" stroke-width="0.382763"/>
</svg>

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -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);
}
}
}

View File

@@ -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("<DataGrid/>", () => {
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<SortModel>([
{
field: "lastName",
sort: "desc",
},
]);
const [rows, setRows] = useState<any[]>([]);
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 (
<CunninghamProvider>
<DataGrid
columns={[
{
field: "firstName",
headerName: "First name",
highlight: true,
},
{
field: "lastName",
headerName: "Last name",
},
{
field: "email",
headerName: "Email",
highlight: true,
},
{
field: "address",
headerName: "Address",
},
]}
rows={rows}
pagination={pagination}
sortModel={sortModel}
onSortModelChange={setSortModel}
isLoading={isLoading}
/>
</CunninghamProvider>
);
};
let deferred = new Deferred<string>();
fetchMock.mockIf(
"https://example.com/?page=1&sort=lastName&sortOrder=desc",
() => deferred.promise
);
render(<Component />);
// 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 (
<CunninghamProvider>
<DataGrid
columns={[
{
field: "firstName",
headerName: "First name",
highlight: true,
},
{
renderCell: () => (
<Button
color="tertiary"
size="small"
icon={<span className="material-icons">delete</span>}
/>
),
},
]}
rows={database}
/>
</CunninghamProvider>
);
};
render(<Component />);
database.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);
getByRole(tds[1], "button", {
name: "delete",
});
});
});
it("should render highlighted column", 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 (
<CunninghamProvider>
<DataGrid
columns={[
{
field: "firstName",
headerName: "First name",
highlight: true,
},
{
field: "lastName",
headerName: "Last name",
},
{
field: "email",
headerName: "Email",
highlight: true,
},
]}
rows={database}
/>
</CunninghamProvider>
);
};
render(<Component />);
const table = screen.getByRole("table");
const ths = getAllByRole(table, "columnheader");
expect(ths.length).toBe(3);
expect(ths[0].textContent).toEqual("First name");
expect(ths[1].textContent).toEqual("Last name");
expect(ths[2].textContent).toEqual("Email");
database.forEach((row) => {
const element = screen.getByTestId(row.id);
const tds = getAllByRole(element, "cell");
expect(tds.length).toBe(3);
expect(tds[0].textContent).toEqual(row.firstName);
expect(Array.from(tds[0].classList)).toContain(
"c__datagrid__row__cell--highlight"
);
expect(tds[1].textContent).toEqual(row.lastName);
expect(Array.from(tds[1].classList)).not.toContain(
"c__datagrid__row__cell--highlight"
);
expect(tds[2].textContent).toEqual(row.email);
expect(Array.from(tds[2].classList)).toContain(
"c__datagrid__row__cell--highlight"
);
});
});
});

View File

@@ -0,0 +1,214 @@
import { Canvas, Meta, Story, Source, ArgsTable } from '@storybook/addon-docs';
import { DataGrid } from './index';
import { SimpleDataGrid } from './SimpleDataGrid';
<Meta title="Components/DataGrid/Doc" component={DataGrid}/>
export const Template = (args) => <DataGrid {...args} />;
# DataGrid
Cunningham provides a DataGrid component that can be used to display data in a tabular format. The DataGrid component
is built on top of [Tan Stack Table](https://tanstack.com/table/v8). It can be used for client only data or server side data.
<Canvas>
<Story id="components-datagrid--full-server-side"/>
</Canvas>
## Get Started
As you will see there are two different implementations of the DataGrid (a simple one and a more versatile one). But
in both cases you will need to provide columns definition and rows, those are required props.
The `columns` props is an array of objects that describe the columns of the table.
The `rows` props is an array of objects that describe the rows of the table. Each object must have a `id` property.
We will explore the possibilities that those props provide through the following examples.
## SimpleDataGrid
<Source
language='ts'
dark
format={false}
code={`import { SimpleDataGrid } from "@openfun/cunningham-react";`}
/>
This component is a wrapper around the more complicated `DataGrid` component. It is mostly intended to be used for client
side data that is already loaded. It provides a simple approach so you don't have to think about controlled Pagination,
Sorting etc ...
Take a look at the following example that renders a table of users.
<Canvas withSource="open">
<Story id="components-datagrid--client-side-without-pagination"/>
</Canvas>
As you can see in this example there is no pagination, but we can simply add it by adding a `defaultPaginationParams` props.
We will also enable a default sorting on the `price` column with the `defaultSortModel` props, along with the row selection
with `enableRowSelection`, `rowSelection` and `onRowSelectionChange` props.
> Please click on checkboxes to select rows to see hows the `onRowSelectionChange` prop works, selected ids are displayed
below the table.
<Canvas withSource="open">
<Story id="components-datagrid--client-side-with-pagination"/>
</Canvas>
As you can see, with `SimpleDataGrid` you can easily add pagination, sorting without have to worry about controlling
their states.
### Props
<ArgsTable of={SimpleDataGrid} />
## DataGrid
Now let's dive into the usage of the `DataGrid` component. This component is more versatile and can be used for both
client side and server side data. It is also more complicated to use as you will need to control the state of the
pagination, sorting etc ...
Please take a look at the following example that simulates a server side data that's re-fetched on each page and sorting
change.
<Canvas withSource="open">
<Story id="components-datagrid--full-server-side"/>
</Canvas>
As you can see, in this example the pagination and sorting are now controlled, this is more verbose but gives you more
control over the state of the table, like being able to use `useEffect` to fetch data when the state changes.
### Props
<ArgsTable of={DataGrid} />
## Columns
As you can see there are two types of columns, one for displaying bare data and one for displaying
custom content like the last one that displays a button.
### Data Columns
Columns that display only data must define a `field` property that will be used to retrieve the data from the row object,
a `headerName` property that will be used as the column header.
### Custom Columns
Columns that display custom content must define a `renderCell` property that will be used to render the content of the
cell. This property is a function that takes a row as argument and must return a ReactNode.
### Highlight
You can highlight any column by setting the `highlight` property to `true`. This will render the column with a bold
font.
## Sorting
By default sorting is enabled on columns but you can disable it by setting the `enableSorting` property of columns to `false`.
Bare in mind that with `SimpleDataGrid` this is all you have to do, but with the `DataGrid` component more work is
needed as you will need to provide `sortModel` and `onSortModelChange` props.
> ⚠️ Even though `sortModel` is an array, only the first element will be used for now.
## Pagination
The pagination for `DataGrid` is exactly the same as [Pagination](?path=/story/components-pagination-doc--page),
that's why you can see that `usePagination` hook is used to control the state of the pagination. You can see it in action
in the server side example.
## Row Selection
In order to enable row selection you need to define the following props: `enableRowSelection`, `rowSelection` and
`onRowSelectionChange` props.
> This feature is controlled even with `SimpleDataGrid` because this is a type of data that you want to keep track of
outside the grid component ( for example to delete selected rows ).
## Loading
The component provides out of the box a loading state that can be enabled by setting the `isLoading` props to `true`.
So feel free to use it between page or sorting changes when you are fetching data from a server.
<Canvas withSource="open">
<Story id="components-datagrid--loading"/>
</Canvas>
## Empty State
The component automatically displays an empty state when there is no data to display and it is not loading.
<Canvas withSource="open">
<Story id="components-datagrid--empty"/>
</Canvas>
## Do's and don'ts
### Button
- ❌ Dont place button(s) in other column than the last column
- ✅ Do place buttons all on the right because it designates the action to be done on the whole row, and end of read parsing by eyes.
- ✅ Be consistent, if you add a button on a row, you must do the same on the other rows.
<img src="components/DataGrid/resources/dd_1_dn.svg"/>
<img src="components/DataGrid/resources/dd_1_d.svg"/>
### Primary Button
- ❌ Dont show on each row the primary button
- ✅ Do show The primary button only at the hover.
- ✅ Do use secondary or tertiary buttons for always displayed button.
<img src="components/DataGrid/resources/dd_2_dn.svg"/>
<img src="components/DataGrid/resources/dd_2_d.svg"/>
### Actions
- ❌ Dont have more than one main action in a line. If you have other secondary actions, you have to put them in a menu.
- ✅ You should only have one icon per line
<img src="components/DataGrid/resources/dd_3_dn.svg"/>
<img src="components/DataGrid/resources/dd_3_d.svg"/>
### Titles
- ❌ Dont use a title before a data table to explain what is the table
- ✅ Do for each table, add a title to explain what we look at in less than 7 words
If you have many subtable add another title in a H+2 sizes.
<img src="components/DataGrid/resources/dd_4_dn.svg"/>
<img src="components/DataGrid/resources/dd_4_d.svg"/>
### Maximum Width text Width in a cell
- ✅ If the size of a text in a cell reaches the maximum size of a cell it becomes 3 small points. If your text exceeds the size of the cell, it will be hidden and will only be displayed on screen hover.
- ❌ Don't exceed 2 lines of entries
<img src="components/DataGrid/resources/dd_5_dn.svg"/>
<img src="components/DataGrid/resources/dd_5_d.svg"/>
### Text weight & color
- ❌ Dont use visual difference on different row in a col (ex: Col2)
- ✅ Do use only one column to catch the eye and very often it is the first one.
<img src="components/DataGrid/resources/dd_6_dn.svg"/>
<img src="components/DataGrid/resources/dd_6_d.svg"/>
### Units
- ❌ Dont put the unit in the cell but in the title of the column to avoid repetition.
<img src="components/DataGrid/resources/dd_7_dn.svg"/>
<img src="components/DataGrid/resources/dd_7_d.svg"/>
### Empty state
- ❌ Dont use the primary button more than once in a page
- ✅ You can have many empty table on one page, it can be a primary if it's really the main goal of the screen
- ✅ Do use secondary button if you have many object inn one page.
<img src="components/DataGrid/resources/dd_8_dn.svg"/>
<img src="components/DataGrid/resources/dd_8_d_1.svg"/>
<img src="components/DataGrid/resources/dd_8_d_2.svg"/>

View File

@@ -0,0 +1,251 @@
import { ComponentMeta } from "@storybook/react";
import React, { useEffect, useMemo, useState } from "react";
import { faker } from "@faker-js/faker";
import { DataGrid, SortModel } from "components/DataGrid/index";
import { usePagination } from "components/Pagination";
import { CunninghamProvider } from "components/Provider";
import { Button } from "components/Button";
import { SimpleDataGrid } from "components/DataGrid/SimpleDataGrid";
export default {
title: "Components/DataGrid",
component: DataGrid,
} as ComponentMeta<typeof DataGrid>;
export const Empty = () => {
return (
<CunninghamProvider>
<DataGrid
columns={[
{
field: "firstName",
headerName: "First name",
highlight: true,
},
]}
rows={[]}
/>
</CunninghamProvider>
);
};
export const Loading = () => {
return (
<CunninghamProvider>
<DataGrid
columns={[
{
field: "firstName",
headerName: "First name",
highlight: true,
},
]}
rows={[]}
isLoading={true}
/>
</CunninghamProvider>
);
};
export const ClientSideWithoutPagination = () => {
const database = useMemo(
() =>
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(),
})),
[]
);
return (
<CunninghamProvider>
<SimpleDataGrid
columns={[
{
field: "firstName",
headerName: "First name",
highlight: true,
},
{
field: "lastName",
headerName: "Last name",
},
{
field: "email",
headerName: "Email",
highlight: true,
},
{
field: "address",
headerName: "Address",
enableSorting: false,
},
{
headerName: "Actions",
renderCell: () => (
<Button
color="tertiary"
size="small"
icon={<span className="material-icons">delete</span>}
/>
),
},
]}
rows={database}
/>
</CunninghamProvider>
);
};
export const ClientSideWithPagination = () => {
const database = useMemo(
() =>
Array.from(Array(23)).map(() => ({
id: faker.datatype.uuid(),
carName: faker.company.name(),
year: faker.date.past().getFullYear(),
price: +faker.commerce.price(5000, 5005),
})),
[]
);
const [rowSelection, setRowSelection] = useState({});
return (
<CunninghamProvider>
<SimpleDataGrid
columns={[
{
field: "carName",
headerName: "Car name",
enableSorting: false,
},
{
field: "year",
headerName: "Year",
},
{
field: "price",
headerName: "Price ($)",
highlight: true,
},
]}
rows={database}
defaultPaginationParams={{
pageSize: 5,
}}
defaultSortModel={[
{
field: "price",
sort: "desc",
},
]}
enableRowSelection={true}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
/>
<div>Selected rows: {Object.keys(rowSelection).join(", ")}</div>
</CunninghamProvider>
);
};
export const FullServerSide = () => {
const database = useMemo(
() =>
Array.from(Array(191)).map(() => ({
id: faker.datatype.uuid(),
firstName: faker.name.firstName(),
lastName: faker.name.lastName(),
email: faker.internet.email(),
address: faker.address.streetAddress(),
})),
[]
);
const [rowSelection, setRowSelection] = useState({});
const [isLoading, setIsLoading] = useState(true);
const pagination = usePagination({ defaultPage: 10 });
const [sortModel, setSortModel] = useState<SortModel>([
{
field: "lastName",
sort: "desc",
},
]);
const [rows, setRows] = useState<any[]>([]);
useEffect(() => {
// Simulate server-side fetching.
// Sort database. On your side this is supposed to be done on the server.
const sortKey = sortModel.length > 0 ? sortModel[0].field : "id";
const sortPolarity =
sortModel.length > 0 && sortModel[0].sort === "asc" ? 1 : -1;
const sortedDatabase = [...database].sort((a: any, b: any) => {
if (a[sortKey] < b[sortKey]) return -sortPolarity;
if (a[sortKey] > b[sortKey]) return sortPolarity;
return 0;
});
setIsLoading(true);
// Simulate HTTP request.
setTimeout(() => {
// Set the pagination length.
pagination.setPagesCount(
Math.ceil(sortedDatabase.length / pagination.pageSize)
);
// Select the rows to display on the current page.
setRows(
sortedDatabase.slice(
(pagination.page - 1) * pagination.pageSize,
pagination.page * pagination.pageSize
)
);
setIsLoading(false);
}, 1000);
}, [pagination.page, sortModel]);
return (
<CunninghamProvider>
<DataGrid
columns={[
{
field: "firstName",
headerName: "First name",
highlight: true,
},
{
field: "lastName",
headerName: "Last name",
},
{
field: "email",
headerName: "Email",
highlight: true,
},
{
field: "address",
headerName: "Address",
},
{
renderCell: () => (
<Button
color="tertiary"
size="small"
icon={<span className="material-icons">delete</span>}
/>
),
},
]}
rows={rows}
pagination={pagination}
sortModel={sortModel}
onSortModelChange={setSortModel}
isLoading={isLoading}
enableRowSelection={true}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
/>
</CunninghamProvider>
);
};

View File

@@ -0,0 +1,242 @@
// import { Button } from "components/Button";
import React, { useMemo } from "react";
import classNames from "classnames";
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
RowSelectionState,
TableOptions,
useReactTable,
} from "@tanstack/react-table";
import { Pagination, PaginationProps } from "components/Pagination";
import { useCunningham } from "components/Provider";
import { Loader } from "components/Loader";
import {
paginationToPaginationState,
sortingStateToSortModel,
sortModelToSortingState,
useHeadlessColumns,
} from "components/DataGrid/utils";
import emptyImageUrl from "./empty.svg";
export interface Row extends Record<string, any> {
id: string;
}
export interface Column<T extends Row = Row> {
field?: string;
headerName?: string;
highlight?: boolean;
renderCell?: (params: { row: T }) => React.ReactNode;
enableSorting?: boolean;
}
export type SortModel = { field: string; sort: "asc" | "desc" | null }[];
export interface BaseProps<T extends Row = Row> {
columns: Column<T>[];
rows: T[];
isLoading?: boolean;
enableRowSelection?: boolean | ((row: T) => boolean);
onRowSelectionChange?: (newSelection: RowSelectionState) => void;
rowSelection?: RowSelectionState;
}
interface Props<T extends Row = Row> extends BaseProps<T> {
pagination?: PaginationProps;
sortModel?: SortModel;
onSortModelChange?: (newSortModel: SortModel) => void;
/** Options for the underlying tanstack table. */
tableOptions?: TableOptions<Row>;
}
export const DataGrid = ({
columns,
rows,
pagination,
sortModel,
onSortModelChange,
isLoading,
enableRowSelection,
onRowSelectionChange,
rowSelection,
tableOptions,
}: Props) => {
const { t } = useCunningham();
const headlessColumns = useHeadlessColumns({ columns, enableRowSelection });
/**
* Features.
*/
const paginationState = useMemo(
() => paginationToPaginationState(pagination),
[pagination]
);
const headlessSorting = useMemo(
() => sortModelToSortingState(sortModel),
[sortModel]
);
/**
* Table.
*/
const table = useReactTable({
data: rows,
columns: headlessColumns,
state: {
sorting: headlessSorting,
rowSelection,
pagination: paginationState,
},
// Sorting
getSortedRowModel: getSortedRowModel(),
manualSorting: true,
onSortingChange: (newHeadlessSorting) => {
// Should always be a function, but we must do this verification to avoid
// a TS error.
if (typeof newHeadlessSorting === "function") {
onSortModelChange?.(
sortingStateToSortModel(newHeadlessSorting(headlessSorting))
);
}
},
// Pagination
manualPagination: true,
pageCount: pagination?.pagesCount ?? 0,
onPaginationChange: (newPagination) => {
if (paginationState && typeof newPagination === "function") {
pagination?.onPageChange?.(newPagination(paginationState).pageIndex);
}
},
// Row selection
getCoreRowModel: getCoreRowModel(),
enableRowSelection,
onRowSelectionChange: (newRowSelection) => {
if (newRowSelection && typeof newRowSelection === "function") {
onRowSelectionChange?.(newRowSelection(rowSelection!));
}
},
// Related to https://github.com/TanStack/table/issues/4555.
getRowId: (row) => {
return row.id;
},
...tableOptions,
});
const isEmpty = rows.length === 0;
const showEmptyPlaceholder = !isLoading && isEmpty;
const getContent = () => {
if (showEmptyPlaceholder) {
return (
<div className="c__datagrid__empty-placeholder fs-h3 clr-greyscale-900 fw-bold">
<img src={emptyImageUrl} alt={t("components.datagrid.empty_alt")} />
{t("components.datagrid.empty")}
</div>
);
}
return (
<>
{isLoading && (
<div className="c__datagrid__loader">
<div className="c__datagrid__loader__background" />
<Loader aria-label={t("components.datagrid.loader_aria")} />
</div>
)}
{!isEmpty && (
<>
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<th key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder ? null : (
<div
className={classNames("c__datagrid__header", {
"c__datagrid__header--sortable":
header.column.getCanSort(),
})}
{...(header.column.getCanSort()
? {
role: "button",
tabIndex: 0,
onClick:
header.column.getToggleSortingHandler(),
}
: {})}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getIsSorted() === "asc" && (
<span className="material-icons">
arrow_drop_up
</span>
)}
{header.column.getIsSorted() === "desc" && (
<span className="material-icons">
arrow_drop_down
</span>
)}
{!header.column.getIsSorted() && (
<span className="c__datagrid__header__icon-placeholder" />
)}
</div>
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} data-testid={row.id}>
{row.getVisibleCells().map((cell, i) => {
let highlight = false;
if (enableRowSelection && i > 0) {
// Enabling selection adds a column at the beginning of the table.
highlight = !!columns[i - 1].highlight;
} else {
highlight = !!columns[i].highlight;
}
return (
<td
key={cell.id}
className={classNames({
"c__datagrid__row__cell--highlight": highlight,
})}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
{!!pagination && <Pagination {...pagination} />}
</>
)}
</>
);
};
return (
<div
className={classNames("c__datagrid", {
"c__datagrid--empty": isEmpty,
"c__datagrid--loading": isLoading,
})}
>
{getContent()}
</div>
);
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 138 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 139 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 138 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 141 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 161 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 312 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 293 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 154 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 147 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 138 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 141 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 131 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 138 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 476 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 169 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 123 KiB

View File

@@ -0,0 +1,103 @@
import {
CellContext,
ColumnDef,
createColumnHelper,
PaginationState,
SortingState,
} from "@tanstack/react-table";
import React from "react";
import { Checkbox } from "components/Forms/Checkbox";
import { PaginationProps } from "components/Pagination";
import { Column, Row, SortModel } from "components/DataGrid/index";
import { useCunningham } from "components/Provider";
/**
* Converts Cunningham's columns to the underlying tanstack table.
*/
export const useHeadlessColumns = ({
columns,
enableRowSelection,
}: {
columns: Column[];
enableRowSelection?: boolean | ((row: Row) => boolean);
}): ColumnDef<Row, any>[] => {
const { t } = useCunningham();
const columnHelper = createColumnHelper<Row>();
let headlessColumns = columns.map((column) => {
const opts = {
id: column.field ?? "actions",
enableSorting: column.enableSorting,
header: column.headerName,
cell: (info: CellContext<Row, any>) => {
if (column.renderCell) {
return column.renderCell({ row: info.row.original });
}
return info.row.original[info.column.id];
},
};
if (column.field) {
return columnHelper.accessor(column.field, opts);
}
return columnHelper.display(opts);
});
if (enableRowSelection) {
headlessColumns = [
columnHelper.display({
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
aria-label={t("components.datagrid.rows_selection_aria")}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
aria-label={t("components.datagrid.row_selection_aria")}
/>
),
}),
...headlessColumns,
];
}
return headlessColumns;
};
export const sortModelToSortingState = (sortModel?: SortModel) => {
if (!sortModel) {
return [];
}
return (
sortModel
// Remove sort: null values.
.filter((sort) => !!sort.sort)
.map((sort) => ({
id: sort.field,
desc: sort.sort === "desc",
}))
);
};
export const sortingStateToSortModel = (sorting: SortingState): SortModel => {
return sorting.map((sort) => ({
field: sort.id,
sort: sort.desc ? "desc" : "asc",
}));
};
export const paginationToPaginationState = (
pagination?: PaginationProps
): PaginationState | undefined => {
if (!pagination) {
return undefined;
}
return {
pageIndex: pagination.page ?? 0,
pageSize: pagination.pageSize,
};
};

View File

@@ -1,6 +1,8 @@
@import "cunningham-tokens";
@import '@openfun/cunningham-tokens/default-tokens';
@import './components/Accessibility';
@import './components/Button';
@import './components/DataGrid';
@import './components/Forms/Input';
@import './components/Loader';
@import './components/Pagination';

View File

@@ -1,6 +1,8 @@
import "./index.scss";
export * from "./components/Button";
export * from "./components/DataGrid";
export * from "./components/DataGrid/SimpleDataGrid";
export * from "./components/Loader";
export * from "./components/Pagination";
export * from "./components/Provider";

View File

@@ -11,7 +11,9 @@
"datagrid": {
"empty": "This table is empty",
"empty_alt": "Illustration of an empty table",
"loader_aria": "Loading data"
"loader_aria": "Loading data",
"rows_selection_aria":"All rows selection",
"row_selection_aria": "Row selection"
},
"provider": {
"test": "This is a test: {name}"

View File

@@ -0,0 +1,7 @@
import createFetchMock from "vitest-fetch-mock";
import { vi } from "vitest";
const fetchMocker = createFetchMock(vi);
// sets globalThis.fetch and globalThis.fetchMock to our mocked version
fetchMocker.enableMocks();

View File

@@ -0,0 +1,24 @@
/*
* Test helper: use a deferred object to control promise resolution without mocking
* deep inside our code.
*/
export class Deferred<T> {
promise: Promise<T>;
reject!: (reason?: any) => void;
resolve!: (value: T | PromiseLike<T>) => void;
constructor() {
this.promise = this._init();
}
reset() {
this.promise = this._init();
}
private _init(): Promise<any> {
return new Promise((resolve, reject) => {
this.reject = reject;
this.resolve = resolve;
});
}
}

View File

@@ -1 +1,5 @@
export const noop = () => undefined;
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -3,7 +3,7 @@
"compilerOptions": {
"noEmit": true,
"baseUrl": "./src",
"types": ["vitest/globals"]
"types": ["vitest/globals", "vite/client"]
},
"include": ["src", "cunningham.ts"],
"exclude": ["node_modules","dist", "**/tokens.ts"],

View File

@@ -36,5 +36,6 @@ export default defineConfig({
include: ["src/**/*.{ts,tsx}"],
exclude: ["**/*.stories.tsx", "**/*.spec.tsx"],
},
setupFiles: ["src/tests/Setup.ts"],
},
});

View File

@@ -1583,7 +1583,12 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@fontsource/material-icons@^4.5.4":
"@faker-js/faker@7.6.0":
version "7.6.0"
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-7.6.0.tgz#9ea331766084288634a9247fcd8b84f16ff4ba07"
integrity sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==
"@fontsource/material-icons@4.5.4":
version "4.5.4"
resolved "https://registry.yarnpkg.com/@fontsource/material-icons/-/material-icons-4.5.4.tgz#bfedde9352c36dbbacb6b49d190a1ce1e5917756"
integrity sha512-YGmXkkEdu6EIgpFKNmB/nIXzZocwSmbI01Ninpmml8x8BT0M6RR++V1KqOfpzZ6Cw/FQ2/KYonQ3x4IY/4VRRA==
@@ -3306,7 +3311,7 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/storybook-deployer@^2.8.16":
"@storybook/storybook-deployer@2.8.16":
version "2.8.16"
resolved "https://registry.yarnpkg.com/@storybook/storybook-deployer/-/storybook-deployer-2.8.16.tgz#890abe4fd81b6fbc028dffb6314016f208aba6c2"
integrity sha512-DRQrjyLKaRLXMYo7SNUznyGabtOLJ0b9yfBKNVMu6PsUHJifGPabXuNXmRPZ6qvyhHUSKLQgeLaX8L3Og6uFUg==
@@ -3386,6 +3391,18 @@
regenerator-runtime "^0.13.7"
resolve-from "^5.0.0"
"@tanstack/react-table@8.7.9":
version "8.7.9"
resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.7.9.tgz#9efcd168fb0080a7e0bc213b5eac8b55513babf4"
integrity sha512-6MbbQn5AupSOkek1+6IYu+1yZNthAKTRZw9tW92Vi6++iRrD1GbI3lKTjJalf8lEEKOqapPzQPE20nywu0PjCA==
dependencies:
"@tanstack/table-core" "8.7.9"
"@tanstack/table-core@8.7.9":
version "8.7.9"
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.7.9.tgz#0e975f8a5079972f1827a569079943d43257c42f"
integrity sha512-4RkayPMV1oS2SKDXfQbFoct1w5k+pvGpmX18tCXMofK/VDRdA2hhxfsQlMvsJ4oTX8b0CI4Y3GDKn5T425jBCw==
"@testing-library/dom@8.19.1":
version "8.19.1"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.19.1.tgz#0e2dafd281dedb930bb235eac1045470b4129d0e"
@@ -5575,6 +5592,11 @@ class-utils@^0.3.5:
isobject "^3.0.0"
static-extend "^0.1.1"
classnames@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
clean-css@^4.2.3:
version "4.2.4"
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178"
@@ -5975,6 +5997,13 @@ create-require@^1.1.0:
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cross-fetch@^3.0.6:
version "3.1.5"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"
integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==
dependencies:
node-fetch "2.6.7"
cross-spawn@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
@@ -10527,6 +10556,13 @@ node-dir@^0.1.10:
dependencies:
minimatch "^3.0.2"
node-fetch@2.6.7:
version "2.6.7"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
dependencies:
whatwg-url "^5.0.0"
node-fetch@^2.6.1, node-fetch@^2.6.7:
version "2.6.8"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.8.tgz#a68d30b162bc1d8fd71a367e81b997e1f4d4937e"
@@ -14004,6 +14040,13 @@ vite@4.0.3:
optionalDependencies:
fsevents "~2.3.2"
vitest-fetch-mock@0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/vitest-fetch-mock/-/vitest-fetch-mock-0.2.2.tgz#f6849dcf7a8e862a509e1cee2fa3bb0cb534f468"
integrity sha512-XmH6QgTSjCWrqXoPREIdbj40T7i1xnGmAsTAgfckoO75W1IEHKR8hcPCQ7SO16RsdW1t85oUm6pcQRLeBgjVYQ==
dependencies:
cross-fetch "^3.0.6"
vitest@0.26.2:
version "0.26.2"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.26.2.tgz#c1647e41d5619d1d059ff65d9c00672261ccac30"