diff --git a/client/state/action-types.js b/client/state/action-types.js index 30495242f8e4f..89239180bd687 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -223,6 +223,10 @@ export const SITE_PLANS_TRIAL_CANCEL = 'SITE_PLANS_TRIAL_CANCEL'; export const SITE_PLANS_TRIAL_CANCEL_COMPLETED = 'SITE_PLANS_TRIAL_CANCEL_COMPLETED'; export const SITE_PLANS_TRIAL_CANCEL_FAILED = 'SITE_PLANS_TRIAL_CANCEL_FAILED'; export const SITE_RECEIVE = 'SITE_RECEIVE'; +export const SITE_STATS_RECEIVE = 'SITE_STATS_RECEIVE'; +export const SITE_STATS_REQUEST = 'SITE_STATS_REQUEST'; +export const SITE_STATS_REQUEST_FAILURE = 'SITE_STATS_REQUEST_FAILURE'; +export const SITE_STATS_REQUEST_SUCCESS = 'SITE_STATS_REQUEST_SUCCESS'; export const SITE_VOUCHERS_ASSIGN_RECEIVE = 'SITE_VOUCHERS_ASSIGN_RECEIVE'; export const SITE_VOUCHERS_ASSIGN_REQUEST = 'SITE_VOUCHERS_ASSIGN_REQUEST'; export const SITE_VOUCHERS_ASSIGN_REQUEST_SUCCESS = 'SITE_VOUCHERS_ASSIGN_REQUEST_SUCCESS'; diff --git a/client/state/stats/lists/actions.js b/client/state/stats/lists/actions.js new file mode 100644 index 0000000000000..e18bd84aaafae --- /dev/null +++ b/client/state/stats/lists/actions.js @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import omit from 'lodash/omit'; + +/** + * Internal dependencies + */ +import wpcom from 'lib/wp'; +import { + SITE_STATS_RECEIVE, + SITE_STATS_REQUEST, + SITE_STATS_REQUEST_FAILURE, + SITE_STATS_REQUEST_SUCCESS +} from 'state/action-types'; + +/** + * Returns an action object to be used in signalling that stats for a given type of stats and query + * have been received. + * + * @param {Number} siteId Site ID + * @param {String} statType Stat Key + * @param {Object} query Stats query + * @param {Array} data Stat Data + * @return {Object} Action object + */ +export function receiveSiteStats( siteId, statType, query, data ) { + return { + type: SITE_STATS_RECEIVE, + statType, + siteId, + query, + data + }; +} + +/** + * Returns an action thunk which, when invoked, triggers a network request to + * retrieve site stats. + * + * @param {Number} siteId Site ID + * @param {String} statType Type of stats + * @param {Object} query Stats Query + * @return {Function} Action thunk + */ +export function requestSiteStats( siteId, statType, query ) { + return ( dispatch ) => { + dispatch( { + type: SITE_STATS_REQUEST, + statType, + siteId, + query + } ); + + return wpcom.site( siteId )[ statType ]( query ).then( data => { + dispatch( receiveSiteStats( siteId, statType, query, omit( data, '_headers' ) ) ); + dispatch( { + type: SITE_STATS_REQUEST_SUCCESS, + statType, + siteId, + query + } ); + } ).catch( error => { + dispatch( { + type: SITE_STATS_REQUEST_FAILURE, + statType, + siteId, + query, + error + } ); + } ); + }; +} diff --git a/client/state/stats/lists/reducer.js b/client/state/stats/lists/reducer.js new file mode 100644 index 0000000000000..686c124d1d24f --- /dev/null +++ b/client/state/stats/lists/reducer.js @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import { combineReducers } from 'redux'; +import merge from 'lodash/merge'; + +/** + * Internal dependencies + */ +import { isValidStateWithSchema } from 'state/utils'; +import { getSerializedStatsQuery } from './utils'; +import { itemSchema } from './schema'; +import { + DESERIALIZE, + SERIALIZE, + SITE_STATS_RECEIVE, + SITE_STATS_REQUEST, + SITE_STATS_REQUEST_FAILURE, + SITE_STATS_REQUEST_SUCCESS, +} from 'state/action-types'; + +/** + * Returns the updated requests state after an action has been dispatched. The + * state maps site ID, post ID and stat keys to whether a request is in progress. + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export function requesting( state = {}, action ) { + switch ( action.type ) { + case SITE_STATS_REQUEST: + case SITE_STATS_REQUEST_SUCCESS: + case SITE_STATS_REQUEST_FAILURE: + const queryKey = getSerializedStatsQuery( action.query ); + return merge( {}, state, { + [ action.siteId ]: { + [ action.statType ]: { + [ queryKey ]: SITE_STATS_REQUEST === action.type + } + } + } ); + + case SERIALIZE: + case DESERIALIZE: + return {}; + } + + return state; +} + +/** + * Returns the updated items state after an action has been dispatched. The + * state maps site ID, statType and and serialized query key to the stat payload. + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export function items( state = {}, action ) { + switch ( action.type ) { + case SITE_STATS_RECEIVE: + const queryKey = getSerializedStatsQuery( action.query ); + return merge( {}, state, { + [ action.siteId ]: { + [ action.statType ]: { + [ queryKey ]: action.data + } + } + } ); + + case DESERIALIZE: + if ( isValidStateWithSchema( state, itemSchema ) ) { + return state; + } + + return {}; + } + + return state; +} + +export default combineReducers( { + requesting, + items +} ); diff --git a/client/state/stats/lists/schema.js b/client/state/stats/lists/schema.js new file mode 100644 index 0000000000000..4e2dd1f15f53e --- /dev/null +++ b/client/state/stats/lists/schema.js @@ -0,0 +1,20 @@ +export const itemSchema = { + type: 'object', + patternProperties: { + '^\\d+$': { + type: 'object', + patternProperties: { + '^[A-Za-z]+$': { + type: 'object', + patternProperties: { + '^\\{[^\\}]*\\}$': { + type: 'object' + } + } + } + }, + additionalProperties: false + } + }, + additionalProperties: false +}; diff --git a/client/state/stats/lists/selectors.js b/client/state/stats/lists/selectors.js new file mode 100644 index 0000000000000..cc4422039d228 --- /dev/null +++ b/client/state/stats/lists/selectors.js @@ -0,0 +1,103 @@ +/** + * External dependencies + */ +import get from 'lodash/get'; +import values from 'lodash/values'; + +/** + * Internal dependencies + */ +import createSelector from 'lib/create-selector'; +import i18n from 'lib/mixins/i18n'; +import { + getSerializedStatsQuery +} from './utils'; + +/** + * Returns true if currently requesting stats for the statType and query combo, or false + * otherwise. + * + * @param {Object} state Global state tree + * @param {Number} siteId Site ID + * @param {String} statType Type of stat + * @param {Object} query Stats query object + * @return {Boolean} Whether stats are being requested + */ +export function isRequestingSiteStatsForQuery( state, siteId, statType, query ) { + const serializedQuery = getSerializedStatsQuery( query ); + return !! get( state.stats.lists.requesting, [ siteId, statType, serializedQuery ] ); +} + +/** + * Returns object of stats data for the statType and query combo, or null if no stats have been + * received. + * + * @param {Object} state Global state tree + * @param {Number} siteId Site ID + * @param {String} statType Type of stat + * @param {Object} query Stats query object + * @return {?Object} Data for the query + */ +export function getSiteStatsForQuery( state, siteId, statType, query ) { + const serializedQuery = getSerializedStatsQuery( query ); + return get( state.stats.lists.items, [ siteId, statType, serializedQuery ], null ); +} + +/** + * Returns a parsed object of statsStreak data for a given query, or default "empty" object + * if no statsStreak data has been received for that site. + * + * @param {Object} state Global state tree + * @param {Number} siteId Site ID + * @param {Object} query Stats query object + * @return {Object} Parsed Data for the query + */ +export const getSiteStatsPostStreakData = createSelector( + ( state, siteId, query ) => { + const response = {}; + const data = getSiteStatsForQuery( state, siteId, 'statsStreak', query ) || []; + + Object.keys( data ).forEach( ( timestamp ) => { + const postDay = i18n.moment.unix( timestamp ); + const datestamp = postDay.format( 'YYYY-MM-DD' ); + if ( 'undefined' === typeof( response[ datestamp ] ) ) { + response[ datestamp ] = 0; + } + + response[ datestamp ] += data[ timestamp ]; + } ); + + return response; + }, + ( state, siteId, query ) => getSiteStatsForQuery( state, siteId, 'statsStreak', query ), + ( state, siteId, taxonomy, query ) => { + const serializedQuery = getSerializedStatsQuery( query ); + return [ siteId, 'statsStreak', serializedQuery ].join(); + } +); + +/** + * Returns a number representing the most posts made during a day for a given query + * + * @param {Object} state Global state tree + * @param {Number} siteId Site ID + * @param {Object} query Stats query object + * @return {?Number} Max number of posts by day + */ +export const getSiteStatsMaxPostsByDay = createSelector( + ( state, siteId, query ) => { + const data = values( getSiteStatsPostStreakData( state, siteId, query ) ).sort(); + + if ( data.length ) { + return data[ data.length - 1 ]; + } + + return null; + }, + ( state, siteId, query ) => getSiteStatsForQuery( state, siteId, 'statsStreak', query ), + ( state, siteId, taxonomy, query ) => { + const serializedQuery = getSerializedStatsQuery( query ); + return [ siteId, 'statsStreakMax', serializedQuery ].join(); + } +); + diff --git a/client/state/stats/lists/test/actions.js b/client/state/stats/lists/test/actions.js new file mode 100644 index 0000000000000..5e3142ba65b9d --- /dev/null +++ b/client/state/stats/lists/test/actions.js @@ -0,0 +1,121 @@ +/** + * External dependencies + */ +import sinon from 'sinon'; +import { expect } from 'chai'; +import nock from 'nock'; + +/** + * Internal dependencies + */ +import { + SITE_STATS_RECEIVE, + SITE_STATS_REQUEST, + SITE_STATS_REQUEST_FAILURE, + SITE_STATS_REQUEST_SUCCESS +} from 'state/action-types'; +import { + receiveSiteStats, + requestSiteStats +} from '../actions'; + +const SITE_ID = 2916284; +const STAT_TYPE = 'statsStreak'; +const STREAK_RESPONSE = { + data: { + 1461961382: 1, + 1464110402: 1, + 1464110448: 1 + } +}; +const STREAK_QUERY = { startDate: '2015-06-01', endDate: '2016-06-01' }; + +describe( 'actions', () => { + const spy = sinon.spy(); + + beforeEach( () => { + spy.reset(); + } ); + + after( () => { + nock.cleanAll(); + } ); + + describe( 'receiveSiteStats()', () => { + it( 'should return an action object', () => { + const action = receiveSiteStats( + SITE_ID, + STAT_TYPE, + STREAK_QUERY, + STREAK_RESPONSE + ); + + expect( action ).to.eql( { + type: SITE_STATS_RECEIVE, + siteId: SITE_ID, + statType: STAT_TYPE, + data: STREAK_RESPONSE, + query: STREAK_QUERY + } ); + } ); + } ); + + describe( 'requestSiteStats()', () => { + before( () => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .get( `/rest/v1.1/sites/${ SITE_ID }/stats/streak?startDate=2015-06-01&endDate=2016-06-01` ) + .reply( 200, STREAK_RESPONSE ) + .get( `/rest/v1.1/sites/${ SITE_ID }/stats/country-views` ) + .reply( 404, { + error: 'not_found' + } ); + } ); + + it( 'should dispatch a SITE_STATS_REQUEST', () => { + requestSiteStats( SITE_ID, STAT_TYPE, STREAK_QUERY )( spy ); + + expect( spy ).to.have.been.calledWith( { + type: SITE_STATS_REQUEST, + siteId: SITE_ID, + statType: STAT_TYPE, + query: STREAK_QUERY + } ); + } ); + + it( 'should dispatch a SITE_STATS_RECEIVE event on success', () => { + return requestSiteStats( SITE_ID, STAT_TYPE, STREAK_QUERY )( spy ).then( () => { + expect( spy ).to.have.been.calledWith( { + type: SITE_STATS_RECEIVE, + siteId: SITE_ID, + statType: STAT_TYPE, + data: STREAK_RESPONSE, + query: STREAK_QUERY + } ); + } ); + } ); + + it( 'should dispatch SITE_STATS_REQUEST_SUCCESS action when request succeeds', () => { + return requestSiteStats( SITE_ID, STAT_TYPE, STREAK_QUERY )( spy ).then( () => { + expect( spy ).to.have.been.calledWith( { + type: SITE_STATS_REQUEST_SUCCESS, + siteId: SITE_ID, + statType: STAT_TYPE, + query: STREAK_QUERY + } ); + } ); + } ); + + it( 'should dispatch SITE_STATS_REQUEST_FAILURE action when request fails', () => { + return requestSiteStats( SITE_ID, 'statsCountryViews', {} )( spy ).then( () => { + expect( spy ).to.have.been.calledWith( { + type: SITE_STATS_REQUEST_FAILURE, + siteId: SITE_ID, + statType: 'statsCountryViews', + query: {}, + error: sinon.match( { error: 'not_found' } ) + } ); + } ); + } ); + } ); +} ); diff --git a/client/state/stats/lists/test/reducer.js b/client/state/stats/lists/test/reducer.js new file mode 100644 index 0000000000000..df40da5f44716 --- /dev/null +++ b/client/state/stats/lists/test/reducer.js @@ -0,0 +1,288 @@ +/** + * External dependencies + */ +import { expect } from 'chai'; +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import { useSandbox } from 'test/helpers/use-sinon'; +import { + DESERIALIZE, + SERIALIZE, + SITE_STATS_RECEIVE, + SITE_STATS_REQUEST, + SITE_STATS_REQUEST_FAILURE, + SITE_STATS_REQUEST_SUCCESS, +} from 'state/action-types'; +import reducer, { + items, + requesting +} from '../reducer'; + +/** + * Test Data + */ +const streakResponse = { + data: { + 1461961382: 1, + 1464110402: 1, + 1464110448: 1 + } +}; + +const streakResponseDos = { + data: { + 1464110448: 1 + } +}; + +const streakQuery = { startDate: '2015-06-01', endDate: '2016-06-01' }; +const streakQueryDos = { startDate: '2014-06-01', endDate: '2015-06-01' }; + +describe( 'reducer', () => { + useSandbox( ( sandbox ) => { + sandbox.stub( console, 'warn' ); + } ); + + it( 'should include expected keys in return value', () => { + expect( reducer( undefined, {} ) ).to.have.keys( [ + 'requesting', + 'items' + ] ); + } ); + + describe( 'requesting()', () => { + it( 'should default to an empty object', () => { + const state = requesting( undefined, {} ); + + expect( state ).to.eql( {} ); + } ); + + it( 'should track stats list request fetching', () => { + const state = requesting( undefined, { + type: SITE_STATS_REQUEST, + siteId: 2916284, + statType: 'statsStreak', + query: streakQuery + } ); + + expect( state ).to.eql( { + 2916284: { + statsStreak: { + '[["endDate","2016-06-01"],["startDate","2015-06-01"]]': true + } + } + } ); + } ); + + it( 'should accumulate queries', () => { + const original = deepFreeze( { + 2916284: { + statsStreak: { + '[["endDate","2016-07-01"],["startDate","2016-06-01"]]': true + } + } + } ); + + const state = requesting( original, { + type: SITE_STATS_REQUEST, + siteId: 2916284, + statType: 'statsStreak', + query: streakQueryDos + } ); + + expect( state ).to.eql( { + 2916284: { + statsStreak: { + '[["endDate","2016-07-01"],["startDate","2016-06-01"]]': true, + '[["endDate","2015-06-01"],["startDate","2014-06-01"]]': true + } + } + } ); + } ); + + it( 'should track stats request success', () => { + const state = requesting( undefined, { + type: SITE_STATS_REQUEST_SUCCESS, + siteId: 2916284, + statType: 'statsStreak', + query: streakQuery + } ); + + expect( state ).to.eql( { + 2916284: { + statsStreak: { + '[["endDate","2016-06-01"],["startDate","2015-06-01"]]': false + } + } + } ); + } ); + + it( 'should track stats request failure', () => { + const state = requesting( undefined, { + type: SITE_STATS_REQUEST_FAILURE, + siteId: 2916284, + statType: 'statsStreak', + query: streakQuery, + error: new Error() + } ); + + expect( state ).to.eql( { + 2916284: { + statsStreak: { + '[["endDate","2016-06-01"],["startDate","2015-06-01"]]': false + } + } + } ); + } ); + + it( 'should never persist state', () => { + const original = deepFreeze( { + 2916284: { + statsStreak: { + '[["endDate","2016-07-01"],["startDate","2016-06-01"]]': true + } + } + } ); + + const state = requesting( original, { type: SERIALIZE } ); + + expect( state ).to.eql( {} ); + } ); + + it( 'should never load persisted state', () => { + const original = deepFreeze( { + 2916284: { + statsStreak: { + '[["endDate","2016-07-01"],["startDate","2016-06-01"]]': true + } + } + } ); + + const state = requesting( original, { type: DESERIALIZE } ); + + expect( state ).to.eql( {} ); + } ); + } ); + + describe( 'items()', () => { + it( 'should persist state', () => { + const original = deepFreeze( { + 2916284: { + statsStreak: { + '[["endDate","2016-07-01"],["startDate","2016-06-01"]]': streakResponse + } + } + } ); + const state = items( original, { type: SERIALIZE } ); + + expect( state ).to.eql( original ); + } ); + + it( 'should load valid persisted state', () => { + const original = deepFreeze( { + 2916284: { + statsStreak: { + '[["endDate","2016-07-01"],["startDate","2016-06-01"]]': streakResponse + } + } + } ); + const state = items( original, { type: DESERIALIZE } ); + + expect( state ).to.eql( original ); + } ); + + it( 'should not load invalid persisted state', () => { + const original = deepFreeze( { + 2916284: { + '[["endDate","2016-07-01"],["startDate","2016-06-01"]]': streakResponse + } + } ); + const state = items( original, { type: DESERIALIZE } ); + + expect( state ).to.eql( {} ); + } ); + + it( 'should default to an empty object', () => { + const state = items( undefined, {} ); + + expect( state ).to.eql( {} ); + } ); + + it( 'should add received stats', () => { + const state = items( undefined, { + type: SITE_STATS_RECEIVE, + siteId: 2916284, + statType: 'statsStreak', + query: streakQuery, + data: streakResponse + } ); + + expect( state ).to.eql( { + 2916284: { + statsStreak: { + '[["endDate","2016-06-01"],["startDate","2015-06-01"]]': streakResponse + } + } + } ); + } ); + + it( 'should accumulate received stats by statType', () => { + const original = deepFreeze( { + 2916284: { + statsStreak: { + '[["endDate","2016-07-01"],["startDate","2016-06-01"]]': streakResponse + } + } + } ); + + const state = items( original, { + type: SITE_STATS_RECEIVE, + siteId: 2916284, + statType: 'statsStreak', + query: streakQueryDos, + data: streakResponseDos + } ); + + expect( state ).to.eql( { + 2916284: { + statsStreak: { + '[["endDate","2016-07-01"],["startDate","2016-06-01"]]': streakResponse, + '[["endDate","2015-06-01"],["startDate","2014-06-01"]]': streakResponseDos, + } + } + } ); + } ); + + it( 'should add additional statTypes', () => { + const original = deepFreeze( { + 2916284: { + statsStreak: { + '[["endDate","2016-07-01"],["startDate","2016-06-01"]]': streakResponse + } + } + } ); + + const state = items( original, { + type: SITE_STATS_RECEIVE, + siteId: 2916284, + statType: 'statsCountryViews', + query: streakQuery, + data: {} + } ); + + expect( state ).to.eql( { + 2916284: { + statsStreak: { + '[["endDate","2016-07-01"],["startDate","2016-06-01"]]': streakResponse + }, + statsCountryViews: { + '[["endDate","2016-06-01"],["startDate","2015-06-01"]]': {} + } + } + } ); + } ); + } ); +} ); diff --git a/client/state/stats/lists/test/selectors.js b/client/state/stats/lists/test/selectors.js new file mode 100644 index 0000000000000..b90c337407cdc --- /dev/null +++ b/client/state/stats/lists/test/selectors.js @@ -0,0 +1,186 @@ +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import { + getSiteStatsMaxPostsByDay, + getSiteStatsPostStreakData, + getSiteStatsForQuery, + isRequestingSiteStatsForQuery +} from '../selectors'; + +describe( 'selectors', () => { + beforeEach( () => { + getSiteStatsPostStreakData.memoizedSelector.cache.clear(); + getSiteStatsMaxPostsByDay.memoizedSelector.cache.clear(); + } ); + + describe( 'isRequestingSiteStatsForQuery()', () => { + it( 'should return false if no request exists', () => { + const requesting = isRequestingSiteStatsForQuery( { + stats: { + lists: { + requesting: {} + } + } + }, 2916284, 'statsStreak', {} ); + + expect( requesting ).to.be.false; + } ); + + it( 'should return false if query is not requesting', () => { + const requesting = isRequestingSiteStatsForQuery( { + stats: { + lists: { + requesting: { + 2916284: { + statsStreak: { + '[["endDate","2016-06-01"],["startDate","2015-06-01"]]': false + } + } + } + } + } + }, 2916284, 'statsStreak', { startDate: '2015-06-01', endDate: '2016-06-01' } ); + + expect( requesting ).to.be.false; + } ); + + it( 'should return true if query is in progress', () => { + const requesting = isRequestingSiteStatsForQuery( { + stats: { + lists: { + requesting: { + 2916284: { + statsStreak: { + '[["endDate","2016-06-01"],["startDate","2015-06-01"]]': true + } + } + } + } + } + }, 2916284, 'statsStreak', { startDate: '2015-06-01', endDate: '2016-06-01' } ); + + expect( requesting ).to.be.true; + } ); + } ); + + describe( 'getSiteStatsForQuery()', () => { + it( 'should return null if no matching query results exist', () => { + const stats = getSiteStatsForQuery( { + stats: { + lists: { + items: {} + } + } + }, 2916284, 'statsStreak', {} ); + + expect( stats ).to.be.null; + } ); + + it( 'should return matching stats', () => { + const stats = getSiteStatsForQuery( { + stats: { + lists: { + items: { + 2916284: { + statsStreak: { + '[["endDate","2016-06-01"],["startDate","2015-06-01"]]': { + 1461961382: 1, + 1464110402: 1, + 1464110448: 1 + } + } + } + } + } + } + }, 2916284, 'statsStreak', { startDate: '2015-06-01', endDate: '2016-06-01' } ); + + expect( stats ).to.eql( { + 1461961382: 1, + 1464110402: 1, + 1464110448: 1 + } ); + } ); + } ); + + describe( 'getSiteStatsPostStreakData()', () => { + it( 'should return an empty object if no matching query results exist', () => { + const stats = getSiteStatsPostStreakData( { + stats: { + lists: { + items: {} + } + } + }, 2916284, {} ); + + expect( stats ).to.eql( {} ); + } ); + + it( 'should properly formatted data if matching data for query exists', () => { + const stats = getSiteStatsPostStreakData( { + stats: { + lists: { + items: { + 2916284: { + statsStreak: { + '[["endDate","2016-06-01"],["startDate","2015-06-01"]]': { + 1461961382: 1, + 1464110402: 1, + 1464110448: 1 + } + } + } + } + } + } + }, 2916284, { startDate: '2015-06-01', endDate: '2016-06-01' } ); + + expect( stats ).to.eql( { + '2016-04-29': 1, + '2016-05-24': 2 + } ); + } ); + } ); + + describe( 'getSiteStatsMaxPostsByDay()', () => { + it( 'should return null if no matching query results exist', () => { + const stats = getSiteStatsMaxPostsByDay( { + stats: { + lists: { + items: {} + } + } + }, 2916284, {} ); + + expect( stats ).to.be.null; + } ); + + it( 'should properly correct number of max posts grouped by day', () => { + const stats = getSiteStatsMaxPostsByDay( { + stats: { + lists: { + items: { + 2916284: { + statsStreak: { + '[["endDate","2016-06-01"],["startDate","2015-06-01"]]': { + 1461961382: 1, + 1464110402: 1, + 1464110448: 1 + } + } + } + } + } + } + }, 2916284, { startDate: '2015-06-01', endDate: '2016-06-01' } ); + + expect( stats ).to.eql( 2 ); + } ); + } ); +} ); diff --git a/client/state/stats/lists/test/utils.js b/client/state/stats/lists/test/utils.js new file mode 100644 index 0000000000000..627bdf6e40694 --- /dev/null +++ b/client/state/stats/lists/test/utils.js @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import { + getSerializedStatsQuery +} from '../utils'; + +describe( 'utils', () => { + describe( 'getSerializedStatsQuery()', () => { + it( 'should return a JSON string of a query', () => { + const serializedQuery = getSerializedStatsQuery( { + startDate: '2016-06-01', + endDate: '2016-07-01' + } ); + + expect( serializedQuery ).to.equal( '[["endDate","2016-07-01"],["startDate","2016-06-01"]]' ); + } ); + + it( 'should return the same JSON string of a query regardless of query object order', () => { + const serializedQuery = getSerializedStatsQuery( { + startDate: '2016-06-01', + endDate: '2016-07-01' + } ); + + const serializedQueryTwo = getSerializedStatsQuery( { + endDate: '2016-07-01', + startDate: '2016-06-01' + } ); + + expect( serializedQuery ).to.eql( serializedQueryTwo ); + } ); + } ); +} ); diff --git a/client/state/stats/lists/utils.js b/client/state/stats/lists/utils.js new file mode 100644 index 0000000000000..8333064e694da --- /dev/null +++ b/client/state/stats/lists/utils.js @@ -0,0 +1,16 @@ +/** + * External dependencies + */ +import sortBy from 'lodash/sortBy'; +import toPairs from 'lodash/toPairs'; + +/** + * Returns a serialized stats query, used as the key in the + * `state.stats.lists.items` and `state.stats.lists.requesting` state objects. + * + * @param {Object} query Stats query + * @return {String} Serialized stats query + */ +export function getSerializedStatsQuery( query = {} ) { + return JSON.stringify( sortBy( toPairs( query ), ( pair ) => pair[ 0 ] ) ); +} diff --git a/client/state/stats/reducer.js b/client/state/stats/reducer.js index e3a62fd35838b..12a4fffae9a8a 100644 --- a/client/state/stats/reducer.js +++ b/client/state/stats/reducer.js @@ -7,7 +7,9 @@ import { combineReducers } from 'redux'; * Internal dependencies */ import posts from './posts/reducer'; +import lists from './lists/reducer'; export default combineReducers( { - posts + posts, + lists } );