Skip to content

Added ... Copy/Paste of Blocks, Collapsible Sections, Shared Field Includes, Recent Blocks#67

Closed
helmutkaufmann wants to merge 44 commits into
wintercms:mainfrom
helmutkaufmann:main
Closed

Added ... Copy/Paste of Blocks, Collapsible Sections, Shared Field Includes, Recent Blocks#67
helmutkaufmann wants to merge 44 commits into
wintercms:mainfrom
helmutkaufmann:main

Conversation

@helmutkaufmann

@helmutkaufmann helmutkaufmann commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Collapsible sections in block forms with persisted open/closed state
    • Block definition includes to share common fields across blocks
    • Recently used blocks pinned in the Add Block palette
    • Copy, cut, paste, and duplicate actions for managing blocks
  • Documentation

    • Expanded documentation covering new block definition features and block management capabilities

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 14 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>
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR introduces three major features for the Blocks plugin (v1.1.0). BlockManager gains YAML include: directive support: block definition files can reference shared YAML files whose fields and config sections are recursively merged, with circular-include guarding and type-collision warnings. Blocks.php adds a collapsible: true shorthand for type: section fields, translating it to data-block-collapsible container attributes at build time. The block item partial is redesigned around a unified horizontal toolbar, and a large inline JavaScript block bootstraps client-side features: clipboard copy/cut/paste/duplicate using sessionStorage, collapsible section state persistence via localStorage, and recently-used block pinning in the Add Block palette. Tests, fixtures, documentation, and version metadata are updated accordingly.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 79.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes all four major features introduced in the PR: Copy/Paste of Blocks, Collapsible Sections, Shared Field Includes, and Recent Blocks, matching the implementation scope.
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

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 and usage tips.

@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: 4

🧹 Nitpick comments (1)
tests/classes/BlockManagerTest.php (1)

152-167: ⚡ Quick win

Add a collision-order assertion in testMultipleIncludes().

This test currently validates only key presence. It should also assert that for the same field key across multiple includes, the later include wins, so precedence 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 152 - 167, The
testMultipleIncludes() method currently only validates that expected fields
exist via assertArrayHasKey calls, but does not verify collision-order behavior
when the same field key appears in multiple includes. Add an assertion that
confirms when a field key exists in both _base.yaml and _shared.yaml includes,
the later include (_shared.yaml) takes precedence by checking the actual field
properties (such as label or type) match the expected value from the later
include, not the earlier one. This ensures field precedence order is maintained
and regressions in override behavior are caught.
🤖 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 157-196: The issue is in the nested loop structure where
$config[$key] is progressively modified across multiple path iterations, causing
earlier merged includes to be treated as block-owned values on subsequent
iterations. This breaks the intended precedence where later includes should
override earlier includes, and block values should override all includes. To fix
this, extract and preserve the original block's own values (the $key entries
from the initial $config) before the outer path loop starts, then use this
preserved original value as $own in each iteration instead of reusing the
progressively merged $config[$key]. This ensures each include is merged against
the block's true original values, not against accumulated results from previous
include merges.

In `@formwidgets/blocks/assets/less/blocks.less`:
- Around line 66-68: Replace all occurrences of the `.transform()` mixin with
the standard CSS `transform` property to fix the stylelint error. Specifically,
change `.transform(rotate(180deg));` to the standard CSS syntax `transform:
rotate(180deg);` in the selector `.field-block-item.collapsed >
.repeater-item-remove .repeater-item-collapse-one` and any other locations where
the `.transform()` mixin is used in the stylesheet.

In `@formwidgets/blocks/partials/_block.php`:
- Around line 175-205: The serializeBlockItem function extracts only the
trailing [key] segment from input names using the regex match, which loses the
full qualified name context and causes collisions when different fields share
the same trailing key. Additionally, both serializeBlockItem and
fillFromClipboard only preserve the .value property, failing to capture
checkbox/radio checked states and other input semantics. To fix this, preserve
the complete input name (or a fully qualified identifier) instead of just the
trailing [key] segment, and when serializing and restoring field data in
serializeBlockItem and fillFromClipboard, capture and restore all relevant input
properties including .value, .checked state, .selected state, and any other
attributes necessary to fully represent each input's state.

