diff --git a/.gitignore b/.gitignore index 6d42620..d7c8c38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .phpunit.result.cache mix.webpack.js +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a048144 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index ba775ce..f4c48d2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -144,6 +155,123 @@ config: ``` +## 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 +== +
{{ title }}
+``` + +`_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. diff --git a/classes/BlockManager.php b/classes/BlockManager.php index e3d2f07..adec9ce 100644 --- a/classes/BlockManager.php +++ b/classes/BlockManager.php @@ -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; @@ -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', @@ -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); + } + } + + 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 */ diff --git a/composer.json b/composer.json index afb381d..237205e 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/formwidgets/Blocks.php b/formwidgets/Blocks.php index 04b2391..1461e16 100644 --- a/formwidgets/Blocks.php +++ b/formwidgets/Blocks.php @@ -53,6 +53,9 @@ protected function loadAssets() { $this->addCss('css/blocks.css', 'Winter.Blocks'); $this->addJs('js/blocks.js', 'Winter.Blocks'); + // Collapsible-section behaviour is bootstrapped inline in the block + // widget partial (formwidgets/blocks/partials/_block.php) so it loads + // reliably regardless of asset-path resolution or the asset combiner. } /** @@ -203,12 +206,12 @@ protected function processGroupMode(): void } $definitions[$code] = [ - 'code' => $code, - 'name' => array_get($config, 'name'), - 'icon' => array_get($config, 'icon', 'icon-square-o'), - 'description' => array_get($config, 'description'), - 'fields' => array_get($config, 'fields'), - 'config' => array_get($config, 'config', null), + 'code' => $code, + 'name' => array_get($config, 'name'), + 'icon' => array_get($config, 'icon', 'icon-square-o'), + 'description' => array_get($config, 'description'), + 'fields' => $this->normalizeBlockFields((array) array_get($config, 'fields', [])), + 'config' => array_get($config, 'config', null), ]; } @@ -219,6 +222,50 @@ protected function processGroupMode(): void $this->useGroups = true; } + /** + * Translates block YAML shorthands before the fields are handed to the form widget. + * + * Supported shorthands on `type: section` fields: + * collapsible: true — makes the section click-to-collapse + * collapsed: true|false — initial state (true = start collapsed, false = start open) + * defaults to true when collapsible is set + */ + protected function normalizeBlockFields(array $fields): array + { + foreach ($fields as &$field) { + if (($field['type'] ?? '') !== 'section') { + continue; + } + + if (!array_key_exists('collapsible', $field)) { + continue; + } + + if ($field['collapsible']) { + $startCollapsed = $field['collapsed'] ?? true; + + // Use our own data attribute (NOT data-field-collapsible) so the + // core form widget's bindCollapsibleSections() never touches these + // sections. Core re-runs that on every FormWidget init — including + // when a nested repeater adds an item — which would re-collapse and + // double-bind handlers on sections the user manually opened. We own + // the behaviour entirely in collapsible.js instead. + $field['containerAttributes'] = array_merge( + $field['containerAttributes'] ?? [], + ['data-block-collapsible' => 1] + ); + + if (!$startCollapsed) { + $field['containerAttributes']['data-block-collapsible-open'] = 1; + } + } + + unset($field['collapsible'], $field['collapsed']); + } + + return $fields; + } + /** * Determines if a block is allowed according to the widget's ignore/allow list. */ diff --git a/formwidgets/blocks/assets/less/blocks.less b/formwidgets/blocks/assets/less/blocks.less index 4d6f7a0..386aaf8 100644 --- a/formwidgets/blocks/assets/less/blocks.less +++ b/formwidgets/blocks/assets/less/blocks.less @@ -5,6 +5,68 @@ .field-blocks { padding-top: 5px; + // Per-block toolbar holding every item control in one horizontal row: + // collapse, cut, paste, duplicate, config, delete. Overrides the core + // fixed 20px width on .repeater-item-remove so the icons never overflow. + // Mirrored as an inline-injected