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