In `@updates/version.yaml`:
- Line 5: Remove the line containing the tabs and secondaryTabs reference from
the version notes. This entry advertises support that was removed and never
rendered properly, so leaving it in the 1.1.0 release notes would mislead users
who are upgrading from older block definitions. Simply delete the entire line
that mentions 'Tabs and secondaryTabs support in block definitions'.

---

Nitpick comments:
In `@tests/classes/BlockManagerTest.php`:
- Around line 152-167: The testMultipleIncludes() method currently only
validates that expected fields exist via assertArrayHasKey calls, but does not
verify collision-order behavior when the same field key appears in multiple
includes. Add an assertion that confirms when a field key exists in both
_base.yaml and _shared.yaml includes, the later include (_shared.yaml) takes
precedence by checking the actual field properties (such as label or type) match
the expected value from the later include, not the earlier one. This ensures
field precedence order is maintained and regressions in override behavior are
caught.
🪄 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: 9d5b25b6-a547-4e20-a81d-a02c8dbe50e1

📥 Commits

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

📒 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 on lines +157 to +196
$mergeKeys = ['fields', 'config'];

foreach ($paths as $path) {
if (!is_string($path) || $path === '') {
continue;
}

$realPath = File::symbolizePath($path);
if (!$realPath || !File::exists($realPath)) {
Log::warning("Winter.Blocks: included file not found: {$path}");
continue;
}

$canonical = PathResolver::standardize($realPath);
if (in_array($canonical, $visited, true)) {
Log::warning("Winter.Blocks: circular include detected, skipping: {$path}");
continue;
}

$included = Yaml::parse(File::get($realPath));
if (!is_array($included)) {
continue;
}

// Resolve nested includes first so they form the deepest base layer.
$included = $this->resolveIncludes($included, array_merge($visited, [$canonical]));

foreach ($mergeKeys as $key) {
if (!isset($included[$key]) || !is_array($included[$key])) {
continue;
}

$own = (isset($config[$key]) && is_array($config[$key])) ? $config[$key] : [];

// Warn when a field is redefined with a different type.
$this->warnOnTypeCollisions($key, $included[$key], $own);

// Included definitions form the base; the block's own win on collision.
$config[$key] = array_replace_recursive($included[$key], $own);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix include-order precedence across multiple includes.

The current loop reuses progressively merged $config[$key] as $own, so earlier include values are treated like block-owned values on later iterations. In collision cases, this makes earlier includes win over later ones.

Suggested fix (preserve: later include overrides earlier include; block overrides all includes)
-        $mergeKeys = ['fields', 'config'];
+        $mergeKeys = ['fields', 'config'];
+        $ownByKey = [];
+        $mergedIncludes = [];
+        foreach ($mergeKeys as $key) {
+            $ownByKey[$key] = (isset($config[$key]) && is_array($config[$key])) ? $config[$key] : [];
+            $mergedIncludes[$key] = [];
+        }

         foreach ($paths as $path) {
             if (!is_string($path) || $path === '') {
                 continue;
             }
@@
             foreach ($mergeKeys as $key) {
                 if (!isset($included[$key]) || !is_array($included[$key])) {
                     continue;
                 }

-                $own = (isset($config[$key]) && is_array($config[$key])) ? $config[$key] : [];
-
                 // Warn when a field is redefined with a different type.
-                $this->warnOnTypeCollisions($key, $included[$key], $own);
+                $this->warnOnTypeCollisions($key, $included[$key], $ownByKey[$key]);

-                // Included definitions form the base; the block's own win on collision.
-                $config[$key] = array_replace_recursive($included[$key], $own);
+                // Merge includes in order (later include wins over earlier include).
+                $mergedIncludes[$key] = array_replace_recursive($mergedIncludes[$key], $included[$key]);
             }
         }

+        // Block-owned definitions win over merged includes.
+        foreach ($mergeKeys as $key) {
+            if (!empty($mergedIncludes[$key]) || !empty($ownByKey[$key])) {
+                $config[$key] = array_replace_recursive($mergedIncludes[$key], $ownByKey[$key]);
+            }
+        }
+
         return $config;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$mergeKeys = ['fields', 'config'];
foreach ($paths as $path) {
if (!is_string($path) || $path === '') {
continue;
}
$realPath = File::symbolizePath($path);
if (!$realPath || !File::exists($realPath)) {
Log::warning("Winter.Blocks: included file not found: {$path}");
continue;
}
$canonical = PathResolver::standardize($realPath);
if (in_array($canonical, $visited, true)) {
Log::warning("Winter.Blocks: circular include detected, skipping: {$path}");
continue;
}
$included = Yaml::parse(File::get($realPath));
if (!is_array($included)) {
continue;
}
// Resolve nested includes first so they form the deepest base layer.
$included = $this->resolveIncludes($included, array_merge($visited, [$canonical]));
foreach ($mergeKeys as $key) {
if (!isset($included[$key]) || !is_array($included[$key])) {
continue;
}
$own = (isset($config[$key]) && is_array($config[$key])) ? $config[$key] : [];
// Warn when a field is redefined with a different type.
$this->warnOnTypeCollisions($key, $included[$key], $own);
// Included definitions form the base; the block's own win on collision.
$config[$key] = array_replace_recursive($included[$key], $own);
}
$mergeKeys = ['fields', 'config'];
$ownByKey = [];
$mergedIncludes = [];
foreach ($mergeKeys as $key) {
$ownByKey[$key] = (isset($config[$key]) && is_array($config[$key])) ? $config[$key] : [];
$mergedIncludes[$key] = [];
}
foreach ($paths as $path) {
if (!is_string($path) || $path === '') {
continue;
}
$realPath = File::symbolizePath($path);
if (!$realPath || !File::exists($realPath)) {
Log::warning("Winter.Blocks: included file not found: {$path}");
continue;
}
$canonical = PathResolver::standardize($realPath);
if (in_array($canonical, $visited, true)) {
Log::warning("Winter.Blocks: circular include detected, skipping: {$path}");
continue;
}
$included = Yaml::parse(File::get($realPath));
if (!is_array($included)) {
continue;
}
// Resolve nested includes first so they form the deepest base layer.
$included = $this->resolveIncludes($included, array_merge($visited, [$canonical]));
foreach ($mergeKeys as $key) {
if (!isset($included[$key]) || !is_array($included[$key])) {
continue;
}
// Warn when a field is redefined with a different type.
$this->warnOnTypeCollisions($key, $included[$key], $ownByKey[$key]);
// Merge includes in order (later include wins over earlier include).
$mergedIncludes[$key] = array_replace_recursive($mergedIncludes[$key], $included[$key]);
}
}
// Block-owned definitions win over merged includes.
foreach ($mergeKeys as $key) {
if (!empty($mergedIncludes[$key]) || !empty($ownByKey[$key])) {
$config[$key] = array_replace_recursive($mergedIncludes[$key], $ownByKey[$key]);
}
}
return $config;
🤖 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 `@classes/BlockManager.php` around lines 157 - 196, The issue is in the nested
loop structure where $config[$key] is progressively modified across multiple
path iterations, causing earlier merged includes to be treated as block-owned
values on subsequent iterations. This breaks the intended precedence where later
includes should override earlier includes, and block values should override all
includes. To fix this, extract and preserve the original block's own values (the
$key entries from the initial $config) before the outer path loop starts, then
use this preserved original value as $own in each iteration instead of reusing
the progressively merged $config[$key]. This ensures each include is merged
against the block's true original values, not against accumulated results from
previous include merges.

Comment on lines +66 to +68
.field-block-item.collapsed > .repeater-item-remove .repeater-item-collapse-one {
.transform(rotate(180deg));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the stylesheet parses cleanly after the change
npx stylelint formwidgets/blocks/assets/less/blocks.less --formatter verbose

Repository: wintercms/wn-blocks-plugin

Length of output: 318


🏁 Script executed:

# First, let's read the blocks.less file to understand the context around lines 66-68
cat -n formwidgets/blocks/assets/less/blocks.less | sed -n '60,75p'

Repository: wintercms/wn-blocks-plugin

Length of output: 686


🏁 Script executed:

# Search for .transform mixin definitions in the repository
rg '\.transform\s*\(' --type less --type css

Repository: wintercms/wn-blocks-plugin

Length of output: 240


🏁 Script executed:

# Check if there are any LESS imports or mixins that might define .transform
rg 'transform' formwidgets/blocks/assets/less/ --type less -A 2 -B 2 | head -50

Repository: wintercms/wn-blocks-plugin

Length of output: 3124


🏁 Script executed:

# Search for .transform and .transition mixin definitions in LESS files
find . -name '*.less' -type f | xargs rg -l 'transform\s*\(' | head -10

Repository: wintercms/wn-blocks-plugin

Length of output: 115


🏁 Script executed:

# Check if there's a common mixins file that might define .transform
find . -name '*mixin*' -o -name '*variable*' | grep -E '\.(less|css)$'

Repository: wintercms/wn-blocks-plugin

Length of output: 52


🏁 Script executed:

# Check the LESS compilation/import chain for this file
head -50 formwidgets/blocks/assets/less/blocks.less

Repository: wintercms/wn-blocks-plugin

Length of output: 1546


🏁 Script executed:

# Check the imported boot.less file for mixin definitions
cat modules/backend/assets/less/core/boot.less | head -100

Repository: wintercms/wn-blocks-plugin

Length of output: 145


🏁 Script executed:

# Search for .transform mixin definition in the backend assets
find modules/backend -name '*.less' -type f | xargs rg '\.transform\s*\(' -B 2 -A 2 | head -40

Repository: wintercms/wn-blocks-plugin

Length of output: 945


🏁 Script executed:

# Check if .transform is defined as a LESS mixin
rg '`@mixin`|\.transform\s*\(' modules/backend/assets/less/ --type less -B 1 -A 3

Repository: wintercms/wn-blocks-plugin

Length of output: 139


🏁 Script executed:

# List the directory structure to understand the import path
ls -la | head -20

Repository: wintercms/wn-blocks-plugin

Length of output: 1259


🏁 Script executed:

# Check if there are any LESS files that might define mixins
find . -name '*.less' -type f -exec grep -l '\.transform\|`@mixin`' {} \;

Repository: wintercms/wn-blocks-plugin

Length of output: 115


🏁 Script executed:

# Look for any mixin definitions with a different pattern
rg 'transform.*\{' --type less -B 2 -A 2

Repository: wintercms/wn-blocks-plugin

Length of output: 52


🏁 Script executed:

# Check the parent directory referenced in the import
pwd
git ls-files | grep -E '(boot|mixin|variable)' | grep less

Repository: wintercms/wn-blocks-plugin

Length of output: 89


🏁 Script executed:

# Check the .stylelintrc.json to understand the linting configuration
cat .stylelintrc.json

Repository: wintercms/wn-blocks-plugin

Length of output: 1430


🏁 Script executed:

# Look for all uses of .transition and .transform in the blocks.less file
rg '\.(transform|transition)\(' formwidgets/blocks/assets/less/blocks.less

Repository: wintercms/wn-blocks-plugin

Length of output: 387


🏁 Script executed:

# Check if there are other LESS files in the repo that might have definitions
find . -type f -name '*.less' -exec wc -l {} + | sort -n

Repository: wintercms/wn-blocks-plugin

Length of output: 119


🏁 Script executed:

# Verify the proposed fix by creating a temporary test file with the corrected syntax
cat > /tmp/test.less <<'EOF'
.field-block-item.collapsed > .repeater-item-remove .repeater-item-collapse-one {
    transform: rotate(180deg);
}
EOF

# Test it with stylelint (need to check what the config expects)
cd /home/jailuser/git && npx stylelint /tmp/test.less --formatter verbose

Repository: wintercms/wn-blocks-plugin

Length of output: 124


🏁 Script executed:

# Also check if there's a .transform mixin available from a dependency
cat package.json 2>/dev/null || echo "No package.json found"

Repository: wintercms/wn-blocks-plugin

Length of output: 92


🏁 Script executed:

# Find the exact line numbers of all .transform usages
grep -n '\.transform(' formwidgets/blocks/assets/less/blocks.less

Repository: wintercms/wn-blocks-plugin

Length of output: 161


🏁 Script executed:

# Also check if there are other uses of .transition that might have the same issue
grep -n '\.transition(' formwidgets/blocks/assets/less/blocks.less

Repository: wintercms/wn-blocks-plugin

Length of output: 323


🏁 Script executed:

# Check the context around line 136 to see if it's also affected
sed -n '130,145p' formwidgets/blocks/assets/less/blocks.less

Repository: wintercms/wn-blocks-plugin

Length of output: 571


🏁 Script executed:

# Run stylelint on the current file to confirm all issues
npx stylelint formwidgets/blocks/assets/less/blocks.less

Repository: wintercms/wn-blocks-plugin

Length of output: 201


Replace the .transform() mixin calls with standard CSS transform property to unblock stylesheet linting.

Lines 67 and 136 both use .transform(...), which stylelint flags as CssSyntaxError: Unknown word .transform. This blocks CI. The fix is to use the standard CSS property instead.

Suggested patch
 .field-block-item.collapsed > .repeater-item-remove .repeater-item-collapse-one {
-    .transform(rotate(180deg));
+    transform: rotate(180deg);
 }
 > .repeater-item-collapse {
     .repeater-item-collapse-one {
-        .transform(rotate(180deg));
+        transform: rotate(180deg);
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.field-block-item.collapsed > .repeater-item-remove .repeater-item-collapse-one {
.transform(rotate(180deg));
}
.field-block-item.collapsed > .repeater-item-remove .repeater-item-collapse-one {
transform: rotate(180deg);
}
🧰 Tools
🪛 Stylelint (17.12.0)

[error] 67-67: Unknown word .transform (CssSyntaxError)

(CssSyntaxError)

🤖 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 `@formwidgets/blocks/assets/less/blocks.less` around lines 66 - 68, Replace all
occurrences of the `.transform()` mixin with the standard CSS `transform`
property to fix the stylelint error. Specifically, change
`.transform(rotate(180deg));` to the standard CSS syntax `transform:
rotate(180deg);` in the selector `.field-block-item.collapsed >
.repeater-item-remove .repeater-item-collapse-one` and any other locations where
the `.transform()` mixin is used in the stylesheet.

Source: Linters/SAST tools

Comment on lines +175 to +205
// Serialize all named inputs in a block <li> by their trailing [key] segment.
// The _group hidden input gives us the block type; all other inputs are field data.
// Nested blocks fields store their JSON in a hidden input, so they serialize cleanly.
function serializeBlockItem(li) {
var data = { group: null, fields: {} };
li.querySelectorAll('input[name], select[name], textarea[name]').forEach(function (el) {
var m = el.name.match(/\[([^\[\]]+)\]$/);
if (!m) { return; }
var key = m[1];
if (key === '_group') {
if (!data.group) { data.group = el.value; }
} else {
data.fields[key] = el.value;
}
});
return data;
}

// Fill form inputs in a newly added <li> from stored field values.
// Matches by trailing [key] segment — works for flat fields and JSON hidden inputs.
function fillFromClipboard(li, fields) {
li.querySelectorAll('input[name], select[name], textarea[name]').forEach(function (el) {
var m = el.name.match(/\[([^\[\]]+)\]$/);
if (!m) { return; }
var key = m[1];
if (Object.prototype.hasOwnProperty.call(fields, key)) {
el.value = fields[key];
// Dispatch change so any widget listeners (e.g. select2, codemirror) react.
el.dispatchEvent(new Event('change', { bubbles: true }));
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Clipboard serialization is lossy and can paste incorrect data.

Line 181/Line 197 collapse names to the final bracket key, so different fields with the same trailing key overwrite each other. Also, Line 187 and Line 201 only round-trip .value, which drops checkbox/radio checked state and other typed input semantics.

Suggested direction
-function serializeBlockItem(li) {
-    var data = { group: null, fields: {} };
+function serializeBlockItem(li) {
+    var data = { group: null, fields: {} };
     li.querySelectorAll('input[name], select[name], textarea[name]').forEach(function (el) {
-        var m = el.name.match(/\[([^\[\]]+)\]$/);
-        if (!m) { return; }
-        var key = m[1];
-        if (key === '_group') {
+        var name = el.name;
+        if (!name) { return; }
+        if (/\[_group\]$/.test(name)) {
             if (!data.group) { data.group = el.value; }
         } else {
-            data.fields[key] = el.value;
+            if (!data.fields[name]) { data.fields[name] = []; }
+            data.fields[name].push({
+                type: (el.type || '').toLowerCase(),
+                value: el.value,
+                checked: !!el.checked
+            });
         }
     });
     return data;
 }
 
 function fillFromClipboard(li, fields) {
     li.querySelectorAll('input[name], select[name], textarea[name]').forEach(function (el) {
-        var m = el.name.match(/\[([^\[\]]+)\]$/);
-        if (!m) { return; }
-        var key = m[1];
-        if (Object.prototype.hasOwnProperty.call(fields, key)) {
-            el.value = fields[key];
+        var bucket = fields[el.name];
+        if (!bucket || !bucket.length) { return; }
+        var state = bucket.shift();
+        if (state.type === 'checkbox' || state.type === 'radio') {
+            el.checked = !!state.checked && el.value === state.value;
+        } else {
+            el.value = state.value;
+        }
             el.dispatchEvent(new Event('change', { bubbles: true }));
-        }
     });
     refreshWidgets(li);
 }
🤖 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 `@formwidgets/blocks/partials/_block.php` around lines 175 - 205, The
serializeBlockItem function extracts only the trailing [key] segment from input
names using the regex match, which loses the full qualified name context and
causes collisions when different fields share the same trailing key.
Additionally, both serializeBlockItem and fillFromClipboard only preserve the
.value property, failing to capture checkbox/radio checked states and other
input semantics. To fix this, preserve the complete input name (or a fully
qualified identifier) instead of just the trailing [key] segment, and when
serializing and restoring field data in serializeBlockItem and
fillFromClipboard, capture and restore all relevant input properties including
.value, .checked state, .selected state, and any other attributes necessary to
fully represent each input's state.

Comment thread updates/version.yaml
- 'First version of Winter.Blocks'
'1.1.0':
- 'Collapsible sections with persisted state'
- 'Tabs and secondaryTabs support in block definitions'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Drop the stale tabs / secondaryTabs note.

This entry advertises support that the stack says was removed because it never rendered. Leaving it in the 1.1.0 notes will mislead anyone upgrading from older block definitions.

🤖 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 `@updates/version.yaml` at line 5, Remove the line containing the tabs and
secondaryTabs reference from the version notes. This entry advertises support
that was removed and never rendered properly, so leaving it in the 1.1.0 release
notes would mislead users who are upgrading from older block definitions. Simply
delete the entire line that mentions 'Tabs and secondaryTabs support in block
definitions'.

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.

1 participant