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 00000000000..a5cc1f6ea12 Binary files /dev/null and b/docs/public/static/toolpad/docs/concepts/data-providers/create-data-provider-dialog.png differ diff --git a/docs/public/static/toolpad/docs/concepts/data-providers/create-data-provider.png b/docs/public/static/toolpad/docs/concepts/data-providers/create-data-provider.png new file mode 100644 index 00000000000..8e186af2a38 Binary files /dev/null and b/docs/public/static/toolpad/docs/concepts/data-providers/create-data-provider.png differ 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 00000000000..12e7c091cb1 Binary files /dev/null and b/docs/public/static/toolpad/docs/concepts/data-providers/open-editor.png differ 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 00000000000..d6a317da42a Binary files /dev/null and b/docs/public/static/toolpad/docs/concepts/data-providers/rows-source.png differ 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 }, });