Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 243
0.00% covered (danger)
0.00%
0 / 90
0.00% covered (danger)
0.00%
0 / 141
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComponentLibraryPanel
0.00% covered (danger)
0.00%
0 / 236
0.00% covered (danger)
0.00%
0 / 90
0.00% covered (danger)
0.00%
0 / 141
0.00% covered (danger)
0.00%
0 / 11
2070
0.00% covered (danger)
0.00%
0 / 1
 create
0.00% covered (danger)
0.00%
0 / 5
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 / 8
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 / 62
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 configurationSummary
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 96
0.00% covered (danger)
0.00%
0 / 1
132
 build
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
30
 getProviders
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getComponentsGrouped
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getComponentsFlat
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getComponentsVariants
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 getComponentsMosaic
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getProvidersOptions
0.00% covered (danger)
0.00%
0 / 11
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
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\Extension\ThemeExtensionList;
9use Drupal\Core\Form\FormStateInterface;
10use Drupal\Core\StringTranslation\TranslatableMarkup;
11use Drupal\Core\Theme\ThemeManagerInterface;
12use Drupal\Core\Url;
13use Drupal\display_builder\Attribute\Island;
14use Drupal\display_builder\ComponentLibraryDefinitionHelper;
15use Drupal\display_builder\InstanceInterface;
16use Drupal\display_builder\Island\IslandConfigurationFormInterface;
17use Drupal\display_builder\Island\IslandConfigurationFormTrait;
18use Drupal\display_builder\Island\IslandPluginBase;
19use Drupal\display_builder\Island\IslandType;
20use Symfony\Component\DependencyInjection\ContainerInterface;
21
22/**
23 * Component library island plugin implementation.
24 */
25#[Island(
26  id: 'component_library',
27  enabled_by_default: TRUE,
28  label: new TranslatableMarkup('Components'),
29  description: new TranslatableMarkup('List of available components.'),
30  type: IslandType::Library,
31  icon: 'puzzle',
32)]
33class ComponentLibraryPanel extends IslandPluginBase implements IslandConfigurationFormInterface {
34
35  use IslandConfigurationFormTrait;
36
37  /**
38   * The theme manager service.
39   */
40  protected ThemeManagerInterface $themeManager;
41
42  /**
43   * The theme extension list service.
44   */
45  protected ThemeExtensionList $themeList;
46
47  /**
48   * The module extension list service.
49   */
50  protected ModuleExtensionList $moduleList;
51
52  /**
53   * The definitions filtered for current theme.
54   *
55   * @var array
56   *   The definitions filtered.
57   */
58  private array $definitionsFiltered = [];
59
60  /**
61   * The definitions filtered and grouped for current theme.
62   *
63   * @var array
64   *   The definitions filtered and grouped.
65   */
66  private array $definitionsGrouped = [];
67
68  /**
69   * The source data for components.
70   *
71   * @var array
72   *   The source data already prepared.
73   */
74  private array $sourcesData = [];
75
76  /**
77   * {@inheritdoc}
78   */
79  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
80    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
81    $instance->themeManager = $container->get('theme.manager');
82    $instance->themeList = $container->get('extension.list.theme');
83    $instance->moduleList = $container->get('extension.list.module');
84
85    return $instance;
86  }
87
88  /**
89   * {@inheritdoc}
90   */
91  public function defaultConfiguration(): array {
92    return [
93      'exclude' => [],
94      'exclude_id' => '',
95      'component_status' => [],
96      'include_no_ui' => FALSE,
97      'show' => 'grouped',
98      'preview' => TRUE,
99    ];
100  }
101
102  /**
103   * {@inheritdoc}
104   */
105  public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
106    $configuration = $this->getConfiguration();
107    $components = $this->sdcManager->getDefinitions();
108
109    // Compatibility layer with previous config.
110    // @todo Remove and replace by config update on next release.
111    if (!isset($configuration['show'])) {
112      $configuration['show'] = $configuration['show_mosaic'] ? 'mosaic' : 'grouped';
113      $configuration['show'] = $configuration['show_variants'] ? 'variants' : $configuration['show'];
114      $configuration['show'] = $configuration['show_grouped'] ? 'grouped' : $configuration['show'];
115    }
116
117    $form['display'] = [
118      '#type' => 'fieldset',
119      '#title' => $this->t('Display'),
120    ];
121
122    $form['display']['show'] = [
123      '#type' => 'select',
124      '#title' => $this->t('Display components as'),
125      '#default_value' => $configuration['show'],
126      '#options' => [
127        'grouped' => $this->t('grouped'),
128        'variants' => $this->t('with variants'),
129        'flat' => $this->t('flat list'),
130        'mosaic' => $this->t('thumbnails mosaic'),
131      ],
132    ];
133
134    $form['display']['preview'] = [
135      '#type' => 'checkbox',
136      '#title' => $this->t('Enable preview on hover'),
137      '#description' => $this->t('Enable or disable the preview of components when hovering over them.'),
138      '#default_value' => $configuration['preview'],
139    ];
140
141    $form['configuration'] = [
142      '#type' => 'fieldset',
143      '#title' => $this->t('Configuration'),
144    ];
145
146    $form['configuration']['exclude'] = [
147      '#type' => 'checkboxes',
148      '#title' => $this->t('Exclude providers'),
149      '#description' => $this->t('Exclude components from specific providers (modules or themes).'),
150      '#options' => $this->getProvidersOptions($components, $this->t('component'), $this->t('components')),
151      '#default_value' => $configuration['exclude'],
152    ];
153
154    $form['configuration']['exclude_id'] = [
155      '#type' => 'textarea',
156      '#title' => $this->t('Exclude by id'),
157      '#description' => $this->t('Provide a list of components id to exclude, one by line, must be prefixed by provider. Example: ui_suite_bootstrap:card_body<br>ui_suite_bootstrap:table_cell.'),
158      '#default_value' => $configuration['exclude_id'],
159    ];
160
161    // @see https://git.drupalcode.org/project/drupal/-/blob/11.x/core/assets/schemas/v1/metadata.schema.json#L239
162    $form['configuration']['component_status'] = [
163      '#type' => 'checkboxes',
164      '#title' => $this->t('Allowed status'),
165      '#options' => [
166        'experimental' => $this->t('Experimental'),
167        'deprecated' => $this->t('Deprecated'),
168        'obsolete' => $this->t('Obsolete'),
169      ],
170      '#description' => $this->t('Components with stable or undefined status will always be available.'),
171      '#default_value' => $configuration['component_status'],
172    ];
173
174    // @see https://git.drupalcode.org/project/drupal/-/blob/11.x/core/assets/schemas/v1/metadata.schema.json#L250
175    // @todo remove 11.3 reference when end of support is reached.
176    $form['configuration']['include_no_ui'] = [
177      '#type' => 'checkbox',
178      '#title' => $this->t('Include non UI'),
179      '#description' => $this->t('Components with `no ui` flag are meant for internal use only. Force to include them. Drupal 11.3+ only.'),
180      '#default_value' => $configuration['include_no_ui'],
181    ];
182
183    return $form;
184  }
185
186  /**
187   * {@inheritdoc}
188   */
189  public function configurationSummary(): array {
190    $configuration = $this->getConfiguration();
191
192    $summary = [];
193
194    $summary[] = $this->t('Components displayed as: <em>@list</em>', [
195      '@list' => match ($configuration['show']) {
196        'variants' => $this->t('variants'),
197        'mosaic' => $this->t('mosaic'),
198        'flat' => $this->t('flat'),
199        default => $this->t('grouped'),
200      },
201    ]);
202
203    if ($configuration['preview']) {
204      $summary[] = $this->t('Preview on hover enabled');
205    }
206
207    $summary[] = $this->t('Excluded providers: @exclude', [
208      '@exclude' => ($exclude = \array_filter($configuration['exclude'] ?? [])) ? \implode(', ', $exclude) : $this->t('None'),
209    ]);
210
211    if (\strlen($configuration['exclude_id'] ?? '') > 5) {
212      $value = \preg_split('/\r\n|\r|\n|\s+/', \trim($configuration['exclude_id'] ?? ''));
213
214      if ($value === FALSE) {
215        $summary[] = $this->t('Component(s) excluded');
216      }
217      else {
218        $summary[] = $this->formatPlural(\count($value), '@count component excluded', '@count components excluded by id');
219      }
220    }
221
222    $summary[] = $this->t('Allowed status: @status', [
223      '@status' => \implode(', ', \array_filter(\array_unique(\array_merge(['stable', 'undefined'], $configuration['component_status'] ?? []))) ?: [$this->t('stable, undefined')]),
224    ]);
225
226    $summary[] = $configuration['include_no_ui'] ? $this->t('Include `No UI` components') : '';
227
228    return $summary;
229  }
230
231  /**
232   * {@inheritdoc}
233   */
234  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
235    $builder_id = (string) $builder->id();
236    // Run a single time and saved as properties to avoid repeating processing
237    // in ::getComponentsMosaic(), ::getComponentsVariants() and
238    // ::getComponentsGrouped().
239    $configuration = $this->getConfiguration();
240
241    $componentDefinitions = new ComponentLibraryDefinitionHelper($this->sdcManager, $this->sourceManager);
242    $definitions = $componentDefinitions->getDefinitions($configuration);
243
244    $this->definitionsFiltered = $definitions['filtered'] ?? [];
245    $this->definitionsGrouped = $definitions['grouped'] ?? [];
246    $this->sourcesData = $definitions['sources'] ?? [];
247
248    $content = match ($configuration['show']) {
249      'mosaic' => $this->getComponentsMosaic($builder_id, (bool) $configuration['preview']),
250      'variants' => $this->getComponentsVariants($builder_id, (bool) $configuration['preview']),
251      'flat' => $this->getComponentsFlat($builder_id, (bool) $configuration['preview']),
252      default => $this->getComponentsGrouped($builder_id, (bool) $configuration['preview']),
253    };
254
255    return [
256      '#type' => 'component',
257      '#component' => 'display_builder:library_panel',
258      '#slots' => [
259        'content' => $content,
260      ],
261    ];
262  }
263
264  /**
265   * Get all providers.
266   *
267   * @param array $definitions
268   *   Plugin definitions.
269   *
270   * @return array
271   *   Drupal extension definitions, keyed by extension ID
272   */
273  protected function getProviders(array $definitions): array {
274    $themes = $this->themeList->getAllInstalledInfo();
275    $modules = $this->moduleList->getAllInstalledInfo();
276    $providers = [];
277
278    foreach ($definitions as $definition) {
279      $provider_id = $definition['provider'];
280
281      $provider = $themes[$provider_id] ?? $modules[$provider_id] ?? NULL;
282
283      if (!$provider) {
284        continue;
285      }
286      $provider['count'] = isset($providers[$provider_id]) ? ($providers[$provider_id]['count']) + 1 : 1;
287      $providers[$provider_id] = $provider;
288    }
289
290    return $providers;
291  }
292
293  /**
294   * Gets the grouped components view.
295   *
296   * @param string $builder_id
297   *   Builder ID.
298   * @param bool $preview
299   *   Whether to show preview on hover.
300   *
301   * @return array
302   *   A renderable array containing the grouped components.
303   */
304  private function getComponentsGrouped(string $builder_id, bool $preview): array {
305    $build = [];
306
307    foreach ($this->definitionsGrouped as $group_name => $group) {
308      $build[] = [
309        '#type' => 'html_tag',
310        '#tag' => 'h4',
311        '#value' => $group_name,
312        '#attributes' => [
313          'class' => ['db-placeholder__group', 'db-filter-hide-on-search'],
314        ],
315      ];
316
317      foreach ($group as $component_id => $definition) {
318        $component_id = (string) $component_id;
319
320        $data = [
321          'source_id' => 'component',
322          'source' => $this->sourcesData[$component_id],
323        ];
324        // Used for search filter.
325        $keywords = \sprintf('%s %s', $definition['label'], $definition['provider']);
326
327        if ($preview) {
328          $component_preview_url = Url::fromRoute('display_builder.api_component_preview', ['component_id' => $component_id]);
329          $build[] = $this->buildPlaceholderListWithPreview($builder_id, $definition['label'], $data, $component_preview_url, $keywords);
330        }
331        else {
332          $build[] = $this->buildPlaceholderList($definition['label'], $data, $keywords);
333        }
334      }
335    }
336
337    return $this->buildDraggables($builder_id, $build);
338  }
339
340  /**
341   * Gets the flat components view.
342   *
343   * @param string $builder_id
344   *   Builder ID.
345   * @param bool $preview
346   *   Whether to show preview on hover.
347   *
348   * @return array
349   *   A renderable array containing the grouped components.
350   */
351  private function getComponentsFlat(string $builder_id, bool $preview): array {
352    $build = [];
353
354    foreach ($this->definitionsFiltered as $component_id => $definition) {
355      $component_id = (string) $component_id;
356
357      $data = [
358        'source_id' => 'component',
359        'source' => $this->sourcesData[$component_id],
360      ];
361      // Used for search filter.
362      $keywords = \sprintf('%s %s', $definition['label'], $definition['provider']);
363
364      if ($preview) {
365        $component_preview_url = Url::fromRoute('display_builder.api_component_preview', ['component_id' => $component_id]);
366        $build[] = $this->buildPlaceholderListWithPreview($builder_id, $definition['label'], $data, $component_preview_url, $keywords);
367      }
368      else {
369        $build[] = $this->buildPlaceholderList($definition['label'], $data, $keywords);
370      }
371    }
372
373    return $this->buildDraggables($builder_id, $build);
374  }
375
376  /**
377   * Gets the components variants view.
378   *
379   * @param string $builder_id
380   *   Builder ID.
381   * @param bool $preview
382   *   Whether to show preview on hover.
383   *
384   * @return array
385   *   A renderable array containing the variants placeholders.
386   */
387  private function getComponentsVariants(string $builder_id, bool $preview): array {
388    $build = [];
389
390    foreach ($this->definitionsFiltered as $component_id => $definition) {
391      $build[] = [
392        '#type' => 'html_tag',
393        '#tag' => 'h4',
394        '#value' => $definition['label'],
395        '#attributes' => [
396          'class' => ['db-placeholder__group'],
397          'data-search-section' => $definition['machineName'],
398        ],
399      ];
400
401      $data = [
402        'source_id' => 'component',
403        'source' => $this->sourcesData[$component_id],
404      ];
405
406      if (!isset($definition['variants'])) {
407        // Used for search filter.
408        $keywords = \sprintf('%s %s', $definition['label'], $definition['provider']);
409
410        if ($preview) {
411          $component_preview_url = Url::fromRoute('display_builder.api_component_preview', ['component_id' => $component_id]);
412          $build_variant = $this->buildPlaceholderListWithPreview($builder_id, $this->t('Default'), $data, $component_preview_url, $keywords);
413        }
414        else {
415          $build_variant = $this->buildPlaceholderList($this->t('Default'), $data, $keywords);
416        }
417
418        $build_variant['#attributes']['data-filter-child'] = $definition['machineName'];
419        $build_variant['#attributes']['data-node-title'] = $definition['label'];
420
421        $build[] = $build_variant;
422
423        continue;
424      }
425
426      foreach ($definition['variants'] ?? [] as $variant_id => $variant) {
427        $params = ['component_id' => $component_id, 'variant_id' => $variant_id];
428        $data['source']['component']['variant_id'] = [
429          'source_id' => 'select',
430          'source' => [
431            'value' => $variant_id,
432          ],
433        ];
434        // Used for search filter.
435        $keywords = \sprintf('%s %s %s', $definition['label'], $variant['title'], $definition['provider']);
436
437        if ($preview) {
438          $component_preview_url = Url::fromRoute('display_builder.api_component_preview', $params);
439          $build_variant = $this->buildPlaceholderListWithPreview($builder_id, $variant['title'], $data, $component_preview_url, $keywords);
440        }
441        else {
442          $build_variant = $this->buildPlaceholderList($variant['title'], $data, $keywords);
443        }
444
445        $build_variant['#attributes']['data-filter-child'] = $definition['machineName'];
446        $build_variant['#attributes']['data-node-title'] = $definition['label'];
447
448        $build[] = $build_variant;
449      }
450    }
451
452    return $this->buildDraggables($builder_id, $build);
453  }
454
455  /**
456   * Gets the mosaic view of components.
457   *
458   * @param string $builder_id
459   *   Builder ID.
460   * @param bool $preview
461   *   Whether to show preview on hover.
462   *
463   * @return array
464   *   A renderable array containing the mosaic view of components.
465   */
466  private function getComponentsMosaic(string $builder_id, bool $preview): array {
467    $components = [];
468
469    foreach (\array_keys($this->definitionsFiltered) as $component_id) {
470      $component_id = (string) $component_id;
471      $component = $this->sdcManager->find($component_id);
472
473      $vals = [
474        'source_id' => 'component',
475        'source' => $this->sourcesData[$component_id],
476      ];
477      $thumbnail = $component->metadata->getThumbnailPath();
478
479      // Used for search filter.
480      $keywords = \sprintf('%s %s', $component->metadata->name, \str_replace(':', ' ', $component_id));
481
482      if ($preview) {
483        $component_preview_url = Url::fromRoute('display_builder.api_component_preview', ['component_id' => $component_id]);
484        $build = $this->buildPlaceholderCardWithPreview($builder_id, $component->metadata->name, $vals, $component_preview_url, $keywords, $thumbnail);
485      }
486      else {
487        $build = $this->buildPlaceholderCard($component->metadata->name, $vals, $keywords, $thumbnail);
488      }
489
490      $build['#attributes']['data-node-title'] = $component->metadata->name;
491      $components[] = $build;
492    }
493
494    return $this->buildDraggables($builder_id, $components, 'mosaic');
495  }
496
497  /**
498   * Get providers options for select input.
499   *
500   * @param array $definitions
501   *   Plugin definitions.
502   * @param string|TranslatableMarkup $singular
503   *   Singular label of the plugins.
504   * @param string|TranslatableMarkup $plural
505   *   Plural label of the plugins.
506   *
507   * @return array
508   *   An associative array with extension ID as key and extension description
509   *   as value.
510   */
511  private function getProvidersOptions(array $definitions, string|TranslatableMarkup $singular = 'definition', string|TranslatableMarkup $plural = 'definitions'): array {
512    $options = [];
513
514    foreach ($this->getProviders($definitions) as $provider_id => $provider) {
515      $params = [
516        '@name' => $provider['name'],
517        '@type' => $provider['type'],
518        '@count' => $provider['count'],
519        '@singular' => $singular,
520        '@plural' => $plural,
521      ];
522      $options[$provider_id] = $this->formatPlural($provider['count'], '@name (@type, @count @singular)', '@name (@type, @count @plural)', $params);
523    }
524
525    return $options;
526  }
527
528}