Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 260
0.00% covered (danger)
0.00%
0 / 127
0.00% covered (danger)
0.00%
0 / 98
0.00% covered (danger)
0.00%
0 / 44
CRAP
0.00% covered (danger)
0.00%
0 / 1
Instance
0.00% covered (danger)
0.00%
0 / 260
0.00% covered (danger)
0.00%
0 / 127
0.00% covered (danger)
0.00%
0 / 98
0.00% covered (danger)
0.00%
0 / 44
7656
0.00% covered (danger)
0.00%
0 / 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
 toArray
0.00% covered (danger)
0.00%
0 / 9
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
 postCreate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getProfile
0.00% covered (danger)
0.00%
0 / 2
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
 setProfile
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
 moveToRoot
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 moveToSlot
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 attachToRoot
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 attachToSlot
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 get
0.00% covered (danger)
0.00%
0 / 4
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
 getParentId
0.00% covered (danger)
0.00%
0 / 4
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
 setSource
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 setThirdPartySettings
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 remove
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getContexts
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
 setSave
0.00% covered (danger)
0.00%
0 / 2
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
 getCurrentState
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
 restore
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
 undo
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 redo
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 clear
0.00% covered (danger)
0.00%
0 / 2
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
 getCountPast
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
 getCountFuture
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
 getUsers
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 canSaveContextsRequirement
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 hasSaveContextsRequirement
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 hasSave
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
 saveIsCurrent
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
 getPathIndex
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 setNewPresent
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getCurrent
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
 getUniqId
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
 buildIndexFromSlot
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 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
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
 currentUser
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
 refreshContexts
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 changeSourcePositionInSlot
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getNodeId
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 buildIndexFromSource
0.00% covered (danger)
0.00%
0 / 16
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
 doAttachToRoot
0.00% covered (danger)
0.00%
0 / 2
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
 doAttachToSlot
0.00% covered (danger)
0.00%
0 / 6
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
 doRemove
