diff --git a/.eslintrc.js b/.eslintrc.js index d1c3c740c9b..7f711070d87 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -96,6 +96,7 @@ module.exports = { files: ['examples/**/*'], rules: { 'no-console': 'off', + 'no-underscore-dangle': 'off', }, }, { diff --git a/docs/data/toolpad/concepts/data-providers.md b/docs/data/toolpad/concepts/data-providers.md index 350b173e860..9dbd21ebc68 100644 --- a/docs/data/toolpad/concepts/data-providers.md +++ b/docs/data/toolpad/concepts/data-providers.md @@ -106,13 +106,25 @@ This feature isn't implemented yet. 👍 Upvote [issue #2888](https://github.com/mui/mui-toolpad/issues/2888) if you want to see it land faster. ::: -## Deleting rows 🚧 +## Deleting rows -:::warning -This feature isn't implemented yet. +The data provider can be extended to automatically support row deletion. To enable this, you'll have to add a `deleteRecord` method to the data provider interface that accepts the `id` of the row that is to be deleted. -👍 Upvote [issue #2889](https://github.com/mui/mui-toolpad/issues/2889) if you want to see it land faster. -::: +```tsx +export default createDataProvider({ + async getRecords({ paginationModel: { start = 0, pageSize } }) { + return db.query(`SELECT * FROM users`); + }, + + async deleteRecord(id) { + await db.query(`DELETE FROM users WHERE id = ?`, [id]); + }, +}); +``` + +When a data provider contains a `deleteRecord` method, each row will have a delete button. When the user clicks that delete button, the delete method will be called with the id of that row and after successful deletion, the data will be reloaded. + +{{"component": "modules/components/DocsImage.tsx", "src": "/static/toolpad/docs/concepts/connecting-to-data/data-providers-delete.png", "alt": "Data provider delete", "caption": "Delete action in data provider" }} ## API diff --git a/docs/public/static/toolpad/docs/concepts/connecting-to-data/data-providers-delete.png b/docs/public/static/toolpad/docs/concepts/connecting-to-data/data-providers-delete.png new file mode 100644 index 00000000000..cd423bd43ca Binary files /dev/null and b/docs/public/static/toolpad/docs/concepts/connecting-to-data/data-providers-delete.png differ diff --git a/examples/with-prisma-data-provider/toolpad/pages/crud/page.yml b/examples/with-prisma-data-provider/toolpad/pages/crud/page.yml new file mode 100644 index 00000000000..5dcf68c3c85 --- /dev/null +++ b/examples/with-prisma-data-provider/toolpad/pages/crud/page.yml @@ -0,0 +1,35 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/mui/mui-toolpad/v0.1.39/docs/schemas/v1/definitions.json#properties/Page + +apiVersion: v1 +kind: page +spec: + title: Prisma data source CRUD example + content: + - component: Text + name: text + layout: + columnSize: 1 + horizontalAlign: start + props: + variant: h2 + value: Users + mode: text + - component: DataGrid + name: usersDataGrid + layout: + columnSize: 1 + props: + rows: null + columns: + - field: id + type: number + width: 77 + - field: name + type: string + width: 120 + - field: email + type: string + width: 335 + height: 336 + rowsSource: dataProvider + dataProviderId: crud.ts:default diff --git a/examples/with-prisma-data-provider/toolpad/pages/cursorBased/page.yml b/examples/with-prisma-data-provider/toolpad/pages/cursorBased/page.yml index 1d8b65457fe..2fbd2f9782e 100644 --- a/examples/with-prisma-data-provider/toolpad/pages/cursorBased/page.yml +++ b/examples/with-prisma-data-provider/toolpad/pages/cursorBased/page.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/mui/mui-toolpad/v0.1.39/docs/schemas/v1/definitions.json#properties/Page + apiVersion: v1 kind: page spec: diff --git a/examples/with-prisma-data-provider/toolpad/pages/indexBased/page.yml b/examples/with-prisma-data-provider/toolpad/pages/indexBased/page.yml index 584b58120a2..03d439f6758 100644 --- a/examples/with-prisma-data-provider/toolpad/pages/indexBased/page.yml +++ b/examples/with-prisma-data-provider/toolpad/pages/indexBased/page.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/mui/mui-toolpad/v0.1.39/docs/schemas/v1/definitions.json#properties/Page + apiVersion: v1 kind: page spec: diff --git a/examples/with-prisma-data-provider/toolpad/prisma.ts b/examples/with-prisma-data-provider/toolpad/prisma.ts new file mode 100644 index 00000000000..5d171c2fb92 --- /dev/null +++ b/examples/with-prisma-data-provider/toolpad/prisma.ts @@ -0,0 +1,7 @@ +import { PrismaClient } from '@prisma/client'; + +// Reuse existing PrismaClient instance during development +(globalThis as any).__prisma ??= new PrismaClient(); +const prisma: PrismaClient = (globalThis as any).__prisma; + +export default prisma; diff --git a/examples/with-prisma-data-provider/toolpad/resources/crud.ts b/examples/with-prisma-data-provider/toolpad/resources/crud.ts new file mode 100644 index 00000000000..0e54173b8af --- /dev/null +++ b/examples/with-prisma-data-provider/toolpad/resources/crud.ts @@ -0,0 +1,29 @@ +/** + * Toolpad data provider file. + * See: https://mui.com/toolpad/concepts/data-providers/ + */ + +import { createDataProvider } from '@mui/toolpad/server'; +import prisma from '../prisma'; + +export default createDataProvider({ + async getRecords({ paginationModel: { start, pageSize } }) { + const [userRecords, totalCount] = await Promise.all([ + prisma.user.findMany({ + skip: start, + take: pageSize, + }), + prisma.user.count(), + ]); + return { + records: userRecords, + totalCount, + }; + }, + + async deleteRecord(id) { + await prisma.user.delete({ + where: { id: Number(id) }, + }); + }, +}); diff --git a/examples/with-prisma-data-provider/toolpad/resources/usersByCursor.ts b/examples/with-prisma-data-provider/toolpad/resources/usersByCursor.ts index 13046692dc2..501b2ee2ccf 100644 --- a/examples/with-prisma-data-provider/toolpad/resources/usersByCursor.ts +++ b/examples/with-prisma-data-provider/toolpad/resources/usersByCursor.ts @@ -1,18 +1,14 @@ -/* eslint-disable no-underscore-dangle */ /** * Toolpad data provider file. * See: https://mui.com/toolpad/concepts/data-providers/ */ import { createDataProvider } from '@mui/toolpad/server'; -import { PrismaClient } from '@prisma/client'; - -// Reuse existing PrismaClient instance during development -(globalThis as any).__prisma ??= new PrismaClient(); -const prisma: PrismaClient = (globalThis as any).__prisma; +import prisma from '../prisma'; export default createDataProvider({ paginationMode: 'cursor', + async getRecords({ paginationModel: { cursor, pageSize } }) { const userRecords = await prisma.user.findMany({ cursor: cursor diff --git a/examples/with-prisma-data-provider/toolpad/resources/usersByIndex.ts b/examples/with-prisma-data-provider/toolpad/resources/usersByIndex.ts index bce3fc670af..faedbb55833 100644 --- a/examples/with-prisma-data-provider/toolpad/resources/usersByIndex.ts +++ b/examples/with-prisma-data-provider/toolpad/resources/usersByIndex.ts @@ -1,15 +1,10 @@ -/* eslint-disable no-underscore-dangle */ /** * Toolpad data provider file. * See: https://mui.com/toolpad/concepts/data-providers/ */ import { createDataProvider } from '@mui/toolpad/server'; -import { PrismaClient } from '@prisma/client'; - -// Reuse existing PrismaClient instance during development -(globalThis as any).__prisma ??= new PrismaClient(); -const prisma: PrismaClient = (globalThis as any).__prisma; +import prisma from '../prisma'; export default createDataProvider({ async getRecords({ paginationModel: { start, pageSize } }) { diff --git a/examples/with-prisma/toolpad/resources/functions.ts b/examples/with-prisma/toolpad/resources/functions.ts index 65758bd3883..b73046201a0 100644 --- a/examples/with-prisma/toolpad/resources/functions.ts +++ b/examples/with-prisma/toolpad/resources/functions.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-underscore-dangle */ import { PrismaClient, Prisma } from '@prisma/client'; // Reuse existing PrismaClient instance during development diff --git a/packages/toolpad-app/src/runtime/useDataProvider.ts b/packages/toolpad-app/src/runtime/useDataProvider.ts index 89e1a1c9b09..5fbf8531e4e 100644 --- a/packages/toolpad-app/src/runtime/useDataProvider.ts +++ b/packages/toolpad-app/src/runtime/useDataProvider.ts @@ -3,6 +3,7 @@ import { UseDataProviderHook } from '@mui/toolpad-core/runtime'; import { useQuery } from '@tanstack/react-query'; import invariant from 'invariant'; import { ToolpadDataProviderBase } from '@mui/toolpad-core'; +import { GridRowId } from '@mui/x-data-grid'; import api from './api'; export const useDataProvider: UseDataProviderHook = (id) => { @@ -31,6 +32,13 @@ export const useDataProvider: UseDataProviderHook = (id) => { const [filePath, name] = id.split(':'); return api.methods.getDataProviderRecords(filePath, name, ...args); }, + deleteRecord: introspection.hasDeleteRecord + ? async (recordId: GridRowId) => { + invariant(id, 'id is required'); + const [filePath, name] = id.split(':'); + return api.methods.deleteDataProviderRecord(filePath, name, recordId); + } + : undefined, }; }, [id, introspection]); diff --git a/packages/toolpad-app/src/server/FunctionsManager.ts b/packages/toolpad-app/src/server/FunctionsManager.ts index b7fb6a3c316..08bd6e6ab73 100644 --- a/packages/toolpad-app/src/server/FunctionsManager.ts +++ b/packages/toolpad-app/src/server/FunctionsManager.ts @@ -18,6 +18,7 @@ import { errorFrom } from '@mui/toolpad-utils/errors'; import { ToolpadDataProviderIntrospection } from '@mui/toolpad-core/runtime'; import * as url from 'node:url'; import invariant from 'invariant'; +import { GridRowId } from '@mui/x-data-grid'; import EnvManager from './EnvManager'; import { ProjectEvents, ToolpadProjectOptions } from '../types'; import { createWorker as createDevWorker } from './functionsDevWorker'; @@ -408,4 +409,14 @@ export default class FunctionsManager { invariant(this.devWorker, 'devWorker must be initialized'); return this.devWorker.getDataProviderRecords(fullPath, exportName, params); } + + async deleteDataProviderRecord( + fileName: string, + exportName: string, + id: GridRowId, + ): Promise { + const fullPath = await this.getBuiltOutputFilePath(fileName); + invariant(this.devWorker, 'devWorker must be initialized'); + return this.devWorker.deleteDataProviderRecord(fullPath, exportName, id); + } } diff --git a/packages/toolpad-app/src/server/functionsDevWorker.ts b/packages/toolpad-app/src/server/functionsDevWorker.ts index ad2a3fab6fc..2372f0a0b52 100644 --- a/packages/toolpad-app/src/server/functionsDevWorker.ts +++ b/packages/toolpad-app/src/server/functionsDevWorker.ts @@ -19,6 +19,8 @@ import { TOOLPAD_DATA_PROVIDER_MARKER, ToolpadDataProvider } from '@mui/toolpad- import * as z from 'zod'; import { fromZodError } from 'zod-validation-error'; import { GetRecordsParams, GetRecordsResult, PaginationMode } from '@mui/toolpad-core'; +import invariant from 'invariant'; +import { GridRowId } from '@mui/x-data-grid'; import.meta.url ??= url.pathToFileURL(__filename).toString(); const currentDirectory = url.fileURLToPath(new URL('.', import.meta.url)); @@ -123,6 +125,9 @@ async function execute(msg: ExecuteParams): Promise { const dataProviderSchema: z.ZodType> = z.object({ paginationMode: z.enum(['index', 'cursor']).optional().default('index'), getRecords: z.function(z.tuple([z.any()]), z.any()), + deleteRecord: z.function(z.tuple([z.any()]), z.any()).optional(), + updateRecord: z.function(z.tuple([z.any()]), z.any()).optional(), + createRecord: z.function(z.tuple([z.any()]), z.any()).optional(), [TOOLPAD_DATA_PROVIDER_MARKER]: z.literal(true), }); @@ -154,6 +159,7 @@ async function introspectDataProvider( return { paginationMode: dataProvider.paginationMode, + hasDeleteRecord: !!dataProvider.deleteRecord, }; } @@ -167,10 +173,21 @@ async function getDataProviderRecords( return dataProvider.getRecords(params); } +async function deleteDataProviderRecord( + filePath: string, + name: string, + id: GridRowId, +): Promise { + const dataProvider = await loadDataProvider(filePath, name); + invariant(dataProvider.deleteRecord, 'DataProvider does not support deleteRecord'); + return dataProvider.deleteRecord(id); +} + type WorkerRpcServer = { execute: typeof execute; introspectDataProvider: typeof introspectDataProvider; getDataProviderRecords: typeof getDataProviderRecords; + deleteDataProviderRecord: typeof deleteDataProviderRecord; }; if (!isMainThread && parentPort) { @@ -178,6 +195,7 @@ if (!isMainThread && parentPort) { execute, introspectDataProvider, getDataProviderRecords, + deleteDataProviderRecord, }); } @@ -233,6 +251,10 @@ export function createWorker(env: Record) { ): Promise> { return client.getDataProviderRecords(filePath, name, params); }, + + async deleteDataProviderRecord(filePath: string, name: string, id: GridRowId): Promise { + return client.deleteDataProviderRecord(filePath, name, id); + }, }; } diff --git a/packages/toolpad-app/src/server/runtimeRpcServer.ts b/packages/toolpad-app/src/server/runtimeRpcServer.ts index ec1a56b8771..08d83f6a74f 100644 --- a/packages/toolpad-app/src/server/runtimeRpcServer.ts +++ b/packages/toolpad-app/src/server/runtimeRpcServer.ts @@ -14,6 +14,11 @@ export function createRpcServer(project: ToolpadProject) { return project.functionsManager.getDataProviderRecords(...params); }, ), + deleteDataProviderRecord: createMethod< + typeof project.functionsManager.deleteDataProviderRecord + >(({ params }) => { + return project.functionsManager.deleteDataProviderRecord(...params); + }), execQuery: createMethod(({ params }) => { return project.dataManager.execQuery(...params); }), diff --git a/packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx b/packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx index e3264fe474a..70118a22eb4 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx @@ -37,9 +37,9 @@ import { createProvidedContext } from '@mui/toolpad-utils/react'; import { TabContext, TabList } from '@mui/lab'; import useDebounced from '@mui/toolpad-utils/hooks/useDebounced'; import { errorFrom } from '@mui/toolpad-utils/errors'; +import useLatest from '@mui/toolpad-utils/hooks/useLatest'; import { JsExpressionEditor } from './PageEditor/JsExpressionEditor'; import JsonView from '../../components/JsonView'; -import useLatest from '../../utils/useLatest'; import { useEvaluateLiveBinding } from './useEvaluateLiveBinding'; import GlobalScopeExplorer from './GlobalScopeExplorer'; import { WithControlledProp, Maybe } from '../../utils/types'; diff --git a/packages/toolpad-app/src/toolpad/AppEditor/NodeMenu.tsx b/packages/toolpad-app/src/toolpad/AppEditor/NodeMenu.tsx index a1428579e52..733664167ac 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/NodeMenu.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/NodeMenu.tsx @@ -4,9 +4,9 @@ import DeleteIcon from '@mui/icons-material/Delete'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ModeEditIcon from '@mui/icons-material/ModeEdit'; import { NodeId } from '@mui/toolpad-core'; +import useLatest from '@mui/toolpad-utils/hooks/useLatest'; import * as appDom from '../../appDom'; import { useAppState } from '../AppState'; -import useLatest from '../../utils/useLatest'; import { ConfirmDialog } from '../../components/SystemDialogs'; import useMenu from '../../utils/useMenu'; diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/QueryEditor/QueryEditorDialog.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/QueryEditor/QueryEditorDialog.tsx index f361aa84e66..fa3587e7418 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/QueryEditor/QueryEditorDialog.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/QueryEditor/QueryEditorDialog.tsx @@ -17,7 +17,7 @@ import { BindableAttrValue } from '@mui/toolpad-core'; import { useBrowserJsRuntime } from '@mui/toolpad-core/jsBrowserRuntime'; import invariant from 'invariant'; import useEventCallback from '@mui/utils/useEventCallback'; -import useLatest from '../../../../utils/useLatest'; +import useLatest from '@mui/toolpad-utils/hooks/useLatest'; import { usePageEditorState } from '../PageEditorProvider'; import * as appDom from '../../../../appDom'; import dataSources from '../../../../toolpadDataSources/client'; diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PagesExplorer/CreateCodeComponentNodeDialog.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PagesExplorer/CreateCodeComponentNodeDialog.tsx index 9c1d7885e27..c258bca11a9 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PagesExplorer/CreateCodeComponentNodeDialog.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PagesExplorer/CreateCodeComponentNodeDialog.tsx @@ -13,12 +13,12 @@ import * as React from 'react'; import invariant from 'invariant'; import CloseIcon from '@mui/icons-material/Close'; import useEventCallback from '@mui/utils/useEventCallback'; +import useLatest from '@mui/toolpad-utils/hooks/useLatest'; import * as appDom from '../../../appDom'; import { useAppState } from '../../AppState'; import DialogForm from '../../../components/DialogForm'; import { useNodeNameValidation } from './validation'; import { useProjectApi } from '../../../projectApi'; -import useLatest from '../../../utils/useLatest'; import OpenCodeEditorButton from '../../OpenCodeEditor'; function handleInputFocus(event: React.FocusEvent) { diff --git a/packages/toolpad-components/src/DataGrid.tsx b/packages/toolpad-components/src/DataGrid.tsx index 78d01d3ddd9..f5dd74c7f55 100644 --- a/packages/toolpad-components/src/DataGrid.tsx +++ b/packages/toolpad-components/src/DataGrid.tsx @@ -20,6 +20,8 @@ import { getGridDefaultColumnTypes, GridColTypeDef, GridPaginationModel, + GridActionsColDef, + GridRowId, } from '@mui/x-data-grid-pro'; import { Unstable_LicenseInfoProvider as LicenseInfoProvider, @@ -32,6 +34,8 @@ import { UseDataProviderContext, CursorPaginationModel, IndexPaginationModel, + ToolpadDataProviderBase, + PaginationMode, } from '@mui/toolpad-core'; import { Box, @@ -43,7 +47,13 @@ import { Typography, Tooltip, Popover, + IconButton, + CircularProgress, + Alert, + Collapse, } from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import CloseIcon from '@mui/icons-material/Close'; import { getObjectKey } from '@mui/toolpad-utils/objectKey'; import { errorFrom } from '@mui/toolpad-utils/errors'; import { hasImageExtension } from '@mui/toolpad-utils/path'; @@ -53,6 +63,7 @@ import { useQuery, keepPreviousData } from '@tanstack/react-query'; import invariant from 'invariant'; import { NumberFormat, createFormat as createNumberFormat } from '@mui/toolpad-core/numberFormat'; import { DateFormat, createFormat as createDateFormat } from '@mui/toolpad-core/dateFormat'; +import useLatest from '@mui/toolpad-utils/hooks/useLatest'; import createBuiltin from './createBuiltin'; import { SX_PROP_HELPER_TEXT } from './constants'; import ErrorOverlay from './components/ErrorOverlay'; @@ -65,6 +76,8 @@ const LICENSE_INFO: MuiLicenseInfo = { const DEFAULT_COLUMN_TYPES = getGridDefaultColumnTypes(); +const SetActionErrorContext = React.createContext<((error: Error) => void) | undefined>(undefined); + // Pseudo random number. See https://stackoverflow.com/a/47593316 function mulberry32(a: number): () => number { return () => { @@ -474,8 +487,41 @@ interface ToolpadDataGridProps extends Omit void; } +interface DeleteActionProps { + id: GridRowId; + dataProvider: ToolpadDataProviderBase; + refetch: () => unknown; +} + +function DeleteAction({ id, dataProvider, refetch }: DeleteActionProps) { + const [loading, setLoading] = React.useState(false); + + const setActionError = React.useContext(SetActionErrorContext); + invariant(setActionError, 'setActionError must be defined'); + + const handleDeleteClick = React.useCallback(async () => { + invariant(dataProvider.deleteRecord, 'dataProvider must be defined'); + setLoading(true); + try { + await dataProvider.deleteRecord(id); + await refetch(); + } catch (error) { + setActionError(errorFrom(error)); + } finally { + setLoading(false); + } + }, [dataProvider, id, refetch, setActionError]); + + return ( + + {loading ? : } + + ); +} + interface DataProviderDataGridProps extends Partial { error?: unknown; + getActions?: GridActionsColDef['getActions']; } function useDataProviderDataGridProps( @@ -493,7 +539,7 @@ function useDataProviderDataGridProps( const mapPageToNextCursor = React.useRef(new Map()); - const { data, isFetching, isPlaceholderData, isLoading, error } = useQuery({ + const { data, isFetching, isPlaceholderData, isLoading, error, refetch } = useQuery({ enabled: !!dataProvider, queryKey: ['toolpadDataProvider', dataProviderId, page, pageSize], placeholderData: keepPreviousData, @@ -550,6 +596,23 @@ function useDataProviderDataGridProps( (data?.hasNextPage ? (paginationModel.page + 1) * paginationModel.pageSize + 1 : undefined) ?? 0; + const getActions = React.useMemo(() => { + if (!dataProvider?.deleteRecord) { + return undefined; + } + + return ({ id }) => { + const result = []; + + if (dataProvider?.deleteRecord) { + result.push( + , + ); + } + return result; + }; + }, [dataProvider, refetch]); + if (!dataProvider) { return {}; } @@ -570,6 +633,7 @@ function useDataProviderDataGridProps( }, rows: data?.records ?? [], error, + getActions, }; } @@ -594,9 +658,11 @@ const DataGridComponent = React.forwardRef(function DataGridComponent( }: ToolpadDataGridProps, ref: React.ForwardedRef, ) { - const { rows: dataProviderRowsInput, ...dataProviderProps } = useDataProviderDataGridProps( - rowsSource === 'dataProvider' ? dataProviderId : null, - ); + const { + rows: dataProviderRowsInput, + getActions: getProviderActions, + ...dataProviderProps + } = useDataProviderDataGridProps(rowsSource === 'dataProvider' ? dataProviderId : null); const nodeRuntime = useNode(); @@ -727,6 +793,36 @@ const DataGridComponent = React.forwardRef(function DataGridComponent( nodeRuntime?.updateEditorNodeData('rawRows', rows); }, [nodeRuntime, rows]); + const renderedColumns = React.useMemo(() => { + if (getProviderActions) { + return [ + ...columns, + { + field: '___actions', + type: 'actions', + headerName: '', + flex: 1, + align: 'right', + getActions: getProviderActions, + }, + ]; + } + + return columns; + }, [columns, getProviderActions]); + + const [actionError, setActionError] = React.useState(); + + const open = !!actionError; + const lastActionError = useLatest(actionError); + + React.useEffect(() => { + if (actionError) { + // Log error to console as well for full stacktrace + console.error(actionError); + } + }, [actionError]); + return (
- + + +
+ + + + { + setActionError(null); + }} + > + + + } + > + {lastActionError?.message} + + +
); diff --git a/packages/toolpad-core/src/runtime.tsx b/packages/toolpad-core/src/runtime.tsx index 206cfb88ae8..a4604d85f4a 100644 --- a/packages/toolpad-core/src/runtime.tsx +++ b/packages/toolpad-core/src/runtime.tsx @@ -295,6 +295,7 @@ export function useComponent(id: string) { export interface ToolpadDataProviderIntrospection { paginationMode: PaginationMode; + hasDeleteRecord: boolean; } export interface UseDataProviderHookResult { diff --git a/packages/toolpad-core/src/types.ts b/packages/toolpad-core/src/types.ts index 362fafac62c..e2c09483324 100644 --- a/packages/toolpad-core/src/types.ts +++ b/packages/toolpad-core/src/types.ts @@ -518,9 +518,8 @@ export interface GetRecordsResult { export interface ToolpadDataProviderBase { paginationMode?: P; getRecords: (params: GetRecordsParams) => Promise>; - // getTotalCount?: () => Promise; - // updateRecord?: (id: string, record: R) => Promise; - // deleteRecord?: (id: string) => Promise; + deleteRecord?: (id: string | number) => Promise; + // updateRecord?: (id: string | number, record: R) => Promise; // createRecord?: (record: R) => Promise; } diff --git a/packages/toolpad-app/src/utils/useLatest.ts b/packages/toolpad-utils/src/hooks/useLatest.ts similarity index 88% rename from packages/toolpad-app/src/utils/useLatest.ts rename to packages/toolpad-utils/src/hooks/useLatest.ts index e75ab3be40b..3cb3d8ec904 100644 --- a/packages/toolpad-app/src/utils/useLatest.ts +++ b/packages/toolpad-utils/src/hooks/useLatest.ts @@ -1,7 +1,7 @@ import * as React from 'react'; /** - * Returns the latest non-null, non-undefiend value that has been passed to it. + * Returns the latest non-null, non-undefined value that has been passed to it. */ function useLatest(value: T): T; function useLatest(value: T | null | undefined): T | null | undefined; diff --git a/test/integration/backend-basic/fixture/toolpad/pages/crud/page.yml b/test/integration/backend-basic/fixture/toolpad/pages/crud/page.yml new file mode 100644 index 00000000000..3df4f3de003 --- /dev/null +++ b/test/integration/backend-basic/fixture/toolpad/pages/crud/page.yml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: page +spec: + title: page + content: + - component: DataGrid + name: dataGrid + props: + dataProviderId: myCrudData.ts:default + columns: + - field: name + width: 133 + height: 500 + rowsSource: dataProvider diff --git a/test/integration/backend-basic/fixture/toolpad/resources/myCrudData.ts b/test/integration/backend-basic/fixture/toolpad/resources/myCrudData.ts new file mode 100644 index 00000000000..a032a8b1165 --- /dev/null +++ b/test/integration/backend-basic/fixture/toolpad/resources/myCrudData.ts @@ -0,0 +1,14 @@ +import { createDataProvider } from '@mui/toolpad/server'; + +let DATA = Array.from({ length: 100_000 }, (_, id) => ({ id, name: `Index item ${id}` })); + +export default createDataProvider({ + async getRecords({ paginationModel: { start = 0, pageSize } }) { + const records = DATA.slice(start, start + pageSize); + return { records, totalCount: DATA.length }; + }, + + async deleteRecord(id) { + DATA = DATA.filter((item) => item.id !== id); + }, +}); diff --git a/test/integration/backend-basic/index.spec.ts b/test/integration/backend-basic/index.spec.ts index 08e6ddd7e84..a0a77e80e2a 100644 --- a/test/integration/backend-basic/index.spec.ts +++ b/test/integration/backend-basic/index.spec.ts @@ -246,3 +246,20 @@ test('data providers', async ({ page }) => { await expect(grid2.getByText('Cursor item 0')).toBeVisible(); }); + +test('data providers crud', async ({ page }) => { + const editorModel = new ToolpadEditor(page); + await editorModel.goToPage('crud'); + + await editorModel.waitForOverlay(); + + const grid = editorModel.appCanvas.getByRole('grid'); + + await expect(grid.getByText('Index item 5')).toBeVisible(); + + await clickCenter(page, grid); + + await grid.getByRole('button', { name: 'Delete row with id "5"', exact: true }).click(); + + await expect(grid.getByText('Index item 5')).not.toBeVisible(); +});