DEV Community

Strapi
Strapi

Posted on • Originally published at strapi.io

Create a guided tour plugin in the admin panel

In this tutorial I will show you how to create your own plugin on Strapi (version 3.4.6) and more precisely how to use reactour to create a guided tour which can be very useful for content managers out there who will have to master Strapi's admin to manage their content.

We implemented this guided tour in the admin of our official demo: Foodadvisor. You can try it right here.

Create a Strapi project

Let's start by creating or simply taking an existing Strapi project. For my part, I will create a new one.

  • Create a new project using the following command:
# Using yarn
yarn create strapi-app my-project --quickstart

# Using npx
npx create-strapi-app my-project --quickstart
Enter fullscreen mode Exit fullscreen mode
  • Wait for installation to end and then create your administrator.

Now we simply want to generate a new plugin for the guided tour.

  • Generate a new plugin with the following command:
cd my-project
yarn strapi generate:plugin guided-tour
Enter fullscreen mode Exit fullscreen mode

You should have this in your terminal:

strapi generate:guided-tour plugin
[...] info Generated a new plugin `guided-tour` at`. / plugins`.
Enter fullscreen mode Exit fullscreen mode
  • Add reactour to your package.json at the root of your Strapi project:
yarn add reactour
Enter fullscreen mode Exit fullscreen mode

Perfect now you can start your server in watch-admin mode to customize the administration panel as your application will have the autoReload enabled.

  • Start you server in watch-admin mode by running the following command:
yarn develop --watch-admin
Enter fullscreen mode Exit fullscreen mode

Override some admin files

To make this work, we need to override some admin files. We simply need to create an ./admin/src folder and to override the necessary files.
Note: The files we are going to create will arrive in the next major of Strapi, I will not delve deeper into these files because it can get too technical but remember that it is to anticipate the arrival of the next major.

  • Create a ./admin/src/containers/PrivateRoute/index.js file with the following code:
/**
 *
 * PrivateRoute
 * Higher Order Component that blocks navigation when the user is not logged in
 * and redirect the user to login page
 *
 * Wrap your protected routes to secure your container
 */

import React, { memo } from 'react';
import { Redirect, Route } from 'react-router-dom';
import PropTypes from 'prop-types';
import { auth, useStrapi } from 'strapi-helper-plugin';

/* eslint-disable react/jsx-curly-newline */

const PrivateRoute = ({ component: Component, path, ...rest }) => {
  const strapi = useStrapi();
  return (
    <Route
      path={path}
      render={(props) =>
        auth.getToken() !== null ? (
          <Component {...rest} {...props} strapi={strapi} />
        ) : (
          <Redirect
            to={{
              pathname: '/auth/login',
            }}
          />
        )
      }
    />
  );
};

PrivateRoute.propTypes = {
  component: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
  path: PropTypes.string.isRequired,
};

export default memo(PrivateRoute);
Enter fullscreen mode Exit fullscreen mode
  • Create a ./admin/src/utils/MiddlewareApi.js file with the following code:
import { cloneDeep } from 'lodash';

class MiddlewaresHandler {
  middlewares = [];

  add(middleware) {
    this.middlewares.push(middleware);
  }

  get middlewares() {
    return cloneDeep(this.middlewares);
  }
}

export default () => new MiddlewaresHandler();
Enter fullscreen mode Exit fullscreen mode
  • Create a ./admin/src/utils/Plugin.js file with the following code:
class Plugin {
  pluginId = null;

  decorators = {};

  injectedComponents = {};

  apis = {};

  constructor(pluginConf) {
    this.pluginId = pluginConf.id;
    this.decorators = pluginConf.decorators || {};
    this.injectedComponents = pluginConf.injectedComponents || {};
    this.apis = pluginConf.apis || {};
  }

  decorate(compoName, compo) {
    if (this.decorators && this.decorators[compoName]) {
      this.decorators[compoName] = compo;
    }
  }

