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