Repository navigation
feat: per-post layout overrides on Query Loop (variant system) - #598
Conversation
Adds the artisanpack/post-variant block — a child of artisanpack/post-template that declares an override template applied to posts matching its rule. Closes the gap left by Gutenberg's single-template Query Loop: authors can now make the first post a hero card while the rest render as a compact list, alternate odd / even styles, give sticky / featured posts their own layout, or wire custom logic via a PHP filter — all without leaving the editor. V1 ships all four matcher kinds (position / pattern / meta / custom), the fixed precedence cascade (instance > position > pattern > meta > custom > base) with priority ascending as the tie-breaker, a "Post Variants" panel in the query inspector, save-time precompilation of static rules to a position->variantOrder map on the parent post-template, and an apve_query_variant_match_<name> filter hook for extending the matcher set without forking the package. Server-side resolution lives in QueryInliner / the new VariantResolver, so the Blade, React, and Vue renderers all consume the same expanded tree — no per-renderer iteration logic to drift. Variants are stripped from the rendered tree on the way out, and items rendered via a variant pick up an extra is-variant class on their core/post-template-item wrapper for downstream styling. Existing query loops with no variants render identically to before. Closes #591 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughIntroduces the ChangesQuery Loop Post-Variant System
Sequence Diagram(s)sequenceDiagram
participant Editor as Block Editor
participant PostVariantsPanel
participant BlockEditorStore as core/block-editor
participant QueryInliner
participant VariantResolver
Editor->>PostVariantsPanel: render(queryClientId, previewTotal)
PostVariantsPanel->>BlockEditorStore: useSelect(post-template + post-variant children)
BlockEditorStore-->>PostVariantsPanel: variantBlocks[]
PostVariantsPanel->>PostVariantsPanel: compileStaticMap(variants, previewTotal)
PostVariantsPanel->>BlockEditorStore: updateBlockAttributes(_compiledVariantMap)
Note over QueryInliner,VariantResolver: Server-side render path
QueryInliner->>QueryInliner: extractVariants(post-template innerBlocks)
QueryInliner->>VariantResolver: prime(variantBlocks, compiledMap, total)
loop Each post in query results
QueryInliner->>VariantResolver: resolve(index, post)
VariantResolver-->>QueryInliner: variantOrder or null
QueryInliner->>VariantResolver: innerBlocksFor(variantOrder, variants)
VariantResolver-->>QueryInliner: activeInnerBlocks
QueryInliner->>QueryInliner: stamp post-template-item + append is-variant class
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
src/Resources/VariantResolver.php (1)
102-127: 💤 Low valueConsider caching the sorted variants array to avoid repeated sorting.
The
usort()call runs on everyresolve()invocation when the compiled map doesn't have an entry. For queries with many results where meta/custom matchers are in play, this sorts the same array repeatedly. Sinceprime()is called once per query expansion, the sorted array could be computed and stored there.This is a minor optimization since: (1) the compiled map short-circuits most iterations for well-formed saves, and (2) the variant count is typically small.
♻️ Proposed optimization
+ /** + * `@var` array<int, array<string, mixed>> + */ + protected array $sortedVariants = []; public function prime( array $variants, array $compiledMap, int $total ): void { $this->variants = $this->describeVariants( $variants ); $this->compiledMap = $this->normalizeMap( $compiledMap ); $this->total = $total; + + $this->sortedVariants = $this->variants; + usort( $this->sortedVariants, function ( array $a, array $b ): int { + $tierDiff = $a['tier'] - $b['tier']; + if ( 0 !== $tierDiff ) { + return $tierDiff; + } + $priorityDiff = $a['priority'] - $b['priority']; + if ( 0 !== $priorityDiff ) { + return $priorityDiff; + } + return $a['order'] - $b['order']; + } ); } public function resolve( int $index, object $post ): ?int { if ( isset( $this->compiledMap[ $index ] ) ) { $mapped = $this->compiledMap[ $index ]; if ( $mapped >= 0 && $mapped < count( $this->variants ) ) { return $mapped; } } - $sorted = $this->variants; - usort( $sorted, function ( array $a, array $b ): int { - $tierDiff = $a['tier'] - $b['tier']; - if ( 0 !== $tierDiff ) { - return $tierDiff; - } - $priorityDiff = $a['priority'] - $b['priority']; - if ( 0 !== $priorityDiff ) { - return $priorityDiff; - } - return $a['order'] - $b['order']; - } ); - - foreach ( $sorted as $variant ) { + foreach ( $this->sortedVariants as $variant ) { if ( $this->matches( $variant, $index, $post ) ) { return $variant['order']; } } return null; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/Resources/VariantResolver.php` around lines 102 - 127, The resolve() method performs usort() on the same variants array on every invocation, which is inefficient when resolve() is called multiple times. To fix this, move the sorting logic from resolve() to the prime() method where it will execute only once per query expansion, and store the sorted result in an instance variable (e.g., $this->sortedVariants). Then in resolve(), use this pre-sorted array instead of calling usort() each time. This eliminates the repeated sorting while keeping the sorting logic in a logical place where initialization happens.resources/js/visual-editor/editor/__tests__/variant-matcher.test.ts (1)
93-122: ⚡ Quick winAdd a zero-total regression case for static-map compilation
A dedicated
total = 0assertion would lock expected behavior and catch map-generation regressions around empty/loading previews.Suggested test
describe( 'compileStaticMap', () => { + it( 'returns an empty map when total is 0', () => { + const variants: VariantDescriptor[] = [ + { order: 0, priority: 10, matcher: { kind: 'position', value: 'last' } }, + { order: 1, priority: 10, matcher: { kind: 'pattern', value: 'odd' } }, + ]; + expect( compileStaticMap( variants, 0 ) ).toEqual( {} ); + } ); + it( 'compiles position + pattern rules into an index → order map', () => {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@resources/js/visual-editor/editor/__tests__/variant-matcher.test.ts` around lines 93 - 122, Add a new test case inside the `compileStaticMap` describe block that tests the edge case where `total = 0` is passed to the compileStaticMap function. This test should verify the expected behavior when handling empty or loading previews (e.g., when no items exist yet). Create a simple variant descriptor array with at least one variant and call compileStaticMap with a total of 0, then assert what the resulting map should be to establish the baseline behavior and catch regressions in this edge case scenario.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@resources/js/visual-editor/blocks/query/post-variants-panel.tsx`:
- Around line 156-160: The Math.max(previewTotal, 1) call on the line assigning
the total variable artificially inflates the total to 1 when previewTotal is 0,
causing compileStaticMap to incorrectly compile position-dependent rules like
position:last as if there is at least one item. Remove the Math.max wrapper and
pass previewTotal directly as the total parameter to compileStaticMap so that
the compiled variant map accurately reflects the actual preview count, including
when it is zero.
In `@resources/js/visual-editor/editor/variant-matcher.ts`:
- Around line 262-329: The compileStaticMap function bakes position-dependent
matches (like position:last) at save time based on a specific total value, and
resolveVariant trusts static map hits without re-validating them. When the post
count changes, these cached matches become stale. To fix this, in
compileStaticMap exclude position and pattern matchers from the static map (keep
only instance-based matches that are position-independent), and in
resolveVariant add fallback evaluation logic that checks position and pattern
matchers against the current total value when the static map misses (using the
same matchPosition and matchPattern helper functions already used in
compileStaticMap). This ensures dynamic queries always evaluate position rules
against the current total rather than a stale saved value.
In `@tests/Unit/VisualEditor/Resources/QueryInlinerVariantsTest.php`:
- Around line 174-193: The test registers a filter with addFilter for
'apve_query_variant_match_premium' but if an assertion throws an exception
before reaching removeAllFilters, the cleanup won't execute and the filter will
leak into subsequent tests. Wrap the filter registration and all subsequent test
code (including the variant block setup, inlining, and assertions) in a
try/finally block, placing the removeAllFilters call inside the finally block to
guarantee cleanup always runs regardless of whether assertions pass or fail.
---
Nitpick comments:
In `@resources/js/visual-editor/editor/__tests__/variant-matcher.test.ts`:
- Around line 93-122: Add a new test case inside the `compileStaticMap` describe
block that tests the edge case where `total = 0` is passed to the
compileStaticMap function. This test should verify the expected behavior when
handling empty or loading previews (e.g., when no items exist yet). Create a
simple variant descriptor array with at least one variant and call
compileStaticMap with a total of 0, then assert what the resulting map should be
to establish the baseline behavior and catch regressions in this edge case
scenario.
In `@src/Resources/VariantResolver.php`:
- Around line 102-127: The resolve() method performs usort() on the same
variants array on every invocation, which is inefficient when resolve() is
called multiple times. To fix this, move the sorting logic from resolve() to the
prime() method where it will execute only once per query expansion, and store
the sorted result in an instance variable (e.g., $this->sortedVariants). Then in
resolve(), use this pre-sorted array instead of calling usort() each time. This
eliminates the repeated sorting while keeping the sorting logic in a logical
place where initialization happens.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 89b9f322-38b8-4102-baae-ee18ae3c1461
📒 Files selected for processing (24)
CHANGELOG.mddocs/Hooks-and-Events.mddocs/blocks/Post-Variants.mdpackages/renderer-parity.jsonpackages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/post-variant.blade.phppackages/visual-editor-renderer-react/src/blocks/core/query.tsxpackages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.tspackages/visual-editor-renderer-vue/src/blocks/core/query.tspackages/visual-editor-renderer-vue/src/blocks/registerCoreBlocks.tsresources/js/visual-editor/blocks/post-template/block.jsonresources/js/visual-editor/blocks/post-variant/block.jsonresources/js/visual-editor/blocks/post-variant/edit.tsxresources/js/visual-editor/blocks/post-variant/index.tsresources/js/visual-editor/blocks/post-variant/inserter-icon.tsxresources/js/visual-editor/blocks/post-variant/save.tsxresources/js/visual-editor/blocks/query/edit.tsxresources/js/visual-editor/blocks/query/post-variants-panel.cssresources/js/visual-editor/blocks/query/post-variants-panel.tsxresources/js/visual-editor/editor/__tests__/variant-matcher.test.tsresources/js/visual-editor/editor/variant-matcher.tssrc/Resources/QueryInliner.phpsrc/Resources/VariantResolver.phpsrc/VisualEditorServiceProvider.phptests/Unit/VisualEditor/Resources/QueryInlinerVariantsTest.php
- Variant matcher: drop position / pattern from compileStaticMap and evaluate them at render time instead. The baked map carried position-dependent results (notably `position:last`) keyed off the editor preview total — when the live query returned a different count, those entries pointed at the wrong index. The static map is now narrowly scoped to `instance:<n1>` pins, which are fixed by definition. resolveVariant walks position / pattern / meta at render time with the live total. - Panel: drop the Math.max(previewTotal, 1) inflation — previewTotal flows through verbatim so the map honestly reflects an empty / loading preview. - VariantResolver: cache the sorted variants array in prime() instead of resorting on every resolve() call. - Tests: add a total=0 regression for compileStaticMap, add render-time evaluation cases for position / pattern / precedence in resolveVariant, wrap the custom-filter test in try/finally so apve_query_variant_match_premium cleanup runs even on assertion failure. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Description
Adds a variant system to the Query Loop block so authors can override the post template for specific posts in the loop — the first post as a hero card, odd / even alternation, sticky posts in a distinct layout, etc. Closes the gap left by Gutenberg's single-template Query Loop without forcing authors back into PHP.
Closes: #591
Type of Change
Related Issue
Issue: #591
Motivation and Context
Pre-Gutenberg PHP gave full control via
the_loop()+ CSS:nth-child. Gutenberg's Query Loop renders every post with the same Post Template. This PR closes that gap inside the visual editor.Changes Made
artisanpack/post-variant(parent-locked toartisanpack/post-template) with attributesmatcher,priority,label. Inspector exposes matcher kind, preset picker, raw value field, priority slider.position → variantOrdermap onto the parent post-template's new_compiledVariantMapattribute.resources/js/visual-editor/editor/variant-matcher.ts) — pure TS spec consumed by save-time compile + canvas. 12 unit tests pass.VariantResolver(src/Resources/VariantResolver.php) — server-side counterpart. Precedence: instance > position > pattern > meta > custom > base, ties onpriorityascending then document order. Implementsmeta(sticky / featured / has-featured-image / author / taxonomy) andcustom(viaapve_query_variant_match_<name>filter hook).QueryInlinerintegration — strips variants from the iteration template, primes the resolver, swaps the iteration template per post. Items rendered via a variant carry anis-variantclass on theircore/post-template-itemwrapper.artisanpack/post-variantregistered in Blade (recursive include), React (PostVariantBlock), Vue (PostVariantBlock); 152/152 renderer-parity check ✅.docs/blocks/Post-Variants.md+apve_query_variant_match_<name>entry indocs/Hooks-and-Events.md;CHANGELOG.mdupdated.How Has This Been Tested?
Testing Environment:
release/1.2)Tests Performed:
useEffectwrite loop was fixed pre-commit by comparing the compiled map against the stored signature before dispatching).is-variantclass lands on the right<li>.QueryInlinerVariantsTest(8 tests, 21 assertions) andvariant-matcher.test.ts(12 tests). All 15 pre-existingQueryInlinerTestcases still pass.Accessibility Tests Run
Details: Panel action buttons (
↑,↓,×) carryaria-labels. The select-variant button is a real<button>so keyboard activates the canvas selection viaselectBlock. Block-editor inspector chrome is unchanged.Tests Added
Test details:
tests/Unit/VisualEditor/Resources/QueryInlinerVariantsTest.php— backward compat (no variants = unchanged), position swap, pattern (odd / even), meta:sticky walk, custom callback viaapve_query_variant_match_<name>, precedence cascade, precompiled map fast-path, priority tie-break.resources/js/visual-editor/editor/__tests__/variant-matcher.test.ts—matchPosition,matchPattern,matchMeta,sortVariantsprecedence + tie-break,compileStaticMap,resolveVariant.Documentation
Documentation details:
docs/blocks/Post-Variants.mdcovers the block, four matcher kinds, the fixed precedence cascade, editor UX, hybrid resolution, custom matchers, and backward-compat guarantees.docs/Hooks-and-Events.mdadds theapve_query_variant_match_<name>filter section.CHANGELOG.mdcarries the user-facing summary under[Unreleased].Pre-Submission Checklist
release/1.2; all findings addressed)🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Documentation
apve_query_variant_match_<name>custom matcher filter.Tests