ra-tour: Tour for React-admin

This module provides a way to guide users through tutorials to showcase and explain important features of your interfaces.

ra-tour lets you implement a tour quickly, and to plug in your own code easily for custom use cases.

Test it live in the Enterprise Edition Storybook and in the e-commerce demo.

Installation

npm install --save @react-admin/ra-tour
# or
yarn add @react-admin/ra-tour

Tip: ra-tour is part of the React-Admin Enterprise Edition, and hosted in a private npm registry. You need to subscribe to one of the Enterprise Edition plans to access this package.

The package contains new translation messages (in English and French). You should add them to your i18nProvider:

import { Admin } from "react-admin";
import polyglotI18nProvider from "ra-i18n-polyglot";
import englishMessages from "ra-language-english";
import frenchMessages from "ra-language-french";

import {
  raTreeLanguageEnglish,
  raTreeLanguageFrench,
} from "@react-admin/ra-tree";

const messages = {
  en: { ...englishMessages, ...raTreeLanguageEnglish },
  fr: { ...frenchMessages, ...raTreeLanguageFrench },
};

const i18nProvider = polyglotI18nProvider((locale) => messages[locale], "en");

const App = () => <Admin i18nProvider={is18nProvider}>{/* ... */}</Admin>;
import { Admin } from "react-admin";
import polyglotI18nProvider from "ra-i18n-polyglot";
import englishMessages from "ra-language-english";
import frenchMessages from "ra-language-french";

import {
  raTreeLanguageEnglish,
  raTreeLanguageFrench,
} from "@react-admin/ra-tree";

const messages = {
  en: { ...englishMessages, ...raTreeLanguageEnglish },
  fr: { ...frenchMessages, ...raTreeLanguageFrench },
};

const i18nProvider = polyglotI18nProvider((locale) => messages[locale], "en");

const App = () => <Admin i18nProvider={is18nProvider}>{/* ... */}</Admin>;

Usage

  1. Add TourProvider to your customized layout.
// index.tsx
import { Admin, Layout, LayoutProps, Resource } from "react-admin";
import { TourProvider } from "@react-admin/ra-tour";

import SongList from "./SongList";

const MyLayout = (props: LayoutProps) => (
  <TourProvider>
    <Layout {...props} />
  </TourProvider>
);

export const MyAdmin = () => (
  <Admin dataProvider={dataProvider} layout={MyLayout}>
    <Resource name="songs" list={SongList} />
  </Admin>
);
// index.tsx
import { Admin, Layout, Resource } from "react-admin";
import { TourProvider } from "@react-admin/ra-tour";

import SongList from "./SongList";

const MyLayout = (props) => (
  <TourProvider>
    <Layout {...props} />
  </TourProvider>
);

export const MyAdmin = () => (
  <Admin dataProvider={dataProvider} layout={MyLayout}>
    <Resource name="songs" list={SongList} />
  </Admin>
);
  1. Create a new tour.
// tours/songsList.ts
import { TourType } from "@react-admin/ra-tour";

const songsListTour: TourType = {
  steps: [
    // first step selects the first line of the songs list
    {
      // which element does the step popup point at?
      target: `[data-tour-id='song-list-line'] a:nth-child(1)`,
      // content of the step popup
      content: "This is a song",
    },
    // then the 7th line
    {
      target: `[data-tour-id='song-list-line'] a:nth-child(7)`,
      content: "This is another song, it should  be lower on the page",
    },
    // content also accepts translation keys
    {
      target: `[data-tour-id='song-list-line'] a:nth-child(7)`,
      content: "myapp.tours.songs.mystep",
    },
  ],
};

export default songsListTour;
const songsListTour = {
  steps: [
    // first step selects the first line of the songs list
    {
      // which element does the step popup point at?
      target: `[data-tour-id='song-list-line'] a:nth-child(1)`,
      // content of the step popup
      content: "This is a song",
    },
    // then the 7th line
    {
      target: `[data-tour-id='song-list-line'] a:nth-child(7)`,
      content: "This is another song, it should  be lower on the page",
    },
    // content also accepts translation keys
    {
      target: `[data-tour-id='song-list-line'] a:nth-child(7)`,
      content: "myapp.tours.songs.mystep",
    },
  ],
};

export default songsListTour;

see TourType for full reference

  1. Add the tour
// index.tsx
import { Admin, Layout, LayoutProps, Resource } from "react-admin";
import { TourProvider } from "@react-admin/ra-tour";

import SongList from "./SongList";

import songsListTour from "./tours/songsList";

