diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index caead74b0f1..adde006d626 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -157,10 +157,10 @@ Some application examples for different JavaScript frameworks (such as Next.js, pnpm dev ``` -3. Run any application in the `playground` folder in development mode, such as `toolpad-core-nextjs` +3. Run any application in the `playground` folder in development mode, such as `playground-nextjs` ```bash - cd playground/toolpad-core-nextjs + cd playground/playground-nextjs ``` ```bash diff --git a/docs/data/toolpad/core/introduction/Tutorial3.js b/docs/data/toolpad/core/introduction/Tutorial3.js index b9b89115743..4301cb95b38 100644 --- a/docs/data/toolpad/core/introduction/Tutorial3.js +++ b/docs/data/toolpad/core/introduction/Tutorial3.js @@ -1,6 +1,7 @@ import * as React from 'react'; import { createDataProvider, DataContext } from '@toolpad/core/DataProvider'; import { DataGrid } from '@toolpad/core/DataGrid'; +import { useSearchParamState } from '@toolpad/core/useSearchParamState'; import { LineChart } from '@toolpad/core/LineChart'; import Stack from '@mui/material/Stack'; import TextField from '@mui/material/TextField'; @@ -27,7 +28,7 @@ const npmData = createDataProvider({ }); export default function Tutorial3() { - const [range, setRange] = React.useState('last-month'); + const [range, setRange] = useSearchParamState('range', 'last-month'); const filter = React.useMemo(() => ({ range: { equals: range } }), [range]); return ( diff --git a/docs/data/toolpad/core/introduction/Tutorial3.tsx b/docs/data/toolpad/core/introduction/Tutorial3.tsx index b9b89115743..4301cb95b38 100644 --- a/docs/data/toolpad/core/introduction/Tutorial3.tsx +++ b/docs/data/toolpad/core/introduction/Tutorial3.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { createDataProvider, DataContext } from '@toolpad/core/DataProvider'; import { DataGrid } from '@toolpad/core/DataGrid'; +import { useSearchParamState } from '@toolpad/core/useSearchParamState'; import { LineChart } from '@toolpad/core/LineChart'; import Stack from '@mui/material/Stack'; import TextField from '@mui/material/TextField'; @@ -27,7 +28,7 @@ const npmData = createDataProvider({ }); export default function Tutorial3() { - const [range, setRange] = React.useState('last-month'); + const [range, setRange] = useSearchParamState('range', 'last-month'); const filter = React.useMemo(() => ({ range: { equals: range } }), [range]); return ( diff --git a/docs/data/toolpad/core/introduction/tutorial.md b/docs/data/toolpad/core/introduction/tutorial.md index 765a37077e4..68e9b6c6a88 100644 --- a/docs/data/toolpad/core/introduction/tutorial.md +++ b/docs/data/toolpad/core/introduction/tutorial.md @@ -200,34 +200,57 @@ The result is the following: ### Global Filtering -Wrap the dashboard with a `DataContext` to apply global filtering: +Many dashboards require interactivity. Global filtering that applies to the whole page. Toolpad Core allows creating a data context that centralizes this filtering. Every data provider used under this context has the default filter applied. There is also a `useSearchParamState` hook available that enable you to persist filter values to the url. Just like the `React.useState` hook, it returns a state variable and an set function. ```js -const [range, setRange] = React.useState('last-month'); +// The range is persisted to the ?range=... url search parameter +const [range, setRange] = useSearchParamState('range', 'last-month'); const filter = React.useMemo(() => ({ range: { equals: range } }), [range]); +``` -// ... +Wrap the dashboard with a `DataContext` to apply global filtering. The filter you pass to this context is applied to any data provider used underneath. -return ( - +```js +export default function App() { + const [range, setRange] = useSearchParamState('range', 'last-month'); + const filter = React.useMemo(() => ({ range: { equals: range } }), [range]); + return ( - - setRange(e.target.value)} - > - Last Month - Last Year - - - {/* ... */} + + + + - -); + ); +} ``` -Any data provider that is used under this context now by default applies this filter. +The result looks as follows. Try to change the range to see it persisted to the url. {{"demo": "Tutorial3.js", "hideToolbar": true}} + +## Conclusion + +This concludes the mini tutorial that brings you from zero to a working dashboard. You can check out the final code of this tutorial with the `create-toolpad-app` CLI: + + + + +```bash npm +npx create-toolpad-app@latest --core --example core-tutorial +``` + +```bash pnpm +pnpm create toolpad-app@latest --core --example core-tutorial +``` + +```bash yarn +yarn create toolpad-app@latest --core --example core-tutorial +``` + + diff --git a/docs/pages/toolpad/core/api/use-search-param-state.json b/docs/pages/toolpad/core/api/use-search-param-state.json new file mode 100644 index 00000000000..dd2850699c6 --- /dev/null +++ b/docs/pages/toolpad/core/api/use-search-param-state.json @@ -0,0 +1,11 @@ +{ + "parameters": {}, + "returnValue": {}, + "name": "useSearchParamState", + "filename": "/packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx", + "imports": [ + "import useSearchParamState from '/packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx';", + "import { useSearchParamState } from '/packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx';" + ], + "demos": "" +} diff --git a/docs/translations/api-docs/use-search-param-state/use-search-param-state.json b/docs/translations/api-docs/use-search-param-state/use-search-param-state.json new file mode 100644 index 00000000000..3a3d4bbce00 --- /dev/null +++ b/docs/translations/api-docs/use-search-param-state/use-search-param-state.json @@ -0,0 +1,5 @@ +{ + "hookDescription": "Works like the React.useState hook, but synchronises the state with a URL query parameter named \"name\".", + "parametersDescriptions": {}, + "returnValueDescriptions": {} +} diff --git a/package.json b/package.json index 36cc9762324..8183616f9ae 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "markdownlint": "markdownlint-cli2 \"**/*.md\"", "prettier": "pretty-quick --ignore-path .eslintignore", "prettier:all": "prettier --write . --ignore-path .eslintignore", - "dev": "dotenv cross-env FORCE_COLOR=1 lerna -- run dev --stream --parallel --ignore docs --ignore toolpad-core-nextjs", + "dev": "dotenv cross-env FORCE_COLOR=1 lerna -- run dev --stream --parallel --ignore docs --ignore playground-nextjs", "docs:dev": "pnpm --filter docs dev", "docs:build": "pnpm --filter docs build", "docs:build:api:core": "tsx --tsconfig ./scripts/tsconfig.json ./scripts/docs/buildCoreApiDocs/index.ts", diff --git a/packages/toolpad-core/src/index.ts b/packages/toolpad-core/src/index.ts index ac93cc55f24..691c6cb498b 100644 --- a/packages/toolpad-core/src/index.ts +++ b/packages/toolpad-core/src/index.ts @@ -7,3 +7,5 @@ export * from './DataProvider'; export * from './DataGrid'; export * from './LineChart'; + +export * from './useSearchParamState'; diff --git a/packages/toolpad-core/src/useSearchParamState/index.ts b/packages/toolpad-core/src/useSearchParamState/index.ts new file mode 100644 index 00000000000..5dfc051f74b --- /dev/null +++ b/packages/toolpad-core/src/useSearchParamState/index.ts @@ -0,0 +1 @@ +export * from './useSearchParamState'; diff --git a/packages/toolpad-core/src/useSearchParamState/useSearchParamState.spec.tsx b/packages/toolpad-core/src/useSearchParamState/useSearchParamState.spec.tsx new file mode 100644 index 00000000000..82227b30567 --- /dev/null +++ b/packages/toolpad-core/src/useSearchParamState/useSearchParamState.spec.tsx @@ -0,0 +1,46 @@ +/** + * @vitest-environment jsdom + */ + +import { renderHook } from '@testing-library/react'; +import { describe, expect, test, afterEach, vi } from 'vitest'; +import { useSearchParamState } from './useSearchParamState'; + +describe('useSearchParamState', () => { + afterEach(() => { + window.history.pushState({}, '', ''); + }); + + test('should save state to a search parameter', () => { + const { result, rerender } = renderHook(() => useSearchParamState('foo', 'bar')); + + expect(result.current[0]).toBe('bar'); + expect(window.location.search).toBe(''); + result.current[1]('baz'); + + rerender(); + + expect(result.current[0]).toBe('baz'); + expect(window.location.search).toBe('?foo=baz'); + result.current[1]('bar'); + + rerender(); + + expect(result.current[0]).toBe('bar'); + expect(window.location.search).toBe(''); + }); + + test('should read state from a search parameter', () => { + window.history.pushState({}, '', '?foo=baz'); + + const { result, rerender } = renderHook(() => useSearchParamState('foo', 'bar')); + + expect(result.current[0]).toBe('baz'); + + window.history.pushState({}, '', '?foo=bar'); + + rerender(); + + expect(result.current[0]).toBe('bar'); + }); +}); diff --git a/packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx b/packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx new file mode 100644 index 00000000000..dd3e0a80ce4 --- /dev/null +++ b/packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx @@ -0,0 +1,132 @@ +import * as React from 'react'; + +export interface Codec { + parse: (value: string) => V; + stringify: (value: V) => string; +} + +type UseSearchParamStateOptions = { + defaultValue?: V; + history?: 'push' | 'replace'; + codec?: Codec; +} & (V extends string ? {} : { codec: Codec }); + +interface NavigationEvent { + destination: { url: URL }; + navigationType: 'push' | 'replace'; +} + +const navigateEventHandlers = new Set<(event: NavigationEvent) => void>(); + +if (typeof window !== 'undefined') { + const origHistoryPushState = window.history.pushState; + const origHistoryReplaceState = window.history.replaceState; + const wrapHistoryMethod = ( + navigationType: 'push' | 'replace', + origMethod: typeof origHistoryPushState, + ): typeof origHistoryPushState => { + return function historyMethod(this: History, data, title, url?: string | URL | null): void { + if (url === null || url === undefined) { + return; + } + + const event = { + destination: { url: new URL(url, window.location.href) }, + navigationType, + }; + + Promise.resolve().then(() => { + navigateEventHandlers.forEach((handler) => { + handler(event); + }); + }); + + origMethod.call(this, data, title, url); + }; + }; + window.history.pushState = wrapHistoryMethod('push', origHistoryPushState); + window.history.replaceState = wrapHistoryMethod('replace', origHistoryReplaceState); +} + +function navigate(url: string, options: { history?: 'push' | 'replace' } = {}) { + const history = options.history ?? 'push'; + if (history === 'push') { + window.history.pushState(null, '', url); + } else { + window.history.replaceState(null, '', url); + } +} + +function addNavigateEventListener(handler: (event: NavigationEvent) => void) { + navigateEventHandlers.add(handler); +} + +function removeNavigateEventListener(handler: (event: NavigationEvent) => void) { + navigateEventHandlers.delete(handler); +} + +function encode(codec: Codec, value: V | null): string | null { + return value === null ? null : codec.stringify(value); +} + +function decode(codec: Codec, value: string | null): V | null { + return value === null ? null : codec.parse(value); +} + +/** + * Works like the React.useState hook, but synchronises the state with a URL query parameter named "name". + * + * API: + * + * - [useSearchParamState API](https://mui.com/toolpad/core/api/use-search-param-state/) + */ +export function useSearchParamState( + name: string, + initialValue: V, + ...args: V extends string ? [UseSearchParamStateOptions?] : [UseSearchParamStateOptions] +): [V, (newValue: V) => void] { + const [options] = args; + const { codec } = options ?? {}; + + const subscribe = React.useCallback((cb: () => void) => { + const handler = () => { + cb(); + }; + addNavigateEventListener(handler); + return () => { + removeNavigateEventListener(handler); + }; + }, []); + const getSnapshot = React.useCallback(() => { + return new URL(window.location.href).searchParams.get(name); + }, [name]); + const getServerSnapshot = React.useCallback(() => null, []); + const rawValue = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const setValue = React.useCallback( + (value: V | null) => { + const url = new URL(window.location.href); + const stringValue = codec ? encode(codec, value) : (value as string); + + if (stringValue === null) { + url.searchParams.delete(name); + } else { + const stringDefaultValue = codec ? encode(codec, initialValue) : initialValue; + + if (stringValue === stringDefaultValue) { + url.searchParams.delete(name); + } else { + url.searchParams.set(name, stringValue); + } + } + + navigate(url.toString(), { history: 'replace' }); + }, + [name, codec, initialValue], + ); + const value = React.useMemo( + () => (codec && typeof rawValue === 'string' ? decode(codec, rawValue) : (rawValue as V)), + [codec, rawValue], + ); + + return [value ?? initialValue, setValue]; +} diff --git a/scripts/docs/buildCoreApiDocs/config/getCoreHookInfo.ts b/scripts/docs/buildCoreApiDocs/config/getCoreHookInfo.ts new file mode 100644 index 00000000000..bcee0fd0693 --- /dev/null +++ b/scripts/docs/buildCoreApiDocs/config/getCoreHookInfo.ts @@ -0,0 +1,63 @@ +import fs from 'fs'; +import path from 'path'; +import kebabCase from 'lodash/kebabCase'; +import { getHeaders, getTitle } from '@mui/internal-markdown'; +import { + ComponentInfo, + HookInfo, + extractPackageFile, + fixPathname, + getApiPath, + parseFile, +} from '@mui-internal/api-docs-builder/buildApiUtils'; +import findPagesMarkdown from '@mui-internal/api-docs-builder/utils/findPagesMarkdown'; + +export function getCoreHookInfo(filename: string): HookInfo { + const { name } = extractPackageFile(filename); + let srcInfo: null | ReturnType = null; + if (!name) { + throw new Error(`Could not find the hook name from: ${filename}`); + } + + const allMarkdowns = findPagesMarkdown().map((markdown) => { + const markdownContent = fs.readFileSync(markdown.filename, 'utf8'); + const markdownHeaders = getHeaders(markdownContent) as any; + + return { + ...markdown, + markdownContent, + hooks: markdownHeaders.hooks as string[], + }; + }); + + const demos = findCoreHooksDemos(name, allMarkdowns); + const apiPath = getApiPath(demos, name); + + return { + filename, + name, + apiPathname: apiPath ?? `/toolpad/core/api/${kebabCase(name)}/`, + apiPagesDirectory: path.join(process.cwd(), `docs/pages/toolpad/core/api`), + readFile: () => { + srcInfo = parseFile(filename); + return srcInfo; + }, + getDemos: () => demos, + }; +} + +function findCoreHooksDemos( + hookName: string, + pagesMarkdown: ReadonlyArray<{ + pathname: string; + hooks: readonly string[]; + markdownContent: string; + }>, +) { + return pagesMarkdown + .filter((page) => page.hooks && page.hooks.includes(hookName)) + .map((page) => ({ + demoPageTitle: getTitle(page.markdownContent), + demoPathname: `${fixPathname(page.pathname)}#hook${page.hooks?.length > 1 ? 's' : ''}`, + })); +} diff --git a/scripts/docs/buildCoreApiDocs/config/projectSettings.ts b/scripts/docs/buildCoreApiDocs/config/projectSettings.ts index 2d2bc4816f3..cfdad1cdd18 100644 --- a/scripts/docs/buildCoreApiDocs/config/projectSettings.ts +++ b/scripts/docs/buildCoreApiDocs/config/projectSettings.ts @@ -3,6 +3,7 @@ import { ProjectSettings } from '@mui-internal/api-docs-builder'; import findApiPages from '@mui-internal/api-docs-builder/utils/findApiPages'; import { LANGUAGES } from '../../../../docs/config'; import { getCoreComponentInfo } from './getCoreComponentInfo'; +import { getCoreHookInfo } from './getCoreHookInfo'; import { getComponentImports } from './getComponentImports'; export const projectSettings: ProjectSettings = { @@ -18,6 +19,7 @@ export const projectSettings: ProjectSettings = { ], getApiPages: () => findApiPages('docs/pages/toolpad/core/api'), getComponentInfo: getCoreComponentInfo, + getHookInfo: getCoreHookInfo, getComponentImports, translationLanguages: LANGUAGES, skipComponent: () => false,