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..f7b69bb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# 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. + +### Performance +- **Cross-request config cache** — `BlockManager::getConfigs()` now stores + built block configs in the Laravel cache (default: file/database driver), + 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). The cache self-invalidates when any `.block` file or any + `include`d YAML file changes, so no manual `php artisan cache:clear` is + needed after editing blocks. A per-request in-memory memo prevents even the + mtime check from running more than once per request. + +### 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 full field data 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. +- **Server-side copy/paste** — copying a block calls `onCopyItem`, which builds + the block's Form widget server-side and calls `getSaveData()`. This correctly + captures every field type — switches, mediafinders, and nested repeaters with + their own rows — which a client-side DOM scrape cannot. The clipboard payload + (`{group, config, data}`) is sent back to `onAddItem` as `_paste_data`, which + seeds the new item via `getValueFromIndex()` before rendering, so the pasted + block appears fully populated without a round-trip DOM fill step. + +### 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..f2cd093 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ 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. -## Installation +> **Block definition features:** **collapsible sections** (with persisted state), **shared field includes** (with nested includes), **recently used blocks** in the palette, and a **cross-request config cache** for fast backend page loads. Copy/Cut/Paste/Duplicate is fully server-side and captures all field types (switches, mediafinders, nested repeaters). See [Collapsible Sections](#collapsible-sections), [Including shared field definitions](#including-shared-field-definitions), [Recently used blocks](#recently-used-blocks), and [Cut, paste and duplicate blocks](#cut-paste-and-duplicate-blocks) below. Full list in [CHANGELOG.md](CHANGELOG.md). +## Installation This plugin is available for installation via [Composer](http://getcomposer.org/). ```bash @@ -99,7 +100,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 +145,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. + +> **How copy/paste works:** Copying a block calls a server-side handler (`onCopyItem`) that builds the block's form widget and calls `getSaveData()`. This correctly captures every field type — including switches, mediafinders, and nested repeaters with their own rows — which a client-side DOM scrape cannot. The payload is stored in `sessionStorage` and sent back on paste, where the new item is rendered server-side pre-populated. Rich-text and code editor widgets are refreshed via their own APIs after the item is inserted into the DOM. + +--- + ## 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..0080a7c 100644 --- a/classes/BlockManager.php +++ b/classes/BlockManager.php @@ -2,11 +2,14 @@ namespace Winter\Blocks\Classes; +use Cache; use Cms\Classes\CmsObjectCollection; use Cms\Classes\Controller; 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; @@ -30,6 +33,36 @@ class BlockManager */ protected $blocks = []; + /** + * @var array Per-request memoization of getConfigs() results, keyed by tags. + * + * Building the configs re-reads and re-parses every block file (plus any + * `include`d YAML), which costs ~15ms for the full set. getConfigs() is + * called several times per backend request (plugin boot, each Blocks + * widget, and once per getConfig() during rendering), so the uncached cost + * compounds. Block definitions cannot change within a request, so the + * result is safe to memoize. + */ + protected $configCache = []; + + /** + * @var string|null Per-request memo of the block-set signature (file mtimes). + */ + protected $signature = null; + + /** + * @var array Included YAML files (path => mtime) touched during the last + * config build, used to invalidate the cross-request cache when an included + * file changes without its parent .block file changing. + */ + protected $touchedIncludes = []; + + /** + * Cross-request cache TTL (seconds) for built configs. The cache is keyed by + * a content signature and self-invalidates, so the TTL is only a safety net. + */ + const CONFIG_CACHE_TTL = 86400; + public function init(): void { // @TODO: Find a better way to handle rendering blocks that doesn't require a "blocks" partial in the theme @@ -86,6 +119,57 @@ public function getBlocks(): CmsObjectCollection * Get an array of blocks and their configuration details in the form of ['key' => $config] */ public function getConfigs(string|array|null $tags = null): array + { + $cacheKey = json_encode($tags); + + // In-request memoization. + if (isset($this->configCache[$cacheKey])) { + return $this->configCache[$cacheKey]; + } + + return $this->configCache[$cacheKey] = $this->rememberConfigs($cacheKey, $tags); + } + + /** + * Returns the built configs for the given tags, served from the cross-request + * cache when the underlying block files (and any included YAML) are unchanged. + * + * Building the configs scans and parses every block file (~17ms for ~100 + * blocks); the cache replaces that with a cheap mtime check (~0.1ms) on every + * request after the first. The cache self-invalidates: a content signature + * derived from block-file mtimes keys the entry, and the mtimes of any + * included YAML files are stored alongside and re-checked on read. + */ + protected function rememberConfigs(string $cacheKey, string|array|null $tags): array + { + $store = 'winter.blocks.configs.' . md5($cacheKey); + $signature = $this->blocksSignature(); + + $cached = Cache::get($store); + if ( + is_array($cached) + && ($cached['signature'] ?? null) === $signature + && $this->includesUnchanged($cached['includes'] ?? []) + ) { + return $cached['data']; + } + + $this->touchedIncludes = []; + $data = $this->buildConfigs($tags); + + Cache::put($store, [ + 'signature' => $signature, + 'includes' => $this->touchedIncludes, + 'data' => $data, + ], static::CONFIG_CACHE_TTL); + + return $data; + } + + /** + * Builds the block configs by scanning and parsing the theme's block files. + */ + protected function buildConfigs(string|array|null $tags = null): array { $configs = []; foreach ($this->getBlocks() as $block) { @@ -98,7 +182,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 +192,262 @@ public function getConfigs(string|array|null $tags = null): array 'code', ] ); + + $config = $this->resolveIncludes($config); + $config = $this->resolveComponent($config); + + $configs[pathinfo($block['fileName'])['filename']] = $config; } return $configs; } + /** + * Computes a signature for the current block set from the mtimes (and paths) + * of every block file. Cheap (~0.1ms): it reads the in-memory list of + * plugin-registered block paths plus any theme-provided block files, and + * never triggers a Halcyon scan. Memoized per request. + */ + protected function blocksSignature(): string + { + if ($this->signature !== null) { + return $this->signature; + } + + $parts = []; + + // Plugin-registered blocks (the bulk) — already resolved to real paths. + foreach ($this->getRegisteredBlocks() as $path) { + $parts[$path] = @filemtime($path) ?: 0; + } + + // Theme-provided block files, if the active theme ships any. + if (($theme = Theme::getActiveTheme()) && is_dir($themeDir = $theme->getPath() . '/blocks')) { + foreach (glob($themeDir . '/*.' . static::BLOCK_EXTENSION) ?: [] as $path) { + $parts[$path] = @filemtime($path) ?: 0; + } + } + + ksort($parts); + + return $this->signature = md5(serialize($parts)); + } + + /** + * Returns true if every included YAML file recorded with a cached config + * still has the same mtime (i.e. nothing was edited under it). + */ + protected function includesUnchanged(array $includes): bool + { + foreach ($includes as $path => $mtime) { + if ((@filemtime($path) ?: 0) !== $mtime) { + return false; + } + } + + return true; + } + + /** + * 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']; + + // Capture the block's own definitions before the loop so that each + // include sees the original block values, not a previously merged result. + // This ensures the block always wins on collision regardless of include order, + // and that later includes correctly override earlier ones (not the merged state). + $ownByKey = []; + foreach ($mergeKeys as $key) { + $ownByKey[$key] = (isset($config[$key]) && is_array($config[$key])) ? $config[$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}"); + // Record even missing files so the cache invalidates if they are created later. + if ($realPath) { + $canonical = PathResolver::standardize($realPath); + $this->touchedIncludes[$canonical] = 0; + } + continue; + } + + $canonical = PathResolver::standardize($realPath); + if (in_array($canonical, $visited, true)) { + Log::warning("Winter.Blocks: circular include detected, skipping: {$path}"); + continue; + } + + // Record the included file so the cross-request config cache can be + // invalidated when it changes independently of the parent .block file. + $this->touchedIncludes[$canonical] = @filemtime($realPath) ?: 0; + + $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 = $ownByKey[$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 definitions always win. + // Later includes override earlier ones; the block overrides all. + $config[$key] = array_replace_recursive($included[$key], $config[$key] ?? [], $own); + } + } + + return $config; + } + + /** + * Resolves a `component:` key in a block config by merging the component's + * `defineProperties()` into the block's `fields:` as base definitions. + * + * A block may declare: + * + * component: mfaGateway + * + * The component's `defineProperties()` entries are converted to block field + * definitions and merged as defaults; the block's own `fields:` always win. + * + * Property-to-field mapping: + * title → label + * description → comment + * type → type (string → text, dropdown → dropdown, checkbox → checkbox) + * default → default + */ + protected function resolveComponent(array $config): array + { + if (empty($config['component']) || !is_string($config['component'])) { + unset($config['component']); + return $config; + } + + $componentName = $config['component']; + unset($config['component']); + + try { + $class = \Cms\Classes\ComponentManager::instance()->resolve($componentName); + if (!$class) { + Log::warning("Winter.Blocks: component '{$componentName}' not found for block."); + return $config; + } + + /** @var \Cms\Classes\ComponentBase $instance */ + $instance = new $class(); + $properties = method_exists($instance, 'defineProperties') ? $instance->defineProperties() : []; + + // Map component type names to block/form field type names. + $typeMap = [ + 'string' => 'text', + 'text' => 'text', + 'integer' => 'number', + 'float' => 'number', + 'checkbox' => 'checkbox', + 'dropdown' => 'dropdown', + 'set' => 'checkboxlist', + ]; + + $fromComponent = []; + foreach ($properties as $name => $def) { + $propType = $def['type'] ?? 'string'; + $fieldType = $typeMap[$propType] ?? 'text'; + $field = [ + 'label' => $def['title'] ?? $name, + 'type' => $fieldType, + ]; + if (isset($def['default'])) $field['default'] = $def['default']; + if (!empty($def['description'])) $field['comment'] = $def['description']; + if (!empty($def['placeholder'])) $field['placeholder'] = $def['placeholder']; + if (!empty($def['options'])) $field['options'] = $def['options']; + + $fromComponent[$name] = $field; + } + + // Block's own fields win; component fields fill in the rest. + $ownFields = (isset($config['fields']) && is_array($config['fields'])) ? $config['fields'] : []; + $config['fields'] = array_replace($fromComponent, $ownFields); + + } catch (\Throwable $e) { + Log::warning("Winter.Blocks: could not resolve component '{$componentName}': " . $e->getMessage()); + } + + 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 + { + foreach ($included as $name => $def) { + if (!isset($own[$name]) || !is_array($def) || !is_array($own[$name])) { + continue; + } + + $includedType = $def['type'] ?? null; + $ownType = $own[$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..d628fe8 100644 --- a/formwidgets/Blocks.php +++ b/formwidgets/Blocks.php @@ -32,6 +32,14 @@ class Blocks extends Repeater */ public array $indexConfigMeta = []; + /** + * Block data to seed into a freshly added item, keyed by index. Used by the + * server-side paste flow so a pasted block renders fully populated (switches, + * mediafinders, nested repeaters) instead of relying on lossy client-side + * field scraping. + */ + protected array $pasteData = []; + /** * {@inheritDoc} */ @@ -53,6 +61,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. } /** @@ -168,6 +179,51 @@ protected function processItems() /** * {@inheritDoc} + * + * Returns the data at a given index, preferring data seeded for the + * server-side paste flow (see onAddItem). + */ + protected function getValueFromIndex($index) + { + if (array_key_exists($index, $this->pasteData)) { + return $this->pasteData[$index]; + } + + return parent::getValueFromIndex($index); + } + + /** + * Returns the full saved data for a single block item, for the copy/cut/ + * duplicate clipboard. Building the item's Form widget and calling + * getSaveData() captures every field type correctly — including switches, + * mediafinders and nested repeaters — which a client-side DOM scrape cannot. + */ + public function onCopyItem() + { + $index = post('_repeater_index'); + $groupCode = post('_repeater_group'); + + $this->prepareVars(); + $widget = $this->makeItemFormWidget($index, $groupCode); + + // The Inspector config is repeater meta, not a form field, so read it + // straight from the posted item value rather than getSaveData(). + $config = array_get(parent::getValueFromIndex($index), '_config') ?: null; + + return [ + 'result' => json_encode([ + 'group' => $groupCode, + 'config' => $config, + 'data' => $widget->getSaveData(), + ]), + ]; + } + + /** + * {@inheritDoc} + * + * Accepts optional `_paste_data` (a JSON-encoded block data array from + * onCopyItem) and `_paste_config` to render the new item pre-populated. */ public function onAddItem() { @@ -175,16 +231,50 @@ public function onAddItem() $index = $this->getNextIndex(); + $pasteConfig = null; + $isPaste = false; + if ($pasteRaw = post('_paste_data')) { + $decoded = json_decode($pasteRaw, true); + if (is_array($decoded)) { + $this->pasteData[$index] = $decoded; + $pasteConfig = post('_paste_config') ?: null; + $isPaste = true; + } + } + + // Keep the static $onAddItemCalled flag set during prepareVars() so the + // OUTER repeater skips re-processing every existing item (Repeater.php:179) + // — we only render the new item here, so rebuilding the rest is wasted work. $this->prepareVars(); - $this->vars['widget'] = $this->makeItemFormWidget($index, $groupCode); - $this->vars['indexValue'] = $index; + + // Repeater::$onAddItemCalled is a *static* flag set during init() of the + // repeater handling this AJAX request. While set, every repeater — + // including any nested repeater inside the new item — skips processItems() + // (Repeater.php:158,179), which normally stops a new empty item pulling a + // sibling's data. But when pasting we *want* the new item's nested + // repeaters to build their rows from the seeded data, so clear the flag + // only while building and rendering the new item, then restore it. + $restoreAddItemFlag = self::$onAddItemCalled; + if ($isPaste) { + self::$onAddItemCalled = false; + } + + try { + $this->vars['widget'] = $this->makeItemFormWidget($index, $groupCode); + $this->vars['indexValue'] = $index; + $this->indexConfigMeta[$index] = $pasteConfig; + + $html = $this->makePartial('block_item') . $this->makePartial('block_add_item'); + } finally { + self::$onAddItemCalled = $restoreAddItemFlag; + } $itemContainer = '@#' . $this->getId('items'); $addItemContainer = '#' . $this->getId('add-item'); return [ $addItemContainer => '', - $itemContainer => $this->makePartial('block_item') . $this->makePartial('block_add_item') + $itemContainer => $html ]; } @@ -203,12 +293,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 +309,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..985e028 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