diff --git a/client/my-sites/term-tree-selector/add-term.jsx b/client/my-sites/term-tree-selector/add-term.jsx index 8cfff1ed4d877..7b14cb49d7fc5 100644 --- a/client/my-sites/term-tree-selector/add-term.jsx +++ b/client/my-sites/term-tree-selector/add-term.jsx @@ -6,7 +6,7 @@ import ReactDom from 'react-dom'; import classNames from 'classnames'; import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; -import { get, find, noop } from 'lodash'; +import { get, find } from 'lodash'; /** * Internal dependencies @@ -16,7 +16,6 @@ import TermTreeSelectorTerms from './terms'; import Button from 'components/button'; import Gridicon from 'components/gridicon'; import FormInputValidation from 'components/forms/form-input-validation'; -import FormTextarea from 'components/forms/form-textarea'; import FormTextInput from 'components/forms/form-text-input'; import FormSectionHeading from 'components/forms/form-section-heading'; import FormCheckbox from 'components/forms/form-checkbox'; @@ -27,7 +26,7 @@ import viewport from 'lib/viewport'; import { getSelectedSiteId } from 'state/ui/selectors'; import { getPostTypeTaxonomy } from 'state/post-types/taxonomies/selectors'; import { getTerms } from 'state/terms/selectors'; -import { addTerm } from 'state/terms/actions'; +import { addTermForPost } from 'state/posts/actions'; class TermSelectorAddTerm extends Component { static initialState = { @@ -40,21 +39,14 @@ class TermSelectorAddTerm extends Component { static propTypes = { labels: PropTypes.object, - onSuccess: PropTypes.func, postType: PropTypes.string, postId: PropTypes.number, - showDescriptionInput: PropTypes.bool, siteId: PropTypes.number, terms: PropTypes.array, taxonomy: PropTypes.string, translate: PropTypes.func }; - static defaultProps = { - onSuccess: noop, - showDescriptionInput: false - }; - constructor( props ) { super( props ); this.state = this.constructor.initialState; @@ -100,15 +92,9 @@ class TermSelectorAddTerm extends Component { getFormValues() { const name = ReactDom.findDOMNode( this.refs.termName ).value.trim(); - const formValues = { name }; - if ( this.props.isHierarchical ) { - formValues.parent = this.state.selectedParent.length ? this.state.selectedParent[ 0 ] : 0; - } - if ( this.props.showDescriptionInput ) { - formValues.description = ReactDom.findDOMNode( this.refs.termDescription ).value.trim(); - } + const parent = this.state.selectedParent.length ? this.state.selectedParent[ 0 ] : 0; - return formValues; + return { name, parent }; } isValid() { @@ -123,7 +109,7 @@ class TermSelectorAddTerm extends Component { const lowerCasedTermName = values.name.toLowerCase(); const matchingTerm = find( this.props.terms, ( term ) => { return ( term.name.toLowerCase() === lowerCasedTermName ) && - ( ! this.props.isHierarchical || ( term.parent === values.parent ) ); + ( term.parent === values.parent ); } ); if ( matchingTerm ) { @@ -159,43 +145,12 @@ class TermSelectorAddTerm extends Component { const { postId, siteId, taxonomy } = this.props; - this.props - .addTerm( siteId, taxonomy, term, postId ) - .then( this.props.onSuccess ); + this.props.addTermForPost( siteId, taxonomy, term, postId ); this.closeDialog(); } - renderParentSelector() { - const { labels, siteId, taxonomy, translate } = this.props; - const { searchTerm, selectedParent } = this.state; - const query = {}; - if ( searchTerm && searchTerm.length ) { - query.search = searchTerm; - } - - return ( - - - { labels.parent_item } - - - - { translate( 'Top level', { context: 'Terms: New term being created is top level' } ) } - - - - ); - } - render() { - const { isHierarchical, labels, translate, terms, showDescriptionInput } = this.props; + const { labels, siteId, taxonomy, translate, terms } = this.props; const buttons = [ { action: 'cancel', label: translate( 'Cancel' ) @@ -207,6 +162,12 @@ class TermSelectorAddTerm extends Component { onClick: this.boundSaveTerm } ]; + const { searchTerm, selectedParent } = this.state; + const query = {}; + if ( searchTerm && searchTerm.length ) { + query.search = searchTerm; + } + const isError = this.state.error && this.state.error.length; const totalTerms = terms ? terms.length : 0; const classes = classNames( 'term-tree-selector__add-term', { 'is-compact': totalTerms < 8 } ); @@ -232,16 +193,23 @@ class TermSelectorAddTerm extends Component { onKeyUp={ this.boundValidateInput } /> { isError && } - { showDescriptionInput && - - { translate( 'Description', { context: 'Terms: Term description label' } ) } - - - - } - { isHierarchical && this.renderParentSelector() } + + + { labels.parent_item } + + + + { translate( 'Top level', { context: 'Terms: New term being created is top level' } ) } + + + ); @@ -252,16 +220,13 @@ export default connect( ( state, ownProps ) => { const { taxonomy, postType } = ownProps; const siteId = getSelectedSiteId( state ); - const taxonomyDetails = getPostTypeTaxonomy( state, siteId, postType, taxonomy ); - const labels = get( taxonomyDetails, 'labels', {} ); - const isHierarchical = taxonomyDetails.hierarchical; + const labels = get( getPostTypeTaxonomy( state, siteId, postType, taxonomy ), 'labels', {} ); return { terms: getTerms( state, siteId, taxonomy ), - isHierarchical, labels, siteId }; }, - { addTerm } + { addTermForPost } )( localize( TermSelectorAddTerm ) ); diff --git a/client/post-editor/editor-term-selector/index.jsx b/client/post-editor/editor-term-selector/index.jsx index ceb47cb5c7406..53d81b1d3f995 100644 --- a/client/post-editor/editor-term-selector/index.jsx +++ b/client/post-editor/editor-term-selector/index.jsx @@ -10,7 +10,7 @@ import { cloneDeep, findIndex, map, toArray } from 'lodash'; */ import TermTreeSelector from 'my-sites/term-tree-selector'; import AddTerm from 'my-sites/term-tree-selector/add-term'; -import { editPost, addTermForPost } from 'state/posts/actions'; +import { editPost } from 'state/posts/actions'; import { getSelectedSiteId } from 'state/ui/selectors'; import { getEditorPostId } from 'state/ui/editor/selectors'; import { getEditedPostValue } from 'state/posts/selectors'; @@ -31,11 +31,6 @@ class EditorTermSelector extends Component { this.boundOnTermsChange = this.onTermsChange.bind( this ); } - onAddTerm = ( term ) => { - const { postId, taxonomyName, siteId } = this.props; - this.props.addTermForPost( siteId, taxonomyName, term, postId ); - } - onTermsChange( selectedTerm ) { const { postTerms, taxonomyName, siteId, postId } = this.props; const terms = cloneDeep( postTerms ) || {}; @@ -63,7 +58,7 @@ class EditorTermSelector extends Component { } render() { - const { postType, siteId, taxonomyName, canEditTerms } = this.props; + const { postType, postId, siteId, taxonomyName, canEditTerms } = this.props; return (
@@ -75,12 +70,7 @@ class EditorTermSelector extends Component { siteId={ siteId } multiple={ true } /> - { canEditTerms && - - } + { canEditTerms && }
); } @@ -99,5 +89,5 @@ export default connect( postId }; }, - { editPost, addTermForPost } + { editPost } )( EditorTermSelector ); diff --git a/client/state/index.js b/client/state/index.js index fc51b871b6120..a6072b3c0aa2b 100644 --- a/client/state/index.js +++ b/client/state/index.js @@ -8,6 +8,7 @@ import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; * Internal dependencies */ import noticesMiddleware from './notices/middleware'; +import postsEditMiddleware from './posts/middleware'; import application from './application/reducer'; import comments from './comments/reducer'; import componentsUsageStats from './components-usage-stats/reducer'; @@ -93,7 +94,7 @@ export const reducer = combineReducers( { wordads } ); -const middleware = [ thunkMiddleware, noticesMiddleware ]; +const middleware = [ thunkMiddleware, noticesMiddleware, postsEditMiddleware ]; if ( typeof window === 'object' ) { // Browser-specific middlewares diff --git a/client/state/posts/actions.js b/client/state/posts/actions.js index 338cc31773aa3..7207a2f0e8c44 100644 --- a/client/state/posts/actions.js +++ b/client/state/posts/actions.js @@ -1,14 +1,10 @@ -/** - * External dependencies - */ -import { isNumber, toArray } from 'lodash'; - /** * Internal dependencies */ import wpcom from 'lib/wp'; +import { extendAction } from 'state/utils'; import { normalizePostForApi } from './utils'; -import { getEditedPost } from 'state/posts/selectors'; +import { addTerm } from 'state/terms/actions'; import { POST_DELETE, POST_DELETE_SUCCESS, @@ -301,7 +297,9 @@ export function restorePost( siteId, postId ) { } /** - * Returns an action thunk which, when dispatched, adds a term to the current edited post + * Returns an action thunk which, when dispatched, triggers a network request + * to create a new term. All actions dispatched by the thunk will include meta + * to associate it with the specified post ID. * * @param {Number} siteId Site ID * @param {String} taxonomy Taxonomy Slug @@ -310,25 +308,5 @@ export function restorePost( siteId, postId ) { * @return {Function} Action thunk */ export function addTermForPost( siteId, taxonomy, term, postId ) { - return ( dispatch, getState ) => { - const state = getState(); - const post = getEditedPost( state, siteId, postId ); - - // if there is no post, no term, or term is temporary, bail - if ( ! post || ! term || ! isNumber( term.ID ) ) { - return; - } - - const postTerms = post.terms || {}; - - // ensure we have an array since API returns an object - const taxonomyTerms = toArray( postTerms[ taxonomy ] ); - taxonomyTerms.push( term ); - - dispatch( editPost( siteId, postId, { - terms: { - [ taxonomy ]: taxonomyTerms - } - } ) ); - }; + return extendAction( addTerm( siteId, taxonomy, term ), { postId } ); } diff --git a/client/state/posts/middleware.js b/client/state/posts/middleware.js new file mode 100644 index 0000000000000..0a03df0090df5 --- /dev/null +++ b/client/state/posts/middleware.js @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import { isNumber, toArray } from 'lodash'; + +/** + * Internal dependencies + */ +import { TERMS_RECEIVE } from 'state/action-types'; +import { getEditedPost } from 'state/posts/selectors'; +import { editPost } from 'state/posts/actions'; + +/** + * Dispatches editPost when a new term has been added + * that also has a postId on the action + * + * @param {Function} dispatch Dispatch method + * @param {Object} state Global state tree + * @param {Object} action Action object + */ +export function onTermsReceive( dispatch, state, action ) { + const { postId, taxonomy, terms, siteId } = action; + const post = getEditedPost( state, siteId, postId ); + const newTerm = terms[ 0 ]; + + // if there is no post, no term, or term is temporary, bail + if ( ! post || ! newTerm || ! isNumber( newTerm.ID ) ) { + return; + } + + const postTerms = post.terms || {}; + + // ensure we have an array since API returns an object + const taxonomyTerms = toArray( postTerms[ taxonomy ] ); + taxonomyTerms.push( newTerm ); + + dispatch( editPost( siteId, postId, { + terms: { + [ taxonomy ]: taxonomyTerms + } + } ) ); +} + +export default ( { dispatch, getState } ) => ( next ) => ( action ) => { + if ( TERMS_RECEIVE === action.type && action.hasOwnProperty( 'postId' ) ) { + onTermsReceive( dispatch, getState(), action ); + } + return next( action ); +}; diff --git a/client/state/posts/test/actions.js b/client/state/posts/test/actions.js index f875466c95ba6..0197e7e8aae40 100644 --- a/client/state/posts/test/actions.js +++ b/client/state/posts/test/actions.js @@ -7,7 +7,6 @@ import { expect } from 'chai'; /** * Internal dependencies */ -import PostQueryManager from 'lib/query-manager/post'; import { POST_DELETE, POST_DELETE_SUCCESS, @@ -26,7 +25,8 @@ import { POSTS_RECEIVE, POSTS_REQUEST, POSTS_REQUEST_SUCCESS, - POSTS_REQUEST_FAILURE + POSTS_REQUEST_FAILURE, + TERMS_RECEIVE } from 'state/action-types'; import { receivePost, @@ -544,58 +544,31 @@ describe( 'actions', () => { } ); describe( 'addTermForPost()', () => { - const postObject = { - ID: 841, - site_ID: 2916284, - global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', - title: 'Hello World' - }; - const getState = () => { - return { - posts: { - items: { - '3d097cb7c5473c169bba0eb8e3c6cb64': [ 2916284, 841 ] - }, - queries: { - 2916284: new PostQueryManager( { - items: { 841: postObject } - } ) - }, - edits: {} - } - }; - }; - - it( 'should dispatch a EDIT_POST event with the new term', () => { - addTermForPost( 2916284, 'jetpack-portfolio', { ID: 123, name: 'ribs' }, 841 )( spy, getState ); - expect( spy ).to.have.been.calledWith( { - post: { - terms: { - 'jetpack-portfolio': [ { - ID: 123, - name: 'ribs' - } ] - } - }, - postId: 841, - siteId: 2916284, - type: POST_EDIT - } ); - } ); - - it( 'should not dispatch anything if no post', () => { - addTermForPost( 2916284, 'jetpack-portfolio', { ID: 123, name: 'ribs' }, 3434 )( spy, getState ); - expect( spy ).not.to.have.been.called; - } ); - - it( 'should not dispatch anything if no term', () => { - addTermForPost( 2916284, 'jetpack-portfolio', null, 841 )( spy, getState ); - expect( spy ).not.to.have.been.called; + useNock( ( nock ) => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .post( '/rest/v1.1/sites/2916284/taxonomies/jetpack-portfolio/terms/new' ) + .reply( 200, { + ID: 123, + name: 'ribs', + description: '' + } ); } ); - it( 'should not dispatch anything if the term is temporary', () => { - addTermForPost( 2916284, 'jetpack-portfolio', { id: 'temporary' }, 841 )( spy, getState ); - expect( spy ).not.to.have.been.called; + it( 'should dispatch a TERMS_RECEIVE event on success with post meta', () => { + return addTermForPost( 2916284, 'jetpack-portfolio', { name: 'ribs' }, 13640 )( spy ).then( () => { + expect( spy ).to.have.been.calledWith( { + type: TERMS_RECEIVE, + siteId: 2916284, + taxonomy: 'jetpack-portfolio', + terms: [ { + ID: 123, + name: 'ribs', + description: '' + } ], + postId: 13640 + } ); + } ); } ); } ); } ); diff --git a/client/state/posts/test/middleware.js b/client/state/posts/test/middleware.js new file mode 100644 index 0000000000000..75b31f078650f --- /dev/null +++ b/client/state/posts/test/middleware.js @@ -0,0 +1,161 @@ +/** + * External dependencies + */ +import sinon from 'sinon'; +import { expect } from 'chai'; +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import { + POST_EDIT +} from 'state/action-types'; +import { onTermsReceive } from '../middleware'; +import PostQueryManager from 'lib/query-manager/post'; + +describe( 'middleware', () => { + const spy = sinon.spy(); + + beforeEach( () => { + spy.reset(); + } ); + + describe( 'onTermsReceive()', () => { + it( 'postEdit should not dispatch when no matching post exists', () => { + const action = { + site: 2916284, + taxonomy: 'category', + postId: 111, + terms: [ { + ID: 777, + name: 'chicken' + } ] + }; + onTermsReceive( spy, { posts: { queries: {}, items: {}, edits: {} } }, action ); + expect( spy ).to.not.have.been.called; + } ); + + it( 'should not dispatch a postEdit when a temporary term is added', () => { + const action = { + siteId: 2916284, + taxonomy: 'category', + postId: 841, + terms: [ { + ID: 'temporary1', + name: 'meat' + } ] + }; + + onTermsReceive( spy, { posts: { queries: {}, items: {}, edits: {} } }, action ); + expect( spy ).to.not.have.been.called; + } ); + + it( 'should dispatch a postEdit when a post exists', () => { + const action = { + siteId: 2916284, + taxonomy: 'category', + postId: 841, + terms: [ { + ID: 777, + name: 'meat' + } ] + }; + + const postObject = { + ID: 841, + site_ID: 2916284, + global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', + title: 'Ribs & Chicken' + }; + + const state = deepFreeze( { + posts: { + items: { + '3d097cb7c5473c169bba0eb8e3c6cb64': [ 2916284, 841 ] + }, + queries: { + 2916284: new PostQueryManager( { + items: { 841: postObject } + } ) + }, + edits: {} + } + } ); + + onTermsReceive( spy, state, action ); + expect( spy ).to.have.been.calledWith( { + type: POST_EDIT, + post: { + terms: { + category: [ { + ID: 777, + name: 'meat' + } ] + } + }, + siteId: 2916284, + postId: 841 + } ); + } ); + + it( 'should dispatch a postEdit with accumulated terms by taxonomy', () => { + const action = { + siteId: 2916284, + taxonomy: 'category', + postId: 841, + terms: [ { + ID: 790, + name: 'yummy' + } ] + }; + + const postObject = { + ID: 841, + site_ID: 2916284, + global_ID: '3d097cb7c5473c169bba0eb8e3c6cb64', + title: 'Ribs & Chicken', + terms: { + category: { + meat: { + ID: 777, + name: 'meat' + } + } + } + }; + + const state = deepFreeze( { + posts: { + items: { + '3d097cb7c5473c169bba0eb8e3c6cb64': [ 2916284, 841 ] + }, + queries: { + 2916284: new PostQueryManager( { + items: { 841: postObject } + } ) + }, + edits: {} + } + } ); + + onTermsReceive( spy, state, action ); + expect( spy ).to.have.been.calledWith( { + type: POST_EDIT, + post: { + terms: { + category: [ { + ID: 777, + name: 'meat' + }, { + ID: 790, + name: 'yummy' + } ] + } + }, + siteId: 2916284, + postId: 841 + } ); + } ); + } ); +} ); diff --git a/client/state/terms/actions.js b/client/state/terms/actions.js index b123ba4443c7b..550328b038c56 100644 --- a/client/state/terms/actions.js +++ b/client/state/terms/actions.js @@ -34,15 +34,9 @@ export function addTerm( siteId, taxonomy, term ) { } ) ); return wpcom.site( siteId ).taxonomy( taxonomy ).term().add( term ).then( - ( data ) => { - dispatch( receiveTerm( siteId, taxonomy, data ) ); - return data; - }, + ( data ) => dispatch( receiveTerm( siteId, taxonomy, data ) ), () => Promise.resolve() // Silently ignore failure so we can proceed to remove temporary - ).then( data => { - dispatch( removeTerm( siteId, taxonomy, temporaryId ) ); - return data; - } ); + ).then( () => dispatch( removeTerm( siteId, taxonomy, temporaryId ) ) ); }; }