diff --git a/client/state/action-types.js b/client/state/action-types.js index 6fd4a01e70150..7d9841c6f1c88 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -13,6 +13,10 @@ 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 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/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..4a14941cb0ef3 --- /dev/null +++ b/client/state/posts/actions.js @@ -0,0 +1,69 @@ +/** + * Internal dependencies + */ +import wpcom from 'lib/wp'; +import { + POSTS_RECEIVE, + POSTS_REQUEST, + POSTS_REQUEST_SUCCESS, + POSTS_REQUEST_FAILURE +} 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 receivePosts( [ 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 + }; +} + +/** + * 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 new file mode 100644 index 0000000000000..45a25cf45bb09 --- /dev/null +++ b/client/state/posts/reducer.js @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import { combineReducers } from 'redux'; +import indexBy from 'lodash/collection/indexBy'; + +/** + * Internal dependencies + */ +import { + POSTS_RECEIVE, + POSTS_REQUEST, + POSTS_REQUEST_SUCCESS, + POSTS_REQUEST_FAILURE +} from 'state/action-types'; + +/** + * Tracks all known post objects, indexed by post global ID. + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export function items( state = {}, action ) { + switch ( action.type ) { + case POSTS_RECEIVE: + state = Object.assign( {}, state, indexBy( action.posts, 'global_ID' ) ); + break; + } + + 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 POSTS_RECEIVE: + state = Object.assign( {}, state ); + action.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, + sitePosts, + siteQueries +} ); 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/actions.js b/client/state/posts/test/actions.js new file mode 100644 index 0000000000000..281153041cbd5 --- /dev/null +++ b/client/state/posts/test/actions.js @@ -0,0 +1,137 @@ +/** + * External dependencies + */ +import nock from 'nock'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import Chai, { expect } from 'chai'; + +/** + * Internal dependencies + */ +import { + POSTS_RECEIVE, + POSTS_REQUEST, + POSTS_REQUEST_SUCCESS, + POSTS_REQUEST_FAILURE +} from 'state/action-types'; +import { + receivePost, + receivePosts, + requestSitePosts +} 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: POSTS_RECEIVE, + posts: [ post ] + } ); + } ); + } ); + + 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 + } ); + } ); + } ); + + 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 new file mode 100644 index 0000000000000..499ddb809d657 --- /dev/null +++ b/client/state/posts/test/reducer.js @@ -0,0 +1,177 @@ +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import { + POSTS_RECEIVE, + POSTS_REQUEST, + POSTS_REQUEST_FAILURE, + POSTS_REQUEST_SUCCESS +} from 'state/action-types'; +import { items, sitePosts, siteQueries } 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 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' } + } ); + } ); + + it( 'should accumulate posts', () => { + const original = Object.freeze( { + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + } ); + const state = items( original, { + type: POSTS_RECEIVE, + posts: [ { 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' } + } ); + } ); + + it( 'should override previous post of same ID', () => { + const original = Object.freeze( { + '3d097cb7c5473c169bba0eb8e3c6cb64': { ID: 841, site_ID: 2916284, global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', title: 'Hello World' } + } ); + const state = items( original, { + type: POSTS_RECEIVE, + posts: [ { 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: POSTS_RECEIVE, + posts: [ { 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( { + 2916284: { + '{"search":"Hello"}': { + fetching: false + } + } + } ); + } ); + } ); +} ); 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' } ); + } ); + } ); +} );