19 KiB
Plugin advanced usage
This section contains advanced resources to develop plugins.
Handle user navigation
User navigation within your plugin can be managed by two different ways :
- Using the React Router V4 API
- Using the main router from the app
Using React Router
Link provides declarative, accessible navigation around your application :
<Link to={{
pathname: `/plugins/my-plugin/foo/${this.props.bar}`,
search: '?foo=bar',
hash: '#the-hash',
}} />
// Same as
<Link to=`/plugins/my-plugin/foo/${this.props.bar}?foo=bar#the-hash` />
NavLink will add styling attributes to the rendered element when it matches the current URL.
<NavLink
to="/faq"
activeClassName="selected"
>
FAQs
</NavLink>
Using the App Router
We use the app router if we want to make a redirection after some user actions (ex: after submitting a form).
Path — ./plugins/my-plugin/admin/src/containers/FooPage/index.js
.
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect, compose } from 'react-redux';
import PropTypes from 'prop-types';
// App router
import { router } from 'app';
// Components
import Input from 'components/inputs';
import Button from 'components/button';
// Utils
import injectSaga from 'utils/injectSaga';
import injectReducer from 'utils/injectReducer';
// Actions
import { changeInput, submitForm } from './actions';
// Sagas
import saga from './sagas';
// Selectors
import selectFooPage from './selectors';
// Reducer
import reducer from './reducer';
export class FooPage extends React.Component {
handleSubmit = () => {
this.props.handleSubmit();
const hash = this.props.location.hash;
const pathname = this.props.match.pathname;
const search = '?foo=bar';
router.push({ pathname, search, hash });
}
render() {
return (
<div>
<form onSubmit={this.handleSubmit}>
<Input
value={this.state.value}
handleChange={this.props.changeInput}
validations={{ required: true }}
label="Text field"
target="data"
type="text"
/>
<Button primary onClick={this.handleSubmit}>Submit form</Button>
</form>
</div>
)
}
}
FooPage.propTypes = {
changeInput: PropTypes.func.isRequired,
location: PropTypes.object.isRequired,
match: PropTypes.object.isRequired,
submitForm: PropTypes.func.isRequired,
};
const mapStateToProps = selectFooPage();
function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
changeInput,
submitForm,
},
dispatch
);
}
const withConnect = connect(mapStateToProps, mapDispatchToProps);
const withReducer = injectReducer({ key: 'fooPage', reducer });
const withSagas = injectSaga({ key: 'fooPage', saga });
export default compose(
withReducer,
withSagas,
withConnect,
)(FooPage);
Inject design
The ExtendComponent
allows you to inject design from one plugin into another.
Example
Let's say that you want to enable another plugin to inject a component into the top area of your plugin's container called FooPage
;
Path — ./plugins/my-plugin/admin/src/containers/FooPage/actions.js
.
import {
ON_TOGGLE_SHOW_LOREM,
} from './constants';
export function onToggleShowLorem() {
return {
type: ON_TOGGLE_SHOW_LOREM,
};
}
Path — ./plugins/my-plugin/admin/src/containers/FooPage/index.js
.
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, compose } from 'redux';
import { createStructuredSelector } from 'reselect';
import PropTypes from 'prop-types';
// Import the ExtendComponent
import ExtendComponent from 'components/ExtendComponent';
// Utils
import injectReducer from 'utils/injectReducer';
// Actions
import { onToggleShowLorem } from './action'
import reducer from './reducer';
// Selectors
import { makeSelectShowLorem } from './selectors';
class FooPage extends React.Component {
render() {
const lorem = this.props.showLorem ? <p>Lorem ipsum dolor sit amet, consectetur adipiscing</p> : '';
return (
<div>
<h1>This is FooPage container</h1>
<ExtendComponent
area="top"
container="FooPage"
plugin="my-plugin"
{...props}
/>
{lorem}
</div>
);
}
}
FooPage.propTypes = {
onToggleShowLorem: PropTypes.func.isRequired,
showLorem: PropTypes.bool.isRequired,
};
function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
onToggleShowLorem,
},
dispatch,
);
}
const mapStateToProps = createStructuredSelector({
showLorem: makeSelectShowLorem(),
});
const withConnect = connect(mapDispatchToProps, mapDispatchToProps);
const withReducer = injectReducer({ key: 'fooPage', reducer });
export default compose(
withReducer,
withConnect,
)(FooPage);
Path — ./plugins/my-plugin/admin/src/containers/FooPage/reducer.js
.
import { fromJS } from 'immutable';
import { ON_TOGGLE_SHOW_LOREM } from './constants';
const initialState = fromJS({
showLorem: false,
});
function fooPageReducer(state= initialState, action) {
switch (action.type) {
case ON_TOGGLE_SHOW_LOREM:
return state.set('showLorem', !state.get('showLorem'));
default:
return state;
}
}
export default fooPageReducer;
Path — ./plugins/my-plugin/admin/src/containers/FooPage/selectors.js
.
import { createSelector } from 'reselect';
/**
* Direct selector to the fooPage state domain
*/
const selectFooPageDomain = () => state => state.get('fooPage');
/**
* Other specific selectors
*/
const makeSelectShowLorem = () => createSelector(
selectFooPageDomain(),
(substate) => substate.get('showLorem'),
);
export { makeSelectShowLorem };
That's all now your plugin's container is injectable!
Let's see how to inject some design from another plugin.
Create your injectedComponent
Path - ./plugins/another-plugin/admin/src/extendables/BarContainer/index.js
;
import React from 'react';
import PropTypes from 'prop-types';
// Import our Button component
import Button from 'components/Button';
// Other imports such as actions, selectors, sagas, reducer...
class BarContainer extends React.Component {
render() {
return (
<div>
<Button primary onClick={this.props.onToggleShowLorem}>
Click me to show lorem paragraph
</Button>
</div>
);
}
}
BarContainer.propTypes = {
onToggleShowLorem: PropTypes.func,
};
BarContainer.defaultProps = {
onToggleShowLorem: () => {},
};
export default BarContainer;
Tell the admin that you want to inject some design into another plugin
You have to create a file called injectedComponents.js
at the root of your another-plugin
src folder.
Path — ./plugins/another-plugin/admin/src/injectedComponents.js
.
import BarContainer from 'extendables/BarContainer';
// export an array containing all the injected components
export default [
{
area: 'top',
container: 'FooPage',
injectedComponent: BarContainer,
plugin: 'my-plugin',
},
];
Just by doing so, the another-plugin
will add a Button
which toggles the lorem
paragraph in the FooPage
view.
Routeless container store injection
See the basic container's store injection documentation.
If you have a container which can be a child of several other containers (i.e. it doesn't have a route); you'll have to inject it directly in the ./plugins/my-plugin/admin/src/containers/App/index.js
file as follows :
Path — ./plugins/my-plugin/admin/src/containers/App/index.js
.
// ...
import fooReducer from 'containers/Foo/reducer';
import fooSaga from 'container/Foo/sagas';
import saga from './sagas';
import { makeSelectFoo } from './selectors';
// ...
export class App extends React.Component {
render() {
return (
<div className={styles.app}>
<Switch>
{*/ List of all your routes here */}
</Switch>
</div>
);
}
}
// ...
function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
},
dispatch
);
}
const mapStateToProps = createStructuredSelector({
// ...
});
const withConnect = connect(mapStateToProps, mapDispatchToProps);
// Foo reducer
const withFooReducer = injectReducer({ key: 'foo', reducer: fooReducer });
// Global reducer
const withReducer = injectReducer({ key: 'global', reducer });
// Foo saga
const withFooSaga = injectSaga({ key: 'foo', saga: fooSaga });
// Global saga
const withSaga = injectSaga({ key: 'global', saga });
export default compose(
withFooReducer,
withReducer,
withFooSaga,
withSaga,
withConnect,
)(App);
Execute a logic before mounting
You can execute a business logic before your plugin is being mounted.
Usage
To do this, you need to create bootstrap.js
file at the root of your src
plugin's folder.
This file must contains a default functions that returns a Promise
.
Example
In this example, we want to populate the left menu with links that will refer to our Content Types.
Path — ./app/plugins/content-manager/admin/src/bootstrap.js
.
import { generateMenu } from 'containers/App/sagas';
// This method is executed before the load of the plugin
const bootstrap = (plugin) => new Promise((resolve, reject) => {
generateMenu()
.then(menu => {
plugin.leftMenuSections = menu;
resolve(plugin);
})
.catch(e => reject(e));
});
export default bootstrap;
Prevent rendering
You can prevent your plugin from being rendered if some conditions aren't met.
Usage
To disable your plugin's rendering, you can simply create requirements.js
file at the root of your src
plugin's folder.
This file must contain a default function that returns a Promise
.
Example
Let's say that you want to disable your plugin if the server autoReload config is disabled in development mode.
Path — ./app/config/environments/development/server.json
.
{
"host": "localhost",
"port": 1337,
"autoReload": {
"enabled": true
},
"cron": {
"enabled": false
}
}
You'll first create a request to check if the autoReload
config is enabled.
Path — ./app/plugins/my-plugin/config/routes.json
.
{
"routes": [
{
"method": "GET",
"path": "/autoReload",
"handler": "MyPlugin.autoReload",
"config": {
"policies": []
}
}
]
}
Then the associated handler:
Path — ./app/plugins/my-plugin/controllers/MyPlugin.js
.
const _ = require('lodash');
const send = require('koa-send');
module.exports = {
autoReload: async ctx => {
ctx.send({ autoReload: _.get(strapi.config.environments, 'development.server.autoReload', false) });
}
}
Finally, you'll create a file called requirements.js
at the root of your plugin's src folder.
The default function exported must return a Promise
.
If you wan't to prevent the plugin from being rendered you'll have to set plugin.preventComponentRendering = true;
.
In this case, you'll have to set:
plugin.blockerComponentProps = {
blockerComponentTitle: 'my-plugin.blocker.title',
blockerComponentDescription: 'my-plugin.blocker.description',
blockerComponentIcon: 'fa-refresh',
};
To follow the example above:
Path — ./app/plugins/my-plugin/admin/src/requirements.js
.
// Use our request helper
import request from 'utils/request';
const shouldRenderCompo = (plugin) => new Promise((resolve, request) => {
request('/my-plugin/autoReload')
.then(response => {
// If autoReload is enabled the response is `{ autoReload: true }`
plugin.preventComponentRendering = !response.autoReload;
// Set the BlockerComponent props
plugin.blockerComponentProps = {
blockerComponentTitle: 'my-plugin.blocker.title',
blockerComponentDescription: 'my-plugin.blocker.description',
blockerComponentIcon: 'fa-refresh',
blockerComponentContent: 'renderIde', // renderIde will add an ide section that shows the development environment server.json config
};
return resolve(plugin);
})
.catch(err => reject(err));
});
export default shouldRenderCompo;
Customization
You can render your own custom blocker by doing as follows:
Path — ./app/plugins/my-plugin/admin/src/requirements.js
.
// Use our request helper
import request from 'utils/request';
// Your custom blockerComponentProps
import MyCustomBlockerComponent from 'components/MyCustomBlockerComponent';
const shouldRenderCompo = (plugin) => new Promise((resolve, request) => {
request('/my-plugin/autoReload')
.then(response => {
// If autoReload is enabled the response is `{ autoReload: true }`
plugin.preventComponentRendering = !response.autoReload;
// Tell which component to be rendered instead
plugin.blockerComponent = MyCustomBlockerComponent;
return resolve(plugin);
})
.catch(err => reject(err));
});
export default shouldRenderCompo;
Using React/Redux and sagas
If your application is going to interact with some back-end application for data, we recommend using redux saga for side effect management. This short tutorial will show how to fetch data using actions/reducer/sagas.
Constants declaration
Path — ./plugins/my-plugin/admin/src/containers/FooPage/constants.js
export const DATA_FETCH = 'MyPlugin/FooPage/DATA_FETCH';
export const DATA_FETCH_ERROR = 'MyPlugin/FooPage/DATA_FETCH_ERROR';
export const DATA_FETCH_SUCCEEDED = 'MyPlugin/FooPage/DATA_FETCH_SUCCEEDED';
Actions declaration
Path — ./plugins/my-plugin/admin/src/containers/FooPage/actions.js
import {
DATA_FETCH,
DATA_FETCH_ERROR,
DATA_FETCH_SUCCEEDED,
} from './constants';
export function dataFetch(params) {
return {
type: DATA_FETCH,
params,
};
}
export function dataFetchError(errorMessage) {
return {
type: DATA_FETCH_ERROR,
errorMessage,
};
}
export function dataFetchSucceeded(data) {
return {
type: DATA_FETCH_SUCCEEDED,
data,
};
}
Reducer
We strongly recommend to use Immutable.js to structure your data.
Path — ./plugins/my-plugin/admin/src/containers/FooPage/reducer.js
import { fromJS, Map } from 'immutable';
import {
DATA_FETCH_ERROR,
DATA_FETCH_SUCCEEDED,
} from './constants';
const initialState = fromJS({
data: Map({}),
error: false,
errorMessage: '',
loading: true,
});
function fooPageReducer(state = initialState, action) {
switch (action.type) {
case DATA_FETCH_ERROR:
return state
.set('error', true)
.set('errorMessage', action.errorMessage)
.set('loading', false);
case DATA_FETCH_SUCCEEDED:
return state
.set('data', Map(action.data))
.set('error', false)
.set('errorMessage', '')
.set('loading', false);
default:
return state;
}
}
export default fooPageReducer;
Sagas
Path — ./plugins/my-plugin/admin/src/containers/FooPage/sagas.js
import { LOCATION_CHANGE } from 'react-router-redux';
import { takeLatest, put, fork, call, take, cancel } from 'redux-saga/effects';
// Use our request helper
import { request } from 'utils/request';
// Actions
import { dataFetchError, dataFetchSucceeded } from './actions';
import { DATA_FETCH } from './constants';
export function* fetchData(action) {
try {
const requestUrl = `/baseUrl/${action.params}`;
const opts = {
method: 'GET',
};
// Fetch data
const response = yield call(request, requestUrl, opts);
// Pass the response to the reducer
yield put(dataFetchSucceeded(response));
} catch(error) {
yield put(dataFetchError(error));
}
}
// Individual export for testing
function* defaultSaga() {
// Listen to DATA_FETCH event
const fetchDataWatcher = yield fork(takeLatest, DATA_FETCH, fetchData);
// Cancel watcher
yield take(LOCATION_CHANGE);
yield cancel(fetchDataWatcher);
}
export default defaultSaga;
N.B. You can use a selector in your sagas :
import { put, select, fork, call, take, cancel } from 'redux-saga/effects';
import { makeSelectUserName } from './selectors';
export function* foo() {
try {
const userName = yield select(makeSelectUserName());
// ...
} catch(error) {
// ...
}
}
function defaultSaga() {
// ...
}
export default defaultSaga;
Selectors
Reselect is a library used for slicing your redux state and providing only the relevant sub-tree to a react component. It has three key features:
- Computational power
- Memoization
- Composability
Creating a selector:
Path — ./plugins/my-plugin/admin/src/containers/FooPage/selectors.js
import { createSelector } from 'reselect';
/**
* Direct selector to the fooPage state domain
*/
const selectFooPageDomain = () => state => state.get('fooPage');
/**
* Other specific selectors
*/
const makeSelectLoading = () => createSelector(
selectFooPageDomain(),
(substate) => substate.get('loading'),
);
/**
* Default selector used by FooPage
*/
const selectFooPage = () => createSelector(
selectFooDomain(),
(substate) => substate.toJS()
);
export default selectFooPage;
export { makeSelectLoading };
Example
Path — ./plugins/my-plugin/admin/src/containers/FooPage/index.js
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect, compose } from 'react-redux';
import PropTypes from 'prop-types';
// Main router
import { router } from 'app';
// Utils
import injectSaga from 'utils/injectSaga';
import injectReducer from 'utils/injectReducer';
// Actions
import { dataFetch } from './actions';
// sagas
import saga from './sagas';
// Selectors
import selectFooPage from './selectors';
// Reducer
import reducer from './reducer';
export class FooPage extends React.Component {
componentWillReceiveProps(nextProps) {
if (this.props.error !== nextProps.error && nextProps.error) {
strapi.notification.error(nextProps.errorMessage);
}
}
componentDidUpdate(prevProps) {
if (prevProps.match.pathname !== this.props.pathname) {
this.props.dataFetch(this.props.match.params.bar);
}
}
render() {
if (this.props.error) return <div>An error occurred</div>;
return (
<div>
<h4>Data display</h4>
<span>{this.props.data.foo}</span>
<span>{this.props.data.bar}</span>
</div>
);
}
FooPage.propTypes = {
data: PropTypes.object.isRequired,
dataFetch: PropTypes.func.isRequired,
error: PropTypes.bool.isRequired,
errorMessage: PropTypes.string.isRequired,
match: PropTypes.object.isRequired,
};
const mapStateToProps = selectFoo();
function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
dataFetch,
},
dispatch
);
}
const withConnect = connect(mapStateToProps, mapDispatchToProps);
const withReducer = injectReducer({ key: 'fooPage', reducer });
const withSagas = injectSaga({ key: 'fooPage', saga });
export default compose(
withReducer,
withSagas,
withConnect,
)(FooPage);
}