diff --git a/lib/compat/wordpress-6.0/blocks.php b/lib/compat/wordpress-6.0/blocks.php index 7a3f6b728eebc0..be2d739329d026 100644 --- a/lib/compat/wordpress-6.0/blocks.php +++ b/lib/compat/wordpress-6.0/blocks.php @@ -119,6 +119,9 @@ function gutenberg_build_query_vars_from_query_block( $block, $page ) { if ( ! empty( $block->context['query']['search'] ) ) { $query['s'] = $block->context['query']['search']; } + if ( ! empty( $block->context['query']['parents'] ) && is_post_type_hierarchical( $query['post_type'] ) ) { + $query['post_parent__in'] = array_filter( array_map( 'intval', $block->context['query']['parents'] ) ); + } } return $query; } diff --git a/packages/block-library/src/post-template/edit.js b/packages/block-library/src/post-template/edit.js index 2422f242aa6901..0d21a98c477d2c 100644 --- a/packages/block-library/src/post-template/edit.js +++ b/packages/block-library/src/post-template/edit.js @@ -84,6 +84,7 @@ export default function PostTemplateEdit( { sticky, inherit, taxQuery, + parents, } = {}, queryContext = [ { page: 1 } ], templateSlug, @@ -138,6 +139,9 @@ export default function PostTemplateEdit( { if ( exclude?.length ) { query.exclude = exclude; } + if ( parents?.length ) { + query.parent = parents; + } // If sticky is not set, it will return all posts in the results. // If sticky is set to `only`, it will limit the results to sticky posts only. // If it is anything else, it will exclude sticky posts from results. For the record the value stored is `exclude`. @@ -172,6 +176,7 @@ export default function PostTemplateEdit( { inherit, templateSlug, taxQuery, + parents, ] ); const blockContexts = useMemo( diff --git a/packages/block-library/src/query/block.json b/packages/block-library/src/query/block.json index 426283966f8d1e..6267e53f24c126 100644 --- a/packages/block-library/src/query/block.json +++ b/packages/block-library/src/query/block.json @@ -24,7 +24,8 @@ "exclude": [], "sticky": "", "inherit": true, - "taxQuery": null + "taxQuery": null, + "parents": [] } }, "tagName": { diff --git a/packages/block-library/src/query/edit/inspector-controls/index.js b/packages/block-library/src/query/edit/inspector-controls/index.js index 23bb0674f38b22..14b4864efc4fa1 100644 --- a/packages/block-library/src/query/edit/inspector-controls/index.js +++ b/packages/block-library/src/query/edit/inspector-controls/index.js @@ -17,15 +17,28 @@ import { import { __ } from '@wordpress/i18n'; import { InspectorControls } from '@wordpress/block-editor'; import { useEffect, useState, useCallback } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ import OrderControl from './order-control'; import AuthorControl from './author-control'; +import ParentControl from './parent-control'; import TaxonomyControls from './taxonomy-controls'; import { usePostTypes } from '../../utils'; +function useIsPostTypeHierarchical( postType ) { + return useSelect( + ( select ) => { + const type = select( coreStore ).getPostType( postType ); + return type?.viewable && type?.hierarchical; + }, + [ postType ] + ); +} + const stickyOptions = [ { label: __( 'Include' ), value: '' }, { label: __( 'Exclude' ), value: 'exclude' }, @@ -45,9 +58,11 @@ export default function QueryInspectorControls( { sticky, inherit, taxQuery, + parents, } = query; const [ showSticky, setShowSticky ] = useState( postType === 'post' ); const { postTypesTaxonomiesMap, postTypesSelectOptions } = usePostTypes(); + const isPostTypeHierarchical = useIsPostTypeHierarchical( postType ); useEffect( () => { setShowSticky( postType === 'post' ); }, [ postType ] ); @@ -72,6 +87,8 @@ export default function QueryInspectorControls( { if ( newValue !== 'post' ) { updateQuery.sticky = ''; } + // We need to reset `parents` because they are tied to each post type. + updateQuery.parents = []; setQuery( updateQuery ); }; const [ querySearch, setQuerySearch ] = useState( query.search ); @@ -156,6 +173,13 @@ export default function QueryInspectorControls( { value={ querySearch } onChange={ setQuerySearch } /> + { isPostTypeHierarchical && ( + + ) } ) } diff --git a/packages/block-library/src/query/edit/inspector-controls/parent-control.js b/packages/block-library/src/query/edit/inspector-controls/parent-control.js new file mode 100644 index 00000000000000..b6be0940d6ab0e --- /dev/null +++ b/packages/block-library/src/query/edit/inspector-controls/parent-control.js @@ -0,0 +1,133 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { FormTokenField } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { useState, useEffect, useMemo } from '@wordpress/element'; +import { useDebounce } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { getEntitiesInfo, mapToIHasNameAndId } from '../../utils'; + +const EMPTY_ARRAY = []; +const BASE_QUERY = { + order: 'asc', + _fields: 'id,title', + context: 'view', +}; + +function ParentControl( { parents, postType, onChange } ) { + const [ search, setSearch ] = useState( '' ); + const [ value, setValue ] = useState( EMPTY_ARRAY ); + const [ suggestions, setSuggestions ] = useState( EMPTY_ARRAY ); + const debouncedSearch = useDebounce( setSearch, 250 ); + const { searchResults, searchHasResolved } = useSelect( + ( select ) => { + if ( ! search ) { + return { searchResults: EMPTY_ARRAY, searchHasResolved: true }; + } + const { getEntityRecords, hasFinishedResolution } = select( + coreStore + ); + const selectorArgs = [ + 'postType', + postType, + { + ...BASE_QUERY, + search, + orderby: 'relevance', + exclude: parents, + per_page: 20, + }, + ]; + return { + searchResults: getEntityRecords( ...selectorArgs ), + searchHasResolved: hasFinishedResolution( + 'getEntityRecords', + selectorArgs + ), + }; + }, + [ search, parents ] + ); + const currentParents = useSelect( + ( select ) => { + if ( ! parents?.length ) return EMPTY_ARRAY; + const { getEntityRecords } = select( coreStore ); + return getEntityRecords( 'postType', postType, { + ...BASE_QUERY, + include: parents, + per_page: parents.length, + } ); + }, + [ parents ] + ); + // Update the `value` state only after the selectors are resolved + // to avoid emptying the input when we're changing parents. + useEffect( () => { + if ( ! parents?.length ) { + setValue( EMPTY_ARRAY ); + } + if ( ! currentParents?.length ) return; + const currentParentsInfo = getEntitiesInfo( + mapToIHasNameAndId( currentParents, 'title.rendered' ) + ); + // Returns only the existing entity ids. This prevents the component + // from crashing in the editor, when non existing ids are provided. + const sanitizedValue = parents.reduce( ( accumulator, id ) => { + const entity = currentParentsInfo.mapById[ id ]; + if ( entity ) { + accumulator.push( { + id, + value: entity.name, + } ); + } + return accumulator; + }, [] ); + setValue( sanitizedValue ); + }, [ parents, currentParents ] ); + + const entitiesInfo = useMemo( () => { + if ( ! searchResults?.length ) return EMPTY_ARRAY; + return getEntitiesInfo( + mapToIHasNameAndId( searchResults, 'title.rendered' ) + ); + }, [ searchResults ] ); + // Update suggestions only when the query has resolved. + useEffect( () => { + if ( ! searchHasResolved ) return; + setSuggestions( entitiesInfo.names ); + }, [ entitiesInfo.names, searchHasResolved ] ); + + const getIdByValue = ( entitiesMappedByName, entity ) => { + const id = entity?.id || entitiesMappedByName?.[ entity ]?.id; + if ( id ) return id; + }; + const onParentChange = ( newValue ) => { + const ids = Array.from( + newValue.reduce( ( accumulator, entity ) => { + // Verify that new values point to existing entities. + const id = getIdByValue( entitiesInfo.mapByName, entity ); + if ( id ) accumulator.add( id ); + return accumulator; + }, new Set() ) + ); + setSuggestions( EMPTY_ARRAY ); + onChange( { parents: ids } ); + }; + return ( + + ); +} + +export default ParentControl; diff --git a/packages/block-library/src/query/utils.js b/packages/block-library/src/query/utils.js index 76a5465d365c71..4fd81566378e25 100644 --- a/packages/block-library/src/query/utils.js +++ b/packages/block-library/src/query/utils.js @@ -1,9 +1,15 @@ +/** + * External dependencies + */ +import { get } from 'lodash'; + /** * WordPress dependencies */ import { useSelect } from '@wordpress/data'; import { useMemo } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; +import { decodeEntities } from '@wordpress/html-entities'; /** * @typedef IHasNameAndId @@ -47,6 +53,22 @@ export const getEntitiesInfo = ( entities ) => { }; }; +/** + * Helper util to map records to add a `name` prop from a + * provided path, in order to handle all entities in the same + * fashion(implementing`IHasNameAndId` interface). + * + * @param {Object[]} entities The array of entities. + * @param {string} path The path to map a `name` property from the entity. + * @return {IHasNameAndId[]} An array of enitities that now implement the `IHasNameAndId` interface. + */ +export const mapToIHasNameAndId = ( entities, path ) => { + return ( entities || [] ).map( ( entity ) => ( { + ...entity, + name: decodeEntities( get( entity, path ) ), + } ) ); +}; + /** * Returns a helper object that contains: * 1. An `options` object from the available post types, to be passed to a `SelectControl`. diff --git a/test/integration/fixtures/blocks/core__query.json b/test/integration/fixtures/blocks/core__query.json index cf8b1567442d9f..4c7ce920a04506 100644 --- a/test/integration/fixtures/blocks/core__query.json +++ b/test/integration/fixtures/blocks/core__query.json @@ -15,7 +15,8 @@ "exclude": [], "sticky": "", "inherit": true, - "taxQuery": null + "taxQuery": null, + "parents": [] }, "tagName": "div", "displayLayout": {