Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
91.49% covered (success)
91.49%
215 / 235
81.29% covered (warning)
81.29%
126 / 155
49.61% covered (danger)
49.61%
64 / 129
73.17% covered (warning)
73.17%
30 / 41
CRAP
0.00% covered (danger)
0.00%
0 / 1
Instance
91.49% covered (success)
91.49%
215 / 235
81.29% covered (warning)
81.29%
126 / 155
49.61% covered (danger)
49.61%
64 / 129
78.05% covered (warning)
78.05%
32 / 41
1150.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
60.00% covered (warning)
60.00%
3 / 5
50.00% covered (danger)
50.00%
3 / 6
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
6.80
 baseFieldDefinitions
100.00% covered (success)
100.00%
9 / 9
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
 isNew
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
 label
100.00% covered (success)
100.00%
5 / 5
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
 toArray
100.00% covered (success)
100.00%
9 / 9
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
 postCreate
100.00% covered (success)
100.00%
13 / 13
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
 getProfile
100.00% covered (success)
100.00%
3 / 3
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
 setProfile
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
 moveToRoot
77.78% covered (warning)
77.78%
7 / 9
60.00% covered (warning)
60.00%
3 / 5
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
5.67
 moveToSlot
85.71% covered (warning)
85.71%
12 / 14
62.50% covered (warning)
62.50%
5 / 8
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
10.75
 attachToRoot
77.78% covered (warning)
77.78%
7 / 9
33.33% covered (danger)
33.33%
2 / 6
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
6.80
 attachToSlot
87.50% covered (warning)
87.50%
14 / 16
54.55% covered (warning)
54.55%
6 / 11
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
16.76
 getNode
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
 getParentId
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
 setSource
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
 setThirdPartySettings
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
 remove
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
9 / 9
60.00% covered (warning)
60.00%
3 / 5
100.00% covered (success)
100.00%
1 / 1
5.02
 getContexts
100.00% covered (success)
100.00%
3 / 3
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
 setSave
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
1
 getCurrentState
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
 restore
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
 undo
100.00% covered (success)
100.00%
13 / 13
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
 redo
100.00% covered (success)
100.00%
10 / 10
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
 clear
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
 getPast
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
4 / 4
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
2.15
 getFuture
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
4 / 4
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
2.15
 getUsers
88.89% covered (warning)
88.89%
8 / 9
83.33% covered (warning)
83.33%
10 / 12
11.11% covered (danger)
11.11%
1 / 9
0.00% covered (danger)
0.00%
0 / 1
31.28
 isPublishable
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
5 / 5
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 hasSaveContextsRequirement
100.00% covered (success)
100.00%
6 / 6
90.00% covered (success)
90.00%
9 / 10
18.75% covered (danger)
18.75%
3 / 16
100.00% covered (success)
100.00%
1 / 1
18.41
 isPublished
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
 isPublishedPresent
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
7 / 7
33.33% covered (danger)
33.33%
2 / 6
100.00% covered (success)
100.00%
1 / 1
8.74
 getPathIndex
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
 setNewPresent
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
11 / 11
56.25% covered (warning)
56.25%
9 / 16
100.00% covered (success)
100.00%
1 / 1
9.01
 getCurrent
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
 getUniqId
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
 getSourceTree
100.00% covered (success)
100.00%
3 / 3
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
 sampleEntityGenerator
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
 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
 nodeLabel
100.00% covered (success)
100.00%
2 / 2
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
2.50
 currentUser
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
 refreshContexts
