Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.18% covered (warning)
83.18%
178 / 214
53.33% covered (warning)
53.33%
8 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
CollectionTable
83.18% covered (warning)
83.18%
178 / 214
53.33% covered (warning)
53.33%
8 / 15
114.01
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 from
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 headers
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 rows
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
12
 treeNode
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 cells
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
7.02
 childCreateLinks
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 childLinks
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 status
55.56% covered (warning)
55.56%
10 / 18
0.00% covered (danger)
0.00%
0 / 1
13.62
 childBlueprints
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 sortKeys
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 columnSort
18.75% covered (danger)
18.75%
3 / 16
0.00% covered (danger)
0.00%
0 / 1
63.64
 displayValue
60.00% covered (warning)
60.00%
15 / 25
0.00% covered (danger)
0.00%
0 / 1
26.54
 formatDate
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 strings
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Panel;
6
7use Cosray\CollectionListMeta;
8use Cosray\Column;
9use DateTimeImmutable;
10use DateTimeInterface;
11use DateTimeZone;
12use IntlDateFormatter;
13use Stringable;
14use Throwable;
15
16final class CollectionTable
17{
18    use NormalizesInput;
19
20    /**
21     * @param list<array{label: string, url: ?string, class: string, kind: string}> $headers
22     * @param list<array{
23     *     uid: string,
24     *     depth: int,
25     *     expanded: bool,
26     *     last: bool,
27     *     published: bool,
28     *     hasChildren: bool,
29     *     cells: list<array{label: string, value: string, class: string, editUrl: ?string}>,
30     *     status: list<array{kind: string, label: string}>,
31     *     childrenUrl: ?string,
32     *     focusedChildrenUrl: ?string,
33     *     childCreateLinks: list<array{slug: string, name: string, url: string}>,
34     *     childLinks: list<array{label: string, url: string}>,
35     * }> $rows
36     */
37    private function __construct(
38        public readonly bool $showChildren,
39        public readonly bool $treeMode,
40        public readonly array $headers,
41        public readonly array $rows,
42    ) {}
43
44    /**
45     * @param iterable<Column> $columns
46     * @param iterable<mixed> $sortKeys
47     * @param iterable<mixed> $nodes
48     */
49    public static function from(
50        iterable $columns,
51        iterable $sortKeys,
52        iterable $nodes,
53        CollectionUrls $urls,
54        CollectionListMeta $meta,
55        string $locale,
56        DateTimeZone $timezone,
57    ): self {
58        $nodes = self::items($nodes);
59        $headers = self::headers($columns, $sortKeys, $urls);
60
61        return new self(
62            showChildren: $meta->showChildren,
63            treeMode: $meta->showChildren && $urls->query->view === 'tree',
64            headers: $headers,
65            rows: self::rows($nodes, $headers, $urls, $meta, $locale, $timezone),
66        );
67    }
68
69    /**
70     * @param iterable<Column> $columns
71     * @param iterable<mixed> $sortKeys
72     * @return list<array{label: string, url: ?string, class: string, kind: string}>
73     */
74    private static function headers(
75        iterable $columns,
76        iterable $sortKeys,
77        CollectionUrls $urls,
78    ): array {
79        $sortKeys = self::sortKeys($sortKeys);
80        $headers = [];
81
82        foreach ($columns as $column) {
83            $label = $column->title;
84            $sort = self::columnSort($column, $sortKeys);
85            $isSorted = $sort !== null && $sort === $urls->query->sort;
86            $nextDir = $isSorted && $urls->query->dir === 'asc' ? 'desc' : 'asc';
87            $class = $sort === null ? '' : 'is-sortable';
88
89            if ($isSorted) {
90                $class .= ' is-sorted is-' . $urls->query->dir;
91            }
92
93            $headers[] = [
94                'label' => $label,
95                'url' => $sort === null
96                    ? null
97                    : $urls->collection(['sort' => $sort, 'dir' => $nextDir, 'offset' => '']),
98                'class' => $class,
99                'kind' => $column->kind(),
100            ];
101        }
102
103        return $headers;
104    }
105
106    /**
107     * @param list<array{label: string, url: ?string, class: string, kind: string}> $headers
108     * @param list<mixed> $nodes
109     * @return list<array{
110     *     uid: string,
111     *     depth: int,
112     *     expanded: bool,
113     *     last: bool,
114     *     published: bool,
115     *     hasChildren: bool,
116     *     cells: list<array{label: string, value: string, class: string, editUrl: ?string}>,
117     *     status: list<array{kind: string, label: string}>,
118     *     childrenUrl: ?string,
119     *     focusedChildrenUrl: ?string,
120     *     childCreateLinks: list<array{slug: string, name: string, url: string}>,
121     *     childLinks: list<array{label: string, url: string}>,
122     * }>
123     */
124    private static function rows(
125        array $nodes,
126        array $headers,
127        CollectionUrls $urls,
128        CollectionListMeta $meta,
129        string $locale,
130        DateTimeZone $timezone,
131    ): array {
132        $rows = [];
133
134        foreach ($nodes as $node) {
135            $tree = self::treeNode($node);
136            $node = $tree['node'];
137            $uid = (string) ($node['uid'] ?? '');
138            $hasChildren = (bool) ($node['hasChildren'] ?? false);
139            $childBlueprints = self::childBlueprints($node);
140            $childrenUrl = null;
141
142            if ($meta->showChildren && $hasChildren && $uid !== '') {
143                $childrenUrl = $tree['expanded']
144                    ? $urls->collapse($uid, $tree['descendants'])
145                    : $urls->expand($uid);
146            }
147
148            $rows[] = [
149                'uid' => $uid,
150                'depth' => $tree['depth'],
151                'expanded' => $tree['expanded'],
152                'last' => $tree['last'],
153                'published' => (bool) ($node['published'] ?? false),
154                'hasChildren' => $hasChildren,
155                'cells' => self::cells($node, $headers, $urls, $locale, $timezone),
156                'status' => self::status($node, $meta),
157                'childrenUrl' => $childrenUrl,
158                'focusedChildrenUrl' =>
159                    $meta->showChildren && $uid !== '' && ($hasChildren || $childBlueprints !== [])
160                        ? $urls->children($uid)
161                        : null,
162                'childCreateLinks' => $meta->showChildren
163                    ? self::childCreateLinks($childBlueprints, $urls, $uid)
164                    : [],
165                'childLinks' => $meta->showChildren
166                    ? self::childLinks($node, $urls, $childBlueprints)
167                    : [],
168            ];
169        }
170
171        return $rows;
172    }
173
174    /**
175     * @return array{
176     *     node: array<string, mixed>,
177     *     depth: int,
178     *     expanded: bool,
179     *     last: bool,
180     *     descendants: list<string>,
181     * }
182     */
183    private static function treeNode(mixed $node): array
184    {
185        $tree = self::arrayFrom($node);
186
187        if (!array_key_exists('node', $tree)) {
188            return [
189                'node' => $tree,
190                'depth' => 0,
191                'expanded' => false,
192                'last' => false,
193                'descendants' => [],
194            ];
195        }
196
197        return [
198            'node' => self::arrayFrom($tree['node']),
199            'depth' => max(0, (int) ($tree['depth'] ?? 0)),
200            'expanded' => (bool) ($tree['expanded'] ?? false),
201            'last' => (bool) ($tree['last'] ?? false),
202            'descendants' => self::strings($tree['descendants'] ?? []),
203        ];
204    }
205
206    /**
207     * @param array<string, mixed> $node
208     * @param list<array{label: string, url: ?string, class: string, kind: string}> $headers
209     * @return list<array{label: string, value: string, class: string, editUrl: ?string}>
210     */
211    private static function cells(
212        array $node,
213        array $headers,
214        CollectionUrls $urls,
215        string $locale,
216        DateTimeZone $timezone,
217    ): array {
218        $cells = [];
219        $uid = (string) ($node['uid'] ?? '');
220
221        foreach (self::items(self::arrayFrom($node['columns'] ?? [])) as $index => $column) {
222            $column = self::arrayFrom($column);
223            $label = $headers[$index]['label'] ?? __('collection:column', [
224                'number' => (int) $index + 1,
225            ]);
226            $classes = ['collection-cell'];
227
228            if ((bool) ($column['bold'] ?? false)) {
229                $classes[] = 'is-bold';
230            }
231
232            if ((bool) ($column['italic'] ?? false)) {
233                $classes[] = 'is-italic';
234            }
235
236            if ((bool) ($column['badge'] ?? false)) {
237                $classes[] = 'is-badge';
238            }
239
240            $cells[] = [
241                'label' => $label,
242                'value' => self::displayValue(
243                    $column['value'] ?? '',
244                    (bool) ($column['date'] ?? false),
245                    $locale,
246                    $timezone,
247                ),
248                'class' => implode(' ', $classes),
249                'editUrl' => $index === 0 && $uid !== '' ? $urls->edit($uid) : null,
250            ];
251        }
252
253        return $cells;
254    }
255
256    /**
257     * @param list<array{slug: string, name: string}> $blueprints
258     * @return list<array{slug: string, name: string, url: string}>
259     */
260    private static function childCreateLinks(
261        array $blueprints,
262        CollectionUrls $urls,
263        string $uid,
264    ): array {
265        $links = [];
266
267        if ($uid === '') {
268            return [];
269        }
270
271        foreach ($blueprints as $blueprint) {
272            $links[] = [
273                'slug' => $blueprint['slug'],
274                'name' => __($blueprint['name']),
275                'url' => $urls->create($blueprint['slug'], $uid),
276            ];
277        }
278
279        return $links;
280    }
281
282    /**
283     * @param array<string, mixed> $node
284     * @param list<array{slug: string, name: string}> $blueprints
285     * @return list<array{label: string, url: string}>
286     */
287    private static function childLinks(
288        array $node,
289        CollectionUrls $urls,
290        array $blueprints,
291    ): array {
292        $links = [];
293        $uid = (string) ($node['uid'] ?? '');
294
295        if ($uid === '') {
296            return [];
297        }
298
299        if ((bool) ($node['hasChildren'] ?? false)) {
300            $links[] = [
301                'label' => __('collection:open-children'),
302                'url' => $urls->children($uid),
303            ];
304        }
305
306        foreach ($blueprints as $blueprint) {
307            $links[] = [
308                'label' => __('collection:add', ['name' => __($blueprint['name'])]),
309                'url' => $urls->create($blueprint['slug'], $uid),
310            ];
311        }
312
313        return $links;
314    }
315
316    /**
317     * @param array<string, mixed> $node
318     * @return list<array{kind: string, label: string}>
319     */
320    private static function status(array $node, CollectionListMeta $meta): array
321    {
322        $badges = [];
323
324        if ($meta->showPublished) {
325            $published = (bool) ($node['published'] ?? false);
326            $badges[] = [
327                'kind' => $published ? 'published' : 'draft',
328                'label' => $published ? __('status:published') : __('status:draft'),
329            ];
330        }
331
332        if ($meta->showHidden && (bool) ($node['hidden'] ?? false)) {
333            $badges[] = [
334                'kind' => 'hidden',
335                'label' => __('status:hidden'),
336            ];
337        }
338
339        if ($meta->showLocked && (bool) ($node['locked'] ?? false)) {
340            $badges[] = [
341                'kind' => 'locked',
342                'label' => __('status:locked'),
343            ];
344        }
345
346        return $badges;
347    }
348
349    /**
350     * @param array<string, mixed> $node
351     * @return list<array{slug: string, name: string}>
352     */
353    private static function childBlueprints(array $node): array
354    {
355        $blueprints = [];
356
357        foreach (self::items(self::arrayFrom($node['childBlueprints'] ?? [])) as $blueprint) {
358            $blueprint = self::arrayFrom($blueprint);
359            $slug = trim((string) ($blueprint['slug'] ?? ''));
360            $name = trim((string) ($blueprint['name'] ?? ''));
361
362            if ($slug === '' || $name === '') {
363                continue;
364            }
365
366            $blueprints[] = [
367                'slug' => $slug,
368                'name' => $name,
369            ];
370        }
371
372        return $blueprints;
373    }
374
375    /** @return list<string> */
376    private static function sortKeys(iterable $sorts): array
377    {
378        return array_values(array_filter(
379            array_map(static fn(mixed $sort): string => trim((string) $sort), self::items($sorts)),
380            static fn(string $sort): bool => $sort !== '',
381        ));
382    }
383
384    /** @param list<string> $sorts */
385    private static function columnSort(Column $column, array $sorts): ?string
386    {
387        $explicit = $column->sortKey();
388
389        if ($explicit !== null) {
390            return in_array($explicit, $sorts, true) ? $explicit : null;
391        }
392
393        if (!is_string($column->field)) {
394            return null;
395        }
396
397        $candidates = [$column->field];
398
399        if (str_starts_with($column->field, 'meta.')) {
400            $candidates[] = substr($column->field, 5);
401        }
402
403        $candidates[] = match ($column->field) {
404            'meta.name', 'meta.class', 'meta.classname' => 'type',
405            default => '',
406        };
407
408        foreach (array_unique($candidates) as $candidate) {
409            if ($candidate !== '' && in_array($candidate, $sorts, true)) {
410                return $candidate;
411            }
412        }
413
414        return null;
415    }
416
417    private static function displayValue(
418        mixed $value,
419        bool $date,
420        string $locale,
421        DateTimeZone $timezone,
422    ): string {
423        if ($date && $value instanceof DateTimeInterface) {
424            $formatted = self::formatDate($value, $locale, $timezone);
425
426            if ($formatted !== null) {
427                return $formatted;
428            }
429        }
430
431        if ($date && (is_scalar($value) || $value instanceof Stringable)) {
432            $original = $value;
433            $value = trim((string) $value);
434
435            if ($value !== '') {
436                try {
437                    $formatted = self::formatDate(
438                        new DateTimeImmutable($value, $timezone),
439                        $locale,
440                        $timezone,
441                    );
442
443                    if ($formatted !== null) {
444                        return $formatted;
445                    }
446                } catch (Throwable) {
447                    $value = $original;
448                }
449            }
450
451            $value = $original;
452        }
453
454        if (is_bool($value)) {
455            return $value ? __('common:yes') : __('common:no');
456        }
457
458        if (is_scalar($value)) {
459            return (string) $value;
460        }
461
462        if ($value instanceof Stringable) {
463            return (string) $value;
464        }
465
466        return '';
467    }
468
469    private static function formatDate(
470        DateTimeInterface $value,
471        string $locale,
472        DateTimeZone $timezone,
473    ): ?string {
474        $formatter = new IntlDateFormatter(
475            $locale,
476            IntlDateFormatter::MEDIUM,
477            IntlDateFormatter::SHORT,
478            $timezone,
479        );
480        $formatted = $formatter->format($value->getTimestamp());
481
482        return $formatted === false ? null : $formatted;
483    }
484
485    /** @return list<string> */
486    private static function strings(mixed $items): array
487    {
488        $strings = [];
489
490        foreach (self::items(self::arrayFrom($items)) as $item) {
491            $item = trim((string) $item);
492
493            if ($item !== '' && !in_array($item, $strings, true)) {
494                $strings[] = $item;
495            }
496        }
497
498        return $strings;
499    }
500}