Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
86.25% covered (warning)
86.25%
69 / 80
84.44% covered (warning)
84.44%
76 / 90
18.24% covered (danger)
18.24%
27 / 148
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
PatternPreset
86.25% covered (warning)
86.25%
69 / 80
84.44% covered (warning)
84.44%
76 / 90
18.24% covered (danger)
18.24%
27 / 148
66.67% covered (warning)
66.67%
8 / 12
1254.17
0.00% covered (danger)
0.00%
0 / 1
 getGroup
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
14 / 14
11.54% covered (danger)
11.54%
3 / 26
100.00% covered (success)
100.00%
1 / 1
30.92
 getSummary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getSources
87.50% covered (warning)
87.50%
7 / 8
90.91% covered (success)
90.91%
10 / 11
8.33% covered (danger)
8.33%
2 / 24
0.00% covered (danger)
0.00%
0 / 1
33.73
 getContexts
45.45% covered (danger)
45.45%
5 / 11
77.78% covered (warning)
77.78%
7 / 9
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
5.93
 calculateDependencies
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 areContextsSatisfied
90.91% covered (success)
90.91%
10 / 11
91.67% covered (success)
91.67%
11 / 12
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
8.83
 getContextFromSource
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getContextsFromSlots
70.00% covered (warning)
70.00%
7 / 10
44.44% covered (danger)
44.44%
8 / 18
3.70% covered (danger)
3.70%
2 / 54
0.00% covered (danger)
0.00%
0 / 1
50.75
 getContextsFromSourceItem
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
7 / 7
50.00% covered (danger)
50.00%
4 / 8
100.00% covered (success)
100.00%
1 / 1
6.00
 fillInternalId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
10 / 10
6.25% covered (danger)
6.25%
1 / 16
100.00% covered (success)
100.00%
1 / 1
25.60
 sourcePluginManager
100.00% covered (success)
100.00%
1 / 1
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
 slotSourceProxy
