Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
41.67% covered (danger)
41.67%
70 / 168
40.74% covered (danger)
40.74%
33 / 81
1.18% covered (danger)
1.18%
9 / 760
22.22% covered (danger)
22.22%
4 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
BuilderPanel
43.48% covered (danger)
43.48%
70 / 161
40.74% covered (danger)
40.74%
33 / 81
1.18% covered (danger)
1.18%
9 / 760
22.22% covered (danger)
22.22%
4 / 18
2763.38
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 / 3
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
 onAttachToRoot
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
 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
 onMove
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
 onHistoryChange
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 / 19
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
72
 buildSingleBlock
81.08% covered (warning)
81.08%
30 / 37
70.37% covered (warning)
70.37%
19 / 27
0.43% covered (danger)
0.43%
3 / 701
0.00% covered (danger)
0.00%
0 / 1
268.73
 replaceInstance
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 useAttributesVariable
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
 renderSource
21.05% covered (danger)
21.05%
4 / 19
37.50% covered (danger)
37.50%
3 / 8
14.29% covered (danger)
14.29%
1 / 7
0.00% covered (danger)
0.00%
0 / 1
20.74
 digFromSlot
61.54% covered (warning)
61.54%
8 / 13
63.64% covered (warning)
63.64%
7 / 11
14.29% covered (danger)
14.29%
1 / 7
0.00% covered (danger)
0.00%
0 / 1
28.67
 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\IslandBuilderInterface;
