From 27e924347101e66541ae801a419ec445c4ec85ab Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 6 Jan 2016 15:37:10 -0500 Subject: [PATCH 1/6] Framework: Add posts Redux state handlers --- client/state/action-types.js | 1 + client/state/index.js | 2 + client/state/posts/Makefile | 10 +++++ client/state/posts/actions.js | 18 +++++++++ client/state/posts/reducer.js | 32 ++++++++++++++++ client/state/posts/test/actions.js | 24 ++++++++++++ client/state/posts/test/reducer.js | 60 ++++++++++++++++++++++++++++++ 7 files changed, 147 insertions(+) create mode 100644 client/state/posts/Makefile create mode 100644 client/state/posts/actions.js create mode 100644 client/state/posts/reducer.js create mode 100644 client/state/posts/test/actions.js create mode 100644 client/state/posts/test/reducer.js diff --git a/client/state/action-types.js b/client/state/action-types.js index 6fd4a01e70150..a0ad30d080119 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -13,6 +13,7 @@ export const FETCH_SITE_PLANS = 'FETCH_SITE_PLANS'; export const FETCH_SITE_PLANS_COMPLETED = 'FETCH_SITE_PLANS_COMPLETED'; export const FETCH_PUBLICIZE_CONNECTIONS = 'FETCH_PUBLICIZE_CONNECTIONS'; export const NEW_NOTICE = 'NEW_NOTICE'; +export const POST_RECEIVE = 'POST_RECEIVE'; export const RECEIVE_PUBLICIZE_CONNECTIONS = 'RECEIVE_PUBLICIZE_CONNECTIONS'; export const RECEIVE_SITE = 'RECEIVE_SITE'; export const REMOVE_NOTICE = 'REMOVE_NOTICE'; diff --git a/client/state/index.js b/client/state/index.js index 695becdac8cb2..8854b12542438 100644 --- a/client/state/index.js +++ b/client/state/index.js @@ -9,6 +9,7 @@ import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; */ import { analyticsMiddleware } from 'lib/themes/middlewares.js'; import notices from './notices/reducer'; +import posts from './posts/reducer'; import sharing from './sharing/reducer'; import sites from './sites/reducer'; import siteSettings from './site-settings/reducer' @@ -21,6 +22,7 @@ import ui from './ui/reducer'; */ const reducer = combineReducers( { notices, + posts, sharing, sites, siteSettings, diff --git a/client/state/posts/Makefile b/client/state/posts/Makefile new file mode 100644 index 0000000000000..67b5631ee05af --- /dev/null +++ b/client/state/posts/Makefile @@ -0,0 +1,10 @@ +REPORTER ?= spec +NODE_BIN := $(shell npm bin) +MOCHA ?= $(NODE_BIN)/mocha +BASE_DIR := $(NODE_BIN)/../.. +NODE_PATH := $(BASE_DIR)/client:$(BASE_DIR)/shared + +test: + @NODE_ENV=test NODE_PATH=$(NODE_PATH) $(MOCHA) --compilers jsx:babel/register,js:babel/register --reporter $(REPORTER) + +.PHONY: test diff --git a/client/state/posts/actions.js b/client/state/posts/actions.js new file mode 100644 index 0000000000000..2f1fb59b2b311 --- /dev/null +++ b/client/state/posts/actions.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +import { POST_RECEIVE } from 'state/action-types'; + +/** + * Returns an action object to be used in signalling that a post object has + * been received. + * + * @param {Object} post Post received + * @return {Object} Action object + */ +export function receivePost( post ) { + return { + type: POST_RECEIVE, + post + }; +} diff --git a/client/state/posts/reducer.js b/client/state/posts/reducer.js new file mode 100644 index 0000000000000..a1f08d8076371 --- /dev/null +++ b/client/state/posts/reducer.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import { combineReducers } from 'redux'; + +/** + * Internal dependencies + */ +import { POST_RECEIVE } from 'state/action-types'; + +/** + * Tracks all known post objects, indexed by post ID. + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export function items( state = {}, action ) { + switch ( action.type ) { + case POST_RECEIVE: + state = Object.assign( {}, state, { + [ action.post.ID ]: action.post + } ); + break; + } + + return state; +} + +export default combineReducers( { + items +} ); diff --git a/client/state/posts/test/actions.js b/client/state/posts/test/actions.js new file mode 100644 index 0000000000000..3fd1bb7d08a74 --- /dev/null +++ b/client/state/posts/test/actions.js @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import { POST_RECEIVE } from 'state/action-types'; +import { receivePost } from '../actions'; + +describe( 'actions', () => { + describe( '#receivePost()', () => { + it( 'should return an action object', () => { + const post = { ID: 841, title: 'Hello World' }; + const action = receivePost( post ); + + expect( action ).to.eql( { + type: POST_RECEIVE, + post + } ); + } ); + } ); +} ); diff --git a/client/state/posts/test/reducer.js b/client/state/posts/test/reducer.js new file mode 100644 index 0000000000000..13034bf9e206b --- /dev/null +++ b/client/state/posts/test/reducer.js @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import { POST_RECEIVE } from 'state/action-types'; +import { items } from '../reducer'; + +describe( 'reducer', () => { + describe( '#items()', () => { + it( 'should default to an empty object', () => { + const state = items( undefined, {} ); + + expect( state ).to.eql( {} ); + } ); + + it( 'should index posts by ID', () => { + const state = items( null, { + type: POST_RECEIVE, + post: { ID: 841, title: 'Hello World' } + } ); + + expect( state ).to.eql( { + 841: { ID: 841, title: 'Hello World' } + } ); + } ); + + it( 'should accumulate posts', () => { + const original = Object.freeze( { + 841: { ID: 841, title: 'Hello World' } + } ); + const state = items( original, { + type: POST_RECEIVE, + post: { ID: 413, title: 'Ribs & Chicken' } + } ); + + expect( state ).to.eql( { + 841: { ID: 841, title: 'Hello World' }, + 413: { ID: 413, title: 'Ribs & Chicken' } + } ); + } ); + + it( 'should override previous post of same ID', () => { + const original = Object.freeze( { + 841: { ID: 841, title: 'Hello World' } + } ); + const state = items( original, { + type: POST_RECEIVE, + post: { ID: 841, title: 'Ribs & Chicken' } + } ); + + expect( state ).to.eql( { + 841: { ID: 841, title: 'Ribs & Chicken' } + } ); + } ); + } ); +} ); From 480420d8a5431fcb974b28a6137a604ed6c216b6 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 8 Jan 2016 14:25:46 -0500 Subject: [PATCH 2/6] Posts: Index posts by global ID --- client/state/posts/reducer.js | 4 ++-- client/state/posts/test/reducer.js | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/client/state/posts/reducer.js b/client/state/posts/reducer.js index a1f08d8076371..e0caf78c09f22 100644 --- a/client/state/posts/reducer.js +++ b/client/state/posts/reducer.js @@ -9,7 +9,7 @@ import { combineReducers } from 'redux'; import { POST_RECEIVE } from 'state/action-types'; /** - * Tracks all known post objects, indexed by post ID. + * Tracks all known post objects, indexed by post global ID. * * @param {Object} state Current state * @param {Object} action Action payload @@ -19,7 +19,7 @@ export function items( state = {}, action ) { switch ( action.type ) { case POST_RECEIVE: state = Object.assign( {}, state, { - [ action.post.ID ]: action.post + [ action.post.global_ID ]: action.post } ); break; } diff --git a/client/state/posts/test/reducer.js b/client/state/posts/test/reducer.js index 13034bf9e206b..ba36c39fbb641 100644 --- a/client/state/posts/test/reducer.js +++ b/client/state/posts/test/reducer.js @@ -17,43 +17,43 @@ describe( 'reducer', () => { expect( state ).to.eql( {} ); } ); - it( 'should index posts by ID', () => { + it( 'should index posts by global ID', () => { const state = items( null, { type: POST_RECEIVE, - post: { ID: 841, title: 'Hello World' } + post: { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } } ); expect( state ).to.eql( { - 841: { ID: 841, title: 'Hello World' } + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } } ); } ); it( 'should accumulate posts', () => { const original = Object.freeze( { - 841: { ID: 841, title: 'Hello World' } + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } } ); const state = items( original, { type: POST_RECEIVE, - post: { ID: 413, title: 'Ribs & Chicken' } + post: { ID: 413, global_ID: '6c831c187ffef321eb43a67761a525a3', title: 'Ribs & Chicken' } } ); expect( state ).to.eql( { - 841: { ID: 841, title: 'Hello World' }, - 413: { ID: 413, title: 'Ribs & Chicken' } + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' }, + '6c831c187ffef321eb43a67761a525a3': { ID: 413, global_ID: '6c831c187ffef321eb43a67761a525a3', title: 'Ribs & Chicken' } } ); } ); it( 'should override previous post of same ID', () => { const original = Object.freeze( { - 841: { ID: 841, title: 'Hello World' } + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } } ); const state = items( original, { type: POST_RECEIVE, - post: { ID: 841, title: 'Ribs & Chicken' } + post: { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Ribs & Chicken' } } ); expect( state ).to.eql( { - 841: { ID: 841, title: 'Ribs & Chicken' } + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Ribs & Chicken' } } ); } ); } ); From 2d8eef4353341f7fd3812529886aecd5b495a849 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 8 Jan 2016 15:20:45 -0500 Subject: [PATCH 3/6] Posts: Add receivePosts action --- client/state/action-types.js | 1 + client/state/posts/actions.js | 19 ++++++++++++++++++- client/state/posts/reducer.js | 14 +++++++++++++- client/state/posts/test/actions.js | 22 ++++++++++++++++++++-- client/state/posts/test/reducer.js | 18 +++++++++++++++++- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/client/state/action-types.js b/client/state/action-types.js index a0ad30d080119..6027d4fe4b342 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -14,6 +14,7 @@ export const FETCH_SITE_PLANS_COMPLETED = 'FETCH_SITE_PLANS_COMPLETED'; export const FETCH_PUBLICIZE_CONNECTIONS = 'FETCH_PUBLICIZE_CONNECTIONS'; export const NEW_NOTICE = 'NEW_NOTICE'; export const POST_RECEIVE = 'POST_RECEIVE'; +export const POSTS_RECEIVE = 'POSTS_RECEIVE'; export const RECEIVE_PUBLICIZE_CONNECTIONS = 'RECEIVE_PUBLICIZE_CONNECTIONS'; export const RECEIVE_SITE = 'RECEIVE_SITE'; export const REMOVE_NOTICE = 'REMOVE_NOTICE'; diff --git a/client/state/posts/actions.js b/client/state/posts/actions.js index 2f1fb59b2b311..c29da5fa99563 100644 --- a/client/state/posts/actions.js +++ b/client/state/posts/actions.js @@ -1,7 +1,10 @@ /** * Internal dependencies */ -import { POST_RECEIVE } from 'state/action-types'; +import { + POST_RECEIVE, + POSTS_RECEIVE, +} from 'state/action-types'; /** * Returns an action object to be used in signalling that a post object has @@ -16,3 +19,17 @@ export function receivePost( post ) { post }; } + +/** + * Returns an action object to be used in signalling that post objects have + * been received. + * + * @param {Array} posts Posts received + * @return {Object} Action object + */ +export function receivePosts( posts ) { + return { + type: POSTS_RECEIVE, + posts + }; +} diff --git a/client/state/posts/reducer.js b/client/state/posts/reducer.js index e0caf78c09f22..7f4ae6a706149 100644 --- a/client/state/posts/reducer.js +++ b/client/state/posts/reducer.js @@ -2,11 +2,15 @@ * External dependencies */ import { combineReducers } from 'redux'; +import indexBy from 'lodash/collection/indexBy'; /** * Internal dependencies */ -import { POST_RECEIVE } from 'state/action-types'; +import { + POST_RECEIVE, + POSTS_RECEIVE, +} from 'state/action-types'; /** * Tracks all known post objects, indexed by post global ID. @@ -22,6 +26,14 @@ export function items( state = {}, action ) { [ action.post.global_ID ]: action.post } ); break; + + case POSTS_RECEIVE: + state = Object.assign( {}, state, indexBy( action.posts, 'global_ID' ) ); + break; + } + + return state; +} } return state; diff --git a/client/state/posts/test/actions.js b/client/state/posts/test/actions.js index 3fd1bb7d08a74..538ed96b03c54 100644 --- a/client/state/posts/test/actions.js +++ b/client/state/posts/test/actions.js @@ -6,8 +6,14 @@ import { expect } from 'chai'; /** * Internal dependencies */ -import { POST_RECEIVE } from 'state/action-types'; -import { receivePost } from '../actions'; +import { + POST_RECEIVE, + POSTS_RECEIVE, +} from 'state/action-types'; +import { + receivePost, + receivePosts, +} from '../actions'; describe( 'actions', () => { describe( '#receivePost()', () => { @@ -21,4 +27,16 @@ describe( 'actions', () => { } ); } ); } ); + + describe( '#receivePosts()', () => { + it( 'should return an action object', () => { + const posts = [ { ID: 841, title: 'Hello World' } ]; + const action = receivePosts( posts ); + + expect( action ).to.eql( { + type: POSTS_RECEIVE, + posts + } ); + } ); + } ); } ); diff --git a/client/state/posts/test/reducer.js b/client/state/posts/test/reducer.js index ba36c39fbb641..5807152ada334 100644 --- a/client/state/posts/test/reducer.js +++ b/client/state/posts/test/reducer.js @@ -6,8 +6,11 @@ import { expect } from 'chai'; /** * Internal dependencies */ -import { POST_RECEIVE } from 'state/action-types'; import { items } from '../reducer'; +import { + POST_RECEIVE, + POSTS_RECEIVE, +} from 'state/action-types'; describe( 'reducer', () => { describe( '#items()', () => { @@ -25,6 +28,19 @@ describe( 'reducer', () => { expect( state ).to.eql( { '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + + it( 'should index multiple posts by global ID', () => { + const state = items( null, { + type: POSTS_RECEIVE, + posts: [ + { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' }, + { ID: 413, site_ID: 2916284, global_ID: '6c831c187ffef321eb43a67761a525a3', title: 'Ribs & Chicken' } + ] + } ); + + expect( state ).to.eql( { + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' }, + '6c831c187ffef321eb43a67761a525a3': { ID: 413, site_ID: 2916284, global_ID: '6c831c187ffef321eb43a67761a525a3', title: 'Ribs & Chicken' } } ); } ); From b227d11774ff9dc334d8e0de2d50eb196a51b53e Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 8 Jan 2016 15:21:48 -0500 Subject: [PATCH 4/6] Posts: Add requestSitePosts action --- client/state/action-types.js | 3 + client/state/posts/actions.js | 38 +++++++++ client/state/posts/reducer.js | 68 ++++++++++++++- client/state/posts/test/actions.js | 98 ++++++++++++++++++++- client/state/posts/test/reducer.js | 133 ++++++++++++++++++++++++++--- 5 files changed, 328 insertions(+), 12 deletions(-) diff --git a/client/state/action-types.js b/client/state/action-types.js index 6027d4fe4b342..c38db4c60cc2c 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -15,6 +15,9 @@ export const FETCH_PUBLICIZE_CONNECTIONS = 'FETCH_PUBLICIZE_CONNECTIONS'; export const NEW_NOTICE = 'NEW_NOTICE'; export const POST_RECEIVE = 'POST_RECEIVE'; export const POSTS_RECEIVE = 'POSTS_RECEIVE'; +export const POSTS_REQUEST = 'POSTS_REQUEST'; +export const POSTS_REQUEST_FAILURE = 'POSTS_REQUEST_FAILURE'; +export const POSTS_REQUEST_SUCCESS = 'POSTS_REQUEST_SUCCESS'; export const RECEIVE_PUBLICIZE_CONNECTIONS = 'RECEIVE_PUBLICIZE_CONNECTIONS'; export const RECEIVE_SITE = 'RECEIVE_SITE'; export const REMOVE_NOTICE = 'REMOVE_NOTICE'; diff --git a/client/state/posts/actions.js b/client/state/posts/actions.js index c29da5fa99563..5be8221f35841 100644 --- a/client/state/posts/actions.js +++ b/client/state/posts/actions.js @@ -1,9 +1,13 @@ /** * Internal dependencies */ +import wpcom from 'lib/wp'; import { POST_RECEIVE, POSTS_RECEIVE, + POSTS_REQUEST, + POSTS_REQUEST_SUCCESS, + POSTS_REQUEST_FAILURE } from 'state/action-types'; /** @@ -33,3 +37,37 @@ export function receivePosts( posts ) { posts }; } + +/** + * Triggers a network request to fetch posts for the specified site and query. + * + * @param {Number} siteId Site ID + * @param {String} query Post query + * @return {Function} Action thunk + */ +export function requestSitePosts( siteId, query = {} ) { + return ( dispatch ) => { + dispatch( { + type: POSTS_REQUEST, + siteId, + query + } ); + + return wpcom.site( siteId ).postsList( { query } ).then( ( { posts } ) => { + dispatch( receivePosts( posts ) ); + dispatch( { + type: POSTS_REQUEST_SUCCESS, + siteId, + query, + posts + } ); + } ).catch( ( error ) => { + dispatch( { + type: POSTS_REQUEST_FAILURE, + siteId, + query, + error + } ); + } ); + }; +} diff --git a/client/state/posts/reducer.js b/client/state/posts/reducer.js index 7f4ae6a706149..c0967a8594378 100644 --- a/client/state/posts/reducer.js +++ b/client/state/posts/reducer.js @@ -10,6 +10,9 @@ import indexBy from 'lodash/collection/indexBy'; import { POST_RECEIVE, POSTS_RECEIVE, + POSTS_REQUEST, + POSTS_REQUEST_SUCCESS, + POSTS_REQUEST_FAILURE } from 'state/action-types'; /** @@ -34,11 +37,74 @@ export function items( state = {}, action ) { return state; } + +/** + * Returns the updated site posts state after an action has been dispatched. + * The state reflects a mapping of site ID, post ID pairing to global post ID. + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export function sitePosts( state = {}, action ) { + switch ( action.type ) { + case POST_RECEIVE: + case POSTS_RECEIVE: + state = Object.assign( {}, state ); + let posts = action.post ? [ action.post ] : action.posts; + posts.forEach( ( post ) => { + if ( ! state[ post.site_ID ] ) { + state[ post.site_ID ] = {}; + } + + state[ post.site_ID ][ post.ID ] = post.global_ID; + } ); + break; + } + + return state; +} + +/** + * Returns the updated post query state after an action has been dispatched. + * The state reflects a mapping of site ID to active queries. + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export function siteQueries( state = {}, action ) { + switch ( action.type ) { + case POSTS_REQUEST: + case POSTS_REQUEST_SUCCESS: + case POSTS_REQUEST_FAILURE: + const { type, siteId, posts } = action; + const query = JSON.stringify( action.query ); + + // Clone state and ensure that site is tracked + state = Object.assign( {}, state ); + if ( ! state[ siteId ] ) { + state[ siteId ] = {}; + } + + // Only the initial request should be tracked as fetching. Success + // or failure types imply that fetching has completed. + state[ siteId ][ query ] = { + fetching: POSTS_REQUEST === type + }; + + // When a request succeeds, map the received posts to state. + if ( POSTS_REQUEST_SUCCESS === type ) { + state[ siteId ][ query ].posts = posts.map( ( post ) => post.global_ID ); + } + break; } return state; } export default combineReducers( { - items + items, + sitePosts, + siteQueries } ); diff --git a/client/state/posts/test/actions.js b/client/state/posts/test/actions.js index 538ed96b03c54..cdb16551a1f11 100644 --- a/client/state/posts/test/actions.js +++ b/client/state/posts/test/actions.js @@ -1,7 +1,10 @@ /** * External dependencies */ -import { expect } from 'chai'; +import nock from 'nock'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import Chai, { expect } from 'chai'; /** * Internal dependencies @@ -9,10 +12,14 @@ import { expect } from 'chai'; import { POST_RECEIVE, POSTS_RECEIVE, + POSTS_REQUEST, + POSTS_REQUEST_SUCCESS, + POSTS_REQUEST_FAILURE } from 'state/action-types'; import { receivePost, receivePosts, + requestSitePosts } from '../actions'; describe( 'actions', () => { @@ -39,4 +46,93 @@ describe( 'actions', () => { } ); } ); } ); + + describe( '#requestSitePosts()', () => { + const spy = sinon.spy(); + + before( () => { + Chai.use( sinonChai ); + + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .get( '/rest/v1.1/sites/2916284/posts' ) + .reply( 200, { + posts: [ + { ID: 841, title: 'Hello World' }, + { ID: 413, title: 'Ribs & Chicken' } + ] + } ) + .get( '/rest/v1.1/sites/2916284/posts' ) + .query( { search: 'Hello' } ) + .reply( 200, { + posts: [ { ID: 841, title: 'Hello World' } ] + } ) + .get( '/rest/v1.1/sites/77203074/posts' ) + .reply( 403, { + error: 'authorization_required', + message: 'User cannot access this private blog.' + } ); + } ); + + beforeEach( () => { + spy.reset(); + } ); + + after( () => { + nock.restore(); + } ); + + it( 'should dispatch fetch action when thunk triggered', () => { + requestSitePosts( 2916284 )( spy ); + + expect( spy ).to.have.been.calledWith( { + type: POSTS_REQUEST, + siteId: 2916284, + query: {} + } ); + } ); + + it( 'should dispatch posts receive action when request completes', ( done ) => { + requestSitePosts( 2916284 )( spy ).then( () => { + expect( spy ).to.have.been.calledWith( { + type: POSTS_RECEIVE, + posts: [ + { ID: 841, title: 'Hello World' }, + { ID: 413, title: 'Ribs & Chicken' } + ] + } ); + + done(); + } ).catch( done ); + } ); + + it( 'should dispatch posts posts request success action when request completes', ( done ) => { + requestSitePosts( 2916284 )( spy ).then( () => { + expect( spy ).to.have.been.calledWith( { + type: POSTS_REQUEST_SUCCESS, + siteId: 2916284, + query: {}, + posts: [ + { ID: 841, title: 'Hello World' }, + { ID: 413, title: 'Ribs & Chicken' } + ] + } ); + + done(); + } ).catch( done ); + } ); + + it( 'should dispatch fail action when request fails', ( done ) => { + requestSitePosts( 77203074 )( spy ).then( () => { + expect( spy ).to.have.been.calledWith( { + type: POSTS_REQUEST_FAILURE, + siteId: 77203074, + query: {}, + error: sinon.match( { message: 'User cannot access this private blog.' } ) + } ); + + done(); + } ).catch( done ); + } ); + } ); } ); diff --git a/client/state/posts/test/reducer.js b/client/state/posts/test/reducer.js index 5807152ada334..4130eb9b73786 100644 --- a/client/state/posts/test/reducer.js +++ b/client/state/posts/test/reducer.js @@ -6,11 +6,14 @@ import { expect } from 'chai'; /** * Internal dependencies */ -import { items } from '../reducer'; import { POST_RECEIVE, POSTS_RECEIVE, + POSTS_REQUEST, + POSTS_REQUEST_FAILURE, + POSTS_REQUEST_SUCCESS } from 'state/action-types'; +import { items, sitePosts, siteQueries } from '../reducer'; describe( 'reducer', () => { describe( '#items()', () => { @@ -23,11 +26,13 @@ describe( 'reducer', () => { it( 'should index posts by global ID', () => { const state = items( null, { type: POST_RECEIVE, - post: { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + post: { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } } ); expect( state ).to.eql( { - '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + } ); + } ); it( 'should index multiple posts by global ID', () => { const state = items( null, { @@ -46,30 +51,138 @@ describe( 'reducer', () => { it( 'should accumulate posts', () => { const original = Object.freeze( { - '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } } ); const state = items( original, { type: POST_RECEIVE, - post: { ID: 413, global_ID: '6c831c187ffef321eb43a67761a525a3', title: 'Ribs & Chicken' } + post: { ID: 413, site_ID: 2916284, global_ID: '6c831c187ffef321eb43a67761a525a3', title: 'Ribs & Chicken' } } ); expect( state ).to.eql( { - '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' }, - '6c831c187ffef321eb43a67761a525a3': { ID: 413, global_ID: '6c831c187ffef321eb43a67761a525a3', title: 'Ribs & Chicken' } + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' }, + '6c831c187ffef321eb43a67761a525a3': { ID: 413, site_ID: 2916284, global_ID: '6c831c187ffef321eb43a67761a525a3', title: 'Ribs & Chicken' } } ); } ); it( 'should override previous post of same ID', () => { const original = Object.freeze( { - '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } } ); const state = items( original, { type: POST_RECEIVE, - post: { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Ribs & Chicken' } + post: { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Ribs & Chicken' } + } ); + + expect( state ).to.eql( { + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Ribs & Chicken' } + } ); + } ); + } ); + + describe( '#sitePosts()', () => { + it( 'should default to an empty object', () => { + const state = sitePosts( undefined, {} ); + + expect( state ).to.eql( {} ); + } ); + + it( 'should map site ID, post ID pair to global ID', () => { + const state = sitePosts( null, { + type: POST_RECEIVE, + post: { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + } ); + + expect( state ).to.eql( { + 2916284: { + 841: '3d097cb7c5473c169bba0eb8e3c6cb64' + } + } ); + } ); + } ); + + describe( '#siteQueries()', () => { + it( 'should default to an empty object', () => { + const state = siteQueries( undefined, {} ); + + expect( state ).to.eql( {} ); + } ); + + it( 'should track site post query request fetching', () => { + const state = siteQueries( null, { + type: POSTS_REQUEST, + siteId: 2916284, + query: { search: 'Hello' } + } ); + + expect( state ).to.eql( { + 2916284: { + '{"search":"Hello"}': { + fetching: true + } + } + } ); + } ); + + it( 'should accumulate site queries', () => { + const original = Object.freeze( { + 2916284: { + '{"search":"Hello"}': { + fetching: true + } + } + } ); + const state = siteQueries( original, { + type: POSTS_REQUEST, + siteId: 2916284, + query: { search: 'Hello W' } + } ); + + expect( state ).to.eql( { + 2916284: { + '{"search":"Hello"}': { + fetching: true + }, + '{"search":"Hello W"}': { + fetching: true + } + } + } ); + } ); + + it( 'should track site post query request success', () => { + const state = siteQueries( null, { + type: POSTS_REQUEST_SUCCESS, + siteId: 2916284, + query: { search: 'Hello' }, + posts: [ + { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + ] + } ); + + expect( state ).to.eql( { + 2916284: { + '{"search":"Hello"}': { + fetching: false, + posts: [ '3d097cb7c5473c169bba0eb8e3c6cb64' ] + } + } + } ); + } ); + + it( 'should track site post query request failure', () => { + const state = siteQueries( null, { + type: POSTS_REQUEST_FAILURE, + siteId: 2916284, + query: { search: 'Hello' }, + error: new Error() } ); expect( state ).to.eql( { - '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Ribs & Chicken' } + 2916284: { + '{"search":"Hello"}': { + fetching: false + } + } } ); } ); } ); From e42d6793f02b9e2616da481c93b621f0a344fa01 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 8 Jan 2016 15:22:04 -0500 Subject: [PATCH 5/6] Posts: Add post selectors --- client/state/posts/selectors.js | 27 ++++++++++++ client/state/posts/test/selectors.js | 66 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 client/state/posts/selectors.js create mode 100644 client/state/posts/test/selectors.js diff --git a/client/state/posts/selectors.js b/client/state/posts/selectors.js new file mode 100644 index 0000000000000..6aa6cb8083d9a --- /dev/null +++ b/client/state/posts/selectors.js @@ -0,0 +1,27 @@ +/** + * Returns a post object by its global ID. + * + * @param {Object} state Global state tree + * @param {String} globalId Post global ID + * @return {Object} Post object + */ +export function getPost( state, globalId ) { + return state.posts.items[ globalId ]; +} + +/** + * Returns a post object by site ID, post ID pair. + * + * @param {Object} state Global state tree + * @param {Number} siteId Site ID + * @param {String} postId Post ID + * @return {?Object} Post object + */ +export function getSitePost( state, siteId, postId ) { + const { sitePosts } = state.posts; + if ( ! sitePosts[ siteId ] || ! sitePosts[ siteId ][ postId ] ) { + return null; + } + + return getPost( state, sitePosts[ siteId ][ postId ] ); +} diff --git a/client/state/posts/test/selectors.js b/client/state/posts/test/selectors.js new file mode 100644 index 0000000000000..81a635ad5ee1e --- /dev/null +++ b/client/state/posts/test/selectors.js @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import { getPost, getSitePost } from '../selectors'; + +describe( 'selectors', () => { + describe( '#getPost()', () => { + it( 'should return the object for the post global ID', () => { + const post = getPost( { + posts: { + items: { + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + } + } + }, '3d097cb7c5473c169bba0eb8e3c6cb64' ); + + expect( post ).to.eql( { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } ); + } ); + } ); + + describe( '#getSitePost()', () => { + it( 'should return null if the site has not received any posts', () => { + const post = getSitePost( { + posts: { + sitePosts: {} + } + }, 2916284, 841 ); + + expect( post ).to.be.null; + } ); + + it( 'should return null if the post is not known for the site', () => { + const post = getSitePost( { + posts: { + sitePosts: { + 2916284: {} + } + } + }, 2916284, 841 ); + + expect( post ).to.be.null; + } ); + + it( 'should return the object for the post site ID, post ID pair', () => { + const post = getSitePost( { + posts: { + items: { + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + }, + sitePosts: { + 2916284: { + 841: '3d097cb7c5473c169bba0eb8e3c6cb64' + } + } + } + }, 2916284, 841 ); + + expect( post ).to.eql( { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } ); + } ); + } ); +} ); From dfc1313361ae36d53bb7223c886c09cd8aa16a05 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Mon, 11 Jan 2016 20:52:43 -0500 Subject: [PATCH 6/6] Posts: Consolidate to multiple posts receive action Simplified handling of single post received vs. multiple posts --- client/state/action-types.js | 1 - client/state/posts/actions.js | 6 +----- client/state/posts/reducer.js | 11 +---------- client/state/posts/test/actions.js | 5 ++--- client/state/posts/test/reducer.js | 24 ++++++------------------ 5 files changed, 10 insertions(+), 37 deletions(-) diff --git a/client/state/action-types.js b/client/state/action-types.js index c38db4c60cc2c..7d9841c6f1c88 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -13,7 +13,6 @@ export const FETCH_SITE_PLANS = 'FETCH_SITE_PLANS'; export const FETCH_SITE_PLANS_COMPLETED = 'FETCH_SITE_PLANS_COMPLETED'; export const FETCH_PUBLICIZE_CONNECTIONS = 'FETCH_PUBLICIZE_CONNECTIONS'; export const NEW_NOTICE = 'NEW_NOTICE'; -export const POST_RECEIVE = 'POST_RECEIVE'; export const POSTS_RECEIVE = 'POSTS_RECEIVE'; export const POSTS_REQUEST = 'POSTS_REQUEST'; export const POSTS_REQUEST_FAILURE = 'POSTS_REQUEST_FAILURE'; diff --git a/client/state/posts/actions.js b/client/state/posts/actions.js index 5be8221f35841..4a14941cb0ef3 100644 --- a/client/state/posts/actions.js +++ b/client/state/posts/actions.js @@ -3,7 +3,6 @@ */ import wpcom from 'lib/wp'; import { - POST_RECEIVE, POSTS_RECEIVE, POSTS_REQUEST, POSTS_REQUEST_SUCCESS, @@ -18,10 +17,7 @@ import { * @return {Object} Action object */ export function receivePost( post ) { - return { - type: POST_RECEIVE, - post - }; + return receivePosts( [ post ] ); } /** diff --git a/client/state/posts/reducer.js b/client/state/posts/reducer.js index c0967a8594378..45a25cf45bb09 100644 --- a/client/state/posts/reducer.js +++ b/client/state/posts/reducer.js @@ -8,7 +8,6 @@ import indexBy from 'lodash/collection/indexBy'; * Internal dependencies */ import { - POST_RECEIVE, POSTS_RECEIVE, POSTS_REQUEST, POSTS_REQUEST_SUCCESS, @@ -24,12 +23,6 @@ import { */ export function items( state = {}, action ) { switch ( action.type ) { - case POST_RECEIVE: - state = Object.assign( {}, state, { - [ action.post.global_ID ]: action.post - } ); - break; - case POSTS_RECEIVE: state = Object.assign( {}, state, indexBy( action.posts, 'global_ID' ) ); break; @@ -48,11 +41,9 @@ export function items( state = {}, action ) { */ export function sitePosts( state = {}, action ) { switch ( action.type ) { - case POST_RECEIVE: case POSTS_RECEIVE: state = Object.assign( {}, state ); - let posts = action.post ? [ action.post ] : action.posts; - posts.forEach( ( post ) => { + action.posts.forEach( ( post ) => { if ( ! state[ post.site_ID ] ) { state[ post.site_ID ] = {}; } diff --git a/client/state/posts/test/actions.js b/client/state/posts/test/actions.js index cdb16551a1f11..281153041cbd5 100644 --- a/client/state/posts/test/actions.js +++ b/client/state/posts/test/actions.js @@ -10,7 +10,6 @@ import Chai, { expect } from 'chai'; * Internal dependencies */ import { - POST_RECEIVE, POSTS_RECEIVE, POSTS_REQUEST, POSTS_REQUEST_SUCCESS, @@ -29,8 +28,8 @@ describe( 'actions', () => { const action = receivePost( post ); expect( action ).to.eql( { - type: POST_RECEIVE, - post + type: POSTS_RECEIVE, + posts: [ post ] } ); } ); } ); diff --git a/client/state/posts/test/reducer.js b/client/state/posts/test/reducer.js index 4130eb9b73786..499ddb809d657 100644 --- a/client/state/posts/test/reducer.js +++ b/client/state/posts/test/reducer.js @@ -7,7 +7,6 @@ import { expect } from 'chai'; * Internal dependencies */ import { - POST_RECEIVE, POSTS_RECEIVE, POSTS_REQUEST, POSTS_REQUEST_FAILURE, @@ -24,17 +23,6 @@ describe( 'reducer', () => { } ); it( 'should index posts by global ID', () => { - const state = items( null, { - type: POST_RECEIVE, - post: { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } - } ); - - expect( state ).to.eql( { - '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } - } ); - } ); - - it( 'should index multiple posts by global ID', () => { const state = items( null, { type: POSTS_RECEIVE, posts: [ @@ -54,8 +42,8 @@ describe( 'reducer', () => { '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } } ); const state = items( original, { - type: POST_RECEIVE, - post: { ID: 413, site_ID: 2916284, global_ID: '6c831c187ffef321eb43a67761a525a3', title: 'Ribs & Chicken' } + type: POSTS_RECEIVE, + posts: [ { ID: 413, site_ID: 2916284, global_ID: '6c831c187ffef321eb43a67761a525a3', title: 'Ribs & Chicken' } ] } ); expect( state ).to.eql( { @@ -69,8 +57,8 @@ describe( 'reducer', () => { '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } } ); const state = items( original, { - type: POST_RECEIVE, - post: { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Ribs & Chicken' } + type: POSTS_RECEIVE, + posts: [ { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Ribs & Chicken' } ] } ); expect( state ).to.eql( { @@ -88,8 +76,8 @@ describe( 'reducer', () => { it( 'should map site ID, post ID pair to global ID', () => { const state = sitePosts( null, { - type: POST_RECEIVE, - post: { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + type: POSTS_RECEIVE, + posts: [ { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } ] } ); expect( state ).to.eql( {