  getDecorator(compoName) {
    if (this.decorators) {
      return this.decorators[compoName] || null;
    }

    return null;
  }

  getInjectedComponents(containerName, blockName) {
    try {
      return this.injectedComponents[containerName][blockName] || {};
    } catch (err) {
      console.error('Cannot get injected component', err);

      return err;
    }
  }

  injectComponent(containerName, blockName, compo) {
    try {
      this.injectedComponents[containerName][blockName].push(compo);
    } catch (err) {
      console.error('Cannot inject component', err);
    }
  }
}

export default (pluginConf) => new Plugin(pluginConf);
Enter fullscreen mode Exit fullscreen mode
  • Create a ./admin/src/utils/Strapi.js file with the following code:
import ComponentApi from './ComponentApi';
import FieldApi from './FieldApi';
import MiddlewareApi from './MiddlewareApi';
import PluginHandler from './Plugin';

class Strapi {
  componentApi = ComponentApi();

  fieldApi = FieldApi();

  middlewares = MiddlewareApi();

  plugins = {
    admin: PluginHandler({
      id: 'admin',
      injectedComponents: {
        admin: {
          onboarding: [
            // { name: 'test', Component: () => 'coming soon' }
          ],
        },
      },
    }),
  };

  getPlugin = (pluginId) => {
    return this.plugins[pluginId];
  };

  registerPlugin = (pluginConf) => {
    if (pluginConf.id) {
      this.plugins[pluginConf.id] = PluginHandler(pluginConf);
    }
  };
}

export default () => {
  return new Strapi();
};
Enter fullscreen mode Exit fullscreen mode
  • Create a ./admin/src/app.js file with the following code:
/* eslint-disable */

import '@babel/polyfill';
import 'sanitize.css/sanitize.css';

// Third party css library needed
import 'bootstrap/dist/css/bootstrap.css';
import 'font-awesome/css/font-awesome.min.css';
import '@fortawesome/fontawesome-free/css/all.css';
import '@fortawesome/fontawesome-free/js/all.min.js';

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
// Strapi provider with the internal APIs
import { StrapiProvider } from 'strapi-helper-plugin';
import { merge } from 'lodash';
import Fonts from './components/Fonts';
import { freezeApp, pluginLoaded, unfreezeApp, updatePlugin } from './containers/App/actions';
import { showNotification } from './containers/NotificationProvider/actions';
import { showNotification as showNewNotification } from './containers/NewNotification/actions';

import basename from './utils/basename';
import injectReducer from './utils/injectReducer';
import injectSaga from './utils/injectSaga';
import Strapi from './utils/Strapi';

// Import root component
import App from './containers/App';
// Import Language provider
import LanguageProvider from './containers/LanguageProvider';

import configureStore from './configureStore';
import { SETTINGS_BASE_URL } from './config';

// Import i18n messages
import { translationMessages, languages } from './i18n';

import history from './utils/history';

import plugins from './plugins';

const strapi = Strapi();

const pluginsReducers = {};
const pluginsToLoad = [];

Object.keys(plugins).forEach((current) => {
  const registerPlugin = (plugin) => {
    strapi.registerPlugin(plugin);

    return plugin;
  };
  const currentPluginFn = plugins[current];

  // By updating this by adding required methods
  // to load a plugin you need to update this file
  // strapi-generate-plugins/files/admin/src/index.js needs to be updated
  const plugin = currentPluginFn({
    registerComponent: strapi.componentApi.registerComponent,
    registerField: strapi.fieldApi.registerField,
    registerPlugin,
    settingsBaseURL: SETTINGS_BASE_URL || '/settings',
  });

  const pluginTradsPrefixed = languages.reduce((acc, lang) => {
    const currentLocale = plugin.trads[lang];

    if (currentLocale) {
      const localeprefixedWithPluginId = Object.keys(currentLocale).reduce((acc2, current) => {
        acc2[`${plugin.id}.${current}`] = currentLocale[current];

        return acc2;
      }, {});

      acc[lang] = localeprefixedWithPluginId;
    }

    return acc;
  }, {});

  // Retrieve all reducers
  const pluginReducers = plugin.reducers || {};

  Object.keys(pluginReducers).forEach((reducerName) => {
    pluginsReducers[reducerName] = pluginReducers[reducerName];
  });

  try {
    merge(translationMessages, pluginTradsPrefixed);
    pluginsToLoad.push(plugin);
  } catch (err) {
    console.log({ err });
  }
});

