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