ra-realtime: Get notified of actions in real-time in React-Admin

In some teams, several people work in parallel on a common task. Admins for these tasks need to allow real-time notifications, and prevent data loss when two editors work on the same resource concurrently. ra-realtime provides solutions for these problems.

Overview

ra-realtime provides alternative components to <List>, <Edit> and <Show>, which display real-time notifications when the underlying data changes. Just use these components in place of their 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 UI components for the Menu, so that users can see something new happened to a resource list while they were working on another one.

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

Usage

dataProvider

The dataProvider used by the <Admin> must support real-time specific 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.

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

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

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:

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-raltime 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

Ra-realtime offers alternative view components for <List>, <Edit>, <Show>, and <Menu>, 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.
  • <RealTimeMenu> displays a badge with the number of modified records on each unactive Menu item.
  • <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 { Datagrid, TextField } from 'react-admin';
import { RealTimeList } from '@react-admin/ra-realtime'

const PostList: FC = props => (
    <RealTimeList {...props}>
        <Datagrid>
            <TextField source="title" />
        </Datagrid>
    </RealTimeList>
);

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}]},
     }
    

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

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}]},
     }
    

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

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}]},
     }
    

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

RealTimeMenu

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}]},
     }
    

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 the to prop)
  • badgeColor: Optional, It's the MUI color used to display the color of the badge. Default is alert (not far from the red). It can also be primary, 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. Its value is resetted at each click on the MenuItem (when the user open this resource default page, and the MenuItem gets 'active' status)

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}]},
     }
    

Customising 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>
);