const initialState = {};
const store = configureStore(initialState, pluginsReducers, strapi);
const { dispatch } = store;

// Load plugins, this will be removed in the v4, temporary fix until the plugin API
// https://plugin-api-rfc.vercel.app/plugin-api/admin.html
pluginsToLoad.forEach((plugin) => {
  const bootPlugin = plugin.boot;

  if (bootPlugin) {
    bootPlugin(strapi);
  }

  dispatch(pluginLoaded(plugin));
});

// TODO
const remoteURL = (() => {
  // Relative URL (ex: /dashboard)
  if (REMOTE_URL[0] === '/') {
    return (window.location.origin + REMOTE_URL).replace(/\/$/, '');
  }

  return REMOTE_URL.replace(/\/$/, '');
})();

const displayNotification = (message, status) => {
  console.warn(
    // Validate the text
    'Deprecated: Will be deleted.\nPlease use strapi.notification.toggle(config).\nDocs : https://strapi.io/documentation/developer-docs/latest/plugin-development/frontend-development.html#strapi-notification'
  );
  dispatch(showNotification(message, status));
};
const displayNewNotification = (config) => {
  dispatch(showNewNotification(config));
};
const lockApp = (data) => {
  dispatch(freezeApp(data));
};
const unlockApp = () => {
  dispatch(unfreezeApp());
};

const lockAppWithOverlay = () => {
  const overlayblockerParams = {
    children: <div />,
    noGradient: true,
  };

  lockApp(overlayblockerParams);
};

window.strapi = Object.assign(window.strapi || {}, {
  node: MODE || 'host',
  env: NODE_ENV,
  remoteURL,
  backendURL: BACKEND_URL === '/' ? window.location.origin : BACKEND_URL,
  notification: {
    // New notification api
    toggle: (config) => {
      displayNewNotification(config);
    },
    success: (message) => {
      displayNotification(message, 'success');
    },
    warning: (message) => {
      displayNotification(message, 'warning');
    },
    error: (message) => {
      displayNotification(message, 'error');
    },
    info: (message) => {
      displayNotification(message, 'info');
    },
  },
  refresh: (pluginId) => ({
    translationMessages: (translationMessagesUpdated) => {
      render(merge({}, translationMessages, translationMessagesUpdated));
    },
    leftMenuSections: (leftMenuSectionsUpdated) => {
      store.dispatch(updatePlugin(pluginId, 'leftMenuSections', leftMenuSectionsUpdated));
    },
  }),
  router: history,
  languages,
  currentLanguage:
    window.localStorage.getItem('strapi-admin-language') ||
    window.navigator.language ||
    window.navigator.userLanguage ||
    'en',
  lockApp,
  lockAppWithOverlay,
  unlockApp,
  injectReducer,
  injectSaga,
  store,
});

const MOUNT_NODE = document.getElementById('app') || document.createElement('div');

const render = (messages) => {
  ReactDOM.render(
    <Provider store={store}>
      <StrapiProvider strapi={strapi}>
        <Fonts />
        <LanguageProvider messages={messages}>
          <BrowserRouter basename={basename}>
            <App store={store} />
          </BrowserRouter>
        </LanguageProvider>
      </StrapiProvider>
    </Provider>,
    MOUNT_NODE
  );
};

if (module.hot) {
  module.hot.accept(['./i18n', './containers/App'], () => {
    ReactDOM.unmountComponentAtNode(MOUNT_NODE);

    render(translationMessages);
  });
}

