Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
ebe6e01
Add collapsible sections shorthand and tabs support
helmutkaufmann Jun 11, 2026
4b1afdd
Add collapsible sections and tabs documentation
helmutkaufmann Jun 11, 2026
4d755b8
Minor README wording cleanup
helmutkaufmann Jun 11, 2026
648eb9a
Fix repeater stall inside manually-uncollapsed sections
helmutkaufmann Jun 11, 2026
c292d65
Fix section re-collapse flicker on repeater AJAX using MutationObserver
helmutkaufmann Jun 11, 2026
448537f
Fix collapsible section re-collapse by shielding during AJAX
helmutkaufmann Jun 11, 2026
f109fe8
Fully own collapsible sections to fix repeater stall
helmutkaufmann Jun 11, 2026
b2fe297
Use MutationObserver for collapsible section init
helmutkaufmann Jun 11, 2026
0a29d9b
Place collapsible.js in the widget's actual asset path
helmutkaufmann Jun 11, 2026
75e3ee0
Inline collapsible-sections bootstrap in block widget partial
helmutkaufmann Jun 11, 2026
0982f31
Remove unused collapsible.js; inline bootstrap is canonical
helmutkaufmann Jun 11, 2026
ca958bd
Stop tracking .DS_Store files
helmutkaufmann Jun 11, 2026
2d8f1e6
Add shared field includes for block definitions
helmutkaufmann Jun 11, 2026
03871e7
Add nested includes, schema guard, collapse persistence, recent blocks
helmutkaufmann Jun 11, 2026
60443ed
Add tests for includes and collapsible shorthand
helmutkaufmann Jun 11, 2026
c1b394b
Remove fork references from README and CHANGELOG
helmutkaufmann Jun 11, 2026
4830cc0
Tidy winter.mix.js comment about the two blocks.js files
helmutkaufmann Jun 11, 2026
742dacc
Add copy/cut/paste for blocks
helmutkaufmann Jun 11, 2026
dc990d4
Improve copy/cut/paste UX: inline paste button per block
helmutkaufmann Jun 11, 2026
bdf8693
Add paste-to-empty-widget support via add-item row
helmutkaufmann Jun 11, 2026
82854eb
Fix paste detection (smart quotes) and toolbar alignment
helmutkaufmann Jun 11, 2026
216d04c
Make paste link fail open and render reliably
helmutkaufmann Jun 11, 2026
6a3dd57
Move "Paste block" onto its own list row so it is never clipped
helmutkaufmann Jun 11, 2026
e097de7
Drive paste from explicit data attributes, not template parsing
helmutkaufmann Jun 11, 2026
a10264d
Consolidate item controls into one non-overflowing toolbar
helmutkaufmann Jun 11, 2026
3f5975d
Fix toolbar overflow with !important; move paste into Add-Item palette
helmutkaufmann Jun 11, 2026
d10d728
Add Copy button; stop "Add new item" rows accumulating
helmutkaufmann Jun 11, 2026
1363950
Fix palette "Paste block" by clicking the real add link
helmutkaufmann Jun 11, 2026
83c08e9
Fix palette paste: fire request on the link instead of synthetic click
helmutkaufmann Jun 11, 2026
e70c80a
Fix copy/paste: resolve infinite MutationObserver loop and show paste…
helmutkaufmann Jun 12, 2026
dbfe9a5
Replace search-bar paste button with grid pseudo-block entry
helmutkaufmann Jun 12, 2026
b6937e6
docs: clarify Duplicate vs Paste after in README
helmutkaufmann Jun 12, 2026
451dcb5
chore: bump version to 1.1.0
helmutkaufmann Jun 12, 2026
6576a94
Clean up custom code from review pass
helmutkaufmann Jun 12, 2026
a5208e9
Remove Duplicate vs Paste table from README
helmutkaufmann Jun 12, 2026
71d2c23
Add git clone instructions for the fork
helmutkaufmann Jun 12, 2026
b77a62f
Add update instructions for the fork
helmutkaufmann Jun 12, 2026
f0a95ac
Add yaml language hint to Fields and Configuration example
helmutkaufmann Jun 12, 2026
1e87f22
Auto-refresh richeditor and codeeditor widgets after paste fill
helmutkaufmann Jun 12, 2026
abb9db4
Reduce MutationObserver overhead for large block lists
helmutkaufmann Jun 12, 2026
05794db
Document field-key collision limitation in paste/duplicate note
helmutkaufmann Jun 12, 2026
e3b45d3
Updated compsoer.json
helmutkaufmann Jun 12, 2026
750033d
Updated compsoer.json
helmutkaufmann Jun 12, 2026
d2c385a
Remove tab/secondaryTabs support — feature was never rendered
helmutkaufmann Jun 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.phpunit.result.cache
mix.webpack.js
.DS_Store
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Changelog

