ra-realtime
Teams where several people work in parallel on a common task need to allow real-time notifications, and prevent data loss when two editors work on the same resource concurrently. ra-realtime
provides hooks and UI components to lock records, live update views when a change occurs in the background, and notify the user of these events.
Overview
Ra-realtime provides live updates via alternative components to <List>
, <Edit>
, and <Show>
. Just use the components from '@react-admin/ra-realtime' instead of their 'react-admin' counterpart. For instance, replace <List>
by <RealTimeList>
to have a list refreshing automatically when an element is added, updated, or deleted:
import {
- List,
Datagrid,
TextField,
NumberField,
Datefield,
} from 'react-admin';
+import { RealTimeList } from '@react-admin/ra-realtime';
const PostList = props => (
- <List {...props}>
+ <RealTimeList {...props}>
<Datagrid>
<TextField source="title" />
<NumberField source="views" />
<DateField source="published_at" />
</Datagrid>
- </List>
+ </RealTimeList>
);
Ra-realtime also provides badge notifications in the Menu, so that users can see something new happened to a resource list while working on another one.
And last but not least, ra-realtime provides a lock mechanism to prevent editing or deleting the same record as another user.
import { useLock, useHasLock } from '@react-admin/ra-realtime';
const MyLockedEditView = props => {
const { resource, id } = props;
const notify = useNotify();
const { loading } = useLock(resource, id, 'mario');
if (loading) {
return <CircularProgress />;
}
return (
<Edit {...props}>
<SimpleForm toolbar={<CustomToolbar />}>
<TextInput source="title" />
</SimpleForm>
</Edit>
);
};
function CustomToolbar(props): FC<Props> {
const { resource, record } = props;
const { data: lock } = useHasLock(resource, record.id, 'mario');
const isMarioLocker = lock?.identity === 'mario';
// Prevent clicking on the `<SaveButton>` if someone else is locking this record
return (
<Toolbar {...props}>
<SaveButton disabled={!isMarioLocker} />
</Toolbar>
);
}
Installation
npm install --save @react-admin/ra-realtime @material-ui/lab@4.0.0-alpha.56
# or
yarn add @react-admin/ra-realtime @material-ui/lab@4.0.0-alpha.56
The package contains new translation messages (in English and French). You should add them to your i18nProvider
. For instance, to add English messages:
import { Admin } from 'react-admin';
import polyglotI18nProvider from 'ra-i18n-polyglot';
import englishMessages from 'ra-language-english';
import frenchMessages from 'ra-language-french';
import { raRealTimeLanguageEnglish, raRealTimeLanguageFrench } from '@react-admin/ra-realtime';
const messages = {
en: { ...englishMessages, ...raRealTimeLanguageEnglish },
fr: { ...frenchMessages, ...raRealTimeLanguageFernch }
}
const i18nProvider = polyglotI18nProvider(locale => messages[locale], 'en');
const App = () => (
<Admin i18nProvider={is18nProvider}>
{ /* ... */}
</Admin>
)
The package contains custom Redux actions for locking records and prevent the other users to edit them. If you don't use this feature, you can skip this part. Otherwise, you should add the custom reducers provided by the package to the <Admin>
.
import { Admin } from 'react-admin';
import { reducer as locks } from '@react-admin/ra-realtime';
const App = () => (
<Admin customReducers={{ locks }}>
{ /* ... */}
</Admin>
)
dataProvider
The dataProvider
used by the <Admin>
must support specific real-time methods:
subscribe(topic, callback)
unsubscribe(topic, callback)
publish(topic, event)
These methods should return a Promise resolved when the action was acknowledged by the real-time bus.
API-Platform Adapter
The ra-realtime package contains a function augmenting a regular (API-based) dataProvider with real-time methods based on the Mercure hub of API-Platform. It could be used in the admin generated by API-Platform.
Use it as follows:
import { Datagrid, EditButton } from 'react-admin';
import {
HydraAdmin,
ResourceGuesser,
FieldGuesser,
hydraDataProvider,
} from "@api-platform/admin";
import { RealTimeList, addRealTimeMethodsBasedOnApiPlatform } from '@react-admin/ra-realtime';
const dataProvider = hydraDataProvider('https://localhost:8443');
const realTimeDataProvider = addRealTimeMethodsBasedOnApiPlatform(
// The original dataProvider (should be an 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 slash at the end)
'https://localhost:8443'
);
function App(){
return (
<HydraAdmin
entrypoint="https://localhost:8443"
dataProvider={realTimeDataProvider}
>
<ResourceGuesser
name="greetings"
list={GreetingsList}
/>
</HydraAdmin>
);
};
// Example for connecting a list of greetings
function GreetingsList(props) {
return (
<RealTimeList {...props}>
<Datagrid>
<FieldGuesser source="name" />
<EditButton />
</Datagrid>
</RealTimeList>
);
}
Mercure Adapter
The ra-realtime package contains a function augmenting a regular (API-based) dataProvider with real-time methods based on a Mercure hub. Use it as follows:
import { addRealTimeMethodsBasedOnMercure } from '@react-admin/ra-realtime';
const realTimeDataProvider = 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={realTimeDataProvider}>
{ /* ... */}
</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 a an implementation using a local variable, that ra-realtime uses in tests:
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('ra-realtime.error.topic'));
}
if (!event.type) {
return Promise.reject(new Error('ra-realtime.error.type'));
}
subscriptions.map(
(subscription) =>
topic === subscription.topic &&
subscription.subscriptionCallback(event)
););
return Promise.resolve({ data: null });
},
};
You can check the behaviour 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 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.
Publishers send an event
to all the subscribers. An event
should be a JavaScript object with a type
and a payload
field. In addition, ra-realtime requires that every event
contains a topic
- the name of the topic
it's published to.
Here is an example event:
{
topic: 'messages',
type: 'created',
payload: 'New message',
date: new Date(),
}
For CRUD operations, ra-realtime expects events to use the types 'created', 'updated', and 'deleted'.
dataProvider
Methods Directly
Calling the 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:
import React, { useState } from 'react';
import { useDataProvider, useNotify } from 'react-admin';
const MessageList = (props) => {
const notify = useNotify();
const [messages, setMessages] = useState([]);
const dataProvider = useDataProvider();
// subscribe to the 'messages' topic on mount
useEffect(() => {
const callback = (event) => {
setMessages(messages => ([...messages, event.payload]));
notify('New message');
}
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 publishing an event to the messages
topic. All the subscribers to this topic will execute their callback:
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',
topic: 'messages',
payload: 'New message',
date: new Date(),
}).then(() => {
notify('Message sent');
});
}
return <Button onClick={handleClick}>Send new message</Button>;
}
Tip: You should not need to call dataProvider.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')
, the API would create the new message AND publish the 'created' event to the real-time bus.
Real Time Hooks
In practice, every component that should react to an event needs a useEffect
calling dataProvider.subscribe()
, and returning a callback calling dataProvider.unsubscribe()
. That's why ra-realtime exposes a useSubscribe
hook, which simplifies that logic a great deal. Here is the same <MessageList>
as above, but using useSubscribe
:
import React, { useState } from 'react';
import { Layout, useNotify } from 'react-admin';
import { useSubscribe } from '@react-admin/ra-realtime';
const MessageList = (props) => {
const notify = useNotify();
const [messages, setMessages] = useState([]);
useSubscribe('messages', (event) => {
setMessages([...messages, event.payload]);
notify('New message');
});
return (
<ul>
{messages.map((message, index) => (
<li key={index}>{message}</li>
))}
</ul>
):
};
CRUD Events
Ra-realtime has deep integration with react-admin, where most of the logic concerns resources and records. To enable this integration, your real-time backend should publish the following events:
- when a new record is created:
{
topic: `resource/${resource}`,
type: 'created',
payload: { ids: [id]},
date: new Date(),
}
- when a record is modified:
{
topic: `resource/${resource}/id`,
type: 'modified',
payload: { ids: [id]},
date: new Date(),
}
{
topic: `resource/${resource}`,
type: 'modified',
payload: { ids: [id]},
date: new Date(),
}
- when a record is deleted:
{
topic: `resource/${resource}/id`,
type: 'deleted',
payload: { ids: [id]},
date: new Date(),
}
{
topic: `resource/${resource}`,
type: 'deleted',
payload: { ids: [id]},
date: new Date(),
}
Special CRUD Hooks
Ra-realtime provides specialized versions of useSubscribe
, to subscribe to events concerning:
- a single record:
useSubscribeToRecord(resource, id, callback)
- a list of records:
useSubscribeToRecordList(resource, callback)
Using these hooks, you can add real-time capabilities to a <Show>
view for instance:
import { Show, useNotify, useRefresh } from 'react-admin';
import { useSubscribeToRecord } from '@react-admin/ra-realtime';
const PostShow: FC<ShowProps> = (props) => {
const notify = useNotify();
const refresh = useRefresh();
useSubscribeToRecord('posts', props.id, (event) => {
switch (event.type) {
case 'modified': {
refresh();
notify('Record updated server-side');
break;
}
case 'deleted': {
notify('Record deleted server-side', 'warning');
break;
}
default: {
console.log('Unsupported event type', event);
}
}
});
return <Show {...props}/>;
};
Real Time Views (List, Edit, Show)
Ra-realtime offers alternative view components for <List>
, <Edit>
, <Show>
, with real-time capabilities:
<RealTimeList>
shows a notification and refreshes the page when a record is created, updated, or deleted.<RealTimeEdit>
displays a warning when the record is modified by another user, and offers to refresh the page. Also, it displays a warning when the record is deleted by another user.<RealTimeShow>
shows a notification and refreshes the page when the record is modified by another user. Also, it displays a warning when the record is deleted by another user.
<RealTimeList>
import React, { FC } from 'react';
import { Datagrid, TextField } from 'react-admin';
import { RealTimeList } from '@react-admin/ra-realtime'
const PostList: FC = props => (
<RealTimeList {...props}>
<Datagrid>
<TextField source="title" />
</Datagrid>
</RealTimeList>
);
To trigger <RealTimeList>
behaviour, the API has to publish events containing at least the followings:
-
topic : '/resource/{resource}'
-
data : { topic : '/resource/{resource}', type: '{deleted || created || updated}', payload: { ids: [{listOfRecordIdentifiers}]}, }
<RealTimeEdit>
import React, { FC } from 'react';
import { SimpleForm, TextInput } from 'react-admin';
import { RealTimeEdit } from '@react-admin/ra-realtime'
const PostEdit: FC = props => (
<RealTimeEdit {...props}>
<SimpleForm>
<TextInput source="title" />
</SimpleForm>
</RealTimeEdit>
);
To trigger RealTimeEdit behaviour, the API has to publish events containing at least the followings
-
topic : '/resource/{resource}/{recordIdentifier}'
-
data : { topic : '/resource/{resource}/{recordIdentifier}', type: '{deleted || updated}', payload: { id: [{recordIdentifier}]}, }
<RealTimeShow>
import React, { FC } from 'react';
import { SimpleShowLayout, TextField } from 'react-admin';
import { RealTimeShow } from '@react-admin/ra-realtime'
const PostShow: FC = props => (
<RealTimeShow {...props}>
<SimpleShowLayout>
<TextField source="title" />
</SimpleShowLayout>
</RealTimeShow>
);
To trigger RealTimeShow behaviour, the API has to publish events containing at least the followings
-
topic : '/resource/{resource}/{recordIdentifier}'
-
data : { topic : '/resource/{resource}/{recordIdentifier}', type: '{deleted || updated}', payload: { id: [{recordIdentifier}]}, }
<RealTimeMenu>
The <RealTimeMenu>
component displays a badge with the number of modified records on each unactive Menu item.
Basic Usage
import React, { FC } from 'react';
import { Admin, Layout, Resource } from 'react-admin';
import { RealTimeMenu } from '@react-admin/ra-realtime'
import { PostList, PostShow, PostEdit, realTimeDataProvider } from '.'
const CustomLayout: FC = props => <Layout {...props} menu={RealTimeMenu} />;
const MyReactAdmin: FC = () => (
<Admin
dataProvider={realTimeDataProvider}
layout={CustomLayout}
>
<Resource
name="posts"
list={PostList}
show={PostShow}
edit={PostEdit}
/>
</Admin>
);
To trigger RealTimeMenu behaviour, the API has to publish events containing at least the followings
-
topic : '/resource/{resource}'
-
data : { topic : '/resource/{resource}', type: '{deleted || created || updated}', payload: { ids: [{listOfRecordIdentifiers}]}, }
<RealTimeMenuItemLink>
displays a badge with the number of modified records if the current menu item is not active (Used to build <RealTimeMenu>
and your custom <MyRealTimeMenu>
).
import React, { FC } from 'react';
import { RealTimeMenuItemLink } from '@react-admin/ra-realtime'
const CustomRealTimeMenu: FC<any> = ({ onMenuClick }) => {{
const open = useSelector(state => state.admin.ui.sidebarOpen);
return (
<div>
<RealTimeMenuItemLink
to="/posts"
primaryText="The Posts"
resource="posts"
badgeColor="primary"
onClick={onMenuClick}
sidebarIsOpen={open}
/>
<RealTimeMenuItemLink
to="/comments"
primaryText="The Comments"
resource="comments"
onClick={onMenuClick}
sidebarIsOpen={open}
/>
</div>
);
<RealTimeMenuItemLink>
has two additional props compared to <MenuItemLink>
:
resource
: Needed, The name of the concerned resource (can be different than the path in theto
prop)badgeColor
: Optional, It's the MUI color used to display the color of the badge. Default isalert
(not far from the red). It can also beprimary
,secondary
or any of the MUI color available in the MUI palette.
The badge displays the total number 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 <RealTimeMenuItemLink>
behaviour, the API have to publish events containing at least the followings
-
topic : '/resource/{resource}'
-
data : { topic : '/resource/{resource}', type: '{deleted || created || updated}', payload: { ids: [{listOfRecordIdentifiers}]}, }
Customizing Translation Messages
This module uses specific translations for displaying buttons and other texts. 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:
import polyglotI18nProvider from 'ra-i18n-polyglot';
import englishMessages from 'ra-language-english';
import frenchMessages from 'ra-language-french';
import { TranslationMessages as BaseTranslationMessages } from 'ra-core';
import {
raRealTimeEnglishMessages,
raRealTimeFrenchMessages,
RaRealTimeTranslationMessages
} from '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',
},
list: {
refreshed:
'Be carefull, this list has been refreshed with %{smart_count} %{name} %{type} by some ghosts',
},
},
},
}
);
const i18nCustomProvider = polyglotI18nProvider(locale => {
if (locale === 'fr') {
return mergeTranslations(frenchMessages, raRealTimeFrenchMessages);
}
return customEnglishMessages;
}, 'en');
export const MyApp: FC = () => (
<Admin
i18nProvider={myDataprovider}
i18nProvider={i18nCustomProvider}
>
...
</Admin>
);
Locks On Content
dataProvider
The dataProvider
used by the <Admin>
must support locks specific methods:
lock(resource, data)
unlock(resource, data)
getLock(resource, data)
getLocks(resource)
These methods should return a Promise.
In the lock
, unlock
and getLock
methods, the param data
should contain a recordId
and a identity
.
The ra-realtime package contains a function augmenting a regular (API-based) dataProvider with locks methods based on a lock
resource.
GET /locks?sort=["id","ASC"]&range=[0, 1]&filter={"resource":"people","recordId":"18"}
POST /locks
The POST
query should contain the following body:
{
identity: "Toad",
resource: "people",
recordId: 18,
createdAt: "2020-09-29 10:20",
}
Please note that the identity
and the createdAt
formats depends on your API.
Here is how to use it in your react-admin application:
import { addLocksMethodsBasedOnALockResource } from '@react-admin/ra-realtime';
const dataProviderWithLocks = addLocksMethodsBasedOnALockResource(
dataProvider, // original dataProvider
);
const App = () => (
<Admin dataProvider={dataProviderWithLocks}>
{ /* ... */}
</Admin>
);
Hooks for Locking a Record
Ra-realtime provides specialized hooks to:
- Lock a single record on mount and unlock it on unmount:
useLock(resource, recordId, identity, options)
- Get the lock status for a record:
useHasLock(resource, recordId)
- Get lock statuses for a resource:
useHasLocks(resource)
Here is a full example of how to use these hooks:
import React, { useState } from 'react';
import { Layout, useNotify } from 'react-admin';
import { useLock, useHasLock, useHasLocks } from '@react-admin/ra-realtime';
const CustomGridRow = ({ locks, ...props }) => {
const recordId = props.record.id;
const lock = locks.find(l => l.recordId === recordId);
return (
<TableRow id={recordId}>
<TableCell>
<TextField source="title" {...props} />
{lock && (
<span style={{ color: 'red' }}>
{` (Locked by ${lock.identity})`}
</span>
)}
</TableCell>
<TableCell align="right">
<EditButton {...props} />
</TableCell>
</TableRow>
);
};
const CustomGrid = props => {
const { data: locks } = useHasLocks(props.resource);
const GridBody = <DatagridBody row={<CustomGridRow locks={locks} />;
return <Datagrid {...props} body={GridBody} />} />;
};
const MyListView = props => (
<List {...props}>
<CustomGrid />
</List>
);
const LockedEdit = (props) => {
const notify = useNotify();
const [messages, setMessages] = useState([]);
// Supposing I'm Mario. But use your own identity for example an auth token.
const { loading } = useLock(resource, id, 'mario', {
onSuccess: () => {
notify('ra-realtime.notification.lock.lockedByMe');
},
onFailure: () => {
notify('ra-realtime.notification.lock.lockedBySomeoneElse');
},
onUnlockSuccess: () => {
notify('ra-realtime.notification.lock.unlocked');
},
});
if (loading) {
return <div>Loading...</div>;
}
return (
<Edit {...props}>
<SimpleForm>
<TextInput source="title" />
</SimpleForm>
</Edit>
);
};
function CustomToolbar(props): FC<Props> {
const { resource, record } = props;
const { data: lock } = useHasLock(resource, record.id);
const amILocker = lock?.identity === 'mario'; // I'm Mario
return (
<Toolbar {...props}>
<SaveButton disabled={!isMarioLocker} />
{!amILocker && <LockMessage identity={lock?.identity} />}
</Toolbar>
);
}
function LockMessage(props): FC<Props> {
const { identity, variant = 'body1' } = props;
const classes = useLockMessageStyles(props);
const message = `This record is locked by ${identity}.`;
return (
<Typography className={classes.root} variant={variant}>
{message}
</Typography>
);
}
v1.0.5
2020-09-18
- (fix) Fix non-working Mercure storybook examples
v1.0.4
2020-09-15
- (fix) Fix missing export
- (deps) Upgrade dependencies
v1.0.3
2020-09-14
- (feat) Add a
useHasLocks
hook to select all locks for a resource
v1.0.2
2020-09-10
- (fix) Add missing resource security check on the
useHasLock
selector
v1.0.0
2020/08/04
- First release