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": {