Skip to content

Commit

Permalink
Showing 49 changed files with 2,503 additions and 44 deletions.
69 changes: 69 additions & 0 deletions docs/DataProviderLive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
layout: default
title: "Real-Time Updates And Locks"
---

# Real-Time Updates And Locks

Teams where several people work in parallel on a common task need to allow live updates, real-time notifications, and prevent data loss when two editors work on the same resource concurrently.

<video controls autoplay muted>
<source src="./img/CollaborativeDemo.mp4" type="video/mp4">
Your browser does not support the video tag.
</video>

React-admin offers powerful realtime features to help you build collaborative applications, based on the Publish / Subscribe (PubSub) pattern. The [Realtime documentation](./Realtime.md) explains how to use them.

These features are part of the [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" />.

## Realtime Data Provider

The realtime features are backend agnostic. Just like for CRUD operations,realtime operations rely on the data provider, using additional methods:

- `dataProvider.subscribe(topic, callback)`
- `dataProvider.unsubscribe(topic, callback)`
- `dataProvider.publish(topic, event)` (optional - publication is often done server-side)

In addition, to support the lock features, the `dataProvider` must implement 4 more methods:

- `dataProvider.lock(resource, { id, identity, meta })`
- `dataProvider.unlock(resource, { id, identity, meta })`
- `dataProvider.getLock(resource, { id, meta })`
- `dataProvider.getLocks(resource, { meta })`

You can implement these features using any realtime backend, including:

- [Mercure](https://mercure.rocks/),
- [API Platform](https://api-platform.com/docs/admin/real-time-mercure/#real-time-updates-with-mercure),
- [supabase](https://supabase.com/),
- [Socket.IO](https://socket.io/),
- [Ably](https://ably.com/),
- and many more.

Check the [Realtime Data Provider documentation](./RealtimeDataProvider.md) for more information, and for helpers to build your own realtime data provider.

## Realtime Hooks And Components

Once your data provider has enabled realtime features, you can use these hooks and components to build realtime applications:

- [`usePublish`](./usePublish.md)
- [`useSubscribe`](./useSubscribe.md)
- [`useSubscribeCallback`](./useSubscribeCallback.md)
- [`useSubscribeToRecord`](./useSubscribeToRecord.md)
- [`useSubscribeToRecordList`](./useSubscribeToRecordList.md)
- [`useLock`](./useLock.md)
- [`useUnlock`](./useUnlock.md)
- [`useGetLock`](./useGetLock.md)
- [`useGetLockLive`](./useGetLockLive.md)
- [`useGetLocks`](./useGetLocks.md)
- [`useGetLocksLive`](./useGetLocksLive.md)
- [`useLockOnMount`](./useLockOnMount.md)
- [`useLockOnCall`](./useLockOnCall.md)
- [`useGetListLive`](./useGetListLive.md)
- [`useGetOneLive`](./useGetOneLive.md)
- [`<ListLive>`](./ListLive.md)
- [`<EditLive>`](./EditLive.md)
- [`<ShowLive>`](./ShowLive.md)
- [`<MenuLive>`](./MenuLive.md)

Refer to the [Realtime documentation](./Realtime.md) for more information.
34 changes: 0 additions & 34 deletions docs/DataProviders.md
Original file line number Diff line number Diff line change
@@ -519,37 +519,3 @@ export const App = () => (
</Admin>
);
```

## Real-Time Updates And Locks

Teams where several people work in parallel on a common task need to allow live updates, real-time notifications, and prevent data loss when two editors work on the same resource concurrently.

[`ra-realtime`](https://marmelab.com/ra-enterprise/modules/ra-realtime) (an [Enterprise Edition <img class="icon" src="./img/premium.svg" />](https://marmelab.com/ra-enterprise) module) provides hooks and UI components to lock records, and update views when the underlying data changes. It's based on the Publish / Subscribe (PubSub) pattern, and requires a backend supporting this pattern (like GraphQL, Mercury).

For instance, here is how to enable live updates on a List view:

```diff
import {
- List,
Datagrid,
TextField,
NumberField,
Datefield,
} from 'react-admin';
+import { RealTimeList } from '@react-admin/ra-realtime';

const PostList = () => (
- <List>
+ <RealTimeList>
<Datagrid>
<TextField source="title" />
<NumberField source="views" />
<DateField source="published_at" />
</Datagrid>
- </List>
+ </RealTimeList>
);
```

Check [the `ra-realtime` documentation](https://marmelab.com/ra-enterprise/modules/ra-realtime) for more details.

21 changes: 21 additions & 0 deletions docs/Edit.md
Original file line number Diff line number Diff line change
@@ -639,3 +639,24 @@ You can do the same for error notifications, by passing a custom `onError` call
* If you want to allow edition from the `list` page, use [the `<EditDialog>` component](./EditDialog.md)
* If you want to allow edition from another page, use [the `<EditInDialogButton>` component](./EditInDialogButton.md)

## Live Updates

If you want to subscribe to live updates on the record (topic: `resource/[resource]/[id]`), use [the `<EditLive>` component](./EditLive.md) instead.

```diff
-import { Edit, SimpleForm, TextInput } from 'react-admin';
+import { SimpleForm, TextInput } from 'react-admin';
+import { EditLive } from '@react-admin/ra-realtime';

const PostEdit = () => (
- <Edit>
+ <EditLive>
<SimpleForm>
<TextInput source="title" />
</SimpleForm>
- </Edit>
+ </EditLive>
);
```

The user will see alerts when other users update or delete the record.
82 changes: 82 additions & 0 deletions docs/EditLive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
layout: default
title: "EditLive"
---

# `<EditLive>`

`<EditLive>` is an [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> component that renders an Edit view. It displays a warning when the record is updated by another user and offers to refresh the page. Also, it displays a warning when the record is deleted by another user.

![EditLive](./img/EditLive.png)

## Usage

Use `<EditLive>` instead of `<Edit>`:

```jsx
import { SimpleForm, TextInput } from 'react-admin';
import { EditLive } from '@react-admin/ra-realtime';

const PostEdit = () => (
<EditLive>
<SimpleForm>
<TextInput source="title" />
</SimpleForm>
</EditLive>
);
```

To trigger `<EditLive>` features, the API has to publish events containing at least the following:

```js
{
topic : '/resource/{resource}/{recordIdentifier}',
type: '{deleted || updated}',
payload: { id: [{recordIdentifier}]},
}
```

`<EditLive>` accepts the same props as `<Edit>`. Refer to [the `<Edit>` documentation](./Edit.md) for more information.

## `onEventReceived`

The `<EditLive>` allows you to customize the side effects triggered when it receives a new event, by passing a function to the `onEventReceived` prop:

```jsx
import { SimpleForm, TextInput, useRefresh } from 'react-admin';
import { EditLive, EventType } from '@react-admin/ra-realtime';

const PostEdit = () => {
const notify = useNotify();

const handleEventReceived = (
event,
{ setDeleted, setUpdated, setUpdatedDisplayed }
) => {
if (event.type === EventType.Updated) {
notify('Record updated');
setUpdated(true);
setUpdatedDisplayed(true);
} else if (event.type === EventType.Deleted) {
notify('Record deleted');
setDeleted(true);
setUpdated(false);
setUpdatedDisplayed(true);
}
};

return (
<EditLive onEventReceived={handleEventReceived}>
<SimpleForm>
<TextInput source="title" />
</SimpleForm>
</EditLive>
);
};
```

The function passed to `onEventReceived` will be called with the event as its first argument and an object containing functions that will update the UI:

- `setDeleted`: If set to `true`, the edit view will show a message to let users know this record has been deleted.
- `setUpdated`: If set to `true`, the edit view will show a message to let users know this record has been updated.
- `setUpdatedDisplayed`: Must be set to true after calling `setUpdated`. This is used to show the message about the record being updated only for a few seconds.
22 changes: 22 additions & 0 deletions docs/List.md
Original file line number Diff line number Diff line change
@@ -864,3 +864,25 @@ const PostList = () => (
);
```
{% endraw %}

## Live Updates

If you want to subscribe to live updates on the list of records (topic: `resource/[resource]`), use [the `<ListLive>` component](./ListLive.md) instead.

```diff
-import { List, Datagrid, TextField } from 'react-admin';
+import { Datagrid, TextField } from 'react-admin';
+import { ListLive } from '@react-admin/ra-realtime';

const PostList = () => (
- <List>
+ <ListLive>
<Datagrid>
<TextField source="title" />
</Datagrid>
- </List>
+ </ListLive>
);
```

The list will automatically update when a new record is created, or an existing record is updated or deleted.
69 changes: 69 additions & 0 deletions docs/ListLive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
layout: default
title: "ListLive"
---

# `<ListLive>`

`<ListLive>` is an [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> component that fetches a list of records, and refreshes the page when a record is created, updated, or deleted.

![ListLive](./img/ListLive.png)

## Usage

Use `<ListLive>` instead of `<List>`:

```jsx
import { Datagrid, TextField } from 'react-admin';
import { ListLive } from '@react-admin/ra-realtime';

const PostList = () => (
<ListLive>
<Datagrid>
<TextField source="title" />
</Datagrid>
</ListLive>
);
```

To trigger refreshes of `<ListLive>`, the API has to publish events containing at least the followings:

```js
{
topic : '/resource/{resource}',
event: {
type: '{deleted || created || updated}',
payload: { ids: [{listOfRecordIdentifiers}]},
}
}
```

`<ListLive>` accepts the same props as `<List>`. Refer to [the `<List>` documentation](https://marmelab.com/react-admin/List.html) for more information.

## `onEventReceived`

The `<ListLive>` allows you to customize the side effects triggered when it receives a new event, by passing a function to the `onEventReceived` prop:

```jsx
import { Datagrid, TextField, useNotify, useRefresh } from 'react-admin';
import { ListLive } from '@react-admin/ra-realtime';

const PostList = () => {
const notify = useNotify();
const refresh = useRefresh();

const handleEventReceived = event => {
const count = get(event, 'payload.ids.length', 1);
notify(`${count} items updated by another user`);
refresh();
};

return (
<ListLive onEventReceived={handleEventReceived}>
<Datagrid>
<TextField source="title" />
</Datagrid>
</ListLive>
);
};
```
22 changes: 22 additions & 0 deletions docs/Menu.md
Original file line number Diff line number Diff line change
@@ -322,3 +322,25 @@ Just use an empty `filter` query parameter to force empty filters:
If you need to display a menu item with a submenu, you should use [the `<MultiLevelMenu>` component](./MultiLevelMenu.md) instead of `<Menu>`.
![multilevel menu](https://marmelab.com/ra-enterprise/modules/assets/ra-multilevelmenu-item.gif)
## Live Updates
You can display a badge on the menu item to indicate that new data is available. Use [the `<MenuLive>` component](./MenuLive.md) instead of `<Menu>` to enable this feature.
```jsx
import { Admin, Layout, LayoutProps, Resource } from 'react-admin';
import { MenuLive } from '@react-admin/ra-realtime';
import { PostList, PostShow, PostEdit, realTimeDataProvider } from '.';

const CustomLayout = (props: LayoutProps) => (
<Layout {...props} menu={MenuLive} />
);

const MyReactAdmin = () => (
<Admin dataProvider={realTimeDataProvider} layout={CustomLayout}>
<Resource name="posts" list={PostList} show={PostShow} edit={PostEdit} />
</Admin>
);
```
![MenuLive](./img/MenuLive.png)
83 changes: 83 additions & 0 deletions docs/MenuLive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
layout: default
title: "The MenuLive Component"
---

# `<MenuLive>`

`<MenuLive>` is an [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> component that renders a Menu, and displays a badge with the number of updated records on each unactive Menu item.

![MenuLive](./img/MenuLive.png)

## Usage

Use `<MenuLive>` instead of `<Menu>` in a custom layout:

```jsx
import { Admin, Layout, LayoutProps, Resource } from 'react-admin';
import { MenuLive } from '@react-admin/ra-realtime';
import { PostList, PostShow, PostEdit, realTimeDataProvider } from '.';

const CustomLayout = (props: LayoutProps) => (
<Layout {...props} menu={MenuLive} />
);

const MyReactAdmin = () => (
<Admin dataProvider={realTimeDataProvider} layout={CustomLayout}>
<Resource name="posts" list={PostList} show={PostShow} edit={PostEdit} />
</Admin>
);
```

To trigger the `<MenuLive>` badges, the API has to publish events containing at least the followings keys:

```js
{
topic : '/resource/{resource}',
type: '{deleted || created || updated}',
payload: { ids: [{listOfRecordIdentifiers}]},
}
```

## `<MenuLiveItemLink>`

`<MenuLiveItemLink>` displays a badge with the number of updated records if the current menu item is not active (Used to build `<MenuLive>` and your custom `<MyMenuLive>`).

```jsx
import React from 'react';
import { MenuProps } from 'react-admin';
import { MenuLiveItemLink } from '@react-admin/ra-realtime';

const CustomMenuLive = () => (
<div>
<MenuLiveItemLink
to="/posts"
primaryText="The Posts"
resource="posts"
badgeColor="primary"
/>
<MenuLiveItemLink
to="/comments"
primaryText="The Comments"
resource="comments"
/>
</div>
);
```

`<MenuLiveItemLink>` has two additional props compared to `<MenuItemLink>`:

- `resource`: Needed, The name of the concerned resource (can be different from the path in the `to` prop)
- `badgeColor`: Optional, It's the MUI color used to display the color of the badge. The default is `alert` (not far from the red). It can also be `primary`, `secondary`, or any of the MUI colors available in the [MUI palette](https://material-ui.com/customization/palette/).

The badge displays the total number of changed records since the last time the `<MenuItem>` opened. The badge value resets whenever the user opens the resource list page, and the `<MenuItem>` becomes active.

To trigger `<MenuLiveItemLink>` behavior, the API has to publish events containing at least the following elements:

```js
{
topic : '/resource/{resource}',
type: '{deleted || created || updated}',
payload: { ids: [{listOfRecordIdentifiers}]},
}
```
249 changes: 249 additions & 0 deletions docs/Realtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
---
layout: default
title: "Realtime"
---

# Realtime

React-admin provides hooks and UI components for collaborative applications where several people work in parallel. It allows publishing and subscribing to real-time events, updating views when another user pushes a change, notifying end users of events, and preventing data loss when two editors work on the same resource concurrently.

<video controls autoplay muted>
<source src="./img/CollaborativeDemo.mp4" type="video/mp4">
Your browser does not support the video tag.
</video>

These features are provided by the `ra-realtime` package, which is part of the [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" />

## Backend Agnostic

The `ra-realtime` package supports various realtime infrastructures:

- [Mercure](https://mercure.rocks/),
- [API Platform](https://api-platform.com/docs/admin/real-time-mercure/#real-time-updates-with-mercure),
- [supabase](https://supabase.com/),
- [Socket.IO](https://socket.io/),
- [Ably](https://ably.com/),
- and many more.

That's because it uses the same _adapter_ approach as for CRUD methods. In fact, the `dataProvider` is used to send and receive events.

See the [Data Provider Requirements](./RealtimeDataProvider.md) page for more information.

## Publish/Subscribe

At its core, `ra-realtime` provides a **pub/sub mechanism** to send and receive real-time events. Events are sent to a topic, and all subscribers to this topic receive the event.

```jsx
// on the publisher side
const [publish] = usePublish();
publish(topic, event);

// on the subscriber side
useSubscribe(topic, callback);
```

`ra-realtime` provides a set of high-level hooks to make it easy to work with real-time events:

- [`usePublish`](./usePublish.md)
- [`useSubscribe`](./useSubscribe.md)
- [`useSubscribeCallback`](./useSubscribeCallback.md)
- [`useSubscribeToRecord`](./useSubscribeToRecord.md)
- [`useSubscribeToRecordList`](./useSubscribeToRecordList.md)

## Live Updates

Ra-realtime provides **live updates** via specialized hooks and components. This means that when a user edits a resource, the other users working on the same resource see the changes in real-time whether they are in a list, a show view, or an edit view.

For instance, replace `<List>` with `<ListLive>` to have a list refreshing automatically when an element is added, updated, or deleted:

```diff
import {
- List,
ListProps,
Datagrid,
TextField,
NumberField,
Datefield,
} from 'react-admin';
+import { ListLive } from '@react-admin/ra-realtime';

const PostList = (props: ListProps) => (
- <List {...props}>
+ <ListLive {...props}>
<Datagrid>
<TextField source="title" />
<NumberField source="views" />
<DateField source="published_at" />
</Datagrid>
- </List>
+ </ListLive>
);
```

![useSubscribeToRecordList](./img/useSubscribeToRecordList.gif)

This feature leverages the following hooks:

- [`useGetListLive`](./useGetListLive.md)
- [`useGetOneLive`](./useGetOneLive.md)

And the following components:

- [`<ListLive>`](./ListLive.md)
- [`<EditLive>`](./EditLive.md)
- [`<ShowLive>`](./ShowLive.md)

## Menu Badges

Ra-realtime also provides **badge notifications in the Menu**, so that users can see that something new happened to a resource list while working on another one.

![MenuLive](./img/RealtimeMenu.png)

Use `<MenuLive>` instead of react-admin's `<Menu>` to get this feature:

```jsx
import React from 'react';
import { Admin, Layout, Resource } from 'react-admin';
import { MenuLive } from '@react-admin/ra-realtime';

import { PostList, PostShow, PostEdit, realTimeDataProvider } from '.';

const CustomLayout = (props) => (
<Layout {...props} menu={MenuLive} />
);

const MyReactAdmin = () => (
<Admin dataProvider={realTimeDataProvider} layout={CustomLayout}>
<Resource name="posts" list={PostList} show={PostShow} edit={PostEdit} />
</Admin>
);
```

This feature leverages the following components:

- [`<MenuLive>`](./MenuLive.md)
- [`<MenuLiveItemLink>`](./MenuLive.md)

## Locks

And last but not least, ra-realtime provides a **lock mechanism** to prevent two users from editing the same resource at the same time.

![Edit With Locks](./img/locks-demo.gif)

A user can lock a resource, either by voluntarily asking for a lock or by editing a resource. When a resource is locked, other users can't edit it. When the lock is released, other users can edit the resource again.

```jsx
export const NewMessageForm = () => {
const [create, { isLoading: isCreating }] = useCreate();
const record = useRecordContext();

const { data: lock } = useGetLockLive('tickets', { id: record.id });
const { identity } = useGetIdentity();
const isFormDisabled = lock && lock.identity !== identity?.id;

const [doLock] = useLockOnCall({ resource: 'tickets' });
const handleSubmit = (values: any) => {
/* ... */
};

return (
<Form onSubmit={handleSubmit}>
<TextInput
source="message"
multiline
onFocus={() => {
doLock();
}}
disabled={isFormDisabled}
/>
<SelectInput
source="status"
choices={statusChoices}
disabled={isFormDisabled}
/>
<Button type="submit" disabled={isCreating || isFormDisabled}>
Submit
</Button>
</Form>
);
};
```
This feature leverages the following hooks:
- [`useLock`](./useLock.md)
- [`useUnlock`](./useUnlock.md)
- [`useGetLock`](./useGetLock.md)
- [`useGetLockLive`](./useGetLockLive.md)
- [`useGetLocks`](./useGetLocks.md)
- [`useGetLocksLive`](./useGetLocksLive.md)
- [`useLockOnCall`](./useLockOnCall.md)
- [`useLockOnMount`](./useLockOnMount.md)
## Installation
```sh
npm install --save @react-admin/ra-realtime
# or
yarn add @react-admin/ra-realtime
```
`ra-realtime` is part of the [React-Admin Enterprise Edition](https://marmelab.com/ra-enterprise/), and hosted in a private npm registry. You need to subscribe to one of the Enterprise Edition plans to install this package.
You will need a data provider that supports real-time subscriptions. Check out the [Data Provider Requirements](./RealTimeDataProvider.md) section for more information.
## I18N
This module uses specific translations for displaying notifications. As for all translations in react-admin, it's possible to customize the messages.
To create your own translations, you can use the TypeScript types to see the structure and see which keys are overridable.
Here is an example of how to customize translations in your app:
```jsx
import polyglotI18nProvider from 'ra-i18n-polyglot';
import englishMessages from 'ra-language-english';
import frenchMessages from 'ra-language-french';
import {
TranslationMessages as BaseTranslationMessages,
raRealTimeEnglishMessages,
raRealTimeFrenchMessages,
RaRealTimeTranslationMessages,
} from '@react-admin/ra-realtime';

/* TranslationMessages extends the defaut translation
* Type from react-admin (BaseTranslationMessages)
* and the ra-realtime translation Type (RaRealTimeTranslationMessages)
*/
interface TranslationMessages
extends RaRealTimeTranslationMessages,
BaseTranslationMessages {}

const customEnglishMessages: TranslationMessages = mergeTranslations(
englishMessages,
raRealTimeEnglishMessages,
{
'ra-realtime': {
notification: {
record: {
updated: 'Wow, this entry has been modified by a ghost',
deleted: 'Hey, a ghost has stolen this entry',
},
},
},
}
);

const i18nCustomProvider = polyglotI18nProvider(locale => {
if (locale === 'fr') {
return mergeTranslations(frenchMessages, raRealTimeFrenchMessages);
}
return customEnglishMessages;
}, 'en');

export const MyApp = () => (
<Admin dataProvider={myDataprovider} i18nProvider={i18nCustomProvider}>
...
</Admin>
);
```
329 changes: 329 additions & 0 deletions docs/RealtimeDataProvider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
---
layout: default
title: "Realtime DataProvider Requirements"
---

# Realtime DataProvider Requirements

To enable real-time features, the `dataProvider` must implement three new methods:

- `subscribe(topic, callback)`
- `unsubscribe(topic, callback)`
- `publish(topic, event)` (optional - publication is often done server-side)

These methods should return an empty Promise resolved when the action was acknowledged by the real-time bus.

In addition, to support the lock features, the `dataProvider` must implement 4 more methods:

- `lock(resource, { id, identity, meta })`
- `unlock(resource, { id, identity, meta })`
- `getLock(resource, { id, meta })`
- `getLocks(resource, { meta })`

## API-Platform Adapter

The `ra-realtime` package contains a function augmenting a regular (API-based) `dataProvider` with real-time methods based on the capabilities of [API-Platform](https://api-platform.com/). Use it as follows:

```jsx
import { Datagrid, EditButton, ListProps } from 'react-admin';
import {
HydraAdmin,
ResourceGuesser,
FieldGuesser,
hydraDataProvider,
} from '@api-platform/admin';
import {
ListLive,
addRealTimeMethodsBasedOnApiPlatform,
} from '@react-admin/ra-realtime';

const dataProvider = hydraDataProvider('https://localhost:8443');
const dataProviderWithRealtime = addRealTimeMethodsBasedOnApiPlatform(
// The original dataProvider (should be a hydra data provider passed by API-Platform)
dataProvider,
// The API-Platform Mercure Hub URL
'https://localhost:1337/.well-known/mercure',
// JWT token to authenticate against the API-Platform Mercure Hub
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.obDjwCgqtPuIvwBlTxUEmibbBf0zypKCNzNKP7Op2UM',
// The topic URL used by API-Platform (without a slash at the end)
'https://localhost:8443'
);

const App = () => (
<HydraAdmin
entrypoint="https://localhost:8443"
dataProvider={dataProviderWithRealtime}
>
<ResourceGuesser name="greetings" list={GreetingsList} />
</HydraAdmin>
);


// Example for connecting a list of greetings
const GreetingsList = () => (
<ListLive>
<Datagrid>
<FieldGuesser source="name" />
<EditButton />
</Datagrid>
</ListLive>
);
```

## Mercure Adapter

The `ra-realtime` package contains a function augmenting a regular (API-based) `dataProvider` with real-time methods based on [a Mercure hub](https://mercure.rocks/). Use it as follows:

```jsx
import { addRealTimeMethodsBasedOnMercure } from '@react-admin/ra-realtime';

const dataProviderWithRealtime = addRealTimeMethodsBasedOnMercure(
// original dataProvider
dataProvider,
// Mercure hub URL
'http://path.to.my.api/.well-known/mercure',
// JWT token to authenticate against the Mercure Hub
'eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdLCJzdWJzY3JpYmUiOlsiKiJdfX0.SWKHNF9wneXTSjBg81YN5iH8Xb2iTf_JwhfUY5Iyhsw'
);

const App = () => (
<Admin dataProvider={dataProviderWithRealtime}>{/* ... */}</Admin>
);
```

## Writing a Custom Adapter

If you're using another transport for real-time messages (WebSockets, long polling, GraphQL subscriptions, etc.), you'll have to implement `subscribe`, `unsubscribe`, and `publish` yourself in your `dataProvider`. As an example, here is an implementation using a local variable, that `ra-realtime` uses in tests:

```ts
let subscriptions = [];

const dataProvider = {
// regular dataProvider methods like getList, getOne, etc,
// ...
subscribe: async (topic, subscriptionCallback) => {
subscriptions.push({ topic, subscriptionCallback });
return Promise.resolve({ data: null });
},

unsubscribe: async (topic, subscriptionCallback) => {
subscriptions = subscriptions.filter(
subscription =>
subscription.topic !== topic ||
subscription.subscriptionCallback !== subscriptionCallback
);
return Promise.resolve({ data: null });
},

publish: (topic, event) => {
if (!topic) {
return Promise.reject(new Error('missing topic'));
}
if (!event.type) {
return Promise.reject(new Error('missing event type'));
}
subscriptions.map(
subscription =>
topic === subscription.topic &&
subscription.subscriptionCallback(event)
);
return Promise.resolve({ data: null });
},
};
```

You can check the behavior of the real-time components by using the default console logging provided in `addRealTimeMethodsInLocalBrowser`.

## Topic And Event Format

You've noticed that all the `dataProvider` real-time methods expect a `topic` as the first argument. A `topic` is just a string, identifying a particular real-time channel. Topics can be used e.g. to dispatch messages to different rooms in a chat application or to identify changes related to a particular record.

Most `ra-realtime` components deal with CRUD logic, so `ra-realtime` subscribes to special topics named `resource/[name]` and `resource/[name]/[id]`. For your own events, use any `topic` you want.

The `event` is the name of the message sent from publishers to subscribers. An `event` should be a JavaScript object with a `type` and a `payload` field.

Here is an example event:

```js
{
type: 'created',
payload: 'New message',
}
```

For CRUD operations, `ra-realtime` expects events to use the types 'created', 'updated', and 'deleted'.

## CRUD Events

Ra-realtime has deep integration with react-admin, where most of the logic concerns Creation, Update or Deletion (CRUD) of records. To enable this integration, your real-time backend should publish the following events:

- when a new record is created:

```js
{
topic: `resource/${resource}`,
event: {
type: 'created',
payload: { ids: [id]},
},
}
```

- when a record is updated:

```js
{
topic: `resource/${resource}/id`,
event: {
type: 'updated',
payload: { ids: [id]},
},
}
{
topic: `resource/${resource}`,
event: {
type: 'updated',
payload: { ids: [id]},
},
}
```

- when a record is deleted:

```js
{
topic: `resource/${resource}/id`,
event: {
type: 'deleted',
payload: { ids: [id]},
},
}
{
topic: `resource/${resource}`,
event: {
type: 'deleted',
payload: { ids: [id]},
},
}
```

## Lock Format

A `lock` stores the record that is locked, the identity of the locker, and the time at which the lock was acquired. It is used to prevent concurrent editing of the same record. A typical lock looks like this:

```js
{
resource: 'posts',
recordId: 123,
identity: 'julien',
createdAt: '2023-01-02T21:36:35.133Z',
}
```

The `dataProvider.getLock()` and `dataProvider.getLocks()` methods should return these locks.

As for the mutation methods (`dataProvider.lock()`, `dataProvider.unlock()`), they expect the following parameters:

- `resource`: the resource name (e.g. `'posts'`)
- `params`: an object containing the following
- `id`: the record id (e.g. `123`)
- `identity`: an identifier (string or number) corresponding to the identity of the locker (e.g. `'julien'`). This could be an authentication token for instance.
- `meta`: an object that will be forwarded to the dataProvider (optional)

## Locks Based On A Lock Resource

The `ra-realtime` package offers a function augmenting a regular (API-based) `dataProvider` with locks methods based on a `locks` resource.

It will translate a `dataProvider.getLocks()` call to a `dataProvider.getList('locks')` call, and a `dataProvider.lock()` call to a `dataProvider.create('locks')` call.

The `lock` resource should contain the following fields:

```json
{
"id": 123,
"identity": "Toad",
"resource": "people",
"recordId": 18,
"createdAt": "2020-09-29 10:20"
}
```

Please note that the `identity` and the `createdAt` formats depend on your API.

Here is how to use it in your react-admin application:

```jsx
import { Admin } from 'react-admin';
import { addLocksMethodsBasedOnALockResource } from '@react-admin/ra-realtime';

const dataProviderWithLocks = addLocksMethodsBasedOnALockResource(
dataProvider // original dataProvider
);

const App = () => (
<Admin dataProvider={dataProviderWithLocks}>{/* ... */}</Admin>
);
```

## Calling the `dataProvider` Methods Directly

Once you've set a real-time `dataProvider` in your `<Admin>`, you can call the real-time methods in your React components via the `useDataProvider` hook.

For instance, here is a component displaying messages posted to the 'messages' topic in real time:

```jsx
import React, { useState } from 'react';
import { useDataProvider, useNotify } from 'react-admin';

const MessageList = () => {
const notify = useNotify();
const [messages, setMessages] = useState([]);
const dataProvider = useDataProvider();

useEffect(() => {
const callback = event => {
// event is like
// {
// topic: 'messages',
// type: 'created',
// payload: 'New message',
// }
setMessages(messages => [...messages, event.payload]);
notify('New message');
};
// subscribe to the 'messages' topic on mount
dataProvider.subscribe('messages', callback);
// unsubscribe on unmount
return () => dataProvider.unsubscribe('messages', callback);
}, [setMessages, notify, dataProvider]);

return (
<ul>
{messages.map((message, index) => (
<li key={index}>{message}</li>
))}
</ul>
);
};
```

And here is a button for publishing an event to the `messages` topic. All the subscribers to this topic will execute their callback:

```jsx
import React from 'react';
import { useDataProvider, useNotify } from 'react-admin';

const SendMessageButton = () => {
const dataProvider = useDataProvider();
const notify = useNotify();
const handleClick = () => {
dataProvider
.publish('messages', { type: 'created', payload: 'New message' })
.then(() => notify('Message sent'));
};

return <Button onClick={handleClick}>Send new message</Button>;
};
```

**Tip**: You should not need to call `publish()` directly very often. Most real-time backends publish events in reaction to a change in the data. So the previous example is fictive. In reality, a typical `<SendMessageButton>` would simply call `dataProvider.create('messages')`, and the API would create the new message AND publish the 'created' event to the real-time bus.
32 changes: 23 additions & 9 deletions docs/Reference.md
Original file line number Diff line number Diff line change
@@ -57,6 +57,7 @@ title: "Index"

**- E -**
* [`<Edit>`](./Edit.md)
* [`<EditLive>`](./EditLive.md)<img class="icon" src="./img/premium.svg" />
* [`<EditableDatagrid>`](./EditableDatagrid.md)<img class="icon" src="./img/premium.svg" />
* [`<EditGuesser>`](./EditGuesser.md)
* [`<EditButton>`](./Buttons.md#editbutton)
@@ -100,12 +101,14 @@ title: "Index"
* [`<ListBase>`](./ListBase.md#usage)
* [`<ListGuesser>`](./ListGuesser.md#usage)
* [`<ListButton>`](./Buttons.md#listbutton)
* [`<ListLive>`](./ListLive.md)<img class="icon" src="./img/premium.svg" />
* [`<LocalesMenuButton>`](./LocalesMenuButton.md)

**- M -**
* [`<MarkdownField>`](./MarkdownField.md)<img class="icon" src="./img/premium.svg" />
* [`<MarkdownInput>`](./MarkdownInput.md)<img class="icon" src="./img/premium.svg" />
* [`<Menu>`](./Menu.md)
* [`<MenuLive>`](./MenuLive.md)<img class="icon" src="./img/premium.svg" />
* [`<MultiLevelMenu>`](./MultiLevelMenu.md)<img class="icon" src="./img/premium.svg" />

**- N -**
@@ -121,10 +124,6 @@ title: "Index"

**- R -**
* [`<RadioButtonGroupInput>`](./RadioButtonGroupInput.md)
* [`<RealTimeEdit>`](https://marmelab.com/ra-enterprise/modules/ra-realtime#real-time-views-list-edit-show)<img class="icon" src="./img/premium.svg" />
* [`<RealTimeList>`](https://marmelab.com/ra-enterprise/modules/ra-realtime#real-time-views-list-edit-show)<img class="icon" src="./img/premium.svg" />
* [`<RealTimeMenu>`](https://marmelab.com/ra-enterprise/modules/ra-realtime#realtimemenu)<img class="icon" src="./img/premium.svg" />
* [`<RealTimeShow>`](https://marmelab.com/ra-enterprise/modules/ra-realtime#real-time-views-list-edit-show)<img class="icon" src="./img/premium.svg" />
* [`<ReferenceArrayField>`](./ReferenceArrayField.md)
* [`<ReferenceArrayInput>`](./ReferenceArrayInput.md)
* [`<ReferenceField>`](./ReferenceField.md)
@@ -154,6 +153,7 @@ title: "Index"
* [`<ShowButton>`](./Buttons.md#showbutton)
* [`<ShowDialog>`](https://marmelab.com/ra-enterprise/modules/ra-form-layout#createdialog-editdialog--showdialog)<img class="icon" src="./img/premium.svg" />
* [`<ShowInDialogButton>`](./ShowInDialogButton.md)<img class="icon" src="./img/premium.svg" />
* [`<ShowLive>`](./ShowLive.md)<img class="icon" src="./img/premium.svg" />
* [`<Sidebar>`](./Theming.md#sidebar-customization)
* [`<SidebarOpenPreferenceSync>`](https://marmelab.com/ra-enterprise/modules/ra-preferences#sidebaropenpreferencesync-store-the-sidebar-openclose-state-in-preferences)<img class="icon" src="./img/premium.svg" />
* [`<SimpleForm>`](./SimpleForm.md)
@@ -190,6 +190,8 @@ title: "Index"

</div>

---

## Hooks

<div class="pages-index" markdown="1">
@@ -205,6 +207,7 @@ title: "Index"
* [`useCanAccess`](./useCanAccess.md)<img class="icon" src="./img/premium.svg" />
* [`useChoicesContext`](./useChoicesContext.md)
* [`useCreate`](./useCreate.md)
* [`useCreateContext`](./useCreateContext.md)
* [`useCreateController`](./useCreateController.md)

**- D -**
@@ -213,22 +216,25 @@ title: "Index"
* [`useDeleteMany`](./useDeleteMany.md)

**- E -**
* [`useEditContext`](./useEditContext.md)
* [`useEditController`](./useEditController.md)

**- G -**
* [`useGetIdentity`](./useGetIdentity.md)
* [`useGetList`](./useGetList.md)
* [`useGetListLive`](./useGetListLive.md)<img class="icon" src="./img/premium.svg" />
* [`useGetLock`](./useGetLock.md)<img class="icon" src="./img/premium.svg" />
* [`useGetLockLive`](./useGetLockLive.md)<img class="icon" src="./img/premium.svg" />
* [`useGetLocks`](./useGetLocks.md)<img class="icon" src="./img/premium.svg" />
* [`useGetLocksLive`](./useGetLocksLive.md)<img class="icon" src="./img/premium.svg" />
* [`useGetMany`](./useGetMany.md)
* [`useGetManyAggregate`](./useGetOne.md#aggregating-getone-calls)
* [`useGetManyReference`](./useGetManyReference.md)
* [`useGetOne`](./useGetOne.md)
* [`useGetOneLive`](./useGetOneLive.md)<img class="icon" src="./img/premium.svg" />
* [`useGetPermissions`](./WithPermissions.md)
* [`useGetRecordId`](./useGetRecordId.md)

**- H -**
* [`useHasLock`](https://marmelab.com/ra-enterprise/modules/ra-realtime#locks-on-content)<img class="icon" src="./img/premium.svg" />
* [`useHasLocks`](https://marmelab.com/ra-enterprise/modules/ra-realtime#locks-on-content)<img class="icon" src="./img/premium.svg" />

**- I -**
* [`useInfiniteGetList`](./useInfiniteGetList.md)
* [`useInput`](./useInput.md)
@@ -238,7 +244,9 @@ title: "Index"
* [`useListContext`](./useListContext.md)
* [`useListController`](./useListController.md)
* [`useLocaleState`](./useLocaleState.md)
* [`useLock`](https://marmelab.com/ra-enterprise/modules/ra-realtime#locks-on-content)<img class="icon" src="./img/premium.svg" />
* [`useLock`](./useLock.md)<img class="icon" src="./img/premium.svg" />
* [`useLockOnCall`](./useLockOnCall.md)<img class="icon" src="./img/premium.svg" />
* [`useLockOnMount`](./useLockOnMount.md)<img class="icon" src="./img/premium.svg" />
* [`useLogin`](./useLogin.md)
* [`useLogout`](./useLogout.md)

@@ -251,6 +259,7 @@ title: "Index"
**- P -**
* [`usePermissions`](./usePermissions.md)
* [`usePreferences`](https://marmelab.com/ra-enterprise/modules/ra-preferences#usepreferences-reading-and-writing-user-preferences)<img class="icon" src="./img/premium.svg" />
* [`usePublish`](./usePublish.md)<img class="icon" src="./img/premium.svg" />

**- R -**
* [`useRecordContext`](./useRecordContext.md)
@@ -268,6 +277,10 @@ title: "Index"
* [`useShowController`](./useShowController.md#useshowcontroller)
* [`useStore`](./useStore.md)
* [`useStoreContext`](./useStoreContext.md)
* [`useSubscribe`](./useSubscribe.md)<img class="icon" src="./img/premium.svg" />
* [`useSubscribeCallback`](./useSubscribeCallback.md)<img class="icon" src="./img/premium.svg" />
* [`useSubscribeToRecord`](./useSubscribeToRecord.md)<img class="icon" src="./img/premium.svg" />
* [`useSubscribeToRecordList`](./useSubscribeToRecordList.md)<img class="icon" src="./img/premium.svg" />

**- T -**
* [`useTheme`](./Theming.md#changing-the-theme-programmatically)
@@ -277,6 +290,7 @@ title: "Index"
**- U -**
* [`useUpdate`](./useUpdate.md)
* [`useUpdateMany`](./useUpdateMany.md)
* [`useUnlock`](./useUnlock.md)<img class="icon" src="./img/premium.svg" />
* [`useUnselect`](./useUnselect.md)
* [`useUnselectAll`](./useUnselectAll.md)

22 changes: 22 additions & 0 deletions docs/Show.md
Original file line number Diff line number Diff line change
@@ -406,6 +406,28 @@ export const PostShow = () => (
```
{% endraw %}

## Live Updates

If you want to subscribe to live updates on the record (topic: `resource/[resource]/[id]`), use [the `<ShowLive>` component](./ShowLive.md) instead.

```diff
-import { Show, SimpleShowLayout, TextField } from 'react-admin';
+import { SimpleShowLayout, TextField } from 'react-admin';
+import { ShowLive } from '@react-admin/ra-realtime';

const PostShow = () => (
- <Show>
+ <ShowLive>
<SimpleShowLayout>
<TextField source="title" />
</SimpleShowLayout>
- </Show>
+ </ShowLive>
);
```

It shows a notification and refreshes the page when the record is updated by another user. Also, it displays a warning when the record is deleted by another user.

## API

* [`<Show>`]
74 changes: 74 additions & 0 deletions docs/ShowLive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
layout: default
title: "ShowLive"
---

# `<ShowLive>`

`<ShowLive>` is an [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> component that renders a Show page. It shows a notification and refreshes the page when the record is updated by another user. Also, it displays a warning when the record is deleted by another user.

![ShowLive](./img/ShowLive.png)

## Usage

Use `<ShowLive>` instead of `<Show>`:

```jsx
import { SimpleShowLayout, TextField } from 'react-admin';
import { ShowLive } from '@react-admin/ra-realtime';

const PostShow = () => (
<ShowLive>
<SimpleShowLayout>
<TextField source="title" />
</SimpleShowLayout>
</ShowLive>
);
```

To trigger the `<ShowLive>` updates, the API has to publish events containing at least the following:

```js
{
topic : '/resource/{resource}/{recordIdentifier}',
type: '{deleted || updated}',
payload: { id: [{recordIdentifier}]},
}
```

`<ShowLive>` accepts the same props as `<Show>`. Refer to [the `<Show>` documentation](./Show.md) for more information.

## `onEventReceived`

The `<ShowLive>` allows you to customize the side effects triggered when it receives a new event, by passing a function to the `onEventReceived` prop:

```jsx
import { SimpleShowLayout, TextField, useRefresh } from 'react-admin';
import { ShowLive, EventType } from '@react-admin/ra-realtime';

const PostShow = () => {
const notify = useNotify();

const handleEventReceived = (event, { setDeleted }) => {
if (event.type === EventType.Updated) {
notify('Record updated');
refresh();
} else if (event.type === EventType.Deleted) {
notify('Record deleted');
setDeleted(true);
}
};

return (
<ShowLive onEventReceived={handleEventReceived}>
<SimpleShowLayout>
<TextField source="title" />
</SimpleShowLayout>
</ShowLive>
);
};
```

The function passed to `onEventReceived` will be called with the event as its first argument and an object containing functions that will update the UI:

- `setDeleted`: If set to `true`, the edit view will show a message to let users know this record has been deleted.
2 changes: 2 additions & 0 deletions docs/css/style-v13.css
Original file line number Diff line number Diff line change
@@ -592,8 +592,10 @@ body.no-sidebar .sidenav-trigger {
}
.pages-index ul > li {
list-style-type: none;
line-height: 1.2em;
}

.pages-index code {
font-size: 0.8em;
background: none!important;
}
8 changes: 8 additions & 0 deletions docs/documentation.html
Original file line number Diff line number Diff line change
@@ -177,6 +177,14 @@ <h2>UI components</h2>
<div class="material-icons">&#xf1c1;</div>
</a>

<a href="./Realtime.html">
<div class="docBlock">
<h2>Realtime</h2>
Pub/Sub, live updates, menu badges, locks.
</div>
<div class="material-icons">&#xe627;</div>
</a>

<a href="./Theming.html">
<div class="docBlock">
<h2>Theming</h2>
Binary file added docs/img/CollaborativeDemo.mp4
Binary file not shown.
Binary file added docs/img/EditLive.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/ListLive.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/MenuLive.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/RealtimeMenu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/ShowLive.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/locks-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/useLockOnCall.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/useLockOnMount.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/useSubscribe.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/useSubscribeCallback.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/useSubscribeOnce.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/useSubscribeOnceCallback.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/useSubscribeToRecord.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/useSubscribeToRecordList.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/useSubscribeUnsubscribe.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 25 additions & 1 deletion docs/navigation.html
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@
<li {% if page.path == 'DataProviders.md' %} class="active" {% endif %}><a class="nav-link" href="./DataProviders.html">Setting Up</a></li>
<li {% if page.path == 'DataProviderWriting.md' %} class="active" {% endif %}><a class="nav-link" href="./DataProviderWriting.html">Writing A Data Provider</a></li>
<li {% if page.path == 'Actions.md' %} class="active" {% endif %}><a class="nav-link" href="./Actions.html">Querying the API</a></li>
<li {% if page.path == 'DataProviderLive.md' %} class="active" {% endif %}><a class="nav-link" href="./DataProviderLive.html">Real-time Updates &amp; Locks<img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useDataProvider.md' %} class="active" {% endif %}><a class="nav-link" href="./useDataProvider.html"><code>useDataProvider</code></a></li>
<li {% if page.path == 'useGetList.md' %} class="active" {% endif %}><a class="nav-link" href="./useGetList.html"><code>useGetList</code></a></li>
<li {% if page.path == 'useInfiniteGetList.md' %} class="active" {% endif %}><a class="nav-link" href="./useInfiniteGetList.html"><code>useInfiniteGetList</code></a></li>
@@ -33,7 +34,6 @@
<li {% if page.path == 'useUpdateMany.md' %} class="active" {% endif %}><a class="nav-link" href="./useUpdateMany.html"><code>useUpdateMany</code></a></li>
<li {% if page.path == 'useDelete.md' %} class="active" {% endif %}><a class="nav-link" href="./useDelete.html"><code>useDelete</code></a></li>
<li {% if page.path == 'useDeleteMany.md' %} class="active" {% endif %}><a class="nav-link" href="./useDeleteMany.html"><code>useDeleteMany</code></a></li>
<li><a class="nav-link external" href="https://marmelab.com/ra-enterprise/modules/ra-realtime#real-time-hooks.html" target="_blank"><code>useSubscribe</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useGetTree.md' %} class="active" {% endif %}><a class="nav-link" href="./useGetTree.html"><code>useGetTree</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'withLifecycleCallbacks.md' %} class="active" {% endif %}><a class="nav-link" href="./withLifecycleCallbacks.html"><code>withLifecycleCallbacks</code></a></li>
</ul>
@@ -227,6 +227,30 @@
<li {% if page.path == 'Confirm.md' %} class="active" {% endif %}><a class="nav-link" href="./Confirm.html">Confirm</a></li>
</ul>

<ul><div>Realtime</div>
<li {% if page.path == 'Realtime.md' %} class="active" {% endif %}><a class="nav-link" href="./Realtime.html">Introduction</a></li>
<li {% if page.path == 'RealtimeDataProvider.md' %} class="active" {% endif %}><a class="nav-link" href="./RealtimeDataProvider.html">Data Provider Requirements</a></li>
<li {% if page.path == 'usePublish.md' %} class="active" {% endif %}><a class="nav-link" href="./usePublish.html"><code>usePublish</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useSubscribe.md' %} class="active" {% endif %}><a class="nav-link" href="./useSubscribe.html"><code>useSubscribe</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useSubscribeCallback.md' %} class="active" {% endif %}><a class="nav-link" href="./useSubscribeCallback.html"><code>useSubscribeCallback</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useSubscribeToRecord.md' %} class="active" {% endif %}><a class="nav-link" href="./useSubscribeToRecord.html"><code>useSubscribeToRecord</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useSubscribeToRecordList.md' %} class="active" {% endif %}><a class="nav-link" href="./useSubscribeToRecordList.html"><code>useSubscribeToRecordList</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useLock.md' %} class="active" {% endif %}><a class="nav-link" href="./useLock.html"><code>useLock</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useUnlock.md' %} class="active" {% endif %}><a class="nav-link" href="./useUnlock.html"><code>useUnlock</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useGetLock.md' %} class="active" {% endif %}><a class="nav-link" href="./useGetLock.html"><code>useGetLock</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useGetLockLive.md' %} class="active" {% endif %}><a class="nav-link" href="./useGetLockLive.html"><code>useGetLockLive</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useGetLocks.md' %} class="active" {% endif %}><a class="nav-link" href="./useGetLocks.html"><code>useGetLocks</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useGetLocksLive.md' %} class="active" {% endif %}><a class="nav-link" href="./useGetLocksLive.html"><code>useGetLocksLive</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useLockOnMount.md' %} class="active" {% endif %}><a class="nav-link" href="./useLockOnMount.html"><code>useLockOnMount</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useLockOnCall.md' %} class="active" {% endif %}><a class="nav-link" href="./useLockOnCall.html"><code>useLockOnCall</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useGetListLive.md' %} class="active" {% endif %}><a class="nav-link" href="./useGetListLive.html"><code>useGetListLive</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'useGetOneLive.md' %} class="active" {% endif %}><a class="nav-link" href="./useGetOneLive.html"><code>useGetOneLive</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'ListLive.md' %} class="active" {% endif %}><a class="nav-link" href="./ListLive.html"><code>&lt;ListLive&gt;</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'EditLive.md' %} class="active" {% endif %}><a class="nav-link" href="./EditLive.html"><code>&lt;EditLive&gt;</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'ShowLive.md' %} class="active" {% endif %}><a class="nav-link" href="./ShowLive.html"><code>&lt;ShowLive&gt;</code><img class="premium" src="./img/premium.svg" /></a></li>
<li {% if page.path == 'MenuLive.md' %} class="active" {% endif %}><a class="nav-link" href="./MenuLive.html"><code>&lt;MenuLive&gt;</code><img class="premium" src="./img/premium.svg" /></a></li>
</ul>

<ul><div>Recipes</div>
<li {% if page.path == 'Theming.md' %} class="active" {% endif %}><a class="nav-link" href="./Theming.html">Theming</a></li>
<li {% if page.path == 'Caching.md' %} class="active" {% endif %}><a class="nav-link" href="./Caching.html">Caching</a></li>
33 changes: 33 additions & 0 deletions docs/useGetList.md
Original file line number Diff line number Diff line change
@@ -85,3 +85,36 @@ const LatestNews = () => {
```

Alternately, you can use [the `useInfiniteGetList` hook](./useInfiniteGetList.md) to keep the previous pages on screen while loading new pages - just like users see older content when they scroll down their feed on social media.

## Live Updates

If you want to subscribe to live updates on the list of records (topic: `resource/[resource]`), use [the `useGetListLive` hook](./useGetListLive.md) instead.

```diff
-import { useGetList } from 'react-admin';
+import { useGetListLive } from '@react-admin/ra-realtime';

const LatestNews = () => {
- const { data, total, isLoading, error } = useGetList('posts', {
+ const { data, total, isLoading, error } = useGetListLive('posts', {
pagination: { page: 1, perPage: 10 },
sort: { field: 'published_at', order: 'DESC' },
});
if (isLoading) {
return <Loading />;
}
if (error) {
return <p>ERROR</p>;
}

return (
<ul>
{data.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
};
```

The `data` will automatically update when a new record is created, or an existing record is updated or deleted.
39 changes: 39 additions & 0 deletions docs/useGetListLive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
layout: default
title: "useGetListLive"
---

# `useGetListLive`

This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook, alternative to [`useGetList`](./useGetList.md), subscribes to live updates on the record list.

## Usage

```jsx
import { useGetListLive } from '@react-admin/ra-realtime';

const LatestNews = () => {
const { data, total, isLoading, error } = useGetListLive('posts', {
pagination: { page: 1, perPage: 10 },
sort: { field: 'published_at', order: 'DESC' },
});
if (isLoading) {
return <Loading />;
}
if (error) {
return <p>ERROR</p>;
}

return (
<ul>
{data.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
};
```

The hook will subscribe to live updates on the list of records (topic: `resource/[resource]`) and will refetch the list when a new record is created, or an existing record is updated or deleted.

See [the `useGetList` documentation](./useGetList.md) for the full list of parameters and return type.
67 changes: 67 additions & 0 deletions docs/useGetLock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
layout: default
title: "useGetLock"
---

# `useGetLock`

This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook gets the lock status for a record. It calls `dataProvider.getLock()` on mount.

## Usage

```jsx
import { useGetLock } from '@react-admin/ra-realtime';

const { data, isLoading } = useGetLock(resource, { id });
```

Here is a custom form Toolbar that displays the lock status of the current record:

```jsx
import {
Toolbar,
SaveButton,
useGetIdentity,
useResourceContext,
useRecordContext,
} from 'react-admin';
import { useGetLock } from '@react-admin/ra-enterprise';

const CustomToolbar = () => {
const resource = useResourceContext();
const record = useRecordContext();
const { isLoading: identityLoading, identity } = useGetIdentity();
const { isLoading: lockLoading, data: lock } = useGetLock(resource, {
id: record.id,
});

if (identityLoading || lockLoading) {
return null;
}

const isLockedByOtherUser = lock?.identity !== identity.id;

return (
<Toolbar>
<SaveButton disabled={isLockedByOtherUser} />
{isLockedByOtherUser && (
<LockMessage>
{`This record is locked by another user: ${lock?.dentity}.`}
</LockMessage>
)}
</Toolbar>
);
};
```
## Parameters
- `resource`: the resource name (e.g. `'posts'`)
- `params`: an object with the following properties:
- `id`: the record id (e.g. `123`)
- `meta`: Optional. an object that will be forwarded to the dataProvider (optional)
## Live Version
To get the list of locks update in real time based on the `lock/[resource]` topic, use [the `useGetLockLive` hook](./useGetLockLive.md) instead.
31 changes: 31 additions & 0 deletions docs/useGetLockLive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
layout: default
title: "useGetLockLive"
---

# `useGetLockLive`

Use the `useGetLockLive()` hook to get the lock status in real time. This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook calls `dataProvider.getLock()` for the current record on mount, and subscribes to live updates on the `lock/[resource]/[id]` topic.

This means that if the lock is acquired or released by another user while the current user is on the page, the return value will be updated.

## Usage

```jsx
import { useGetLockLive } from '@react-admin/ra-realtime';
import { useGetIdentity } from 'react-admin';

const LockStatus = () => {
const { data: lock } = useGetLockLive();
const { identity } = useGetIdentity();
if (!lock) return <span>No lock</span>;
if (lock.identity === identity?.id) return <span>Locked by you</span>;
return <span>Locked by {lock.identity}</span>;
};
```
`useGetLockLive` reads the current resource and record id from the `ResourceContext` and `RecordContext`. You can provide them explicitly if you are not in such a context:
```tsx
const { data: lock } = useGetLockLive('posts', { id: 123 });
```
68 changes: 68 additions & 0 deletions docs/useGetLocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
layout: default
title: "useGetLocks"
---

# `useGetLocks`

This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook gets all the locks for a given resource. Calls `dataProvider.getLocks()` on mount.

## Usage

```jsx
import { useGetLocks } from '@react-admin/ra-realtime';

const { data } = useGetLocks('posts');
```

Here is how to use it in a custom Datagrid, to disable edit and delete buttons for locked records:

{% raw %}
```jsx
const MyPostGrid = () => {
const resource = useResourceContext();
const { data: locks } = useGetLocks(resource);
return (
<Datagrid
bulkActionButtons={false}
sx={{
'& .MuiTableCell-root:last-child': {
textAlign: 'right',
},
}}
>
<MyPostTitle label="Title" locks={locks} />
<MyPostActions label="Actions" locks={locks} />
</Datagrid>
);
};

const MyPostTitle = ({ label, locks }: { label: string; locks: Lock[] }) => {
const record = useRecordContext();
const lock = locks.find(l => l.recordId === record.id);

return (
<WrapperField label={label}>
<TextField source="title" />
{lock && (
<span style={{ color: 'red' }}>
{` (Locked by ${lock.identity})`}
</span>
)}
</WrapperField>
);
};

const MyPostActions = ({ label, locks }: { label: string; locks: Lock[] }) => {
const record = useRecordContext();
const locked = locks.find(l => l.recordId === record.id);

return (
<WrapperField label={label}>
<DeleteButton disabled={!!locked} />
<LockableEditButton disabled={!!locked} />
</WrapperField>
);
};
```
{% endraw %}
44 changes: 44 additions & 0 deletions docs/useGetLocksLive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
layout: default
title: "useGetLocksLive"
---

# `useGetLocksLive`

Use the `useGetLocksLive` hook to get the locks in real time. This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook calls `dataProvider.getLocks()` for the current resource on mount, and subscribes to live updates on the `lock/[resource]` topic.

This means that if a lock is acquired or released by another user while the current user is on the page, the return value will be updated.

## Usage

```jsx
import { List, useRecordContext } from 'react-admin';
import LockIcon from '@mui/icons-material/Lock';
import { useGetLocksLive } from '@react-admin/ra-realtime';

const LockField = ({ locks }) => {
const record = useRecordContext();
if (!record) return null;
const lock = locks?.find(lock => lock.recordId === record?.id);
if (!lock) return <Box width={20} />;
return <LockIcon fontSize="small" color="disabled" />;
};

const PostList = () => {
const { data: locks } = useGetLocksLive();
return (
<List>
<Datagrid>
<TextField source="title" />
<LockField locks={locks} />
</Datagrid>
</List>
);
};
```
`useGetLocksLive` reads the current resource from the `ResourceContext`. You can provide it explicitly if you are not in such a context:
```jsx
const { data: locks } = useGetLocksLive('posts');
```
23 changes: 23 additions & 0 deletions docs/useGetOne.md
Original file line number Diff line number Diff line change
@@ -105,3 +105,26 @@ const UserProfile = () => {
return <div>User {data.username}</div>;
};
```

## Live Updates

If you want to subscribe to live updates on the record (topic: `resource/[resource]/[id]`), use [the `useGetOneLive` hook](./useGetOneLive.md) instead.

```diff
-import { useGetOne, useRecordContext } from 'react-admin';
+import { useRecordContext } from 'react-admin';
+import { useGetOneLive } from '@react-admin/ra-realtime';

const UserProfile = () => {
const record = useRecordContext();
- const { data, isLoading, error } = useGetOne('users', { id: record.userId });
+ const { data, isLoading, error } = useGetOneLive('users', { id: record.userId });
if (isLoading) {
return <Loading />;
}
if (error) {
return <p>ERROR</p>;
}
return <div>User {data.username}</div>;
};
```
31 changes: 31 additions & 0 deletions docs/useGetOneLive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
layout: default
title: "useGetOneLive"
---

# `useGetOneLive`

This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook, alternative to [`useGetOne`](./useGetOne.md), subscribes to live updates on the record.

## Usage

```jsx
import { useRecordContext } from 'react-admin';
import { useGetOneLive } from '@react-admin/ra-realtime';

const UserProfile = () => {
const record = useRecordContext();
const { data, isLoading, error } = useGetOneLive('users', { id: record.userId });
if (isLoading) {
return <Loading />;
}
if (error) {
return <p>ERROR</p>;
}
return <div>User {data.username}</div>;
};
```

The hook will subscribe to live updates on the record (topic: `resource/[resource]/[id]`) and will refetch the record when it is updated or deleted.

See [the `useGetOne` documentation](./useGetOne.md) for the full list of parameters and return type.
36 changes: 36 additions & 0 deletions docs/useLock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
layout: default
title: "useLock"
---

# `useLock`

`useLock` is a low-level [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook that returns a callback to call `dataProvider.lock()`, leveraging react-query's `useMutation`.

## Usage

```jsx
import { useLock } from '@react-admin/ra-realtime';

const [lock, { isLoading, error }] = useLock(
resource,
{ id, identity, meta },
options
);
```

## Parameters

The first parameter is a resource string (e.g. `'posts'`).

The second is a payload - an object with the following properties:

- `id`: the record id (e.g. `123`)
- `identity`: an identifier (string or number) corresponding to the identity of the locker (e.g. `'julien'`). This usually comes from `authProvider.getIdentity()`.
- `meta`: an object that will be forwarded to the dataProvider (optional)

The optional `options` argument is passed to react-query's `useMutation` hook.

## Utility Hooks

For most use cases, you won't need to call the `useLock` hook directly. Instead, you should use the [`useLockOnMount`](./useLockOnMound.md) or [`useLockOnCall`](./useLockOnCall.md) orchestration hooks, which are responsible for calling `useLock` and `useUnlock`.
77 changes: 77 additions & 0 deletions docs/useLockOnCall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
layout: default
title: "useLockOnCall"
---

# `useLockOnCall`

This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook gets a callback to lock a record and get a mutation state.

`useLockOnCall` calls `dataProvider.lock()` when the callback is called. It relies on `authProvider.getIdentity()` to get the identity of the current user. It guesses the current `resource` and `recordId` from the context (or the route) if not provided. It releases the lock when the component unmounts by calling `dataProvider.unlock()`.

![Locking a record](./img/useLockOnCall.gif)

## Usage

Use this hook in a toolbar, to let the user lock the record manually.

```jsx
import { Edit, SimpleForm, TextInput } from 'react-admin';
import { useLockOnMount } from '@react-admin/ra-realtime';
import { Alert, AlertTitle, Box, Button } from '@material-ui/core';

const PostAside = () => {
const [doLock, { data, error, isLoading }] = useLockOnCall();
return (
<Box width={200} ml={1}>
{isLoading ? (
<Alert severity="info">Locking post...</Alert>
) : error ? (
<Alert severity="warning">
<AlertTitle>Failed to lock</AlertTitle>Someone else is
probably already locking it.
</Alert>
) : data ? (
<Alert severity="success">
<AlertTitle>Post locked</AlertTitle> Only you can edit it.
</Alert>
) : (
<Button onClick={() => doLock()} fullWidth>
Lock post
</Button>
)}
</Box>
);
};
const PostEdit = () => (
<Edit aside={<PostAside />}>
<SimpleForm>
<TextInput source="title" fullWidth />
<TextInput source="headline" fullWidth multiline />
<TextInput source="author" fullWidth />
</SimpleForm>
</Edit>
);
```

## Parameters

`useLockOnCall` accepts a single options parameter, with the following properties (all optional):

- `identity`: An identifier (string or number) corresponding to the identity of the locker (e.g. `'julien'`). This could be an authentication token for instance. Falls back to the identifier of the identity returned by the `AuthProvider.getIdentity()` function.
- `resource`: The resource name (e.g. `'posts'`). The hook uses the `ResourceContext` if not provided.
- `id`: The record id (e.g. `123`). The hook uses the `RecordContext` if not provided.
- `meta`: An object that will be forwarded to the `dataProvider.lock()` call
- `lockMutationOptions`: `react-query` mutation options, used to customize the lock side-effects for instance
- `unlockMutationOptions`: `react-query` mutation options, used to customize the unlock side-effects for instance

```jsx
const LockButton = ({ resource, id, identity }) => {
const [doLock, lockMutation] = useLockOnCall({ resource, id, identity });
return (
<button onClick={() => doLock()} disabled={lockMutation.isLoading}>
Lock
</button>
);
};
```
91 changes: 91 additions & 0 deletions docs/useLockOnMount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
layout: default
title: "useLockOnMount"
---

# `useLockOnMount`

This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook locks the current record on mount.

`useLockOnMount` calls `dataProvider.lock()` on mount and `dataProvider.unlock()` on unmount to lock and unlock the record. It relies on `authProvider.getIdentity()` to get the identity of the current user. It guesses the current `resource` and `recordId` from the context (or the route) if not provided.

![Locking a record](./img/useLockOnMount.gif)

## Usage

Use this hook e.g. in an `<Edit>` component to lock the record so that it only accepts updates from the current user.

```jsx
import { Edit, SimpleForm, TextInput } from 'react-admin';
import { useLockOnMount } from '@react-admin/ra-realtime';
import { Alert, AlertTitle, Box } from '@material-ui/core';

const PostAside = () => {
const { isLocked, error, isLoading } = useLockOnMount();
return (
<Box width={200} ml={1}>
{isLoading && <Alert severity="info">Locking post...</Alert>}
{error && (
<Alert severity="warning">
<AlertTitle>Failed to lock</AlertTitle>Someone else is
probably already locking it.
</Alert>
)}
{isLocked && (
<Alert severity="success">
<AlertTitle>Post locked</AlertTitle> Only you can edit it.
</Alert>
)}
</Box>
);
};

const PostEdit = () => (
<Edit aside={<PostAside />}>
<SimpleForm>
<TextInput source="title" fullWidth />
<TextInput source="headline" fullWidth multiline />
<TextInput source="author" fullWidth />
</SimpleForm>
</Edit>
);
```

## Parameters

`useLockOnMount` accepts a single options parameter, with the following properties (all optional):

- `identity`: An identifier (string or number) corresponding to the identity of the locker (e.g. `'julien'`). This could be an authentication token for instance. Falls back to the identifier of the identity returned by the `AuthProvider.getIdentity()` function.
- `resource`: The resource name (e.g. `'posts'`). The hook uses the `ResourceContext` if not provided.
- `id`: The record id (e.g. `123`). The hook uses the `RecordContext` if not provided.
- `meta`: An object that will be forwarded to the `dataProvider.lock()` call
- `lockMutationOptions`: `react-query` mutation options, used to customize the lock side-effects for instance
- `unlockMutationOptions`: `react-query` mutation options, used to customize the unlock side-effects for instance

You can call `useLockOnMount` with no parameter, and it will guess the resource and record id from the context (or the route):

```jsx
const { isLocked, error, isLoading } = useLockOnMount();
```

Or you can provide them explicitly:

```jsx
const { isLocked, error, isLoading } = useLockOnMount({
resource: 'venues',
id: 123,
identity: 'John Doe',
});
```

**Tip**: If the record can't be locked because another user is already locking it, you can use [`react-query`'s retry feature](https://react-query-v3.tanstack.com/guides/mutations#retry) to try again later:

```jsx
const { isLocked, error, isLoading } = useLockOnMount({
lockMutationOptions: {
// retry every 5 seconds, until the lock is acquired
retry: true,
retryDelay: 5000,
},
});
```
105 changes: 105 additions & 0 deletions docs/usePublish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
layout: default
title: "usePublish"
---

# `usePublish`

This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook returns a callback to publish an event on a topic. The callback returns a promise that resolves when the event is published.

`usePublish` calls `dataProvider.publish()` to publish the event. It leverages react-query's `useMutation` hook to provide a callback.

**Note**: Events should generally be published by the server, in reaction to an action by an end user. They should seldom be published directly by the client. This hook is provided mostly for testing purposes, but you may use it in your own custom components if you know what you're doing.

## Usage

`usePublish` returns a callback with the following signature:

```jsx
const publish = usePublish();
publish(topic, event, options);
```

For instance, in a chat application, when a user is typing a message, the following component publishes a `typing` event to the `chat/[channel]` topic:

```jsx
import { useInput, useGetIdentity } from 'react-admin';
import { usePublish } from '@react-admin/ra-realtime';

const MessageInput = ({ channel }) => {
const [publish, { isLoading }] = usePublish();
const { id, field, fieldState } = useInput({ source: 'message' });
const { identity } = useGetIdentity();

const handleUserInput = event => {
publish(`chat/${channel}`, {
type: 'typing',
payload: { user: identity },
});
};

return (
<label htmlFor={id}>
Type your message
<input id={id} {...field} onInput={handleUserInput} />
</label>
);
};
```

The event format is up to you. It should at least contain a `type` property and may contain a `payload` property. The `payload` property can contain any data you want to send to the subscribers.

Some hooks and components in this package are specialized to handle "CRUD" events, which are events with a `type` property set to `created`, `updated` or `deleted`. For instance:

```js
{
topic: `resource/${resource}/id`,
event: {
type: 'deleted',
payload: { ids: [id]},
},
}
```

See the [CRUD events](./RealtimeDataProvider.md#crud-events) section for more details.

## Return Value

`usePublish` returns an array with the following values:

- `publish`: The callback to publish an event to a topic.
- `state`: The state of the mutation ([see react-query documentation](https://react-query-v3.tanstack.com/reference/useMutation)). Notable properties:
- `isLoading`: Whether the mutation is loading.
- `error`: The error if the mutation failed.
- `data`: The published event if the mutation succeeded.

```jsx
const [publish, { isLoading, error, data }] = usePublish();
```

## Callback Parameters

The `publish` callback accepts the following parameters:

- `topic`: The topic to publish the event on.
- `event`: The event to publish. It must contain a `type` property.
- `options`: `useMutation` options ([see react-query documentation](https://react-query-v3.tanstack.com/reference/useMutation)). Notable properties:
- `onSuccess`: A callback to call when the event is published. It receives the published event as its first argument.
- `onError`: A callback to call when the event could not be published. It receives the error as its first argument.
- `retry`: Whether to retry on failure. Defaults to `0`.

```jsx
const [publish] = usePublish();
publish(
'chat/general',
{
type: 'message',
payload: { user: 'John', message: 'Hello!' },
},
{
onSuccess: event => console.log('Event published', event),
onError: error => console.log('Could not publish event', error),
retry: 3,
}
);
```
153 changes: 153 additions & 0 deletions docs/useSubscribe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
---
layout: default
title: "useSubscribe"
---

# `useSubscribe`

This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook subscribes to the events from a topic on mount (and unsubscribe on unmount).

![useSubscribe](./img/useSubscribe.gif)

## Usage

The following component subscribes to the `messages/{channelName}` topic and displays a badge with the number of unread messages:

```jsx
import { useState, useCallback } from 'react';
import { Badge, Typography } from '@mui/material';
import { useSubscribe } from '@react-admin/ra-realtime';

const ChannelName = ({ name }) => {
const [nbMessages, setNbMessages] = useState(0);

const callback = useCallback(
event => {
if (event.type === 'created') {
setNbMessages(count => count + 1);
}
},
[setNbMessages]
);

useSubscribe(`messages/${name}`, callback);

return nbMessages > 0 ? (
<Badge badgeContent={nbMessages} color="primary">
<Typography># {name}</Typography>
</Badge>
) : (
<Typography># {name}</Typography>
);
};
```

## Parameters

| Prop | Required | Type | Default | Description |
| ---------- | -------- | ---------- | ------- | ------------------------------------------------------------------ |
| `topic` | Optional | `string` | - | The topic to subscribe to. When empty, no subscription is created. |
| `callback` | Optional | `function` | - | The callback to execute when an event is received. |
| `options` | Optional | `object` | - | Options to modify the subscription / unsubscription behavior. |

## `callback`

This function will be called with the event as its first argument, so you can use it to update the UI.

```jsx
useSubscribe(`messages/${name}`, event => {
if (event.type === 'created') {
setNbMessages(count => count + 1);
}
});
```

**Tip**: Memoize the callback using `useCallback` to avoid unnecessary subscriptions/unsubscriptions.

```jsx
const callback = useCallback(
event => {
if (event.type === 'created') {
setNbMessages(count => count + 1);
}
},
[setNbMessages]
);
useSubscribe(`messages/${name}`, callback);
```

The callback function receives an `unsubscribe` callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event.

```jsx
import { useState, useCallback } from 'react';
import { LinearProgress } from '@mui/material';
import { useSubscribe } from '@react-admin/ra-realtime';

const JobProgress = ({ jobId }) => {
const [progress, setProgress] = useState(0);
const [color, setColor] = useState('primary');
const callback = useCallback(
(event, unsubscribe) => {
if (event.type === 'progress') {
setProgress(event.payload.progress);
}
if (event.type === 'completed') {
setColor('success');
unsubscribe();
}
},
[setColor]
);
useSubscribe(`jobs/${jobId}`, callback);
return (
<LinearProgress variant="determinate" value={progress} color={color} />
);
};
```

![Using unsubscribe](./img/useSubscribeUnsubscribe.gif)

## `options`

The `options` object can contain the following properties:

- `enabled`: Whether to subscribe or not. Defaults to `true`
- `once`: Whether to unsubscribe after the first event. Defaults to `false`.
- `unsubscribeOnUnmount`: Whether to unsubscribe on unmount. Defaults to `true`.

You can use the `once` option to subscribe to a topic only once, and then unsubscribe.

For instance, the following component subscribes to the `office/restart` topic and changes the message when the office is open, then unsubscribes from the topic:

```jsx
import { useState } from 'react';
import { useSubscribe } from '@react-admin/ra-realtime';

const OfficeClosed = () => {
const [state, setState] = useState('closed');

useSubscribe('office/restart', () => setState('open'), { once: true });

return (
<div>
{state === 'closed'
? 'Sorry, the office is closed for maintenance.'
: 'Welcome! The office is open.'}
</div>
);
};
```

![useSubscribeOnce](./img/useSubscribeOnce.gif)

## `topic`

The first argument of `useSubscribe` is the topic to subscribe to. It can be an arbitrary string.

```jsx
useSubscribe('messages', event => {
// ...
});
```

If you want to subscribe to CRUD events, instead of writing the topic manually like `resource/[resource]`, you can use the `useSubscribeToRecord` or `useSubscribeToRecordList` hooks.
194 changes: 194 additions & 0 deletions docs/useSubscribeCallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
---
layout: default
title: "useSubscribeCallback"
---

# `useSubscribeCallback`

This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook gets a callback to subscribe to events on a topic and optionally unsubscribe on unmount.

This is useful to start a subscription from an event handler, like a button click.

![useSubscribeCallback](./img/useSubscribeCallback.gif)

## Usage

The following component subscribes to the `backgroundJobs/recompute` topic on click, and displays the progress of the background job:

{% raw %}
```jsx
import { useState, useCallback } from 'react';
import { useDataProvider } from 'react-admin';
import { Button, Card, Alert, AlertTitle, LinearProgress } from '@mui/material';
import { useSubscribeCallback } from '@react-admin/ra-realtime';

const LaunchBackgroundJob = () => {
const dataProvider = useDataProvider();
const [progress, setProgress] = useState(0);
const callback = useCallback(
(event, unsubscribe) => {
setProgress(event.payload?.progress || 0);
if (event.payload?.progress === 100) {
unsubscribe();
}
},
[setProgress]
);
const subscribe = useSubscribeCallback(
'backgroundJobs/recompute',
callback
);

return (
<div>
<Button
onClick={() => {
subscribe();
dataProvider.recompute();
}}
>
Launch recompute
</Button>
{progress > 0 && (
<Card sx={{ m: 2, maxWidth: 400 }}>
<Alert severity={progress === 100 ? 'success' : 'info'}>
<AlertTitle>
Recompute{' '}
{progress === 100 ? 'complete' : 'in progress'}
</AlertTitle>
<LinearProgressWithLabel value={progress} />
</Alert>
</Card>
)}
</div>
);
};
```
{% endraw %}
## Parameters
| Prop | Required | Type | Default | Description |
| ---------- | -------- | ---------- | ------- | ------------------------------------------------------------------ |
| `topic` | Optional | `string` | - | The topic to subscribe to. When empty, no subscription is created. |
| `callback` | Optional | `function` | - | The callback to execute when an event is received. |
| `options` | Optional | `object` | - | Options to modify the subscription / unsubscription behavior. |

## `callback`

Whenever an event is published on the `topic` passed as the first argument, the function passed as the second argument will be called with the event as a parameter.

```jsx
const subscribe = useSubscribeCallback('backgroundJobs/recompute', event => {
if (event.type === 'progress') {
setProgress(event.payload.progress);
}
});

// later
subscribe();
```

**Tip**: Memoize the callback using `useCallback` to avoid unnecessary subscriptions/unsubscriptions.

```jsx
const callback = useCallback(
event => {
if (event.type === 'progress') {
setProgress(event.payload.progress);
}
},
[setProgress]
);
```
The callback function receives an `unsubscribe` callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event.
```jsx
const subscribe = useSubscribeCallback(
'backgroundJobs/recompute',
(event, unsubscribe) => {
if (event.type === 'completed') {
setProgress(100);
unsubscribe();
}
}
);
```
## `options`
The `options` object can contain the following properties:
- `enabled`: Whether to subscribe or not. Defaults to `true`
- `once`: Whether to unsubscribe after the first event. Defaults to `false`.
- `unsubscribeOnUnmount`: Whether to unsubscribe on unmount. Defaults to `true`.
You can use the `once` option to subscribe to a topic only once, and then unsubscribe.
For instance, the following component subscribes to the `backgroundJobs/recompute` topic on click, displays a notification when the background job is complete, then unsubscribes:
```jsx
import { useDataProvider, useNotify } from 'react-admin';
import { useSubscribeCallback } from '@react-admin/ra-realtime';

const LaunchBackgroundJob = () => {
const dataProvider = useDataProvider();
const notify = useNotify();

const subscribe = useSubscribeCallback(
'backgroundJobs/recompute',
event =>
notify('Recompute complete: %{summary}', {
type: 'success',
messageArgs: {
summary: event.payload?.summary,
},
}),
{
unsubscribeOnUnmount: false, // show the notification even if the user navigates away
once: true, // unsubscribe after the first event
}
);

return (
<button
onClick={() => {
subscribe();
dataProvider.recompute();
}}
>
Launch background job
</button>
);
};
```
![useSubscribeOnceCallback](./img/useSubscribeOnceCallback.gif)
You can use the `unsubscribeOnUnmount` option to keep the subscription alive after the component unmounts.
This can be useful when you want the subscription to persist across multiple pages.
```jsx
const subscribe = useSubscribeCallback(
'backgroundJobs/recompute',
event => setProgress(event.payload?.progress || 0),
{
unsubscribeOnUnmount: false, // don't unsubscribe on unmount
}
);
```
## `topic`
The first argument of `useSubscribeCallback` is the topic to subscribe to. It can be an arbitrary string.
```jsx
const subscribe = useSubscribeCallback('backgroundJobs/recompute', event => {
// ...
});

// later
subscribe();
```
217 changes: 217 additions & 0 deletions docs/useSubscribeToRecord.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
---
layout: default
title: "useSubscribeToRecord"
---

# `useSubscribeToRecord`

This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook is a specialized version of [`useSubscribe`](./useSubscribe.md) that subscribes to events concerning a single record.

![useSubscribeToRecord](./img/useSubscribeToRecord.gif)

## Usage

The hook expects a callback function as its only argument, as it guesses the record and resource from the current context. The callback will be executed whenever an event is published on the `resource/[resource]/[recordId]` topic.

For instance, the following component displays a dialog when the record is updated by someone else:

```jsx
import { useState } from 'react';
import { useEditContext, useFormContext } from 'react-admin';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from '@mui/material';
import { useSubscribeToRecord } from '@react-admin/ra-realtime';

const WarnWhenUpdatedBySomeoneElse = () => {
const [open, setOpen] = useState(false);
const [author, setAuthor] = useState<string | null>(null);
const handleClose = () => {
setOpen(false);
};
const { refetch } = useEditContext();
const refresh = () => {
refetch();
handleClose();
};
const {
formState: { isDirty },
} = useFormContext();

useSubscribeToRecord((event: Event) => {
if (event.type === 'edited') {
if (isDirty) {
setOpen(true);
setAuthor(event.payload.user);
} else {
refetch();
}
}
});

return (
<Dialog
open={open}
onClose={handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
Post Updated by {author}
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
Your changes and their changes may conflict. What do you
want to do?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Keep my changes</Button>
<Button onClick={refresh}>
Get their changes (and lose mine)
</Button>
</DialogActions>
</Dialog>
);
};

const PostEdit = () => (
<Edit>
<SimpleForm>
<TextInput source="id" disabled />
<TextInput source="title" fullWidth />
<TextInput source="body" fullWidth multiline />
<WarnWhenUpdatedBySomeoneElse />
</SimpleForm>
</Edit>
);
```
`useSubscribeToRecord` reads the current resource and record from the `ResourceContext` and `RecordContext` respectively. In the example above, the notification is displayed when the app receives an event on the `resource/books/123` topic.
Just like `useSubscribe`, `useSubscribeToRecord` unsubscribes from the topic when the component unmounts.
**Tip**: In the example above, `<Show>` creates the `RecordContext`- that's why the `useSubscribeToRecord` hook is used in its child component instead of in the `<BookShow>` component.
You can provide the resource and record id explicitly if you are not in such contexts:
```jsx
useSubscribeToRecord(event => { /* ... */ }, 'posts', 123);
```
**Tip**: If your reason to subscribe to events on a record is to keep the record up to date, you should use [the `useGetOneLive` hook](#usegetonelive) instead.
## Parameters
| Prop | Required | Type | Default | Description |
| ---------- | -------- | ---------- | ------- | --------------------------------------------------------------------------------------- |
| `callback` | Required | `function` | - | The callback to execute when an event is received. |
| `resource` | Optional | `string` | - | The resource to subscribe to. Defaults to the resource in the `ResourceContext`. |
| `recordId` | Optional | `string` | - | The record id to subscribe to. Defaults to the id of the record in the `RecordContext`. |
| `options` | Optional | `object` | - | The subscription options. |

## `callback`

Whenever an event is published on the `resource/[resource]/[recordId]` topic, the function passed as the first argument will be called with the event as a parameter.

```jsx
const [open, setOpen] = useState(false);
const [author, setAuthor] = useState<string | null>(null);
const { refetch } = useEditContext();
const {
formState: { isDirty },
} = useFormContext();
useSubscribeToRecord((event: Event) => {
if (event.type === 'edited') {
if (isDirty) {
setOpen(true);
setAuthor(event.payload.user);
} else {
refetch();
}
}
});
```
**Tip**: Memoize the callback using `useCallback` to avoid unnecessary subscriptions/unsubscriptions.
```jsx
const [open, setOpen] = useState(false);
const [author, setAuthor] = useState<string | null>(null);
const { refetch } = useEditContext();
const {
formState: { isDirty },
} = useFormContext();

const handleEvent = useCallback(
(event: Event) => {
if (event.type === 'edited') {
if (isDirty) {
setOpen(true);
setAuthor(event.payload.user);
} else {
refetch();
}
}
},
[isDirty, refetch, setOpen, setAuthor]
);

useSubscribeToRecord(handleEvent);
```
Just like for `useSubscribe`, the callback function receives an `unsubscribe` callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event.
```jsx
useSubscribeToRecord((event: Event, unsubscribe) => {
if (event.type === 'deleted') {
// do something
unsubscribe();
}
if (event.type === 'edited') {
if (isDirty) {
setOpen(true);
setAuthor(event.payload.user);
} else {
refetch();
}
}
});
```
## `options`
The `options` object can contain the following properties:
- `enabled`: Whether to subscribe or not. Defaults to `true`
- `once`: Whether to unsubscribe after the first event. Defaults to `false`.
- `unsubscribeOnUnmount`: Whether to unsubscribe on unmount. Defaults to `true`.
See [`useSubscribe`](./useSubscribe.md) for more details.
## `recordId`
The record id to subscribe to. By default, `useSubscribeToRecord` builds the topic it subscribes to using the id of the record in the `RecordContext`. But you can override this behavior by passing a record id as the third argument.
```jsx
// will subscribe to the 'resource/posts/123' topic
useSubscribeToRecord(event => { /* ... */ }, 'posts', 123);
```
Note that if you pass a null record id, the hook will not subscribe to any topic.
## `resource`
The resource to subscribe to. By default, `useSubscribeToRecord` builds the topic it subscribes to using the resource in the `ResourceContext`. But you can override this behavior by passing a resource name as the second argument.
```jsx
// will subscribe to the 'resource/posts/123' topic
useSubscribeToRecord(event => { /* ... */ }, 'posts', 123);
```
Note that if you pass an empty string as the resource name, the hook will not subscribe to any topic.
162 changes: 162 additions & 0 deletions docs/useSubscribeToRecordList.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
---
layout: default
title: "useSubscribeToRecordList"
---

# `useSubscribeToRecordList`

This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook is a specialized version of [`useSubscribe`](./useSubscribe.md) that subscribes to events concerning a list of records.

![useSubscribeToRecordList](./img/useSubscribeToRecordList.gif)

## Usage

`useSubscribeToRecordList` expects a callback function as its first argument. It will be executed whenever an event is published on the `resource/[resource]` topic.

For instance, the following component displays notifications when a record is created, updated, or deleted by someone else:

```jsx
import React from 'react';
import { useNotify, useListContext } from 'react-admin';
import { useSubscribeToRecordList } from '@react-admin/ra-realtime';

const ListWatcher = () => {
const notity = useNotify();
const { refetch, data } = useListContext();
useSubscribeToRecordList(event => {
switch (event.type) {
case 'created': {
notity('New movie created');
refetch();
break;
}
case 'updated': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} updated`);
refetch();
}
break;
}
case 'deleted': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} deleted`);
refetch();
}
break;
}
}
});
return null;
};

const MovieList = () => (
<List>
<Datagrid>
<TextField source="id" />
<TextField source="title" />
<TextField source="director" />
<TextField source="year" />
</Datagrid>
<ListWatcher />
</List>
);
```

## Parameters

| Prop | Required | Type | Default | Description |
| ---------- | -------- | ---------- | ------- | -------------------------------------------------------------------------------- |
| `callback` | Required | `function` | - | The callback function to execute when an event is published on the topic. |
| `resource` | Optional | `string` | - | The resource to subscribe to. Defaults to the resource in the `ResourceContext`. |
| `options` | Optional | `object` | - | The subscription options. |

## `callback`

Whenever an event is published on the `resource/[resource]` topic, the function passed as the first argument will be called with the event as a parameter.

```jsx
const notity = useNotify();
const { refetch, data } = useListContext();
useSubscribeToRecordList(event => {
switch (event.type) {
case 'created': {
notity('New movie created');
refetch();
break;
}
case 'updated': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} updated`);
refetch();
}
break;
}
case 'deleted': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} deleted`);
refetch();
}
break;
}
}
});
```

**Tip**: Memoize the callback using `useCallback` to avoid unnecessary subscriptions/unsubscriptions.

```jsx
const notity = useNotify();
const { refetch, data } = useListContext();
const callback = useCallback(
event => {
switch (event.type) {
case 'created': {
notity('New movie created');
refetch();
break;
}
case 'updated': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} updated`);
refetch();
}
break;
}
case 'deleted': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} deleted`);
refetch();
}
break;
}
}
},
[data, refetch, notity]
);
useSubscribeToRecordList(callback);
```

Just like for `useSubscribe`, the callback function receives an `unsubscribe` callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event.

## `options`

The `options` object can contain the following properties:

- `enabled`: Whether to subscribe or not. Defaults to `true`
- `once`: Whether to unsubscribe after the first event. Defaults to `false`.
- `unsubscribeOnUnmount`: Whether to unsubscribe on unmount. Defaults to `true`.

See [`useSubscribe`](#usesubscribe) for more details.

## `resource`

`useSubscribeToRecordList` reads the current resource from the `ResourceContext`. You can provide the resource explicitly if you are not in such a context:

```jsx
useSubscribeToRecordList(event => {
if (event.type === 'updated') {
notify('Post updated');
refresh();
}
}, 'posts');
```
32 changes: 32 additions & 0 deletions docs/useUnlock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
layout: default
title: "useUnlock"
---

# `useUnlock`

`useUnlock` is a low-level [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> hook that returns a callback to call `dataProvider.unlock()`, leveraging react-query's `useMutation`.

## Usage

```jsx
import { useUnlock } from '@react-admin/ra-realtime';

const [unlock, { isLoading, error }] = useUnlock(
resource,
{ id, identity, meta },
options
);
```

## Parameters

The first parameter is a resource string (e.g. `'posts'`).

The second is a payload - an object with the following properties:

- `id`: the record id (e.g. `123`)
- `identity`: an identifier (string or number) corresponding to the identity of the locker (e.g. `'julien'`). This usually comes from `authProvider.getIdentity()`
- `meta`: an object that will be forwarded to the dataProvider (optional)

The optional `options` argument is passed to react-query's `useMutation` hook.

0 comments on commit 5f2001a

Please sign in to comment.