In this tutorial, we 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. Give us a try to our demo.
Let's start by creating or simply taking an existing Strapi project. For my part, I will create a new one.
# Using yarn
yarn create strapi-app my-project --quickstart
# Using npx
npx create-strapi-app my-project --quickstart
Now we want to generate a new plugin for the guided tour.
cd my-project
yarn strapi generate:plugin guided-tour
You should have this in your terminal:
strapi generate:guided-tour plugin
[...] info Generated a new plugin `guided-tour` at`. / plugins`.
package.json
at the root of your Strapi project:yarn add reactour
You can now start your server in watch-admin mode to customize the administration panel, as your application will have the autoReload enabled.
yarn develop --watch-admin
To make this work, we need to override some admin files. We must create an ./admin/src
folder and 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.
./admin/src/containers/PrivateRoute/index.js
file with the following code:1/**
2 *
3 * PrivateRoute
4 * Higher Order Component that blocks navigation when the user is not logged in
5 * and redirect the user to the login page
6 *
7 * Wrap your protected routes to secure your container
8 */
9
10import React, { memo } from 'react';
11import { Redirect, Route } from 'react-router-dom';
12import PropTypes from 'prop-types';
13import { auth, useStrapi } from 'strapi-helper-plugin';
14
15/* eslint-disable react/jsx-curly-newline */
16
17const PrivateRoute = ({ component: Component, path, ...rest }) => {
18 const strapi = useStrapi();
19 return (
20 <Route
21 path={path}
22 render={(props) =>
23 auth.getToken() !== null ? (
24 <Component {...rest} {...props} strapi={strapi} />
25 ) : (
26 <Redirect
27 to={{
28 pathname: '/auth/login',
29 }}
30 />
31 )
32 }
33 />
34 );
35};
36
37PrivateRoute.propTypes = {
38 component: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
39 path: PropTypes.string.isRequired,
40};
41
42export default memo(PrivateRoute);
./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();
./admin/src/utils/Plugin.js
file with the following code:1class Plugin {
2 pluginId = null;
3
4 decorators = {};
5
6 injectedComponents = {};
7
8 apis = {};
9
10 constructor(pluginConf) {
11 this.pluginId = pluginConf.id;
12 this.decorators = pluginConf.decorators || {};
13 this.injectedComponents = pluginConf.injectedComponents || {};
14 this.apis = pluginConf.apis || {};
15 }
16
17 decorate(compoName, compo) {
18 if (this.decorators && this.decorators[compoName]) {
19 this.decorators[compoName] = compo;
20 }
21 }
22
23 getDecorator(compoName) {
24 if (this.decorators) {
25 return this.decorators[compoName] || null;
26 }
27
28 return null;
29 }
30
31 getInjectedComponents(containerName, blockName) {
32 try {
33 return this.injectedComponents[containerName][blockName] || {};
34 } catch (err) {
35 console.error('Cannot get injected component', err);
36
37 return err;
38 }
39 }
40
41 injectComponent(containerName, blockName, compo) {
42 try {
43 this.injectedComponents[containerName][blockName].push(compo);
44 } catch (err) {
45 console.error('Cannot inject component', err);
46 }
47 }
48}
49
50export default (pluginConf) => new Plugin(pluginConf);
./admin/src/utils/Strapi.js
file with the following code:1import ComponentApi from './ComponentApi';
2import FieldApi from './FieldApi';
3import MiddlewareApi from './MiddlewareApi';
4import PluginHandler from './Plugin';
5
6class Strapi {
7 componentApi = ComponentApi();
8
9 fieldApi = FieldApi();
10
11 middlewares = MiddlewareApi();
12
13 plugins = {
14 admin: PluginHandler({
15 id: 'admin',
16 injectedComponents: {
17 admin: {
18 onboarding: [
19 // { name: 'test', Component: () => 'coming soon' }
20 ],
21 },
22 },
23 }),
24 };
25
26 getPlugin = (pluginId) => {
27 return this.plugins[pluginId];
28 };
29
30 registerPlugin = (pluginConf) => {
31 if (pluginConf.id) {
32 this.plugins[pluginConf.id] = PluginHandler(pluginConf);
33 }
34 };
35}
36
37export default () => {
38 return new Strapi();
39};
./admin/src/app.js
file with the following code:1/* eslint-disable */
2
3import '@babel/polyfill';
4import 'sanitize.css/sanitize.css';
5
6// Third party css library needed
7import 'bootstrap/dist/css/bootstrap.css';
8import 'font-awesome/css/font-awesome.min.css';
9import '@fortawesome/fontawesome-free/css/all.css';
10import '@fortawesome/fontawesome-free/js/all.min.js';
11
12import React from 'react';
13import ReactDOM from 'react-dom';
14import { Provider } from 'react-redux';
15import { BrowserRouter } from 'react-router-dom';
16// Strapi provider with the internal APIs
17import { StrapiProvider } from 'strapi-helper-plugin';
18import { merge } from 'lodash';
19import Fonts from './components/Fonts';
20import { freezeApp, pluginLoaded, unfreezeApp, updatePlugin } from './containers/App/actions';
21import { showNotification } from './containers/NotificationProvider/actions';
22import { showNotification as showNewNotification } from './containers/NewNotification/actions';
23
24import basename from './utils/basename';
25import injectReducer from './utils/injectReducer';
26import injectSaga from './utils/injectSaga';
27import Strapi from './utils/Strapi';
28
29// Import root component
30import App from './containers/App';
31// Import Language provider
32import LanguageProvider from './containers/LanguageProvider';
33
34import configureStore from './configureStore';
35import { SETTINGS_BASE_URL } from './config';
36
37// Import i18n messages
38import { translationMessages, languages } from './i18n';
39
40import history from './utils/history';
41
42import plugins from './plugins';
43
44const strapi = Strapi();
45
46const pluginsReducers = {};
47const pluginsToLoad = [];
48
49Object.keys(plugins).forEach((current) => {
50 const registerPlugin = (plugin) => {
51 strapi.registerPlugin(plugin);
52
53 return plugin;
54 };
55 const currentPluginFn = plugins[current];
56
57 // By updating this by adding the required methods
58 // to load a plugin, you need to update this file
59 // strapi-generate-plugins/files/admin/src/index.js needs to be updated
60 const plugin = currentPluginFn({
61 registerComponent: strapi.componentApi.registerComponent,
62 registerField: strapi.fieldApi.registerField,
63 registerPlugin,
64 settingsBaseURL: SETTINGS_BASE_URL || '/settings',
65 });
66
67 const pluginTradsPrefixed = languages.reduce((acc, lang) => {
68 const currentLocale = plugin.trads[lang];
69
70 if (currentLocale) {
71 const localeprefixedWithPluginId = Object.keys(currentLocale).reduce((acc2, current) => {
72 acc2[`${plugin.id}.${current}`] = currentLocale[current];
73
74 return acc2;
75 }, {});
76
77 acc[lang] = localeprefixedWithPluginId;
78 }
79
80 return acc;
81 }, {});
82
83 // Retrieve all reducers
84 const pluginReducers = plugin.reducers || {};
85
86 Object.keys(pluginReducers).forEach((reducerName) => {
87 pluginsReducers[reducerName] = pluginReducers[reducerName];
88 });
89
90 try {
91 merge(translationMessages, pluginTradsPrefixed);
92 pluginsToLoad.push(plugin);
93 } catch (err) {
94 console.log({ err });
95 }
96});
97
98const initialState = {};
99const store = configureStore(initialState, pluginsReducers, strapi);
100const { dispatch } = store;
101
102// Load plugins, this will be removed in the v4, temporary fix until the plugin API
103// https://plugin-api-rfc.vercel.app/plugin-api/admin.html
104pluginsToLoad.forEach((plugin) => {
105 const bootPlugin = plugin.boot;
106
107 if (bootPlugin) {
108 bootPlugin(strapi);
109 }
110
111 dispatch(pluginLoaded(plugin));
112});
113
114// TODO
115const remoteURL = (() => {
116 // Relative URL (ex: /dashboard)
117 if (REMOTE_URL[0] === '/') {
118 return (window.location.origin + REMOTE_URL).replace(/\/$/, '');
119 }
120
121 return REMOTE_URL.replace(/\/$/, '');
122})();
123
124const displayNotification = (message, status) => {
125 console.warn(
126 // Validate the text
127 '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'
128 );
129 dispatch(showNotification(message, status));
130};
131const displayNewNotification = (config) => {
132 dispatch(showNewNotification(config));
133};
134const lockApp = (data) => {
135 dispatch(freezeApp(data));
136};
137const unlockApp = () => {
138 dispatch(unfreezeApp());
139};
140
141const lockAppWithOverlay = () => {
142 const overlayblockerParams = {
143 children: <div />,
144 noGradient: true,
145 };
146
147 lockApp(overlayblockerParams);
148};
149
150window.strapi = Object.assign(window.strapi || {}, {
151 node: MODE || 'host',
152 env: NODE_ENV,
153 remoteURL,
154 backendURL: BACKEND_URL === '/' ? window.location.origin : BACKEND_URL,
155 notification: {
156 // New notification api
157 toggle: (config) => {
158 displayNewNotification(config);
159 },
160 success: (message) => {
161 displayNotification(message, 'success');
162 },
163 warning: (message) => {
164 displayNotification(message, 'warning');
165 },
166 error: (message) => {
167 displayNotification(message, 'error');
168 },
169 info: (message) => {
170 displayNotification(message, 'info');
171 },
172 },
173 refresh: (pluginId) => ({
174 translationMessages: (translationMessagesUpdated) => {
175 render(merge({}, translationMessages, translationMessagesUpdated));
176 },
177 leftMenuSections: (leftMenuSectionsUpdated) => {
178 store.dispatch(updatePlugin(pluginId, 'leftMenuSections', leftMenuSectionsUpdated));
179 },
180 }),
181 router: history,
182 languages,
183 currentLanguage:
184 window.localStorage.getItem('strapi-admin-language') ||
185 window.navigator.language ||
186 window.navigator.userLanguage ||
187 'en',
188 lockApp,
189 lockAppWithOverlay,
190 unlockApp,
191 injectReducer,
192 injectSaga,
193 store,
194});
195
196const MOUNT_NODE = document.getElementById('app') || document.createElement('div');
197
198const render = (messages) => {
199 ReactDOM.render(
200 <Provider store={store}>
201 <StrapiProvider strapi={strapi}>
202 <Fonts />
203 <LanguageProvider messages={messages}>
204 <BrowserRouter basename={basename}>
205 <App store={store} />
206 </BrowserRouter>
207 </LanguageProvider>
208 </StrapiProvider>
209 </Provider>,
210 MOUNT_NODE
211 );
212};
213
214if (module.hot) {
215 module.hot.accept(['./i18n', './containers/App'], () => {
216 ReactDOM.unmountComponentAtNode(MOUNT_NODE);
217
218 render(translationMessages);
219 });
220}
221
222if (NODE_ENV !== 'test') {
223 render(translationMessages);
224}
225
226export { dispatch };
227
228if (window.Cypress) {
229 window.__store__ = Object.assign(window.__store__ || {}, { store });
230}
./admin/src/configureStore.js
file with the following code:1/**
2 * Create the store with dynamic reducers
3 */
4
5import { createStore, applyMiddleware, compose } from 'redux';
6import { fromJS } from 'immutable';
7// import { routerMiddleware } from 'react-router-redux';
8import createSagaMiddleware from 'redux-saga';
9import createReducer from './reducers';
10
11const sagaMiddleware = createSagaMiddleware();
12
13export default function configureStore(initialState = {}, reducers, strapi) {
14 // Create the store with two middlewares
15 // 1. sagaMiddleware: Makes redux-sagas work
16 // 2. routerMiddleware: Syncs the location/URL path to the state
17 const middlewares = [sagaMiddleware];
18
19 strapi.middlewares.middlewares.forEach((middleware) => {
20 middlewares.push(middleware());
21 });
22
23 const enhancers = [applyMiddleware(...middlewares)];
24
25 // If Redux DevTools Extension is installed use it, otherwise use Redux compose
26 /* eslint-disable no-underscore-dangle */
27 const composeEnhancers =
28 process.env.NODE_ENV !== 'production' &&
29 typeof window === 'object' &&
30 window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
31 ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
32 // TODO Try to remove when `react-router-redux` is out of beta, LOCATION_CHANGE should not be fired more than once after hot reloading
33 // Prevent recomputing reducers for `replaceReducer`
34 shouldHotReload: false,
35 name: 'Strapi - Dashboard',
36 })
37 : compose;
38 /* eslint-enable */
39
40 const store = createStore(
41 createReducer(reducers),
42 fromJS(initialState),
43 composeEnhancers(...enhancers)
44 );
45
46 // Extensions
47 store.runSaga = sagaMiddleware.run;
48 store.injectedReducers = {}; // Reducer registry
49 store.injectedSagas = {}; // Saga registry
50
51 // Make reducers hot reloadable, see http://mxs.is/googmo
52 /* istanbul ignore next */
53 if (module.hot) {
54 module.hot.accept('./reducers', () => {
55 store.replaceReducer(createReducer(store.injectedReducers));
56 });
57 }
58
59 return store;
60}
Now it's time to work in the guided tour! Following instructions will happen in the ./plugins/guided-tour-admin/src
folder.
cd ./plugins/guided-tour-admin/src
index.js
file with the following code:1import pluginPkg from '../../package.json';
2import pluginId from './pluginId';
3import Tour from './components/Tour';
4import trads from './translations';
5
6export default (strapi) => {
7 const pluginDescription = pluginPkg.strapi.description || pluginPkg.description;
8 const icon = pluginPkg.strapi.icon;
9 const name = pluginPkg.strapi.name;
10
11 const plugin = {
12 description: pluginDescription,
13 icon,
14 id: pluginId,
15 initializer: null,
16 isReady: true,
17 isRequired: false,
18 mainComponent: null,
19 name,
20 preventComponentRendering: false,
21 trads,
22 boot(app) {
23 app.getPlugin('admin').injectComponent('admin', 'onboarding', {
24 name: 'guided-tour',
25 Component: Tour,
26 });
27 },
28 };
29
30 return strapi.registerPlugin(plugin);
31};
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
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 on the homepage, 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 makes sense as you don't want to start from the very beginning from there.
To achieve this, we need first to define how we structure our tour. Easy to do, it will simply be a javascript object organizing steps depending on plugins.
utils/tour.js
with the following:1import React from 'react'
2
3const tour = {
4 'admin': {
5 steps: [{
6 selector: 'a[href="/admin"]',
7 content: () => (
8 <div>
9 <h1>Hi! 👋 </h1><br />
10 <h4>Welcome to the official demo of Strapi: <strong>Foodadvisor!</strong></h4><br />
11 What about following this little guided tour to learn more about our product? Ready? <strong>Let's go!</strong><br /><br />
12 (Use arrow keys to change steps)
13 </div>
14 )
15 },
16 {
17 selector: 'a[href="/admin/plugins/content-type-builder"]',
18 content: () => (
19 <div>
20 <h1>Content Types Builder</h1><br />
21 This is the most important tool, the one that allows you to <strong>create the architecture of your project</strong>.<br /><br />
22 <ul>
23 <li>
24 <strong>Click</strong> on the link to be redirected to the Content Types Builder.
25 </li>
26 </ul>
27 </div>
28 )
29 }],
30 },
31 'content-type-builder': {
32 steps: [
33 {
34 selector: 'a[href="/admin"]',
35 content: () => (
36 <div>
37 <h1>Content Types Builder</h1><br />
38 Welcome to the CTB! This is where you create your <strong>collection types</strong>, <strong>single types</strong> and <strong>components</strong>.<br /><br />
39 Let's see how it's working here!
40 </div>
41 ),
42 },
43 {
44 selector: 'div.col-md-3',
45 content: () => (
46 <div>
47 <h1>Manage your Content Types</h1><br />
48 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 />
49 Let's see one specific collection type!
50 </div>
51 ),
52 },
53 ],
54 }
55};
56
57export default tour;
As you can see, this is a tour
object containing an admin
object which contains steps, and you also have another content-type-builder
object containing steps.
This way, we completely associate steps with 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 do our guided-tour works.
Tour/index.js
file with the following code:1import React, { memo, useEffect, useCallback, useReducer, useMemo } from 'react';
2import reducer, { initialState } from './reducer';
3import { useRouteMatch } from 'react-router-dom';
4import { Button } from '@buffetjs/core';
5import ReactTour from 'reactour';
6import { get } from 'lodash';
7
8const Tour = () => {
9 // Get the current plugin name => pluginId
10 const match = useRouteMatch('/plugins/:pluginId');
11 const pluginId = get(match, ['params', 'pluginId'], 'admin');
12
13 // Use the usereducer hook to manage our state. See reducer.js file
14 const [{ isOpen, tour, actualPlugin, currentStep, totalLength }, dispatch] = useReducer(
15 reducer,
16 initialState
17 );
18
19 // Called when we click on the guided-tour button.
20 // Change the isOpen state variable. See TOGGLE_IS_OPEN action type in reducer.js file.
21 const handleClick = useCallback(() => {
22 dispatch({ type: 'TOGGLE_IS_OPEN', pluginId });
23 }, []);
24
25 // Calculate the steps from the tour. See utils/tour.js file.
26 const steps = useMemo(() => {
27 return Object.values(tour).reduce((acc, current) => {
28 return [...acc, ...current.steps];
29 }, []);
30 }, [tour]);
31
32 // Main logic of the tour.
33 useEffect(() => {
34 let totalSteps = 0;
35 const keys = Object.keys(tour);
36 const previousPlugins = keys.slice(0, keys.indexOf(pluginId));
37 if (previousPlugins.length > 0) {
38 previousPlugins.forEach((plugin, i) => {
39 totalSteps += tour[plugin].steps.length;
40 });
41 }
42 if (tour[pluginId] && pluginId !== actualPlugin)
43 dispatch({ type: 'SETUP', pluginId, totalSteps });
44 }, [tour, pluginId, actualPlugin]);
45
46 const handleNextStep = () => {
47 if (tour[pluginId] && currentStep === totalLength - 1 && totalLength > 0) {
48 return;
49 } else if (tour[pluginId]) {
50 dispatch({ type: 'NEXT_STEP', length: tour[pluginId].steps.length });
51 }
52 };
53
54 return (
55 <>
56 {tour[pluginId] && (
57 <Button
58 onClick={handleClick}
59 color="primary"
60 style={{ right: '70px', bottom: '15px', position: 'fixed', height: '37px' }}
61 >
62 Guided Tour
63 </Button>
64 )}
65 <ReactTour
66 isOpen={tour[pluginId] ? isOpen : false}
67 onRequestClose={handleClick}
68 steps={steps}
69 startAt={currentStep}
70 goToStep={currentStep}
71 nextStep={handleNextStep}
72 prevStep={() => dispatch({ type: 'PREV_STEP' })}
73 showNavigation={false}
74 rounded={2}
75 />
76 </>
77 );
78};
79
80export default memo(Tour);
I agree that's a lot! but don't worry, I will explain everything in this file.
1import React, { memo, useEffect, useCallback, useReducer, useMemo } from 'react';
2import reducer, { initialState } from './reducer';
3import { useRouteMatch } from 'react-router-dom';
4import { Button } from '@buffetjs/core';
5import ReactTour from 'reactour';
6import { get } from 'lodash';
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:
1 const match = useRouteMatch('/plugins/:pluginId');
2 const pluginId = get(match, ['params', 'pluginId'], 'admin');
It will allow us to get the current plugin name in the pluginId
variable.
1const [{ isOpen, tour, actualPlugin, currentStep, totalLength }, dispatch] = useReducer(
2 reducer,
3 initialState
4);
Our state defined by the useReducer
hook.
1const handleClick = useCallback(() => {
2 dispatch({ type: 'TOGGLE_IS_OPEN', pluginId });
3}, []);
This will be called when we'll click on the guided tour button to change the value of the isOpen
state variable.
1const steps = useMemo(() => {
2 return Object.values(tour).reduce((acc, current) => {
3 return [...acc, ...current.steps];
4 }, []);
5}, [tour]);
steps array that will contains the steps depending on our utils/tour.js
file.
1useEffect(() => {
2 let totalSteps = 0;
3 const keys = Object.keys(tour);
4 const previousPlugins = keys.slice(0, keys.indexOf(pluginId));
5 if (previousPlugins.length > 0) {
6 previousPlugins.forEach((plugin, i) => {
7 totalSteps += tour[plugin].steps.length;
8 });
9 }
10 if (tour[pluginId] && pluginId !== actualPlugin)
11 dispatch({ type: 'SETUP', pluginId, totalSteps });
12}, [tour, pluginId, actualPlugin]);
The main logic of the tour. This will define where the tour should start depending on where you start it.
1const handleNextStep = () => {
2 if (tour[pluginId] && currentStep === totalLength - 1 && totalLength > 0) {
3 return;
4 } else if (tour[pluginId]) {
5 dispatch({ type: 'NEXT_STEP', length: tour[pluginId].steps.length });
6 }
7};
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.1return (
2 <>
3 {tour[pluginId] && (
4 <Button
5 onClick={handleClick}
6 color="primary"
7 style={{ right: '70px', bottom: '15px', position: 'fixed', height: '37px' }}
8 >
9 Guided Tour
10 </Button>
11 )}
12 <ReactTour
13 isOpen={tour[pluginId] ? isOpen : false}
14 onRequestClose={handleClick}
15 steps={steps}
16 startAt={currentStep}
17 goToStep={currentStep}
18 nextStep={handleNextStep}
19 prevStep={() => dispatch({ type: 'PREV_STEP' })}
20 showNavigation={false}
21 rounded={2}
22 />
23 </>
24);
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.
utils/reducer.js
file with the following code:1import produce from 'immer';
2import { isEmpty, pick } from 'lodash';
3import tour from './utils/tour';
4
5const initialState = {
6 tour,
7 isOpen: true,
8 totalLength: 0,
9 currentStep: 0,
10 actualPlugin: null
11};
12
13const reducer = (state, action) =>
14 produce(state, draftState => {
15 switch (action.type) {
16 case 'TOGGLE_IS_OPEN': {
17 draftState.isOpen = !state.isOpen;
18 draftState.currentStep = state.currentStep;
19 break;
20 }
21 case 'SETUP': {
22 draftState.currentStep = action.totalSteps;
23 draftState.actualPlugin = action.pluginId;
24 draftState.totalLength = action.totalSteps + tour[action.pluginId].steps.length
25 break;
26 }
27 case 'PREV_STEP': {
28 draftState.currentStep = state.currentStep > 0 ? state.currentStep - 1 : state.currentStep;
29 break;
30 }
31 case 'NEXT_STEP': {
32 draftState.currentStep =
33 state.currentStep < state.totalLength - 1 ? state.currentStep + 1 : state.currentStep;
34 break;
35 }
36 default:
37 return draftState;
38 }
39 });
40
41export default reducer;
42export { initialState };
We define here our initial state and we create behaviours depending on which action type we received:
1case 'TOGGLE_IS_OPEN': {
2 draftState.isOpen = !state.isOpen;
3 draftState.currentStep = state.currentStep;
4 break;
5}
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.
1case 'SETUP': {
2 draftState.currentStep = action.totalSteps;
3 draftState.actualPlugin = action.pluginId;
4 draftState.totalLength = action.totalSteps + tour[action.pluginId].steps.length
5 break;
6}
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.
1case 'PREV_STEP': {
2 draftState.currentStep = state.currentStep > 0 ? state.currentStep - 1 : state.currentStep;
3 break;
4}
5case 'NEXT_STEP': {
6 draftState.currentStep =
7 state.currentStep < state.totalLength - 1 ? state.currentStep + 1 : state.currentStep;
8 break;
9}
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
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.
Get started with Strapi by creating a project using a starter or trying our live demo. Also, consult our forum if you have any questions. We will be there to help you.
See you soon!Maxime started to code in 2015 and quickly joined the Growth team of Strapi. He particularly likes to create useful content for the awesome Strapi community. Send him a meme on Twitter to make his day: @MaxCastres