@wordpress/editor
Version:
Enhanced block editor for WordPress posts.
570 lines (533 loc) • 13.8 kB
JavaScript
/**
* External dependencies
*/
import {
get,
unescape as unescapeString,
without,
find,
some,
invoke,
} from 'lodash';
/**
* WordPress dependencies
*/
import { __, _x, _n, sprintf } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import {
CheckboxControl,
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
*/
import { buildTermsTree } from '../../utils/terms';
/**
* Module Constants
*/
const DEFAULT_QUERY = {
per_page: -1,
orderby: 'name',
order: 'asc',
_fields: 'id,name,parent',
};
const MIN_TERMS_COUNT_FOR_FILTER = 8;
class HierarchicalTermSelector extends Component {
constructor() {
super( ...arguments );
this.findTerm = this.findTerm.bind( this );
this.onChange = this.onChange.bind( this );
this.onChangeFormName = this.onChangeFormName.bind( this );
this.onChangeFormParent = this.onChangeFormParent.bind( this );
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: '',
showForm: false,
filterValue: '',
filteredTermsTree: [],
};
}
onChange( termId ) {
const { onUpdateTerms, terms = [], taxonomy } = this.props;
const hasTerm = terms.indexOf( termId ) !== -1;
const newTerms = hasTerm
? without( terms, termId )
: [ ...terms, termId ];
onUpdateTerms( newTerms, taxonomy.rest_base );
}
onChangeFormName( event ) {
const newValue =
event.target.value.trim() === '' ? '' : event.target.value;
this.setState( { formName: newValue } );
}
onChangeFormParent( newParent ) {
this.setState( { formParent: newParent } );
}
onToggleForm() {
this.setState( ( state ) => ( {
showForm: ! state.showForm,
} ) );
}
findTerm( terms, parent, name ) {
return find( terms, ( term ) => {
return (
( ( ! term.parent && ! parent ) ||
parseInt( term.parent ) === parseInt( parent ) ) &&
term.name.toLowerCase() === name.toLowerCase()
);
} );
}
onAddTerm( event ) {
event.preventDefault();
const { onUpdateTerms, taxonomy, terms, slug } = this.props;
const { formName, formParent, adding, availableTerms } = this.state;
if ( formName === '' || adding ) {
return;
}
// check if the term we are adding already exists
const existingTerm = this.findTerm(
availableTerms,
formParent,
formName
);
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
);
}
this.setState( {
formName: '',
formParent: '',
} );
return;
}
this.setState( {
adding: true,
} );
this.addRequest = apiFetch( {
path: `/wp/v2/${ taxonomy.rest_base }`,
method: 'POST',
data: {
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.then(
( term ) => {
const hasTerm = !! find(
this.state.availableTerms,
( availableTerm ) => availableTerm.id === term.id
);
const newAvailableTerms = hasTerm
? this.state.availableTerms
: [ term, ...this.state.availableTerms ];
const termAddedMessage = sprintf(
/* translators: %s: taxonomy name */
_x( '%s added', 'term' ),
get(
this.props.taxonomy,
[ 'labels', 'singular_name' ],
slug === 'category' ? __( 'Category' ) : __( 'Term' )
)
);
this.props.speak( termAddedMessage, 'assertive' );
this.addRequest = null;
this.setState( {
adding: false,
formName: '',
formParent: '',
availableTerms: newAvailableTerms,
availableTermsTree: this.sortBySelected(
buildTermsTree( newAvailableTerms )
),
} );
onUpdateTerms( [ ...terms, term.id ], taxonomy.rest_base );
},
( xhr ) => {
if ( xhr.statusText === 'abort' ) {
return;
}
this.addRequest = null;
this.setState( {
adding: false,
} );
}
);
}
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 ) => {
if ( terms.indexOf( termTree.id ) !== -1 ) {
return true;
}
if ( undefined === termTree.children ) {
return false;
}
const anyChildIsSelected =
termTree.children
.map( treeHasSelection )
.filter( ( child ) => child ).length > 0;
if ( anyChildIsSelected ) {
return true;
}
return false;
};
const termOrChildIsSelected = ( termA, termB ) => {
const termASelected = treeHasSelection( termA );
const termBSelected = treeHasSelection( termB );
if ( termASelected === termBSelected ) {
return 0;
}
if ( termASelected && ! termBSelected ) {
return -1;
}
if ( ! termASelected && termBSelected ) {
return 1;
}
return 0;
};
termsTree.sort( termOrChildIsSelected );
return termsTree;
}
setFilterValue( event ) {
const { availableTermsTree } = this.state;
const filterValue = event.target.value;
const filteredTermsTree = availableTermsTree
.map( this.getFilterMatcher( filterValue ) )
.filter( ( term ) => term );
const getResultCount = ( terms ) => {
let count = 0;
for ( let i = 0; i < terms.length; i++ ) {
count++;
if ( undefined !== terms[ i ].children ) {
count += getResultCount( terms[ i ].children );
}
}
return count;
};
this.setState( {
filterValue,
filteredTermsTree,
} );
const resultCount = getResultCount( filteredTermsTree );
const resultsFoundMessage = sprintf(
/* translators: %d: number of results */
_n( '%d result found.', '%d results found.', resultCount ),
resultCount
);
this.props.debouncedSpeak( resultsFoundMessage, 'assertive' );
}
getFilterMatcher( filterValue ) {
const matchTermsForFilter = ( originalTerm ) => {
if ( '' === filterValue ) {
return originalTerm;
}
// Shallow clone, because we'll be filtering the term's children and
// don't want to modify the original term.
const term = { ...originalTerm };
// Map and filter the children, recursive so we deal with grandchildren
// and any deeper levels.
if ( term.children.length > 0 ) {
term.children = term.children
.map( matchTermsForFilter )
.filter( ( child ) => child );
}
// If the term's name contains the filterValue, or it has children
// (i.e. some child matched at some point in the tree) then return it.
if (
-1 !==
term.name
.toLowerCase()
.indexOf( filterValue.toLowerCase() ) ||
term.children.length > 0
) {
return term;
}
// Otherwise, return false. After mapping, the list of terms will need
// to have false values filtered out.
return false;
};
return matchTermsForFilter;
}
renderTerms( renderedTerms ) {
const { terms = [] } = this.props;
return renderedTerms.map( ( term ) => {
return (
<div
key={ term.id }
className="editor-post-taxonomies__hierarchical-terms-choice"
>
<CheckboxControl
checked={ terms.indexOf( term.id ) !== -1 }
onChange={ () => {
const termId = parseInt( term.id, 10 );
this.onChange( termId );
} }
label={ unescapeString( term.name ) }
/>
{ !! term.children.length && (
<div className="editor-post-taxonomies__hierarchical-terms-subchoices">
{ this.renderTerms( term.children ) }
</div>
) }
</div>
);
} );
}
render() {
const {
slug,
taxonomy,
instanceId,
hasCreateAction,
hasAssignAction,
} = this.props;
if ( ! hasAssignAction ) {
return null;
}
const {
availableTermsTree,
availableTerms,
filteredTermsTree,
formName,
formParent,
loading,
showForm,
filterValue,
} = this.state;
const labelWithFallback = (
labelProperty,
fallbackIsCategory,
fallbackIsNotCategory
) =>
get(
taxonomy,
[ 'labels', labelProperty ],
slug === 'category' ? fallbackIsCategory : fallbackIsNotCategory
);
const newTermButtonLabel = labelWithFallback(
'add_new_item',
__( 'Add new category' ),
__( 'Add new term' )
);
const newTermLabel = labelWithFallback(
'new_item_name',
__( 'Add new category' ),
__( 'Add new term' )
);
const parentSelectLabel = labelWithFallback(
'parent_item',
__( 'Parent Category' ),
__( 'Parent Term' )
);
const noParentOption = `— ${ parentSelectLabel } —`;
const newTermSubmitLabel = newTermButtonLabel;
const inputId = `editor-post-taxonomies__hierarchical-terms-input-${ instanceId }`;
const filterInputId = `editor-post-taxonomies__hierarchical-terms-filter-${ instanceId }`;
const filterLabel = get(
this.props.taxonomy,
[ 'labels', 'search_items' ],
__( 'Search Terms' )
);
const groupLabel = get(
this.props.taxonomy,
[ 'name' ],
__( 'Terms' )
);
const showFilter = availableTerms.length >= MIN_TERMS_COUNT_FOR_FILTER;
return [
showFilter && (
<label key="filter-label" htmlFor={ filterInputId }>
{ filterLabel }
</label>
),
showFilter && (
<input
type="search"
id={ filterInputId }
value={ filterValue }
onChange={ this.setFilterValue }
className="editor-post-taxonomies__hierarchical-terms-filter"
key="term-filter-input"
/>
),
<div
className="editor-post-taxonomies__hierarchical-terms-list"
key="term-list"
tabIndex="0"
role="group"
aria-label={ groupLabel }
>
{ this.renderTerms(
'' !== filterValue ? filteredTermsTree : availableTermsTree
) }
</div>,
! loading && hasCreateAction && (
<Button
key="term-add-button"
onClick={ this.onToggleForm }
className="editor-post-taxonomies__hierarchical-terms-add"
aria-expanded={ showForm }
isLink
>
{ newTermButtonLabel }
</Button>
),
showForm && (
<form onSubmit={ this.onAddTerm } key="hierarchical-terms-form">
<label
htmlFor={ inputId }
className="editor-post-taxonomies__hierarchical-terms-label"
>
{ newTermLabel }
</label>
<input
type="text"
id={ inputId }
className="editor-post-taxonomies__hierarchical-terms-input"
value={ formName }
onChange={ this.onChangeFormName }
required
/>
{ !! availableTerms.length && (
<TreeSelect
label={ parentSelectLabel }
noOptionLabel={ noParentOption }
onChange={ this.onChangeFormParent }
selectedId={ formParent }
tree={ availableTermsTree }
/>
) }
<Button
isSecondary
type="submit"
className="editor-post-taxonomies__hierarchical-terms-submit"
>
{ newTermSubmitLabel }
</Button>
</form>
),
];
}
}
export default compose( [
withSelect( ( select, { slug } ) => {
const { getCurrentPost } = select( 'core/editor' );
const { getTaxonomy } = select( 'core' );
const taxonomy = getTaxonomy( slug );
return {
hasCreateAction: taxonomy
? get(
getCurrentPost(),
[ '_links', 'wp:action-create-' + taxonomy.rest_base ],
false
)
: false,
hasAssignAction: taxonomy
? get(
getCurrentPost(),
[ '_links', 'wp:action-assign-' + taxonomy.rest_base ],
false
)
: false,
terms: taxonomy
? select( 'core/editor' ).getEditedPostAttribute(
taxonomy.rest_base
)
: [],
taxonomy,
};
} ),
withDispatch( ( dispatch ) => ( {
onUpdateTerms( terms, restBase ) {
dispatch( 'core/editor' ).editPost( { [ restBase ]: terms } );
},
} ) ),
withSpokenMessages,
withInstanceId,
withFilters( 'editor.PostTaxonomyType' ),
] )( HierarchicalTermSelector );