UNPKG

@vermilion/post-selector

Version:

A Gutenberg component that allows you to attach posts and pages to a custom block

386 lines (344 loc) 11.9 kB
const { Component, Fragment } = wp.element; const { decodeEntities } = wp.htmlEntities; const { UP, DOWN, ENTER } = wp.keycodes; const { Spinner, Popover, IconButton } = wp.components; const { withInstanceId } = wp.compose; const { withSelect } = wp.data; const { apiFetch } = wp; const { addQueryArgs } = wp.url; const stopEventPropagation = event => event.stopPropagation(); const subtypeStyle = { border: '3px solid lightgrey', padding: '5px', borderRadius: '7px', marginRight: '10px', fontSize: '80%' } function debounce(func, wait = 100) { let timeout; return function (...args) { clearTimeout(timeout); timeout = setTimeout(() => { func.apply(this, args); }, wait); }; } class PostSelector extends Component { /** * ===== Available Props ======= * * posts <Array> of Post Objects, must include ID and title. * data <Array> array of post properties to return (top level only right now) * postType = <String> singular name of post type to restrict results to. * onPostSelect <Function> callback for when a new post is selected. * onChange <Function> callback for when posts are deleted or rearranged. * limit <Number> limit selection to posts to X number of posts. * */ constructor() { super(...arguments); this.onChange = this.onChange.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.bindListNode = this.bindListNode.bind(this); this.updateSuggestions = debounce(this.updateSuggestions.bind(this), 200); this.limit = this.props.limit ? parseInt(this.props.limit) : false; this.suggestionNodes = []; this.postTypes = null; this.state = { posts: [], showSuggestions: false, selectedSuggestion: null, input: '' }; } componentDidUpdate() { } componentWillUnmount() { delete this.suggestionsRequest; } bindListNode(ref) { this.listNode = ref; } bindSuggestionNode(index) { return ref => { this.suggestionNodes[index] = ref; }; } updateSuggestions(value) { // Show the suggestions after typing at least 2 characters // and also for URLs if (value.length < 2 || /^https?:/.test(value)) { this.setState({ showSuggestions: false, selectedSuggestion: null, loading: false }); return; } this.setState({ showSuggestions: true, selectedSuggestion: null, loading: true }); const request = apiFetch({ path: addQueryArgs('/wp/v2/search', { search: value, per_page: 20, type: 'post', subtype: this.props.postType ? this.props.postType : undefined }) }); request .then(posts => { // A fetch Promise doesn't have an abort option. It's mimicked by // comparing the request reference in on the instance, which is // reset or deleted on subsequent requests or unmounting. if (this.suggestionsRequest !== request) { return; } this.setState({ posts, loading: false }); }) .catch(() => { if (this.suggestionsRequest === request) { this.setState({ loading: false }); } }); this.suggestionsRequest = request; } onChange(event) { const inputValue = event.target.value; this.setState({ input: inputValue }); this.updateSuggestions(inputValue); } onKeyDown(event) { const { showSuggestions, selectedSuggestion, posts, loading } = this.state; // If the suggestions are not shown or loading, we shouldn't handle the arrow keys // We shouldn't preventDefault to allow block arrow keys navigation if (!showSuggestions || !posts.length || loading) { return; } switch (event.keyCode) { case UP: { event.stopPropagation(); event.preventDefault(); const previousIndex = !selectedSuggestion ? posts.length - 1 : selectedSuggestion - 1; this.setState({ selectedSuggestion: previousIndex }); break; } case DOWN: { event.stopPropagation(); event.preventDefault(); const nextIndex = selectedSuggestion === null || selectedSuggestion === posts.length - 1 ? 0 : selectedSuggestion + 1; this.setState({ selectedSuggestion: nextIndex }); break; } case ENTER: { if (this.state.selectedSuggestion !== null) { event.stopPropagation(); const post = this.state.posts[this.state.selectedSuggestion]; this.selectLink(post); } } } } selectLink(post) { // get the "full" post data if a post was selected. this may be something to add as a prop in the future for custom use cases. if (this.props.data) { // if data already exists in the post object, there's no need to make an API call. let reachOutToApi = false; const returnData = {}; for (const prop of this.props.data) { if (!post.hasOwnProperty(prop)) { reachOutToApi = true; return; } returnData[prop] = post[prop]; } if (!reachOutToApi) { this.props.onPostSelect(returnData); this.setState({ input: '', selectedSuggestion: null, showSuggestions: false }); return; } } // get the base of the URL for the post API request const restBase = this.getPostTypeData(post.subtype).restBase; apiFetch({ path: `/wp/v2/${restBase}/${post.id}` }).then(response => { // console.log( response ); const fullpost = { title: decodeEntities(response.title.rendered), id: response.id, excerpt: decodeEntities(response.excerpt.rendered), url: response.link, date: response.date, type: response.type, status: response.status }; // send data to the block; this.props.onPostSelect(fullpost); }); this.setState({ input: '', selectedSuggestion: null, showSuggestions: false }); } renderSelectedPosts() { // show each post in the list. return ( <ul> {this.props.posts.map((post, i) => ( <li style={{ display: 'flex', justifyContent: 'flex-start', alignItems: 'center', flexWrap: 'nowrap' }} key={post.id}> { /* render the post type if we have the data to support it */ this.hasPostTypeData() && <span style={subtypeStyle}>{this.getPostTypeData(post.type).displayName}</span> } <span style={{ flex: 1 }}>{post.title}</span> <span> {i !== 0 ? ( <IconButton style={{ display: 'inline-flex', padding: '8px 2px', textAlign: 'center' }} icon="arrow-up-alt2" onClick={() => { this.props.posts.splice(i - 1, 0, this.props.posts.splice(i, 1)[0]); this.props.onChange(this.props.posts); this.setState({ state: this.state }); }} /> ) : null} {i !== this.props.posts.length - 1 ? ( <IconButton style={{ display: 'inline-flex', padding: '8px 2px', textAlign: 'center' }} icon="arrow-down-alt2" onClick={() => { this.props.posts.splice(i + 1, 0, this.props.posts.splice(i, 1)[0]); this.props.onChange(this.props.posts); this.setState({ state: this.state }); }} /> ) : null} <IconButton style={{ display: 'inline-flex', textAlign: 'center' }} icon="no" onClick={() => { this.props.posts.splice(i, 1); this.props.onChange(this.props.posts); // force a re-render. this.setState({ state: this.state }); }} /> </span> </li> ))} </ul> ); } resolvePostTypes(sourcePostTypes) { // check if the post types have already been resolved if (this.postTypes !== null) { return; } // check if we have the source post types from the API if (sourcePostTypes == null) { return; } // transform the source post types from the API // into the data we need and put it in a map const arr = sourcePostTypes.map((p) => { return [p.slug, { slug: p.slug, displayName: p.labels.singular_name, restBase: p.rest_base }] }) this.postTypes = new Map(arr); } // get the post type data getPostTypeData(slug) { if (!this.hasPostTypeData()) { return {} } return this.postTypes.get(slug); } hasPostTypeData() { return this.postTypes !== null; } render() { this.resolvePostTypes(this.props.sourcePostTypes); const { autoFocus = true, instanceId, limit } = this.props; const { showSuggestions, posts, selectedSuggestion, loading, input } = this.state; const inputDisabled = !!limit && this.props.posts.length >= limit; /* eslint-disable jsx-a11y/no-autofocus */ return ( <Fragment> {this.renderSelectedPosts()} <div className="block-editor-url-input"> <input autoFocus={autoFocus} type="text" aria-label={'URL'} required value={input} onChange={this.onChange} onInput={stopEventPropagation} placeholder={inputDisabled ? `Limted to ${limit} posts` : 'Type page or post name'} onKeyDown={this.onKeyDown} role="combobox" aria-expanded={showSuggestions} aria-autocomplete="list" aria-owns={`block-editor-url-input-suggestions-${instanceId}`} aria-activedescendant={selectedSuggestion !== null ? `block-editor-url-input-suggestion-${instanceId}-${selectedSuggestion}` : undefined} style={{ width: '100%' }} disabled={inputDisabled} /> {loading && <Spinner />} </div> {showSuggestions && !!posts.length && ( <Popover position="bottom" noArrow focusOnMount={false}> <div className="block-editor-url-input__suggestions" id={`block-editor-url-input-suggestions-${instanceId}`} ref={this.bindListNode} role="listbox"> {posts.map((post, index) => ( <button key={post.id} role="option" tabIndex="-1" id={`block-editor-url-input-suggestion-${instanceId}-${index}`} ref={this.bindSuggestionNode(index)} className={`block-editor-url-input__suggestion ${index === selectedSuggestion ? 'is-selected' : ''}`} onClick={() => this.selectLink(post)} aria-selected={index === selectedSuggestion} > <div style={{ display: 'flex', alignItems: 'center' }}> { /* render the post type if we have the data to support it */ this.hasPostTypeData() && <div style={subtypeStyle}>{this.getPostTypeData(post.subtype).displayName}</div> } <div>{decodeEntities(post.title) || '(no title)'}</div> </div> </button> ))} </div> </Popover> )} </Fragment> ); /* eslint-enable jsx-a11y/no-autofocus */ } } export default withSelect((select) => { const { getPostTypes } = select('core'); return { sourcePostTypes: getPostTypes() } })(withInstanceId(PostSelector));