## Unreleased

### Collapsible sections
- `.block` `type: section` fields support a `collapsible: true` shorthand, with
`collapsed: true|false` controlling the initial state.
- Handled via the `data-block-collapsible` attribute and an inline bootstrap in
the block widget partial, independent of core's collapsible-section JS. This
fixes the core behaviour where adding an item to a repeater nested inside a
section re-collapsed it and double-bound its click handler (causing the
"Add item" stall).
- **Open/closed state now persists** per section across page reloads
(`localStorage`, keyed by field name).

### 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
collision. Paths resolve via `File::symbolizePath()` (`$/`, `~/`, `#/`).
- **Nested includes** are resolved recursively, guarded against circular
references.
- A **schema guard** logs a warning when an include would redefine a field with
a different `type`.
- Missing include files are skipped and logged as a warning.

### Editor UX
- **Recently used blocks** are pinned to the top of the "add block" palette
(tracked in `localStorage`, most-recent first).
- **Copy / Cut / Paste / Duplicate blocks** — each block has one horizontal
toolbar (collapse, copy, cut, paste, duplicate, config, delete). Copy/cut/
duplicate place the block's field values on the clipboard (`sessionStorage`);
paste inserts after a block, or appends via a "Paste block" entry at the top
of the *+ Add New Item* palette (for empty widgets). Duplicate also clones in
place. Paste affordances appear only where the copied block type is offered
(respects `allow`/`ignore`/`tags`) and survive navigation within the same
browser tab. Direct add/paste/duplicate requests run the same empty-add-item
cleanup as the core popover flow, so "Add new item" rows no longer pile up.

### 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 to
`data-block-collapsible` / `data-block-collapsible-open`, and that non-section
/ plain-section fields are left untouched.
- Fixtures under `tests/fixtures/blocks/includes/`.

### Housekeeping
- Documented the two distinct `blocks.js` files (frontend Snowboard build vs.
backend FormWidget script) in `winter.mix.js` to prevent accidental merging.
- Stopped tracking `.DS_Store` files.
130 changes: 129 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,19 @@ Provides a "block based" content management experience in Winter CMS

>**NOTE:** This plugin is still in development and is likely to undergo changes. Do not use in production environments without using a version constraint in your composer.json file and carefully monitoring for breaking changes.