30.00% covered (danger)
30.00%
3 / 10
45.45% covered (danger)
45.45%
5 / 11
14.29% covered (danger)
14.29%
1 / 7
0.00% covered (danger)
0.00%
0 / 1
20.74
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder\Entity;
6
7use Drupal\Core\Entity\Attribute\ContentEntityType;
8use Drupal\Core\Entity\ContentEntityBase;
9use Drupal\Core\Entity\EntityStorageInterface;
10use Drupal\Core\Entity\EntityTypeInterface;
11use Drupal\Core\Entity\EntityTypeManagerInterface;
12use Drupal\Core\Field\BaseFieldDefinition;
13use Drupal\Core\Plugin\Context\EntityContext;
14use Drupal\Core\Session\AccountInterface;
15use Drupal\Core\StringTranslation\TranslatableMarkup;
16use Drupal\display_builder\Exception\InvalidNodeException;
17use Drupal\display_builder\InstanceAccessControlHandler;
18use Drupal\display_builder\InstanceInterface;
19use Drupal\display_builder\InstanceStorage;
20use Drupal\display_builder\Plugin\Field\FieldType\HistoryStep;
21use Drupal\display_builder\SlotSourceProxy;
22use Drupal\display_builder\SourceTree;
23use Drupal\display_builder_ui\InstanceListBuilder;
24use Drupal\ui_patterns\Entity\SampleEntityGeneratorInterface;
25use Drupal\ui_patterns\Plugin\Context\RequirementsContext;
26
27/**
28 * Defines the display builder instance entity class.
29 */
30#[ContentEntityType(
31  id: 'display_builder_instance',
32  label: new TranslatableMarkup('Display Builder instance'),
33  label_collection: new TranslatableMarkup('Display builder instances'),
34  label_singular: new TranslatableMarkup('display builder instance'),
35  label_plural: new TranslatableMarkup('display builder instances'),
36  entity_keys: [
37    'id' => 'id',
38  ],
39  handlers: [
40    'access' => InstanceAccessControlHandler::class,
41    'storage' => InstanceStorage::class,
42    // Managed by display_builder_ui.
43    'list_builder' => InstanceListBuilder::class,
44  ],
45  links: [
46    // Managed by display_builder_ui.
47    'collection' => '/admin/structure/display-builder/instances',
48  ],
49  label_count: [
50    'singular' => '@count instance',
51    'plural' => '@count instances',
52  ],
53)]
54class Instance extends ContentEntityBase implements InstanceInterface {
55
56  private const MAX_HISTORY = 10;
57
58  /**
59   * Current user.
60   */
61  public AccountInterface $currentUser;
62
63  /**
64   * Path index.
65   *
66   * A mapping where each key is an slot source node ID and each value has
67   * two properties:
68   * - path: the path
69   * - parent: the node ID of the parent. This is necessary because not every
70   *   SourceWithSlotsInterface implementations has the same "deepness". For
71   *   example, ComponentSource has 4 levels (component, slots, slot_id,
72   *   'sources), LayoutSource has 2 levels (regions, slot_id), etc.
73   */
74  protected array $pathIndex = [];
75
76  /**
77   * Entity type manager.
78   */
79  protected EntityTypeManagerInterface $entityTypeManager;
80
81  /**
82   * Sample entity generator.
83   */
84  protected SampleEntityGeneratorInterface $sampleEntityGenerator;
85
86  /**
87   * Slot source proxy for resolving node labels.
88   */
89  private SlotSourceProxy $slotSourceProxy;
90
91  /**
92   * Cached normalized source tree for the current present state.
93   *
94   * Stays valid after mutations (index=FALSE path) and is cleared on undo/redo
95   * when the present pointer jumps to a different history step.
96   */
97  private ?SourceTree $sourceTree = NULL;
98
99  /**
100   * {@inheritdoc}
101   */
102  public function __construct(array $values, mixed $entity_type, mixed $bundle = FALSE, mixed $translations = []) {
103    parent::__construct($values, $entity_type, $bundle, $translations);
104    $fields = $this->fieldDefinitions;
105
106    foreach ($values as $key => $value) {
107      if (isset($fields[$key])) {
108        $this->set($key, $value);
109      }
110    }
111  }
112
113  /**
114   * {@inheritdoc}
115   */
116  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
117    $fields = parent::baseFieldDefinitions($entity_type);
118    // Override from ContentEntityBase.
119    $fields['id'] = BaseFieldDefinition::create('string');
120    // @todo replace by entity_reference.
121    $fields['profileId'] = BaseFieldDefinition::create('string');
122    $fields['contexts'] = BaseFieldDefinition::create('map');
123    // @todo replace by Revisions API.
124    $fields['present'] = BaseFieldDefinition::create('step');
125    $fields['past'] = BaseFieldDefinition::create('step')->setCardinality(-1);
126    $fields['future'] = BaseFieldDefinition::create('step')->setCardinality(-1);
127    $fields['save'] = BaseFieldDefinition::create('step');
128
129    return $fields;
130  }
131
132  /**
133   * {@inheritdoc}
134   */
135  public function isNew(): bool {
136    // We don't support enforceIsNew property because we have no practical
137    // use of it and because it seems to break the invalidation of
138    // ::getCacheTags().
139    return !$this->id();
140  }
141
142  /**
143   * {@inheritdoc}
144   */
145  public function label() {
146    // Extract a human readable name from an instance id.
147    // Example: "provider__my_display" -> "My display".
148    $parts = \explode('__', (string) $this->id());
149
150    if (\count($parts) > 1) {
151      \array_shift($parts);
152
153      return \ucfirst(\implode(' ', \str_replace('_', ' ', $parts)));
154    }
155
156    return (string) $this->id();
157  }
158
159  /**
160   * {@inheritdoc}
161   *
162   * @see \Drupal\Core\Entity\EntityInterface
163   */
164  public function toArray(): array {
165    return [
166      'id' => $this->id(),
167      'profileId' => $this->get('profileId')->getString(),
168      'contexts' => $this->get('contexts')->first()?->getValue(),
169      'past' => $this->get('past')->getValue(),
170      'present' => $this->get('present')->first()?->getValue(),
171      'future' => $this->get('future')->getValue(),
172      'save' => $this->get('save')->first()?->getValue(),
173    ];
174  }
175
176  /**
177   * {@inheritdoc}
178   *
179   * @see \Drupal\Core\Entity\EntityInterface
180   */
181  public function postCreate(EntityStorageInterface $storage): void {
182    if ($this->get('present')->isEmpty()) {
183      return;
184    }
185    /** @var \Drupal\display_builder\Plugin\Field\FieldType\HistoryStep $present */
186    $present = $this->get('present')->first();
187    $this->sourceTree = new SourceTree($present->getData() ?? []);
188    $indexed = $this->sourceTree->getTree();
189    $hash = self::getUniqId($indexed);
190
191    $this->set('present', [
192      'data' => $indexed,
193      'hash' => $hash,
194      'log' => $present->getLog(),
195      'time' => $present->getTime(),
196      'user' => $present->getUser(),
197    ]);
198  }
199
200  /**
201   * {@inheritdoc}
202   */
203  public function getProfile(): ?ProfileInterface {
204    $profile_id = $this->get('profileId')->getString();
205    /** @var \Drupal\display_builder\Entity\ProfileInterface $profile */
206    $profile = $this->entityTypeManager()->getStorage('display_builder_profile')->load($profile_id);
207
208    return $profile;
209  }
210
211  /**
212   * {@inheritdoc}
213   */
214  public function setProfile(string $profile_id): void {
215    $this->set('profileId', $profile_id);
216  }
217
218  /**
219   * {@inheritdoc}
220   */
221  public function moveToRoot(string $node_id, int $position): bool {
222    $tree = $this->getSourceTree();
223    $data = $tree->getNodeData($node_id);
224
225    if (!$data) {
226      return FALSE;
227    }
228
229    if (!$tree->moveToRoot($node_id, $position)) {
230      return FALSE;
231    }
232
233    $log = new TranslatableMarkup('@label moved to root', ['@label' => $this->nodeLabel($data)]);
234    $this->setNewPresent($tree->getTree(), $log, TRUE, FALSE);
235
236    return TRUE;
237  }
238
239  /**
240   * {@inheritdoc}
241   */
242  public function moveToSlot(string $node_id, string $parent_id, string $slot_id, int $position): bool {
243    $tree = $this->getSourceTree();
244    $data = $tree->getNodeData($node_id);
245
246    if (!$data) {
247      return FALSE;
248    }
249
250    if (!$tree->moveToSlot($node_id, $parent_id, $slot_id, $position)) {
251      return FALSE;
252    }
253
254    $parentData = $tree->getNodeData($parent_id);
255    $log = new TranslatableMarkup('@label moved to slot @slot_id in @parent_label', [
256      '@label' => $this->nodeLabel($data),
257      '@slot_id' => $slot_id,
258      '@parent_label' => $parentData ? $this->nodeLabel($parentData) : $parent_id,
259    ]);
260
261    $this->setNewPresent($tree->getTree(), $log, TRUE, FALSE);
262
263    return TRUE;
264  }
265
266  /**
267   * {@inheritdoc}
268   */
269  public function attachToRoot(int $position, string $source_id, array $data, array $third_party_settings = []): string {
270    $tree = $this->getSourceTree();
271    $node_id = $tree->attachToRoot($position, $source_id, $data);
272
273    if ($third_party_settings) {
274      foreach ($third_party_settings as $island_id => $settings) {
275        $tree->setThirdPartySettings($node_id, $island_id, $settings);
276      }
277    }
278
279    $nodeData = $tree->getNodeData($node_id);
280    $log = new TranslatableMarkup('@label attached to root', ['@label' => $this->nodeLabel($nodeData ?? [])]);
281    $this->setNewPresent($tree->getTree(), $log, FALSE, FALSE);
282
283    return $node_id;
284  }
285
286  /**
287   * {@inheritdoc}
288   */
289  public function attachToSlot(string $parent_id, string $slot_id, int $position, string $source_id, array $data, array $third_party_settings = []): string {
290    $tree = $this->getSourceTree();
291    $node_id = $tree->attachToSlot($parent_id, $slot_id, $position, $source_id, $data);
292
293    if (!$node_id) {
294      throw new InvalidNodeException('Parent or slot not found');
295    }
296
297    if ($third_party_settings) {
298      foreach ($third_party_settings as $island_id => $settings) {
299        $tree->setThirdPartySettings($node_id, $island_id, $settings);
300      }
301    }
302
303    $nodeData = $tree->getNodeData($node_id);
304    $parentData = $tree->getNodeData($parent_id);
305    $log = new TranslatableMarkup('@label attached to slot @slot_id in @parent_label', [
306      '@label' => $this->nodeLabel($nodeData ?? []),
307      '@slot_id' => $slot_id,
308      '@parent_label' => $parentData ? $this->nodeLabel($parentData) : $parent_id,
309    ]);
310    $this->setNewPresent($tree->getTree(), $log, TRUE, FALSE);
311
312    return $node_id;
313  }
314
315  /**
316   * {@inheritdoc}
317   */
318  public function getNode(string $node_id): array {
319    return $this->getSourceTree()->getNode($node_id) ?? [];
320  }
321
322  /**
323   * {@inheritdoc}
324   */
325  public function getParentId(string $node_id): ?string {
326    return $this->getPathIndex()[$node_id]['parent'] ?? NULL;
327  }
328
329  /**
330   * {@inheritdoc}
331   */
332  public function setSource(string $node_id, string $source_id, array $data): void {
333    $tree = $this->getSourceTree();
334
335    if (!$tree->setSource($node_id, $source_id, $data)) {
336      throw new InvalidNodeException('Internal node ID mismatch');
337    }
338
339    $nodeData = $tree->getNodeData($node_id);
340    $log = new TranslatableMarkup('@label updated config', ['@label' => $this->nodeLabel($nodeData ?? [])]);
341    $this->setNewPresent($tree->getTree(), $log, TRUE, FALSE);
342  }
343
344  /**
345   * {@inheritdoc}
346   */
347  public function setThirdPartySettings(string $node_id, string $island_id, array $data): void {
348    $tree = $this->getSourceTree();
349    $nodeData = $tree->getNodeData($node_id);
350
351    if (!$tree->setThirdPartySettings($node_id, $island_id, $data)) {
352      return;
353    }
354
355    $log = new TranslatableMarkup('@label settings updated by @island_id', [
356      '@label' => $this->nodeLabel($nodeData),
357      '@island_id' => $island_id,
358    ]);
359    $this->setNewPresent($tree->getTree(), $log, TRUE, FALSE);
360  }
361
362  /**
363   * {@inheritdoc}
364   */
365  public function remove(string $node_id): void {
366    $tree = $this->getSourceTree();
367    $data = $tree->getNodeData($node_id);
368
369    if (!$data) {
370      return;
371    }
372    $parent_id = $tree->getParentId($node_id);
373
374    $tree->remove($node_id);
375
376    $parentData = $parent_id === NULL ? NULL : $tree->getNodeData($parent_id);
377    $log = new TranslatableMarkup('@label removed from @parent_label', [
378      '@label' => $this->nodeLabel($data),
379      '@parent_label' => $parentData ? $this->nodeLabel($parentData) : 'root',
380    ]);
381    $this->setNewPresent($tree->getTree(), $log, FALSE, FALSE);
382  }
383
384  /**
385   * {@inheritdoc}
386   */
387  public function getContexts(): array {
388    if ($this->get('contexts')->isEmpty()) {
389      return [];
390    }
391
392    return $this->refreshContexts($this->get('contexts')->first()->getValue());
393  }
394
395  /**
396   * {@inheritdoc}
397   */
398  public function setSave(array $save_data): void {
399    $tree = new SourceTree($save_data);
400    $indexed = $tree->getTree();
401    $hash = self::getUniqId($indexed);
402    $this->set('save', ['data' => $indexed, 'hash' => $hash, 'log' => NULL, 'time' => \time(), 'user' => NULL]);
403  }
404
405  /**
406   * {@inheritdoc}
407   *
408   * @see \Drupal\display_builder\HistoryInterface
409   */
410  public function getCurrentState(): array {
411    return $this->getCurrent()?->getData() ?? [];
412  }
413
414  /**
415   * {@inheritdoc}
416   */
417  public function restore(): void {
418    /** @var \Drupal\display_builder\Plugin\Field\FieldType\HistoryStep|null $first */
419    $first = $this->get('save')->first();
420    $this->setNewPresent($first->getData(), new TranslatableMarkup('Back to saved data.'));
421  }
422
423  /**
424   * {@inheritdoc}
425   *
426   * @see \Drupal\display_builder\HistoryInterface
427   */
428  public function undo(): void {
429    $past = $this->get('past');
430
431    if ($past->isEmpty()) {
432      return;
433    }
434
435    $present_values = $this->get('present')->getValue();
436    \assert(\array_is_list($present_values));
437
438    // Remove the last element from the past.
439    $past_values = $past->getValue();
440    \assert(\array_is_list($past_values));
441    $last = \array_pop($past_values);
442    \assert(!\array_is_list($last));
443    $this->set('past', $past_values);
444
445    // Set the present to the element we removed in the previous step.
446    $this->set('present', $last);
447    // Insert the old present state at the beginning of the future.
448    $this->set('future', \array_merge($present_values, $this->get('future')->getValue()));
449    $this->sourceTree = NULL;
450  }
451
452  /**
453   * {@inheritdoc}
454   *
455   * @see \Drupal\display_builder\HistoryInterface
456   */
457  public function redo(): void {
458    $future = $this->get('future');
459
460    if ($future->isEmpty()) {
461      return;
462    }
463
464    // Remove the first element from the future.
465    $first = $future->first()->getValue();
466    \assert(!\array_is_list($first));
467    $future->removeItem(0);
468    // Insert the old present state at the end of the past.
469    $this->get('past')->appendItem($this->get('present')->first());
470    // Set the present to the element we removed in the previous step.
471    $this->set('present', $first);
472    $this->set('future', $future->getValue());
473    $this->sourceTree = NULL;
474  }
475
476  /**
477   * {@inheritdoc}
478   */
479  public function clear(): void {
480    $this->set('past', NULL);
481    $this->set('future', NULL);
482  }
483
484  /**
485   * {@inheritdoc}
486   *
487   * @see \Drupal\display_builder\HistoryInterface
488   */
489  public function getPast(): array {
490    $past = [];
491
492    foreach ($this->get('past') as $step) {
493      $past[] = $step;
494    }
495
496    return $past;
497  }
498
499  /**
500   * {@inheritdoc}
501   *
502   * @see \Drupal\display_builder\HistoryInterface
503   */
504  public function getFuture(): array {
505    $future = [];
506
507    foreach ($this->get('future') as $step) {
508      $future[] = $step;
509    }
510
511    return $future;
512  }
513
514  /**
515   * {@inheritdoc}
516   */
517  public function getUsers(): array {
518    $users = [];
519    $steps = \array_merge($this->get('past')->getValue(), $this->get('present')->getValue(), $this->get('future')->getValue());
520
521    foreach ($steps as $step) {
522      if ($step === NULL) {
523        continue;
524      }
525      $user_id = $step['user'];
526
527      if ($user_id !== NULL && (!isset($users[$user_id]) || $step['time'] > $users[$user_id])) {
528        $users[$user_id] = $step['time'];
529      }
530    }
531
532    return $users;
533  }
534
535  /**
536   * {@inheritdoc}
537   */
538  public function isPublishable(): bool {
539    $contexts = $this->getContexts();
540
541    if (!\array_key_exists('context_requirements', $contexts)
542      || !($contexts['context_requirements'] instanceof RequirementsContext)) {
543      return FALSE;
544    }
545
546    return TRUE;
547  }
548
549  /**
550   * {@inheritdoc}
551   */
552  public function hasSaveContextsRequirement(string $key, array $contexts = []): bool {
553    $contexts = empty($contexts) ? $this->getContexts() : $contexts;
554
555    if (!\array_key_exists('context_requirements', $contexts)
556      || !($contexts['context_requirements'] instanceof RequirementsContext)
557      || !$contexts['context_requirements']->hasValue($key)) {
558      return FALSE;
559    }
560
561    return TRUE;
562  }
563
564  /**
565   * {@inheritdoc}
566   */
567  public function isPublished(): bool {
568    return !$this->get('save')->isEmpty();
569  }
570
571  /**
572   * {@inheritdoc}
573   */
574  public function isPublishedPresent(): bool {
575    $present = $this->get('present');
576    $save = $this->get('save');
577
578    // If either present or save is null, they can't be equal unless both are
579    // null.
580    if ($present->isEmpty() || $save->isEmpty()) {
581      return $present->isEmpty() && $save->isEmpty();
582    }
583
584    /** @var \Drupal\display_builder\Plugin\Field\FieldType\HistoryStep|null $present */
585    $present = $present->first();
586    /** @var \Drupal\display_builder\Plugin\Field\FieldType\HistoryStep|null $save */
587    $save = $save->first();
588
589    return $present->getHash() === $save->getHash();
590  }
591
592  /**
593   * {@inheritdoc}
594   */
595  public function getPathIndex(): array {
596    return $this->getSourceTree()->getPathIndex();
597  }
598
599  /**
600   * {@inheritdoc}
601   *
602   * @see \Drupal\display_builder\HistoryInterface
603   */
604  public function setNewPresent(array $data, string|\Stringable $log_message = '', bool $check_hash = TRUE, bool $index = TRUE): void {
605    if ($index) {
606      // Raw data needs normalizing; build tree and keep it as the new cache.
607      $this->sourceTree = new SourceTree($data);
608      $data = $this->sourceTree->getTree();
609    }
610    // When index=FALSE the data was produced by the cached tree's getTree(),
611    // so $this->sourceTree already reflects the new state â€” no invalidation.
612    $hash = self::getUniqId($data);
613
614    if (!$this->get('present')->isEmpty()) {
615      /** @var \Drupal\display_builder\Plugin\Field\FieldType\HistoryStep|null $present */
616      $present = $this->get('present')->first();
617
618      // Check if this present is the same to avoid duplicates, for example move
619      // to the same place.
620      if ($check_hash && $hash === $present->getHash()) {
621        return;
622      }
623
624      // 1. Insert the present at the end of the past.
625      // If it's the very first action, we want a NULL in the past to be able to
626      // undo to initial empty state.
627      $this->get('past')->appendItem($present->getValue());
628    }
629
630    // Keep only the last x history.
631    if ($this->get('past')->count() > self::MAX_HISTORY) {
632      $this->get('past')->removeItem(0);
633    }
634
635    // 2. Set the present to the new state.
636    $this->set('present', [
637      'data' => $data,
638      'hash' => $hash,
639      'log' => $log_message,
640      'time' => \time(),
641      'user' => (int) $this->currentUser()->id(),
642    ]);
643
644    // 3. Clear the future.
645    $this->set('future', []);
646  }
647
648  /**
649   * {@inheritdoc}
650   *
651   * @see \Drupal\display_builder\HistoryInterface
652   */
653  public function getCurrent(): ?HistoryStep {
654    /** @var \Drupal\display_builder\Plugin\Field\FieldType\HistoryStep|null $step */
655    $step = $this->get('present')->first();
656
657    return $step;
658  }
659
660  /**
661   * {@inheritdoc}
662   */
663  public static function getUniqId(array $data): int {
664    return \crc32((string) \serialize($data));
665  }
666
667  /**
668   * Get or create the cached source tree for the current present state.
669   *
670   * The cache is populated lazily on first access and remains valid until
671   * undo() or redo() changes the present pointer to a different history step.
672   * Mutations (index=FALSE path) keep it alive since the tree is the source
673   * of the new present data. The index=TRUE path in setNewPresent() replaces
674   * it with a freshly normalized tree.
675   *
676   * @return \Drupal\display_builder\SourceTree
677   *   The source tree for the current state.
678   */
679  private function getSourceTree(): SourceTree {
680    if ($this->sourceTree === NULL) {
681      $this->sourceTree = new SourceTree($this->getCurrentState());
682    }
683
684    return $this->sourceTree;
685  }
686
687  /**
688   * Sample entity generator.
689   */
690  private function sampleEntityGenerator(): SampleEntityGeneratorInterface {
691    return $this->sampleEntityGenerator ??= \Drupal::service('ui_patterns.sample_entity_generator');
692  }
693
694  /**
695   * Slot source proxy lazy loader.
696   */
697  private function slotSourceProxy(): SlotSourceProxy {
698    return $this->slotSourceProxy ??= \Drupal::service('display_builder.slot_sources_proxy');
699  }
700
701  /**
702   * Returns the human-readable label for a node, falling back to source_id.
703   */
704  private function nodeLabel(array $data): string {
705    $label = $this->slotSourceProxy()->getLabelWithSummary($data, [], TRUE)['label'];
706
707    return $label !== '' ? $label : ($data['source_id'] ?? '');
708  }
709
710  /**
711   * Slot source proxy.
712   */
713  private function currentUser(): AccountInterface {
714    return $this->currentUser ??= \Drupal::service('current_user');
715  }
716
717  /**
718   * Refresh contexts after loaded from storage.
719   *
720   * @param \Drupal\Core\Plugin\Context\ContextInterface[] $contexts
721   *   The contexts.
722   *
723   * @throws \Drupal\Component\Plugin\Exception\ContextException
724   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
725   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
726   *
727   * @return array
728   *   The refreshed contexts or NULL if no context.
729   */
730  private function refreshContexts(array $contexts): array {
731    foreach ($contexts as &$context) {
732      if ($context instanceof EntityContext) {
733        // @todo We should use cache entries here
734        // with the corresponding cache contexts in it.
735        // This may avoid some unnecessary entity loads or generation.
736        $entity = $context->getContextValue();
737
738        // Check if sample entity.
739        if ($entity->id()) {
740          $entity = $this->entityTypeManager()->getStorage($entity->getEntityTypeId())->load($entity->id());
741        }
742        else {
743          $entity = $this->sampleEntityGenerator()->get($entity->getEntityTypeId(), $entity->bundle());
744        }
745
746        // Edge case when the parent entity is deleted but not the builder
747        // instance.
748        if (!$entity) {
749          return $contexts;
750        }
751        $context = (\get_class($context))::fromEntity($entity);
752      }
753    }
754
755    return $contexts;
756  }
757
758}