import Action from './Action'import _Symbol from './utils/_Symbol'export const __store = _Symbol('store')export const __mountPath = _Symbol('mountPath')export const __mountPathString = _Symbol('mountPathString')const __selectors = _Symbol('selectors')const __selectorsByKey = _Symbol('selectorsByKey')const __actions = _Symbol('actions')const getAllClassMethods = (obj) => { let keys = [] let topObject = obj const onlyOriginalMethods = (p, i, arr) => typeof topObject[p] === 'function' && // only the methods p !== 'constructor' && // not the constructor (i === 0 || p !== arr[i - 1]) && // not overriding in this prototype keys.indexOf(p) === -1 // not overridden in a child do { const l = Object.getOwnPropertyNames(obj) .sort() .filter(onlyOriginalMethods) keys = keys.concat(l) // walk-up the prototype chain obj = Object.getPrototypeOf(obj) } while ( // not the the Object prototype methods (hasOwnProperty, etc...) obj && Object.getPrototypeOf(obj) ) return keys}/** * Base class for controllers. Controllers are responsible for managing the certain part of the * **state**, which includes: * * - *Sending* relevant **actions** in response to certain related events. * - *Modifying* the state by providing the relevant **reducer**. * - *Providing access* and if necessary *transforming* the data in the managed part of the * **state**. * * Controller is intended to be subclassed. It won't work as is, because it doesn't provide * a reducer function ([reducer()]{@link Controller#reducer} returns `undefined`). * * The only function mandatory for overriding is: * * - [reducer()]{@link Controller#reducer} - to provide the reducer function. * * You may also need to override: * * - [afterCreateStore()]{@link Controller#afterCreateStore} - will be called for all Controllers * after Redux store was created and all Controllers were mounted. * - [areStatesEqual()]{@link Controller#areStatesEqual} - to control the optimizations done by * {@link Container}. If you follow Redux recommendation of immutability and your Controller * selector methods only transform the global state (which is also highly recommended), you do * **not need** to override this method. * * In order to utilize the full power of the framework, subclasses define two types of functions: * * - **Selectors** transform the part of the Controller-managed state into different structure. * Any method that starts with `$` symbol is considered to be a selector. * - **Dispatches** are special functions that are expected to dispatch some actions either * synchronously or asynchronously. Any method that starts with `dispatch` is considered to be * a dispatch method. * * @example * class ToDoController extends Controller { * constructor() { * super() * this.createAction('add') * } * * // Dispatch function. Will be mapped as `add(text)` in Container * dispatchAdd(text) { * this.dispatchAction('add', text) * } * * // Selector function. Will be used to collect data for `props.text` in Container * $texts(state) { * return (this.$$(state)._items || []).map((item) => item.text) * } * * reducer() { * const { add } = this.actions * return this.createReducer( * add.on((state, text) => ({ * _items: (state._items || []).concat({ text }) * })) * ) * } * } */class Controller { /** * Constructor is a good place to create all [actions]{@link Action} that Controller needs. * And of course to pass and store any external dependencies and parameters Controller needs * to function. */ constructor () { this[__actions] = { } } /** * Checks if provided value *looks like* an instance of the Controller class. It does pretty * minimal check and should not be relied to actually detect the fact of being Controller's * subclass if needed. * * @param instance Value to be checked. */ static is (value) { // Enough to consider instance to be a Controller for most cases return value && typeof value.reducer === 'function' } /** * Redux store object this controller is mounted to. * @type {Store} */ get store () { return this[__store] } /** * Path under which this controller is mounted as the array of keys. * @type {string[]} */ get mountPath () { return this[__mountPath] } /** * Path under which this controller is mounted as the single string. Path components are joined * with `.` (dot) symbol. * @type {string} */ get mountPathString () { return this[__mountPathString] } /** * Array of [Actions]{@link Action} previously created by * [Controller.createAction()]{@link Controller#createAction} calls. * @type {Object.<string, Action>} */ get actions () { return this[__actions] } /** * Redux store `dispatch()` function. * @type {function} */ get dispatch () { return this[__store] ? this[__store].dispatch : undefined } /** * Create new {@link Action} and attach it to [Controller.actions]{@link Controller#actions}. * Intended to be used from inside the Controller. If provided a string key the {@link Action} * will be created with type equal to `${Controller.mountPathString}/${action}`. * * @param {string|Action} action String to be used as action key in {@link Controller#actions} * and as a part of [Action.baseType]{@link Action#baseType}. Alternatively the ready made * {@link Action} can be specified to be attached to the Controller. * @param {(string)} key If {@link Action} object was passed as first argument, this defines * a key to be used in [Controller.actions]{@link Controller#actions}. * * @example * // Create new action with key "update" and attach it to the Controller * this.createAction("update") * * // Attach existing Action to the Controller using key "load" * this.createAction(loadAction, "load") * * // Later on these actions are available in this.actions: * const { update, load } = this.actions */ createAction (action, key) { if (typeof action === 'string') { const baseType = () => (this[__mountPathString] + '/' + action) this[__actions][action] = new Action(baseType) } else { this[__actions][key || action.type()] = action } } /** * Dispatch the {@link Action} into the store by key and optionally a stage with the provided * payload. This is a shortcut method provided for convenience. Is it intended to be used from * inside the Controller. * * @param {string} actionType Action key string with optional stage (see {@link Action}). * @param payload Any object that should be sent as action payload. * * @example * // Dispatch action with key "update" * dispatchAction("update", { objectId: "1" }) * * // Dispatch action with key "update" and stage "started" * dispatchAction("update.started", { objectId: "1" }) */ dispatchAction (actionType, payload) { // TODO: error processing if action was not found. const dotI = actionType.indexOf('.') const actionBaseType = dotI === -1 ? actionType : actionType.substring(0, dotI) const actionStage = dotI === -1 ? undefined : actionType.substring(dotI + 1) if (actionStage === 'error') { this.store.dispatch(this.actions[actionBaseType].error(payload)) } else { this.store.dispatch(this.actions[actionBaseType].action( actionStage, payload)) } } /** * This a convenience function, which simply calls * [Action.createReducer()]{@link Action#createReducer} passing through all of the arguments. */ createReducer (...args) { return Action.createReducer(...args) } /** * Get the raw part of the stored state, managed by the controller. No selectors * will be called and no dispatches to be added to the result. * * @param {Object} state The root of the state tree managed by the Redux * store. If ommitted, the function will operate on current state of the store. */ $$ (state) { if (arguments.length === 0) { state = this.store.getState() } let innerState = state this[__mountPath].forEach((key) => { innerState = innerState ? innerState[key] : undefined }) return innerState } /** * Select the value at specified path of the stored state. If no path is specified * (any falsey value or `"*"`), the full state of the tree is returned. All the * required selector functions are called in both cases, first level keys in the state that * start with underscore symbol (`_`) are considered "private" and ommitted. * * @param {Object=} state The root of the state tree managed by the Redux * store. If ommitted, the function will operate on current state of the store. * * @param {(string|string[])=} path The path of the sub tree to obtain from * the state, relative to the controller mount path. It should either be a * string of dot separated keys or an array of strings. Falsey value as * well as not specifying this parameter makes the function to return the * full state managed by the controller. * * @returns Value selected from the specified path or `undefined` if nothing found * at the specified path. */ $ (state, path) { let _state, _path if (arguments.length === 1) { // either state or path if (Array.isArray(arguments[0]) || typeof arguments[0] === 'string') { _path = arguments[0] _state = this.store.getState() } else { _state = arguments[0] } } else if (arguments.length > 1) { _state = arguments[0] _path = arguments[1] } else { _state = this.store.getState() } const all = !_path || (_path.length === 0) || _path === '*' if (!this[__selectors]) { // cache selectors this[__selectors] = this .getAllSelectKeys() .map((key) => ({ key: key.substr('$'.length), selector: this[key].bind(this) })) this[__selectorsByKey] = this[__selectors].reduce( (res, s) => Object.assign(res, {[s.key]: s.selector}), {}) } const $$state = this.$$(_state) if (all) { const selectedState = Object.keys($$state) .filter((key) => key[0] !== '_') .reduce((res, key) => Object.assign(res, { [key]: $$state[key] }), {}) this[__selectors] .reduce((res, s) => Object.assign(res, { [s.key]: s.selector(_state) }), selectedState) return selectedState } else { if (typeof _path === 'string') { _path = _path.split('.').filter((el) => el.length > 0) } let selectedState = $$state for (let i = 0; i < _path.length; ++i) { if (i === 0 && this[__selectorsByKey][_path[i]]) { selectedState = this[__selectorsByKey][_path[i]](_state) } else { selectedState = selectedState[_path[i]] } } return selectedState } } /** * Passed as a callback to [Controller.subscribe()]{@link Controller#subscribe}. * @callback Controller~SubscribeListener * @param value Current value at the subscribed path. * @param prevValue Previous value at the subscribed path. */ /** * Subscribes to changes of some value at path relative to the controller. * * @param {string|Array.<string>} * @param {Controller~SubscribeListener} */ subscribe (path, listener, isEqual = (value, prevValue) => (value === prevValue)) { let value = this.$(path) return this.store.subscribe(() => { let prevValue = value value = this.$(path) if (!isEqual(prevValue, value)) { listener(value, prevValue) } }) } /** * Called when Controller reducer is needed for the first time. Override this method and return * the reducer function. Reducer function is executed on the part state where Controller * was mounted. It is recommended to utilize {@link Action} and convenience functions * [Controller.createReducer]{@link Controller#createReducer}, * [Controller.createAction]{@link Controller#createAction} and * [Controller.dispatchAction]{@link Controller#dispatchAction}, but is not mandatory. A regular * Redux reducer function will also work just fine. * * @returns {function} Reducer function. * * @example * reducer() { * const { update } = this.actions * * return this.createReducer( * update.onStarted((state, payload) => ({...state, isUpdating: true })), * update.onSuccess((state, items) => ({...state, items, isUpdating: false })) * ) * } */ reducer () { // to be overriden in children } /** * Executed for all controllers after createStore() was called. * At this point all of the controllers are created and store is initialized. */ afterCreateStore () { // to be overriden in children } /** * Returns array of all dispatch* function names defined in the Controller. * * @private */ getAllDispatchKeys () { return getAllClassMethods(this).filter((key) => /^dispatch./.test(key) && key !== 'dispatchAction' && typeof this[key] === 'function') } /** * Returns array of all $* function names (selectors) defined in the Controller. * * @private */ getAllSelectKeys () { return getAllClassMethods(this) .filter((key) => /^\$[^$]+/.test(key) && typeof this[key] === 'function') } /** * This method is used by [Controller.hasChanges]{@link Controller#hasChanges} by default. * It checks if the state was changed comparing to an old state, so selectors need to be * reevaluated. By default it compares state objects by reference (`===`). This should * be fine if your state is immutable, which is highly recommended. Otherwise * you are responsible for overriding this check according to your needs or * just return false if you want reevaluate all selectors each time the state * tree is updated. * * Its purpose is basically the same as of `options.areStatesEqual` argument * to `connect` function from `react-redux` library. * * If you need to check the parts of the state, not managed by the controller, * override [Controller.hasChanges]{@link Controller#hasChanges} instead. * * @param $$prev Previous value of part of the state managed by the Controller. * @param $$next Next value part of the state managed by the Controller to be compared. */ areStatesEqual ($$prev, $$next) { return $$prev === $$next } /** * This method is used by {@link Container} for optimizations. It checks if the state * was changed comparing to an old state, so selectors need to be reevaluated. * By default it calls [Controller.areStatesEqual]{@link Controller#areStatesEqual} * and returns the opposite boolean value. * * It is useful, if controller selects parts of the state, not managed by itself. * * @param prevState Previous Redux state value. * @param next Next Redux state value. */ hasChanges (prevState, nextState) { return !this.areStatesEqual(this.$$(prevState), this.$$(nextState)) }}export default Controller