13use Drupal\display_builder\IslandPluginBase;
14use Drupal\display_builder\IslandType;
15use Drupal\display_builder\SlotSourceProxy;
16use Drupal\ui_patterns\Element\ComponentElementBuilder;
17use Drupal\ui_styles\Render\Element;
18use Masterminds\HTML5;
19use Symfony\Component\DependencyInjection\ContainerInterface;
20
21/**
22 * Builder island plugin implementation.
23 */
24#[Island(
25  id: 'builder',
26  enabled_by_default: TRUE,
27  label: new TranslatableMarkup('Builder'),
28  description: new TranslatableMarkup('The Display Builder main island. Build the display with dynamic preview.'),
29  type: IslandType::View,
30  icon: 'tools',
31)]
32class BuilderPanel extends IslandPluginBase implements IslandBuilderInterface {
33
34  /**
35   * The renderer service.
36   */
37  protected RendererInterface $renderer;
38
39  /**
40   * Proxy for slot source operations.
41   */
42  protected SlotSourceProxy $slotSourceProxy;
43
44  /**
45   * The component element builder.
46   */
47  protected ComponentElementBuilder $componentElementBuilder;
48
49  /**
50   * {@inheritdoc}
51   */
52  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
53    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
54    $instance->renderer = $container->get('renderer');
55    $instance->slotSourceProxy = $container->get('display_builder.slot_sources_proxy');
56    $instance->componentElementBuilder = $container->get('ui_patterns.component_element_builder');
57
58    return $instance;
59  }
60
61  /**
62   * {@inheritdoc}
63   */
64  public static function keyboardShortcuts(): array {
65    return [
66      'b' => t('Show the builder'),
67    ];
68  }
69
70  /**
71   * {@inheritdoc}
72   */
73  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
74    $builder_id = (string) $builder->id();
75    $build = [
76      '#type' => 'component',
77      '#component' => 'display_builder:dropzone',
78      '#props' => [
79        'variant' => 'root',
80      ],
81      '#slots' => [
82        'content' => $this->digFromSlot($builder_id, $data),
83      ],
84      '#attributes' => [
85        // Required for JavaScript @see components/dropzone/dropzone.js.
86        'data-db-id' => $builder_id,
87        'data-node-title' => $this->t('Base container'),
88        'data-db-root' => TRUE,
89      ],
90    ];
91
92    return $this->htmxEvents->onRootDrop($build, $builder_id, $this->getPluginID());
93  }
94
95  /**
96   * {@inheritdoc}
97   */
98  public function onAttachToRoot(string $builder_id, string $instance_id): array {
99    return $this->reloadWithGlobalData($builder_id);
100  }
101
102  /**
103   * {@inheritdoc}
104   */
105  public function onAttachToSlot(string $builder_id, string $instance_id, string $parent_id): array {
106    return $this->replaceInstance($builder_id, $parent_id);
107  }
108
109  /**
110   * {@inheritdoc}
111   */
112  public function onMove(string $builder_id, string $instance_id): array {
113    return $this->reloadWithGlobalData($builder_id);
114  }
115
116  /**
117   * {@inheritdoc}
118   */
119  public function onHistoryChange(string $builder_id): array {
120    return $this->reloadWithGlobalData($builder_id);
121  }
122
123  /**
124   * {@inheritdoc}
125   */
126  public function onUpdate(string $builder_id, string $instance_id): array {
127    return $this->replaceInstance($builder_id, $instance_id);
128  }
129
130  /**
131   * {@inheritdoc}
132   */
133  public function onDelete(string $builder_id, string $parent_id): array {
134    if (empty($parent_id)) {
135      return $this->reloadWithGlobalData($builder_id);
136    }
137
138    return $this->replaceInstance($builder_id, $parent_id);
139  }
140
141  /**
142   * {@inheritdoc}
143   */
144  public function buildSingleComponent(string $builder_id, string $instance_id, array $data, int $index = 0): ?array {
145    $component_id = $data['source']['component']['component_id'] ?? NULL;
146    $instance_id = $instance_id ?: $data['node_id'];
147
148    if (!$instance_id && !$component_id) {
149      return NULL;
150    }
151
152    $component = $this->sdcManager->getDefinition($component_id);
153
154    if (!$component) {
155      return NULL;
156    }
157
158    $build = $this->renderSource($data);
159    // Required for the context menu label.
160    // @see assets/js/contextual_menu.js
161    $build['#attributes']['data-node-title'] = $component['label'];
162    $build['#attributes']['data-slot-position'] = $index;
163
164    foreach ($component['slots'] ?? [] as $slot_id => $definition) {
165      $build['#slots'][$slot_id] = $this->buildComponentSlot($builder_id, $slot_id, $definition, $data, $instance_id);
166      // Prevent the slot to be generated again.
167      unset($build['#ui_patterns']['slots'][$slot_id]);
168    }
169
170    if ($this->isEmpty($build)) {
171      // Keep the placeholder if the component is not renderable.
172      $message = $component['name'] . ': ' . $this->t('Empty by default. Configure it to make it visible');
173      $build = $this->buildPlaceholder($message);
174    }
175
176    if (!$this->useAttributesVariable($build)) {
177      $build = $this->wrapContent($build);
178    }
179
180    return $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $component['label'], $index);
181  }
182
183  /**
184   * {@inheritdoc}
185   */
186  public function buildSingleBlock(string $builder_id, string $instance_id, array $data, int $index = 0): ?array {
187    $instance_id = $instance_id ?: $data['node_id'];
188
189    if (!$instance_id) {
190      return NULL;
191    }
192
193    $classes = ['db-block'];
194
195    if (isset($data['source']['plugin_id'])) {
196      $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source']['plugin_id']));
197    }
198    else {
199      $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source_id']));
200    }
201    $build = $this->renderSource($data, $classes);
202    $is_empty = FALSE;
203
204    if (isset($data['source_id']) && $data['source_id'] === 'token') {
205      if (isset($build['content']) && empty($build['content'])) {
206        $is_empty = TRUE;
207      }
208    }
209
210    if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') {
211      // system_messages_block is never empty, but often invisible.
212      // See: core/modules/system/src/Plugin/Block/SystemMessagesBlock.php
213      // See: core/lib/Drupal/Core/Render/Element/StatusMessages.php
214      // Let's always display it in a placeholder.
215      $is_empty = TRUE;
216    }
217
218    $label_info = $this->slotSourceProxy->getLabelWithSummary($data, $this->configuration['contexts'] ?? []);
219
220    if (isset($data['source_id'])) {
221      switch ($data['source_id']) {
222        case 'entity_field':
223          $label_info['summary'] = (string) $this->t('Field: @label', ['@label' => $label_info['label']]);
224
225          break;
226
227        case 'block':
228          $label_info['summary'] = (string) $this->t('Block: @label', ['@label' => $label_info['summary']]);
229
230          break;
231      }
232    }
233
234    // This is the placeholder without configuration or content yet.
235    if ($this->isEmpty($build) || $is_empty) {
236      $build = $this->buildPlaceholderButton($label_info['summary']);
237      // Highlight in the view to show it's a temporary block waiting for
238      // configuration.
239      $build['#attributes']['class'][] = 'db-background';
240    }
241    elseif (!Element::isAcceptingAttributes($build) || $this->hasMultipleRoot($build)) {
242      $build = [
243        '#type' => 'html_tag',
244        '#tag' => 'div',
245        '#attributes' => ['class' => $classes],
246        'content' => $build,
247      ];
248    }
249
250    // This label is used for contextual menu.
251    // @see assets/js/contextual_menu.js
252    // The 'data-node-title' attribute is expected to contain a human-readable
253    // label or summary describing the block instance. This value is usd in the
254    // contextual menu for user actions such as edit, delete. The format should
255    // be a plain string, typically the label or field summary.
256    $build['#attributes']['data-node-title'] = $label_info['summary'] ?? $data['source_id'] ?? $data['node_id'] ?? '';
257    $build['#attributes']['data-slot-position'] = $index;
258
259    $build = $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $label_info['summary'] ?? $label_info['label'] ?? '', $index);
260
261    return $build;
262  }
263
264  /**
265   * Helper method to replace a specific instance in the DOM.
266   *
267   * @param string $builder_id
268   *   The builder ID.
269   * @param string $instance_id
270   *   The instance ID.
271   *
272   * @return array
273   *   Returns a render array with out-of-band commands.
274   */
275  protected function replaceInstance(string $builder_id, string $instance_id): array {
276    $parent_selector = '#' . $this->getHtmlId($builder_id) . ' [data-node-id="' . $instance_id . '"]';
277    // @todo pass \Drupal\display_builder\InstanceInterface object in
278    // parameters instead of loading again.
279    /** @var \Drupal\display_builder\InstanceInterface $builder */
280    $builder = $this->entityTypeManager->getStorage('display_builder_instance')->load($builder_id);
281    $data = $builder->get($instance_id);
282
283    $build = [];
284
285    if (isset($data['source_id']) && $data['source_id'] === 'component') {
286      $build = $this->buildSingleComponent($builder_id, $instance_id, $data);
287    }
288    else {
289      $build = $this->buildSingleBlock($builder_id, $instance_id, $data);
290    }
291
292    return $this->makeOutOfBand(
293      $build,
294      $parent_selector,
295      'outerHTML'
296    );
297  }
298
299  /**
300   * Does the component use the attributes variable in template?
301   *
302   * @param array $renderable
303   *   Component renderable.
304   *
305   * @return bool
306   *   Use it or not.
307   */
308  protected function useAttributesVariable(array $renderable): bool {
309    $random = \uniqid();
310    $renderable['#attributes'][$random] = $random;
311    $html = $this->renderer->renderInIsolation($renderable);
312
313    return \str_contains((string) $html, $random);
314  }
315
316  /**
317   * Get renderable array for a slot source.
318   *
319   * @param array $data
320   *   The slot source data array containing:
321   *   - source_id: The source ID
322   *   - source: Array of source configuration.
323   * @param array $classes
324   *   (Optional) Classes to use to wrap the rendered source if needed.
325   *
326   * @return array
327   *   The renderable array for this slot source.
328   */
329  protected function renderSource(array $data, array $classes = []): array {
330    $build = $this->componentElementBuilder->buildSource([], 'content', [], $data, $this->configuration['contexts'] ?? []) ?? [];
331    $build = $build['#slots']['content'][0] ?? [];
332
333    // Fixes for token which is simple markup or html.
334    if (isset($data['source_id']) && $data['source_id'] !== 'token') {
335      return $build;
336    }
337
338    // If token is only markup, we don't have a wrapper, add it like ui_styles
339    // so the placeholder can be styled.
340    if (!isset($build['#type'])) {
341      $build = [
342        '#type' => 'html_tag',
343        '#tag' => 'div',
344        '#attributes' => ['class' => $classes],
345        'content' => $build,
346      ];
347    }
348
349    // If a style is applied, we have a wrapper from ui_styles with classes, to
350    // avoid our placeholder classes to be replaced we need to wrap it.
351    elseif (isset($build['#attributes'])) {
352      $build = [
353        '#type' => 'html_tag',
354        '#tag' => 'div',
355        '#attributes' => ['class' => $classes],
356        'content' => $build,
357      ];
358    }
359
360    return $build;
361  }
362
363  /**
364   * Build builder renderable, recursively.
365   *
366   * @param string $builder_id
367   *   Builder ID.
368   * @param array $data
369   *   The current 'slice' of data.
370   *
371   * @return array
372   *   A renderable array.
373   */
374  protected function digFromSlot(string $builder_id, array $data): array {
375    $renderable = [];
376
377    foreach ($data as $index => $source) {
378      if (!isset($source['source_id'])) {
379        continue;
380      }
381
382      if ($source['source_id'] === 'component') {
383        $component = $this->buildSingleComponent($builder_id, '', $source, $index);
384
385        if ($component) {
386          $renderable[$index] = $component;
387        }
388
389        continue;
390      }
391
392      $block = $this->buildSingleBlock($builder_id, '', $source, $index);
393
394      if ($block) {
395        $renderable[$index] = $block;
396      }
397    }
398
399    return $renderable;
400  }
401
402  /**
403   * Check if a renderable has multiple HTML root elements once rendered.
404   *
405   * @param array $renderable
406   *   The renderable array to check.
407   *
408   * @return bool
409   *   TRUE if the rendered output has multiple root elements, FALSE otherwise.
410   */
411  private function hasMultipleRoot(array $renderable): bool {
412    $html = (string) $this->renderer->renderInIsolation($renderable);
413    $dom = new HTML5(['disable_html_ns' => TRUE, 'encoding' => 'UTF-8']);
414    $dom = $dom->loadHTMLFragment($html);
415
416    return $dom->childElementCount > 1;
417  }
418
419  /**
420   * Check if a renderable array is empty.
421   *
422   * @param array $renderable
423   *   The renderable array to check.
424   *
425   * @return bool
426   *   TRUE if the rendered output is empty, FALSE otherwise.
427   */
428  private function isEmpty(array $renderable): bool {
429    $html = $this->renderer->renderInIsolation($renderable);
430
431    return empty(\trim((string) $html));
432  }
433
434  /**
435   * Build a component slot with dropzone.
436   *
437   * @param string $builder_id
438   *   The builder ID.
439   * @param string $slot_id
440   *   The slot ID.
441   * @param array $definition
442   *   The slot definition.
443   * @param array $data
444   *   The component data.
445   * @param string $instance_id
446   *   The instance ID.
447   *
448   * @return array
449   *   A renderable array for the slot.
450   */
451  private function buildComponentSlot(string $builder_id, string $slot_id, array $definition, array $data, string $instance_id): array {
452    $dropzone = [
453      '#type' => 'component',
454      '#component' => 'display_builder:dropzone',
455      '#props' => [
456        'title' => $definition['title'],
457        'variant' => 'highlighted',
458      ],
459      '#attributes' => [
460        // Required for JavaScript @see components/dropzone/dropzone.js.
461        'data-db-id' => $builder_id,
462        // Slot is needed for contextual menu paste.
463        // @see assets/js/contextual_menu.js
464        'data-slot-id' => $slot_id,
465        'data-slot-title' => \ucfirst($definition['title']),
466        'data-node-id' => $instance_id,
467      ],
468    ];
469
470    if (isset($data['source']['component']['slots'][$slot_id]['sources'])) {
471      $sources = $data['source']['component']['slots'][$slot_id]['sources'];
472      $dropzone['#slots']['content'] = $this->digFromSlot($builder_id, $sources);
473    }
474
475    return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $this->getPluginID(), $instance_id, $slot_id);
476  }
477
478}

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
73  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
74    $builder_id = (string) $builder->id();
75    $build = [
76      '#type' => 'component',
77      '#component' => 'display_builder:dropzone',
78      '#props' => [
79        'variant' => 'root',
80      ],
81      '#slots' => [
82        'content' => $this->digFromSlot($builder_id, $data),
83      ],
84      '#attributes' => [
85        // Required for JavaScript @see components/dropzone/dropzone.js.
86        'data-db-id' => $builder_id,
87        'data-node-title' => $this->t('Base container'),
88        'data-db-root' => TRUE,
89      ],
90    ];
91
92    return $this->htmxEvents->onRootDrop($build, $builder_id, $this->getPluginID());
BuilderPanel->buildComponentSlot
451  private function buildComponentSlot(string $builder_id, string $slot_id, array $definition, array $data, string $instance_id): array {
452    $dropzone = [
453      '#type' => 'component',
454      '#component' => 'display_builder:dropzone',
455      '#props' => [
456        'title' => $definition['title'],
457        'variant' => 'highlighted',
458      ],
459      '#attributes' => [
460        // Required for JavaScript @see components/dropzone/dropzone.js.
461        'data-db-id' => $builder_id,
462        // Slot is needed for contextual menu paste.
463        // @see assets/js/contextual_menu.js
464        'data-slot-id' => $slot_id,
465        'data-slot-title' => \ucfirst($definition['title']),
466        'data-node-id' => $instance_id,
467      ],
468    ];
469
470    if (isset($data['source']['component']['slots'][$slot_id]['sources'])) {
471      $sources = $data['source']['component']['slots'][$slot_id]['sources'];
472      $dropzone['#slots']['content'] = $this->digFromSlot($builder_id, $sources);
473    }
474
475    return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $this->getPluginID(), $instance_id, $slot_id);
475    return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $this->getPluginID(), $instance_id, $slot_id);
BuilderPanel->buildSingleBlock
186  public function buildSingleBlock(string $builder_id, string $instance_id, array $data, int $index = 0): ?array {
187    $instance_id = $instance_id ?: $data['node_id'];
188
189    if (!$instance_id) {
190      return NULL;
193    $classes = ['db-block'];
194
195    if (isset($data['source']['plugin_id'])) {
195    if (isset($data['source']['plugin_id'])) {
196      $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source']['plugin_id']));
199      $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source_id']));
200    }
201    $build = $this->renderSource($data, $classes);
201    $build = $this->renderSource($data, $classes);
202    $is_empty = FALSE;
203
204    if (isset($data['source_id']) && $data['source_id'] === 'token') {
204    if (isset($data['source_id']) && $data['source_id'] === 'token') {
205      if (isset($build['content']) && empty($build['content'])) {
205      if (isset($build['content']) && empty($build['content'])) {
206        $is_empty = TRUE;
207      }
208    }
209
210    if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') {
210    if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') {
215      $is_empty = TRUE;
216    }
217
218    $label_info = $this->slotSourceProxy->getLabelWithSummary($data, $this->configuration['contexts'] ?? []);
218    $label_info = $this->slotSourceProxy->getLabelWithSummary($data, $this->configuration['contexts'] ?? []);
219
220    if (isset($data['source_id'])) {
221      switch ($data['source_id']) {
222        case 'entity_field':
227        case 'block':
227        case 'block':
223          $label_info['summary'] = (string) $this->t('Field: @label', ['@label' => $label_info['label']]);
224
225          break;
228          $label_info['summary'] = (string) $this->t('Block: @label', ['@label' => $label_info['summary']]);
229
230          break;
230          break;
231      }
232    }
233
234    // This is the placeholder without configuration or content yet.
235    if ($this->isEmpty($build) || $is_empty) {
235    if ($this->isEmpty($build) || $is_empty) {
235    if ($this->isEmpty($build) || $is_empty) {
235    if ($this->isEmpty($build) || $is_empty) {
236      $build = $this->buildPlaceholderButton($label_info['summary']);
241    elseif (!Element::isAcceptingAttributes($build) || $this->hasMultipleRoot($build)) {
241    elseif (!Element::isAcceptingAttributes($build) || $this->hasMultipleRoot($build)) {
243        '#type' => 'html_tag',
244        '#tag' => 'div',
245        '#attributes' => ['class' => $classes],
246        'content' => $build,
247      ];
248    }
249
250    // This label is used for contextual menu.
251    // @see assets/js/contextual_menu.js
252    // The 'data-node-title' attribute is expected to contain a human-readable
253    // label or summary describing the block instance. This value is usd in the
254    // contextual menu for user actions such as edit, delete. The format should
255    // be a plain string, typically the label or field summary.
256    $build['#attributes']['data-node-title'] = $label_info['summary'] ?? $data['source_id'] ?? $data['node_id'] ?? '';
256    $build['#attributes']['data-node-title'] = $label_info['summary'] ?? $data['source_id'] ?? $data['node_id'] ?? '';
257    $build['#attributes']['data-slot-position'] = $index;
258
259    $build = $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $label_info['summary'] ?? $label_info['label'] ?? '', $index);
260
261    return $build;
BuilderPanel->buildSingleComponent
144  public function buildSingleComponent(string $builder_id, string $instance_id, array $data, int $index = 0): ?array {
145    $component_id = $data['source']['component']['component_id'] ?? NULL;
146    $instance_id = $instance_id ?: $data['node_id'];
147
148    if (!$instance_id && !$component_id) {
148    if (!$instance_id && !$component_id) {
149      return NULL;
152    $component = $this->sdcManager->getDefinition($component_id);
153
154    if (!$component) {
155      return NULL;
158    $build = $this->renderSource($data);
159    // Required for the context menu label.
160    // @see assets/js/contextual_menu.js
161    $build['#attributes']['data-node-title'] = $component['label'];
162    $build['#attributes']['data-slot-position'] = $index;
163
164    foreach ($component['slots'] ?? [] as $slot_id => $definition) {
164    foreach ($component['slots'] ?? [] as $slot_id => $definition) {
164    foreach ($component['slots'] ?? [] as $slot_id => $definition) {
164    foreach ($component['slots'] ?? [] as $slot_id => $definition) {
165      $build['#slots'][$slot_id] = $this->buildComponentSlot($builder_id, $slot_id, $definition, $data, $instance_id);
166      // Prevent the slot to be generated again.
167      unset($build['#ui_patterns']['slots'][$slot_id]);
168    }
169
170    if ($this->isEmpty($build)) {
172      $message = $component['name'] . ': ' . $this->t('Empty by default. Configure it to make it visible');
173      $build = $this->buildPlaceholder($message);
174    }
175
176    if (!$this->useAttributesVariable($build)) {
176    if (!$this->useAttributesVariable($build)) {
177      $build = $this->wrapContent($build);
178    }
179
180    return $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $component['label'], $index);
180    return $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $component['label'], $index);
BuilderPanel->create
52  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
53    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
54    $instance->renderer = $container->get('renderer');
55    $instance->slotSourceProxy = $container->get('display_builder.slot_sources_proxy');
56    $instance->componentElementBuilder = $container->get('ui_patterns.component_element_builder');
57
58    return $instance;
BuilderPanel->digFromSlot
374  protected function digFromSlot(string $builder_id, array $data): array {
375    $renderable = [];
376
377    foreach ($data as $index => $source) {
377    foreach ($data as $index => $source) {
377    foreach ($data as $index => $source) {
378      if (!isset($source['source_id'])) {
379        continue;
382      if ($source['source_id'] === 'component') {
383        $component = $this->buildSingleComponent($builder_id, '', $source, $index);
384
385        if ($component) {
386          $renderable[$index] = $component;
387        }
388
389        continue;
389        continue;
392      $block = $this->buildSingleBlock($builder_id, '', $source, $index);
393
394      if ($block) {
377    foreach ($data as $index => $source) {
378      if (!isset($source['source_id'])) {
379        continue;
380      }
381
382      if ($source['source_id'] === 'component') {
383        $component = $this->buildSingleComponent($builder_id, '', $source, $index);
384
385        if ($component) {
386          $renderable[$index] = $component;
387        }
388
389        continue;
390      }
391
392      $block = $this->buildSingleBlock($builder_id, '', $source, $index);
393
394      if ($block) {
395        $renderable[$index] = $block;
377    foreach ($data as $index => $source) {
378      if (!isset($source['source_id'])) {
379        continue;
380      }
381
382      if ($source['source_id'] === 'component') {
383        $component = $this->buildSingleComponent($builder_id, '', $source, $index);
384
385        if ($component) {
386          $renderable[$index] = $component;
387        }
388
389        continue;
390      }
391
392      $block = $this->buildSingleBlock($builder_id, '', $source, $index);
393
394      if ($block) {
395        $renderable[$index] = $block;
396      }
397    }
398
399    return $renderable;
BuilderPanel->hasMultipleRoot
411  private function hasMultipleRoot(array $renderable): bool {
412    $html = (string) $this->renderer->renderInIsolation($renderable);
413    $dom = new HTML5(['disable_html_ns' => TRUE, 'encoding' => 'UTF-8']);
414    $dom = $dom->loadHTMLFragment($html);
415
416    return $dom->childElementCount > 1;
BuilderPanel->isEmpty
428  private function isEmpty(array $renderable): bool {
429    $html = $this->renderer->renderInIsolation($renderable);
430
431    return empty(\trim((string) $html));
BuilderPanel->keyboardShortcuts
66      'b' => t('Show the builder'),
BuilderPanel->onAttachToRoot
98  public function onAttachToRoot(string $builder_id, string $instance_id): array {
99    return $this->reloadWithGlobalData($builder_id);
BuilderPanel->onAttachToSlot
105  public function onAttachToSlot(string $builder_id, string $instance_id, string $parent_id): array {
106    return $this->replaceInstance($builder_id, $parent_id);
BuilderPanel->onDelete
133  public function onDelete(string $builder_id, string $parent_id): array {
134    if (empty($parent_id)) {
135      return $this->reloadWithGlobalData($builder_id);
138    return $this->replaceInstance($builder_id, $parent_id);
BuilderPanel->onHistoryChange
119  public function onHistoryChange(string $builder_id): array {
120    return $this->reloadWithGlobalData($builder_id);
BuilderPanel->onMove
112  public function onMove(string $builder_id, string $instance_id): array {
113    return $this->reloadWithGlobalData($builder_id);
BuilderPanel->onUpdate
126  public function onUpdate(string $builder_id, string $instance_id): array {
127    return $this->replaceInstance($builder_id, $instance_id);
BuilderPanel->renderSource
329  protected function renderSource(array $data, array $classes = []): array {
330    $build = $this->componentElementBuilder->buildSource([], 'content', [], $data, $this->configuration['contexts'] ?? []) ?? [];
331    $build = $build['#slots']['content'][0] ?? [];
332
333    // Fixes for token which is simple markup or html.
334    if (isset($data['source_id']) && $data['source_id'] !== 'token') {
334    if (isset($data['source_id']) && $data['source_id'] !== 'token') {
335      return $build;
340    if (!isset($build['#type'])) {
340    if (!isset($build['#type'])) {
341      $build = [
342        '#type' => 'html_tag',
351    elseif (isset($build['#attributes'])) {
353        '#type' => 'html_tag',
354        '#tag' => 'div',
355        '#attributes' => ['class' => $classes],
356        'content' => $build,
357      ];
358    }
359
360    return $build;
360    return $build;
BuilderPanel->replaceInstance
275  protected function replaceInstance(string $builder_id, string $instance_id): array {
276    $parent_selector = '#' . $this->getHtmlId($builder_id) . ' [data-node-id="' . $instance_id . '"]';
277    // @todo pass \Drupal\display_builder\InstanceInterface object in
278    // parameters instead of loading again.
279    /** @var \Drupal\display_builder\InstanceInterface $builder */
280    $builder = $this->entityTypeManager->getStorage('display_builder_instance')->load($builder_id);
281    $data = $builder->get($instance_id);
282
283    $build = [];
284
285    if (isset($data['source_id']) && $data['source_id'] === 'component') {
285    if (isset($data['source_id']) && $data['source_id'] === 'component') {
285    if (isset($data['source_id']) && $data['source_id'] === 'component') {
286      $build = $this->buildSingleComponent($builder_id, $instance_id, $data);
289      $build = $this->buildSingleBlock($builder_id, $instance_id, $data);
290    }
291
292    return $this->makeOutOfBand(
292    return $this->makeOutOfBand(
293      $build,
294      $parent_selector,
295      'outerHTML'
BuilderPanel->useAttributesVariable
308  protected function useAttributesVariable(array $renderable): bool {
309    $random = \uniqid();
310    $renderable['#attributes'][$random] = $random;
311    $html = $this->renderer->renderInIsolation($renderable);
312
313    return \str_contains((string) $html, $random);