Skip to content

Add collapsible sections, shared includes, copy/paste, perf cache, and recently-used blocks#68

Open
helmutkaufmann wants to merge 53 commits into
wintercms:mainfrom
helmutkaufmann:main
Open

Add collapsible sections, shared includes, copy/paste, perf cache, and recently-used blocks#68
helmutkaufmann wants to merge 53 commits into
wintercms:mainfrom
helmutkaufmann:main

Conversation

@helmutkaufmann

@helmutkaufmann helmutkaufmann commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds several independent editor and developer-experience improvements to the Blocks plugin.

Collapsible sections

  • type: section fields support collapsible: true shorthand, with collapsed: true|false controlling the initial state.
  • Open/closed state persists across page reloads via localStorage (keyed by field name).
  • Implemented via a data-block-collapsible attribute bootstrapped inline in the block widget partial, independent of core's collapsible-section JS. This is intentional: core re-collapses and re-binds every section on each form-widget init (including when a nested repeater adds an item), which broke manually-opened sections and stalled repeater "Add item" clicks.

Shared field includes

  • Block definitions may declare a top-level include: (string or list) to merge fields and config from external plain-YAML files.
  • Included definitions form the base; the block's own definitions override on key collision.
  • Nested includes are resolved recursively, guarded against circular references.
  • Missing include files are skipped and logged as a warning.
  • A schema guard logs a warning when an include would redefine a field with a different type.

Recently used blocks

  • The most-recently used block types are pinned to the top of the "Add block" palette, tracked per browser in localStorage.

Copy / Cut / Paste / Duplicate blocks

  • Each block item has a single horizontal toolbar: collapse, copy, cut, paste, duplicate, (config if applicable), delete.
  • Server-side copy/paste: copying calls onCopyItem, which builds the block's form widget server-side and calls getSaveData(). This correctly captures every field type — switches, mediafinders, nested repeaters with their own rows — which a client-side DOM scrape cannot.
  • Paste inserts the new block immediately after the target, or via a "Paste block" entry in the + Add New Item palette (useful for empty widgets).
  • Paste and duplicate respect the widget's allow / ignore / tags constraints.
  • Clipboard persists for the browser session (sessionStorage).

Performance: cross-request config cache

  • BlockManager::getConfigs() now stores built block configs in the Laravel cache, keyed by an md5 signature of every block file's mtime.
  • On warm requests the full config build (~17–97ms for ~100 blocks) is replaced by a cheap mtime check (~0.1ms).
  • Cache self-invalidates when any .block file or included YAML file changes — no manual cache:clear needed.
  • A per-request in-memory memo prevents even the mtime check from running more than once per request.

Tests

  • BlockManagerTest: include merging, block-overrides-include precedence, nested includes, circular-include guard, missing-file skip, multiple includes, and the no-include no-op.
  • BlocksTest: collapsible/collapsed shorthand translation, and that non-section / plain-section fields are left untouched.
  • Fixtures under tests/fixtures/blocks/includes/.

Test plan

  • Add a collapsible: true section to a block, confirm it collapses/expands and state survives a page reload
  • Define a shared _shared.yaml include and confirm fields merge correctly (block fields override)
  • Copy a gallery block (with mediafinder + switch + nested repeater rows) and paste it — confirm all content is reproduced
  • Duplicate a block and confirm the clone appears immediately after
  • Paste affordances should only appear for block types allowed by the target widget's allow/ignore/tags
  • Edit a .block file and confirm the config cache invalidates (no stale data)
  • Run php artisan test --filter=BlockManagerTest and php artisan test --filter=BlocksTest

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Collapsible block sections with persisted open/closed state
    • Shared block field includes with recursive resolution, collision rules, and circular-reference protection
    • “Recently used” blocks pinned in the Add Block palette
    • Improved clipboard actions (copy/cut/paste/duplicate) that preserve complex nested block data
    • Cross-request block configuration caching for faster block setup
  • Documentation
    • Expanded README with end-to-end YAML/Twig examples and detailed feature guidance
  • Chores
    • Added .DS_Store to ignore rules and clarified build runtime separation for blocks.js files

