From e6a19c1faa444a01d4321908f24d7e7357790080 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Fri, 21 Sep 2018 11:33:16 +0100 Subject: [PATCH 01/19] Entities: Support adding and updating entities --- docs/data/data-core.md | 12 +++++- packages/core-data/src/actions.js | 31 +++++++++++++- packages/core-data/src/index.js | 14 +++++-- packages/core-data/src/test/actions.js | 42 +++++++++++++++++++ .../src/components/with-dispatch/index.js | 2 +- 5 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 packages/core-data/src/test/actions.js diff --git a/docs/data/data-core.md b/docs/data/data-core.md index 9730c7cdecc6bb..d6ea069a459ab7 100644 --- a/docs/data/data-core.md +++ b/docs/data/data-core.md @@ -182,4 +182,14 @@ a given URl has been received. *Parameters* * url: URL to preview the embed for. - * preview: Preview data. \ No newline at end of file + * preview: Preview data. + +### saveEntityRecord + +Action triggered to save an entity record. + +*Parameters* + + * kind: Kind of the received entity. + * name: Name of the received entity. + * record: Record to be saved. \ No newline at end of file diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index bcba6c6c4b93b5..1bc8917777a947 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { castArray } from 'lodash'; +import { castArray, find } from 'lodash'; /** * Internal dependencies @@ -10,6 +10,8 @@ import { receiveItems, receiveQueriedItems, } from './queried-data'; +import { getKindEntities } from './entities'; +import { apiFetch } from './controls'; /** * Returns an action object used in signalling that authors have been received. @@ -96,3 +98,30 @@ export function receiveEmbedPreview( url, preview ) { preview, }; } + +/** + * Action triggered to save an entity record. + * + * @param {string} kind Kind of the received entity. + * @param {string} name Name of the received entity. + * @param {Object} record Record to be saved. + * + * @return {Object} Updated record. + */ +export function* saveEntityRecord( kind, name, record ) { + const entities = yield getKindEntities( kind ); + const entity = find( entities, { kind, name } ); + if ( ! entity ) { + return; + } + const key = entity[ key ] || 'id'; + const recordId = record[ key ]; + const updatedRecord = yield apiFetch( { + path: `${ entity.baseURL }${ recordId ? '/' + recordId : '' }`, + method: recordId ? 'PUT' : 'POST', + data: record, + } ); + yield receiveEntityRecords( kind, name, updatedRecord ); + + return updatedRecord; +} diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index bd6ccc11218095..661406a84a19d9 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { pick } from 'lodash'; + /** * WordPress dependencies */ @@ -21,20 +26,23 @@ const createEntityRecordSelector = ( source ) => defaultEntities.reduce( ( resul return result; }, {} ); -const createEntityRecordResolver = ( source ) => defaultEntities.reduce( ( result, entity ) => { +const createEntityRecordResolverOrAction = ( source ) => defaultEntities.reduce( ( result, entity ) => { const { kind, name } = entity; result[ getMethodName( kind, name ) ] = ( key ) => source.getEntityRecord( kind, name, key ); result[ getMethodName( kind, name, 'get', true ) ] = ( ...args ) => source.getEntityRecords( kind, name, ...args ); return result; }, {} ); -const entityResolvers = createEntityRecordResolver( resolvers ); +const entityActions = createEntityRecordResolverOrAction( + pick( actions, [ 'saveEntityRecord' ] ) +); +const entityResolvers = createEntityRecordResolverOrAction( resolvers ); const entitySelectors = createEntityRecordSelector( selectors ); const store = registerStore( REDUCER_KEY, { reducer, - actions, controls, + actions: { ...actions, ...entityActions }, selectors: { ...selectors, ...entitySelectors }, resolvers: { ...resolvers, ...entityResolvers }, } ); diff --git a/packages/core-data/src/test/actions.js b/packages/core-data/src/test/actions.js new file mode 100644 index 00000000000000..041ddc87030f09 --- /dev/null +++ b/packages/core-data/src/test/actions.js @@ -0,0 +1,42 @@ +/** + * Internal dependencies + */ +import { saveEntityRecord, receiveEntityRecords } from '../actions'; + +describe( 'saveEntityRecord', () => { + it( 'triggers a POST request for a new record', async () => { + const post = { title: 'new post' }; + const entities = [ { name: 'post', kind: 'postType', baseURL: '/wp/v2/posts' } ]; + const fulfillment = saveEntityRecord( 'postType', 'post', post ); + // Trigger generator + fulfillment.next(); + // Provide entities and trigger apiFetch + const { value: apiFetchAction } = fulfillment.next( entities ); + expect( apiFetchAction.request ).toEqual( { + path: '/wp/v2/posts', + method: 'POST', + data: post, + } ); + // Provide response and trigger action + const { value: received } = fulfillment.next( { ...post, id: 10 } ); + expect( received ).toEqual( receiveEntityRecords( 'postType', 'post', { ...post, id: 10 } ) ); + } ); + + it( 'triggers a PUT request for an existing record', async () => { + const post = { id: 10, title: 'new post' }; + const entities = [ { name: 'post', kind: 'postType', baseURL: '/wp/v2/posts' } ]; + const fulfillment = saveEntityRecord( 'postType', 'post', post ); + // Trigger generator + fulfillment.next(); + // Provide entities and trigger apiFetch + const { value: apiFetchAction } = fulfillment.next( entities ); + expect( apiFetchAction.request ).toEqual( { + path: '/wp/v2/posts/10', + method: 'PUT', + data: post, + } ); + // Provide response and trigger action + const { value: received } = fulfillment.next( post ); + expect( received ).toEqual( receiveEntityRecords( 'postType', 'post', post ) ); + } ); +} ); diff --git a/packages/data/src/components/with-dispatch/index.js b/packages/data/src/components/with-dispatch/index.js index 85a7b0961a1aab..e16cc21d720d06 100644 --- a/packages/data/src/components/with-dispatch/index.js +++ b/packages/data/src/components/with-dispatch/index.js @@ -44,7 +44,7 @@ const withDispatch = ( mapDispatchToProps ) => createHigherOrderComponent( proxyDispatch( propName, ...args ) { // Original dispatcher is a pre-bound (dispatching) action creator. - mapDispatchToProps( this.props.registry.dispatch, this.props.ownProps )[ propName ]( ...args ); + return mapDispatchToProps( this.props.registry.dispatch, this.props.ownProps )[ propName ]( ...args ); } setProxyProps( props ) { From 28817ede82c4ea0d0be0caf72ceda48fd2f7b1a5 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Fri, 21 Sep 2018 12:16:22 +0100 Subject: [PATCH 02/19] Refactor the hierarchical term selector to use the data module instead of apiFetch --- .../hierarchical-term-selector.js | 161 ++++++------------ 1 file changed, 53 insertions(+), 108 deletions(-) diff --git a/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js b/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js index 698271cf89a07a..e63b453693d117 100644 --- a/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js +++ b/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { get, unescape as unescapeString, without, find, some, invoke } from 'lodash'; +import { get, unescape as unescapeString, without, find, some } from 'lodash'; /** * WordPress dependencies @@ -11,8 +11,6 @@ import { Component } from '@wordpress/element'; import { TreeSelect, withSpokenMessages, withFilters, Button } from '@wordpress/components'; import { withSelect, withDispatch } from '@wordpress/data'; import { withInstanceId, compose } from '@wordpress/compose'; -import apiFetch from '@wordpress/api-fetch'; -import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies @@ -41,11 +39,7 @@ class HierarchicalTermSelector extends Component { this.onAddTerm = this.onAddTerm.bind( this ); this.onToggleForm = this.onToggleForm.bind( this ); this.setFilterValue = this.setFilterValue.bind( this ); - this.sortBySelected = this.sortBySelected.bind( this ); this.state = { - loading: true, - availableTermsTree: [], - availableTerms: [], adding: false, formName: '', formParent: '', @@ -55,14 +49,18 @@ class HierarchicalTermSelector extends Component { }; } + componentWillUnmount() { + this.addRequest = null; + } + onChange( event ) { - const { onUpdateTerms, terms = [], taxonomy } = this.props; + const { onUpdateTerms, terms = [] } = this.props; const termId = parseInt( event.target.value, 10 ); const hasTerm = terms.indexOf( termId ) !== -1; const newTerms = hasTerm ? without( terms, termId ) : [ ...terms, termId ]; - onUpdateTerms( newTerms, taxonomy.rest_base ); + onUpdateTerms( newTerms ); } onChangeFormName( event ) { @@ -89,8 +87,8 @@ class HierarchicalTermSelector extends Component { onAddTerm( event ) { event.preventDefault(); - const { onUpdateTerms, taxonomy, terms, slug } = this.props; - const { formName, formParent, adding, availableTerms } = this.state; + const { onUpdateTerms, onSaveTerm, terms, slug, availableTerms, taxonomy } = this.props; + const { formName, formParent, adding } = this.state; if ( formName === '' || adding ) { return; } @@ -100,7 +98,7 @@ class HierarchicalTermSelector extends Component { if ( existingTerm ) { // if the term we are adding exists but is not selected select it if ( ! some( terms, ( term ) => term === existingTerm.id ) ) { - onUpdateTerms( [ ...terms, existingTerm.id ], taxonomy.rest_base ); + onUpdateTerms( [ ...terms, existingTerm.id ] ); } this.setState( { formName: '', @@ -112,41 +110,21 @@ class HierarchicalTermSelector extends Component { this.setState( { adding: true, } ); - this.addRequest = apiFetch( { - path: `/wp/v2/${ taxonomy.rest_base }`, - method: 'POST', - data: { - name: formName, - parent: formParent ? formParent : undefined, - }, + + this.addRequest = onSaveTerm( { + name: formName, + parent: formParent ? formParent : undefined, } ); - // Tries to create a term or fetch it if it already exists - const findOrCreatePromise = this.addRequest - .catch( ( error ) => { - const errorCode = error.code; - if ( errorCode === 'term_exists' ) { - // search the new category created since last fetch - this.addRequest = apiFetch( { - path: addQueryArgs( - `/wp/v2/${ taxonomy.rest_base }`, - { ...DEFAULT_QUERY, parent: formParent || 0, search: formName } - ), - } ); - return this.addRequest - .then( ( searchResult ) => { - return this.findTerm( searchResult, formParent, formName ); - } ); - } - return Promise.reject( error ); - } ); - findOrCreatePromise + + this.addRequest .then( ( term ) => { - const hasTerm = !! find( this.state.availableTerms, ( availableTerm ) => availableTerm.id === term.id ); - const newAvailableTerms = hasTerm ? this.state.availableTerms : [ term, ...this.state.availableTerms ]; + if ( this.addRequest === null ) { + return; + } const termAddedMessage = sprintf( _x( '%s added', 'term' ), get( - this.props.taxonomy, + taxonomy, [ 'data', 'labels', 'singular_name' ], slug === 'category' ? __( 'Category' ) : __( 'Term' ) ) @@ -157,12 +135,10 @@ class HierarchicalTermSelector extends Component { adding: false, formName: '', formParent: '', - availableTerms: newAvailableTerms, - availableTermsTree: this.sortBySelected( buildTermsTree( newAvailableTerms ) ), } ); - onUpdateTerms( [ ...terms, term.id ], taxonomy.rest_base ); - }, ( xhr ) => { - if ( xhr.statusText === 'abort' ) { + onUpdateTerms( [ ...terms, term.id ] ); + }, () => { + if ( this.addRequest === null ) { return; } this.addRequest = null; @@ -172,52 +148,6 @@ class HierarchicalTermSelector extends Component { } ); } - componentDidMount() { - this.fetchTerms(); - } - - componentWillUnmount() { - invoke( this.fetchRequest, [ 'abort' ] ); - invoke( this.addRequest, [ 'abort' ] ); - } - - componentDidUpdate( prevProps ) { - if ( this.props.taxonomy !== prevProps.taxonomy ) { - this.fetchTerms(); - } - } - - fetchTerms() { - const { taxonomy } = this.props; - if ( ! taxonomy ) { - return; - } - this.fetchRequest = apiFetch( { - path: addQueryArgs( `/wp/v2/${ taxonomy.rest_base }`, DEFAULT_QUERY ), - } ); - this.fetchRequest.then( - ( terms ) => { // resolve - const availableTermsTree = this.sortBySelected( buildTermsTree( terms ) ); - - this.fetchRequest = null; - this.setState( { - loading: false, - availableTermsTree, - availableTerms: terms, - } ); - }, - ( xhr ) => { // reject - if ( xhr.statusText === 'abort' ) { - return; - } - this.fetchRequest = null; - this.setState( { - loading: false, - } ); - } - ); - } - sortBySelected( termsTree ) { const { terms } = this.props; const treeHasSelection = ( termTree ) => { @@ -256,7 +186,7 @@ class HierarchicalTermSelector extends Component { } setFilterValue( event ) { - const { availableTermsTree } = this.state; + const { availableTermsTree } = this.props; const filterValue = event.target.value; const filteredTermsTree = availableTermsTree.map( this.getFilterMatcher( filterValue ) ).filter( ( term ) => term ); const getResultCount = ( terms ) => { @@ -269,12 +199,10 @@ class HierarchicalTermSelector extends Component { } return count; }; - this.setState( - { - filterValue, - filteredTermsTree, - } - ); + this.setState( { + filterValue, + filteredTermsTree, + } ); const resultCount = getResultCount( filteredTermsTree ); const resultsFoundMessage = sprintf( @@ -339,13 +267,21 @@ class HierarchicalTermSelector extends Component { } render() { - const { slug, taxonomy, instanceId, hasCreateAction, hasAssignAction } = this.props; + const { + slug, + taxonomy, + instanceId, + hasCreateAction, + hasAssignAction, + availableTermsTree, + availableTerms, + } = this.props; if ( ! hasAssignAction ) { return null; } - const { availableTermsTree, availableTerms, filteredTermsTree, formName, formParent, loading, showForm, filterValue } = this.state; + const { filteredTermsTree, formName, formParent, isRequestingTerms, showForm, filterValue } = this.state; const labelWithFallback = ( labelProperty, fallbackIsCategory, fallbackIsNotCategory ) => get( taxonomy, [ 'data', 'labels', labelProperty ], @@ -409,9 +345,9 @@ class HierarchicalTermSelector extends Component { role="group" aria-label={ groupLabel } > - { this.renderTerms( '' !== filterValue ? filteredTermsTree : availableTermsTree ) } + { this.renderTerms( '' !== filterValue ? filteredTermsTree : this.sortBySelected( availableTermsTree ) ) } , - ! loading && hasCreateAction && ( + ! isRequestingTerms && hasCreateAction && (