> **Block definition features:** **collapsible sections** (with persisted state), **shared field includes** (with nested includes), and **recently used blocks** in the palette. See [Collapsible Sections](#collapsible-sections), [Including shared field definitions](#including-shared-field-definitions), and [Recently used blocks](#recently-used-blocks) below. Full list in [CHANGELOG.md](CHANGELOG.md).

## Installation

> **Using this fork?** Clone it directly into your Winter CMS plugins directory:
> ```bash
> git clone https://github.com/helmutkaufmann/wn-blocks-plugin-1.git plugins/winter/blocks
> ```
> To update to the latest version:
> ```bash
> git -C plugins/winter/blocks pull
> ```

This plugin is available for installation via [Composer](http://getcomposer.org/).

```bash
Expand Down Expand Up @@ -99,7 +110,7 @@ For example, let's say you have a **Title** block which can display a heading ta

**Example:**

```
```yaml
name: Title
description: Adds a title
icon: icon-heading
Expand Down Expand Up @@ -144,6 +155,123 @@ config:
</{{ config.size }}>
```

## Collapsible Sections

Block fields that use `type: section` can be made collapsible directly in the block YAML.

```yaml
fields:
section_advanced:
label: Advanced settings
type: section
collapsible: true # makes the section click-to-collapse
collapsed: true # initial state: true = start collapsed (default), false = start open
some_field:
label: Some field
type: text
```

**Shorthand rules:**

| Key | Type | Default | Description |
|---|---|---|---|
| `collapsible` | bool | — | Set to `true` to enable the collapse toggle |
| `collapsed` | bool | `true` | Initial state. `false` = section starts open |

When `collapsible: true` is set, the section header becomes a click target. Sections start collapsed by default; set `collapsed: false` to have the section open on first load. Each section's open/closed state is **remembered across page reloads** (stored in `localStorage`, keyed by field name), so the editor returns to the state you left it in.

> **Note:** Collapsible behaviour is handled via the `data-block-collapsible` attribute, bootstrapped inline in the block widget partial (`formwidgets/blocks/partials/_block.php`), independent of WinterCMS's core collapsible-section JS. This is deliberate: 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. Owning the behaviour avoids that entirely, so collapsible sections work correctly even with repeater fields nested inside them.

---

## Including shared field definitions

To avoid repeating the same fields (or sections/tabs) across many blocks, a block can pull them in from one or more external YAML files via the top-level `include` key.

```yaml
name: Article
description: An article block
icon: icon-newspaper

include: $/myauthor/myplugin/blocks/_seo.yaml

fields:
title:
label: Title
type: text
==
<article>{{ title }}</article>
```

`_seo.yaml` is a plain YAML file (no `==` markup, no block metadata) containing any of `fields` or `config`:

```yaml
# blocks/_seo.yaml
fields:
meta_title:
label: Meta title
type: text
meta_description:
label: Meta description
type: textarea
```

**Multiple includes** — pass a list; they are merged in order:

```yaml
include:
- $/myauthor/myplugin/blocks/_seo.yaml
- ~/app/blocks/_tracking.yaml
```

**Merge rules:**

| | |
|---|---|
| Merged keys | `fields`, `config` |
| Precedence | Included files form the base; the block's own definitions **override** on key collision |
| Order | Multiple includes merge top-to-bottom (later files override earlier ones, the block still wins overall) |
| Nested includes | An included file may itself declare `include:` — resolved recursively, with a circular-reference guard |
| Type guard | Redefining a field with a different `type` than the include logs a warning |
| Missing files | Skipped, and logged as a warning |

**Path resolution** uses the standard Winter path symbols via `File::symbolizePath()`:

| Symbol | Resolves to |
|---|---|
| `$/author/plugin/...` | `plugins/author/plugin/...` |
| `~/...` | application root |
| `#/...` | `storage/app/...` |

This works for `collapsible` sections too — an included file can define a complete collapsible section that every block reuses without copy-paste.

---

## Recently used blocks

When adding a block from the palette, the blocks you use most recently are pinned to the top of the list (tracked per browser in `localStorage`, most-recent first). This speeds up repetitive content building where the same few block types are added over and over. No configuration is required.

---

## Cut, paste and duplicate blocks

Every block has a single horizontal toolbar (top-right) with, in order:
**collapse**, **copy**, **cut**, **paste**, **duplicate**, *(config, if the
block has an inspector)* and **delete**.

- **Copy** — places the block's field values on the clipboard (non-destructive).
- **Cut** — places the block's field values on the clipboard, then removes it (with the usual confirmation prompt).
- **Paste after** — once the clipboard holds a block, a per-block paste icon inserts the copied block immediately **after** that block. A **Paste block** entry also appears at the top of the "Add Block" palette (the popover opened by *+ Add New Item*), which is handy for inserting into an empty widget or at the end of a list.
- **Duplicate** — one-step clone: serialises the current block, saves it to the clipboard, and immediately inserts a filled copy right after it.

Use **Duplicate** for a quick in-place clone. Use **Paste after** (or **Paste block** in the palette) when you want to insert a previously copied block at a specific position or into a different widget.

Paste/duplicate respect the widget's `allow` / `ignore` / `tags` constraints: the paste affordances only appear where the copied block type is actually offered. The clipboard persists for the duration of the browser session (`sessionStorage`), so you can paste across different pages in the same tab.

> **Note on nested blocks:** Field values — including nested `blocks` fields, which store their content as JSON — are captured and restored correctly. Rich-text and code editor widgets are refreshed automatically via their own APIs after paste. Field matching uses the trailing name segment (e.g. `content` from `Blocks[0][content]`), so two fields in the same block that share the same trailing key would collide — avoid duplicate field keys within a single block definition.

---

## Using the `blocks` FormWidget

In order to provide an interface for managing block-based content, this plugin provides the `blocks` FormWidget. This widget can be used in the backend as a form field to manage blocks.
Expand Down
120 changes: 119 additions & 1 deletion classes/BlockManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Cms\Classes\Theme;
use Event;
use File;
use Log;
use Yaml;
use System\Classes\PluginManager;
use Winter\Storm\Support\Traits\Singleton;
use Winter\Storm\Support\Str;
Expand Down Expand Up @@ -98,7 +100,7 @@ public function getConfigs(string|array|null $tags = null): array
}
}

