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