diff --git a/packages/editor/src/components/post-taxonomies/flat-term-selector.js b/packages/editor/src/components/post-taxonomies/flat-term-selector.js index decc8953ec9c5..12d800c72da3f 100644 --- a/packages/editor/src/components/post-taxonomies/flat-term-selector.js +++ b/packages/editor/src/components/post-taxonomies/flat-term-selector.js @@ -7,9 +7,7 @@ import { get, invoke, isEmpty, - map, throttle, - unescape as lodashUnescapeString, uniqBy, } from 'lodash'; @@ -18,12 +16,24 @@ import { */ import { __, _x, sprintf } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; -import { FormTokenField, withFilters } from '@wordpress/components'; +import { + FormTokenField, + withFilters, + withSpokenMessages, +} from '@wordpress/components'; import { withSelect, withDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; import { compose } from '@wordpress/compose'; import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { store as editorStore } from '../../store'; +import { unescapeString, unescapeTerm, unescapeTerms } from '../../utils/terms'; +import MostUsedTerms from './most-used-terms'; + /** * Module constants */ @@ -32,42 +42,18 @@ const DEFAULT_QUERY = { per_page: MAX_TERMS_SUGGESTIONS, orderby: 'count', order: 'desc', - _fields: 'id,name', + _fields: 'id,name,count', }; -// Lodash unescape function handles ' but not ' which may be return in some API requests. -const unescapeString = ( arg ) => { - return lodashUnescapeString( arg.replace( ''', "'" ) ); -}; const isSameTermName = ( termA, termB ) => unescapeString( termA ).toLowerCase() === unescapeString( termB ).toLowerCase(); -/** - * Returns a term object with name unescaped. - * The unescape of the name property is done using lodash unescape function. - * - * @param {Object} term The term object to unescape. - * - * @return {Object} Term object with name property unescaped. - */ -const unescapeTerm = ( term ) => { - return { - ...term, - name: unescapeString( term.name ), - }; -}; - -/** - * Returns an array of term objects with names unescaped. - * The unescape of each term is performed using the unescapeTerm function. - * - * @param {Object[]} terms Array of term objects to unescape. - * - * @return {Object[]} Array of term objects unescaped. - */ -const unescapeTerms = ( terms ) => { - return map( terms, unescapeTerm ); +const termNamesToIds = ( names, terms ) => { + return names.map( + ( termName ) => + find( terms, ( term ) => isSameTermName( term.name, termName ) ).id + ); }; class FlatTermSelector extends Component { @@ -76,6 +62,7 @@ class FlatTermSelector extends Component { this.onChange = this.onChange.bind( this ); this.searchTerms = throttle( this.searchTerms.bind( this ), 500 ); this.findOrCreateTerm = this.findOrCreateTerm.bind( this ); + this.appendTerm = this.appendTerm.bind( this ); this.state = { loading: ! isEmpty( this.props.terms ), availableTerms: [], @@ -197,14 +184,6 @@ class FlatTermSelector extends Component { isSameTermName( term.name, termName ) ) ); - const termNamesToIds = ( names, availableTerms ) => { - return names.map( - ( termName ) => - find( availableTerms, ( term ) => - isSameTermName( term.name, termName ) - ).id - ); - }; if ( newTermNames.length === 0 ) { return this.props.onUpdateTerms( @@ -233,6 +212,34 @@ class FlatTermSelector extends Component { } } + appendTerm( newTerm ) { + const { onUpdateTerms, taxonomy, terms = [], slug, speak } = this.props; + + if ( terms.includes( newTerm.id ) ) { + return; + } + + const newTerms = [ ...terms, newTerm.id ]; + + const termAddedMessage = sprintf( + /* translators: %s: term name. */ + _x( '%s added', 'term' ), + get( + taxonomy, + [ 'labels', 'singular_name' ], + slug === 'post_tag' ? __( 'Tag' ) : __( 'Term' ) + ) + ); + + speak( termAddedMessage, 'assertive' ); + + this.setState( { + availableTerms: [ ...this.state.availableTerms, newTerm ], + } ); + + onUpdateTerms( newTerms, taxonomy.rest_base ); + } + render() { const { slug, taxonomy, hasAssignAction } = this.props; @@ -269,28 +276,34 @@ class FlatTermSelector extends Component { ); return ( - + <> + + + ); } } export default compose( withSelect( ( select, { slug } ) => { - const { getCurrentPost } = select( 'core/editor' ); - const { getTaxonomy } = select( 'core' ); + const { getCurrentPost } = select( editorStore ); + const { getTaxonomy } = select( coreStore ); const taxonomy = getTaxonomy( slug ); return { hasCreateAction: taxonomy @@ -308,7 +321,7 @@ export default compose( ) : false, terms: taxonomy - ? select( 'core/editor' ).getEditedPostAttribute( + ? select( editorStore ).getEditedPostAttribute( taxonomy.rest_base ) : [], @@ -318,9 +331,10 @@ export default compose( withDispatch( ( dispatch ) => { return { onUpdateTerms( terms, restBase ) { - dispatch( 'core/editor' ).editPost( { [ restBase ]: terms } ); + dispatch( editorStore ).editPost( { [ restBase ]: terms } ); }, }; } ), + withSpokenMessages, withFilters( 'editor.PostTaxonomyType' ) )( FlatTermSelector ); diff --git a/packages/editor/src/components/post-taxonomies/most-used-terms.js b/packages/editor/src/components/post-taxonomies/most-used-terms.js new file mode 100644 index 0000000000000..da3539b05457e --- /dev/null +++ b/packages/editor/src/components/post-taxonomies/most-used-terms.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import { get } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { unescapeTerms } from '../../utils/terms'; + +const MAX_MOST_USED_TERMS = 10; +const DEFAULT_QUERY = { + per_page: MAX_MOST_USED_TERMS, + orderby: 'count', + order: 'desc', + _fields: 'id,name,count', +}; + +export default function MostUsedTerms( { onSelect, taxonomy } ) { + const { _terms, showTerms } = useSelect( ( select ) => { + const mostUsedTerms = select( coreStore ).getEntityRecords( + 'taxonomy', + taxonomy.slug, + DEFAULT_QUERY + ); + return { + _terms: mostUsedTerms, + showTerms: mostUsedTerms?.length >= MAX_MOST_USED_TERMS, + }; + }, [] ); + + if ( ! showTerms ) { + return null; + } + + const terms = unescapeTerms( _terms ); + const label = get( taxonomy, [ 'labels', 'most_used' ] ); + + return ( +
+

+ { label } +

+ { /* + * Disable reason: The `list` ARIA role is redundant but + * Safari+VoiceOver won't announce the list otherwise. + */ + /* eslint-disable jsx-a11y/no-redundant-roles */ } +
    + { terms.map( ( term ) => ( +
  • + +
  • + ) ) } +
+ { /* eslint-enable jsx-a11y/no-redundant-roles */ } +
+ ); +} diff --git a/packages/editor/src/components/post-taxonomies/style.scss b/packages/editor/src/components/post-taxonomies/style.scss index 232ac77059769..089c549111b4d 100644 --- a/packages/editor/src/components/post-taxonomies/style.scss +++ b/packages/editor/src/components/post-taxonomies/style.scss @@ -36,3 +36,23 @@ margin-bottom: 8px; width: 100%; } + +.editor-post-taxonomies__flat-term-most-used { + .editor-post-taxonomies__flat-term-most-used-label { + font-weight: 400; + margin-bottom: $grid-unit-15; + } +} + +.editor-post-taxonomies__flat-term-most-used-list { + margin: 0; + + li { + display: inline-block; + margin-right: $grid-unit-10; + } + + .components-button { + font-size: 12px; + } +} diff --git a/packages/editor/src/utils/terms.js b/packages/editor/src/utils/terms.js index 60cfdd8563d0b..75c37b2714efa 100644 --- a/packages/editor/src/utils/terms.js +++ b/packages/editor/src/utils/terms.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { groupBy } from 'lodash'; +import { groupBy, map, unescape as lodashUnescapeString } from 'lodash'; /** * Returns terms in a tree form. @@ -38,3 +38,35 @@ export function buildTermsTree( flatTerms ) { return fillWithChildren( termsByParent[ '0' ] || [] ); } + +// Lodash unescape function handles ' but not ' which may be return in some API requests. +export const unescapeString = ( arg ) => { + return lodashUnescapeString( arg.replace( ''', "'" ) ); +}; + +/** + * Returns a term object with name unescaped. + * The unescape of the name property is done using lodash unescape function. + * + * @param {Object} term The term object to unescape. + * + * @return {Object} Term object with name property unescaped. + */ +export const unescapeTerm = ( term ) => { + return { + ...term, + name: unescapeString( term.name ), + }; +}; + +/** + * Returns an array of term objects with names unescaped. + * The unescape of each term is performed using the unescapeTerm function. + * + * @param {Object[]} terms Array of term objects to unescape. + * + * @return {Object[]} Array of term objects unescaped. + */ +export const unescapeTerms = ( terms ) => { + return map( terms, unescapeTerm ); +};