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 ( +
+ + { include.length > 0 && ( + + ) } + + + + + { isLoading && ( +
+ + { __( 'Searching…', 'hm-query-loop' ) } +
+ ) } + + { ! isLoading && error && ( +

{ error }

+ ) } + + { ! isLoading && + ! error && + debouncedTerm.length >= 2 && + filteredResults.length === 0 && ( +

+ { __( 'No matching posts found.', 'hm-query-loop' ) } +

+ ) } + + { ! isLoading && filteredResults.length > 0 && ( + + ) } + + { include.length > 0 && ( + + ) } +
+ ); +} diff --git a/src/index.js b/src/index.js index fd63db9..2dd8646 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,11 @@ import { import { __ } from '@wordpress/i18n'; import { createContext, useContext, useEffect } from '@wordpress/element'; +/** + * Internal dependencies + */ +import CuratedPostsControl from './curated-posts-control'; + /** * Styles */ @@ -170,10 +175,22 @@ const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => { } ) ), ]; + const onChangeQuery = ( nextQuery ) => + setAttributes( { query: nextQuery } ); + return ( <> + + + } The clientId of the inserted query block. + */ +async function insertCuratedQueryBlock( page, query = {} ) { + return page.evaluate( ( queryAttrs ) => { + const { dispatch } = window.wp.data; + const { createBlock } = window.wp.blocks; + const postTemplate = createBlock( 'core/post-template', {}, [ + createBlock( 'core/post-title', { isLink: false } ), + ] ); + const queryBlock = createBlock( + 'core/query', + { + queryId: 1, + query: { + perPage: 10, + postType: 'post', + inherit: false, + ...queryAttrs, + }, + namespace: 'hm/curated-test', + }, + [ postTemplate ] + ); + dispatch( 'core/block-editor' ).insertBlock( queryBlock ); + dispatch( 'core/block-editor' ).selectBlock( queryBlock.clientId ); + return queryBlock.clientId; + }, query ); +} + +/** + * Read the live `query` attribute from a Query Loop block. + * + * @param {import('@playwright/test').Page} page + * @param {string} clientId Query block clientId. + * @return {Promise} The current `query` attribute. + */ +async function readQuery( page, clientId ) { + return page.evaluate( ( id ) => { + return window.wp.data.select( 'core/block-editor' ).getBlock( id ) + ?.attributes?.query; + }, clientId ); +} + +/** + * Fetch a handful of seeded post IDs we can curate against. + * + * @param {import('@playwright/test').Page} page + * @return {Promise>} Seeded posts in date order. + */ +async function getSeededPosts( page ) { + return page.evaluate( async () => { + const data = await window.wp.apiFetch( { + path: '/wp/v2/posts?per_page=5&_fields=id,title&orderby=date&order=asc', + } ); + return data.map( ( p ) => ( { id: p.id, title: p.title.rendered } ) ); + } ); +} + +test.describe( 'Curated Posts', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost( { title: 'Curated posts spec' } ); + } ); + + test( 'panel is registered on the Query Loop block', async ( { + page, + blockEditor, + } ) => { + await insertCuratedQueryBlock( page ); + await blockEditor.openSettingsSidebar(); + + const panel = page.locator( + '.components-panel__body-title:has-text("Curated Posts")' + ); + await expect( panel ).toBeVisible( { timeout: 5000 } ); + + await blockEditor.queryBlock.openCuratedPanel(); + await expect( page.locator( '.hm-query-loop-curated' ) ).toBeVisible(); + await expect( + page.locator( 'label:has-text("Add a post")' ) + ).toBeVisible(); + } ); + + test( 'pre-filled include resolves IDs to titles', async ( { + page, + blockEditor, + } ) => { + const posts = await getSeededPosts( page ); + expect( posts.length ).toBeGreaterThanOrEqual( 3 ); + const picks = posts.slice( 0, 3 ); + + await insertCuratedQueryBlock( page, { + include: picks.map( ( p ) => p.id ), + orderBy: 'include', + } ); + + await blockEditor.openSettingsSidebar(); + await blockEditor.queryBlock.openCuratedPanel(); + + for ( const post of picks ) { + await expect( + page + .locator( '.hm-query-loop-curated-item__title' ) + .filter( { hasText: post.title } ) + ).toBeVisible( { timeout: 5000 } ); + } + } ); + + test( 'search adds a post and updates query.include + orderBy', async ( { + page, + blockEditor, + } ) => { + const posts = await getSeededPosts( page ); + const target = posts[ 0 ]; + + const clientId = await insertCuratedQueryBlock( page ); + await blockEditor.openSettingsSidebar(); + await blockEditor.queryBlock.openCuratedPanel(); + + // Search by first word of the seeded title. + const term = target.title.split( ' ' )[ 0 ]; + await page + .locator( '.hm-query-loop-curated input[type="text"]' ) + .fill( term ); + + const result = page + .locator( '.hm-query-loop-curated-result' ) + .filter( { hasText: target.title } ); + await expect( result ).toBeVisible( { + timeout: SEARCH_DEBOUNCE_MS + 2000, + } ); + await result.click(); + + await expect( + page + .locator( '.hm-query-loop-curated-item__title' ) + .filter( { hasText: target.title } ) + ).toBeVisible(); + + const queryAttr = await readQuery( page, clientId ); + expect( queryAttr.include ).toEqual( [ target.id ] ); + expect( queryAttr.orderBy ).toBe( 'include' ); + } ); + + test( 'reorder buttons update the include array order', async ( { + page, + blockEditor, + } ) => { + const posts = await getSeededPosts( page ); + const picks = posts.slice( 0, 3 ); + + const clientId = await insertCuratedQueryBlock( page, { + include: picks.map( ( p ) => p.id ), + orderBy: 'include', + } ); + + await blockEditor.openSettingsSidebar(); + await blockEditor.queryBlock.openCuratedPanel(); + + // Move the last item up one position. + const items = page.locator( '.hm-query-loop-curated-item' ); + await expect( items ).toHaveCount( 3 ); + await items.nth( 2 ).getByRole( 'button', { name: 'Move up' } ).click(); + + const queryAttr = await readQuery( page, clientId ); + expect( queryAttr.include ).toEqual( [ + picks[ 0 ].id, + picks[ 2 ].id, + picks[ 1 ].id, + ] ); + } ); + + test( 'clear curated list removes include and orderBy', async ( { + page, + blockEditor, + } ) => { + const posts = await getSeededPosts( page ); + const picks = posts.slice( 0, 2 ); + + const clientId = await insertCuratedQueryBlock( page, { + include: picks.map( ( p ) => p.id ), + orderBy: 'include', + } ); + + await blockEditor.openSettingsSidebar(); + await blockEditor.queryBlock.openCuratedPanel(); + + await page.locator( '.hm-query-loop-curated-clear' ).click(); + + const queryAttr = await readQuery( page, clientId ); + expect( queryAttr ).not.toHaveProperty( 'include' ); + expect( queryAttr ).not.toHaveProperty( 'orderBy' ); + } ); + + test( 'front-end renders posts in curated order', async ( { + page, + blockEditor, + } ) => { + const posts = await getSeededPosts( page ); + // Pick three posts and arrange them in a non-default order. + const ordered = [ posts[ 2 ], posts[ 0 ], posts[ 1 ] ]; + + await insertCuratedQueryBlock( page, { + perPage: 3, + include: ordered.map( ( p ) => p.id ), + orderBy: 'include', + } ); + + await blockEditor.publishAndVisit(); + + const renderedTitles = await page + .locator( '.wp-block-post-template .wp-block-post-title' ) + .allTextContents(); + + // Compare without HTML entities — post titles round-trip cleanly here. + const trimmed = renderedTitles.map( ( t ) => t.trim() ); + expect( trimmed.slice( 0, 3 ) ).toEqual( + ordered.map( ( p ) => p.title ) + ); + } ); + + test( 'curated list overrides a selected preset', async ( { + page, + blockEditor, + } ) => { + const posts = await getSeededPosts( page ); + // Pick three posts in a sequence that disagrees with alphabetical + // order — that way, if the preset wins, the assertion fails. + const sorted = [ ...posts ].sort( ( a, b ) => + a.title.localeCompare( b.title ) + ); + const alphabetical = sorted.slice( 0, 3 ); + const curated = [ + alphabetical[ 2 ], + alphabetical[ 0 ], + alphabetical[ 1 ], + ]; + + // Sanity: the two orderings really do differ. + expect( curated.map( ( p ) => p.id ) ).not.toEqual( + alphabetical.map( ( p ) => p.id ) + ); + + await insertCuratedQueryBlock( page, { + perPage: 3, + hmPreset: 'alphabetical_title', + include: curated.map( ( p ) => p.id ), + orderBy: 'include', + } ); + + await blockEditor.publishAndVisit(); + + const renderedTitles = await page + .locator( '.wp-block-post-template .wp-block-post-title' ) + .allTextContents(); + + const trimmed = renderedTitles.map( ( t ) => t.trim() ).slice( 0, 3 ); + + expect( trimmed ).toEqual( curated.map( ( p ) => p.title ) ); + expect( trimmed ).not.toEqual( alphabetical.map( ( p ) => p.title ) ); + } ); +} ); diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index 88fd6cf..f4ad26c 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -217,6 +217,24 @@ export const test = base.extend( { } } }, + async openCuratedPanel() { + const curatedPanel = page.locator( + '.components-panel__body-title:has-text("Curated Posts")' + ); + if ( + await curatedPanel + .isVisible( { timeout: 2000 } ) + .catch( () => false ) + ) { + const isExpanded = await curatedPanel + .locator( 'button' ) + .getAttribute( 'aria-expanded' ); + if ( isExpanded !== 'true' ) { + await curatedPanel.locator( 'button' ).click(); + await page.waitForTimeout( 300 ); + } + } + }, async excludeDisplayed() { const excludeDisplayedToggle = page .locator(