Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
42.57% |
86 / 202 |
|
47.37% |
45 / 95 |
|
0.26% |
11 / 4153 |
|
31.25% |
5 / 16 |
CRAP | |
0.00% |
0 / 1 |
| BuilderPanel | |
44.33% |
86 / 194 |
|
47.37% |
45 / 95 |
|
0.26% |
11 / 4153 |
|
31.25% |
5 / 16 |
2946.89 | |
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 / 4 |
|
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 | |||
| onAttachToSlot | |
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 / 17 |
|
0.00% |
0 / 10 |
|
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
30 | |||
| resolveComponentInfo | |
0.00% |
0 / 18 |
|
0.00% |
0 / 7 |
|
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
30 | |||
| buildSingleBlock | |
92.50% |
37 / 40 |
|
81.82% |
27 / 33 |
|
0.10% |
4 / 4096 |
|
0.00% |
0 / 1 |
305.15 | |||
| replaceNode | |
0.00% |
0 / 20 |
|
0.00% |
0 / 7 |
|
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
| useAttributesVariable | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| renderSource | |
21.05% |
4 / 19 |
|
44.44% |
4 / 9 |
|
12.50% |
1 / 8 |
|
0.00% |
0 / 1 |
21.75 | |||
| digFromSlot | |
61.90% |
13 / 21 |
|
60.00% |
9 / 15 |
|
7.69% |
1 / 13 |
|
0.00% |
0 / 1 |
45.54 | |||
| 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\Island\IslandPluginBase; |
| 13 | use Drupal\display_builder\Island\IslandReloadEventsTrait; |
| 14 | use Drupal\display_builder\Island\IslandType; |
| 15 | use Drupal\display_builder\SlotSourceProxy; |
| 16 | use Drupal\display_builder\SourceWithSlotsInterface; |
| 17 | use Drupal\ui_patterns\Element\ComponentElementBuilder; |
| 18 | use Drupal\ui_patterns\SourcePluginBase; |
| 19 | use Drupal\ui_patterns\SourceWithChoicesInterface; |
| 20 | use Masterminds\HTML5; |
| 21 | use 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 | )] |
| 35 | class BuilderPanel extends IslandPluginBase { |
| 36 | |
| 37 | use IslandReloadEventsTrait; |
| 38 | |
| 39 | /** |
| 40 | * The renderer service. |
| 41 | */ |
| 42 | protected RendererInterface $renderer; |
| 43 | |
| 44 | /** |
| 45 | * Proxy for slot source operations. |
| 46 | */ |
| 47 | protected SlotSourceProxy $slotSourceProxy; |
| 48 | |
| 49 | /** |
| 50 | * The component element builder. |
| 51 | */ |
| 52 | protected ComponentElementBuilder $componentElementBuilder; |
| 53 | |
| 54 | /** |
| 55 | * {@inheritdoc} |
| 56 | */ |
| 57 | public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { |
| 58 | $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition); |
| 59 | $instance->renderer = $container->get('renderer'); |
| 60 | $instance->slotSourceProxy = $container->get('display_builder.slot_sources_proxy'); |
| 61 | $instance->componentElementBuilder = $container->get('ui_patterns.component_element_builder'); |
| 62 | |
| 63 | return $instance; |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * {@inheritdoc} |
| 68 | */ |
| 69 | public static function keyboardShortcuts(): array { |
| 70 | return [ |
| 71 | 'key' => 'b', |
| 72 | 'help' => t('Show the builder'), |
| 73 | ]; |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * {@inheritdoc} |
| 78 | */ |
| 79 | public function build(InstanceInterface $builder, array $data = [], array $options = []): array { |
| 80 | $builder_id = (string) $builder->id(); |
| 81 | $build = [ |
| 82 | '#type' => 'component', |
| 83 | '#component' => 'display_builder:dropzone', |
| 84 | '#props' => [ |
| 85 | 'variant' => 'root', |
| 86 | ], |
| 87 | '#slots' => [ |
| 88 | 'content' => $this->digFromSlot($builder_id, $data), |
| 89 | ], |
| 90 | '#attributes' => [ |
| 91 | // Required for JavaScript @see components/dropzone/dropzone.js. |
| 92 | 'data-db-id' => $builder_id, |
| 93 | 'data-node-title' => $this->t('Base container'), |
| 94 | 'data-db-root' => TRUE, |
| 95 | ], |
| 96 | ]; |
| 97 | |
| 98 | return $this->htmxEvents->onRootDrop($build, $builder_id, $this->getPluginID()); |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * {@inheritdoc} |
| 103 | */ |
| 104 | public function onAttachToSlot(InstanceInterface $instance, string $node_id, string $parent_id): array { |
| 105 | return $this->replaceNode($instance, $parent_id); |
| 106 | } |
| 107 | |
| 108 | /** |
| 109 | * {@inheritdoc} |
| 110 | */ |
| 111 | public function onUpdate(InstanceInterface $instance, string $node_id): array { |
| 112 | return $this->replaceNode($instance, $node_id); |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * {@inheritdoc} |
| 117 | */ |
| 118 | public function onDelete(InstanceInterface $instance, ?string $parent_id): array { |
| 119 | if (empty($parent_id)) { |
| 120 | return $this->reloadWithGlobalData($instance); |
| 121 | } |
| 122 | |
| 123 | return $this->replaceNode($instance, $parent_id); |
| 124 | } |
| 125 | |
| 126 | /** |
| 127 | * Build renderable from state data. |
| 128 | * |
| 129 | * @param string $builder_id |
| 130 | * Display Builder ID. |
| 131 | * @param string $instance_id |
| 132 | * The instance ID. |
| 133 | * @param \Drupal\display_builder\SourceWithSlotsInterface $source |
| 134 | * The source plugin. |
| 135 | * @param array $data |
| 136 | * The UI Patterns form state data. |
| 137 | * @param int $index |
| 138 | * (Optional) The index of the block. Default to 0. |
| 139 | * |
| 140 | * @return array|null |
| 141 | * A renderable array. |
| 142 | */ |
| 143 | protected function buildSingleComponent(string $builder_id, string $instance_id, SourceWithSlotsInterface $source, array $data, int $index = 0): ?array { |
| 144 | $info = $this->resolveComponentInfo($source, $data, $instance_id); |
| 145 | |
| 146 | if ($info === NULL) { |
| 147 | return NULL; |
| 148 | } |
| 149 | |
| 150 | ['component_id' => $component_id, 'label' => $label, 'instance_id' => $instance_id] = $info; |
| 151 | |
| 152 | $build = $this->renderSource($data); |
| 153 | // Required for the context menu label. |
| 154 | // @see assets/js/contextual_menu.js |
| 155 | $build['#attributes']['data-node-title'] = $label; |
| 156 | $build['#attributes']['data-slot-position'] = $index; |
| 157 | $build['#attributes']['data-instance-id'] = $instance_id; |
| 158 | |
| 159 | foreach ($source->getSlotDefinitions() as $slot_id => $definition) { |
| 160 | $slot = $this->buildComponentSlot($builder_id, $source, $slot_id, $definition, $instance_id); |
| 161 | $build = $source->setSlotRenderable($build, $slot_id, $slot); |
| 162 | } |
| 163 | |
| 164 | if ($this->isEmpty($build)) { |
| 165 | // Keep the placeholder if the component is not renderable. |
| 166 | $message = $component_id . ': ' . $this->t('Empty by default. Configure it to make it visible'); |
| 167 | $build = $this->buildPlaceholder($message); |
| 168 | } |
| 169 | |
| 170 | if (!$this->useAttributesVariable($build)) { |
| 171 | $build = $this->wrapContent($build); |
| 172 | } |
| 173 | |
| 174 | return $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $source->label(), $index); |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * Resolves component ID, label, and instance ID from source and data. |
| 179 | * |
| 180 | * Extracts the shared preamble logic used by all buildSingleComponent() |
| 181 | * implementations across BuilderPanel, LayersPanel, and TreePanel. |
| 182 | * |
| 183 | * @param \Drupal\display_builder\SourceWithSlotsInterface $source |
| 184 | * The source plugin. |
| 185 | * @param array $data |
| 186 | * The UI Patterns form state data. |
| 187 | * @param string $instance_id |
| 188 | * The instance ID. May be empty; resolved from data['node_id'] as fallback. |
| 189 | * |
| 190 | * @return array{component_id: string, label: string, instance_id: string}|null |
| 191 | * Associative array with 'component_id', 'label', 'instance_id', or NULL |
| 192 | * if either component_id or instance_id could not be resolved. |
| 193 | */ |
| 194 | protected function resolveComponentInfo(SourceWithSlotsInterface $source, array $data, string $instance_id): ?array { |
| 195 | $component_id = $source->getPluginID(); |
| 196 | $label = $source->label(); |
| 197 | |
| 198 | if ($source instanceof SourceWithChoicesInterface) { |
| 199 | $component_id = $source->getChoice($data['source']); |
| 200 | $result = $this->slotSourceProxy->getLabelWithSummary($data, [], TRUE); |
| 201 | $label = $result['label'] ?? $source->label(); |
| 202 | } |
| 203 | |
| 204 | $instance_id = $instance_id ?: $data['node_id'] ?? NULL; |
| 205 | |
| 206 | if (!$instance_id || !$component_id) { |
| 207 | $this->logger->error( |
| 208 | '[' . static::class . '::buildSingleComponent] missing component ID: @component_id or instance ID: @instance_id. <pre>' . \print_r($data, TRUE) . '</pre>', |
| 209 | ['@instance_id' => $instance_id ?? 'NULL', '@component_id' => $component_id], |
| 210 | ); |
| 211 | |
| 212 | return NULL; |
| 213 | } |
| 214 | |
| 215 | return [ |
| 216 | 'component_id' => $component_id, |
| 217 | 'label' => $label, |
| 218 | 'instance_id' => $instance_id, |
| 219 | ]; |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * Build renderable from state data. |
| 224 | * |
| 225 | * @param string $builder_id |
| 226 | * Display Builder ID. |
| 227 | * @param string $instance_id |
| 228 | * The instance ID. |
| 229 | * @param array $data |
| 230 | * The UI Patterns form state data. |
| 231 | * @param int $index |
| 232 | * (Optional) The index of the block. Default to 0. |
| 233 | * |
| 234 | * @return array|null |
| 235 | * A renderable array. |
| 236 | */ |
| 237 | protected function buildSingleBlock(string $builder_id, string $instance_id, array $data, int $index = 0): ?array { |
| 238 | $instance_id = $instance_id ?: $data['node_id'] ?? NULL; |
| 239 | |
| 240 | if (!$instance_id) { |
| 241 | return NULL; |
| 242 | } |
| 243 | |
| 244 | $classes = ['db-block']; |
| 245 | |
| 246 | if (isset($data['source']['plugin_id'])) { |
| 247 | $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source']['plugin_id'])); |
| 248 | } |
| 249 | else { |
| 250 | $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source_id'])); |
| 251 | } |
| 252 | $build = $this->renderSource($data, $classes); |
| 253 | $is_empty = FALSE; |
| 254 | |
| 255 | if (isset($data['source_id']) && $data['source_id'] === 'token') { |
| 256 | if (isset($build['content']) && empty($build['content'])) { |
| 257 | $is_empty = TRUE; |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') { |
| 262 | // system_messages_block is never empty, but often invisible. |
| 263 | // See: core/modules/system/src/Plugin/Block/SystemMessagesBlock.php |
| 264 | // See: core/lib/Drupal/Core/Render/Element/StatusMessages.php |
| 265 | // Let's always display it in a placeholder. |
| 266 | $is_empty = TRUE; |
| 267 | } |
| 268 | |
| 269 | $label_info = $this->slotSourceProxy->getLabelWithSummary($data, $this->configuration['contexts'] ?? []); |
| 270 | |
| 271 | if (isset($data['source_id'])) { |
| 272 | switch ($data['source_id']) { |
| 273 | case 'entity_field': |
| 274 | $label_info['summary'] = (string) $this->t('Field: @label', ['@label' => $label_info['label']]); |
| 275 | |
| 276 | break; |
| 277 | |
| 278 | case 'block': |
| 279 | $label_info['summary'] = (string) $this->t('Block: @label', ['@label' => $label_info['summary']]); |
| 280 | |
| 281 | break; |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | // This is the placeholder without configuration or content yet. |
| 286 | if ($this->isEmpty($build) || $is_empty) { |
| 287 | $build = $this->buildPlaceholderButton($label_info['summary']); |
| 288 | // Highlight in the view to show it's a temporary block waiting for |
| 289 | // configuration. |
| 290 | $build['#attributes']['class'][] = 'db-background'; |
| 291 | } |
| 292 | elseif (!$this->useAttributesVariable($build) || $this->hasMultipleRoot($build)) { |
| 293 | $build = [ |
| 294 | '#type' => 'html_tag', |
| 295 | '#tag' => 'div', |
| 296 | '#attributes' => ['class' => $classes], |
| 297 | 'content' => $build, |
| 298 | ]; |
| 299 | } |
| 300 | |
| 301 | // This label is used for contextual menu. |
| 302 | // @see assets/js/contextual_menu.js |
| 303 | // The 'data-node-title' attribute is expected to contain a human-readable |
| 304 | // label or summary describing the block instance. This value is usd in the |
| 305 | // contextual menu for user actions such as edit, delete. The format should |
| 306 | // be a plain string, typically the label or field summary. |
| 307 | $build['#attributes']['data-node-title'] = $label_info['summary'] ?? $data['source_id'] ?? $data['node_id'] ?? ''; |
| 308 | $build['#attributes']['data-slot-position'] = $index; |
| 309 | $build['#attributes']['data-instance-id'] = $instance_id; |
| 310 | |
| 311 | // Add data-node-type for easier identification of block types in JS or CSS. |
| 312 | if (isset($data['source_id'])) { |
| 313 | $build['#attributes']['data-node-type'] = $data['source_id']; |
| 314 | } |
| 315 | |
| 316 | $build = $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $label_info['summary'] ?? $label_info['label'] ?? '', $index); |
| 317 | |
| 318 | return $build; |
| 319 | } |
| 320 | |
| 321 | /** |
| 322 | * Helper method to replace a specific instance in the DOM. |
| 323 | * |
| 324 | * @param \Drupal\display_builder\InstanceInterface $instance |
| 325 | * The builder instance. |
| 326 | * @param string $node_id |
| 327 | * The node ID from the source tree. |
| 328 | * |
| 329 | * @return array |
| 330 | * Returns a render array with out-of-band commands. |
| 331 | */ |
| 332 | protected function replaceNode(InstanceInterface $instance, string $node_id): array { |
| 333 | $builder_id = (string) $instance->id(); |
| 334 | $parent_selector = '#' . $this->getHtmlId($builder_id) . ' [data-node-id="' . $node_id . '"]'; |
| 335 | $data = $instance->getNode($node_id); |
| 336 | $build = []; |
| 337 | $slot_definition = ['ui_patterns' => ['type_definition' => $this->sourceManager->getSlotPropType()]]; |
| 338 | |
| 339 | try { |
| 340 | $source = $this->sourceManager->createInstance( |
| 341 | $data['source_id'], |
| 342 | SourcePluginBase::buildConfiguration('slot', $slot_definition, $data, $this->configuration['contexts'] ?? []) |
| 343 | ); |
| 344 | } |
| 345 | catch (\Throwable $e) { |
| 346 | $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]); |
| 347 | |
| 348 | return []; |
| 349 | } |
| 350 | |
| 351 | if ($source instanceof SourceWithSlotsInterface) { |
| 352 | $build = $this->buildSingleComponent($builder_id, $node_id, $source, $data); |
| 353 | } |
| 354 | else { |
| 355 | $build = $this->buildSingleBlock($builder_id, $node_id, $data); |
| 356 | } |
| 357 | |
| 358 | return $this->makeOutOfBand( |
| 359 | $build ?? [], |
| 360 | $parent_selector, |
| 361 | 'outerHTML' |
| 362 | ); |
| 363 | } |
| 364 | |
| 365 | /** |
| 366 | * Does the component use the attributes variable in template? |
| 367 | * |
| 368 | * @param array $renderable |
| 369 | * Component renderable. |
| 370 | * |
| 371 | * @return bool |
| 372 | * Use it or not. |
| 373 | */ |
| 374 | protected function useAttributesVariable(array $renderable): bool { |
| 375 | $random = \uniqid(); |
| 376 | $renderable['#attributes'][$random] = $random; |
| 377 | $html = $this->renderer->renderInIsolation($renderable); |
| 378 | |
| 379 | return \str_contains((string) $html, $random); |
| 380 | } |
| 381 | |
| 382 | /** |
| 383 | * Get renderable array for a slot source. |
| 384 | * |
| 385 | * @param array $data |
| 386 | * The slot source data array containing: |
| 387 | * - source_id: The source ID |
| 388 | * - source: Array of source configuration. |
| 389 | * @param array $classes |
| 390 | * (Optional) Classes to use to wrap the rendered source if needed. |
| 391 | * |
| 392 | * @return array |
| 393 | * The renderable array for this slot source. |
| 394 | */ |
| 395 | protected function renderSource(array $data, array $classes = []): array { |
| 396 | $build = $this->componentElementBuilder->buildSource([], 'content', [], $data, $this->configuration['contexts'] ?? []) ?? []; |
| 397 | $build = $build['#slots']['content'][0] ?? []; |
| 398 | |
| 399 | // Fixes for token which is simple markup or html. |
| 400 | if (isset($data['source_id']) && $data['source_id'] !== 'token') { |
| 401 | return $build; |
| 402 | } |
| 403 | |
| 404 | // If token is only markup, we don't have a wrapper, add it like styles |
| 405 | // so the placeholder can be styled. |
| 406 | if (!isset($build['#type'])) { |
| 407 | $build = [ |
| 408 | '#type' => 'html_tag', |
| 409 | '#tag' => 'div', |
| 410 | '#attributes' => ['class' => $classes], |
| 411 | 'content' => $build, |
| 412 | ]; |
| 413 | } |
| 414 | |
| 415 | // If a style is applied, we have a wrapper from styles with classes, to |
| 416 | // avoid our placeholder classes to be replaced we need to wrap it. |
| 417 | elseif (isset($build['#attributes'])) { |
| 418 | $build = [ |
| 419 | '#type' => 'html_tag', |
| 420 | '#tag' => 'div', |
| 421 | '#attributes' => ['class' => $classes], |
| 422 | 'content' => $build, |
| 423 | ]; |
| 424 | } |
| 425 | |
| 426 | return $build; |
| 427 | } |
| 428 | |
| 429 | /** |
| 430 | * Build builder renderable, recursively. |
| 431 | * |
| 432 | * @param string $builder_id |
| 433 | * Builder ID. |
| 434 | * @param array $data |
| 435 | * The current 'slice' of data. |
| 436 | * |
| 437 | * @return array |
| 438 | * A renderable array. |
| 439 | */ |
| 440 | protected function digFromSlot(string $builder_id, array $data): array { |
| 441 | $renderable = []; |
| 442 | $slot_definition = ['ui_patterns' => ['type_definition' => $this->sourceManager->getSlotPropType()]]; |
| 443 | |
| 444 | foreach ($data as $index => $source) { |
| 445 | if (!isset($source['source_id'])) { |
| 446 | continue; |
| 447 | } |
| 448 | |
| 449 | try { |
| 450 | $source_plugin = $this->sourceManager->createInstance( |
| 451 | $source['source_id'], |
| 452 | SourcePluginBase::buildConfiguration('slot', $slot_definition, $source, $this->configuration['contexts'] ?? []) |
| 453 | ); |
| 454 | } |
| 455 | catch (\Throwable $e) { |
| 456 | $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]); |
| 457 | |
| 458 | continue; |
| 459 | } |
| 460 | |
| 461 | if ($source_plugin instanceof SourceWithSlotsInterface) { |
| 462 | $component = $this->buildSingleComponent($builder_id, '', $source_plugin, $source, $index); |
| 463 | |
| 464 | if ($component) { |
| 465 | $renderable[$index] = $component; |
| 466 | } |
| 467 | |
| 468 | continue; |
| 469 | } |
| 470 | |
| 471 | $block = $this->buildSingleBlock($builder_id, '', $source, $index); |
| 472 | |
| 473 | if ($block) { |
| 474 | $renderable[$index] = $block; |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | return $renderable; |
| 479 | } |
| 480 | |
| 481 | /** |
| 482 | * Check if a renderable has multiple HTML root elements once rendered. |
| 483 | * |
| 484 | * @param array $renderable |
| 485 | * The renderable array to check. |
| 486 | * |
| 487 | * @return bool |
| 488 | * TRUE if the rendered output has multiple root elements, FALSE otherwise. |
| 489 | */ |
| 490 | private function hasMultipleRoot(array $renderable): bool { |
| 491 | $html = (string) $this->renderer->renderInIsolation($renderable); |
| 492 | $dom = new HTML5(['disable_html_ns' => TRUE, 'encoding' => 'UTF-8']); |
| 493 | $dom = $dom->loadHTMLFragment($html); |
| 494 | |
| 495 | return $dom->childElementCount > 1; |
| 496 | } |
| 497 | |
| 498 | /** |
| 499 | * Check if a renderable array is empty. |
| 500 | * |
| 501 | * @param array $renderable |
| 502 | * The renderable array to check. |
| 503 | * |
| 504 | * @return bool |
| 505 | * TRUE if the rendered output is empty, FALSE otherwise. |
| 506 | */ |
| 507 | private function isEmpty(array $renderable): bool { |
| 508 | $html = $this->renderer->renderInIsolation($renderable); |
| 509 | |
| 510 | return empty(\trim((string) $html)); |
| 511 | } |
| 512 | |
| 513 | /** |
| 514 | * Build a component slot with dropzone. |
| 515 | * |
| 516 | * @param string $builder_id |
| 517 | * The builder ID. |
| 518 | * @param \Drupal\display_builder\SourceWithSlotsInterface $source |
| 519 | * The source plugin. |
| 520 | * @param string $slot_id |
| 521 | * The slot ID. |
| 522 | * @param array $definition |
| 523 | * The slot definition. |
| 524 | * @param string $instance_id |
| 525 | * The instance ID. |
| 526 | * |
| 527 | * @return array |
| 528 | * A renderable array for the slot. |
| 529 | */ |
| 530 | private function buildComponentSlot(string $builder_id, SourceWithSlotsInterface $source, string $slot_id, array $definition, string $instance_id): array { |
| 531 | $dropzone = [ |
| 532 | '#type' => 'component', |
| 533 | '#component' => 'display_builder:dropzone', |
| 534 | '#props' => [ |
| 535 | 'title' => $definition['title'], |
| 536 | 'variant' => 'highlighted', |
| 537 | ], |
| 538 | '#attributes' => [ |
| 539 | // Required for JavaScript @see components/dropzone/dropzone.js. |
| 540 | 'data-db-id' => $builder_id, |
| 541 | // Slot is needed for contextual menu paste. |
| 542 | // @see assets/js/contextual_menu.js |
| 543 | 'data-slot-id' => $slot_id, |
| 544 | 'data-slot-title' => \ucfirst($definition['title']), |
| 545 | 'data-node-id' => $instance_id, |
| 546 | 'data-instance-id' => $instance_id . '_' . $slot_id, |
| 547 | ], |
| 548 | ]; |
| 549 | |
| 550 | if ($sources = $source->getSlotValue($slot_id)) { |
| 551 | $dropzone['#slots']['content'] = $this->digFromSlot($builder_id, $sources); |
| 552 | } |
| 553 | |
| 554 | return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $this->getPluginID(), $instance_id, $slot_id); |
| 555 | } |
| 556 | |
| 557 | } |
Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not
necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once.
Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement
always has an else as part of its logical flow even if you didn't write one.
| 79 | public function build(InstanceInterface $builder, array $data = [], array $options = []): array { |
| 80 | $builder_id = (string) $builder->id(); |
| 81 | $build = [ |
| 82 | '#type' => 'component', |
| 83 | '#component' => 'display_builder:dropzone', |
| 84 | '#props' => [ |
| 85 | 'variant' => 'root', |
| 86 | ], |
| 87 | '#slots' => [ |
| 88 | 'content' => $this->digFromSlot($builder_id, $data), |
| 89 | ], |
| 90 | '#attributes' => [ |
| 91 | // Required for JavaScript @see components/dropzone/dropzone.js. |
| 92 | 'data-db-id' => $builder_id, |
| 93 | 'data-node-title' => $this->t('Base container'), |
| 94 | 'data-db-root' => TRUE, |
| 95 | ], |
| 96 | ]; |
| 97 | |
| 98 | return $this->htmxEvents->onRootDrop($build, $builder_id, $this->getPluginID()); |
| 99 | } |
| 530 | private function buildComponentSlot(string $builder_id, SourceWithSlotsInterface $source, string $slot_id, array $definition, string $instance_id): array { |
| 531 | $dropzone = [ |
| 532 | '#type' => 'component', |
| 533 | '#component' => 'display_builder:dropzone', |
| 534 | '#props' => [ |
| 535 | 'title' => $definition['title'], |
| 536 | 'variant' => 'highlighted', |
| 537 | ], |
| 538 | '#attributes' => [ |
| 539 | // Required for JavaScript @see components/dropzone/dropzone.js. |
| 540 | 'data-db-id' => $builder_id, |
| 541 | // Slot is needed for contextual menu paste. |
| 542 | // @see assets/js/contextual_menu.js |
| 543 | 'data-slot-id' => $slot_id, |
| 544 | 'data-slot-title' => \ucfirst($definition['title']), |
| 545 | 'data-node-id' => $instance_id, |
| 546 | 'data-instance-id' => $instance_id . '_' . $slot_id, |
| 547 | ], |
| 548 | ]; |
| 549 | |
| 550 | if ($sources = $source->getSlotValue($slot_id)) { |
| 551 | $dropzone['#slots']['content'] = $this->digFromSlot($builder_id, $sources); |
| 552 | } |
| 553 | |
| 554 | return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $this->getPluginID(), $instance_id, $slot_id); |
| 554 | return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $this->getPluginID(), $instance_id, $slot_id); |
| 555 | } |
| 237 | protected function buildSingleBlock(string $builder_id, string $instance_id, array $data, int $index = 0): ?array { |
| 238 | $instance_id = $instance_id ?: $data['node_id'] ?? NULL; |
| 239 | |
| 240 | if (!$instance_id) { |
| 241 | return NULL; |
| 244 | $classes = ['db-block']; |
| 245 | |
| 246 | if (isset($data['source']['plugin_id'])) { |
| 246 | if (isset($data['source']['plugin_id'])) { |
| 247 | $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source']['plugin_id'])); |
| 250 | $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source_id'])); |
| 251 | } |
| 252 | $build = $this->renderSource($data, $classes); |
| 252 | $build = $this->renderSource($data, $classes); |
| 253 | $is_empty = FALSE; |
| 254 | |
| 255 | if (isset($data['source_id']) && $data['source_id'] === 'token') { |
| 255 | if (isset($data['source_id']) && $data['source_id'] === 'token') { |
| 255 | if (isset($data['source_id']) && $data['source_id'] === 'token') { |
| 256 | if (isset($build['content']) && empty($build['content'])) { |
| 256 | if (isset($build['content']) && empty($build['content'])) { |
| 256 | if (isset($build['content']) && empty($build['content'])) { |
| 257 | $is_empty = TRUE; |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') { |
| 261 | if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') { |
| 266 | $is_empty = TRUE; |
| 267 | } |
| 268 | |
| 269 | $label_info = $this->slotSourceProxy->getLabelWithSummary($data, $this->configuration['contexts'] ?? []); |
| 269 | $label_info = $this->slotSourceProxy->getLabelWithSummary($data, $this->configuration['contexts'] ?? []); |
| 270 | |
| 271 | if (isset($data['source_id'])) { |
| 272 | switch ($data['source_id']) { |
| 273 | case 'entity_field': |
| 278 | case 'block': |
| 278 | case 'block': |
| 274 | $label_info['summary'] = (string) $this->t('Field: @label', ['@label' => $label_info['label']]); |
| 275 | |
| 276 | break; |
| 279 | $label_info['summary'] = (string) $this->t('Block: @label', ['@label' => $label_info['summary']]); |
| 280 | |
| 281 | break; |
| 281 | break; |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | // This is the placeholder without configuration or content yet. |
| 286 | if ($this->isEmpty($build) || $is_empty) { |
| 286 | if ($this->isEmpty($build) || $is_empty) { |
| 286 | if ($this->isEmpty($build) || $is_empty) { |
| 286 | if ($this->isEmpty($build) || $is_empty) { |
| 286 | if ($this->isEmpty($build) || $is_empty) { |
| 287 | $build = $this->buildPlaceholderButton($label_info['summary']); |
| 292 | elseif (!$this->useAttributesVariable($build) || $this->hasMultipleRoot($build)) { |
| 292 | elseif (!$this->useAttributesVariable($build) || $this->hasMultipleRoot($build)) { |
| 292 | elseif (!$this->useAttributesVariable($build) || $this->hasMultipleRoot($build)) { |
| 294 | '#type' => 'html_tag', |
| 295 | '#tag' => 'div', |
| 296 | '#attributes' => ['class' => $classes], |
| 297 | 'content' => $build, |
| 298 | ]; |
| 299 | } |
| 300 | |
| 301 | // This label is used for contextual menu. |
| 302 | // @see assets/js/contextual_menu.js |
| 303 | // The 'data-node-title' attribute is expected to contain a human-readable |
| 304 | // label or summary describing the block instance. This value is usd in the |
| 305 | // contextual menu for user actions such as edit, delete. The format should |
| 306 | // be a plain string, typically the label or field summary. |
| 307 | $build['#attributes']['data-node-title'] = $label_info['summary'] ?? $data['source_id'] ?? $data['node_id'] ?? ''; |
| 307 | $build['#attributes']['data-node-title'] = $label_info['summary'] ?? $data['source_id'] ?? $data['node_id'] ?? ''; |
| 308 | $build['#attributes']['data-slot-position'] = $index; |
| 309 | $build['#attributes']['data-instance-id'] = $instance_id; |
| 310 | |
| 311 | // Add data-node-type for easier identification of block types in JS or CSS. |
| 312 | if (isset($data['source_id'])) { |
| 313 | $build['#attributes']['data-node-type'] = $data['source_id']; |
| 314 | } |
| 315 | |
| 316 | $build = $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $label_info['summary'] ?? $label_info['label'] ?? '', $index); |
| 316 | $build = $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $label_info['summary'] ?? $label_info['label'] ?? '', $index); |
| 317 | |
| 318 | return $build; |
| 319 | } |
| 143 | protected function buildSingleComponent(string $builder_id, string $instance_id, SourceWithSlotsInterface $source, array $data, int $index = 0): ?array { |
| 144 | $info = $this->resolveComponentInfo($source, $data, $instance_id); |
| 145 | |
| 146 | if ($info === NULL) { |
| 147 | return NULL; |
| 150 | ['component_id' => $component_id, 'label' => $label, 'instance_id' => $instance_id] = $info; |
| 151 | |
| 152 | $build = $this->renderSource($data); |
| 153 | // Required for the context menu label. |
| 154 | // @see assets/js/contextual_menu.js |
| 155 | $build['#attributes']['data-node-title'] = $label; |
| 156 | $build['#attributes']['data-slot-position'] = $index; |
| 157 | $build['#attributes']['data-instance-id'] = $instance_id; |
| 158 | |
| 159 | foreach ($source->getSlotDefinitions() as $slot_id => $definition) { |
| 159 | foreach ($source->getSlotDefinitions() as $slot_id => $definition) { |
| 159 | foreach ($source->getSlotDefinitions() as $slot_id => $definition) { |
| 159 | foreach ($source->getSlotDefinitions() as $slot_id => $definition) { |
| 160 | $slot = $this->buildComponentSlot($builder_id, $source, $slot_id, $definition, $instance_id); |
| 161 | $build = $source->setSlotRenderable($build, $slot_id, $slot); |
| 162 | } |
| 163 | |
| 164 | if ($this->isEmpty($build)) { |
| 166 | $message = $component_id . ': ' . $this->t('Empty by default. Configure it to make it visible'); |
| 167 | $build = $this->buildPlaceholder($message); |
| 168 | } |
| 169 | |
| 170 | if (!$this->useAttributesVariable($build)) { |
| 170 | if (!$this->useAttributesVariable($build)) { |
| 171 | $build = $this->wrapContent($build); |
| 172 | } |
| 173 | |
| 174 | return $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $source->label(), $index); |
| 174 | return $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $source->label(), $index); |
| 175 | } |
| 57 | public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { |
| 58 | $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition); |
| 59 | $instance->renderer = $container->get('renderer'); |
| 60 | $instance->slotSourceProxy = $container->get('display_builder.slot_sources_proxy'); |
| 61 | $instance->componentElementBuilder = $container->get('ui_patterns.component_element_builder'); |
| 62 | |
| 63 | return $instance; |
| 64 | } |
| 440 | protected function digFromSlot(string $builder_id, array $data): array { |
| 441 | $renderable = []; |
| 442 | $slot_definition = ['ui_patterns' => ['type_definition' => $this->sourceManager->getSlotPropType()]]; |
| 443 | |
| 444 | foreach ($data as $index => $source) { |
| 444 | foreach ($data as $index => $source) { |
| 444 | foreach ($data as $index => $source) { |
| 445 | if (!isset($source['source_id'])) { |
| 446 | continue; |
| 449 | try { |
| 450 | $source_plugin = $this->sourceManager->createInstance( |
| 455 | catch (\Throwable $e) { |
| 456 | $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]); |
| 457 | |
| 458 | continue; |
| 461 | if ($source_plugin instanceof SourceWithSlotsInterface) { |
| 462 | $component = $this->buildSingleComponent($builder_id, '', $source_plugin, $source, $index); |
| 463 | |
| 464 | if ($component) { |
| 465 | $renderable[$index] = $component; |
| 466 | } |
| 467 | |
| 468 | continue; |
| 468 | continue; |
| 471 | $block = $this->buildSingleBlock($builder_id, '', $source, $index); |
| 472 | |
| 473 | if ($block) { |
| 444 | foreach ($data as $index => $source) { |
| 445 | if (!isset($source['source_id'])) { |
| 446 | continue; |
| 447 | } |
| 448 | |
| 449 | try { |
| 450 | $source_plugin = $this->sourceManager->createInstance( |
| 451 | $source['source_id'], |
| 452 | SourcePluginBase::buildConfiguration('slot', $slot_definition, $source, $this->configuration['contexts'] ?? []) |
| 453 | ); |
| 454 | } |
| 455 | catch (\Throwable $e) { |
| 456 | $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]); |
| 457 | |
| 458 | continue; |
| 459 | } |
| 460 | |
| 461 | if ($source_plugin instanceof SourceWithSlotsInterface) { |
| 462 | $component = $this->buildSingleComponent($builder_id, '', $source_plugin, $source, $index); |
| 463 | |
| 464 | if ($component) { |
| 465 | $renderable[$index] = $component; |
| 466 | } |
| 467 | |
| 468 | continue; |
| 469 | } |
| 470 | |
| 471 | $block = $this->buildSingleBlock($builder_id, '', $source, $index); |
| 472 | |
| 473 | if ($block) { |
| 474 | $renderable[$index] = $block; |
| 444 | foreach ($data as $index => $source) { |
| 444 | foreach ($data as $index => $source) { |
| 445 | if (!isset($source['source_id'])) { |
| 446 | continue; |
| 447 | } |
| 448 | |
| 449 | try { |
| 450 | $source_plugin = $this->sourceManager->createInstance( |
| 451 | $source['source_id'], |
| 452 | SourcePluginBase::buildConfiguration('slot', $slot_definition, $source, $this->configuration['contexts'] ?? []) |
| 453 | ); |
| 454 | } |
| 455 | catch (\Throwable $e) { |
| 456 | $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]); |
| 457 | |
| 458 | continue; |
| 459 | } |
| 460 | |
| 461 | if ($source_plugin instanceof SourceWithSlotsInterface) { |
| 462 | $component = $this->buildSingleComponent($builder_id, '', $source_plugin, $source, $index); |
| 463 | |
| 464 | if ($component) { |
| 465 | $renderable[$index] = $component; |
| 466 | } |
| 467 | |
| 468 | continue; |
| 469 | } |
| 470 | |
| 471 | $block = $this->buildSingleBlock($builder_id, '', $source, $index); |
| 472 | |
| 473 | if ($block) { |
| 474 | $renderable[$index] = $block; |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | return $renderable; |
| 479 | } |
| 490 | private function hasMultipleRoot(array $renderable): bool { |
| 491 | $html = (string) $this->renderer->renderInIsolation($renderable); |
| 492 | $dom = new HTML5(['disable_html_ns' => TRUE, 'encoding' => 'UTF-8']); |
| 493 | $dom = $dom->loadHTMLFragment($html); |
| 494 | |
| 495 | return $dom->childElementCount > 1; |
| 496 | } |
| 507 | private function isEmpty(array $renderable): bool { |
| 508 | $html = $this->renderer->renderInIsolation($renderable); |
| 509 | |
| 510 | return empty(\trim((string) $html)); |
| 511 | } |
| 71 | 'key' => 'b', |
| 72 | 'help' => t('Show the builder'), |
| 73 | ]; |
| 74 | } |
| 104 | public function onAttachToSlot(InstanceInterface $instance, string $node_id, string $parent_id): array { |
| 105 | return $this->replaceNode($instance, $parent_id); |
| 106 | } |
| 118 | public function onDelete(InstanceInterface $instance, ?string $parent_id): array { |
| 119 | if (empty($parent_id)) { |
| 120 | return $this->reloadWithGlobalData($instance); |
| 123 | return $this->replaceNode($instance, $parent_id); |
| 124 | } |
| 111 | public function onUpdate(InstanceInterface $instance, string $node_id): array { |
| 112 | return $this->replaceNode($instance, $node_id); |
| 113 | } |
| 395 | protected function renderSource(array $data, array $classes = []): array { |
| 396 | $build = $this->componentElementBuilder->buildSource([], 'content', [], $data, $this->configuration['contexts'] ?? []) ?? []; |
| 397 | $build = $build['#slots']['content'][0] ?? []; |
| 398 | |
| 399 | // Fixes for token which is simple markup or html. |
| 400 | if (isset($data['source_id']) && $data['source_id'] !== 'token') { |
| 400 | if (isset($data['source_id']) && $data['source_id'] !== 'token') { |
| 400 | if (isset($data['source_id']) && $data['source_id'] !== 'token') { |
| 401 | return $build; |
| 406 | if (!isset($build['#type'])) { |
| 406 | if (!isset($build['#type'])) { |
| 407 | $build = [ |
| 408 | '#type' => 'html_tag', |
| 417 | elseif (isset($build['#attributes'])) { |
| 419 | '#type' => 'html_tag', |
| 420 | '#tag' => 'div', |
| 421 | '#attributes' => ['class' => $classes], |
| 422 | 'content' => $build, |
| 423 | ]; |
| 424 | } |
| 425 | |
| 426 | return $build; |
| 426 | return $build; |
| 427 | } |
| 332 | protected function replaceNode(InstanceInterface $instance, string $node_id): array { |
| 333 | $builder_id = (string) $instance->id(); |
| 334 | $parent_selector = '#' . $this->getHtmlId($builder_id) . ' [data-node-id="' . $node_id . '"]'; |
| 335 | $data = $instance->getNode($node_id); |
| 336 | $build = []; |
| 337 | $slot_definition = ['ui_patterns' => ['type_definition' => $this->sourceManager->getSlotPropType()]]; |
| 338 | |
| 339 | try { |
| 340 | $source = $this->sourceManager->createInstance( |
| 345 | catch (\Throwable $e) { |
| 346 | $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]); |
| 347 | |
| 348 | return []; |
| 351 | if ($source instanceof SourceWithSlotsInterface) { |
| 351 | if ($source instanceof SourceWithSlotsInterface) { |
| 352 | $build = $this->buildSingleComponent($builder_id, $node_id, $source, $data); |
| 355 | $build = $this->buildSingleBlock($builder_id, $node_id, $data); |
| 356 | } |
| 357 | |
| 358 | return $this->makeOutOfBand( |
| 358 | return $this->makeOutOfBand( |
| 359 | $build ?? [], |
| 360 | $parent_selector, |
| 361 | 'outerHTML' |
| 362 | ); |
| 363 | } |
| 194 | protected function resolveComponentInfo(SourceWithSlotsInterface $source, array $data, string $instance_id): ?array { |
| 195 | $component_id = $source->getPluginID(); |
| 196 | $label = $source->label(); |
| 197 | |
| 198 | if ($source instanceof SourceWithChoicesInterface) { |
| 199 | $component_id = $source->getChoice($data['source']); |
| 200 | $result = $this->slotSourceProxy->getLabelWithSummary($data, [], TRUE); |
| 201 | $label = $result['label'] ?? $source->label(); |
| 202 | } |
| 203 | |
| 204 | $instance_id = $instance_id ?: $data['node_id'] ?? NULL; |
| 204 | $instance_id = $instance_id ?: $data['node_id'] ?? NULL; |
| 205 | |
| 206 | if (!$instance_id || !$component_id) { |
| 206 | if (!$instance_id || !$component_id) { |
| 206 | if (!$instance_id || !$component_id) { |
| 207 | $this->logger->error( |
| 208 | '[' . static::class . '::buildSingleComponent] missing component ID: @component_id or instance ID: @instance_id. <pre>' . \print_r($data, TRUE) . '</pre>', |
| 209 | ['@instance_id' => $instance_id ?? 'NULL', '@component_id' => $component_id], |
| 210 | ); |
| 211 | |
| 212 | return NULL; |
| 216 | 'component_id' => $component_id, |
| 217 | 'label' => $label, |
| 218 | 'instance_id' => $instance_id, |
| 219 | ]; |
| 220 | } |
| 374 | protected function useAttributesVariable(array $renderable): bool { |
| 375 | $random = \uniqid(); |
| 376 | $renderable['#attributes'][$random] = $random; |
| 377 | $html = $this->renderer->renderInIsolation($renderable); |
| 378 | |
| 379 | return \str_contains((string) $html, $random); |
| 380 | } |