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}