From de5baa5601c8bd9418497e8e05293e2dd9b06123 Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 24 Sep 2026 23:37:17 +0200 Subject: [PATCH 01/15] Support Silverstripe 5 and 6 from one line - composer: php ^8.1, framework ^5 || ^6, elemental ^5.4 || ^6, admintweaks ^3 || ^4, vendor-plugin ^2 || ^3; suggest elemental-virtual and subsites; funding entry. - Extensions extend Extension instead of DataExtension (removed in 6), without the parent::onBeforeWrite() calls that only DataExtension could answer. - Block descriptions use class_description / i18n_classDescription(): elemental 5.4 ignores the old 'description' config and elemental 6 removed getDescription(). - BlockAdmin links the page through getCMSEditLink() where it exists. - BlockIconsPreviewController: index() now matches LeftAndMain::index(), which fataled every flush on Silverstripe 5 and 6; it lists BaseElement subclasses (Block::class never resolved); the inline template moved to a .ss file (no SSViewer::fromString() in 6); the ArrayList class is picked per major. --- _config/config.yml | 8 +- composer.json | 46 ++++++++-- src/Admin/BlockAdmin.php | 6 +- src/Blocks/BlockBase.php | 5 +- src/Blocks/BlockContent.php | 5 +- src/Dev/BlockIconsPreviewController.php | 92 +++++++++++-------- .../ContentBlocksToggleExtension.php | 7 +- src/Extensions/ElementBlockBaseExtension.php | 28 +++++- src/Extensions/ElementVirtualExtension.php | 11 ++- src/Extensions/SubsitesPageExtension.php | 7 +- .../BlockBase/Dev/BlockIconsPreview.ss | 29 ++++++ 11 files changed, 182 insertions(+), 62 deletions(-) create mode 100644 templates/Restruct/Silverstripe/BlockBase/Dev/BlockIconsPreview.ss diff --git a/_config/config.yml b/_config/config.yml index f28176c..f66271c 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -40,7 +40,9 @@ DNADesign\Elemental\Controllers\ElementController: DNADesign\Elemental\Models\BaseElement: inline_editable: true displays_title_in_template: false - description: 'Base' + # 'description' was renamed 'class_description' (read since elemental 5.4, the only key in elemental 6) +# description: 'Base' + class_description: 'Base' controller_template: 'BlockHolder' extensions: - Restruct\Silverstripe\BlockBase\Extensions\ElementBlockBaseExtension @@ -80,7 +82,9 @@ DNADesign\ElementalVirtual\Model\ElementVirtual: # table_name: 'BlockClone' singular_name: 'clone block' plural_name: 'clone blocks' - description: 'Duplicaat (clone)' + # 'description' was renamed 'class_description' (read since elemental 5.4, the only key in elemental 6) +# description: 'Duplicaat (clone)' + class_description: 'Duplicaat (clone)' controller_template: 'BlockHolder' # Make virtual (clone) elements work with inline editing inline_editable: true diff --git a/composer.json b/composer.json index 66d2619..1db28d3 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,9 @@ "description": "Various tweaks & enhancements on top of Elemental", "type": "silverstripe-vendormodule", "keywords": [ - "silverstripe" + "silverstripe", + "elemental", + "blocks" ], "license": "MIT", "authors": [ @@ -14,19 +16,49 @@ } ], "require": { - "silverstripe/vendor-plugin": "^1.0", - "silverstripe/framework": "^4", - "dnadesign/silverstripe-elemental": "^4.5", - "restruct/silverstripe-admintweaks": "^2" + "php": "^8.1", + "silverstripe/vendor-plugin": "^2 || ^3", + "silverstripe/framework": "^5 || ^6", + "dnadesign/silverstripe-elemental": "^5.4 || ^6", + "restruct/silverstripe-admintweaks": "^3 || ^4" + }, + "require-dev": { + "silverstripe/recipe-testing": "^3 || ^4" + }, + "suggest": { + "dnadesign/silverstripe-elemental-virtual": "Virtual (clone) blocks; blockbase adds a block picker and inline-editing support for them", + "silverstripe/subsites": "Allow or disallow specific block types per subsite (subsites_allowed_elements / subsites_disallowed_elements)" }, "autoload": { "psr-4": { "Restruct\\Silverstripe\\BlockBase\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Restruct\\Silverstripe\\BlockBase\\Tests\\": "tests/" + } + }, "extra": { "expose": [ "client/dist" - ] - } + ], + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "config": { + "allow-plugins": { + "composer/installers": true, + "silverstripe/vendor-plugin": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/restruct" + } + ] } diff --git a/src/Admin/BlockAdmin.php b/src/Admin/BlockAdmin.php index 34d2d5e..30c1e22 100644 --- a/src/Admin/BlockAdmin.php +++ b/src/Admin/BlockAdmin.php @@ -91,9 +91,13 @@ protected function getGridFieldConfig(): GridFieldConfig ->setForm($form) ); if($elPage){ + // SiteTree::CMSEditLink() became getCMSEditLink() in Silverstripe 6 (the old name only + // survives as a deprecated DataObject wrapper); Silverstripe 5 has only the old name. + $editLink = $elPage->hasMethod('getCMSEditLink') ? $elPage->getCMSEditLink() : $elPage->CMSEditLink(); $treeDrd->setRightTitle( DBHTMLVarchar::create()->setValue( - sprintf("ID: %d (Edit in CMS)", $elPage->ID, $elPage->CMSEditLink()) +// sprintf("ID: %d (Edit in CMS)", $elPage->ID, $elPage->CMSEditLink()) + sprintf("ID: %d (Edit in CMS)", $elPage->ID, $editLink) ) ); } diff --git a/src/Blocks/BlockBase.php b/src/Blocks/BlockBase.php index 17acc6d..8b9f90d 100644 --- a/src/Blocks/BlockBase.php +++ b/src/Blocks/BlockBase.php @@ -35,7 +35,10 @@ class BlockBase */ public function getType() { - return $this->getDescription(); + // getDescription() is deprecated in elemental 5 and removed in elemental 6 (BadMethodCallException); + // i18n_classDescription() is what it returned. Uninherited config, so fall back to the singular name. +// return $this->getDescription(); + return $this->i18n_classDescription() ?: parent::getType(); } /** diff --git a/src/Blocks/BlockContent.php b/src/Blocks/BlockContent.php index e620fd3..84dacd5 100644 --- a/src/Blocks/BlockContent.php +++ b/src/Blocks/BlockContent.php @@ -23,7 +23,10 @@ class BlockContent // (for additional icons see https://gbaumeister.github.io/ss4-icons/) private static $icon = 'font-icon-block-content'; - private static $description = 'Text/Content'; + // 'description' was renamed 'class_description': elemental 5.4 reads only the new name (the old one + // is deprecated and ignored), elemental 6 has only the new one. +// private static $description = 'Text/Content'; + private static $class_description = 'Text/Content'; /** * @config bool enable/disable attributes on a project basis, can also be overridden in subclasses diff --git a/src/Dev/BlockIconsPreviewController.php b/src/Dev/BlockIconsPreviewController.php index 4940fbc..a7a3585 100644 --- a/src/Dev/BlockIconsPreviewController.php +++ b/src/Dev/BlockIconsPreviewController.php @@ -2,14 +2,21 @@ namespace Restruct\Silverstripe\BlockBase\Dev; +use DNADesign\Elemental\Models\BaseElement; use Restruct\Silverstripe\BlockBase\Blocks\BlockBase; use SilverStripe\Admin\LeftAndMain; +use SilverStripe\Control\HTTPRequest; +use SilverStripe\Control\HTTPResponse; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Config\Config; -use SilverStripe\ORM\ArrayList; +// ArrayList moved in Silverstripe 6 (ORM\ArrayList -> Model\List\ArrayList) with no alias left behind, so +// the class is resolved per major in index() instead of imported: +//use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\DataObject; use SilverStripe\View\Requirements; -use SilverStripe\View\SSViewer; +// SSViewer::fromString() is gone in Silverstripe 6; the inline template now lives in +// templates/Restruct/Silverstripe/BlockBase/Dev/BlockIconsPreview.ss +//use SilverStripe\View\SSViewer; /** * Class CRMController @@ -27,59 +34,68 @@ class BlockIconsPreviewController // protected $title = 'Preview icons of all Block-Types'; // protected $description = 'Helper to preview block-icons and get crops right'; - public function index($request) + // Previous version, kept for reference. Its signature was never compatible with LeftAndMain::index() + // (typed HTTPRequest, returns HTTPResponse, on admin 2 and 3 alike), which fataled every flush, and + // Block::class resolved to the non-existent Restruct\Silverstripe\BlockBase\Dev\Block, so no block + // type would have been listed. +// public function index($request) +// { +// $blockTypes = []; +// $blockClasses = ClassInfo::subclassesFor(Block::class, false); +// /** @var BlockBase $blockClass */ +// $i = 0; +// foreach ($blockClasses as $blockClass) { +// $i++; +// $blockTypes[] = DataObject::singleton($blockClass) +// ->customise([ +// 'IconClass' => Config::inst()->get($blockClass, 'icon'), +// 'LastItem' => $i==count($blockClasses) ? 'last': '', +// ]); +// }; +// +//// Requirements::css('app/client/dist/css/app-cms-tweaks.css'); +// +// $html = $this +// ->customise([ +// 'BlockTypes' => ArrayList::create( $blockTypes ), +// ]) +// ->renderWith(SSViewer::fromString('...')); // inline template, moved verbatim to BlockIconsPreview.ss +// +// return Requirements::includeInHTML($html); +// } + + public function index(HTTPRequest $request): HTTPResponse { $blockTypes = []; - $blockClasses = ClassInfo::subclassesFor(Block::class, false); + // Every block type: elemental's own, this module's and the project's + $blockClasses = ClassInfo::subclassesFor(BaseElement::class, false); /** @var BlockBase $blockClass */ $i = 0; foreach ($blockClasses as $blockClass) { $i++; - $blockTypes[] = DataObject::singleton($blockClass) + $block = DataObject::singleton($blockClass); + $blockTypes[] = $block ->customise([ 'IconClass' => Config::inst()->get($blockClass, 'icon'), 'LastItem' => $i==count($blockClasses) ? 'last': '', + // getDescription() no longer exists on elemental 6 (ElementBlockBaseExtension) + 'Description' => $block->getBlockDescription(), ]); }; // Requirements::css('app/client/dist/css/app-cms-tweaks.css'); + // Class names as strings, without a leading backslash, so neither needs an import + $listClass = class_exists('SilverStripe\\Model\\List\\ArrayList') + ? 'SilverStripe\\Model\\List\\ArrayList' # Silverstripe 6 + : 'SilverStripe\\ORM\\ArrayList'; # Silverstripe 5 + $html = $this ->customise([ - 'BlockTypes' => ArrayList::create( $blockTypes ), + 'BlockTypes' => $listClass::create( $blockTypes ), ]) - ->renderWith(SSViewer::fromString(' - - - - <% base_tag %> - Block-Type Icons - - - -
-



