Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi-app
my-project
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
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
*
* PrivateRoute
* Higher Order Component that blocks navigation when the user is not logged in
* and redirect the user to the 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);
./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:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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);
./admin/src/utils/Strapi.js
file with the following code:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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();
};
./admin/src/app.js
file with the following code:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
/* 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 the 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 });
}
./admin/src/configureStore.js
file with the following code:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
* 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;
}
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:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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);
};
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:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
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;
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:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
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);
I agree that's a lot! but don't worry, I will explain everything in this file.
1
2
3
4
5
6
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';
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
2
const match = useRouteMatch('/plugins/:pluginId');
const pluginId = get(match, ['params', 'pluginId'], 'admin');
It will allow us to get the current plugin name in the pluginId
variable.
1
2
3
4
const [{ isOpen, tour, actualPlugin, currentStep, totalLength }, dispatch] = useReducer(
reducer,
initialState
);
Our state defined by the useReducer
hook.
1
2
3
const handleClick = useCallback(() => {
dispatch({ type: 'TOGGLE_IS_OPEN', pluginId });
}, []);
This will be called when we'll click on the guided tour button to change the value of the isOpen
state variable.
1
2
3
4
5
const steps = useMemo(() => {
return Object.values(tour).reduce((acc, current) => {
return [...acc, ...current.steps];
}, []);
}, [tour]);
steps array that will contains the steps depending on our utils/tour.js
file.
1
2
3
4
5
6
7
8
9
10
11
12
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]);
The main logic of the tour. This will define where the tour should start depending on where you start it.
1
2
3
4
5
6
7
const handleNextStep = () => {
if (tour[pluginId] && currentStep === totalLength - 1 && totalLength > 0) {
return;
} else if (tour[pluginId]) {
dispatch({ type: 'NEXT_STEP', length: tour[pluginId].steps.length });
}
};
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.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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}
/>
</>
);
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:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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 };
We define here our initial state and we create behaviours depending on which action type we received:
1
2
3
4
5
case 'TOGGLE_IS_OPEN': {
draftState.isOpen = !state.isOpen;
draftState.currentStep = state.currentStep;
break;
}
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.
1
2
3
4
5
6
case 'SETUP': {
draftState.currentStep = action.totalSteps;
draftState.actualPlugin = action.pluginId;
draftState.totalLength = action.totalSteps + tour[action.pluginId].steps.length
break;
}
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.
1
2
3
4
5
6
7
8
9
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;
}
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