Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
86.67% covered (warning)
86.67%
26 / 30
75.00% covered (warning)
75.00%
9 / 12
50.00% covered (danger)
50.00%
5 / 10
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiControllerBase
86.67% covered (warning)
86.67%
26 / 30
75.00% covered (warning)
75.00%
9 / 12
50.00% covered (danger)
50.00%
5 / 10
80.00% covered (warning)
80.00%
4 / 5
19.12
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
n/a
0 / 0
n/a
0 / 0
100.00% covered (success)
100.00%
1 / 1
1
 getVisibleIslands
42.86% covered (danger)
42.86%
3 / 7
57.14% covered (warning)
57.14%
4 / 7
16.67% covered (danger)
16.67%
1 / 6
0.00% covered (danger)
0.00%
0 / 1
13.26
 dispatchDisplayBuilderEvent
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
 createEventWithEnabledIsland
100.00% covered (success)
100.00%
10 / 10
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
 saveSseData
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
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder\Controller;
6
7use Drupal\Component\Datetime\TimeInterface;
8use Drupal\Core\Controller\ControllerBase;
9use Drupal\Core\Render\RendererInterface;
10use Drupal\Core\TempStore\SharedTempStoreFactory;
11use Drupal\display_builder\Entity\ProfileInterface;
12use Drupal\display_builder\Event\DisplayBuilderEvent;
13use Drupal\display_builder\Event\DisplayBuilderEvents;
14use Drupal\display_builder\InstanceInterface;
15use Symfony\Component\DependencyInjection\Attribute\Autowire;
16use Symfony\Component\HttpFoundation\Request;
17use Symfony\Component\HttpFoundation\RequestStack;
18use Symfony\Component\HttpFoundation\Session\SessionInterface;
19use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
20
21/**
22 * Returns responses for Display builder routes.
23 */
24abstract class ApiControllerBase extends ControllerBase {
25
26  public const string SSE_COLLECTION = 'display_builder_sse';
27
28  /**
29   * Request header listing the island plugin IDs currently visible on screen.
30   *
31   * Sent as a header rather than a request parameter on purpose: update() and
32   * thirdPartySettingsUpdate() feed the whole request payload straight into
33   * FormState::setValues(), so an extra body field would become a bogus form
34   * value.
35   *
36   * When the header is absent - server-sent events, functional tests, any
37   * non-JS caller - every island is rendered, which is the behavior that
38   * predates deferral.
39   *
40   * @see components/display_builder/js/deferred_islands.js
41   * @see \Drupal\display_builder\Event\DisplayBuilderEventsSubscriber::shouldDefer()
42   */
43  public const string VISIBLE_ISLANDS_HEADER = 'X-DB-Visible-Islands';
44
45  /**
46   * The list of DB events which triggers SSE refresh.
47   *
48   * ON_ACTIVE is intentionally excluded: it is a client-side presence signal
49   * that must not trigger a full SSE broadcast to avoid feedback loops.
50   */
51  public const array SSE_EVENTS = [
52    DisplayBuilderEvents::ON_ATTACH_TO_ROOT,
53    DisplayBuilderEvents::ON_ATTACH_TO_SLOT,
54    DisplayBuilderEvents::ON_DELETE,
55    DisplayBuilderEvents::ON_HISTORY_CHANGE,
56    DisplayBuilderEvents::ON_MOVE,
57    DisplayBuilderEvents::ON_PRESET_SAVE,
58    DisplayBuilderEvents::ON_PUBLISH,
59    DisplayBuilderEvents::ON_RESTORE,
60    DisplayBuilderEvents::ON_REVERT,
61    DisplayBuilderEvents::ON_UPDATE,
62  ];
63
64  /**
65   * The Display Builder instance triggering the action.
66   */
67  protected InstanceInterface $builder;
68
69  /**
70   * Plugin ID of the island triggering the HTMX event.
71   *
72   * If not NULL, the island will be skipped from the event dispatch. Useful to
73   * avoid swapping the content of an island which is already in the expected
74   * state. For examples, if we move an instance in Builder, Wireframe or Tree
75   * panels, if we change the settings in InstanceForm.
76   *
77   * @see \Drupal\display_builder\Event\DisplayBuilderEventsSubscriber::dispatchToIslands()
78   * @see \Drupal\display_builder\HtmxEvents
79   */
80  protected ?string $islandId = NULL;
81
82  /**
83   * The lazy loaded display builder.
84   */
85  protected ?ProfileInterface $displayBuilder = NULL;
86
87  public function __construct(
88    protected EventDispatcherInterface $eventDispatcher,
89    protected RendererInterface $renderer,
90    protected TimeInterface $time,
91    #[Autowire(service: 'tempstore.shared')]
92    protected SharedTempStoreFactory $sharedTempStoreFactory,
93    protected SessionInterface $session,
94    protected RequestStack $requestStack,
95  ) {}
96
97  /**
98   * Reads the islands the client reports as currently visible.
99   *
100   * @return array|null
101   *   The island plugin IDs, or NULL when the client did not report any, in
102   *   which case no island is deferred.
103   *
104   * @see self::VISIBLE_ISLANDS_HEADER
105   */
106  protected function getVisibleIslands(): ?array {
107    $request = $this->requestStack->getCurrentRequest();
108
109    if (!$request instanceof Request || !$request->headers->has($this::VISIBLE_ISLANDS_HEADER)) {
110      return NULL;
111    }
112
113    $header = \trim((string) $request->headers->get($this::VISIBLE_ISLANDS_HEADER));
114
115    // An empty header is a valid report: the client says nothing deferrable is
116    // on screen. Distinct from an absent header, which reports nothing at all.
117    if ($header === '') {
118      return [];
119    }
120
121    return \array_values(\array_filter(\array_map(\trim(...), \explode(',', $header))));
122  }
123
124  /**
125   * Dispatches a display builder event.
126   *
127   * @param string $event_id
128   *   The event ID.
129   * @param array|null $data
130   *   The data.
131   * @param string|null $node_id
132   *   Optional instance ID.
133   * @param string|null $parent_id
134   *   Optional parent ID.
135   *
136   * @return array
137   *   A renderable array.
138   */
139  protected function dispatchDisplayBuilderEvent(
140    string $event_id,
141    ?array $data = NULL,
142    ?string $node_id = NULL,
143    ?string $parent_id = NULL,
144  ): array {
145    $event = $this->createEventWithEnabledIsland($event_id, $data, $node_id, $parent_id);
146    $this->saveSseData($event_id);
147
148    return $event->getResult();
149  }
150
151  /**
152   * Creates a display builder event with enabled islands only.
153   *
154   * Use a cache to avoid loading all the builder configuration.
155   *
156   * @param string $event_id
157   *   The event ID.
158   * @param array|null $data
159   *   The data.
160   * @param string|null $node_id
161   *   Optional Instance entity ID.
162   * @param string|null $parent_id
163   *   Optional parent ID.
164   *
165   * @return \Drupal\display_builder\Event\DisplayBuilderEvent
166   *   The event.
167   */
168  protected function createEventWithEnabledIsland(string $event_id, ?array $data, ?string $node_id, ?string $parent_id): DisplayBuilderEvent {
169    $event = new DisplayBuilderEvent(
170      $this->builder,
171      $data,
172      $node_id,
173      $parent_id,
174      $this->islandId,
175      $this->getVisibleIslands(),
176    );
177    $this->eventDispatcher->dispatch($event, $event_id);
178
179    return $event;
180  }
181
182  /**
183   * Save data for SSE.
184   *
185   * @param string $event_id
186   *   The event ID.
187   */
188  protected function saveSseData(string $event_id): void {
189    if (!\in_array($event_id, $this::SSE_EVENTS, TRUE)) {
190      return;
191    }
192
193    $state = [
194      'sessionId' => $this->session->getId(),
195      'timestamp' => $this->time->getRequestTime(),
196      // instanceId here is the ID of a display_builder_instance entity.
197      'instanceId' => (string) $this->builder->id(),
198    ];
199    $collection = $this->sharedTempStoreFactory->get($this::SSE_COLLECTION);
200    $collection->set(\sprintf('%s_latest', (string) $this->builder->id()), $state);
201  }
202
203}