Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Framework: Support registering and invoking actions in the data module #5137

Merged
merged 8 commits into from
Feb 22, 2018
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 );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment every registerActions call overwrites stored action for the given reducer key. Should we prevent calling this method more than once or allow to merge actions? The same applies to registerSelectors and registerReducer.

Copy link
Contributor Author

@youknowriad youknowriad Feb 21, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I said above, we could consolidate all these three functions with a single registerState

}

/**
* 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();
}
Copy link
Contributor Author

@youknowriad youknowriad Feb 22, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add componentDidUpdate for use-cases where the props changes but no subscribe is run?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add componentDidUpdate for use-cases where the props changes but no subscribe is run?

I think it would be componentWillReceiveProps, but yes, I think so.

I'd also also it would only need to be run if the props have changed.


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