const MyLayout = (props: LayoutProps) => (
  <TourProvider
    tours={{
      "songs-list": songsListTour,
    }}
  >
    <Layout {...props} />
  </TourProvider>
);

export const MyAdmin = () => (
  <Admin dataProvider={dataProvider} layout={MyLayout}>
    <Resource name="songs" list={SongList} />
  </Admin>
);
// index.tsx
import { Admin, Layout, Resource } from "react-admin";
import { TourProvider } from "@react-admin/ra-tour";

import SongList from "./SongList";

import songsListTour from "./tours/songsList";

const MyLayout = (props) => (
  <TourProvider
    tours={{
      "songs-list": songsListTour,
    }}
  >
    <Layout {...props} />
  </TourProvider>
);

export const MyAdmin = () => (
  <Admin dataProvider={dataProvider} layout={MyLayout}>
    <Resource name="songs" list={SongList} />
  </Admin>
);
  1. Add a button to start the tour, for instance on list actions toolbar
// SongList.tsx
import { ListProps, ListActionsProps } from "react-admin";
import { useTour } from "@react-admin/ra-tour";

const ListActions = (props: ListActionsProps) => {
  const [{ running }, { start }] = useTour();
  return (
    <TopToolbar {...sanitizeListRestProps(props)}>
      <Button
        onClick={(): void => start("songs-list")}
        disabled={running} // can't click on the button when tour is running
      >
        <ContactSupportIcon />
      </Button>
    </TopToolbar>
  );
};
const SongList = (props: ListProps) => (
  <List {...props} actions={<ListActions />}>
    <SimpleList
      data-tour-id="song-list-line"
      primaryText={(record: any): string => record.title}
    />
  </List>
);
import { useTour } from "@react-admin/ra-tour";

const ListActions = (props) => {
  const [{ running }, { start }] = useTour();
  return (
    <TopToolbar {...sanitizeListRestProps(props)}>
      <Button
        onClick={() => start("songs-list")}
        disabled={running} // can't click on the button when tour is running
      >
        <ContactSupportIcon />
      </Button>
    </TopToolbar>
  );
};
const SongList = (props) => (
  <List {...props} actions={<ListActions />}>
    <SimpleList
      data-tour-id="song-list-line"
      primaryText={(record) => record.title}
    />
  </List>
);

When the user click on the button, the tour starts.

Advanced Usage : Controlling React-Admin

In case you need more control over what happens for each step, you can use the before and after functions in a tour configuration:

// tours.tsx
import { TourType } from "@react-admin/ra-tour";

const tours: { [id: string]: TourType } = {
  "songs-list": {
    before: (): void => {
      // executed before tour starts
    },
    steps: [
      {
        before: (): void => {
          // executed before step starts
        },
        target: `[data-tour-id='song-list-line'] a:nth-child(1)`,
        event: "hover",
        content: "This is a song",
        after: (): void => {
          // executed after step ends
        },
      },
      {
        target: `[data-tour-id='song-list-line'] a:nth-child(7)`,
        content: "This is another song, it should  be lower on the page",
      },
    ],
    after: (): void => {
      // executed after tour ends
    },
  },
};

export default tours;
const tours = {
  "songs-list": {
    before: () => {
      // executed before tour starts
    },
    steps: [
      {
        before: () => {
          // executed before step starts
        },
        target: `[data-tour-id='song-list-line'] a:nth-child(1)`,
        event: "hover",
        content: "This is a song",
        after: () => {
          // executed after step ends
        },
      },
      {
        target: `[data-tour-id='song-list-line'] a:nth-child(7)`,
        content: "This is another song, it should  be lower on the page",
      },
    ],
    after: () => {
      // executed after tour ends
    },
  },
};

export default tours;

And in order to control react-admin within those before and after functions, you can inject callbacks in the tools prop of the <TourProvider> component. For instance, to use the react-admin notification and redirection hooks, do the following:

// index.tsx
import {
  Admin,
  Layout,
  LayoutProps,
  Resource,
  useNotify,
  useRedirect,
} from "react-admin";
import { TourProvider } from "@react-admin/ra-tour";

import SongList from "./SongList";
import tours from "./tours";

const MyLayout = (props: LayoutProps) => {
  const notify = useNotify();
  const redirect = useRedirect();
  return (
    <TourProvider tours={tours} tools={{ notify, redirect }}>
      <Layout {...props} />
    </TourProvider>
  );
};
export const MyAdmin = () => (
  <Admin dataProvider={dataProvider} layout={MyLayout}>
    <Resource name="songs" list={SongList} />
  </Admin>
);
// index.tsx
import { Admin, Layout, Resource, useNotify, useRedirect } from "react-admin";
import { TourProvider } from "@react-admin/ra-tour";

