From 8e774e10fd5c7a8b6647054af01bc86fc92d63fe Mon Sep 17 00:00:00 2001 From: MUI bot <2109932+Janpot@users.noreply.github.com> Date: Thu, 18 Jul 2024 09:42:16 +0200 Subject: [PATCH] initial commit --- .../components/data-grid/BasicDataProvider.js | 29 + .../data-grid/BasicDataProvider.tsx | 29 + .../data-grid/BasicDataProvider.tsx.preview | 8 + .../core/components/data-grid/CrudCreate.js | 43 + .../core/components/data-grid/CrudCreate.tsx | 43 + .../data-grid/CrudCreate.tsx.preview | 6 + .../core/components/data-grid/CrudDelete.js | 40 + .../core/components/data-grid/CrudDelete.tsx | 40 + .../data-grid/CrudDelete.tsx.preview | 3 + .../core/components/data-grid/CrudUpdate.js | 47 + .../core/components/data-grid/CrudUpdate.tsx | 47 + .../data-grid/CrudUpdate.tsx.preview | 10 + .../components/data-grid/FieldInference.js | 26 + .../components/data-grid/FieldInference.tsx | 26 + .../data-grid/FieldInference.tsx.preview | 11 + .../components/data-grid/OverrideColumns.js | 40 + .../components/data-grid/OverrideColumns.tsx | 40 + .../data-grid/OverrideColumns.tsx.preview | 12 + .../data-grid/ServerSidePagination.js | 35 + .../data-grid/ServerSidePagination.tsx | 35 + .../ServerSidePagination.tsx.preview | 11 + .../core/components/data-grid/data-grid.md | 120 +++ docs/data/toolpad/core/components/index.md | 2 + .../components/line-chart/BasicLineChart.js | 30 + .../components/line-chart/BasicLineChart.tsx | 30 + .../line-chart/BasicLineChart.tsx.preview | 1 + .../line-chart/CustomizedLineChart.js | 42 + .../line-chart/CustomizedLineChart.tsx | 42 + .../CustomizedLineChart.tsx.preview | 13 + .../components/line-chart/ErrorLineChart.js | 22 + .../components/line-chart/ErrorLineChart.tsx | 22 + .../line-chart/ErrorLineChart.tsx.preview | 1 + .../core/components/line-chart/line-chart.md | 27 + .../toolpad/core/features/data-providers.md | 57 ++ .../toolpad/core/introduction/Tutorial1.js | 31 + .../toolpad/core/introduction/Tutorial1.tsx | 31 + .../core/introduction/Tutorial1.tsx.preview | 1 + .../toolpad/core/introduction/Tutorial2.js | 38 + .../toolpad/core/introduction/Tutorial2.tsx | 38 + .../core/introduction/Tutorial2.tsx.preview | 7 + .../toolpad/core/introduction/Tutorial3.js | 57 ++ .../toolpad/core/introduction/Tutorial3.tsx | 57 ++ .../toolpad/core/introduction/overview.md | 4 +- .../toolpad/core/introduction/tutorial.md | 128 +++ docs/data/toolpad/core/pages.ts | 24 + docs/data/toolpad/core/pagesApi.js | 2 + docs/pages/toolpad/core/api/data-grid.js | 23 + docs/pages/toolpad/core/api/data-grid.json | 864 ++++++++++++++++++ docs/pages/toolpad/core/api/line-chart.js | 23 + docs/pages/toolpad/core/api/line-chart.json | 27 + .../toolpad/core/features/data-providers.js | 7 + .../toolpad/core/react-data-grid/index.js | 7 + .../toolpad/core/react-line-chart/index.js | 7 + .../api-docs/data-grid/data-grid.json | 584 ++++++++++++ .../api-docs/line-chart/line-chart.json | 9 + packages/toolpad-core/package.json | 3 + .../src/DataGrid/DataGrid.test.tsx | 262 ++++++ .../toolpad-core/src/DataGrid/DataGrid.tsx | 864 ++++++++++++++++++ .../src/DataGrid/InferencingAlert.tsx | 117 +++ .../src/DataGrid/LoadingOverlay.tsx | 103 +++ .../src/DataGrid/NotificationSnackbar.tsx | 97 ++ packages/toolpad-core/src/DataGrid/index.ts | 1 + .../src/DataProvider/DataProvider.test.tsx | 24 + .../src/DataProvider/DataProvider.tsx | 326 +++++++ .../toolpad-core/src/DataProvider/filter.tsx | 161 ++++ .../toolpad-core/src/DataProvider/index.ts | 1 + .../src/LineChart/LineChart.test.tsx | 45 + .../toolpad-core/src/LineChart/LineChart.tsx | 170 ++++ packages/toolpad-core/src/LineChart/index.ts | 1 + packages/toolpad-core/src/index.ts | 6 + packages/toolpad-core/src/shared/index.tsx | 44 + pnpm-lock.yaml | 15 +- 72 files changed, 5194 insertions(+), 5 deletions(-) create mode 100644 docs/data/toolpad/core/components/data-grid/BasicDataProvider.js create mode 100644 docs/data/toolpad/core/components/data-grid/BasicDataProvider.tsx create mode 100644 docs/data/toolpad/core/components/data-grid/BasicDataProvider.tsx.preview create mode 100644 docs/data/toolpad/core/components/data-grid/CrudCreate.js create mode 100644 docs/data/toolpad/core/components/data-grid/CrudCreate.tsx create mode 100644 docs/data/toolpad/core/components/data-grid/CrudCreate.tsx.preview create mode 100644 docs/data/toolpad/core/components/data-grid/CrudDelete.js create mode 100644 docs/data/toolpad/core/components/data-grid/CrudDelete.tsx create mode 100644 docs/data/toolpad/core/components/data-grid/CrudDelete.tsx.preview create mode 100644 docs/data/toolpad/core/components/data-grid/CrudUpdate.js create mode 100644 docs/data/toolpad/core/components/data-grid/CrudUpdate.tsx create mode 100644 docs/data/toolpad/core/components/data-grid/CrudUpdate.tsx.preview create mode 100644 docs/data/toolpad/core/components/data-grid/FieldInference.js create mode 100644 docs/data/toolpad/core/components/data-grid/FieldInference.tsx create mode 100644 docs/data/toolpad/core/components/data-grid/FieldInference.tsx.preview create mode 100644 docs/data/toolpad/core/components/data-grid/OverrideColumns.js create mode 100644 docs/data/toolpad/core/components/data-grid/OverrideColumns.tsx create mode 100644 docs/data/toolpad/core/components/data-grid/OverrideColumns.tsx.preview create mode 100644 docs/data/toolpad/core/components/data-grid/ServerSidePagination.js create mode 100644 docs/data/toolpad/core/components/data-grid/ServerSidePagination.tsx create mode 100644 docs/data/toolpad/core/components/data-grid/ServerSidePagination.tsx.preview create mode 100644 docs/data/toolpad/core/components/data-grid/data-grid.md create mode 100644 docs/data/toolpad/core/components/line-chart/BasicLineChart.js create mode 100644 docs/data/toolpad/core/components/line-chart/BasicLineChart.tsx create mode 100644 docs/data/toolpad/core/components/line-chart/BasicLineChart.tsx.preview create mode 100644 docs/data/toolpad/core/components/line-chart/CustomizedLineChart.js create mode 100644 docs/data/toolpad/core/components/line-chart/CustomizedLineChart.tsx create mode 100644 docs/data/toolpad/core/components/line-chart/CustomizedLineChart.tsx.preview create mode 100644 docs/data/toolpad/core/components/line-chart/ErrorLineChart.js create mode 100644 docs/data/toolpad/core/components/line-chart/ErrorLineChart.tsx create mode 100644 docs/data/toolpad/core/components/line-chart/ErrorLineChart.tsx.preview create mode 100644 docs/data/toolpad/core/components/line-chart/line-chart.md create mode 100644 docs/data/toolpad/core/features/data-providers.md create mode 100644 docs/data/toolpad/core/introduction/Tutorial1.js create mode 100644 docs/data/toolpad/core/introduction/Tutorial1.tsx create mode 100644 docs/data/toolpad/core/introduction/Tutorial1.tsx.preview create mode 100644 docs/data/toolpad/core/introduction/Tutorial2.js create mode 100644 docs/data/toolpad/core/introduction/Tutorial2.tsx create mode 100644 docs/data/toolpad/core/introduction/Tutorial2.tsx.preview create mode 100644 docs/data/toolpad/core/introduction/Tutorial3.js create mode 100644 docs/data/toolpad/core/introduction/Tutorial3.tsx create mode 100644 docs/pages/toolpad/core/api/data-grid.js create mode 100644 docs/pages/toolpad/core/api/data-grid.json create mode 100644 docs/pages/toolpad/core/api/line-chart.js create mode 100644 docs/pages/toolpad/core/api/line-chart.json create mode 100644 docs/pages/toolpad/core/features/data-providers.js create mode 100644 docs/pages/toolpad/core/react-data-grid/index.js create mode 100644 docs/pages/toolpad/core/react-line-chart/index.js create mode 100644 docs/translations/api-docs/data-grid/data-grid.json create mode 100644 docs/translations/api-docs/line-chart/line-chart.json create mode 100644 packages/toolpad-core/src/DataGrid/DataGrid.test.tsx create mode 100644 packages/toolpad-core/src/DataGrid/DataGrid.tsx create mode 100644 packages/toolpad-core/src/DataGrid/InferencingAlert.tsx create mode 100644 packages/toolpad-core/src/DataGrid/LoadingOverlay.tsx create mode 100644 packages/toolpad-core/src/DataGrid/NotificationSnackbar.tsx create mode 100644 packages/toolpad-core/src/DataGrid/index.ts create mode 100644 packages/toolpad-core/src/DataProvider/DataProvider.test.tsx create mode 100644 packages/toolpad-core/src/DataProvider/DataProvider.tsx create mode 100644 packages/toolpad-core/src/DataProvider/filter.tsx create mode 100644 packages/toolpad-core/src/DataProvider/index.ts create mode 100644 packages/toolpad-core/src/LineChart/LineChart.test.tsx create mode 100644 packages/toolpad-core/src/LineChart/LineChart.tsx create mode 100644 packages/toolpad-core/src/LineChart/index.ts create mode 100644 packages/toolpad-core/src/shared/index.tsx diff --git a/docs/data/toolpad/core/components/data-grid/BasicDataProvider.js b/docs/data/toolpad/core/components/data-grid/BasicDataProvider.js new file mode 100644 index 00000000000..ca73e33eaf7 --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/BasicDataProvider.js @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +const myData = createDataProvider({ + // preview-start + async getMany() { + return { + rows: [ + { id: '1', name: 'John' }, + { id: '2', name: 'Jane' }, + ], + }; + }, + // preview-end + fields: { + id: { label: 'ID' }, + name: { label: 'Name' }, + }, +}); + +export default function BasicDataProvider() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/BasicDataProvider.tsx b/docs/data/toolpad/core/components/data-grid/BasicDataProvider.tsx new file mode 100644 index 00000000000..ca73e33eaf7 --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/BasicDataProvider.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +const myData = createDataProvider({ + // preview-start + async getMany() { + return { + rows: [ + { id: '1', name: 'John' }, + { id: '2', name: 'Jane' }, + ], + }; + }, + // preview-end + fields: { + id: { label: 'ID' }, + name: { label: 'Name' }, + }, +}); + +export default function BasicDataProvider() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/BasicDataProvider.tsx.preview b/docs/data/toolpad/core/components/data-grid/BasicDataProvider.tsx.preview new file mode 100644 index 00000000000..31379280dab --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/BasicDataProvider.tsx.preview @@ -0,0 +1,8 @@ +async getMany() { + return { + rows: [ + { id: '1', name: 'John' }, + { id: '2', name: 'Jane' }, + ], + }; +}, \ No newline at end of file diff --git a/docs/data/toolpad/core/components/data-grid/CrudCreate.js b/docs/data/toolpad/core/components/data-grid/CrudCreate.js new file mode 100644 index 00000000000..2bd5f606110 --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/CrudCreate.js @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +let nextId = 1; +const getNextId = () => { + const id = `id-${nextId}`; + nextId += 1; + return id; +}; +let DATA = [ + { id: getNextId(), name: 'John' }, + { id: getNextId(), name: 'Jane' }, +]; + +const myData = createDataProvider({ + async getMany() { + return { + rows: DATA, + }; + }, + // preview-start + async createOne(values) { + const id = getNextId(); + const newItem = { ...values, id }; + DATA = [...DATA, newItem]; + return newItem; + }, + // preview-end + fields: { + id: { label: 'ID' }, + name: { label: 'Name' }, + }, +}); + +export default function CrudCreate() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/CrudCreate.tsx b/docs/data/toolpad/core/components/data-grid/CrudCreate.tsx new file mode 100644 index 00000000000..2bd5f606110 --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/CrudCreate.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +let nextId = 1; +const getNextId = () => { + const id = `id-${nextId}`; + nextId += 1; + return id; +}; +let DATA = [ + { id: getNextId(), name: 'John' }, + { id: getNextId(), name: 'Jane' }, +]; + +const myData = createDataProvider({ + async getMany() { + return { + rows: DATA, + }; + }, + // preview-start + async createOne(values) { + const id = getNextId(); + const newItem = { ...values, id }; + DATA = [...DATA, newItem]; + return newItem; + }, + // preview-end + fields: { + id: { label: 'ID' }, + name: { label: 'Name' }, + }, +}); + +export default function CrudCreate() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/CrudCreate.tsx.preview b/docs/data/toolpad/core/components/data-grid/CrudCreate.tsx.preview new file mode 100644 index 00000000000..d33b2cfbf39 --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/CrudCreate.tsx.preview @@ -0,0 +1,6 @@ +async createOne(values) { + const id = getNextId(); + const newItem = { ...values, id }; + DATA = [...DATA, newItem]; + return newItem; +}, \ No newline at end of file diff --git a/docs/data/toolpad/core/components/data-grid/CrudDelete.js b/docs/data/toolpad/core/components/data-grid/CrudDelete.js new file mode 100644 index 00000000000..540876e9d83 --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/CrudDelete.js @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +let nextId = 1; +const getNextId = () => { + const id = `id-${nextId}`; + nextId += 1; + return id; +}; +let DATA = [ + { id: getNextId(), name: 'John' }, + { id: getNextId(), name: 'Jane' }, +]; + +const myData = createDataProvider({ + async getMany() { + return { + rows: DATA, + }; + }, + // preview-start + async deleteOne(id) { + DATA = DATA.filter((item) => item.id !== id); + }, + // preview-end + fields: { + id: { label: 'ID' }, + name: { label: 'Name' }, + }, +}); + +export default function CrudDelete() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/CrudDelete.tsx b/docs/data/toolpad/core/components/data-grid/CrudDelete.tsx new file mode 100644 index 00000000000..540876e9d83 --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/CrudDelete.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +let nextId = 1; +const getNextId = () => { + const id = `id-${nextId}`; + nextId += 1; + return id; +}; +let DATA = [ + { id: getNextId(), name: 'John' }, + { id: getNextId(), name: 'Jane' }, +]; + +const myData = createDataProvider({ + async getMany() { + return { + rows: DATA, + }; + }, + // preview-start + async deleteOne(id) { + DATA = DATA.filter((item) => item.id !== id); + }, + // preview-end + fields: { + id: { label: 'ID' }, + name: { label: 'Name' }, + }, +}); + +export default function CrudDelete() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/CrudDelete.tsx.preview b/docs/data/toolpad/core/components/data-grid/CrudDelete.tsx.preview new file mode 100644 index 00000000000..79976f85bda --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/CrudDelete.tsx.preview @@ -0,0 +1,3 @@ +async deleteOne(id) { + DATA = DATA.filter((item) => item.id !== id); +}, \ No newline at end of file diff --git a/docs/data/toolpad/core/components/data-grid/CrudUpdate.js b/docs/data/toolpad/core/components/data-grid/CrudUpdate.js new file mode 100644 index 00000000000..60f4936e126 --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/CrudUpdate.js @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +let nextId = 1; +const getNextId = () => { + const id = `id-${nextId}`; + nextId += 1; + return id; +}; +let DATA = [ + { id: getNextId(), name: 'John' }, + { id: getNextId(), name: 'Jane' }, +]; + +const myData = createDataProvider({ + async getMany() { + return { + rows: DATA, + }; + }, + // preview-start + async updateOne(id, values) { + console.log(id, values); + const existing = DATA.find((item) => item.id === id); + if (!existing) { + throw new Error(`Item with id ${id} not found`); + } + const updated = { ...existing, ...values }; + DATA = DATA.map((item) => (item.id === updated.id ? updated : item)); + return updated; + }, + // preview-end + fields: { + id: { label: 'ID' }, + name: { label: 'Name' }, + }, +}); + +export default function CrudUpdate() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/CrudUpdate.tsx b/docs/data/toolpad/core/components/data-grid/CrudUpdate.tsx new file mode 100644 index 00000000000..60f4936e126 --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/CrudUpdate.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +let nextId = 1; +const getNextId = () => { + const id = `id-${nextId}`; + nextId += 1; + return id; +}; +let DATA = [ + { id: getNextId(), name: 'John' }, + { id: getNextId(), name: 'Jane' }, +]; + +const myData = createDataProvider({ + async getMany() { + return { + rows: DATA, + }; + }, + // preview-start + async updateOne(id, values) { + console.log(id, values); + const existing = DATA.find((item) => item.id === id); + if (!existing) { + throw new Error(`Item with id ${id} not found`); + } + const updated = { ...existing, ...values }; + DATA = DATA.map((item) => (item.id === updated.id ? updated : item)); + return updated; + }, + // preview-end + fields: { + id: { label: 'ID' }, + name: { label: 'Name' }, + }, +}); + +export default function CrudUpdate() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/CrudUpdate.tsx.preview b/docs/data/toolpad/core/components/data-grid/CrudUpdate.tsx.preview new file mode 100644 index 00000000000..c1c73cdd9f2 --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/CrudUpdate.tsx.preview @@ -0,0 +1,10 @@ +async updateOne(id, values) { + console.log(id, values); + const existing = DATA.find((item) => item.id === id); + if (!existing) { + throw new Error(`Item with id ${id} not found`); + } + const updated = { ...existing, ...values }; + DATA = DATA.map((item) => (item.id === updated.id ? updated : item)); + return updated; +}, \ No newline at end of file diff --git a/docs/data/toolpad/core/components/data-grid/FieldInference.js b/docs/data/toolpad/core/components/data-grid/FieldInference.js new file mode 100644 index 00000000000..479eaab87bc --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/FieldInference.js @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +// preview-start +const myData = createDataProvider({ + async getMany() { + return { + rows: [ + { id: '1', name: 'John' }, + { id: '2', name: 'Jane' }, + ], + }; + }, + // paste fields here: +}); +// preview-end + +export default function FieldInference() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/FieldInference.tsx b/docs/data/toolpad/core/components/data-grid/FieldInference.tsx new file mode 100644 index 00000000000..479eaab87bc --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/FieldInference.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +// preview-start +const myData = createDataProvider({ + async getMany() { + return { + rows: [ + { id: '1', name: 'John' }, + { id: '2', name: 'Jane' }, + ], + }; + }, + // paste fields here: +}); +// preview-end + +export default function FieldInference() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/FieldInference.tsx.preview b/docs/data/toolpad/core/components/data-grid/FieldInference.tsx.preview new file mode 100644 index 00000000000..c72d251850b --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/FieldInference.tsx.preview @@ -0,0 +1,11 @@ +const myData = createDataProvider({ + async getMany() { + return { + rows: [ + { id: '1', name: 'John' }, + { id: '2', name: 'Jane' }, + ], + }; + }, + // paste fields here: +}); \ No newline at end of file diff --git a/docs/data/toolpad/core/components/data-grid/OverrideColumns.js b/docs/data/toolpad/core/components/data-grid/OverrideColumns.js new file mode 100644 index 00000000000..f6f8290543b --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/OverrideColumns.js @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +const myData = createDataProvider({ + async getMany() { + return { + rows: [ + { id: '1', name: 'John' }, + { id: '2', name: 'Jane' }, + ], + }; + }, + fields: { + id: { label: 'ID' }, + name: { label: 'Name' }, + }, +}); + +export default function OverrideColumns() { + return ( + + {/* preview-start */} + `Hi, ${row.name}` }, + ]} + /> + {/* preview-end */} + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/OverrideColumns.tsx b/docs/data/toolpad/core/components/data-grid/OverrideColumns.tsx new file mode 100644 index 00000000000..f6f8290543b --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/OverrideColumns.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +const myData = createDataProvider({ + async getMany() { + return { + rows: [ + { id: '1', name: 'John' }, + { id: '2', name: 'Jane' }, + ], + }; + }, + fields: { + id: { label: 'ID' }, + name: { label: 'Name' }, + }, +}); + +export default function OverrideColumns() { + return ( + + {/* preview-start */} + `Hi, ${row.name}` }, + ]} + /> + {/* preview-end */} + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/OverrideColumns.tsx.preview b/docs/data/toolpad/core/components/data-grid/OverrideColumns.tsx.preview new file mode 100644 index 00000000000..533feafb5f3 --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/OverrideColumns.tsx.preview @@ -0,0 +1,12 @@ + `Hi, ${row.name}` }, + ]} +/> \ No newline at end of file diff --git a/docs/data/toolpad/core/components/data-grid/ServerSidePagination.js b/docs/data/toolpad/core/components/data-grid/ServerSidePagination.js new file mode 100644 index 00000000000..6c5a145123d --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/ServerSidePagination.js @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +import invariant from 'invariant'; + +const myData = createDataProvider({ + // preview-start + async getMany({ pagination }) { + invariant(pagination, 'This data provider requires server-side pagination.'); + return { + rows: Array.from(new Array(pagination.pageSize), (_, i) => ({ + id: `${pagination.start + i}`, + item: `Item ${pagination.start + i}`, + page: Math.floor(pagination.start / pagination.pageSize) + 1, + })), + rowCount: 1000000, + }; + }, + // preview-end + fields: { + id: { label: 'ID' }, + item: { label: 'Item' }, + page: { label: 'Page' }, + }, +}); + +export default function ServerSidePagination() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/ServerSidePagination.tsx b/docs/data/toolpad/core/components/data-grid/ServerSidePagination.tsx new file mode 100644 index 00000000000..6c5a145123d --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/ServerSidePagination.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Box from '@mui/material/Box'; + +import invariant from 'invariant'; + +const myData = createDataProvider({ + // preview-start + async getMany({ pagination }) { + invariant(pagination, 'This data provider requires server-side pagination.'); + return { + rows: Array.from(new Array(pagination.pageSize), (_, i) => ({ + id: `${pagination.start + i}`, + item: `Item ${pagination.start + i}`, + page: Math.floor(pagination.start / pagination.pageSize) + 1, + })), + rowCount: 1000000, + }; + }, + // preview-end + fields: { + id: { label: 'ID' }, + item: { label: 'Item' }, + page: { label: 'Page' }, + }, +}); + +export default function ServerSidePagination() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/data-grid/ServerSidePagination.tsx.preview b/docs/data/toolpad/core/components/data-grid/ServerSidePagination.tsx.preview new file mode 100644 index 00000000000..3a074447b7b --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/ServerSidePagination.tsx.preview @@ -0,0 +1,11 @@ +async getMany({ pagination }) { + invariant(pagination, 'This data provider requires server-side pagination.'); + return { + rows: Array.from(new Array(pagination.pageSize), (_, i) => ({ + id: `${pagination.start + i}`, + item: `Item ${pagination.start + i}`, + page: Math.floor(pagination.start / pagination.pageSize) + 1, + })), + rowCount: 1000000, + }; +}, \ No newline at end of file diff --git a/docs/data/toolpad/core/components/data-grid/data-grid.md b/docs/data/toolpad/core/components/data-grid/data-grid.md new file mode 100644 index 00000000000..68c29dc9e4d --- /dev/null +++ b/docs/data/toolpad/core/components/data-grid/data-grid.md @@ -0,0 +1,120 @@ +--- +productId: toolpad-core +title: DataGrid +components: DataGrid, DataProvider +--- + +# Data Grid + +

Data Grid component for CRUD ("Create Read Update Delete") applications.

+ +Toolpad Core extends the [X data grid](https://mui.com/x/react-data-grid/) with CRUD functionality. It abstracts the manipulations in a data provider object. The data provider object describes the shape of the data and the available manipulations. When you pass the data provider to a grid it is automatically configured as a CRUD grid. All properties of the X grid are also available and can be used to override the data provider behavior. + +Where Core and X components focus on the user interface, Toolpad Core components start from a definition of the data. It centralizes data loading, filtering, pagination, field formatting, mutations, access control and more. + +## Basic + +The simplest data provider exposes a `getMany` function and a `fields` definition. This is enough for a grid to render the rows. + +{{"demo": "BasicDataProvider.js"}} + +## Override columns + +The Toolpad Core grid adopts the fields that are defined in its data provider. This is handy because it allows for sharing formatting and validation options between data rendering components. However, it is still possible to override the defaults at the level of an individual grid. The grid adopts the columns you've defined in the `columns` property, and sets default values for the individual column options for each of them. + +{{"demo": "OverrideColumns.js"}} + +## Column inference + +To help you get started quickly, the grid is able to infer data provider fields if they are not defined. This allows you to quickly get started with a basic field definition based on the returned data. When a data provider is passed that doesn't have a field definiton, the grid infers field definitions and shows a warning. Click the question mark to show more information on how to solve the warning message. Try copying the snippet from the dialog and paste it in the data provider definition below: + +{{"demo": "FieldInference.js"}} + +## Server-side Pagination + +By default the grid paginates items client-side. If your backend supports server-side pagination, enable it with the `paginationMode` flag in the data grid. Now the `getMany` method receives a `pagination` parameter. This parameter is an object containing a `start` and `pageSize` property that denote the start index and offset for the requested page. You can optionally send a `rowCount` along with the `rows`. + +{{"demo": "ServerSidePagination.js"}} + +You can decide whether your data provider supports pagination exclusively or optionally by throwing an error when `pagination` is `null`. + +## Server-side Filtering + +By default, the grid filters rows client-side. If your backend supports filtering, you can enable it with the `filterMode` property in the data grid. To pass a `filter` to the data provider, set `filterMode` to `'server'`. + +```js +async getMany({ filter }) { + const url = new URL('https://api.example.com/data'); + for (const [field, ops = {}] of Object.entries(filter)) { + for (const [operator, value] of Object.entries(ops)) { + url.searchParams.append(field, `${operator}:${value}`); + } + } + const res = await fetch(url); + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${await res.text()}`); + } + return { rows: await res.json() }; +}, +``` + +## CRUD + +The data provider supports all basic CRUD operations + +### Create a row + +When you add a `createOne` method to the data provider, the grid gets a "Add record" button in the Toolbar. When you click this button, a draft row shows wich you can fill with values for the created item. To commit the new row, click the save button. This calls the `createOne` function with the values that were filled. When the operation finishes, a notification shows. + +{{"demo": "CrudCreate.js"}} + +### Update a row + +When you add a `updateOne` method to the data provider, the grid gets edit buttons in its action column. When you click this button, the row goes in editing mode. When you click the save button, the `updateOne` method is called with the row id as first parameter and the changed values as the second parameter. When the operation finishes, a notification shows. + +{{"demo": "CrudUpdate.js"}} + +### Delete a row + +When you add a `deleteOne` method to your data provider, the grid gets delete buttons in its action column. When you click this button, the `deleteOne` method is called with the id of the relevant row. When the operation finishes, a notification shows. + +{{"demo": "CrudDelete.js"}} + +### 🚧 Delete multiple rows + +When the data provider contains a `deleteMany` method, the grid allows for multiple selection and delete. + +## 🚧 Input validation + +For create and update logic, the data provider supports validation + +### 🚧 Static + +In the field definitions, with for example a `required` property. + +```js +fields: { + name: { + required: true, + validate: (value) => value.length < 10 ? null : 'Too long' + }, +} +``` + +### 🚧 Dynamic + +In the `updateOne`/`createOne` method, by throwing a specific error. + +```js +throw new ValidationError({ + name: 'Already exists', +}); +``` + +## 🚧 Premium/pro grid + +An X premium and pro version of the grid are exported from the `@toolpad/enterprise` package. An X license is required accordingly. + +## 🚧 Access control + +The data provider integrates with Toolpad access control to enable/disable CRUD features based on the current user roles. diff --git a/docs/data/toolpad/core/components/index.md b/docs/data/toolpad/core/components/index.md index 413dcb65b20..90b83666295 100644 --- a/docs/data/toolpad/core/components/index.md +++ b/docs/data/toolpad/core/components/index.md @@ -3,3 +3,5 @@

This page contains an index to the component pages that come with Toolpad Core.

- [Dashboard Layout](/toolpad/core/components/dashboard-layout/) +- [Data Grid](/toolpad/core/components/data-grid/) +- [Line Chart](/toolpad/core/components/line-chart/) diff --git a/docs/data/toolpad/core/components/line-chart/BasicLineChart.js b/docs/data/toolpad/core/components/line-chart/BasicLineChart.js new file mode 100644 index 00000000000..a2721a24da3 --- /dev/null +++ b/docs/data/toolpad/core/components/line-chart/BasicLineChart.js @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { LineChart } from '@toolpad/core/LineChart'; +import Box from '@mui/material/Box'; + +const myData = createDataProvider({ + async getMany() { + return { + rows: [ + { id: 1, value: 19 }, + { id: 2, value: 34 }, + { id: 3, value: 6 }, + { id: 4, value: 14 }, + { id: 5, value: 17 }, + ], + }; + }, + fields: { + id: { label: 'ID' }, + value: { label: 'Value', type: 'number' }, + }, +}); + +export default function BasicLineChart() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/line-chart/BasicLineChart.tsx b/docs/data/toolpad/core/components/line-chart/BasicLineChart.tsx new file mode 100644 index 00000000000..a2721a24da3 --- /dev/null +++ b/docs/data/toolpad/core/components/line-chart/BasicLineChart.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { LineChart } from '@toolpad/core/LineChart'; +import Box from '@mui/material/Box'; + +const myData = createDataProvider({ + async getMany() { + return { + rows: [ + { id: 1, value: 19 }, + { id: 2, value: 34 }, + { id: 3, value: 6 }, + { id: 4, value: 14 }, + { id: 5, value: 17 }, + ], + }; + }, + fields: { + id: { label: 'ID' }, + value: { label: 'Value', type: 'number' }, + }, +}); + +export default function BasicLineChart() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/line-chart/BasicLineChart.tsx.preview b/docs/data/toolpad/core/components/line-chart/BasicLineChart.tsx.preview new file mode 100644 index 00000000000..583757dc505 --- /dev/null +++ b/docs/data/toolpad/core/components/line-chart/BasicLineChart.tsx.preview @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/data/toolpad/core/components/line-chart/CustomizedLineChart.js b/docs/data/toolpad/core/components/line-chart/CustomizedLineChart.js new file mode 100644 index 00000000000..0e0f589a74a --- /dev/null +++ b/docs/data/toolpad/core/components/line-chart/CustomizedLineChart.js @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { LineChart } from '@toolpad/core/LineChart'; +import Box from '@mui/material/Box'; + +const myData = createDataProvider({ + async getMany() { + return { + rows: [ + { id: 1, value: 19 }, + { id: 2, value: 34 }, + { id: 3, value: 6 }, + { id: 4, value: 14 }, + { id: 5, value: 17 }, + ], + }; + }, + fields: { + id: { label: 'ID' }, + value: { label: 'Value', type: 'number' }, + }, +}); + +export default function CustomizedLineChart() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/line-chart/CustomizedLineChart.tsx b/docs/data/toolpad/core/components/line-chart/CustomizedLineChart.tsx new file mode 100644 index 00000000000..0e0f589a74a --- /dev/null +++ b/docs/data/toolpad/core/components/line-chart/CustomizedLineChart.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { LineChart } from '@toolpad/core/LineChart'; +import Box from '@mui/material/Box'; + +const myData = createDataProvider({ + async getMany() { + return { + rows: [ + { id: 1, value: 19 }, + { id: 2, value: 34 }, + { id: 3, value: 6 }, + { id: 4, value: 14 }, + { id: 5, value: 17 }, + ], + }; + }, + fields: { + id: { label: 'ID' }, + value: { label: 'Value', type: 'number' }, + }, +}); + +export default function CustomizedLineChart() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/line-chart/CustomizedLineChart.tsx.preview b/docs/data/toolpad/core/components/line-chart/CustomizedLineChart.tsx.preview new file mode 100644 index 00000000000..4a5448bb072 --- /dev/null +++ b/docs/data/toolpad/core/components/line-chart/CustomizedLineChart.tsx.preview @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/docs/data/toolpad/core/components/line-chart/ErrorLineChart.js b/docs/data/toolpad/core/components/line-chart/ErrorLineChart.js new file mode 100644 index 00000000000..bafc8021a4b --- /dev/null +++ b/docs/data/toolpad/core/components/line-chart/ErrorLineChart.js @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { LineChart } from '@toolpad/core/LineChart'; +import Box from '@mui/material/Box'; + +const myData = createDataProvider({ + async getMany() { + throw new Error('Failed to fetch data'); + }, + fields: { + id: { label: 'ID' }, + value: { label: 'Value', type: 'number' }, + }, +}); + +export default function ErrorLineChart() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/line-chart/ErrorLineChart.tsx b/docs/data/toolpad/core/components/line-chart/ErrorLineChart.tsx new file mode 100644 index 00000000000..bafc8021a4b --- /dev/null +++ b/docs/data/toolpad/core/components/line-chart/ErrorLineChart.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { LineChart } from '@toolpad/core/LineChart'; +import Box from '@mui/material/Box'; + +const myData = createDataProvider({ + async getMany() { + throw new Error('Failed to fetch data'); + }, + fields: { + id: { label: 'ID' }, + value: { label: 'Value', type: 'number' }, + }, +}); + +export default function ErrorLineChart() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/components/line-chart/ErrorLineChart.tsx.preview b/docs/data/toolpad/core/components/line-chart/ErrorLineChart.tsx.preview new file mode 100644 index 00000000000..9990bfbbf05 --- /dev/null +++ b/docs/data/toolpad/core/components/line-chart/ErrorLineChart.tsx.preview @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/data/toolpad/core/components/line-chart/line-chart.md b/docs/data/toolpad/core/components/line-chart/line-chart.md new file mode 100644 index 00000000000..f7ccf464a4f --- /dev/null +++ b/docs/data/toolpad/core/components/line-chart/line-chart.md @@ -0,0 +1,27 @@ +--- +productId: toolpad-core +title: Line Chart +components: LineChart +--- + +# Line Chart + +

Line Chart component for Toolpad Core applications.

+ +Toolpad Core extends X Charts with data provider support. Toolpad Core charts automatically load data and adopt defaults for labels and formatting. + +## Basic example + +Add a data provider to a chart and its data is automatically loaded in the chart. + +{{"demo": "BasicLineChart.js"}} + +Error and loading states are automatically handled. Errors from the data provider are shown in an overlay: + +{{"demo": "ErrorLineChart.js", "hideToolbar": true}} + +## Customization + +The chart automatically adopts configuration from the data provider. When you pick a `dataKey` for an axis or a series, the chart automatically infers default values for series and axis options. The Toolpad Core components accept all properties that the X components offer. SO to customize the chart further, you can override these defaults and add extra options as you need. + +{{"demo": "CustomizedLineChart.js"}} diff --git a/docs/data/toolpad/core/features/data-providers.md b/docs/data/toolpad/core/features/data-providers.md new file mode 100644 index 00000000000..2cd2d962991 --- /dev/null +++ b/docs/data/toolpad/core/features/data-providers.md @@ -0,0 +1,57 @@ +# Data Providers + +

Connect with multiple data providers fast - no boilerplate or lengthy integration efforts

+ +## Interface + +A data provider is a stateless interface representing a collection of remote data. It mainly contains methods to fetch and manipulate this data, along with certain additional methods for certain data providers. + +```tsx +import { createDataProvider } from '@toolpad/data'; + +const dataProvider = createDataProvider({ + async getRecords({}) { + return fetch('/...'); + }, +}); +``` + +## Connecting to components + +To connect to components, the stateless data provider can be made stateful using a headless API. + +```tsx +const gridProps = useDataGrid(dataProvider, { + // options +}); + +const chartProps = useChart(dataProvider, { + // options +}); + +const sharedDataSource = useSharedDataProvider(dataProvider, { + // options +}); +``` + +## Server-side data providers + +```tsx +// ./pages/api/myDataProvider.ts +import { serverDataProvider } from '@toopad/core'; +import db from '../postgres'; + +export default servedataProvider({ + async getRecords({}) { + return db.getRows(); + }, +}); +``` + +Then connect to it from the client with + +```tsx +import { createRestProvider } from '@toopad/core'; + +const dataProvider = createRestProvider('/api/myDataProvider'); +``` diff --git a/docs/data/toolpad/core/introduction/Tutorial1.js b/docs/data/toolpad/core/introduction/Tutorial1.js new file mode 100644 index 00000000000..a84726e5795 --- /dev/null +++ b/docs/data/toolpad/core/introduction/Tutorial1.js @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Stack from '@mui/material/Stack'; + +const npmData = createDataProvider({ + async getMany({ filter }) { + const res = await fetch( + `https://api.npmjs.org/downloads/range/${encodeURIComponent(filter.range?.equals ?? 'last-month')}/react`, + ); + if (!res.ok) { + const { error } = await res.json(); + throw new Error(`HTTP ${res.status}: ${error}`); + } + const { downloads } = await res.json(); + return { rows: downloads }; + }, + idField: 'day', + fields: { + day: { type: 'date' }, + downloads: { type: 'number' }, + }, +}); + +export default function Tutorial1() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/introduction/Tutorial1.tsx b/docs/data/toolpad/core/introduction/Tutorial1.tsx new file mode 100644 index 00000000000..a84726e5795 --- /dev/null +++ b/docs/data/toolpad/core/introduction/Tutorial1.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import Stack from '@mui/material/Stack'; + +const npmData = createDataProvider({ + async getMany({ filter }) { + const res = await fetch( + `https://api.npmjs.org/downloads/range/${encodeURIComponent(filter.range?.equals ?? 'last-month')}/react`, + ); + if (!res.ok) { + const { error } = await res.json(); + throw new Error(`HTTP ${res.status}: ${error}`); + } + const { downloads } = await res.json(); + return { rows: downloads }; + }, + idField: 'day', + fields: { + day: { type: 'date' }, + downloads: { type: 'number' }, + }, +}); + +export default function Tutorial1() { + return ( + + + + ); +} diff --git a/docs/data/toolpad/core/introduction/Tutorial1.tsx.preview b/docs/data/toolpad/core/introduction/Tutorial1.tsx.preview new file mode 100644 index 00000000000..6d9f8e338b6 --- /dev/null +++ b/docs/data/toolpad/core/introduction/Tutorial1.tsx.preview @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/data/toolpad/core/introduction/Tutorial2.js b/docs/data/toolpad/core/introduction/Tutorial2.js new file mode 100644 index 00000000000..63ad2737a29 --- /dev/null +++ b/docs/data/toolpad/core/introduction/Tutorial2.js @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import { LineChart } from '@toolpad/core/LineChart'; +import Stack from '@mui/material/Stack'; + +const npmData = createDataProvider({ + async getMany({ filter }) { + const res = await fetch( + `https://api.npmjs.org/downloads/range/${encodeURIComponent(filter.range?.equals ?? 'last-month')}/react`, + ); + if (!res.ok) { + const { error } = await res.json(); + throw new Error(`HTTP ${res.status}: ${error}`); + } + const { downloads } = await res.json(); + return { rows: downloads }; + }, + idField: 'day', + fields: { + day: { type: 'date' }, + downloads: { type: 'number', label: 'Npm Downloads' }, + }, +}); + +export default function Tutorial2() { + return ( + + + + + ); +} diff --git a/docs/data/toolpad/core/introduction/Tutorial2.tsx b/docs/data/toolpad/core/introduction/Tutorial2.tsx new file mode 100644 index 00000000000..63ad2737a29 --- /dev/null +++ b/docs/data/toolpad/core/introduction/Tutorial2.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { createDataProvider } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import { LineChart } from '@toolpad/core/LineChart'; +import Stack from '@mui/material/Stack'; + +const npmData = createDataProvider({ + async getMany({ filter }) { + const res = await fetch( + `https://api.npmjs.org/downloads/range/${encodeURIComponent(filter.range?.equals ?? 'last-month')}/react`, + ); + if (!res.ok) { + const { error } = await res.json(); + throw new Error(`HTTP ${res.status}: ${error}`); + } + const { downloads } = await res.json(); + return { rows: downloads }; + }, + idField: 'day', + fields: { + day: { type: 'date' }, + downloads: { type: 'number', label: 'Npm Downloads' }, + }, +}); + +export default function Tutorial2() { + return ( + + + + + ); +} diff --git a/docs/data/toolpad/core/introduction/Tutorial2.tsx.preview b/docs/data/toolpad/core/introduction/Tutorial2.tsx.preview new file mode 100644 index 00000000000..263a8cfda3a --- /dev/null +++ b/docs/data/toolpad/core/introduction/Tutorial2.tsx.preview @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/docs/data/toolpad/core/introduction/Tutorial3.js b/docs/data/toolpad/core/introduction/Tutorial3.js new file mode 100644 index 00000000000..b9b89115743 --- /dev/null +++ b/docs/data/toolpad/core/introduction/Tutorial3.js @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { createDataProvider, DataContext } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import { LineChart } from '@toolpad/core/LineChart'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import MenuItem from '@mui/material/MenuItem'; +import { Toolbar } from '@mui/material'; + +const npmData = createDataProvider({ + async getMany({ filter }) { + const res = await fetch( + `https://api.npmjs.org/downloads/range/${encodeURIComponent(filter.range?.equals ?? 'last-month')}/react`, + ); + if (!res.ok) { + const { error } = await res.json(); + throw new Error(`HTTP ${res.status}: ${error}`); + } + const { downloads } = await res.json(); + return { rows: downloads }; + }, + idField: 'day', + fields: { + day: { type: 'date' }, + downloads: { type: 'number', label: 'Npm Downloads' }, + }, +}); + +export default function Tutorial3() { + const [range, setRange] = React.useState('last-month'); + const filter = React.useMemo(() => ({ range: { equals: range } }), [range]); + + return ( + + + + setRange(e.target.value)} + > + Last Month + Last Year + + + + + + + ); +} diff --git a/docs/data/toolpad/core/introduction/Tutorial3.tsx b/docs/data/toolpad/core/introduction/Tutorial3.tsx new file mode 100644 index 00000000000..b9b89115743 --- /dev/null +++ b/docs/data/toolpad/core/introduction/Tutorial3.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { createDataProvider, DataContext } from '@toolpad/core/DataProvider'; +import { DataGrid } from '@toolpad/core/DataGrid'; +import { LineChart } from '@toolpad/core/LineChart'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import MenuItem from '@mui/material/MenuItem'; +import { Toolbar } from '@mui/material'; + +const npmData = createDataProvider({ + async getMany({ filter }) { + const res = await fetch( + `https://api.npmjs.org/downloads/range/${encodeURIComponent(filter.range?.equals ?? 'last-month')}/react`, + ); + if (!res.ok) { + const { error } = await res.json(); + throw new Error(`HTTP ${res.status}: ${error}`); + } + const { downloads } = await res.json(); + return { rows: downloads }; + }, + idField: 'day', + fields: { + day: { type: 'date' }, + downloads: { type: 'number', label: 'Npm Downloads' }, + }, +}); + +export default function Tutorial3() { + const [range, setRange] = React.useState('last-month'); + const filter = React.useMemo(() => ({ range: { equals: range } }), [range]); + + return ( + + + + setRange(e.target.value)} + > + Last Month + Last Year + + + + + + + ); +} diff --git a/docs/data/toolpad/core/introduction/overview.md b/docs/data/toolpad/core/introduction/overview.md index 0d36e60e06a..9e103476ec5 100644 --- a/docs/data/toolpad/core/introduction/overview.md +++ b/docs/data/toolpad/core/introduction/overview.md @@ -25,6 +25,8 @@ The framework follows the open-core model, with some features being available un - Built with and exclusively for React ⚛️ - High performance 🚀 - [Layout and Navigation](/toolpad/core/react-dashboard-layout/) +- [Data Providers](/toolpad/core/features/data-providers/) +- [Data Grid](/toolpad/core/react-data-grid/) ## Upcoming Features 🚧 @@ -36,5 +38,3 @@ Visit the [roadmap](/toolpad/core/introduction/roadmap/) to see more details aro - [Dialogs and Notifications](/) - [Role-based Access Control](/) - [Audit Logs](/) -- [Data Providers](/) -- [Data Grid](/) diff --git a/docs/data/toolpad/core/introduction/tutorial.md b/docs/data/toolpad/core/introduction/tutorial.md index e007a4697ef..765a37077e4 100644 --- a/docs/data/toolpad/core/introduction/tutorial.md +++ b/docs/data/toolpad/core/introduction/tutorial.md @@ -103,3 +103,131 @@ const NAVIGATION: Navigation = [ The newly created page can now be navigated to from the sidebar, like the following: {{"demo": "TutorialPages.js", "iframe": true, "hideToolbar": true }} + +## Dashboard content + +Now that your project is set up, it's time to build your first dashboard. This part of the tutorial takes you through building a small dashboard that allows monitoring npm downloads. + +### Connecting to a data source + +Toolpad Core comes with the concept of data providers. At its core, you could look at a data provider as an abstraction over a remote collection. At the very least, a data provider implements the `getMany` method and defines the fields it returns. The `getMany` method must return an object with a `rows` property: + +```js +import { createDataProvider } from '@toolpad/core/DataProvider'; + +const npmData = createDataProvider({ + async getMany({ filter }) { + const res = await fetch( + `https://api.npmjs.org/downloads/range/${encodeURIComponent(filter.range?.equals ?? 'last-month')}/react`, + ); + if (!res.ok) { + const { error } = await res.json(); + throw new Error(`HTTP ${res.status}: ${error}`); + } + const { downloads } = await res.json(); + return { rows: downloads }; + }, + idField: 'day', + fields: { + day: { type: 'date' }, + downloads: { type: 'number' }, + }, +}); +``` + +This data provider calls the npm API and returns the downloads collection. It defines the two fields available in this collection, `day`, which we mark as the unique id field with the `idField` property and `downloads`. You can then visualize this data by connecting it to a grid: + +```js +import { DataGrid } from '@toolpad/core'; +import { Stack } from '@mui/material'; + +// ... + +export default function App() { + return ( + + + + ); +} +``` + +This results in the following output + +{{"demo": "Tutorial1.js", "hideToolbar": true}} + +You don't need to configure any columns, the grid infers them from the data provider. Any default that you define in the fields is adopted by any data rendering component that uses this data provider. + +### Sharing data providers + +Interesting things happen when you share data providers between different components. For instance, you can add a chart that uses the same data. Similar to the grid, this chart displays the same data as the grid. Under the hood the data fetching happens only once. + +```js +// ... +import { DataGrid, LineChart } from '@toolpad/core'; + +// ... + +export default function App() { + return ( + + + + + ); +} +``` + +The Toolpad Core components automatically adopt default values. For instance, if you add a `label` to the field, both the grid uses it in the column header, and the chart uses it in the legend: + +```js + // ... + fields: { + day: { type: 'date' }, + downloads: { type: 'number', label: 'Npm Downloads' }, + }, + // ... +``` + +The result is the following: + +{{"demo": "Tutorial2.js", "hideToolbar": true}} + +### Global Filtering + +Wrap the dashboard with a `DataContext` to apply global filtering: + +```js +const [range, setRange] = React.useState('last-month'); +const filter = React.useMemo(() => ({ range: { equals: range } }), [range]); + +// ... + +return ( + + + + setRange(e.target.value)} + > + Last Month + Last Year + + + {/* ... */} + + +); +``` + +Any data provider that is used under this context now by default applies this filter. + +{{"demo": "Tutorial3.js", "hideToolbar": true}} diff --git a/docs/data/toolpad/core/pages.ts b/docs/data/toolpad/core/pages.ts index 1c6b3d68394..a3ecaeda98a 100644 --- a/docs/data/toolpad/core/pages.ts +++ b/docs/data/toolpad/core/pages.ts @@ -36,6 +36,16 @@ const pages: MuiPage[] = [ pathname: '/toolpad/core/introduction/support', title: 'Support', }, + { + pathname: '/toolpad/core/features-group', + subheader: 'Features', + children: [ + { + pathname: '/toolpad/core/features/data-providers', + title: 'Data Providers', + }, + ], + }, ], }, { @@ -63,6 +73,20 @@ const pages: MuiPage[] = [ }, ], }, + { + pathname: '/toolpad/core/components/data-group', + subheader: 'Data', + children: [ + { + pathname: '/toolpad/core/react-data-grid', + title: 'Data Grid', + }, + { + pathname: '/toolpad/core/react-line-chart', + title: 'Line Chart', + }, + ], + }, { pathname: '/toolpad/core/components/filter-group', subheader: 'Utils', diff --git a/docs/data/toolpad/core/pagesApi.js b/docs/data/toolpad/core/pagesApi.js index 3b1a64b542a..360899799b6 100644 --- a/docs/data/toolpad/core/pagesApi.js +++ b/docs/data/toolpad/core/pagesApi.js @@ -1,6 +1,8 @@ module.exports = [ { pathname: '/toolpad/core/api/app-provider' }, { pathname: '/toolpad/core/api/dashboard-layout' }, + { pathname: '/toolpad/core/api/data-grid' }, { pathname: '/toolpad/core/api/dialogs-provider' }, + { pathname: '/toolpad/core/api/line-chart' }, { pathname: '/toolpad/core/api/notifications-provider' }, ]; diff --git a/docs/pages/toolpad/core/api/data-grid.js b/docs/pages/toolpad/core/api/data-grid.js new file mode 100644 index 00000000000..0e9f5785156 --- /dev/null +++ b/docs/pages/toolpad/core/api/data-grid.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './data-grid.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs-toolpad/translations/api-docs/data-grid', + false, + /\.\/data-grid.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/toolpad/core/api/data-grid.json b/docs/pages/toolpad/core/api/data-grid.json new file mode 100644 index 00000000000..cf92ca6e998 --- /dev/null +++ b/docs/pages/toolpad/core/api/data-grid.json @@ -0,0 +1,864 @@ +{ + "props": { + "dataProvider": { + "type": { + "name": "shape", + "description": "{ createOne?: func, deleteOne?: func, fields?: object, getMany: func, getOne?: func, idField?: object, updateOne?: func }" + } + }, + "height": { "type": { "name": "number" } } + }, + "name": "DataGrid", + "imports": [ + "import { DataGrid } from '@toolpad-core/DataGrid';", + "import { DataGrid } from '@toolpad-core';" + ], + "classes": [ + { + "key": "actionsCell", + "className": "", + "description": "Styles applied to the root element of the cell with type=\"actions\".", + "isGlobal": false + }, + { + "key": "aggregationColumnHeader", + "className": "", + "description": "Styles applied to the root element of the column header when aggregated.", + "isGlobal": false + }, + { + "key": "aggregationColumnHeader--alignCenter", + "className": "", + "description": "Styles applied to the root element of the header when aggregation if `headerAlign=\"center\"`.", + "isGlobal": false + }, + { + "key": "aggregationColumnHeader--alignLeft", + "className": "", + "description": "Styles applied to the root element of the header when aggregation if `headerAlign=\"left\"`.", + "isGlobal": false + }, + { + "key": "aggregationColumnHeader--alignRight", + "className": "", + "description": "Styles applied to the root element of the header when aggregation if `headerAlign=\"right\"`.", + "isGlobal": false + }, + { + "key": "aggregationColumnHeaderLabel", + "className": "", + "description": "Styles applied to the aggregation label in the column header when aggregated.", + "isGlobal": false + }, + { + "key": "autoHeight", + "className": "", + "description": "Styles applied to the root element if `autoHeight={true}`.", + "isGlobal": false + }, + { + "key": "autosizing", + "className": "", + "description": "Styles applied to the root element while it is being autosized.", + "isGlobal": false + }, + { + "key": "booleanCell", + "className": "", + "description": "Styles applied to the icon of the boolean cell.", + "isGlobal": false + }, + { + "key": "cell", + "className": "", + "description": "Styles applied to the cell element.", + "isGlobal": false + }, + { + "key": "cell--editable", + "className": "", + "description": "Styles applied to the cell element if the cell is editable.", + "isGlobal": false + }, + { + "key": "cell--editing", + "className": "", + "description": "Styles applied to the cell element if the cell is in edit mode.", + "isGlobal": false + }, + { + "key": "cell--flex", + "className": "", + "description": "Styles applied to the cell element in flex display mode.", + "isGlobal": false + }, + { + "key": "cell--pinnedLeft", + "className": "", + "description": "Styles applied to the cell element if it is pinned to the left.", + "isGlobal": false + }, + { + "key": "cell--pinnedRight", + "className": "", + "description": "Styles applied to the cell element if it is pinned to the right.", + "isGlobal": false + }, + { + "key": "cell--rangeBottom", + "className": "", + "description": "Styles applied to the cell element if it is at the bottom edge of a cell selection range.", + "isGlobal": false + }, + { + "key": "cell--rangeLeft", + "className": "", + "description": "Styles applied to the cell element if it is at the left edge of a cell selection range.", + "isGlobal": false + }, + { + "key": "cell--rangeRight", + "className": "", + "description": "Styles applied to the cell element if it is at the right edge of a cell selection range.", + "isGlobal": false + }, + { + "key": "cell--rangeTop", + "className": "", + "description": "Styles applied to the cell element if it is at the top edge of a cell selection range.", + "isGlobal": false + }, + { + "key": "cell--selectionMode", + "className": "", + "description": "Styles applied to the cell element if it is in a cell selection range.", + "isGlobal": false + }, + { + "key": "cell--textCenter", + "className": "", + "description": "Styles applied to the cell element if `align=\"center\"`.", + "isGlobal": false + }, + { + "key": "cell--textLeft", + "className": "", + "description": "Styles applied to the cell element if `align=\"left\"`.", + "isGlobal": false + }, + { + "key": "cell--textRight", + "className": "", + "description": "Styles applied to the cell element if `align=\"right\"`.", + "isGlobal": false + }, + { + "key": "cell--withLeftBorder", + "className": "", + "description": "Styles applied the cell if `showColumnVerticalBorder={true}`.", + "isGlobal": false + }, + { + "key": "cell--withRightBorder", + "className": "", + "description": "Styles applied the cell if `showColumnVerticalBorder={true}`.", + "isGlobal": false + }, + { + "key": "cellCheckbox", + "className": "", + "description": "Styles applied to the cell checkbox element.", + "isGlobal": false + }, + { + "key": "cellEmpty", + "className": "", + "description": "Styles applied to the empty cell element.", + "isGlobal": false + }, + { + "key": "cellSkeleton", + "className": "", + "description": "Styles applied to the skeleton cell element.", + "isGlobal": false + }, + { + "key": "checkboxInput", + "className": "", + "description": "Styles applied to the selection checkbox element.", + "isGlobal": false + }, + { + "key": "columnHeader", + "className": "", + "description": "Styles applied to the column header element.", + "isGlobal": false + }, + { + "key": "columnHeader--alignCenter", + "className": "", + "description": "Styles applied to the column header if `headerAlign=\"center\"`.", + "isGlobal": false + }, + { + "key": "columnHeader--alignLeft", + "className": "", + "description": "Styles applied to the column header if `headerAlign=\"left\"`.", + "isGlobal": false + }, + { + "key": "columnHeader--alignRight", + "className": "", + "description": "Styles applied to the column header if `headerAlign=\"right\"`.", + "isGlobal": false + }, + { + "key": "columnHeader--dragging", + "className": "", + "description": "Styles applied to the floating column header element when it is dragged.", + "isGlobal": false + }, + { + "key": "columnHeader--emptyGroup", + "className": "", + "description": "Styles applied to the empty column group header cell.", + "isGlobal": false + }, + { + "key": "columnHeader--filledGroup", + "className": "", + "description": "Styles applied to the column group header cell if not empty.", + "isGlobal": false + }, + { + "key": "columnHeader--filtered", + "className": "", + "description": "Styles applied to the column header if the column has a filter applied to it.", + "isGlobal": false + }, + { + "key": "columnHeader--last", + "className": "", + "description": "Styles applied to the last column header element.", + "isGlobal": false + }, + { + "key": "columnHeader--moving", + "className": "", + "description": "Styles applied to the column header if it is being dragged.", + "isGlobal": false + }, + { + "key": "columnHeader--numeric", + "className": "", + "description": "Styles applied to the column header if the type of the column is `number`.", + "isGlobal": false + }, + { "key": "columnHeader--pinnedLeft", "className": "", "description": "", "isGlobal": false }, + { "key": "columnHeader--pinnedRight", "className": "", "description": "", "isGlobal": false }, + { + "key": "columnHeader--sortable", + "className": "", + "description": "Styles applied to the column header if the column is sortable.", + "isGlobal": false + }, + { + "key": "columnHeader--sorted", + "className": "", + "description": "Styles applied to the column header if the column is sorted.", + "isGlobal": false + }, + { + "key": "columnHeader--withLeftBorder", + "className": "", + "description": "", + "isGlobal": false + }, + { + "key": "columnHeader--withRightBorder", + "className": "", + "description": "Styles applied the column header if `showColumnVerticalBorder={true}`.", + "isGlobal": false + }, + { + "key": "columnHeaderCheckbox", + "className": "", + "description": "Styles applied to the header checkbox cell element.", + "isGlobal": false + }, + { + "key": "columnHeaderDraggableContainer", + "className": "", + "description": "Styles applied to the column header's draggable container element.", + "isGlobal": false + }, + { + "key": "columnHeaders", + "className": "", + "description": "Styles applied to the column headers.", + "isGlobal": false + }, + { + "key": "columnHeaderTitle", + "className": "", + "description": "Styles applied to the column header's title element;", + "isGlobal": false + }, + { + "key": "columnHeaderTitleContainer", + "className": "", + "description": "Styles applied to the column header's title container element.", + "isGlobal": false + }, + { + "key": "columnHeaderTitleContainerContent", + "className": "", + "description": "Styles applied to the column header's title excepted buttons.", + "isGlobal": false + }, + { + "key": "columnSeparator", + "className": "", + "description": "Styles applied to the column header separator element.", + "isGlobal": false + }, + { + "key": "columnSeparator--resizable", + "className": "", + "description": "Styles applied to the column header separator if the column is resizable.", + "isGlobal": false + }, + { + "key": "columnSeparator--resizing", + "className": "", + "description": "Styles applied to the column header separator if the column is being resized.", + "isGlobal": false + }, + { + "key": "columnSeparator--sideLeft", + "className": "", + "description": "Styles applied to the column header separator if the side is \"left\".", + "isGlobal": false + }, + { + "key": "columnSeparator--sideRight", + "className": "", + "description": "Styles applied to the column header separator if the side is \"right\".", + "isGlobal": false + }, + { + "key": "columnsManagement", + "className": "", + "description": "Styles applied to the columns management body.", + "isGlobal": false + }, + { + "key": "columnsManagementFooter", + "className": "", + "description": "Styles applied to the columns management footer element.", + "isGlobal": false + }, + { + "key": "columnsManagementHeader", + "className": "", + "description": "Styles applied to the columns management header element.", + "isGlobal": false + }, + { + "key": "columnsManagementRow", + "className": "", + "description": "Styles applied to the columns management row element.", + "isGlobal": false + }, + { + "key": "container--bottom", + "className": "", + "description": "Styles applied to the bottom container.", + "isGlobal": false + }, + { + "key": "container--top", + "className": "", + "description": "Styles applied to the top container.", + "isGlobal": false + }, + { + "key": "detailPanel", + "className": "", + "description": "Styles applied to the detail panel element.", + "isGlobal": false + }, + { + "key": "detailPanels", + "className": "", + "description": "Styles applied to the detail panels wrapper element.", + "isGlobal": false + }, + { + "key": "detailPanelToggleCell", + "className": "", + "description": "Styles applied to the detail panel toggle cell element.", + "isGlobal": false + }, + { + "key": "detailPanelToggleCell--expanded", + "className": "", + "description": "Styles applied to the detail panel toggle cell element if expanded.", + "isGlobal": false + }, + { + "key": "editBooleanCell", + "className": "", + "description": "Styles applied to root of the boolean edit component.", + "isGlobal": false + }, + { + "key": "editInputCell", + "className": "", + "description": "Styles applied to the root of the input component.", + "isGlobal": false + }, + { + "key": "filterForm", + "className": "", + "description": "Styles applied to the root of the filter form component.", + "isGlobal": false + }, + { + "key": "filterFormColumnInput", + "className": "", + "description": "Styles applied to the column input of the filter form component.", + "isGlobal": false + }, + { + "key": "filterFormDeleteIcon", + "className": "", + "description": "Styles applied to the delete icon of the filter form component.", + "isGlobal": false + }, + { + "key": "filterFormLogicOperatorInput", + "className": "", + "description": "Styles applied to the link operator input of the filter form component.", + "isGlobal": false + }, + { + "key": "filterFormOperatorInput", + "className": "", + "description": "Styles applied to the operator input of the filter form component.", + "isGlobal": false + }, + { + "key": "filterFormValueInput", + "className": "", + "description": "Styles applied to the value input of the filter form component.", + "isGlobal": false + }, + { + "key": "filterIcon", + "className": "", + "description": "Styles applied to the filter icon element.", + "isGlobal": false + }, + { + "key": "footerCell", + "className": "", + "description": "Styles applied to the root element of the cell inside a footer row.", + "isGlobal": false + }, + { + "key": "footerContainer", + "className": "", + "description": "Styles applied to the footer container element.", + "isGlobal": false + }, + { + "key": "groupingCriteriaCell", + "className": "", + "description": "Styles applied to the root element of the grouping criteria cell", + "isGlobal": false + }, + { + "key": "groupingCriteriaCellToggle", + "className": "", + "description": "Styles applied to the toggle of the grouping criteria cell", + "isGlobal": false + }, + { + "key": "headerFilterRow", + "className": "", + "description": "Styles applied to the column header filter row.", + "isGlobal": false + }, + { + "key": "iconButtonContainer", + "className": "", + "description": "Styles applied to the column header icon's container.", + "isGlobal": false + }, + { + "key": "iconSeparator", + "className": "", + "description": "Styles applied to the column header separator icon element.", + "isGlobal": false + }, + { + "key": "main", + "className": "", + "description": "Styles applied to the main container element.", + "isGlobal": false + }, + { + "key": "main--hasPinnedRight", + "className": "", + "description": "Styles applied to the main container element when it has right pinned columns.", + "isGlobal": false + }, + { + "key": "menu", + "className": "", + "description": "Styles applied to the menu element.", + "isGlobal": false + }, + { + "key": "menuIcon", + "className": "", + "description": "Styles applied to the menu icon element.", + "isGlobal": false + }, + { + "key": "menuIconButton", + "className": "", + "description": "Styles applied to the menu icon button element.", + "isGlobal": false + }, + { + "key": "menuList", + "className": "", + "description": "Styles applied to the menu list element.", + "isGlobal": false + }, + { + "key": "menuOpen", + "className": "", + "description": "Styles applied to the menu icon element if the menu is open.", + "isGlobal": false + }, + { + "key": "overlay", + "className": "", + "description": "Styles applied to the overlay element.", + "isGlobal": false + }, + { + "key": "overlayWrapper", + "className": "", + "description": "Styles applied to the overlay wrapper element.", + "isGlobal": false + }, + { + "key": "overlayWrapperInner", + "className": "", + "description": "Styles applied to the overlay wrapper inner element.", + "isGlobal": false + }, + { + "key": "panel", + "className": "", + "description": "Styles applied to the panel element.", + "isGlobal": false + }, + { + "key": "panelContent", + "className": "", + "description": "Styles applied to the panel content element.", + "isGlobal": false + }, + { + "key": "panelFooter", + "className": "", + "description": "Styles applied to the panel footer element.", + "isGlobal": false + }, + { + "key": "panelHeader", + "className": "", + "description": "Styles applied to the panel header element.", + "isGlobal": false + }, + { + "key": "panelWrapper", + "className": "", + "description": "Styles applied to the panel wrapper element.", + "isGlobal": false + }, + { + "key": "paper", + "className": "", + "description": "Styles applied to the paper element.", + "isGlobal": false + }, + { + "key": "pinnedColumns", + "className": "", + "description": "Styles applied to the pinned columns.", + "isGlobal": false + }, + { + "key": "pinnedRows", + "className": "", + "description": "Styles applied to the pinned rows container.", + "isGlobal": false + }, + { + "key": "pinnedRows--bottom", + "className": "", + "description": "Styles applied to the bottom pinned rows container.", + "isGlobal": false + }, + { + "key": "pinnedRows--top", + "className": "", + "description": "Styles applied to the top pinned rows container.", + "isGlobal": false + }, + { + "key": "pinnedRowsRenderZone", + "className": "", + "description": "Styles applied to pinned rows render zones.", + "isGlobal": false + }, + { + "key": "root", + "className": "", + "description": "Styles applied to the root element.", + "isGlobal": false + }, + { + "key": "root--densityComfortable", + "className": "", + "description": "Styles applied to the root element if density is \"comfortable\".", + "isGlobal": false + }, + { + "key": "root--densityCompact", + "className": "", + "description": "Styles applied to the root element if density is \"compact\".", + "isGlobal": false + }, + { + "key": "root--densityStandard", + "className": "", + "description": "Styles applied to the root element if density is \"standard\" (default).", + "isGlobal": false + }, + { + "key": "root--disableUserSelection", + "className": "", + "description": "Styles applied to the root element when user selection is disabled.", + "isGlobal": false + }, + { + "key": "row", + "className": "", + "description": "Styles applied to the row element.", + "isGlobal": false + }, + { + "key": "row--detailPanelExpanded", + "className": "", + "description": "Styles applied to the row if its detail panel is open.", + "isGlobal": false + }, + { + "key": "row--dragging", + "className": "", + "description": "Styles applied to the floating special row reorder cell element when it is dragged.", + "isGlobal": false + }, + { + "key": "row--dynamicHeight", + "className": "", + "description": "Styles applied to the row if it has dynamic row height.", + "isGlobal": false + }, + { + "key": "row--editable", + "className": "", + "description": "Styles applied to the row element if the row is editable.", + "isGlobal": false + }, + { + "key": "row--editing", + "className": "", + "description": "Styles applied to the row element if the row is in edit mode.", + "isGlobal": false + }, + { + "key": "row--firstVisible", + "className": "", + "description": "Styles applied to the first visible row element on every page of the grid.", + "isGlobal": false + }, + { + "key": "row--lastVisible", + "className": "", + "description": "Styles applied to the last visible row element on every page of the grid.", + "isGlobal": false + }, + { + "key": "rowCount", + "className": "", + "description": "Styles applied to the footer row count element to show the total number of rows.\nOnly works when pagination is disabled.", + "isGlobal": false + }, + { + "key": "rowReorderCell", + "className": "", + "description": "Styles applied to the root element of the row reorder cell", + "isGlobal": false + }, + { + "key": "rowReorderCell--draggable", + "className": "", + "description": "Styles applied to the root element of the row reorder cell when dragging is allowed", + "isGlobal": false + }, + { + "key": "rowReorderCellContainer", + "className": "", + "description": "Styles applied to the row reorder cell container element.", + "isGlobal": false + }, + { + "key": "rowReorderCellPlaceholder", + "className": "", + "description": "Styles applied to the row's draggable placeholder element inside the special row reorder cell.", + "isGlobal": false + }, + { + "key": "rowSkeleton", + "className": "", + "description": "Styles applied to the skeleton row element.", + "isGlobal": false + }, + { + "key": "scrollArea", + "className": "", + "description": "Styles applied to both scroll area elements.", + "isGlobal": false + }, + { + "key": "scrollArea--left", + "className": "", + "description": "Styles applied to the left scroll area element.", + "isGlobal": false + }, + { + "key": "scrollArea--right", + "className": "", + "description": "Styles applied to the right scroll area element.", + "isGlobal": false + }, + { + "key": "scrollbar", + "className": "", + "description": "Styles applied to the scrollbars.", + "isGlobal": false + }, + { + "key": "scrollbar--horizontal", + "className": "", + "description": "Styles applied to the horizontal scrollbar.", + "isGlobal": false + }, + { + "key": "scrollbar--vertical", + "className": "", + "description": "Styles applied to the horizontal scrollbar.", + "isGlobal": false + }, + { + "key": "selectedRowCount", + "className": "", + "description": "Styles applied to the footer selected row count element.", + "isGlobal": false + }, + { + "key": "sortIcon", + "className": "", + "description": "Styles applied to the sort icon element.", + "isGlobal": false + }, + { + "key": "toolbarContainer", + "className": "", + "description": "Styles applied to the toolbar container element.", + "isGlobal": false + }, + { + "key": "toolbarFilterList", + "className": "", + "description": "Styles applied to the toolbar filter list element.", + "isGlobal": false + }, + { + "key": "treeDataGroupingCell", + "className": "", + "description": "Styles applied to the root of the grouping column of the tree data.", + "isGlobal": false + }, + { + "key": "treeDataGroupingCellToggle", + "className": "", + "description": "Styles applied to the toggle of the grouping cell of the tree data.", + "isGlobal": false + }, + { + "key": "virtualScroller", + "className": "", + "description": "Styles applied to the virtualization container.", + "isGlobal": false + }, + { + "key": "virtualScrollerContent", + "className": "", + "description": "Styles applied to the virtualization content.", + "isGlobal": false + }, + { + "key": "virtualScrollerContent--overflowed", + "className": "", + "description": "Styles applied to the virtualization content when its height is bigger than the virtualization container.", + "isGlobal": false + }, + { + "key": "virtualScrollerRenderZone", + "className": "", + "description": "Styles applied to the virtualization render zone.", + "isGlobal": false + }, + { + "key": "withBorderColor", + "className": "", + "description": "Styles applied to cells, column header and other elements that have border.\nSets border color only.", + "isGlobal": false + }, + { + "key": "withVerticalBorder", + "className": "", + "description": "Styles applied the grid if `showColumnVerticalBorder={true}`.", + "isGlobal": false + } + ], + "spread": true, + "themeDefaultProps": false, + "muiName": "DataGrid", + "filename": "/packages/toolpad-core/src/DataGrid/DataGrid.tsx", + "inheritance": { + "component": "X DataGrid", + "pathname": "https://mui.com/x/api/data-grid/data-grid/" + }, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/toolpad/core/api/line-chart.js b/docs/pages/toolpad/core/api/line-chart.js new file mode 100644 index 00000000000..f580ac941c6 --- /dev/null +++ b/docs/pages/toolpad/core/api/line-chart.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './line-chart.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs-toolpad/translations/api-docs/line-chart', + false, + /\.\/line-chart.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/toolpad/core/api/line-chart.json b/docs/pages/toolpad/core/api/line-chart.json new file mode 100644 index 00000000000..8965eb537fc --- /dev/null +++ b/docs/pages/toolpad/core/api/line-chart.json @@ -0,0 +1,27 @@ +{ + "props": { + "dataProvider": { + "type": { + "name": "shape", + "description": "{ createOne?: func, deleteOne?: func, fields?: object, getMany: func, getOne?: func, idField?: object, updateOne?: func }" + } + } + }, + "name": "LineChart", + "imports": [ + "import { LineChart } from '@toolpad-core/LineChart';", + "import { LineChart } from '@toolpad-core';" + ], + "classes": [], + "spread": true, + "themeDefaultProps": false, + "muiName": "LineChart", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/toolpad-core/src/LineChart/LineChart.tsx", + "inheritance": { + "component": "X LineChart", + "pathname": "https://mui.com/x/api/charts/line-chart/" + }, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/toolpad/core/features/data-providers.js b/docs/pages/toolpad/core/features/data-providers.js new file mode 100644 index 00000000000..5b8c7f49d97 --- /dev/null +++ b/docs/pages/toolpad/core/features/data-providers.js @@ -0,0 +1,7 @@ +import * as React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import * as pageProps from '../../../../data/toolpad/core/features/data-providers.md?muiMarkdown'; + +export default function Page() { + return ; +} diff --git a/docs/pages/toolpad/core/react-data-grid/index.js b/docs/pages/toolpad/core/react-data-grid/index.js new file mode 100644 index 00000000000..597c9359db3 --- /dev/null +++ b/docs/pages/toolpad/core/react-data-grid/index.js @@ -0,0 +1,7 @@ +import * as React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import * as pageProps from '../../../../data/toolpad/core/components/data-grid/data-grid.md?muiMarkdown'; + +export default function Page() { + return ; +} diff --git a/docs/pages/toolpad/core/react-line-chart/index.js b/docs/pages/toolpad/core/react-line-chart/index.js new file mode 100644 index 00000000000..d87fc257bbf --- /dev/null +++ b/docs/pages/toolpad/core/react-line-chart/index.js @@ -0,0 +1,7 @@ +import * as React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import * as pageProps from '../../../../data/toolpad/core/components/line-chart/line-chart.md?muiMarkdown'; + +export default function Page() { + return ; +} diff --git a/docs/translations/api-docs/data-grid/data-grid.json b/docs/translations/api-docs/data-grid/data-grid.json new file mode 100644 index 00000000000..13c2d950b25 --- /dev/null +++ b/docs/translations/api-docs/data-grid/data-grid.json @@ -0,0 +1,584 @@ +{ + "componentDescription": "", + "propDescriptions": { + "dataProvider": { + "description": "The data provider to resolve the displayed data. This object must be referentially stable." + }, + "height": { + "description": "The height of the datagrid in pixels. If left undefined, it adopts the height of its parent." + } + }, + "classDescriptions": { + "actionsCell": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the root element of the cell with type="actions"" + }, + "aggregationColumnHeader": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the root element of the column header", + "conditions": "aggregated" + }, + "aggregationColumnHeader--alignCenter": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the root element of the header", + "conditions": "aggregation if headerAlign=\"center\"" + }, + "aggregationColumnHeader--alignLeft": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the root element of the header", + "conditions": "aggregation if headerAlign=\"left\"" + }, + "aggregationColumnHeader--alignRight": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the root element of the header", + "conditions": "aggregation if headerAlign=\"right\"" + }, + "aggregationColumnHeaderLabel": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the aggregation label in the column header", + "conditions": "aggregated" + }, + "autoHeight": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "autoHeight={true}" + }, + "autosizing": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the root element while it is being autosized" + }, + "booleanCell": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the icon of the boolean cell" + }, + "cell": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the cell element" }, + "cell--editable": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "the cell is editable" + }, + "cell--editing": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "the cell is in edit mode" + }, + "cell--flex": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the cell element in flex display mode" + }, + "cell--pinnedLeft": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "it is pinned to the left" + }, + "cell--pinnedRight": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "it is pinned to the right" + }, + "cell--rangeBottom": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "it is at the bottom edge of a cell selection range" + }, + "cell--rangeLeft": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "it is at the left edge of a cell selection range" + }, + "cell--rangeRight": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "it is at the right edge of a cell selection range" + }, + "cell--rangeTop": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "it is at the top edge of a cell selection range" + }, + "cell--selectionMode": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "it is in a cell selection range" + }, + "cell--textCenter": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "align=\"center\"" + }, + "cell--textLeft": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "align=\"left\"" + }, + "cell--textRight": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "align=\"right\"" + }, + "cell--withLeftBorder": { + "description": "Styles applied the cell if showColumnVerticalBorder={true}." + }, + "cell--withRightBorder": { + "description": "Styles applied the cell if showColumnVerticalBorder={true}." + }, + "cellCheckbox": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the cell checkbox element" + }, + "cellEmpty": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the empty cell element" + }, + "cellSkeleton": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the skeleton cell element" + }, + "checkboxInput": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the selection checkbox element" + }, + "columnHeader": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column header element" + }, + "columnHeader--alignCenter": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column header", + "conditions": "headerAlign=\"center\"" + }, + "columnHeader--alignLeft": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column header", + "conditions": "headerAlign=\"left\"" + }, + "columnHeader--alignRight": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column header", + "conditions": "headerAlign=\"right\"" + }, + "columnHeader--dragging": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the floating column header element", + "conditions": "it is dragged" + }, + "columnHeader--emptyGroup": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the empty column group header cell" + }, + "columnHeader--filledGroup": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "not empty" + }, + "columnHeader--filtered": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column header", + "conditions": "the column has a filter applied to it" + }, + "columnHeader--last": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the last column header element" + }, + "columnHeader--moving": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column header", + "conditions": "it is being dragged" + }, + "columnHeader--numeric": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column header", + "conditions": "the type of the column is number" + }, + "columnHeader--pinnedLeft": { "description": "" }, + "columnHeader--pinnedRight": { "description": "" }, + "columnHeader--sortable": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column header", + "conditions": "the column is sortable" + }, + "columnHeader--sorted": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column header", + "conditions": "the column is sorted" + }, + "columnHeader--withLeftBorder": { "description": "" }, + "columnHeader--withRightBorder": { + "description": "Styles applied the column header if showColumnVerticalBorder={true}." + }, + "columnHeaderCheckbox": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the header checkbox cell element" + }, + "columnHeaderDraggableContainer": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column header's draggable container element" + }, + "columnHeaders": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column headers" + }, + "columnHeaderTitle": { + "description": "Styles applied to the column header's title element;" + }, + "columnHeaderTitleContainer": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column header's title container element" + }, + "columnHeaderTitleContainerContent": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column header's title excepted buttons" + }, + "columnSeparator": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column header separator element" + }, + "columnSeparator--resizable": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column header separator", + "conditions": "the column is resizable" + }, + "columnSeparator--resizing": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column header separator", + "conditions": "the column is being resized" + }, + "columnSeparator--sideLeft": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column header separator", + "conditions": "the side is "left"" + }, + "columnSeparator--sideRight": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column header separator", + "conditions": "the side is "right"" + }, + "columnsManagement": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the columns management body" + }, + "columnsManagementFooter": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the columns management footer element" + }, + "columnsManagementHeader": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the columns management header element" + }, + "columnsManagementRow": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the columns management row element" + }, + "container--bottom": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the bottom container" + }, + "container--top": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the top container" + }, + "detailPanel": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the detail panel element" + }, + "detailPanels": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the detail panels wrapper element" + }, + "detailPanelToggleCell": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the detail panel toggle cell element" + }, + "detailPanelToggleCell--expanded": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the detail panel toggle cell element", + "conditions": "expanded" + }, + "editBooleanCell": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "root of the boolean edit component" + }, + "editInputCell": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the root of the input component" + }, + "filterForm": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the root of the filter form component" + }, + "filterFormColumnInput": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column input of the filter form component" + }, + "filterFormDeleteIcon": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the delete icon of the filter form component" + }, + "filterFormLogicOperatorInput": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the link operator input of the filter form component" + }, + "filterFormOperatorInput": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the operator input of the filter form component" + }, + "filterFormValueInput": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the value input of the filter form component" + }, + "filterIcon": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the filter icon element" + }, + "footerCell": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the root element of the cell inside a footer row" + }, + "footerContainer": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the footer container element" + }, + "groupingCriteriaCell": { + "description": "Styles applied to the root element of the grouping criteria cell" + }, + "groupingCriteriaCellToggle": { + "description": "Styles applied to the toggle of the grouping criteria cell" + }, + "headerFilterRow": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column header filter row" + }, + "iconButtonContainer": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column header icon's container" + }, + "iconSeparator": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column header separator icon element" + }, + "main": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the main container element" + }, + "main--hasPinnedRight": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the main container element", + "conditions": "it has right pinned columns" + }, + "menu": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the menu element" }, + "menuIcon": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the menu icon element" + }, + "menuIconButton": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the menu icon button element" + }, + "menuList": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the menu list element" + }, + "menuOpen": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the menu icon element", + "conditions": "the menu is open" + }, + "overlay": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the overlay element" + }, + "overlayWrapper": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the overlay wrapper element" + }, + "overlayWrapperInner": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the overlay wrapper inner element" + }, + "panel": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the panel element" }, + "panelContent": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the panel content element" + }, + "panelFooter": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the panel footer element" + }, + "panelHeader": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the panel header element" + }, + "panelWrapper": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the panel wrapper element" + }, + "paper": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the paper element" }, + "pinnedColumns": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the pinned columns" + }, + "pinnedRows": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the pinned rows container" + }, + "pinnedRows--bottom": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the bottom pinned rows container" + }, + "pinnedRows--top": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the top pinned rows container" + }, + "pinnedRowsRenderZone": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "pinned rows render zones" + }, + "root": { "description": "Styles applied to the root element." }, + "root--densityComfortable": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "density is "comfortable"" + }, + "root--densityCompact": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "density is "compact"" + }, + "root--densityStandard": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "density is "standard" (default)" + }, + "root--disableUserSelection": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the root element", + "conditions": "user selection is disabled" + }, + "row": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the row element" }, + "row--detailPanelExpanded": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the row", + "conditions": "its detail panel is open" + }, + "row--dragging": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the floating special row reorder cell element", + "conditions": "it is dragged" + }, + "row--dynamicHeight": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the row", + "conditions": "it has dynamic row height" + }, + "row--editable": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the row element", + "conditions": "the row is editable" + }, + "row--editing": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the row element", + "conditions": "the row is in edit mode" + }, + "row--firstVisible": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the first visible row element on every page of the grid" + }, + "row--lastVisible": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the last visible row element on every page of the grid" + }, + "rowCount": { + "description": "Styles applied to {{nodeName}}. Only works when pagination is disabled.", + "nodeName": "the footer row count element to show the total number of rows" + }, + "rowReorderCell": { + "description": "Styles applied to the root element of the row reorder cell" + }, + "rowReorderCell--draggable": { + "description": "Styles applied to the root element of the row reorder cell when dragging is allowed" + }, + "rowReorderCellContainer": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the row reorder cell container element" + }, + "rowReorderCellPlaceholder": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the row's draggable placeholder element inside the special row reorder cell" + }, + "rowSkeleton": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the skeleton row element" + }, + "scrollArea": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "both scroll area elements" + }, + "scrollArea--left": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the left scroll area element" + }, + "scrollArea--right": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the right scroll area element" + }, + "scrollbar": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the scrollbars" }, + "scrollbar--horizontal": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the horizontal scrollbar" + }, + "scrollbar--vertical": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the horizontal scrollbar" + }, + "selectedRowCount": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the footer selected row count element" + }, + "sortIcon": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the sort icon element" + }, + "toolbarContainer": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the toolbar container element" + }, + "toolbarFilterList": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the toolbar filter list element" + }, + "treeDataGroupingCell": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the root of the grouping column of the tree data" + }, + "treeDataGroupingCellToggle": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the toggle of the grouping cell of the tree data" + }, + "virtualScroller": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the virtualization container" + }, + "virtualScrollerContent": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the virtualization content" + }, + "virtualScrollerContent--overflowed": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the virtualization content", + "conditions": "its height is bigger than the virtualization container" + }, + "virtualScrollerRenderZone": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the virtualization render zone" + }, + "withBorderColor": { + "description": "Styles applied to {{nodeName}}, {{conditions}}. Sets border color only.", + "nodeName": "cells", + "conditions": "column header and other elements that have border" + }, + "withVerticalBorder": { + "description": "Styles applied the grid if showColumnVerticalBorder={true}." + } + } +} diff --git a/docs/translations/api-docs/line-chart/line-chart.json b/docs/translations/api-docs/line-chart/line-chart.json new file mode 100644 index 00000000000..7136b3f1a65 --- /dev/null +++ b/docs/translations/api-docs/line-chart/line-chart.json @@ -0,0 +1,9 @@ +{ + "componentDescription": "", + "propDescriptions": { + "dataProvider": { + "description": "The data provider to resolve the displayed data. This object must be referentially stable." + } + }, + "classDescriptions": {} +} diff --git a/packages/toolpad-core/package.json b/packages/toolpad-core/package.json index 5565248454b..5800fe83d30 100644 --- a/packages/toolpad-core/package.json +++ b/packages/toolpad-core/package.json @@ -54,6 +54,9 @@ "@mui/base": "5.0.0-beta.40", "@mui/lab": "5.0.0-alpha.172", "@mui/utils": "5.16.4", + "@mui/x-charts": "7.10.0", + "@mui/x-data-grid": "7.10.0", + "@tanstack/react-query": "5.49.0", "@toolpad/utils": "workspace:*", "client-only": "^0.0.1", "invariant": "2.2.4", diff --git a/packages/toolpad-core/src/DataGrid/DataGrid.test.tsx b/packages/toolpad-core/src/DataGrid/DataGrid.test.tsx new file mode 100644 index 00000000000..ad426b68dca --- /dev/null +++ b/packages/toolpad-core/src/DataGrid/DataGrid.test.tsx @@ -0,0 +1,262 @@ +/** + * @vitest-environment jsdom + */ + +import * as React from 'react'; +import { describe, test, expect, vi } from 'vitest'; +import { render, waitFor, within, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { DataGrid as XDataGrid } from '@mui/x-data-grid'; +import describeConformance from '@toolpad/utils/describeConformance'; +import invariant from 'invariant'; +import Box from '@mui/material/Box'; +import { DataGrid } from './DataGrid'; +import { createDataProvider } from '../DataProvider'; + +function createSequence() { + let nextId = 1; + return () => { + const id = nextId; + nextId += 1; + return id; + }; +} + +function queryRow(root: HTMLElement, rowIndex: number): HTMLElement | null { + const row = within(root) + .queryAllByRole('row') + ?.find((theRow) => Number(theRow.dataset.rowindex) === rowIndex); + return row || null; +} + +function getRow(root: HTMLElement, rowIndex: number): HTMLElement { + const row = queryRow(root, rowIndex); + if (!row) { + throw new Error(`Row ${rowIndex} not found`); + } + return row; +} + +function queryCell(root: HTMLElement, rowIndex: number, colIndex: number): HTMLElement | null { + const row = queryRow(root, rowIndex); + if (!row) { + return null; + } + + const cell = within(row) + .queryAllByRole('gridcell') + ?.find((theCell) => Number(theCell.dataset.colindex) === colIndex); + + return cell || null; +} + +function getCell(root: HTMLElement, rowIndex: number, colIndex: number): HTMLElement { + const cell = queryCell(root, rowIndex, colIndex); + if (!cell) { + throw new Error(`Cell [${rowIndex}, ${colIndex}] not found`); + } + return cell; +} + +describe('DataGrid', () => { + describeConformance(, () => ({ + inheritComponent: XDataGrid, + skip: ['themeDefaultProps'], + })); + + test('renders content correctly', async () => { + const getNextId = createSequence(); + const rows = [ + { id: getNextId(), name: 'Alice' }, + { id: getNextId(), name: 'Bob' }, + ]; + const dataProvider = createDataProvider({ + getMany: async () => ({ rows }), + fields: { + id: { type: 'number' }, + name: { type: 'string' }, + }, + }); + + render(); + + await screen.findByText('Alice'); + + expect(screen.queryByRole('button', { name: 'Add record' })).toBeFalsy(); + }); + + test('Supports create flow', async () => { + const getNextId = createSequence(); + let rows = [ + { id: getNextId(), name: 'Alice' }, + { id: getNextId(), name: 'Bob' }, + ]; + const dataProvider = createDataProvider({ + getMany: async () => ({ rows }), + createOne: vi.fn(async (data) => { + const newRow = { ...data, id: getNextId() }; + rows = [...rows, newRow]; + return newRow; + }), + fields: { + id: { type: 'number' }, + name: { type: 'string' }, + }, + }); + + const user = userEvent.setup(); + const view = render( + + + , + ); + + await screen.findByText('Alice'); + + expect(screen.queryAllByRole('menuitem', { name: 'Edit' })).toHaveLength(0); + expect(screen.queryAllByRole('menuitem', { name: 'Delete' })).toHaveLength(0); + + const addRecordButton = screen.getByRole('button', { name: 'Add record' }); + + await user.click(addRecordButton); + + const nameInput = within(getCell(view.baseElement, 0, 1)).getByRole('textbox'); + expect(nameInput).toBeTruthy(); + + const saveButton = screen.getByRole('menuitem', { name: 'Save' }); + + await user.keyboard('Charlie'); + + await user.click(saveButton); + + await waitFor(() => expect(getCell(view.baseElement, 2, 1).textContent).toBe('Charlie')); + const snackbar = await screen.findByRole('alert'); + expect(snackbar.textContent).toMatch('Row created'); + + const showButton = within(snackbar).getByRole('button', { name: 'Show' }); + await user.click(showButton); + + expect(within(screen.getByRole('rowgroup')).queryAllByRole('row')).toHaveLength(1); + expect(screen.getByText('Charlie')).toBeTruthy(); + + expect(dataProvider.createOne).toHaveBeenCalledOnce(); + expect(dataProvider.createOne).toHaveBeenCalledWith({ name: 'Charlie' }); + }); + + test('Supports update flow', async () => { + const getNextId = createSequence(); + let rows = [ + { id: getNextId(), name: 'Alice' }, + { id: getNextId(), name: 'Bob' }, + ]; + const dataProvider = createDataProvider({ + getMany: async () => ({ rows }), + updateOne: vi.fn(async (id, data) => { + const existingRow = rows.find((row) => row.id === id); + invariant(existingRow, `Row with id ${id} not found`); + const updatedRow = { ...existingRow, ...data }; + rows = rows.map((row) => (row.id === updatedRow.id ? updatedRow : row)); + return updatedRow; + }), + fields: { + id: { type: 'number' }, + name: { type: 'string' }, + }, + }); + + const user = userEvent.setup(); + const view = render(); + + await screen.findByText('Alice'); + + expect( + screen + .queryAllByRole('menuitem', { name: 'Edit' }) + .filter((el) => !(el as HTMLButtonElement).disabled), + ).toHaveLength(2); + expect(screen.queryByRole('button', { name: 'Add record' })).toBeFalsy(); + + const editRecordButton = within(getRow(view.baseElement, 1)).getByRole('menuitem', { + name: 'Edit', + }); + + await user.click(editRecordButton); + + const saveButton = await screen.findByRole('menuitem', { name: 'Save' }); + + expect( + screen + .queryAllByRole('menuitem', { name: 'Edit' }) + .filter((el) => !(el as HTMLButtonElement).disabled), + ).toHaveLength(0); + + const nameInput = within(getCell(view.baseElement, 1, 1)).getByRole( + 'textbox', + ); + expect(nameInput.value).toBe('Bob'); + + await user.keyboard('{Backspace>3}Charlie'); + + await user.click(saveButton); + + await waitFor(() => expect(getCell(view.baseElement, 1, 1).textContent).toBe('Charlie')); + + await waitFor(() => { + expect( + screen + .queryAllByRole('menuitem', { name: 'Edit' }) + .filter((el) => !(el as HTMLButtonElement).disabled), + ).toHaveLength(2); + }); + + expect(dataProvider.updateOne).toHaveBeenCalledOnce(); + + const snackbar = await screen.findByRole('alert'); + expect(snackbar.textContent).toMatch('Row updated'); + + const showButton = within(snackbar).getByRole('button', { name: 'Show' }); + await user.click(showButton); + + expect(within(screen.getByRole('rowgroup')).queryAllByRole('row')).toHaveLength(1); + expect(screen.getByText('Charlie')).toBeTruthy(); + }); + + test('Supports delete flow', async () => { + const getNextId = createSequence(); + let rows = [ + { id: getNextId(), name: 'Alice' }, + { id: getNextId(), name: 'Bob' }, + ]; + const dataProvider = createDataProvider({ + getMany: async () => ({ rows }), + deleteOne: vi.fn(async (id) => { + rows = rows.filter((row) => row.id !== id); + }), + fields: { + id: { type: 'number' }, + name: { type: 'string' }, + }, + }); + + const user = userEvent.setup(); + const view = render(); + + await screen.findByText('Alice'); + + expect(screen.queryAllByRole('menuitem', { name: 'Edit' })).toHaveLength(0); + expect(screen.queryByRole('button', { name: 'Add record' })).toBeFalsy(); + + const deleteRecordButton = within(getRow(view.baseElement, 1)).getByRole('menuitem', { + name: 'Delete "2"', + }); + + await screen.findByText('Bob'); + + await user.click(deleteRecordButton); + + await waitFor(() => expect(screen.queryByText('Bob')).toBeFalsy()); + + const snackbar = await screen.findByRole('alert'); + expect(snackbar.textContent).toMatch('Row deleted'); + }); +}); diff --git a/packages/toolpad-core/src/DataGrid/DataGrid.tsx b/packages/toolpad-core/src/DataGrid/DataGrid.tsx new file mode 100644 index 00000000000..e2062d9c333 --- /dev/null +++ b/packages/toolpad-core/src/DataGrid/DataGrid.tsx @@ -0,0 +1,864 @@ +'use client'; + +import { + DataGrid as XDataGrid, + DataGridProps as XDataGridProps, + GridColDef, + GridSlotsComponent, + GridRowId, + GridRowModes, + GridRowModesModel, + GridToolbarColumnsButton, + GridToolbarContainer, + GridToolbarDensitySelector, + GridToolbarExport, + GridToolbarFilterButton, + GridValueGetter, + useGridApiRef, + GridActionsCellItemProps, + GridActionsCellItem, + GridEventListener, + GridPaginationModel, + gridClasses, + GridRowIdGetter, + GridFilterModel, + GridApi, +} from '@mui/x-data-grid'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { Button, CircularProgress, styled, useControlled } from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import AddIcon from '@mui/icons-material/Add'; +import EditIcon from '@mui/icons-material/Edit'; +import SaveIcon from '@mui/icons-material/Save'; +import CloseIcon from '@mui/icons-material/Close'; +import invariant from 'invariant'; +import { useNonNullableContext } from '@toolpad/utils/react'; +import { errorFrom } from '@toolpad/utils/errors'; +import RowsLoadingOverlay from './LoadingOverlay'; +import { ErrorOverlay, LoadingOverlay } from '../shared'; +import { + ResolvedDataProvider, + ResolvedField, + Datum, + useGetMany, + GetManyParams, + FieldOf, + ResolvedFields, + ValidId, + DEFAULT_ID_FIELD, +} from '../DataProvider'; +import InferencingAlert from './InferencingAlert'; +import { + NotificationSnackbar, + DataGridNotification, + SetDataGridNotificationContext, +} from './NotificationSnackbar'; +import { type Filter } from '../DataProvider/filter'; + +const RootContainer = styled('div')({ + display: 'flex', + flexDirection: 'column', +}); + +const GridContainer = styled('div')({ + flex: 1, + position: 'relative', + minHeight: 0, +}); + +const subscribe = () => () => {}; +const getSnapshot = () => false; +const getServerSnapshot = () => true; + +function useSsr() { + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +interface ToolbarCreateButtonContextValue { + slotsProp?: Partial; + onClick: () => void; + visible: boolean; + disabled: boolean; +} + +const ToolbarCreateButtonContext = React.createContext( + null, +); + +const RefetchContext = React.createContext<(() => void) | null>(null); + +const ACTIONS_COLUMN_FIELD = '::toolpad-internal-field::actions::'; + +const DRAFT_ROW_ID = '::toolpad-internal-row::draft::'; + +const DRAFT_ROW_MARKER = Symbol('draft-row'); + +function createDraftRow(): {} { + const row = { [DRAFT_ROW_MARKER]: true }; + return row; +} + +type MaybeDraftRow = R & { [DRAFT_ROW_MARKER]?: true }; + +function isDraftRow(row: MaybeDraftRow): boolean { + return !!row[DRAFT_ROW_MARKER]; +} + +function cleanDraftRow(row: MaybeDraftRow): R { + const cleanedRow = { ...row }; + delete cleanedRow[DRAFT_ROW_MARKER]; + return cleanedRow; +} + +const PlaceholderBorder = styled('div')(({ theme }) => ({ + position: 'absolute', + inset: '0 0 0 0', + borderColor: theme.palette.divider, + borderWidth: 1, + borderStyle: 'solid', + borderRadius: theme.shape.borderRadius, +})); + +type ProcessRowUpdate = XDataGridProps['processRowUpdate']; + +export interface DataGridProps extends Partial> { + /** + * The height of the datagrid in pixels. If left `undefined`, it adopts the height of its parent. + */ + height?: number; + /** + * The data provider to resolve the displayed data. This object must be referentially stable. + */ + dataProvider?: ResolvedDataProvider; +} + +const dateValueGetter = (value: any) => { + if (value === null || value === undefined) { + return undefined; + } + + return new Date(value); +}; + +function wrapWithDateValueGetter( + valueGetter?: GridValueGetter, +): GridValueGetter { + if (!valueGetter) { + return dateValueGetter; + } + + return (oldValue, ...args) => { + const newValue = valueGetter(oldValue, ...args); + return dateValueGetter(newValue); + }; +} + +interface DeleteActionProps { + id: GridRowId; + dataProvider: ResolvedDataProvider; +} + +function DeleteAction({ id, dataProvider }: DeleteActionProps) { + const refetch = useNonNullableContext(RefetchContext); + const [pending, setPending] = React.useState(false); + + const setNotification = useNonNullableContext(SetDataGridNotificationContext); + + const handleDeleteClick = React.useCallback(async () => { + try { + setPending(true); + invariant(dataProvider.deleteOne, 'deleteOne not implemented'); + await dataProvider.deleteOne(id); + setNotification({ + key: `delete-success-${id}`, + message: 'Row deleted', + severity: 'success', + }); + } catch (error) { + setNotification({ + key: `delete-failed-${id}`, + message: 'Failed to delete row', + severity: 'error', + }); + } finally { + setPending(false); + await refetch(); + } + }, [dataProvider, id, refetch, setNotification]); + + return ( + : } + label={`Delete "${id}"`} + onClick={handleDeleteClick} + /> + ); +} + +function inferFields(rows: any[]): ResolvedFields { + const result: any = {}; + + const types = new Map>(); + + const rowsToConsider = 10; + const rowSlice = rows.slice(0, rowsToConsider); + + for (const row of rowSlice) { + for (const key of Object.keys(row)) { + const value = row[key]; + const type = typeof value; + const existingType = types.get(key); + if (existingType) { + existingType.add(type); + } else { + types.set(key, new Set([type])); + } + } + } + + for (const [field, value] of Array.from(types.entries())) { + if (value.size === 1) { + const type = value.size === 1 ? Array.from(value)[0] : 'string'; + result[field] = { type }; + } + } + + return result; +} + +interface GridState { + editedRowId: GridRowId | null; + isProcessingRowUpdate: boolean; + rowModesModel: GridRowModesModel; +} + +type GridAction = + | { kind: 'START_ROW_EDIT'; rowId: GridRowId; fieldToFocus: string | undefined } + | { kind: 'CANCEL_ROW_EDIT' } + | { kind: 'START_ROW_UPDATE' } + | { kind: 'END_ROW_UPDATE' }; + +function gridEditingReducer(state: GridState, action: GridAction): GridState { + switch (action.kind) { + case 'START_ROW_EDIT': + if (state.editedRowId !== null) { + return state; + } + return { + ...state, + editedRowId: action.rowId, + rowModesModel: { + [action.rowId]: { + mode: GridRowModes.Edit, + fieldToFocus: action.fieldToFocus, + }, + }, + }; + case 'CANCEL_ROW_EDIT': + return { + ...state, + editedRowId: null, + rowModesModel: state.editedRowId + ? { + [state.editedRowId]: { + mode: GridRowModes.View, + ignoreModifications: true, + }, + } + : {}, + }; + case 'START_ROW_UPDATE': + return { + ...state, + isProcessingRowUpdate: true, + rowModesModel: {}, + }; + case 'END_ROW_UPDATE': + return { ...state, editedRowId: null, isProcessingRowUpdate: false }; + default: + throw new Error(`Unhandled action: ${JSON.stringify(action)}`); + } +} +/** + * + * Demos: + * + * - [Data Grid](https://mui.com/) + * + * API: + * + * - [DataGrid API](https://mui.com/toolpad/core/api/data-grid) + */ +export function getColumnsFromDataProviderFields( + fields?: ResolvedFields, + baseColumns?: readonly GridColDef[], +): readonly GridColDef[] { + const fieldMap = new Map>(Object.entries(fields ?? {})); + + baseColumns = baseColumns ?? Object.keys(fields ?? {}).map((field) => ({ field })); + + const resolvedColumns = baseColumns.map(function mapper>( + baseColDef: GridColDef, + ): GridColDef { + const dataProviderField: ResolvedField | undefined = fieldMap.get(baseColDef.field); + const colDef: GridColDef = { + type: dataProviderField?.type, + headerName: dataProviderField?.label, + ...baseColDef, + }; + + const valueFormatter = dataProviderField?.valueFormatter; + if (valueFormatter && !colDef.valueFormatter) { + colDef.valueFormatter = (value) => valueFormatter(value, colDef.field as K); + } + + let valueGetter: GridValueGetter | undefined = colDef.valueGetter; + + if (colDef.type === 'date' || colDef.type === 'dateTime') { + valueGetter = wrapWithDateValueGetter(valueGetter); + } + + return { + ...colDef, + valueGetter, + }; + }); + + return resolvedColumns; +} + +function updateColumnsWithDataProviderEditing( + apiRef: React.MutableRefObject, + dataProvider: ResolvedDataProvider, + baseColumns: readonly GridColDef[], + state: GridState, + dispatch: React.Dispatch, +): readonly GridColDef[] { + const idField = dataProvider.idField ?? DEFAULT_ID_FIELD; + const canEdit = !!dataProvider.updateOne; + const canDelete = !!dataProvider.deleteOne; + const canCreate = !!dataProvider.createOne; + const hasEditableRows = canCreate || canEdit; + const hasActionsColumn: boolean = canCreate || canEdit || canDelete; + + const resolvedColumns = baseColumns.map(function mapper>( + baseColDef: GridColDef, + ): GridColDef { + const colDef = { ...baseColDef }; + + if (hasEditableRows && colDef.field !== idField) { + colDef.editable = true; + } + + return colDef; + }); + + if (hasActionsColumn) { + resolvedColumns.push({ + field: ACTIONS_COLUMN_FIELD, + headerName: 'Actions', + type: 'actions', + align: 'center', + resizable: false, + pinnable: false, + width: 100, + getActions: (params) => { + const actions: React.ReactElement[] = []; + const rowId = params.row[idField] as GridRowId; + + const isEditing = state.editedRowId !== null || state.isProcessingRowUpdate; + const isEditedRow = isDraftRow(params.row) || rowId === state.editedRowId; + + if (isEditedRow) { + actions.push( + : } + label="Save" + disabled={state.isProcessingRowUpdate} + onClick={() => { + dispatch({ kind: 'START_ROW_UPDATE' }); + }} + />, + } + label="Cancel" + disabled={state.isProcessingRowUpdate} + onClick={() => { + dispatch({ kind: 'CANCEL_ROW_EDIT' }); + }} + />, + ); + } else { + if (canEdit) { + actions.push( + } + label="Edit" + disabled={isEditing} + onClick={() => { + dispatch({ + kind: 'START_ROW_EDIT', + rowId, + fieldToFocus: getEditedRowFieldToFocus(apiRef, idField), + }); + }} + />, + ); + } + if (canDelete) { + actions.push(); + } + } + return actions; + }, + }); + } + + return resolvedColumns; +} + +function ToolbarGridCreateButton() { + const { visible, slotsProp, onClick, disabled } = useNonNullableContext( + ToolbarCreateButtonContext, + ); + const ButtonComponent = slotsProp?.baseButton ?? Button; + return visible ? ( + } onClick={onClick} disabled={disabled}> + Add record + + ) : null; +} + +function ToolbarGridToolbar() { + return ( + + + + + + + + ); +} + +function diffRows>(original: R, changed: R): Partial { + const keys = new Set([...Object.keys(original), ...Object.keys(changed)]); + const diff: Partial = {}; + Array.from(keys).forEach((key: keyof R) => { + const originalValue = original[key]; + const changedValue = changed[key]; + if (Object.is(originalValue, changedValue)) { + return; + } + if ( + originalValue instanceof Date && + changedValue instanceof Date && + originalValue.getTime() === changedValue.getTime() + ) { + return; + } + (diff as any)[key] = changedValue; + }); + return diff; +} + +function fromGridFilterModel(filterModel: GridFilterModel): Filter { + const filter: Filter = {}; + for (const [field, filterItem] of Object.entries(filterModel.items)) { + for (const [operator, value] of Object.entries(filterItem)) { + filter[field as FieldOf] ??= {}; + (filter[field as FieldOf] as any)[operator] = value; + } + } + return filter; +} + +function getEditedRowFieldToFocus( + apiRef: React.MutableRefObject, + idField: ValidId, +): string | undefined { + const firstNonIdColumn = apiRef.current.getVisibleColumns().find((col) => col.field !== idField); + return firstNonIdColumn?.field; +} + +/** + * + * Demos: + * + * - [Data Grid](https://mui.com/) + * + * API: + * + * - [DataGrid API](https://mui.com/toolpad/core/api/data-grid) + * - inherits [X DataGrid API](https://mui.com/x/api/data-grid/data-grid/) + */ +function DataGrid(props: DataGridProps) { + const { dataProvider, ...restProps1 } = props; + + // TODO: figure out how to stop generating prop types for X Grid properties + // and document with inheritance + const restProps2 = restProps1; + + const { + columns: columnsProp, + processRowUpdate: processRowUpdateProp, + slots: slotsProp, + apiRef: apiRefProp, + initialState: initialStateProp, + autosizeOptions: autosizeOptionsProp, + getRowId: getRowIdProp, + rowModesModel: rowModesModelProp, + filterMode: filterModeProp, + filterModel: filterModelProp, + onFilterModelChange: onFilterModelChangeProp, + paginationMode: paginationModeProp, + paginationModel: paginationModelProp, + onPaginationModelChange: onPaginationModelChangeProp, + ...restProps + } = restProps2; + + const idField = dataProvider?.idField ?? DEFAULT_ID_FIELD; + + const [notification, setNotification] = React.useState(null); + + const gridApiRefOwn = useGridApiRef(); + const apiRef = apiRefProp ?? gridApiRefOwn; + + const [gridPaginationModel, setGridPaginationModel] = useControlled({ + controlled: paginationModelProp, + default: { page: 0, pageSize: 100 }, + name: 'DataGrid', + state: 'paginationModel', + }); + + const onGridPaginationModelChange = React.useCallback< + NonNullable + >( + (paginationModel, details) => { + setGridPaginationModel(paginationModel); + onPaginationModelChangeProp?.(paginationModel, details); + }, + [onPaginationModelChangeProp, setGridPaginationModel], + ); + + const [gridFilterModel, setGridFilterModel] = useControlled({ + controlled: filterModelProp, + default: { items: [] }, + name: 'DataGrid', + state: 'filterModel', + }); + + const onGridFilterModelChange = React.useCallback< + NonNullable + >( + (filterModel, details) => { + setGridFilterModel(filterModel); + onFilterModelChangeProp?.(filterModel, details); + }, + [onFilterModelChangeProp, setGridFilterModel], + ); + + const [editingState, dispatchEditingAction] = React.useReducer(gridEditingReducer, { + editedRowId: null, + isProcessingRowUpdate: false, + rowModesModel: {}, + }); + + const handleCreateRowRequest = React.useCallback(() => { + dispatchEditingAction({ + kind: 'START_ROW_EDIT', + rowId: DRAFT_ROW_ID, + fieldToFocus: getEditedRowFieldToFocus(apiRef, idField), + }); + }, [apiRef, idField]); + + const useGetManyParams = React.useMemo>( + () => ({ + pagination: + paginationModeProp === 'server' + ? { + start: gridPaginationModel.page * gridPaginationModel.pageSize, + pageSize: gridPaginationModel.pageSize, + } + : null, + filter: filterModeProp === 'server' ? fromGridFilterModel(gridFilterModel) : {}, + }), + [ + filterModeProp, + gridFilterModel, + gridPaginationModel.page, + gridPaginationModel.pageSize, + paginationModeProp, + ], + ); + + const { data, loading, error, refetch } = useGetMany(dataProvider ?? null, useGetManyParams); + + const rows = React.useMemo(() => { + const renderedRows = data?.rows ?? []; + if (editingState.editedRowId === DRAFT_ROW_ID) { + return [createDraftRow(), ...renderedRows]; + } + return renderedRows; + }, [data?.rows, editingState.editedRowId]); + + const processRowUpdate = React.useMemo(() => { + if (processRowUpdateProp) { + return processRowUpdateProp; + } + const updateOne = dataProvider?.updateOne; + const createOne = dataProvider?.createOne; + if (!(updateOne || createOne)) { + return undefined; + } + return async (updatedRow: R, originalRow: R): Promise => { + try { + let result: R; + if (isDraftRow(updatedRow)) { + invariant(createOne, 'createOne not implemented'); + + const rowInit = cleanDraftRow(updatedRow); + + try { + result = await createOne(rowInit); + } catch (creationError) { + let message = 'Failed to create row'; + if (process.env.NODE_ENV !== 'production') { + message = `${message}: ${errorFrom(creationError).message}`; + } + setNotification({ key: `create-failed`, message, severity: 'error' }); + return { ...originalRow, _action: 'delete' }; + } + + const createdId = result[idField] as GridRowId; + setNotification({ + key: `create-success-${createdId}`, + message: 'Row created', + severity: 'success', + showId: createdId, + }); + } else { + invariant(updateOne, 'updateOne not implemented'); + + const changedValues = diffRows(originalRow, updatedRow); + if (Object.keys(changedValues).length <= 0) { + return originalRow; + } + + const updatedId = updatedRow[idField] as ValidId; + try { + result = await updateOne(updatedId, changedValues); + } catch (updateError) { + let message = 'Failed to update row'; + if (process.env.NODE_ENV !== 'production') { + message = `${message}: ${errorFrom(updateError).message}`; + } + setNotification({ + key: `update-failed-${updatedId}`, + message, + severity: 'error', + }); + return originalRow; + } + + setNotification({ + key: `update-success-${updatedId}`, + message: 'Row updated', + severity: 'success', + showId: result[idField] as GridRowId, + }); + } + + return result; + } finally { + dispatchEditingAction({ kind: 'END_ROW_UPDATE' }); + refetch(); + } + }; + }, [dataProvider, idField, processRowUpdateProp, refetch]); + + const slots = React.useMemo>( + () => ({ + loadingOverlay: RowsLoadingOverlay, + toolbar: ToolbarGridToolbar, + ...slotsProp, + }), + [slotsProp], + ); + const hasCreateButton = !!dataProvider?.createOne; + + const createButtonContext = React.useMemo(() => { + return { + slotsProp, + onClick: () => { + handleCreateRowRequest(); + }, + visible: hasCreateButton, + disabled: !!editingState.editedRowId || loading, + }; + }, [editingState.editedRowId, handleCreateRowRequest, hasCreateButton, loading, slotsProp]); + + const getRowId = React.useCallback>( + (row: R) => { + if (isDraftRow(row)) { + return DRAFT_ROW_ID; + } + if (getRowIdProp) { + return getRowIdProp(row); + } + return row[idField] as GridRowId; + }, + [getRowIdProp, idField], + ); + + const handleRowEditStart = React.useCallback>( + (params) => { + const rowId = params.row[idField] as GridRowId; + const canEdit = !!dataProvider?.updateOne; + if (params.reason === 'cellDoubleClick' && canEdit) { + dispatchEditingAction({ + kind: 'START_ROW_EDIT', + rowId, + fieldToFocus: getEditedRowFieldToFocus(apiRef, idField), + }); + } + }, + [apiRef, dataProvider?.updateOne, idField], + ); + + // Calculate separately to avoid re-calculating columns on every render + const inferredFields = React.useMemo | null>(() => { + if (!dataProvider) { + // There are no rows coming from the data provider + return null; + } + if (dataProvider.fields) { + // The data provider already provides fields + return null; + } + if (!data?.rows) { + return null; + } + return inferFields(data.rows); + }, [dataProvider, data?.rows]); + + const columns = React.useMemo(() => { + if (!dataProvider) { + return columnsProp ?? []; + } + + let gridColumns = getColumnsFromDataProviderFields( + inferredFields ?? dataProvider.fields, + columnsProp, + ); + + gridColumns = updateColumnsWithDataProviderEditing( + apiRef, + dataProvider, + gridColumns, + editingState, + dispatchEditingAction, + ); + + return gridColumns; + }, [apiRef, columnsProp, dataProvider, editingState, inferredFields]); + + const isSsr = useSsr(); + + return ( + + + + + {inferredFields ? : null} + + + {isSsr ? ( + // At last show something during SSR https://github.com/mui/mui-x/issues/7599 + + + + ) : null} + + {error ? ( + + + + ) : null} + + + + + + + + ); +} + +DataGrid.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The data provider to resolve the displayed data. This object must be referentially stable. + */ + dataProvider: PropTypes.shape({ + createOne: PropTypes.func, + deleteOne: PropTypes.func, + fields: PropTypes.object, + getMany: PropTypes.func.isRequired, + getOne: PropTypes.func, + idField: PropTypes.object, + updateOne: PropTypes.func, + }), + /** + * The height of the datagrid in pixels. If left `undefined`, it adopts the height of its parent. + */ + height: PropTypes.number, +} as any; + +export { DataGrid }; diff --git a/packages/toolpad-core/src/DataGrid/InferencingAlert.tsx b/packages/toolpad-core/src/DataGrid/InferencingAlert.tsx new file mode 100644 index 00000000000..10c09f40fb1 --- /dev/null +++ b/packages/toolpad-core/src/DataGrid/InferencingAlert.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { styled } from '@mui/material'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import HelpIcon from '@mui/icons-material/Help'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { ResolvedFields } from '../DataProvider'; + +const COPY_BUTTON_CLASS = 'copy-button'; + +const Pre = styled('pre')(({ theme }) => ({ + padding: theme.spacing(2), + backgroundColor: theme.palette.background.default, + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + position: 'relative', + [`& .${COPY_BUTTON_CLASS}`]: { + display: 'none', + }, + [`&:hover .${COPY_BUTTON_CLASS}`]: { + display: 'flex', + }, +})); + +const CopyButton = styled(IconButton)(({ theme }) => ({ + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1), +})); + +function CodeSnippet({ children }: { children: string }) { + const handleCopy = () => { + navigator.clipboard.writeText(children); + }; + return ( +
+      {children}
+      
+        
+      
+    
+ ); +} + +const INFERENCING_DOCS_URL = 'https://mui.com/toolpad/core/react-data-grid/#column-inference'; + +interface InferencingDialogProps { + open: boolean; + onClose: () => void; + fields: ResolvedFields; +} + +function InferencingDialog({ open, onClose, fields }: InferencingDialogProps) { + const snippet = React.useMemo(() => { + const serializedFields = Object.entries(fields).map(([field, def]) => { + return ` ${field}: { type: '${def.type}' },`; + }); + const text = `fields: {\n${serializedFields.join('\n')}\n}`; + return text; + }, [fields]); + + return ( + onClose()}> + Inferred fields + + This data grid gets its data from a Toolpad data provider. As the data provider is missing + definitions for fields, this grid inferred a default set of fields. These fields are not + visible in production. To bake them in the data provider, copy the following snippet into + the data provider definition: + {snippet} + Read more about inferencing in the docs. + + + + + + ); +} + +export interface InferencingAlertProps { + fields: ResolvedFields; +} + +/** + * @ignore - internal component. + */ +export default function InferencingAlert({ fields }: InferencingAlertProps) { + const [open, setOpen] = React.useState(false); + + return ( + + setOpen(false)} fields={fields} /> + { + setOpen(true); + }} + > + + + } + > + The fields for this grid were inferred. Don"t use in + production. + + + ); +} diff --git a/packages/toolpad-core/src/DataGrid/LoadingOverlay.tsx b/packages/toolpad-core/src/DataGrid/LoadingOverlay.tsx new file mode 100644 index 00000000000..740e0e4bfec --- /dev/null +++ b/packages/toolpad-core/src/DataGrid/LoadingOverlay.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { LinearProgress, Skeleton, styled } from '@mui/material'; +import { + gridDensityFactorSelector, + gridDimensionsSelector, + gridRowCountSelector, + gridVisibleColumnDefinitionsSelector, + useGridApiContext, + useGridRootProps, + useGridSelector, +} from '@mui/x-data-grid'; + +// Pseudo random number. See https://stackoverflow.com/a/47593316 +function mulberry32(a: number): () => number { + return () => { + /* eslint-disable */ + let t = (a += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + /* eslint-enable */ + }; +} + +function randomBetween(seed: number, min: number, max: number): () => number { + const random = mulberry32(seed); + return () => min + (max - min) * random(); +} + +const SkeletonCell = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + borderBottom: `1px solid ${theme.palette.divider}`, +})); + +/** + * @ignore - internal component. + */ +export default function SkeletonLoadingOverlay() { + const apiRef = useGridApiContext(); + const rootProps = useGridRootProps(); + + const dimensions = useGridSelector(apiRef, gridDimensionsSelector); + const viewportHeight = dimensions?.viewportInnerSize.height ?? 0; + + const factor = useGridSelector(apiRef, gridDensityFactorSelector); + const rowHeight = Math.floor(rootProps.rowHeight * factor); + + const skeletonRowsCount = Math.ceil(viewportHeight / rowHeight); + + const columns = useGridSelector(apiRef, gridVisibleColumnDefinitionsSelector); + + const children = React.useMemo(() => { + // reseed random number generator to create stable lines betwen renders + const random = randomBetween(12345, 25, 75); + const array: React.ReactNode[] = []; + + for (let i = 0; i < skeletonRowsCount; i += 1) { + for (const column of columns) { + const width = Math.round(random()); + array.push( + + + , + ); + } + array.push(); + } + return array; + }, [skeletonRowsCount, columns]); + + const rowsCount = useGridSelector(apiRef, gridRowCountSelector); + + const scrollRef = React.useRef(null); + + React.useEffect(() => { + // The `subscribeEvent` method will automatically unsubscribe in the cleanup function of the `useEffect`. + return apiRef.current.subscribeEvent('scrollPositionChange', (params) => { + if (scrollRef.current) { + scrollRef.current.scrollLeft = params.left; + } + }); + }, [apiRef]); + + return rowsCount > 0 ? ( + + ) : ( +
`${computedWidth}px`) + .join(' ')} 1fr`, + gridAutoRows: `${rowHeight}px`, + overflowX: 'hidden', + }} + > + {children} +
+ ); +} diff --git a/packages/toolpad-core/src/DataGrid/NotificationSnackbar.tsx b/packages/toolpad-core/src/DataGrid/NotificationSnackbar.tsx new file mode 100644 index 00000000000..083bfaa7468 --- /dev/null +++ b/packages/toolpad-core/src/DataGrid/NotificationSnackbar.tsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import { + GridApi, + GridRowId, + gridFilterableColumnDefinitionsSelector, + useGridSelector, +} from '@mui/x-data-grid'; +import useLatest from '@toolpad/utils/hooks/useLatest'; +import { useNonNullableContext } from '@toolpad/utils/react'; +import CloseIcon from '@mui/icons-material/Close'; + +export interface DataGridNotification { + key: string; + severity: 'error' | 'success'; + message: React.ReactNode; + showId?: GridRowId; +} + +export const SetDataGridNotificationContext = React.createContext +> | null>(null); + +export interface NotificationSnackbarProps { + notification: DataGridNotification | null; + apiRef: React.MutableRefObject; + idField: string; +} + +/** + * @ignore - internal component. + */ +export function NotificationSnackbar({ notification, apiRef, idField }: NotificationSnackbarProps) { + const latestNotification = useLatest(notification); + const open = !!notification; + const setNotification = useNonNullableContext(SetDataGridNotificationContext); + + const filterableColumns = useGridSelector(apiRef, gridFilterableColumnDefinitionsSelector); + const operator = React.useMemo(() => { + const operators = filterableColumns.find((column) => column.field === idField)?.filterOperators; + return operators?.find(({ value }) => value === '=' || value === 'equals')?.value; + }, [filterableColumns, idField]); + + return ( + setNotification(null)} + > + setNotification(null)} + sx={{ width: '100%' }} + action={ + + {latestNotification?.showId === undefined || !operator ? null : ( + + )} + setNotification(null)} + > + + + + } + > + {latestNotification?.message} + + + ); +} diff --git a/packages/toolpad-core/src/DataGrid/index.ts b/packages/toolpad-core/src/DataGrid/index.ts new file mode 100644 index 00000000000..8bbb546cef5 --- /dev/null +++ b/packages/toolpad-core/src/DataGrid/index.ts @@ -0,0 +1 @@ +export * from './DataGrid'; diff --git a/packages/toolpad-core/src/DataProvider/DataProvider.test.tsx b/packages/toolpad-core/src/DataProvider/DataProvider.test.tsx new file mode 100644 index 00000000000..88f3ee81211 --- /dev/null +++ b/packages/toolpad-core/src/DataProvider/DataProvider.test.tsx @@ -0,0 +1,24 @@ +/** + * @vitest-environment jsdom + */ + +import * as React from 'react'; +import { describe, test, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { createDataProvider } from './DataProvider'; +import { DataGrid } from '../DataGrid'; + +describe('DataProvider', () => { + test('renders content correctly', async () => { + // placeholder test + const data = createDataProvider({ + async getMany() { + return { rows: [{ id: 1, hello: 'foo' }] }; + }, + }); + + render(); + + expect(screen.getByText('Columns')).toBeTruthy(); + }); +}); diff --git a/packages/toolpad-core/src/DataProvider/DataProvider.tsx b/packages/toolpad-core/src/DataProvider/DataProvider.tsx new file mode 100644 index 00000000000..8f7a0a99001 --- /dev/null +++ b/packages/toolpad-core/src/DataProvider/DataProvider.tsx @@ -0,0 +1,326 @@ +import { keepPreviousData, QueryClient, useMutation, useQuery } from '@tanstack/react-query'; +import PropTypes from 'prop-types'; +import invariant from 'invariant'; +import * as React from 'react'; +import { getObjectKey } from '@toolpad/utils/objectKey'; +import { deepmerge } from '@mui/utils'; +import { type Filter, FilterProvider, getKeyFromFilter, useFilter } from './filter'; + +export { type Filter, useFilter } from './filter'; + +/** + * @ignore - do not document. + * Not a hook nor a component + */ + +export const DEFAULT_ID_FIELD = 'id'; +export type DefaultIdField = typeof DEFAULT_ID_FIELD; + +export type ValidId = string | number; +export type ValidDatum = { + id: ValidId; + [key: string]: string | number | boolean | Date | null; +}; +export type Datum = R; + +export type FieldOf = keyof R & string; + +export type FieldType = 'string' | 'number' | 'boolean' | 'date'; + +export interface ValueFormatter> { + (value: R[K], field: K): string; +} + +export interface FieldDef = FieldOf> { + type?: FieldType; + label?: string; + valueFormatter?: ValueFormatter; +} + +export type FieldDefs = { + [K in Exclude, DefaultIdField>]: FieldDef; +} & { + id?: FieldDef; +}; + +export interface IndexPagination { + start: number; + pageSize: number; +} + +export type Pagination = IndexPagination; + +export interface GetManyParams { + pagination: Pagination | null; + filter: Filter; +} + +export interface GetManyResult { + rows: R[]; + rowCount?: number; +} + +export interface GetManyMethod { + (params: GetManyParams): Promise>; +} + +export interface ResolvedField = FieldOf> { + type: FieldType; + label: string; + valueFormatter?: ValueFormatter; +} + +export interface GetOneMethod { + (id: ValidId): Promise; +} + +export interface CreateOneMethod { + (data: R): Promise; +} + +export interface UpdateOneMethod { + (id: ValidId, data: Partial): Promise; +} + +export interface DeleteOneMethod { + (id: ValidId): Promise; +} + +export interface DataProviderDefinition { + getMany: GetManyMethod; + getOne?: GetOneMethod; + createOne?: CreateOneMethod; + updateOne?: UpdateOneMethod; + deleteOne?: DeleteOneMethod; + idField?: FieldOf; + fields?: FieldDefs; +} + +export type ResolvedFields = { [K in FieldOf]: ResolvedField }; + +export interface ResolvedDataProvider { + getMany: GetManyMethod; + getOne?: GetOneMethod; + createOne?: CreateOneMethod; + updateOne?: UpdateOneMethod; + deleteOne?: DeleteOneMethod; + idField?: FieldOf; + fields?: ResolvedFields; +} + +export function createDataProvider( + input: DataProviderDefinition, +): ResolvedDataProvider { + const result = { ...input } as ResolvedDataProvider; + if (input.fields) { + result.fields = { + [input.idField ?? DEFAULT_ID_FIELD]: { type: 'string' }, + ...Object.fromEntries( + Object.entries(input.fields).map(([k, v]) => [k, { type: 'string', label: k, ...v }]), + ), + } as ResolvedFields; + } + return result; +} + +export interface Query { + loading: boolean; + error: Error | null; + data?: R; + refetch: () => void; +} + +function getKeyFromPagination(pagination: Pagination): string { + return `${pagination.start}-${pagination.pageSize}`; +} + +function getKeyForParams(params: GetManyParams): string[] { + return [ + params.filter ? getKeyFromFilter(params.filter) : '', + params.pagination ? getKeyFromPagination(params.pagination) : '', + ]; +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + networkMode: 'always', + }, + mutations: { + networkMode: 'always', + }, + }, +}); + +export function useGetMany( + dataProvider: ResolvedDataProvider | null, + params?: GetManyParams, +): Query> { + const providerKey = dataProvider ? getObjectKey(dataProvider) : null; + const environmentFilter = useFilter(); + + const resolvedParams: GetManyParams = React.useMemo(() => { + const filter = deepmerge({} as Filter, environmentFilter, params?.filter ?? {}); + return { paginatuon: null, ...params, filter } as GetManyParams; + }, [environmentFilter, params]); + + const { data, error, isLoading, isPlaceholderData, isFetching, refetch } = useQuery( + { + queryKey: ['getMany', providerKey, ...getKeyForParams(resolvedParams)], + queryFn: () => { + invariant(dataProvider?.getMany, 'getMany not implemented'); + return dataProvider.getMany(resolvedParams); + }, + placeholderData: keepPreviousData, + enabled: !!dataProvider, + }, + queryClient, + ); + + const loading = (isFetching && isPlaceholderData) || isLoading; + + return React.useMemo(() => ({ data, error, loading, refetch }), [data, error, loading, refetch]); +} + +export function useGetOne( + dataProvider: ResolvedDataProvider | null, + id: string, +): Query { + const key = dataProvider ? getObjectKey(dataProvider) : null; + const { + data = null, + error, + isPending: loading, + refetch, + } = useQuery( + { + queryKey: ['getOne', key, id], + queryFn: () => { + invariant(dataProvider?.getOne, 'getOne not implemented'); + return dataProvider.getOne(id); + }, + enabled: !!dataProvider, + }, + queryClient, + ); + + return React.useMemo(() => ({ data, error, loading, refetch }), [data, error, loading, refetch]); +} + +export interface Mutation Promise> { + pending: boolean; + error: Error | null; + mutate: F; + reset: () => void; +} + +export function useCreateOne( + dataProvider: ResolvedDataProvider | null, +): Mutation> { + const { mutateAsync, isPending, error, reset } = useMutation( + { + async mutationFn(data: R) { + if (!dataProvider) { + throw new Error('no dataProvider available'); + } + invariant(dataProvider.createOne, 'createOne not implemented'); + return dataProvider.createOne(data); + }, + }, + queryClient, + ); + + return React.useMemo( + () => ({ + pending: isPending, + error, + mutate: mutateAsync, + reset, + }), + [isPending, error, mutateAsync, reset], + ); +} + +export function useUpdateOne( + dataProvider: ResolvedDataProvider | null, +): Mutation> { + const { mutateAsync, error, isPending, reset } = useMutation( + { + async mutationFn([id, data]: Parameters>) { + if (!dataProvider) { + throw new Error('no dataProvider available'); + } + invariant(dataProvider.updateOne, 'updateOne not implemented'); + return dataProvider.updateOne(id, data); + }, + }, + queryClient, + ); + + const mutate = React.useCallback>( + (id, data) => mutateAsync([id, data]), + [mutateAsync], + ); + + return React.useMemo( + () => ({ + pending: isPending, + error, + mutate, + reset, + }), + [isPending, error, mutate, reset], + ); +} + +export function useDeleteOne( + dataProvider: ResolvedDataProvider | null, +): Mutation { + const { mutateAsync, error, isPending, reset } = useMutation( + { + async mutationFn(id: ValidId) { + if (!dataProvider) { + throw new Error('no dataProvider available'); + } + invariant(dataProvider.deleteOne, 'deleteOne not implemented'); + return dataProvider.deleteOne(id); + }, + }, + queryClient, + ); + + return React.useMemo( + () => ({ + pending: isPending, + error, + mutate: mutateAsync, + reset, + }), + [isPending, error, mutateAsync, reset], + ); +} + +export interface DataContextProps { + filter?: Filter; + children?: React.ReactNode; +} + +const defaultFilter: Filter = {}; + +function DataContext(props: DataContextProps) { + const { filter = defaultFilter, children } = props; + + return {children}; +} + +DataContext.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + children: PropTypes.node, + filter: PropTypes.object, +} as any; + +export { DataContext }; diff --git a/packages/toolpad-core/src/DataProvider/filter.tsx b/packages/toolpad-core/src/DataProvider/filter.tsx new file mode 100644 index 00000000000..04c707f1dd4 --- /dev/null +++ b/packages/toolpad-core/src/DataProvider/filter.tsx @@ -0,0 +1,161 @@ +import 'client-only'; +import * as React from 'react'; +import type { Datum } from './DataProvider'; + +export interface FilterOption { + field: K; + operator: string; + value: R[K]; +} + +export function getKeyFromFilter(filter: Filter): string { + return JSON.stringify(filter); +} + +export type Filter = { + [field in keyof R & string]?: { + [operator in string]?: string; + }; +}; + +export interface Codec { + parse: (value: string) => V; + stringify: (value: V) => string; +} + +export type CreateUrlParameterOptions = { + codec?: Codec; + defaultValue?: V; +} & (V extends string ? {} : { codec: Codec }); + +const FilterContext = React.createContext>({}); + +export const FilterProvider = FilterContext.Provider; + +export function useFilter() { + return React.useContext(FilterContext); +} + +type UseUrlQueryParameterStateOptions = { + defaultValue?: V; + codec?: Codec; +} & (V extends string ? {} : { codec: Codec }); + +interface NavigationEvent { + destination: { url: URL }; + navigationType: 'push' | 'replace'; +} + +const navigateEventHandlers = new Set<(event: NavigationEvent) => void>(); + +type HistoryMethod = typeof window.history.pushState; + +if (typeof window !== 'undefined') { + const wrapHistoryMethod = ( + navigationType: 'push' | 'replace', + origMethod: HistoryMethod, + ): HistoryMethod => { + return function historyMethodOverride(this: History, data, title, url?: string | URL | null) { + if (url === null || url === undefined) { + return; + } + const event = { + destination: { url: new URL(url, window.location.href) }, + navigationType, + }; + Promise.resolve().then(() => { + navigateEventHandlers.forEach((handler) => { + handler(event); + }); + }); + origMethod.call(this, data, title, url); + }; + }; + window.history.pushState = wrapHistoryMethod('push', window.history.pushState); + window.history.replaceState = wrapHistoryMethod('replace', window.history.replaceState); +} + +function navigate(url: string, options: { history?: 'push' | 'replace' } = {}) { + const history = options.history ?? 'push'; + if (history === 'push') { + window.history.pushState(null, '', url); + } else { + window.history.replaceState(null, '', url); + } +} + +function addNavigateEventListener(handler: (event: NavigationEvent) => void) { + navigateEventHandlers.add(handler); +} + +function removeNavigateEventListener(handler: (event: NavigationEvent) => void) { + navigateEventHandlers.delete(handler); +} + +function encode(codec: Codec, value: V | null): string | null { + return value === null ? null : codec.stringify(value); +} + +function decode(codec: Codec, value: string | null): V | null { + return value === null ? null : codec.parse(value); +} + +/** + * Works like the React.useState hook, but synchronises the state with a URL query parameter named "name". + * @param name + * @param options + */ +export function useUrlQueryParameterState( + name: string, + ...args: V extends string + ? [UseUrlQueryParameterStateOptions?] + : [UseUrlQueryParameterStateOptions] +): [V | null, (newValue: V | null) => void] { + const [options] = args; + const subscribe = React.useCallback((cb: () => void) => { + const handler = () => { + cb(); + }; + addNavigateEventListener(handler); + return () => { + removeNavigateEventListener(handler); + }; + }, []); + const getSnapshot = React.useCallback(() => { + return new URL(window.location.href).searchParams.get(name); + }, [name]); + const getServerSnapshot = React.useCallback(() => null, []); + const rawValue = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const setValue = React.useCallback( + (value: V | null) => { + const url = new URL(window.location.href); + const stringValue = options?.codec ? encode(options.codec, value) : (value as string); + + if (stringValue === null) { + url.searchParams.delete(name); + } else { + const defaultValue = options?.defaultValue ?? null; + const stringDefaultValue = options?.codec + ? encode(options.codec, defaultValue) + : defaultValue; + + if (stringValue === stringDefaultValue) { + url.searchParams.delete(name); + } else { + url.searchParams.set(name, stringValue); + } + } + + navigate(url.toString(), { history: 'replace' }); + }, + [name, options?.codec, options?.defaultValue], + ); + const value = React.useMemo( + () => + options?.codec && typeof rawValue === 'string' + ? decode(options.codec, rawValue) + : (rawValue as V), + [options?.codec, rawValue], + ); + return [value ?? options?.defaultValue ?? null, setValue]; +} diff --git a/packages/toolpad-core/src/DataProvider/index.ts b/packages/toolpad-core/src/DataProvider/index.ts new file mode 100644 index 00000000000..57add511165 --- /dev/null +++ b/packages/toolpad-core/src/DataProvider/index.ts @@ -0,0 +1 @@ +export * from './DataProvider'; diff --git a/packages/toolpad-core/src/LineChart/LineChart.test.tsx b/packages/toolpad-core/src/LineChart/LineChart.test.tsx new file mode 100644 index 00000000000..53e128449c3 --- /dev/null +++ b/packages/toolpad-core/src/LineChart/LineChart.test.tsx @@ -0,0 +1,45 @@ +/** + * @vitest-environment jsdom + */ + +import * as React from 'react'; +import { describe, test, expect, afterEach } from 'vitest'; +import { render, waitFor, screen } from '@testing-library/react'; +import sinon from 'sinon'; +import { LineChart as XLineChart } from '@mui/x-charts'; +import describeConformance from '@toolpad/utils/describeConformance'; +import { LineChart } from './LineChart'; + +export const stubMatchMedia = (matches = true) => + sinon.stub().returns({ + matches, + addEventListener: () => {}, + removeEventListener: () => {}, + }); + +describe('LineChart', () => { + const originalMatchMedia = window.matchMedia; + + afterEach(() => { + window.matchMedia = originalMatchMedia; + }); + + describeConformance(, () => ({ + inheritComponent: XLineChart, + refInstanceof: window.HTMLDivElement, + skip: ['themeDefaultProps'], + })); + + test('renders content correctly', async () => { + window.matchMedia = stubMatchMedia(false); + // placeholder test + render(); + + await waitFor( + () => { + expect(screen.getByText('No data to display')).toBeTruthy(); + }, + { timeout: 1000 }, + ); + }); +}); diff --git a/packages/toolpad-core/src/LineChart/LineChart.tsx b/packages/toolpad-core/src/LineChart/LineChart.tsx new file mode 100644 index 00000000000..c885ac965d7 --- /dev/null +++ b/packages/toolpad-core/src/LineChart/LineChart.tsx @@ -0,0 +1,170 @@ +'use client'; + +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { + LineChart as XLineChart, + LineChartProps as XLineChartProps, + blueberryTwilightPalette, + LineSeriesType, +} from '@mui/x-charts'; +import { styled, useTheme } from '@mui/material'; +import { Datum, ResolvedDataProvider, useGetMany } from '../DataProvider'; +import { ErrorOverlay, LoadingOverlay } from '../shared'; + +const LineChartRoot = styled('div')({ + position: 'relative', +}); + +export type LineChartSeries = XLineChartProps['series']; + +export interface LineChartProps extends Partial { + /** + * The data provider to resolve the displayed data. This object must be referentially stable. + */ + dataProvider?: ResolvedDataProvider; +} + +type ChartsXAxis = NonNullable[number]; + +/** + * + * Demos: + * + * - [Line Chart](https://mui.com/toolpad/core/react-line-chart/) + * + * API: + * + * - [LineChart API](https://mui.com/toolpad/core/api/line-chart) + * - inherits [X LineChart API](https://mui.com/x/api/charts/line-chart/) + */ +const LineChart = React.forwardRef(function LineChart( + props: LineChartProps, + ref: React.Ref, +) { + const { dataProvider, ...restProps1 } = props; + // TODO: figure out how to stop generating prop types for X Grid properties + // and document with inheritance + const restProps2 = restProps1; + const { xAxis, series, ...rest } = restProps2; + const theme = useTheme(); + const { data, loading, error } = useGetMany(dataProvider ?? null); + const resolvedXAxis = React.useMemo(() => { + if (!xAxis || xAxis.length <= 0) { + return [{ dataKey: 'id' }]; + } + return xAxis.map((axis) => { + let defaults: Partial = {}; + if (axis.dataKey) { + const field = dataProvider?.fields?.[axis.dataKey]; + if (field) { + defaults = { + label: field.label, + }; + if (field.type === 'date') { + defaults.scaleType = 'time'; + } + } + } + return { ...defaults, ...axis }; + }); + }, [dataProvider?.fields, xAxis]); + + const resolvedSeries = React.useMemo(() => { + const idField = dataProvider?.idField ?? 'id'; + const resolvedSeriesProp: LineChartSeries = + series || + Object.keys(dataProvider?.fields ?? {}) + .filter( + (dataKey) => dataKey !== idField && dataProvider?.fields?.[dataKey]?.type === 'number', + ) + .map((dataKey) => ({ dataKey })); + + const colorSchemeIndices = new Map( + Object.keys(dataProvider?.fields ?? {}).map((name, i) => [name, i]), + ); + + const colors = blueberryTwilightPalette(theme.palette.mode); + + return resolvedSeriesProp.map((s) => { + let defaults: Partial = {}; + if (s.dataKey) { + const name = s.dataKey; + const field = dataProvider?.fields?.[name]; + if (field) { + const colorSchemeIndex = colorSchemeIndices.get(name) ?? 0; + defaults = { + label: field.label, + color: colors[colorSchemeIndex % colors.length], + }; + const valueFormatter = field.valueFormatter; + if (valueFormatter) { + defaults.valueFormatter = (value: any) => valueFormatter(value, name); + } + } + } + return { ...defaults, ...s }; + }); + }, [dataProvider?.idField, dataProvider?.fields, series, theme.palette.mode]); + + const dataSet = React.useMemo(() => { + const resolvedRows = data?.rows ?? []; + return resolvedRows.map((row) => { + const result: NonNullable[number] = {}; + for (const [name, field] of Object.entries(dataProvider?.fields ?? {})) { + let value = row[name]; + if (field.type === 'date' && (typeof value === 'string' || typeof value === 'number')) { + value = new Date(value); + } + + if (typeof value === 'string' || typeof value === 'number' || value instanceof Date) { + result[name] = value; + } + } + return result; + }); + }, [data?.rows, dataProvider?.fields]); + + return ( + +
+ +
+ {loading ? : null} + {error ? : null} +
+ ); +}); + +LineChart.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * The data provider to resolve the displayed data. This object must be referentially stable. + */ + dataProvider: PropTypes.shape({ + createOne: PropTypes.func, + deleteOne: PropTypes.func, + fields: PropTypes.object, + getMany: PropTypes.func.isRequired, + getOne: PropTypes.func, + idField: PropTypes.object, + updateOne: PropTypes.func, + }), +} as any; + +export { LineChart }; diff --git a/packages/toolpad-core/src/LineChart/index.ts b/packages/toolpad-core/src/LineChart/index.ts new file mode 100644 index 00000000000..d2dbedfcecb --- /dev/null +++ b/packages/toolpad-core/src/LineChart/index.ts @@ -0,0 +1 @@ +export * from './LineChart'; diff --git a/packages/toolpad-core/src/index.ts b/packages/toolpad-core/src/index.ts index de4af47c8a6..024dbe86f59 100644 --- a/packages/toolpad-core/src/index.ts +++ b/packages/toolpad-core/src/index.ts @@ -2,6 +2,12 @@ export * from './AppProvider'; export * from './DashboardLayout'; +export * from './DataProvider'; + +export * from './DataGrid'; + +export * from './LineChart'; + export * from './useDialogs'; export * from './useNotifications'; diff --git a/packages/toolpad-core/src/shared/index.tsx b/packages/toolpad-core/src/shared/index.tsx new file mode 100644 index 00000000000..3aeed599eb3 --- /dev/null +++ b/packages/toolpad-core/src/shared/index.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { CircularProgress, Typography, styled } from '@mui/material'; +import ErrorIcon from '@mui/icons-material/Error'; + +const OverlayRoot = styled('div')(({ theme }) => ({ + position: 'absolute', + inset: '0 0 0 0', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(2), +})); + +export interface ErrorOverlayProps { + error?: unknown; +} + +export function ErrorOverlay({ error }: ErrorOverlayProps) { + return ( + + + Error + + {(error as any)?.message ?? 'Unknown error'} + + ); +} + +export function LoadingOverlay() { + return ( + + + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fc344cb61b..9fbba98ddb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -591,6 +591,15 @@ importers: '@mui/utils': specifier: 5.16.4 version: 5.16.4(@types/react@18.3.3)(react@18.3.1) + '@mui/x-charts': + specifier: 7.10.0 + version: 7.10.0(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@mui/material@5.16.4(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/x-data-grid': + specifier: 7.10.0 + version: 7.10.0(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@mui/material@5.16.4(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: 5.49.0 + version: 5.49.0(react@18.3.1) '@toolpad/utils': specifier: workspace:* version: link:../toolpad-utils @@ -2086,7 +2095,7 @@ packages: '@docsearch/react@3.6.0': resolution: {integrity: sha512-HUFut4ztcVNmqy9gp/wxNbC7pTOHhgVVkHVGCACTuLhUKUhKAF9KYHJtMiLUJxEqiFLQiuri1fWF8zqwM/cu1w==} peerDependencies: - '@types/react': 18.3.3 + '@types/react': '>= 16.8.0 < 19.0.0' react: '>= 16.8.0 < 19.0.0' react-dom: '>= 16.8.0 < 19.0.0' search-insights: '>= 1 < 3' @@ -3619,8 +3628,8 @@ packages: engines: {node: '>=18'} peerDependencies: '@testing-library/dom': ^10.0.0 - '@types/react': 18.3.3 - '@types/react-dom': 18.3.0 + '@types/react': ^18.0.0 + '@types/react-dom': ^18.0.0 react: ^18.0.0 react-dom: ^18.0.0 peerDependenciesMeta: