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.

You can test this module in the ra-realtime live demo.

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.

Edit With Locks

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, ...raRealTimeLanguageFrench }
}

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

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

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

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

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

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

<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 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. 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,
    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:

Edit With Locks

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

CHANGELOG

v1.2.0

2020-10-05

  • Upgrade to react-admin 3.9

v1.1.3

2020-09-30

  • Update Readme

v1.1.2

2020-09-30

  • (fix) Fix bad export in realtime Edit Storybook Action

v1.1.1

2020-09-28

  • (fix) Fix extra spacing in storybook edit examples

v1.1.0

2020-09-21

  • (feat) Add the autoclaim capability (autolock when unlocked) to the useLock hook
  • (fix) Fix missing storybook examples for realtime locking

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