From 97818006d2ca7a239ea8bf0bc055b12d350b6515 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Thu, 22 Feb 2018 17:57:56 +0100 Subject: [PATCH] Framework: Support registering and invoking actions in the data module (#5137) * Framework: Support registering and invoking actions in the data module * Introduce onSubcribe HoC as an alternative to query * Store: Register all selectors and action creators * Data: Separate onSubscribe to withData, withDispatch * Store: Ensure editor actions / selectors registered early * Inserter: Convert Inserter to data HOCs * Data: Update withSelect, withDispatch to invoke callback with select, dispatch * Data: Re-run selection on props changes --- data/README.md | 68 +++++++-- data/index.js | 204 +++++++++++++++++++++----- data/test/__snapshots__/index.js.snap | 7 - data/test/index.js | 176 ++++++++++++++++++++-- editor/components/inserter/index.js | 55 +++---- editor/index.js | 3 - editor/store/index.js | 26 ++-- 7 files changed, 418 insertions(+), 121 deletions(-) delete mode 100644 data/test/__snapshots__/index.js.snap diff --git a/data/README.md b/data/README.md index 32522d178e9e6..134483cd61ca5 100644 --- a/data/README.md +++ b/data/README.md @@ -39,6 +39,25 @@ Let's say the state of our plugin (registered with the key `myPlugin`) has the f wp.data.registerSelectors( 'myPlugin', { getTitle: ( state ) => state.title } ); ``` +### `wp.data.registerActions( reducerKey: string, newActions: object )` + +If your module or plugin needs to expose its actions to other modules and plugins, you'll have to register action creators. + +An action creator is a function that takes arguments and returns an action object dispatch to the registered reducer to update the state. + +#### Example: + +```js +wp.data.registerActions( 'myPlugin', { + setTitle( newTitle ) { + return { + type: 'SET_TITLE', + title: newTitle, + }; + }, +} ); +``` + ### `wp.data.select( key: string )` This function allows calling any registered selector. Given a module's key, this function returns an object of all selector functions registered for the module. @@ -49,18 +68,14 @@ This function allows calling any registered selector. Given a module's key, this wp.data.select( 'myPlugin' ).getTitle(); // Returns "My post title" ``` -### `wp.data.query( mapSelectorsToProps: function )( WrappedComponent: Component )` +### `wp.data.dispatch( key: string )` -If you use a React or WordPress Element, a Higher Order Component is made available to inject data into your components like so: +This function allows calling any registered action. Given a module's key, this function returns an object of all action creators functions registered for the module. -```js -const Component = ( { title } ) =>
{ title }
; +#### Example: -wp.data.query( select => { - return { - title: select( 'myPlugin' ).getTitle(), - }; -} )( Component ); +```js +wp.data.dispatch( 'myPlugin' ).setTitle( 'new Title' ); // Dispatches the setTitle action to the reducer ``` ### `wp.data.subscribe( listener: function )` @@ -80,3 +95,38 @@ const unsubscribe = wp.data.subscribe( () => { // Unsubcribe. unsubscribe(); ``` + +### `wp.data.withSelect( mapStateToProps: Object|Function )( WrappedComponent: Component )` + +To inject state-derived props into a WordPress Element Component, use the `withSelect` higher-order component: + +```jsx +const Component = ( { title } ) =>
{ title }
; + +const EnhancedComponent = wp.data.withSelect( ( select ) => { + return { + title: select( 'myPlugin' ).getTitle, + }; +} )( Component ); +``` + +### `wp.data.withDispatch( propsToDispatchers: Object )( WrappedComponent: Component )` + +To manipulate store data, you can pass dispatching actions into your component as props using the `withDispatch` higher-order component: + +```jsx +const Component = ( { title, updateTitle } ) => ; + +const EnhancedComponent = wp.element.compose( [ + wp.data.withSelect( ( select ) => { + return { + title: select( 'myPlugin' ).getTitle(), + }; + } ), + wp.data.withDispatch( ( dispatch ) => { + return { + updateTitle: dispatch( 'myPlugin' ).setTitle, + }; + } ), +] )( Component ); +``` diff --git a/data/index.js b/data/index.js index 0d80e47cb63b6..93fa8fa80c09a 100644 --- a/data/index.js +++ b/data/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { connect } from 'react-redux'; +import isEqualShallow from 'is-equal-shallow'; import { createStore } from 'redux'; import { flowRight, without, mapValues } from 'lodash'; @@ -9,6 +9,7 @@ import { flowRight, without, mapValues } from 'lodash'; * WordPress dependencies */ import { deprecated } from '@wordpress/utils'; +import { Component, getWrapperDisplayName } from '@wordpress/element'; /** * Internal dependencies @@ -20,6 +21,7 @@ export { loadAndPersist, withRehydratation } from './persist'; */ const stores = {}; const selectors = {}; +const actions = {}; let listeners = []; /** @@ -29,21 +31,6 @@ export function globalListener() { listeners.forEach( listener => listener() ); } -/** - * Subscribe to changes to any data. - * - * @param {Function} listener Listener function. - * - * @return {Function} Unsubscribe function. - */ -export const subscribe = ( listener ) => { - listeners.push( listener ); - - return () => { - listeners = without( listeners, listener ); - }; -}; - /** * Registers a new sub-reducer to the global state and returns a Redux-like store object. * @@ -79,6 +66,34 @@ export function registerSelectors( reducerKey, newSelectors ) { selectors[ reducerKey ] = mapValues( newSelectors, createStateSelector ); } +/** + * Registers actions for external usage. + * + * @param {string} reducerKey Part of the state shape to register the + * selectors for. + * @param {Object} newActions Actions to register. + */ +export function registerActions( reducerKey, newActions ) { + const store = stores[ reducerKey ]; + const createBoundAction = ( action ) => ( ...args ) => store.dispatch( action( ...args ) ); + actions[ reducerKey ] = mapValues( newActions, createBoundAction ); +} + +/** + * Subscribe to changes to any data. + * + * @param {Function} listener Listener function. + * + * @return {Function} Unsubscribe function. + */ +export const subscribe = ( listener ) => { + listeners.push( listener ); + + return () => { + listeners = without( listeners, listener ); + }; +}; + /** * Calls a selector given the current state and extra arguments. * @@ -102,33 +117,142 @@ export function select( reducerKey ) { } /** - * Higher Order Component used to inject data using the registered selectors. + * Returns the available actions for a part of the state. * - * @param {Function} mapSelectorsToProps Gets called with the selectors object - * to determine the data for the - * component. + * @param {string} reducerKey Part of the state shape to dispatch the + * action for. * - * @return {Function} Renders the wrapped component and passes it data. + * @return {*} The action's returned value. */ -export const query = ( mapSelectorsToProps ) => ( WrappedComponent ) => { - const store = { - getState() { - return mapValues( stores, subStore => subStore.getState() ); - }, - subscribe, - dispatch() { - // eslint-disable-next-line no-console - console.warn( 'Dispatch is not supported.' ); - }, - }; - const connectWithStore = ( ...args ) => { - const ConnectedWrappedComponent = connect( ...args )( WrappedComponent ); - return ( props ) => { - return ; - }; - }; +export function dispatch( reducerKey ) { + return actions[ reducerKey ]; +} + +/** + * Higher-order component used to inject state-derived props using registered + * selectors. + * + * @param {Function} mapStateToProps Function called on every state change, + * expected to return object of props to + * merge with the component's own props. + * + * @return {Component} Enhanced component with merged state data props. + */ +export const withSelect = ( mapStateToProps ) => ( WrappedComponent ) => { + class ComponentWithSelect extends Component { + constructor() { + super( ...arguments ); + + this.runSelection = this.runSelection.bind( this ); + + this.state = {}; + } + + componentWillMount() { + this.subscribe(); + + // Populate initial state. + this.runSelection(); + } + + componentWillReceiveProps( nextProps ) { + if ( ! isEqualShallow( nextProps, this.props ) ) { + this.runSelection( nextProps ); + } + } + + componentWillUnmount() { + this.unsubscribe(); + } + + subscribe() { + this.unsubscribe = subscribe( this.runSelection ); + } + + runSelection( props = this.props ) { + const newState = mapStateToProps( select, props ); + if ( ! isEqualShallow( newState, this.state ) ) { + this.setState( newState ); + } + } + + render() { + return ; + } + } + + ComponentWithSelect.displayName = getWrapperDisplayName( WrappedComponent, 'select' ); + + return ComponentWithSelect; +}; + +/** + * Higher-order component used to add dispatch props using registered action + * creators. + * + * @param {Object} mapDispatchToProps Object of prop names where value is a + * dispatch-bound action creator, or a + * function to be called with with the + * component's props and returning an + * action creator. + * + * @return {Component} Enhanced component with merged dispatcher props. + */ +export const withDispatch = ( mapDispatchToProps ) => ( WrappedComponent ) => { + class ComponentWithDispatch extends Component { + constructor() { + super( ...arguments ); + + this.proxyProps = {}; + } + + componentWillMount() { + this.setProxyProps( this.props ); + } + + componentWillUpdate( nextProps ) { + this.setProxyProps( nextProps ); + } + + proxyDispatch( propName, ...args ) { + // Original dispatcher is a pre-bound (dispatching) action creator. + mapDispatchToProps( dispatch, this.props )[ propName ]( ...args ); + } + + setProxyProps( props ) { + // Assign as instance property so that in reconciling subsequent + // renders, the assigned prop values are referentially equal. + const propsToDispatchers = mapDispatchToProps( dispatch, props ); + this.proxyProps = mapValues( propsToDispatchers, ( dispatcher, propName ) => { + // Prebind with prop name so we have reference to the original + // dispatcher to invoke. Track between re-renders to avoid + // creating new function references every render. + if ( this.proxyProps.hasOwnProperty( propName ) ) { + return this.proxyProps[ propName ]; + } + + return this.proxyDispatch.bind( this, propName ); + } ); + } + + render() { + return ; + } + } + + ComponentWithDispatch.displayName = getWrapperDisplayName( WrappedComponent, 'dispatch' ); + + return ComponentWithDispatch; +}; + +export const query = ( mapSelectToProps ) => { + deprecated( 'wp.data.query', { + version: '2.5', + alternative: 'wp.data.withSelect', + plugin: 'Gutenberg', + } ); - return connectWithStore( ( state, ownProps ) => { - return mapSelectorsToProps( select, ownProps ); + return withSelect( ( props ) => { + return mapSelectToProps( select, props ); } ); }; diff --git a/data/test/__snapshots__/index.js.snap b/data/test/__snapshots__/index.js.snap deleted file mode 100644 index 46758a91bd19a..0000000000000 --- a/data/test/__snapshots__/index.js.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`query passes the relevant data to the component 1`] = ` -
- reactState -
-`; diff --git a/data/test/index.js b/data/test/index.js index dfafb0e286f05..8b63e41f4e716 100644 --- a/data/test/index.js +++ b/data/test/index.js @@ -1,12 +1,26 @@ /** * External dependencies */ -import { render } from 'enzyme'; +import { mount } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { compose } from '@wordpress/element'; /** * Internal dependencies */ -import { registerReducer, registerSelectors, select, query, subscribe } from '../'; +import { + registerReducer, + registerSelectors, + registerActions, + dispatch, + select, + withSelect, + withDispatch, + subscribe, +} from '../'; describe( 'store', () => { it( 'Should append reducers to the state', () => { @@ -51,23 +65,146 @@ describe( 'select', () => { } ); } ); -describe( 'query', () => { +describe( 'withSelect', () => { it( 'passes the relevant data to the component', () => { registerReducer( 'reactReducer', () => ( { reactKey: 'reactState' } ) ); registerSelectors( 'reactReducer', { reactSelector: ( state, key ) => state[ key ], } ); - const Component = query( ( selectFunc, ownProps ) => { + + // In normal circumstances, the fact that we have to add an arbitrary + // prefix to the variable name would be concerning, and perhaps an + // argument that we ought to expect developer to use select from the + // wp.data export. But in-fact, this serves as a good deterrent for + // including both `withSelect` and `select` in the same scope, which + // shouldn't occur for a typical component, and if it did might wrongly + // encourage the developer to use `select` within the component itself. + const Component = withSelect( ( _select, ownProps ) => ( { + data: _select( 'reactReducer' ).reactSelector( ownProps.keyName ), + } ) )( ( props ) =>
{ props.data }
); + + const wrapper = mount( ); + + // Wrapper is the enhanced component. Find props on the rendered child. + const child = wrapper.childAt( 0 ); + expect( child.props() ).toEqual( { + keyName: 'reactKey', + data: 'reactState', + } ); + expect( wrapper.text() ).toBe( 'reactState' ); + + wrapper.unmount(); + } ); + + it( 'should rerun selection on state changes', () => { + registerReducer( 'counter', ( state = 0, action ) => { + if ( action.type === 'increment' ) { + return state + 1; + } + + return state; + } ); + + registerSelectors( 'counter', { + getCount: ( state ) => state, + } ); + + registerActions( 'counter', { + increment: () => ( { type: 'increment' } ), + } ); + + const Component = compose( [ + withSelect( ( _select ) => ( { + count: _select( 'counter' ).getCount(), + } ) ), + withDispatch( ( _dispatch ) => ( { + increment: _dispatch( 'counter' ).increment, + } ) ), + ] )( ( props ) => ( + + ) ); + + const wrapper = mount( ); + + const button = wrapper.find( 'button' ); + + button.simulate( 'click' ); + + expect( button.text() ).toBe( '1' ); + + wrapper.unmount(); + } ); + + it( 'should rerun selection on props changes', () => { + registerReducer( 'counter', ( state = 0, action ) => { + if ( action.type === 'increment' ) { + return state + 1; + } + + return state; + } ); + + registerSelectors( 'counter', { + getCount: ( state, offset ) => state + offset, + } ); + + const Component = withSelect( ( _select, ownProps ) => ( { + count: _select( 'counter' ).getCount( ownProps.offset ), + } ) )( ( props ) =>
{ props.count }
); + + const wrapper = mount( ); + + wrapper.setProps( { offset: 10 } ); + + expect( wrapper.childAt( 0 ).text() ).toBe( '10' ); + + wrapper.unmount(); + } ); +} ); + +describe( 'withDispatch', () => { + it( 'passes the relevant data to the component', () => { + const store = registerReducer( 'counter', ( state = 0, action ) => { + if ( action.type === 'increment' ) { + return state + action.count; + } + return state; + } ); + + const increment = ( count = 1 ) => ( { type: 'increment', count } ); + registerActions( 'counter', { + increment, + } ); + + const Component = withDispatch( ( _dispatch, ownProps ) => { + const { count } = ownProps; + return { - data: selectFunc( 'reactReducer' ).reactSelector( ownProps.keyName ), + increment: () => _dispatch( 'counter' ).increment( count ), }; - } )( ( props ) => { - return
{ props.data }
; - } ); + } )( ( props ) =>