Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
67.14% |
143 / 213 |
|
67.65% |
69 / 102 |
|
0.73% |
21 / 2864 |
|
35.00% |
7 / 20 |
CRAP | |
0.00% |
0 / 1 |
| BuilderPanel | |
69.76% |
143 / 205 |
|
67.65% |
69 / 102 |
|
0.73% |
21 / 2864 |
|
35.00% |
7 / 20 |
3463.99 | |
0.00% |
0 / 1 |
| create | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| keyboardShortcuts | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| build | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| onAttachToSlot | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| onUpdate | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| onDelete | |
0.00% |
0 / 3 |
|
0.00% |
0 / 3 |
|
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| buildRootDropzone | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| buildSingleComponent | |
80.00% |
4 / 5 |
|
66.67% |
2 / 3 |
|
50.00% |
1 / 2 |
|
0.00% |
0 / 1 |
2.50 | |||
| buildComponentRealRender | |
83.33% |
10 / 12 |
|
87.50% |
7 / 8 |
|
8.33% |
1 / 12 |
|
0.00% |
0 / 1 |
16.32 | |||
| resolveComponentInfo | |
72.22% |
13 / 18 |
|
85.71% |
6 / 7 |
|
12.50% |
1 / 8 |
|
0.00% |
0 / 1 |
21.75 | |||
| buildNodeAttributes | |
100.00% |
8 / 8 |
|
100.00% |
3 / 3 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| buildSlotAttributes | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| buildSingleBlock | |
91.89% |
34 / 37 |
|
80.65% |
25 / 31 |
|
0.18% |
5 / 2801 |
|
0.00% |
0 / 1 |
270.63 | |||
| replaceNode | |
0.00% |
0 / 20 |
|
0.00% |
0 / 7 |
|
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
| useAttributesVariable | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| renderSource | |
21.05% |
4 / 19 |
|
44.44% |
4 / 9 |
|
12.50% |
1 / 8 |
|
0.00% |
0 / 1 |
21.75 | |||
| digFromSlot | |
80.95% |
17 / 21 |
|
80.00% |
12 / 15 |
|
15.38% |
2 / 13 |
|
0.00% |
0 / 1 |
36.69 | |||
| hasMultipleRoot | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isEmpty | |
50.00% |
2 / 4 |
|
50.00% |
2 / 4 |
|
50.00% |
1 / 2 |
|
0.00% |
0 / 1 |
2.50 | |||
| buildComponentSlot | |
93.33% |
14 / 15 |
|
66.67% |
2 / 3 |
|
50.00% |
1 / 2 |
|
0.00% |
0 / 1 |
2.50 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Drupal\display_builder\Plugin\display_builder\Island; |
| 6 | |
| 7 | use Drupal\Component\Utility\Html; |
| 8 | use Drupal\Core\Render\RendererInterface; |
| 9 | use Drupal\Core\StringTranslation\TranslatableMarkup; |
| 10 | use Drupal\display_builder\Attribute\Island; |
| 11 | use Drupal\display_builder\DisplayBuilderHtmx; |
| 12 | use Drupal\display_builder\InstanceInterface; |
| 13 | use Drupal\display_builder\Island\IslandPluginBase; |
| 14 | use Drupal\display_builder\Island\IslandReloadEventsTrait; |
| 15 | use Drupal\display_builder\Island\IslandType; |
| 16 | use Drupal\display_builder\SlotSourceProxy; |
| 17 | use Drupal\display_builder\SourceWithSlotsInterface; |
| 18 | use Drupal\ui_patterns\Element\ComponentElementBuilder; |
| 19 | use Drupal\ui_patterns\SourcePluginBase; |
| 20 | use Drupal\ui_patterns\SourceWithChoicesInterface; |
| 21 | use Masterminds\HTML5; |
| 22 | use Symfony\Component\DependencyInjection\ContainerInterface; |
| 23 | |
| 24 | /** |
| 25 | * Builder island plugin implementation. |
| 26 | */ |
| 27 | #[Island( |
| 28 | id: 'builder', |
| 29 | enabled_by_default: TRUE, |
| 30 | label: new TranslatableMarkup('Canvas'), |
| 31 | description: new TranslatableMarkup('The Display Builder main island. Build the display with dynamic preview.'), |
| 32 | type: IslandType::View, |
| 33 | default_region: 'main', |
| 34 | icon: 'tools', |
| 35 | )] |
| 36 | class BuilderPanel extends IslandPluginBase { |
| 37 | |
| 38 | use IslandReloadEventsTrait; |
| 39 | |
| 40 | /** |
| 41 | * The renderer service. |
| 42 | */ |
| 43 | protected RendererInterface $renderer; |
| 44 | |
| 45 | /** |
| 46 | * Proxy for slot source operations. |
| 47 | */ |
| 48 | protected SlotSourceProxy $slotSourceProxy; |
| 49 | |
| 50 | /** |
| 51 | * The component element builder. |
| 52 | */ |
| 53 | protected ComponentElementBuilder $componentElementBuilder; |
| 54 | |
| 55 | /** |
| 56 | * {@inheritdoc} |
| 57 | */ |
| 58 | public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { |
| 59 | $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition); |
| 60 | $instance->renderer = $container->get('renderer'); |
| 61 | $instance->slotSourceProxy = $container->get('display_builder.slot_sources_proxy'); |
| 62 | $instance->componentElementBuilder = $container->get('ui_patterns.component_element_builder'); |
| 63 | |
| 64 | return $instance; |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * {@inheritdoc} |
| 69 | */ |
| 70 | public static function keyboardShortcuts(): array { |
| 71 | return [ |
| 72 | 'key' => 'c', |
| 73 | 'help' => t('Show the canvas'), |
| 74 | ]; |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * {@inheritdoc} |
| 79 | */ |
| 80 | public function build(InstanceInterface $builder, array $data = [], array $options = []): array { |
| 81 | return $this->buildRootDropzone($builder, $data); |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * {@inheritdoc} |
| 86 | */ |
| 87 | public function onAttachToSlot(InstanceInterface $instance, string $node_id, string $parent_id): array { |
| 88 | return $this->replaceNode($instance, $parent_id); |
| 89 | } |
| 90 | |
| 91 | /** |
| 92 | * {@inheritdoc} |
| 93 | */ |
| 94 | public function onUpdate(InstanceInterface $instance, string $node_id): array { |
| 95 | return $this->replaceNode($instance, $node_id); |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * {@inheritdoc} |
| 100 | */ |
| 101 | public function onDelete(InstanceInterface $instance, ?string $parent_id): array { |
| 102 | if (empty($parent_id)) { |
| 103 | return $this->reloadWithGlobalData($instance); |
| 104 | } |
| 105 | |
| 106 | return $this->replaceNode($instance, $parent_id); |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * Builds the root dropzone render array. |
| 111 | * |
| 112 | * Split out from build() so WireframePanelBase can reuse it without also |
| 113 | * inheriting anything Builder-specific. |
| 114 | * |
| 115 | * @param \Drupal\display_builder\InstanceInterface $builder |
| 116 | * The Display Builder instance. |
| 117 | * @param array $data |
| 118 | * The current 'slice' of data. |
| 119 | * |
| 120 | * @return array |
| 121 | * The root dropzone render array. |
| 122 | */ |
| 123 | protected function buildRootDropzone(InstanceInterface $builder, array $data): array { |
| 124 | $builder_id = (string) $builder->id(); |
| 125 | $build = [ |
| 126 | '#type' => 'component', |
| 127 | '#component' => 'display_builder:dropzone', |
| 128 | '#props' => [ |
| 129 | 'variant' => 'root', |
| 130 | ], |
| 131 | '#slots' => [ |
| 132 | 'content' => $this->digFromSlot($builder, $data), |
| 133 | ], |
| 134 | '#attributes' => [ |
| 135 | // Required for JavaScript @see components/dropzone/dropzone.js. |
| 136 | 'data-db-id' => $builder_id, |
| 137 | 'data-node-title' => $this->t('Root container'), |
| 138 | 'data-db-root' => TRUE, |
| 139 | ], |
| 140 | ]; |
| 141 | |
| 142 | return $this->htmxEvents->onRootDrop($build, $builder_id, $this->getPluginID()); |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * Build renderable from state data. |
| 147 | * |
| 148 | * @param \Drupal\display_builder\InstanceInterface $instance |
| 149 | * The Display Builder instance ID. |
| 150 | * @param string $node_id |
| 151 | * The tree node ID. |
| 152 | * @param \Drupal\display_builder\SourceWithSlotsInterface $source |
| 153 | * The source plugin. |
| 154 | * @param array $data |
| 155 | * The UI Patterns form state data. |
| 156 | * @param int $index |
| 157 | * (Optional) The index of the block. Default to 0. |
| 158 | * |
| 159 | * @return array|null |
| 160 | * A renderable array. |
| 161 | */ |
| 162 | protected function buildSingleComponent(InstanceInterface $instance, string $node_id, SourceWithSlotsInterface $source, array $data, int $index = 0): ?array { |
| 163 | $info = $this->resolveComponentInfo($source, $data, $node_id); |
| 164 | |
| 165 | if ($info === NULL) { |
| 166 | return NULL; |
| 167 | } |
| 168 | |
| 169 | ['component_id' => $component_id, 'label' => $label, 'instance_id' => $node_id] = $info; |
| 170 | |
| 171 | return $this->buildComponentRealRender($instance, $node_id, $source, $data, $component_id, $label, $index); |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * Renders a component with its real output, slots wired as live dropzones. |
| 176 | * |
| 177 | * The Builder canvas's own rendering: the component's actual SDC/block |
| 178 | * markup, with each of its slots replaced by a real, draggable dropzone |
| 179 | * (@see buildComponentSlot()) - as opposed to WireframePanelBase's |
| 180 | * schematic card. Split out from buildSingleComponent() so ScaffoldPanel |
| 181 | * can reuse it for its own configured allowlist of "layout" components |
| 182 | * (e.g. grid rows), which render this way while everything else stays a |
| 183 | * wireframe-style card. |
| 184 | * |
| 185 | * @param \Drupal\display_builder\InstanceInterface $instance |
| 186 | * The Display Builder instance ID. |
| 187 | * @param string $node_id |
| 188 | * The tree node ID. |
| 189 | * @param \Drupal\display_builder\SourceWithSlotsInterface $source |
| 190 | * The source plugin. |
| 191 | * @param array $data |
| 192 | * The UI Patterns form state data. |
| 193 | * @param string $component_id |
| 194 | * The resolved component ID (@see resolveComponentInfo()). |
| 195 | * @param string $label |
| 196 | * The resolved label (@see resolveComponentInfo()). |
| 197 | * @param int $index |
| 198 | * The index of the component within its parent slot/root. |
| 199 | * |
| 200 | * @return array |
| 201 | * A renderable array. |
| 202 | */ |
| 203 | protected function buildComponentRealRender(InstanceInterface $instance, string $node_id, SourceWithSlotsInterface $source, array $data, string $component_id, string $label, int $index): array { |
| 204 | $build = $this->renderSource($data); |
| 205 | $build['#attributes'] = \array_merge($build['#attributes'] ?? [], $this->buildNodeAttributes($label, $index)); |
| 206 | $build['#attributes']['data-testid'] = $component_id; |
| 207 | |
| 208 | foreach ($source->getSlotDefinitions() as $slot_id => $definition) { |
| 209 | $slot = $this->buildComponentSlot($instance, $source, $slot_id, $definition, $node_id); |
| 210 | $build = $source->setSlotRenderable($build, $slot_id, $slot); |
| 211 | } |
| 212 | |
| 213 | if ($this->isEmpty($build)) { |
| 214 | // Keep the placeholder if the component is not renderable. |
| 215 | $message = $component_id . ': ' . $this->t('Empty by default. Configure it to make it visible'); |
| 216 | $build = $this->buildPlaceholder($message); |
| 217 | } |
| 218 | |
| 219 | if (!$this->useAttributesVariable($build)) { |
| 220 | $build = $this->wrapContent($build); |
| 221 | } |
| 222 | |
| 223 | return $this->htmxEvents->onInstanceClick($build, (string) $instance->id(), $node_id, $source->label(), $index); |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * Resolves component ID, label, and instance ID from source and data. |
| 228 | * |
| 229 | * Extracts the shared preamble logic used by all buildSingleComponent() |
| 230 | * implementations across BuilderPanel, WireframePanelBase, and TreePanel. |
| 231 | * |
| 232 | * @param \Drupal\display_builder\SourceWithSlotsInterface $source |
| 233 | * The source plugin. |
| 234 | * @param array $data |
| 235 | * The UI Patterns form state data. |
| 236 | * @param string $instance_id |
| 237 | * The instance ID. May be empty; resolved from data['node_id'] as fallback. |
| 238 | * |
| 239 | * @return array{component_id: string, label: string, instance_id: string}|null |
| 240 | * Associative array with 'component_id', 'label', 'instance_id', or NULL |
| 241 | * if either component_id or instance_id could not be resolved. |
| 242 | */ |
| 243 | protected function resolveComponentInfo(SourceWithSlotsInterface $source, array $data, string $instance_id): ?array { |
| 244 | $component_id = $source->getPluginID(); |
| 245 | $label = $source->label(); |
| 246 | |
| 247 | if ($source instanceof SourceWithChoicesInterface) { |
| 248 | $component_id = $source->getChoice($data['source']); |
| 249 | $result = $this->slotSourceProxy->getLabelWithSummary($data, [], TRUE); |
| 250 | $label = $result['label'] ?? $source->label(); |
| 251 | } |
| 252 | |
| 253 | $instance_id = $instance_id ?: $data['node_id'] ?? NULL; |
| 254 | |
| 255 | if (!$instance_id || !$component_id) { |
| 256 | $this->logger->error( |
| 257 | '[' . static::class . '::buildSingleComponent] missing component ID: @component_id or instance ID: @instance_id. <pre>' . \print_r($data, TRUE) . '</pre>', |
| 258 | ['@instance_id' => $instance_id ?? 'NULL', '@component_id' => $component_id], |
| 259 | ); |
| 260 | |
| 261 | return NULL; |
| 262 | } |
| 263 | |
| 264 | return [ |
| 265 | 'component_id' => $component_id, |
| 266 | 'label' => $label, |
| 267 | 'instance_id' => $instance_id, |
| 268 | ]; |
| 269 | } |
| 270 | |
| 271 | /** |
| 272 | * Stamps the node-identity attributes the contextual menu relies on. |
| 273 | * |
| 274 | * Resolves "what did I right-click" - shared across Builder, Wireframe, and |
| 275 | * Tree so a new attribute only needs to be added here once. |
| 276 | * |
| 277 | * Also stamps `data-island-id`, the island that rendered this specific |
| 278 | * node wrapper: Builder, Wireframe, and Tree all share the same Sortable |
| 279 | * group (@see components/dropzone/dropzone.js), so a node can be dragged |
| 280 | * from one panel's dropzone into another's. Sortable only relocates the |
| 281 | * existing DOM node - it never re-renders it - so after a cross-panel |
| 282 | * drop the moved element is still wearing its *source* panel's markup. |
| 283 | * `display_builder.js`'s `addVals()` reads this attribute off the |
| 284 | * dragged element to tell the server which island actually rendered it, |
| 285 | * so `ApiController::attachToRoot()/attachToSlot()` can tell a |
| 286 | * cross-panel move (destination island needs a fresh render, its own |
| 287 | * markup differs) apart from a same-panel reorder (destination is |
| 288 | * already correct, safe to skip). |
| 289 | * |
| 290 | * @param string $title |
| 291 | * Human-readable label for the instance. |
| 292 | * @param int $index |
| 293 | * Position within its parent slot/root. |
| 294 | * @param string|null $node_type |
| 295 | * (Optional) The source ID, for CSS/JS targeting of a given node type. |
| 296 | * |
| 297 | * @return array |
| 298 | * Attributes to merge into the instance wrapper's '#attributes'. |
| 299 | * |
| 300 | * @see components/contextual_menu/contextual_menu.js |
| 301 | */ |
| 302 | protected function buildNodeAttributes(string $title, int $index, ?string $node_type = NULL): array { |
| 303 | $attributes = [ |
| 304 | 'data-node-title' => $title, |
| 305 | 'data-slot-position' => $index, |
| 306 | 'data-island-id' => $this->getPluginID(), |
| 307 | ]; |
| 308 | |
| 309 | if ($node_type !== NULL) { |
| 310 | $attributes['data-node-type'] = $node_type; |
| 311 | } |
| 312 | |
| 313 | return $attributes; |
| 314 | } |
| 315 | |
| 316 | /** |
| 317 | * Stamps the slot-identity attributes the contextual menu relies on. |
| 318 | * |
| 319 | * Resolves "paste/duplicate into this slot" - shared across Builder, |
| 320 | * Wireframe, and Tree so a new attribute only needs to be added here once. |
| 321 | * |
| 322 | * @param string $slot_id |
| 323 | * The slot ID. |
| 324 | * @param string $slot_title |
| 325 | * The slot's human-readable title. |
| 326 | * @param string $parent_node_id |
| 327 | * The node ID of the component owning this slot. |
| 328 | * @param string $parent_title |
| 329 | * The human-readable label of the component owning this slot. |
| 330 | * |
| 331 | * @return array |
| 332 | * Attributes to merge into the slot dropzone/tree-item's '#attributes'. |
| 333 | * |
| 334 | * @see components/contextual_menu/contextual_menu.js |
| 335 | */ |
| 336 | protected function buildSlotAttributes(string $slot_id, string $slot_title, string $parent_node_id, string $parent_title): array { |
| 337 | return [ |
| 338 | 'data-slot-id' => $slot_id, |
| 339 | 'data-slot-title' => \ucfirst($slot_title), |
| 340 | 'data-node-id' => $parent_node_id, |
| 341 | 'data-node-title' => $parent_title, |
| 342 | ]; |
| 343 | } |
| 344 | |
| 345 | /** |
| 346 | * Build renderable from state data. |
| 347 | * |
| 348 | * @param \Drupal\display_builder\InstanceInterface $instance |
| 349 | * The Display Builder instance ID. |
| 350 | * @param string $node_id |
| 351 | * The tree node ID. |
| 352 | * @param array $data |
| 353 | * The UI Patterns form state data. |
| 354 | * @param int $index |
| 355 | * (Optional) The index of the block. Default to 0. |
| 356 | * |
| 357 | * @return array|null |
| 358 | * A renderable array. |
| 359 | */ |
| 360 | protected function buildSingleBlock(InstanceInterface $instance, string $node_id, array $data, int $index = 0): ?array { |
| 361 | $node_id = $node_id ?: $data['node_id'] ?? NULL; |
| 362 | |
| 363 | if (!$node_id) { |
| 364 | return NULL; |
| 365 | } |
| 366 | |
| 367 | $classes = ['db-block']; |
| 368 | |
| 369 | if (isset($data['source']['plugin_id'])) { |
| 370 | $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source']['plugin_id'])); |
| 371 | } |
| 372 | else { |
| 373 | $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source_id'])); |
| 374 | } |
| 375 | $build = $this->renderSource($data, $classes); |
| 376 | $is_empty = FALSE; |
| 377 | |
| 378 | if (isset($data['source_id']) && $data['source_id'] === 'token') { |
| 379 | if (isset($build['content']) && empty($build['content'])) { |
| 380 | $is_empty = TRUE; |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') { |
| 385 | // system_messages_block is never empty, but often invisible. |
| 386 | // See: core/modules/system/src/Plugin/Block/SystemMessagesBlock.php |
| 387 | // See: core/lib/Drupal/Core/Render/Element/StatusMessages.php |
| 388 | // Let's always display it in a placeholder. |
| 389 | $is_empty = TRUE; |
| 390 | } |
| 391 | |
| 392 | $label_info = $this->slotSourceProxy->getLabelWithSummary($data, $this->configuration['contexts'] ?? []); |
| 393 | |
| 394 | if (isset($data['source_id'])) { |
| 395 | switch ($data['source_id']) { |
| 396 | case 'entity_field': |
| 397 | $label_info['summary'] = (string) $this->t('Field: @label', ['@label' => $label_info['label']]); |
| 398 | |
| 399 | break; |
| 400 | |
| 401 | case 'block': |
| 402 | $label_info['summary'] = (string) $this->t('Block: @label', ['@label' => $label_info['summary']]); |
| 403 | |
| 404 | break; |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | // This is the placeholder without configuration or content yet. |
| 409 | if ($this->isEmpty($build) || $is_empty) { |
| 410 | $build = $this->buildPlaceholderButton($label_info['summary']); |
| 411 | } |
| 412 | elseif (!$this->useAttributesVariable($build) || $this->hasMultipleRoot($build)) { |
| 413 | $build = [ |
| 414 | '#type' => 'html_tag', |
| 415 | '#tag' => 'div', |
| 416 | '#attributes' => ['class' => $classes], |
| 417 | 'content' => $build, |
| 418 | ]; |
| 419 | } |
| 420 | |
| 421 | // The title is expected to contain a human-readable label or summary |
| 422 | // describing the block instance, used by the contextual menu for user |
| 423 | // actions such as edit, delete. |
| 424 | // @see components/contextual_menu/contextual_menu.js |
| 425 | $title = $label_info['label'] ?? $data['source_id'] ?? $data['node_id'] ?? ''; |
| 426 | $build['#attributes'] = \array_merge($build['#attributes'] ?? [], $this->buildNodeAttributes($title, $index, $data['source_id'] ?? NULL)); |
| 427 | $build['#attributes']['data-testid'] = $data['source_id'] ?? $data['node_id'] ?? '_' . $index; |
| 428 | |
| 429 | $build = $this->htmxEvents->onInstanceClick($build, (string) $instance->id(), $node_id, $label_info['summary'] ?? $label_info['label'] ?? '', $index); |
| 430 | |
| 431 | return $build; |
| 432 | } |
| 433 | |
| 434 | /** |
| 435 | * Helper method to replace a specific instance in the DOM. |
| 436 | * |
| 437 | * @param \Drupal\display_builder\InstanceInterface $instance |
| 438 | * The builder instance. |
| 439 | * @param string $node_id |
| 440 | * The node ID from the source tree. |
| 441 | * |
| 442 | * @return array |
| 443 | * Returns a render array with out-of-band commands. |
| 444 | */ |
| 445 | protected function replaceNode(InstanceInterface $instance, string $node_id): array { |
| 446 | $builder_id = (string) $instance->id(); |
| 447 | $parent_selector = '#' . $this->getHtmlId($builder_id) . ' [data-node-id="' . $node_id . '"]'; |
| 448 | $data = $instance->getNode($node_id); |
| 449 | $build = []; |
| 450 | $slot_definition = ['ui_patterns' => ['type_definition' => $this->sourceManager->getSlotPropType()]]; |
| 451 | |
| 452 | try { |
| 453 | $source = $this->sourceManager->createInstance( |
| 454 | $data['source_id'], |
| 455 | SourcePluginBase::buildConfiguration('slot', $slot_definition, $data, $this->configuration['contexts'] ?? []) |
| 456 | ); |
| 457 | } |
| 458 | catch (\Throwable $e) { |
| 459 | $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]); |
| 460 | |
| 461 | return []; |
| 462 | } |
| 463 | |
| 464 | if ($source instanceof SourceWithSlotsInterface) { |
| 465 | $build = $this->buildSingleComponent($instance, $node_id, $source, $data); |
| 466 | } |
| 467 | else { |
| 468 | $build = $this->buildSingleBlock($instance, $node_id, $data); |
| 469 | } |
| 470 | |
| 471 | return DisplayBuilderHtmx::makeOutOfBand( |
| 472 | $build ?? [], |
| 473 | $parent_selector, |
| 474 | 'outerHTML' |
| 475 | ); |
| 476 | } |
| 477 | |
| 478 | /** |
| 479 | * Does the component use the attributes variable in template? |
| 480 | * |
| 481 | * @param array $renderable |
| 482 | * Component renderable. |
| 483 | * |
| 484 | * @return bool |
| 485 | * Use it or not. |
| 486 | */ |
| 487 | protected function useAttributesVariable(array $renderable): bool { |
| 488 | $random = \bin2hex(\random_bytes(8)); |
| 489 | $renderable['#attributes'][$random] = $random; |
| 490 | $html = $this->renderer->renderInIsolation($renderable); |
| 491 | |
| 492 | return \str_contains((string) $html, $random); |
| 493 | } |
| 494 | |
| 495 | /** |
| 496 | * Get renderable array for a slot source. |
| 497 | * |
| 498 | * @param array $data |
| 499 | * The slot source data array containing: |
| 500 | * - source_id: The source ID |
| 501 | * - source: Array of source configuration. |
| 502 | * @param array $classes |
| 503 | * (Optional) Classes to use to wrap the rendered source if needed. |
| 504 | * |
| 505 | * @return array |
| 506 | * The renderable array for this slot source. |
| 507 | */ |
| 508 | protected function renderSource(array $data, array $classes = []): array { |
| 509 | $build = $this->componentElementBuilder->buildSource([], 'content', [], $data, $this->configuration['contexts'] ?? []) ?? []; |
| 510 | $build = $build['#slots']['content'][0] ?? []; |
| 511 | |
| 512 | // Fixes for token which is simple markup or html. |
| 513 | if (isset($data['source_id']) && $data['source_id'] !== 'token') { |
| 514 | return $build; |
| 515 | } |
| 516 | |
| 517 | // If token is only markup, we don't have a wrapper, add it like styles |
| 518 | // so the placeholder can be styled. |
| 519 | if (!isset($build['#type'])) { |
| 520 | $build = [ |
| 521 | '#type' => 'html_tag', |
| 522 | '#tag' => 'div', |
| 523 | '#attributes' => ['class' => $classes], |
| 524 | 'content' => $build, |
| 525 | ]; |
| 526 | } |
| 527 | |
| 528 | // If a style is applied, we have a wrapper from styles with classes, to |
| 529 | // avoid our placeholder classes to be replaced we need to wrap it. |
| 530 | elseif (isset($build['#attributes'])) { |
| 531 | $build = [ |
| 532 | '#type' => 'html_tag', |
| 533 | '#tag' => 'div', |
| 534 | '#attributes' => ['class' => $classes], |
| 535 | 'content' => $build, |
| 536 | ]; |
| 537 | } |
| 538 | |
| 539 | return $build; |
| 540 | } |
| 541 | |
| 542 | /** |
| 543 | * Build builder renderable, recursively. |
| 544 | * |
| 545 | * @param \Drupal\display_builder\InstanceInterface $instance |
| 546 | * The Display Builder instance ID. |
| 547 | * @param array $data |
| 548 | * The current 'slice' of data. |
| 549 | * |
| 550 | * @return array |
| 551 | * A renderable array. |
| 552 | */ |
| 553 | protected function digFromSlot(InstanceInterface $instance, array $data): array { |
| 554 | $renderable = []; |
| 555 | $slot_definition = ['ui_patterns' => ['type_definition' => $this->sourceManager->getSlotPropType()]]; |
| 556 | |
| 557 | foreach ($data as $index => $source) { |
| 558 | if (!isset($source['source_id'])) { |
| 559 | continue; |
| 560 | } |
| 561 | |
| 562 | try { |
| 563 | $source_plugin = $this->sourceManager->createInstance( |
| 564 | $source['source_id'], |
| 565 | SourcePluginBase::buildConfiguration('slot', $slot_definition, $source, $this->configuration['contexts'] ?? []) |
| 566 | ); |
| 567 | } |
| 568 | catch (\Throwable $e) { |
| 569 | $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]); |
| 570 | |
| 571 | continue; |
| 572 | } |
| 573 | |
| 574 | if ($source_plugin instanceof SourceWithSlotsInterface) { |
| 575 | $component = $this->buildSingleComponent($instance, '', $source_plugin, $source, $index); |
| 576 | |
| 577 | if ($component) { |
| 578 | $renderable[$index] = $component; |
| 579 | } |
| 580 | |
| 581 | continue; |
| 582 | } |
| 583 | |
| 584 | $block = $this->buildSingleBlock($instance, '', $source, $index); |
| 585 | |
| 586 | if ($block) { |
| 587 | $renderable[$index] = $block; |
| 588 | } |
| 589 | } |
| 590 | |
| 591 | return $renderable; |
| 592 | } |
| 593 | |
| 594 | /** |
| 595 | * Check if a renderable has multiple HTML root elements once rendered. |
| 596 | * |
| 597 | * @param array $renderable |
| 598 | * The renderable array to check. |
| 599 | * |
| 600 | * @return bool |
| 601 | * TRUE if the rendered output has multiple root elements, FALSE otherwise. |
| 602 | */ |
| 603 | private function hasMultipleRoot(array $renderable): bool { |
| 604 | $html = (string) $this->renderer->renderInIsolation($renderable); |
| 605 | $dom = new HTML5(['disable_html_ns' => TRUE, 'encoding' => 'UTF-8']); |
| 606 | $dom = $dom->loadHTMLFragment($html); |
| 607 | |
| 608 | return $dom->childElementCount > 1; |
| 609 | } |
| 610 | |
| 611 | /** |
| 612 | * Check if a renderable array is empty. |
| 613 | * |
| 614 | * If assertion is enabled, the render can fail on some specific cases like |
| 615 | * comment form without a commented entity. In that case, we consider the |
| 616 | * renderable as empty. |
| 617 | * |
| 618 | * @todo This is a workaround for a specific case, we should find a better way |
| 619 | * |
| 620 | * @param array $renderable |
| 621 | * The renderable array to check. |
| 622 | * |
| 623 | * @return bool |
| 624 | * TRUE if the rendered output is empty, FALSE otherwise. |
| 625 | */ |
| 626 | private function isEmpty(array $renderable): bool { |
| 627 | try { |
| 628 | $html = $this->renderer->renderInIsolation($renderable); |
| 629 | } |
| 630 | catch (\Throwable $e) { |
| 631 | return TRUE; |
| 632 | } |
| 633 | |
| 634 | return empty(\trim((string) $html)); |
| 635 | } |
| 636 | |
| 637 | /** |
| 638 | * Build a component slot with dropzone. |
| 639 | * |
| 640 | * @param \Drupal\display_builder\InstanceInterface $instance |
| 641 | * The Display Builder instance ID. |
| 642 | * @param \Drupal\display_builder\SourceWithSlotsInterface $source |
| 643 | * The source plugin. |
| 644 | * @param string $slot_id |
| 645 | * The slot ID. |
| 646 | * @param array $definition |
| 647 | * The slot definition. |
| 648 | * @param string $node_id |
| 649 | * The node id of the source. |
| 650 | * |
| 651 | * @return array |
| 652 | * A renderable array for the slot. |
| 653 | */ |
| 654 | private function buildComponentSlot(InstanceInterface $instance, SourceWithSlotsInterface $source, string $slot_id, array $definition, string $node_id): array { |
| 655 | $builder_id = (string) $instance->id(); |
| 656 | $dropzone = [ |
| 657 | '#type' => 'component', |
| 658 | '#component' => 'display_builder:dropzone', |
| 659 | '#attributes' => \array_merge( |
| 660 | [ |
| 661 | // Required for JavaScript @see components/dropzone/dropzone.js. |
| 662 | 'data-db-id' => $builder_id, |
| 663 | 'data-testid' => 'dropzone_' . $slot_id, |
| 664 | ], |
| 665 | $this->buildSlotAttributes($slot_id, $definition['title'], $node_id, $source->label()) |
| 666 | ), |
| 667 | ]; |
| 668 | |
| 669 | if ($sources = $source->getSlotValue($slot_id)) { |
| 670 | $dropzone['#slots']['content'] = $this->digFromSlot($instance, $sources); |
| 671 | } |
| 672 | |
| 673 | return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $this->getPluginID(), $node_id, $slot_id); |
| 674 | } |
| 675 | |
| 676 | } |