Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
67.14% covered (warning)
67.14%
143 / 213
67.65% covered (warning)
67.65%
69 / 102
0.73% covered (danger)
0.73%
21 / 2864
35.00% covered (danger)
35.00%
7 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
BuilderPanel
69.76% covered (warning)
69.76%
143 / 205
67.65% covered (warning)
67.65%
69 / 102
0.73% covered (danger)
0.73%
21 / 2864
35.00% covered (danger)
35.00%
7 / 20
3463.99
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%
1 / 1
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
 buildRootDropzone
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
 buildSingleComponent
80.00% covered (warning)
80.00%
4 / 5
66.67% covered (warning)
66.67%
2 / 3
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 buildComponentRealRender
83.33% covered (warning)
83.33%
10 / 12
87.50% covered (warning)
87.50%
7 / 8
8.33% covered (danger)
8.33%
1 / 12
0.00% covered (danger)
0.00%
0 / 1
16.32
 resolveComponentInfo
72.22% covered (warning)
72.22%
13 / 18
85.71% covered (warning)
85.71%
6 / 7
12.50% covered (danger)
12.50%
1 / 8
0.00% covered (danger)
0.00%
0 / 1
21.75
 buildNodeAttributes
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 buildSlotAttributes
100.00% covered (success)
100.00%
6 / 6
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
 buildSingleBlock
91.89% covered (success)
91.89%
34 / 37
80.65% covered (warning)
80.65%
25 / 31
0.18% covered (danger)
0.18%
5 / 2801
0.00% covered (danger)
0.00%
0 / 1
270.63
 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
80.95% covered (warning)
80.95%
17 / 21
80.00% covered (warning)
80.00%
12 / 15
15.38% covered (danger)
15.38%
2 / 13
0.00% covered (danger)
0.00%
0 / 1
36.69
 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
50.00% covered (danger)
50.00%
2 / 4
50.00% covered (danger)
50.00%
2 / 4
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 buildComponentSlot
93.33% covered (success)
93.33%
14 / 15
66.67% covered (warning)
66.67%
2 / 3
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
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\DisplayBuilderHtmx;
12use Drupal\display_builder\InstanceInterface;
13use Drupal\display_builder\Island\IslandPluginBase;
14use Drupal\display_builder\Island\IslandReloadEventsTrait;
15use Drupal\display_builder\Island\IslandType;
16use Drupal\display_builder\SlotSourceProxy;
17use Drupal\display_builder\SourceWithSlotsInterface;
18use Drupal\ui_patterns\Element\ComponentElementBuilder;
19use Drupal\ui_patterns\SourcePluginBase;
20use Drupal\ui_patterns\SourceWithChoicesInterface;
21use Masterminds\HTML5;
22use 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)]
36class 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}