helmutkaufmann and others added 30 commits June 11, 2026 18:51
- normalizeBlockFields() translates collapsible/collapsed YAML shorthands
  on section fields into containerAttributes so the WinterCMS form widget's
  bindCollapsibleSections JS picks them up:
    collapsible: true         → data-field-collapsible attribute
    collapsed: true (default) → starts collapsed (core JS behaviour)
    collapsed: false          → starts open (handled by collapsible.js)

- collapsible.js re-opens sections marked data-field-collapsible-open after
  the core form widget collapses them all on init; also fires on ajaxSuccess
  for dynamically added repeater items

- getGroupFormFieldConfig() override passes tabs and secondaryTabs from the
  block definition through to Backend\Widgets\Form, enabling native WinterCMS
  tab syntax in .block files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents the collapsible/collapsed YAML shorthand and tabs/secondaryTabs
block-level support added in this fork.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WinterCMS calls bindCollapsibleSections() after every ajaxSuccess, which
re-collapses all collapsible sections including ones the user manually opened.
Track user-toggled open state via data-user-opened on the element and restore
it after each ajaxSuccess alongside the configured-open sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
setTimeout was unreliable — the repeater's post-AJAX init could trigger
another bindCollapsibleSections() call after our timeout, causing a visible
"briefly shows then collapses" flicker.

Switch to a MutationObserver that immediately undoes programmatic re-collapses
of user-opened (data-user-opened) sections. A userTogglingSection flag
suppresses the observer during genuine user click-to-collapse so the user
can still close sections normally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reactive approaches (MutationObserver, setTimeout) lose the race because
the repeater's post-AJAX widget init can trigger another bindCollapsibleSections
pass after our correction fires.

Instead, prevent the problem: on ajaxBeforeSend, strip data-field-collapsible
from user-opened sections so bindCollapsibleSections() cannot select them.
Restore the attribute on ajaxComplete. Keep MutationObserver as backup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: core FormWidget.bindCollapsibleSections() runs on every widget
init scoped to the closest form. Adding a nested repeater item inits a new
FormWidget, which re-collapses all data-field-collapsible sections in the
shared form and binds a SECOND click handler to each — the doubled handler
made "Add item" appear to stall and snapped open sections shut.

