-
-
Notifications
You must be signed in to change notification settings - Fork 5.4k
Add support for offline/local first applications #10545
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
base: next
Are you sure you want to change the base?
Changes from 30 commits
aaf7860
2aa2cba
a1b2157
9d66e05
fe15863
1abc446
25ff38b
9f83cab
eae5aa6
87e537a
b0bc9a0
cf79b3b
78262e9
6287a1c
7d3d2e2
fd94c6a
b87aed7
39ce253
2737f75
07121fd
233ce57
f88c509
8f3096b
9f418bf
6778fd1
934fff4
32af617
02a957a
4c71375
47cdf92
466aeb1
72ff0b1
6d3daeb
c7b22d6
1be8e73
01a1a94
10ae938
9849a2c
4a3cb09
84207e0
2e2e10c
8452c4f
a55d5ef
8384ca3
02dbe24
0ee8837
b648d08
929d606
1154f17
5b7f83a
d114f96
30a5ec0
68d00a7
ed0f0df
d0586a1
b03dfb4
dbd8dd1
b8b13c5
a3b1da8
4413807
3fda18b
66416c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -884,3 +884,129 @@ export default App; | |||||||||||||||||
``` | ||||||||||||||||||
|
||||||||||||||||||
**Tip**: This example uses the function version of `setState` (`setDataProvider(() => dataProvider)`) instead of the more classic version (`setDataProvider(dataProvider)`). This is because some legacy Data Providers are actually functions, and `setState` would call them immediately on mount. | ||||||||||||||||||
|
||||||||||||||||||
## Offline Support | ||||||||||||||||||
|
||||||||||||||||||
React Query supports offline/local-first applications. To enable it in your React Admin application, install the required React Query packages: | ||||||||||||||||||
djhi marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
|
||||||||||||||||||
```sh | ||||||||||||||||||
yarn add @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister | ||||||||||||||||||
``` | ||||||||||||||||||
|
||||||||||||||||||
Then, register default functions for React Admin mutations on the `QueryClient` to enable resumable mutations (mutations triggered while offline). React Admin provides the `addOfflineSupportToQueryClient` function for this: | ||||||||||||||||||
djhi marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
|
||||||||||||||||||
```ts | ||||||||||||||||||
// in src/queryClient.ts | ||||||||||||||||||
import { addOfflineSupportToQueryClient } from 'react-admin'; | ||||||||||||||||||
import { QueryClient } from '@tanstack/react-query'; | ||||||||||||||||||
import { dataProvider } from './dataProvider'; | ||||||||||||||||||
|
||||||||||||||||||
export const queryClient = new QueryClient(); | ||||||||||||||||||
|
||||||||||||||||||
const queryClientWithOfflineSupport = addOfflineSupportToQueryClient({ | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the point of EDIT: in following examples, it's much clearer.
Suggested change
|
||||||||||||||||||
queryClient, | ||||||||||||||||||
dataProvider, | ||||||||||||||||||
resources: ['posts', 'comments'], | ||||||||||||||||||
}); | ||||||||||||||||||
``` | ||||||||||||||||||
|
||||||||||||||||||
Then, wrap your `<Admin>` inside a [`<PersistQueryClientProvider>`](https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#persistqueryclientprovider): | ||||||||||||||||||
|
||||||||||||||||||
{% raw %} | ||||||||||||||||||
```tsx | ||||||||||||||||||
// in src/App.tsx | ||||||||||||||||||
import { Admin, Resource } from 'react-admin'; | ||||||||||||||||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; | ||||||||||||||||||
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; | ||||||||||||||||||
import { queryClient } from './queryClient'; | ||||||||||||||||||
import { dataProvider } from './dataProvider'; | ||||||||||||||||||
import { posts } from './posts'; | ||||||||||||||||||
import { comments } from './comments'; | ||||||||||||||||||
|
||||||||||||||||||
const localStoragePersister = createSyncStoragePersister({ | ||||||||||||||||||
storage: window.localStorage, | ||||||||||||||||||
}); | ||||||||||||||||||
|
||||||||||||||||||
export const App = () => ( | ||||||||||||||||||
<PersistQueryClientProvider | ||||||||||||||||||
client={queryClient} | ||||||||||||||||||
persistOptions={{ persister: localStoragePersister }} | ||||||||||||||||||
onSuccess={() => { | ||||||||||||||||||
// resume mutations after initial restore from localStorage is successful | ||||||||||||||||||
queryClient.resumePausedMutations(); | ||||||||||||||||||
}} | ||||||||||||||||||
> | ||||||||||||||||||
<Admin queryClient={queryClient} dataProvider={dataProvider}> | ||||||||||||||||||
<Resource name="posts" {...posts} /> | ||||||||||||||||||
<Resource name="comments" {...comments} /> | ||||||||||||||||||
</Admin> | ||||||||||||||||||
</PersistQueryClientProvider> | ||||||||||||||||||
) | ||||||||||||||||||
``` | ||||||||||||||||||
{% endraw %} | ||||||||||||||||||
|
||||||||||||||||||
slax57 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
If you have [custom mutations](./Actions.md#calling-custom-methods) on your dataProvider, you can enable offline support for them too. For instance, if your `dataProvider` exposes a `banUser()` method: | ||||||||||||||||||
|
||||||||||||||||||
```ts | ||||||||||||||||||
const dataProvider = { | ||||||||||||||||||
getList: /* ... */, | ||||||||||||||||||
getOne: /* ... */, | ||||||||||||||||||
getMany: /* ... */, | ||||||||||||||||||
getManyReference: /* ... */, | ||||||||||||||||||
create: /* ... */, | ||||||||||||||||||
update: /* ... */, | ||||||||||||||||||
updateMany: /* ... */, | ||||||||||||||||||
delete: /* ... */, | ||||||||||||||||||
deleteMany: /* ... */, | ||||||||||||||||||
banUser: (userId: string) => { | ||||||||||||||||||
return fetch(`/api/user/${userId}/ban`, { method: 'POST' }) | ||||||||||||||||||
.then(response => response.json()); | ||||||||||||||||||
}, | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
export type MyDataProvider = DataProvider & { | ||||||||||||||||||
banUser: (userId: string) => Promise<{ data: RaRecord }> | ||||||||||||||||||
} | ||||||||||||||||||
``` | ||||||||||||||||||
|
||||||||||||||||||
First, you must set a `mutationKey` for this mutation: | ||||||||||||||||||
|
||||||||||||||||||
{% raw %} | ||||||||||||||||||
```tsx | ||||||||||||||||||
const BanUserButton = ({ userId }: { userId: string }) => { | ||||||||||||||||||
const dataProvider = useDataProvider(); | ||||||||||||||||||
const { mutate, isPending } = useMutation({ | ||||||||||||||||||
mutationKey: 'banUser' | ||||||||||||||||||
mutationFn: (userId) => dataProvider.banUser(userId) | ||||||||||||||||||
}); | ||||||||||||||||||
return <Button label="Ban" onClick={() => mutate(userId)} disabled={isPending} />; | ||||||||||||||||||
}; | ||||||||||||||||||
``` | ||||||||||||||||||
{% endraw %} | ||||||||||||||||||
|
||||||||||||||||||
**Tip**: Note that unlike the [_Calling Custom Methods_ example](./Actions.md#calling-custom-methods), we passed `userId` to the `mutate` function. This is necessary so that React Query passes it too to the default function when resuming the mutation. | ||||||||||||||||||
|
||||||||||||||||||
Then, register a default function for it: | ||||||||||||||||||
|
||||||||||||||||||
```ts | ||||||||||||||||||
// in src/queryClient.ts | ||||||||||||||||||
import { addOfflineSupportToQueryClient } from 'react-admin'; | ||||||||||||||||||
import { QueryClient } from '@tanstack/react-query'; | ||||||||||||||||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; | ||||||||||||||||||
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; | ||||||||||||||||||
erwanMarmelab marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
import { dataProvider } from './dataProvider'; | ||||||||||||||||||
|
||||||||||||||||||
const baseQueryClient = new QueryClient(); | ||||||||||||||||||
|
||||||||||||||||||
export const queryClient = addOfflineSupportToQueryClient({ | ||||||||||||||||||
queryClient, | ||||||||||||||||||
dataProvider, | ||||||||||||||||||
resources: ['posts', 'comments'], | ||||||||||||||||||
}); | ||||||||||||||||||
|
||||||||||||||||||
queryClient.setMutationDefaults('banUser', { | ||||||||||||||||||
mutationFn: async (userId) => { | ||||||||||||||||||
return dataProviderFn.banUser(userId); | ||||||||||||||||||
slax57 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
}, | ||||||||||||||||||
}); | ||||||||||||||||||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -53,12 +53,13 @@ Each `<DataTable.Col>` defines one column of the table: its `source` (used for s | |
| `empty` | Optional | Element | `<Empty>` | The component to render when the list is empty. | | ||
| `expand` | Optional | Element | | The component rendering the expand panel for each row. | | ||
| `expandSingle` | Optional | Boolean | `false` | Whether to allow only one expanded row at a time. | | ||
| `head` | Optional | Element | `<DataTable Header>` | The component rendering the table header. | | ||
| `head` | Optional | Element | `<DataTable Header>` | The component rendering the table header. | | ||
| `hiddenColumns` | Optional | Array | `[]` | The list of columns to hide by default. | | ||
| `foot` | Optional | Element | | The component rendering the table footer. | | ||
| `hover` | Optional | Boolean | `true` | Whether to highlight the row under the mouse. | | ||
| `isRowExpandable` | Optional | Function | `() => true` | A function that returns whether a row is expandable. | | ||
| `isRowSelectable` | Optional | Function | `() => true` | A function that returns whether a row is selectable. | | ||
| `offline` | Optional | Element | `<Offline>` | The content rendered to render when data could not be fetched because of connectivity issues. | | ||
| `rowClick` | Optional | mixed | | The action to trigger when the user clicks on a row. | | ||
| `rowSx` | Optional | Function | | A function that returns the `sx` prop to apply to a row. | | ||
| `size` | Optional | `'small'` or `'medium'` | `'small'` | The size of the table. | | ||
|
@@ -754,6 +755,38 @@ export const PostList = () => ( | |
``` | ||
{% endraw %} | ||
|
||
## `offline` | ||
|
||
It's possible that a `<DataTable>` will have no records to display because of connectivity issues. In that case, `<DataTable>` will display a message indicating data couldn't be fetched. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you should include the default English translation for the offline message in the doc, to make it searchable. |
||
|
||
You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. | ||
|
||
```tsx | ||
const messages = { | ||
ra: { | ||
notification: { | ||
offline: "No network. Data couldn't be fetched.", | ||
} | ||
} | ||
} | ||
``` | ||
|
||
If you need to go beyond text, pass a custom element as the `<DataTable offline>` prop: | ||
|
||
```tsx | ||
const Offline = () => ( | ||
<p>No network. Data couldn't be fetched.</p> | ||
); | ||
|
||
const BookList = () => ( | ||
<List> | ||
<DataTable offline={<Offline />}> | ||
... | ||
</DataTable> | ||
</List> | ||
); | ||
``` | ||
|
||
## `rowClick` | ||
|
||
By default, `<DataTable>` uses the current [resource definition](https://marmelab.com/react-admin/Resource.html) to determine what to do when the user clicks on a row. If the resource has a `show` page, a row click redirects to the Show view. If the resource has an `edit` page, a row click redirects to the Edit view. Otherwise, the row is not clickable. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because react-query now persist queries and mutations for offline mode, the previous test now leaks into the second (e.g. this post has its title changed to Lorem Ipsum). I tried to configure testIsolation in Cypress but our version is probably too old