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}