Skip to content

Commit

Permalink
Framework: Support registering and invoking actions in the data module (
Browse files Browse the repository at this point in the history
#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
  • Loading branch information
youknowriad authored and aduth committed Feb 22, 2018
1 parent 35122e1 commit 9781800
Show file tree
Hide file tree
Showing 7 changed files with 418 additions and 121 deletions.
68 changes: 59 additions & 9 deletions data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 } ) => <div>{ title }</div>;
#### 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 )`
Expand All @@ -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 } ) => <div>{ title }</div>;

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 } ) => <input value={ title } onChange={ 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 );
```
204 changes: 164 additions & 40 deletions data/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
/**
* External dependencies
*/
import { connect } from 'react-redux';
import isEqualShallow from 'is-equal-shallow';
import { createStore } from 'redux';
import { flowRight, without, mapValues } from 'lodash';

/**
* WordPress dependencies
*/
import { deprecated } from '@wordpress/utils';
import { Component, getWrapperDisplayName } from '@wordpress/element';

/**
* Internal dependencies
Expand All @@ -20,6 +21,7 @@ export { loadAndPersist, withRehydratation } from './persist';
*/
const stores = {};
const selectors = {};
const actions = {};
let listeners = [];

/**
Expand All @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand All @@ -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 <ConnectedWrappedComponent { ...props } store={ store } />;
};
};
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 <WrappedComponent { ...this.props } { ...this.state } />;
}
}

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 <WrappedComponent { ...this.props } { ...this.proxyProps } />;
}
}

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 );
} );
};
7 changes: 0 additions & 7 deletions data/test/__snapshots__/index.js.snap

This file was deleted.

Loading

0 comments on commit 9781800

Please sign in to comment.