Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.11% |
168 / 173 |
|
77.78% |
14 / 18 |
CRAP | |
0.00% |
0 / 1 |
| Listing | |
97.11% |
168 / 173 |
|
77.78% |
14 / 18 |
69 | |
0.00% |
0 / 1 |
| __construct | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
6.01 | |||
| entries | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| parent | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| list | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
7 | |||
| place | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| neighbour | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
4.02 | |||
| childBlueprints | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
7.14 | |||
| rows | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
5 | |||
| row | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
5 | |||
| hasChildrenMap | |
95.45% |
21 / 22 |
|
0.00% |
0 / 1 |
7 | |||
| scope | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| scopeOf | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| group | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
| positionOrder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| handle | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| uids | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| defaultSort | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| resolveOrder | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Collection; |
| 6 | |
| 7 | use Cosray\Cms; |
| 8 | use Cosray\CollectionListMeta; |
| 9 | use Cosray\Column; |
| 10 | use Cosray\Contract\Columns; |
| 11 | use Cosray\Contract\Entries; |
| 12 | use Cosray\Exception\RuntimeException; |
| 13 | use Cosray\Finder\Nodes; |
| 14 | use Cosray\Finder\Order; |
| 15 | use Cosray\Finder\SortField; |
| 16 | use Cosray\Node\Positions; |
| 17 | use Cosray\Node\Types; |
| 18 | use 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 | */ |
| 28 | final 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 | } |