diff --git a/README.md b/README.md index 5817273..7631790 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,16 @@ Register custom query configurations in PHP that can be selected from a dropdown - Queries work in both the editor preview and on the frontend - Automatically hooks into all public post types via the REST API +### 7. Curated Posts + +A "Curated Posts" inspector panel lets editors hand-pick and order the posts a Query Loop returns. Search is scoped to the block's current post type and writes to the core `query.include` attribute, with `query.orderBy = 'include'` so the editorial sequence is preserved on both the editor preview and the frontend. + +**Behavior:** +- The list overrides whatever post set the block would otherwise return, including a selected `hmPreset` — useful for featuring specific items on a particular page while still defaulting to the preset elsewhere. +- An empty list is a no-op: the block falls back to its normal query. +- Search uses the post type's own REST collection endpoint (`/wp/v2/{rest_base}`), so any post type registered with `show_in_rest` is supported — there is no need to opt into `/wp/v2/search`. +- Posts already in the curated list are filtered out of the search results so they cannot be added twice. + ## Installation 1. Upload the plugin to your `/wp-content/plugins/` directory diff --git a/hm-query-loop.php b/hm-query-loop.php index 6b74491..885f29d 100644 --- a/hm-query-loop.php +++ b/hm-query-loop.php @@ -44,6 +44,10 @@ function init() { // Hook query_loop_block_query_vars to modify the query. add_filter( 'query_loop_block_query_vars', __NAMESPACE__ . '\\filter_query_loop_block_query_vars', 11, 2 ); + // Honor curated post lists (query.include). Priority 20 runs after the + // query presets filter (priority 15) so curated lists override presets. + add_filter( 'query_loop_block_query_vars', __NAMESPACE__ . '\\honor_query_include', 20, 2 ); + // Hook into the_posts to track displayed posts and limit post-template posts. add_filter( 'the_posts', __NAMESPACE__ . '\\track_displayed_posts', 10, 2 ); @@ -492,6 +496,41 @@ function modify_query_from_block_attrs( $query = [], $attrs = [] ) { return $query; } +/** + * Translate the core/query block's `query.include` attribute into `post__in`. + * + * Core's build_query_vars_from_query_block() recognizes `exclude` but not + * `include`, so curated post lists set via the Curated Posts inspector + * control (or directly in pattern markup) need promotion to real WP_Query + * arguments. Order is preserved via `orderby = 'post__in'`. + * + * Hooked at priority 20 so it runs after the query presets filter — when an + * editor sets a curated list on a block that also has an `hmPreset`, the + * curated list wins. + * + * @param array $query Query arguments to be passed to WP_Query. + * @param \WP_Block $block The child block whose render triggered this filter. + * @return array Modified query arguments. + */ +function honor_query_include( array $query, \WP_Block $block ): array { + $include = $block->context['query']['include'] ?? null; + + if ( ! is_array( $include ) || empty( $include ) ) { + return $query; + } + + $include = array_values( array_filter( array_map( 'intval', $include ) ) ); + + if ( empty( $include ) ) { + return $query; + } + + $query['post__in'] = $include; + $query['orderby'] = 'post__in'; + + return $query; +} + /** * Track displayed posts using the_posts filter. * diff --git a/src/curated-posts-control.js b/src/curated-posts-control.js new file mode 100644 index 0000000..f09aab6 --- /dev/null +++ b/src/curated-posts-control.js @@ -0,0 +1,392 @@ +/** + * Curated Posts control for the Query Loop block. + * + * Renders a post search + ordered selection list that writes to the core + * `query.include` attribute. When non-empty, the list overrides whatever + * post set the block would otherwise return (including a registered + * `hmPreset`), and order is preserved via `query.orderBy = 'include'`. + */ + +import apiFetch from '@wordpress/api-fetch'; +import { useSelect } from '@wordpress/data'; +import { + Button, + BaseControl, + TextControl, + Spinner, +} from '@wordpress/components'; +import { useEffect, useMemo, useState, useRef } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; + +const SEARCH_DEBOUNCE_MS = 300; +const SEARCH_RESULTS_PER_PAGE = 10; + +/** + * Resolve a list of post IDs to lightweight {id, title, type} records via the + * `core` data store. Scoped to the query's post type when known so we use the + * right REST entity; falls back to the search endpoint for mixed/unknown types. + * + * @param {number[]} ids Selected post IDs in display order. + * @param {string} postType Current query post type, or empty for any. + * @return {{records: Array, isResolving: boolean}} Resolved post records and loading state. + */ +function useResolvedPosts( ids, postType ) { + const idsKey = ids.join( ',' ); + return useSelect( + ( select ) => { + if ( ! ids || ids.length === 0 ) { + return { records: [], isResolving: false }; + } + + const { getEntityRecords, isResolving } = select( 'core' ); + const type = postType && postType !== 'any' ? postType : 'post'; + + const query = { + include: ids, + per_page: ids.length, + orderby: 'include', + _fields: 'id,title,type', + context: 'view', + }; + + const records = getEntityRecords( 'postType', type, query ) || []; + const resolving = isResolving( 'getEntityRecords', [ + 'postType', + type, + query, + ] ); + + return { records, isResolving: resolving }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ idsKey, postType ] + ); +} + +/** + * Search posts scoped to the current query's post type. + * + * Uses the post type's own REST collection endpoint (`/wp/v2/{rest_base}`) + * rather than `/wp/v2/search`, since the latter only indexes post types that + * explicitly opt in via a search handler — most custom post types do not. + * + * @param {string} term Search term. + * @param {string} postType Post type slug, or empty/`'any'` for any. + * @return {{results: Array, isLoading: boolean, error: string|null}} Search results and request state. + */ +function usePostSearch( term, postType ) { + const [ results, setResults ] = useState( [] ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ error, setError ] = useState( null ); + const requestRef = useRef( 0 ); + + // Resolve the post type's REST base so the search hits the right endpoint. + const restBase = useSelect( + ( select ) => { + const type = postType && postType !== 'any' ? postType : 'post'; + const postTypeObject = select( 'core' ).getPostType( type ); + return postTypeObject?.rest_base || type; + }, + [ postType ] + ); + + useEffect( () => { + if ( ! term || term.length < 2 ) { + setResults( [] ); + setIsLoading( false ); + setError( null ); + return; + } + + const requestId = ++requestRef.current; + setIsLoading( true ); + setError( null ); + + const params = { + search: term, + per_page: SEARCH_RESULTS_PER_PAGE, + _fields: 'id,title', + }; + + apiFetch( { + path: addQueryArgs( `/wp/v2/${ restBase }`, params ), + } ) + .then( ( data ) => { + if ( requestId !== requestRef.current ) { + return; + } + setResults( Array.isArray( data ) ? data : [] ); + setIsLoading( false ); + } ) + .catch( ( err ) => { + if ( requestId !== requestRef.current ) { + return; + } + setError( + err?.message || __( 'Search failed.', 'hm-query-loop' ) + ); + setIsLoading( false ); + } ); + }, [ term, restBase ] ); + + return { results, isLoading, error }; +} + +/** + * Coerce the include attribute to a clean numeric array. + * + * @param {*} include Raw attribute value. + * @return {number[]} Cleaned IDs. + */ +function normalizeIds( include ) { + if ( ! Array.isArray( include ) ) { + return []; + } + return include + .map( ( id ) => parseInt( id, 10 ) ) + .filter( ( id ) => Number.isInteger( id ) && id > 0 ); +} + +/** + * The control rendered inside the Query Loop block's inspector panel. + * + * @param {Object} props + * @param {Object} props.query Current `query` block attribute. + * @param {Function} props.onChangeQuery Updates the `query` attribute. + * @return {JSX.Element} The rendered control. + */ +export default function CuratedPostsControl( { query, onChangeQuery } ) { + const include = useMemo( + () => normalizeIds( query?.include ), + [ query?.include ] + ); + const postType = query?.postType || 'post'; + + const [ searchTerm, setSearchTerm ] = useState( '' ); + const [ debouncedTerm, setDebouncedTerm ] = useState( '' ); + + useEffect( () => { + const id = setTimeout( + () => setDebouncedTerm( searchTerm ), + SEARCH_DEBOUNCE_MS + ); + return () => clearTimeout( id ); + }, [ searchTerm ] ); + + const { records, isResolving } = useResolvedPosts( include, postType ); + const { results, isLoading, error } = usePostSearch( + debouncedTerm, + postType + ); + + const recordsById = useMemo( () => { + const map = {}; + for ( const record of records ) { + map[ record.id ] = record; + } + return map; + }, [ records ] ); + + const setInclude = ( nextIds ) => { + const { include: _omit, orderBy: _omitOrder, ...rest } = query || {}; + if ( nextIds.length === 0 ) { + onChangeQuery( rest ); + return; + } + onChangeQuery( { + ...rest, + include: nextIds, + orderBy: 'include', + } ); + }; + + const addPost = ( id ) => { + if ( include.includes( id ) ) { + return; + } + setInclude( [ ...include, id ] ); + setSearchTerm( '' ); + setDebouncedTerm( '' ); + }; + + const removePost = ( id ) => { + setInclude( include.filter( ( existing ) => existing !== id ) ); + }; + + const movePost = ( id, direction ) => { + const index = include.indexOf( id ); + if ( index === -1 ) { + return; + } + const target = index + direction; + if ( target < 0 || target >= include.length ) { + return; + } + const next = [ ...include ]; + const [ moved ] = next.splice( index, 1 ); + next.splice( target, 0, moved ); + setInclude( next ); + }; + + const clearAll = () => setInclude( [] ); + + const filteredResults = results.filter( + ( result ) => ! include.includes( result.id ) + ); + + return ( +
{ error }
+ ) } + + { ! isLoading && + ! error && + debouncedTerm.length >= 2 && + filteredResults.length === 0 && ( ++ { __( 'No matching posts found.', 'hm-query-loop' ) } +
+ ) } + + { ! isLoading && filteredResults.length > 0 && ( +