Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.00% covered (success)
94.00%
141 / 150
71.43% covered (warning)
71.43%
15 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Collection
94.00% covered (success)
94.00%
141 / 150
71.43% covered (warning)
71.43%
15 / 21
51.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 entries
n/a
0 / 0
n/a
0 / 0
0
 blueprints
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 children
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 columns
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 header
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listing
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 list
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 searchFields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sorts
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 defaultSort
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 defaultDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rows
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 row
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 hasChildrenMap
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
7.04
 childBlueprints
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
7.14
 resolveOrder
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 nav
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 icon
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 handle
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 humanizeClassName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray;
6
7use Cosray\Exception\RuntimeException;
8use Cosray\Finder\Nodes;
9use Cosray\Node\Node;
10use Cosray\Node\Types;
11use Override;
12
13abstract class Collection implements NavigationItem
14{
15    protected static string $name = '';
16    protected static string $handle = '';
17    protected static ?string $icon = null;
18    protected static ?string $badge = null;
19    protected static ?string $permission = null;
20    protected static bool $hidden = false;
21    protected static int $order = 0;
22    protected static bool $showPublished = true;
23    protected static bool $showLocked = false;
24    protected static bool $showHidden = false;
25    protected static bool $showChildren = false;
26
27    public readonly NavMeta $meta;
28
29    private readonly Types $types;
30
31    public function __construct(
32        public readonly ?Cms $cms = null,
33        ?Types $types = null,
34    ) {
35        $this->meta = static::nav();
36        $this->types = $types ?? new Types();
37    }
38
39    abstract public function entries(): Nodes;
40
41    public CollectionListMeta $listMeta {
42        get => new CollectionListMeta(
43            showPublished: static::$showPublished,
44            showLocked: static::$showLocked,
45            showHidden: static::$showHidden,
46            showChildren: static::$showChildren,
47        );
48    }
49
50    /** @return list<class-name> */
51    public function blueprints(): array
52    {
53        return [];
54    }
55
56    #[Override]
57    public function slug(): ?string
58    {
59        return static::handle();
60    }
61
62    /** @return list<NavigationItem> */
63    #[Override]
64    public function children(): array
65    {
66        return [];
67    }
68
69    /**
70     * Returns an array of columns with column definitions.
71     *
72     * Each column array must have the fields `title` and `field`
73     */
74    public function columns(): array
75    {
76        return [
77            Column::new('Titel', 'title')->bold(true)->sort('title'),
78            Column::new('Seitentyp', 'meta.name')->sort('type'),
79            Column::new('Editor', 'meta.editor')->sort('editor'),
80            Column::new('Bearbeitet', 'meta.changed')->date(true)->sort('changed'),
81            Column::new('Erstellt', 'meta.created')->date(true)->sort('created'),
82        ];
83    }
84
85    public function header(): array
86    {
87        return array_map(static fn(Column $column) => $column->title, $this->columns());
88    }
89
90    public function listing(): array
91    {
92        return $this->list();
93    }
94
95    public function list(
96        int $offset = 0,
97        int $limit = 50,
98        string $q = '',
99        string $sort = '',
100        string $dir = 'desc',
101        ?string $parent = null,
102    ): array {
103        $nodes = $this->entries();
104
105        if ($this->listMeta->showChildren) {
106            $parent = trim((string) $parent);
107
108            if ($parent === '') {
109                $nodes->roots();
110            } else {
111                $nodes->childrenOf($parent);
112            }
113        }
114
115        $q = trim($q);
116
117        if ($q !== '') {
118            $nodes->search($q, $this->searchFields());
119        }
120
121        [$sort, $dir, $order] = $this->resolveOrder($sort, $dir);
122        $nodes->order(...$order);
123
124        $total = $nodes->count();
125        $nodes->offset($offset)->limit($limit);
126        $pageNodes = iterator_to_array($nodes);
127
128        return [
129            'total' => $total,
130            'offset' => $offset,
131            'limit' => $limit,
132            'q' => $q,
133            'sort' => $sort,
134            'dir' => $dir,
135            'nodes' => $this->rows($pageNodes),
136        ];
137    }
138
139    public function searchFields(): array
140    {
141        return ['uid', 'title'];
142    }
143
144    public function sorts(): array
145    {
146        return [
147            'changed' => 'changed',
148            'created' => 'created',
149            'uid' => 'uid',
150        ];
151    }
152
153    public function defaultSort(): string
154    {
155        return 'changed';
156    }
157
158    public function defaultDir(): string
159    {
160        return 'desc';
161    }
162
163    /**
164     * @param list<Node> $nodes
165     */
166    private function rows(array $nodes): array
167    {
168        $result = [];
169        $hasChildren = $this->listMeta->showChildren
170            ? $this->hasChildrenMap($nodes)
171            : [];
172
173        foreach ($nodes as $node) {
174            $result[] = $this->row($node, $hasChildren[$node->meta->uid] ?? false);
175        }
176
177        return $result;
178    }
179
180    private function row(Node $node, bool $hasChildren): array
181    {
182        $columns = [];
183        $parent = $node->meta->get('parent');
184
185        if (!is_string($parent) || trim($parent) === '') {
186            $parent = null;
187        }
188
189        $childBlueprints = $this->listMeta->showChildren
190            ? $this->childBlueprints($node)
191            : [];
192
193        foreach ($this->columns() as $column) {
194            $columns[] = $column->get($node);
195        }
196
197        return [
198            'uid' => $node->meta->uid,
199            'published' => $node->meta->published,
200            'locked' => $node->meta->locked,
201            'hidden' => $node->meta->hidden,
202            'parent' => $parent,
203            'hasChildren' => $hasChildren,
204            'childBlueprints' => $childBlueprints,
205            'columns' => $columns,
206        ];
207    }
208
209    /**
210     * @param list<Node> $nodes
211     * @return array<string, bool>
212     */
213    private function hasChildrenMap(array $nodes): array
214    {
215        if ($nodes === []) {
216            return [];
217        }
218
219        $uids = [];
220
221        foreach ($nodes as $node) {
222            $uids[] = $node->meta->uid;
223        }
224
225        $uids = array_values(array_unique($uids));
226        $list = implode(',', array_map(
227            static fn(string $uid): string => "'" . str_replace("'", "\\\\'", $uid) . "'",
228            $uids,
229        ));
230
231        if ($list === '') {
232            return [];
233        }
234
235        $children = $this->cms
236            ->nodes("parent @ [{$list}]")
237            ->published(null)
238            ->hidden(null);
239        $result = [];
240
241        foreach ($children as $child) {
242            $parentUid = $child->meta->get('parent');
243
244            if (is_string($parentUid) && $parentUid !== '') {
245                $result[$parentUid] = true;
246            }
247        }
248
249        return $result;
250    }
251
252    /** @return list<array{slug: string, name: string}> */
253    public function childBlueprints(Node $node): array
254    {
255        $children = $node->meta->type->children;
256
257        if (!is_array($children) || $children === []) {
258            return [];
259        }
260
261        $result = [];
262
263        foreach ($children as $class) {
264            if (!is_string($class) || $class === '') {
265                throw new RuntimeException('The children schema must contain non-empty class names');
266            }
267
268            if (!$this->types->isNode($class)) {
269                throw new RuntimeException("Unknown child node class '{$class}' in #[Children(...)]");
270            }
271
272            $result[] = [
273                'slug' => (string) $this->types->get($class, 'handle'),
274                'name' => (string) $this->types->get($class, 'label'),
275            ];
276        }
277
278        return $result;
279    }
280
281    private function resolveOrder(string $sort, string $dir): array
282    {
283        $sort = trim($sort);
284        $dir = strtolower(trim($dir));
285        $dir = in_array($dir, ['asc', 'desc'], true) ? $dir : strtolower($this->defaultDir());
286        $sorts = $this->sorts();
287        $sort = array_key_exists($sort, $sorts) ? $sort : $this->defaultSort();
288        $field = $sorts[$sort] ?? 'changed';
289        $order = [sprintf('%s %s', $field, strtoupper($dir))];
290
291        if ($field !== 'uid') {
292            $order[] = 'uid ASC';
293        }
294
295        return [$sort, $dir, $order];
296    }
297
298    public static function nav(): NavMeta
299    {
300        $icon = static::$icon;
301        $icon = $icon === null ? null : trim($icon);
302
303        return new NavMeta(
304            label: static::$name ?: static::humanizeClassName(),
305            icon: $icon === null || $icon === ''
306                ? null
307                : [
308                    'id' => $icon,
309                    'args' => [],
310                ],
311            badge: static::$badge,
312            permission: static::$permission,
313            hidden: static::$hidden,
314            order: static::$order,
315        );
316    }
317
318    /** @param array<array-key, mixed> $args */
319    public function icon(string $id, array $args = []): static
320    {
321        $id = trim($id);
322
323        if ($id === '') {
324            throw new RuntimeException('Collection icon ids must not be empty');
325        }
326
327        $this->meta->icon = [
328            'id' => $id,
329            'args' => $args,
330        ];
331
332        return $this;
333    }
334
335    public static function handle(): string
336    {
337        return (
338            static::$handle ?: ltrim(
339                strtolower(preg_replace(
340                    '/[A-Z]([A-Z](?![a-z]))*/',
341                    '-$0',
342                    basename(str_replace('\\', '/', static::class)),
343                )),
344                '-',
345            )
346        );
347    }
348
349    private static function humanizeClassName(): string
350    {
351        $class = basename(str_replace('\\', '/', static::class));
352
353        return (string) preg_replace('/(?<!^)[A-Z]/', ' $0', $class);
354    }
355}