if (NODE_ENV !== 'test') {
  render(translationMessages);
}

export { dispatch };

if (window.Cypress) {
  window.__store__ = Object.assign(window.__store__ || {}, { store });
}
Enter fullscreen mode Exit fullscreen mode
  • Create a ./admin/src/configureStore.js file with the following code:
/**
 * Create the store with dynamic reducers
 */

import { createStore, applyMiddleware, compose } from 'redux';
import { fromJS } from 'immutable';
// import { routerMiddleware } from 'react-router-redux';
import createSagaMiddleware from 'redux-saga';
import createReducer from './reducers';

const sagaMiddleware = createSagaMiddleware();

export default function configureStore(initialState = {}, reducers, strapi) {
  // Create the store with two middlewares
  // 1. sagaMiddleware: Makes redux-sagas work
  // 2. routerMiddleware: Syncs the location/URL path to the state
  const middlewares = [sagaMiddleware];

  strapi.middlewares.middlewares.forEach((middleware) => {
    middlewares.push(middleware());
  });

  const enhancers = [applyMiddleware(...middlewares)];

  // If Redux DevTools Extension is installed use it, otherwise use Redux compose
  /* eslint-disable no-underscore-dangle */
  const composeEnhancers =
    process.env.NODE_ENV !== 'production' &&
    typeof window === 'object' &&
    window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
      ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
          // TODO Try to remove when `react-router-redux` is out of beta, LOCATION_CHANGE should not be fired more than once after hot reloading
          // Prevent recomputing reducers for `replaceReducer`
          shouldHotReload: false,
          name: 'Strapi - Dashboard',
        })
      : compose;
  /* eslint-enable */

  const store = createStore(
    createReducer(reducers),
    fromJS(initialState),
    composeEnhancers(...enhancers)
  );

  // Extensions
  store.runSaga = sagaMiddleware.run;
  store.injectedReducers = {}; // Reducer registry
  store.injectedSagas = {}; // Saga registry

  // Make reducers hot reloadable, see http://mxs.is/googmo
  /* istanbul ignore next */
  if (module.hot) {
    module.hot.accept('./reducers', () => {
      store.replaceReducer(createReducer(store.injectedReducers));
    });
  }

  return store;
}
Enter fullscreen mode Exit fullscreen mode

Create the tour

Now it's time to work in the guided tour! Following instructions will happen in the ./plugins/guided-tour-admin/src folder.

  • Dive in the guided tour folder by running the following command:
cd ./plugins/guided-tour-admin/src
Enter fullscreen mode Exit fullscreen mode
  • Update the existing index.js file with the following code:
import pluginPkg from '../../package.json';
import pluginId from './pluginId';
import Tour from './components/Tour';
import trads from './translations';

export default (strapi) => {
  const pluginDescription = pluginPkg.strapi.description || pluginPkg.description;
  const icon = pluginPkg.strapi.icon;
  const name = pluginPkg.strapi.name;

  const plugin = {
    description: pluginDescription,
    icon,
    id: pluginId,
    initializer: null,
    isReady: true,
    isRequired: false,
    mainComponent: null,
    name,
    preventComponentRendering: false,
    trads,
    boot(app) {
      app.getPlugin('admin').injectComponent('admin', 'onboarding', {
        name: 'guided-tour',
        Component: Tour,
      });
    },
  };

  return strapi.registerPlugin(plugin);
};
Enter fullscreen mode Exit fullscreen mode
  • Remove the containers folder by running the following command and create the new architecture for your plugin:
rm -rf containers
mkdir -p components/Tour/utils
cd components/Tour
touch index.js reducer.js
touch utils/tour.js
Enter fullscreen mode Exit fullscreen mode

Perfect! You have all the necessary files. Now it's time to create the logic of the tour.

We wanted to create a tour where steps are organized by plugins. In fact, if you open the guided tour in the homepage then the tour will start at the very beginning. However if you open the tour in the Content-Types builder we want to open the tour at a certain step, actually, the beginning of the Content-Types Builder steps which make sense as you don't want to start from the very beginning from there.

