Skip to content

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

Open
wants to merge 31 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
aaf7860
Add support for offline/local first applications
djhi Feb 24, 2025
2aa2cba
Ensure no existing tests are broken
djhi Feb 24, 2025
a1b2157
Try stabilizing tests
djhi Feb 24, 2025
9d66e05
Fix e2e tests
djhi Feb 25, 2025
fe15863
Introduce `addOfflineSupportToQueryClient`
djhi Apr 25, 2025
1abc446
Add documentation
djhi Apr 25, 2025
25ff38b
Trigger build
djhi Apr 25, 2025
9f83cab
Merge remote-tracking branch 'origin/next' into support-offline-mode
slax57 Apr 29, 2025
eae5aa6
fix merge issues
slax57 Apr 29, 2025
87e537a
Merge branch 'next' into support-offline-mode
djhi Apr 30, 2025
b0bc9a0
Cleanup documentation
djhi Apr 30, 2025
cf79b3b
Fix e2e tests
djhi Apr 30, 2025
78262e9
Merge branch 'next' into support-offline-mode
djhi May 6, 2025
6287a1c
Fix documentation
djhi May 6, 2025
7d3d2e2
Remove unnecessary ts-ignore
djhi May 6, 2025
fd94c6a
Apply suggestions from code review
djhi May 7, 2025
b87aed7
Apply review suggestions
djhi May 7, 2025
39ce253
Update useListContextWithProps
djhi May 7, 2025
2737f75
Improve post creation ux
djhi May 9, 2025
07121fd
Fix e2e tests
djhi May 12, 2025
233ce57
Add offline message to ReferenceField
djhi May 15, 2025
f88c509
Add offline support to list components
djhi May 15, 2025
8f3096b
Add offline support to reference components
djhi May 15, 2025
9f418bf
Add offline support to details components
djhi May 15, 2025
6778fd1
Fix tests and stories
djhi May 15, 2025
934fff4
Use Offline everywhere
djhi May 15, 2025
32af617
Add support for offline in Reference inputs
djhi May 15, 2025
02a957a
Add documentation
djhi May 15, 2025
4c71375
Fix documentation
djhi May 15, 2025
47cdf92
Improve EditView types
djhi May 15, 2025
466aeb1
Apply suggestions from code review
djhi May 19, 2025
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
6 changes: 5 additions & 1 deletion cypress/e2e/edit.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ describe('Edit Page', () => {
it('should redirect to list page after edit success', () => {
// For some unknown reason, the click on submit didn't work in cypress
// so we submit with enter
EditPostPage.setInputValue('input', 'title', 'Lorem Ipsum{enter}');
EditPostPage.setInputValue(
'input',
'title',
'Lorem Ipsum again{enter}'
Copy link
Collaborator Author

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

);
cy.url().should('match', /\/#\/posts$/);
});

Expand Down
9 changes: 5 additions & 4 deletions cypress/support/CreatePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ export default url => ({
inputs: `.ra-input`,
richTextInputError: '.create-page .ra-rich-text-input-error',
snackbar: 'div[role="alert"]',
submitButton: ".create-page div[role='toolbar'] button[type='submit']",
submitButton:
".create-page div[role='toolbar'] div:first-child button[type='submit']",
submitAndShowButton:
".create-page form div[role='toolbar'] button[type='button']:nth-child(2)",
".create-page form div[role='toolbar'] div:first-child button[type='button']:nth-child(2)",
submitAndAddButton:
".create-page form div[role='toolbar'] button[type='button']:nth-child(3)",
".create-page form div[role='toolbar'] div:first-child button[type='button']:nth-child(3)",
submitCommentable:
".create-page form div[role='toolbar'] button[type='button']:last-child",
".create-page form div[role='toolbar'] div:first-child button[type='button']:last-child",
descInput: '.ProseMirror',
tab: index => `.form-tab:nth-of-type(${index})`,
title: '#react-admin-title',
Expand Down
126 changes: 126 additions & 0 deletions docs/DataProviders.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-admin supports offline/local-first applications. To enable this feature, install the following react-query packages:

```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:

```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({
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 %}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're missing a sentence to explain that the setup is done here - the rest is only for developers with custom methods.

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';
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);
},
});
```
35 changes: 34 additions & 1 deletion docs/DataTable.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The 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.
Expand Down
21 changes: 21 additions & 0 deletions docs/Datagrid.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Both are [Enterprise Edition](https://react-admin-ee.marmelab.com) components.
| `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. |
| `optimized` | Optional | Boolean | `false` | Whether to optimize the rendering of the table. |
| `rowClick` | Optional | mixed | | The action to trigger when the user clicks on a row. |
| `rowStyle` | Optional | Function | | A function that returns the style to apply to a row. |
Expand Down Expand Up @@ -640,6 +641,26 @@ export const PostList = () => (
```
{% endraw %}

## `offline`

It's possible that a Datagrid will have no records to display because of connectivity issues. In that case, the Datagrid will display a message indicating data couldn't be fetched. This message is translatable and its key is `ra.notification.offline`.

You can customize the content to display by passing a component to the `offline` prop:

```tsx
const CustomOffline = () => <div>No network. Data couldn't be fetched.</div>;

const PostList = () => (
<List>
<Datagrid offline={<CustomOffline />}>
<TextField source="id" />
<TextField source="title" />
<TextField source="views" />
</Datagrid>
</List>
);
```

## `optimized`

When displaying large pages of data, you might experience some performance issues.
Expand Down
67 changes: 50 additions & 17 deletions docs/Edit.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,22 +60,25 @@ export default App;

You can customize the `<Edit>` component using the following props:

* [`actions`](#actions): override the actions toolbar with a custom component
* [`aside`](#aside): component to render aside to the main content
* `children`: the components that renders the form
* `className`: passed to the root component
* [`component`](#component): override the root component
* [`disableAuthentication`](#disableauthentication): disable the authentication check
* [`emptyWhileLoading`](#emptywhileloading): Set to `true` to return `null` while the edit is loading.
* [`id`](#id): the id of the record to edit
* [`mutationMode`](#mutationmode): switch to optimistic or pessimistic mutations (undoable by default)
* [`mutationOptions`](#mutationoptions): options for the `dataProvider.update()` call
* [`queryOptions`](#queryoptions): options for the `dataProvider.getOne()` call
* [`redirect`](#redirect): change the redirect location after successful creation
* [`resource`](#resource): override the name of the resource to create
* [`sx`](#sx-css-api): Override the styles
* [`title`](#title): override the page title
* [`transform`](#transform): transform the form data before calling `dataProvider.update()`
| Prop | Required | Type | Default | Description |
| ----------------------- | -------- | ------------------------------------- | --------------------- | ------------------------------------------------------------- |
| `actions` | | `ReactNode` | | override the actions toolbar with a custom component |
| `aside` | | `ReactNode` | | component to render aside to the main content |
| `children` | | `ReactNode` | | The components that renders the form |
| `className` | | `string` | | passed to the root component |
| `component` | | `Component` | | override the root component |
| `disableAuthentication` | | `boolean` | | disable the authentication check |
| `emptyWhileLoading` | | `boolean` | | Set to `true` to return `null` while the edit is loading. |
| `id` | | `string | number` | | the id of the record to edit |
| `mutationMode` | | `pessimistic | optimistic | undoable` | | switch to optimistic or pessimistic mutations (undoable by default) |
| `mutationOptions` | | `object` | | options for the `dataProvider.update()` call |
| `offline` | | `ReactNode` | | The content rendered to render when data could not be fetched because of connectivity issues |
| `queryOptions` | | `object` | | options for the `dataProvider.getOne()` call |
| `redirect` | | `string | Function | false` | | change the redirect location after successful creation |
| `resource` | | `string` | | override the name of the resource to create |
| `sx` | | `object` | | Override the styles |
| `title` | | `ReactNode` | | override the page title |
| `transform` | | `Function` | | transform the form data before calling `dataProvider.update()` |

## `actions`

Expand Down Expand Up @@ -488,6 +491,36 @@ The default `onError` function is:

**Tip**: If you want to have different failure side effects based on the button clicked by the user, you can set the `mutationOptions` prop on the `<SaveButton>` component, too.

## `offline`

It's possible that an `<Edit>` will have no record to display because of connectivity issues. In that case, `<Edit>` will display a message indicating data couldn't be fetched.

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 `<Edit offline>` prop:

```tsx
const Offline = () => (
<p>No network. Data couldn't be fetched.</p>
);

const BookEdit = () => (
<Edit offline={<Offline />}>
...
</Edit>
);
```

## `queryOptions`

`<Edit>` calls `dataProvider.getOne()` on mount via react-query's `useQuery` hook. You can customize the options you pass to this hook by setting the `queryOptions` prop.
Expand All @@ -498,7 +531,7 @@ This can be useful e.g. to pass [a custom `meta`](./Actions.md#meta-parameter) t
```jsx
import { Edit, SimpleForm } from 'react-admin';

export const PostShow = () => (
export const PostEdit = () => (
<Edit queryOptions={{ meta: { foo: 'bar' } }}>
<SimpleForm>
...
Expand Down
26 changes: 26 additions & 0 deletions docs/ReferenceArrayInput.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ See the [`children`](#children) section for more details.
| `enableGet Choices` | Optional | `({q: string}) => boolean` | `() => true` | Function taking the `filterValues` and returning a boolean to enable the `getList` call. |
| `filter` | Optional | `Object` | `{}` | Permanent filters to use for getting the suggestion list |
| `label` | Optional | `string` | - | Useful only when `ReferenceArrayInput` is in a Filter array, the label is used as the Filter label. |
| `offline ` | Optional | `ReactNode` | - | The content rendered to render when data could not be fetched because of connectivity issues |
| `page` | Optional | `number` | 1 | The current page number |
| `perPage` | Optional | `number` | 25 | Number of suggestions to show |
| `queryOptions` | Optional | [`UseQueryOptions`](https://tanstack.com/query/v5/docs/react/reference/useQuery) | `{}` | `react-query` client options |
Expand Down Expand Up @@ -216,6 +217,31 @@ const filters = [
];
```

## `offline`

`<ReferenceArrayInput>` displays a message when data cannot be fetched because of connectivity issues.
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 `<ReferenceArrayInput offline>` prop:

```jsx
const Offline = () => (
<p>No network. Data couldn't be fetched.</p>
);

<ReferenceArrayInput source="tags_ids" reference="tags" offline={<Offline />} />
```

## `parse`

By default, children of `<ReferenceArrayInput>` transform the empty form value (an empty string) into `null` before passing it to the `dataProvider`.
Expand Down
Loading
Loading