Skip to content

Commit b965dc1

Browse files
authored
Merge pull request marmelab#11092 from marmelab/ra-core-primitives-useSavedQueries
Add `useSavedQueries` primitives to ra-core
2 parents c456aba + 2ee22c4 commit b965dc1

File tree

12 files changed

+412
-15
lines changed

12 files changed

+412
-15
lines changed

docs_headless/astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export default defineConfig({
127127
'uselist',
128128
'uselistcontext',
129129
'uselistcontroller',
130+
'usesavedqueries',
130131
'useunselect',
131132
'useunselectall',
132133
],
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
---
2+
title: "useSavedQueries"
3+
storybook_path: ra-core-list-filter-usesavedqueries--basic
4+
---
5+
6+
This hook allows to read and write saved queries for a specific resource. Saved queries store a combination of filters, sort order, page size, and displayed filters that users can save and reuse later. The data is persisted in the [Store](./Store.md).
7+
8+
## Usage
9+
10+
```jsx
11+
import { useSavedQueries } from 'ra-core';
12+
13+
const [savedQueries, setSavedQueries] = useSavedQueries(resource);
14+
```
15+
16+
The `resource` parameter should be a string representing the resource name (e.g., 'posts', 'users').
17+
18+
The hook returns a tuple with:
19+
- `savedQueries`: an array of `SavedQuery` objects for the specified resource
20+
- `setSavedQueries`: a function to update the saved queries array
21+
22+
This hook is typically used within a list context where filter values, sort order, and pagination state are available. It's commonly used to implement saved query functionality in filter sidebars:
23+
24+
```jsx
25+
import { ListBase, useSavedQueries, useListContext } from 'ra-core';
26+
27+
const MyListComponent = () => (
28+
<ListBase>
29+
<SavedQueriesComponent />
30+
{/* Other list components */}
31+
</ListBase>
32+
);
33+
```
34+
35+
The saved queries are stored per resource using the pattern `${resource}.savedQueries` in the store, ensuring that each resource maintains its own set of saved queries.
36+
37+
## SavedQuery Interface
38+
39+
```typescript
40+
interface SavedQuery {
41+
label: string;
42+
value: {
43+
filter?: any;
44+
displayedFilters?: any[];
45+
sort?: SortPayload;
46+
perPage?: number;
47+
};
48+
}
49+
```
50+
51+
## Example Component Implementation
52+
53+
```jsx
54+
import { useSavedQueries, useListContext, extractValidSavedQueries } from 'ra-core';
55+
import isEqual from 'lodash/isEqual.js';
56+
57+
const SavedQueriesComponent = () => {
58+
const { resource, filterValues, displayedFilters, sort, perPage } = useListContext();
59+
const [savedQueries, setSavedQueries] = useSavedQueries(resource);
60+
const validSavedQueries = extractValidSavedQueries(savedQueries);
61+
62+
const hasFilterValues = !isEqual(filterValues, {});
63+
const hasSavedCurrentQuery = validSavedQueries.some(savedQuery =>
64+
isEqual(savedQuery.value, {
65+
filter: filterValues,
66+
sort,
67+
perPage,
68+
displayedFilters,
69+
})
70+
);
71+
72+
const addQuery = () => {
73+
const newSavedQuery = {
74+
label: 'My Custom Query',
75+
value: {
76+
filter: filterValues,
77+
sort,
78+
perPage,
79+
displayedFilters,
80+
},
81+
};
82+
const newSavedQueries = extractValidSavedQueries(savedQueries);
83+
setSavedQueries(newSavedQueries.concat(newSavedQuery));
84+
};
85+
86+
const removeQuery = () => {
87+
const savedQueryToRemove = {
88+
filter: filterValues,
89+
sort,
90+
perPage,
91+
displayedFilters,
92+
};
93+
const newSavedQueries = extractValidSavedQueries(savedQueries);
94+
const index = newSavedQueries.findIndex(savedQuery =>
95+
isEqual(savedQuery.value, savedQueryToRemove)
96+
);
97+
setSavedQueries([
98+
...newSavedQueries.slice(0, index),
99+
...newSavedQueries.slice(index + 1),
100+
]);
101+
};
102+
103+
return (
104+
<div>
105+
<h3>Saved Queries</h3>
106+
{validSavedQueries.length === 0 && (
107+
<p>No saved queries yet. Set a filter to save it.</p>
108+
)}
109+
<ul>
110+
{validSavedQueries.map((savedQuery, index) => (
111+
<li key={index}>
112+
{savedQuery.label}
113+
</li>
114+
))}
115+
</ul>
116+
{hasFilterValues && !hasSavedCurrentQuery && (
117+
<button onClick={addQuery}>
118+
Save current query
119+
</button>
120+
)}
121+
{hasSavedCurrentQuery && (
122+
<button onClick={removeQuery}>
123+
Remove current query
124+
</button>
125+
)}
126+
</div>
127+
);
128+
};
129+
```
130+
131+
## Helper Functions
132+
133+
The hook is often used with these helper functions:
134+
135+
### `extractValidSavedQueries`
136+
137+
Filters out invalid saved queries from an array:
138+
139+
```jsx
140+
import { extractValidSavedQueries } from 'ra-core';
141+
142+
const validQueries = extractValidSavedQueries(savedQueries);
143+
```
144+
145+
### `isValidSavedQuery`
146+
147+
Validates whether a saved query has the correct structure:
148+
149+
```jsx
150+
import { isValidSavedQuery } from 'ra-core';
151+
152+
const isValid = isValidSavedQuery(savedQuery);
153+
```
154+
155+
A valid saved query must have:
156+
- A non-empty string `label`
157+
- A `value` object containing:
158+
- `displayedFilters`: array
159+
- `perPage`: number
160+
- `sort.field`: string
161+
- `sort.order`: string
162+
- `filter`: object

packages/ra-core/src/controller/list/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ export * from './useUnselect';
2727
export * from './useUnselectAll';
2828
export * from './useSelectAll';
2929
export * from './WithListContext';
30+
export * from './useSavedQueries';
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React from 'react';
2+
import { render, fireEvent, screen } from '@testing-library/react';
3+
import { Basic } from './useSavedQueries.stories';
4+
5+
describe('useSavedQueries', () => {
6+
it('should allow to save a query', async () => {
7+
render(<Basic />);
8+
fireEvent.change(await screen.findByLabelText('Title'), {
9+
target: { value: 'Post 1' },
10+
});
11+
fireEvent.click(await screen.findByText('Save current query'));
12+
await screen.findByText('My saved query: Post 1 - unpublished');
13+
});
14+
15+
it('should allow to apply a query', async () => {
16+
render(<Basic />);
17+
await screen.findByText('1-2 of 2');
18+
fireEvent.change(await screen.findByLabelText('Title'), {
19+
target: { value: 'Post 1' },
20+
});
21+
await screen.findByText('1-1 of 1');
22+
fireEvent.click(await screen.findByText('Save current query'));
23+
await screen.findByText('My saved query: Post 1 - unpublished');
24+
fireEvent.change(await screen.findByLabelText('Title'), {
25+
target: { value: '' },
26+
});
27+
await screen.findByText('1-2 of 2');
28+
fireEvent.click(await screen.findByText('Apply'));
29+
await screen.findByText('1-1 of 1');
30+
});
31+
32+
it('should allow to remove a query', async () => {
33+
render(<Basic />);
34+
fireEvent.change(await screen.findByLabelText('Title'), {
35+
target: { value: 'Post 1' },
36+
});
37+
fireEvent.click(await screen.findByText('Save current query'));
38+
await screen.findByText('My saved query: Post 1 - unpublished');
39+
fireEvent.click(await screen.findByText('Remove'));
40+
await screen.findByText(
41+
'No saved queries yet. Set a filter to save it.'
42+
);
43+
});
44+
});
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import * as React from 'react';
2+
import fakeRestDataProvider from 'ra-data-fakerest';
3+
import { useNavigate } from 'react-router';
4+
import queryString from 'query-string';
5+
import isEqual from 'lodash/isEqual.js';
6+
import {
7+
TestMemoryRouter,
8+
Resource,
9+
ListBase,
10+
FilterLiveForm,
11+
useListContext,
12+
useSavedQueries,
13+
extractValidSavedQueries,
14+
SavedQuery,
15+
} from '../..';
16+
import {
17+
Admin,
18+
DataTable,
19+
TextInput,
20+
BooleanInput,
21+
Pagination,
22+
} from '../../test-ui';
23+
24+
export default { title: 'ra-core/controller/list/useSavedQueries' };
25+
26+
const SavedQueries = () => {
27+
const { resource, filterValues, displayedFilters, sort, perPage } =
28+
useListContext();
29+
const hasFilterValues = !isEqual(filterValues, {});
30+
const navigate = useNavigate();
31+
const [savedQueries, setSavedQueries] = useSavedQueries(resource);
32+
const validSavedQueries = extractValidSavedQueries(savedQueries);
33+
const hasSavedCurrentQuery = validSavedQueries.some(savedQuery =>
34+
isEqual(savedQuery.value, {
35+
filter: filterValues,
36+
sort,
37+
perPage,
38+
displayedFilters,
39+
})
40+
);
41+
42+
const removeQuery = () => {
43+
const savedQueryToRemove = {
44+
filter: filterValues,
45+
sort,
46+
perPage,
47+
displayedFilters,
48+
};
49+
const newSavedQueries = extractValidSavedQueries(savedQueries);
50+
const index = newSavedQueries.findIndex(savedFilter =>
51+
isEqual(savedFilter.value, savedQueryToRemove)
52+
);
53+
setSavedQueries([
54+
...newSavedQueries.slice(0, index),
55+
...newSavedQueries.slice(index + 1),
56+
]);
57+
};
58+
59+
const addQuery = () => {
60+
const newSavedQuery = {
61+
label: `My saved query: ${filterValues.title || 'all'} - ${filterValues.published ? 'published' : 'unpublished'}`,
62+
value: {
63+
filter: filterValues,
64+
sort,
65+
perPage,
66+
displayedFilters,
67+
},
68+
};
69+
const newSavedQueries = extractValidSavedQueries(savedQueries);
70+
setSavedQueries(newSavedQueries.concat(newSavedQuery));
71+
};
72+
73+
const applyQuery = (savedQuery: SavedQuery) => {
74+
navigate({
75+
search: queryString.stringify({
76+
filter: JSON.stringify(savedQuery.value.filter),
77+
sort: savedQuery.value.sort?.field,
78+
order: savedQuery.value.sort?.order,
79+
page: 1,
80+
perPage: savedQuery.value.perPage,
81+
displayedFilters: JSON.stringify(
82+
savedQuery.value.displayedFilters
83+
),
84+
}),
85+
});
86+
};
87+
88+
return (
89+
<>
90+
<p>Saved Queries</p>
91+
{validSavedQueries.length === 0 && (
92+
<p>No saved queries yet. Set a filter to save it.</p>
93+
)}
94+
<ul>
95+
{validSavedQueries.map(
96+
(savedQuery: SavedQuery, index: number) => (
97+
<li key={index}>
98+
{savedQuery.label}{' '}
99+
{isEqual(savedQuery.value, {
100+
filter: filterValues,
101+
sort,
102+
perPage,
103+
displayedFilters,
104+
}) ? (
105+
<button type="button" onClick={removeQuery}>
106+
Remove
107+
</button>
108+
) : (
109+
<button
110+
type="button"
111+
onClick={() => {
112+
applyQuery(savedQuery);
113+
}}
114+
>
115+
Apply
116+
</button>
117+
)}
118+
</li>
119+
)
120+
)}
121+
{hasFilterValues && !hasSavedCurrentQuery && (
122+
<li>
123+
<button onClick={addQuery} type="button">
124+
Save current query
125+
</button>
126+
</li>
127+
)}
128+
</ul>
129+
</>
130+
);
131+
};
132+
133+
const FilterForm = () => {
134+
return (
135+
<FilterLiveForm>
136+
<TextInput source="title" />
137+
<BooleanInput source="published" />
138+
</FilterLiveForm>
139+
);
140+
};
141+
142+
export const Basic = () => (
143+
<TestMemoryRouter>
144+
<Admin
145+
dataProvider={fakeRestDataProvider(
146+
{
147+
posts: [
148+
{ id: 1, title: 'Post 1', published: true },
149+
{ id: 2, title: 'Post 2', published: false },
150+
],
151+
},
152+
process.env.NODE_ENV !== 'test',
153+
process.env.NODE_ENV !== 'test' ? 300 : 0
154+
)}
155+
>
156+
<Resource
157+
name="posts"
158+
list={
159+
<ListBase>
160+
<FilterForm />
161+
<SavedQueries />
162+
<DataTable>
163+
<DataTable.Col source="id" />
164+
<DataTable.Col source="title" />
165+
<DataTable.Col source="published" />
166+
</DataTable>
167+
<Pagination />
168+
</ListBase>
169+
}
170+
/>
171+
</Admin>
172+
</TestMemoryRouter>
173+
);

0 commit comments

Comments
 (0)