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}

Branches

Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

BuilderPanel->build
80  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
81    return $this->buildRootDropzone($builder, $data);
82  }
BuilderPanel->buildComponentRealRender
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) {
208    foreach ($source->getSlotDefinitions() as $slot_id => $definition) {
208    foreach ($source->getSlotDefinitions() as $slot_id => $definition) {
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)) {
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)) {
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);
223    return $this->htmxEvents->onInstanceClick($build, (string) $instance->id(), $node_id, $source->label(), $index);
224  }
BuilderPanel->buildComponentSlot
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);
673    return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $this->getPluginID(), $node_id, $slot_id);
674  }
BuilderPanel->buildNodeAttributes
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;
313    return $attributes;
314  }
BuilderPanel->buildRootDropzone
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  }
BuilderPanel->buildSingleBlock
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;
367    $classes = ['db-block'];
368
369    if (isset($data['source']['plugin_id'])) {
369    if (isset($data['source']['plugin_id'])) {
370      $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source']['plugin_id']));
373      $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source_id']));
374    }
375    $build = $this->renderSource($data, $classes);
375    $build = $this->renderSource($data, $classes);
376    $is_empty = FALSE;
377
378    if (isset($data['source_id']) && $data['source_id'] === 'token') {
378    if (isset($data['source_id']) && $data['source_id'] === 'token') {
378    if (isset($data['source_id']) && $data['source_id'] === 'token') {
379      if (isset($build['content']) && empty($build['content'])) {
379      if (isset($build['content']) && empty($build['content'])) {
379      if (isset($build['content']) && empty($build['content'])) {
380        $is_empty = TRUE;
381      }
382    }
383
384    if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') {
384    if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') {
389      $is_empty = TRUE;
390    }
391
392    $label_info = $this->slotSourceProxy->getLabelWithSummary($data, $this->configuration['contexts'] ?? []);
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':
401        case 'block':
401        case 'block':
397          $label_info['summary'] = (string) $this->t('Field: @label', ['@label' => $label_info['label']]);
398
399          break;
402          $label_info['summary'] = (string) $this->t('Block: @label', ['@label' => $label_info['summary']]);
403
404          break;
404          break;
405      }
406    }
407
408    // This is the placeholder without configuration or content yet.
409    if ($this->isEmpty($build) || $is_empty) {
409    if ($this->isEmpty($build) || $is_empty) {
409    if ($this->isEmpty($build) || $is_empty) {
409    if ($this->isEmpty($build) || $is_empty) {
409    if ($this->isEmpty($build) || $is_empty) {
410      $build = $this->buildPlaceholderButton($label_info['summary']);
412    elseif (!$this->useAttributesVariable($build) || $this->hasMultipleRoot($build)) {
412    elseif (!$this->useAttributesVariable($build) || $this->hasMultipleRoot($build)) {
412    elseif (!$this->useAttributesVariable($build) || $this->hasMultipleRoot($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'] ?? '';
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  }
BuilderPanel->buildSingleComponent
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;
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  }
BuilderPanel->buildSlotAttributes
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  }
BuilderPanel->create
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  }
BuilderPanel->digFromSlot
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) {
557    foreach ($data as $index => $source) {
557    foreach ($data as $index => $source) {
558      if (!isset($source['source_id'])) {
559        continue;
562      try {
563        $source_plugin = $this->sourceManager->createInstance(
568      catch (\Throwable $e) {
569        $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]);
570
571        continue;
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;
581        continue;
584      $block = $this->buildSingleBlock($instance, '', $source, $index);
585
586      if ($block) {
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;
557    foreach ($data as $index => $source) {
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  }
BuilderPanel->hasMultipleRoot
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  }
BuilderPanel->isEmpty
626  private function isEmpty(array $renderable): bool {
627    try {
628      $html = $this->renderer->renderInIsolation($renderable);
630    catch (\Throwable $e) {
631      return TRUE;
634    return empty(\trim((string) $html));
635  }
BuilderPanel->keyboardShortcuts
72      'key' => 'c',
73      'help' => t('Show the canvas'),
74    ];
75  }
BuilderPanel->onAttachToSlot
87  public function onAttachToSlot(InstanceInterface $instance, string $node_id, string $parent_id): array {
88    return $this->replaceNode($instance, $parent_id);
89  }
BuilderPanel->onDelete
101  public function onDelete(InstanceInterface $instance, ?string $parent_id): array {
102    if (empty($parent_id)) {
103      return $this->reloadWithGlobalData($instance);
106    return $this->replaceNode($instance, $parent_id);
107  }
BuilderPanel->onUpdate
94  public function onUpdate(InstanceInterface $instance, string $node_id): array {
95    return $this->replaceNode($instance, $node_id);
96  }
BuilderPanel->renderSource
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') {
513    if (isset($data['source_id']) && $data['source_id'] !== 'token') {
513    if (isset($data['source_id']) && $data['source_id'] !== 'token') {
514      return $build;
519    if (!isset($build['#type'])) {
519    if (!isset($build['#type'])) {
520      $build = [
521        '#type' => 'html_tag',
530    elseif (isset($build['#attributes'])) {
532        '#type' => 'html_tag',
533        '#tag' => 'div',
534        '#attributes' => ['class' => $classes],
535        'content' => $build,
536      ];
537    }
538
539    return $build;
539    return $build;
540  }
BuilderPanel->replaceNode
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(
458    catch (\Throwable $e) {
459      $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]);
460
461      return [];
464    if ($source instanceof SourceWithSlotsInterface) {
464    if ($source instanceof SourceWithSlotsInterface) {
465      $build = $this->buildSingleComponent($instance, $node_id, $source, $data);
468      $build = $this->buildSingleBlock($instance, $node_id, $data);
469    }
470
471    return DisplayBuilderHtmx::makeOutOfBand(
471    return DisplayBuilderHtmx::makeOutOfBand(
472      $build ?? [],
473      $parent_selector,
474      'outerHTML'
475    );
476  }
BuilderPanel->resolveComponentInfo
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;
253    $instance_id = $instance_id ?: $data['node_id'] ?? NULL;
254
255    if (!$instance_id || !$component_id) {
255    if (!$instance_id || !$component_id) {
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;
265      'component_id' => $component_id,
266      'label' => $label,
267      'instance_id' => $instance_id,
268    ];
269  }
BuilderPanel->useAttributesVariable
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  }