BlockTypes:

- <% loop $BlockTypes %> -
-

-
-
$Icon
-

$Description
$ClassName

-
- -
-

-
- <% end_loop %> -
- - - ')); + ->renderWith('Restruct\\Silverstripe\\BlockBase\\Dev\\BlockIconsPreview'); - return Requirements::includeInHTML($html); + return HTTPResponse::create(Requirements::includeInHTML((string) $html)); } } diff --git a/src/Extensions/ContentBlocksToggleExtension.php b/src/Extensions/ContentBlocksToggleExtension.php index dbdbec3..55de016 100644 --- a/src/Extensions/ContentBlocksToggleExtension.php +++ b/src/Extensions/ContentBlocksToggleExtension.php @@ -5,10 +5,13 @@ use Restruct\Silverstripe\AdminTweaks\Helpers\GeneralHelpers; use SilverStripe\Forms\DropdownField; use SilverStripe\Forms\FieldList; -use SilverStripe\ORM\DataExtension; +// DataExtension is deprecated in Silverstripe 5.3 and removed in 6; a plain Extension carries the +// same config statics ($db, $many_many, ...) on both majors. +//use SilverStripe\ORM\DataExtension; +use SilverStripe\Core\Extension; class ContentBlocksToggleExtension - extends DataExtension + extends Extension { private static $db = [ "ContentBlocksToggle" => "Enum('content_blocks,content,blocks','content_blocks')", diff --git a/src/Extensions/ElementBlockBaseExtension.php b/src/Extensions/ElementBlockBaseExtension.php index 9b118a8..447e507 100644 --- a/src/Extensions/ElementBlockBaseExtension.php +++ b/src/Extensions/ElementBlockBaseExtension.php @@ -13,12 +13,15 @@ use SilverStripe\Forms\MultiSelectField; use SilverStripe\Forms\OptionsetField; use SilverStripe\Forms\SelectionGroup; -use SilverStripe\ORM\DataExtension; +// DataExtension is deprecated in Silverstripe 5.3 and removed in 6; a plain Extension carries the +// same config statics ($db, $many_many, ...) on both majors. +//use SilverStripe\ORM\DataExtension; +use SilverStripe\Core\Extension; use SilverStripe\ORM\FieldType\DBHTMLVarchar; use SilverStripe\TagField\StringTagField; class ElementBlockBaseExtension - extends DataExtension + extends Extension { // // DISABLE SOME BLOCKTYPES @@ -121,7 +124,9 @@ public function getPath() public function onBeforeWrite() { - parent::onBeforeWrite(); + // Extension has no onBeforeWrite() to call (DataExtension had an empty one), so the parent + // call would fatal on Silverstripe 6: +// parent::onBeforeWrite(); // Workaround VirtualElement bug https://github.com/dnadesign/silverstripe-elemental-virtual/issues/42 if (!$this->owner->ID) { @@ -129,7 +134,7 @@ public function onBeforeWrite() } if (!$this->owner->Title) { - $this->owner->Title = $this->owner->getDescription() . " Block"; + $this->owner->Title = $this->getBlockDescription() . " Block"; } } @@ -140,6 +145,19 @@ public function updateBlockSchema( &$blockSchema ) { // Summary takes content and/or fileUrl/fileTitle props, see: // https://github.com/silverstripe/silverstripe-elemental/blob/4/client/src/components/ElementEditor/Summary.js - $blockSchema['content'] = "{$this->owner->getDescription()} block – “{$this->owner->getSummary()}…”"; + $blockSchema['content'] = "{$this->getBlockDescription()} block – “{$this->owner->getSummary()}…”"; + } + + /** + * The block type's description, for summaries and default titles. + * + * BaseElement::getDescription() is deprecated in elemental 5 and gone in elemental 6, so this reads + * i18n_classDescription() (the $class_description config, translatable) on both. That config is + * UNINHERITED, so a block class without its own description falls back to its type name + * (singular name) instead of an empty string. + */ + public function getBlockDescription(): string + { + return (string) ($this->owner->i18n_classDescription() ?: $this->owner->getType()); } } diff --git a/src/Extensions/ElementVirtualExtension.php b/src/Extensions/ElementVirtualExtension.php index 6718e18..d72bf68 100644 --- a/src/Extensions/ElementVirtualExtension.php +++ b/src/Extensions/ElementVirtualExtension.php @@ -4,11 +4,14 @@ use DNADesign\Elemental\Models\BaseElement; use SilverStripe\Forms\DropdownField; -use SilverStripe\ORM\DataExtension; +// DataExtension is deprecated in Silverstripe 5.3 and removed in 6; a plain Extension carries the +// same config statics ($db, $many_many, ...) on both majors. +//use SilverStripe\ORM\DataExtension; +use SilverStripe\Core\Extension; use SilverStripe\ORM\UnsavedRelationList; class ElementVirtualExtension - extends DataExtension + extends Extension { private static $many_many = [ 'LinkedElements' => BaseElement::class, @@ -94,7 +97,9 @@ public function onBeforeWrite() $this->owner->LinkedElementID = $this->owner->LinkedElementRelation->first()->ID; } - parent::onBeforeWrite(); + // Extension has no onBeforeWrite() to call (DataExtension had an empty one), so the parent + // call would fatal on Silverstripe 6: +// parent::onBeforeWrite(); } } diff --git a/src/Extensions/SubsitesPageExtension.php b/src/Extensions/SubsitesPageExtension.php index f4590be..8cc3d75 100644 --- a/src/Extensions/SubsitesPageExtension.php +++ b/src/Extensions/SubsitesPageExtension.php @@ -4,7 +4,10 @@ use SilverStripe\Core\Config\Config; use SilverStripe\Forms\FieldList; -use SilverStripe\ORM\DataExtension; +// DataExtension is deprecated in Silverstripe 5.3 and removed in 6; a plain Extension carries the +// same config statics ($db, $many_many, ...) on both majors. +//use SilverStripe\ORM\DataExtension; +use SilverStripe\Core\Extension; use SilverStripe\Subsites\Model\Subsite; use SilverStripe\Subsites\State\SubsiteState; @@ -12,7 +15,7 @@ * @package elemental */ class SubsitesPageExtension - extends DataExtension + extends Extension { /** * @config array enable/disable block types (classes) on subsites (and remove UI if none allowed) diff --git a/templates/Restruct/Silverstripe/BlockBase/Dev/BlockIconsPreview.ss b/templates/Restruct/Silverstripe/BlockBase/Dev/BlockIconsPreview.ss new file mode 100644 index 0000000..4c49f83 --- /dev/null +++ b/templates/Restruct/Silverstripe/BlockBase/Dev/BlockIconsPreview.ss @@ -0,0 +1,29 @@ + + + + <% base_tag %> + Block-Type Icons + + + +
+



BlockTypes:

+ <% loop $BlockTypes %> +
+

+
+
$Icon
+

$Description
$ClassName

+
+ +
+

