Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
41.67% |
70 / 168 |
|
40.74% |
33 / 81 |
|
1.18% |
9 / 760 |
|
22.22% |
4 / 18 |
CRAP | |
0.00% |
0 / 1 |
| BuilderPanel | |
43.48% |
70 / 161 |
|
40.74% |
33 / 81 |
|
1.18% |
9 / 760 |
|
22.22% |
4 / 18 |
2763.38 | |
0.00% |
0 / 1 |
| create | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| keyboardShortcuts | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| build | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| onAttachToRoot | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| onAttachToSlot | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| onMove | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| onHistoryChange | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| onUpdate | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| onDelete | |
0.00% |
0 / 3 |
|
0.00% |
0 / 3 |
|
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| buildSingleComponent | |
0.00% |
0 / 19 |
|
0.00% |
0 / 13 |
|
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
72 | |||
| buildSingleBlock | |
81.08% |
30 / 37 |
|
70.37% |
19 / 27 |
|
0.43% |
3 / 701 |
|
0.00% |
0 / 1 |
268.73 | |||
| replaceInstance | |
0.00% |
0 / 12 |
|
0.00% |
0 / 5 |
|
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
| useAttributesVariable | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| renderSource | |
21.05% |
4 / 19 |
|
37.50% |
3 / 8 |
|
14.29% |
1 / 7 |
|
0.00% |
0 / 1 |
20.74 | |||
| digFromSlot | |
61.54% |
8 / 13 |
|
63.64% |
7 / 11 |
|
14.29% |
1 / 7 |
|
0.00% |
0 / 1 |
28.67 | |||
| hasMultipleRoot | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isEmpty | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| buildComponentSlot | |
0.00% |
0 / 18 |
|
0.00% |
0 / 3 |
|
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Drupal\display_builder\Plugin\display_builder\Island; |
| 6 | |
| 7 | use Drupal\Component\Utility\Html; |
| 8 | use Drupal\Core\Render\RendererInterface; |
| 9 | use Drupal\Core\StringTranslation\TranslatableMarkup; |
| 10 | use Drupal\display_builder\Attribute\Island; |
| 11 | use Drupal\display_builder\InstanceInterface; |
| 12 | use Drupal\display_builder\IslandBuilderInterface; |
| 13 | use Drupal\display_builder\IslandPluginBase; |
| 14 | use Drupal\display_builder\IslandType; |
| 15 | use Drupal\display_builder\SlotSourceProxy; |
| 16 | use Drupal\ui_patterns\Element\ComponentElementBuilder; |
| 17 | use Drupal\ui_styles\Render\Element; |
| 18 | use Masterminds\HTML5; |
| 19 | use 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 | )] |
| 32 | class 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 | } |