diff --git a/packages/strapi-admin/files/admin/src/app.js b/packages/strapi-admin/files/admin/src/app.js index 9027ef4050..061fd53910 100644 --- a/packages/strapi-admin/files/admin/src/app.js +++ b/packages/strapi-admin/files/admin/src/app.js @@ -10,58 +10,32 @@ import 'babel-polyfill'; import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; -import { applyRouterMiddleware, Router, browserHistory } from 'react-router'; -import { syncHistoryWithStore } from 'react-router-redux'; -import useScroll from 'react-router-scroll'; +import { ConnectedRouter } from 'react-router-redux'; +import createHistory from 'history/createBrowserHistory'; import _ from 'lodash'; import 'sanitize.css/sanitize.css'; import LanguageProvider from 'containers/LanguageProvider'; -import NotificationProvider from 'containers/NotificationProvider'; -import { selectLocationState } from 'containers/App/selectors'; + import App from 'containers/App'; import { showNotification } from 'containers/NotificationProvider/actions'; import { pluginLoaded, updatePlugin } from 'containers/App/actions'; -import createRoutes from './routes'; import configureStore from './store'; import { translationMessages, languages } from './i18n'; // Create redux store with history -// this uses the singleton browserHistory provided by react-router -// Optionally, this could be changed to leverage a created history -// e.g. `const browserHistory = useRouterHistory(createBrowserHistory)();` const initialState = {}; -const store = configureStore(initialState, browserHistory); - -// Sync history and store, as the react-router-redux reducer -// is under the non-default key ("routing"), selectLocationState -// must be provided for resolving how to retrieve the "route" in the state -const history = syncHistoryWithStore(browserHistory, store, { - selectLocationState: selectLocationState(), -}); - -// Set up the router, wrapping all Routes in the App component -const rootRoute = { - component: App, - childRoutes: createRoutes(store), -}; +const history = createHistory(); +const store = configureStore(initialState, history); const render = (translatedMessages) => { ReactDOM.render( - - - + + + , document.getElementById('app') @@ -160,7 +134,7 @@ window.Strapi = { store.dispatch(updatePlugin(pluginId, 'leftMenuSections', leftMenuSectionsUpdated)); }, }), - router: browserHistory, + // router: browserHistory, languages, }; diff --git a/packages/strapi-admin/files/admin/src/components/LeftMenuHeader/index.js b/packages/strapi-admin/files/admin/src/components/LeftMenuHeader/index.js index 9acbe43b18..b17b7f6d6f 100644 --- a/packages/strapi-admin/files/admin/src/components/LeftMenuHeader/index.js +++ b/packages/strapi-admin/files/admin/src/components/LeftMenuHeader/index.js @@ -5,7 +5,7 @@ */ import React from 'react'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import styles from './styles.scss'; @@ -13,7 +13,7 @@ class LeftMenuHeader extends React.Component { // eslint-disable-line react/pref render() { return (
- +
diff --git a/packages/strapi-admin/files/admin/src/components/LeftMenuLink/index.js b/packages/strapi-admin/files/admin/src/components/LeftMenuLink/index.js index 8a6fa18c98..33f952067f 100644 --- a/packages/strapi-admin/files/admin/src/components/LeftMenuLink/index.js +++ b/packages/strapi-admin/files/admin/src/components/LeftMenuLink/index.js @@ -7,7 +7,7 @@ import _ from 'lodash'; import React from 'react'; import { FormattedMessage } from 'react-intl'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import styles from './styles.scss'; diff --git a/packages/strapi-admin/files/admin/src/components/LeftMenuLinkContainer/index.js b/packages/strapi-admin/files/admin/src/components/LeftMenuLinkContainer/index.js index 68bcb759dd..cf7e79290f 100644 --- a/packages/strapi-admin/files/admin/src/components/LeftMenuLinkContainer/index.js +++ b/packages/strapi-admin/files/admin/src/components/LeftMenuLinkContainer/index.js @@ -68,17 +68,17 @@ class LeftMenuLinkContainer extends React.Component { // eslint-disable-line rea diff --git a/packages/strapi-admin/files/admin/src/containers/AdminPage/index.js b/packages/strapi-admin/files/admin/src/containers/AdminPage/index.js new file mode 100644 index 0000000000..9d2096c52e --- /dev/null +++ b/packages/strapi-admin/files/admin/src/containers/AdminPage/index.js @@ -0,0 +1,73 @@ +/* + * AdminPage + * + * This is the first thing users see of our AdminPage, at the '/' route + * + * NOTE: while this component should technically be a stateless functional + * component (SFC), hot reloading does not currently support SFCs. If hot + * reloading is not a neccessity for you then you can refactor it and remove + * the linting exception. + */ + +import React from 'react'; +import { connect } from 'react-redux'; +import { createStructuredSelector } from 'reselect'; +import { Switch, Route } from 'react-router-dom'; + +import HomePage from 'containers/HomePage'; +import PluginPage from 'containers/PluginPage'; +import ComingSoonPage from 'containers/ComingSoonPage'; +import LeftMenu from 'containers/LeftMenu'; +import Content from 'containers/Content'; +import NotFoundPage from 'containers/NotFoundPage'; + +import { selectPlugins } from 'containers/App/selectors'; +import { hideNotification } from 'containers/NotificationProvider/actions'; + +import Header from 'components/Header/index'; + +import styles from './syles.scss'; + +export class AdminPage extends React.Component { // eslint-disable-line react/prefer-stateless-function + render() { + return ( +
+ +
+
+ + + + + + + + + + +
+
+ ); + } +} + +AdminPage.contextTypes = { + router: React.PropTypes.object.isRequired, +}; + +AdminPage.propTypes = { + plugins: React.PropTypes.object.isRequired, +}; + +const mapStateToProps = createStructuredSelector({ + plugins: selectPlugins(), +}); + +function mapDispatchToProps(dispatch) { + return { + onHideNotification: (id) => { dispatch(hideNotification(id)); }, + dispatch, + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(AdminPage); diff --git a/packages/strapi-admin/files/admin/src/containers/HomePage/syles.scss b/packages/strapi-admin/files/admin/src/containers/AdminPage/syles.scss similarity index 65% rename from packages/strapi-admin/files/admin/src/containers/HomePage/syles.scss rename to packages/strapi-admin/files/admin/src/containers/AdminPage/syles.scss index f83d2c60e4..8a0e52d152 100644 --- a/packages/strapi-admin/files/admin/src/containers/HomePage/syles.scss +++ b/packages/strapi-admin/files/admin/src/containers/AdminPage/syles.scss @@ -1,10 +1,10 @@ /* Import */ @import '../../styles/variables/variables'; -.homePage { /* stylelint-disable */ +.adminPage { /* stylelint-disable */ display: flex; } -.homePageRightWrapper { +.adminPageRightWrapper { width: calc(100% - #{$left-menu-width}); -} \ No newline at end of file +} diff --git a/packages/strapi-admin/files/admin/src/containers/App/index.js b/packages/strapi-admin/files/admin/src/containers/App/index.js index 547af7f104..ec002821ca 100644 --- a/packages/strapi-admin/files/admin/src/containers/App/index.js +++ b/packages/strapi-admin/files/admin/src/containers/App/index.js @@ -1,6 +1,6 @@ /** * - * App.react.js + * App.js * * This component is the skeleton around the actual pages, and should only * contain code that should be seen on all pages. (e.g. navigation bar) @@ -12,14 +12,13 @@ */ import React from 'react'; -import { connect } from 'react-redux'; -import { createStructuredSelector } from 'reselect'; +import { Switch, Route } from 'react-router-dom'; -import { hideNotification } from 'containers/NotificationProvider/actions'; -import { selectNotifications } from 'containers/NotificationProvider/selectors'; -import NotificationsContainer from 'components/NotificationsContainer'; +import AdminPage from 'containers/AdminPage'; +import NotFoundPage from 'containers/NotFoundPage'; + +import NotificationProvider from 'containers/NotificationProvider'; -import { selectPlugins } from './selectors'; import '../../styles/main.scss'; import styles from './styles.scss'; @@ -27,9 +26,12 @@ export class App extends React.Component { // eslint-disable-line react/prefer-s render() { return (
- +
- {React.Children.toArray(this.props.children)} + + + +
); @@ -40,22 +42,6 @@ App.contextTypes = { router: React.PropTypes.object.isRequired, }; -App.propTypes = { - children: React.PropTypes.node.isRequired, - notifications: React.PropTypes.object.isRequired, - onHideNotification: React.PropTypes.func.isRequired, -}; +App.propTypes = {}; -const mapStateToProps = createStructuredSelector({ - plugins: selectPlugins(), - notifications: selectNotifications(), -}); - -function mapDispatchToProps(dispatch) { - return { - onHideNotification: (id) => { dispatch(hideNotification(id)); }, - dispatch, - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(App); +export default App; diff --git a/packages/strapi-admin/files/admin/src/containers/App/selectors.js b/packages/strapi-admin/files/admin/src/containers/App/selectors.js index 304f5947b7..5a126e322d 100644 --- a/packages/strapi-admin/files/admin/src/containers/App/selectors.js +++ b/packages/strapi-admin/files/admin/src/containers/App/selectors.js @@ -1,22 +1,5 @@ import { createSelector } from 'reselect'; -// selectLocationState expects a plain JS object for the routing state -const selectLocationState = () => { - let prevRoutingState; - let prevRoutingStateJS; - - return (state) => { - const routingState = state.get('route'); // or state.route - - if (!routingState.equals(prevRoutingState)) { - prevRoutingState = routingState; - prevRoutingStateJS = routingState.toJS(); - } - - return prevRoutingStateJS; - }; -}; - /** * Direct selector to the languageToggle state domain */ @@ -28,12 +11,10 @@ const selectApp = () => (state) => state.get('app'); const selectPlugins = () => createSelector( selectApp(), - (languageState) => languageState.get('plugins') + (appState) => appState.get('plugins') ); export { selectApp, selectPlugins, - selectLocationState, }; - diff --git a/packages/strapi-admin/files/admin/src/containers/HomePage/index.js b/packages/strapi-admin/files/admin/src/containers/HomePage/index.js index 7c571f0581..d3fc554480 100644 --- a/packages/strapi-admin/files/admin/src/containers/HomePage/index.js +++ b/packages/strapi-admin/files/admin/src/containers/HomePage/index.js @@ -1,46 +1,29 @@ /* + * * HomePage * - * This is the first thing users see of our App, at the '/' route - * - * NOTE: while this component should technically be a stateless functional - * component (SFC), hot reloading does not currently support SFCs. If hot - * reloading is not a neccessity for you then you can refactor it and remove - * the linting exception. */ import React from 'react'; import { connect } from 'react-redux'; -import { createStructuredSelector } from 'reselect'; -import { selectPlugins } from 'containers/App/selectors'; -import LeftMenu from 'containers/LeftMenu'; -import Header from 'components/Header/index'; -import Content from 'containers/Content'; -import styles from './syles.scss'; +import Helmet from 'react-helmet'; +import { FormattedMessage } from 'react-intl'; +import messages from './messages.json'; +import styles from './styles.scss'; export class HomePage extends React.Component { // eslint-disable-line react/prefer-stateless-function render() { return ( -
- -
-
- - - -
+
+ +

.

); } } -HomePage.propTypes = { - plugins: React.PropTypes.object.isRequired, -}; - -const mapStateToProps = createStructuredSelector({ - plugins: selectPlugins(), -}); function mapDispatchToProps(dispatch) { return { @@ -48,4 +31,4 @@ function mapDispatchToProps(dispatch) { }; } -export default connect(mapStateToProps, mapDispatchToProps)(HomePage); +export default connect(mapDispatchToProps)(HomePage); diff --git a/packages/strapi-admin/files/admin/src/containers/HomePage/messages.json b/packages/strapi-admin/files/admin/src/containers/HomePage/messages.json new file mode 100644 index 0000000000..26d7afc8cf --- /dev/null +++ b/packages/strapi-admin/files/admin/src/containers/HomePage/messages.json @@ -0,0 +1,6 @@ +{ + "welcome": { + "id": "app.components.HomePage.welcome", + "defaultMessage": "Welcome to the home page" + } +} diff --git a/packages/strapi-admin/files/admin/src/containers/HomePage/styles.scss b/packages/strapi-admin/files/admin/src/containers/HomePage/styles.scss new file mode 100644 index 0000000000..5c51919c86 --- /dev/null +++ b/packages/strapi-admin/files/admin/src/containers/HomePage/styles.scss @@ -0,0 +1,3 @@ +.wrapper { + padding: 2.3rem; +} diff --git a/packages/strapi-admin/files/admin/src/containers/NotFoundPage/index.js b/packages/strapi-admin/files/admin/src/containers/NotFoundPage/index.js index 5ce3b88321..107d8da036 100644 --- a/packages/strapi-admin/files/admin/src/containers/NotFoundPage/index.js +++ b/packages/strapi-admin/files/admin/src/containers/NotFoundPage/index.js @@ -11,7 +11,7 @@ import React from 'react'; import { defineMessages, FormattedMessage } from 'react-intl'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import styles from './styles.scss'; import messages from './messages.json'; @@ -28,7 +28,7 @@ export default class NotFound extends React.Component { // eslint-disable-line r

- Back to home page. + Back to home page.
); } diff --git a/packages/strapi-admin/files/admin/src/containers/NotificationProvider/index.js b/packages/strapi-admin/files/admin/src/containers/NotificationProvider/index.js index 431966d57d..093663fdce 100644 --- a/packages/strapi-admin/files/admin/src/containers/NotificationProvider/index.js +++ b/packages/strapi-admin/files/admin/src/containers/NotificationProvider/index.js @@ -6,27 +6,38 @@ import React from 'react'; import { connect } from 'react-redux'; +import { createStructuredSelector } from 'reselect'; + +import NotificationsContainer from 'components/NotificationsContainer'; +import { selectNotifications } from './selectors'; +import { hideNotification } from './actions'; -import selectNotificationProvider from './selectors'; export class NotificationProvider extends React.Component { // eslint-disable-line react/prefer-stateless-function render() { return ( -
- {React.Children.only(this.props.children)} -
+ ); } } NotificationProvider.propTypes = { - children: React.PropTypes.object.isRequired, + notifications: React.PropTypes.object.isRequired, + onHideNotification: React.PropTypes.func.isRequired, }; -const mapStateToProps = selectNotificationProvider(); +const mapStateToProps = createStructuredSelector({ + notifications: selectNotifications(), +}); function mapDispatchToProps(dispatch) { return { + onHideNotification: (id) => { + dispatch(hideNotification(id)); + }, dispatch, }; } diff --git a/packages/strapi-admin/files/admin/src/reducers.js b/packages/strapi-admin/files/admin/src/reducers.js index 22846eca71..af77133771 100644 --- a/packages/strapi-admin/files/admin/src/reducers.js +++ b/packages/strapi-admin/files/admin/src/reducers.js @@ -1,12 +1,12 @@ /** * Combine all reducers in this file and export the combined reducers. - * If we were to do this in store.js, reducers wouldn't be hot reloadable. */ -import { combineReducers } from 'redux-immutable'; import { fromJS } from 'immutable'; +import { combineReducers } from 'redux-immutable'; import { LOCATION_CHANGE } from 'react-router-redux'; -import appReducer from 'containers/App/reducer'; + +import globalReducer from 'containers/App/reducer'; import languageProviderReducer from 'containers/LanguageProvider/reducer'; import notificationProviderReducer from 'containers/NotificationProvider/reducer'; @@ -14,13 +14,13 @@ import notificationProviderReducer from 'containers/NotificationProvider/reducer * routeReducer * * The reducer merges route location changes into our immutable state. - * The change is necessitated by moving to react-router-redux@4 + * The change is necessitated by moving to react-router-redux@5 * */ // Initial routing state const routeInitialState = fromJS({ - locationBeforeTransitions: null, + location: null, }); /** @@ -31,7 +31,7 @@ function routeReducer(state = routeInitialState, action) { /* istanbul ignore next */ case LOCATION_CHANGE: return state.merge({ - locationBeforeTransitions: action.payload, + location: action.payload, }); default: return state; @@ -39,14 +39,14 @@ function routeReducer(state = routeInitialState, action) { } /** - * Creates the main reducer with the asynchronously loaded ones + * Creates the main reducer with the dynamically injected ones */ -export default function createReducer(asyncReducers) { +export default function createReducer(injectedReducers) { return combineReducers({ route: routeReducer, + app: globalReducer, language: languageProviderReducer, notification: notificationProviderReducer, - app: appReducer, - ...asyncReducers, + ...injectedReducers, }); } diff --git a/packages/strapi-admin/files/admin/src/store.js b/packages/strapi-admin/files/admin/src/store.js index 6c8edbb0ef..398699cf7c 100644 --- a/packages/strapi-admin/files/admin/src/store.js +++ b/packages/strapi-admin/files/admin/src/store.js @@ -1,5 +1,5 @@ /** - * Create the store with asynchronously loaded reducers + * Create the store with dynamic reducers */ import { createStore, applyMiddleware, compose } from 'redux'; @@ -9,7 +9,6 @@ import createSagaMiddleware from 'redux-saga'; import createReducer from './reducers'; const sagaMiddleware = createSagaMiddleware(); -const devtools = window.devToolsExtension || (() => noop => noop); export default function configureStore(initialState = {}, history) { // Create the store with two middlewares @@ -22,30 +21,40 @@ export default function configureStore(initialState = {}, history) { const enhancers = [ applyMiddleware(...middlewares), - devtools(), ]; + // 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, + }) + : compose; + /* eslint-enable */ + const store = createStore( createReducer(), fromJS(initialState), - compose(...enhancers) + composeEnhancers(...enhancers) ); - // Create hook for async sagas + // 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) { - System.import('./reducers').then((reducerModule) => { - const createReducers = reducerModule.default; - const nextReducers = createReducers(store.asyncReducers); - - store.replaceReducer(nextReducers); + module.hot.accept('./reducers', () => { + store.replaceReducer(createReducer(store.injectedReducers)); }); } - // Initialize it with no other reducers - store.asyncReducers = {}; return store; } diff --git a/packages/strapi-admin/files/admin/src/utils/asyncInjectors.js b/packages/strapi-admin/files/admin/src/utils/asyncInjectors.js deleted file mode 100644 index 1ff068d16e..0000000000 --- a/packages/strapi-admin/files/admin/src/utils/asyncInjectors.js +++ /dev/null @@ -1,72 +0,0 @@ -import { conformsTo, isEmpty, isFunction, isObject, isString } from 'lodash'; -import invariant from 'invariant'; -import warning from 'warning'; -import createReducer from 'reducers'; - -/** - * Validate the shape of redux store - */ -export function checkStore(store) { - const shape = { - dispatch: isFunction, - subscribe: isFunction, - getState: isFunction, - replaceReducer: isFunction, - runSaga: isFunction, - asyncReducers: isObject, - }; - invariant( - conformsTo(store, shape), - '(app/utils...) asyncInjectors: Expected a valid redux store' - ); -} - -/** - * Inject an asynchronously loaded reducer - */ -export function injectAsyncReducer(store, isValid) { - return function injectReducer(name, asyncReducer) { - if (!isValid) checkStore(store); - - invariant( - isString(name) && !isEmpty(name) && isFunction(asyncReducer), - '(app/utils...) injectAsyncReducer: Expected `asyncReducer` to be a reducer function' - ); - - store.asyncReducers[name] = asyncReducer; // eslint-disable-line no-param-reassign - store.replaceReducer(createReducer(store.asyncReducers)); - }; -} - -/** - * Inject an asynchronously loaded saga - */ -export function injectAsyncSagas(store, isValid) { - return function injectSagas(sagas) { - if (!isValid) checkStore(store); - - invariant( - Array.isArray(sagas), - '(app/utils...) injectAsyncSagas: Expected `sagas` to be an array of generator functions' - ); - - warning( - !isEmpty(sagas), - '(app/utils...) injectAsyncSagas: Received an empty `sagas` array' - ); - - sagas.map(store.runSaga); - }; -} - -/** - * Helper for creating injectors - */ -export function getAsyncInjectors(store) { - checkStore(store); - - return { - injectReducer: injectAsyncReducer(store, true), - injectSagas: injectAsyncSagas(store, true), - }; -} diff --git a/packages/strapi-admin/files/admin/src/utils/checkStore.js b/packages/strapi-admin/files/admin/src/utils/checkStore.js new file mode 100644 index 0000000000..0fd381cdc4 --- /dev/null +++ b/packages/strapi-admin/files/admin/src/utils/checkStore.js @@ -0,0 +1,25 @@ +import conformsTo from 'lodash/conformsTo'; +import isFunction from 'lodash/isFunction'; +import isObject from 'lodash/isObject'; +import invariant from 'invariant'; + +/** + * Validate the shape of redux store + */ +export default function checkStore(store) { + console.log(store); + + const shape = { + dispatch: isFunction, + subscribe: isFunction, + getState: isFunction, + replaceReducer: isFunction, + runSaga: isFunction, + injectedReducers: isObject, + injectedSagas: isObject, + }; + invariant( + conformsTo(store, shape), + '(app/utils...) injectors: Expected a valid redux store' + ); +} diff --git a/packages/strapi-admin/files/admin/src/utils/constants.js b/packages/strapi-admin/files/admin/src/utils/constants.js new file mode 100644 index 0000000000..97ece0f3b3 --- /dev/null +++ b/packages/strapi-admin/files/admin/src/utils/constants.js @@ -0,0 +1,3 @@ +export const RESTART_ON_REMOUNT = '@@saga-injector/restart-on-remount'; +export const DAEMON = '@@saga-injector/daemon'; +export const ONCE_TILL_UNMOUNT = '@@saga-injector/once-till-unmount'; diff --git a/packages/strapi-admin/files/admin/src/utils/injectReducer.js b/packages/strapi-admin/files/admin/src/utils/injectReducer.js new file mode 100644 index 0000000000..1e46dbb3b8 --- /dev/null +++ b/packages/strapi-admin/files/admin/src/utils/injectReducer.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import hoistNonReactStatics from 'hoist-non-react-statics'; + +import getInjectors from './reducerInjectors'; + +/** + * Dynamically injects a reducer + * + * @param {string} key A key of the reducer + * @param {function} reducer A reducer that will be injected + * + */ +export default ({ key, reducer }) => (WrappedComponent) => { + class ReducerInjector extends React.Component { + static WrappedComponent = WrappedComponent; + static contextTypes = { + store: PropTypes.object.isRequired, + }; + static displayName = `withReducer(${(WrappedComponent.displayName || WrappedComponent.name || 'Component')})`; + + componentWillMount() { + const { injectReducer } = this.injectors; + + injectReducer(key, reducer); + } + + injectors = getInjectors(this.context.store); + + render() { + return ; + } + } + + return hoistNonReactStatics(ReducerInjector, WrappedComponent); +}; diff --git a/packages/strapi-admin/files/admin/src/utils/injectSaga.js b/packages/strapi-admin/files/admin/src/utils/injectSaga.js new file mode 100644 index 0000000000..1679cf9a99 --- /dev/null +++ b/packages/strapi-admin/files/admin/src/utils/injectSaga.js @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import hoistNonReactStatics from 'hoist-non-react-statics'; + +import getInjectors from './sagaInjectors'; + +/** + * Dynamically injects a saga, passes component's props as saga arguments + * + * @param {string} key A key of the saga + * @param {function} saga A root saga that will be injected + * @param {string} [mode] By default (constants.RESTART_ON_REMOUNT) the saga will be started on component mount and + * cancelled with `task.cancel()` on component un-mount for improved performance. Another two options: + * - constants.DAEMON—starts the saga on component mount and never cancels it or starts again, + * - constants.ONCE_TILL_UNMOUNT—behaves like 'RESTART_ON_REMOUNT' but never runs it again. + * + */ +export default ({ key, saga, mode }) => (WrappedComponent) => { + class InjectSaga extends React.Component { + static WrappedComponent = WrappedComponent; + static contextTypes = { + store: PropTypes.object.isRequired, + }; + static displayName = `withSaga(${(WrappedComponent.displayName || WrappedComponent.name || 'Component')})`; + + componentWillMount() { + const { injectSaga } = this.injectors; + + injectSaga(key, { saga, mode }, this.props); + } + + componentWillUnmount() { + const { ejectSaga } = this.injectors; + + ejectSaga(key); + } + + injectors = getInjectors(this.context.store); + + render() { + return ; + } + } + + return hoistNonReactStatics(InjectSaga, WrappedComponent); +}; diff --git a/packages/strapi-admin/files/admin/src/utils/reducerInjectors.js b/packages/strapi-admin/files/admin/src/utils/reducerInjectors.js new file mode 100644 index 0000000000..2eba578790 --- /dev/null +++ b/packages/strapi-admin/files/admin/src/utils/reducerInjectors.js @@ -0,0 +1,32 @@ +import invariant from 'invariant'; +import isEmpty from 'lodash/isEmpty'; +import isFunction from 'lodash/isFunction'; +import isString from 'lodash/isString'; + +import createReducer from '../reducers'; +import checkStore from './checkStore'; + +export function injectReducerFactory(store, isValid) { + return function injectReducer(key, reducer) { + if (!isValid) checkStore(store); + + invariant( + isString(key) && !isEmpty(key) && isFunction(reducer), + '(app/utils...) injectReducer: Expected `reducer` to be a reducer function' + ); + + // Check `store.injectedReducers[key] === reducer` for hot reloading when a key is the same but a reducer is different + if (Reflect.has(store.injectedReducers, key) && store.injectedReducers[key] === reducer) return; + + store.injectedReducers[key] = reducer; // eslint-disable-line no-param-reassign + store.replaceReducer(createReducer(store.injectedReducers)); + }; +} + +export default function getInjectors(store) { + checkStore(store); + + return { + injectReducer: injectReducerFactory(store, true), + }; +} diff --git a/packages/strapi-admin/files/admin/src/utils/request.js b/packages/strapi-admin/files/admin/src/utils/request.js new file mode 100644 index 0000000000..52cdf2b4d3 --- /dev/null +++ b/packages/strapi-admin/files/admin/src/utils/request.js @@ -0,0 +1,46 @@ +import 'whatwg-fetch'; + +/** + * Parses the JSON returned by a network request + * + * @param {object} response A response from a network request + * + * @return {object} The parsed JSON from the request + */ +function parseJSON(response) { + if (response.status === 204 || response.status === 205) { + return null; + } + return response.json(); +} + +/** + * Checks if a network request came back fine, and throws an error if not + * + * @param {object} response A response from a network request + * + * @return {object|undefined} Returns either the response, or throws an error + */ +function checkStatus(response) { + if (response.status >= 200 && response.status < 300) { + return response; + } + + const error = new Error(response.statusText); + error.response = response; + throw error; +} + +/** + * Requests a URL, returning a promise + * + * @param {string} url The URL we want to request + * @param {object} [options] The options we want to pass to "fetch" + * + * @return {object} The response data + */ +export default function request(url, options) { + return fetch(url, options) + .then(checkStatus) + .then(parseJSON); +} diff --git a/packages/strapi-admin/files/admin/src/utils/sagaInjectors.js b/packages/strapi-admin/files/admin/src/utils/sagaInjectors.js new file mode 100644 index 0000000000..f7d524d539 --- /dev/null +++ b/packages/strapi-admin/files/admin/src/utils/sagaInjectors.js @@ -0,0 +1,86 @@ +import isEmpty from 'lodash/isEmpty'; +import isFunction from 'lodash/isFunction'; +import isString from 'lodash/isString'; +import invariant from 'invariant'; +import conformsTo from 'lodash/conformsTo'; + +import checkStore from './checkStore'; +import { + DAEMON, + ONCE_TILL_UNMOUNT, + RESTART_ON_REMOUNT, +} from './constants'; + +const allowedModes = [RESTART_ON_REMOUNT, DAEMON, ONCE_TILL_UNMOUNT]; + +const checkKey = (key) => invariant( + isString(key) && !isEmpty(key), + '(app/utils...) injectSaga: Expected `key` to be a non empty string' +); + +const checkDescriptor = (descriptor) => { + const shape = { + saga: isFunction, + mode: (mode) => isString(mode) && allowedModes.includes(mode), + }; + invariant( + conformsTo(descriptor, shape), + '(app/utils...) injectSaga: Expected a valid saga descriptor' + ); +}; + +export function injectSagaFactory(store, isValid) { + return function injectSaga(key, descriptor = {}, args) { + if (!isValid) checkStore(store); + + const newDescriptor = { ...descriptor, mode: descriptor.mode || RESTART_ON_REMOUNT }; + const { saga, mode } = newDescriptor; + + checkKey(key); + checkDescriptor(newDescriptor); + + let hasSaga = Reflect.has(store.injectedSagas, key); + + if (process.env.NODE_ENV !== 'production') { + const oldDescriptor = store.injectedSagas[key]; + // enable hot reloading of daemon and once-till-unmount sagas + if (hasSaga && oldDescriptor.saga !== saga) { + oldDescriptor.task.cancel(); + hasSaga = false; + } + } + + if (!hasSaga || (hasSaga && mode !== DAEMON && mode !== ONCE_TILL_UNMOUNT)) { + store.injectedSagas[key] = { ...newDescriptor, task: store.runSaga(saga, args) }; // eslint-disable-line no-param-reassign + } + }; +} + +export function ejectSagaFactory(store, isValid) { + return function ejectSaga(key) { + if (!isValid) checkStore(store); + + checkKey(key); + + if (Reflect.has(store.injectedSagas, key)) { + const descriptor = store.injectedSagas[key]; + if (descriptor.mode !== DAEMON) { + descriptor.task.cancel(); + // Clean up in production; in development we need `descriptor.saga` for hot reloading + if (process.env.NODE_ENV === 'production') { + // Need some value to be able to detect `ONCE_TILL_UNMOUNT` sagas in `injectSaga` + store.injectedSagas[key] = 'done'; // eslint-disable-line no-param-reassign + } + } + } + }; +} + +export default function getInjectors(store) { + checkStore(store); + + return { + injectSaga: injectSagaFactory(store, true), + ejectSaga: ejectSagaFactory(store, true), + }; +} diff --git a/packages/strapi-helper-plugin/package.json b/packages/strapi-helper-plugin/package.json index 30c5697ecc..242910d459 100644 --- a/packages/strapi-helper-plugin/package.json +++ b/packages/strapi-helper-plugin/package.json @@ -66,7 +66,7 @@ "express": "^4.15.4", "extract-text-webpack-plugin": "^3.0.0", "file-loader": "^0.11.2", - "history": "3.0.0", + "history": "^4.6.3", "html-loader": "^0.5.1", "html-webpack-plugin": "^2.30.1", "image-webpack-loader": "^3.3.1", @@ -92,9 +92,8 @@ "react-helmet": "^5.1.3", "react-intl": "^2.3.0", "react-redux": "^5.0.6", - "react-router": "2.6.1", - "react-router-redux": "4.0.5", - "react-router-scroll": "0.2.1", + "react-router-dom": "^4.1.2", + "react-router-redux": "^5.0.0-alpha.6", "redux": "^3.7.2", "redux-immutable": "^4.0.0", "redux-saga": "^0.15.6", @@ -112,4 +111,4 @@ "devDependencies": { "uglifyjs-webpack-plugin": "^1.0.0-beta.2" } -} +} \ No newline at end of file