import SongList from "./SongList";
import tours from "./tours";

const MyLayout = (props) => {
  const notify = useNotify();
  const redirect = useRedirect();
  return (
    <TourProvider tours={tours} tools={{ notify, redirect }}>
      <Layout {...props} />
    </TourProvider>
  );
};
export const MyAdmin = () => (
  <Admin dataProvider={dataProvider} layout={MyLayout}>
    <Resource name="songs" list={SongList} />
  </Admin>
);

ra-tour injects the tools as arguments when it calls the before and after functions:

// tours.tsx
import { TourType } from "@react-admin/ra-tour";

const tours: { [id: string]: TourType } = {
  "songs-list": {
    before: ({ notify, redirect }): void => {
      notify("Tour starting");
      redirect("/songs");
    },
    // ...
  },
};
const tours = {
  "songs-list": {
    before: ({ notify, redirect }) => {
      notify("Tour starting");
      redirect("/songs");
    },
    // ...
  },
};
export {};

Advanced Usage: Accessing The Tour State

In some scenarii, you might want to access the tour state - for instance, if you want your tour to survive a reload.

  1. Add a saving mechanism as a tool (here, ra-preferences):
// index.tsx
import { Admin, Layout, LayoutProps, Resource } from "react-admin";
import { TourProvider } from "@react-admin/ra-tour";
import { usePreferences } from "@react-admin/ra-preferences";

import SongList from "./SongList";
import tours from "./tours";

const MyLayout = (props: LayoutProps) => {
  const [tourState, setTourState] = usePreferences("tour", null);
  return (
    <TourProvider
      tours={tours}
      tools={{ setTourState }}
      // initialize the tour with what's in local storage
      initialState={tourState}
    >
      <Layout {...props} />
    </TourProvider>
  );
};
export const MyAdmin = () => (
  <Admin dataProvider={dataProvider} layout={MyLayout}>
    <Resource name="songs" list={SongList} />
  </Admin>
);
// index.tsx
import { Admin, Layout, Resource } from "react-admin";
import { TourProvider } from "@react-admin/ra-tour";
import { usePreferences } from "@react-admin/ra-preferences";

import SongList from "./SongList";
import tours from "./tours";

const MyLayout = (props) => {
  const [tourState, setTourState] = usePreferences("tour", null);
  return (
    <TourProvider
      tours={tours}
      tools={{ setTourState }}
      // initialize the tour with what's in local storage
      initialState={tourState}
    >
      <Layout {...props} />
    </TourProvider>
  );
};
export const MyAdmin = () => (
  <Admin dataProvider={dataProvider} layout={MyLayout}>
    <Resource name="songs" list={SongList} />
  </Admin>
);
  1. Save or reset the state in before and after functions
// tours.tsx
const tours: { [id: string]: TourType } = {
  "songs-list": {
    steps: [
      {
        // The tour state is injected together with tools in before and after functions:
        before: ({ setTourState, state }) => {
          setTourState(state);
        },
        target: "body",
        content: "This persists a reload",
        after: ({ setTourState }) => {
          setTourState({});
        },
      },
    ],
  },
};
//...
// tours.tsx
const tours = {
  "songs-list": {
    steps: [
      {
        // The tour state is injected together with tools in before and after functions:
        before: ({ setTourState, state }) => {
          setTourState(state);
        },
        target: "body",
        content: "This persists a reload",
        after: ({ setTourState }) => {
          setTourState({});
        },
      },
    ],
  },
};
//...

Advanced Usage: Custom Steps

The content key on the step can take any react component, for instance:

// tours.tsx
const tours: { [id: string]: TourType } = {
  "songs-list": {
    steps: [
      {
        before: ({ setTourPreferences, state }) => {
          setTourPreferences(state);
        },
        target: "body",
        content: (
          <div>
            This step persists a reload,
            <button
              onClick={(): void => {
                window.location.reload();
              }}
            >
              try it!
            </button>
          </div>
        ),
        after: ({ setTourPreferences }) => {
          setTourPreferences({});
        },
      },
    ],
  },
};
// tours.tsx
const tours = {
  "songs-list": {
    steps: [
      {
        before: ({ setTourPreferences, state }) => {
          setTourPreferences(state);
        },
        target: "body",
        content: (
          <div>
            This step persists a reload,
            <button
              onClick={() => {
                window.location.reload();
              }}
            >
              try it!
            </button>
          </div>
        ),
        after: ({ setTourPreferences }) => {
          setTourPreferences({});
        },
      },
    ],
  },
};

