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 && ( + + ) } + + + + + { 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 && ( + + ) } + + { sticky.length > 0 && ( + + ) } +
+ ); +}