diff --git a/client/components/data/query-post-formats/README.md b/client/components/data/query-post-formats/README.md new file mode 100644 index 0000000000000..13a2be9cebec5 --- /dev/null +++ b/client/components/data/query-post-formats/README.md @@ -0,0 +1,38 @@ +Query Post Formats +================ + +`` is a React component used in managing network requests for post formats. + +## Usage + +Render the component, passing `siteId`. It does not accept any children, nor does it render any elements to the page. You can use it adjacent to other sibling components which make use of the fetched data made available through the global application state. + +```jsx +import React from 'react'; +import QueryPostFormats from 'components/data/query-post-formats'; +import MyPostFormatsListItem from './list-item'; + +export default function MyPostFormatsList( { postFormats } ) { + return ( +
+ + { postFormats.map( ( label, id ) => { + return ( + + ); + } } +
+ ); +} +``` + +## Props + +### `siteId` + + + + +
TypeNumber
RequiredYes
+ +The site ID for which post formats should be requested. diff --git a/client/components/data/query-post-formats/index.jsx b/client/components/data/query-post-formats/index.jsx new file mode 100644 index 0000000000000..4a9fae85e2592 --- /dev/null +++ b/client/components/data/query-post-formats/index.jsx @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; + +/** + * Internal dependencies + */ +import { isRequestingPostFormats } from 'state/post-types/selectors'; +import { requestPostFormats } from 'state/post-types/actions'; + +class QueryPostFormats extends Component { + static propTypes = { + siteId: PropTypes.number.isRequired, + requestingPostFormats: PropTypes.bool, + requestPostFormats: PropTypes.func + }; + + componentWillMount() { + this.request( this.props ); + } + + componentWillReceiveProps( nextProps ) { + if ( this.props.siteId !== nextProps.siteId ) { + this.request( nextProps ); + } + } + + request( props ) { + if ( props.requestingPostFormats ) { + return; + } + + props.requestPostFormats( props.siteId ); + } + + render() { + return null; + } +} + +export default connect( + ( state, ownProps ) => { + return { + requestingPostFormats: isRequestingPostFormats( state, ownProps.siteId ) + }; + }, + { requestPostFormats } +)( QueryPostFormats ); diff --git a/client/lib/wpcom-undocumented/lib/site.js b/client/lib/wpcom-undocumented/lib/site.js index f63725b597450..bcd57aa852328 100644 --- a/client/lib/wpcom-undocumented/lib/site.js +++ b/client/lib/wpcom-undocumented/lib/site.js @@ -99,7 +99,7 @@ UndocumentedSite.prototype.domains = function( callback ) { }; UndocumentedSite.prototype.postFormatsList = function( callback ) { - this.wpcom.withLocale().req.get( '/sites/' + this._id + '/post-formats', {}, callback ); + return this.wpcom.withLocale().req.get( '/sites/' + this._id + '/post-formats', {}, callback ); }; UndocumentedSite.prototype.postAutosave = function( postId, attributes, callback ) { diff --git a/client/state/action-types.js b/client/state/action-types.js index ab631193291a4..e0aba758e3bd6 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -187,6 +187,10 @@ export const POST_DELETE_FAILURE = 'POST_DELETE_FAILURE'; export const POST_DELETE_SUCCESS = 'POST_DELETE_SUCCESS'; export const POST_EDIT = 'POST_EDIT'; export const POST_EDITS_RESET = 'POST_EDITS_RESET'; +export const POST_FORMATS_RECEIVE = 'POST_FORMATS_RECEIVE'; +export const POST_FORMATS_REQUEST = 'POST_FORMATS_REQUEST'; +export const POST_FORMATS_REQUEST_FAILURE = 'POST_FORMATS_REQUEST_FAILURE'; +export const POST_FORMATS_REQUEST_SUCCESS = 'POST_FORMATS_REQUEST_SUCCESS'; export const POST_REQUEST = 'POST_REQUEST'; export const POST_REQUEST_FAILURE = 'POST_REQUEST_FAILURE'; export const POST_REQUEST_SUCCESS = 'POST_REQUEST_SUCCESS'; diff --git a/client/state/index.js b/client/state/index.js index 08431e78ce0ac..a6072b3c0aa2b 100644 --- a/client/state/index.js +++ b/client/state/index.js @@ -26,6 +26,7 @@ import notices from './notices/reducer'; import pageTemplates from './page-templates/reducer'; import plans from './plans/reducer'; import plugins from './plugins/reducer'; +import postFormats from './post-formats/reducer'; import posts from './posts/reducer'; import postTypes from './post-types/reducer'; import preferences from './preferences/reducer'; @@ -69,10 +70,11 @@ export const reducer = combineReducers( { pageTemplates, plugins, plans, - preferences, - preview, + postFormats, posts, postTypes, + preferences, + preview, productsList, purchases, pushNotifications, diff --git a/client/state/post-formats/README.md b/client/state/post-formats/README.md new file mode 100644 index 0000000000000..b0db9265137ef --- /dev/null +++ b/client/state/post-formats/README.md @@ -0,0 +1,62 @@ +Post Formats +=============== + +A module for managing post formats. + +## Actions + +Used in combination with the Redux store instance `dispatch` function, actions can be used in manipulating the current global state. + +### `requestPostFormats( siteId: number )` + +Get a list of supported post formats for a given site. + +```js +import { requestPostFormats } from 'state/post-formats/actions'; + +requestPostFormats( 12345678 ); +``` + +## Reducer + +Data from the aforementioned actions is added to the global state tree, under `postFormats`, with the following structure: + +```js +state.postFormats = { + requesting: { + 12345678: false, + 87654321: true + }, + items: { + 12345678: { + image: 'Image', + video: 'Video' + }, + 87654321: { + status: 'Status' + } + } +} +``` + +## Selectors are intended to assist in extracting data from the global state tree for consumption by other modules. + +#### `isRequestingPostFormats` + +Returns true if post formats are currently fetching for the given site ID. + +```js +import { isRequestingPostFormats } from 'state/post-formats/selectors'; + +const isRequesting = isRequestingPostFormats( state, 12345678 ); +``` + +#### `getPostFormats` + +Returns an array of all supported site formats for the given site ID. + +```js +import { getPostFormats } from 'state/post-formats/selectors'; + +const postFormats = getPostFormats( state, 12345678 ); +``` diff --git a/client/state/post-formats/actions.js b/client/state/post-formats/actions.js new file mode 100644 index 0000000000000..ab39278f35914 --- /dev/null +++ b/client/state/post-formats/actions.js @@ -0,0 +1,45 @@ +/** + * Internal dependencies + */ +import wpcom from 'lib/wp'; +import { + POST_FORMATS_RECEIVE, + POST_FORMATS_REQUEST, + POST_FORMATS_REQUEST_SUCCESS, + POST_FORMATS_REQUEST_FAILURE +} from 'state/action-types'; + +/** + * Returns an action thunk which, when invoked, triggers a network request to + * retrieve post formats for a site. + * + * @param {Number} siteId Site ID + * @return {Function} Action thunk + */ +export function requestPostFormats( siteId ) { + return ( dispatch ) => { + dispatch( { + type: POST_FORMATS_REQUEST, + siteId + } ); + + return wpcom.undocumented().site( siteId ).postFormatsList().then( ( { formats } ) => { + dispatch( { + type: POST_FORMATS_RECEIVE, + siteId, + formats + } ); + + dispatch( { + type: POST_FORMATS_REQUEST_SUCCESS, + siteId + } ); + } ).catch( ( error ) => { + dispatch( { + type: POST_FORMATS_REQUEST_FAILURE, + siteId, + error + } ); + } ); + }; +} diff --git a/client/state/post-formats/reducer.js b/client/state/post-formats/reducer.js new file mode 100644 index 0000000000000..65485ec01d546 --- /dev/null +++ b/client/state/post-formats/reducer.js @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import { combineReducers } from 'redux'; + +/** + * Internal dependencies + */ +import { postFormatsItemsSchema } from './schema'; +import { createReducer } from 'state/utils'; +import { + POST_FORMATS_RECEIVE, + POST_FORMATS_REQUEST, + POST_FORMATS_REQUEST_SUCCESS, + POST_FORMATS_REQUEST_FAILURE +} from 'state/action-types'; + +/** + * Returns the updated requests state after an action has been dispatched. The + * state maps site ID keys to whether a request for post formats is in progress. + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export const requesting = createReducer( {}, { + [ POST_FORMATS_REQUEST ]: ( state, { siteId } ) => ( { ...state, [ siteId ]: true } ), + [ POST_FORMATS_REQUEST_SUCCESS ]: ( state, { siteId } ) => ( { ...state, [ siteId ]: false } ), + [ POST_FORMATS_REQUEST_FAILURE ]: ( state, { siteId } ) => ( { ...state, [ siteId ]: false } ) +} ); + +/** + * Returns the updated items state after an action has been dispatched. The + * state maps site ID keys to an object that contains the site supported post formats. + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export const items = createReducer( {}, { + [ POST_FORMATS_RECEIVE ]: ( state, { siteId, formats } ) => { + return { ...state, [ siteId ]: formats }; + } +}, postFormatsItemsSchema ); + +export default combineReducers( { + requesting, + items +} ); diff --git a/client/state/post-formats/schema.js b/client/state/post-formats/schema.js new file mode 100644 index 0000000000000..a1412cae9aa03 --- /dev/null +++ b/client/state/post-formats/schema.js @@ -0,0 +1,19 @@ +export const postFormatsItemsSchema = { + type: 'object', + additionalProperties: false, + patternProperties: { + // Site ID + '^\\d+$': { + type: 'object', + description: 'List of supported post formats.', + additionalProperties: false, + patternProperties: { + // ID of the post format + '^[0-9a-z\-_]+$': { + type: 'string', + description: 'Label of the post format', + } + } + } + } +}; diff --git a/client/state/post-formats/selectors.js b/client/state/post-formats/selectors.js new file mode 100644 index 0000000000000..4cec7d58f3515 --- /dev/null +++ b/client/state/post-formats/selectors.js @@ -0,0 +1,22 @@ +/** + * Returns true if currently requesting post formats for the specified site ID, or + * false otherwise. + * + * @param {Object} state Global state tree + * @param {Number} siteId Site ID + * @return {Boolean} Whether post formats are being requested + */ +export function isRequestingPostFormats( state, siteId ) { + return !! state.postFormats.requesting[ siteId ]; +} + +/** + * Returns the supported post formats for a site. + * + * @param {Object} state Global state tree + * @param {Number} siteId Site ID + * @return {?Object} Site post formats + */ +export function getPostFormats( state, siteId ) { + return state.postFormats.items[ siteId ] || null; +} diff --git a/client/state/post-formats/test/actions.js b/client/state/post-formats/test/actions.js new file mode 100644 index 0000000000000..fda8aec04ec09 --- /dev/null +++ b/client/state/post-formats/test/actions.js @@ -0,0 +1,87 @@ +/** + * External dependencies + */ +import sinon from 'sinon'; +import { expect } from 'chai'; +import { useSandbox } from 'test/helpers/use-sinon'; +import useNock from 'test/helpers/use-nock'; + +/** + * Internal dependencies + */ +import { + POST_FORMATS_RECEIVE, + POST_FORMATS_REQUEST, + POST_FORMATS_REQUEST_SUCCESS, + POST_FORMATS_REQUEST_FAILURE +} from 'state/action-types'; +import { requestPostFormats } from '../actions'; + +describe( 'actions', () => { + let spy; + useSandbox( ( sandbox ) => spy = sandbox.spy() ); + + describe( '#requestPostFormats()', () => { + useNock( ( nock ) => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .get( '/rest/v1.1/sites/12345678/post-formats' ) + .reply( 200, { + formats: { + image: 'Image', + video: 'Video', + link: 'Link' + } + } ) + .get( '/rest/v1.1/sites/87654321/post-formats' ) + .reply( 403, { + error: 'authorization_required', + message: 'User cannot access this private blog.' + } ); + } ); + + it( 'should dispatch fetch action when thunk triggered', () => { + requestPostFormats( 12345678 )( spy ); + + expect( spy ).to.have.been.calledWith( { + type: POST_FORMATS_REQUEST, + siteId: 12345678 + } ); + } ); + + it( 'should dispatch receive action when request completes', () => { + return requestPostFormats( 12345678 )( spy ).then( () => { + expect( spy ).to.have.been.calledWith( { + type: POST_FORMATS_RECEIVE, + siteId: 12345678, + formats: { + image: 'Image', + video: 'Video', + link: 'Link' + } + } ); + } ); + } ); + + it( 'should dispatch request success action when request completes', () => { + return requestPostFormats( 12345678 )( spy ).then( () => { + expect( spy ).to.have.been.calledWith( { + type: POST_FORMATS_REQUEST_SUCCESS, + siteId: 12345678 + } ); + } ); + } ); + + it( 'should dispatch request failure action when request fails', () => { + return requestPostFormats( 87654321 )( spy ).then( () => { + expect( spy ).to.have.been.calledWith( { + type: POST_FORMATS_REQUEST_FAILURE, + siteId: 87654321, + error: sinon.match( { + message: 'User cannot access this private blog.' + } ) + } ); + } ); + } ); + } ); +} ); diff --git a/client/state/post-formats/test/reducer.js b/client/state/post-formats/test/reducer.js new file mode 100644 index 0000000000000..b8156543ce5c6 --- /dev/null +++ b/client/state/post-formats/test/reducer.js @@ -0,0 +1,230 @@ +/** + * External dependencies + */ +import { expect } from 'chai'; +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import { + POST_FORMATS_RECEIVE, + POST_FORMATS_REQUEST, + POST_FORMATS_REQUEST_FAILURE, + POST_FORMATS_REQUEST_SUCCESS, + SERIALIZE, + DESERIALIZE +} from 'state/action-types'; +import reducer, { requesting, items } from '../reducer'; +import { useSandbox } from 'test/helpers/use-sinon'; + +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 set site ID to true value if a request is initiated', () => { + const state = requesting( undefined, { + type: POST_FORMATS_REQUEST, + siteId: 12345678 + } ); + + expect( state ).to.eql( { + 12345678: true + } ); + } ); + + it( 'should accumulate the requested site IDs', () => { + const state = requesting( deepFreeze( { + 12345678: true + } ), { + type: POST_FORMATS_REQUEST, + siteId: 87654321 + } ); + + expect( state ).to.eql( { + 12345678: true, + 87654321: true + } ); + } ); + + it( 'should set site ID to false if request finishes successfully', () => { + const state = requesting( deepFreeze( { + 12345678: true + } ), { + type: POST_FORMATS_REQUEST_SUCCESS, + siteId: 12345678 + } ); + + expect( state ).to.eql( { + 12345678: false + } ); + } ); + + it( 'should set site ID to false if request finishes unsuccessfully', () => { + const state = requesting( deepFreeze( { + 12345678: true + } ), { + type: POST_FORMATS_REQUEST_FAILURE, + siteId: 12345678 + } ); + + expect( state ).to.eql( { + 12345678: false + } ); + } ); + + it( 'should not persist state', () => { + const state = requesting( deepFreeze( { + 12345678: true + } ), { + type: SERIALIZE + } ); + + expect( state ).to.eql( {} ); + } ); + + it( 'should not load persisted state', () => { + const state = requesting( deepFreeze( { + 12345678: true + } ), { + type: DESERIALIZE + } ); + + expect( state ).to.eql( {} ); + } ); + } ); + + describe( '#items()', () => { + it( 'should default to an empty object', () => { + const state = items( undefined, {} ); + + expect( state ).to.eql( {} ); + } ); + + it( 'should index post formats by site ID', () => { + const state = items( null, { + type: POST_FORMATS_RECEIVE, + siteId: 12345678, + formats: { + image: 'Image', + video: 'Video', + link: 'Link' + } + } ); + + expect( state ).to.eql( { + 12345678: { + image: 'Image', + video: 'Video', + link: 'Link' + } + } ); + } ); + + it( 'should accumulate sites', () => { + const state = items( deepFreeze( { + 12345678: { + image: 'Image', + video: 'Video', + link: 'Link' + } + } ), { + type: POST_FORMATS_RECEIVE, + siteId: 87654321, + formats: { + status: 'Status' + } + } ); + + expect( state ).to.eql( { + 12345678: { + image: 'Image', + video: 'Video', + link: 'Link' + }, + 87654321: { + status: 'Status' + } + } ); + } ); + + it( 'should override previous post formats of same site ID', () => { + const state = items( deepFreeze( { + 12345678: { + image: 'Image', + video: 'Video', + link: 'Link' + } + } ), { + type: POST_FORMATS_RECEIVE, + siteId: 12345678, + formats: { + status: 'Status' + } + } ); + + expect( state ).to.eql( { + 12345678: { + status: 'Status' + } + } ); + } ); + + it( 'should persist state', () => { + const state = items( deepFreeze( { + 12345678: { + status: 'Status' + } + } ), { + type: SERIALIZE + } ); + + expect( state ).to.eql( { + 12345678: { + status: 'Status' + } + } ); + } ); + + it( 'should load valid persisted state', () => { + const state = items( deepFreeze( { + 12345678: { + status: 'Status' + } + } ), { + type: DESERIALIZE + } ); + + expect( state ).to.eql( { + 12345678: { + status: 'Status' + } + } ); + } ); + + it( 'should not load invalid persisted state', () => { + const state = items( deepFreeze( { + status: 'Status' + } ), { + type: DESERIALIZE + } ); + + expect( state ).to.eql( {} ); + } ); + } ); +} ); diff --git a/client/state/post-formats/test/selectors.js b/client/state/post-formats/test/selectors.js new file mode 100644 index 0000000000000..4c8f275b3f6cd --- /dev/null +++ b/client/state/post-formats/test/selectors.js @@ -0,0 +1,80 @@ +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import { + isRequestingPostFormats, + getPostFormats +} from '../selectors'; + +describe( 'selectors', () => { + describe( '#isRequestingPostFormats()', () => { + it( 'should return false if the site has never been fetched', () => { + const isRequesting = isRequestingPostFormats( { + postFormats: { + requesting: {} + } + }, 12345678 ); + + expect( isRequesting ).to.be.false; + } ); + + it( 'should return false if the site is not fetching', () => { + const isRequesting = isRequestingPostFormats( { + postFormats: { + requesting: { + 12345678: false + } + } + }, 12345678 ); + + expect( isRequesting ).to.be.false; + } ); + + it( 'should return true if the site is fetching', () => { + const isRequesting = isRequestingPostFormats( { + postFormats: { + requesting: { + 12345678: true + } + } + }, 12345678 ); + + expect( isRequesting ).to.be.true; + } ); + } ); + + describe( '#getPostFormats()', () => { + it( 'should return null if the site has never been fetched', () => { + const postFormats = getPostFormats( { + postFormats: { + items: {} + } + }, 12345678 ); + + expect( postFormats ).to.be.null; + } ); + + it( 'should return the post formats for a site', () => { + const postFormats = getPostFormats( { + postFormats: { + items: { + 12345678: { + image: 'Image', + link: 'Link' + } + } + } + }, 12345678 ); + + expect( postFormats ).to.eql( { + image: 'Image', + link: 'Link' + } ); + } ); + } ); +} );