From 60e2052a93e6df48e42e6fc2c31e2f05435ac708 Mon Sep 17 00:00:00 2001 From: Stefan Hagspiel Date: Wed, 3 Jan 2024 14:13:57 +0100 Subject: [PATCH] Respect editable configuration for standalone editables in headless document (#213) --- UPGRADE.md | 3 + config/services/editable.yaml | 1 + public/css/admin.css | 11 +- src/Builder/AbstractConfigBuilder.php | 238 +--------------- src/Document/Editable/ConfigParser.php | 261 ++++++++++++++++++ .../Editable/DTO/HeadlessEditableInfo.php | 6 + .../Editable/HeadlessEditableRenderer.php | 12 + src/Factory/HeadlessEditableInfoFactory.php | 6 +- .../HeadlessDocumentResolver.php | 31 ++- 9 files changed, 335 insertions(+), 234 deletions(-) create mode 100644 src/Document/Editable/ConfigParser.php diff --git a/UPGRADE.md b/UPGRADE.md index 51fe0cf8..e917e8e6 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,8 @@ # Upgrade Notes +## 5.0.3 +- Respect editable configuration for standalone editables in headless document + ## 5.0.2 - Fix element config load priority to allow config overwrites diff --git a/config/services/editable.yaml b/config/services/editable.yaml index 19f5884b..e88d95c8 100644 --- a/config/services/editable.yaml +++ b/config/services/editable.yaml @@ -17,6 +17,7 @@ services: ToolboxBundle\Document\Editable\EditableWorker: public: true + ToolboxBundle\Document\Editable\ConfigParser: ~ ToolboxBundle\Document\Editable\HeadlessEditableRenderer: ~ ToolboxBundle\Factory\HeadlessEditableInfoFactory: ~ diff --git a/public/css/admin.css b/public/css/admin.css index 9d219688..7e3a57db 100644 --- a/public/css/admin.css +++ b/public/css/admin.css @@ -595,6 +595,10 @@ body > .single-teaser img { padding: 3px 0 0 0; } +.toolbox-element-edit-button.not-configurable:before { + top: -18px; +} + .toolbox-element-edit-button.no-interaction { height: 26px; } @@ -711,6 +715,11 @@ body > .single-teaser img { [TOOLBOX HEADLESS] Inline Editables - Config Node */ +.toolbox-headless .toolbox-container { + border: none; + padding: 0; +} + .toolbox-headless .inline-config-area { border: 1px dashed #d6d6d6; padding: 10px; @@ -770,5 +779,5 @@ body > .single-teaser img { .toolbox-headless .inline-config-area .pimcore_block_entry { margin: 0 0 20px 0; border: 1px dotted #6428b44a; - padding: 5px 10px; + padding: 10px; } \ No newline at end of file diff --git a/src/Builder/AbstractConfigBuilder.php b/src/Builder/AbstractConfigBuilder.php index 688a890d..f581e8f8 100644 --- a/src/Builder/AbstractConfigBuilder.php +++ b/src/Builder/AbstractConfigBuilder.php @@ -3,11 +3,10 @@ namespace ToolboxBundle\Builder; use Pimcore\Model\Document\Editable\Area\Info; -use Pimcore\Model\Document\Editable\Checkbox; use Pimcore\Templating\Renderer\EditableRenderer; use Pimcore\Translation\Translator; +use ToolboxBundle\Document\Editable\ConfigParser; use ToolboxBundle\Manager\AreaManagerInterface; -use ToolboxBundle\Registry\StoreProviderRegistryInterface; use Twig\Environment; abstract class AbstractConfigBuilder @@ -15,8 +14,8 @@ abstract class AbstractConfigBuilder public function __construct( protected Translator $translator, protected Environment $templating, - protected StoreProviderRegistryInterface $storeProvider, protected AreaManagerInterface $areaManager, + protected ConfigParser $configParser, protected EditableRenderer $editableRenderer ) { } @@ -47,8 +46,7 @@ protected function parseConfigElements( $editableNode = $this->parseConfigElement($info, $configElementName, $elementData, $acStoreProcessed, $brickId, $themeOptions); - //if element need's a store and store is empty: skip field - if ($this->needStore($elementData['type']) && $this->hasValidStore($editableNode['config']) === false) { + if ($editableNode === null) { continue; } @@ -99,34 +97,14 @@ protected function parseConfigElements( return $editableNodes; } - private function parseConfigElement(?Info $info, string $elementName, array $elementData, bool $acStoreProcessed, string $brickId, array $themeOptions): array + private function parseConfigElement(?Info $info, string $elementName, array $elementData, bool $acStoreProcessed, string $brickId, array $themeOptions): ?array { - $editableConfig = $elementData['config']; $editableType = $elementData['type']; - //set element config data - $parsedNode = $this->parseElementNode($elementName, $elementData, $acStoreProcessed); + $parsedNode = $this->configParser->parseConfigElement($info, $elementName, $elementData, $acStoreProcessed); - //set width - if ($this->canHaveDynamicWidth($editableType)) { - $parsedNode['width'] = $parsedNode['width'] ?? '100%'; - } else { - unset($parsedNode['width']); - } - - //set height - if ($this->canHaveDynamicHeight($editableType)) { - $parsedNode['height'] = $parsedNode['height'] ?? 200; - } else { - unset($parsedNode['height']); - } - - // set default - $parsedNode['config']['defaultValue'] = $this->getSelectedValue($info, $parsedNode, $editableConfig['default'] ?? null); - - // check store - if ($this->needStore($editableType) && $this->hasValidStore($editableConfig)) { - $parsedNode['config']['store'] = $this->buildStore($editableType, $editableConfig); + if ($parsedNode === null) { + return null; } if ($editableType === 'block' && array_key_exists('children', $elementData) && is_array($elementData['children']) && count($elementData['children']) > 0) { @@ -136,105 +114,6 @@ private function parseConfigElement(?Info $info, string $elementName, array $ele return $parsedNode; } - private function parseElementNode(string $configElementName, array $elementData, bool $acStoreProcessed): array - { - $elementNode = [ - 'type' => $elementData['type'], - 'name' => $configElementName, - 'tab' => $elementData['tab'], - 'label' => !empty($elementData['title']) ? $elementData['title'] : null, - 'description' => !empty($elementData['description']) ? $elementData['description'] : null, - 'config' => $elementData['config'] ?? [], - 'additional_classes_element' => false, - ]; - - if ($elementData['type'] === 'additionalClasses') { - if ($acStoreProcessed === true) { - throw new \Exception( - sprintf( - 'A element of type "additionalClasses" in element "%s" already has been defined. You can only add one field of type "%s" per area. Use "%s" instead.', - $configElementName, - 'additionalClasses', - 'additionalClassesChained' - ) - ); - } - - $elementNode['type'] = 'select'; - $elementNode['label'] = !empty($elementData['title']) ? $elementData['title'] : 'Additional'; - $elementNode['additional_classes_element'] = true; - $elementNode['name'] = 'add_classes'; - } elseif ($elementData['type'] === 'additionalClassesChained') { - if ($acStoreProcessed === false) { - throw new \Exception( - sprintf( - 'You need to add a element of type "%s" before adding a "%s" element.', - 'additionalClasses', - 'additionalClassesChained' - ) - ); - } - - if (!str_starts_with($configElementName, 'additional_classes_chain_')) { - throw new \Exception( - sprintf( - 'Chained AC element name needs to start with "%s" followed by a numeric. "%s" given.', - 'additional_classes_chain_', - $configElementName - ) - ); - } - - $chainedElementName = explode('_', $configElementName); - $chainedIncrementor = end($chainedElementName); - if (!is_numeric($chainedIncrementor)) { - throw new \Exception('Chained AC element name must end with an numeric. "' . $chainedIncrementor . '" given.'); - } - - $elementNode['type'] = 'select'; - $elementNode['label'] = !empty($elementData['title']) ? $elementData['title'] : 'Additional'; - $elementNode['additional_classes_element'] = true; - $elementNode['name'] = 'add_cclasses_' . $chainedIncrementor; - } - - // translate title - if (!empty($elementNode['label'])) { - $elementNode['label'] = $this->translator->trans($elementNode['label'], [], 'admin'); - } - - // translate description - if (!empty($elementNode['description'])) { - $elementNode['description'] = $this->translator->trans($elementNode['description'], [], 'admin'); - } - - return $elementNode; - } - - private function getSelectedValue(?Info $info, array $config, mixed $defaultConfigValue): mixed - { - if (!$info instanceof Info) { - return $this->castPimcoreEditableValue($config['type'], $defaultConfigValue); - } - - $el = $info->getDocumentElement($config['name'], $config['type']); - - if ($el === null) { - return $this->castPimcoreEditableValue($config['type'], $defaultConfigValue); - } - - // force default (only if it returns false) - // checkboxes may return an empty string and are impossible to track into default mode - if (!empty($defaultConfigValue) && (method_exists($el, 'isEmpty') && $el->isEmpty() === true)) { - $el->setDataFromResource($defaultConfigValue); - } - - $value = $el instanceof Checkbox ? $el->isChecked() : $el->getData(); - - $fallbackAwareValue = !empty($value) ? $value : $defaultConfigValue; - - return $this->castPimcoreEditableValue($config['type'], $fallbackAwareValue); - } - private function checkColumnAdjusterField(string $brickId, ?string $tab, array $themeOptions, string $configElementName, array $editableNodes): array { if ($brickId !== 'columns') { @@ -261,107 +140,4 @@ private function checkColumnAdjusterField(string $brickId, ?string $tab, array $ return $editableNodes; } - private function buildStore($type, $config): array - { - $storeValues = []; - if (isset($config['store'])) { - $storeValues = $config['store']; - } elseif (isset($config['store_provider'])) { - $storeProvider = $this->storeProvider->get($config['store_provider']); - $storeValues = $storeProvider->getValues(); - } - - if (count($storeValues) === 0) { - throw new \Exception($type . ' has no valid configured store'); - } - - $store = []; - foreach ($storeValues as $k => $v) { - if (is_array($v)) { - $v = $v['name']; - } - $store[] = [$k, $this->translator->trans($v, [], 'admin')]; - } - - return $store; - } - - private function hasValidStore($parsedConfig): bool - { - if (isset($parsedConfig['store']) && is_array($parsedConfig['store']) && count($parsedConfig['store']) > 0) { - return true; - } - - if (isset($parsedConfig['store_provider']) && $this->storeProvider->has($parsedConfig['store_provider'])) { - return true; - } - - return false; - } - - private function needStore($type): bool - { - return in_array($type, ['select', 'multiselect', 'additionalClasses', 'additionalClassesChained']); - } - - private function canHaveDynamicWidth($type): bool - { - return in_array( - $type, - [ - 'multihref', - 'relations', - 'href', - 'relation', - 'image', - 'input', - 'multiselect', - 'numeric', - 'embed', - 'pdf', - 'renderlet', - 'select', - 'snippet', - 'table', - 'textarea', - 'video', - 'wysiwyg', - 'parallaximage', - 'additionalClasses', - 'additionalClassesChained' - ] - ); - } - - private function canHaveDynamicHeight($type): bool - { - return in_array($type, [ - 'multihref', - 'relations', - 'image', - 'multiselect', - 'embed', - 'pdf', - 'renderlet', - 'snippet', - 'textarea', - 'video', - 'wysiwyg', - 'parallaximage' - ]); - } - - private function castPimcoreEditableValue(string $type, mixed $value): mixed - { - // pimcore numeric editable requires string type - if ($type === 'numeric') { - return $value === null ? null : (string) $value; - } - - if ($type === 'areablock') { - return $value === null ? null : (is_string($value) ? $value : serialize($value)); - } - - return $value; - } } diff --git a/src/Document/Editable/ConfigParser.php b/src/Document/Editable/ConfigParser.php new file mode 100644 index 00000000..4c4d95a5 --- /dev/null +++ b/src/Document/Editable/ConfigParser.php @@ -0,0 +1,261 @@ +needStore($editableType) && $this->hasValidStore($editableConfig) === false) { + return null; + } + + // set element config data + $parsedNode = $this->parseElementNode($elementName, $elementData, $acStoreProcessed); + + // set width + if ($this->canHaveDynamicWidth($editableType)) { + $parsedNode['width'] = $parsedNode['width'] ?? '100%'; + } else { + unset($parsedNode['width']); + } + + // set height + if ($this->canHaveDynamicHeight($editableType)) { + $parsedNode['height'] = $parsedNode['height'] ?? 200; + } else { + unset($parsedNode['height']); + } + + // set default + $parsedNode['config']['defaultValue'] = $this->getSelectedValue($info, $parsedNode, $editableConfig['default'] ?? null); + + // check store + if ($this->needStore($editableType) && $this->hasValidStore($editableConfig)) { + $parsedNode['config']['store'] = $this->buildStore($editableType, $editableConfig); + } + + return $parsedNode; + } + + private function parseElementNode(string $configElementName, array $elementData, bool $acStoreProcessed): array + { + $elementNode = [ + 'type' => $elementData['type'], + 'name' => $configElementName, + 'tab' => $elementData['tab'] ?? null, + 'label' => !empty($elementData['title']) ? $elementData['title'] : null, + 'description' => !empty($elementData['description']) ? $elementData['description'] : null, + 'config' => $elementData['config'] ?? [], + 'additional_classes_element' => false, + ]; + + if ($elementData['type'] === 'additionalClasses') { + if ($acStoreProcessed === true) { + throw new \Exception( + sprintf( + 'A element of type "additionalClasses" in element "%s" already has been defined. You can only add one field of type "%s" per area. Use "%s" instead.', + $configElementName, + 'additionalClasses', + 'additionalClassesChained' + ) + ); + } + + $elementNode['type'] = 'select'; + $elementNode['label'] = !empty($elementData['title']) ? $elementData['title'] : 'Additional'; + $elementNode['additional_classes_element'] = true; + $elementNode['name'] = 'add_classes'; + } elseif ($elementData['type'] === 'additionalClassesChained') { + if ($acStoreProcessed === false) { + throw new \Exception( + sprintf( + 'You need to add a element of type "%s" before adding a "%s" element.', + 'additionalClasses', + 'additionalClassesChained' + ) + ); + } + + if (!str_starts_with($configElementName, 'additional_classes_chain_')) { + throw new \Exception( + sprintf( + 'Chained AC element name needs to start with "%s" followed by a numeric. "%s" given.', + 'additional_classes_chain_', + $configElementName + ) + ); + } + + $chainedElementName = explode('_', $configElementName); + $chainedIncrementor = end($chainedElementName); + if (!is_numeric($chainedIncrementor)) { + throw new \Exception('Chained AC element name must end with an numeric. "' . $chainedIncrementor . '" given.'); + } + + $elementNode['type'] = 'select'; + $elementNode['label'] = !empty($elementData['title']) ? $elementData['title'] : 'Additional'; + $elementNode['additional_classes_element'] = true; + $elementNode['name'] = 'add_cclasses_' . $chainedIncrementor; + } + + // translate title + if (!empty($elementNode['label'])) { + $elementNode['label'] = $this->translator->trans($elementNode['label'], [], 'admin'); + } + + // translate description + if (!empty($elementNode['description'])) { + $elementNode['description'] = $this->translator->trans($elementNode['description'], [], 'admin'); + } + + return $elementNode; + } + + private function getSelectedValue(?Info $info, array $config, mixed $defaultConfigValue): mixed + { + if (!$info instanceof Info) { + return $this->castPimcoreEditableValue($config['type'], $defaultConfigValue); + } + + $el = $info->getDocumentElement($config['name'], $config['type']); + + if ($el === null) { + return $this->castPimcoreEditableValue($config['type'], $defaultConfigValue); + } + + // force default (only if it returns false) + // checkboxes may return an empty string and are impossible to track into default mode + if (!empty($defaultConfigValue) && (method_exists($el, 'isEmpty') && $el->isEmpty() === true)) { + $el->setDataFromResource($defaultConfigValue); + } + + $value = $el instanceof Checkbox ? $el->isChecked() : $el->getData(); + + $fallbackAwareValue = !empty($value) ? $value : $defaultConfigValue; + + return $this->castPimcoreEditableValue($config['type'], $fallbackAwareValue); + } + + private function castPimcoreEditableValue(string $type, mixed $value): mixed + { + // pimcore numeric editable requires string type + if ($type === 'numeric') { + return $value === null ? null : (string) $value; + } + + if ($type === 'areablock') { + return $value === null ? null : (is_string($value) ? $value : serialize($value)); + } + + return $value; + } + + + private function buildStore($type, $config): array + { + $storeValues = []; + if (isset($config['store'])) { + $storeValues = $config['store']; + } elseif (isset($config['store_provider'])) { + $storeProvider = $this->storeProvider->get($config['store_provider']); + $storeValues = $storeProvider->getValues(); + } + + if (count($storeValues) === 0) { + throw new \Exception($type . ' has no valid configured store'); + } + + $store = []; + foreach ($storeValues as $k => $v) { + if (is_array($v)) { + $v = $v['name']; + } + $store[] = [$k, $this->translator->trans($v, [], 'admin')]; + } + + return $store; + } + + private function hasValidStore($parsedConfig): bool + { + if (isset($parsedConfig['store']) && is_array($parsedConfig['store']) && count($parsedConfig['store']) > 0) { + return true; + } + + if (isset($parsedConfig['store_provider']) && $this->storeProvider->has($parsedConfig['store_provider'])) { + return true; + } + + return false; + } + + private function needStore($type): bool + { + return in_array($type, ['select', 'multiselect', 'additionalClasses', 'additionalClassesChained']); + } + + private function canHaveDynamicWidth($type): bool + { + return in_array( + $type, + [ + 'multihref', + 'relations', + 'href', + 'relation', + 'image', + 'input', + 'multiselect', + 'numeric', + 'embed', + 'pdf', + 'renderlet', + 'select', + 'snippet', + 'table', + 'textarea', + 'video', + 'wysiwyg', + 'parallaximage', + 'additionalClasses', + 'additionalClassesChained' + ] + ); + } + + private function canHaveDynamicHeight($type): bool + { + return in_array($type, [ + 'multihref', + 'relations', + 'image', + 'multiselect', + 'embed', + 'pdf', + 'renderlet', + 'snippet', + 'textarea', + 'video', + 'wysiwyg', + 'parallaximage' + ]); + } +} diff --git a/src/Document/Editable/DTO/HeadlessEditableInfo.php b/src/Document/Editable/DTO/HeadlessEditableInfo.php index 6c809d58..a308da9b 100644 --- a/src/Document/Editable/DTO/HeadlessEditableInfo.php +++ b/src/Document/Editable/DTO/HeadlessEditableInfo.php @@ -13,6 +13,7 @@ public function __construct( protected mixed $editableId, protected string $name, protected string $type, + protected ?string $label, protected ?string $brickParent = null, protected ?array $editableConfiguration = null, protected array $config = [], @@ -38,6 +39,11 @@ public function getName(): string return $this->name; } + public function getLabel(): ?string + { + return $this->label; + } + public function getType(): string { return $this->type; diff --git a/src/Document/Editable/HeadlessEditableRenderer.php b/src/Document/Editable/HeadlessEditableRenderer.php index edff26d5..2cf3dd7c 100644 --- a/src/Document/Editable/HeadlessEditableRenderer.php +++ b/src/Document/Editable/HeadlessEditableRenderer.php @@ -147,8 +147,20 @@ private function buildBlockEditable(HeadlessEditableInfo $headlessEditableInfo): foreach ($blockEditable->getIterator() as $blockIndex) { foreach ($headlessEditableInfo->getChildren() as $childHeadlessEditableInfo) { + ob_start(); + echo $this->processEditable($childHeadlessEditableInfo, true); + $renderedBlockEditable = ob_get_clean(); + + echo $this->renderEditableWithWrapper($childHeadlessEditableInfo->getType(), [ + 'item' => [ + 'label' => $childHeadlessEditableInfo->getLabel(), + 'description' => null + ], + 'editable' => $renderedBlockEditable + ]); + if ($editMode === false) { $data[] = $this->processEditable($childHeadlessEditableInfo); } diff --git a/src/Factory/HeadlessEditableInfoFactory.php b/src/Factory/HeadlessEditableInfoFactory.php index 9186c325..4edd0344 100644 --- a/src/Factory/HeadlessEditableInfoFactory.php +++ b/src/Factory/HeadlessEditableInfoFactory.php @@ -22,6 +22,7 @@ public function createViaBrick(Document\Editable\Area\Info $info, bool $editMode editableId: $info->getId(), name: $item['name'], type: $item['type'], + label: $item['label'] ?? null, brickParent: null, editableConfiguration: null, config: $this->createConfig($info->getDocument(), $item, $editMode, $item['type'] === 'areablock' ? $info->getId() : null), @@ -38,6 +39,7 @@ public function createViaEditable(Document $document, mixed $editableId, bool $e editableId: $editableId, name: $item['name'], type: $item['type'], + label: $item['label'] ?? null, brickParent: null, editableConfiguration: $this->createEditableConfiguration($item), config: $this->createConfig($document, $item, $editMode, $item['type'] === 'areablock' ? $item['name'] : null), @@ -86,6 +88,7 @@ protected function createColumnChildren( editableId: $editableId, name: $columnName, type: 'areablock', + label: null, brickParent: $editableId, editableConfiguration: null, config: $this->createConfig($document, $item, $editMode, $editableId), @@ -126,9 +129,10 @@ protected function createBlockChildren( editableId: $editableId, name: array_key_exists('name', $childData) ? $childData['name'] : $childName, type: $childData['type'], + label: $childData['label'] ?? null, brickParent: $brickParent, editableConfiguration: $editableConfiguration, - config: $this->createConfig($document, $childData, $editMode, $editableId), + config: $this->createConfig($document, $childData, $editMode, null), params: $parameters, children: [], editMode: $editMode, diff --git a/src/HeadlessDocument/HeadlessDocumentResolver.php b/src/HeadlessDocument/HeadlessDocumentResolver.php index 22f10f86..9e923d1d 100644 --- a/src/HeadlessDocument/HeadlessDocumentResolver.php +++ b/src/HeadlessDocument/HeadlessDocumentResolver.php @@ -8,6 +8,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use ToolboxBundle\Document\Editable\ConfigParser; use ToolboxBundle\Document\Editable\EditableJsonSubscriber; use ToolboxBundle\Document\Editable\HeadlessEditableRenderer; use ToolboxBundle\Factory\HeadlessEditableInfoFactory; @@ -21,6 +22,7 @@ class HeadlessDocumentResolver public function __construct( protected Environment $environment, protected ConfigManagerInterface $configManager, + protected ConfigParser $configParser, protected EditmodeResolver $editmodeResolver, protected EventDispatcherInterface $eventDispatcher, protected HeadlessEditableRenderer $headlessEditableRenderer, @@ -57,6 +59,12 @@ private function buildEditModeOutput(Document $document, string $headlessDocumen $item['name'] = $itemName; + if (!in_array($item['type'], ['areablock', 'area'])) { + // configuration of standalone editables in headless documents needs to be transformed here, + // since we don't have any brick action to handle it! + $item = $this->parseConfigElement($item, $itemName); + } + $headlessInfo = $this->editableInfoFactory->createViaEditable($document, $itemName, true, $item); $renderedEditable = $this->headlessEditableRenderer->buildEditable($headlessInfo); @@ -66,7 +74,7 @@ private function buildEditModeOutput(Document $document, string $headlessDocumen } else { $configurationView = $this->headlessEditableRenderer->renderStandaloneEditableWithWrapper( $this->headlessEditableRenderer->renderEditableWithWrapper($item['type'], [ - 'item' => array_merge($item, ['label' => $item['title']]), + 'item' => $item, 'editable' => $renderedEditable ]) ); @@ -123,4 +131,25 @@ private function unregisterEventSubscriber(): void $this->subscriber = null; } } + + protected function parseConfigElement(array $config, string $itemName): array + { + $editableConfig = $this->configParser->parseConfigElement(null, $itemName, $config, true); + + if ($editableConfig === null) { + return []; + } + + if (array_key_exists('children', $config) && is_array($config['children']) && count($config['children']) > 0) { + $children = []; + + foreach ($config['children'] as $childName => $childConfig) { + $children[] = $this->parseConfigElement($childConfig, $childName); + } + + $editableConfig['children'] = $children; + } + + return $editableConfig; + } }