✨(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"
|
||||
},
|
||||
"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-interactions',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/preset-scss'
|
||||
],
|
||||
'framework': '@storybook/react',
|
||||
'core': {
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"@fontsource/material-icons": "4.5.4",
|
||||
"@fontsource/roboto": "4.5.8",
|
||||
"@openfun/cunningham-tokens": "*",
|
||||
"@tanstack/react-table": "8.7.9",
|
||||
"classnames": "2.3.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
@@ -49,6 +51,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.20.7",
|
||||
"@faker-js/faker": "7.6.0",
|
||||
"@openfun/cunningham-tokens": "*",
|
||||
"@openfun/typescript-configs": "*",
|
||||
"@storybook/addon-a11y": "6.5.16",
|
||||
@@ -59,7 +62,7 @@
|
||||
"@storybook/builder-vite": "0.2.6",
|
||||
"@storybook/preset-scss": "1.0.3",
|
||||
"@storybook/react": "6.5.15",
|
||||
"@storybook/storybook-deployer": "^2.8.16",
|
||||
"@storybook/storybook-deployer": "2.8.16",
|
||||
"@storybook/testing-library": "0.0.13",
|
||||
"@testing-library/dom": "8.19.1",
|
||||
"@testing-library/react": "13.4.0",
|
||||
@@ -80,6 +83,7 @@
|
||||
"vite": "4.0.3",
|
||||
"vite-plugin-dts": "1.7.1",
|
||||
"vite-tsconfig-paths": "4.0.3",
|
||||
"vitest": "0.26.2"
|
||||
"vitest": "0.26.2",
|
||||
"vitest-fetch-mock": "0.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
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 '@openfun/cunningham-tokens/default-tokens';
|
||||
@import './components/Accessibility';
|
||||
@import './components/Button';
|
||||
@import './components/DataGrid';
|
||||
@import './components/Forms/Input';
|
||||
@import './components/Loader';
|
||||
@import './components/Pagination';
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "./index.scss";
|
||||
|
||||
export * from "./components/Button";
|
||||
export * from "./components/DataGrid";
|
||||
export * from "./components/DataGrid/SimpleDataGrid";
|
||||
export * from "./components/Loader";
|
||||
export * from "./components/Pagination";
|
||||
export * from "./components/Provider";
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"datagrid": {
|
||||
"empty": "This table is empty",
|
||||
"empty_alt": "Illustration of an empty table",
|
||||
"loader_aria": "Loading data"
|
||||
"loader_aria": "Loading data",
|
||||
"rows_selection_aria":"All rows selection",
|
||||
"row_selection_aria": "Row selection"
|
||||
},
|
||||
"provider": {
|
||||
"test": "This is a test: {name}"
|
||||
|
||||
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 function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"baseUrl": "./src",
|
||||
"types": ["vitest/globals"]
|
||||
"types": ["vitest/globals", "vite/client"]
|
||||
},
|
||||
"include": ["src", "cunningham.ts"],
|
||||
"exclude": ["node_modules","dist", "**/tokens.ts"],
|
||||
|
||||
@@ -36,5 +36,6 @@ export default defineConfig({
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: ["**/*.stories.tsx", "**/*.spec.tsx"],
|
||||
},
|
||||
setupFiles: ["src/tests/Setup.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
47
yarn.lock
@@ -1583,7 +1583,12 @@
|
||||
minimatch "^3.1.2"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
"@fontsource/material-icons@^4.5.4":
|
||||
"@faker-js/faker@7.6.0":
|
||||
version "7.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-7.6.0.tgz#9ea331766084288634a9247fcd8b84f16ff4ba07"
|
||||
integrity sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==
|
||||
|
||||
"@fontsource/material-icons@4.5.4":
|
||||
version "4.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@fontsource/material-icons/-/material-icons-4.5.4.tgz#bfedde9352c36dbbacb6b49d190a1ce1e5917756"
|
||||
integrity sha512-YGmXkkEdu6EIgpFKNmB/nIXzZocwSmbI01Ninpmml8x8BT0M6RR++V1KqOfpzZ6Cw/FQ2/KYonQ3x4IY/4VRRA==
|
||||
@@ -3306,7 +3311,7 @@
|
||||
ts-dedent "^2.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
"@storybook/storybook-deployer@^2.8.16":
|
||||
"@storybook/storybook-deployer@2.8.16":
|
||||
version "2.8.16"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/storybook-deployer/-/storybook-deployer-2.8.16.tgz#890abe4fd81b6fbc028dffb6314016f208aba6c2"
|
||||
integrity sha512-DRQrjyLKaRLXMYo7SNUznyGabtOLJ0b9yfBKNVMu6PsUHJifGPabXuNXmRPZ6qvyhHUSKLQgeLaX8L3Og6uFUg==
|
||||
@@ -3386,6 +3391,18 @@
|
||||
regenerator-runtime "^0.13.7"
|
||||
resolve-from "^5.0.0"
|
||||
|
||||
"@tanstack/react-table@8.7.9":
|
||||
version "8.7.9"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.7.9.tgz#9efcd168fb0080a7e0bc213b5eac8b55513babf4"
|
||||
integrity sha512-6MbbQn5AupSOkek1+6IYu+1yZNthAKTRZw9tW92Vi6++iRrD1GbI3lKTjJalf8lEEKOqapPzQPE20nywu0PjCA==
|
||||
dependencies:
|
||||
"@tanstack/table-core" "8.7.9"
|
||||
|
||||
"@tanstack/table-core@8.7.9":
|
||||
version "8.7.9"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.7.9.tgz#0e975f8a5079972f1827a569079943d43257c42f"
|
||||
integrity sha512-4RkayPMV1oS2SKDXfQbFoct1w5k+pvGpmX18tCXMofK/VDRdA2hhxfsQlMvsJ4oTX8b0CI4Y3GDKn5T425jBCw==
|
||||
|
||||
"@testing-library/dom@8.19.1":
|
||||
version "8.19.1"
|
||||
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.19.1.tgz#0e2dafd281dedb930bb235eac1045470b4129d0e"
|
||||
@@ -5575,6 +5592,11 @@ class-utils@^0.3.5:
|
||||
isobject "^3.0.0"
|
||||
static-extend "^0.1.1"
|
||||
|
||||
classnames@2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
|
||||
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
|
||||
|
||||
clean-css@^4.2.3:
|
||||
version "4.2.4"
|
||||
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178"
|
||||
@@ -5975,6 +5997,13 @@ create-require@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
|
||||
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
|
||||
|
||||
cross-fetch@^3.0.6:
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"
|
||||
integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==
|
||||
dependencies:
|
||||
node-fetch "2.6.7"
|
||||
|
||||
cross-spawn@^5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
|
||||
@@ -10527,6 +10556,13 @@ node-dir@^0.1.10:
|
||||
dependencies:
|
||||
minimatch "^3.0.2"
|
||||
|
||||
node-fetch@2.6.7:
|
||||
version "2.6.7"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
|
||||
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
|
||||
dependencies:
|
||||
whatwg-url "^5.0.0"
|
||||
|
||||
node-fetch@^2.6.1, node-fetch@^2.6.7:
|
||||
version "2.6.8"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.8.tgz#a68d30b162bc1d8fd71a367e81b997e1f4d4937e"
|
||||
@@ -14004,6 +14040,13 @@ vite@4.0.3:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
vitest-fetch-mock@0.2.2:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/vitest-fetch-mock/-/vitest-fetch-mock-0.2.2.tgz#f6849dcf7a8e862a509e1cee2fa3bb0cb534f468"
|
||||
integrity sha512-XmH6QgTSjCWrqXoPREIdbj40T7i1xnGmAsTAgfckoO75W1IEHKR8hcPCQ7SO16RsdW1t85oUm6pcQRLeBgjVYQ==
|
||||
dependencies:
|
||||
cross-fetch "^3.0.6"
|
||||
|
||||
vitest@0.26.2:
|
||||
version "0.26.2"
|
||||
resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.26.2.tgz#c1647e41d5619d1d059ff65d9c00672261ccac30"
|
||||
|
||||