From b6d91e9cf97ba043e0db11f20efcb21fe6243091 Mon Sep 17 00:00:00 2001 From: Claus Due Date: Mon, 16 Sep 2024 14:12:52 +0200 Subject: [PATCH] [FEATURE] Add custom page layout selector Note: Not available on TYPO3v10. Adds a replacement page layout selector which can be enabled instead of the default TCA "select" type. The page layout selector is enabled in extension configuration and can be configured through TCA overrides (in Configuration/TCA/Overrides/pages.php of an extension that depends on Flux): ``` // For both tx_fed_page_controller_action and tx_fed_page_controller_action_sub // and can be configured individually, e.g. to make sub-layout icons smaller // or only render titles/descriptions for "this page" layouts. $GLOBALS['TCA']['pages']['columns']['tx_fed_page_controller_action']['config']['iconHeight'] = 200; $GLOBALS['TCA']['pages']['columns']['tx_fed_page_controller_action']['config']['titles'] = true; $GLOBALS['TCA']['pages']['columns']['tx_fed_page_controller_action']['config']['descriptions'] = true; ``` --- Classes/Enum/ExtensionOption.php | 1 + .../FormEngine/PageLayoutSelector.php | 155 ++++++++++++++++++ .../Utility/ExtensionConfigurationUtility.php | 1 + Configuration/TCA/Overrides/pages.php | 51 +++--- Resources/Private/Language/locallang.xlf | 3 + Resources/Public/Icons/Layout.svg | 5 + Resources/Public/css/flux.css | 36 ++++ .../FormEngine/PageLayoutSelectorTest.php | 119 ++++++++++++++ ext_conf_template.txt | 3 + ext_localconf.php | 5 + phpstan-baseline.neon | 4 + 11 files changed, 358 insertions(+), 25 deletions(-) create mode 100644 Classes/Integration/FormEngine/PageLayoutSelector.php create mode 100644 Resources/Public/Icons/Layout.svg create mode 100644 Tests/Unit/Integration/FormEngine/PageLayoutSelectorTest.php diff --git a/Classes/Enum/ExtensionOption.php b/Classes/Enum/ExtensionOption.php index f1164fef8..b2470dfa5 100644 --- a/Classes/Enum/ExtensionOption.php +++ b/Classes/Enum/ExtensionOption.php @@ -14,4 +14,5 @@ class ExtensionOption public const OPTION_FLEXFORM_TO_IRRE = 'flexFormToIrre'; public const OPTION_INHERITANCE_MODE = 'inheritanceMode'; public const OPTION_UNIQUE_FILE_FIELD_NAMES = 'uniqueFileFieldNames'; + public const OPTION_CUSTOM_PAGE_LAYOUT_SELECTOR = 'customLayoutSelector'; } diff --git a/Classes/Integration/FormEngine/PageLayoutSelector.php b/Classes/Integration/FormEngine/PageLayoutSelector.php new file mode 100644 index 000000000..78b6d26dc --- /dev/null +++ b/Classes/Integration/FormEngine/PageLayoutSelector.php @@ -0,0 +1,155 @@ +nodeFactory = $nodeFactory ?? GeneralUtility::makeInstance(NodeFactory::class); + $this->data = $data; + $this->pageService = GeneralUtility::makeInstance(PageService::class); + $this->pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); + $this->packageManager = GeneralUtility::makeInstance(PackageManager::class); + } + + public function render(): array + { + $this->attachAssets(); + + $result = $this->initializeResultArray(); + + $selectedValue = $this->data['databaseRow'][$this->data['fieldName']] ?? null; + + $fieldName = 'data' . $this->data['elementBaseName']; + $height = $this->data['parameterArray']['fieldConf']['config']['iconHeight'] ?? self::DEFAULT_ICON_WIDTH; + $renderTitle = $this->data['parameterArray']['fieldConf']['config']['titles'] ?? false; + $renderDescription = $this->data['parameterArray']['fieldConf']['config']['descriptions'] ?? false; + + $templates = $this->pageService->getAvailablePageTemplateFiles(); + + $html = []; + + $html[] = '
'; + $html[] = ''; + + foreach ($templates as $groupName => $group) { + $extensionName = ExtensionNamingUtility::getExtensionName($groupName); + $packageInfo = $this->packageManager->getPackage(ExtensionNamingUtility::getExtensionKey($groupName)); + + $html[] = '

' . ($packageInfo->getPackageMetaData()->getTitle() ?? $groupName) . '

'; + $html[] = '
'; + foreach ($group as $form) { + $icon = $this->resolveIconForForm($form); + + /** @var string $templateName */ + $templateName = $form->getOption(FormOption::TEMPLATE_FILE_RELATIVE); + $identifier = $groupName . '->' . lcfirst($templateName); + + $html[] = ''; + $html[] = ''; + $html[] = ''; + if ($renderTitle && ($title = $form->getLabel())) { + $title = $this->translate((string) $title, $extensionName) ?? $templateName; + if (strpos($title, 'LLL:EXT:') !== 0) { + $html[] = '

' . $title . '

'; + } + } + + if ($renderDescription && ($description = $form->getDescription())) { + $description = $this->translate((string) $description, $extensionName) ?? $description; + if (strpos($description, 'LLL:EXT:') !== 0) { + $html[] = '

' . $description . '

'; + } + } + + $html[] = ''; + } + $html[] = '
'; + } + + $html[] = '
'; + + $result['html'] = implode(PHP_EOL, $html); + + return $result; + } + + /** + * @codeCoverageIgnore + */ + protected function resolveIconForForm(Form $form): string + { + $defaultIcon = 'EXT:flux/Resources/Public/Icons/Layout.svg'; + $icon = MiscellaneousUtility::getIconForTemplate($form) ?? $defaultIcon; + + if (!file_exists(GeneralUtility::getFileAbsFileName($icon))) { + $icon = PathUtility::getPublicResourceWebPath($defaultIcon); + } elseif (strpos($icon, 'EXT:') === 0) { + $icon = PathUtility::getPublicResourceWebPath($icon); + } elseif (($icon[0] ?? null) !== '/') { + $icon = PathUtility::getAbsoluteWebPath($icon); + } + return $icon; + } + + /** + * @codeCoverageIgnore + */ + protected function translate(string $label, string $extensionName): ?string + { + return LocalizationUtility::translate($label, $extensionName); + } + + /** + * @codeCoverageIgnore + */ + protected function attachAssets(): void + { + if (!self::$assetsIncluded) { + $this->pageRenderer->addCssFile('EXT:flux/Resources/Public/css/flux.css'); + + self::$assetsIncluded = true; + } + } +} diff --git a/Classes/Utility/ExtensionConfigurationUtility.php b/Classes/Utility/ExtensionConfigurationUtility.php index 2077a8d05..2a4f745af 100644 --- a/Classes/Utility/ExtensionConfigurationUtility.php +++ b/Classes/Utility/ExtensionConfigurationUtility.php @@ -22,6 +22,7 @@ class ExtensionConfigurationUtility ExtensionOption::OPTION_FLEXFORM_TO_IRRE => false, ExtensionOption::OPTION_INHERITANCE_MODE => 'restricted', ExtensionOption::OPTION_UNIQUE_FILE_FIELD_NAMES => false, + ExtensionOption::OPTION_CUSTOM_PAGE_LAYOUT_SELECTOR => false, ]; public static function initialize(?string $extensionConfiguration): void diff --git a/Configuration/TCA/Overrides/pages.php b/Configuration/TCA/Overrides/pages.php index ddb2a1905..be7746317 100644 --- a/Configuration/TCA/Overrides/pages.php +++ b/Configuration/TCA/Overrides/pages.php @@ -4,41 +4,42 @@ return; } + if (version_compare(\TYPO3\CMS\Core\Utility\VersionNumberUtility::getCurrentTypo3Version(), '11.0', '>=') && \FluidTYPO3\Flux\Utility\ExtensionConfigurationUtility::getOption(\FluidTYPO3\Flux\Enum\ExtensionOption::OPTION_CUSTOM_PAGE_LAYOUT_SELECTOR)) { + $layoutSelectorFieldConfiguration = [ + 'type' => 'radio', + 'items' => [], + 'renderType' => 'fluxPageLayoutSelector', + 'iconHeight' => 200, + 'titles' => true, + 'descriptions' => true, + ]; + } else { + $layoutSelectorFieldConfiguration = [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'behaviour' => [ + 'allowLanguageSynchronization' => true, + ], + 'fieldWizard' => [ + 'selectIcons' => [ + 'disabled' => false, + ], + ], + ]; + } + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns('pages', [ 'tx_fed_page_controller_action' => [ 'exclude' => 1, 'label' => 'LLL:EXT:flux/Resources/Private/Language/locallang.xlf:pages.tx_fed_page_controller_action', 'onChange' => 'reload', - 'config' => [ - 'type' => 'select', - 'renderType' => 'selectSingle', - 'behaviour' => [ - 'allowLanguageSynchronization' => true, - ], - 'fieldWizard' => [ - 'selectIcons' => [ - 'disabled' => false - ] - ] - ] + 'config' => $layoutSelectorFieldConfiguration, ], 'tx_fed_page_controller_action_sub' => [ 'exclude' => 1, 'label' => 'LLL:EXT:flux/Resources/Private/Language/locallang.xlf:pages.tx_fed_page_controller_action_sub', 'onChange' => 'reload', - - 'config' => [ - 'type' => 'select', - 'renderType' => 'selectSingle', - 'behaviour' => [ - 'allowLanguageSynchronization' => true, - ], - 'fieldWizard' => [ - 'selectIcons' => [ - 'disabled' => false - ] - ] - ] + 'config' => $layoutSelectorFieldConfiguration, ], 'tx_fed_page_flexform' => [ 'exclude' => 1, diff --git a/Resources/Private/Language/locallang.xlf b/Resources/Private/Language/locallang.xlf index d5c9611ae..c1652d625 100644 --- a/Resources/Private/Language/locallang.xlf +++ b/Resources/Private/Language/locallang.xlf @@ -42,6 +42,9 @@ Unique file field names: When this is enabled, FAL reference fields within a Flux context will be prefixed with the parent field name. This is done in order to ensure that references written to sys_file_reference will contain a unique value in "fieldname". Without this option enabled, any record which renders the same Flux form in two fields (e.g. pages when "this page" and "subpages" templates are identical) will show the same references in both fields because the "fieldname" saved in sys_file_reference is the same for both contexts. So, in order to avoid this duplication symptom, you can prefix the "fieldname" value stored in DB. Note: although this is handled transparently when you use transform="file" (and other file transformations) the prefix must manually be added to the field name when using e.g. v:resources.record.fal. CHANGING THIS OPTION WILL ORPHAN ALL EXISTING RELATIONS - TOGGLE IT ON FOR NEW SITES BUT LEAVE OLD SITES USING THE OPTION VALUE THEY WERE BORN WITH, OR YOU WILL NEED TO MIGRATE YOUR FILE REFERENCES! + + Custom Layout Selector: (Requires TYPO3v11 or higher). When enabled, uses a fully custom page layout selector instead of the default "select" type for the "Page Layouts" fields in page properties. The custom page layout selector can be further configured through TCA overrides for TCA.pages.columns.tx_fed_page_controller_action.config and TCA.pages.columns.tx_fed_page_controller_action_sub.config. Supported settings are: "iconHeight" (integer, sets the height of icons, defaults to 200), titles (boolean, whether to render template titles in the selector, defaults to true) and descriptions (boolean, whether to render template descriptions in the selector, defaults to true). + Flux-based Content Type diff --git a/Resources/Public/Icons/Layout.svg b/Resources/Public/Icons/Layout.svg new file mode 100644 index 000000000..db911b093 --- /dev/null +++ b/Resources/Public/Icons/Layout.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Resources/Public/css/flux.css b/Resources/Public/css/flux.css index e02a449da..eb1d5350a 100644 --- a/Resources/Public/css/flux.css +++ b/Resources/Public/css/flux.css @@ -40,3 +40,39 @@ .flux-grid-hidden { display: none !important; } + +.flux-page-layouts { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-content: flex-start; +} + +.flux-page-layouts input { + display: none; +} + +.flux-page-layouts img { + display: block; +} + +.flux-page-layouts label { + width: min-content; + display: inline-table; + flex-shrink: 1; + margin-right: 0.5em; + margin-bottom: 0.25em; + border: 3px solid lightgrey; + padding: 0.5em; +} + +.flux-page-layouts label:hover, +.flux-page-layouts label.selected { + border-color: black; +} + +.flux-page-layouts label h4, +.flux-page-layouts label p { + margin: 0; + margin-top: 0.5em; +} diff --git a/Tests/Unit/Integration/FormEngine/PageLayoutSelectorTest.php b/Tests/Unit/Integration/FormEngine/PageLayoutSelectorTest.php new file mode 100644 index 000000000..1a5586d3c --- /dev/null +++ b/Tests/Unit/Integration/FormEngine/PageLayoutSelectorTest.php @@ -0,0 +1,119 @@ +singletons = GeneralUtility::getSingletonInstances(); + + $pageService = $this->getMockBuilder(PageService::class)->disableOriginalConstructor()->getMock(); + $packageManager = $this->getMockBuilder(PackageManager::class)->disableOriginalConstructor()->getMock(); + $pageRenderer = $this->getMockBuilder(PageRenderer::class)->disableOriginalConstructor()->getMock(); + + $packageMetaData = $this->getMockBuilder(MetaData::class)->disableOriginalConstructor()->getMock(); + $packageMetaData->method('getTitle')->willReturn('package-title'); + $package = $this->getMockBuilder(Package::class)->disableOriginalConstructor()->getMock(); + $package->method('getPackageMetaData')->willReturn($packageMetaData); + $packageManager->method('getPackage')->willReturn($package); + + $form = Form::create(); + $form->setOption(FormOption::TEMPLATE_FILE_RELATIVE, 'Foobar'); + $form->setDescription('description'); + $form->setLabel('title'); + $pageService->method('getAvailablePageTemplateFiles')->willReturn(['Foo.Bar' => [$form]]); + + GeneralUtility::setSingletonInstance(PageService::class, $pageService); + GeneralUtility::setSingletonInstance(PackageManager::class, $packageManager); + GeneralUtility::setSingletonInstance(PageRenderer::class, $pageRenderer); + } + + protected function tearDown(): void + { + parent::tearDown(); + + GeneralUtility::resetSingletonInstances($this->singletons); + } + + public function testRenderWithSelectedValue(): void + { + $html = $this->executeTest('Foo.Bar->foobar'); + self::assertStringContainsString( + '', + $html + ); + self::assertStringContainsString('Parent decides', $html); + self::assertStringContainsString('

Foobar

', $html); + self::assertStringContainsString('

description

', $html); + } + + public function testRenderWithoutSelectedValue(): void + { + $html = $this->executeTest(''); + self::assertStringContainsString( + '', + $html + ); + self::assertStringNotContainsString( + '', + $html + ); + self::assertStringContainsString('Parent decides', $html); + self::assertStringContainsString('

Foobar

', $html); + self::assertStringContainsString('

description

', $html); + } + + private function executeTest(string $value): string + { + $nodeFactory = $this->getMockBuilder(NodeFactory::class)->disableOriginalConstructor()->getMock(); + $data = [ + 'parameterArray' => ['foo' => 'bar'], + 'fieldName' => 'field', + 'elementBaseName' => 'elementBaseName', + 'parameterArray' => [ + 'fieldConf' => [ + 'config' => [ + 'iconHeight' => 300, + 'titles' => true, + 'descriptions' => true, + ], + ], + ], + 'databaseRow' => [ + 'field' => $value, + ], + ]; + $subject = $this->getMockBuilder(PageLayoutSelector::class) + ->onlyMethods(['initializeResultArray', 'resolveIconForForm', 'translate', 'attachAssets']) + ->setConstructorArgs([$nodeFactory, $data]) + ->getMock(); + $subject->method('initializeResultArray')->willReturn([]); + $subject->method('resolveIconForForm')->willReturn('icon'); + $subject->method('translate')->willReturnArgument('translated'); + + return $subject->render()['html']; + } +} diff --git a/ext_conf_template.txt b/ext_conf_template.txt index 31116620a..a8973411a 100644 --- a/ext_conf_template.txt +++ b/ext_conf_template.txt @@ -27,3 +27,6 @@ inheritanceMode = restricted # cat=basic/enable; type=boolean; label=LLL:EXT:flux/Resources/Private/Language/locallang.xlf:extension_configuration.uniqueFileFieldNames uniqueFileFieldNames = 0 + +# cat=basic/enable; type=boolean; label=LLL:EXT:flux/Resources/Private/Language/locallang.xlf:extension_configuration.customLayoutSelector +customLayoutSelector = 0 diff --git a/ext_localconf.php b/ext_localconf.php index e46fdcb76..9b3e7124a 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -66,6 +66,11 @@ ]; // FormEngine integration for custom TCA field types used by Flux + $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1726225012] = [ + 'nodeName' => 'fluxPageLayoutSelector', + 'priority' => 40, + 'class' => \FluidTYPO3\Flux\Integration\FormEngine\PageLayoutSelector::class, + ]; $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1575276512] = [ 'nodeName' => 'fluxContentTypeValidator', 'priority' => 40, diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index bb60f7805..78c1e35f3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,9 @@ parameters: ignoreErrors: + - + message: "#^Property .+ does not accept#" + count: 4 + path: Classes/Integration/FormEngine/PageLayoutSelector.php - message: "#^Variable \\$EM_CONF might not be defined\\.$#" count: 1