Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.01% covered (warning)
89.01%
170 / 191
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
CollectionTable
89.01% covered (warning)
89.01%
170 / 191
58.33% covered (warning)
58.33%
7 / 12
76.51
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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 headers
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
9
 rows
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
13
 treeNode
100.00% covered (success)
100.00%
18 / 18
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
 status
60.00% covered (warning)
60.00%
12 / 20
0.00% covered (danger)
0.00%
0 / 1
14.18
 childBlueprints
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 displayValue
64.00% covered (warning)
64.00%
16 / 25
0.00% covered (danger)
0.00%
0 / 1
23.14
 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     *     group: ?string,
35     *     moveUp: bool,
36     *     moveDown: bool,
37     *     moved: bool,
38     * }> $rows
39     */
40    private function __construct(
41        public readonly bool $showChildren,
42        public readonly bool $treeMode,
43        public readonly array $headers,
44        public readonly array $rows,
45        public readonly string $positionUrl,
46    ) {}
47
48    /**
49     * @param iterable<Column> $columns
50     * @param iterable<mixed> $nodes
51     */
52    public static function from(
53        iterable $columns,
54        iterable $nodes,
55        CollectionUrls $urls,
56        CollectionListMeta $meta,
57        string $locale,
58        DateTimeZone $timezone,
59        bool $arranged = false,
60        string $moved = '',
61    ): self {
62        $nodes = self::items($nodes);
63        $headers = self::headers($columns, $urls, $arranged);
64
65        return new self(
66            showChildren: $meta->showChildren,
67            treeMode: $meta->showChildren && $urls->query->view === 'tree',
68            headers: $headers,
69            rows: self::rows($nodes, $headers, $urls, $meta, $locale, $timezone, $moved),
70            positionUrl: $urls->position(),
71        );
72    }
73
74    /**
75     * @param iterable<Column> $columns
76     * @return list<array{label: string, url: ?string, class: string, kind: string, ariaSort: ?string}>
77     */
78    private static function headers(
79        iterable $columns,
80        CollectionUrls $urls,
81        bool $arranged,
82    ): array {
83        $headers = [];
84
85        foreach ($columns as $column) {
86            $label = $column->title;
87            // A manually ordered top level has no column order to offer.
88            $sort = $arranged ? null : $column->sort;
89            $isSorted = $sort !== null && $sort->key === $urls->query->sort;
90            $nextDir = $sort?->direction;
91            $ariaSort = null;
92            $class = $sort === null ? '' : 'is-sortable';
93
94            if ($isSorted) {
95                $ascending = $urls->query->dir === 'asc';
96                $nextDir = $ascending ? 'desc' : 'asc';
97                $ariaSort = $ascending ? 'ascending' : 'descending';
98                $class .= ' is-sorted is-' . $urls->query->dir;
99            }
100
101            $headers[] = [
102                'label' => $label,
103                'url' => $sort === null
104                    ? null
105                    : $urls->collection(['sort' => $sort->key, 'dir' => $nextDir, 'offset' => '']),
106                'class' => $class,
107                'kind' => $column->kind(),
108                'ariaSort' => $ariaSort,
109            ];
110        }
111
112        return $headers;
113    }
114
115    /**
116     * @param list<array{label: string, url: ?string, class: string, kind: string}> $headers
117     * @param list<mixed> $nodes
118     * @return list<array{
119     *     uid: string,
120     *     depth: int,
121     *     expanded: bool,
122     *     last: bool,
123     *     published: bool,
124     *     hasChildren: bool,
125     *     cells: list<array{label: string, value: string, class: string, editUrl: ?string}>,
126     *     status: list<array{kind: string, label: string}>,
127     *     childrenUrl: ?string,
128     *     focusedChildrenUrl: ?string,
129     *     childCreateLinks: list<array{slug: string, name: string, url: string}>,
130     *     group: ?string,
131     *     moveUp: bool,
132     *     moveDown: bool,
133     *     moved: bool,
134     * }>
135     */
136    private static function rows(
137        array $nodes,
138        array $headers,
139        CollectionUrls $urls,
140        CollectionListMeta $meta,
141        string $locale,
142        DateTimeZone $timezone,
143        string $moved,
144    ): array {
145        $rows = [];
146
147        foreach ($nodes as $node) {
148            $tree = self::treeNode($node);
149            $node = $tree['node'];
150            $uid = (string) ($node['uid'] ?? '');
151            $hasChildren = (bool) ($node['hasChildren'] ?? false);
152            $childBlueprints = self::childBlueprints($node);
153            $childrenUrl = null;
154
155            if ($meta->showChildren && $hasChildren && $uid !== '') {
156                $childrenUrl = $tree['expanded']
157                    ? $urls->collapse($uid, $tree['descendants'])
158                    : $urls->expand($uid);
159            }
160
161            $rows[] = [
162                'uid' => $uid,
163                'depth' => $tree['depth'],
164                'expanded' => $tree['expanded'],
165                'last' => $tree['last'],
166                'guides' => $tree['guides'],
167                'published' => (bool) ($node['published'] ?? false),
168                'hasChildren' => $hasChildren,
169                'cells' => self::cells($node, $headers, $urls, $locale, $timezone),
170                'status' => self::status($node, $meta),
171                'childrenUrl' => $childrenUrl,
172                'focusedChildrenUrl' =>
173                    $meta->showChildren && $uid !== '' && ($hasChildren || $childBlueprints !== [])
174                        ? $urls->children($uid)
175                        : null,
176                'childCreateLinks' => $meta->showChildren
177                    ? self::childCreateLinks($childBlueprints, $urls, $uid)
178                    : [],
179                'group' => is_string($node['group'] ?? null) ? $node['group'] : null,
180                'moveUp' => (bool) ($node['moveUp'] ?? false),
181                'moveDown' => (bool) ($node['moveDown'] ?? false),
182                'moved' => $moved !== '' && $uid === $moved,
183            ];
184        }
185
186        return $rows;
187    }
188
189    /**
190     * @return array{
191     *     node: array<string, mixed>,
192     *     depth: int,
193     *     expanded: bool,
194     *     last: bool,
195     *     guides: list<int>,
196     *     descendants: list<string>,
197     * }
198     */
199    private static function treeNode(mixed $node): array
200    {
201        $tree = self::arrayFrom($node);
202
203        if (!array_key_exists('node', $tree)) {
204            return [
205                'node' => $tree,
206                'depth' => 0,
207                'expanded' => false,
208                'last' => false,
209                'guides' => [],
210                'descendants' => [],
211            ];
212        }
213
214        return [
215            'node' => self::arrayFrom($tree['node']),
216            'depth' => max(0, (int) ($tree['depth'] ?? 0)),
217            'expanded' => (bool) ($tree['expanded'] ?? false),
218            'last' => (bool) ($tree['last'] ?? false),
219            'guides' => array_values(array_map(intval(...), (array) ($tree['guides'] ?? []))),
220            'descendants' => self::strings($tree['descendants'] ?? []),
221        ];
222    }
223
224    /**
225     * @param array<string, mixed> $node
226     * @param list<array{label: string, url: ?string, class: string, kind: string}> $headers
227     * @return list<array{label: string, value: string, class: string, editUrl: ?string}>
228     */
229    private static function cells(
230        array $node,
231        array $headers,
232        CollectionUrls $urls,
233        string $locale,
234        DateTimeZone $timezone,
235    ): array {
236        $cells = [];
237        $uid = (string) ($node['uid'] ?? '');
238
239        foreach (self::items(self::arrayFrom($node['columns'] ?? [])) as $index => $column) {
240            $column = self::arrayFrom($column);
241            $label = $headers[$index]['label'] ?? __('collection:column', [
242                'number' => (int) $index + 1,
243            ]);
244            $classes = ['collection-cell'];
245
246            if ((bool) ($column['bold'] ?? false)) {
247                $classes[] = 'is-bold';
248            }
249
250            if ((bool) ($column['italic'] ?? false)) {
251                $classes[] = 'is-italic';
252            }
253
254            if ((bool) ($column['badge'] ?? false)) {
255                $classes[] = 'is-badge';
256            }
257
258            $cells[] = [
259                'label' => $label,
260                'value' => self::displayValue(
261                    $column['value'] ?? '',
262                    (bool) ($column['date'] ?? false),
263                    $locale,
264                    $timezone,
265                ),
266                'class' => implode(' ', $classes),
267                'editUrl' => $index === 0 && $uid !== '' ? $urls->edit($uid) : null,
268            ];
269        }
270
271        return $cells;
272    }
273
274    /**
275     * @param list<array{slug: string, name: string}> $blueprints
276     * @return list<array{slug: string, name: string, url: string}>
277     */
278    private static function childCreateLinks(
279        array $blueprints,
280        CollectionUrls $urls,
281        string $uid,
282    ): array {
283        $links = [];
284
285        if ($uid === '') {
286            return [];
287        }
288
289        foreach ($blueprints as $blueprint) {
290            $links[] = [
291                'slug' => $blueprint['slug'],
292                'name' => __($blueprint['name']),
293                'url' => $urls->create($blueprint['slug'], $uid),
294            ];
295        }
296
297        return $links;
298    }
299
300    /**
301     * @param array<string, mixed> $node
302     * @return list<array{kind: string, label: string}>
303     */
304    private static function status(array $node, CollectionListMeta $meta): array
305    {
306        $badges = [];
307
308        if ($meta->showPublished) {
309            $published = (bool) ($node['published'] ?? false);
310            $badges[] = [
311                'kind' => $published ? 'published' : 'unpublished',
312                'label' => $published ? __('status:published') : __('status:unpublished'),
313            ];
314
315            if ((bool) ($node['hasDraft'] ?? false)) {
316                $badges[] = ['kind' => 'changes', 'label' => __('status:changes')];
317            }
318        }
319
320        if ($meta->showHidden && (bool) ($node['hidden'] ?? false)) {
321            $badges[] = [
322                'kind' => 'hidden',
323                'label' => __('status:hidden'),
324            ];
325        }
326
327        if ($meta->showLocked && (bool) ($node['locked'] ?? false)) {
328            $badges[] = [
329                'kind' => 'locked',
330                'label' => __('status:locked'),
331            ];
332        }
333
334        return $badges;
335    }
336
337    /**
338     * @param array<string, mixed> $node
339     * @return list<array{slug: string, name: string}>
340     */
341    private static function childBlueprints(array $node): array
342    {
343        $blueprints = [];
344
345        foreach (self::items(self::arrayFrom($node['childBlueprints'] ?? [])) as $blueprint) {
346            $blueprint = self::arrayFrom($blueprint);
347            $slug = trim((string) ($blueprint['slug'] ?? ''));
348            $name = trim((string) ($blueprint['name'] ?? ''));
349
350            if ($slug === '' || $name === '') {
351                continue;
352            }
353
354            $blueprints[] = [
355                'slug' => $slug,
356                'name' => $name,
357            ];
358        }
359
360        return $blueprints;
361    }
362
363    private static function displayValue(
364        mixed $value,
365        bool $date,
366        string $locale,
367        DateTimeZone $timezone,
368    ): string {
369        if ($date && $value instanceof DateTimeInterface) {
370            $formatted = self::formatDate($value, $locale, $timezone);
371
372            if ($formatted !== null) {
373                return $formatted;
374            }
375        }
376
377        if ($date && (is_scalar($value) || $value instanceof Stringable)) {
378            $original = $value;
379            $value = trim((string) $value);
380
381            if ($value !== '') {
382                try {
383                    $formatted = self::formatDate(
384                        new DateTimeImmutable($value, $timezone),
385                        $locale,
386                        $timezone,
387                    );
388
389                    if ($formatted !== null) {
390                        return $formatted;
391                    }
392                } catch (Throwable) {
393                    $value = $original;
394                }
395            }
396
397            $value = $original;
398        }
399
400        if (is_bool($value)) {
401            return $value ? __('common:yes') : __('common:no');
402        }
403
404        if (is_scalar($value)) {
405            return (string) $value;
406        }
407
408        if ($value instanceof Stringable) {
409            return (string) $value;
410        }
411
412        return '';
413    }
414
415    private static function formatDate(
416        DateTimeInterface $value,
417        string $locale,
418        DateTimeZone $timezone,
419    ): ?string {
420        $formatter = new IntlDateFormatter(
421            $locale,
422            IntlDateFormatter::MEDIUM,
423            IntlDateFormatter::SHORT,
424            $timezone,
425        );
426        $formatted = $formatter->format($value->getTimestamp());
427
428        return $formatted === false ? null : $formatted;
429    }
430
431    /** @return list<string> */
432    private static function strings(mixed $items): array
433    {
434        $strings = [];
435
436        foreach (self::items(self::arrayFrom($items)) as $item) {
437            $item = trim((string) $item);
438
439            if ($item !== '' && !in_array($item, $strings, true)) {
440                $strings[] = $item;
441            }
442        }
443
444        return $strings;
445    }
446}