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