To achieve this, we need to first define how we structure our tour. Easy to do, it will simply be a javascript object organizing steps depending on plugins.

  • Update the utils/tour.js with the following:
import React from 'react'

const tour = {
  'admin': {
    steps: [{
      selector: 'a[href="/admin"]',
      content: () => (
        <div>
        <h1>Hi! 👋 </h1><br />
          <h4>Welcome to the official demo of Strapi: <strong>Foodadvisor!</strong></h4><br />
          What about following this little guided tour to learn more about our product? Ready? <strong>Let's go!</strong><br /><br />
          (Use arrow keys to change steps)
        </div>
      )
    },
    {
      selector: 'a[href="/admin/plugins/content-type-builder"]',
      content: () => (
        <div>
        <h1>Content Types Builder</h1><br />
          This is the most important tool, the one that allows you to <strong>create the architecture of your project</strong>.<br /><br />
          <ul>
            <li>
              <strong>Click</strong> on the link to be redirected to the Content Types Builder.
            </li>
          </ul>
        </div>
      )
    }],
  },
  'content-type-builder': {
    steps: [
      {
        selector: 'a[href="/admin"]',
        content: () => (
          <div>
          <h1>Content Types Builder</h1><br />
          Welcome to the CTB! This is where you create your <strong>collection types</strong>, <strong>single types</strong> and <strong>components</strong>.<br /><br />
          Let's see how it's working here!
          </div>
        ),
      },
      {
        selector: 'div.col-md-3',
        content: () => (
          <div>
          <h1>Manage your Content Types</h1><br />
          In this sidebar you can browse your <strong>collection types</strong>, <strong>single types</strong>, <strong>components</strong> and also create new ones!<br /><br />
          Let's see one specific collection type!
          </div>
        ),
      },
    ],
  }
};

export default tour;
Enter fullscreen mode Exit fullscreen mode

As you can see, this is a tour object containing an admin object which itself contains steps and you also have another content-type-builder object containing steps too.

This way we completely associate steps to specific plugins. However admin is not a plugin. Well this is when you'll be on the homepage, settings page etc...

Great, now let's build the logic to make our guided-tour works.

  • Update the Tour/index.js file with the following code:
import React, { memo, useEffect, useCallback, useReducer, useMemo } from 'react';
import reducer, { initialState } from './reducer';
import { useRouteMatch } from 'react-router-dom';
import { Button } from '@buffetjs/core';
import ReactTour from 'reactour';
import { get } from 'lodash';

const Tour = () => {
  // Get the current plugin name => pluginId
  const match = useRouteMatch('/plugins/:pluginId');
  const pluginId = get(match, ['params', 'pluginId'], 'admin');

  // Use the usereducer hook to manage our state. See reducer.js file
  const [{ isOpen, tour, actualPlugin, currentStep, totalLength }, dispatch] = useReducer(
    reducer,
    initialState
  );

  // Called when we click on the guided-tour button.
  // Change the isOpen state variable. See TOGGLE_IS_OPEN action type in reducer.js file.
  const handleClick = useCallback(() => {
    dispatch({ type: 'TOGGLE_IS_OPEN', pluginId });
  }, []);

  // Calculate the steps from the tour. See utils/tour.js file.
  const steps = useMemo(() => {
    return Object.values(tour).reduce((acc, current) => {
      return [...acc, ...current.steps];
    }, []);
  }, [tour]);

  // Main logic of the tour.
  useEffect(() => {
    let totalSteps = 0;
    const keys = Object.keys(tour);
    const previousPlugins = keys.slice(0, keys.indexOf(pluginId));
    if (previousPlugins.length > 0) {
      previousPlugins.forEach((plugin, i) => {
        totalSteps += tour[plugin].steps.length;
      });
    }
    if (tour[pluginId] && pluginId !== actualPlugin)
      dispatch({ type: 'SETUP', pluginId, totalSteps });
  }, [tour, pluginId, actualPlugin]);

  const handleNextStep = () => {
    if (tour[pluginId] && currentStep === totalLength - 1 && totalLength > 0) {
      return;
    } else if (tour[pluginId]) {
      dispatch({ type: 'NEXT_STEP', length: tour[pluginId].steps.length });
    }
  };

  return (
    <>
      {tour[pluginId] && (
        <Button
          onClick={handleClick}
          color="primary"
          style={{ right: '70px', bottom: '15px', position: 'fixed', height: '37px' }}
        >
          Guided Tour
        </Button>
      )}
      <ReactTour
        isOpen={tour[pluginId] ? isOpen : false}
        onRequestClose={handleClick}
        steps={steps}
        startAt={currentStep}
        goToStep={currentStep}
        nextStep={handleNextStep}
        prevStep={() => dispatch({ type: 'PREV_STEP' })}
        showNavigation={false}
        rounded={2}
      />
    </>
  );
};

