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