From c8aa7129794cd2d0ed41c673bf3c9e792c245aae Mon Sep 17 00:00:00 2001 From: Jan Potoms <2109932+Janpot@users.noreply.github.com> Date: Fri, 6 Oct 2023 16:02:17 +0200 Subject: [PATCH] Introduce Data providers (#2644) Signed-off-by: Jan Potoms <2109932+Janpot@users.noreply.github.com> Co-authored-by: Marija Najdova Co-authored-by: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> --- .eslintrc.js | 4 + docs/data/pages.ts | 16 +- docs/data/toolpad/concepts/data-providers.md | 119 ++++++ .../reference/api/create-data-provider.md | 91 +++++ docs/data/toolpad/reference/api/index.md | 1 + .../toolpad/reference/components/data-grid.md | 24 +- docs/pages/toolpad/concepts/data-providers.js | 7 + .../reference/api/create-data-provider.js | 7 + .../create-data-provider-dialog.png | Bin 0 -> 54945 bytes .../data-providers/create-data-provider.png | Bin 0 -> 20815 bytes .../concepts/data-providers/open-editor.png | Bin 0 -> 20438 bytes .../concepts/data-providers/rows-source.png | Bin 0 -> 24498 bytes docs/src/modules/components/DocsImage.tsx | 10 +- .../src/components/OpenCodeEditor.tsx | 8 +- packages/toolpad-app/src/constants.ts | 1 + .../toolpad-app/src/runtime/ToolpadApp.tsx | 48 +-- .../src/runtime/useDataProvider.ts | 38 ++ .../toolpad-app/src/server/DataManager.ts | 2 +- .../src/server/FunctionsManager.ts | 97 ++++- .../toolpad-app/src/server/appServerWorker.ts | 11 +- .../src/server/functionsDevWorker.ts | 86 ++++- .../src/server/functionsTypesWorker.ts | 39 +- packages/toolpad-app/src/server/localMode.ts | 21 +- .../src/server/rpcRuntimeServer.ts | 10 + packages/toolpad-app/src/server/rpcServer.ts | 6 + .../src/server/toolpadAppBuilder.ts | 2 +- .../src/server/toolpadAppServer.ts | 19 +- .../AppEditor/HierarchyExplorer/index.tsx | 2 +- .../AppEditor/PageEditor/ComponentPanel.tsx | 16 +- .../AppEditor/PageEditor/EditorCanvasHost.tsx | 13 +- .../PageEditor/PageEditorProvider.tsx | 28 +- .../PageEditor/RenderPanel/RenderPanel.tsx | 17 +- .../CreateCodeComponentNodeDialog.tsx | 8 +- packages/toolpad-app/src/toolpad/AppState.tsx | 13 +- .../propertyControls/DataProviderSelector.tsx | 345 ++++++++++++++++++ .../toolpad/propertyControls/GridColumns.tsx | 16 +- .../propertyControls/ToggleButtons.tsx | 54 +++ .../src/toolpad/propertyControls/select.tsx | 5 +- .../src/toolpadDataSources/local/client.tsx | 2 +- packages/toolpad-app/src/types.ts | 8 +- packages/toolpad-components/package.json | 2 + packages/toolpad-components/src/DataGrid.tsx | 194 +++++++++- packages/toolpad-core/src/index.tsx | 1 + packages/toolpad-core/src/runtime.tsx | 38 +- packages/toolpad-core/src/server.ts | 35 +- packages/toolpad-core/src/types.ts | 45 ++- .../fixture/toolpad/pages/basic/page.yml | 18 - .../toolpad/pages/dataProviders/page.yml | 26 ++ .../toolpad/pages/extractedTypes/page.yml | 2 - .../toolpad/pages/serialization/page.yml | 3 - .../fixture/toolpad/resources/myCursorData.ts | 14 + .../fixture/toolpad/resources/myIndexData.ts | 10 + test/integration/backend-basic/index.spec.ts | 31 ++ test/integration/mysql-basic/index.spec.ts | 2 +- 54 files changed, 1426 insertions(+), 189 deletions(-) create mode 100644 docs/data/toolpad/concepts/data-providers.md create mode 100644 docs/data/toolpad/reference/api/create-data-provider.md create mode 100644 docs/pages/toolpad/concepts/data-providers.js create mode 100644 docs/pages/toolpad/reference/api/create-data-provider.js create mode 100644 docs/public/static/toolpad/docs/concepts/data-providers/create-data-provider-dialog.png create mode 100644 docs/public/static/toolpad/docs/concepts/data-providers/create-data-provider.png create mode 100644 docs/public/static/toolpad/docs/concepts/data-providers/open-editor.png create mode 100644 docs/public/static/toolpad/docs/concepts/data-providers/rows-source.png create mode 100644 packages/toolpad-app/src/runtime/useDataProvider.ts create mode 100644 packages/toolpad-app/src/toolpad/propertyControls/DataProviderSelector.tsx create mode 100644 packages/toolpad-app/src/toolpad/propertyControls/ToggleButtons.tsx create mode 100644 test/integration/backend-basic/fixture/toolpad/pages/dataProviders/page.yml create mode 100644 test/integration/backend-basic/fixture/toolpad/resources/myCursorData.ts create mode 100644 test/integration/backend-basic/fixture/toolpad/resources/myIndexData.ts diff --git a/.eslintrc.js b/.eslintrc.js index 23dee134d4c..81044795dec 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,6 +34,10 @@ module.exports = { message: 'Avoid kitchensink libraries like lodash-es. We prefer a slightly more verbose, but more universally understood javascript style', }, + { + name: 'react-query', + message: 'deprecated package, use @tanstack/react-query instead.', + }, ], patterns: [ { diff --git a/docs/data/pages.ts b/docs/data/pages.ts index 5b76173b783..2541df62a5b 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -44,6 +44,10 @@ const pages: MuiPage[] = [ pathname: '/toolpad/concepts/custom-functions', title: 'Custom functions', }, + { + pathname: '/toolpad/concepts/data-providers', + title: 'Data providers', + }, ], }, { @@ -163,14 +167,18 @@ const pages: MuiPage[] = [ pathname: '/toolpad/reference/api/functions-group', subheader: 'Functions', children: [ - { - title: 'createFunction', - pathname: '/toolpad/reference/api/create-function', - }, { title: 'createComponent', pathname: '/toolpad/reference/api/create-component', }, + { + title: 'createDataProvider', + pathname: '/toolpad/reference/api/create-data-provider', + }, + { + title: 'createFunction', + pathname: '/toolpad/reference/api/create-function', + }, { title: 'getContext', pathname: '/toolpad/reference/api/get-context', diff --git a/docs/data/toolpad/concepts/data-providers.md b/docs/data/toolpad/concepts/data-providers.md new file mode 100644 index 00000000000..2fb036a6f29 --- /dev/null +++ b/docs/data/toolpad/concepts/data-providers.md @@ -0,0 +1,119 @@ +# Data Providers + +

Bring tabular data to the frontend with server-side pagination and filtering.

+ +Toolpad functions are great to bring some backend state to the page, but they fall short when it comes to offering pagination and filtering capabilities from the server. Toolpad offers a special construct to enable this use case: Data providers. Data providers abstract server-side collections. They could be database tables, REST APIs, or any data that represents a set of records that share a common interface. Data providers are defined as server-side objects and can be directly connected to a data grid to make it fully interactive. + +Follow these steps to create a new data provider: + +1. Drag a data grid into the canvas + +2. Under its **Row Source** property, select the option **Data Provider**. + +{{"component": "modules/components/DocsImage.tsx", "src": "/static/toolpad/docs/concepts/data-providers/rows-source.png", "alt": "Select data provider row source", "caption": "Select data provider row source", "zoom": false, "width": 297}} + +3. Click the data provider selector and choose **Create new data provider**. + +{{"component": "modules/components/DocsImage.tsx", "src": "/static/toolpad/docs/concepts/data-providers/create-data-provider.png", "alt": "Create data provider", "caption": "Create data provider", "zoom": false, "width": 294}} + +4. Name the new data provider and click **Create** + +{{"component": "modules/components/DocsImage.tsx", "src": "/static/toolpad/docs/concepts/data-providers/create-data-provider-dialog.png", "alt": "Create data provider dialog", "caption": "Create data provider dialog", "zoom": false, "width": 490}} + +5. Use the code button to open your code editor with the data provider backend. + +{{"component": "modules/components/DocsImage.tsx", "src": "/static/toolpad/docs/concepts/data-providers/open-editor.png", "alt": "Open data provider editor", "caption": "Open data provider editor", "zoom": false, "width": 272}} + +6. A data provider that iterates over a static list could look as follows: + + ```tsx + import { createDataProvider } from '@mui/toolpad-core/server'; + import DATA from './movies.json'; + + export default createDataProvider({ + async getRecords({ paginationModel: { start = 0, pageSize } }) { + const records = DATA.slice(start, start + pageSize); + return { records, totalCount: DATA.length }; + }, + }); + ``` + +## Pagination + +The data provider supports two styles of pagination. Index based, and cursor based pagination. + +### Index based + +This is the strategy your data is paginated by when it returns data based on a page number and page size. The `getRecords` method will receive `page` and `pageSize` values in it `paginationModel` parameter and returns a set of records representing the page. Index based pagination is the default but you can explicitly enable this by setting `paginationMode` to `'index'`. + +```tsx +export default createDataProvider({ + paginationMode: 'index', + async getRecords({ paginationModel: { start = 0, pageSize } }) { + const { page, totalCount } = await db.getRecords(start, pageSize); + return { + records: page, + totalCount, + }; + }, +}); +``` + +### Cursor based + +This is the strategy your data is paginated by when it returns data based on a cursor and a page size. The `getRecords` method will receive `cursor` and `pageSize` values in its `paginationModel` parameter and returns a set of records representing the page. You indicate the cursor of the next page with a `cursor` property in the result. Pass `null` to signal the end of the collection. You can enable Cursor based pagination by setting `paginationMode` to `'cursor'`. + +The `cursor` property of the `paginationModel` is `null` when Toolpad fetches the initial page. Any result set returned from the `getRecords` function must be accompanied with a `cursor` property, a string which contains a reference to the next page. This value will be passed as the `cursor` parameter in the `paginationModel` when fetching the subsequent page. Return `null` for this value to indicate the end of the sequence. + +```tsx +export default createDataProvider({ + paginationMode: 'cursor', + async getRecords({ paginationModel: { cursor = null, pageSize } }) { + const { page, nextPageCursor, totalCount } = await db.getRecords( + cursor, + pageSize, + ); + return { + records: page, + cursor: nextPageCursor, + totalCount, + }; + }, +}); +``` + +## Filtering 🚧 + +:::warning +This feature isn't implemented yet. It's coming. +::: + +## Sorting 🚧 + +:::warning +This feature isn't implemented yet. It's coming. +::: + +## Row editing 🚧 + +:::warning +This feature isn't implemented yet. It's coming. +::: + +## Row creation 🚧 + +:::warning +This feature isn't implemented yet. It's coming. +::: + +## Deleting rows 🚧 + +:::warning +This feature isn't implemented yet. It's coming. +::: + +## API + +See the documentation below for a complete reference to all of the functions and interfaces mentioned in this page. + +- [`createDataProvider`](/toolpad/reference/api/create-data-provider/) diff --git a/docs/data/toolpad/reference/api/create-data-provider.md b/docs/data/toolpad/reference/api/create-data-provider.md new file mode 100644 index 00000000000..d73645bfea6 --- /dev/null +++ b/docs/data/toolpad/reference/api/create-data-provider.md @@ -0,0 +1,91 @@ +# createDataProvider API + +

Define a backend to load server-side collections.

+ +## Import + +```jsx +import { createDataProvider } from '@mui/toolpad/server'; +``` + +## Description + +```jsx +import { createDataProvider } from '@mui/toolpad-core/server'; +import DATA from './movies.json'; + +export default createDataProvider({ + async getRecords({ paginationModel: { start = 0, pageSize } }) { + const records = DATA.slice(start, start + pageSize); + return { records, totalCount: DATA.length }; + }, +}); +``` + +Data providers expose collections to the Toolpad frontend. They are server-side data structures that abstract the loading and manipulation of a backend collection of records of similar shape. They can be directly connected to data grids to display the underlying data. + +## Parameters + +- `config`: [`DataProviderConfig`](#dataproviderconfig) An object describing the data provider capabilities + +## Returns + +An object that is recognized by Toolpad as a data provider and which is made available to the front-end. + +## Types + +### DataProviderConfig + +Describes the capabilities of the data provider. + +**Properties** + +| Name | Type | Description | +| :---------------- | :----------------------------------------------------- | :------------------------------------------------------ | +| `paginationMode?` | `'index' \| 'cursor'` | Declares the pagination strategy of this data provider. | +| `getRecords` | `async (params: GetRecordsParams) => GetRecordsResult` | Responsible for fetching slices of underlying data. | + +### GetRecordsParams + +**Properties** + +| Name | Type | Description | +| :----------------- | :---------------- | :------------------------------------------------------- | +| `paginationModel?` | `PaginationModel` | The pagination model that describes the requested slice. | + +### PaginationModel + +- `IndexPaginationModel` when `paginationMode` is set to `'index'`. +- `CursorPaginationModel` when `paginationMode` is set to `'cursor'`. + +### IndexPaginationModel + +**Properties** + +| Name | Type | Description | +| :--------- | :------- | :------------------------------------------------------ | +| `start` | `number` | The start index of the requested slice requested slice. | +| `pageSize` | `number` | The length of the requested slice. | + +### CursorPaginationModel + +**Properties** + +| Name | Type | Description | +| :--------- | :------- | :---------------------------------------------------------------------- | +| `cursor` | `number` | The cursor addressing the requested slice. `null` for the initial page. | +| `pageSize` | `number` | The length of the requested slice. | + +### GetRecordsResult + +| Name | Type | Description | +| :------------ | :--------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | +| `records` | `any[]` | The start index of the requested slice requested slice. | +| `totalCount?` | `number` | The length of the requested slice. | +| `cursor?` | `string \| null` | Used when `paginationMode` is set to `cursor`. It addresses the next page in the collection. Pass `null` to signal the end of the collection. | + +## Usage + +:::info +See [data providers](/toolpad/concepts/data-providers/) +::: diff --git a/docs/data/toolpad/reference/api/index.md b/docs/data/toolpad/reference/api/index.md index be44d4e1341..df9f2f6ed41 100644 --- a/docs/data/toolpad/reference/api/index.md +++ b/docs/data/toolpad/reference/api/index.md @@ -5,5 +5,6 @@ ## Functions - [createComponent](/toolpad/reference/api/create-component/) +- [createDataProvider](/toolpad/reference/api/create-data-provider/) - [createFunction](/toolpad/reference/api/create-function/) - [getContext](/toolpad/reference/api/get-context/) diff --git a/docs/data/toolpad/reference/components/data-grid.md b/docs/data/toolpad/reference/components/data-grid.md index 9179b5c89df..7affa9a7032 100644 --- a/docs/data/toolpad/reference/components/data-grid.md +++ b/docs/data/toolpad/reference/components/data-grid.md @@ -10,14 +10,16 @@ The datagrid lets users display tabular data in a flexible grid. ## Properties -| Name | Type | Default | Description | -| :----------------------------------------- | :------------------------------------- | :------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| rows | array | | The data to be displayed as rows. Must be an array of objects. | -| columns | array | | The columns to be displayed. | -| rowIdField | string | | Defines which column contains the [id](https://mui.com/x/react-data-grid/row-definition/#row-identifier) that uniquely identifies each row. | -| selection | object | null | The currently selected row. Or `null` in case no row has been selected. | -| density | string | "compact" | The [density](https://mui.com/x/react-data-grid/accessibility/#density-prop) of the rows. Possible values are `compact`, `standard`, or `comfortable`. | -| height | number | 350 | The height of the data grid. | -| loading | boolean | | Displays a loading animation indicating the data grid isn't ready to present data yet. | -| hideToolbar | boolean | | Hide the toolbar area that contains the data grid user controls. | -| sx | object | | The [`sx` prop](https://mui.com/system/getting-started/the-sx-prop/) is used for defining custom styles that have access to the theme. All MUI System properties are available via the `sx` prop. In addition, the `sx` prop allows you to specify any other CSS rules you may need. | +| Name | Type | Default | Description | +| :-------------------------------------------- | :------------------------------------- | :------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| rowsSource | string | "prop" | Defines how rows are provided to the grid. | +| rows | array | | The data to be displayed as rows. Must be an array of objects. | +| dataProviderId | string | | The backend data provider that will supply the rows to this grid | +| columns | array | | The columns to be displayed. | +| rowIdField | string | | Defines which column contains the [id](https://mui.com/x/react-data-grid/row-definition/#row-identifier) that uniquely identifies each row. | +| selection | object | null | The currently selected row. Or `null` in case no row has been selected. | +| density | string | "compact" | The [density](https://mui.com/x/react-data-grid/accessibility/#density-prop) of the rows. Possible values are `compact`, `standard`, or `comfortable`. | +| height | number | 350 | The height of the data grid. | +| loading | boolean | | Displays a loading animation indicating the data grid isn't ready to present data yet. | +| hideToolbar | boolean | | Hide the toolbar area that contains the data grid user controls. | +| sx | object | | The [`sx` prop](https://mui.com/system/getting-started/the-sx-prop/) is used for defining custom styles that have access to the theme. All MUI System properties are available via the `sx` prop. In addition, the `sx` prop allows you to specify any other CSS rules you may need. | diff --git a/docs/pages/toolpad/concepts/data-providers.js b/docs/pages/toolpad/concepts/data-providers.js new file mode 100644 index 00000000000..7ca366032ee --- /dev/null +++ b/docs/pages/toolpad/concepts/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/concepts/data-providers.md?@mui/markdown'; + +export default function Page() { + return ; +} diff --git a/docs/pages/toolpad/reference/api/create-data-provider.js b/docs/pages/toolpad/reference/api/create-data-provider.js new file mode 100644 index 00000000000..9ea52d0b250 --- /dev/null +++ b/docs/pages/toolpad/reference/api/create-data-provider.js @@ -0,0 +1,7 @@ +import * as React from 'react'; +import MarkdownDocs from '@mui/monorepo/docs/src/modules/components/MarkdownDocs'; +import * as pageProps from '../../../../data/toolpad/reference/api/create-data-provider.md?@mui/markdown'; + +export default function Page() { + return ; +} diff --git a/docs/public/static/toolpad/docs/concepts/data-providers/create-data-provider-dialog.png b/docs/public/static/toolpad/docs/concepts/data-providers/create-data-provider-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..a5cc1f6ea12951c743171c9f5d14460e479d5447 GIT binary patch literal 54945 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 literal 0 HcmV?d00001 diff --git a/docs/public/static/toolpad/docs/concepts/data-providers/open-editor.png b/docs/public/static/toolpad/docs/concepts/data-providers/open-editor.png new file mode 100644 index 0000000000000000000000000000000000000000..12e7c091cb11a4c07a3e5618c78cdf633462669c GIT binary patch literal 20438 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 literal 0 HcmV?d00001 diff --git a/docs/public/static/toolpad/docs/concepts/data-providers/rows-source.png b/docs/public/static/toolpad/docs/concepts/data-providers/rows-source.png new file mode 100644 index 0000000000000000000000000000000000000000..d6a317da42ab20e435dfecb79829675529f00110 GIT binary patch literal 24498 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 Fe*m@mzqbGY literal 0 HcmV?d00001 diff --git a/docs/src/modules/components/DocsImage.tsx b/docs/src/modules/components/DocsImage.tsx index b00a89c4fb9..34d4a1987a6 100644 --- a/docs/src/modules/components/DocsImage.tsx +++ b/docs/src/modules/components/DocsImage.tsx @@ -5,6 +5,10 @@ import DialogContent from '@mui/material/DialogContent'; const Root = styled('div')({ position: 'relative', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', }); interface DocsImageProps { @@ -31,9 +35,9 @@ const Img = styled('img')(({ theme, zoom, indent, width, aspectR position: 'relative', aspectRatio: aspectRatio ?? zoom === false ? 'unset' : '1.80904522613', // 1440 / 796 marginTop: theme.spacing(3), - marginLeft: indent ? theme.spacing(5 * indent) : 'auto', + marginLeft: indent ? theme.spacing(5 * indent) : undefined, marginBottom: 0, - marginRight: indent ? 0 : 'auto', + marginRight: indent ? 0 : undefined, zIndex: 5, borderRadius: 4, maxWidth: zoom === false ? 'min(50vw, 500px)' : 'unset', @@ -50,7 +54,7 @@ const ImageCaption = styled('p')>(({ theme, inden fontFamily: theme.typography.fontFamily, textAlign: 'center', marginBottom: theme.spacing(3), - marginLeft: indent ? theme.spacing(5 * indent) : 'auto', + marginLeft: indent ? theme.spacing(5 * indent) : undefined, color: theme.palette.mode === 'dark' ? alpha(theme.palette.grey[500], 0.8) : theme.palette.grey[700], '& a': { diff --git a/packages/toolpad-app/src/components/OpenCodeEditor.tsx b/packages/toolpad-app/src/components/OpenCodeEditor.tsx index d4c097493a8..c890bd254c1 100644 --- a/packages/toolpad-app/src/components/OpenCodeEditor.tsx +++ b/packages/toolpad-app/src/components/OpenCodeEditor.tsx @@ -15,10 +15,11 @@ import { import CodeIcon from '@mui/icons-material/Code'; import { LoadingButton } from '@mui/lab'; import client from '../api'; +import { CodeEditorFileType } from '../types'; interface OpenCodeEditorButtonProps extends ButtonProps { filePath: string; - fileType: string; + fileType: CodeEditorFileType; onSuccess?: () => void; iconButton?: boolean; } @@ -72,6 +73,7 @@ export default function OpenCodeEditorButton({ fileType, iconButton, onSuccess, + disabled, ...rest }: OpenCodeEditorButtonProps) { const [missingEditorDialog, setMissingEditorDialog] = React.useState(false); @@ -97,7 +99,7 @@ export default function OpenCodeEditorButton({ {iconButton ? ( - + {busy ? ( ) : ( @@ -106,7 +108,7 @@ export default function OpenCodeEditorButton({ ) : ( - + Open )} diff --git a/packages/toolpad-app/src/constants.ts b/packages/toolpad-app/src/constants.ts index 1f2ff0e1916..558bbc6c3f9 100644 --- a/packages/toolpad-app/src/constants.ts +++ b/packages/toolpad-app/src/constants.ts @@ -1,6 +1,7 @@ export const HTML_ID_EDITOR_OVERLAY = 'editor-overlay'; export const WINDOW_PROP_TOOLPAD_APP_RENDER_PARAMS = '__TOOLPAD_APP_RENDER_PARAMS__'; export const RUNTIME_CONFIG_WINDOW_PROPERTY = '__TOOLPAD_RUNTIME_CONFIG__'; +export const INITIAL_STATE_WINDOW_PROPERTY = '__initialToolpadState__'; export const TOOLPAD_TARGET_CE = 'CE'; export const TOOLPAD_TARGET_CLOUD = 'CLOUD'; diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index 58f27438bb5..48d23191999 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -54,6 +54,7 @@ import { NodeErrorProps, NodeRuntimeWrapper, ResetNodeErrorsKeyProvider, + UseDataProviderContext, } from '@mui/toolpad-core/runtime'; import ErrorIcon from '@mui/icons-material/Error'; import { getBrowserRuntime } from '@mui/toolpad-core/jsBrowserRuntime'; @@ -85,6 +86,7 @@ import { useDataQuery, UseFetch } from './useDataQuery'; import { NavigateToPage } from './CanvasHooksContext'; import PreviewHeader from './PreviewHeader'; import { AppLayout } from './AppLayout'; +import { useDataProvider } from './useDataProvider'; import api, { queryClient } from './api'; const browserJsRuntime = getBrowserRuntime(); @@ -1540,27 +1542,29 @@ export default function ToolpadApp({ rootRef, extraComponents, basename, state } }, [toggleDevtools]); return ( - - - - - - - - }> - - - - - {showDevtools ? : null} - - - - - - - - - + + + + + + + + + }> + + + + + {showDevtools ? : null} + + + + + + + + + + ); } diff --git a/packages/toolpad-app/src/runtime/useDataProvider.ts b/packages/toolpad-app/src/runtime/useDataProvider.ts new file mode 100644 index 00000000000..89e1a1c9b09 --- /dev/null +++ b/packages/toolpad-app/src/runtime/useDataProvider.ts @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { UseDataProviderHook } from '@mui/toolpad-core/runtime'; +import { useQuery } from '@tanstack/react-query'; +import invariant from 'invariant'; +import { ToolpadDataProviderBase } from '@mui/toolpad-core'; +import api from './api'; + +export const useDataProvider: UseDataProviderHook = (id) => { + const { + isLoading, + error, + data: introspection, + } = useQuery({ + queryKey: ['introspectDataProvider', id], + enabled: !!id, + queryFn: async () => { + invariant(id, 'id is required'); + const [filePath, name] = id.split(':'); + return api.methods.introspectDataProvider(filePath, name); + }, + }); + + const dataProvider: ToolpadDataProviderBase | null = React.useMemo(() => { + if (!introspection) { + return null; + } + return { + paginationMode: introspection.paginationMode, + getRecords: async (...args) => { + invariant(id, 'id is required'); + const [filePath, name] = id.split(':'); + return api.methods.getDataProviderRecords(filePath, name, ...args); + }, + }; + }, [id, introspection]); + + return { isLoading, error, dataProvider }; +}; diff --git a/packages/toolpad-app/src/server/DataManager.ts b/packages/toolpad-app/src/server/DataManager.ts index 861a973947e..5e8c6a7deea 100644 --- a/packages/toolpad-app/src/server/DataManager.ts +++ b/packages/toolpad-app/src/server/DataManager.ts @@ -27,7 +27,7 @@ interface IToolpadProject { options: ToolpadProjectOptions; getRoot(): string; loadDom(): Promise; - saveDom(dom: appDom.AppDom): Promise<{ fingerprint: number }>; + saveDom(dom: appDom.AppDom): Promise; functionsManager: FunctionsManager; envManager: EnvManager; getRuntimeConfig: () => RuntimeConfig; diff --git a/packages/toolpad-app/src/server/FunctionsManager.ts b/packages/toolpad-app/src/server/FunctionsManager.ts index 4c40f72e320..62a7a6de4b6 100644 --- a/packages/toolpad-app/src/server/FunctionsManager.ts +++ b/packages/toolpad-app/src/server/FunctionsManager.ts @@ -8,8 +8,14 @@ import chalk from 'chalk'; import { glob } from 'glob'; import { writeFileRecursive, fileExists, readJsonFile } from '@mui/toolpad-utils/fs'; import Piscina from 'piscina'; -import { ExecFetchResult } from '@mui/toolpad-core'; +import { + ExecFetchResult, + GetRecordsParams, + GetRecordsResult, + PaginationMode, +} from '@mui/toolpad-core'; import { errorFrom } from '@mui/toolpad-utils/errors'; +import { ToolpadDataProviderIntrospection } from '@mui/toolpad-core/runtime'; import * as url from 'node:url'; import EnvManager from './EnvManager'; import { ProjectEvents, ToolpadProjectOptions } from '../types'; @@ -19,6 +25,10 @@ import { Awaitable } from '../utils/types'; import { format } from '../utils/prettier'; import { compilerOptions } from './functionsShared'; +export interface CreateDataProviderOptions { + paginationMode: PaginationMode; +} + import.meta.url ??= url.pathToFileURL(__filename).toString(); const currentDirectory = url.fileURLToPath(new URL('.', import.meta.url)); @@ -38,6 +48,36 @@ async function createDefaultFunction(filePath: string): Promise { return result; } +async function createDefaultDataProvider( + filePath: string, + options: CreateDataProviderOptions, +): Promise { + const result = await format( + ` + /** + * Toolpad data provider file. + * See: https://mui.com/toolpad/concepts/data-providers/ + */ + + import { createDataProvider } from '@mui/toolpad/server'; + + export default createDataProvider({ + ${options.paginationMode === 'cursor' ? 'paginationMode: "cursor",' : ''} + async getRecords({ paginationModel: ${ + options.paginationMode === 'cursor' ? '{ cursor, pageSize }' : '{ start, pageSize }' + } }) { + return { + records: [], + ${options.paginationMode === 'cursor' ? 'cursor: null,' : ''} + }; + } + }) + `, + filePath, + ); + return result; +} + function formatCodeFrame(location: esbuild.Location): string { const lineNumberCharacters = Math.ceil(Math.log10(location.line)); return [ @@ -121,10 +161,6 @@ export default class FunctionsManager { return this.buildErrors.filter((error) => error.location?.file === entryPoint); } - private getOutputFile(fileName: string): string | undefined { - return path.resolve(this.getFunctionsOutputFolder(), `${path.basename(fileName, '.ts')}.js`); - } - private getFunctionsOutputFolder(): string { return path.resolve(this.project.getOutputFolder(), 'functions'); } @@ -168,6 +204,7 @@ export default class FunctionsManager { this.buildErrors = args.errors; this.project.invalidateQueries(); + this.project.events.emit('functionsChanged', {}); }; const toolpadPlugin: esbuild.Plugin = { @@ -265,11 +302,7 @@ export default class FunctionsManager { ]); } - async exec( - fileName: string, - name: string, - parameters: Record, - ): Promise> { + async getBuiltOutputFilePath(fileName: string): Promise { const resourcesFolder = this.getResourcesFolder(); const fullPath = path.resolve(resourcesFolder, fileName); const entryPoint = path.relative(this.project.getRoot(), fullPath); @@ -280,11 +313,20 @@ export default class FunctionsManager { throw formatError(buildErrors[0]); } - const outputFilePath = this.getOutputFile(fileName); - if (!outputFilePath) { - throw new Error(`No build found for "${fileName}"`); - } + const outputFilePath = path.resolve( + this.getFunctionsOutputFolder(), + `${path.basename(fileName, '.ts')}.js`, + ); + return outputFilePath; + } + + async exec( + fileName: string, + name: string, + parameters: Record, + ): Promise> { + const outputFilePath = await this.getBuiltOutputFilePath(fileName); const extractedTypes = await this.introspect(); if (extractedTypes.error) { @@ -330,4 +372,31 @@ export default class FunctionsManager { await writeFileRecursive(filePath, content, { encoding: 'utf-8' }); this.extractedTypes = undefined; } + + async createDataProviderFile(name: string, options: CreateDataProviderOptions): Promise { + const filePath = path.resolve(this.getResourcesFolder(), ensureSuffix(name, '.ts')); + const content = await createDefaultDataProvider(filePath, options); + if (await fileExists(filePath)) { + throw new Error(`"${name}" already exists`); + } + await writeFileRecursive(filePath, content, { encoding: 'utf-8' }); + this.extractedTypes = undefined; + } + + async introspectDataProvider( + fileName: string, + exportName: string = 'default', + ): Promise { + const fullPath = await this.getBuiltOutputFilePath(fileName); + return this.devWorker.introspectDataProvider(fullPath, exportName); + } + + async getDataProviderRecords( + fileName: string, + exportName: string, + params: GetRecordsParams, + ): Promise> { + const fullPath = await this.getBuiltOutputFilePath(fileName); + return this.devWorker.getDataProviderRecords(fullPath, exportName, params); + } } diff --git a/packages/toolpad-app/src/server/appServerWorker.ts b/packages/toolpad-app/src/server/appServerWorker.ts index cb060028ca4..727fb4c0bc8 100644 --- a/packages/toolpad-app/src/server/appServerWorker.ts +++ b/packages/toolpad-app/src/server/appServerWorker.ts @@ -6,6 +6,7 @@ import { getHtmlContent, createViteConfig, resolvedComponentsId } from './toolpa import type { RuntimeConfig } from '../config'; import type * as appDom from '../appDom'; import type { ComponentEntry } from './localMode'; +import createRuntimeState from '../runtime/createRuntimeState'; import { postProcessHtml } from './toolpadAppServer'; export type Command = { kind: 'reload-components' } | { kind: 'exit' }; @@ -51,7 +52,10 @@ function devServerPlugin({ config }: ToolpadAppDevServerParams): Plugin { let html = await viteServer.transformIndexHtml(req.url, template); - html = postProcessHtml(html, { config, dom }); + html = postProcessHtml(html, { + config, + initialState: createRuntimeState({ dom }), + }); res.setHeader('content-type', 'text/html; charset=utf-8').end(html); } catch (e) { @@ -108,7 +112,4 @@ export async function main({ port, ...config }: AppViteServerConfig) { await notifyReady(); } -main(workerData).catch((err) => { - console.error(err); - process.exit(1); -}); +main(workerData); diff --git a/packages/toolpad-app/src/server/functionsDevWorker.ts b/packages/toolpad-app/src/server/functionsDevWorker.ts index f9f6f234b84..ea87595e632 100644 --- a/packages/toolpad-app/src/server/functionsDevWorker.ts +++ b/packages/toolpad-app/src/server/functionsDevWorker.ts @@ -10,6 +10,11 @@ import { isWebContainer } from '@webcontainer/env'; import SuperJSON from 'superjson'; import { createRpcClient, serveRpc } from '@mui/toolpad-utils/workerRpc'; import { workerData } from 'node:worker_threads'; +import { ToolpadDataProviderIntrospection } from '@mui/toolpad-core/runtime'; +import { TOOLPAD_DATA_PROVIDER_MARKER, ToolpadDataProvider } from '@mui/toolpad-core/server'; +import * as z from 'zod'; +import { fromZodError } from 'zod-validation-error'; +import { GetRecordsParams, GetRecordsResult, PaginationMode } from '@mui/toolpad-core'; import.meta.url ??= url.pathToFileURL(__filename).toString(); const currentDirectory = url.fileURLToPath(new URL('.', import.meta.url)); @@ -34,7 +39,7 @@ function loadModule(fullPath: string, content: string) { return moduleObject; } -async function resolveFunctions(filePath: string): Promise> { +async function resolveExports(filePath: string): Promise> { const fullPath = path.resolve(filePath); const content = await fs.readFile(fullPath, 'utf-8'); @@ -50,11 +55,7 @@ async function resolveFunctions(filePath: string): Promise - typeof value === 'function' ? [[key, value]] : [], - ), - ); + return new Map(Object.entries(cachedModule.exports)); } interface ExecuteParams { @@ -70,9 +71,9 @@ interface ExecuteResult { } async function execute(msg: ExecuteParams): Promise { - const fns = await resolveFunctions(msg.filePath); + const exports = await resolveExports(msg.filePath); - const fn = fns[msg.name]; + const fn = exports.get(msg.name); if (typeof fn !== 'function') { throw new Error(`Function "${msg.name}" not found`); } @@ -113,13 +114,64 @@ 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()), + [TOOLPAD_DATA_PROVIDER_MARKER]: z.literal(true), +}); + +async function loadDataProvider( + filePath: string, + name: string, +): Promise> { + const exports = await resolveExports(filePath); + const dataProviderExport = exports.get(name); + + if (!dataProviderExport || typeof dataProviderExport !== 'object') { + throw new Error(`DataProvider "${name}" not found`); + } + + const parsed = dataProviderSchema.safeParse(dataProviderExport); + + if (parsed.success) { + return parsed.data; + } + + throw fromZodError(parsed.error); +} + +async function introspectDataProvider( + filePath: string, + name: string, +): Promise { + const dataProvider = await loadDataProvider(filePath, name); + + return { + paginationMode: dataProvider.paginationMode, + }; +} + +async function getDataProviderRecords( + filePath: string, + name: string, + params: GetRecordsParams, +): Promise> { + const dataProvider = await loadDataProvider(filePath, name); + + return dataProvider.getRecords(params); +} + type WorkerRpcServer = { execute: typeof execute; + introspectDataProvider: typeof introspectDataProvider; + getDataProviderRecords: typeof getDataProviderRecords; }; if (!isMainThread && parentPort) { serveRpc(workerData.workerRpcPort, { execute, + introspectDataProvider, + getDataProviderRecords, }); } @@ -133,7 +185,7 @@ export function createWorker(env: Record) { transferList: [workerRpcChannel.port1], }); - const client = createRpcClient(workerRpcChannel.port2); + const client = createRpcClient(workerRpcChannel.port2); return { async terminate() { @@ -144,7 +196,6 @@ export function createWorker(env: Record) { const ctx = getServerContext(); const { result: serializedResult, newCookies } = await client.execute({ - kind: 'execute', filePath, name, parameters, @@ -161,6 +212,21 @@ export function createWorker(env: Record) { return result; }, + + async introspectDataProvider( + filePath: string, + name: string, + ): Promise { + return client.introspectDataProvider(filePath, name); + }, + + async getDataProviderRecords( + filePath: string, + name: string, + params: GetRecordsParams, + ): Promise> { + return client.getDataProviderRecords(filePath, name, params); + }, }; } diff --git a/packages/toolpad-app/src/server/functionsTypesWorker.ts b/packages/toolpad-app/src/server/functionsTypesWorker.ts index f087a2b5384..45b03f5e900 100644 --- a/packages/toolpad-app/src/server/functionsTypesWorker.ts +++ b/packages/toolpad-app/src/server/functionsTypesWorker.ts @@ -19,6 +19,10 @@ export interface HandlerIntrospectionResult { returnType: ReturnTypeIntrospectionResult; } +export interface DataProviderIntrospectionResult { + name: string; +} + export interface IntrospectionMessage { message: string; } @@ -28,6 +32,7 @@ export interface FileIntrospectionResult { errors: IntrospectionMessage[]; warnings: IntrospectionMessage[]; handlers: HandlerIntrospectionResult[]; + dataProviders: DataProviderIntrospectionResult[]; } export interface IntrospectionResult { @@ -339,6 +344,20 @@ function isToolpadCreateFunction(exportType: ts.Type): boolean { return false; } +function isToolpadCreateDataProvider(exportType: ts.Type): boolean { + const properties = exportType.getProperties(); + + for (const property of properties) { + if (ts.isPropertySignature(property.valueDeclaration!)) { + if (property.valueDeclaration.name.getText() === '[TOOLPAD_DATA_PROVIDER_MARKER]') { + return true; + } + } + } + + return false; +} + function getCreateFunctionParameters( callSignatures: readonly ts.Signature[], checker: ts.TypeChecker, @@ -425,8 +444,9 @@ export default async function extractTypes({ return null; } - const handlers = checker - .getExportsOfModule(moduleSymbol) + const exports = checker.getExportsOfModule(moduleSymbol); + + const handlers: HandlerIntrospectionResult[] = exports .map((symbol) => { const exportType = checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration!); const callSignatures = exportType.getCallSignatures(); @@ -450,6 +470,20 @@ export default async function extractTypes({ }) .filter(Boolean); + const dataProviders: DataProviderIntrospectionResult[] = exports + .map((symbol) => { + const exportType = checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration!); + + if (isToolpadCreateDataProvider(exportType)) { + return { + name: symbol.name, + }; + } + + return null; + }) + .filter(Boolean); + return { name: relativeEntrypoint, errors: diagnostics @@ -459,6 +493,7 @@ export default async function extractTypes({ .filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Warning) .map((diagnostic) => ({ message: formatDiagnostic(diagnostic) })), handlers, + dataProviders, } satisfies FileIntrospectionResult; }) .filter(Boolean) diff --git a/packages/toolpad-app/src/server/localMode.ts b/packages/toolpad-app/src/server/localMode.ts index 5fd56fd34bc..ac34c58ca9c 100644 --- a/packages/toolpad-app/src/server/localMode.ts +++ b/packages/toolpad-app/src/server/localMode.ts @@ -50,10 +50,10 @@ import { ResponseType as AppDomRestResponseType, } from '../toolpadDataSources/rest/types'; import { LocalQuery } from '../toolpadDataSources/local/types'; -import { ProjectEvents, ToolpadProjectOptions } from '../types'; +import { ProjectEvents, ToolpadProjectOptions, CodeEditorFileType } from '../types'; import { Awaitable } from '../utils/types'; import EnvManager from './EnvManager'; -import FunctionsManager from './FunctionsManager'; +import FunctionsManager, { CreateDataProviderOptions } from './FunctionsManager'; import { VersionInfo, checkVersion } from './versionInfo'; import { VERSION_CHECK_INTERVAL } from '../constants'; import DataManager from './DataManager'; @@ -1025,8 +1025,8 @@ class ToolpadProject { loadDomFromDisk(this.root), calculateDomFingerprint(this.root), ]); - this.events.emit('change', { fingerprint }); - this.events.emit('externalChange', { fingerprint }); + this.events.emit('change', {}); + this.events.emit('externalChange', {}); const newCodeComponentsFingerprint = getCodeComponentsFingerprint(dom); if (this.codeComponentsFingerprint !== newCodeComponentsFingerprint) { @@ -1151,29 +1151,28 @@ class ToolpadProject { const newFingerprint = await calculateDomFingerprint(this.root); this.domAndFingerprint = [newDom, newFingerprint]; this.events.emit('change', { fingerprint: newFingerprint }); - return { fingerprint: newFingerprint }; } async saveDom(newDom: appDom.AppDom) { - return this.domAndFingerprintLock.use(async () => { + await this.domAndFingerprintLock.use(async () => { return this.writeDomToDisk(newDom); }); } async applyDomDiff(domDiff: appDom.DomDiff) { - return this.domAndFingerprintLock.use(async () => { + await this.domAndFingerprintLock.use(async () => { const dom = await this.loadDom(); const newDom = appDom.applyDiff(dom, domDiff); return this.writeDomToDisk(newDom); }); } - async openCodeEditor(fileName: string, fileType: string) { + async openCodeEditor(fileName: string, fileType: CodeEditorFileType) { const supportedEditor = await findSupportedEditor(); const root = this.getRoot(); let resolvedPath = fileName; - if (fileType === 'query') { + if (fileType === 'resource') { resolvedPath = await this.functionsManager.getFunctionFilePath(fileName); } if (fileType === 'component') { @@ -1203,6 +1202,10 @@ class ToolpadProject { await writeFileRecursive(filePath, content, { encoding: 'utf-8' }); } + async createDataProvider(name: string, options: CreateDataProviderOptions) { + return this.functionsManager.createDataProviderFile(name, options); + } + async deletePage(name: string) { const pageFolder = getPageFolder(this.root, name); await fs.rm(pageFolder, { force: true, recursive: true }); diff --git a/packages/toolpad-app/src/server/rpcRuntimeServer.ts b/packages/toolpad-app/src/server/rpcRuntimeServer.ts index 9c2c8342608..6a4889c80ee 100644 --- a/packages/toolpad-app/src/server/rpcRuntimeServer.ts +++ b/packages/toolpad-app/src/server/rpcRuntimeServer.ts @@ -4,6 +4,16 @@ import type { ToolpadProject } from './localMode'; // Methods exposed to the Toolpad editor export function createRpcRuntimeServer(project: ToolpadProject) { return { + introspectDataProvider: createMethod( + ({ params }) => { + return project.functionsManager.introspectDataProvider(...params); + }, + ), + getDataProviderRecords: createMethod( + ({ params }) => { + return project.functionsManager.getDataProviderRecords(...params); + }, + ), execQuery: createMethod(({ params }) => { return project.dataManager.execQuery(...params); }), diff --git a/packages/toolpad-app/src/server/rpcServer.ts b/packages/toolpad-app/src/server/rpcServer.ts index d6719b89411..5d28d9070a5 100644 --- a/packages/toolpad-app/src/server/rpcServer.ts +++ b/packages/toolpad-app/src/server/rpcServer.ts @@ -18,6 +18,9 @@ export function createRpcServer(project: ToolpadProject) { getVersionInfo: createMethod(({ params }) => { return project.getVersionInfo(...params); }), + introspect: createMethod(({ params }) => { + return project.functionsManager.introspect(...params); + }), getPrettierConfig: createMethod(({ params }) => { return project.getPrettierConfig(...params); }), @@ -41,6 +44,9 @@ export function createRpcServer(project: ToolpadProject) { return project.dataManager.dataSourceExecPrivate(...params); }, ), + createDataProvider: createMethod(({ params }) => { + return project.createDataProvider(...params); + }), } satisfies MethodResolvers; } diff --git a/packages/toolpad-app/src/server/toolpadAppBuilder.ts b/packages/toolpad-app/src/server/toolpadAppBuilder.ts index dc0c298b404..1a051e9d270 100644 --- a/packages/toolpad-app/src/server/toolpadAppBuilder.ts +++ b/packages/toolpad-app/src/server/toolpadAppBuilder.ts @@ -4,7 +4,7 @@ import { InlineConfig, Plugin, build } from 'vite'; import react from '@vitejs/plugin-react'; import { indent } from '@mui/toolpad-utils/strings'; import type { ComponentEntry } from './localMode'; -import { INITIAL_STATE_WINDOW_PROPERTY } from './toolpadAppServer'; +import { INITIAL_STATE_WINDOW_PROPERTY } from '../constants'; import.meta.url ??= url.pathToFileURL(__filename).toString(); const currentDirectory = url.fileURLToPath(new URL('.', import.meta.url)); diff --git a/packages/toolpad-app/src/server/toolpadAppServer.ts b/packages/toolpad-app/src/server/toolpadAppServer.ts index bdb3530d388..be795d952ed 100644 --- a/packages/toolpad-app/src/server/toolpadAppServer.ts +++ b/packages/toolpad-app/src/server/toolpadAppServer.ts @@ -8,21 +8,21 @@ import { asyncHandler } from '../utils/express'; import { basicAuthUnauthorized, checkBasicAuthHeader } from './basicAuth'; import { createRpcRuntimeServer } from './rpcRuntimeServer'; import { createRpcHandler } from './rpc'; -import { RUNTIME_CONFIG_WINDOW_PROPERTY } from '../constants'; +import { RUNTIME_CONFIG_WINDOW_PROPERTY, INITIAL_STATE_WINDOW_PROPERTY } from '../constants'; import type { RuntimeConfig } from '../config'; -import type * as appDom from '../appDom'; import createRuntimeState from '../runtime/createRuntimeState'; - -export const INITIAL_STATE_WINDOW_PROPERTY = '__initialToolpadState__'; +import { RuntimeState } from '../types'; export interface PostProcessHtmlParams { config: RuntimeConfig; - dom: appDom.AppDom; + initialState: RuntimeState; } -export function postProcessHtml(html: string, { config, dom }: PostProcessHtmlParams): string { +export function postProcessHtml( + html: string, + { config, initialState }: PostProcessHtmlParams, +): string { const serializedConfig = serializeJavascript(config, { ignoreFunction: true }); - const initialState = createRuntimeState({ dom }); const serializedInitialState = serializeJavascript(initialState, { isJSON: true }); const toolpadScripts = [ @@ -74,7 +74,10 @@ export async function createProdHandler(project: ToolpadProject) { const htmlFilePath = path.resolve(project.getAppOutputFolder(), './index.html'); let html = await fs.readFile(htmlFilePath, { encoding: 'utf-8' }); - html = postProcessHtml(html, { config: project.getRuntimeConfig(), dom }); + html = postProcessHtml(html, { + config: project.getRuntimeConfig(), + initialState: createRuntimeState({ dom }), + }); res.setHeader('Content-Type', 'text/html; charset=utf-8').status(200).end(html); }), diff --git a/packages/toolpad-app/src/toolpad/AppEditor/HierarchyExplorer/index.tsx b/packages/toolpad-app/src/toolpad/AppEditor/HierarchyExplorer/index.tsx index 05e692d9181..c0b261afe77 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/HierarchyExplorer/index.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/HierarchyExplorer/index.tsx @@ -231,7 +231,7 @@ export default function HierarchyExplorer() { defaultCollapseIcon={} defaultExpandIcon={} expanded={Array.from(expandedDomNodeIdSet)} - selected={selectedDomNodeId as string} + selected={selectedDomNodeId} onNodeSelect={handleNodeSelect} onNodeFocus={handleNodeFocus} onNodeToggle={handleNodeToggle} diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentPanel.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentPanel.tsx index 050bb887acb..eb9dad374ec 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentPanel.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentPanel.tsx @@ -7,16 +7,16 @@ import ThemeEditor from './ThemeEditor'; import { useAppState, useAppStateApi } from '../../AppState'; import { PageViewTab } from '../../../utils/domView'; import * as appDom from '../../../appDom'; - import { PropControlsContextProvider, PropTypeControls } from '../../propertyControls'; import string from '../../propertyControls/string'; import boolean from '../../propertyControls/boolean'; import number from '../../propertyControls/number'; import select from '../../propertyControls/select'; import json from '../../propertyControls/json'; +import event from '../../propertyControls/event'; import markdown from '../../propertyControls/Markdown'; -import eventControl from '../../propertyControls/event'; import GridColumns from '../../propertyControls/GridColumns'; +import ToggleButtons from '../../propertyControls/ToggleButtons'; import SelectOptions from '../../propertyControls/SelectOptions'; import ChartData from '../../propertyControls/ChartData'; import RowIdFieldSelect from '../../propertyControls/RowIdFieldSelect'; @@ -24,16 +24,18 @@ import HorizontalAlign from '../../propertyControls/HorizontalAlign'; import VerticalAlign from '../../propertyControls/VerticalAlign'; import NumberFormat from '../../propertyControls/NumberFormat'; import ColorScale from '../../propertyControls/ColorScale'; +import DataProviderSelector from '../../propertyControls/DataProviderSelector'; -const propTypeControls: PropTypeControls = { +export const PROP_TYPE_CONTROLS: PropTypeControls = { string, boolean, number, select, json, markdown, - event: eventControl, + event, GridColumns, + ToggleButtons, SelectOptions, ChartData, RowIdFieldSelect, @@ -41,6 +43,7 @@ const propTypeControls: PropTypeControls = { VerticalAlign, NumberFormat, ColorScale, + DataProviderSelector, }; const classes = { @@ -76,11 +79,12 @@ export default function ComponentPanel({ className }: ComponentPanelProps) { const selectedNodeId = currentView.kind === 'page' ? currentView.selectedNodeId : null; const selectedNode = selectedNodeId ? appDom.getMaybeNode(dom, selectedNodeId) : null; - const handleChange = (event: React.SyntheticEvent, newValue: PageViewTab) => + const handleChange = (_: React.SyntheticEvent, newValue: PageViewTab) => { appStateApi.setTab(newValue); + }; return ( - + diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx index b66dd192741..3ed32acb3b3 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx @@ -6,13 +6,11 @@ import { CacheProvider } from '@emotion/react'; import * as ReactDOM from 'react-dom'; import invariant from 'invariant'; import useEventCallback from '@mui/utils/useEventCallback'; -import * as appDom from '../../../appDom'; import { TOOLPAD_BRIDGE_GLOBAL } from '../../../constants'; import { HTML_ID_EDITOR_OVERLAY } from '../../../runtime/constants'; -import { NodeHashes } from '../../../types'; +import { NodeHashes, RuntimeState } from '../../../types'; import { LogEntry } from '../../../components/Console'; import { useAppStateApi } from '../../AppState'; -import createRuntimeState from '../../../runtime/createRuntimeState'; import type { ToolpadBridge } from '../../../canvas/ToolpadBridge'; import CenteredSpinner from '../../../components/CenteredSpinner'; import { useOnProjectEvent } from '../../../projectEvents'; @@ -41,7 +39,7 @@ function Overlay(props: OverlayProps) { export interface EditorCanvasHostProps { className?: string; pageNodeId: NodeId; - dom: appDom.AppDom; + runtimeState: RuntimeState; savedNodes: NodeHashes; onConsoleEntry?: (entry: LogEntry) => void; overlay?: React.ReactNode; @@ -82,8 +80,8 @@ function useOnChange(value: T, handler: (newValue: T, oldValue: T) export default function EditorCanvasHost({ className, pageNodeId, + runtimeState, base, - dom, savedNodes, overlay, onConsoleEntry, @@ -95,10 +93,9 @@ export default function EditorCanvasHost({ const updateOnBridge = React.useCallback(() => { if (bridge) { - const data = createRuntimeState({ dom }); - bridge.canvasCommands.update({ ...data, savedNodes }); + bridge.canvasCommands.update({ ...runtimeState, savedNodes }); } - }, [bridge, dom, savedNodes]); + }, [bridge, runtimeState, savedNodes]); React.useEffect(() => { updateOnBridge(); diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/PageEditorProvider.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/PageEditorProvider.tsx index 0d8276d12dc..86d0d123f65 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/PageEditorProvider.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/PageEditorProvider.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import * as appDom from '../../../appDom'; import { PageViewState } from '../../../types'; import { RectangleEdge } from '../../../utils/geometry'; -import { update } from '../../../utils/immutability'; +import { update, updateOrCreate } from '../../../utils/immutability'; export const DROP_ZONE_TOP = 'top'; export const DROP_ZONE_BOTTOM = 'bottom'; @@ -29,6 +29,7 @@ export interface PageEditorState { readonly draggedEdge: RectangleEdge | null; readonly viewState: PageViewState; readonly pageState: Record; + readonly nodeData: Record | undefined>; readonly globalScopeMeta: ScopeMeta; readonly bindings: LiveBindings; readonly vm: ApplicationVm; @@ -70,6 +71,12 @@ export type PageEditorAction = pageState: Record; globalScopeMeta: ScopeMeta; } + | { + type: 'NODE_DATA_UPDATE'; + nodeId: NodeId; + prop: string; + value: unknown; + } | { type: 'PAGE_VIEW_STATE_UPDATE'; viewState: PageViewState; @@ -98,6 +105,7 @@ export function createPageEditorState(nodeId: NodeId): PageEditorState { pageState: {}, globalScopeMeta: {}, bindings: {}, + nodeData: {}, vm: { scopes: {}, bindingScopes: {}, @@ -167,6 +175,16 @@ export function pageEditorReducer( globalScopeMeta, }); } + case 'NODE_DATA_UPDATE': { + const { nodeId, prop, value } = action; + return update(state, { + nodeData: update(state.nodeData, { + [nodeId]: updateOrCreate(state.nodeData[nodeId], { + [prop]: value, + }), + }), + }); + } case 'PAGE_BINDINGS_UPDATE': { const { bindings } = action; return update(state, { @@ -227,6 +245,14 @@ function createPageEditorApi(dispatch: React.Dispatch) { globalScopeMeta, }); }, + nodeDataUpdate(nodeId: NodeId, prop: string, value: unknown) { + dispatch({ + type: 'NODE_DATA_UPDATE', + nodeId, + prop, + value, + }); + }, pageBindingsUpdate(bindings: LiveBindings) { dispatch({ type: 'PAGE_BINDINGS_UPDATE', diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderPanel.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderPanel.tsx index 3e6093df344..ff0b1b0c052 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderPanel.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderPanel.tsx @@ -7,9 +7,10 @@ import EditorCanvasHost from '../EditorCanvasHost'; import { getNodeHashes, useAppState, useAppStateApi, useDomApi } from '../../../AppState'; import { usePageEditorApi, usePageEditorState } from '../PageEditorProvider'; import RenderOverlay from './RenderOverlay'; -import { NodeHashes } from '../../../../types'; +import { NodeHashes, RuntimeState } from '../../../../types'; import type { ToolpadBridge } from '../../../../canvas/ToolpadBridge'; import { getBindingType } from '../../../../bindings'; +import createRuntimeState from '../../../../runtime/createRuntimeState'; const classes = { view: 'Toolpad_View', @@ -24,13 +25,17 @@ const RenderPanelRoot = styled('div')({ }, }); +function useRuntimeState(): RuntimeState { + const { dom } = useAppState(); + return React.useMemo(() => createRuntimeState({ dom }), [dom]); +} + export interface RenderPanelProps { className?: string; } export default function RenderPanel({ className }: RenderPanelProps) { const appState = useAppState(); - const { dom } = useAppState(); const domApi = useDomApi(); const appStateApi = useAppStateApi(); const pageEditorApi = usePageEditorApi(); @@ -66,6 +71,10 @@ export default function RenderPanel({ className }: RenderPanelProps) { }); }); + initializedBridge.canvasEvents.on('editorNodeDataUpdated', (event) => { + pageEditorApi.nodeDataUpdate(event.nodeId, event.prop, event.value); + }); + initializedBridge.canvasEvents.on('pageStateUpdated', (event) => { pageEditorApi.pageStateUpdate(event.pageState, event.globalScopeMeta); }); @@ -90,12 +99,14 @@ export default function RenderPanel({ className }: RenderPanelProps) { setBridge(initializedBridge); }); + const runtimeState = useRuntimeState(); + return ( } diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PagesExplorer/CreateCodeComponentNodeDialog.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PagesExplorer/CreateCodeComponentNodeDialog.tsx index 30f63489997..1071a86bc06 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PagesExplorer/CreateCodeComponentNodeDialog.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PagesExplorer/CreateCodeComponentNodeDialog.tsx @@ -21,6 +21,10 @@ import client from '../../../api'; import useLatest from '../../../utils/useLatest'; import OpenCodeEditorButton from '../../../components/OpenCodeEditor'; +function handleInputFocus(event: React.FocusEvent) { + event.target.select(); +} + const DEFAULT_NAME = 'MyComponent'; export interface CreateCodeComponentDialogProps { @@ -53,10 +57,6 @@ export default function CreateCodeComponentDialog({ } }, [open, handleReset]); - const handleInputFocus = React.useCallback((event: React.FocusEvent) => { - event.target.select(); - }, []); - const inputErrorMsg = useNodeNameValidation(name, existingNames, 'component'); const isNameValid = !inputErrorMsg; const isFormValid = isNameValid; diff --git a/packages/toolpad-app/src/toolpad/AppState.tsx b/packages/toolpad-app/src/toolpad/AppState.tsx index 106904a1071..e962e884da9 100644 --- a/packages/toolpad-app/src/toolpad/AppState.tsx +++ b/packages/toolpad-app/src/toolpad/AppState.tsx @@ -397,13 +397,7 @@ function createAppStateApi( export const [useAppStateContext, AppStateProvider] = createProvidedContext('AppState'); export function useAppState(): AppState { - const appState = useAppStateContext(); - - if (!appState.dom) { - throw new Error("Trying to access the DOM before it's loaded"); - } - - return appState; + return useAppStateContext(); } const DomApiContext = React.createContext(createDomApi(() => undefined)); @@ -537,8 +531,6 @@ export default function AppProvider({ children }: DomContextProps) { [dispatchWithHistory, scheduleTextInputHistoryUpdate], ); - const fingerprint = React.useRef(); - const handleSave = React.useCallback(() => { if (!state.dom || state.savingDom || state.savedDom === state.dom) { return; @@ -549,8 +541,7 @@ export default function AppProvider({ children }: DomContextProps) { const domDiff = appDom.createDiff(state.savedDom, domToSave); client.methods .applyDomDiff(domDiff) - .then(({ fingerprint: newFingerPrint }) => { - fingerprint.current = newFingerPrint; + .then(() => { dispatch({ type: 'DOM_SAVED', savedDom: domToSave }); }) .catch((err) => { diff --git a/packages/toolpad-app/src/toolpad/propertyControls/DataProviderSelector.tsx b/packages/toolpad-app/src/toolpad/propertyControls/DataProviderSelector.tsx new file mode 100644 index 00000000000..173b539c853 --- /dev/null +++ b/packages/toolpad-app/src/toolpad/propertyControls/DataProviderSelector.tsx @@ -0,0 +1,345 @@ +import * as React from 'react'; +import { + Autocomplete, + TextField, + styled, + autocompleteClasses, + createFilterOptions, + DialogContentText, + DialogContent, + DialogTitle, + Dialog, + DialogActions, + Button, + Box, + FormControl, + FormLabel, + RadioGroup, + FormControlLabel, + Radio, + FormHelperText, +} from '@mui/material'; +import { errorFrom } from '@mui/toolpad-utils/errors'; +import AddIcon from '@mui/icons-material/Add'; +import { useMutation } from '@tanstack/react-query'; +import { LoadingButton } from '@mui/lab'; +import { generateUniqueString } from '@mui/toolpad-utils/strings'; +import { PaginationMode } from '@mui/toolpad-core'; +import { Stack } from '@mui/system'; +import { EditorProps } from '../../types'; +import client from '../../api'; +import type { + DataProviderIntrospectionResult, + FileIntrospectionResult, +} from '../../server/functionsTypesWorker'; +import { projectEvents } from '../../projectEvents'; +import OpenCodeEditorButton from '../../components/OpenCodeEditor'; +import type { CreateDataProviderOptions } from '../../server/FunctionsManager'; + +const PAGINATION_DOCUMENTATION_URL = 'https://mui.com/toolpad/concepts/data-providers/#pagination'; + +projectEvents.on('functionsChanged', () => client.invalidateQueries('introspect', [])); + +function useFunctionsIntrospectQuery() { + return client.useQuery('introspect', []); +} + +function handleInputFocus(event: React.FocusEvent) { + event.target.select(); +} + +type DataProviderSelectorOption = + | { + kind: 'option'; + file: FileIntrospectionResult; + dataProvider: DataProviderIntrospectionResult; + displayName: string; + } + | { + kind: 'create'; + inputValue: string; + }; + +const filter = createFilterOptions(); + +const classes = { + editButton: 'DataProviderSelector_editButton', +}; + +const DataProviderSelectorRoot = styled('div')({ + [`& .${classes.editButton}`]: { + visibility: 'hidden', + }, + + [`&:hover .${classes.editButton}, & .${autocompleteClasses.focused} .${classes.editButton}`]: { + visibility: 'visible', + }, +}); + +interface CreateNewDataProviderDialogProps { + open: boolean; + onClose: () => void; + onCommit: (newName: string) => void; + existingNames: Set; + initialName: string; +} + +function CreateNewDataProviderDialog({ + open, + onClose, + onCommit, + existingNames, + initialName, +}: CreateNewDataProviderDialogProps) { + const [newName, setNewName] = React.useState(initialName); + React.useEffect(() => { + if (open) { + setNewName(initialName); + } + }, [open, initialName]); + + const [options, setOptions] = React.useState({ + paginationMode: 'index', + }); + + const createProviderMutation = useMutation({ + mutationKey: [newName, options], + mutationFn: () => client.methods.createDataProvider(newName, options), + onSuccess: () => { + onCommit(newName); + onClose(); + }, + }); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + createProviderMutation.mutate(); + }; + + const nameExists = existingNames.has(newName); + + const errorMessage = React.useMemo(() => { + if (nameExists) { + return `Provider "${newName}" already exists`; + } + if (createProviderMutation.error) { + return errorFrom(createProviderMutation.error).message; + } + return null; + }, [nameExists, createProviderMutation.error, newName]); + + const paginationModeSelectId = React.useId(); + + return ( +

+
+ Create a new data provider + + + To create a new data provider please enter the name here. + + + setNewName(event.target.value)} + label="name" + type="text" + onFocus={handleInputFocus} + required + error={!!errorMessage} + helperText={errorMessage} + /> + + + Pagination mode + + setOptions((existing) => ({ + ...existing, + paginationMode: event.target.value as PaginationMode, + })) + } + > + } label="Index based" /> + } label="Cursor based" /> + + + How is your backend data paginated? By index, or by cursor? Find more about + pagination modes in the{' '} + + documentation + + . + + + + + + + + Create + + +
+
+ ); +} + +function getProviderNameFromFile(file: FileIntrospectionResult): string { + return file.name.replace(/\.[^.]+$/, ''); +} + +function DataProviderSelector({ value, onChange }: EditorProps) { + const { data: introspection, isLoading, error } = useFunctionsIntrospectQuery(); + + const options = React.useMemo(() => { + return ( + introspection?.files.flatMap((file) => + file.dataProviders + ?.filter((dataProvider) => dataProvider.name === 'default') + .map((dataProvider) => ({ + kind: 'option', + file, + dataProvider, + displayName: getProviderNameFromFile(file), + })), + ) ?? [] + ); + }, [introspection]); + + const [fileName = null, providerName = null] = value ? value.split(':') : []; + + const autocompleteValue = React.useMemo(() => { + return ( + options.find( + (option) => + option.kind === 'option' && + option.file.name === fileName && + option.dataProvider.name === providerName, + ) ?? null + ); + }, [fileName, providerName, options]); + + const errorMessage = error ? errorFrom(error).message : undefined; + + const [dialogOpen, setDialogOpen] = React.useState(false); + const handleClose = () => { + setDialogOpen(false); + }; + const [dialogValue, setDialogValue] = React.useState(''); + + const existingNames = React.useMemo( + () => new Set(introspection?.files.map((file) => getProviderNameFromFile(file))), + [introspection], + ); + + const handleCreateNewDataProvider = React.useCallback((suggestion: string) => { + setDialogValue(suggestion); + setDialogOpen(true); + }, []); + + return ( + + onChange(`${newName}.ts:default`)} + initialName={dialogValue} + existingNames={existingNames} + /> + + { + if (typeof option === 'string' || option.kind === 'create') { + const inputValue = typeof option === 'string' ? option : option.inputValue; + return inputValue ? `Create data provider "${inputValue}"` : 'Create new data provider'; + } + return option.displayName; + }} + renderInput={(params) => ( + + {fileName ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }} + label="Data Provider" + placeholder="Click to create or select a data provider" + error={!!errorMessage} + helperText={errorMessage} + /> + )} + filterOptions={(unfilteredOptions, params) => { + const filtered = filter(unfilteredOptions, params); + + if (!existingNames.has(params.inputValue)) { + filtered.push({ + kind: 'create', + inputValue: params.inputValue, + }); + } + + return filtered; + }} + value={autocompleteValue} + loading={isLoading} + onChange={(event, newValue) => { + if (typeof newValue === 'string') { + handleCreateNewDataProvider( + newValue || generateUniqueString('dataProvider', existingNames), + ); + } else if (newValue && newValue.kind === 'create') { + handleCreateNewDataProvider( + newValue.inputValue || generateUniqueString('dataProvider', existingNames), + ); + } else { + onChange(newValue ? `${newValue.file.name}:${newValue.dataProvider.name}` : undefined); + } + }} + renderOption={(props, option, state, ownerState) => { + const icon = option.kind === 'create' ? : undefined; + return ( +
  • + + {icon} + {ownerState.getOptionLabel(option)} + +
  • + ); + }} + selectOnFocus + clearOnBlur + freeSolo + sx={{ flex: 1 }} + /> +
    + ); +} + +export default DataProviderSelector; diff --git a/packages/toolpad-app/src/toolpad/propertyControls/GridColumns.tsx b/packages/toolpad-app/src/toolpad/propertyControls/GridColumns.tsx index f862b5a22a7..d0f061a66fc 100644 --- a/packages/toolpad-app/src/toolpad/propertyControls/GridColumns.tsx +++ b/packages/toolpad-app/src/toolpad/propertyControls/GridColumns.tsx @@ -267,7 +267,7 @@ function GridColumnsPropEditor({ onChange, disabled, }: EditorProps) { - const { bindings } = usePageEditorState(); + const { nodeData } = usePageEditorState(); const [editedIndex, setEditedIndex] = React.useState(null); const editedColumn = typeof editedIndex === 'number' ? value[editedIndex] : null; @@ -281,19 +281,17 @@ function GridColumnsPropEditor({ setMenuAnchorEl(null); }; - const rowsValue = nodeId && bindings[`${nodeId}.props.rows`]; - const definedRows: unknown = rowsValue?.value; + const rawRows: unknown = nodeId && nodeData[nodeId]?.rawRows; const inferredColumns = React.useMemo( - () => inferColumns(Array.isArray(definedRows) ? definedRows : []), - [definedRows], + () => inferColumns(Array.isArray(rawRows) ? rawRows : []), + [rawRows], ); const columnSuggestions = React.useMemo(() => { const existingFields = new Set(value.map(({ field }) => field)); return inferredColumns.filter((column) => !existingFields.has(column.field)); }, [inferredColumns, value]); - const hasColumnSuggestions = columnSuggestions.length > 0; const handleCreateColumn = React.useCallback( (suggestion: SerializableGridColumn) => () => { @@ -330,10 +328,10 @@ function GridColumnsPropEditor({ ); const handleRecreateColumns = React.useCallback(() => { - if (hasColumnSuggestions) { + if (inferredColumns.length > 0) { onChange(inferredColumns); } - }, [hasColumnSuggestions, inferredColumns, onChange]); + }, [inferredColumns, onChange]); const [anchorEl, setAnchorEl] = React.useState(null); @@ -394,7 +392,7 @@ function GridColumnsPropEditor({ diff --git a/packages/toolpad-app/src/toolpad/propertyControls/ToggleButtons.tsx b/packages/toolpad-app/src/toolpad/propertyControls/ToggleButtons.tsx new file mode 100644 index 00000000000..0e90740c29d --- /dev/null +++ b/packages/toolpad-app/src/toolpad/propertyControls/ToggleButtons.tsx @@ -0,0 +1,54 @@ +import { + ToggleButton, + ToggleButtonGroup, + Typography, + styled, + toggleButtonClasses, +} from '@mui/material'; +import * as React from 'react'; +import type { EditorProps } from '../../types'; +import PropertyControl from '../../components/PropertyControl'; + +const PropControlToggleButtonGroup = styled(ToggleButtonGroup)({ + display: 'flex', + [`& .${toggleButtonClasses.root}`]: { + flex: 1, + }, +}); + +function SelectPropEditor({ label, propType, value, onChange, disabled }: EditorProps) { + const items = propType.type === 'string' ? propType.enum ?? [] : []; + const handleChange = React.useCallback( + (event: React.MouseEvent, newValue: string) => { + onChange(newValue || undefined); + }, + [onChange], + ); + + const enumLabels: Record = + propType.type === 'string' ? propType.enumLabels ?? {} : {}; + + return ( + +
    + {label} + + {items.map((item) => ( + + {enumLabels[item] || item} + + ))} + +
    +
    + ); +} + +export default SelectPropEditor; diff --git a/packages/toolpad-app/src/toolpad/propertyControls/select.tsx b/packages/toolpad-app/src/toolpad/propertyControls/select.tsx index 858f4836d30..d47ce6ff6e4 100644 --- a/packages/toolpad-app/src/toolpad/propertyControls/select.tsx +++ b/packages/toolpad-app/src/toolpad/propertyControls/select.tsx @@ -12,6 +12,9 @@ function SelectPropEditor({ label, propType, value, onChange, disabled }: Editor [onChange], ); + const enumLabels: Record = + propType.type === 'string' ? propType.enumLabels ?? {} : {}; + return ( - : null} {items.map((item) => ( - {item} + {enumLabels[item] || item} ))} diff --git a/packages/toolpad-app/src/toolpadDataSources/local/client.tsx b/packages/toolpad-app/src/toolpadDataSources/local/client.tsx index 784145a559b..5660e20f171 100644 --- a/packages/toolpad-app/src/toolpadDataSources/local/client.tsx +++ b/packages/toolpad-app/src/toolpadDataSources/local/client.tsx @@ -79,7 +79,7 @@ function HandlerFileTreeItem({ file }: HandlerFileTreeItemProps) { {file.name} - + } > diff --git a/packages/toolpad-app/src/types.ts b/packages/toolpad-app/src/types.ts index c3a3ce3214e..18172f0b231 100644 --- a/packages/toolpad-app/src/types.ts +++ b/packages/toolpad-app/src/types.ts @@ -194,15 +194,17 @@ export interface AppCanvasState extends RuntimeState { export type ProjectEvents = { // a change in the DOM - change: { fingerprint: number }; + change: {}; // a change in the DOM caused by an external action (e.g. user editing a file outside of toolpad) - externalChange: { fingerprint: number }; + externalChange: {}; // a component has been added or removed componentsListChanged: {}; // the function runtime build has finished queriesInvalidated: {}; // An environment variable has changed envChanged: {}; + // Functions or datasources have been updated + functionsChanged: {}; }; export interface ToolpadProjectOptions { @@ -212,3 +214,5 @@ export interface ToolpadProjectOptions { base: string; customServer: boolean; } + +export type CodeEditorFileType = 'resource' | 'component'; diff --git a/packages/toolpad-components/package.json b/packages/toolpad-components/package.json index 987bc32bc03..df0a12d9558 100644 --- a/packages/toolpad-components/package.json +++ b/packages/toolpad-components/package.json @@ -46,7 +46,9 @@ "@mui/x-data-grid-pro": "6.16.1", "@mui/x-date-pickers": "6.16.1", "@mui/x-license-pro": "6.10.2", + "@tanstack/react-query": "4.35.3", "dayjs": "1.11.10", + "invariant": "2.2.4", "markdown-to-jsx": "7.3.2", "react-error-boundary": "4.0.11", "react-hook-form": "7.46.2", diff --git a/packages/toolpad-components/src/DataGrid.tsx b/packages/toolpad-components/src/DataGrid.tsx index e58409f07c7..dda1fdecef6 100644 --- a/packages/toolpad-components/src/DataGrid.tsx +++ b/packages/toolpad-components/src/DataGrid.tsx @@ -19,13 +19,20 @@ import { useGridSelector, getGridDefaultColumnTypes, GridColTypeDef, + GridPaginationModel, } from '@mui/x-data-grid-pro'; import { Unstable_LicenseInfoProvider as LicenseInfoProvider, Unstable_LicenseInfoProviderProps as LicenseInfoProviderProps, } from '@mui/x-license-pro'; import * as React from 'react'; -import { useNode, useComponents } from '@mui/toolpad-core'; +import { + useNode, + useComponents, + UseDataProviderContext, + CursorPaginationModel, + IndexPaginationModel, +} from '@mui/toolpad-core'; import { Box, debounce, @@ -41,6 +48,9 @@ import { getObjectKey } from '@mui/toolpad-utils/objectKey'; import { errorFrom } from '@mui/toolpad-utils/errors'; import { hasImageExtension } from '@mui/toolpad-utils/path'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; +import { useNonNullableContext } from '@mui/toolpad-utils/react'; +import { useQuery } 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 createBuiltin from './createBuiltin'; @@ -450,6 +460,8 @@ interface Selection { } interface ToolpadDataGridProps extends Omit { + rowsSource?: 'prop' | 'dataProvider'; + dataProviderId?: string; rows?: GridRowsProp; columns?: SerializableGridColumns; height?: number; @@ -458,6 +470,107 @@ interface ToolpadDataGridProps extends Omit void; hideToolbar?: boolean; + rawRows?: GridRowsProp; + onRawRowsChange?: (rows: GridRowsProp) => void; +} + +interface DataProviderDataGridProps extends Partial { + error?: unknown; +} + +function useDataProviderDataGridProps( + dataProviderId: string | null | undefined, +): DataProviderDataGridProps { + const useDataProvider = useNonNullableContext(UseDataProviderContext); + const { dataProvider } = useDataProvider(dataProviderId || null); + + const [paginationModel, setPaginationModel] = React.useState({ + page: 0, + pageSize: 100, + }); + + const { page, pageSize } = paginationModel; + + const mapPageToNextCursor = React.useRef(new Map()); + + const { data, isFetching, isPreviousData, isLoading, error } = useQuery({ + enabled: !!dataProvider, + queryKey: ['toolpadDataProvider', dataProviderId, page, pageSize], + keepPreviousData: true, + queryFn: async () => { + invariant(dataProvider, 'dataProvider must be defined'); + let dataProviderPaginationModel: IndexPaginationModel | CursorPaginationModel; + if (dataProvider.paginationMode === 'cursor') { + // cursor based pagination + let cursor: string | null = null; + if (page !== 0) { + cursor = mapPageToNextCursor.current.get(page - 1) ?? null; + if (cursor === null) { + throw new Error(`No cursor found for page ${page - 1}`); + } + } + dataProviderPaginationModel = { + cursor, + pageSize, + } satisfies CursorPaginationModel; + } else { + // index based pagination + dataProviderPaginationModel = { + start: page * pageSize, + pageSize, + } satisfies IndexPaginationModel; + } + + const result = await dataProvider.getRecords({ + paginationModel: dataProviderPaginationModel, + }); + + if (dataProvider.paginationMode === 'cursor') { + if (typeof result.cursor === 'undefined') { + throw new Error( + `No cursor returned for page ${page}. Return \`null\` to signal the end of the data.`, + ); + } + + if (typeof result.cursor === 'string') { + mapPageToNextCursor.current.set(page, result.cursor); + } + } + + return result; + }, + }); + + const rowCount = + data?.totalCount ?? + (data?.hasNextPage ? (paginationModel.page + 1) * paginationModel.pageSize + 1 : undefined) ?? + 0; + + if (!dataProvider) { + return {}; + } + + return { + loading: isLoading || (isPreviousData && isFetching), + paginationMode: 'server', + pagination: true, + paginationModel, + rowCount, + onPaginationModelChange(model) { + setPaginationModel((prevModel) => { + if (prevModel.pageSize !== model.pageSize) { + return { ...model, page: 0 }; + } + return model; + }); + }, + rows: data?.records ?? [], + error, + }; +} + +function dataGridFallbackRender({ error }: FallbackProps) { + return ; } const DataGridComponent = React.forwardRef(function DataGridComponent( @@ -470,10 +583,17 @@ const DataGridComponent = React.forwardRef(function DataGridComponent( selection, onSelectionChange, hideToolbar, + rowsSource, + dataProviderId, + onRawRowsChange, ...props }: ToolpadDataGridProps, ref: React.ForwardedRef, ) { + const { rows: dataProviderRowsInput, ...dataProviderProps } = useDataProviderDataGridProps( + rowsSource === 'dataProvider' ? dataProviderId : null, + ); + const nodeRuntime = useNode(); const handleResize = React.useMemo( @@ -520,7 +640,12 @@ const DataGridComponent = React.forwardRef(function DataGridComponent( ); React.useEffect(() => handleColumnOrderChange.clear(), [handleColumnOrderChange]); - const rowsInput = rowsProp || EMPTY_ROWS; + let rowsInput: GridRowsProp; + if (rowsSource === 'dataProvider') { + rowsInput = dataProviderRowsInput ?? EMPTY_ROWS; + } else { + rowsInput = rowsProp ?? EMPTY_ROWS; + } const hasExplicitRowId: boolean = React.useMemo(() => { const hasRowIdField: boolean = !!(rowIdFieldProp && rowIdFieldProp !== 'id'); @@ -587,7 +712,16 @@ const DataGridComponent = React.forwardRef(function DataGridComponent( [getRowId, columns], ); - const error: Error | null = errorProp ? errorFrom(errorProp) : null; + let error: Error | null = null; + if (dataProviderProps?.error) { + error = errorFrom(dataProviderProps.error); + } else if (errorProp) { + error = errorFrom(errorProp); + } + + React.useEffect(() => { + nodeRuntime?.updateEditorNodeData('rawRows', rows); + }, [nodeRuntime, rows]); return ( @@ -604,22 +738,25 @@ const DataGridComponent = React.forwardRef(function DataGridComponent( visibility: error ? 'hidden' : 'visible', }} > - + + + @@ -634,6 +771,18 @@ export default createBuiltin(DataGridComponent, { loadingProp: 'loading', resizableHeightProp: 'height', argTypes: { + rowsSource: { + helperText: 'Defines how rows are provided to the grid.', + type: 'string', + enum: ['prop', 'dataProvider'], + enumLabels: { + prop: 'Direct', + dataProvider: 'Data provider', + }, + default: 'prop', + label: 'Rows source', + control: { type: 'ToggleButtons', bindable: false }, + }, rows: { helperText: 'The data to be displayed as rows. Must be an array of objects.', type: 'array', @@ -650,6 +799,13 @@ export default createBuiltin(DataGridComponent, { required: ['id'], }, }, + visible: ({ rowsSource }: ToolpadDataGridProps) => rowsSource === 'prop', + }, + dataProviderId: { + helperText: 'The backend data provider that will supply the rows to this grid', + type: 'string', + control: { type: 'DataProviderSelector', bindable: false }, + visible: ({ rowsSource }: ToolpadDataGridProps) => rowsSource === 'dataProvider', }, columns: { helperText: 'The columns to be displayed.', diff --git a/packages/toolpad-core/src/index.tsx b/packages/toolpad-core/src/index.tsx index 47a8fba10c2..2c28519075a 100644 --- a/packages/toolpad-core/src/index.tsx +++ b/packages/toolpad-core/src/index.tsx @@ -16,6 +16,7 @@ export { useComponents, ComponentsContextProvider, useComponent, + UseDataProviderContext, } from './runtime'; export type FlowDirection = 'row' | 'column' | 'row-reverse' | 'column-reverse'; diff --git a/packages/toolpad-core/src/runtime.tsx b/packages/toolpad-core/src/runtime.tsx index 9bf6e509732..9dea160561f 100644 --- a/packages/toolpad-core/src/runtime.tsx +++ b/packages/toolpad-core/src/runtime.tsx @@ -7,7 +7,15 @@ import { createProvidedContext } from '@mui/toolpad-utils/react'; import { Stack } from '@mui/material'; import { RuntimeEvents, ToolpadComponents, ToolpadComponent, ArgTypeDefinition } from './types'; import { RUNTIME_PROP_NODE_ID, RUNTIME_PROP_SLOTS, TOOLPAD_COMPONENT } from './constants'; -import type { SlotType, ComponentConfig, RuntimeEvent, RuntimeError } from './types'; +import type { + SlotType, + ComponentConfig, + RuntimeEvent, + RuntimeError, + PaginationMode, + ToolpadDataProviderBase, + NodeId, +} from './types'; import { createComponent } from './browser'; const ResetNodeErrorsKeyContext = React.createContext(0); @@ -25,7 +33,7 @@ declare global { } export const NodeRuntimeContext = React.createContext<{ - nodeId: string | null; + nodeId: NodeId | null; nodeName: string | null; }>({ nodeId: null, @@ -104,7 +112,7 @@ function NodeFiberHost({ children }: NodeFiberHostProps) { export interface NodeRuntimeWrapperProps { children: React.ReactElement; - nodeId: string; + nodeId: NodeId; nodeName: string; componentConfig: ComponentConfig; NodeError: React.ComponentType; @@ -159,6 +167,7 @@ export interface NodeRuntime

    { key: K, value: React.SetStateAction, ) => void; + updateEditorNodeData: (key: string, value: any) => void; } export function useNode

    (): NodeRuntime

    | null { @@ -179,6 +188,13 @@ export function useNode

    (): NodeRuntime

    | null { value, }); }, + updateEditorNodeData: (prop: string, value: any) => { + canvasEvents.emit('editorNodeDataUpdated', { + nodeId, + prop, + value, + }); + }, } satisfies NodeRuntime

    ; }, [canvasEvents, nodeId, nodeName]); } @@ -274,3 +290,19 @@ export function useComponent(id: string) { ); }, [components, id]); } + +export interface ToolpadDataProviderIntrospection { + paginationMode: PaginationMode; +} + +export interface UseDataProviderHookResult { + isLoading: boolean; + error?: unknown; + dataProvider: ToolpadDataProviderBase | null; +} + +export interface UseDataProviderHook { + (id: string | null): UseDataProviderHookResult; +} + +export const UseDataProviderContext = React.createContext(null); diff --git a/packages/toolpad-core/src/server.ts b/packages/toolpad-core/src/server.ts index afc594ed55d..cd360c52f3e 100644 --- a/packages/toolpad-core/src/server.ts +++ b/packages/toolpad-core/src/server.ts @@ -1,7 +1,13 @@ /// import { TOOLPAD_FUNCTION } from './constants'; -import { InferParameterType, PrimitiveValueType, PropValueType } from './types'; +import { + InferParameterType, + PaginationMode, + PrimitiveValueType, + PropValueType, + ToolpadDataProviderBase, +} from './types'; import { ServerContext, getServerContext } from './serverRuntime'; /** @@ -99,7 +105,7 @@ export type { ServerContext }; * * API: * - * - [getContext API](https://mui.com/toolpad/reference/api/get-context) + * - [`getContext` API](https://mui.com/toolpad/reference/api/get-context) * */ export function getContext(): ServerContext { @@ -109,3 +115,28 @@ export function getContext(): ServerContext { } return ctx; } + +export const TOOLPAD_DATA_PROVIDER_MARKER = Symbol.for('TOOLPAD_DATA_PROVIDER_MARKER'); + +export interface ToolpadDataProvider + extends ToolpadDataProviderBase { + [TOOLPAD_DATA_PROVIDER_MARKER]: true; +} + +/** + * Create a Toolpad data provider. Data providers act as a bridge between Toolpad and your data. + * + * Demos: + * + * - [Data Providers](https://mui.com/toolpad/concepts/data-providers/) + * + * API: + * + * - [`createDataProvider` API](https://mui.com/toolpad/reference/api/create-data-provider/) + * + */ +export function createDataProvider( + input: ToolpadDataProviderBase, +): ToolpadDataProvider { + return Object.assign(input, { [TOOLPAD_DATA_PROVIDER_MARKER]: true as const }); +} diff --git a/packages/toolpad-core/src/types.ts b/packages/toolpad-core/src/types.ts index 8131bc82ce8..b739b329f72 100644 --- a/packages/toolpad-core/src/types.ts +++ b/packages/toolpad-core/src/types.ts @@ -87,6 +87,7 @@ export interface StringValueType extends ValueTypeBase { * The different possible values for the property. */ enum?: string[]; + enumLabels?: Record; default?: string; } @@ -198,7 +199,9 @@ export interface ArgControlSpec { | 'event' | 'NumberFormat' | 'ColorScale' - | 'RowIdFieldSelect'; // Row id field specialized select + | 'ToggleButtons' + | 'RowIdFieldSelect' // Row id field specialized select + | 'DataProviderSelector'; // Row id field specialized select bindable?: boolean; hideLabel?: boolean; } @@ -372,6 +375,11 @@ export type RuntimeEvents = { prop: string; value: React.SetStateAction; }; + editorNodeDataUpdated: { + nodeId: NodeId; + prop: string; + value: any; + }; pageStateUpdated: { pageState: Record; globalScopeMeta: ScopeMeta; @@ -480,3 +488,38 @@ export interface ApplicationVm { scopes: { [id in string]?: RuntimeScope }; bindingScopes: { [id in string]?: string }; } + +export interface IndexPaginationModel { + start: number; + pageSize: number; +} + +export interface CursorPaginationModel { + cursor: string | null; + pageSize: number; +} + +export type PaginationMode = 'index' | 'cursor'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export interface GetRecordsParams { + paginationModel: P extends 'cursor' ? CursorPaginationModel : IndexPaginationModel; + // filterModel: FilterModel; + // sortModel: SortModel; +} + +export interface GetRecordsResult { + records: R[]; + hasNextPage?: boolean; + totalCount?: number; + cursor?: P extends 'cursor' ? string | null : undefined; +} + +export interface ToolpadDataProviderBase { + paginationMode?: P; + getRecords: (params: GetRecordsParams) => Promise>; + // getTotalCount?: () => Promise; + // updateRecord?: (id: string, record: R) => Promise; + // deleteRecord?: (id: string) => Promise; + // createRecord?: (record: R) => Promise; +} diff --git a/test/integration/backend-basic/fixture/toolpad/pages/basic/page.yml b/test/integration/backend-basic/fixture/toolpad/pages/basic/page.yml index 412e572986e..61bda8e54c0 100644 --- a/test/integration/backend-basic/fixture/toolpad/pages/basic/page.yml +++ b/test/integration/backend-basic/fixture/toolpad/pages/basic/page.yml @@ -6,7 +6,6 @@ spec: content: - component: Text name: typography - children: [] layout: columnSize: 1 props: @@ -15,7 +14,6 @@ spec: `hello, message: ${hello.data.message}` - component: Text name: typography1 - children: [] layout: columnSize: 1 props: @@ -24,7 +22,6 @@ spec: `throws, error.message: ${throws.error}` - component: Text name: typography2 - children: [] layout: columnSize: 1 props: @@ -33,7 +30,6 @@ spec: `throws, data ${throws.error ? "had an error" : throws.data}` - component: Text name: text4 - children: [] layout: columnSize: 1 props: @@ -42,7 +38,6 @@ spec: `throws, data ${throwsError1.data}` - component: DataGrid name: dataGrid - children: [] layout: columnSize: 1 props: @@ -51,7 +46,6 @@ spec: throws.data - component: Text name: typography3 - children: [] layout: columnSize: 1 props: @@ -67,17 +61,14 @@ spec: value: $$jsExpression: | `echo, secret: ${echo.data.secrets.bar}` - children: [] - component: Text name: typography5 props: value: $$jsExpression: | `echo, secret not in .env: ${echo.data.secrets.baz}` - children: [] - component: Text name: text5 - children: [] layout: columnSize: 1 props: @@ -86,7 +77,6 @@ spec: edited.data - component: Button name: button - children: [] layout: columnSize: 1 props: @@ -97,7 +87,6 @@ spec: $$jsExpressionAction: manualQuery.call() - component: Text name: text1 - children: [] layout: columnSize: 1 props: @@ -115,17 +104,14 @@ spec: $$jsExpressionAction: |- await increment.call() getGlobal.refetch() - children: [] - component: Text name: text props: value: $$jsExpression: | `global value: ${getGlobal.data}` - children: [] - component: Text name: text3 - children: [] layout: columnSize: 1 props: @@ -134,7 +120,6 @@ spec: `Propagated error: ${errorInput.error}` - component: Text name: text2 - children: [] layout: columnSize: 1 props: @@ -144,7 +129,6 @@ spec: ${propagatedLoading.isLoading}` - component: Text name: text6 - children: [] layout: columnSize: 1 props: @@ -153,7 +137,6 @@ spec: `Raw text: ${getRawText.data}` - component: Text name: text7 - children: [] layout: columnSize: 1 props: @@ -162,7 +145,6 @@ spec: `my custom cookie: ${context.data.cookies.MY_TOOLPAD_COOKIE}` - component: Button name: button2 - children: [] props: content: set cookie onClick: diff --git a/test/integration/backend-basic/fixture/toolpad/pages/dataProviders/page.yml b/test/integration/backend-basic/fixture/toolpad/pages/dataProviders/page.yml new file mode 100644 index 00000000000..ee32c75cfde --- /dev/null +++ b/test/integration/backend-basic/fixture/toolpad/pages/dataProviders/page.yml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: page +spec: + id: VnOzPpU + title: page + content: + - component: DataGrid + name: dataGrid + props: + dataProvider: myData.ts:default + dataProviderId: myIndexData.ts:default + columns: + - field: name + width: 135 + height: 242 + rowsSource: dataProvider + - component: DataGrid + name: dataGrid1 + props: + dataProviderId: myCursorData.ts:default + columns: + - field: name + width: 133 + height: 244 + rowsSource: dataProvider + display: shell diff --git a/test/integration/backend-basic/fixture/toolpad/pages/extractedTypes/page.yml b/test/integration/backend-basic/fixture/toolpad/pages/extractedTypes/page.yml index 0fe93d9ed05..694a0dcc04d 100644 --- a/test/integration/backend-basic/fixture/toolpad/pages/extractedTypes/page.yml +++ b/test/integration/backend-basic/fixture/toolpad/pages/extractedTypes/page.yml @@ -7,7 +7,6 @@ spec: content: - component: Text name: text - children: [] layout: columnSize: 1 props: @@ -16,7 +15,6 @@ spec: `bare function with parameters: ${bareWithParams.data?.message}` - component: Text name: text1 - children: [] layout: columnSize: 1 props: diff --git a/test/integration/backend-basic/fixture/toolpad/pages/serialization/page.yml b/test/integration/backend-basic/fixture/toolpad/pages/serialization/page.yml index f535c7625df..0a6605fc3e5 100644 --- a/test/integration/backend-basic/fixture/toolpad/pages/serialization/page.yml +++ b/test/integration/backend-basic/fixture/toolpad/pages/serialization/page.yml @@ -20,7 +20,6 @@ spec: content: - component: Text name: text - children: [] layout: columnSize: 1 props: @@ -29,7 +28,6 @@ spec: `Circlular property: ${circularData.data.a}` - component: Text name: text1 - children: [] layout: columnSize: 1 props: @@ -39,7 +37,6 @@ spec: ${nonCircularData.data.a1.b}:${nonCircularData.data.a2.b}` - component: Text name: text2 - children: [] layout: columnSize: 1 props: diff --git a/test/integration/backend-basic/fixture/toolpad/resources/myCursorData.ts b/test/integration/backend-basic/fixture/toolpad/resources/myCursorData.ts new file mode 100644 index 00000000000..2e363ec115c --- /dev/null +++ b/test/integration/backend-basic/fixture/toolpad/resources/myCursorData.ts @@ -0,0 +1,14 @@ +import { createDataProvider } from '@mui/toolpad-core/server'; + +const DATA = Array.from({ length: 1_000 }, (_, id) => ({ id, name: `Cursor item ${id}` })); + +export default createDataProvider({ + paginationMode: 'cursor', + async getRecords({ paginationModel: { cursor, pageSize } }) { + const start = cursor ? Number(cursor) : 0; + const end = start + pageSize; + const records = DATA.slice(start, end); + const nextCursor = DATA.length > end ? String(end) : null; + return { records, totalCount: DATA.length, cursor: nextCursor }; + }, +}); diff --git a/test/integration/backend-basic/fixture/toolpad/resources/myIndexData.ts b/test/integration/backend-basic/fixture/toolpad/resources/myIndexData.ts new file mode 100644 index 00000000000..8b19fec09dc --- /dev/null +++ b/test/integration/backend-basic/fixture/toolpad/resources/myIndexData.ts @@ -0,0 +1,10 @@ +import { createDataProvider } from '@mui/toolpad-core/server'; + +const 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 }; + }, +}); diff --git a/test/integration/backend-basic/index.spec.ts b/test/integration/backend-basic/index.spec.ts index 7939eb78565..a2fbc732874 100644 --- a/test/integration/backend-basic/index.spec.ts +++ b/test/integration/backend-basic/index.spec.ts @@ -7,9 +7,11 @@ import { waitForMatch } from '../../utils/streams'; import { expectBasicPageContent } from './shared'; import { setPageHidden } from '../../utils/page'; import { withTemporaryEdits } from '../../utils/fs'; +import clickCenter from '../../utils/clickCenter'; const BASIC_TESTS_PAGE_ID = '5q1xd0t'; const EXTRACTED_TYPES_PAGE_ID = 'dt1T4rY'; +const DATA_PROVIDERS_PAGE_ID = 'VnOzPpU'; test.use({ ignoreConsoleErrors: [ @@ -180,3 +182,32 @@ test('function editor extracted parameters', async ({ page, localApp }) => { await expect(queryEditor.getByRole('textbox', { name: 'buzz', exact: true })).toBeVisible(); }); + +test('data providers', async ({ page }) => { + const editorModel = new ToolpadEditor(page); + await editorModel.goToPageById(DATA_PROVIDERS_PAGE_ID); + + await editorModel.waitForOverlay(); + + const grid1 = editorModel.appCanvas.getByRole('grid').nth(0); + const grid2 = editorModel.appCanvas.getByRole('grid').nth(1); + + await expect(grid1.getByText('Index item 0')).toBeVisible(); + await expect(grid2.getByText('Cursor item 0')).toBeVisible(); + + await clickCenter(page, grid1); + + await grid1.getByRole('button', { name: 'Go to next page' }).click(); + await expect(grid1.getByText('Index item 100')).toBeVisible(); + + await clickCenter(page, grid2); + + await grid2.getByRole('button', { name: 'Go to next page' }).click(); + await expect(grid2.getByText('Cursor item 100')).toBeVisible(); + await expect(grid2.getByText('Cursor item 0')).not.toBeVisible(); + + await grid2.getByRole('combobox', { name: 'Rows per page:' }).click(); + await editorModel.appCanvas.getByRole('option', { name: '25', exact: true }).click(); + + await expect(grid2.getByText('Cursor item 0')).toBeVisible(); +}); diff --git a/test/integration/mysql-basic/index.spec.ts b/test/integration/mysql-basic/index.spec.ts index 3570ce77fb3..ebd6dbf54d4 100644 --- a/test/integration/mysql-basic/index.spec.ts +++ b/test/integration/mysql-basic/index.spec.ts @@ -24,7 +24,7 @@ test('mysql basics', async ({ page, api }) => { return value; }); - const app = await api.mutation.createApp(`App ${generateId()}`, { + const app = await api.methods.createApp(`App ${generateId()}`, { from: { kind: 'dom', dom }, });