From a9423d491bb6fbae6ffe0ad90cd30bbefccaeda6 Mon Sep 17 00:00:00 2001 From: goldenapples Date: Mon, 17 Aug 2026 14:06:23 -0400 Subject: [PATCH] Add control for specifying "sticky" posts in a query loop Adds a new UI field that can be used to specify specific posts as "sticky". If present, these posts will be ordered to the beginning of the query loop, in the order saved. --- README.md | 14 +- hm-query-loop.php | 14 ++ inc/sticky-posts.php | 112 +++++++++++ src/index.js | 20 ++ src/index.scss | 65 ++++++ src/sticky-posts-control.js | 386 ++++++++++++++++++++++++++++++++++++ 6 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 inc/sticky-posts.php create mode 100644 src/sticky-posts-control.js diff --git a/README.md b/README.md index ff48eed..0ed2e0c 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. Sticky Posts + +Pin a hand-picked, ordered set of posts to the front of a query loop. Selected posts render first, in the order chosen in the editor; everything else follows in whatever order the block's own settings produce. + +This is an *ordering* feature, not a filter. A post the query would not return anyway — wrong post type, excluded by a taxonomy filter, not published — is **not** pulled in by pinning it. "Which posts appear" stays the job of the query settings; this only decides what order they appear in. + +The ordering is applied in SQL via `posts_orderby` rather than by re-sorting results afterwards, so it composes correctly with `posts_per_page` and pagination: pinned posts lead the whole result set, not merely the page being rendered. + +**Known limitation:** Elasticsearch bypasses `posts_orderby`. A loop routed through ElasticPress (including via this plugin's own ElasticPress toggle) ignores pinning. + ## Installation 1. Upload the plugin to your `/wp-content/plugins/` directory @@ -89,6 +99,7 @@ See [tests/e2e/README.md](tests/e2e/README.md) for more details on the test setu - **Hide on paginated pages**: Toggle to hide this block on page 2+ - **Exclude already displayed posts**: Toggle to avoid showing duplicate posts 4. For non-inherited queries with multiple Post Template blocks, select each `core/post-template` and set **Posts per template** to control how many posts each template shows +5. To pin posts to the front, open the **Sticky Posts** panel, search for a post and select it. Use the arrows to reorder pinned posts, or **Unpin** to remove one ## Block Context @@ -99,7 +110,8 @@ The plugin exposes an `hmQueryLoop` context object from `core/query` to `core/po { perPage: number | undefined, // Custom posts per page value hideOnPaged: boolean, // Whether to hide on paginated pages - excludeDisplayed: boolean // Whether to exclude displayed posts + excludeDisplayed: boolean, // Whether to exclude displayed posts + stickyPosts: number[] | undefined // Post IDs pinned to the front, in order } ``` diff --git a/hm-query-loop.php b/hm-query-loop.php index 761ec85..05543ca 100644 --- a/hm-query-loop.php +++ b/hm-query-loop.php @@ -27,6 +27,9 @@ // Load query presets functionality. require_once HM_QUERY_LOOP_PATH . 'inc/query-presets.php'; +// Load sticky posts functionality. +require_once HM_QUERY_LOOP_PATH . 'inc/sticky-posts.php'; + /** * Initialize the plugin. */ @@ -52,6 +55,9 @@ function init() { // Initialize query presets functionality. QueryPresets\init(); + + // Initialize sticky posts functionality. + StickyPosts\bootstrap(); } add_action( 'init', __NAMESPACE__ . '\\init', 9 ); @@ -504,6 +510,14 @@ function modify_query_from_block_attrs( $query = [], $attrs = [] ) { } } + // Carry pinned post IDs through to the ORDER BY clause. This is an + // ordering concern rather than a query var, so it is stashed on the query + // and read back in StickyPosts\apply_sticky_order(). + $sticky_posts = StickyPosts\normalize_ids( $settings['stickyPosts'] ?? [] ); + if ( ! empty( $sticky_posts ) ) { + $query[ StickyPosts\QUERY_VAR ] = $sticky_posts; + } + // Exclude already displayed posts for this loop if enabled. if ( isset( $settings['excludeDisplayedForCurrentLoop'] ) ) { $query['query_id'] = $settings['excludeDisplayedForCurrentLoop']; diff --git a/inc/sticky-posts.php b/inc/sticky-posts.php new file mode 100644 index 0000000..6b4dc31 --- /dev/null +++ b/inc/sticky-posts.php @@ -0,0 +1,112 @@ +get( QUERY_VAR ) ); + + if ( empty( $ids ) ) { + return $orderby; + } + + global $wpdb; + + // Safe to interpolate: every value has been through absint() and the + // list is built here rather than taken from the request. + $field = sprintf( + 'FIELD( %s.ID, %s )', + $wpdb->posts, + implode( ',', $ids ) + ); + + $sticky = sprintf( '%1$s = 0 ASC, %1$s ASC', $field ); + + $orderby = is_string( $orderby ) ? trim( $orderby ) : ''; + + return $orderby === '' ? $sticky : $sticky . ', ' . $orderby; +} diff --git a/src/index.js b/src/index.js index 04e9f79..24a477e 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,7 @@ import { Spinner, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; +import StickyPostsControl from './sticky-posts-control'; import { createContext, useContext, @@ -382,6 +383,25 @@ const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => { /> ) } + + { + const { stickyPosts: _omit, ...rest } = + hmQueryLoop; + setAttributes( { + hmQueryLoop: + nextIds.length > 0 + ? { ...rest, stickyPosts: nextIds } + : rest, + } ); + } } + /> + ); diff --git a/src/index.scss b/src/index.scss index 677cfdb..69a2613 100644 --- a/src/index.scss +++ b/src/index.scss @@ -35,3 +35,68 @@ min-height: 200px; } } + +/** + * Sticky Posts control. + * + * Mirrors the ordered-list + search-results shape used by the other + * post-selection controls in this plugin's inspector. + */ +.hm-query-loop-sticky { + &-list, + &-results { + list-style: none; + margin: 0 0 8px; + padding: 0; + } + + &-item { + align-items: center; + border-bottom: 1px solid #e0e0e0; + display: flex; + gap: 4px; + justify-content: space-between; + padding: 4px 0; + + &__title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__actions { + display: flex; + flex-shrink: 0; + } + } + + &-results li { + margin: 0; + } + + &-result { + text-align: left; + width: 100%; + } + + &-loading { + align-items: center; + display: flex; + gap: 8px; + } + + &-error { + color: #cc1818; + } + + &-empty, + &-error { + font-size: 12px; + margin: 4px 0; + } + + &-clear { + margin-top: 4px; + } +} diff --git a/src/sticky-posts-control.js b/src/sticky-posts-control.js new file mode 100644 index 0000000..703964f --- /dev/null +++ b/src/sticky-posts-control.js @@ -0,0 +1,386 @@ +/** + * Sticky Posts control for the Query Loop block. + * + * Renders a post search plus an ordered selection list that writes to the + * `hmQueryLoop.stickyPosts` attribute. Selected posts are pinned to the front + * of the loop, in the order shown here; everything else keeps the ordering the + * block's own settings produce. + * + * This pins rather than filters: a post that the query would not return anyway + * is not pulled in by pinning it. That keeps "which posts appear" the job of + * the query settings, and "what order they appear in" the job of this control. + * + * The search and resolution hooks here follow the same approach as the + * Curated Posts control proposed in #15. + */ + +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; + +/** + * Coerce a stored sticky list to a clean array of post IDs. + * + * @param {*} value Raw attribute value. + * @return {number[]} Cleaned IDs, in order. + */ +function normalizeIds( value ) { + if ( ! Array.isArray( value ) ) { + return []; + } + return value + .map( ( id ) => parseInt( id, 10 ) ) + .filter( ( id ) => Number.isInteger( id ) && id > 0 ); +} + +/** + * Resolve post IDs to lightweight {id, title} records via the `core` store. + * + * Scoped to the query's post type so the right REST entity is used. + * + * @param {number[]} ids Selected post IDs in display order. + * @param {string} postType Current query post type. + * @return {{records: Array, isResolving: boolean}} Resolved 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', + }; + + return { + records: getEntityRecords( 'postType', type, query ) || [], + isResolving: isResolving( 'getEntityRecords', [ + 'postType', + type, + query, + ] ), + }; + }, + // 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 rather than + * `/wp/v2/search`, since the latter only indexes post types that opt in via a + * search handler — most custom post types do not. + * + * @param {string} term Search term. + * @param {string} postType Post type slug. + * @return {{results: Array, isLoading: boolean, error: string|null}} Search state. + */ +function usePostSearch( term, postType ) { + const [ results, setResults ] = useState( [] ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ error, setError ] = useState( null ); + const requestRef = useRef( 0 ); + + const restBase = useSelect( + ( select ) => { + const type = postType && postType !== 'any' ? postType : 'post'; + return select( 'core' ).getPostType( type )?.rest_base || type; + }, + [ postType ] + ); + + useEffect( () => { + if ( ! term || term.length < 2 ) { + setResults( [] ); + setIsLoading( false ); + setError( null ); + return; + } + + const requestId = ++requestRef.current; + setIsLoading( true ); + setError( null ); + + apiFetch( { + path: addQueryArgs( `/wp/v2/${ restBase }`, { + search: term, + per_page: SEARCH_RESULTS_PER_PAGE, + _fields: 'id,title', + } ), + } ) + .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 }; +} + +/** + * Resolve a post's display title, falling back while it is still loading. + * + * @param {Object} record Resolved post record, if any. + * @param {number} id Post ID. + * @param {boolean} isResolving Whether the record is still being fetched. + * @return {string} Title markup string. + */ +function getTitle( record, id, isResolving ) { + return ( + record?.title?.rendered || + record?.title || + ( isResolving + ? __( 'Loading…', 'hm-query-loop' ) + : sprintf( + /* translators: %d: post ID */ + __( 'Post #%d', 'hm-query-loop' ), + id + ) ) + ); +} + +/** + * The control rendered inside the Query Loop block's inspector panel. + * + * @param {Object} props + * @param {Object} props.query Current `query` block attribute. + * @param {number[]} props.stickyPosts Currently pinned post IDs, in order. + * @param {Function} props.onChange Receives the next pinned ID array. + * @return {Element} The rendered control. + */ +export default function StickyPostsControl( { query, stickyPosts, onChange } ) { + const sticky = useMemo( + () => normalizeIds( stickyPosts ), + [ stickyPosts ] + ); + 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( sticky, 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 addPost = ( id ) => { + if ( sticky.includes( id ) ) { + return; + } + onChange( [ ...sticky, id ] ); + setSearchTerm( '' ); + setDebouncedTerm( '' ); + }; + + const removePost = ( id ) => { + onChange( sticky.filter( ( existing ) => existing !== id ) ); + }; + + const movePost = ( id, direction ) => { + const index = sticky.indexOf( id ); + const target = index + direction; + if ( index === -1 || target < 0 || target >= sticky.length ) { + return; + } + const next = [ ...sticky ]; + const [ moved ] = next.splice( index, 1 ); + next.splice( target, 0, moved ); + onChange( next ); + }; + + const filteredResults = results.filter( + ( result ) => ! sticky.includes( result.id ) + ); + + return ( +
+ + { sticky.length > 0 && ( +
    + { sticky.map( ( id, index ) => { + const title = getTitle( + recordsById[ id ], + id, + isResolving + ); + return ( +
  • + + { `${ index + 1 }. ` } + + + +
  • + ); + } ) } +
+ ) } +
+ + + + { 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 && ( +
    + { filteredResults.map( ( result ) => ( +
  • + +
  • + ) ) } +
+ ) } + + { sticky.length > 0 && ( + + ) } +
+ ); +}