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

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
83  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
84    $builder_id = (string) $builder->id();
85    $build = [
86      '#type' => 'component',
87      '#component' => 'display_builder:dropzone',
88      '#props' => [
89        'variant' => 'root',
90      ],
91      '#slots' => [
92        'content' => $this->digFromSlot($builder_id, $data),
93      ],
94      '#attributes' => [
95        // Required for JavaScript @see components/dropzone/dropzone.js.
96        'data-db-id' => $builder_id,
97        'data-node-title' => $this->t('Base container'),
98        'data-db-root' => TRUE,
99      ],
100    ];
101
102    return $this->htmxEvents->onRootDrop($build, $builder_id, $this->getPluginID());
103  }
BuilderPanel->buildComponentSlot
526  private function buildComponentSlot(string $builder_id, SourceWithSlotsInterface $source, string $slot_id, array $definition, string $instance_id): array {
527    $dropzone = [
528      '#type' => 'component',
529      '#component' => 'display_builder:dropzone',
530      '#props' => [
531        'title' => $definition['title'],
532        'variant' => 'highlighted',
533      ],
534      '#attributes' => [
535        // Required for JavaScript @see components/dropzone/dropzone.js.
536        'data-db-id' => $builder_id,
537        // Slot is needed for contextual menu paste.
538        // @see assets/js/contextual_menu.js
539        'data-slot-id' => $slot_id,
540        'data-slot-title' => \ucfirst($definition['title']),
541        'data-node-id' => $instance_id,
542        'data-instance-id' => $instance_id . '_' . $slot_id,
543      ],
544    ];
545
546    if ($sources = $source->getSlotValue($slot_id)) {
547      $dropzone['#slots']['content'] = $this->digFromSlot($builder_id, $sources);
548    }
549
550    return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $this->getPluginID(), $instance_id, $slot_id);
550    return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $this->getPluginID(), $instance_id, $slot_id);
551  }
BuilderPanel->buildSingleBlock
230  protected function buildSingleBlock(string $builder_id, string $instance_id, array $data, int $index = 0): ?array {
231    $instance_id = $instance_id ?: $data['node_id'] ?? NULL;
232
233    if (!$instance_id) {
234      return NULL;
237    $classes = ['db-block'];
238
239    if (isset($data['source']['plugin_id'])) {
239    if (isset($data['source']['plugin_id'])) {
240      $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source']['plugin_id']));
243      $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source_id']));
244    }
245    $build = $this->renderSource($data, $classes);
245    $build = $this->renderSource($data, $classes);
246    $is_empty = FALSE;
247
248    if (isset($data['source_id']) && $data['source_id'] === 'token') {
248    if (isset($data['source_id']) && $data['source_id'] === 'token') {
248    if (isset($data['source_id']) && $data['source_id'] === 'token') {
249      if (isset($build['content']) && empty($build['content'])) {
249      if (isset($build['content']) && empty($build['content'])) {
249      if (isset($build['content']) && empty($build['content'])) {
250        $is_empty = TRUE;
251      }
252    }
253
254    if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') {
254    if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') {
259      $is_empty = TRUE;
260    }
261
262    $label_info = $this->slotSourceProxy->getLabelWithSummary($data, $this->configuration['contexts'] ?? []);
262    $label_info = $this->slotSourceProxy->getLabelWithSummary($data, $this->configuration['contexts'] ?? []);
263
264    if (isset($data['source_id'])) {
265      switch ($data['source_id']) {
266        case 'entity_field':
271        case 'block':
271        case 'block':
267          $label_info['summary'] = (string) $this->t('Field: @label', ['@label' => $label_info['label']]);
268
269          break;
272          $label_info['summary'] = (string) $this->t('Block: @label', ['@label' => $label_info['summary']]);
273
274          break;
274          break;
275      }
276    }
277
278    // This is the placeholder without configuration or content yet.
279    if ($this->isEmpty($build) || $is_empty) {
279    if ($this->isEmpty($build) || $is_empty) {
279    if ($this->isEmpty($build) || $is_empty) {
279    if ($this->isEmpty($build) || $is_empty) {
279    if ($this->isEmpty($build) || $is_empty) {
280      $build = $this->buildPlaceholderButton($label_info['summary']);
285    elseif (!$this->useAttributesVariable($build) || $this->hasMultipleRoot($build)) {
285    elseif (!$this->useAttributesVariable($build) || $this->hasMultipleRoot($build)) {
285    elseif (!$this->useAttributesVariable($build) || $this->hasMultipleRoot($build)) {
287        '#type' => 'html_tag',
288        '#tag' => 'div',
289        '#attributes' => ['class' => $classes],
290        'content' => $build,
291      ];
292    }
293
294    // This label is used for contextual menu.
295    // @see assets/js/contextual_menu.js
296    // The 'data-node-title' attribute is expected to contain a human-readable
297    // label or summary describing the block instance. This value is usd in the
298    // contextual menu for user actions such as edit, delete. The format should
299    // be a plain string, typically the label or field summary.
300    $build['#attributes']['data-node-title'] = $label_info['summary'] ?? $data['source_id'] ?? $data['node_id'] ?? '';
300    $build['#attributes']['data-node-title'] = $label_info['summary'] ?? $data['source_id'] ?? $data['node_id'] ?? '';
301    $build['#attributes']['data-slot-position'] = $index;
302    $build['#attributes']['data-instance-id'] = $instance_id;
303
304    // Add data-node-type for easier identification of block types in JS or CSS.
305    if (isset($data['source_id'])) {
306      $build['#attributes']['data-node-type'] = $data['source_id'];
307    }
308
309    $build = $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $label_info['summary'] ?? $label_info['label'] ?? '', $index);
309    $build = $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $label_info['summary'] ?? $label_info['label'] ?? '', $index);
310
311    return $build;
312  }
BuilderPanel->buildSingleComponent
168  protected function buildSingleComponent(string $builder_id, string $instance_id, SourceWithSlotsInterface $source, array $data, int $index = 0): ?array {
169    $component_id = $source->getPluginID();
170    $label = $source->label();
171
172    if ($source instanceof SourceWithChoicesInterface) {
173      $component_id = $source->getChoice($data['source']);
174      $label = $this->slotSourceProxy->getLabelWithSummary($data, [], TRUE);
175      $label = $label['label'] ?? $source->label();
176    }
177
178    $instance_id = $instance_id ?: $data['node_id'] ?? NULL;
178    $instance_id = $instance_id ?: $data['node_id'] ?? NULL;
179
180    if (!$instance_id || !$component_id) {
180    if (!$instance_id || !$component_id) {
180    if (!$instance_id || !$component_id) {
182        '@instance_id' => $instance_id ?? 'NULL',
183        '@component_id' => $component_id,
184      ];
185      $this->logger->error('[BuilderPanel::buildSingleComponent] missing component ID: @component_id or instance ID: @instance_id. <pre>' . \print_r($data, TRUE) . '</pre>', $params);
186
187      return NULL;
190    $build = $this->renderSource($data);
191    // Required for the context menu label.
192    // @see assets/js/contextual_menu.js
193    $build['#attributes']['data-node-title'] = $label;
194    $build['#attributes']['data-slot-position'] = $index;
195    $build['#attributes']['data-instance-id'] = $instance_id;
196
197    foreach ($source->getSlotDefinitions() as $slot_id => $definition) {
197    foreach ($source->getSlotDefinitions() as $slot_id => $definition) {
197    foreach ($source->getSlotDefinitions() as $slot_id => $definition) {
197    foreach ($source->getSlotDefinitions() as $slot_id => $definition) {
198      $slot = $this->buildComponentSlot($builder_id, $source, $slot_id, $definition, $instance_id);
199      $build = $source->setSlotRenderable($build, $slot_id, $slot);
200    }
201
202    if ($this->isEmpty($build)) {
204      $message = $component_id . ': ' . $this->t('Empty by default. Configure it to make it visible');
205      $build = $this->buildPlaceholder($message);
206    }
207
208    if (!$this->useAttributesVariable($build)) {
208    if (!$this->useAttributesVariable($build)) {
209      $build = $this->wrapContent($build);
210    }
211
212    return $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $source->label(), $index);
212    return $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $source->label(), $index);
213  }
BuilderPanel->create
60  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
61    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
62    $instance->renderer = $container->get('renderer');
63    $instance->slotSourceProxy = $container->get('display_builder.slot_sources_proxy');
64    $instance->componentElementBuilder = $container->get('ui_patterns.component_element_builder');
65    $instance->sourceManager = $container->get('plugin.manager.ui_patterns_source');
66
67    return $instance;
68  }
BuilderPanel->digFromSlot
436  protected function digFromSlot(string $builder_id, array $data): array {
437    $renderable = [];
438    $slot_definition = ['ui_patterns' => ['type_definition' => $this->sourceManager->getSlotPropType()]];
439
440    foreach ($data as $index => $source) {
440    foreach ($data as $index => $source) {
440    foreach ($data as $index => $source) {
441      if (!isset($source['source_id'])) {
442        continue;
445      try {
446        $source_plugin = $this->sourceManager->createInstance(
451      catch (\Throwable $e) {
452        $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]);
453
454        continue;
457      if ($source_plugin instanceof SourceWithSlotsInterface) {
458        $component = $this->buildSingleComponent($builder_id, '', $source_plugin, $source, $index);
459
460        if ($component) {
461          $renderable[$index] = $component;
462        }
463
464        continue;
464        continue;
467      $block = $this->buildSingleBlock($builder_id, '', $source, $index);
468
469      if ($block) {
440    foreach ($data as $index => $source) {
441      if (!isset($source['source_id'])) {
442        continue;
443      }
444
445      try {
446        $source_plugin = $this->sourceManager->createInstance(
447          $source['source_id'],
448          SourcePluginBase::buildConfiguration('slot', $slot_definition, $source, $this->configuration['contexts'] ?? [])
449        );
450      }
451      catch (\Throwable $e) {
452        $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]);
453
454        continue;
455      }
456
457      if ($source_plugin instanceof SourceWithSlotsInterface) {
458        $component = $this->buildSingleComponent($builder_id, '', $source_plugin, $source, $index);
459
460        if ($component) {
461          $renderable[$index] = $component;
462        }
463
464        continue;
465      }
466
467      $block = $this->buildSingleBlock($builder_id, '', $source, $index);
468
469      if ($block) {
470        $renderable[$index] = $block;
440    foreach ($data as $index => $source) {
440    foreach ($data as $index => $source) {
441      if (!isset($source['source_id'])) {
442        continue;
443      }
444
445      try {
446        $source_plugin = $this->sourceManager->createInstance(
447          $source['source_id'],
448          SourcePluginBase::buildConfiguration('slot', $slot_definition, $source, $this->configuration['contexts'] ?? [])
449        );
450      }
451      catch (\Throwable $e) {
452        $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]);
453
454        continue;
455      }
456
457      if ($source_plugin instanceof SourceWithSlotsInterface) {
458        $component = $this->buildSingleComponent($builder_id, '', $source_plugin, $source, $index);
459
460        if ($component) {
461          $renderable[$index] = $component;
462        }
463
464        continue;
465      }
466
467      $block = $this->buildSingleBlock($builder_id, '', $source, $index);
468
469      if ($block) {
470        $renderable[$index] = $block;
471      }
472    }
473
474    return $renderable;
475  }
BuilderPanel->hasMultipleRoot
486  private function hasMultipleRoot(array $renderable): bool {
487    $html = (string) $this->renderer->renderInIsolation($renderable);
488    $dom = new HTML5(['disable_html_ns' => TRUE, 'encoding' => 'UTF-8']);
489    $dom = $dom->loadHTMLFragment($html);
490
491    return $dom->childElementCount > 1;
492  }
BuilderPanel->isEmpty
503  private function isEmpty(array $renderable): bool {
504    $html = $this->renderer->renderInIsolation($renderable);
505
506    return empty(\trim((string) $html));
507  }
BuilderPanel->keyboardShortcuts
75      'key' => 'b',
76      'help' => t('Show the builder'),
77    ];
78  }
BuilderPanel->onAttachToRoot
108  public function onAttachToRoot(string $builder_id, string $instance_id): array {
109    return $this->reloadWithGlobalData($builder_id);
110  }
BuilderPanel->onAttachToSlot
115  public function onAttachToSlot(string $builder_id, string $instance_id, string $parent_id): array {
116    return $this->replaceInstance($builder_id, $parent_id);
117  }
BuilderPanel->onDelete
143  public function onDelete(string $builder_id, string $parent_id): array {
144    if (empty($parent_id)) {
145      return $this->reloadWithGlobalData($builder_id);
148    return $this->replaceInstance($builder_id, $parent_id);
149  }
BuilderPanel->onHistoryChange
129  public function onHistoryChange(string $builder_id): array {
130    return $this->reloadWithGlobalData($builder_id);
131  }
BuilderPanel->onMove
122  public function onMove(string $builder_id, string $instance_id): array {
123    return $this->reloadWithGlobalData($builder_id);
124  }
BuilderPanel->onUpdate
136  public function onUpdate(string $builder_id, string $instance_id): array {
137    return $this->replaceInstance($builder_id, $instance_id);
138  }
BuilderPanel->renderSource
391  protected function renderSource(array $data, array $classes = []): array {
392    $build = $this->componentElementBuilder->buildSource([], 'content', [], $data, $this->configuration['contexts'] ?? []) ?? [];
393    $build = $build['#slots']['content'][0] ?? [];
394
395    // Fixes for token which is simple markup or html.
396    if (isset($data['source_id']) && $data['source_id'] !== 'token') {
396    if (isset($data['source_id']) && $data['source_id'] !== 'token') {
396    if (isset($data['source_id']) && $data['source_id'] !== 'token') {
397      return $build;
402    if (!isset($build['#type'])) {
402    if (!isset($build['#type'])) {
403      $build = [
404        '#type' => 'html_tag',
413    elseif (isset($build['#attributes'])) {
415        '#type' => 'html_tag',
416        '#tag' => 'div',
417        '#attributes' => ['class' => $classes],
418        'content' => $build,
419      ];
420    }
421
422    return $build;
422    return $build;
423  }
BuilderPanel->replaceInstance
325  protected function replaceInstance(string $builder_id, string $instance_id): array {
326    $parent_selector = '#' . $this->getHtmlId($builder_id) . ' [data-node-id="' . $instance_id . '"]';
327    // @todo pass \Drupal\display_builder\InstanceInterface object in
328    // parameters instead of loading again.
329    /** @var \Drupal\display_builder\InstanceInterface $builder */
330    $builder = $this->entityTypeManager->getStorage('display_builder_instance')->load($builder_id);
331    $data = $builder->getNode($instance_id);
332    $build = [];
333    $slot_definition = ['ui_patterns' => ['type_definition' => $this->sourceManager->getSlotPropType()]];
334
335    try {
336      $source = $this->sourceManager->createInstance(
341    catch (\Throwable $e) {
342      $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]);
343
344      return [];
347    if ($source instanceof SourceWithSlotsInterface) {
347    if ($source instanceof SourceWithSlotsInterface) {
348      $build = $this->buildSingleComponent($builder_id, $instance_id, $source, $data);
351      $build = $this->buildSingleBlock($builder_id, $instance_id, $data);
352    }
353
354    return $this->makeOutOfBand(
354    return $this->makeOutOfBand(
355      $build ?? [],
356      $parent_selector,
357      'outerHTML'
358    );
359  }
BuilderPanel->useAttributesVariable
370  protected function useAttributesVariable(array $renderable): bool {
371    $random = \uniqid();
372    $renderable['#attributes'][$random] = $random;
373    $html = $this->renderer->renderInIsolation($renderable);
374
375    return \str_contains((string) $html, $random);
376  }