Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
69.86% covered (warning)
69.86%
51 / 73
67.35% covered (warning)
67.35%
33 / 49
18.57% covered (danger)
18.57%
13 / 70
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComponentLibraryDefinitionHelper
69.86% covered (warning)
69.86%
51 / 73
67.35% covered (warning)
67.35%
33 / 49
18.57% covered (danger)
18.57%
13 / 70
60.00% covered (warning)
60.00%
3 / 5
515.93
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefinitions
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
8 / 8
40.00% covered (danger)
40.00%
2 / 5
100.00% covered (success)
100.00%
1 / 1
10.40
 prepareComponentData
32.14% covered (danger)
32.14%
9 / 28
29.41% covered (danger)
29.41%
5 / 17
15.38% covered (danger)
15.38%
2 / 13
0.00% covered (danger)
0.00%
0 / 1
46.77
 filterAccordingToConfiguration
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
14 / 14
16.28% covered (danger)
16.28%
7 / 43
100.00% covered (success)
100.00%
1 / 1
68.68
 getDefaultValue
62.50% covered (warning)
62.50%
5 / 8
55.56% covered (warning)
55.56%
5 / 9
12.50% covered (danger)
12.50%
1 / 8
0.00% covered (danger)
0.00%
0 / 1
30.12
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder;
6
7use Drupal\Component\Plugin\Definition\PluginDefinitionInterface;
8use Drupal\Core\Plugin\Component;
9use Drupal\Core\Theme\ComponentPluginManager;
10use Drupal\ui_patterns\SourcePluginManager;
11use Drupal\ui_patterns\SourceWithChoicesInterface;
12
13/**
14 * Helper class for block library source handling.
15 */
16class ComponentLibraryDefinitionHelper {
17
18  /**
19   * The UI Patterns source plugin manager.
20   */
21  protected SourcePluginManager $sourceManager;
22
23  /**
24   * The UI Patterns source plugin manager.
25   */
26  private ComponentPluginManager $sdcManager;
27
28  public function __construct(ComponentPluginManager $sdcManager, SourcePluginManager $sourceManager) {
29    $this->sdcManager = $sdcManager;
30    $this->sourceManager = $sourceManager;
31  }
32
33  /**
34   * Init filtered and grouped definitions, and source data.
35   *
36   * @param array $configuration
37   *   Component library panel definition.
38   *
39   * @return array
40   *   An associative array of arrays, where each sub array is keyed by
41   *    component ID.
42   */
43  public function getDefinitions(array $configuration): array {
44    /** @var \Drupal\ui_patterns\ComponentPluginManager $uiPatternsSdcManager */
45    $uiPatternsSdcManager = $this->sdcManager;
46    $definitions = $uiPatternsSdcManager->getNegotiatedSortedDefinitions();
47    $filtered = $grouped = $sources = [];
48    $exclude_by_id = \preg_split('/\s+/', \trim($configuration['exclude_id'] ?? '')) ?: [];
49    /** @var \Drupal\ui_patterns\SourceWithChoicesInterface $source */
50    $source = $this->sourceManager->createInstance('component');
51
52    foreach ($definitions as $id => $definition) {
53      if (!self::filterAccordingToConfiguration($id, $definition, $configuration, $exclude_by_id)) {
54        continue;
55      }
56
57      $component = $this->sdcManager->find($id);
58      $data = $this->prepareComponentData($source, $component);
59
60      if ($data === NULL) {
61        // We skip components with at least a required prop without default
62        // value in order to avoid \Twig\Error\RuntimeError.
63        // In UI Patterns 2, we rely on Form API blocking ComponentForm
64        // submission to avoid this unfortunate situation.
65        // In Display Builder, we can render component without submitting
66        // ComponentForm, on preview and builder panels for example.
67        // @todo Do we send a log message?
68        continue;
69      }
70
71      $sources[$id] = $data;
72      $filtered[$id] = $definition;
73      $grouped[(string) $definition['category']][$id] = $definition;
74    }
75
76    // Order list ignoring starting '(' that is used for components names that
77    // are sub components.
78    \uasort($filtered, static function ($a, $b) {
79      $nameA = \ltrim($a['name'] ?? $a['label'], '(');
80
81      return \strnatcasecmp($nameA, \ltrim($b['name'] ?? $b['label'], '('));
82    });
83
84    return [
85      'filtered' => $filtered,
86      'grouped' => $grouped,
87      'sources' => $sources,
88    ];
89  }
90
91  /**
92   * Prepare component data.
93   *
94   * @param \Drupal\ui_patterns\SourceWithChoicesInterface $source
95   *   Component source.
96   * @param \Drupal\Core\Plugin\Component $component
97   *   Component.
98   *
99   * @return ?array
100   *   The component data, with the default value for required props.
101   *   If NULL, that means a required prop has no default value and the
102   *   component will be skipped.
103   */
104  private function prepareComponentData(SourceWithChoicesInterface $source, Component $component): ?array {
105    $data = $source->getChoiceSettings($component->getPluginId());
106    $props = $component->metadata->schema['properties'] ?? [];
107    $source_cache = [];
108
109    foreach ($component->metadata->schema['required'] ?? [] as $prop_id) {
110      $prop = $props[$prop_id];
111
112      $default = self::getDefaultValue($prop);
113
114      if ($default === NULL) {
115        return NULL;
116      }
117      $prop_type = $prop['ui_patterns']['type_definition'] ?? NULL;
118
119      if (!$prop_type) {
120        return NULL;
121      }
122      $prop_source_id = $prop_type->getDefaultSourceId();
123
124      if (!$prop_source_id) {
125        return NULL;
126      }
127
128      if (!isset($source_cache[$prop_source_id])) {
129        /** @var \Drupal\ui_patterns\SourceInterface $default_source */
130        $source_cache[$prop_source_id] = $this->sourceManager->createInstance($prop_source_id);
131      }
132      $default_source = $source_cache[$prop_source_id];
133
134      // We bet on widgets having a 'value' configuration. It may be a bit
135      // naive, but the most important is to avoid \Twig\Error\RuntimeError.
136      $definition = $default_source->getPluginDefinition();
137      $tags = ($definition instanceof PluginDefinitionInterface) ? [] : $definition['tags'] ?? [];
138
139      if (!\in_array('widget', $tags, TRUE)) {
140        return NULL;
141      }
142      $data['component']['props'][$prop_id] = [
143        'source_id' => $default_source->getPluginId(),
144        'source' => [
145          'value' => $default,
146        ],
147      ];
148    }
149
150    return $data;
151  }
152
153  /**
154   * Filter definitions according to configuration.
155   *
156   * @param string $id
157   *   Component ID, the array key.
158   * @param array $definition
159   *   Component definition, the array value.
160   * @param array $configuration
161   *   Component library panel configuration.
162   * @param string[] $exclude_by_id
163   *   An array of component IDs to exclude.
164   *
165   * @return bool
166   *   Must the component be kept?
167   */
168  private static function filterAccordingToConfiguration(string $id, array $definition, array $configuration, array $exclude_by_id): bool {
169    if (isset($definition['provider']) && \in_array($id, $exclude_by_id, TRUE)) {
170      return FALSE;
171    }
172
173    if (isset($definition['provider']) && \in_array($definition['provider'], $configuration['exclude'], TRUE)) {
174      return FALSE;
175    }
176
177    // Excluded no ui components unless forced.
178    if (isset($definition['noUi']) && $definition['noUi'] === TRUE) {
179      if ((bool) $configuration['include_no_ui'] !== TRUE) {
180        return FALSE;
181      }
182    }
183
184    // Filter components according to configuration.
185    // Components with stable or undefined status will always be available.
186    $allowed_status = \array_merge($configuration['component_status'], ['stable']);
187
188    if (isset($definition['status']) && !\in_array($definition['status'], $allowed_status, TRUE)) {
189      return FALSE;
190    }
191
192    return TRUE;
193  }
194
195  /**
196   * Get default value for prop.
197   *
198   * @param array $prop
199   *   Prop JSON schema definition.
200   *
201   * @return mixed
202   *   NULL if no default value found.
203   */
204  private static function getDefaultValue(array $prop): mixed {
205    // First, we try to get teh default value or the first example.
206    $default = $prop['default'] ?? $prop['examples'][0] ?? NULL;
207
208    if ($default !== NULL) {
209      return $default;
210    }
211
212    // Then, we try to get the first value from enumeration.
213    if (isset($prop['enum']) && !empty($prop['enum'])) {
214      return $prop['enum'][0];
215    }
216
217    // Finally, we set the boolean value to false. Boolean is the only JSON
218    // schema type where we can be sure the default value is OK because there is
219    // no additional criteria to deal with:
220    // - string has minLength, maxLength, pattern...
221    // - array has items, minItems, maxItems, uniqueItems...
222    // - object has properties...
223    // - number and integer have multipleOf, minimum, maximum...
224    // There is this weird mechanism in SDC adding the object type to all
225    // props. We need to deal with that until we remove it.
226    // @see \Drupal\Core\Theme\Component\ComponentMetadata::parseSchemaInfo()
227    if ($prop['type'] === 'boolean' || empty(\array_diff($prop['type'], ['object', 'boolean']))) {
228      return FALSE;
229    }
230
231    return NULL;
232  }
233
234}