Skip to content

[core] Initial useSearchParamState hook #3669

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/data/toolpad/core/introduction/Tutorial3.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
Expand Down
3 changes: 2 additions & 1 deletion docs/data/toolpad/core/introduction/Tutorial3.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
Expand Down
63 changes: 43 additions & 20 deletions docs/data/toolpad/core/introduction/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Stack sx={{ width: '100%' }} spacing={2}>
```js
export default function App() {
const [range, setRange] = useSearchParamState('range', 'last-month');
const filter = React.useMemo(() => ({ range: { equals: range } }), [range]);
return (
<DataContext filter={filter}>
<Toolbar disableGutters>
<TextField
select
label="Range"
value={range}
onChange={(e) => setRange(e.target.value)}
>
<MenuItem value="last-month">Last Month</MenuItem>
<MenuItem value="last-year">Last Year</MenuItem>
</TextField>
</Toolbar>
{/* ... */}
<Stack sx={{ width: '100%' }} spacing={2}>
<DataGrid height={300} dataProvider={npmData} />
<LineChart
height={300}
dataProvider={npmData}
xAxis={[{ dataKey: 'day' }]}
series={[{ dataKey: 'downloads' }]}
/>
</Stack>
</DataContext>
</Stack>
);
);
}
```

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:

<!-- TODO: fix the following command -->

<codeblock storageKey="package-manager">
```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
```

</codeblock>
11 changes: 11 additions & 0 deletions docs/pages/toolpad/core/api/use-search-param-state.json
Original file line number Diff line number Diff line change
@@ -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": "<ul></ul>"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"hookDescription": "Works like the React.useState hook, but synchronises the state with a URL query parameter named \"name\".",
"parametersDescriptions": {},
"returnValueDescriptions": {}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/toolpad-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export * from './DataProvider';
export * from './DataGrid';

export * from './LineChart';

export * from './useSearchParamState';
1 change: 1 addition & 0 deletions packages/toolpad-core/src/useSearchParamState/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useSearchParamState';
Original file line number Diff line number Diff line change
@@ -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');
});
});
132 changes: 132 additions & 0 deletions packages/toolpad-core/src/useSearchParamState/useSearchParamState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import * as React from 'react';

export interface Codec<V> {
parse: (value: string) => V;
stringify: (value: V) => string;
}

type UseSearchParamStateOptions<V> = {
defaultValue?: V;
history?: 'push' | 'replace';
codec?: Codec<V>;
} & (V extends string ? {} : { codec: Codec<V> });

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<V>(codec: Codec<V>, value: V | null): string | null {
return value === null ? null : codec.stringify(value);
}

function decode<V>(codec: Codec<V>, 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<V = string>(
name: string,
initialValue: V,
...args: V extends string ? [UseSearchParamStateOptions<V>?] : [UseSearchParamStateOptions<V>]
): [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];
}
Loading
Loading