Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
69.74% covered (warning)
69.74%
53 / 76
70.18% covered (warning)
70.18%
40 / 57
11.93% covered (danger)
11.93%
13 / 109
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComponentLibraryDefinitionHelper
69.74% covered (warning)
69.74%
53 / 76
70.18% covered (warning)
70.18%
40 / 57
11.93% covered (danger)
11.93%
13 / 109
60.00% covered (warning)
60.00%
3 / 5
687.53
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
35.48% covered (danger)
35.48%
11 / 31
31.58% covered (danger)
31.58%
6 / 19
14.29% covered (danger)
14.29%
2 / 14
0.00% covered (danger)
0.00%
0 / 1
60.01
 filterAccordingToConfiguration
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
18 / 18
8.97% covered (danger)
8.97%
7 / 78
100.00% covered (success)
100.00%
1 / 1
85.42
 getDefaultValue
62.50% covered (warning)
62.50%
5 / 8
63.64% covered (warning)
63.64%
7 / 11
9.09% covered (danger)
9.09%
1 / 11
0.00% covered (danger)
0.00%
0 / 1
33.05
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    $required = $component->metadata->schema['required'] ?? NULL;
107
108    if (!$required) {
109      return $data;
110    }
111
112    $props = $component->metadata->schema['properties'] ?? [];
113    $source_cache = [];
114
115    foreach ($required as $prop_id) {
116      $prop = $props[$prop_id];
117
118      $default = self::getDefaultValue($prop);
119
120      if ($default === NULL) {
121        return NULL;
122      }
123      $prop_type = $prop['ui_patterns']['type_definition'] ?? NULL;
124
125      if (!$prop_type) {
126        return NULL;
127      }
128      $prop_source_id = $prop_type->getDefaultSourceId();
129
130      if (!$prop_source_id) {
131        return NULL;
132      }
133
134      if (!isset($source_cache[$prop_source_id])) {
135        /** @var \Drupal\ui_patterns\SourceInterface $default_source */
136        $source_cache[$prop_source_id] = $this->sourceManager->createInstance($prop_source_id);
137      }
138      $default_source = $source_cache[$prop_source_id];
139
140      // We bet on widgets having a 'value' configuration. It may be a bit
141      // naive, but the most important is to avoid \Twig\Error\RuntimeError.
142      $definition = $default_source->getPluginDefinition();
143      $tags = ($definition instanceof PluginDefinitionInterface) ? [] : $definition['tags'] ?? [];
144
145      if (!\in_array('widget', $tags, TRUE)) {
146        return NULL;
147      }
148
149      $data['component']['props'][$prop_id] = [
150        'source_id' => $default_source->getPluginId(),
151        'source' => [
152          'value' => $default,
153        ],
154      ];
155    }
156
157    return $data;
158  }
159
160  /**
161   * Filter definitions according to configuration.
162   *
163   * @param string $id
164   *   Component ID, the array key.
165   * @param array $definition
166   *   Component definition, the array value.
167   * @param array $configuration
168   *   Component library panel configuration.
169   * @param string[] $exclude_by_id
170   *   An array of component IDs to exclude.
171   *
172   * @return bool
173   *   Must the component be kept?
174   */
175  private static function filterAccordingToConfiguration(string $id, array $definition, array $configuration, array $exclude_by_id): bool {
176    if (isset($definition['provider']) && \in_array($id, $exclude_by_id, TRUE)) {
177      return FALSE;
178    }
179
180    if (isset($definition['provider']) && \in_array($definition['provider'], $configuration['exclude'], TRUE)) {
181      return FALSE;
182    }
183
184    // Excluded no ui components unless forced.
185    if (isset($definition['noUi']) && $definition['noUi'] === TRUE) {
186      if ((bool) $configuration['include_no_ui'] !== TRUE) {
187        return FALSE;
188      }
189    }
190
191    // Filter components according to configuration.
192    // Components with stable or undefined status will always be available.
193    $allowed_status = \array_merge($configuration['component_status'], ['stable']);
194
195    if (isset($definition['status']) && !\in_array($definition['status'], $allowed_status, TRUE)) {
196      return FALSE;
197    }
198
199    return TRUE;
200  }
201
202  /**
203   * Get default value for prop.
204   *
205   * @param array $prop
206   *   Prop JSON schema definition.
207   *
208   * @return mixed
209   *   NULL if no default value found.
210   */
211  private static function getDefaultValue(array $prop): mixed {
212    // First, we try to get the default value or the first example.
213    $default = $prop['default'] ?? $prop['examples'][0] ?? NULL;
214
215    if ($default !== NULL) {
216      return $default;
217    }
218
219    // Then, we try to get the first value from enumeration.
220    if (isset($prop['enum']) && !empty($prop['enum'])) {
221      return $prop['enum'][0];
222    }
223
224    // Finally, we set the boolean value to false. Boolean is the only JSON
225    // schema type where we can be sure the default value is OK because there is
226    // no additional criteria to deal with:
227    // - string has minLength, maxLength, pattern...
228    // - array has items, minItems, maxItems, uniqueItems...
229    // - object has properties...
230    // - number and integer have multipleOf, minimum, maximum...
231    // There is this weird mechanism in SDC adding the object type to all
232    // props. We need to deal with that until we remove it.
233    // @see \Drupal\Core\Theme\Component\ComponentMetadata::parseSchemaInfo()
234    if ($prop['type'] === 'boolean' || empty(\array_diff($prop['type'], ['object', 'boolean']))) {
235      return FALSE;
236    }
237
238    return NULL;
239  }
240
241}