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