From 82e91affa59d3256251d495be32865799f5151fd Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Thu, 18 Jul 2019 12:24:24 -0600 Subject: [PATCH] page search, first pass --- package-lock.json | 14 ++ .../sidebar/page-attributes/index.js | 7 +- packages/editor/package.json | 1 + .../src/components/page-attributes/parent.js | 199 +++++++++++++++--- .../src/components/page-attributes/style.scss | 96 +++++++++ 5 files changed, 292 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e27a7383cf08a..dc6d8296654eab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4040,6 +4040,7 @@ "@wordpress/url": "file:packages/url", "@wordpress/viewport": "file:packages/viewport", "@wordpress/wordcount": "file:packages/wordcount", + "accessible-autocomplete": "^1.6.2", "classnames": "^2.2.5", "inherits": "^2.0.3", "lodash": "^4.17.14", @@ -4396,6 +4397,14 @@ "negotiator": "0.6.1" } }, + "accessible-autocomplete": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/accessible-autocomplete/-/accessible-autocomplete-1.6.2.tgz", + "integrity": "sha512-7S+6Vi82LQFSSd5feKedu46tiY2/DShpdXiRp0NY3cLwc+DKe1ayWd66mb3JVi8LTQubRM7jco+u92e6w0bbvg==", + "requires": { + "preact": "^8.3.1" + } + }, "acorn": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.1.tgz", @@ -20366,6 +20375,11 @@ "integrity": "sha512-jL6eFIzoN3xUEvbo33OAkSDE2VIKU4JQ1wENOows1DpfnrdapR/K3Q1/fB43Mq7wQlcSgRm23nFrvoioufM7eA==", "dev": true }, + "preact": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-8.4.2.tgz", + "integrity": "sha512-TsINETWiisfB6RTk0wh3/mvxbGRvx+ljeBccZ4Z6MPFKgu/KFGyf2Bmw3Z/jlXhL5JlNKY6QAbA9PVyzIy9//A==" + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", diff --git a/packages/edit-post/src/components/sidebar/page-attributes/index.js b/packages/edit-post/src/components/sidebar/page-attributes/index.js index 17944112b43ae1..584a3c4cb01808 100644 --- a/packages/edit-post/src/components/sidebar/page-attributes/index.js +++ b/packages/edit-post/src/components/sidebar/page-attributes/index.js @@ -29,10 +29,15 @@ export function PageAttributes( { isEnabled, isOpened, onTogglePanel, postType } onToggle={ onTogglePanel } > - + + + + +