$configs[pathinfo($block['fileName'])['filename']] = array_except(
$config = array_except(
$block->getAttributes(),
[
'fileName',
Expand All @@ -108,11 +110,127 @@ public function getConfigs(string|array|null $tags = null): array
'code',
]
);

$config = $this->resolveIncludes($config);

$configs[pathinfo($block['fileName'])['filename']] = $config;
}

return $configs;
}

/**
* Resolves an `include` directive in a block definition by merging field
* definitions from one or more external YAML files.
*
* A block may declare:
*
* include: $/author/plugin/blocks/_shared.yaml
* # or
* include:
* - $/author/plugin/blocks/_seo.yaml
* - ~/app/blocks/_tracking.yaml
*
* Each included file is a plain YAML file that may contain any of the keys
* `fields` and `config`. Included definitions are
* merged in order and act as a base; the block's own definitions take
* precedence on key collisions.
*
* Included files may themselves declare an `include` key — nested includes
* are resolved recursively, guarded against circular references.
*
* Paths are resolved with File::symbolizePath(), so the usual Winter symbols
* are supported ($ = plugins, ~ = app, # = app/storage/...).
*
* @param string[] $visited Canonical paths already being resolved (cycle guard).
*/
protected function resolveIncludes(array $config, array $visited = []): array
{
if (empty($config['include'])) {
unset($config['include']);
return $config;
}

$paths = (array) $config['include'];
unset($config['include']);

$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);
}
Comment on lines +157 to +196

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.

}

return $config;
}

/**
* Logs a warning when merging an include would redefine a field with a
* different `type`, which is almost always a mistake. Field definitions live
* directly under `fields`/`config`, and under a `fields` sub-key for
* `tabs`/`secondaryTabs`.
*/
protected function warnOnTypeCollisions(string $key, array $included, array $own): void
{
$includedFields = $included;
$ownFields = $own;

if (!is_array($includedFields) || !is_array($ownFields)) {
return;
}

foreach ($includedFields as $name => $def) {
if (!isset($ownFields[$name]) || !is_array($def) || !is_array($ownFields[$name])) {
continue;
}

$includedType = $def['type'] ?? null;
$ownType = $ownFields[$name]['type'] ?? null;

if ($includedType && $ownType && $includedType !== $ownType) {
Log::warning(
"Winter.Blocks: field '{$name}' redefined with a different type " .
"('{$ownType}' overrides included '{$includedType}') in '{$key}'."
);
}
}
}

/**
* Get the configuration of the provided block type
*/
Expand Down
6 changes: 6 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
"name": "Winter CMS Maintainers",
"homepage": "https://wintercms.com",
"role": "Maintainer"
},
{
"name": "Helmut Kaufmann",
"homepage": "https://mercator.li",
"email": "software@mercator.li",
"role": "Contributor"
}
],
"support": {
Expand Down
Loading
Loading