diff --git a/package-lock.json b/package-lock.json index 8a432d50d705a..aae97e7e7145c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14134,6 +14134,11 @@ "aproba": "^1.1.1" } }, + "rungen": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/rungen/-/rungen-0.3.2.tgz", + "integrity": "sha1-QAwJ6+kU57F+C27zJjQA/Cq8fLM=" + }, "rx": { "version": "2.3.24", "resolved": "https://registry.npmjs.org/rx/-/rx-2.3.24.tgz", diff --git a/package.json b/package.json index 358ba58d6c8fc..ffd7fdddcb587 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "redux-optimist": "1.0.0", "refx": "3.0.0", "rememo": "3.0.0", + "rungen": "0.3.2", "showdown": "1.8.6", "simple-html-tokenizer": "0.4.1", "tinycolor2": "1.4.1", diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 687c5e55d32df..ac04255fb1189 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -66,7 +66,7 @@ export const getMethodName = ( kind, name, prefix = 'get', usePlural = false ) = * * @return {Array} Entities */ -export async function* getKindEntities( state, kind ) { +export function* getKindEntities( state, kind ) { let entities = getEntitiesByKind( state, kind ); if ( entities && entities.length !== 0 ) { @@ -78,7 +78,7 @@ export async function* getKindEntities( state, kind ) { return []; } - entities = await kindConfig.loadEntities(); + entities = yield kindConfig.loadEntities(); yield addEntities( entities ); return entities; diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index b35844b6bd78e..57c87db8890a1 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -23,16 +23,16 @@ import { getKindEntities } from './entities'; * Requests categories from the REST API, yielding action objects on request * progress. */ -export async function* getCategories() { - const categories = await apiRequest( { path: '/wp/v2/categories?per_page=-1' } ); +export function* getCategories() { + const categories = yield apiRequest( { path: '/wp/v2/categories?per_page=-1' } ); yield receiveTerms( 'categories', categories ); } /** * Requests authors from the REST API. */ -export async function* getAuthors() { - const users = await apiRequest( { path: '/wp/v2/users/?who=authors&per_page=-1' } ); +export function* getAuthors() { + const users = yield apiRequest( { path: '/wp/v2/users/?who=authors&per_page=-1' } ); yield receiveUserQuery( 'authors', users ); } @@ -44,13 +44,13 @@ export async function* getAuthors() { * @param {string} name Entity name. * @param {number} key Record's key */ -export async function* getEntityRecord( state, kind, name, key ) { - const entities = yield* await getKindEntities( state, kind ); +export function* getEntityRecord( state, kind, name, key ) { + const entities = yield getKindEntities( state, kind ); const entity = find( entities, { kind, name } ); if ( ! entity ) { return; } - const record = await apiRequest( { path: `${ entity.baseUrl }/${ key }?context=edit` } ); + const record = yield apiRequest( { path: `${ entity.baseUrl }/${ key }?context=edit` } ); yield receiveEntityRecords( kind, name, record ); } @@ -61,20 +61,20 @@ export async function* getEntityRecord( state, kind, name, key ) { * @param {string} kind Entity kind. * @param {string} name Entity name. */ -export async function* getEntityRecords( state, kind, name ) { - const entities = yield* await getKindEntities( state, kind ); +export function* getEntityRecords( state, kind, name ) { + const entities = yield getKindEntities( state, kind ); const entity = find( entities, { kind, name } ); if ( ! entity ) { return; } - const records = await apiRequest( { path: `${ entity.baseUrl }?context=edit` } ); + const records = yield apiRequest( { path: `${ entity.baseUrl }?context=edit` } ); yield receiveEntityRecords( kind, name, Object.values( records ) ); } /** * Requests theme supports data from the index. */ -export async function* getThemeSupports() { - const index = await apiRequest( { path: '/' } ); +export function* getThemeSupports() { + const index = yield apiRequest( { path: '/' } ); yield receiveThemeSupportsFromIndex( index ); } diff --git a/packages/core-data/src/test/entities.js b/packages/core-data/src/test/entities.js index 4f97e4d039ab7..e46fb3a00386f 100644 --- a/packages/core-data/src/test/entities.js +++ b/packages/core-data/src/test/entities.js @@ -59,26 +59,27 @@ describe( 'getKindEntities', () => { } ); } ); - it( 'shouldn\'t do anything if the entities have already been resolved', async () => { + it( 'shouldn\'t do anything if the entities have already been resolved', () => { const state = { entities: { config: [ { kind: 'postType' } ] }, }; const fulfillment = getKindEntities( state, 'postType' ); - const done = ( await fulfillment.next() ).done; + const done = fulfillment.next().done; expect( done ).toBe( true ); } ); - it( 'shouldn\'t do anything if there no defined kind config', async () => { + it( 'shouldn\'t do anything if there no defined kind config', () => { const state = { entities: { config: [] } }; const fulfillment = getKindEntities( state, 'unknownKind' ); - const done = ( await fulfillment.next() ).done; + const done = fulfillment.next().done; expect( done ).toBe( true ); } ); it( 'should fetch and add the entities', async () => { const state = { entities: { config: [] } }; const fulfillment = getKindEntities( state, 'postType' ); - const received = ( await fulfillment.next() ).value; + const receivedEntities = await fulfillment.next().value; + const received = ( fulfillment.next( receivedEntities ) ).value; expect( received ).toEqual( addEntities( [ { baseUrl: '/wp/v2/posts', kind: 'postType', diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index 9e20fc496ae9c..d305e2808c08b 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -24,7 +24,8 @@ describe( 'getCategories', () => { it( 'yields with requested terms', async () => { const fulfillment = getCategories(); - const received = ( await fulfillment.next() ).value; + const categories = await fulfillment.next().value; + const received = fulfillment.next( categories ).value; expect( received ).toEqual( receiveTerms( 'categories', CATEGORIES ) ); } ); } ); @@ -43,39 +44,16 @@ describe( 'getEntityRecord', () => { if ( options.path === '/wp/v2/types/post?context=edit' ) { return Promise.resolve( POST_TYPE ); } - if ( options.path === '/wp/v2/posts/10?context=edit' ) { - return Promise.resolve( POST ); - } - if ( options.path === '/wp/v2/types?context=edit' ) { - return Promise.resolve( POST_TYPES ); - } } ); } ); it( 'yields with requested post type', async () => { - const state = { - entities: { - config: [ - { name: 'postType', kind: 'root', baseUrl: '/wp/v2/types' }, - ], - }, - }; - const fulfillment = getEntityRecord( state, 'root', 'postType', 'post' ); - const received = ( await fulfillment.next() ).value; + const fulfillment = getEntityRecord( {}, 'root', 'postType', 'post' ); + fulfillment.next(); // Trigger the getKindEntities generator + const records = await fulfillment.next( [ { name: 'postType', kind: 'root', baseUrl: '/wp/v2/types' } ] ).value; + const received = await fulfillment.next( records ).value; expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', POST_TYPE ) ); } ); - - it( 'loads the kind entities and yields with requested post type', async () => { - const fulfillment = getEntityRecord( { entities: {} }, 'postType', 'post', 10 ); - const receivedEntities = ( await fulfillment.next() ).value; - expect( receivedEntities ).toEqual( addEntities( [ { - baseUrl: '/wp/v2/posts', - kind: 'postType', - name: 'post', - } ] ) ); - const received = ( await fulfillment.next() ).value; - expect( received ).toEqual( receiveEntityRecords( 'postType', 'post', POST ) ); - } ); } ); describe( 'getEntityRecords', () => { @@ -93,15 +71,10 @@ describe( 'getEntityRecords', () => { } ); it( 'yields with requested post type', async () => { - const state = { - entities: { - config: [ - { name: 'postType', kind: 'root', baseUrl: '/wp/v2/types' }, - ], - }, - }; - const fulfillment = getEntityRecords( state, 'root', 'postType' ); - const received = ( await fulfillment.next() ).value; + const fulfillment = getEntityRecords( {}, 'root', 'postType' ); + fulfillment.next(); // Trigger the getKindEntities generator + const records = await fulfillment.next( [ { name: 'postType', kind: 'root', baseUrl: '/wp/v2/types' } ] ).value; + const received = ( await fulfillment.next( records ) ).value; expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', Object.values( POST_TYPES ) ) ); } ); } ); diff --git a/packages/data/src/components/registry-provider/index.js b/packages/data/src/components/registry-provider/index.js new file mode 100644 index 0000000000000..7b86a20a0cfca --- /dev/null +++ b/packages/data/src/components/registry-provider/index.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { createContext } from '@wordpress/element'; + +const { Consumer, Provider } = createContext( null ); + +export const RegistryConsumer = Consumer; + +export default Provider; diff --git a/packages/data/src/components/with-dispatch/index.js b/packages/data/src/components/with-dispatch/index.js new file mode 100644 index 0000000000000..ef41c38d0575b --- /dev/null +++ b/packages/data/src/components/with-dispatch/index.js @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import { mapValues } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + Component, + compose, + createElement, + createHigherOrderComponent, + pure, +} from '@wordpress/element'; + +/** + * Internal dependencies + */ +import defaultRegistry from '../../default-registry'; +import { RegistryConsumer } from '../registry-provider'; + +/** + * 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. + */ +const withDispatch = ( mapDispatchToProps ) => createHigherOrderComponent( + compose( [ + pure, + ( WrappedComponent ) => { + class ComponentWithDispatch extends Component { + constructor( props ) { + super( ...arguments ); + + this.proxyProps = {}; + this.setProxyProps( props ); + } + + componentDidUpdate() { + this.setProxyProps( this.props ); + } + + proxyDispatch( propName, ...args ) { + // Original dispatcher is a pre-bound (dispatching) action creator. + const dispatch = this.props.registry ? this.props.registry.dispatch : defaultRegistry.dispatch; + mapDispatchToProps( dispatch, this.props.ownProps )[ propName ]( ...args ); + } + + setProxyProps( props ) { + // Assign as instance property so that in reconciling subsequent + // renders, the assigned prop values are referentially equal. + const dispatch = props.registry ? props.registry.dispatch : defaultRegistry.dispatch; + const propsToDispatchers = mapDispatchToProps( dispatch, props.ownProps ); + 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 ; + } + } + + return ( ownProps ) => ( + + { ( registry ) => ( + + ) } + + ); + }, + ] ), + 'withDispatch' +); + +export default withDispatch; diff --git a/packages/data/src/components/with-dispatch/test/index.js b/packages/data/src/components/with-dispatch/test/index.js new file mode 100644 index 0000000000000..8ee8988307868 --- /dev/null +++ b/packages/data/src/components/with-dispatch/test/index.js @@ -0,0 +1,69 @@ +/** + * External dependencies + */ +import TestRenderer from 'react-test-renderer'; + +/** + * WordPress dependencies + */ +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import withDispatch from '../'; +import { createRegistry } from '../../../registry'; +import RegistryProvider from '../../registry-provider'; + +describe( 'withDispatch', () => { + let registry; + beforeEach( () => { + registry = createRegistry(); + } ); + + it( 'passes the relevant data to the component', () => { + const store = registry.registerStore( 'counter', { + reducer: ( state = 0, action ) => { + if ( action.type === 'increment' ) { + return state + action.count; + } + return state; + }, + actions: { + increment: ( count = 1 ) => ( { type: 'increment', count } ), + }, + } ); + + const Component = withDispatch( ( _dispatch, ownProps ) => { + const { count } = ownProps; + + return { + increment: () => _dispatch( 'counter' ).increment( count ), + }; + } )( ( props ) => + ) ); + + const Component = compose( [ + withSelect( mapSelectToProps ), + withDispatch( mapDispatchToProps ), + ] )( OriginalComponent ); + + const testRenderer = TestRenderer.create( + + + + ); + const testInstance = testRenderer.root; + + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( mapDispatchToProps ).toHaveBeenCalledTimes( 1 ); + + // Simulate a click on the button + testInstance.findByType( 'button' ).props.onClick(); + + expect( testInstance.findByType( 'button' ).props.children ).toBe( 1 ); + // 3 times = + // 1. Initial mount + // 2. When click handler is called + // 3. After select updates its merge props + expect( mapDispatchToProps ).toHaveBeenCalledTimes( 3 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'should rerun selection on props changes', () => { + registry.registerStore( 'counter', { + reducer: ( state = 0, action ) => { + if ( action.type === 'increment' ) { + return state + 1; + } + + return state; + }, + selectors: { + getCount: ( state, offset ) => state + offset, + }, + } ); + + const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => ( { + count: _select( 'counter' ).getCount( ownProps.offset ), + } ) ); + + const OriginalComponent = jest.fn().mockImplementation( ( props ) => ( +
{ props.count }
+ ) ); + + const Component = withSelect( mapSelectToProps )( OriginalComponent ); + + const testRenderer = TestRenderer.create( + + + + ); + const testInstance = testRenderer.root; + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + + testRenderer.update( + + + + ); + + expect( testInstance.findByType( 'div' ).props.children ).toBe( 10 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'should render if props have changed but not state', () => { + registry.registerStore( 'unchanging', { + reducer: ( state = {} ) => state, + selectors: { + getState: ( state ) => state, + }, + } ); + + const mapSelectToProps = jest.fn(); + + const OriginalComponent = jest.fn().mockImplementation( () =>
); + + const Component = compose( [ + withSelect( mapSelectToProps ), + ] )( OriginalComponent ); + + const testRenderer = TestRenderer.create( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + + testRenderer.update( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'should not rerun selection on unchanging state', () => { + const store = registry.registerStore( 'unchanging', { + reducer: ( state = {} ) => state, + selectors: { + getState: ( state ) => state, + }, + } ); + + const mapSelectToProps = jest.fn(); + + const OriginalComponent = jest.fn().mockImplementation( () =>
); + + const Component = compose( [ + withSelect( mapSelectToProps ), + ] )( OriginalComponent ); + + TestRenderer.create( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + + store.dispatch( { type: 'dummy' } ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'omits props which are not returned on subsequent mappings', () => { + registry.registerStore( 'demo', { + reducer: ( state = 'OK' ) => state, + selectors: { + getValue: ( state ) => state, + }, + } ); + + const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => { + return { + [ ownProps.propName ]: _select( 'demo' ).getValue(), + }; + } ); + + const OriginalComponent = jest.fn() + .mockImplementation( ( props ) =>
{ JSON.stringify( props ) }
); + + const Component = withSelect( mapSelectToProps )( OriginalComponent ); + + const testRenderer = TestRenderer.create( + + + + ); + const testInstance = testRenderer.root; + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + + expect( JSON.parse( testInstance.findByType( 'div' ).props.children ) ) + .toEqual( { foo: 'OK', propName: 'foo' } ); + + testRenderer.update( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); + expect( JSON.parse( testInstance.findByType( 'div' ).props.children ) ) + .toEqual( { bar: 'OK', propName: 'bar' } ); + } ); + + it( 'allows undefined return from mapSelectToProps', () => { + registry.registerStore( 'demo', { + reducer: ( state = 'OK' ) => state, + selectors: { + getValue: ( state ) => state, + }, + } ); + + const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => { + if ( ownProps.pass ) { + return { + count: _select( 'demo' ).getValue(), + }; + } + } ); + + const OriginalComponent = jest.fn().mockImplementation( ( + ( props ) =>
{ props.count || 'Unknown' }
+ ) ); + + const Component = withSelect( mapSelectToProps )( OriginalComponent ); + + const testRenderer = TestRenderer.create( + + + + ); + const testInstance = testRenderer.root; + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + expect( testInstance.findByType( 'div' ).props.children ).toBe( 'Unknown' ); + + testRenderer.update( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); + expect( testInstance.findByType( 'div' ).props.children ).toBe( 'OK' ); + + testRenderer.update( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 3 ); + expect( testInstance.findByType( 'div' ).props.children ).toBe( 'Unknown' ); + } ); + + it( 'should run selections on parents before its children', () => { + registry.registerStore( 'childRender', { + reducer: ( state = true, action ) => ( + action.type === 'TOGGLE_RENDER' ? ! state : state + ), + selectors: { + getValue: ( state ) => state, + }, + actions: { + toggleRender: () => ( { type: 'TOGGLE_RENDER' } ), + }, + } ); + + const childMapStateToProps = jest.fn(); + const parentMapStateToProps = jest.fn().mockImplementation( ( _select ) => ( { + isRenderingChild: _select( 'childRender' ).getValue(), + } ) ); + + const ChildOriginalComponent = jest.fn().mockImplementation( () =>
); + const ParentOriginalComponent = jest.fn().mockImplementation( ( props ) => ( +
{ props.isRenderingChild ? : null }
+ ) ); + + const Child = withSelect( childMapStateToProps )( ChildOriginalComponent ); + const Parent = withSelect( parentMapStateToProps )( ParentOriginalComponent ); + + TestRenderer.create( + + + + ); + + expect( childMapStateToProps ).toHaveBeenCalledTimes( 1 ); + expect( parentMapStateToProps ).toHaveBeenCalledTimes( 1 ); + expect( ChildOriginalComponent ).toHaveBeenCalledTimes( 1 ); + expect( ParentOriginalComponent ).toHaveBeenCalledTimes( 1 ); + + registry.dispatch( 'childRender' ).toggleRender(); + + expect( childMapStateToProps ).toHaveBeenCalledTimes( 1 ); + expect( parentMapStateToProps ).toHaveBeenCalledTimes( 2 ); + expect( ChildOriginalComponent ).toHaveBeenCalledTimes( 1 ); + expect( ParentOriginalComponent ).toHaveBeenCalledTimes( 2 ); + } ); +} ); diff --git a/packages/data/src/default-registry.js b/packages/data/src/default-registry.js new file mode 100644 index 0000000000000..f593e59530c31 --- /dev/null +++ b/packages/data/src/default-registry.js @@ -0,0 +1,3 @@ +import { createRegistry } from './registry'; + +export default createRegistry(); diff --git a/packages/data/src/index.js b/packages/data/src/index.js index 14247b8bb601c..b9db830daf591 100644 --- a/packages/data/src/index.js +++ b/packages/data/src/index.js @@ -1,104 +1,16 @@ /** * External dependencies */ -import { combineReducers, createStore } from 'redux'; -import { flowRight, without, mapValues, overEvery } from 'lodash'; - -/** - * WordPress dependencies - */ -import { - Component, - compose, - createElement, - createHigherOrderComponent, - pure, -} from '@wordpress/element'; -import isShallowEqual from '@wordpress/is-shallow-equal'; +import { combineReducers } from 'redux'; /** * Internal dependencies */ -import registerDataStore from './store'; - +import defaultRegistry from './default-registry'; export { loadAndPersist, withRehydration, withRehydratation } from './persist'; - -/** - * Module constants - */ -const stores = {}; -const selectors = {}; -const actions = {}; -let listeners = []; - -/** - * Global listener called for each store's update. - */ -export function globalListener() { - listeners.forEach( ( listener ) => listener() ); -} - -/** - * Convenience for registering reducer with actions and selectors. - * - * @param {string} reducerKey Reducer key. - * @param {Object} options Store description (reducer, actions, selectors, resolvers). - * - * @return {Object} Registered store object. - */ -export function registerStore( reducerKey, options ) { - if ( ! options.reducer ) { - throw new TypeError( 'Must specify store reducer' ); - } - - const store = registerReducer( reducerKey, options.reducer ); - - if ( options.actions ) { - registerActions( reducerKey, options.actions ); - } - - if ( options.selectors ) { - registerSelectors( reducerKey, options.selectors ); - } - - if ( options.resolvers ) { - registerResolvers( reducerKey, options.resolvers ); - } - - return store; -} - -/** - * Registers a new sub-reducer to the global state and returns a Redux-like store object. - * - * @param {string} reducerKey Reducer key. - * @param {Object} reducer Reducer function. - * - * @return {Object} Store Object. - */ -export function registerReducer( reducerKey, reducer ) { - const enhancers = []; - if ( window.__REDUX_DEVTOOLS_EXTENSION__ ) { - enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: reducerKey, instanceId: reducerKey } ) ); - } - const store = createStore( reducer, flowRight( enhancers ) ); - stores[ reducerKey ] = store; - - // Customize subscribe behavior to call listeners only on effective change, - // not on every dispatch. - let lastState = store.getState(); - store.subscribe( () => { - const state = store.getState(); - const hasChanged = state !== lastState; - lastState = state; - - if ( hasChanged ) { - globalListener(); - } - } ); - - return store; -} +export { default as withSelect } from './components/with-select'; +export { default as withDispatch } from './components/with-dispatch'; +export { default as RegistryProvider } from './components/registry-provider'; /** * The combineReducers helper function turns an object whose values are different @@ -112,349 +24,12 @@ export function registerReducer( reducerKey, reducer ) { */ export { combineReducers }; -/** - * Registers selectors for external usage. - * - * @param {string} reducerKey Part of the state shape to register the - * selectors for. - * @param {Object} newSelectors Selectors to register. Keys will be used as the - * public facing API. Selectors will get passed the - * state as first argument. - */ -export function registerSelectors( reducerKey, newSelectors ) { - const store = stores[ reducerKey ]; - const createStateSelector = ( selector ) => ( ...args ) => selector( store.getState(), ...args ); - selectors[ reducerKey ] = mapValues( newSelectors, createStateSelector ); -} - -/** - * Registers resolvers for a given reducer key. Resolvers are side effects - * invoked once per argument set of a given selector call, used in ensuring - * that the data needs for the selector are satisfied. - * - * @param {string} reducerKey Part of the state shape to register the - * resolvers for. - * @param {Object} newResolvers Resolvers to register. - */ -export function registerResolvers( reducerKey, newResolvers ) { - const { hasStartedResolution } = select( 'core/data' ); - const { startResolution, finishResolution } = dispatch( 'core/data' ); - - const createResolver = ( selector, selectorName ) => { - // Don't modify selector behavior if no resolver exists. - if ( ! newResolvers.hasOwnProperty( selectorName ) ) { - return selector; - } - - const store = stores[ reducerKey ]; - - // Normalize resolver shape to object. - let resolver = newResolvers[ selectorName ]; - if ( ! resolver.fulfill ) { - resolver = { fulfill: resolver }; - } - - async function fulfill( ...args ) { - if ( hasStartedResolution( reducerKey, selectorName, args ) ) { - return; - } - - startResolution( reducerKey, selectorName, args ); - - // At this point, selectors have already been pre-bound to inject - // state, it would not be otherwise provided to fulfill. - const state = store.getState(); - - let fulfillment = resolver.fulfill( state, ...args ); - - // Attempt to normalize fulfillment as async iterable. - fulfillment = toAsyncIterable( fulfillment ); - if ( ! isAsyncIterable( fulfillment ) ) { - return; - } - - for await ( const maybeAction of fulfillment ) { - // Dispatch if it quacks like an action. - if ( isActionLike( maybeAction ) ) { - store.dispatch( maybeAction ); - } - } - - finishResolution( reducerKey, selectorName, args ); - } - - if ( typeof resolver.isFulfilled === 'function' ) { - // When resolver provides its own fulfillment condition, fulfill - // should only occur if not already fulfilled (opt-out condition). - fulfill = overEvery( [ - ( ...args ) => { - const state = store.getState(); - return ! resolver.isFulfilled( state, ...args ); - }, - fulfill, - ] ); - } - - return ( ...args ) => { - fulfill( ...args ); - return selector( ...args ); - }; - }; - - selectors[ reducerKey ] = mapValues( selectors[ reducerKey ], createResolver ); -} - -/** - * 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. - * - * @param {string} reducerKey Part of the state shape to register the - * selectors for. - * - * @return {*} The selector's returned value. - */ -export function select( reducerKey ) { - return selectors[ reducerKey ]; -} - -/** - * Returns the available actions for a part of the state. - * - * @param {string} reducerKey Part of the state shape to dispatch the - * action for. - * - * @return {*} The action's returned value. - */ -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 ) => createHigherOrderComponent( ( WrappedComponent ) => { - const DEFAULT_MERGE_PROPS = {}; - - return class ComponentWithSelect extends Component { - constructor() { - super( ...arguments ); - - this.subscribe(); - - this.state = {}; - } - - static getDerivedStateFromProps( props ) { - // A constant value is used as the fallback since it can be more - // efficiently shallow compared in case component is repeatedly - // rendered without its own merge props. - const mergeProps = ( - mapStateToProps( select, props ) || - DEFAULT_MERGE_PROPS - ); - - return { mergeProps }; - } - - componentDidMount() { - this.canRunSelection = true; - } - - componentWillUnmount() { - this.canRunSelection = false; - this.unsubscribe(); - } - - shouldComponentUpdate( nextProps, nextState ) { - return ( - ! isShallowEqual( this.props, nextProps ) || - ! isShallowEqual( this.state.mergeProps, nextState.mergeProps ) - ); - } - - subscribe() { - this.unsubscribe = subscribe( () => { - if ( ! this.canRunSelection ) { - return; - } - - // Trigger an update. Behavior of `getDerivedStateFromProps` as - // of React 16.4.0 is such that it will be called by any update - // to the component, including state changes. - // - // See: https://reactjs.org/blog/2018/05/23/react-v-16-4.html#bugfix-for-getderivedstatefromprops - this.setState( () => ( {} ) ); - } ); - } - - render() { - return ; - } - }; -}, 'withSelect' ); - -/** - * 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 ) => createHigherOrderComponent( - compose( [ - pure, - ( WrappedComponent ) => { - return class ComponentWithDispatch extends Component { - constructor( props ) { - super( ...arguments ); - - this.proxyProps = {}; - this.setProxyProps( props ); - } - - componentDidUpdate() { - this.setProxyProps( this.props ); - } - - 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 ; - } - }; - }, - ] ), - 'withDispatch' -); - -/** - * Returns true if the given argument appears to be a dispatchable action. - * - * @param {*} action Object to test. - * - * @return {boolean} Whether object is action-like. - */ -export function isActionLike( action ) { - return ( - !! action && - typeof action.type === 'string' - ); -} - -/** - * Returns true if the given object is an async iterable, or false otherwise. - * - * @param {*} object Object to test. - * - * @return {boolean} Whether object is an async iterable. - */ -export function isAsyncIterable( object ) { - return ( - !! object && - typeof object[ Symbol.asyncIterator ] === 'function' - ); -} - -/** - * Returns true if the given object is iterable, or false otherwise. - * - * @param {*} object Object to test. - * - * @return {boolean} Whether object is iterable. - */ -export function isIterable( object ) { - return ( - !! object && - typeof object[ Symbol.iterator ] === 'function' - ); -} - -/** - * Normalizes the given object argument to an async iterable, asynchronously - * yielding on a singular or array of generator yields or promise resolution. - * - * @param {*} object Object to normalize. - * - * @return {AsyncGenerator} Async iterable actions. - */ -export function toAsyncIterable( object ) { - if ( isAsyncIterable( object ) ) { - return object; - } - - return ( async function* () { - // Normalize as iterable... - if ( ! isIterable( object ) ) { - object = [ object ]; - } - - for ( let maybeAction of object ) { - // ...of Promises. - if ( ! ( maybeAction instanceof Promise ) ) { - maybeAction = Promise.resolve( maybeAction ); - } - - yield await maybeAction; - } - }() ); -} +export const select = defaultRegistry.select; +export const dispatch = defaultRegistry.dispatch; +export const subscribe = defaultRegistry.subscribe; +export const registerStore = defaultRegistry.registerStore; +export const registerReducer = defaultRegistry.registerReducer; +export const registerActions = defaultRegistry.registerActions; +export const registerSelectors = defaultRegistry.registerSelectors; +export const registerResolvers = defaultRegistry.registerResolvers; -registerDataStore(); diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js new file mode 100644 index 0000000000000..2824897574df8 --- /dev/null +++ b/packages/data/src/registry.js @@ -0,0 +1,237 @@ +/** + * External dependencies + */ +import { createStore } from 'redux'; +import { flowRight, without, mapValues, overEvery, get } from 'lodash'; +import createStoreRuntime from './runtime'; + +/** + * Internal dependencies + */ +import dataStore from './store'; + +export function createRegistry( storeConfigs = {} ) { + const namespaces = {}; + let listeners = []; + + /** + * Global listener called for each store's update. + */ + function globalListener() { + listeners.forEach( ( listener ) => listener() ); + } + + /** + * Registers a new sub-reducer to the global state and returns a Redux-like store object. + * + * @param {string} reducerKey Reducer key. + * @param {Object} reducer Reducer function. + * + * @return {Object} Store Object. + */ + function registerReducer( reducerKey, reducer ) { + const enhancers = []; + if ( window.__REDUX_DEVTOOLS_EXTENSION__ ) { + enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: reducerKey, instanceId: reducerKey } ) ); + } + const store = createStore( reducer, flowRight( enhancers ) ); + namespaces[ reducerKey ] = { + runtime: createStoreRuntime( store ), + store, + }; + + // Customize subscribe behavior to call listeners only on effective change, + // not on every dispatch. + let lastState = store.getState(); + store.subscribe( () => { + const state = store.getState(); + const hasChanged = state !== lastState; + lastState = state; + + if ( hasChanged ) { + globalListener(); + } + } ); + + return store; + } + + /** + * Registers selectors for external usage. + * + * @param {string} reducerKey Part of the state shape to register the + * selectors for. + * @param {Object} newSelectors Selectors to register. Keys will be used as the + * public facing API. Selectors will get passed the + * state as first argument. + */ + function registerSelectors( reducerKey, newSelectors ) { + const store = namespaces[ reducerKey ].store; + const createStateSelector = ( selector ) => ( ...args ) => selector( store.getState(), ...args ); + namespaces[ reducerKey ].selectors = mapValues( newSelectors, createStateSelector ); + } + + /** + * Registers resolvers for a given reducer key. Resolvers are side effects + * invoked once per argument set of a given selector call, used in ensuring + * that the data needs for the selector are satisfied. + * + * @param {string} reducerKey Part of the state shape to register the + * resolvers for. + * @param {Object} newResolvers Resolvers to register. + */ + function registerResolvers( reducerKey, newResolvers ) { + const { hasStartedResolution } = select( 'core/data' ); + const { startResolution, finishResolution } = dispatch( 'core/data' ); + + const createResolver = ( selector, selectorName ) => { + // Don't modify selector behavior if no resolver exists. + if ( ! newResolvers.hasOwnProperty( selectorName ) ) { + return selector; + } + + const store = namespaces[ reducerKey ].store; + const runtime = namespaces[ reducerKey ].runtime; + + // Normalize resolver shape to object. + let resolver = newResolvers[ selectorName ]; + if ( ! resolver.fulfill ) { + resolver = { fulfill: resolver }; + } + + function fulfill( ...args ) { + if ( hasStartedResolution( reducerKey, selectorName, args ) ) { + return; + } + + startResolution( reducerKey, selectorName, args ); + + // At this point, selectors have already been pre-bound to inject + // state, it would not be otherwise provided to fulfill. + const state = store.getState(); + + const fulfillment = resolver.fulfill( state, ...args ); + runtime( fulfillment, () => { + finishResolution( reducerKey, selectorName, args ); + } ); + } + + if ( typeof resolver.isFulfilled === 'function' ) { + // When resolver provides its own fulfillment condition, fulfill + // should only occur if not already fulfilled (opt-out condition). + fulfill = overEvery( [ + ( ...args ) => { + const state = store.getState(); + return ! resolver.isFulfilled( state, ...args ); + }, + fulfill, + ] ); + } + + return ( ...args ) => { + fulfill( ...args ); + return selector( ...args ); + }; + }; + + namespaces[ reducerKey ].selectors = mapValues( namespaces[ reducerKey ].selectors, createResolver ); + } + + /** + * Registers actions for external usage. + * + * @param {string} reducerKey Part of the state shape to register the + * selectors for. + * @param {Object} newActions Actions to register. + */ + function registerActions( reducerKey, newActions ) { + const runtime = namespaces[ reducerKey ].runtime; + const createBoundAction = ( action ) => ( ...args ) => runtime( action( ...args ) ); + namespaces[ reducerKey ].actions = mapValues( newActions, createBoundAction ); + } + + /** + * Convenience for registering reducer with actions and selectors. + * + * @param {string} reducerKey Reducer key. + * @param {Object} options Store description (reducer, actions, selectors, resolvers). + * + * @return {Object} Registered store object. + */ + function registerStore( reducerKey, options ) { + if ( ! options.reducer ) { + throw new TypeError( 'Must specify store reducer' ); + } + + const store = registerReducer( reducerKey, options.reducer ); + + if ( options.actions ) { + registerActions( reducerKey, options.actions ); + } + + if ( options.selectors ) { + registerSelectors( reducerKey, options.selectors ); + } + + if ( options.resolvers ) { + registerResolvers( reducerKey, options.resolvers ); + } + + return store; + } + + /** + * Subscribe to changes to any data. + * + * @param {Function} listener Listener function. + * + * @return {Function} Unsubscribe function. + */ + const subscribe = ( listener ) => { + listeners.push( listener ); + + return () => { + listeners = without( listeners, listener ); + }; + }; + + /** + * Calls a selector given the current state and extra arguments. + * + * @param {string} reducerKey Part of the state shape to register the + * selectors for. + * + * @return {*} The selector's returned value. + */ + function select( reducerKey ) { + return get( namespaces, [ reducerKey, 'selectors' ] ); + } + + /** + * Returns the available actions for a part of the state. + * + * @param {string} reducerKey Part of the state shape to dispatch the + * action for. + * + * @return {*} The action's returned value. + */ + function dispatch( reducerKey ) { + return get( namespaces, [ reducerKey, 'actions' ] ); + } + + Object.entries( { + 'core/data': dataStore, + ...storeConfigs, + } ).map( ( [ name, config ] ) => registerStore( name, config ) ); + + return { + registerReducer, + registerSelectors, + registerResolvers, + registerActions, + registerStore, + subscribe, + select, + dispatch, + }; +} diff --git a/packages/data/src/runtime.js b/packages/data/src/runtime.js new file mode 100644 index 0000000000000..880fce3ec044d --- /dev/null +++ b/packages/data/src/runtime.js @@ -0,0 +1,124 @@ +/** + * External dependencies + */ +import { create, all } from 'rungen'; + +/** + * WordPress dependencies + */ +import deprecated from '@wordpress/deprecated'; + +/** + * Returns true if the given argument appears to be a dispatchable action. + * + * @param {*} action Object to test. + * + * @return {boolean} Whether object is action-like. + */ +export function isActionLike( action ) { + return ( + !! action && + typeof action.type === 'string' + ); +} + +/** + * Returns true if the given object is an async iterable, or false otherwise. + * + * @param {*} object Object to test. + * + * @return {boolean} Whether object is an async iterable. + */ +export function isAsyncIterable( object ) { + return ( + !! object && + typeof object[ Symbol.asyncIterator ] === 'function' + ); +} + +/** + * Returns true if the given object is iterable, or false otherwise. + * + * @param {*} object Object to test. + * + * @return {boolean} Whether object is iterable. + */ +export function isIterable( object ) { + return ( + !! object && + typeof object[ Symbol.iterator ] === 'function' + ); +} + +/** + * Normalizes the given object argument to a sync iterable + * yielding on a singular or array of generator yields or promise resolution. + * + * @param {*} object Object to normalize. + * + * @return {Generator} Iterable actions. + */ +export function toSyncIterable( object ) { + // If it's an array make sure that each value is yielded separately + if ( Array.isArray( object ) ) { + object = all( object ); + } + + // Normalize as iterable... + if ( isIterable( object ) ) { + return object; + } + + return ( function* () { + return yield object; + }() ); +} + +export default function createStoreRuntime( store ) { + const actionControl = ( value, next ) => { + if ( ! isActionLike( value ) ) { + return false; + } + store.dispatch( value ); + next(); + return true; + }; + + const promiseControl = ( value, next, rungen, yieldNext, raiseNext ) => { + if ( ! value || typeof value.then !== 'function' ) { + return false; + } + value.then( yieldNext, raiseNext ); + return true; + }; + + const syncIterableRuntime = create( [ + promiseControl, + actionControl, + ] ); + const asyncIterableRuntime = async ( actionCreator ) => { + deprecated( 'Asynchronous generators support in Resolvers', { + version: '3.3', + alternative: 'Simple generators', + plugin: 'Gutenberg', + } ); + for await ( const maybeAction of actionCreator ) { + // Dispatch if it quacks like an action. + if ( isActionLike( maybeAction ) ) { + store.dispatch( maybeAction ); + } + } + }; + + return ( actionCreator, onSuccess ) => { + if ( isAsyncIterable( actionCreator ) ) { + // Todo dispatch deprecated + asyncIterableRuntime( actionCreator ).then( onSuccess ); + return; + } + + // Attempt to normalize the action creator as async iterable. + actionCreator = toSyncIterable( actionCreator ); + syncIterableRuntime( actionCreator, onSuccess ); + }; +} diff --git a/packages/data/src/store/index.js b/packages/data/src/store/index.js index 417babf5a51a8..67ff3b6722085 100644 --- a/packages/data/src/store/index.js +++ b/packages/data/src/store/index.js @@ -1,16 +1,12 @@ /** * Internal dependencies */ -import { registerStore } from '../'; - import reducer from './reducer'; import * as selectors from './selectors'; import * as actions from './actions'; -export default function registerDataStore() { - registerStore( 'core/data', { - reducer, - actions, - selectors, - } ); -} +export default { + reducer, + actions, + selectors, +}; diff --git a/packages/data/src/test/index.js b/packages/data/src/test/index.js deleted file mode 100644 index 816b3914dc5e5..0000000000000 --- a/packages/data/src/test/index.js +++ /dev/null @@ -1,950 +0,0 @@ -/** - * External dependencies - */ -import { mount } from 'enzyme'; -import { castArray } from 'lodash'; - -/** - * WordPress dependencies - */ -import { compose, createElement } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { - registerStore, - registerReducer, - registerSelectors, - registerResolvers, - registerActions, - dispatch, - select, - withSelect, - withDispatch, - subscribe, - isActionLike, - isAsyncIterable, - isIterable, - toAsyncIterable, -} from '../'; - -// Mock data store to prevent self-initialization, as it needs to be reset -// between tests of `registerResolvers` by replacement (new `registerStore`). -jest.mock( '../store', () => () => {} ); -const registerDataStore = require.requireActual( '../store' ).default; - -describe( 'registerStore', () => { - it( 'should be shorthand for reducer, actions, selectors registration', () => { - const store = registerStore( 'butcher', { - reducer( state = { ribs: 6, chicken: 4 }, action ) { - switch ( action.type ) { - case 'sale': - return { - ...state, - [ action.meat ]: state[ action.meat ] / 2, - }; - } - - return state; - }, - selectors: { - getPrice: ( state, meat ) => state[ meat ], - }, - actions: { - startSale: ( meat ) => ( { type: 'sale', meat } ), - }, - } ); - - expect( store.getState() ).toEqual( { ribs: 6, chicken: 4 } ); - expect( dispatch( 'butcher' ) ).toHaveProperty( 'startSale' ); - expect( select( 'butcher' ) ).toHaveProperty( 'getPrice' ); - expect( select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 4 ); - expect( select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 ); - dispatch( 'butcher' ).startSale( 'chicken' ); - expect( select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 2 ); - expect( select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 ); - } ); -} ); - -describe( 'registerReducer', () => { - it( 'Should append reducers to the state', () => { - const reducer1 = () => 'chicken'; - const reducer2 = () => 'ribs'; - - const store = registerReducer( 'red1', reducer1 ); - expect( store.getState() ).toEqual( 'chicken' ); - - const store2 = registerReducer( 'red2', reducer2 ); - expect( store2.getState() ).toEqual( 'ribs' ); - } ); -} ); - -describe( 'registerResolvers', () => { - beforeEach( () => { - registerDataStore(); - } ); - - const unsubscribes = []; - afterEach( () => { - let unsubscribe; - while ( ( unsubscribe = unsubscribes.shift() ) ) { - unsubscribe(); - } - } ); - - function subscribeWithUnsubscribe( ...args ) { - const unsubscribe = subscribe( ...args ); - unsubscribes.push( unsubscribe ); - return unsubscribe; - } - - function subscribeUntil( predicates ) { - predicates = castArray( predicates ); - - return new Promise( ( resolve ) => { - subscribeWithUnsubscribe( () => { - if ( predicates.every( ( predicate ) => predicate() ) ) { - resolve(); - } - } ); - } ); - } - - it( 'should not do anything for selectors which do not have resolvers', () => { - registerReducer( 'demo', ( state = 'OK' ) => state ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', {} ); - - expect( select( 'demo' ).getValue() ).toBe( 'OK' ); - } ); - - it( 'should behave as a side effect for the given selector, with arguments', () => { - const resolver = jest.fn(); - - registerReducer( 'demo', ( state = 'OK' ) => state ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - getValue: resolver, - } ); - - const value = select( 'demo' ).getValue( 'arg1', 'arg2' ); - expect( value ).toBe( 'OK' ); - expect( resolver ).toHaveBeenCalledWith( 'OK', 'arg1', 'arg2' ); - select( 'demo' ).getValue( 'arg1', 'arg2' ); - expect( resolver ).toHaveBeenCalledTimes( 1 ); - select( 'demo' ).getValue( 'arg3', 'arg4' ); - expect( resolver ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'should support the object resolver definition', () => { - const resolver = jest.fn(); - - registerReducer( 'demo', ( state = 'OK' ) => state ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - getValue: { fulfill: resolver }, - } ); - - const value = select( 'demo' ).getValue( 'arg1', 'arg2' ); - expect( value ).toBe( 'OK' ); - } ); - - it( 'should use isFulfilled definition before calling the side effect', () => { - const fulfill = jest.fn().mockImplementation( ( state, page ) => { - return { type: 'SET_PAGE', page, result: [] }; - } ); - - const store = registerReducer( 'demo', ( state = {}, action ) => { - switch ( action.type ) { - case 'SET_PAGE': - return { - ...state, - [ action.page ]: action.result, - }; - } - - return state; - } ); - - store.dispatch( { type: 'SET_PAGE', page: 4, result: [] } ); - - registerSelectors( 'demo', { - getPage: ( state, page ) => state[ page ], - } ); - registerResolvers( 'demo', { - getPage: { - fulfill, - isFulfilled( state, page ) { - return state.hasOwnProperty( page ); - }, - }, - } ); - - select( 'demo' ).getPage( 1 ); - select( 'demo' ).getPage( 2 ); - - expect( fulfill ).toHaveBeenCalledTimes( 2 ); - - select( 'demo' ).getPage( 1 ); - select( 'demo' ).getPage( 2 ); - select( 'demo' ).getPage( 3, {} ); - - // Expected: First and second page fulfillments already triggered, so - // should only be one more than previous assertion set. - expect( fulfill ).toHaveBeenCalledTimes( 3 ); - - select( 'demo' ).getPage( 1 ); - select( 'demo' ).getPage( 2 ); - select( 'demo' ).getPage( 3, {} ); - select( 'demo' ).getPage( 4 ); - - // Expected: - // - Fourth page was pre-filled. Necessary to determine via - // isFulfilled, but fulfillment resolver should not be triggered. - // - Third page arguments are not strictly equal but are equivalent, - // so fulfillment should already be satisfied. - expect( fulfill ).toHaveBeenCalledTimes( 3 ); - - select( 'demo' ).getPage( 4, {} ); - } ); - - it( 'should resolve action to dispatch', () => { - registerReducer( 'demo', ( state = 'NOTOK', action ) => { - return action.type === 'SET_OK' ? 'OK' : state; - } ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - getValue: () => ( { type: 'SET_OK' } ), - } ); - - const promise = subscribeUntil( [ - () => select( 'demo' ).getValue() === 'OK', - () => select( 'core/data' ).hasFinishedResolution( 'demo', 'getValue' ), - ] ); - - select( 'demo' ).getValue(); - - return promise; - } ); - - it( 'should resolve mixed type action array to dispatch', () => { - registerReducer( 'counter', ( state = 0, action ) => { - return action.type === 'INCREMENT' ? state + 1 : state; - } ); - registerSelectors( 'counter', { - getCount: ( state ) => state, - } ); - registerResolvers( 'counter', { - getCount: () => [ - { type: 'INCREMENT' }, - Promise.resolve( { type: 'INCREMENT' } ), - ], - } ); - - const promise = subscribeUntil( [ - () => select( 'counter' ).getCount() === 2, - () => select( 'core/data' ).hasFinishedResolution( 'counter', 'getCount' ), - ] ); - - select( 'counter' ).getCount(); - - return promise; - } ); - - it( 'should resolve generator action to dispatch', () => { - registerReducer( 'demo', ( state = 'NOTOK', action ) => { - return action.type === 'SET_OK' ? 'OK' : state; - } ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - * getValue() { - yield { type: 'SET_OK' }; - }, - } ); - - const promise = subscribeUntil( [ - () => select( 'demo' ).getValue() === 'OK', - () => select( 'core/data' ).hasFinishedResolution( 'demo', 'getValue' ), - ] ); - - select( 'demo' ).getValue(); - - return promise; - } ); - - it( 'should resolve promise action to dispatch', () => { - registerReducer( 'demo', ( state = 'NOTOK', action ) => { - return action.type === 'SET_OK' ? 'OK' : state; - } ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - getValue: () => Promise.resolve( { type: 'SET_OK' } ), - } ); - - const promise = subscribeUntil( [ - () => select( 'demo' ).getValue() === 'OK', - () => select( 'core/data' ).hasFinishedResolution( 'demo', 'getValue' ), - ] ); - - select( 'demo' ).getValue(); - - return promise; - } ); - - it( 'should resolve promise non-action to dispatch', ( done ) => { - let shouldThrow = false; - registerReducer( 'demo', ( state = 'OK' ) => { - if ( shouldThrow ) { - throw 'Should not have dispatched'; - } - - return state; - } ); - shouldThrow = true; - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - getValue: () => Promise.resolve(), - } ); - - select( 'demo' ).getValue(); - - process.nextTick( () => { - done(); - } ); - } ); - - it( 'should resolve async iterator action to dispatch', () => { - registerReducer( 'counter', ( state = 0, action ) => { - return action.type === 'INCREMENT' ? state + 1 : state; - } ); - registerSelectors( 'counter', { - getCount: ( state ) => state, - } ); - registerResolvers( 'counter', { - getCount: async function* () { - yield { type: 'INCREMENT' }; - yield await Promise.resolve( { type: 'INCREMENT' } ); - }, - } ); - - const promise = subscribeUntil( [ - () => select( 'counter' ).getCount() === 2, - () => select( 'core/data' ).hasFinishedResolution( 'counter', 'getCount' ), - ] ); - - select( 'counter' ).getCount(); - - return promise; - } ); - - it( 'should not dispatch resolved promise action on subsequent selector calls', () => { - registerReducer( 'demo', ( state = 'NOTOK', action ) => { - return action.type === 'SET_OK' && state === 'NOTOK' ? 'OK' : 'NOTOK'; - } ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - getValue: () => Promise.resolve( { type: 'SET_OK' } ), - } ); - - const promise = subscribeUntil( () => select( 'demo' ).getValue() === 'OK' ); - - select( 'demo' ).getValue(); - select( 'demo' ).getValue(); - - return promise; - } ); -} ); - -describe( 'select', () => { - it( 'registers multiple selectors to the public API', () => { - const store = registerReducer( 'reducer1', () => 'state1' ); - const selector1 = jest.fn( () => 'result1' ); - const selector2 = jest.fn( () => 'result2' ); - - registerSelectors( 'reducer1', { - selector1, - selector2, - } ); - - expect( select( 'reducer1' ).selector1() ).toEqual( 'result1' ); - expect( selector1 ).toBeCalledWith( store.getState() ); - - expect( select( 'reducer1' ).selector2() ).toEqual( 'result2' ); - expect( selector2 ).toBeCalledWith( store.getState() ); - } ); -} ); - -describe( 'withSelect', () => { - let wrapper, store; - - afterEach( () => { - if ( wrapper ) { - wrapper.unmount(); - wrapper = null; - } - } ); - - it( 'passes the relevant data to the component', () => { - registerReducer( 'reactReducer', () => ( { reactKey: 'reactState' } ) ); - registerSelectors( 'reactReducer', { - reactSelector: ( state, key ) => state[ key ], - } ); - - // 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 mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => ( { - data: _select( 'reactReducer' ).reactSelector( ownProps.keyName ), - } ) ); - - const OriginalComponent = jest.fn().mockImplementation( ( props ) => ( -
{ props.data }
- ) ); - - const Component = withSelect( mapSelectToProps )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - - // 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' ); - } ); - - 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 mapSelectToProps = jest.fn().mockImplementation( ( _select ) => ( { - count: _select( 'counter' ).getCount(), - } ) ); - - const mapDispatchToProps = jest.fn().mockImplementation( ( _dispatch ) => ( { - increment: _dispatch( 'counter' ).increment, - } ) ); - - const OriginalComponent = jest.fn().mockImplementation( ( props ) => ( - - ) ); - - const Component = compose( [ - withSelect( mapSelectToProps ), - withDispatch( mapDispatchToProps ), - ] )( OriginalComponent ); - - wrapper = mount( ); - - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( mapDispatchToProps ).toHaveBeenCalledTimes( 1 ); - - const button = wrapper.find( 'button' ); - - button.simulate( 'click' ); - - expect( button.text() ).toBe( '1' ); - // 3 times = - // 1. Initial mount - // 2. When click handler is called - // 3. After select updates its merge props - expect( mapDispatchToProps ).toHaveBeenCalledTimes( 3 ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); - } ); - - 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 mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => ( { - count: _select( 'counter' ).getCount( ownProps.offset ), - } ) ); - - const OriginalComponent = jest.fn().mockImplementation( ( props ) => ( -
{ props.count }
- ) ); - - const Component = withSelect( mapSelectToProps )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - - wrapper.setProps( { offset: 10 } ); - - expect( wrapper.childAt( 0 ).text() ).toBe( '10' ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'should render if props have changed but not state', () => { - store = registerReducer( 'unchanging', ( state = {} ) => state ); - - registerSelectors( 'unchanging', { - getState: ( state ) => state, - } ); - - const mapSelectToProps = jest.fn(); - - const OriginalComponent = jest.fn().mockImplementation( () =>
); - - const Component = compose( [ - withSelect( mapSelectToProps ), - ] )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - - wrapper.setProps( { propName: 'foo' } ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'should not rerun selection on unchanging state', () => { - store = registerReducer( 'unchanging', ( state = {} ) => state ); - - registerSelectors( 'unchanging', { - getState: ( state ) => state, - } ); - - const mapSelectToProps = jest.fn(); - - const OriginalComponent = jest.fn().mockImplementation( () =>
); - - const Component = compose( [ - withSelect( mapSelectToProps ), - ] )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - - store.dispatch( { type: 'dummy' } ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - } ); - - it( 'omits props which are not returned on subsequent mappings', () => { - registerReducer( 'demo', ( state = 'OK' ) => state ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - - const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => { - return { - [ ownProps.propName ]: _select( 'demo' ).getValue(), - }; - } ); - - const OriginalComponent = jest.fn().mockImplementation( () =>
); - - const Component = withSelect( mapSelectToProps )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - expect( wrapper.childAt( 0 ).props() ).toEqual( { foo: 'OK', propName: 'foo' } ); - - wrapper.setProps( { propName: 'bar' } ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); - expect( wrapper.childAt( 0 ).props() ).toEqual( { bar: 'OK', propName: 'bar' } ); - } ); - - it( 'allows undefined return from mapSelectToProps', () => { - registerReducer( 'demo', ( state = 'OK' ) => state ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - - const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => { - if ( ownProps.pass ) { - return { - count: _select( 'demo' ).getValue(), - }; - } - } ); - - const OriginalComponent = jest.fn().mockImplementation( ( - ( props ) =>
{ props.count || 'Unknown' }
- ) ); - - const Component = withSelect( mapSelectToProps )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - expect( wrapper.childAt( 0 ).text() ).toBe( 'Unknown' ); - - wrapper.setProps( { pass: true } ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); - expect( wrapper.childAt( 0 ).text() ).toBe( 'OK' ); - - wrapper.setProps( { pass: false } ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 3 ); - expect( wrapper.childAt( 0 ).text() ).toBe( 'Unknown' ); - } ); - - it( 'should run selections on parents before its children', () => { - registerReducer( 'childRender', ( state = true, action ) => ( - action.type === 'TOGGLE_RENDER' ? ! state : state - ) ); - registerSelectors( 'childRender', { - getValue: ( state ) => state, - } ); - registerActions( 'childRender', { - toggleRender: () => ( { type: 'TOGGLE_RENDER' } ), - } ); - - const childMapStateToProps = jest.fn(); - const parentMapStateToProps = jest.fn().mockImplementation( ( _select ) => ( { - isRenderingChild: _select( 'childRender' ).getValue(), - } ) ); - - const ChildOriginalComponent = jest.fn().mockImplementation( () =>
); - const ParentOriginalComponent = jest.fn().mockImplementation( ( props ) => ( -
{ props.isRenderingChild ? : null }
- ) ); - - const Child = withSelect( childMapStateToProps )( ChildOriginalComponent ); - const Parent = withSelect( parentMapStateToProps )( ParentOriginalComponent ); - - wrapper = mount( ); - - expect( childMapStateToProps ).toHaveBeenCalledTimes( 1 ); - expect( parentMapStateToProps ).toHaveBeenCalledTimes( 1 ); - expect( ChildOriginalComponent ).toHaveBeenCalledTimes( 1 ); - expect( ParentOriginalComponent ).toHaveBeenCalledTimes( 1 ); - - dispatch( 'childRender' ).toggleRender(); - - expect( childMapStateToProps ).toHaveBeenCalledTimes( 1 ); - expect( parentMapStateToProps ).toHaveBeenCalledTimes( 2 ); - expect( ChildOriginalComponent ).toHaveBeenCalledTimes( 1 ); - expect( ParentOriginalComponent ).toHaveBeenCalledTimes( 2 ); - } ); -} ); - -describe( 'withDispatch', () => { - let wrapper; - afterEach( () => { - if ( wrapper ) { - wrapper.unmount(); - wrapper = null; - } - } ); - - 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 { - increment: () => _dispatch( 'counter' ).increment( count ), - }; - } )( ( props ) =>