export default memo(Tour);
Enter fullscreen mode Exit fullscreen mode

I agree that's a lot! but don't worry, I will explain everything in this file.

import React, { memo, useEffect, useCallback, useReducer, useMemo } from 'react';
import reducer, { initialState } from './reducer';
import { useRouteMatch } from 'react-router-dom';
import { Button } from '@buffetjs/core';
import ReactTour from 'reactour';
import { get } from 'lodash';
Enter fullscreen mode Exit fullscreen mode

First we import everything we'll need to make it work. Hooks, our reducer (reducer.js) file that we didn't updated yet, useRouteMatch hook attempts to match the current URL in the same way that a <Route> would just to get the current plugin we're in, the Button component from Buffet.js, reactour and finally lodash.

Then in the Tour component we have:

  const match = useRouteMatch('/plugins/:pluginId');
  const pluginId = get(match, ['params', 'pluginId'], 'admin');
Enter fullscreen mode Exit fullscreen mode

It will allow us to get the current plugin name in the pluginId variable.

const [{ isOpen, tour, actualPlugin, currentStep, totalLength }, dispatch] = useReducer(
  reducer,
  initialState
);
Enter fullscreen mode Exit fullscreen mode

Our state defined by the useReducer hook.

const handleClick = useCallback(() => {
  dispatch({ type: 'TOGGLE_IS_OPEN', pluginId });
}, []);
Enter fullscreen mode Exit fullscreen mode

This will be called when we'll click on the guided tour button to change the value of the isOpen state variable.

const steps = useMemo(() => {
  return Object.values(tour).reduce((acc, current) => {
    return [...acc, ...current.steps];
  }, []);
}, [tour]);
Enter fullscreen mode Exit fullscreen mode

steps array that will contains the steps depending on our utils/tour.js file.

useEffect(() => {
  let totalSteps = 0;
  const keys = Object.keys(tour);
  const previousPlugins = keys.slice(0, keys.indexOf(pluginId));
  if (previousPlugins.length > 0) {
    previousPlugins.forEach((plugin, i) => {
      totalSteps += tour[plugin].steps.length;
    });
  }
  if (tour[pluginId] && pluginId !== actualPlugin)
    dispatch({ type: 'SETUP', pluginId, totalSteps });
}, [tour, pluginId, actualPlugin]);
Enter fullscreen mode Exit fullscreen mode

The main logic of the tour. This will define where the tour should start depending on where you start it.

const handleNextStep = () => {
  if (tour[pluginId] && currentStep === totalLength - 1 && totalLength > 0) {
    return;
  } else if (tour[pluginId]) {
    dispatch({ type: 'NEXT_STEP', length: tour[pluginId].steps.length });
  }
};
Enter fullscreen mode Exit fullscreen mode
  • Override of the nexStep function of reactour to create a behaviour like: if you need to make the user change location to another plugin then you can prevent the user to go to the next steps.
