✨(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.
@@ -7,6 +7,6 @@
|
|||||||
"project": "./tsconfig.eslint.json"
|
"project": "./tsconfig.eslint.json"
|
||||||
},
|
},
|
||||||
"rules": {
|
"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/*"]}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ module.exports = {
|
|||||||
'@storybook/addon-essentials',
|
'@storybook/addon-essentials',
|
||||||
'@storybook/addon-interactions',
|
'@storybook/addon-interactions',
|
||||||
'@storybook/addon-a11y',
|
'@storybook/addon-a11y',
|
||||||
|
'@storybook/preset-scss'
|
||||||
],
|
],
|
||||||
'framework': '@storybook/react',
|
'framework': '@storybook/react',
|
||||||
'core': {
|
'core': {
|
||||||
|
|||||||
@@ -41,6 +41,8 @@
|
|||||||
"@fontsource/material-icons": "4.5.4",
|
"@fontsource/material-icons": "4.5.4",
|
||||||
"@fontsource/roboto": "4.5.8",
|
"@fontsource/roboto": "4.5.8",
|
||||||
"@openfun/cunningham-tokens": "*",
|
"@openfun/cunningham-tokens": "*",
|
||||||
|
"@tanstack/react-table": "8.7.9",
|
||||||
|
"classnames": "2.3.2",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0"
|
"react-dom": "18.2.0"
|
||||||
},
|
},
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.20.7",
|
"@babel/core": "7.20.7",
|
||||||
|
"@faker-js/faker": "7.6.0",
|
||||||
"@openfun/cunningham-tokens": "*",
|
"@openfun/cunningham-tokens": "*",
|
||||||
"@openfun/typescript-configs": "*",
|
"@openfun/typescript-configs": "*",
|
||||||
"@storybook/addon-a11y": "6.5.16",
|
"@storybook/addon-a11y": "6.5.16",
|
||||||
@@ -59,7 +62,7 @@
|
|||||||
"@storybook/builder-vite": "0.2.6",
|
"@storybook/builder-vite": "0.2.6",
|
||||||
"@storybook/preset-scss": "1.0.3",
|
"@storybook/preset-scss": "1.0.3",
|
||||||
"@storybook/react": "6.5.15",
|
"@storybook/react": "6.5.15",
|
||||||
"@storybook/storybook-deployer": "^2.8.16",
|
"@storybook/storybook-deployer": "2.8.16",
|
||||||
"@storybook/testing-library": "0.0.13",
|
"@storybook/testing-library": "0.0.13",
|
||||||
"@testing-library/dom": "8.19.1",
|
"@testing-library/dom": "8.19.1",
|
||||||
"@testing-library/react": "13.4.0",
|
"@testing-library/react": "13.4.0",
|
||||||
@@ -80,6 +83,7 @@
|
|||||||
"vite": "4.0.3",
|
"vite": "4.0.3",
|
||||||
"vite-plugin-dts": "1.7.1",
|
"vite-plugin-dts": "1.7.1",
|
||||||
"vite-tsconfig-paths": "4.0.3",
|
"vite-tsconfig-paths": "4.0.3",
|
||||||
"vitest": "0.26.2"
|
"vitest": "0.26.2",
|
||||||
|
"vitest-fetch-mock": "0.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
packages/react/src/components/Accessibility/index.scss
Normal 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;
|
||||||
|
}
|
||||||
543
packages/react/src/components/DataGrid/SimpleDataGrid.spec.tsx
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
packages/react/src/components/DataGrid/SimpleDataGrid.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
85
packages/react/src/components/DataGrid/empty.svg
Normal 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 |
114
packages/react/src/components/DataGrid/index.scss
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
326
packages/react/src/components/DataGrid/index.spec.tsx
Normal 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
214
packages/react/src/components/DataGrid/index.stories.mdx
Normal 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
|
||||||
|
|
||||||
|
- ❌ Don’t 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
|
||||||
|
|
||||||
|
- ❌ Don’t 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
|
||||||
|
|
||||||
|
- ❌ Don’t 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
|
||||||
|
|
||||||
|
- ❌ Don’t 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
|
||||||
|
|
||||||
|
- ❌ Don’t 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
|
||||||
|
|
||||||
|
- ❌ Don’t 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
|
||||||
|
|
||||||
|
- ❌ Don’t 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"/>
|
||||||
251
packages/react/src/components/DataGrid/index.stories.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
242
packages/react/src/components/DataGrid/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
46
packages/react/src/components/DataGrid/resources/dd_1_d.svg
Normal file
|
After Width: | Height: | Size: 138 KiB |
61
packages/react/src/components/DataGrid/resources/dd_1_dn.svg
Normal file
|
After Width: | Height: | Size: 139 KiB |
55
packages/react/src/components/DataGrid/resources/dd_2_d.svg
Normal file
|
After Width: | Height: | Size: 138 KiB |
52
packages/react/src/components/DataGrid/resources/dd_2_dn.svg
Normal file
|
After Width: | Height: | Size: 141 KiB |
68
packages/react/src/components/DataGrid/resources/dd_3_d.svg
Normal file
|
After Width: | Height: | Size: 161 KiB |
50
packages/react/src/components/DataGrid/resources/dd_3_dn.svg
Normal file
|
After Width: | Height: | Size: 92 KiB |
102
packages/react/src/components/DataGrid/resources/dd_4_d.svg
Normal file
|
After Width: | Height: | Size: 312 KiB |
90
packages/react/src/components/DataGrid/resources/dd_4_dn.svg
Normal file
|
After Width: | Height: | Size: 293 KiB |
38
packages/react/src/components/DataGrid/resources/dd_5_d.svg
Normal file
|
After Width: | Height: | Size: 154 KiB |
47
packages/react/src/components/DataGrid/resources/dd_5_dn.svg
Normal file
|
After Width: | Height: | Size: 147 KiB |
43
packages/react/src/components/DataGrid/resources/dd_6_d.svg
Normal file
|
After Width: | Height: | Size: 138 KiB |
43
packages/react/src/components/DataGrid/resources/dd_6_dn.svg
Normal file
|
After Width: | Height: | Size: 141 KiB |
43
packages/react/src/components/DataGrid/resources/dd_7_d.svg
Normal file
|
After Width: | Height: | Size: 131 KiB |
43
packages/react/src/components/DataGrid/resources/dd_7_dn.svg
Normal file
|
After Width: | Height: | Size: 138 KiB |
120
packages/react/src/components/DataGrid/resources/dd_8_d_1.svg
Normal file
|
After Width: | Height: | Size: 476 KiB |
119
packages/react/src/components/DataGrid/resources/dd_8_d_2.svg
Normal file
|
After Width: | Height: | Size: 169 KiB |
31
packages/react/src/components/DataGrid/resources/dd_8_dn.svg
Normal file
|
After Width: | Height: | Size: 123 KiB |
103
packages/react/src/components/DataGrid/utils.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
@import "cunningham-tokens";
|
@import "cunningham-tokens";
|
||||||
@import '@openfun/cunningham-tokens/default-tokens';
|
@import '@openfun/cunningham-tokens/default-tokens';
|
||||||
|
@import './components/Accessibility';
|
||||||
@import './components/Button';
|
@import './components/Button';
|
||||||
|
@import './components/DataGrid';
|
||||||
@import './components/Forms/Input';
|
@import './components/Forms/Input';
|
||||||
@import './components/Loader';
|
@import './components/Loader';
|
||||||
@import './components/Pagination';
|
@import './components/Pagination';
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
|
|
||||||
export * from "./components/Button";
|
export * from "./components/Button";
|
||||||
|
export * from "./components/DataGrid";
|
||||||
|
export * from "./components/DataGrid/SimpleDataGrid";
|
||||||
export * from "./components/Loader";
|
export * from "./components/Loader";
|
||||||
export * from "./components/Pagination";
|
export * from "./components/Pagination";
|
||||||
export * from "./components/Provider";
|
export * from "./components/Provider";
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
"datagrid": {
|
"datagrid": {
|
||||||
"empty": "This table is empty",
|
"empty": "This table is empty",
|
||||||
"empty_alt": "Illustration of an empty table",
|
"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": {
|
"provider": {
|
||||||
"test": "This is a test: {name}"
|
"test": "This is a test: {name}"
|
||||||
|
|||||||
7
packages/react/src/tests/Setup.ts
Normal 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();
|
||||||
24
packages/react/src/tests/deferred.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,5 @@
|
|||||||
export const noop = () => undefined;
|
export const noop = () => undefined;
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"baseUrl": "./src",
|
"baseUrl": "./src",
|
||||||
"types": ["vitest/globals"]
|
"types": ["vitest/globals", "vite/client"]
|
||||||
},
|
},
|
||||||
"include": ["src", "cunningham.ts"],
|
"include": ["src", "cunningham.ts"],
|
||||||
"exclude": ["node_modules","dist", "**/tokens.ts"],
|
"exclude": ["node_modules","dist", "**/tokens.ts"],
|
||||||
|
|||||||
@@ -36,5 +36,6 @@ export default defineConfig({
|
|||||||
include: ["src/**/*.{ts,tsx}"],
|
include: ["src/**/*.{ts,tsx}"],
|
||||||
exclude: ["**/*.stories.tsx", "**/*.spec.tsx"],
|
exclude: ["**/*.stories.tsx", "**/*.spec.tsx"],
|
||||||
},
|
},
|
||||||
|
setupFiles: ["src/tests/Setup.ts"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
47
yarn.lock
@@ -1583,7 +1583,12 @@
|
|||||||
minimatch "^3.1.2"
|
minimatch "^3.1.2"
|
||||||
strip-json-comments "^3.1.1"
|
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"
|
version "4.5.4"
|
||||||
resolved "https://registry.yarnpkg.com/@fontsource/material-icons/-/material-icons-4.5.4.tgz#bfedde9352c36dbbacb6b49d190a1ce1e5917756"
|
resolved "https://registry.yarnpkg.com/@fontsource/material-icons/-/material-icons-4.5.4.tgz#bfedde9352c36dbbacb6b49d190a1ce1e5917756"
|
||||||
integrity sha512-YGmXkkEdu6EIgpFKNmB/nIXzZocwSmbI01Ninpmml8x8BT0M6RR++V1KqOfpzZ6Cw/FQ2/KYonQ3x4IY/4VRRA==
|
integrity sha512-YGmXkkEdu6EIgpFKNmB/nIXzZocwSmbI01Ninpmml8x8BT0M6RR++V1KqOfpzZ6Cw/FQ2/KYonQ3x4IY/4VRRA==
|
||||||
@@ -3306,7 +3311,7 @@
|
|||||||
ts-dedent "^2.0.0"
|
ts-dedent "^2.0.0"
|
||||||
util-deprecate "^1.0.2"
|
util-deprecate "^1.0.2"
|
||||||
|
|
||||||
"@storybook/storybook-deployer@^2.8.16":
|
"@storybook/storybook-deployer@2.8.16":
|
||||||
version "2.8.16"
|
version "2.8.16"
|
||||||
resolved "https://registry.yarnpkg.com/@storybook/storybook-deployer/-/storybook-deployer-2.8.16.tgz#890abe4fd81b6fbc028dffb6314016f208aba6c2"
|
resolved "https://registry.yarnpkg.com/@storybook/storybook-deployer/-/storybook-deployer-2.8.16.tgz#890abe4fd81b6fbc028dffb6314016f208aba6c2"
|
||||||
integrity sha512-DRQrjyLKaRLXMYo7SNUznyGabtOLJ0b9yfBKNVMu6PsUHJifGPabXuNXmRPZ6qvyhHUSKLQgeLaX8L3Og6uFUg==
|
integrity sha512-DRQrjyLKaRLXMYo7SNUznyGabtOLJ0b9yfBKNVMu6PsUHJifGPabXuNXmRPZ6qvyhHUSKLQgeLaX8L3Og6uFUg==
|
||||||
@@ -3386,6 +3391,18 @@
|
|||||||
regenerator-runtime "^0.13.7"
|
regenerator-runtime "^0.13.7"
|
||||||
resolve-from "^5.0.0"
|
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":
|
"@testing-library/dom@8.19.1":
|
||||||
version "8.19.1"
|
version "8.19.1"
|
||||||
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.19.1.tgz#0e2dafd281dedb930bb235eac1045470b4129d0e"
|
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"
|
isobject "^3.0.0"
|
||||||
static-extend "^0.1.1"
|
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:
|
clean-css@^4.2.3:
|
||||||
version "4.2.4"
|
version "4.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178"
|
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"
|
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
|
||||||
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
|
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:
|
cross-spawn@^5.1.0:
|
||||||
version "5.1.0"
|
version "5.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
|
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
|
||||||
@@ -10527,6 +10556,13 @@ node-dir@^0.1.10:
|
|||||||
dependencies:
|
dependencies:
|
||||||
minimatch "^3.0.2"
|
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:
|
node-fetch@^2.6.1, node-fetch@^2.6.7:
|
||||||
version "2.6.8"
|
version "2.6.8"
|
||||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.8.tgz#a68d30b162bc1d8fd71a367e81b997e1f4d4937e"
|
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.8.tgz#a68d30b162bc1d8fd71a367e81b997e1f4d4937e"
|
||||||
@@ -14004,6 +14040,13 @@ vite@4.0.3:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.2"
|
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:
|
vitest@0.26.2:
|
||||||
version "0.26.2"
|
version "0.26.2"
|
||||||
resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.26.2.tgz#c1647e41d5619d1d059ff65d9c00672261ccac30"
|
resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.26.2.tgz#c1647e41d5619d1d059ff65d9c00672261ccac30"
|
||||||
|
|||||||