0.00% covered (danger)
0.00%
0 / 6
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
 getPath
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
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder\Entity;
6
7use Drupal\Component\Render\FormattableMarkup;
8use Drupal\Component\Utility\NestedArray;
9use Drupal\Core\Entity\Attribute\EntityType;
10use Drupal\Core\Entity\EntityBase;
11use Drupal\Core\Entity\EntityStorageInterface;
12use Drupal\Core\Entity\EntityTypeManagerInterface;
13use Drupal\Core\Plugin\Context\EntityContext;
14use Drupal\Core\Session\AccountInterface;
15use Drupal\Core\StringTranslation\TranslatableMarkup;
16use Drupal\display_builder\HistoryStep;
17use Drupal\display_builder\InstanceAccessControlHandler;
18use Drupal\display_builder\InstanceInterface;
19use Drupal\display_builder\InstanceStorage;
20use Drupal\display_builder\ProfileInterface;
21use Drupal\display_builder\SlotSourceProxy;
22use Drupal\display_builder_ui\InstanceListBuilder;
23use Drupal\ui_patterns\Entity\SampleEntityGeneratorInterface;
24use Drupal\ui_patterns\Plugin\Context\RequirementsContext;
25
26/**
27 * Defines the display builder instance entity class.
28 */
29#[EntityType(
30  id: 'display_builder_instance',
31  label: new TranslatableMarkup('Display Builder instance'),
32  label_collection: new TranslatableMarkup('Display builder instances'),
33  label_singular: new TranslatableMarkup('display builder instance'),
34  label_plural: new TranslatableMarkup('display builder instances'),
35  entity_keys: [
36    'label' => 'id',
37  ],
38  handlers: [
39    'access' => InstanceAccessControlHandler::class,
40    'storage' => InstanceStorage::class,
41    // Managed by display_builder_ui.
42    'list_builder' => InstanceListBuilder::class,
43  ],
44  links: [
45    // Managed by display_builder_ui.
46    'collection' => '/admin/structure/display-builder/instances',
47  ],
48  label_count: [
49    'singular' => '@count instance',
50    'plural' => '@count instances',
51  ],
52)]
53class Instance extends EntityBase implements InstanceInterface {
54
55  private const MAX_HISTORY = 10;
56
57  /**
58   * Entity ID.
59   */
60  protected string $id;
61
62  /**
63   * Entity label.
64   */
65  protected string $label;
66
67  /**
68   * Display Builder profile ID.
69   */
70  protected string $profileId = '';
71
72  /**
73   * Past steps.
74   *
75   * @var \Drupal\display_builder\HistoryStep[]
76   */
77  protected array $past = [];
78
79  /**
80   * Present step.
81   */
82  protected ?HistoryStep $present = NULL;
83
84  /**
85   * Future steps.
86   *
87   * @var \Drupal\display_builder\HistoryStep[]
88   */
89  protected array $future = [];
90
91  /**
92   * Contexts.
93   *
94   * @var \Drupal\Core\Plugin\Context\ContextInterface[]
95   *   An array of contexts, keyed by context name.
96   */
97  protected array $contexts = [];
98
99  /**
100   * Saved step.
101   */
102  protected ?HistoryStep $save = NULL;
103
104  /**
105   * Path index.
106   *
107   * A mapping where each key is an slot source node ID and each value is
108   * the path where this source is located in the data state.
109   */
110  protected array $pathIndex = [];
111
112  /**
113   * Entity type manager.
114   */
115  protected EntityTypeManagerInterface $entityTypeManager;
116
117  /**
118   * Sample entity generator.
119   */
120  protected SampleEntityGeneratorInterface $sampleEntityGenerator;
121
122  /**
123   * Slot source proxy.
124   */
125  protected SlotSourceProxy $slotSourceProxy;
126
127  /**
128   * Current user.
129   */
130  protected AccountInterface $currentUser;
131
132  /**
133   * {@inheritdoc}
134   */
135  public function isNew() {
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   * @see \Drupal\Core\Entity\EntityInterface
146   */
147  public function toArray(): array {
148    return [
149      'id' => $this->id,
150      'profileId' => $this->profileId,
151      'contexts' => $this->contexts,
152      'past' => $this->past,
153      'present' => $this->present,
154      'future' => $this->future,
155      'save' => $this->save,
156    ];
157  }
158
159  /**
160   * {@inheritdoc}
161   *
162   * @see \Drupal\Core\Entity\EntityInterface
163   */
164  public function postCreate(EntityStorageInterface $storage): void {
165    if ($this->present) {
166      $this->present->data = $this->buildIndexFromSlot([], $this->getCurrentState());
167    }
168  }
169
170  /**
171   * {@inheritdoc}
172   */
173  public function getProfile(): ?ProfileInterface {
174    /** @var \Drupal\display_builder\ProfileInterface $profile */
175    $profile = $this->entityTypeManager()->getStorage('display_builder_profile')->load($this->profileId);
176
177    return $profile;
178  }
179
180  /**
181   * {@inheritdoc}
182   */
183  public function setProfile(string $profile_id): void {
184    $this->profileId = $profile_id;
185  }
186
187  /**
188   * {@inheritdoc}
189   */
190  public function moveToRoot(string $node_id, int $position): bool {
191    $root = $this->getCurrentState();
192    $path = $this->getPath($root, $node_id);
193    $data = NestedArray::getValue($root, $path);
194
195    if (empty($data) || !isset($data['source_id'])) {
196      return FALSE;
197    }
198
199    $root = $this->doRemove($root, $node_id);
200    $root = $this->doAttachToRoot($root, $position, $data);
201
202    // Get friendly label to display in log instead of ids.
203    $labelWithSummary = $this->slotSourceProxy()->getLabelWithSummary($data, $this->getContexts());
204
205    $log = new FormattableMarkup('%node @thingy has been moved to root', [
206      '%node' => $labelWithSummary['summary'],
207      '@thingy' => $data['source_id'],
208    ]);
209    $this->setNewPresent($root, $log);
210
211    return TRUE;
212  }
213
214  /**
215   * {@inheritdoc}
216   */
217  public function moveToSlot(string $node_id, string $parent_id, string $slot_id, int $position): bool {
218    $root = $this->getCurrentState();
219    $path = $this->getPath($root, $node_id);
220    $data = NestedArray::getValue($root, $path);
221
222    if (empty($data) || !isset($data['source_id'])) {
223      return FALSE;
224    }
225
226    $parent_slot = \array_slice($path, \count($path) - 3, 1)[0];
227
228    if (($parent_id === $this->getParentId($root, $node_id)) && ($slot_id === $parent_slot)) {
229      // Moving to the same slot is tricky, because we don't want to remove a
230      // sibling.
231      $slot_path = \array_slice($path, 0, \count($path) - 1);
232      $slot = NestedArray::getValue($root, $slot_path);
233      $slot = $this->changeSourcePositionInSlot($slot, $node_id, $position);
234      NestedArray::setValue($root, $slot_path, $slot);
235    }
236    else {
237      // Moving to a different slot is easier, we can first delete the previous
238      // node data, and attach it to the new position.
239      $root = $this->doRemove($root, $node_id);
240      $root = $this->doAttachToSlot($root, $parent_id, $slot_id, $position, $data);
241    }
242
243    // Get friendly label to display in log instead of ids.
244    $labelWithSummary = $this->slotSourceProxy()->getLabelWithSummary($data, $this->getContexts());
245    $labelWithSummaryParent = $this->slotSourceProxy()->getLabelWithSummary($this->get($parent_id));
246
247    $log = new FormattableMarkup("%node @thingy has been moved to %parent's @slot_id", [
248      '%node' => $labelWithSummary['summary'],
249      '@thingy' => $data['source_id'],
250      '%parent' => $labelWithSummaryParent['summary'],
251      '@slot_id' => $slot_id,
252    ]);
253
254    $this->setNewPresent($root, $log);
255
256    return TRUE;
257  }
258
259  /**
260   * {@inheritdoc}
261   */
262  public function attachToRoot(int $position, string $source_id, array $data, array $third_party_settings = []): string {
263    $data = [
264      'node_id' => \uniqid(),
265      'source_id' => $source_id,
266      'source' => $data,
267    ];
268
269    if ($third_party_settings) {
270      $data['_third_party_settings'] = $third_party_settings;
271    }
272
273    $root = $this->getCurrentState();
274    $root = $this->doAttachToRoot($root, $position, $data);
275
276    // Get friendly label to display in log instead of ids.
277    $labelWithSummary = $this->slotSourceProxy()->getLabelWithSummary($data, $this->getContexts() ?? []);
278
279    $log = new FormattableMarkup('%node @source_id has been attached to root', [
280      '%node' => $labelWithSummary['summary'],
281      '@source_id' => $source_id,
282    ]);
283    $this->setNewPresent($root, $log, FALSE);
284
285    return $data['node_id'];
286  }
287
288  /**
289   * {@inheritdoc}
290   */
291  public function attachToSlot(string $parent_id, string $slot_id, int $position, string $source_id, array $data, array $third_party_settings = []): string {
292    $root = $this->getCurrentState();
293    $data = [
294      'node_id' => \uniqid(),
295      'source_id' => $source_id,
296      'source' => $data,
297    ];
298
299    if ($third_party_settings) {
300      $data['_third_party_settings'] = $third_party_settings;
301    }
302
303    $root = $this->doAttachToSlot($root, $parent_id, $slot_id, $position, $data);
304
305    // Get friendly label to display in log instead of ids.
306    $labelWithSummary = $this->slotSourceProxy()->getLabelWithSummary($data, $this->getContexts() ?? []);
307    $labelWithSummaryParent = $this->slotSourceProxy()->getLabelWithSummary($this->get($parent_id));
308
309    $log = new FormattableMarkup("%node @source_id has been attached to %parent's @slot_id", [
310      '%node' => $labelWithSummary['summary'],
311      '@source_id' => $source_id,
312      '%parent' => $labelWithSummaryParent['summary'],
313      '@slot_id' => $slot_id,
314    ]);
315    $this->setNewPresent($root, $log);
316
317    return $data['node_id'];
318  }
319
320  /**
321   * {@inheritdoc}
322   */
323  public function get(string $node_id): array {
324    $root = $this->getCurrentState();
325    $path = $this->getPath($root, $node_id);
326    $value = NestedArray::getValue($root, $path);
327
328    return $value ?? [];
329  }
330
331  /**
332   * {@inheritdoc}
333   */
334  public function getParentId(array $root, string $node_id): string {
335    $path = $this->getPath($root, $node_id);
336    $length = \count(['source', 'component', 'slots', '{slot_id}', 'sources', '{position}']);
337    $parent_path = \array_slice($path, 0, \count($path) - $length);
338
339    return $this->getNodeId($parent_path);
340  }
341
342  /**
343   * {@inheritdoc}
344   */
345  public function setSource(string $node_id, string $source_id, array $data): void {
346    $root = $this->getCurrentState();
347    $path = $this->getPath($root, $node_id);
348    $existing_data = NestedArray::getValue($root, $path) ?? [];
349
350    if (!isset($existing_data['node_id']) || ($existing_data['node_id'] !== $node_id)) {
351      throw new \Exception('Node ID mismatch');
352    }
353    $existing_data['source_id'] = $source_id;
354    $existing_data['source'] = $data;
355    NestedArray::setValue($root, $path, $existing_data);
356
357    // Get friendly label to display in log instead of ids.
358    $labelWithSummary = $this->slotSourceProxy()->getLabelWithSummary($existing_data, $this->getContexts());
359
360    $log = new FormattableMarkup('%source has been updated', [
361      '%source' => $labelWithSummary['summary'],
362    ]);
363    $this->setNewPresent($root, $log);
364  }
365
366  /**
367   * {@inheritdoc}
368   */
369  public function setThirdPartySettings(string $node_id, string $island_id, array $data): void {
370    $root = $this->getCurrentState();
371    $path = $this->getPath($root, $node_id);
372    $existing_data = NestedArray::getValue($root, $path);
373
374    if (!isset($existing_data['_third_party_settings'])) {
375      $existing_data['_third_party_settings'] = [];
376    }
377    $existing_data['_third_party_settings'][$island_id] = $data;
378    NestedArray::setValue($root, $path, $existing_data);
379
380    // Get friendly label to display in log instead of ids.
381    $labelWithSummary = $this->slotSourceProxy()->getLabelWithSummary($existing_data, $this->getContexts());
382
383    $log = new FormattableMarkup('%source has been updated by @island_id', [
384      '%source' => $labelWithSummary['summary'],
385      '@island_id' => $island_id,
386    ]);
387    $this->setNewPresent($root, $log);
388  }
389
390  /**
391   * {@inheritdoc}
392   */
393  public function remove(string $node_id): void {
394    $root = $this->getCurrentState();
395    $path = $this->getPath($root, $node_id);
396    $data = NestedArray::getValue($root, $path);
397    $parent_id = $this->getParentId($root, $node_id);
398    $root = $this->doRemove($root, $node_id);
399
400    $contexts = $this->getContexts() ?? [];
401
402    // Get friendly label to display in log instead of ids.
403    $labelWithSummary = $this->slotSourceProxy()->getLabelWithSummary($data, $contexts);
404    $labelWithSummaryParent = empty($parent_id) ? ['summary' => 'root'] : $this->slotSourceProxy()->getLabelWithSummary($this->get($parent_id), $contexts);
405
406    $log = new FormattableMarkup('%node has been removed from %parent', [
407      '%node' => $labelWithSummary['summary'],
408      '%parent' => $labelWithSummaryParent['summary'],
409    ]);
410    $this->setNewPresent($root, $log, FALSE);
411  }
412
413  /**
414   * {@inheritdoc}
415   */
416  public function getContexts(): ?array {
417    return $this->refreshContexts($this->contexts);
418  }
419
420  /**
421   * {@inheritdoc}
422   */
423  public function setSave(array $save_data): void {
424    $hash = self::getUniqId($save_data);
425    $this->save = new HistoryStep($save_data, $hash, NULL, \time(), NULL);
426  }
427
428  /**
429   * {@inheritdoc}
430   *
431   * @see \Drupal\display_builder\HistoryInterface
432   */
433  public function getCurrentState(): array {
434    return $this->getCurrent()->data ?? [];
435  }
436
437  /**
438   * {@inheritdoc}
439   */
440  public function restore(): void {
441    $this->setNewPresent($this->save->data, 'Back to saved data.');
442  }
443
444  /**
445   * {@inheritdoc}
446   *
447   * @see \Drupal\display_builder\HistoryInterface
448   */
449  public function undo(): void {
450    $past = $this->past ?? [];
451
452    if (empty($past)) {
453      return;
454    }
455
456    $present = $this->present;
457    // Remove the last element from the past.
458    $last = \array_pop($past);
459    $this->past = $past;
460    // Set the present to the element we removed in the previous step.
461    $this->present = $last;
462    // Insert the old present state at the beginning of the future.
463    $this->future = \array_merge([$present], $this->future);
464  }
465
466  /**
467   * {@inheritdoc}
468   *
469   * @see \Drupal\display_builder\HistoryInterface
470   */
471  public function redo(): void {
472    $future = $this->future ?? [];
473
474    if (empty($future)) {
475      return;
476    }
477
478    // Remove the first element from the future.
479    $first = \array_shift($future);
480    // Insert the old present state at the end of the past.
481    $this->past = \array_merge($this->past, [$this->present]);
482    // Set the present to the element we removed in the previous step.
483    $this->present = $first;
484    $this->future = $future;
485  }
486
487  /**
488   * {@inheritdoc}
489   *
490   * @see \Drupal\display_builder\HistoryInterface
491   */
492  public function clear(): void {
493    $this->past = [];
494    $this->future = [];
495  }
496
497  /**
498   * {@inheritdoc}
499   *
500   * @see \Drupal\display_builder\HistoryInterface
501   */
502  public function getCountPast(): int {
503    return \count($this->past);
504  }
505
506  /**
507   * {@inheritdoc}
508   *
509   * @see \Drupal\display_builder\HistoryInterface
510   */
511  public function getCountFuture(): int {
512    return \count($this->future);
513  }
514
515  /**
516   * {@inheritdoc}
517   */
518  public function getUsers(): array {
519    $users = [];
520    $steps = \array_merge($this->past, [$this->present], $this->future);
521
522    foreach ($steps as $step) {
523      $user_id = $step->user ?? NULL;
524
525      if ($user_id && ($users[$user_id] ?? $step->time > 0)) {
526        $users[$user_id] = $step->time;
527      }
528    }
529
530    return $users;
531  }
532
533  /**
534   * {@inheritdoc}
535   */
536  public function canSaveContextsRequirement(?array $contexts = NULL): bool {
537    $contexts ??= $this->getContexts();
538
539    if ($contexts === NULL) {
540      return FALSE;
541    }
542
543    if (!\array_key_exists('context_requirements', $contexts)
544      || !($contexts['context_requirements'] instanceof RequirementsContext)) {
545      return FALSE;
546    }
547
548    return TRUE;
549  }
550
551  /**
552   * {@inheritdoc}
553   */
554  public function hasSaveContextsRequirement(string $key, array $contexts = []): bool {
555    $contexts = empty($contexts) ? $this->getContexts() : $contexts;
556    // Some strange edge cases where context is null.
557    $contexts ??= [];
558
559    if (!\array_key_exists('context_requirements', $contexts)
560      || !($contexts['context_requirements'] instanceof RequirementsContext)
561      || !$contexts['context_requirements']->hasValue($key)) {
562      return FALSE;
563    }
564
565    return TRUE;
566  }
567
568  /**
569   * {@inheritdoc}
570   */
571  public function hasSave(): bool {
572    return !empty($this->save);
573  }
574
575  /**
576   * {@inheritdoc}
577   */
578  public function saveIsCurrent(): bool {
579    return $this->present->hash === $this->save->hash;
580  }
581
582  /**
583   * {@inheritdoc}
584   */
585  public function getPathIndex(array $root = []): array {
586    if (empty($root)) {
587      // When called from the outside, root is not already retrieved.
588      // When called from an other method, it is better to pass an already
589      // retrieved root, for performance.
590      $root = $this->getCurrentState();
591    }
592    // It may be slow to rebuild the index every time we request it. But it is
593    // very difficult to maintain an index synchronized with the state storage
594    // history.
595    $this->buildIndexFromSlot([], $root);
596
597    return $this->pathIndex ?? [];
598  }
599
600  /**
601   * {@inheritdoc}
602   *
603   * @see \Drupal\display_builder\HistoryInterface
604   */
605  public function setNewPresent(array $data, FormattableMarkup|string $log_message = '', bool $check_hash = TRUE): void {
606    $hash = self::getUniqId($data);
607
608    // Check if this present is the same to avoid duplicates, for example move
609    // to the same place.
610    if ($check_hash && $hash === $this->present?->hash) {
611      return;
612    }
613
614    // 1. Insert the present at the end of the past.
615    $this->past[] = $this->present;
616
617    // Keep only the last x history.
618    if (\count($this->past) > self::MAX_HISTORY) {
619      \array_shift($this->past);
620    }
621
622    // 2. Set the present to the new state.
623    $this->present = new HistoryStep(
624      $data,
625      $hash,
626      $log_message,
627      \time(),
628      (int) $this->currentUser()->id(),
629    );
630
631    // 3. Clear the future.
632    $this->future = [];
633  }
634
635  /**
636   * {@inheritdoc}
637   *
638   * @see \Drupal\display_builder\HistoryInterface
639   */
640  public function getCurrent(): ?HistoryStep {
641    return $this->present;
642  }
643
644  /**
645   * {@inheritdoc}
646   */
647  public static function getUniqId(array $data): int {
648    return \crc32((string) \serialize($data));
649  }
650
651  /**
652   * Build the index from a slot.
653   *
654   * @param array $path
655   *   The path to the slot.
656   * @param array $data
657   *   (Optional) The slot data.
658   *
659   * @return array
660   *   The slot data with the index updated.
661   */
662  private function buildIndexFromSlot(array $path, array $data = []): array {
663    foreach ($data as $index => $source) {
664      $source_path = \array_merge($path, [$index]);
665      $data[$index] = $this->buildIndexFromSource($source_path, $source);
666    }
667
668    return $data;
669  }
670
671  /**
672   * Sample entity generator.
673   */
674  private function sampleEntityGenerator(): SampleEntityGeneratorInterface {
675    return $this->sampleEntityGenerator ??= \Drupal::service('ui_patterns.sample_entity_generator');
676  }
677
678  /**
679   * Slot source proxy.
680   */
681  private function slotSourceProxy(): SlotSourceProxy {
682    return $this->slotSourceProxy ??= \Drupal::service('display_builder.slot_sources_proxy');
683  }
684
685  /**
686   * Slot source proxy.
687   */
688  private function currentUser(): AccountInterface {
689    return $this->currentUser ??= \Drupal::service('current_user');
690  }
691
692  /**
693   * Refresh contexts after loaded from storage.
694   *
695   * @param \Drupal\Core\Plugin\Context\ContextInterface[] $contexts
696   *   The contexts.
697   *
698   * @throws \Drupal\Component\Plugin\Exception\ContextException
699   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
700   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
701   *
702   * @return array
703   *   The refreshed contexts or NULL if no context.
704   */
705  private function refreshContexts(array $contexts): array {
706    foreach ($contexts as &$context) {
707      if ($context instanceof EntityContext) {
708        // @todo We should use cache entries here
709        // with the corresponding cache contexts in it.
710        // This may avoid some unnecessary entity loads or generation.
711        $entity = $context->getContextValue();
712
713        // Check if sample entity.
714        if ($entity->id()) {
715          $entity = $this->entityTypeManager()->getStorage($entity->getEntityTypeId())->load($entity->id());
716        }
717        else {
718          $entity = $this->sampleEntityGenerator()->get($entity->getEntityTypeId(), $entity->bundle());
719        }
720
721        // Edge case when the parent entity is deleted but not the builder
722        // instance.
723        if (!$entity) {
724          return $contexts;
725        }
726        $context = (\get_class($context))::fromEntity($entity);
727      }
728    }
729
730    return $contexts;
731  }
732
733  /**
734   * Change the position of an source in a slot.
735   *
736   * @param array $slot
737   *   The slot.
738   * @param string $node_id
739   *   The node id of the source.
740   * @param int $to
741   *   The new position.
742   *
743   * @return array
744   *   The updated slot.
745   */
746  private function changeSourcePositionInSlot(array $slot, string $node_id, int $to): array {
747    foreach ($slot as $position => $source) {
748      if ($source['node_id'] === $node_id) {
749        $p1 = \array_splice($slot, $position, 1);
750        $p2 = \array_splice($slot, 0, $to);
751
752        return \array_merge($p2, $p1, $slot);
753      }
754    }
755
756    return $slot;
757  }
758
759  /**
760   * Get the source nod ID from a path.
761   *
762   * @todo may be slow.
763   *
764   * @param array $path
765   *   The path to the slot.
766   */
767  private function getNodeId(array $path): string {
768    $index = $this->getPathIndex();
769
770    foreach ($index as $node_id => $node_path) {
771      if ($path === $node_path) {
772        return $node_id;
773      }
774    }
775
776    return '';
777  }
778
779  /**
780   * Add path to index and add node ID to source.
781   *
782   * @param array $path
783   *   The path to the slot.
784   * @param array $data
785   *   (Optional) The slot data.
786   *
787   * @return array
788   *   The slot data with the index updated.
789   */
790  private function buildIndexFromSource(array $path, array $data = []): array {
791    // First job: Add missing node_id keys.
792    $node_id = $data['node_id'] ?? \uniqid();
793    $data['node_id'] = $node_id;
794    // Second job: Save the path to the index.
795    $this->pathIndex[$node_id] = $path;
796
797    if (!isset($data['source_id'])) {
798      return $data;
799    }
800
801    // Let's continue the exploration.
802    if ($data['source_id'] !== 'component') {
803      return $data;
804    }
805
806    if (!isset($data['source']['component']['slots'])) {
807      return $data;
808    }
809
810    foreach ($data['source']['component']['slots'] as $slot_id => $slot) {
811      if (!isset($slot['sources'])) {
812        continue;
813      }
814      $slot_path = \array_merge($path, ['source', 'component', 'slots', $slot_id, 'sources']);
815      $slot['sources'] = $this->buildIndexFromSlot($slot_path, $slot['sources']);
816      $data['source']['component']['slots'][$slot_id] = $slot;
817    }
818
819    return $data;
820  }
821
822  /**
823   * Internal atomic change of the root state.
824   *
825   * @param array $root
826   *   The root state.
827   * @param int $position
828   *   The position where to insert the data.
829   * @param array $data
830   *   The data to insert.
831   *
832   * @return array
833   *   The updated root state
834   */
835  private function doAttachToRoot(array $root, int $position, array $data): array {
836    \array_splice($root, $position, 0, [$data]);
837
838    return $root;
839  }
840
841  /**
842   * Internal atomic change of the root state.
843   *
844   * @param array $root
845   *   The root state.
846   * @param string $parent_id
847   *   The ID of the parent node.
848   * @param string $slot_id
849   *   The ID of the slot where to insert the data.
850   * @param int $position
851   *   The position where to insert the data.
852   * @param array $data
853   *   The data to insert.
854   *
855   * @return array
856   *   The updated root state
857   */
858  private function doAttachToSlot(array $root, string $parent_id, string $slot_id, int $position, array $data): array {
859    $parent_path = $this->getPath($root, $parent_id);
860    $slot_path = \array_merge($parent_path, ['source', 'component', 'slots', $slot_id, 'sources']);
861    $slot = NestedArray::getValue($root, $slot_path) ?? [];
862    \array_splice($slot, $position, 0, [$data]);
863    NestedArray::setValue($root, $slot_path, $slot);
864
865    return $root;
866  }
867
868  /**
869   * Internal atomic change of the root state.
870   *
871   * @param array $root
872   *   The root state.
873   * @param string $node_id
874   *   The node id of the source.
875   *
876   * @return array
877   *   The updated root state
878   */
879  private function doRemove(array $root, string $node_id): array {
880    $path = $this->getPath($root, $node_id);
881    NestedArray::unsetValue($root, $path);
882    // To avoid non consecutive array keys, we rebuild the value list.
883    $slot_path = \array_slice($path, 0, \count($path) - 1);
884    $slot = NestedArray::getValue($root, $slot_path);
885    NestedArray::setValue($root, $slot_path, \array_values($slot));
886
887    return $root;
888  }
889
890  /**
891   * Get the path to an source.
892   *
893   * @param array $root
894   *   The root state.
895   * @param string $node_id
896   *   The node id of the source.
897   *
898   * @return array
899   *   The path, one array item by level.
900   */
901  private function getPath(array $root, string $node_id): array {
902    return $this->getPathIndex($root)[$node_id] ?? [];
903  }
904
905}