Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
}
```

Expand Down
14 changes: 14 additions & 0 deletions hm-query-loop.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -52,6 +55,9 @@ function init() {

// Initialize query presets functionality.
QueryPresets\init();

// Initialize sticky posts functionality.
StickyPosts\bootstrap();
}

add_action( 'init', __NAMESPACE__ . '\\init', 9 );
Expand Down Expand Up @@ -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'];
Expand Down
112 changes: 112 additions & 0 deletions inc/sticky-posts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php
/**
* Sticky posts for the Query Loop block.
*
* Lets an editor pin a hand-picked, ordered set of posts to the front of a
* query loop while the rest of the loop keeps its own ordering.
*
* This is deliberately an *ordering* feature, not a filter. Pinned posts are
* moved to the front of the result set the query already produces; a post that
* the query would not have returned — wrong post type, excluded by a taxonomy
* filter, not published — is not pulled in by pinning it. That keeps the
* block's own settings authoritative about *which* posts appear, and leaves
* this concerned only with *what order* they appear in.
*
* The ordering is applied in SQL rather than by re-sorting results after the
* fact, so it composes correctly with `posts_per_page` and pagination: pinned
* posts lead the whole result set, not merely the page that happens to be
* rendering.
*
* @package HM\QueryLoop\StickyPosts
*/

namespace HM\QueryLoop\StickyPosts;

use WP_Query;

/**
* Query var carrying the pinned post IDs for a single WP_Query.
*
* Set from the block's `hmQueryLoop.stickyPosts` attribute in
* `HM\QueryLoop\modify_query_from_block_attrs()`, and read back off the
* WP_Query instance when its ORDER BY clause is assembled.
*/
const QUERY_VAR = 'hm_query_loop_sticky_posts';

/**
* Connect namespace functions to hooks.
*/
function bootstrap(): void {
add_filter( 'posts_orderby', __NAMESPACE__ . '\\apply_sticky_order', 10, 2 );
}

/**
* Normalise a stored sticky list into usable post IDs.
*
* The value arrives from block attributes, so it is whatever was serialised
* into the post content: expected to be an array of integers, but not
* guaranteed to be. Everything that is not a positive integer is dropped, and
* duplicates are collapsed, because both would produce a malformed or
* ambiguous `FIELD()` list.
*
* @param mixed $value Raw `stickyPosts` value from block attributes.
* @return int[] Post IDs, in the editor's chosen order.
*/
function normalize_ids( $value ): array {
if ( ! is_array( $value ) ) {
return [];
}

$ids = array_map( 'absint', $value );
$ids = array_filter( $ids );

return array_values( array_unique( $ids ) );
}

/**
* Move pinned posts to the front of a query's ORDER BY clause.
*
* Two terms are prepended to whatever ordering the query already had:
*
* FIELD( wp_posts.ID, 12,45 ) = 0 ASC -- pinned posts before the rest
* FIELD( wp_posts.ID, 12,45 ) ASC -- pinned posts in the chosen order
*
* `FIELD()` returns the 1-based position of the ID in the list, or `0` when it
* is absent. The first term therefore sorts "not pinned" (1) after "pinned"
* (0); the second orders the pinned group among itself. Unpinned posts all
* score `0` on the second term, so their relative order is decided entirely by
* the original clause, which is appended unchanged.
*
* Note that `FIELD()` is MySQL/MariaDB syntax.
*
* @param string $orderby The ORDER BY clause, without the `ORDER BY` keyword.
* @param WP_Query $query The query being prepared.
* @return string Filtered ORDER BY clause.
*/
function apply_sticky_order( $orderby, $query ) {
if ( ! $query instanceof WP_Query ) {
return $orderby;
}

$ids = normalize_ids( $query->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;
}
20 changes: 20 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Spinner,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import StickyPostsControl from './sticky-posts-control';
import {
createContext,
useContext,
Expand Down Expand Up @@ -382,6 +383,25 @@ const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => {
/>
) }
</PanelBody>
<PanelBody
title={ __( 'Sticky Posts', 'hm-query-loop' ) }
initialOpen={ false }
>
<StickyPostsControl
query={ query }
stickyPosts={ hmQueryLoop.stickyPosts }
onChange={ ( nextIds ) => {
const { stickyPosts: _omit, ...rest } =
hmQueryLoop;
setAttributes( {
hmQueryLoop:
nextIds.length > 0
? { ...rest, stickyPosts: nextIds }
: rest,
} );
} }
/>
</PanelBody>
</InspectorControls>
</>
);
Expand Down
65 changes: 65 additions & 0 deletions src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading
Loading