Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 179
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
InstanceListBuilder
0.00% covered (danger)
0.00%
0 / 179
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 13
1190
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
2
 buildHeader
0.00% covered (danger)
0.00%
0 / 28
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
2
 buildRow
0.00% covered (danger)
0.00%
0 / 21
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
12
 createInstance
0.00% covered (danger)
0.00%
0 / 10
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
2
 getSessionFilters
0.00% covered (danger)
0.00%
0 / 6
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
12
 getSessionSort
0.00% covered (danger)
0.00%
0 / 2
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
2
 load
0.00% covered (danger)
0.00%
0 / 17
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
12
 render
0.00% covered (danger)
0.00%
0 / 28
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
12
 getOperations
0.00% covered (danger)
0.00%
0 / 18
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
2
 applyPager
0.00% covered (danger)
0.00%
0 / 8
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
12
 filterEntities
0.00% covered (danger)
0.00%
0 / 11
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
42
 getInstancesFromProviders
0.00% covered (danger)
0.00%
0 / 9
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
12
 sortEntities
0.00% covered (danger)
0.00%
0 / 19
n/a
0 / 0
n/a
0 / 0
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder_ui;
6
7use Drupal\Core\Cache\CacheableMetadata;
8use Drupal\Core\Datetime\DateFormatterInterface;
9use Drupal\Core\Entity\EntityInterface;
10use Drupal\Core\Entity\EntityListBuilder;
11use Drupal\Core\Entity\EntityStorageInterface;
12use Drupal\Core\Entity\EntityTypeInterface;
13use Drupal\Core\Entity\EntityTypeManagerInterface;
14use Drupal\Core\Form\FormBuilderInterface;
15use Drupal\Core\Pager\PagerManagerInterface;
16use Drupal\Core\StringTranslation\TranslatableMarkup;
17use Drupal\Core\Utility\TableSort;
18use Drupal\display_builder\DisplayBuildablePluginManager;
19use Drupal\display_builder\DisplayBuilderHelpers;
20use Drupal\display_builder_ui\Form\InstanceListFilterForm;
21use Symfony\Component\DependencyInjection\ContainerInterface;
22use Symfony\Component\HttpFoundation\RequestStack;
23use Symfony\Component\HttpFoundation\Session\SessionInterface;
24
25/**
26 * Provides a listing of display builders instances.
27 */
28final class InstanceListBuilder extends EntityListBuilder {
29
30  /**
31   * {@inheritdoc}
32   */
33  protected $limit = 20;
34
35  /**
36   * Cached list of display builder providers.
37   */
38  protected array $providers = [];
39
40  /**
41   * {@inheritdoc}
42   */
43  public function __construct(
44    protected EntityTypeInterface $entity_type,
45    EntityStorageInterface $storage,
46    protected DateFormatterInterface $dateFormatter,
47    protected FormBuilderInterface $formBuilder,
48    protected PagerManagerInterface $pagerManager,
49    protected RequestStack $requestStack,
50    protected EntityTypeManagerInterface $entityTypeManager,
51    protected DisplayBuildablePluginManager $displayBuildableManager,
52  ) {
53    parent::__construct($entity_type, $storage);
54
55    // Cache providers so we don't call invokeAll multiple times.
56    $this->providers = $this->displayBuildableManager->getDefinitions();
57  }
58
59  /**
60   * {@inheritdoc}
61   */
62  public function buildHeader(): array {
63    $header = [
64      'id' => [
65        'data' => $this->t('ID'),
66        'class' => ['hidden'],
67      ],
68      'context' => [
69        'data' => $this->t('Context'),
70        'class' => ['priority-medium'],
71      ],
72      'name' => [
73        'data' => $this->t('Instance'),
74        'field' => 'name',
75        'sort' => 'asc',
76        'class' => ['priority-medium'],
77      ],
78      'profile' => $this->t('Profile'),
79      'updated' => [
80        'data' => $this->t('Updated'),
81        'field' => 'updated',
82        'sort' => 'desc',
83        'class' => ['priority-medium', 'db-nowrap'],
84      ],
85      'log' => [
86        'data' => $this->t('Last log'),
87        'class' => ['priority-low'],
88      ],
89    ];
90
91    return $header + parent::buildHeader();
92  }
93
94  /**
95   * {@inheritdoc}
96   */
97  public function buildRow(EntityInterface $instance): array {
98    /** @var \Drupal\display_builder\InstanceInterface $instance */
99    $instance_id = (string) $instance->id();
100
101    $row = [];
102
103    $row['id']['data'] = $instance_id;
104    $row['id']['class'] = ['hidden'];
105
106    /** @var \Drupal\display_builder\Plugin\Field\FieldType\PluginItem $item */
107    $item = $instance->get('buildable')->first();
108    /** @var \Drupal\display_builder\DisplayBuildableInterface $buildable */
109    $buildable = $item->getInstance();
110    $type = $buildable->label() ?? '-';
111
112    // Set a human readable name from id.
113    $row['context']['data'] = $type;
114    $row['context']['class'] = ['priority-medium'];
115
116    $row['name']['data'] = $instance->label();
117    $row['name']['class'] = ['priority-medium'];
118
119    $row['profile']['data'] = $instance->getProfile()?->label() ?? '';
120
121    $row['updated']['data'] = $instance->get('revision_created') ? DisplayBuilderHelpers::formatTime($this->dateFormatter, (int) $instance->get('revision_created')->getString()) : '-';
122    $row['updated']['class'] = ['priority-medium', 'db-nowrap'];
123    $row['log']['data'] = $instance->getRevisionLogMessage() ?: '-';
124    $row['log']['class'] = ['priority-low'];
125
126    $result = [
127      'data' => $row + parent::buildRow($instance),
128      'class' => $instance_id,
129    ];
130
131    return $result;
132  }
133
134  /**
135   * {@inheritdoc}
136   */
137  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type): self {
138    return new self(
139      $entity_type,
140      $container->get('entity_type.manager')->getStorage($entity_type->id()),
141      $container->get('date.formatter'),
142      $container->get('form_builder'),
143      $container->get('pager.manager'),
144      $container->get('request_stack'),
145      $container->get('entity_type.manager'),
146      $container->get('plugin.manager.display_buildable'),
147    );
148  }
149
150  /**
151   * Retrieve filter values from session.
152   *
153   * @param \Symfony\Component\HttpFoundation\Session\SessionInterface $session
154   *   Current session.
155   *
156   * @return array
157   *   Associative array of filters.
158   */
159  public static function getSessionFilters(SessionInterface $session): array {
160    $state = $session->get('db_instances_overview', []);
161    $filters = $state['filters'] ?? [];
162
163    return [
164      'context' => isset($filters['context']) ? (string) $filters['context'] : '',
165      'name' => isset($filters['name']) ? (string) $filters['name'] : '',
166    ];
167  }
168
169  /**
170   * Retrieve sort values from session.
171   *
172   * @param \Symfony\Component\HttpFoundation\Session\SessionInterface $session
173   *   Current session.
174   *
175   * @return array
176   *   Associative array with 'key' and 'direction'.
177   */
178  public static function getSessionSort(SessionInterface $session): array {
179    $state = $session->get('db_instances_overview', []);
180
181    return $state['sort'] ?? [];
182  }
183
184  /**
185   * {@inheritdoc}
186   */
187  public function load(): array {
188    $entities = $this->getInstancesFromProviders();
189
190    // Apply filters from session and create missing instances if any.
191    $entities = $this->filterEntities($entities);
192
193    // Build headers & request once.
194    $headers = $this->buildHeader();
195    $request = $this->requestStack->getCurrentRequest() ?? \Drupal::request();
196    $session = $this->requestStack->getSession();
197
198    if ($request->query->has('order') || $request->query->has('sort')) {
199      // Sort params are explicit in the URL â€” use and merge into session.
200      $order = TableSort::getOrder($headers, $request);
201      $direction = TableSort::getSort($headers, $request);
202      $sortKey = $order['sql'] ?? 'updated';
203      $state = $session->get('db_instances_overview', []);
204      $state['sort'] = ['key' => $sortKey, 'direction' => $direction];
205      $session->set('db_instances_overview', $state);
206    }
207    else {
208      // No sort in URL â€” restore from session or fall back to default.
209      $saved = self::getSessionSort($session);
210      $sortKey = $saved['key'] ?? 'updated';
211      $direction = $saved['direction'] ?? TableSort::DESC;
212    }
213
214    // Sort using a dedicated helper.
215    $this->sortEntities($entities, $sortKey, $direction);
216
217    // Apply pager and return the page slice.
218    return $this->applyPager($entities);
219  }
220
221  /**
222   * {@inheritdoc}
223   */
224  public function render(): array {
225    $request = $this->requestStack->getCurrentRequest() ?? \Drupal::request();
226
227    // When sort is not in the URL, inject the effective sort (session or
228    // default) into the request so TableSort marks the correct header column.
229    if (!$request->query->has('order') && !$request->query->has('sort')) {
230      $saved = self::getSessionSort($this->requestStack->getSession());
231      $sortKey = $saved['key'] ?? 'updated';
232      $direction = $saved['direction'] ?? TableSort::DESC;
233      // TableSort matches 'order' against header 'data' labels (translated).
234      $labelMap = [
235        'name' => (string) $this->t('Instance'),
236        'updated' => (string) $this->t('Updated'),
237      ];
238      $request->query->set('order', $labelMap[$sortKey] ?? (string) $this->t('Updated'));
239      $request->query->set('sort', $direction);
240    }
241
242    $build = parent::render();
243
244    $build['#attached']['library'][] = 'display_builder_ui/instance_list';
245
246    $info = $this->t('Instances are existing displays (entity views, page layouts, views...) from configuration or currently under work.');
247
248    $build['notice'] = [
249      '#type' => 'html_tag',
250      '#tag' => 'p',
251      '#value' => $info,
252      '#attributes' => ['class' => ['description']],
253      '#weight' => -11,
254    ];
255
256    $build['filters'] = $this->formBuilder->getForm(InstanceListFilterForm::class, $this->providers);
257    $build['filters']['#weight'] = -10;
258
259    $build['pager'] = [
260      '#type' => 'pager',
261      '#weight' => 100,
262    ];
263
264    return $build;
265  }
266
267  /**
268   * {@inheritdoc}
269   */
270  public function getOperations(EntityInterface $entity, ?CacheableMetadata $cacheability = NULL) {
271    /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
272    /** @var \Drupal\display_builder\Plugin\Field\FieldType\PluginItem $field */
273    $field = $entity->get('buildable')->first();
274    /** @var \Drupal\display_builder\DisplayBuildableInterface $buildable */
275    $buildable = $field->getInstance();
276    $operations = [
277      'build' => [
278        'title' => new TranslatableMarkup('Build display'),
279        'url' => $buildable::getUrlFromInstanceId((string) $entity->id()),
280        'weight' => -1,
281      ],
282      'edit' => [
283        'title' => new TranslatableMarkup('Edit display'),
284        'url' => $buildable::getDisplayUrlFromInstanceId((string) $entity->id()),
285        'weight' => 10,
286      ],
287    ];
288
289    return \array_merge(
290      $operations,
291      parent::getOperations($entity),
292    );
293  }
294
295  /**
296   * Apply Drupal pager to an array of entities.
297   *
298   * @param array $entities
299   *   The full list of (already filtered & sorted) entities.
300   *
301   * @return array
302   *   The paged slice of entities for the current page.
303   */
304  private function applyPager(array $entities): array {
305    $total = \count($entities);
306    $limit = (int) $this->limit;
307
308    if ($limit <= 0 || $total <= $limit) {
309      // No paging needed.
310      return $entities;
311    }
312
313    $pager = $this->pagerManager->createPager($total, $limit);
314    $current_page = $pager->getCurrentPage();
315    $offset = $current_page * $limit;
316
317    return \array_slice($entities, $offset, $limit, TRUE);
318  }
319
320  /**
321   * Filter the loaded entities according to GET filters.
322   *
323   * @param array $entities
324   *   Loaded entities.
325   *
326   * @return array
327   *   Filtered entities.
328   */
329  private function filterEntities(array $entities): array {
330    $filters = self::getSessionFilters($this->requestStack->getSession());
331    $context = $filters['context'] ?? '';
332    $name = $filters['name'] ?? '';
333
334    $result = [];
335
336    foreach ($entities as $entity) {
337      if ($context !== '' && $context !== $entity['context']) {
338        continue;
339      }
340
341      if ($name !== '' && !\str_contains($entity['id'] ?? '', $name)) {
342        continue;
343      }
344
345      $result[] = $entity['instance'];
346    }
347
348    return $result;
349  }
350
351  /**
352   * Get instances from providers definitions.
353   *
354   * @return array
355   *   List of instances indexed by id.
356   */
357  private function getInstancesFromProviders(): array {
358    $instances = [];
359
360    foreach ($this->providers as $provider_id => $provider) {
361      foreach ($provider['class']::collectInstances($this->entityTypeManager) as $instance_id => $instance) {
362        $instances[$instance_id] = [
363          'id' => $instance_id,
364          'instance' => $instance,
365          'context' => $provider_id,
366        ];
367      }
368    }
369
370    return $instances;
371  }
372
373  /**
374   * Sort the entities array in place according to provided sort key/direction.
375   *
376   * @param array $entities
377   *   Entities to sort (passed by reference).
378   * @param string $sortKey
379   *   The SQL sort key from TableSort.
380   * @param string|int $direction
381   *   Sort direction value.
382   */
383  private function sortEntities(array &$entities, string $sortKey, $direction): void {
384    // Factor to invert comparison when descending.
385    $factor = ($direction === TableSort::DESC) ? -1 : 1;
386
387    switch ($sortKey) {
388      case 'updated':
389        \usort($entities, static function ($a, $b) use ($factor) {
390          $aTime = (int) ($a->get('revision_created')->getString() ?? 0);
391          $bTime = (int) ($b->get('revision_created')->getString() ?? 0);
392
393          // Default comparator is ascending, multiply by factor to handle desc.
394          return $factor * ($aTime <=> $bTime);
395        });
396
397        break;
398
399      case 'name':
400        \usort($entities, static function ($a, $b) use ($factor) {
401          $aName = $a->label();
402          $bName = $b->label();
403
404          // Use case-insensitive string comparison.
405          return $factor * \strcasecmp($aName, $bName);
406        });
407
408        break;
409
410      default:
411        // Unknown sort: fallback to updated desc behavior for predictability.
412        \usort($entities, static function ($a, $b) {
413          return (int) ($b->get('revision_created')->getString() ?? 0) <=> (int) ($a->get('revision_created')->getString() ?? 0);
414        });
415
416        break;
417    }
418  }
419
420}