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 | } |
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.
| 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()); |
| 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); |
| 475 | return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $this->getPluginID(), $instance_id, $slot_id); |
| 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; |
| 193 | $classes = ['db-block']; |
| 194 | |
| 195 | if (isset($data['source']['plugin_id'])) { |
| 195 | if (isset($data['source']['plugin_id'])) { |
| 196 | $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source']['plugin_id'])); |
| 199 | $classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source_id'])); |
| 200 | } |
| 201 | $build = $this->renderSource($data, $classes); |
| 201 | $build = $this->renderSource($data, $classes); |
| 202 | $is_empty = FALSE; |
| 203 | |
| 204 | if (isset($data['source_id']) && $data['source_id'] === 'token') { |
| 204 | if (isset($data['source_id']) && $data['source_id'] === 'token') { |
| 205 | if (isset($build['content']) && empty($build['content'])) { |
| 205 | if (isset($build['content']) && empty($build['content'])) { |
| 206 | $is_empty = TRUE; |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') { |
| 210 | if (($data['source']['plugin_id'] ?? '') === 'system_messages_block') { |
| 215 | $is_empty = TRUE; |
| 216 | } |
| 217 | |
| 218 | $label_info = $this->slotSourceProxy->getLabelWithSummary($data, $this->configuration['contexts'] ?? []); |
| 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': |
| 227 | case 'block': |
| 227 | case 'block': |
| 223 | $label_info['summary'] = (string) $this->t('Field: @label', ['@label' => $label_info['label']]); |
| 224 | |
| 225 | break; |
| 228 | $label_info['summary'] = (string) $this->t('Block: @label', ['@label' => $label_info['summary']]); |
| 229 | |
| 230 | break; |
| 230 | break; |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | // This is the placeholder without configuration or content yet. |
| 235 | if ($this->isEmpty($build) || $is_empty) { |
| 235 | if ($this->isEmpty($build) || $is_empty) { |
| 235 | if ($this->isEmpty($build) || $is_empty) { |
| 235 | if ($this->isEmpty($build) || $is_empty) { |
| 236 | $build = $this->buildPlaceholderButton($label_info['summary']); |
| 241 | elseif (!Element::isAcceptingAttributes($build) || $this->hasMultipleRoot($build)) { |
| 241 | elseif (!Element::isAcceptingAttributes($build) || $this->hasMultipleRoot($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'] ?? ''; |
| 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; |
| 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) { |
| 148 | if (!$instance_id && !$component_id) { |
| 149 | return NULL; |
| 152 | $component = $this->sdcManager->getDefinition($component_id); |
| 153 | |
| 154 | if (!$component) { |
| 155 | return NULL; |
| 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) { |
| 164 | foreach ($component['slots'] ?? [] as $slot_id => $definition) { |
| 164 | foreach ($component['slots'] ?? [] as $slot_id => $definition) { |
| 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)) { |
| 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)) { |
| 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); |
| 180 | return $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $component['label'], $index); |
| 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; |
| 374 | protected function digFromSlot(string $builder_id, array $data): array { |
| 375 | $renderable = []; |
| 376 | |
| 377 | foreach ($data as $index => $source) { |
| 377 | foreach ($data as $index => $source) { |
| 377 | foreach ($data as $index => $source) { |
| 378 | if (!isset($source['source_id'])) { |
| 379 | continue; |
| 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; |
| 389 | continue; |
| 392 | $block = $this->buildSingleBlock($builder_id, '', $source, $index); |
| 393 | |
| 394 | if ($block) { |
| 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; |
| 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; |
| 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; |
| 428 | private function isEmpty(array $renderable): bool { |
| 429 | $html = $this->renderer->renderInIsolation($renderable); |
| 430 | |
| 431 | return empty(\trim((string) $html)); |
| 66 | 'b' => t('Show the builder'), |
| 98 | public function onAttachToRoot(string $builder_id, string $instance_id): array { |
| 99 | return $this->reloadWithGlobalData($builder_id); |
| 105 | public function onAttachToSlot(string $builder_id, string $instance_id, string $parent_id): array { |
| 106 | return $this->replaceInstance($builder_id, $parent_id); |
| 133 | public function onDelete(string $builder_id, string $parent_id): array { |
| 134 | if (empty($parent_id)) { |
| 135 | return $this->reloadWithGlobalData($builder_id); |
| 138 | return $this->replaceInstance($builder_id, $parent_id); |
| 119 | public function onHistoryChange(string $builder_id): array { |
| 120 | return $this->reloadWithGlobalData($builder_id); |
| 112 | public function onMove(string $builder_id, string $instance_id): array { |
| 113 | return $this->reloadWithGlobalData($builder_id); |
| 126 | public function onUpdate(string $builder_id, string $instance_id): array { |
| 127 | return $this->replaceInstance($builder_id, $instance_id); |
| 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') { |
| 334 | if (isset($data['source_id']) && $data['source_id'] !== 'token') { |
| 335 | return $build; |
| 340 | if (!isset($build['#type'])) { |
| 340 | if (!isset($build['#type'])) { |
| 341 | $build = [ |
| 342 | '#type' => 'html_tag', |
| 351 | elseif (isset($build['#attributes'])) { |
| 353 | '#type' => 'html_tag', |
| 354 | '#tag' => 'div', |
| 355 | '#attributes' => ['class' => $classes], |
| 356 | 'content' => $build, |
| 357 | ]; |
| 358 | } |
| 359 | |
| 360 | return $build; |
| 360 | return $build; |
| 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') { |
| 285 | if (isset($data['source_id']) && $data['source_id'] === 'component') { |
| 285 | if (isset($data['source_id']) && $data['source_id'] === 'component') { |
| 286 | $build = $this->buildSingleComponent($builder_id, $instance_id, $data); |
| 289 | $build = $this->buildSingleBlock($builder_id, $instance_id, $data); |
| 290 | } |
| 291 | |
| 292 | return $this->makeOutOfBand( |
| 292 | return $this->makeOutOfBand( |
| 293 | $build, |
| 294 | $parent_selector, |
| 295 | 'outerHTML' |
| 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); |