100.00% covered (success)
100.00%
1 / 1
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
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder\Entity;
6
7use Drupal\Component\Plugin\Exception\PluginException;
8use Drupal\Core\Config\Entity\ConfigEntityBase;
9use Drupal\Core\Entity\Attribute\ConfigEntityType;
10use Drupal\Core\Entity\EntityDeleteForm;
11use Drupal\Core\Plugin\Context\ContextDefinitionInterface;
12use Drupal\Core\StringTranslation\TranslatableMarkup;
13use Drupal\display_builder\Form\PatternPresetForm;
14use Drupal\display_builder\SlotSourceProxy;
15use Drupal\display_builder\SourceWithSlotsInterface;
16use Drupal\display_builder_ui\PatternPresetListBuilder;
17use Drupal\ui_patterns\SourcePluginManager;
18
19/**
20 * Defines the Pattern preset entity type.
21 */
22#[ConfigEntityType(
23  id: 'pattern_preset',
24  label: new TranslatableMarkup('Pattern preset'),
25  label_collection: new TranslatableMarkup('Pattern presets'),
26  label_singular: new TranslatableMarkup('Pattern preset'),
27  label_plural: new TranslatableMarkup('Pattern presets'),
28  entity_keys: [
29    'id' => 'id',
30    'label' => 'label',
31    'theme' => 'theme',
32    'description' => 'description',
33    'group' => 'group',
34    'sources' => 'sources',
35    'weight' => 'weight',
36  ],
37  handlers: [
38    'list_builder' => PatternPresetListBuilder::class,
39    'form' => [
40      'add' => PatternPresetForm::class,
41      'edit' => PatternPresetForm::class,
42      'delete' => EntityDeleteForm::class,
43    ],
44  ],
45  links: [
46    'add-form' => '/admin/structure/display-builder/preset/add',
47    'delete-form' => '/admin/structure/display-builder/preset/{pattern_preset}/delete',
48    'collection' => '/admin/structure/display-builder/preset',
49  ],
50  admin_permission: 'administer Pattern preset',
51  constraints: [
52    'ImmutableProperties' => [
53      'id',
54    ],
55  ],
56  config_export: [
57    'id',
58    'label',
59    'description',
60    'group',
61    'sources',
62    'weight',
63  ],
64)]
65final class PatternPreset extends ConfigEntityBase implements PatternPresetInterface {
66
67  /**
68   * The preset ID.
69   */
70  protected string $id;
71
72  /**
73   * The preset label.
74   */
75  protected string $label = '';
76
77  /**
78   * The preset description.
79   */
80  protected string $description = '';
81
82  /**
83   * The preset group.
84   */
85  protected ?string $group = NULL;
86
87  /**
88   * The preset sources.
89   */
90  protected array $sources = [];
91
92  /**
93   * Weight to order the entity in lists.
94   *
95   * @var int
96   */
97  protected $weight = 0;
98
99  /**
100   * The UI Patterns source plugin manager.
101   */
102  protected SourcePluginManager $sourcePluginManager;
103
104  /**
105   * Slot source proxy.
106   */
107  protected SlotSourceProxy $slotSourceProxy;
108
109  /**
110   * {@inheritdoc}
111   */
112  public function getGroup(): ?string {
113    if (isset($this->group) && !empty($this->group)) {
114      return $this->group;
115    }
116
117    if (isset($this->sources['source'], $this->sources['source_id'])) {
118      $configuration = [
119        'settings' => $this->sources['source'] ?? [],
120      ];
121      /** @var \Drupal\ui_patterns\SourceInterface $source */
122      $source = $this->sourcePluginManager()->createInstance($this->sources['source_id'], $configuration);
123
124      // We check if the UI Patterns source plugin has a getGroup() method.
125      // At the moment, this method is not part of SourceInterface but is
126      // anticipated for a future update to the UI Patterns API.
127      // Using method_exists() ensures compatibility in the meantime.
128      if (\method_exists($source, 'getGroup')) {
129        $this->group = (string) $source->getGroup();
130      }
131      $this->save();
132    }
133
134    return !empty($this->group) ? $this->group : NULL;
135  }
136
137  /**
138   * {@inheritdoc}
139   */
140  public function getSummary(): string {
141    $contexts = [];
142    $data = $this->getSources($contexts, FALSE);
143    $data = $this->slotSourceProxy()->getLabelWithSummary($data);
144
145    return $data['summary'] ?: $data['label'];
146  }
147
148  /**
149   * {@inheritdoc}
150   */
151  public function getSources(array $contexts = [], bool $fillInternalId = TRUE): array {
152    $data = $this->get('sources') ?? [];
153
154    if (isset($data[0]) && \count($data) === 1) {
155      $data = \reset($data);
156    }
157
158    if (empty($data) || !isset($data['source_id'])) {
159      return [];
160    }
161
162    if ($fillInternalId) {
163      self::fillInternalId($data);
164    }
165
166    return $data;
167  }
168
169  /**
170   * {@inheritdoc}
171   *
172   * @see \Drupal\Core\Config\Entity\ConfigEntityInterface
173   */
174  public function getContexts(): array {
175    // The root level is a single nestable source plugin.
176    if (!isset($this->sources['source_id']) || !isset($this->sources['source'])) {
177      return [];
178    }
179
180    // In case of a missing or malformed plugin (e.g. after config import),
181    // return empty rather than crashing. Unexpected exceptions are logged.
182    try {
183      return $this->getContextFromSource($this->sources['source_id'], $this->sources['source']);
184    }
185    catch (PluginException) {
186      // Plugin no longer exists or config is malformed â€” silently skip.
187      return [];
188    }
189    catch (\Exception $e) {
190      // Unexpected runtime error from plugin code: log and degrade gracefully.
191      \Drupal::logger('display_builder')->warning(
192        'PatternPreset @id: unexpected exception resolving contexts: @message',
193        ['@id' => $this->id(), '@message' => $e->getMessage()],
194      );
195
196      return [];
197    }
198  }
199
200  /**
201   * {@inheritdoc}
202   */
203  public function calculateDependencies(): self {
204    parent::calculateDependencies();
205
206    // The root level is a single nestable source plugin.
207    if (!isset($this->sources['source_id'])) {
208      return $this;
209    }
210    // This will automatically be done by parent::calculateDependencies() if we
211    // implement EntityWithPluginCollectionInterface.
212    $configuration = [
213      'settings' => $this->sources['source'] ?? [],
214    ];
215    /** @var \Drupal\ui_patterns\SourceInterface $source */
216    $source = $this->sourcePluginManager()->createInstance($this->sources['source_id'], $configuration);
217    $this->addDependencies($source->calculateDependencies());
218
219    return $this;
220  }
221
222  /**
223   * {@inheritdoc}
224   */
225  public function areContextsSatisfied(array $contexts): bool {
226    $context_definitions = $this->getContexts();
227
228    if (empty($context_definitions)) {
229      return TRUE;
230    }
231
232    foreach ($context_definitions as $key => $context_definition) {
233      if (!$context_definition->isRequired()) {
234        continue;
235      }
236
237      if (!\array_key_exists($key, $contexts)) {
238        return FALSE;
239      }
240
241      if (!$context_definition->isSatisfiedBy($contexts[$key])) {
242        return FALSE;
243      }
244    }
245
246    return TRUE;
247  }
248
249  /**
250   * Recursively get the source contexts.
251   *
252   * @param string $source_id
253   *   Source plugin ID.
254   * @param array $source
255   *   Source plugin configuration.
256   *
257   * @return array
258   *   Context definitions of the source.
259   */
260  private function getContextFromSource(string $source_id, array $source): array {
261    /** @var \Drupal\ui_patterns\SourceInterface $source */
262    $source = $this->sourcePluginManager()->createInstance($source_id, ['settings' => $source]);
263
264    if ($source instanceof SourceWithSlotsInterface) {
265      return $this->getContextsFromSlots($source);
266    }
267
268    // @todo Traverse also context switchers.
269    return \array_filter($source->getContextDefinitions(), static function (ContextDefinitionInterface $definition) {
270      return $definition->isRequired();
271    });
272  }
273
274  /**
275   * Go through all slots and props to get the nested sources contexts.
276   *
277   * @param \Drupal\display_builder\SourceWithSlotsInterface $source
278   *   Source plugin.
279   *
280   * @return array
281   *   Context definitions of the source.
282   */
283  private function getContextsFromSlots(SourceWithSlotsInterface $source): array {
284    $contexts = [];
285
286    if (\is_iterable($source)) {
287      foreach ($source as $slot) {
288        foreach (\is_array($slot) ? ($slot['sources'] ?? []) : [] as $source_item) {
289          $contexts = \array_merge($contexts, $this->getContextsFromSourceItem($source_item));
290        }
291      }
292    }
293
294    // @todo Make it generic if SourceWithPropsInterface is introduced in UI Patterns.
295    $component = $source->getSetting('component');
296    $props = \is_array($component) ? ($component['props'] ?? []) : [];
297
298    foreach ($props as $source_item) {
299      $contexts = \array_merge($contexts, $this->getContextsFromSourceItem($source_item));
300    }
301
302    return $contexts;
303  }
304
305  /**
306   * Returns context definitions for a single source item, if valid.
307   *
308   * @param mixed $source_item
309   *   A raw source item, expected to be an array with 'source_id' and 'source'
310   *   keys. Invalid or incomplete items return an empty array.
311   *
312   * @return array
313   *   Context definitions, or an empty array if the item is not a valid source.
314   */
315  private function getContextsFromSourceItem(mixed $source_item): array {
316    if (!\is_array($source_item) || empty($source_item['source']) || empty($source_item['source_id'])) {
317      return [];
318    }
319
320    return $this->getContextFromSource($source_item['source_id'], $source_item['source']);
321  }
322
323  /**
324   * Recursively fill the node_id key.
325   *
326   * @param array $array
327   *   The array reference.
328   */
329  private static function fillInternalId(array &$array): void {
330    if (isset($array['source_id']) && !isset($array['node_id'])) {
331      $array['node_id'] = \bin2hex(\random_bytes(8));
332    }
333
334    foreach ($array as &$value) {
335      if (\is_array($value)) {
336        self::fillInternalId($value);
337      }
338    }
339  }
340
341  /**
342   * Gets the source plugin manager.
343   *
344   * @return \Drupal\ui_patterns\SourcePluginManager
345   *   The source plugin manager.
346   */
347  private function sourcePluginManager(): SourcePluginManager {
348    return $this->sourcePluginManager ??= \Drupal::service('plugin.manager.ui_patterns_source');
349  }
350
351  /**
352   * Slot source proxy.
353   *
354   * @return \Drupal\display_builder\SlotSourceProxy
355   *   The slot source proxy.
356   */
357  private function slotSourceProxy(): SlotSourceProxy {
358    return $this->slotSourceProxy ??= \Drupal::service('display_builder.slot_sources_proxy');
359  }
360
361}