Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SaveStatus
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 13
240
0.00% covered (danger)
0.00%
0 / 1
 build
0.00% covered (danger)
0.00%
0 / 10
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
 onAttachToRoot
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
 onAttachToSlot
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
 onMove
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
 onUpdate
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
 onDelete
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
 onHistoryChange
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
 onPublish
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
 onRestore
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
 onRevert
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
 reloadWithStatus
0.00% covered (danger)
0.00%
0 / 5
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
 restingStatus
0.00% covered (danger)
0.00%
0 / 3
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
 statusLabels
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
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder\Plugin\display_builder\Island;
6
7use Drupal\Core\StringTranslation\TranslatableMarkup;
8use Drupal\display_builder\Attribute\Island;
9use Drupal\display_builder\DisplayBuilderHtmx;
10use Drupal\display_builder\InstanceInterface;
11use Drupal\display_builder\Island\IslandPluginBase;
12use Drupal\display_builder\Island\IslandType;
13
14/**
15 * Save status island plugin implementation.
16 *
17 * A dot and a word in the toolbar confirming that the last action reached
18 * the stored state. Every mutation already persists a revision before the
19 * response is built, but nothing said so: the undo counter only reports
20 * how much history exists, and the toast stack is reserved for errors -
21 * success toasts on every drag would train people to stop reading the
22 * corner that carries failures.
23 *
24 * The label is state, the pulse is event. build() derives the label from
25 * the instance itself whenever it is called without a status (the initial
26 * page build, or a plain reload()), so the pip is a standing answer to
27 * "where does this display stand", not only a flash. An event handler
28 * passes its own status through $options, which both overrides the label
29 * for that one render and turns the animation on.
30 *
31 * That is why publish, restore and revert are separate statuses rather
32 * than all folded into "saved": they are the three actions whose outcome
33 * a user cannot infer from the canvas, and each carries its own color.
34 * The transient ones (restored, reverted) describe how the display got
35 * here, so decaying to the plain state label on the next render is
36 * correct rather than a bug.
37 */
38#[Island(
39  id: 'save_status',
40  enabled_by_default: TRUE,
41  label: new TranslatableMarkup('Save status'),
42  description: new TranslatableMarkup('A small indicator confirming the last action reached the stored state.'),
43  type: IslandType::Button,
44  default_region: 'end',
45)]
46class SaveStatus extends IslandPluginBase {
47
48  /**
49   * A draft revision was written, the display differs from the published one.
50   */
51  private const string STATUS_SAVED = 'saved';
52
53  /**
54   * The display was published: the current state is the published one.
55   */
56  private const string STATUS_PUBLISHED = 'published';
57
58  /**
59   * The display was restored to the last published version.
60   */
61  private const string STATUS_RESTORED = 'restored';
62
63  /**
64   * An entity display override was reverted to the published entity display.
65   */
66  private const string STATUS_REVERTED = 'reverted';
67
68  /**
69   * Key carrying the status through the $options passed to build().
70   */
71  private const string OPTION_STATUS = 'save_status';
72
73  /**
74   * {@inheritdoc}
75   *
76   * Overrides build() rather than buildContent(), like the other toolbar
77   * islands: IslandPluginBase::build() gates on isApplicable(), which
78   * requires a node context no toolbar island ever has.
79   */
80  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
81    $status = $options[self::OPTION_STATUS] ?? $this->restingStatus($builder);
82
83    return [
84      '#type' => 'component',
85      '#component' => 'display_builder:save_status',
86      '#props' => [
87        'variant' => $status,
88        'label' => (string) ($this->statusLabels()[$status] ?? $this->statusLabels()[self::STATUS_SAVED]),
89        // Only an action animates. A reload triggered by anything else
90        // rebuilds the same pip silently.
91        'pulse' => isset($options[self::OPTION_STATUS]),
92      ],
93    ];
94  }
95
96  /**
97   * {@inheritdoc}
98   */
99  public function onAttachToRoot(InstanceInterface $instance, string $node_id): array {
100    return $this->reloadWithStatus($instance, self::STATUS_SAVED);
101  }
102
103  /**
104   * {@inheritdoc}
105   */
106  public function onAttachToSlot(InstanceInterface $instance, string $node_id, string $parent_id): array {
107    return $this->reloadWithStatus($instance, self::STATUS_SAVED);
108  }
109
110  /**
111   * {@inheritdoc}
112   */
113  public function onMove(InstanceInterface $instance, string $node_id): array {
114    return $this->reloadWithStatus($instance, self::STATUS_SAVED);
115  }
116
117  /**
118   * {@inheritdoc}
119   */
120  public function onUpdate(InstanceInterface $instance, string $node_id): array {
121    return $this->reloadWithStatus($instance, self::STATUS_SAVED);
122  }
123
124  /**
125   * {@inheritdoc}
126   */
127  public function onDelete(InstanceInterface $instance, ?string $parent_id): array {
128    return $this->reloadWithStatus($instance, self::STATUS_SAVED);
129  }
130
131  /**
132   * {@inheritdoc}
133   */
134  public function onHistoryChange(InstanceInterface $instance): array {
135    return $this->reloadWithStatus($instance, self::STATUS_SAVED);
136  }
137
138  /**
139   * {@inheritdoc}
140   */
141  public function onPublish(InstanceInterface $instance): array {
142    return $this->reloadWithStatus($instance, self::STATUS_PUBLISHED);
143  }
144
145  /**
146   * {@inheritdoc}
147   */
148  public function onRestore(InstanceInterface $instance): array {
149    return $this->reloadWithStatus($instance, self::STATUS_RESTORED);
150  }
151
152  /**
153   * {@inheritdoc}
154   */
155  public function onRevert(InstanceInterface $instance): array {
156    return $this->reloadWithStatus($instance, self::STATUS_REVERTED);
157  }
158
159  /**
160   * Reloads the island showing a given status, with the pulse animation on.
161   *
162   * @param \Drupal\display_builder\InstanceInterface $instance
163   *   The display builder instance.
164   * @param string $status
165   *   One of the self::STATUS_* constants.
166   *
167   * @return array
168   *   Returns a render array with out-of-band commands.
169   */
170  private function reloadWithStatus(InstanceInterface $instance, string $status): array {
171    return DisplayBuilderHtmx::outOfBand(
172      $this->build($instance, $instance->getCurrentState(), [self::OPTION_STATUS => $status]),
173      '#' . $this->getHtmlId((string) $instance->id()),
174      'innerHTML'
175    );
176  }
177
178  /**
179   * The status to show when no action caused this render.
180   *
181   * @param \Drupal\display_builder\InstanceInterface $builder
182   *   The display builder instance.
183   *
184   * @return string
185   *   One of the self::STATUS_* constants.
186   */
187  private function restingStatus(InstanceInterface $builder): string {
188    // isPublishedPresent() resolves the buildable plugin's sources to hash
189    // them, so it is only worth asking where publishing is a thing at all.
190    if ($builder->isPublishable() && $builder->isPublishedPresent()) {
191      return self::STATUS_PUBLISHED;
192    }
193
194    return self::STATUS_SAVED;
195  }
196
197  /**
198   * Human-readable name for each status.
199   *
200   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
201   *   Labels keyed by the self::STATUS_* constants.
202   */
203  private function statusLabels(): array {
204    return [
205      self::STATUS_SAVED => $this->t('Saved'),
206      self::STATUS_PUBLISHED => $this->t('Published'),
207      self::STATUS_RESTORED => $this->t('Restored'),
208      self::STATUS_REVERTED => $this->t('Reverted'),
209    ];
210  }
211
212}