Fix: emit data-block-collapsible (core's JS ignores it) and handle collapse
entirely in collapsible.js with a one-time per-section init guard and a single
delegated click handler that cannot double-bind. Reuses core's is-collapsible
/ collapsed CSS classes so styling is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DOMContentLoaded / ajaxSuccess / render via native addEventListener never
fired: block section fields are injected by AJAX, and the backend dispatches
lifecycle events through jQuery, which native listeners don't receive. Result
was that nothing collapsed at all.

Detect sections with a MutationObserver instead — fires on any DOM insertion
regardless of framework. Per-section guard class keeps init idempotent;
childList-only observation avoids a feedback loop from our class/style writes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Blocks widget resolves addJs('js/collapsible.js') relative to
formwidgets/blocks/assets/ (via guessViewPath), not assets/dist/. The file
only existed under assets/dist/js/, so it 404'd and never loaded — which is
why collapsible sections stopped working after switching to the
data-block-collapsible attribute that core's JS ignores.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
addJs path/combiner resolution proved unreliable (the file resolved to a
location where it 404'd). Embed the collapse logic inline in _block.php with
a global one-time guard so it is guaranteed to load. Uses data-block-collapsible
(ignored by core) plus a MutationObserver, so sections collapse correctly and
nested repeaters no longer stall.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The standalone collapsible.js never reliably loaded via addJs. The collapse
logic now lives inline in the block widget partial, so both copies of
collapsible.js and the addJs registration are dead and removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A block may declare `include: path` (or a list of paths) to merge fields,
tabs, secondaryTabs, and config from external plain-YAML files. Included
definitions form the base; the block's own definitions override on collision.
Paths resolve via File::symbolizePath() ($/, ~/, #/). Lets shared sections,
tabs, and field groups be reused across blocks without copy-paste.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BlockManager: resolve nested includes recursively with a circular-reference
  guard; warn when an include redefines a field with a different type; log
  missing include files instead of skipping silently.
- Block widget partial: persist each collapsible section's open/closed state in
  localStorage; pin recently used blocks to the top of the add-block palette.
- winter.mix.js: document the two distinct blocks.js files (frontend Snowboard
  build vs backend FormWidget script) to prevent accidental merging.
- Add CHANGELOG.md summarising fork additions; update README.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BlockManagerTest covers resolveIncludes: field/tab/config merging,
block-overrides-include precedence, nested includes, circular-include guard,
missing-file skip, multiple includes, and no-include no-op.

BlocksTest covers normalizeBlockFields: collapsible/collapsed shorthand
translation to data-block-collapsible[-open] and that non-collapsible fields
are untouched.

Adds YAML fixtures under tests/fixtures/blocks/includes/. Merge behaviour
verified end-to-end against the real resolveIncludes() code path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These features are intended to be proposed upstream, so the docs describe them
as plugin features rather than fork-specific additions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy and Cut buttons on each block item serialize field values to
sessionStorage. A Paste entry appears at the top of the add-block palette
when clipboard data is present and the block type is allowed in the target
widget. Paste works across pages within the same browser session.

The MutationObserver that already watches for new block items now fills
clipboard values into a newly added item immediately after onAddItem's AJAX
response is injected, before any debounced re-init runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Align copy/cut/paste/remove buttons horizontally with flexbox
- Move paste out of the add-block palette into each block's own toolbar:
  a paste icon button (hidden until clipboard has data) appears next to
  copy/cut/remove on every block item
- Clicking paste inserts the copied block immediately after the clicked
  block by firing onAddItem via the palette template's handler name, then
  the MutationObserver moves and fills the new <li> before any re-init runs
- updatePasteButtons() shows/hides paste buttons based on clipboard state
  and whether the copied block type is available in the same widget

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A 'Paste block' link appears in the add-item row of every blocks widget
when the clipboard holds a compatible block type. This allows pasting into
a widget that has no existing items (e.g. an empty right column), where
the per-item paste button would not be present. The pasted block is
appended at the end of the list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs in the copy/cut/paste feature:

- Paste buttons never appeared: the availability check and add-handler
  lookup compared against `data-block-code=` using curly quotes (U+201D)
  instead of straight ASCII quotes, so indexOf/querySelector never matched
  the real palette markup. Replaced with straight quotes.

- Toolbar buttons overlapped: copy/cut/paste/remove reused Bootstrap's
  `.close` class, inheriting float and a large font-size/line-height that
  crammed the glyphs together. Replaced with dedicated `.block-item-action`
  buttons in a flex `.block-item-toolbar`, sized and spaced consistently.
  Styles live in blocks.less and are also injected inline (once, guarded) so
  the toolbar renders correctly even if the compiled CSS is stale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The paste availability check hid the paste options whenever it could not
positively match the block code in the palette template (fail closed),
which could hide the "Paste block" link even for a valid block. It now
fails open: a paste option is hidden only when the template positively
proves the block type is not offered by that widget. The paste action
already no-ops safely on an unknown type.

Also dropped the wn-icon-paste class on the add-item link (which could
render with no visible label) in favour of an explicit icon + text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The append link was a second inline anchor inside the centered add-item
<li>, so it wrapped under the "+" button and could be clipped by the item
height. It now renders as its own full-width list row beneath the Add
button, toggled via a row container. Added matching styles in blocks.less
and the inline-injected CSS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The paste availability check and add-handler lookup parsed the popover
<script> template at click time. That was fragile: when parsing failed,
availability failed open (paste link always shown) and the handler lookup
returned null (paste did nothing) — exactly the "always shown and does
not work" symptom.

Now the widget renders two explicit attributes on .field-blocks:
  - data-add-handler: the onAddItem AJAX handler name
  - data-block-codes: comma list of block codes offered by this widget

blockTypeAvailable() and findAddHandler() read these directly, so the
paste link shows only for compatible blocks and the paste action always
has a valid handler to fire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the separate absolutely-positioned collapse chevron, copy/cut/
paste buttons and remove button (which were constrained to a fixed 20px
box and overflowed) with a single horizontal flex toolbar in this order:
collapse, cut, paste, duplicate, [config], delete.

- Overrides the core fixed width on .repeater-item-remove so icons never
  overflow; icons use the neutral backend text colour, not blue.
- Copy is replaced by Duplicate: clones the block in place and also puts it
  on the clipboard (so cross-widget paste still works).
- The collapse chevron now lives in the toolbar, outside the core
  .repeater-item-collapse wrapper its delegated handler targets, so we bind
  our own document-level collapse toggle (also more robust for dynamically
  added blocks). Added a collapsed-state rotate rule for the new location.
- "Paste block" append affordance restyled from a blue text link to a
  neutral primary-style icon+label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The toolbar width override lost to core's more specific selector
  (.field-blocks ul.field-block-items > li > .repeater-item-remove), so the
  icons stayed in a 20px box and overflowed. Force width/height/display with
  !important (in both blocks.less and the inline-injected CSS) and keep the
  row on one line (white-space:nowrap, flex:0 0 auto on each action).

- Replaced the separate "Paste block" row (which looked out of place) with a
  "Paste block" entry injected at the top of the + Add New Item palette
  popover. The popover renders at body level, so the active widget is tracked
  on add-group click and used to check compatibility and target the insert.
  The popover closes itself via its own delegated handler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Re-added a Copy button (non-destructive: clipboard only) alongside
  Duplicate (clone in place). Toolbar order is now collapse, copy, cut,
  paste, duplicate, [config], delete, using distinct icons (copy vs clone).

- Fixed the accumulating "+ Add New Item" rows. onAddItem returns an empty
  add-item plus a fresh one; the core popover flow removes the empty
  leftovers on ajaxUpdateComplete, but our direct paste/duplicate requests
  bypassed that handler so the empties piled up. Added a requestAdd() helper
  that runs the same cleanup after each direct request, and runAll() now also
  sweeps stray empty add-items so any existing accumulation self-heals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The palette paste entry called a hand-rolled add request that didn't take
effect. Replace it with clicking the actual block-add link inside the same
popover grid, after setting the pending-paste fields. This reuses core's
proven add flow (form context, load indicator, empty-add-item cleanup); the
MutationObserver then fills the new block from the clipboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
addLink.click() did not reliably trigger Winter's AJAX, so palette "Paste
block" did nothing. Call $(addLink).request(handler, {data}) directly,
reusing the link's own onAddItem handler and its data('request-form')
context (the link lives in the body-level popover, outside the form, so
that data binding is what makes the add work). Also runs the empty-add-item
cleanup on completion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… affordances

- Palette paste: replace cloneNode+replaceChild (which triggered MutationObserver
  → runAll → injectPalettePaste → another clone → infinite loop) with
  removeEventListener/addEventListener on the existing button element
- Palette paste button moved to search bar (outside scrollpad) as a plain
  <button> so click events are not intercepted by the scrollpad/filelist controls
- updatePasteButtons: add has-paste class to field-block-item so the toolbar is
  dimly visible (opacity 0.45) when clipboard holds a compatible block, making
  the paste icon discoverable without hovering
- blocks.less: add flex layout for search container, remove unused blocks-paste-item rule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
helmutkaufmann and others added 22 commits June 12, 2026 06:45
Inject "Paste block" as a first-class grid item that looks identical to real
block entries. The infinite-loop fix: check data-paste-group on the existing
item before touching the DOM — if the group matches, return immediately, so
the MutationObserver loop terminates after a single injection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove trailing dead string concatenation (+ '') in injectToolbarCss
- Remove duplicate pendingPaste/ajaxUpdateComplete setup in palette paste
  handler — requestAdd already handles both
- Fix toolbar order comments to include 'copy' (was missing from two places)
- Correct applyPalettePaste comment to describe the actual idempotency mechanism

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Froala and CodeMirror maintain internal state and ignore native change
events on the underlying textarea. Call their own APIs directly using
the data keys Winter's widget plugins set on the element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
updatePasteButtons and cleanupAddItems were running on every DOM
mutation (text changes, class toggles, widget updates) via runAll().
Now they only run when the observer detects that new element nodes
were actually added to the DOM. Copy/cut/duplicate handlers already
call updatePasteButtons directly, so observer-triggered scans are
only needed when blocks are added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The block item partial renders fields via individual renderField() calls
in a loop, which never emits a tab strip. The tabs/secondaryTabs config
was wired up on the PHP side but silently ignored at render time, making
any tab: declarations in .block files a no-op.

- Drop tabs/secondaryTabs from BlockManager::$mergeKeys (includes no
  longer merge those keys) and simplify warnOnTypeCollisions accordingly
- Remove processGroupMode() entries for tabs/secondaryTabs
- Remove getGroupFormFieldConfig() override (existed solely to pass tabs
  through to the form widget)
- Update test, fixture, README, and CHANGELOG to match

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Block::listInTheme() rescans every block file in the active theme on each
call (~17ms warm, ~90ms cold for ~100 blocks), and getConfigs() rebuilds the
full config set (re-reading attributes and any included YAML) without caching.

Both are hit several times per backend request — twice during plugin boot,
once per Blocks widget, and once per block via getConfig()/getDefaultConfig()
during rendering — so the cost compounds to hundreds of ms on pages using the
blocks editor.

Memoize both per request. Block definitions cannot change within a request,
callers only read the results, and per-widget allow/ignore filtering happens
after getConfigs(), so the shared caches are safe. A simulated request
workload drops from ~540ms to a single ~22ms theme scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BlocksDatasource calls getBlocks() in its constructor, before it has been
added to the AutoDatasource chain. Caching that early, incomplete result
caused all subsequent calls to return a stale collection, making blocks
render as empty rows in the backend.

getConfigs() memoization is unaffected and still in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getConfigs() scans and parses every block file (~17ms warm, ~97ms cold for
~100 blocks) on the first call of each request. The in-request memo avoids
repeats within a request but every request still paid the scan.

Cache the built configs in the application cache store, keyed by a content
signature derived from block-file mtimes. The signature is computed in ~0.1ms
from the in-memory registered-paths list without triggering a Halcyon scan,
so requests after the first skip the build entirely (~5ms cache hit vs ~17ms).

The cache self-invalidates: changing/adding/removing a .block file changes the
signature, and the mtimes of any included YAML files are stored with the entry
and re-checked on read, so editing a shared include invalidates it too. A TTL
is kept only as a safety net.

This caches getConfigs() (never called during BlocksDatasource construction),
not getBlocks(), so it avoids the constructor-ordering issue that the earlier
getBlocks() memoization hit. Verified the cached output is byte-identical to a
fresh build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous copy/paste scraped field values from the DOM by their trailing
[key] segment and re-applied them client-side. That silently lost any field it
couldn't represent flatly: switches (it read the static value, not the checked
state), mediafinders (preview never refreshed), and nested repeaters (rows
don't exist in a freshly added block, and leaf keys collide). Galleries, which
use a media repeater plus switches and mediafinders, came across as structure
only.

Move copy and paste to the server:

- onCopyItem builds the item's Form widget and returns getSaveData(), which
  reads the posted values and delegates to each field widget (including nested
  repeaters and mediafinders). This captures every field type correctly. The
  clipboard now holds { group, config, data }.
- onAddItem accepts optional _paste_data / _paste_config and seeds it via a
  getValueFromIndex() override, so the new item's form renders fully populated
  server-side — the same path Winter uses when loading a saved page.
- The client just stores the copied payload and, on paste, repositions the new
  <li>; the lossy serialize/fill/refresh code is removed.

Verified end to end: galleries paste back with their media directories,
switches, dropdowns and mediafinder values intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Repeater::$onAddItemCalled is a static flag set during init() of the repeater
handling an onAddItem request. While set, every repeater — including any nested
repeater inside the new item — skips processItems() (Repeater.php:158,179).
That is intended to stop a freshly added empty item pulling a sibling's data,
but it also prevented a pasted block's nested repeaters (e.g. a gallery's media
directories) from building their rows from the seeded paste data, so they
rendered empty.

When pasting, clear the static flag for the duration of building and rendering
the new item (restored in a finally), so nested repeaters populate from the
seeded data. Non-paste palette adds are unaffected — the flag is only cleared
when _paste_data is present.

Verified: pasting/duplicating a gallery now brings across its media directory
rows with folder values and switch states intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The nested-repeater paste fix cleared the static $onAddItemCalled flag before
prepareVars(), which made the outer repeater re-process every existing item's
form widget on each paste — wasted work on pages with many blocks, since the
handler only renders the new item.

Run prepareVars() with the flag still set (outer skips re-processing), then
clear it only around building and rendering the new item, so nested repeaters
still populate from the seeded data without touching the rest of the list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 250dff5b-5065-4260-83f8-e69dfbf2c0f3

📥 Commits

Reviewing files that changed from the base of the PR and between 394a016 and f907876.

📒 Files selected for processing (5)
  • classes/BlockManager.php
  • formwidgets/Blocks.php
  • formwidgets/blocks/assets/less/blocks.less
  • formwidgets/blocks/partials/_block.php
  • updates/version.yaml
💤 Files with no reviewable changes (1)
  • updates/version.yaml
🚧 Files skipped from review as they are similar to previous changes (4)
  • formwidgets/blocks/assets/less/blocks.less
  • formwidgets/Blocks.php
  • formwidgets/blocks/partials/_block.php
  • classes/BlockManager.php

Walkthrough

This PR introduces several interrelated features to the WinterCMS blocks plugin. BlockManager gains recursive YAML include: resolution with cycle detection, merge/override semantics, type-collision warnings, and a two-tier config cache (per-request memoization + cross-request Laravel cache keyed by block-file mtimes). Blocks.php adds a normalizeBlockFields() method translating collapsible/collapsed YAML shorthands into data-block-collapsible container attributes, plus onCopyItem() and a reworked onAddItem() that seed paste data server-side via getSaveData(). The _block_item.php partial consolidates item controls into a unified toolbar; a large inline JavaScript IIFE in _block.php implements clipboard management, collapsible section state persistence, recently used block palette reordering, and paste insertion. Tests cover include resolution and collapsible normalization. Documentation, version, and housekeeping files are also updated.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the five main features added: collapsible sections, shared includes, copy/paste functionality, performance caching, and recently-used blocks tracking.
Docstring Coverage ✅ Passed Docstring coverage is 81.25% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch main

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@LukeTowers

Copy link
Copy Markdown
Member

@helmutkaufmann can you add screenshots for the changes you've made? Claude is really good at using playwright to generate those for you FYI. Ideal if able to provide before / afters to make it easier to review such a large PR.

@helmutkaufmann

Copy link
Copy Markdown
Contributor Author

Yes, I can

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
CHANGELOG.md (1)

1-70: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Document the "Tabs and secondaryTabs support" feature or remove it from version.yaml.

The version.yaml file (line 5) lists "Tabs and secondaryTabs support in block definitions" as part of the 1.1.0 release, but this feature is not documented anywhere in the CHANGELOG.md Unreleased section. The PR objectives also do not mention this feature—only five features are listed (collapsible sections, shared field includes, recently used blocks, copy/paste/duplicate blocks, and performance cache). Ensure consistency between version.yaml and CHANGELOG.md: either add documentation for tabs/secondaryTabs to the CHANGELOG, or remove this item from version.yaml if it is not part of this release.

🤖 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 `@CHANGELOG.md` around lines 1 - 70, The version.yaml file lists "Tabs and
secondaryTabs support in block definitions" as part of the 1.1.0 release, but
this feature is not documented in the CHANGELOG.md Unreleased section. To fix
this inconsistency, either add documentation for the tabs/secondaryTabs feature
to the CHANGELOG.md Unreleased section (following the same format and structure
as the other features like collapsible sections, shared field includes, etc.),
or remove the tabs/secondaryTabs line from version.yaml if this feature is not
actually part of this release. Ensure both files are consistent about what
features are included in this version.
🧹 Nitpick comments (1)
tests/classes/BlockManagerTest.php (1)

149-167: ⚡ Quick win

Add a collision-precedence assertion for multi-include order.

testMultipleIncludes() currently checks only presence. It should also assert which include wins on field-key collisions, so ordering regressions are caught.

🤖 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 `@tests/classes/BlockManagerTest.php` around lines 149 - 167, The
testMultipleIncludes method in BlockManagerTest.php currently only verifies that
fields from multiple includes are present, but does not test
collision-precedence (which include wins when field keys overlap). Add an
assertion to check the actual content or properties of a field that exists in
both _base.yaml and _shared.yaml to verify that the correct include takes
precedence based on the order specified in the include array. This ensures that
changes to the include-merging order are caught as regressions.
🤖 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 `@classes/BlockManager.php`:
- Around line 292-296: The issue is that when an included file cannot be found
or symbolized in the BlockManager class, it is skipped without being recorded in
the $touchedIncludes tracking mechanism. This causes includesUnchanged() to
incorrectly report unchanged includes when a previously missing file is later
created. To fix this, ensure that the $path (or $realPath if available) is added
to $touchedIncludes even when File::exists($realPath) returns false or when
symbolizePath fails. This way, the cache metadata will properly track missing
files and invalidate when they are subsequently created. Apply the same fix to
both the first occurrence at lines 292-296 and the second occurrence at lines
304-307 where similar file existence checks occur.
- Around line 321-327: The issue is that `$own` is being extracted from the
already-merged `$config` array, causing earlier includes to take precedence over
later ones instead of following list-order precedence. To fix this in the
BlockManager.php code block around the warnOnTypeCollisions method call, obtain
`$own` from the raw, original block configuration before any merging occurs,
rather than from the current state of `$config`. This ensures that later
includes in the list can properly override earlier includes when collisions
happen, maintaining correct precedence order.

In `@formwidgets/Blocks.php`:
- Around line 201-206: In the onCopyItem() method in formwidgets/Blocks.php, add
a call to $this->prepareVars() before the existing call to makeItemFormWidget()
to ensure that $groupDefinitions is properly populated. This is necessary
because makeItemFormWidget() requires the group definitions to correctly build
the form widget with the proper schema for the specified group code, and without
calling prepareVars() first, the copy operation may fail or use incorrect field
schemas. Follow the same pattern used in onAddItem() and render() methods which
both call prepareVars() before working with form widgets.

In `@formwidgets/blocks/assets/less/blocks.less`:
- Around line 64-68: The `.transform(rotate(180deg));` LESS mixin call in the
collapsed repeater item rule is not recognized by Stylelint and causes linting
errors. Replace the mixin call with the standard CSS transform property syntax:
change `.transform(rotate(180deg));` to `transform: rotate(180deg);` within the
`.field-block-item.collapsed > .repeater-item-remove
.repeater-item-collapse-one` selector block.

In `@formwidgets/blocks/partials/_block.php`:
- Around line 386-389: The storageKey function is generating persistence keys
using only the data-field-name attribute, which causes sections with identical
field names in different block groups or widgets to share the same storage key
and overwrite each other's collapsed state. Modify the storageKey function to
include additional context that uniquely identifies the block or widget
containing the section (such as a parent block identifier, widget reference, or
other distinguishing attribute) alongside the field name when constructing the
COLLAPSE_PREFIX key. This will ensure each section's collapsed state is stored
independently based on both its field name and its containing context.

---

Outside diff comments:
In `@CHANGELOG.md`:
- Around line 1-70: The version.yaml file lists "Tabs and secondaryTabs support
in block definitions" as part of the 1.1.0 release, but this feature is not
documented in the CHANGELOG.md Unreleased section. To fix this inconsistency,
either add documentation for the tabs/secondaryTabs feature to the CHANGELOG.md
Unreleased section (following the same format and structure as the other
features like collapsible sections, shared field includes, etc.), or remove the
tabs/secondaryTabs line from version.yaml if this feature is not actually part
of this release. Ensure both files are consistent about what features are
included in this version.

---

Nitpick comments:
In `@tests/classes/BlockManagerTest.php`:
- Around line 149-167: The testMultipleIncludes method in BlockManagerTest.php
currently only verifies that fields from multiple includes are present, but does
not test collision-precedence (which include wins when field keys overlap). Add
an assertion to check the actual content or properties of a field that exists in
both _base.yaml and _shared.yaml to verify that the correct include takes
precedence based on the order specified in the include array. This ensures that
changes to the include-merging order are caught as regressions.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c5771813-68f9-43c2-94e1-c236e72dbb86

📥 Commits

Reviewing files that changed from the base of the PR and between 6a824f9 and 394a016.

📒 Files selected for processing (18)
  • .gitignore
  • CHANGELOG.md
  • README.md
  • classes/BlockManager.php
  • composer.json
  • formwidgets/Blocks.php
  • formwidgets/blocks/assets/less/blocks.less
  • formwidgets/blocks/partials/_block.php
  • formwidgets/blocks/partials/_block_item.php
  • tests/classes/BlockManagerTest.php
  • tests/fixtures/blocks/includes/_base.yaml
  • tests/fixtures/blocks/includes/_cycle_a.yaml
  • tests/fixtures/blocks/includes/_cycle_b.yaml
  • tests/fixtures/blocks/includes/_shared.yaml
  • tests/fixtures/blocks/includes/_with_nested.yaml
  • tests/formwidgets/BlocksTest.php
  • updates/version.yaml
  • winter.mix.js

Comment thread classes/BlockManager.php
Comment thread classes/BlockManager.php Outdated
Comment thread formwidgets/Blocks.php
Comment thread formwidgets/blocks/assets/less/blocks.less
Comment thread formwidgets/blocks/partials/_block.php
@helmutkaufmann

Copy link
Copy Markdown
Contributor Author

Copy/Paste/Duplicate (top right in the Gallery exampe block) icons to copy the existing block with any nested blocks inside
Screenshot 2026-06-20 at 08 45 35


When a block is in the clipboard, it can directly pasted when adding a new block to a repeater

Screenshot 2026-06-20 at 08 48 21

@helmutkaufmann

helmutkaufmann commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

@LukeTowers: These are the only visible changes. Note that the Gallery block, when adding a new block to the repeater, has moved to the front as it has been recently used.

And apologies for the big PR. I will do better next time. Think though that these are some good extensions to the plugin.

…torageKey scoping, and dead code

- resolveIncludes: capture original block fields before the include loop so the
  block always wins on collision and later includes correctly override earlier
  ones (not the already-merged result)
- resolveIncludes: record missing include paths in touchedIncludes with mtime 0
  so the cache invalidates if the file is later created
- onCopyItem: call prepareVars() before makeItemFormWidget() so groupDefinitions
  is populated (matches onAddItem/render pattern)
- storageKey: scope localStorage key to data-widget-id so sections with the same
  field name in different block widgets don't share state
- warnOnTypeCollisions: remove unreachable is_array guard (params are typed array)
- blocks.less: replace .transform() mixin with plain CSS transform property
- version.yaml: remove stale "Tabs and secondaryTabs" entry not in this PR

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@LukeTowers

Copy link
Copy Markdown
Member

@helmutkaufmann can you fix the merge conflict?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants