Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs_headless/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export default defineConfig({
'uselist',
'uselistcontext',
'uselistcontroller',
'usesavedqueries',
'useunselect',
'useunselectall',
],
Expand Down
162 changes: 162 additions & 0 deletions docs_headless/src/content/docs/useSavedQueries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
---
title: "useSavedQueries"
storybook_path: ra-core-list-filter-usesavedqueries--basic
---

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).

## Usage

```jsx
import { useSavedQueries } from 'ra-core';

const [savedQueries, setSavedQueries] = useSavedQueries(resource);
```

The `resource` parameter should be a string representing the resource name (e.g., 'posts', 'users').

The hook returns a tuple with:
- `savedQueries`: an array of `SavedQuery` objects for the specified resource
- `setSavedQueries`: a function to update the saved queries array

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:

```jsx
import { ListBase, useSavedQueries, useListContext } from 'ra-core';

const MyListComponent = () => (
<ListBase>
<SavedQueriesComponent />
{/* Other list components */}
</ListBase>
);
```

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.

## SavedQuery Interface

```typescript
interface SavedQuery {
label: string;
value: {
filter?: any;
displayedFilters?: any[];
sort?: SortPayload;
perPage?: number;
};
}
```

## Example Component Implementation

```jsx
import { useSavedQueries, useListContext, extractValidSavedQueries } from 'ra-core';
import isEqual from 'lodash/isEqual.js';

const SavedQueriesComponent = () => {
const { resource, filterValues, displayedFilters, sort, perPage } = useListContext();
const [savedQueries, setSavedQueries] = useSavedQueries(resource);
const validSavedQueries = extractValidSavedQueries(savedQueries);

const hasFilterValues = !isEqual(filterValues, {});
const hasSavedCurrentQuery = validSavedQueries.some(savedQuery =>
isEqual(savedQuery.value, {
filter: filterValues,
sort,
perPage,
displayedFilters,
})
);

const addQuery = () => {
const newSavedQuery = {
label: 'My Custom Query',
value: {
filter: filterValues,
sort,
perPage,
displayedFilters,
},
};
const newSavedQueries = extractValidSavedQueries(savedQueries);
setSavedQueries(newSavedQueries.concat(newSavedQuery));
};

const removeQuery = () => {
const savedQueryToRemove = {
filter: filterValues,
sort,
perPage,
displayedFilters,
};
const newSavedQueries = extractValidSavedQueries(savedQueries);
const index = newSavedQueries.findIndex(savedQuery =>
isEqual(savedQuery.value, savedQueryToRemove)
);
setSavedQueries([
...newSavedQueries.slice(0, index),
...newSavedQueries.slice(index + 1),
]);
};

return (
<div>
<h3>Saved Queries</h3>
{validSavedQueries.length === 0 && (
<p>No saved queries yet. Set a filter to save it.</p>
)}
<ul>
{validSavedQueries.map((savedQuery, index) => (
<li key={index}>
{savedQuery.label}
</li>
))}
</ul>
{hasFilterValues && !hasSavedCurrentQuery && (
<button onClick={addQuery}>
Save current query
</button>
)}
{hasSavedCurrentQuery && (
<button onClick={removeQuery}>
Remove current query
</button>
)}
</div>
);
};
```

## Helper Functions

The hook is often used with these helper functions:

### `extractValidSavedQueries`

Filters out invalid saved queries from an array:

```jsx
import { extractValidSavedQueries } from 'ra-core';

const validQueries = extractValidSavedQueries(savedQueries);
```

### `isValidSavedQuery`

Validates whether a saved query has the correct structure:

```jsx
import { isValidSavedQuery } from 'ra-core';

const isValid = isValidSavedQuery(savedQuery);
```

A valid saved query must have:
- A non-empty string `label`
- A `value` object containing:
- `displayedFilters`: array
- `perPage`: number
- `sort.field`: string
- `sort.order`: string
- `filter`: object
1 change: 1 addition & 0 deletions packages/ra-core/src/controller/list/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ export * from './useUnselect';
export * from './useUnselectAll';
export * from './useSelectAll';
export * from './WithListContext';
export * from './useSavedQueries';
44 changes: 44 additions & 0 deletions packages/ra-core/src/controller/list/useSavedQueries.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { Basic } from './useSavedQueries.stories';

describe('useSavedQueries', () => {
it('should allow to save a query', async () => {
render(<Basic />);
fireEvent.change(await screen.findByLabelText('Title'), {
target: { value: 'Post 1' },
});
fireEvent.click(await screen.findByText('Save current query'));
await screen.findByText('My saved query: Post 1 - unpublished');
});

it('should allow to apply a query', async () => {
render(<Basic />);
await screen.findByText('1-2 of 2');
fireEvent.change(await screen.findByLabelText('Title'), {
target: { value: 'Post 1' },
});
await screen.findByText('1-1 of 1');
fireEvent.click(await screen.findByText('Save current query'));
await screen.findByText('My saved query: Post 1 - unpublished');
fireEvent.change(await screen.findByLabelText('Title'), {
target: { value: '' },
});
await screen.findByText('1-2 of 2');
fireEvent.click(await screen.findByText('Apply'));
await screen.findByText('1-1 of 1');
});

it('should allow to remove a query', async () => {
render(<Basic />);
fireEvent.change(await screen.findByLabelText('Title'), {
target: { value: 'Post 1' },
});
fireEvent.click(await screen.findByText('Save current query'));
await screen.findByText('My saved query: Post 1 - unpublished');
fireEvent.click(await screen.findByText('Remove'));
await screen.findByText(
'No saved queries yet. Set a filter to save it.'
);
});
});
173 changes: 173 additions & 0 deletions packages/ra-core/src/controller/list/useSavedQueries.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import * as React from 'react';
import fakeRestDataProvider from 'ra-data-fakerest';
import { useNavigate } from 'react-router';
import queryString from 'query-string';
import isEqual from 'lodash/isEqual.js';
import {
TestMemoryRouter,
Resource,
ListBase,
FilterLiveForm,
useListContext,
useSavedQueries,
extractValidSavedQueries,
SavedQuery,
} from '../..';
import {
Admin,
DataTable,
TextInput,
BooleanInput,
Pagination,
} from '../../test-ui';

export default { title: 'ra-core/controller/list/useSavedQueries' };

const SavedQueries = () => {
const { resource, filterValues, displayedFilters, sort, perPage } =
useListContext();
const hasFilterValues = !isEqual(filterValues, {});
const navigate = useNavigate();
const [savedQueries, setSavedQueries] = useSavedQueries(resource);
const validSavedQueries = extractValidSavedQueries(savedQueries);
const hasSavedCurrentQuery = validSavedQueries.some(savedQuery =>
isEqual(savedQuery.value, {
filter: filterValues,
sort,
perPage,
displayedFilters,
})
);

const removeQuery = () => {
const savedQueryToRemove = {
filter: filterValues,
sort,
perPage,
displayedFilters,
};
const newSavedQueries = extractValidSavedQueries(savedQueries);
const index = newSavedQueries.findIndex(savedFilter =>
isEqual(savedFilter.value, savedQueryToRemove)
);
setSavedQueries([
...newSavedQueries.slice(0, index),
...newSavedQueries.slice(index + 1),
]);
};

const addQuery = () => {
const newSavedQuery = {
label: `My saved query: ${filterValues.title || 'all'} - ${filterValues.published ? 'published' : 'unpublished'}`,
value: {
filter: filterValues,
sort,
perPage,
displayedFilters,
},
};
const newSavedQueries = extractValidSavedQueries(savedQueries);
setSavedQueries(newSavedQueries.concat(newSavedQuery));
};

const applyQuery = (savedQuery: SavedQuery) => {
navigate({
search: queryString.stringify({
filter: JSON.stringify(savedQuery.value.filter),
sort: savedQuery.value.sort?.field,
order: savedQuery.value.sort?.order,
page: 1,
perPage: savedQuery.value.perPage,
displayedFilters: JSON.stringify(
savedQuery.value.displayedFilters
),
}),
});
};

return (
<>
<p>Saved Queries</p>
{validSavedQueries.length === 0 && (
<p>No saved queries yet. Set a filter to save it.</p>
)}
<ul>
{validSavedQueries.map(
(savedQuery: SavedQuery, index: number) => (
<li key={index}>
{savedQuery.label}{' '}
{isEqual(savedQuery.value, {
filter: filterValues,
sort,
perPage,
displayedFilters,
}) ? (
<button type="button" onClick={removeQuery}>
Remove
</button>
) : (
<button
type="button"
onClick={() => {
applyQuery(savedQuery);
}}
>
Apply
</button>
)}
</li>
)
)}
{hasFilterValues && !hasSavedCurrentQuery && (
<li>
<button onClick={addQuery} type="button">
Save current query
</button>
</li>
)}
</ul>
</>
);
};

const FilterForm = () => {
return (
<FilterLiveForm>
<TextInput source="title" />
<BooleanInput source="published" />
</FilterLiveForm>
);
};

export const Basic = () => (
<TestMemoryRouter>
<Admin
dataProvider={fakeRestDataProvider(
{
posts: [
{ id: 1, title: 'Post 1', published: true },
{ id: 2, title: 'Post 2', published: false },
],
},
process.env.NODE_ENV !== 'test',
process.env.NODE_ENV !== 'test' ? 300 : 0
)}
>
<Resource
name="posts"
list={
<ListBase>
<FilterForm />
<SavedQueries />
<DataTable>
<DataTable.Col source="id" />
<DataTable.Col source="title" />
<DataTable.Col source="published" />
</DataTable>
<Pagination />
</ListBase>
}
/>
</Admin>
</TestMemoryRouter>
);
Loading
Loading