Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
42.57% covered (danger)
42.57%
86 / 202
47.37% covered (danger)
47.37%
45 / 95
0.26% covered (danger)
0.26%
11 / 4153
31.25% covered (danger)
31.25%
5 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
BuilderPanel
44.33% covered (danger)
44.33%
86 / 194
47.37% covered (danger)
47.37%
45 / 95
0.26% covered (danger)
0.26%
11 / 4153
31.25% covered (danger)
31.25%
5 / 16
2946.89
0.00% covered (danger)
0.00%
0 / 1
 create
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 keyboardShortcuts
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 build
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onAttachToSlot
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onUpdate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onDelete
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 buildSingleComponent
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 resolveComponentInfo
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 buildSingleBlock
92.50% covered (success)
92.50%
37 / 40
81.82% covered (warning)
81.82%
27 / 33
0.10% covered (danger)
0.10%
4 / 4096
0.00% covered (danger)
0.00%
0 / 1
305.15
 replaceNode
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 useAttributesVariable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderSource
21.05% covered (danger)
21.05%
4 / 19
44.44% covered (danger)
44.44%
4 / 9
12.50% covered (danger)
12.50%
1 / 8
0.00% covered (danger)
0.00%
0 / 1
21.75
 digFromSlot
61.90% covered (warning)
61.90%
13 / 21
60.00% covered (warning)
60.00%
9 / 15
7.69% covered (danger)
7.69%
1 / 13
0.00% covered (danger)
0.00%
0 / 1
45.54
 hasMultipleRoot
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isEmpty
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildComponentSlot
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder\Plugin\display_builder\Island;
6
7use Drupal\Component\Utility\Html;
8use Drupal\Core\Render\RendererInterface;
9use Drupal\Core\StringTranslation\TranslatableMarkup;
10use Drupal\display_builder\Attribute\Island;
11use Drupal\display_builder\InstanceInterface;
12use Drupal\display_builder\Island\IslandPluginBase;
13use Drupal\display_builder\Island\IslandReloadEventsTrait;
14use Drupal\display_builder\Island\IslandType;
15use Drupal\display_builder\SlotSourceProxy;
16use Drupal\display_builder\SourceWithSlotsInterface;
17use Drupal\ui_patterns\Element\ComponentElementBuilder;
18use Drupal\ui_patterns\SourcePluginBase;
19use Drupal\ui_patterns\SourceWithChoicesInterface;
20use Masterminds\HTML5;
21use Symfony\Component\DependencyInjection\ContainerInterface;
22
23/**
24 * Builder island plugin implementation.
25 */
26#[Island(
27  id: 'builder',
28  enabled_by_default: TRUE,
29  label: new TranslatableMarkup('Builder'),
30  description: new TranslatableMarkup('The Display Builder main island. Build the display with dynamic preview.'),
31  type: IslandType::View,
32  default_region: 'main',
33  icon: 'tools',
34)]
35class BuilderPanel extends IslandPluginBase {
36
37  use IslandReloadEventsTrait;
38
39  /**
40   * The renderer service.
41   */
42  protected RendererInterface $renderer;
43
44  /**
45   * Proxy for slot source operations.
46   */
47  protected SlotSourceProxy $slotSourceProxy;
48
49  /**
50   * The component element builder.
51   */
52  protected ComponentElementBuilder $componentElementBuilder;
53
54  /**
55   * {@inheritdoc}
56   */
57  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
58    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
59    $instance->renderer = $container->get('renderer');
60    $instance->slotSourceProxy = $container->get('display_builder.slot_sources_proxy');
61    $instance->componentElementBuilder = $container->get('ui_patterns.component_element_builder');
62
63    return $instance;
64  }
65
66  /**
67   * {@inheritdoc}
68   */
69  public static function keyboardShortcuts(): array {
70    return [
71      'key' => 'b',
72      'help' => t('Show the builder'),
73    ];
74  }
75
76  /**
77   * {@inheritdoc}
78   */
79  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
80    $builder_id = (string) $builder->id();
81    $build = [
82      '#type' => 'component',
83      '#component' => 'display_builder:dropzone',
84      '#props' => [
85        'variant' => 'root',
86      ],
87      '#slots' => [
88        'content' => $this->digFromSlot($builder_id, $data),
89      ],
90      '#attributes' => [
91        // Required for JavaScript @see components/dropzone/dropzone.js.
92        'data-db-id' => $builder_id,
93        'data-node-title' => $this->t('Base container'),
94        'data-db-root' => TRUE,
95      ],
96    ];
97
98    return $this->htmxEvents->onRootDrop($build, $builder_id, $this->getPluginID());
99  }
100
101  /**
102   * {@inheritdoc}
103   */
104  public function onAttachToSlot(InstanceInterface $instance, string $node_id, string $parent_id): array {
105    return $this->replaceNode($instance, $parent_id);
106  }
107
108  /**
109   * {@inheritdoc}
110   */
111  public function onUpdate(InstanceInterface $instance, string $node_id): array {
112    return $this->replaceNode($instance, $node_id);
113  }
114
115  /**
116   * {@inheritdoc}
117   */
118  public function onDelete(InstanceInterface $instance, ?string $parent_id): array {
119    if (empty($parent_id)) {
120      return $this->reloadWithGlobalData($instance);
121    }
122
123    return $this->replaceNode($instance, $parent_id);
124  }
125
126  /**
127   * Build renderable from state data.
128   *
129   * @param string $builder_id
130   *   Display Builder ID.
131   * @param string $instance_id
132   *   The instance ID.
133   * @param \Drupal\display_builder\SourceWithSlotsInterface $source
134   *   The source plugin.
135   * @param array $data
136   *   The UI Patterns form state data.
137   * @param int $index
138   *   (Optional) The index of the block. Default to 0.
139   *
140   * @return array|null
141   *   A renderable array.
142   */
143  protected function buildSingleComponent(string $builder_id, string $instance_id, SourceWithSlotsInterface $source, array $data, int $index = 0): ?array {
144    $info = $this->resolveComponentInfo($source, $data, $instance_id);
145
146    if ($info === NULL) {
147      return NULL;
148    }
149
150    ['component_id' => $component_id, 'label' => $label, 'instance_id' => $instance_id] = $info;
151
152    $build = $this->renderSource($data);
153    // Required for the context menu label.
154    // @see assets/js/contextual_menu.js
155    $build['#attributes']['data-node-title'] = $label;
156    $build['#attributes']['data-slot-position'] = $index;
157    $build['#attributes']['data-instance-id'] = $instance_id;
158
159    foreach ($source->getSlotDefinitions() as $slot_id => $definition) {
160      $slot = $this->buildComponentSlot($builder_id, $source, $slot_id, $definition, $instance_id);
161      $build = $source->setSlotRenderable($build, $slot_id, $slot);
162    }
163
164    if ($this->isEmpty($build)) {
165      // Keep the placeholder if the component is not renderable.
166      $message = $component_id . ': ' . $this->t('Empty by default. Configure it to make it visible');
167      $build = $this->buildPlaceholder($message);
168    }
169
170    if (!$this->useAttributesVariable($build)) {
171      $build = $this->wrapContent($build);
172    }
173
174    return $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $source->label(), $index);
175  }
176
177  /**
178   * Resolves component ID, label, and instance ID from source and data.
179   *
180   * Extracts the shared preamble logic used by all buildSingleComponent()
181   * implementations across BuilderPanel, LayersPanel, and TreePanel.
182   *
183   * @param \Drupal\display_builder\SourceWithSlotsInterface $source
184   *   The source plugin.
185   * @param array $data
186   *   The UI Patterns form state data.
187   * @param string $instance_id
188   *   The instance ID. May be empty; resolved from data['node_id'] as fallback.
189   *
190   * @return array{component_id: string, label: string, instance_id: string}|null
191   *   Associative array with 'component_id', 'label', 'instance_id', or NULL
192   *   if either component_id or instance_id could not be resolved.
193   */
194  protected function resolveComponentInfo(SourceWithSlotsInterface $source, array $data, string $instance_id): ?array {
195    $component_id = $source->getPluginID();
196    $label = $source->label();
197
198    if ($source instanceof SourceWithChoicesInterface) {
199      $component_id = $source->getChoice($data['source']);
200      $result = $this->slotSourceProxy->getLabelWithSummary($data, [], TRUE);
201      $label = $result['label'] ?? $source->label();
202    }
203
204    $instance_id = $instance_id ?: $data['node_id'] ?? NULL;
205
206    if (!$instance_id || !$component_id) {
207      $this->logger->error(
208        '[' . static::class . '::buildSingleComponent] missing component ID: @component_id or instance ID: @instance_id. <pre>' . \print_r($data, TRUE) . '</pre>',
209        ['@instance_id' => $instance_id ?? 'NULL', '@component_id' => $component_id],
210      );
211
212      return NULL;
213    }
214
215    return [
216      'component_id' => $component_id,
217      'label' => $label,
218      'instance_id' => $instance_id,
219    ];
220  }
221
222  /**
223   * Build renderable from state data.
224   *
225   * @param string $builder_id
226   *   Display Builder ID.
227   * @param string $instance_id
228   *   The instance ID.
229   * @param array $data
230   *   The UI Patterns form state data.
231   * @param int $index
232   *   (Optional) The index of the block. Default to 0.
233   *
234   * @return array|null
235   *   A renderable array.
236   */
237  protected function buildSingleBlock(string $builder_id, string $instance_id, array $data, int $index = 0): ?array {
238    $instance_id = $instance_id ?: $data['node_id'] ?? NULL;
239
240    if (!$instance_id) {
241      return NULL;
242    }
243
244    $classes = ['db-block'];
245
246    if (isset($data['source']['plugin_id'])) {
247      $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source']['plugin_id']));
248    }
249    else {
250      $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source_id']));
251    }
252    $build = $this->renderSource($data, $classes);
253    $is_empty = FALSE;
254
255    if (isset($data['source_id']) && $data['source_id'] === 'token') {
256      if (isset($build['content']) && empty($build['content'])) {
257        $is_empty = TRUE;
258      }
259    }
260
261    if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') {
262      // system_messages_block is never empty, but often invisible.
263      // See: core/modules/system/src/Plugin/Block/SystemMessagesBlock.php
264      // See: core/lib/Drupal/Core/Render/Element/StatusMessages.php
265      // Let's always display it in a placeholder.
266      $is_empty = TRUE;
267    }
268
269    $label_info = $this->slotSourceProxy->getLabelWithSummary($data, $this->configuration['contexts'] ?? []);
270
271    if (isset($data['source_id'])) {
272      switch ($data['source_id']) {
273        case 'entity_field':
274          $label_info['summary'] = (string) $this->t('Field: @label', ['@label' => $label_info['label']]);
275
276          break;
277
278        case 'block':
279          $label_info['summary'] = (string) $this->t('Block: @label', ['@label' => $label_info['summary']]);
280
281          break;
282      }
283    }
284
285    // This is the placeholder without configuration or content yet.
286    if ($this->isEmpty($build) || $is_empty) {
287      $build = $this->buildPlaceholderButton($label_info['summary']);
288      // Highlight in the view to show it's a temporary block waiting for
289      // configuration.
290      $build['#attributes']['class'][] = 'db-background';
291    }
292    elseif (!$this->useAttributesVariable($build) || $this->hasMultipleRoot($build)) {
293      $build = [
294        '#type' => 'html_tag',
295        '#tag' => 'div',
296        '#attributes' => ['class' => $classes],
297        'content' => $build,
298      ];
299    }
300
301    // This label is used for contextual menu.
302    // @see assets/js/contextual_menu.js
303    // The 'data-node-title' attribute is expected to contain a human-readable
304    // label or summary describing the block instance. This value is usd in the
305    // contextual menu for user actions such as edit, delete. The format should
306    // be a plain string, typically the label or field summary.
307    $build['#attributes']['data-node-title'] = $label_info['summary'] ?? $data['source_id'] ?? $data['node_id'] ?? '';
308    $build['#attributes']['data-slot-position'] = $index;
309    $build['#attributes']['data-instance-id'] = $instance_id;
310
311    // Add data-node-type for easier identification of block types in JS or CSS.
312    if (isset($data['source_id'])) {
313      $build['#attributes']['data-node-type'] = $data['source_id'];
314    }
315
316    $build = $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $label_info['summary'] ?? $label_info['label'] ?? '', $index);
317
318    return $build;
319  }
320
321  /**
322   * Helper method to replace a specific instance in the DOM.
323   *
324   * @param \Drupal\display_builder\InstanceInterface $instance
325   *   The builder instance.
326   * @param string $node_id
327   *   The node ID from the source tree.
328   *
329   * @return array
330   *   Returns a render array with out-of-band commands.
331   */
332  protected function replaceNode(InstanceInterface $instance, string $node_id): array {
333    $builder_id = (string) $instance->id();
334    $parent_selector = '#' . $this->getHtmlId($builder_id) . ' [data-node-id="' . $node_id . '"]';
335    $data = $instance->getNode($node_id);
336    $build = [];
337    $slot_definition = ['ui_patterns' => ['type_definition' => $this->sourceManager->getSlotPropType()]];
338
339    try {
340      $source = $this->sourceManager->createInstance(
341        $data['source_id'],
342        SourcePluginBase::buildConfiguration('slot', $slot_definition, $data, $this->configuration['contexts'] ?? [])
343      );
344    }
345    catch (\Throwable $e) {
346      $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]);
347
348      return [];
349    }
350
351    if ($source instanceof SourceWithSlotsInterface) {
352      $build = $this->buildSingleComponent($builder_id, $node_id, $source, $data);
353    }
354    else {
355      $build = $this->buildSingleBlock($builder_id, $node_id, $data);
356    }
357
358    return $this->makeOutOfBand(
359      $build ?? [],
360      $parent_selector,
361      'outerHTML'
362    );
363  }
364
365  /**
366   * Does the component use the attributes variable in template?
367   *
368   * @param array $renderable
369   *   Component renderable.
370   *
371   * @return bool
372   *   Use it or not.
373   */
374  protected function useAttributesVariable(array $renderable): bool {
375    $random = \uniqid();
376    $renderable['#attributes'][$random] = $random;
377    $html = $this->renderer->renderInIsolation($renderable);
378
379    return \str_contains((string) $html, $random);
380  }
381
382  /**
383   * Get renderable array for a slot source.
384   *
385   * @param array $data
386   *   The slot source data array containing:
387   *   - source_id: The source ID
388   *   - source: Array of source configuration.
389   * @param array $classes
390   *   (Optional) Classes to use to wrap the rendered source if needed.
391   *
392   * @return array
393   *   The renderable array for this slot source.
394   */
395  protected function renderSource(array $data, array $classes = []): array {
396    $build = $this->componentElementBuilder->buildSource([], 'content', [], $data, $this->configuration['contexts'] ?? []) ?? [];
397    $build = $build['#slots']['content'][0] ?? [];
398
399    // Fixes for token which is simple markup or html.
400    if (isset($data['source_id']) && $data['source_id'] !== 'token') {
401      return $build;
402    }
403
404    // If token is only markup, we don't have a wrapper, add it like styles
405    // so the placeholder can be styled.
406    if (!isset($build['#type'])) {
407      $build = [
408        '#type' => 'html_tag',
409        '#tag' => 'div',
410        '#attributes' => ['class' => $classes],
411        'content' => $build,
412      ];
413    }
414
415    // If a style is applied, we have a wrapper from styles with classes, to
416    // avoid our placeholder classes to be replaced we need to wrap it.
417    elseif (isset($build['#attributes'])) {
418      $build = [
419        '#type' => 'html_tag',
420        '#tag' => 'div',
421        '#attributes' => ['class' => $classes],
422        'content' => $build,
423      ];
424    }
425
426    return $build;
427  }
428
429  /**
430   * Build builder renderable, recursively.
431   *
432   * @param string $builder_id
433   *   Builder ID.
434   * @param array $data
435   *   The current 'slice' of data.
436   *
437   * @return array
438   *   A renderable array.
439   */
440  protected function digFromSlot(string $builder_id, array $data): array {
441    $renderable = [];
442    $slot_definition = ['ui_patterns' => ['type_definition' => $this->sourceManager->getSlotPropType()]];
443
444    foreach ($data as $index => $source) {
445      if (!isset($source['source_id'])) {
446        continue;
447      }
448
449      try {
450        $source_plugin = $this->sourceManager->createInstance(
451          $source['source_id'],
452          SourcePluginBase::buildConfiguration('slot', $slot_definition, $source, $this->configuration['contexts'] ?? [])
453        );
454      }
455      catch (\Throwable $e) {
456        $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]);
457
458        continue;
459      }
460
461      if ($source_plugin instanceof SourceWithSlotsInterface) {
462        $component = $this->buildSingleComponent($builder_id, '', $source_plugin, $source, $index);
463
464        if ($component) {
465          $renderable[$index] = $component;
466        }
467
468        continue;
469      }
470
471      $block = $this->buildSingleBlock($builder_id, '', $source, $index);
472
473      if ($block) {
474        $renderable[$index] = $block;
475      }
476    }
477
478    return $renderable;
479  }
480
481  /**
482   * Check if a renderable has multiple HTML root elements once rendered.
483   *
484   * @param array $renderable
485   *   The renderable array to check.
486   *
487   * @return bool
488   *   TRUE if the rendered output has multiple root elements, FALSE otherwise.
489   */
490  private function hasMultipleRoot(array $renderable): bool {
491    $html = (string) $this->renderer->renderInIsolation($renderable);
492    $dom = new HTML5(['disable_html_ns' => TRUE, 'encoding' => 'UTF-8']);
493    $dom = $dom->loadHTMLFragment($html);
494
495    return $dom->childElementCount > 1;
496  }
497
498  /**
499   * Check if a renderable array is empty.
500   *
501   * @param array $renderable
502   *   The renderable array to check.
503   *
504   * @return bool
505   *   TRUE if the rendered output is empty, FALSE otherwise.
506   */
507  private function isEmpty(array $renderable): bool {
508    $html = $this->renderer->renderInIsolation($renderable);
509
510    return empty(\trim((string) $html));
511  }
512
513  /**
514   * Build a component slot with dropzone.
515   *
516   * @param string $builder_id
517   *   The builder ID.
518   * @param \Drupal\display_builder\SourceWithSlotsInterface $source
519   *   The source plugin.
520   * @param string $slot_id
521   *   The slot ID.
522   * @param array $definition
523   *   The slot definition.
524   * @param string $instance_id
525   *   The instance ID.
526   *
527   * @return array
528   *   A renderable array for the slot.
529   */
530  private function buildComponentSlot(string $builder_id, SourceWithSlotsInterface $source, string $slot_id, array $definition, string $instance_id): array {
531    $dropzone = [
532      '#type' => 'component',
533      '#component' => 'display_builder:dropzone',
534      '#props' => [
535        'title' => $definition['title'],
536        'variant' => 'highlighted',
537      ],
538      '#attributes' => [
539        // Required for JavaScript @see components/dropzone/dropzone.js.
540        'data-db-id' => $builder_id,
541        // Slot is needed for contextual menu paste.
542        // @see assets/js/contextual_menu.js
543        'data-slot-id' => $slot_id,
544        'data-slot-title' => \ucfirst($definition['title']),
545        'data-node-id' => $instance_id,
546        'data-instance-id' => $instance_id . '_' . $slot_id,
547      ],
548    ];
549
550    if ($sources = $source->getSlotValue($slot_id)) {
551      $dropzone['#slots']['content'] = $this->digFromSlot($builder_id, $sources);
552    }
553
554    return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $this->getPluginID(), $instance_id, $slot_id);
555  }
556
557}