+
+ <% end_loop %> +
+ + From a3e2cf93abf0895f206724208470dc4d00ecc07b Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 24 Sep 2026 23:37:22 +0200 Subject: [PATCH 02/15] Fix crossed FieldEnabled() answers, no-op migration hooks, and json_decode(null) - BlockContent::FieldEnabled('Content') answered has_image and ('Image') answered has_content. - ElementContentMigrationExtension took $migratable and $skip by value, so its assignments never reached MigrateContentToElement. - getExtraData() passed NULL to json_decode() for a block without ExtraData, a deprecation since PHP 8.1. --- src/Blocks/BlockContent.php | 12 +++++++++--- src/Extensions/ElementContentMigrationExtension.php | 9 +++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Blocks/BlockContent.php b/src/Blocks/BlockContent.php index 84dacd5..d5b39bf 100644 --- a/src/Blocks/BlockContent.php +++ b/src/Blocks/BlockContent.php @@ -43,8 +43,11 @@ public function FieldEnabled($field = 'IntroLine') switch ($field) { case 'Heading': return $this->config()->get('has_heading'); case 'IntroLine': return $this->config()->get('has_introline'); - case 'Content': return $this->config()->get('has_image'); - case 'Image': return $this->config()->get('has_content'); + // These two were crossed (Content answered has_image, Image answered has_content): +// case 'Content': return $this->config()->get('has_image'); +// case 'Image': return $this->config()->get('has_content'); + case 'Content': return $this->config()->get('has_content'); + case 'Image': return $this->config()->get('has_image'); case 'BackgroundImage': return $this->config()->get('has_bg_image'); } @@ -166,7 +169,10 @@ public function loadExtraData() } public function getExtraData() { - return json_decode($this->ExtraDataJSON, JSON_OBJECT_AS_ARRAY); + // Cast: a block that never stored ExtraData has NULL here, and json_decode(null) is deprecated + // since PHP 8.1. An empty string decodes to null, as before. +// return json_decode($this->ExtraDataJSON, JSON_OBJECT_AS_ARRAY); + return json_decode((string) $this->ExtraDataJSON, true); } // Write any ExtraData_* fields into ExtraDataJSON diff --git a/src/Extensions/ElementContentMigrationExtension.php b/src/Extensions/ElementContentMigrationExtension.php index 8feefce..cd81c6b 100644 --- a/src/Extensions/ElementContentMigrationExtension.php +++ b/src/Extensions/ElementContentMigrationExtension.php @@ -11,11 +11,14 @@ class ElementContentMigrationExtension extends Extension { + // By reference: MigrateContentToElement passes its variables through extend() by reference, and a + // by-value parameter here made the assignment below a no-op (every page type stayed migratable) +// public function updateIsMigratable ($migratable, $pageType) /** * @param bool $migratable * @param string|SiteTree $pageType */ - public function updateIsMigratable ($migratable, $pageType) + public function updateIsMigratable (&$migratable, $pageType) { if($pageType !== Page::class){ $migratable = false; @@ -27,7 +30,9 @@ public function updateIsMigratable ($migratable, $pageType) // // } - public function updatePageShouldSkip ($skip, $page) + // By reference, as above: by value, pages that already had blocks were never skipped +// public function updatePageShouldSkip ($skip, $page) + public function updatePageShouldSkip (&$skip, $page) { // // Delete previously migrated block (if any) // $pageBlocks = $page->ElementalArea->Elements(); From fa9c28e4c8d92e4ba465290a5ac84ba3d6223a22 Mon Sep 17 00:00:00 2001 From: micschk Date: Fri, 25 Sep 2026 00:16:25 +0200 Subject: [PATCH 03/15] BlockAdmin: pass addFieldsToTab() an array Silverstripe 6 types the argument as array, so opening a block in the Content Blocks admin threw a TypeError. An array works on Silverstripe 5 too. --- src/Admin/BlockAdmin.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Admin/BlockAdmin.php b/src/Admin/BlockAdmin.php index 30c1e22..e9f9750 100644 --- a/src/Admin/BlockAdmin.php +++ b/src/Admin/BlockAdmin.php @@ -71,7 +71,9 @@ protected function getGridFieldConfig(): GridFieldConfig $elPage = $elArea ? $elArea->getOwnerPage() : null; $fields->addFieldToTab('Root.Main', HeaderField::create('SettingsHeader', $baseElement->fieldLabel('Settings'))); - $fields->addFieldsToTab('Root.Main', $fields->fieldByName('Root.Settings')->Fields()); + // addFieldsToTab() takes an array in Silverstripe 6 (TypeError for a FieldList); an array works on 5 too +// $fields->addFieldsToTab('Root.Main', $fields->fieldByName('Root.Settings')->Fields()); + $fields->addFieldsToTab('Root.Main', $fields->fieldByName('Root.Settings')->Fields()->toArray()); $fields->removeByName('Settings'); $fields->removeByName('History'); From 7871d975566191b48ec51cb5af5d20a761ee43ac Mon Sep 17 00:00:00 2001 From: micschk Date: Fri, 25 Sep 2026 00:16:25 +0200 Subject: [PATCH 04/15] Add a behavioural test suite Covers BlockContent (schema, has_* config, upload folder, ExtraData, templates), ElementBlockBaseExtension, ContentBlocksToggleExtension, the block admin, the block-type icon preview, the migration hooks, the module config, and the optional elemental-virtual and subsites integrations (skipped when those are not installed). Each defect fixed in this pass has a regression test. --- tests/BlockAdminTest.php | 60 +++++ tests/BlockContentTest.php | 221 ++++++++++++++++++ tests/BlockIconsPreviewControllerTest.php | 46 ++++ tests/ContentBlocksToggleExtensionTest.php | 89 +++++++ tests/ElementBlockBaseExtensionTest.php | 149 ++++++++++++ .../ElementContentMigrationExtensionTest.php | 87 +++++++ tests/ElementVirtualExtensionTest.php | 65 ++++++ tests/ModuleConfigTest.php | 62 +++++ tests/Stub/MigrationHost.php | 20 ++ tests/Stub/TestPage.php | 19 ++ tests/SubsitesPageExtensionTest.php | 130 +++++++++++ 11 files changed, 948 insertions(+) create mode 100644 tests/BlockAdminTest.php create mode 100644 tests/BlockContentTest.php create mode 100644 tests/BlockIconsPreviewControllerTest.php create mode 100644 tests/ContentBlocksToggleExtensionTest.php create mode 100644 tests/ElementBlockBaseExtensionTest.php create mode 100644 tests/ElementContentMigrationExtensionTest.php create mode 100644 tests/ElementVirtualExtensionTest.php create mode 100644 tests/ModuleConfigTest.php create mode 100644 tests/Stub/MigrationHost.php create mode 100644 tests/Stub/TestPage.php create mode 100644 tests/SubsitesPageExtensionTest.php diff --git a/tests/BlockAdminTest.php b/tests/BlockAdminTest.php new file mode 100644 index 0000000..0f85a44 --- /dev/null +++ b/tests/BlockAdminTest.php @@ -0,0 +1,60 @@ +assertSame( + ['block' => ['dataClass' => BaseElement::class, 'title' => 'Blocks']], + BlockAdmin::config()->get('managed_models') + ); + $this->assertSame('blocks-admin', BlockAdmin::config()->get('url_segment')); + } + + public function testListAndEditFormShowWhereABlockIsUsed() + { + // The page type the TreeDropdownField is built for is the first elemental page type + $this->assertTrue(Page::has_extension(ElementalPageExtension::class)); + + $page = Page::create(['Title' => 'Page with a block', 'URLSegment' => 'page-with-a-block']); + $page->write(); + // A new page's area is not written until something writes it, and adding to the relation of + // an unsaved area writes nothing + $area = $page->ElementalArea(); + $area->write(); + $page->ElementalAreaID = $area->ID; + $page->write(); + $block = BlockContent::create(['Title' => 'Listed block', 'Heading' => 'Listed', 'ParentID' => $area->ID]); + $block->write(); + + $this->logInWithPermission('ADMIN'); + $list = $this->get('admin/blocks-admin'); + $this->assertSame(200, $list->getStatusCode()); + $this->assertStringContainsString('Listed block', $list->getBody()); + // Column added by getGridFieldConfig(): the page the block is used on + $this->assertStringContainsString('col-Parent-OwnerPage-Link', $list->getBody()); + + // Find the item's edit link in the list rather than predicting the GridField's name + $this->assertSame(1, preg_match('#"([^"]*/item/' . $block->ID . '/edit)[^"]*"#', $list->getBody(), $m)); + $edit = $this->get(html_entity_decode($m[1])); + $this->assertSame(200, $edit->getStatusCode()); + $body = $edit->getBody(); + $this->assertStringContainsString('Linked to page', $body); + $this->assertStringContainsString('ID: ' . $page->ID . ' (', $body); + // The page's own CMS edit link (getCMSEditLink() on 6, CMSEditLink() on 5) + $this->assertStringContainsString('admin/pages/edit/show/' . $page->ID, $body); + // Settings fields moved to the main tab under a header + $this->assertStringContainsString('SettingsHeader', $body); + } +} diff --git a/tests/BlockContentTest.php b/tests/BlockContentTest.php new file mode 100644 index 0000000..32652c3 --- /dev/null +++ b/tests/BlockContentTest.php @@ -0,0 +1,221 @@ +config()->get('db'); + $this->assertSame('Varchar(255)', $db['Heading']); + $this->assertSame('Text', $db['IntroLine']); + $this->assertSame('HTMLText', $db['Content']); + $this->assertSame('Text', $db['ExtraDataJSON']); + + $hasOne = BlockContent::singleton()->config()->get('has_one'); + $this->assertArrayHasKey('Image', $hasOne); + $this->assertArrayHasKey('BackgroundImage', $hasOne); + $owns = BlockContent::singleton()->config()->get('owns'); + $this->assertContains('Image', $owns); + $this->assertContains('BackgroundImage', $owns); + } + + /** + * Regression: the block type came from the 'description' config, which elemental 5.4 no longer + * reads and elemental 6 does not have; getDescription() is gone from elemental 6 altogether. + */ + public function testTypeIsTheClassDescription() + { + $block = BlockContent::create(); + $this->assertSame('Text/Content', $block->i18n_classDescription()); + $this->assertSame('Text/Content', $block->getType()); + } + + public function testTypeFallsBackToSingularNameWithoutDescription() + { + // class_description is uninherited, so BlockBase does not see the 'Base' set on BaseElement + $block = BlockBase::create(); + $this->assertEmpty($block->i18n_classDescription()); + $this->assertSame($block->i18n_singular_name(), $block->getType()); + } + + /** + * Regression: FieldEnabled('Content') answered has_image and FieldEnabled('Image') has_content. + */ + public function testFieldEnabledAnswersEachFieldsOwnSetting() + { + Config::modify()->set(BlockContent::class, 'has_image', false); + Config::modify()->set(BlockContent::class, 'has_content', true); + $block = BlockContent::create(); + $this->assertFalse((bool) $block->FieldEnabled('Image')); + $this->assertTrue((bool) $block->FieldEnabled('Content')); + + Config::modify()->set(BlockContent::class, 'has_image', true); + Config::modify()->set(BlockContent::class, 'has_content', false); + $this->assertTrue((bool) $block->FieldEnabled('Image')); + $this->assertFalse((bool) $block->FieldEnabled('Content')); + + Config::modify()->set(BlockContent::class, 'has_heading', false); + Config::modify()->set(BlockContent::class, 'has_introline', false); + Config::modify()->set(BlockContent::class, 'has_bg_image', false); + $this->assertFalse((bool) $block->FieldEnabled('Heading')); + $this->assertFalse((bool) $block->FieldEnabled('IntroLine')); + $this->assertFalse((bool) $block->FieldEnabled('BackgroundImage')); + // Anything else is always enabled + $this->assertTrue($block->FieldEnabled('Something')); + } + + public function testAllFieldsShowByDefault() + { + $fields = BlockContent::create()->getCMSFields(); + foreach (['Heading', 'IntroLine', 'Content', 'Image', 'BackgroundImage'] as $name) { + $this->assertNotNull($fields->dataFieldByName($name), "$name should be shown"); + } + $this->assertSame(2, (int) $fields->dataFieldByName('IntroLine')->getRows()); + $this->assertSame(20, (int) $fields->dataFieldByName('Content')->getRows()); + // The raw JSON store is never edited directly + $this->assertNull($fields->dataFieldByName('ExtraDataJSON')); + } + + public function testEachHasConfigRemovesOnlyItsOwnField() + { + // No data provider: PHPUnit 9 reads @dataProvider, PHPUnit 11 wants an attribute instead + $map = [ + 'has_heading' => 'Heading', + 'has_introline' => 'IntroLine', + 'has_content' => 'Content', + 'has_image' => 'Image', + 'has_bg_image' => 'BackgroundImage', + ]; + foreach ($map as $config => $field) { + Config::nest(); + try { + Config::modify()->set(BlockContent::class, $config, false); + $fields = BlockContent::create()->getCMSFields(); + $this->assertNull($fields->dataFieldByName($field), "$config: false should remove $field"); + foreach (array_diff($map, [$field]) as $other) { + $this->assertNotNull($fields->dataFieldByName($other), "$config: false should keep $other"); + } + } finally { + Config::unnest(); + } + } + } + + public function testImageUploadDirSetsTheUploadFolder() + { + $fields = BlockContent::create()->getCMSFields(); + $this->assertInstanceOf(UploadField::class, $fields->dataFieldByName('Image')); + $this->assertNotSame('block-images-test', $fields->dataFieldByName('Image')->getFolderName()); + + Config::modify()->set(BlockContent::class, 'image_upload_dir', 'block-images-test'); + $fields = BlockContent::create()->getCMSFields(); + $this->assertSame('block-images-test', $fields->dataFieldByName('Image')->getFolderName()); + $this->assertSame('block-images-test', $fields->dataFieldByName('BackgroundImage')->getFolderName()); + } + + public function testExtraDataFieldsAreStoredAsJsonAndLoadedBack() + { + $block = BlockContent::create(['Heading' => 'Extra']); + $block->ExtraData_Colour = 'blue'; + $block->ExtraData_Count = '3'; + $block->write(); + + $this->assertSame(['Colour' => 'blue', 'Count' => '3'], json_decode($block->ExtraDataJSON, true)); + + $reloaded = BlockContent::get()->byID($block->ID); + $this->assertSame(['Colour' => 'blue', 'Count' => '3'], $reloaded->getExtraData()); + + // getCMSFields() loads the stored values into ExtraData_* so form fields show them + $reloaded->getCMSFields(); + $this->assertSame('blue', $reloaded->ExtraData_Colour); + + // A later write keeps values that were not touched and updates the ones that were + $again = BlockContent::get()->byID($block->ID); + $again->ExtraData_Count = '4'; + $again->write(); + $this->assertSame( + ['Colour' => 'blue', 'Count' => '4'], + BlockContent::get()->byID($block->ID)->getExtraData() + ); + } + + /** + * Regression: json_decode(NULL) for a block without stored ExtraData, deprecated since PHP 8.1. + * Neither PHPUnit major fails a test on a deprecation by default, so it is collected here. + */ + public function testExtraDataWithoutStoredDataRaisesNoDeprecation() + { + $block = BlockContent::create(); + $deprecations = []; + set_error_handler(function ($errno, $errstr) use (&$deprecations) { + $deprecations[] = $errstr; + return true; + }, E_DEPRECATED | E_USER_DEPRECATED); + try { + $data = $block->getExtraData(); + } finally { + restore_error_handler(); + } + $this->assertNull($data); + $this->assertSame([], array_values(array_filter($deprecations, fn ($m) => str_contains($m, 'json_decode')))); + } + + public function testSummaryCombinesHeadingAndContent() + { + $block = BlockContent::create(['Heading' => 'Hello', 'Content' => '

World of blocks

']); + $summary = (string) $block->getSummary(); + $this->assertStringContainsString('Hello', $summary); + $this->assertStringContainsString('World of blocks', $summary); + $this->assertStringNotContainsString('

', $summary); + } + + public function testRenderTemplatesIncludePlainThemePaths() + { + $block = BlockContent::create(['Style' => 'wide']); + $templates = $block->getRenderTemplates(); + $area = $block->getAreaRelationName(); + foreach ([ + "Blocks\\BlockContent_{$area}_wide", + 'Blocks\\BlockContent_wide', + "Blocks\\BlockContent_{$area}", + 'Blocks\\BlockContent', + 'BlockContent_wide', + 'BlockContent', + ] as $expected) { + $this->assertContains($expected, $templates); + } + // Namespaced candidates from elemental come first; the plain ones are fallbacks + $this->assertGreaterThan( + array_search(BlockContent::class, $templates), + array_search('Blocks\\BlockContent', $templates) + ); + } + + public function testRendersWithTheModuleTemplate() + { + $area = ElementalArea::create(); + $area->write(); + $block = BlockContent::create([ + 'Heading' => 'Rendered heading', + 'Content' => '

Rendered body

', + 'ParentID' => $area->ID, + ]); + $block->write(); + + // Without the holder: templates/Blocks/BlockContent.ss + $html = (string) $block->forTemplate(false); + $this->assertStringContainsString('

Rendered heading

', $html); + $this->assertStringContainsString('

Rendered body

', $html); + $this->assertStringContainsString('class="col typography"', $html); + } +} diff --git a/tests/BlockIconsPreviewControllerTest.php b/tests/BlockIconsPreviewControllerTest.php new file mode 100644 index 0000000..ee98b43 --- /dev/null +++ b/tests/BlockIconsPreviewControllerTest.php @@ -0,0 +1,46 @@ +markTestSkipped('The admin/blocktypeicons route is only configured in dev'); + } + } + + /** + * Regression: index() did not match LeftAndMain::index() (a fatal on every flush), and it listed + * subclasses of a class that does not exist, so no block type would have been shown. + */ + public function testListsEveryBlockTypeWithItsIconAndDescription() + { + $this->logInWithPermission('ADMIN'); + $response = $this->get('admin/blocktypeicons'); + $this->assertSame(200, $response->getStatusCode()); + + $body = $response->getBody(); + $this->assertStringContainsString('Block-Type Icons', $body); + $this->assertStringContainsString(htmlspecialchars(BlockContent::class), $body); + $this->assertStringContainsString('font-icon-block-content', $body); + $this->assertStringContainsString('Text/Content', $body); + // elemental's own types are listed too + $this->assertStringContainsString(htmlspecialchars('DNADesign\\Elemental\\Models\\ElementContent'), $body); + } + + public function testNeedsCmsAccess() + { + $response = $this->get('admin/blocktypeicons'); + $this->assertStringNotContainsString('Block-Type Icons', $response->getBody()); + } +} diff --git a/tests/ContentBlocksToggleExtensionTest.php b/tests/ContentBlocksToggleExtensionTest.php new file mode 100644 index 0000000..112612c --- /dev/null +++ b/tests/ContentBlocksToggleExtensionTest.php @@ -0,0 +1,89 @@ + [ + ElementalPageExtension::class, + ContentBlocksToggleExtension::class, + ], + ]; + + public function testModuleConfigAppliesItToPage() + { + $this->assertTrue(Page::has_extension(ContentBlocksToggleExtension::class)); + $this->assertTrue(Page::has_extension(ElementalPageExtension::class)); + } + + public function testToggleFieldDefaultsToContentAndBlocks() + { + $db = TestPage::singleton()->config()->get('db'); + $this->assertStringStartsWith('Enum(', $db['ContentBlocksToggle']); + // The Enum default is applied by the database, so read the page back + $page = TestPage::create(); + $page->write(); + $this->assertSame('content_blocks', TestPage::get()->byID($page->ID)->ContentBlocksToggle); + } + + public function testToggleIsInsertedDirectlyBeforeContent() + { + $page = TestPage::create(); + $page->write(); + $fields = $page->getCMSFields(); + + $toggle = $fields->dataFieldByName('ContentBlocksToggle'); + $this->assertInstanceOf(DropdownField::class, $toggle); + $this->assertSame( + [ + 'content_blocks' => 'Content field followed by blocks', + 'content' => 'Content field only (do not use blocks)', + 'blocks' => 'Blocks only (do not use regular Content field)', + ], + $toggle->getSource() + ); + $this->assertSame('Show/use content, blocks, or both', $toggle->Title()); + + $names = array_map(fn ($f) => $f->getName(), $fields->fieldByName('Root.Main')->Fields()->toArray()); + $this->assertSame( + array_search('Content', $names) - 1, + array_search('ContentBlocksToggle', $names), + 'ContentBlocksToggle should sit directly before Content' + ); + // keep_content_fields: both editors are there + $this->assertNotNull($fields->dataFieldByName('Content')); + $this->assertNotNull($fields->dataFieldByName('ElementalArea')); + } + + public function testContentOnlyHidesTheBlocksEditor() + { + $page = TestPage::create(['ContentBlocksToggle' => 'content']); + $page->write(); + $fields = $page->getCMSFields(); + $this->assertNull($fields->dataFieldByName('ElementalArea')); + $this->assertNotNull($fields->dataFieldByName('Content')); + } + + public function testBlocksOnlyHidesTheContentField() + { + $page = TestPage::create(['ContentBlocksToggle' => 'blocks']); + $page->write(); + $fields = $page->getCMSFields(); + $this->assertNull($fields->dataFieldByName('Content')); + $this->assertNotNull($fields->dataFieldByName('ElementalArea')); + } +} diff --git a/tests/ElementBlockBaseExtensionTest.php b/tests/ElementBlockBaseExtensionTest.php new file mode 100644 index 0000000..c950bb4 --- /dev/null +++ b/tests/ElementBlockBaseExtensionTest.php @@ -0,0 +1,149 @@ +assertTrue(BaseElement::has_extension(ElementBlockBaseExtension::class)); + $this->assertTrue(BlockContent::singleton()->hasExtension(ElementBlockBaseExtension::class)); + } + + public function testBaseTypesCannotBeCreated() + { + $this->logInWithPermission('ADMIN'); + foreach ([BaseElement::class, ElementContent::class, BlockBase::class] as $class) { + $this->assertFalse($class::singleton()->canCreate(), "$class should be hidden from the add-block menu"); + } + $this->assertTrue(BlockContent::singleton()->canCreate()); + } + + /** + * Regression: onBeforeWrite() called getDescription(), which elemental 6 removed, so every new + * block without a Title threw on write. + */ + public function testNewBlockWithoutTitleIsNamedAfterItsType() + { + $block = BlockContent::create(); + $block->write(); + $this->assertSame('Text/Content Block', $block->Title); + + $named = BlockContent::create(['Title' => 'My own title']); + $named->write(); + $this->assertSame('My own title', $named->Title); + } + + public function testBlockDescriptionFallsBackToTheTypeName() + { + $this->assertSame('Text/Content', BlockContent::singleton()->getBlockDescription()); + // No own class_description: the (singular) type name, never an empty string + $this->assertSame(BlockBase::singleton()->getType(), BlockBase::singleton()->getBlockDescription()); + $this->assertNotSame('', BlockBase::singleton()->getBlockDescription()); + } + + public function testNewBlockIsNotAvailableGlobally() + { + if (!BaseElement::singleton()->hasField('AvailableGlobally')) { + $this->markTestSkipped('dnadesign/silverstripe-elemental-virtual is not installed'); + } + // elemental-virtual defaults new blocks to globally available; the workaround turns that off + $block = BlockContent::create(['AvailableGlobally' => 1]); + $block->write(); + $this->assertSame(0, (int) BlockContent::get()->byID($block->ID)->AvailableGlobally); + + // ...only on creation: an existing block keeps what the editor chose + $block->AvailableGlobally = 1; + $block->write(); + $this->assertSame(1, (int) BlockContent::get()->byID($block->ID)->AvailableGlobally); + } + + public function testBlockSchemaSummarisesTheBlock() + { + $block = BlockContent::create(['Heading' => 'Schema heading', 'Content' => '

Body

']); + $block->write(); + $schema = $block->getBlockSchema(); + $this->assertStringStartsWith('Text/Content block – “', $schema['content']); + $this->assertStringContainsString('Schema heading', $schema['content']); + } + + public function testExtraClassIsRemovedWithoutStyleOptions() + { + $fields = BlockContent::create()->getCMSFields(); + $this->assertNull($fields->dataFieldByName('ExtraClass')); + } + + public function testStyleOptionsMakeExtraClassADropdownOnTheMainTab() + { + Config::modify()->set(BlockContent::class, 'style_options', ['light' => 'Light', 'dark' => 'Dark']); + $fields = BlockContent::create()->getCMSFields(); + $field = $fields->dataFieldByName('ExtraClass'); + $this->assertInstanceOf(DropdownField::class, $field); + $this->assertSame(['light' => 'Light', 'dark' => 'Dark'], $field->getSource()); + $this->assertNotNull($fields->fieldByName('Root.Main')->fieldByName('ExtraClass')); + } + + public function testStyleDropdownHasNoEmptyOptionAndSitsOnTheMainTab() + { + Config::modify()->set(BlockContent::class, 'styles', ['wide' => 'Wide', 'narrow' => 'Narrow']); + $fields = BlockContent::create()->getCMSFields(); + $style = $fields->dataFieldByName('Style'); + $this->assertInstanceOf(DropdownField::class, $style); + $this->assertFalse($style->getHasEmptyDefault()); + $this->assertNotNull($fields->fieldByName('Root.Main')->fieldByName('Style')); + $this->assertSame('Layout style', $style->Title()); + } + + public function testTitleIsRelabelledAsInternalDescription() + { + $fields = BlockContent::create()->getCMSFields(); + $this->assertStringContainsString('(intern)', (string) $fields->dataFieldByName('Title')->Title()); + } + + public function testBlockHolderClassesRenderOnOneLine() + { + $block = BlockContent::create(['Style' => 'wide', 'ExtraClass' => 'dark']); + $block->write(); + $classes = $block->BlockHolderClasses(); + $this->assertStringNotContainsString("\n", $classes); + $this->assertStringContainsString('block-item-holder block-outer', $classes); + $this->assertStringContainsString('block-outer_BlockContent', $classes); + $this->assertStringContainsString('wide', $classes); + $this->assertStringContainsString('dark', $classes); + } + + public function testHolderTemplateWrapsTheBlock() + { + $area = ElementalArea::create(); + $area->write(); + $block = BlockContent::create(['Heading' => 'In a holder', 'ParentID' => $area->ID]); + $block->write(); + + // The holder is rendered by the element's controller, with controller_template: BlockHolder + // -> templates/DNADesign/Elemental/Layout/BlockHolder.ss + $html = (string) $block->getController()->forTemplate(); + $this->assertStringContainsString('id="' . $block->getAnchor() . '"', $html); + $this->assertStringContainsString('block-outer_BlockContent', $html); + $this->assertStringContainsString('pos-1', $html); + $this->assertStringContainsString('

In a holder

', $html); + } + + public function testPathNamesPageTitleAndType() + { + $block = BlockContent::create(['Title' => 'Orphan']); + $block->write(); + $this->assertSame('(-) Orphan [type: Text/Content]', $block->getPath()); + } +} diff --git a/tests/ElementContentMigrationExtensionTest.php b/tests/ElementContentMigrationExtensionTest.php new file mode 100644 index 0000000..4ec0c2b --- /dev/null +++ b/tests/ElementContentMigrationExtensionTest.php @@ -0,0 +1,87 @@ + [ + ElementContentMigrationExtension::class, + ], + TestPage::class => [ + ElementalPageExtension::class, + ], + ]; + + /** + * Regression: the parameter was taken by value, so no page type was ever excluded. + */ + public function testOnlyPlainPagesAreMigratable() + { + $host = MigrationHost::create(); + + # Variables, not literals: extend() takes every argument by reference + $migratable = true; + $pageType = RedirectorPage::class; + $host->extend('updateIsMigratable', $migratable, $pageType); + $this->assertFalse($migratable); + + $migratable = true; + $pageType = Page::class; + $host->extend('updateIsMigratable', $migratable, $pageType); + $this->assertTrue($migratable); + } + + /** + * Regression: the parameter was taken by value, so pages with blocks were migrated again. + */ + public function testPagesThatAlreadyHaveBlocksAreSkipped() + { + $host = MigrationHost::create(); + $page = TestPage::create(); + $page->write(); + + $skip = false; + $host->extend('updatePageShouldSkip', $skip, $page); + $this->assertFalse($skip, 'A page without blocks is migrated'); + + $area = $page->ElementalArea(); + $area->write(); + $page->ElementalAreaID = $area->ID; + $page->write(); + BlockContent::create(['ParentID' => $area->ID])->write(); + $skip = false; + $host->extend('updatePageShouldSkip', $skip, $page); + $this->assertTrue($skip, 'A page that already has blocks is skipped'); + } + + public function testMigratedElementIsNamedAfterItsPage() + { + $host = MigrationHost::create(); + $page = TestPage::create(['Title' => 'Source page']); + $page->write(); + $element = BlockContent::create(['Title' => 'Migrated']); + + $content = '

x

'; + $host->extend('updateMigratedElement', $element, $content, $page); + $this->assertSame('Migrated: Source page', $element->Title); + } +} diff --git a/tests/ElementVirtualExtensionTest.php b/tests/ElementVirtualExtensionTest.php new file mode 100644 index 0000000..69a7b43 --- /dev/null +++ b/tests/ElementVirtualExtensionTest.php @@ -0,0 +1,65 @@ +markTestSkipped('dnadesign/silverstripe-elemental-virtual is not installed'); + } + parent::setUp(); + } + + public function testModuleConfigAppliesToVirtualBlocks() + { + $virtual = self::VIRTUAL; + $this->assertTrue($virtual::has_extension(ElementVirtualExtension::class)); + $this->assertSame('BlockHolder', $virtual::config()->get('controller_template')); + $this->assertTrue($virtual::config()->get('inline_editable')); + // Renamed from 'description', which elemental 5.4 ignores and elemental 6 does not have + $this->assertSame('Duplicaat (clone)', $virtual::singleton()->i18n_classDescription()); + } + + public function testPickerOffersOnlyGloballyAvailableBlocks() + { + $shared = BlockContent::create(['Title' => 'Shared']); + $shared->write(); + // The extension switches AvailableGlobally off on creation, so set it on the saved block + $shared->AvailableGlobally = 1; + $shared->write(); + $private = BlockContent::create(['Title' => 'Private']); + $private->write(); + + $fields = singleton(self::VIRTUAL)->create()->getCMSFields(); + $picker = $fields->dataFieldByName('LinkedElementID'); + $this->assertInstanceOf(DropdownField::class, $picker); + $this->assertSame([$shared->ID => $shared->getPath()], $picker->getSource()); + + foreach (['Style', 'AvailableGlobally', 'ExtraClass'] as $name) { + $this->assertNull($fields->dataFieldByName($name), "$name does not apply to a clone"); + } + } + + public function testPickerExplainsWhatToDoWhenNothingIsShared() + { + $fields = singleton(self::VIRTUAL)->create()->getCMSFields(); + $picker = $fields->dataFieldByName('LinkedElementID'); + $this->assertSame([], $picker->getSource()); + $this->assertStringContainsString('beschikbaar', (string) $picker->getDescription()); + } +} diff --git a/tests/ModuleConfigTest.php b/tests/ModuleConfigTest.php new file mode 100644 index 0000000..0e1313b --- /dev/null +++ b/tests/ModuleConfigTest.php @@ -0,0 +1,62 @@ +assertTrue($config->get('inline_editable')); + $this->assertFalse($config->get('displays_title_in_template')); + $this->assertSame('BlockHolder', $config->get('controller_template')); + // Renamed from 'description', which elemental 5.4 ignores and elemental 6 does not have. + // (Displayed, elemental's own lang entry for BaseElement.CLASS_DESCRIPTION takes precedence.) + $this->assertSame('Base', Config::inst()->get(BaseElement::class, 'class_description', Config::UNINHERITED)); + // ...and no longer sets the old key (elemental 5 still declares its own default there) + $this->assertNotSame('Base', Config::inst()->get(BaseElement::class, 'description', Config::UNINHERITED)); + } + + public function testElementalBehaviour() + { + $this->assertTrue(Config::inst()->get(ElementalAreasExtension::class, 'keep_content_fields')); + $this->assertFalse(ElementController::config()->get('include_default_styles')); + + $ignored = Config::inst()->get(ElementalPageExtension::class, 'ignored_classes'); + $this->assertContains(RedirectorPage::class, $ignored); + $this->assertContains(VirtualPage::class, $ignored); + } + + public function testAdminStylesheetIsRequiredAndExists() + { + $css = 'restruct/silverstripe-blockbase:client/dist/css/admin-block-tweaks.css'; + $this->assertContains($css, LeftAndMain::config()->get('extra_requirements_css')); + $this->assertFileExists(Director::getAbsFile(ModuleResourceLoader::singleton()->resolvePath($css))); + } + + public function testIconPreviewRouteExistsInDev() + { + if (!Director::isDev()) { + $this->markTestSkipped('The route is only configured in dev'); + } + $this->assertSame( + 'Restruct\\Silverstripe\\BlockBase\\Dev\\BlockIconsPreviewController', + Director::config()->get('rules')['admin/blocktypeicons'] ?? null + ); + } +} diff --git a/tests/Stub/MigrationHost.php b/tests/Stub/MigrationHost.php new file mode 100644 index 0000000..08e79b7 --- /dev/null +++ b/tests/Stub/MigrationHost.php @@ -0,0 +1,20 @@ + [ + ElementalPageExtension::class, + ContentBlocksToggleExtension::class, + SubsitesPageExtension::class, + ], + ]; + + # Class names as strings: the package may not be installed + private const SUBSITE_STATE = 'SilverStripe\\Subsites\\State\\SubsiteState'; + + protected function setUp(): void + { + if (!class_exists(self::SUBSITE_STATE)) { + // Before parent::setUp(): $required_extensions would apply an extension whose hooks + // reference subsites classes + $this->markTestSkipped('silverstripe/subsites is not installed'); + } + parent::setUp(); + } + + private function inSubsite(int $id, callable $callback) + { + // Elemental 6 caches the available types per page class for the rest of the process, keyed on + // the class only, so a list computed for one subsite would be served for another. Reset it on + // the way in and out (the reset() method does not exist in elemental 5, which has no cache). + $reset = function () { + if (method_exists(ElementalAreasExtension::class, 'reset')) { + ElementalAreasExtension::reset(); + } + }; + $state = call_user_func([self::SUBSITE_STATE, 'singleton']); + $reset(); + try { + return $state->withState(function ($state) use ($id, $callback) { + $state->setSubsiteId($id); + return $callback(); + }); + } finally { + $reset(); + } + } + + public function testMainSiteKeepsEveryType() + { + Config::modify()->set(TestPage::class, 'subsites_allowed_elements', false); + $types = TestPage::create()->getElementalTypes(); + $this->assertArrayHasKey(BlockContent::class, $types); + } + + public function testFalseAllowsNoTypesOnASubsite() + { + Config::modify()->set(TestPage::class, 'subsites_allowed_elements', false); + $types = $this->inSubsite(1, fn () => TestPage::create()->getElementalTypes()); + $this->assertSame([], $types); + } + + public function testAllowListLimitsTheTypesOnASubsite() + { + Config::modify()->set(TestPage::class, 'subsites_allowed_elements', [BlockContent::class]); + $types = $this->inSubsite(1, fn () => TestPage::create()->getElementalTypes()); + $this->assertSame([BlockContent::class], array_keys($types)); + } + + public function testDenyListRemovesTypesOnASubsite() + { + $all = TestPage::create()->getElementalTypes(); + $this->assertArrayHasKey(BlockContent::class, $all); + + Config::modify()->set(TestPage::class, 'subsites_disallowed_elements', [BlockContent::class]); + $types = $this->inSubsite(1, fn () => TestPage::create()->getElementalTypes()); + $this->assertArrayNotHasKey(BlockContent::class, $types); + $this->assertCount(count($all) - 1, $types); + } + + public function testBlockEditorIsRemovedWhenNoTypesAreAllowed() + { + $page = TestPage::create(); + $page->write(); + $fields = $page->getCMSFields(); + $this->assertNotNull($fields->dataFieldByName('ElementalArea')); + + Config::modify()->set(TestPage::class, 'subsites_allowed_elements', false); + $fields = $this->inSubsite(1, fn () => $page->getCMSFields()); + $this->assertNull($fields->dataFieldByName('ElementalArea')); + $this->assertNull($fields->dataFieldByName('ContentBlocksToggle')); + } + + public function testBlockEditorStaysWhileBlocksRemain() + { + $page = TestPage::create(); + $page->write(); + // Existing blocks must stay removable after a config change + $area = $page->ElementalArea(); + $area->write(); + $page->ElementalAreaID = $area->ID; + $page->write(); + ElementContent::create(['ParentID' => $area->ID])->write(); + $this->assertSame(1, $page->ElementalArea()->Elements()->count()); + + Config::modify()->set(TestPage::class, 'subsites_allowed_elements', false); + $fields = $this->inSubsite(1, fn () => $page->getCMSFields()); + $this->assertNotNull($fields->dataFieldByName('ElementalArea')); + } +} From 8d3375315c1818f593cc7be2bc22bcdbb824ee63 Mon Sep 17 00:00:00 2001 From: micschk Date: Fri, 25 Sep 2026 00:48:16 +0200 Subject: [PATCH 05/15] Test getBlockDescription() on an element outside BlockBase A mutation that returned getType() survived: for BlockBase subclasses getType() is the class description already. --- tests/ElementBlockBaseExtensionTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/ElementBlockBaseExtensionTest.php b/tests/ElementBlockBaseExtensionTest.php index c950bb4..db74a4d 100644 --- a/tests/ElementBlockBaseExtensionTest.php +++ b/tests/ElementBlockBaseExtensionTest.php @@ -52,6 +52,13 @@ public function testBlockDescriptionFallsBackToTheTypeName() // No own class_description: the (singular) type name, never an empty string $this->assertSame(BlockBase::singleton()->getType(), BlockBase::singleton()->getBlockDescription()); $this->assertNotSame('', BlockBase::singleton()->getBlockDescription()); + + // An element outside BlockBase, whose getType() is its singular name, still reports its + // class description (elemental's ElementContent has both, and they differ) + $content = ElementContent::singleton(); + $this->assertNotEmpty($content->i18n_classDescription()); + $this->assertNotSame($content->getType(), $content->i18n_classDescription()); + $this->assertSame($content->i18n_classDescription(), $content->getBlockDescription()); } public function testNewBlockIsNotAvailableGlobally() From 1797db5f9d3456f3c9778077d892c312cf4277bb Mon Sep 17 00:00:00 2001 From: micschk Date: Fri, 25 Sep 2026 00:49:52 +0200 Subject: [PATCH 06/15] CI: test on Silverstripe 5 and 6, with a consumer-shape DB test and a boot step Matrix: Silverstripe 5 on PHP 8.1 and 8.3, Silverstripe 6 on PHP 8.3 and 8.4, with elemental-virtual and subsites installed. Tests run before the database is built, with --fail-on-empty-test-suite on every leg. .gitattributes keeps tests and CI files out of dist installs. --- .gitattributes | 10 ++ .github/workflows/ci.yml | 238 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfd8481 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Keep development-only files out of dist installs (Packagist zips). CI and local module work must +# therefore install this module as a PATH repository with symlink: true, or a real checkout - +# a dist install contains no tests at all. +/tests export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.editorconfig export-ignore +/.babelrc export-ignore +/UPGRADING.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..65c9c03 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,238 @@ +name: CI + +on: + push: + # The repository's default branch (checked with `gh api repos/restruct/blockbase`) + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + name: SS${{ matrix.silverstripe }} / PHP ${{ matrix.php }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + # One job per supported Silverstripe major, at the oldest and newest PHP that major is + # used with here. The module's composer.json is the source of truth for the supported + # range; this matrix must follow it. + - silverstripe: '5' + php: '8.1' + recipe: '^5' + testing: '^3' + virtual: '^2' + subsites: '^3' + - silverstripe: '5' + php: '8.3' + recipe: '^5' + testing: '^3' + virtual: '^2' + subsites: '^3' + - silverstripe: '6' + php: '8.3' + recipe: '^6' + testing: '^4' + virtual: '^3' + subsites: '^4' + - silverstripe: '6' + php: '8.4' + recipe: '^6' + testing: '^4' + virtual: '^3' + subsites: '^4' + + services: + database: + # MariaDB rather than MySQL: it matches both the local dev machines and the Forge + # servers, so a version-specific failure here means something real. + image: mariadb:11.4 + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: ss_ci + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s --health-timeout=5s --health-retries=10 + + env: + SS_DATABASE_CLASS: MySQLDatabase + SS_DATABASE_SERVER: 127.0.0.1 + SS_DATABASE_PORT: 3306 + SS_DATABASE_USERNAME: root + SS_DATABASE_PASSWORD: root + SS_DATABASE_NAME: ss_ci + # dev: the admin/blocktypeicons route (and its test) is only configured in dev + SS_ENVIRONMENT_TYPE: dev + + steps: + - name: Check out the module + uses: actions/checkout@v4 + with: + path: module + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, intl, pdo_mysql, mysqli, gd, zip, dom, xml, tokenizer, simplexml, ctype, hash, session + tools: composer:v2 + coverage: none + + - name: Build a host project + # The module cannot be tested on its own: it needs a booted Silverstripe app. The host + # composer.json is written by hand; recipe-cms is required so the host is a realistic + # site (the recipe installs the app/ project files, including the Page class the module's + # config extends). + # + # elemental-virtual and subsites are only SUGGESTED by the module, but it ships code for + # both, so they are installed here to test it. Their tests skip themselves when the package + # is missing. + # + # The module is pulled in through a PATH repository pointing at the checkout, symlinked + # rather than mirrored ON PURPOSE: /tests is export-ignore in .gitattributes, so a + # mirrored (dist-style) install would contain no tests at all. + run: | + mkdir -p app-host + cd app-host + cat > composer.json < phpunit.xml <<'XML' + + + + + + vendor/restruct/silverstripe-blockbase/tests + + + app/tests + + + + XML + + - name: Add a consumer-shape database test + # A module's own suite structurally cannot catch an unbuildable fixture in its tests/ dir + # (PHPUnit loads those files, the class manifest never registers them, no tables are + # built). A DB test in the HOST project, run with a flush, builds the temp database with + # this module's tests/ in the manifest - the way a consuming project would. + run: | + mkdir -p app-host/app/tests + cat > app-host/app/tests/ConsumerDbTest.php <<'PHP' + 'x']); + $m->write(); + $this->assertGreaterThan(0, $m->ID); + } + } + PHP + + - name: Run tests + working-directory: app-host + # The tests run BEFORE the database is built (next step), against an empty ss_ci + # database, so a test that silently relies on a built database fails here. + # + # Every run flushes the manifest, and how differs per major: + # - SS5 (framework 5, PHPUnit 9): SS_PHPUNIT_FLUSH does not exist in framework 5, so it + # would be inert. The flush is a trailing `flush=1` argument, which only works AFTER an + # explicit test path - `--testsuite x flush=1` makes PHPUnit 9 open "flush=1" as a file + # and fail. So the SS5 legs run by path; phpunit.xml still supplies bootstrap and + # failOnWarning. + # - SS6 (framework 6, PHPUnit 11): a `flush=1` argument no longer works; framework 6 reads + # SS_PHPUNIT_FLUSH instead, which also combines with --testsuite. + # + # A green run is not enough on its own: a suite that silently loads no tests exits 0 on + # both PHPUnit 9 and 11. Every leg therefore passes --fail-on-empty-test-suite; PHPUnit 9.6 + # accepts it too, although its --help does not list it. No exact test-count check on the + # summary line: PHPUnit 11 prints "OK, but there were issues!" (exit 0) instead of + # "OK (n tests" as soon as one deprecation is recorded, so a count grep would turn a + # passing suite red. + run: | + if [ "${{ matrix.silverstripe }}" = "5" ]; then + vendor/bin/phpunit --fail-on-empty-test-suite vendor/restruct/silverstripe-blockbase/tests flush=1 + vendor/bin/phpunit --fail-on-empty-test-suite app/tests flush=1 + else + SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit --testsuite blockbase --fail-on-empty-test-suite + SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit --testsuite consumer --fail-on-empty-test-suite + fi + + - name: Boot the app and build the database + # The suite proves the classes behave; this proves the module does not break a real boot. + # 1.x fatals here on Silverstripe 5 and 6, on every flush (BlockIconsPreviewController::index()). + # The sake CLI differs per major. config:audit (SS6 only) lists YAML keyed to properties + # that no class declares; it is informational here (it also lists other modules' keys), and + # it does NOT catch elemental's old 'description' key (measured: the key was put back on + # ElementVirtual and the audit output did not change); ModuleConfigTest and + # ElementVirtualExtensionTest cover it. + working-directory: app-host + run: | + if [ "${{ matrix.silverstripe }}" = "5" ]; then + vendor/bin/sake dev/build flush=1 + else + vendor/bin/sake db:build --flush + vendor/bin/sake config:audit + fi + + lint: + name: Lint PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The floor and the ceiling of the PHP versions the supported Silverstripe majors run on. + php: ['8.1', '8.4'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - name: Syntax check every PHP file + run: find src tests -name '*.php' -print0 | xargs -0 -n1 php -l From 3f0fc57bd2109b8df9b9b5b847fbfe32261613ff Mon Sep 17 00:00:00 2001 From: micschk Date: Fri, 25 Sep 2026 00:49:52 +0200 Subject: [PATCH 07/15] Document 2.0.0: README, CHANGELOG, UPGRADING, licence line README: requirements, compatibility table, what installing configures, every config option and public method, the optional integrations, running the tests, sponsorship line. --- CHANGELOG.md | 61 ++++++++++++ LICENSE | 2 +- README.md | 265 ++++++++++++++++++++++++++++++++++++++++----------- UPGRADING.md | 28 ++++++ 4 files changed, 302 insertions(+), 54 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 UPGRADING.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2bc7af2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +## 2.0.0 (2026-09-25) + +**Silverstripe 5 and 6.** One line, `main`, supports both. Silverstripe 4 stays on the `1.0.x` tags; +nothing here is backported. Upgrade guide: [UPGRADING.md](UPGRADING.md). + +Requires PHP `^8.1`, `silverstripe/framework ^5 || ^6`, `dnadesign/silverstripe-elemental ^5.4 || ^6` +and `restruct/silverstripe-admintweaks ^3 || ^4`. + +### Changed + +- **Block descriptions use `class_description`.** Elemental 5.4 no longer reads the `description` + config and elemental 6 removed it, together with `BaseElement::getDescription()`. The module's own + config (`BlockContent`, `BaseElement`, `ElementVirtual`) now sets `class_description`; project blocks + must do the same (see UPGRADING). `BlockBase::getType()` returns the class description, or the + singular name when a class has none. +- New public method `getBlockDescription()` on every block (from `ElementBlockBaseExtension`): the class + description, falling back to the type name. It replaces the `getDescription()` calls in the default + block title and the editor summary. +- The extensions now extend `SilverStripe\Core\Extension` instead of `DataExtension` (removed in + Silverstripe 6). +- The block-type icon preview (`admin/blocktypeicons`) renders from + `templates/Restruct/Silverstripe/BlockBase/Dev/BlockIconsPreview.ss` instead of an inline template + string, and lists every `BaseElement` subclass. +- `composer.json`: PHP floor, `suggest` entries for `dnadesign/silverstripe-elemental-virtual` and + `silverstripe/subsites`, a `funding` entry, a `2.x-dev` branch alias. +- Licence copyright line reads "Restruct". + +### Fixed + +- **Every flush fataled on Silverstripe 5 and 6**: `BlockIconsPreviewController::index()` was not + compatible with `LeftAndMain::index(HTTPRequest $request): HTTPResponse`, so `dev/build` and + `?flush=1` died before building anything. +- **The icon preview listed nothing**: it asked for subclasses of `Block::class`, which resolved to the + non-existent `Restruct\Silverstripe\BlockBase\Dev\Block`. +- **`BlockContent::FieldEnabled()` answered crossed**: `'Content'` returned `has_image` and `'Image'` + returned `has_content`. +- **`ElementContentMigrationExtension` had no effect**: `updateIsMigratable()` and + `updatePageShouldSkip()` took their first argument by value, so excluding page types and skipping + pages that already had blocks never reached `MigrateContentToElement`. +- **Opening a block in the Content Blocks admin threw a TypeError on Silverstripe 6**: + `addFieldsToTab()` was given a `FieldList` instead of an array. +- `getExtraData()` passed `NULL` to `json_decode()` for a block without ExtraData (deprecated since + PHP 8.1). +- On Silverstripe 6: the page edit link in the block admin uses `getCMSEditLink()`, and the + `ArrayList` class is picked per major. + +### Added + +- Test suite (50 tests, identical on Silverstripe 5 and 6) with a regression test for each fix above, + and a GitHub Actions matrix: Silverstripe 5 on PHP 8.1 and 8.3, Silverstripe 6 on PHP 8.3 and 8.4, + with elemental-virtual and subsites installed. +- README: what installing the module configures, every config option, the public API, the optional + integrations, running the tests, and a version compatibility table. + +### Notes + +- No open GitHub issues or pull requests existed for this repository at release time. +- Consumers counted locally only: no local project requires this package; `restruct/silverstripe-newsgrid` + suggests it (its `BlockNewsItems` block extends `BlockContent`). No hosting-wide inventory was made. diff --git a/LICENSE b/LICENSE index 78b9003..0168cbd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Restruct web & apps +Copyright (c) 2022 Restruct Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index f5b5d8a..7c46776 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,110 @@ # Restruct Silverstripe Block Base module -This module serves as a base on top of Elemental, for blocks based projects by Restruct. +*Maintained by [Restruct](https://github.com/restruct). If this module saves you time, you can +[support ongoing maintenance](https://github.com/sponsors/restruct).* -## Unset nested blocks .container padding (for first level nested) -In case your project already wraps blocks inside a .container, the block's .container will have double padding. -Below CSS unsets this (based on a project which wraps all content in a subnav and a content .col, adapt to your specific project): +This module serves as a base on top of [Elemental](https://github.com/silverstripe/silverstripe-elemental), +for blocks based projects by Restruct: a content block with configurable fields, editor tweaks for every +block type, a CMS admin listing all blocks, and optional support for virtual (clone) blocks and subsites. -```css -.blocks-container { - // unset padding/margin of (second level ) .container if contained within .subnav-slot-* (before/after) - &.subnav-slot-before, - &.subnav-slot-after { - & > .block-item-wrapper > .container > .row > [class*="col"] > .block-item-wrapper > .container { - padding: 0; - // .row & .col* should remain unchanged as there may be multiple columns with a block - } - } -} +## Requirements + +* Silverstripe 5 or 6, with `dnadesign/silverstripe-elemental` 5.4+ or 6 +* PHP 8.1 or newer (Silverstripe 6 itself needs 8.3) +* `restruct/silverstripe-admintweaks` 3 or 4 (installed automatically) + +Optional, and supported when installed: + +* `dnadesign/silverstripe-elemental-virtual`: virtual (clone) blocks +* `silverstripe/subsites`: allow or disallow block types per subsite + +## Installation + +``` +composer require restruct/silverstripe-blockbase ``` -## Functionality: -- Various Block (Elemental) tweaks (+ block icon/thumbnail preview route at `admin/blocktypeicons`) -- ... +Then run `dev/build` (Silverstripe 5) or `sake db:build` (Silverstripe 6) with a flush. +## Version compatibility -## Show Block design/thumbnails instead of icons in admin UI (Elemental) +| Branch | Module version | Silverstripe | Elemental | PHP | +|--------|----------------|--------------|-----------|-----| +| `main` | `2.x` | `^5 \|\| ^6` | `^5.4 \|\| ^6` | `^8.1` | +| (tags only) | `1.0.x` | `^4` | `^4.5` | per Silverstripe 4 | -1. copy & adapt below section to specific project code css to show designed block previews instead of icons -2. set private static $icon to 'block-design block-section {block-name-offset}' -3. add stacked blocks img to app/client (.block-name-offset sets offset if multiple stacked in one image) +Silverstripe 4 reached end of life in April 2025 and is no longer supported or tested here. Projects +still on it should stay on the `1.0.x` tags, which remain available. -```scss -i.block-section, button.block-section:before { - background-image: url(~app/client/imgs/block-group-designs_stacked.png); - background-position: 0 0; -} -i.block-section, button.block-section { - &.block-name-offset {&, &:before { - background-position: 0 -128px; - }} - &.block-othername-offset {&, &:before { - background-position: 0 -28px; - }} -} +`main` is the only maintained line: it supports every Silverstripe version this module still targets, +so there is no separate maintenance branch. A version branch will be created only when a change cannot +be made compatible across the supported range. + +**`composer.json` is the source of truth** for exact constraints; this table is a quick reference. + +## What installing it does + +The module's `_config/config.yml` applies the following to every project that installs it: + +* `Page` gets Elemental's `ElementalPageExtension` and this module's `ContentBlocksToggleExtension` + (below). `UserDefinedForm`, `RedirectorPage`, `VirtualPage`, `SubsitesVirtualPage`, the newsgrid + holder and the sitemap page are excluded via `ElementalPageExtension.ignored_classes`. +* The page's own `Content` field is kept alongside the blocks (`ElementalAreasExtension.keep_content_fields: true`). +* Elemental's default front-end block styles are not included (`ElementController.include_default_styles: false`). +* Every block (`BaseElement`) is inline editable, does not show its title in the template, renders in + this module's holder template (`controller_template: BlockHolder`) and gets `ElementBlockBaseExtension`. +* A CMS stylesheet (`client/dist/css/admin-block-tweaks.css`) is added to the admin. +* In `dev` mode only, the route `admin/blocktypeicons` (below). + +## Functionality + +### `BlockContent`: text/content block + +`Restruct\Silverstripe\BlockBase\Blocks\BlockContent` has a `Heading`, `IntroLine`, `Content` (HTML), +`Image` and `BackgroundImage`. Each can be switched off per project or per subclass: + +| Config | Default | Effect | +|--------|---------|--------| +| `has_heading` | `true` | `false` removes the `Heading` field from the CMS | +| `has_introline` | `true` | `false` removes `IntroLine` | +| `has_content` | `true` | `false` removes `Content` | +| `has_image` | `true` | `false` removes `Image` | +| `has_bg_image` | `true` | `false` removes `BackgroundImage` | +| `image_upload_dir` | `null` | folder name for both image upload fields; unset uses the upload default | + +`FieldEnabled('Heading')` (and `IntroLine`, `Content`, `Image`, `BackgroundImage`) returns the matching +setting, for use in templates. + +```yml +Restruct\Silverstripe\BlockBase\Blocks\BlockContent: + has_bg_image: false + image_upload_dir: 'block-images' ``` -## Notes +**ExtraData.** Any field named `ExtraData_` added to a `BlockContent` (sub)class's CMS fields is saved +as `` in the block's `ExtraDataJSON` column, so project blocks can store extra settings without a +schema change. `getExtraData()` returns them as an array (or `null` when nothing was stored), and +`loadExtraData()` (called from `getCMSFields()`) puts them back on the record so the form shows them. This +works for plain fields (text, number, dropdown); more complex fields probably need additional work. -### Template relations -The DNADesign/Elemental/Models/ElementalArea.ss template loops over each of the element controller instances. -Each controller instance will render $ElementHolder which represents the element contained within a holder div. -The wrapper div is the ElementHolder.ss template. +`BlockBase` is the (hidden) common parent of the module's blocks; project blocks can subclass it. Its +`getRenderTemplates()` adds plain theme template names after Elemental's namespaced ones, so a theme can +use `Blocks/BlockContent.ss`, `BlockContent_