diff --git a/assets/components/package.json b/assets/components/package.json index fa5385a45b..e2d93ce3bf 100644 --- a/assets/components/package.json +++ b/assets/components/package.json @@ -25,7 +25,7 @@ "dependencies": { "@material-ui/core": "^4.11.2", "@material-ui/icons": "^4.11.2", - "@wordpress/base-styles": "^1.6.0", + "@wordpress/base-styles": "^3.4.4", "@wordpress/components": "^12.0.6", "@wordpress/element": "^2.19.0", "@wordpress/i18n": "^3.17.0", diff --git a/assets/components/src/autocomplete-tokenfield/index.js b/assets/components/src/autocomplete-tokenfield/index.js index 8359b9f1b8..90f8a8cdf5 100644 --- a/assets/components/src/autocomplete-tokenfield/index.js +++ b/assets/components/src/autocomplete-tokenfield/index.js @@ -92,7 +92,21 @@ class AutocompleteTokenField extends Component { * @return {Array} Array of valid values corresponding to the labels. */ getValuesForLabels( labels ) { + const { returnFullObjects } = this.props; // If this prop is passed, return both the value and label. Otherwise, return just the label. const { validValues } = this.state; + + if ( returnFullObjects ) { + return labels.reduce( ( acc, label ) => { + Object.keys( validValues ).forEach( key => { + if ( validValues[ key ] === label ) { + acc.push( { value: key, label } ); + } + } ); + + return acc; + }, [] ); + } + return labels.map( label => Object.keys( validValues ).find( key => validValues[ key ] === label ) ); @@ -120,15 +134,18 @@ class AutocompleteTokenField extends Component { return; } - const { validValues } = this.state; - const currentSuggestions = []; + const currentSuggestions = [ ...suggestions ]; + const currentValidValues = {}; suggestions.forEach( suggestion => { - currentSuggestions.push( suggestion.label ); - validValues[ suggestion.value ] = suggestion.label; + currentValidValues[ suggestion.value ] = suggestion.label; } ); - this.setState( { suggestions: currentSuggestions, validValues, loading: false } ); + this.setState( { + suggestions: currentSuggestions, + validValues: currentValidValues, + loading: false, + } ); } ) .catch( () => { if ( this.suggestionsRequest === request ) { @@ -173,7 +190,7 @@ class AutocompleteTokenField extends Component {
suggestion.label ) } onChange={ tokens => this.handleOnChange( tokens ) } onInputChange={ input => this.debouncedUpdateSuggestions( input ) } label={ label } diff --git a/assets/components/src/autocomplete-with-suggestions/index.js b/assets/components/src/autocomplete-with-suggestions/index.js index c30cf1199f..8cc654a682 100644 --- a/assets/components/src/autocomplete-with-suggestions/index.js +++ b/assets/components/src/autocomplete-with-suggestions/index.js @@ -1,9 +1,12 @@ /** * WordPress dependencies */ -import { __, sprintf } from '@wordpress/i18n'; -import { Button } from '@wordpress/components'; +import apiFetch from '@wordpress/api-fetch'; +import { Button, CheckboxControl, SelectControl, Spinner } from '@wordpress/components'; +import { decodeEntities } from '@wordpress/html-entities'; import { useEffect, useState } from '@wordpress/element'; +import { __, _x, sprintf } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; /** * External dependencies. @@ -12,38 +15,242 @@ import { AutocompleteTokenField } from '../'; import './style.scss'; const AutocompleteWithSuggestions = ( { - fetchSavedPosts = false, - fetchSuggestions = false, - onChange = false, + fetchSavedPosts = false, // If passed, will use this function to fetch saved data. + fetchSuggestions = false, // If passed, will use this function to fetch suggestions data. help = __( 'Begin typing search term, click autocomplete result to select.', 'newspack' ), + hideHelp = false, // If true, all help text will be hidden. label = __( 'Search', 'newspack' ), - postTypeLabel = __( 'post', 'newspack' ), - selectedPost = 0, + maxItemsToSuggest = 0, // If passed, will be used to determine "load more" state. Necessary if you want "load more" functionality when using a custom `fetchSuggestions` function. + multiSelect = false, // If true, component can select multiple values at once. + onChange = false, // Function to call when selections change. + onPostTypeChange = false, // Funciton to call when the post type selector is changed. + postTypes = [ { slug: 'post', label: 'Post' } ], // If passed, will render a selector to change the post type queried for suggestions. + postTypeLabel = __( 'item', 'newspack' ), // String to describe the data being shown. + postTypeLabelPlural = __( 'items', 'newspack' ), // Plural string to describe the data being shown. + selectedItems = [], // Array of saved items. + selectedPost = 0, // Legacy prop when single-select was the only option. + suggestionsToFetch = 10, // Number of suggestions to fetch per query. } ) => { + const [ isLoading, setIsLoading ] = useState( true ); + const [ isLoadingMore, setIsLoadingMore ] = useState( false ); const [ suggestions, setSuggestions ] = useState( [] ); + const [ maxSuggestions, setMaxSuggestions ] = useState( 0 ); + const [ postTypeToSearch, setPostTypeToSearch ] = useState( postTypes[ 0 ].slug ); + const classNames = [ 'newspack-autocomplete-with-suggestions' ]; + + if ( hideHelp ) { + classNames.push( 'hide-help' ); + } /** * Fetch recent posts to show as suggestions. */ useEffect( () => { - if ( fetchSuggestions ) { - fetchSuggestions().then( _suggestions => { + if ( onPostTypeChange ) { + onPostTypeChange( postTypeToSearch ); + } + + setIsLoading( true ); + handleFetchSuggestions( null, 0, postTypeToSearch ) + .then( _suggestions => { if ( 0 < _suggestions.length ) { setSuggestions( _suggestions ); } - } ); + } ) + .finally( () => setIsLoading( false ) ); + }, [ postTypeToSearch ] ); + + /** + * Fetch more suggestions. + */ + useEffect( () => { + if ( isLoadingMore ) { + handleFetchSuggestions( null, suggestions.length, postTypeToSearch ) + .then( _suggestions => { + if ( 0 < _suggestions.length ) { + setSuggestions( suggestions.concat( _suggestions ) ); + } + } ) + .finally( () => setIsLoadingMore( false ) ); + } + }, [ isLoadingMore ] ); + + /** + * If passed a `fetchSavedPosts` prop, use that, otherwise, build it based on the selected post type. + */ + const handleFetchSaved = fetchSavedPosts + ? fetchSavedPosts + : async ( postIds = [], searchSlug = null ) => { + const postTypeSlug = searchSlug || postTypeToSearch; + const endpoint = + 'post' === postTypeSlug || 'page' === postTypeSlug + ? postTypeSlug + 's' // Default post type endpoints are plural. + : postTypeSlug; // Custom post type endpoints are singular. + const posts = await apiFetch( { + path: addQueryArgs( '/wp/v2/' + endpoint, { + per_page: 100, + include: postIds.join( ',' ), + _fields: 'id,title', + } ), + } ); + + return posts.map( post => ( { + value: parseInt( post.id ), + label: decodeEntities( post.title ) || __( '(no title)', 'newspack' ), + } ) ); + }; + + /** + * If passed a `fetchSuggestions` prop, use that, otherwise, build it based on the selected post type. + */ + const handleFetchSuggestions = fetchSuggestions + ? fetchSuggestions + : async ( search = null, offset = 0, searchSlug = null ) => { + const postTypeSlug = searchSlug || postTypeToSearch; + const endpoint = + 'post' === postTypeSlug || 'page' === postTypeSlug + ? postTypeSlug + 's' // Default post type endpoints are plural. + : postTypeSlug; // Custom post type endpoints are singular. + const response = await apiFetch( { + parse: false, + path: addQueryArgs( '/wp/v2/' + endpoint, { + search, + offset, + per_page: suggestionsToFetch, + _fields: 'id,title', + } ), + } ); + + const total = parseInt( response.headers.get( 'x-wp-total' ) || 0 ); + const posts = await response.json(); + + setMaxSuggestions( total ); + + // Format suggestions for FormTokenField display. + return posts.reduce( ( acc, post ) => { + acc.push( { + value: parseInt( post.id ), + label: decodeEntities( post?.title.rendered ) || __( '(no title)', 'newspack' ), + } ); + + return acc; + }, [] ); + }; + + /** + * Intercept onChange callback so we can decide whether to allow multiple selections. + */ + const handleOnChange = _selections => { + // If only allowing one selection, just return the one selected item. + if ( ! multiSelect ) { + return onChange( _selections ); } - }, [] ); + + // Handle legacy `selectedPost` prop. + const selections = selectedPost ? [ ...selectedItems, selectedPost ] : [ ...selectedItems ]; + + // Loop through new selections to determine whether to add or remove them. + _selections.forEach( _selection => { + const existingSelection = selections.findIndex( + selection => parseInt( selection.value ) === parseInt( _selection.value ) + ); + + if ( -1 < existingSelection ) { + // If the selection is already selected, remove it. + selections.splice( existingSelection, 1 ); + } else { + // Otherwise, add it. + selections.push( _selection ); + } + } ); + + // Include currently selected post type in selection results. + onChange( selections.map( selection => ( { ...selection, postType: postTypeToSearch } ) ) ); + }; + + /** + * Render selected item(s) for this component. Clicking on a selection deselects it. + */ + const renderSelections = () => { + // Handle legacy `selectedPost` prop. + const selections = selectedPost ? [ ...selectedItems, selectedPost ] : selectedItems; + const selectedMessage = multiSelect + ? sprintf( + __( '%s %s selected', 'newspack' ), + selections.length, + selections.length > 1 ? postTypeLabelPlural : postTypeLabel + ) + : sprintf( __( 'Selected %s', 'newspack' ), postTypeLabel ); + + return ( +
+

+ { selectedMessage } + { 1 < selections.length && _x( ' – ', 'separator character', 'newspack' ) } + { 1 < selections.length && ( + + ) } +

+ { selections.map( selection => ( + + ) ) } +
+ ); + }; + + /** + * Render post type select dropdown. + */ + const renderPostTypeSelect = () => { + return ( + ( { + label: postTypeOption.label, + value: postTypeOption.slug, + } ) ) } + onChange={ _postType => setPostTypeToSearch( _postType ) } + /> + ); + }; /** * Render a single suggestion object that can be clicked to select it immediately. * * @param {Object} suggestion Suggestion object with value and label keys. - * @param {number} index Index of this suggestion in the array. */ - const renderSuggestion = ( suggestion, index ) => { + const renderSuggestion = suggestion => { + if ( multiSelect ) { + const selections = selectedPost ? [ ...selectedItems, selectedPost ] : [ ...selectedItems ]; + const isSelected = !! selections.find( + _selection => parseInt( _selection.value ) === parseInt( suggestion.value ) + ); + return ( + handleOnChange( [ suggestion ] ) } + label={ suggestion.label } + /> + ); + } return ( - ); @@ -53,29 +260,59 @@ const AutocompleteWithSuggestions = ( { * Render a list of suggestions that can be clicked to select instead of searching by title. */ const renderSuggestions = () => { + if ( isLoading ) { + return ( +
+ +
+ ); + } + + if ( 0 === suggestions.length ) { + return null; + } + + const className = multiSelect + ? 'newspack-autocomplete-with-suggestions__search-suggestions-multiselect' + : 'newspack-autocomplete-with-suggestions__search-suggestions'; + return ( <> -

- { __( 'Or, select a recent ', 'newspack' ) + sprintf( ' %s:', postTypeLabel ) } -

-
+ { ! hideHelp && ( +

+ { sprintf( __( 'Or, select a recent %s:', 'newspack' ), postTypeLabel ) } +

+ ) } +
{ suggestions.map( renderSuggestion ) } + { suggestions.length < ( maxItemsToSuggest || maxSuggestions ) && ( + + ) }
); }; return ( -
+
+ { 0 < selectedItems.length && renderSelections() } + { 1 < postTypes.length && renderPostTypeSelect() } fetchSavedPosts( postIDs ) } + tokens={ [] } + onChange={ handleOnChange } + fetchSuggestions={ async search => handleFetchSuggestions( search, 0, postTypeToSearch ) } + fetchSavedInfo={ postIds => handleFetchSaved( postIds ) } label={ label } - help={ help } + help={ ! hideHelp && help } + returnFullObjects /> - { 0 < suggestions.length && renderSuggestions() } + { renderSuggestions() }
); }; diff --git a/assets/components/src/autocomplete-with-suggestions/style.scss b/assets/components/src/autocomplete-with-suggestions/style.scss index 9c590a3f62..e0fcade489 100644 --- a/assets/components/src/autocomplete-with-suggestions/style.scss +++ b/assets/components/src/autocomplete-with-suggestions/style.scss @@ -8,12 +8,41 @@ .newspack-autocomplete-with-suggestions { width: 100%; + &.hide-help { + > .components-base-control, + > .newspack-autocomplete-tokenfield, + > .newspack-autocomplete-with-suggestions__search-suggestions, + > .newspack-autocomplete-with-suggestions__search-suggestions-multiselect { + margin-top: 1.5rem; + } + } + .components-spinner { - position: absolute; right: 0; top: 2em; } + .components-select-control__input { + max-width: none; + } + + .components-input-control__backdrop { + border-color: $gray-900 !important; + } + + .newspack-autocomplete-with-suggestions__label, + .components-form-token-field__label, + .components-input-control__label, + .components-checkbox-control__label { + color: $gray-900 !important; + } + + &__suggestions-spinner .components-spinner { + display: block; + margin: 0 auto 1rem; + position: relative; + } + .newspack-autocomplete-tokenfield__help { font-size: inherit; } @@ -38,16 +67,53 @@ margin-bottom: 4px; } - &__search-suggestions { + &__selected-items { + border-bottom: 1px solid $gray-300; + margin-bottom: 2rem; + padding-bottom: 1.75rem; + + .newspack-autocomplete-with-suggestions__label { + font-weight: bold; + + .components-button.is-link { + color: $alert-red; + font-weight: normal; + } + } + } + + &__selected-item-button.components-button.is-tertiary { + background-color: $gray-300; border-radius: 2px; - border: 1px solid $gray-900; - max-height: 164px; - overflow: scroll; + color: #1e1e1e; + font-size: 13px; + height: auto; + margin-bottom: 4px; + margin-right: 4px; + padding-left: 8px; + padding-right: 8px; - .components-button { + &::after { + content: '✕'; + margin-left: 8px; + } + + &:hover { + box-shadow: none; + color: black; + } + } + + &__search-suggestions, + &__search-suggestions-multiselect { + max-height: 166px; + overflow-x: visible; + overflow-y: auto; + + .components-button.is-link { color: $gray-900; display: block; - margin: 0; + margin: auto; padding: 8px; text-decoration: none; width: 100%; @@ -59,5 +125,23 @@ color: white; } } + .components-button.is-secondary { + display: block; + margin: 1rem auto; + } + } + + /* Extra space on sides to handle outline styles of focused checkboxes. */ + &__search-suggestions-multiselect { + margin-left: -4px; + margin-right: -4px; + padding: 0 4px; + } + + &__search-suggestions { + border-radius: 2px; + border: 1px solid $gray-900; + max-height: 192px; + padding: 4px; } } diff --git a/assets/wizards/componentsDemo/index.js b/assets/wizards/componentsDemo/index.js index 3b7d582bf1..a5c8c72bf5 100644 --- a/assets/wizards/componentsDemo/index.js +++ b/assets/wizards/componentsDemo/index.js @@ -10,9 +10,6 @@ import '../../shared/js/public-path'; * WordPress dependencies. */ import { Component, Fragment, render } from '@wordpress/element'; -import apiFetch from '@wordpress/api-fetch'; -import { decodeEntities } from '@wordpress/html-entities'; -import { addQueryArgs } from '@wordpress/url'; import { __ } from '@wordpress/i18n'; import { audio, plus, reusableBlock, typography } from '@wordpress/icons'; @@ -49,6 +46,7 @@ class ComponentsDemo extends Component { super( ...arguments ); this.state = { selectedPostForAutocompleteWithSuggestions: [], + selectedPostsForAutocompleteWithSuggestionsMultiSelect: [], inputTextValue1: 'Input value', inputTextValue2: '', inputNumValue: 0, @@ -67,6 +65,7 @@ class ComponentsDemo extends Component { render() { const { selectedPostForAutocompleteWithSuggestions, + selectedPostsForAutocompleteWithSuggestionsMultiSelect, inputTextValue1, inputTextValue2, inputNumValue, @@ -77,6 +76,7 @@ class ComponentsDemo extends Component { toggleGroupChecked, color1, } = this.state; + return (
@@ -87,50 +87,37 @@ class ComponentsDemo extends Component {
-

{ __( 'Autocomplete with Suggestions', 'newspack' ) }

+

{ __( 'Autocomplete with Suggestions (single-select)', 'newspack' ) }

{ - const posts = await apiFetch( { - path: addQueryArgs( '/wp/v2/posts', { - per_page: 100, - include: postIDs.join( ',' ), - _fields: 'id,title', - } ), - } ); - - return posts.map( post => ( { - value: post.id, - label: decodeEntities( post.title ) || __( '(no title)', 'newspack' ), - } ) ); - } } - fetchSuggestions={ async search => { - const posts = await apiFetch( { - path: addQueryArgs( '/wp/v2/posts', { - search, - per_page: 10, - _fields: 'id,title', - } ), - } ); + onChange={ items => + this.setState( { selectedPostForAutocompleteWithSuggestions: items } ) + } + selectedItems={ selectedPostForAutocompleteWithSuggestions } + /> - // Format suggestions for FormTokenField display. - return posts.reduce( ( acc, post ) => { - acc.push( { - value: post.id, - label: decodeEntities( post.title.rendered ) || __( '(no title)', 'newspack' ), - } ); +
- return acc; - }, [] ); - } } +

{ __( 'Autocomplete with Suggestions (multi-select)', 'newspack' ) }

+ - this.setState( { selectedPostForAutocompleteWithSuggestions: items.pop() } ) + this.setState( { selectedPostsForAutocompleteWithSuggestionsMultiSelect: items } ) } - selectedPost={ selectedPostForAutocompleteWithSuggestions } + postTypes={ [ { slug: 'page', label: 'Pages' }, { slug: 'post', label: 'Posts' } ] } + postTypeLabel={ 'widget' } + postTypeLabelPlural={ 'widgets' } + selectedItems={ selectedPostsForAutocompleteWithSuggestionsMultiSelect } />