Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.79% covered (success)
91.79%
179 / 195
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Collection
91.79% covered (success)
91.79%
179 / 195
40.00% covered (danger)
40.00%
4 / 10
56.67
0.00% covered (danger)
0.00%
0 / 1
 collection
98.88% covered (success)
98.88%
88 / 89
0.00% covered (danger)
0.00%
0 / 1
21
 notice
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
6
 parentNode
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 nodeStatus
57.89% covered (warning)
57.89%
11 / 19
0.00% covered (danger)
0.00%
0 / 1
12.78
 types
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 blueprints
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 intParam
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
9.36
 openParam
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 stringParam
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 navigation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Celema\Core\Exception\HttpNotFound;
9use Celema\Core\Request;
10use Celema\Wire\Creator;
11use Cosray\Collection as CmsCollection;
12use Cosray\Collection\Listing;
13use Cosray\Exception\RuntimeException;
14use Cosray\Navigation;
15use Cosray\Node\Types;
16use Cosray\Node\Wrapper;
17use Cosray\Panel\CollectionPage;
18use Cosray\Panel\CollectionQuery;
19use Cosray\Panel\CollectionTree;
20use Cosray\Panel\CollectionUrls;
21
22final class Collection extends Panel
23{
24    private const int LIMIT_DEFAULT = 50;
25    private const int LIMIT_MAX = 250;
26
27    public function collection(string $collection): array
28    {
29        $creator = new Creator($this->container);
30        $navigation = $this->navigation();
31
32        try {
33            $ref = $navigation->ref($collection);
34        } catch (RuntimeException $e) {
35            throw new HttpNotFound($this->request, previous: $e);
36        }
37
38        $obj = $creator->create(
39            $ref->class,
40            predefinedTypes: [Request::class => $this->request],
41        );
42        assert($obj instanceof CmsCollection, 'The collection route must resolve a collection');
43        $lister = new Listing($obj, $this->types());
44
45        $offset = $this->intParam('offset', 0, min: 0);
46        $limit = $this->intParam('limit', self::LIMIT_DEFAULT, min: 1, max: self::LIMIT_MAX);
47        $q = $this->stringParam('q');
48        $sort = $this->stringParam('sort');
49        $dir = strtolower($this->stringParam('dir'));
50        $parent = $this->stringParam('parent');
51        $view = $this->stringParam('view');
52        $open = $this->openParam('open');
53
54        if ($dir !== '' && !in_array($dir, ['asc', 'desc'], true)) {
55            throw new HttpBadRequest($this->request);
56        }
57
58        $sorts = $obj->sorts();
59
60        if ($sort !== '' && !array_key_exists($sort, $sorts)) {
61            throw new HttpBadRequest($this->request);
62        }
63
64        $parentUid = $obj->listMeta->showChildren && $parent !== '' ? $parent : null;
65        $defaultView = $obj->listMeta->showChildren && $parentUid === null ? 'tree' : 'list';
66
67        if ($view === '') {
68            $view = $defaultView;
69        }
70
71        if (!in_array($view, ['tree', 'list'], true)) {
72            throw new HttpBadRequest($this->request);
73        }
74
75        if (!$obj->listMeta->showChildren) {
76            $open = [];
77        }
78
79        $parentNode = $parentUid === null ? null : $this->parentNode($obj, $parentUid);
80        $parentTitle = $parentNode?->title();
81
82        if ($parentTitle !== null && trim($parentTitle) === '') {
83            $parentTitle = $parentUid;
84        }
85
86        $listing = $lister->list(
87            offset: $offset,
88            limit: $limit,
89            q: $q,
90            sort: $sort,
91            dir: $dir,
92            parent: $parentUid,
93        );
94
95        $query = new CollectionQuery(
96            q: $listing['q'],
97            sort: $listing['sort'],
98            dir: $listing['dir'],
99            offset: $listing['offset'],
100            limit: $listing['limit'],
101            parent: $parentUid,
102            view: $view,
103            open: $open,
104            defaultView: $defaultView,
105        );
106        $nodes = $listing['nodes'];
107
108        if ($obj->listMeta->showChildren && $view === 'tree') {
109            $nodes = CollectionTree::build(
110                nodes: $nodes,
111                open: $open,
112                children: static fn(string $uid): array => $lister->list(
113                    offset: 0,
114                    limit: self::LIMIT_MAX,
115                    sort: $listing['sort'],
116                    dir: $listing['dir'],
117                    parent: $uid,
118                )['nodes'],
119            );
120        }
121
122        $urls = new CollectionUrls($this->panelPath(), $collection, $query);
123
124        return $this->context([
125            'notice' => $this->notice(),
126            'page' => CollectionPage::from(
127                name: __($ref->meta->label),
128                urls: $urls,
129                columns: $obj->columns(),
130                sortKeys: array_keys($sorts),
131                blueprints: $this->blueprints($obj),
132                nodes: $nodes,
133                total: $listing['total'],
134                meta: $obj->listMeta,
135                locale: $this->localeId(),
136                timezone: $this->config->app->timezone,
137                parentTitle: $parentTitle,
138                parentType: $parentNode === null
139                    ? null
140                    : __((string) $parentNode->meta->type->get('label', '')),
141                parentStatus: $parentNode === null ? null : $this->nodeStatus($obj, $parentNode),
142                createBlueprints: $parentNode === null ? null : $lister->childBlueprints($parentNode),
143            ),
144        ]);
145    }
146
147    /**
148     * The bulk-action result summary from the redirect's `notice` param
149     * (`key:count` pairs), reduced to display strings. Display-only, so
150     * unknown or malformed parts are dropped rather than rejected.
151     *
152     * @return list<string>
153     */
154    private function notice(): array
155    {
156        $value = $this->request->param('notice', '');
157
158        if (!is_string($value) || trim($value) === '') {
159            return [];
160        }
161
162        // Literal ids so the i18n scanner sees every key.
163        $keys = [
164            'deleted' => static fn(int $n): string => __n(
165                'bulk:notice-deleted',
166                'bulk:notice-deleted-plural',
167                $n,
168            ),
169            'published' => static fn(int $n): string => __n(
170                'bulk:notice-published',
171                'bulk:notice-published-plural',
172                $n,
173            ),
174            'drafted' => static fn(int $n): string => __n(
175                'bulk:notice-drafted',
176                'bulk:notice-drafted-plural',
177                $n,
178            ),
179            'duplicated' => static fn(int $n): string => __n(
180                'bulk:notice-duplicated',
181                'bulk:notice-duplicated-plural',
182                $n,
183            ),
184            'skipped-children' => static fn(int $n): string => __n(
185                'bulk:notice-skipped-children',
186                'bulk:notice-skipped-children-plural',
187                $n,
188            ),
189            'skipped-locked' => static fn(int $n): string => __n(
190                'bulk:notice-skipped-locked',
191                'bulk:notice-skipped-locked-plural',
192                $n,
193            ),
194            'skipped' => static fn(int $n): string => __n(
195                'bulk:notice-skipped',
196                'bulk:notice-skipped-plural',
197                $n,
198            ),
199        ];
200        $messages = [];
201
202        foreach (explode(',', $value) as $part) {
203            [$key, $count] = array_pad(explode(':', $part, 2), 2, '');
204
205            if (!isset($keys[$key]) || !preg_match('/^[1-9][0-9]{0,5}$/', $count)) {
206                continue;
207            }
208
209            $messages[] = $keys[$key]((int) $count);
210        }
211
212        return $messages;
213    }
214
215    private function parentNode(CmsCollection $collection, string $uid): Wrapper
216    {
217        $node = $collection->cms?->node->byUid($uid, published: null);
218
219        if (!$node) {
220            throw new HttpNotFound($this->request);
221        }
222
223        return $node;
224    }
225
226    /** @return list<array{kind: string, label: string}> */
227    private function nodeStatus(CmsCollection $collection, Wrapper $node): array
228    {
229        $status = [];
230        $meta = $collection->listMeta;
231
232        if ($meta->showPublished) {
233            $published = (bool) $node->meta->get('published');
234            $status[] = [
235                'kind' => $published ? 'published' : 'draft',
236                'label' => $published ? __('status:published') : __('status:draft'),
237            ];
238        }
239
240        if ($meta->showHidden && (bool) $node->meta->get('hidden')) {
241            $status[] = [
242                'kind' => 'hidden',
243                'label' => __('status:hidden'),
244            ];
245        }
246
247        if ($meta->showLocked && (bool) $node->meta->get('locked')) {
248            $status[] = [
249                'kind' => 'locked',
250                'label' => __('status:locked'),
251            ];
252        }
253
254        return $status;
255    }
256
257    private function types(): Types
258    {
259        $types = $this->container->get(Types::class);
260        assert($types instanceof Types, 'The node type service must be available');
261
262        return $types;
263    }
264
265    /** @return list<array{slug: string, name: string}> */
266    private function blueprints(CmsCollection $collection): array
267    {
268        $types = $this->types();
269        $result = [];
270
271        foreach ($collection->blueprints() as $blueprint) {
272            $result[] = [
273                'slug' => (string) $types->get($blueprint, 'handle'),
274                'name' => (string) $types->get($blueprint, 'label'),
275            ];
276        }
277
278        return $result;
279    }
280
281    private function intParam(
282        string $key,
283        int $default,
284        int $min,
285        ?int $max = null,
286    ): int {
287        $value = $this->request->param($key, (string) $default);
288
289        if (is_int($value)) {
290            $int = $value;
291        } elseif (is_string($value) && preg_match('/^-?[0-9]+$/', $value)) {
292            $int = (int) $value;
293        } else {
294            throw new HttpBadRequest($this->request);
295        }
296
297        if ($int < $min) {
298            throw new HttpBadRequest($this->request);
299        }
300
301        if ($max !== null && $int > $max) {
302            throw new HttpBadRequest($this->request);
303        }
304
305        return $int;
306    }
307
308    /** @return list<string> */
309    private function openParam(string $key): array
310    {
311        $value = $this->request->param($key, '');
312
313        if (!is_string($value)) {
314            throw new HttpBadRequest($this->request);
315        }
316
317        $open = [];
318
319        foreach (explode(',', $value) as $uid) {
320            $uid = trim($uid);
321
322            if ($uid !== '' && !in_array($uid, $open, true)) {
323                $open[] = $uid;
324            }
325        }
326
327        return $open;
328    }
329
330    private function stringParam(string $key): string
331    {
332        $value = $this->request->param($key, '');
333
334        if (!is_string($value)) {
335            throw new HttpBadRequest($this->request);
336        }
337
338        return trim($value);
339    }
340
341    private function navigation(): Navigation
342    {
343        $navigation = $this->container->get(Navigation::class);
344        assert($navigation instanceof Navigation, 'The navigation service must be available');
345
346        return $navigation;
347    }
348}