Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.11% covered (success)
97.11%
168 / 173
77.78% covered (warning)
77.78%
14 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Listing
97.11% covered (success)
97.11%
168 / 173
77.78% covered (warning)
77.78%
14 / 18
69
0.00% covered (danger)
0.00%
0 / 1
 __construct
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 entries
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 parent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 list
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
7
 place
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 neighbour
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 childBlueprints
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
7.14
 rows
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 row
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 hasChildrenMap
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
7
 scope
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 scopeOf
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 group
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 positionOrder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 handle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 uids
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 defaultSort
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 resolveOrder
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Collection;
6
7use Cosray\Cms;
8use Cosray\CollectionListMeta;
9use Cosray\Column;
10use Cosray\Contract\Columns;
11use Cosray\Contract\Entries;
12use Cosray\Exception\RuntimeException;
13use Cosray\Finder\Nodes;
14use Cosray\Finder\Order;
15use Cosray\Finder\SortField;
16use Cosray\Node\Positions;
17use Cosray\Node\Types;
18use Cosray\Node\Wrapper;
19
20/**
21 * A registered collection as the panel lists it: its entries, columns,
22 * sorts, list options and blueprints, plus the row and hierarchy logic
23 * of collection pages.
24 *
25 * Collections are plain classes configured through schema attributes.
26 * The instance is only needed when it implements Entries or Columns.
27 */
28final class Listing
29{
30    /** @var list<Column> */
31    public readonly array $columns;
32    /** @var array<string, Sort> */
33    public readonly array $sorts;
34    public readonly CollectionListMeta $meta;
35    /** @var list<class-string> */
36    public readonly array $blueprints;
37    public readonly string $defaultSort;
38
39    public function __construct(
40        private readonly Schema $schema,
41        private readonly Cms $cms,
42        private readonly Types $types,
43        private readonly ?object $collection = null,
44    ) {
45        $this->meta = $schema->listing;
46        $this->blueprints = $schema->blueprints;
47        $this->columns = array_values($collection instanceof Columns ? $collection->columns() : Column::defaults());
48        $sorts = [];
49        foreach ($this->columns as $column) {
50            if (!$column instanceof Column) {
51                throw new RuntimeException('Collection columns must be Column objects');
52            }
53            $sort = $column->sort;
54            if ($sort === null) {
55                continue;
56            }
57            if (isset($sorts[$sort->key])) {
58                throw new RuntimeException("Duplicate collection sort key '{$sort->key}'");
59            }
60            $sorts[$sort->key] = $sort;
61        }
62        $this->sorts = $sorts;
63        $this->defaultSort = self::defaultSort($sorts);
64    }
65
66    /** A fresh finder over the collection's entries per call; callers narrow it. */
67    public function entries(): Nodes
68    {
69        $nodes = $this->cms->nodes()->published(null)->hidden(null);
70
71        if ($this->schema->types !== []) {
72            $nodes->types(...$this->schema->types);
73        }
74
75        return $this->collection instanceof Entries ? $this->collection->entries($nodes) : $nodes;
76    }
77
78    /** The node a hierarchy listing is scoped to, in any state. */
79    public function parent(string $uid): ?Wrapper
80    {
81        return $this->cms->node->byUid($uid, published: null);
82    }
83
84    /**
85     * @return array{
86     *     total: int,
87     *     offset: int,
88     *     limit: int,
89     *     q: string,
90     *     sort: string,
91     *     dir: string,
92     *     arranged: bool,
93     *     nodes: list<array<string, mixed>>,
94     * }
95     */
96    public function list(
97        int $offset = 0,
98        int $limit = 50,
99        string $q = '',
100        string $sort = '',
101        string $dir = '',
102        ?string $parent = null,
103    ): array {
104        [$sort, $dir, $order] = $this->resolveOrder($sort, $dir);
105        $nodes = $this->entries();
106        $parent = $this->meta->showChildren ? trim((string) $parent) : '';
107
108        if ($this->meta->showChildren) {
109            if ($parent === '') {
110                $nodes->roots();
111            } else {
112                $nodes->childrenOf($parent);
113            }
114        }
115
116        // A manually ordered level ignores the column sort: the tree shares
117        // one header row between several levels, and a column click must
118        // not re-sort some of them.
119        $scope = $this->scope($parent);
120
121        if ($scope !== null) {
122            $order = [$this->positionOrder($scope)];
123        }
124
125        $q = trim($q);
126
127        if ($q !== '') {
128            $nodes->search($q, $this->schema->search);
129        }
130
131        $nodes->order(...$order);
132
133        $total = $nodes->count();
134        $nodes->offset($offset)->limit($limit);
135        $pageNodes = iterator_to_array($nodes);
136
137        return [
138            'total' => $total,
139            'offset' => $offset,
140            'limit' => $limit,
141            'q' => $q,
142            'sort' => $sort,
143            'dir' => $dir,
144            'arranged' => $scope !== null,
145            // A search shows a subset, where moving next to a hit would
146            // silently jump the rows in between.
147            'nodes' => $this->rows($pageNodes, $q === '' ? $scope : null, $offset, $total),
148        ];
149    }
150
151    /**
152     * Moves an entry directly before or after a neighbour in the manual
153     * order it belongs to: its parent's children in a hierarchy listing,
154     * otherwise the collection's own top level. The whole group is
155     * renumbered, members this listing doesn't show included.
156     */
157    public function place(Positions $positions, string $uid, string $neighbour, bool $after): void
158    {
159        $scope = $this->scopeOf($uid);
160        $order = Positions::move($this->uids($this->group($scope)), $uid, $neighbour, $after);
161
162        if ($scope === '') {
163            $positions->orderEntries($this->handle(), $order);
164
165            return;
166        }
167
168        $positions->orderChildren($scope, $order);
169    }
170
171    /**
172     * The row above or below an entry as this listing shows its group, so
173     * a move always changes what the editor sees, even with sibling types
174     * the collection doesn't list in between. Null at either end.
175     */
176    public function neighbour(string $uid, bool $below): ?string
177    {
178        $scope = $this->scopeOf($uid);
179        $group = $scope === ''
180            ? $this->group($scope)
181            : $this->entries()->childrenOf($scope)->order($this->positionOrder($scope));
182        $uids = $this->uids($group);
183        $index = array_search($uid, $uids, true);
184
185        if ($index === false) {
186            return null;
187        }
188
189        return $uids[$below ? $index + 1 : $index - 1] ?? null;
190    }
191
192    /** @return list<array{slug: string, name: string}> */
193    public function childBlueprints(Wrapper $node): array
194    {
195        $children = $node->meta->type->children;
196
197        if (!is_array($children) || $children === []) {
198            return [];
199        }
200
201        $result = [];
202
203        foreach ($children as $class) {
204            if (!is_string($class) || $class === '') {
205                throw new RuntimeException('The children schema must contain non-empty class names');
206            }
207
208            if (!$this->types->isNode($class)) {
209                throw new RuntimeException("Unknown child node class '{$class}' in #[Children(...)]");
210            }
211
212            $result[] = [
213                'slug' => (string) $this->types->get($class, 'handle'),
214                'name' => (string) $this->types->get($class, 'label'),
215            ];
216        }
217
218        return $result;
219    }
220
221    /**
222     * @param array<Wrapper> $nodes
223     * @param ?string $group the manual order the rows can be moved in, null when they can't
224     * @return list<array<string, mixed>>
225     */
226    private function rows(array $nodes, ?string $group, int $offset, int $total): array
227    {
228        $result = [];
229        $hasChildren = $this->meta->showChildren
230            ? $this->hasChildrenMap($nodes)
231            : [];
232
233        foreach (array_values($nodes) as $index => $node) {
234            $position = $offset + $index;
235            $result[] = [
236                ...$this->row($node, $hasChildren[$node->meta->uid] ?? false),
237                'group' => $group,
238                'moveUp' => $group !== null && $position > 0,
239                'moveDown' => $group !== null && $position < ($total - 1),
240            ];
241        }
242
243        return $result;
244    }
245
246    /** @return array<string, mixed> */
247    private function row(Wrapper $node, bool $hasChildren): array
248    {
249        $columns = [];
250        $parent = $node->meta->get('parent');
251
252        if (!is_string($parent) || trim($parent) === '') {
253            $parent = null;
254        }
255
256        $childBlueprints = $this->meta->showChildren
257            ? $this->childBlueprints($node)
258            : [];
259
260        foreach ($this->columns as $column) {
261            $columns[] = $column->get($node);
262        }
263
264        return [
265            'uid' => $node->meta->uid,
266            'published' => $node->meta->published,
267            'hasDraft' => $node->meta->get('draft') !== null,
268            'locked' => $node->meta->locked,
269            'hidden' => $node->meta->hidden,
270            'parent' => $parent,
271            'hasChildren' => $hasChildren,
272            'childBlueprints' => $childBlueprints,
273            'columns' => $columns,
274        ];
275    }
276
277    /**
278     * @param array<Wrapper> $nodes
279     * @return array<string, bool>
280     */
281    private function hasChildrenMap(array $nodes): array
282    {
283        if ($nodes === []) {
284            return [];
285        }
286
287        $uids = [];
288
289        foreach ($nodes as $node) {
290            $uids[] = $node->meta->uid;
291        }
292
293        $uids = array_values(array_unique($uids));
294        $list = implode(',', array_map(
295            static fn(string $uid): string => "'" . str_replace("'", "\\\\'", $uid) . "'",
296            $uids,
297        ));
298
299        if ($list === '') {
300            return [];
301        }
302
303        $children = $this->cms
304            ->nodes("parent @ [{$list}]")
305            ->published(null)
306            ->hidden(null);
307        $result = [];
308
309        foreach ($children as $child) {
310            $parentUid = $child->meta->get('parent');
311
312            if (is_string($parentUid) && $parentUid !== '') {
313                $result[$parentUid] = true;
314            }
315        }
316
317        return $result;
318    }
319
320    /**
321     * The manual order a level follows: the parent's uid when its type
322     * declares sortable children, an empty string for the top level of a
323     * sortable listing, null when the columns decide.
324     */
325    private function scope(string $parent): ?string
326    {
327        if ($parent === '') {
328            return $this->meta->sortable ? '' : null;
329        }
330
331        $node = $this->parent($parent);
332
333        return $node?->meta->type->get('sortableChildren', false) === true ? $parent : null;
334    }
335
336    /** The scope an entry of this listing is moved in. */
337    private function scopeOf(string $uid): string
338    {
339        $node = iterator_to_array($this->entries()->only($uid))[0] ?? null;
340
341        if (!$node instanceof Wrapper) {
342            throw new RuntimeException("'{$uid}' is not an entry of this collection");
343        }
344
345        $parent = $node->meta->get('parent');
346        $level = $this->meta->showChildren && is_string($parent) ? $parent : '';
347
348        return $this->scope($level) ?? throw new RuntimeException("'{$uid}' is not in a manual order");
349    }
350
351    /**
352     * Every member of a manual order in its current order: all children of
353     * the parent, whatever this collection lists, because templates read
354     * the whole group through children(); or the collection's top level.
355     */
356    private function group(string $scope): Nodes
357    {
358        if ($scope !== '') {
359            return $this->cms
360                ->nodes()
361                ->published(null)
362                ->hidden(null)
363                ->childrenOf($scope)
364                ->order($this->positionOrder($scope));
365        }
366
367        $nodes = $this->entries();
368
369        if ($this->meta->showChildren) {
370            $nodes->roots();
371        }
372
373        return $nodes->order($this->positionOrder($scope));
374    }
375
376    private function positionOrder(string $scope): Order
377    {
378        return new Order($scope === '' ? SortField::position($this->handle()) : SortField::position());
379    }
380
381    private function handle(): string
382    {
383        return (string) $this->schema->handle;
384    }
385
386    /** @return list<string> */
387    private function uids(Nodes $nodes): array
388    {
389        $uids = [];
390
391        foreach ($nodes as $node) {
392            $uids[] = $node->meta->uid;
393        }
394
395        return $uids;
396    }
397
398    /**
399     * The flagged sort, or the first sortable column when none is flagged.
400     *
401     * @param array<string, Sort> $sorts
402     */
403    private static function defaultSort(array $sorts): string
404    {
405        $defaults = array_keys(array_filter($sorts, static fn(Sort $sort): bool => $sort->default));
406
407        if (count($defaults) > 1) {
408            throw new RuntimeException(
409                "Only one collection sort can be the default, found '" . implode("', '", $defaults) . "'",
410            );
411        }
412
413        $default = $defaults[0] ?? array_key_first($sorts);
414
415        if ($default === null) {
416            throw new RuntimeException('Collection listings need at least one sortable column');
417        }
418
419        return $default;
420    }
421
422    /** @return array{string, string, non-empty-list<Order>} */
423    private function resolveOrder(string $sort, string $dir): array
424    {
425        $sort = trim($sort);
426        $sort = $sort === '' ? $this->defaultSort : $sort;
427        $definition = $this->sorts[$sort] ?? throw new RuntimeException("Unknown collection sort '{$sort}'");
428        $dir = strtolower(trim($dir));
429        $dir = $dir === '' ? $definition->direction : $dir;
430
431        return [$sort, $dir, $definition->order($dir)];
432    }
433}