return (
  <>
    {tour[pluginId] && (
      <Button
        onClick={handleClick}
        color="primary"
        style={{ right: '70px', bottom: '15px', position: 'fixed', height: '37px' }}
      >
        Guided Tour
      </Button>
    )}
    <ReactTour
      isOpen={tour[pluginId] ? isOpen : false}
      onRequestClose={handleClick}
      steps={steps}
      startAt={currentStep}
      goToStep={currentStep}
      nextStep={handleNextStep}
      prevStep={() => dispatch({ type: 'PREV_STEP' })}
      showNavigation={false}
      rounded={2}
    />
  </>
);
Enter fullscreen mode Exit fullscreen mode

Finally we simply render the guided-tour button only if the tour contains steps for the plugin you are currently in. Then we render the reactour component with the necessary props.

  • Great ! Now let's create our utils/reducer.js file with the following code:
import produce from 'immer';
import { isEmpty, pick } from 'lodash';
import tour from './utils/tour';

const initialState = {
  tour,
  isOpen: true,
  totalLength: 0,
  currentStep: 0,
  actualPlugin: null
};

const reducer = (state, action) =>
  produce(state, draftState => {
    switch (action.type) {
      case 'TOGGLE_IS_OPEN': {
        draftState.isOpen = !state.isOpen;
        draftState.currentStep = state.currentStep;
        break;
      }
      case 'SETUP': {
        draftState.currentStep = action.totalSteps;
        draftState.actualPlugin = action.pluginId;
        draftState.totalLength = action.totalSteps + tour[action.pluginId].steps.length
        break;
      }
      case 'PREV_STEP': {
        draftState.currentStep = state.currentStep > 0 ? state.currentStep - 1 : state.currentStep;
        break;
      }
      case 'NEXT_STEP': {
        draftState.currentStep =
          state.currentStep < state.totalLength - 1 ? state.currentStep + 1 : state.currentStep;
        break;
      }
      default:
        return draftState;
    }
  });

export default reducer;
export { initialState };
Enter fullscreen mode Exit fullscreen mode

We define here our initial state and we create behaviours depending on which action type we received:

case 'TOGGLE_IS_OPEN': {
  draftState.isOpen = !state.isOpen;
  draftState.currentStep = state.currentStep;
  break;
}
Enter fullscreen mode Exit fullscreen mode

We change the value of the isOpen to it's opposite state variable when we click on the tour button. We keep the value of the currentStep so that the user will go back to the step he quit the tour.

case 'SETUP': {
  draftState.currentStep = action.totalSteps;
  draftState.actualPlugin = action.pluginId;
  draftState.totalLength = action.totalSteps + tour[action.pluginId].steps.length
  break;
}
Enter fullscreen mode Exit fullscreen mode

This will setup the currentStep, actualPlugin and totalLength state variables every time we change plugin. These variable will not be the same depending on where you start the tour.

case 'PREV_STEP': {
  draftState.currentStep = state.currentStep > 0 ? state.currentStep - 1 : state.currentStep;
  break;
}
case 'NEXT_STEP': {
  draftState.currentStep =
   state.currentStep < state.totalLength - 1 ? state.currentStep + 1 : state.currentStep;
  break;
}
Enter fullscreen mode Exit fullscreen mode

We simply recreate the behaviour of reactour. This is necessary as we wanted to override the nextStep function of the package.

  • Save you files and it should be ready! You should be able to have a small guided-tour with 4 steps, 2 in the admin and 2 in the Content-Types Builder.

  • Now you can stop your server, build your admin and normally start your server by running the following commands at the root of the project:

yarn build
yarn develop
Enter fullscreen mode Exit fullscreen mode

guided-tour-2.png
guided-tour-1.png

Thanks for for following this little tutorial! See you in another one ;)

Please note: Since we initially published this blog post, we released new versions of Strapi and tutorials may be outdated. Sorry for the inconvenience if it's the case. Please help us by reporting it here.

If you want to know more

Discussion (0)