Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
191 / 191
100.00% covered (success)
100.00%
71 / 71
26.92% covered (danger)
26.92%
35 / 130
100.00% covered (success)
100.00%
16 / 16
CRAP
100.00% covered (success)
100.00%
1 / 1
RenderableBuilderTrait
100.00% covered (success)
100.00%
191 / 191
100.00% covered (success)
100.00%
71 / 71
26.92% covered (danger)
26.92%
35 / 130
100.00% covered (success)
100.00%
16 / 16
764.57
100.00% covered (success)
100.00%
1 / 1
 buildError
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
5 / 5
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 isRenderEmptyOrFailing
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 buildPlaceholder
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
15 / 15
7.81% covered (danger)
7.81%
5 / 64
100.00% covered (success)
100.00%
1 / 1
58.14
 buildPlaceholderButton
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
 buildPlaceholderList
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 buildPlaceholderListWithPreview
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
7 / 7
50.00% covered (danger)
50.00%
3 / 6
100.00% covered (success)
100.00%
1 / 1
6.00
 buildPlaceholderCard
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
5 / 5
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 buildPlaceholderCardWithPreview
100.00% covered (success)
100.00%
3 / 3
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
 buildButton
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
5 / 5
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 buildMenuItem
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 buildMenuDivider
100.00% covered (success)
100.00%
7 / 7
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
 buildDraggables
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 buildTabs
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 buildInput
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
11 / 11
6.25% covered (danger)
6.25%
2 / 32
100.00% covered (success)
100.00%
1 / 1
35.66
 wrapContent
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 applyPreview
100.00% covered (success)
100.00%
11 / 11
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
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder;
6
7use Drupal\Core\Htmx\Htmx;
8use Drupal\Core\Render\RendererInterface;
9use Drupal\Core\StringTranslation\TranslatableMarkup;
10use Drupal\Core\Url;
11
12/**
13 * Trait with helpers to build renderables.
14 */
15trait RenderableBuilderTrait {
16
17  /**
18   * Build error message.
19   *
20   * @param string $builder_id
21   *   The builder id.
22   * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $message
23   *   The message to display.
24   * @param bool $global
25   *   (Optional) Try to use the default builder toast stack.
26   * @param int|null $duration
27   *   (Optional) Alert duration before closing.
28   *
29   * @return array
30   *   The input render array.
31   */
32  public function buildError(string $builder_id, string|TranslatableMarkup $message, bool $global = FALSE, ?int $duration = NULL): array {
33    $build = [
34      '#type' => 'component',
35      '#component' => 'display_builder:alert',
36      '#slots' => [
37        'content' => $message,
38      ],
39      '#props' => [
40        'variant' => 'danger',
41        'icon' => 'exclamation-octagon',
42        'open' => TRUE,
43        'closable' => TRUE,
44      ],
45      '#attributes' => [
46        'class' => 'db-message',
47      ],
48    ];
49
50    if ($duration) {
51      $build['#props']['duration'] = $duration;
52    }
53
54    if ($global) {
55      // Append into the builder's toast stack rather than replacing whatever
56      // is already on screen: a "beforeend" out-of-band swap keeps previous
57      // messages visible, so a burst of errors stacks instead of each one
58      // silently overwriting the last. htmx swaps the *content* of the
59      // out-of-band element for any non-inline swap style, hence the wrapper.
60      $build = [
61        '#type' => 'container',
62        'message' => $build,
63      ];
64      (new Htmx())
65        ->swapOob(\sprintf('beforeend:#message-%s', $builder_id))
66        ->applyTo($build);
67    }
68
69    return $build;
70  }
71
72  /**
73   * Checks whether a renderable array is empty, or throws once rendered.
74   *
75   * Some render arrays (e.g. a comment field formatter's "Add comment" form
76   * #lazy_builder placeholder, built against an unsaved sample entity with
77   * no real ID - @see \Drupal\display_builder_entity_view\Plugin\display_builder\Buildable\EntityView)
78   * throw rather than produce empty markup when actually rendered. Checking
79   * with renderInIsolation() here, synchronously, keeps a bad lazy_builder
80   * from surviving unresolved into the *caller's own* render array, where -
81   * left unrendered - it would only fail later during Drupal core's own
82   * BigPipe processing, well outside of this try/catch's reach, breaking
83   * page rendering entirely instead of degrading gracefully to a
84   * placeholder.
85   *
86   * @param \Drupal\Core\Render\RendererInterface $renderer
87   *   The renderer.
88   * @param array $renderable
89   *   The renderable array to check.
90   *
91   * @return bool
92   *   TRUE if the renderable is empty or fails to render, FALSE otherwise.
93   */
94  protected function isRenderEmptyOrFailing(RendererInterface $renderer, array $renderable): bool {
95    try {
96      $html = $renderer->renderInIsolation($renderable);
97    }
98    catch (\Throwable $e) {
99      return TRUE;
100    }
101
102    return empty(\trim((string) $html));
103  }
104
105  /**
106   * Build placeholder component wrapper.
107   *
108   * @param string $label
109   *   The placeholder label.
110   * @param string $title
111   *   (Optional) Title attribute value.
112   * @param array $vals
113   *   (Optional) HTMX vals data when placeholder trigger something when moving.
114   * @param string|null $keywords
115   *   (Optional) Data attributes keywords for search.
116   *
117   * @return array
118   *   A renderable array.
119   */
120  protected function buildPlaceholder(string|TranslatableMarkup $label, string $title = '', array $vals = [], ?string $keywords = NULL): array {
121    $build = [
122      '#type' => 'component',
123      '#component' => 'display_builder:placeholder',
124      '#slots' => [
125        'content' => $label,
126      ],
127    ];
128
129    if (isset($vals['source_id'])) {
130      $build['#attributes']['class'][] = \sprintf('db-placeholder-%s', $vals['source_id']);
131    }
132
133    $testid_source = $vals['source']['component']['component_id']
134      ?? $vals['source']['plugin_id']
135      ?? $vals['source']['derivable_context']
136      ?? NULL;
137
138    if ($testid_source !== NULL) {
139      $build['#attributes']['data-testid'] = \sprintf('placeholder-%s', \str_replace(':', '-', $testid_source));
140    }
141    elseif (isset($vals['source_id'])) {
142      $build['#attributes']['data-testid'] = \sprintf('placeholder-%s', $vals['source_id']);
143    }
144    elseif (\is_string(\reset($vals))) {
145      $build['#attributes']['data-testid'] = \sprintf('placeholder-%s', \reset($vals));
146    }
147
148    if ($keywords) {
149      $build['#attributes']['data-keywords'] = \trim(\strtolower($keywords));
150    }
151
152    if (!empty($title)) {
153      $build['#attributes']['title'] = $title;
154    }
155
156    if (!empty($vals)) {
157      (new Htmx())->vals($vals)->applyTo($build);
158    }
159
160    return $build;
161  }
162
163  /**
164   * Build placeholder as Button.
165   *
166   * @param string $label
167   *   The placeholder label.
168   * @param array $vals
169   *   (Optional) HTMX vals data when placeholder trigger something when moving.
170   *
171   * @return array
172   *   A renderable array.
173   */
174  protected function buildPlaceholderButton(string|TranslatableMarkup $label, array $vals = []): array {
175    $build = $this->buildPlaceholder($label, '', $vals);
176    $build['#props']['variant'] = 'button';
177    // To be able to identify the node when dragging and set the drawer title.
178    $build['#attributes']['data-node-title'] = (string) $label;
179
180    return $build;
181  }
182
183  /**
184   * Build placeholder as List.
185   *
186   * @param string $label
187   *   The placeholder label.
188   * @param array $vals
189   *   (Optional) HTMX vals data when placeholder trigger something when moving.
190   * @param string|null $keywords
191   *   (Optional) Keywords attributes to add used by search.
192   *
193   * @return array
194   *   A renderable array.
195   */
196  protected function buildPlaceholderList(string|TranslatableMarkup $label, array $vals = [], ?string $keywords = NULL): array {
197    $build = $this->buildPlaceholder($label, '', $vals);
198    $build['#props']['variant'] = 'list';
199    // To be able to identify the node when dragging and set the drawer title.
200    $build['#attributes']['data-node-title'] = (string) $label;
201
202    if ($keywords) {
203      $build['#attributes']['data-keywords'] = \trim(\strtolower($keywords));
204    }
205
206    return $build;
207  }
208
209  /**
210   * Build placeholder.
211   *
212   * @param string $builder_id
213   *   The builder id.
214   * @param string $label
215   *   The placeholder label.
216   * @param array $vals
217   *   HTMX vals data if the placeholder is triggering something when moving.
218   * @param \Drupal\Core\Url $preview_url
219   *   The preview_url prop value.
220   * @param string|null $keywords
221   *   (Optional) Keywords attributes to add used by search.
222   *
223   * @return array
224   *   A renderable array.
225   */
226  protected function buildPlaceholderListWithPreview(string $builder_id, string|TranslatableMarkup $label, array $vals, Url $preview_url, ?string $keywords = NULL): array {
227    $build = $this->buildPlaceholderList($label, $vals, $keywords);
228
229    // Do not include entity field previews as we don't have generated value.
230    if (isset($vals['source_id']) && ($vals['source_id'] === 'entity_field' || $vals['source_id'] === 'entity_reference')) {
231      return $build;
232    }
233
234    $this->applyPreview($build, $builder_id, $preview_url);
235
236    return $build;
237  }
238
239  /**
240   * Build placeholder.
241   *
242   * @param string $label
243   *   The placeholder label.
244   * @param array $vals
245   *   HTMX vals data if the placeholder is triggering something when moving.
246   * @param string|null $keywords
247   *   (Optional) Keywords attributes to add used by search.
248   * @param string|null $thumbnail
249   *   (Optional) The thumbnail URL.
250   *
251   * @return array
252   *   A renderable array.
253   */
254  protected function buildPlaceholderCard(string|TranslatableMarkup $label, array $vals, ?string $keywords = NULL, ?string $thumbnail = NULL): array {
255    $build = $this->buildPlaceholder($label, '', $vals);
256
257    if ($thumbnail) {
258      $build['#slots']['image'] = [
259        '#type' => 'html_tag',
260        '#tag' => 'img',
261        '#attributes' => [
262          // @todo generate proper relative url.
263          'src' => '/' . $thumbnail,
264        ],
265      ];
266    }
267
268    if ($keywords) {
269      $build['#attributes']['data-keywords'] = \trim(\strtolower($keywords));
270    }
271
272    return $build;
273  }
274
275  /**
276   * Build placeholder.
277   *
278   * @param string $builder_id
279   *   The builder id.
280   * @param string $label
281   *   The placeholder label.
282   * @param array $vals
283   *   HTMX vals data if the placeholder is triggering something when moving.
284   * @param \Drupal\Core\Url $preview_url
285   *   The preview_url prop value.
286   * @param string|null $keywords
287   *   (Optional) Keywords attributes to add used by search.
288   * @param string|null $thumbnail
289   *   (Optional) The thumbnail URL.
290   *
291   * @return array
292   *   A renderable array.
293   */
294  protected function buildPlaceholderCardWithPreview(string $builder_id, string|TranslatableMarkup $label, array $vals, Url $preview_url, ?string $keywords = NULL, ?string $thumbnail = NULL): array {
295    $build = $this->buildPlaceholderCard($label, $vals, $keywords, $thumbnail);
296    $this->applyPreview($build, $builder_id, $preview_url);
297
298    return $build;
299  }
300
301  /**
302   * Build a button.
303   *
304   * Uniq id is required for keyboard mapping with ajax requests.
305   *
306   * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $label
307   *   The button label.
308   * @param string $action
309   *   (Optional) The action value attribute. Used mainly for e2e tests.
310   * @param string|null $icon
311   *   (Optional) The icon name. Default none.
312   * @param string|TranslatableMarkup|null $tooltip
313   *   (Optional) Enable the tooltip feature. Default no tooltip.
314   * @param array|null $keyboard
315   *   (Optional) Keyboard shortcut as associative array key => description.
316   *
317   * @return array
318   *   The button render array.
319   */
320  protected function buildButton(
321    string|TranslatableMarkup $label,
322    ?string $action,
323    ?string $icon = NULL,
324    string|TranslatableMarkup|null $tooltip = NULL,
325    ?array $keyboard = NULL,
326  ): array {
327    $button = [
328      '#type' => 'component',
329      '#component' => 'display_builder:button',
330      '#props' => [
331        'label' => $label,
332        'icon' => $icon,
333        'tooltip' => $tooltip,
334      ],
335    ];
336
337    if ($keyboard) {
338      $button['#attributes']['data-keyboard-key'] = \key($keyboard);
339      $button['#attributes']['aria-keyshortcuts'] = $button['#attributes']['data-keyboard-key'];
340      $button['#attributes']['data-keyboard-help'] = \reset($keyboard) ?? '';
341    }
342
343    // Used to ease e2e tests.
344    if ($action) {
345      $button['#attributes']['data-island-action'] = $action;
346      $button['#attributes']['data-testid'] = $action;
347    }
348
349    return $button;
350  }
351
352  /**
353   * Build a menu item.
354   *
355   * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $title
356   *   The menu title.
357   * @param string $value
358   *   The menu value.
359   * @param string|null $icon
360   *   (Optional) The icon name. Default none.
361   * @param string $icon_position
362   *   (Optional) The icon position. Default 'prefix'.
363   * @param bool $disabled
364   *   (Optional) Is the menu disabled? Default no.
365   * @param array $submenu
366   *   (Optional) Nested menu item render arrays, e.g. built with this same
367   *   method, to display as a submenu. Default none.
368   *
369   * @return array
370   *   The menu item render array.
371   */
372  protected function buildMenuItem(
373    string|TranslatableMarkup $title,
374    string $value,
375    ?string $icon = NULL,
376    string $icon_position = 'prefix',
377    bool $disabled = FALSE,
378    array $submenu = [],
379  ): array {
380    $build = [
381      '#type' => 'component',
382      '#component' => 'display_builder:menu_item',
383      '#props' => [
384        'title' => $title,
385        'value' => $value,
386        'icon' => $icon,
387        'icon_position' => $icon_position,
388        'disabled' => $disabled,
389      ],
390      '#attributes' => [
391        // Attribute data-contextual-menu is important for the js mapping.
392        // @see components/contextual_menu/contextual_menu.js
393        'data-contextual-menu' => TRUE,
394      ],
395    ];
396
397    if ($submenu) {
398      $build['#slots']['submenu'] = $submenu;
399    }
400
401    return $build;
402  }
403
404  /**
405   * Build a menu item divider.
406   *
407   * @return array
408   *   The menu item render array.
409   */
410  protected function buildMenuDivider(): array {
411    return [
412      '#type' => 'component',
413      '#component' => 'display_builder:menu_item',
414      '#props' => [
415        'variant' => 'divider',
416      ],
417    ];
418  }
419
420  /**
421   * Build draggables placeholders.
422   *
423   * Used in library islands.
424   *
425   * @param string $builder_id
426   *   Builder ID.
427   * @param array $draggables
428   *   Draggable placeholders.
429   * @param string $variant
430   *   (Optional) The variant.
431   *
432   * @return array
433   *   The draggables render array.
434   */
435  protected function buildDraggables(string $builder_id, array $draggables, string $variant = ''): array {
436    $build = [
437      '#type' => 'component',
438      '#component' => 'display_builder:draggables',
439      '#slots' => [
440        'content' => $draggables,
441      ],
442      '#attributes' => [
443        // Required for JavaScript @see components/draggables/draggables.js.
444        'data-db-id' => $builder_id,
445      ],
446    ];
447
448    if ($variant) {
449      $build['#props']['variant'] = $variant;
450    }
451
452    return $build;
453  }
454
455  /**
456   * Build tabs.
457   *
458   * @param string $id
459   *   The ID. Used for saving active tab in local storage.
460   * @param array $tabs
461   *   Tabs as links.
462   * @param bool $contextual
463   *   (Optional) Is the tabs contextual? Default no.
464   *
465   * @return array
466   *   The tabs render array.
467   */
468  protected function buildTabs(string $id, array $tabs, bool $contextual = FALSE): array {
469    $build = [
470      '#type' => 'component',
471      '#component' => 'display_builder:tabs',
472      '#props' => [
473        'tabs' => $tabs,
474        'contextual' => $contextual,
475      ],
476    ];
477
478    if ($id) {
479      $build['#props']['id'] = $id;
480    }
481
482    return $build;
483  }
484
485  /**
486   * Build input.
487   *
488   * @param string $id
489   *   The ID. Used for saving active tab in local storage.
490   * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $label
491   *   The label.
492   * @param string $type
493   *   The input type.
494   * @param string $size
495   *   (Optional) The input size. Default medium.
496   * @param string|null $autocomplete
497   *   (Optional) The input autocomplete.
498   * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $placeholder
499   *   (Optional) The input placeholder.
500   * @param bool|null $clearable
501   *   (Optional) The input clearable.
502   * @param string|null $icon
503   *   (Optional) The input icon.
504   *
505   * @return array
506   *   The input render array.
507   */
508  protected function buildInput(string $id, string|TranslatableMarkup $label, string $type, string $size = 'medium', ?string $autocomplete = NULL, string|TranslatableMarkup $placeholder = '', ?bool $clearable = NULL, ?string $icon = NULL): array {
509    $build = [
510      '#type' => 'component',
511      '#component' => 'display_builder:input',
512      '#props' => [
513        'label' => $label,
514        'variant' => $type,
515        'size' => $size,
516      ],
517    ];
518
519    if ($id) {
520      $build['#props']['id'] = $id;
521    }
522
523    if ($autocomplete) {
524      $build['#props']['autocomplete'] = $autocomplete;
525    }
526
527    if ($placeholder) {
528      $build['#props']['placeholder'] = $placeholder;
529    }
530
531    if ($clearable) {
532      $build['#props']['clearable'] = TRUE;
533    }
534
535    if ($icon) {
536      $build['#props']['icon'] = $icon;
537    }
538
539    return $build;
540  }
541
542  /**
543   * Wraps a renderable in a div.
544   *
545   * Commonly used with tabs.
546   *
547   * @param array $content
548   *   The renderable content.
549   * @param string $id
550   *   (Optional) The div id.
551   *
552   * @return array
553   *   The wrapped render array.
554   */
555  protected function wrapContent(array $content, string $id = ''): array {
556    $build = [
557      '#type' => 'html_tag',
558      '#tag' => 'div',
559      'content' => $content,
560    ];
561
562    if (!empty($id)) {
563      $build['#attributes']['id'] = $id;
564    }
565
566    return $build;
567  }
568
569  /**
570   * Make a placeholder show a preview popup on hover.
571   *
572   * Only the request is declared here. Showing, positioning and hiding the
573   * popup belongs to js/preview.js, which can act once the response is in
574   * the DOM - htmx alone can only show an empty box the moment the pointer
575   * arrives, which Floating UI then measures at the wrong size.
576   *
577   * @param array $build
578   *   The placeholder renderable, altered by reference.
579   * @param string $builder_id
580   *   The builder id, owning the popup element.
581   * @param \Drupal\Core\Url $preview_url
582   *   The URL returning the preview markup.
583   */
584  private function applyPreview(array &$build, string $builder_id, Url $preview_url): void {
585    // Marks the element for js/preview.js, whatever the placeholder variant.
586    // The URL itself lives in hx-get, JS only needs to recognize a trigger.
587    $build['#attributes']['data-preview'] = TRUE;
588
589    (new Htmx())
590      ->get($preview_url)
591      ->target(\sprintf('#preview-%s', $builder_id))
592      // Hover intent. js/preview.js cancels the request if the pointer left
593      // in the meantime, as htmx debounces but never cancels on its own.
594      // Placeholders are focusable, so keyboard users reach the preview the
595      // same way - focusin, because focus does not bubble to the delegated
596      // listener that arbitrates which trigger is the current one.
597      ->trigger('mouseenter delay:250ms, focusin delay:250ms')
598      ->applyTo($build);
599  }
600
601}