Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 175
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
BlockLibraryPanel
0.00% covered (danger)
0.00%
0 / 168
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 9
1482
0.00% covered (danger)
0.00%
0 / 1
 create
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 defaultConfiguration
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildConfigurationForm
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 configurationSummary
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 build
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 buildCategorySection
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 getSources
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 getProvidersOptions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 3
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 / 17
0.00% covered (danger)
0.00%
0 / 13
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\Core\Extension\ModuleExtensionList;
8use Drupal\Core\Form\FormStateInterface;
9use Drupal\Core\StringTranslation\TranslatableMarkup;
10use Drupal\display_builder\Attribute\Island;
11use Drupal\display_builder\BlockLibrarySourceHelper;
12use Drupal\display_builder\InstanceInterface;
13use Drupal\display_builder\Island\IslandConfigurationFormInterface;
14use Drupal\display_builder\Island\IslandConfigurationFormTrait;
15use Drupal\display_builder\Island\IslandPluginBase;
16use Drupal\display_builder\Island\IslandType;
17use Drupal\display_builder\SourceWithSlotsInterface;
18use Drupal\ui_patterns\SourcePluginBase;
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'),
29  description: new TranslatableMarkup('List of available blocks.'),
30  type: IslandType::Library,
31  icon: 'bricks',
32)]
33class BlockLibraryPanel extends IslandPluginBase implements IslandConfigurationFormInterface {
34
35  use IslandConfigurationFormTrait;
36
37  private const HIDE_SOURCE = [
38    'component',
39    // Token is deprecated in UI Patterns; 'Textfield' allow token.
40    'token',
41    'wysiwyg',
42  ];
43
44  private const HIDE_PROVIDER = ['ui_patterns_blocks'];
45
46  /**
47   * The sources.
48   */
49  protected array $sources = [];
50
51  /**
52   * The module extension list service.
53   */
54  protected ModuleExtensionList $moduleList;
55
56  /**
57   * {@inheritdoc}
58   */
59  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
60    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
61    $instance->moduleList = $container->get('extension.list.module');
62
63    return $instance;
64  }
65
66  /**
67   * {@inheritdoc}
68   */
69  public function defaultConfiguration(): array {
70    return [
71      'exclude' => [
72        'devel',
73        'htmx',
74        'shortcut',
75      ],
76      'exclude_id' => '',
77      'show' => 'grouped',
78      'preview' => TRUE,
79    ];
80  }
81
82  /**
83   * {@inheritdoc}
84   */
85  public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
86    $configuration = $this->getConfiguration();
87
88    $form['display'] = [
89      '#type' => 'fieldset',
90      '#title' => $this->t('Display'),
91    ];
92
93    $form['display']['show'] = [
94      '#type' => 'select',
95      '#title' => $this->t('Display blocks as'),
96      '#default_value' => $configuration['show'],
97      '#options' => [
98        'grouped' => $this->t('grouped'),
99        'flat' => $this->t('flat list'),
100      ],
101    ];
102
103    $form['display']['preview'] = [
104      '#type' => 'checkbox',
105      '#title' => $this->t('Enable preview on hover'),
106      '#description' => $this->t('Enable or disable the preview of components when hovering over them.'),
107      '#default_value' => $configuration['preview'],
108    ];
109
110    $form['configuration'] = [
111      '#type' => 'fieldset',
112      '#title' => $this->t('Configuration'),
113    ];
114
115    $form['configuration']['exclude'] = [
116      '#type' => 'checkboxes',
117      '#title' => $this->t('Exclude from modules'),
118      '#description' => $this->t('Select the modules which blocks have to be excluded from the block library.'),
119      '#options' => $this->getProvidersOptions(),
120      '#default_value' => $configuration['exclude'],
121    ];
122
123    $form['configuration']['exclude_id'] = [
124      '#type' => 'textarea',
125      '#title' => $this->t('Exclude by id'),
126      '#description' => $this->t('Provide a list of id to exclude. One id by line, source id is used. Example: "help_block views_block:comments_recent-block_1 system_menu_block:account field:node:article:vid".'),
127      '#default_value' => $configuration['exclude_id'],
128    ];
129
130    return $form;
131  }
132
133  /**
134   * {@inheritdoc}
135   */
136  public function configurationSummary(): array {
137    $configuration = $this->getConfiguration();
138
139    $summary[] = $this->t('Blocks displayed as: <em>@list</em>', [
140      '@list' => match ($configuration['show']) {
141        'flat' => $this->t('flat'),
142        default => $this->t('grouped'),
143      },
144    ]);
145
146    if ($configuration['preview']) {
147      $summary[] = $this->t('Preview on hover enabled');
148    }
149
150    $summary[] = $this->t('Excluded modules: @exclude', [
151      '@exclude' => \implode(', ', \array_filter($configuration['exclude'] ?? []) ?: [$this->t('None')]),
152    ]);
153
154    if (\strlen($configuration['exclude_id'] ?? '') > 5) {
155      $value = \preg_split('/\r\n|\r|\n/', \trim($configuration['exclude_id'] ?? ''));
156
157      if ($value === FALSE) {
158        $summary[] = $this->t('Block(s) excluded');
159      }
160      else {
161        $summary[] = $this->formatPlural(\count($value), '@count block excluded', '@count blocks excluded by id');
162      }
163    }
164
165    return $summary;
166  }
167
168  /**
169   * {@inheritdoc}
170   */
171  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
172    $builder_id = (string) $builder->id();
173    $configuration = $this->getConfiguration();
174
175    $exclude_providers = \array_merge(
176      $configuration['exclude'] ?? [],
177      self::HIDE_PROVIDER
178    );
179    $exclude_by_id = \preg_split('/\r\n|\r|\n/', \trim($configuration['exclude_id'] ?? '')) ?: [];
180
181    $build = [];
182
183    if ($configuration['show'] === 'grouped') {
184      $categories = BlockLibrarySourceHelper::getGroupedChoices(
185        $this->getSources(),
186        $exclude_providers,
187        $exclude_by_id,
188        (bool) $configuration['preview'],
189      );
190
191      foreach ($categories as $category_data) {
192        $build[] = $this->buildCategorySection($category_data, $builder_id, (bool) $configuration['preview']);
193      }
194    }
195    else {
196      $choices = BlockLibrarySourceHelper::getChoices(
197        $this->getSources(),
198        $exclude_providers,
199        $exclude_by_id,
200        (bool) $configuration['preview'],
201      );
202
203      foreach ($choices as $choice) {
204        if ($configuration['preview']) {
205          $build[] = $this->buildPlaceholderListWithPreview($builder_id, $choice['label'], $data, $choice['preview'], $choice['keywords']);
206        }
207        else {
208          $build[] = $this->buildPlaceholderList($choice['label'], $data, $choice['keywords']);
209        }
210      }
211    }
212
213    return [
214      '#type' => 'component',
215      '#component' => 'display_builder:library_panel',
216      '#slots' => [
217        'content' => $this->buildDraggables($builder_id, $build),
218      ],
219    ];
220  }
221
222  /**
223   * Build a category section.
224   *
225   * @param array $category_data
226   *   The category data.
227   * @param string $builder_id
228   *   The builder ID.
229   * @param bool $preview
230   *   Whether to show preview on hover.
231   *
232   * @return array
233   *   The render array for the category section.
234   */
235  private function buildCategorySection(array $category_data, string $builder_id, bool $preview): array {
236    $section = [];
237
238    if (!empty($category_data['label'])) {
239      $section[] = [
240        '#type' => 'html_tag',
241        '#tag' => 'h4',
242        '#attributes' => [
243          'class' => ['db-placeholder__group', 'db-filter-hide-on-search'],
244        ],
245        '#value' => $category_data['label'],
246      ];
247    }
248
249    foreach ($category_data['choices'] as $choice) {
250      $label = $choice['label'] ?? '-';
251
252      if ($preview && $choice['preview']) {
253        $section[] = $this->buildPlaceholderListWithPreview($builder_id, $label, $choice['data'] ?? [], $choice['preview'], $choice['keywords'] ?? '');
254      }
255      else {
256        $section[] = $this->buildPlaceholderList($label, $choice['data'] ?? [], $choice['keywords'] ?? '');
257      }
258    }
259
260    return $section;
261  }
262
263  /**
264   * Returns all possible sources.
265   *
266   * @throws \Drupal\Component\Plugin\Exception\PluginException
267   *
268   * @return array<string, array>
269   *   An array of sources.
270   */
271  private function getSources(): array {
272    if (!empty($this->sources)) {
273      return $this->sources;
274    }
275
276    $definitions = $this->sourceManager->getDefinitionsForPropType('slot', $this->configuration['contexts'] ?? []);
277    $slot_definition = ['ui_patterns' => ['type_definition' => $this->sourceManager->getSlotPropType()]];
278
279    foreach ($definitions as $source_id => $definition) {
280      // A block is a source for slots but without slots.
281      if (\is_a($definition['class'], SourceWithSlotsInterface::class, TRUE)) {
282        continue;
283      }
284
285      if (\in_array($source_id, self::HIDE_SOURCE, TRUE)) {
286        continue;
287      }
288
289      try {
290        $source = $this->sourceManager->createInstance(
291          $source_id,
292          SourcePluginBase::buildConfiguration('slot', $slot_definition, ['source' => []], $this->configuration['contexts'] ?? [])
293        );
294      }
295      catch (\Throwable $e) {
296        $this->logger->error('Invalid source found: %message', ['%message' => $e->getMessage()]);
297
298        continue;
299      }
300
301      $this->sources[$source_id] = [
302        'definition' => $definition,
303        'source' => $source,
304      ];
305
306      if ($source instanceof SourceWithChoicesInterface) {
307        $this->sources[$source_id]['choices'] = $source->getChoices();
308      }
309    }
310
311    return $this->sources;
312  }
313
314  /**
315   * Get providers options for select input.
316   *
317   * @return array
318   *   An associative array with module ID as key and module description as
319   *   value.
320   */
321  private function getProvidersOptions(): array {
322    $options = [];
323
324    foreach ($this->getProviders() as $provider_id => $provider) {
325      $params = [
326        '@name' => $provider['name'],
327        '@count' => $provider['count'],
328      ];
329      $options[$provider_id] = $this->formatPlural($provider['count'], '@name (@count block)', '@name (@count blocks)', $params);
330    }
331
332    return $options;
333  }
334
335  /**
336   * Get all providers.
337   *
338   * @return array
339   *   Drupal modules definitions, keyed by extension ID
340   */
341  private function getProviders(): array {
342    $sources = $this->getSources();
343    $providers = [];
344    $modules = $this->moduleList->getAllInstalledInfo();
345
346    foreach ($sources as $source_data) {
347      if (!isset($source_data['choices'])) {
348        continue;
349      }
350      $choices = $source_data['choices'];
351
352      foreach ($choices as $choice) {
353        $provider = $choice['provider'] ?? '';
354
355        if (!$provider || \in_array($provider, self::HIDE_PROVIDER, TRUE)) {
356          continue;
357        }
358
359        if (!isset($modules[$provider])) {
360          // If the provider is not a module, skip it.
361          continue;
362        }
363
364        if (!isset($providers[$provider])) {
365          $providers[$provider] = $modules[$provider];
366          $providers[$provider]['count'] = 0;
367        }
368        ++$providers[$provider]['count'];
369      }
370    }
371
372    return $providers;
373  }
374
375}