Advanced Usage: Full Customization

Under the hood, ra-tour uses react-joyride.

You can override joyride props either at a global level:

//...
import { Layout, LayoutProps } from "react-admin";
import { MyTooltip } from "./MyTooltip";

const MyLayout = (props: LayoutProps) => (
  <TourProvider
    tours={tours}
    joyrideProps={{
      tooltipComponent: MyTooltip,
    }}
  >
    <Layout {...props} />
  </TourProvider>
);

//...
//...
import { Layout } from "react-admin";
import { MyTooltip } from "./MyTooltip";

const MyLayout = (props) => (
  <TourProvider
    tours={tours}
    joyrideProps={{
      tooltipComponent: MyTooltip,
    }}
  >
    <Layout {...props} />
  </TourProvider>
);

//...

Or at the step level. For instance if you want to style the red beacon:

const tours: { [id: string]: TourType } = {
  "songs-list": {
    steps: [
      {
        target: `[data-tour-id='grid-line']:nth-child(3)`,
        event: "hover",
        content:
          "This is a poster, one of the products our shop is selling, let's go to its details",
        joyrideProps: {
          styles: {
            beacon: {
              marginTop: -100,
            },
          },
        },
      },
    ],
  },
};
//...
const tours = {
  "songs-list": {
    steps: [
      {
        target: `[data-tour-id='grid-line']:nth-child(3)`,
        event: "hover",
        content:
          "This is a poster, one of the products our shop is selling, let's go to its details",
        joyrideProps: {
          styles: {
            beacon: {
              marginTop: -100,
            },
          },
        },
      },
    ],
  },
};
//...

List of all available joyride props.

API

TourType

type TourType = {
  /**
   * Function called before the tour starts.
   * @param tools: The tools passed to the TourProvider.
   * @see TourProvider
   * @returns May return a Promise.
   */
  before?: (tools?: any) => void | Promise<void>;
  /**
   * The tour steps.
   * @see StepType
   */
  steps: StepType[];
  /**
   * Function called after the tour ends.
   * @param tools: The tools passed to the TourProvider.
   * @see TourProvider
   * @returns May return a Promise.
   */
  after?: (tools?: any) => void | Promise<void>;
};

StepType

type StepType = {
  /**
   * Function called before the step starts.
   * @param tools: The tools passed to the TourProvider.
   * @see TourProvider
   * @returns May return a Promise.
   */
  before?: (tools?: any) => void | Promise<void>;
  /**
   * A string containing a CSS selector which will be used to get the node to highlight.
   */
  target: string;
  /**
   * A boolean indicating whether the beacon should be disabled.
   */
  disableBeacon?: boolean;
  /**
   * The name of the event which will activate the tooltip from the Joyride beacon. It has no effect if `disableBeacon` is set to `false`.
   */
  event?: "hover" | "click";
  /**
   * The content of the Tooltip header. Accepts a React node.
   */
  title?: ReactNode;
  /**
   * The content of the Tooltip. Accepts a React node or a translation key
   */
  content: ReactNode;
  /**
   * The Joyride options which extend and may override the Joyride options set on TourProvider.
   */
  joyrideProps?: any;
  /**
   * Function called after the step ends.
   * @param tools: The tools passed to the TourProvider.
   * @see TourProvider
   * @returns May return a Promise.
   */
  after?: (tools?: any) => void | Promise<void>;
};

CHANGELOG

v1.2.0

2021-08-04

  • (feat) Add translation support for the steps contents
  • (feat) Add translations for Joyride actions
const songsListTour: TourType = {
  steps: [
    {
      target: `[data-tour-id='song-list-line'] a:nth-child(7)`,
      content: "myapp.tours.songs.mystep",
    },
  ],
};
const songsListTour = {
  steps: [
    {
      target: `[data-tour-id='song-list-line'] a:nth-child(7)`,
      content: "myapp.tours.songs.mystep",
    },
  ],
};

v1.1.2

2021-06-29

  • (fix) Update peer dependencies ranges (support react 17)

v1.1.1

2021-02-11

  • (doc) Update documentation to describe the tour and step objects.

v1.1.0

2020-10-05

  • Upgrade to react-admin 3.9

v1.0.1

2020-09-15

  • (fix) Fix Skip button still execute current step after phase
  • (fix) Fix Close button should have the same behavior as the Skip button
  • (deps) Upgrade dependencies

v1.0.0

2020-07-31

  • First release