+ ); diff --git a/packages/editor/package.json b/packages/editor/package.json index 88ddf540a926aa..bc51b55d02ef5e 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -44,6 +44,7 @@ "@wordpress/url": "file:../url", "@wordpress/viewport": "file:../viewport", "@wordpress/wordcount": "file:../wordcount", + "accessible-autocomplete": "^1.6.2", "classnames": "^2.2.5", "inherits": "^2.0.3", "lodash": "^4.17.14", diff --git a/packages/editor/src/components/page-attributes/parent.js b/packages/editor/src/components/page-attributes/parent.js index 6032794e754227..c14d1d16381c2a 100644 --- a/packages/editor/src/components/page-attributes/parent.js +++ b/packages/editor/src/components/page-attributes/parent.js @@ -1,44 +1,195 @@ /** * External dependencies */ -import { get } from 'lodash'; +import { get, debounce } from 'lodash'; +import Autocomplete from 'accessible-autocomplete/react'; /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { sprintf, __, _n } from '@wordpress/i18n'; import { TreeSelect } from '@wordpress/components'; import { compose } from '@wordpress/compose'; import { withSelect, withDispatch } from '@wordpress/data'; +import { Component } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; /** * Internal dependencies */ import { buildTermsTree } from '../../utils/terms'; -export function PageAttributesParent( { parent, postType, items, onUpdateParent } ) { - const isHierarchical = get( postType, [ 'hierarchical' ], false ); - const parentPageLabel = get( postType, [ 'labels', 'parent_item_colon' ] ); - const pageItems = items || []; - if ( ! isHierarchical || ! parentPageLabel || ! pageItems.length ) { - return null; +export class PageAttributesParent extends Component { + constructor() { + super( ...arguments ); + this.searchCache = []; + this.getCurrentParentFromAPI = this.getCurrentParentFromAPI.bind( this ); + this.handleSelection = this.handleSelection.bind( this ); + this.suggestPage = this.suggestPage.bind( this ); + + this.requestResults = debounce( ( query, populateResults ) => { + const payload = '?search=' + encodeURIComponent( query ); + apiFetch( { path: `/wp/v2/pages${ payload }` } ).then( ( results ) => { + populateResults( this.resolveResults( results ) ); + this.searchCache[ query ] = results; + } ); + }, 300 ); + this.state = { + parentPost: false, + }; + } + + /** + * Retrieve the parent page by id. + * + * @param {number} parentId The id of the parent to fetch. + */ + async getCurrentParentFromAPI( parentId ) { + if ( ! parentId ) { + return ''; + } + const parentPost = await apiFetch( { path: `/wp/v2/pages/${ parentId }` } ); + this.setState( { + parentPost, + } ); + } + + /** + * Resolve the results for display. + * + * @param {Array} results The array of pages that matched the search. + * + * @return {Array} an array of strings ready for displaying. + */ + resolveResults( results ) { + return results.map( ( item ) => item.title.rendered ? `${ item.title.rendered } (#${ item.id })` : `${ __( 'no title' ) } (#${ item.id })` ); } - const pagesTree = buildTermsTree( pageItems.map( ( item ) => ( { - id: item.id, - parent: item.parent, - name: item.title.raw ? item.title.raw : `#${ item.id } (${ __( 'no title' ) })`, - } ) ) ); - return ( - - ); + handleSelection( selection ) { + const { onUpdateParent } = this.props; + + // Extract the id from the selection. + const matches = selection.match( /.*\(#(\d*)\)$/ ); + if ( matches && matches[ 1 ] ) { + onUpdateParent( matches[ 1 ] ); + } + } + + /** + * Search for pages that match the passed query, passing them to a callback function when resolved. + * + * @param {string} query The search query. + * @param {Function} populateResults A callback function which receives the results. + */ + suggestPage( query, populateResults ) { + const { items } = this.props; + + if ( query === items ) { + populateResults( this.resolveResults( items ) ); + return; + } + + if ( query.length < 2 ) { + populateResults( this.resolveResults( items ) ); + return; + } + + if ( this.searchCache[ query ] ) { + populateResults( this.resolveResults( this.searchCache[ query ] ) ); + return; + } + + this.requestResults( query, populateResults ); + } + + render() { + const { postType, items, onUpdateParent, parent } = this.props; + const { parentPost } = this.state; + let currentParent = false; + + if ( ! parentPost && false !== parent ) { + if ( 0 === parent ) { + currentParent = ''; + } else { + // We have the parent page id, we need to display its name. + const currentParentFromItems = items && items.find( ( item ) => { + return item.id === parent; + } ); + + // Set or fetch the current author. + if ( currentParentFromItems ) { + this.setState( { + parentPost: parent, + } ); + } else { + this.getCurrentParentFromAPI( parent ); + } + } + } + + const isHierarchical = get( postType, [ 'hierarchical' ], false ); + const parentPageLabel = get( postType, [ 'labels', 'parent_item_colon' ] ); + const pageItems = items || []; + if ( ! isHierarchical || ! parentPageLabel || ! pageItems.length ) { + return null; + } + + if ( false === currentParent ) { + currentParent = parentPost && parentPost.title.rendered ? + `${ parentPost.title.rendered } (#${ parentPost.id })` : + `${ __( 'no title' ) } (#${ parentPost.id })`; + } + + if ( items.length > 99 ) { + return ( + <> + + + // translators: %d: the number characters required to initiate a page search. + sprintf( __( 'Type in %d or more characters for results' ), minQueryLength ) + } + tStatusNoResults={ () => __( 'No search results' ) } + // translators: 1: the index of thre selected result. 2: The total number of results. + tStatusSelectedOption={ ( selectedOption, length ) => sprintf( __( '%1$s (1 of %2$s) is selected' ), selectedOption, length ) } + tStatusResults={ ( length, contentSelectedOption ) => { + return ( + _n( '%d result is available.', '%d results are available.', length ) + + ' ' + contentSelectedOption + ); + } } + cssNamespace="components-parent-page__autocomplete" + /> + + ); + } + + const pagesTree = buildTermsTree( pageItems.map( ( item ) => ( { + id: item.id, + parent: item.parent, + name: item.title.rendered ? `${ item.title.rendered } (#${ item.id })` : `${ __( 'no title' ) } (#${ item.id })`, + } ) ) ); + + return ( + + ); + } } const applyWithSelect = withSelect( ( select ) => { @@ -49,7 +200,7 @@ const applyWithSelect = withSelect( ( select ) => { const postId = getCurrentPostId(); const isHierarchical = get( postType, [ 'hierarchical' ], false ); const query = { - per_page: -1, + per_page: 100, exclude: postId, parent_exclude: postId, orderby: 'menu_order', diff --git a/packages/editor/src/components/page-attributes/style.scss b/packages/editor/src/components/page-attributes/style.scss index 53c1de5260961f..e3caa85ab1de4e 100644 --- a/packages/editor/src/components/page-attributes/style.scss +++ b/packages/editor/src/components/page-attributes/style.scss @@ -18,3 +18,99 @@ width: 66px; } } + +.components-parent-page__autocomplete__wrapper { + box-sizing: border-box; + position: relative; +} + +.components-parent-page__autocomplete__hint, +.components-parent-page__autocomplete__input { + -webkit-appearance: none; + border: 2px solid; + border-radius: 0; /* Safari 10 on iOS adds implicit border rounding. */ + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + margin-bottom: 0; /* BUG: Safari 10 on macOS seems to add an implicit margin. */ + width: 100%; +} + +.components-parent-page__autocomplete__input { + background-color: transparent; + position: relative; +} + +.components-parent-page__autocomplete__hint { + position: absolute; +} + +.components-parent-page__autocomplete__dropdown-arrow-down { + display: none; +} + +.components-parent-page__autocomplete__menu { + background: $white; + border-top: 0; + color: $dark-gray-300; + margin: 0; + padding: #{$grid-size-small / 2} 0; + overflow-x: overlay; + overflow-y: auto; + max-height: 96px; + transition: all 0.15s ease-in-out; + width: calc(100% + 2px); + + &.components-parent-page__autocomplete__menu--visible { + display: block; + } + + &.components-parent-page__autocomplete__menu--hidden { + display: none; + } +} + +.components-parent-page__autocomplete__menu--overlay { + box-shadow: $shadow-popover; + border: $border-width solid $light-gray-500; + left: 0; + position: absolute; + top: 100%; + z-index: 100; +} + +.components-parent-page__autocomplete__menu--inline { + position: relative; +} + +.components-parent-page__autocomplete__option { + font-size: $default-font-size; + padding: #{$grid-size-small / 2} $grid-size; + margin-bottom: 0; + cursor: pointer; + display: block; + position: relative; + + > * { + pointer-events: none; + } + + &:hover { + background: $light-gray-500; + } + + &:focus, + &.components-parent-page__autocomplete__option--focused + &.components-parent-page__autocomplete__option--focused:hover { + background: color(theme(primary) shade(15%)); + color: $white; + outline: none; + } + + &.components-parent-page__autocomplete__option--no-results, + &.components-parent-page__autocomplete__option--no-results:hover { + background: $white; + font-style: italic; + cursor: not-allowed; + } +}