Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 182
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
BlockLibraryPanel
0.00% covered (danger)
0.00%
0 / 176
0.00% covered (danger)
0.00%
0 / 14
3540
0.00% covered (danger)
0.00%
0 / 1
 create
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 defaultConfiguration
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 buildConfigurationForm
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 configurationSummary
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 build
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 label
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getChoiceGroupLabel
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
110
 getGroupedChoices
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 sortGroupedChoices
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 getSources
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 isChoiceValid
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 getChoices
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
42
 getProvidersOptions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getProviders
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder\Plugin\display_builder\Island;
6
7use Drupal\Component\Render\MarkupInterface;
8use Drupal\Core\Extension\ModuleExtensionList;
9use Drupal\Core\Form\FormStateInterface;
10use Drupal\Core\StringTranslation\TranslatableMarkup;
11use Drupal\display_builder\Attribute\Island;
12use Drupal\display_builder\InstanceInterface;
13use Drupal\display_builder\IslandConfigurationFormInterface;
14use Drupal\display_builder\IslandConfigurationFormTrait;
15use Drupal\display_builder\IslandPluginBase;
16use Drupal\display_builder\IslandType;
17use Drupal\ui_patterns\SourcePluginBase;
18use Drupal\ui_patterns\SourcePluginManager;
19use Drupal\ui_patterns\SourceWithChoicesInterface;
20use Symfony\Component\DependencyInjection\ContainerInterface;
21
22/**
23 * Block library island plugin implementation.
24 */
25#[Island(
26  id: 'block_library',
27  enabled_by_default: TRUE,
28  label: new TranslatableMarkup('Blocks library'),
29  description: new TranslatableMarkup('List of available Drupal blocks to use.'),
30  type: IslandType::Library,
31)]
32class BlockLibraryPanel extends IslandPluginBase implements IslandConfigurationFormInterface {
33
34  use IslandConfigurationFormTrait;
35
36  private const HIDE_BLOCK = [
37    'help_block',
38    'system_messages_block',
39    'htmx_loader',
40    'broken',
41    'system_main_block',
42    'page_title_block',
43  ];
44
45  private const HIDE_SOURCE = [
46    'component',
47    // Used only for imports from Manage Display and Layout Builder.
48    'extra_field',
49  ];
50
51  private const HIDE_PROVIDER = ['ui_patterns_blocks'];
52
53  /**
54   * The sources.
55   */
56  protected ?array $sources = NULL;
57
58  /**
59   * The choices from all sources.
60   */
61  protected ?array $choices = NULL;
62
63  /**
64   * The module list extension service.
65   */
66  protected ModuleExtensionList $moduleList;
67
68  /**
69   * The UI Patterns source plugin manager.
70   */
71  protected SourcePluginManager $sourceManager;
72
73  /**
74   * {@inheritdoc}
75   */
76  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
77    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
78    $instance->moduleList = $container->get('extension.list.module');
79    $instance->sourceManager = $container->get('plugin.manager.ui_patterns_source');
80
81    return $instance;
82  }
83
84  /**
85   * {@inheritdoc}
86   */
87  public function defaultConfiguration(): array {
88    return [
89      'exclude' => [
90        'devel',
91        'htmx',
92        'shortcut',
93      ],
94    ];
95  }
96
97  /**
98   * {@inheritdoc}
99   */
100  public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
101    $configuration = $this->getConfiguration();
102
103    $form['exclude'] = [
104      '#type' => 'checkboxes',
105      '#title' => $this->t('Exclude modules'),
106      '#options' => $this->getProvidersOptions(),
107      '#default_value' => $configuration['exclude'],
108    ];
109
110    return $form;
111  }
112
113  /**
114   * {@inheritdoc}
115   */
116  public function configurationSummary(): array {
117    $configuration = $this->getConfiguration();
118
119    return [
120      $this->t('Excluded modules: @exclude', [
121        '@exclude' => \implode(', ', \array_filter($configuration['exclude'] ?? []) ?: [$this->t('None')]),
122      ]),
123    ];
124  }
125
126  /**
127   * {@inheritdoc}
128   */
129  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
130    $builder_id = (string) $builder->id();
131    $categories = $this->getGroupedChoices();
132    $build = [];
133
134    foreach ($categories as $category_data) {
135      if (!empty($category_data['label'])) {
136        $build[] = [
137          [
138            '#type' => 'html_tag',
139            '#tag' => 'h4',
140            // We hide the group titles on search.
141            '#attributes' => ['class' => 'db-filter-hide-on-search'],
142            '#value' => $category_data['label'],
143          ],
144        ];
145      }
146      $category_choices = $category_data['choices'];
147
148      foreach ($category_choices as $choice) {
149        $build[] = $this->buildPlaceholderButton(
150          $choice['label'],
151          $choice['data'] ?? [],
152          $choice['keywords'] ?? ''
153        );
154      }
155    }
156
157    return $this->buildDraggables($builder_id, $build);
158  }
159
160  /**
161   * {@inheritdoc}
162   */
163  public function label(): string {
164    return 'Blocks';
165  }
166
167  /**
168   * Get the group label for a choice.
169   *
170   * @param array $choice
171   *   The choice to get the group for.
172   * @param array $source_definition
173   *   The source definition to use for the group.
174   *
175   * @return string
176   *   The group label for the choice.
177   */
178  private static function getChoiceGroupLabel(array &$choice, array &$source_definition): string {
179    $group = $source_definition['label'] ?? '';
180
181    switch ($source_definition['id']) {
182      case 'block':
183        $block_id = $choice['original_id'] ?? '';
184
185        if (\str_starts_with($block_id, 'views_block:') && $choice['group']) {
186          $group = $choice['group'];
187        }
188        elseif (\str_starts_with($block_id, 'system_menu_block:') && $choice['group']) {
189          $group = $choice['group'];
190        }
191        else {
192          $group = new TranslatableMarkup('Others');
193        }
194
195        break;
196
197      case 'entity_reference':
198        $group = new TranslatableMarkup('Referenced entities');
199
200        break;
201
202      case 'entity_field':
203        $group = new TranslatableMarkup('Fields');
204
205        break;
206
207      default:
208        break;
209    }
210
211    return ($group instanceof MarkupInterface) ? (string) $group : $group;
212  }
213
214  /**
215   * Get the choices grouped by category.
216   *
217   * @return array
218   *   An array of grouped choices.
219   */
220  private function getGroupedChoices(): array {
221    $choices = $this->getChoices();
222    $categories = [];
223
224    foreach ($choices as $choice) {
225      $category = $choice['group'] ?? '';
226
227      if ($category instanceof MarkupInterface) {
228        $category = (string) $category;
229      }
230
231      if (!isset($categories[$category])) {
232        $categories[$category] = [
233          'label' => $category,
234          'metadata' => $choice,
235          'choices' => [],
236        ];
237      }
238      $categories[$category]['choices'][] = $choice;
239    }
240    self::sortGroupedChoices($categories);
241
242    return $categories;
243  }
244
245  /**
246   * Sorts the grouped choices.
247   *
248   * This method sorts the categories by their labels, placing empty category
249   * first, views blocks are sorted to the end of the list.
250   *
251   * @param array $categories
252   *   The categories to sort, passed by reference.
253   */
254  private static function sortGroupedChoices(array &$categories): void {
255    // Sort categories : empty first, views at the end.
256    \usort($categories, static function ($a, $b) {
257      if (empty($a['label'])) {
258        return -1;
259      }
260
261      if (empty($b['label'])) {
262        return 1;
263      }
264      $source_id_a = $a['metadata']['data']['source_id'] ?? '';
265      $source_id_b = $b['metadata']['data']['source_id'] ?? '';
266
267      if (($source_id_a === 'block') && ($source_id_b !== 'block')) {
268        return 1;
269      }
270
271      if (($source_id_b === 'block') && ($source_id_a !== 'block')) {
272        return -1;
273      }
274
275      return \strnatcmp($a['label'], $b['label']);
276    });
277  }
278
279  /**
280   * Returns all possible sources.
281   *
282   * @throws \Drupal\Component\Plugin\Exception\PluginException
283   *
284   * @return array<string, array>
285   *   An array of sources.
286   */
287  private function getSources(): array {
288    if ($this->sources === NULL) {
289      $definitions = $this->sourceManager->getDefinitionsForPropType('slot', $this->configuration['contexts'] ?? []);
290      $slot_definition = ['ui_patterns' => ['type_definition' => $this->sourceManager->getSlotPropType()]];
291
292      foreach ($definitions as $source_id => $definition) {
293        if (\in_array($source_id, self::HIDE_SOURCE, TRUE)) {
294          continue;
295        }
296        $source = $this->sourceManager->createInstance($source_id,
297          SourcePluginBase::buildConfiguration('slot', $slot_definition, ['source' => []], $this->configuration['contexts'] ?? [])
298        );
299        $this->sources[$source_id] = [
300          'definition' => $definition,
301          'source' => $source,
302        ];
303
304        if ($source instanceof SourceWithChoicesInterface) {
305          $this->sources[$source_id]['choices'] = $source->getChoices();
306        }
307      }
308    }
309
310    return $this->sources;
311  }
312
313  /**
314   * Validate a choice against the source definition and allowed providers.
315   *
316   * @param array $choice
317   *   The choice to validate.
318   * @param array $source_definition
319   *   The source definition.
320   * @param array $excluded_providers
321   *   The excluded providers.
322   *
323   * @return bool
324   *   Whether the choice is valid or not.
325   */
326  private function isChoiceValid(array &$choice, array &$source_definition, array $excluded_providers = []): bool {
327    $provider = $choice['provider'] ?? '';
328
329    if ($provider) {
330      if (\in_array($provider, self::HIDE_PROVIDER, TRUE) && \in_array($provider, $excluded_providers, TRUE)) {
331        return FALSE;
332      }
333    }
334
335    if ($source_definition['id'] === 'block') {
336      $block_id = $choice['original_id'] ?? '';
337
338      if ($block_id && \in_array($block_id, self::HIDE_BLOCK, TRUE)) {
339        return FALSE;
340      }
341    }
342
343    return TRUE;
344  }
345
346  /**
347   * Get the choices from all sources.
348   *
349   * @return array
350   *   An array of choices.
351   */
352  private function getChoices(): array {
353    if ($this->choices !== NULL) {
354      return $this->choices;
355    }
356
357    $this->choices = [];
358
359    $configuration = $this->getConfiguration();
360    $excluded_providers = $configuration['exclude'] ?? [];
361    $sources = $this->getSources();
362
363    foreach ($sources as $source_id => $source_data) {
364      $definition = $source_data['definition'];
365      $source = $source_data['source'];
366
367      if (!isset($source_data['choices'])) {
368        $this->choices[] = [
369          'label' => $definition['label'] ?? $source_id,
370          'data' => ['source_id' => $source_id],
371          'keywords' => \sprintf('%s %s %s', $definition['id'], $definition['label'] ?? $source_id, $definition['description'] ?? ''),
372        ];
373
374        continue;
375      }
376      $choices = $source_data['choices'];
377
378      foreach ($choices as $choice_id => $choice) {
379        if (!$this->isChoiceValid($choice, $definition, $excluded_providers)) {
380          continue;
381        }
382        $choice_label = $choice['label'] ?? $choice_id;
383        $group_label = self::getChoiceGroupLabel($choice, $definition);
384        $this->choices[] = [
385          'group' => $group_label,
386          'label' => $choice_label,
387          'data' => [
388            'source_id' => $source_id,
389            'source' => $source->getChoiceSettings($choice_id),
390          ],
391          'keywords' => \sprintf('%s %s %s %s', $definition['id'], $choice_label, $definition['description'] ?? '', $choice_id),
392        ];
393      }
394    }
395
396    return $this->choices;
397  }
398
399  /**
400   * Get providers options for select input.
401   *
402   * @return array
403   *   An associative array with module ID as key and module description as
404   *   value.
405   */
406  private function getProvidersOptions(): array {
407    $options = [];
408
409    foreach ($this->getProviders() as $provider_id => $provider) {
410      $params = [
411        '@name' => $provider['name'],
412        '@count' => $provider['count'],
413      ];
414      $options[$provider_id] = $this->formatPlural($provider['count'], '@name (@count block)', '@name (@count blocks)', $params);
415    }
416
417    return $options;
418  }
419
420  /**
421   * Get all providers.
422   *
423   * @return array
424   *   Drupal modules definitions, keyed by extension ID
425   */
426  private function getProviders(): array {
427    $sources = $this->getSources();
428    $providers = [];
429    $modules = $this->moduleList->getAllInstalledInfo();
430
431    foreach ($sources as $source_data) {
432      if (!isset($source_data['choices'])) {
433        continue;
434      }
435      $choices = $source_data['choices'];
436
437      foreach ($choices as $choice) {
438        $provider = $choice['provider'] ?? '';
439
440        if (!$provider || \in_array($provider, self::HIDE_PROVIDER, TRUE)) {
441          continue;
442        }
443
444        if (!isset($modules[$provider])) {
445          // If the provider is not a module, skip it.
446          continue;
447        }
448
449        if (!isset($providers[$provider])) {
450          $providers[$provider] = $modules[$provider];
451          $providers[$provider]['count'] = 0;
452        }
453        ++$providers[$provider]['count'];
454      }
455    }
456
457    return $providers;
458  }
459
460}