Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
94.89% |
130 / 137 |
|
40.00% |
4 / 10 |
CRAP | |
0.00% |
0 / 1 |
| Reference | |
94.89% |
130 / 137 |
|
40.00% |
4 / 10 |
43.25 | |
0.00% |
0 / 1 |
| search | |
96.55% |
28 / 29 |
|
0.00% |
0 / 1 |
5 | |||
| nodes | |
96.15% |
25 / 26 |
|
0.00% |
0 / 1 |
4 | |||
| labels | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
6 | |||
| item | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| constraints | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
7 | |||
| registeredTypes | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
7.03 | |||
| nodeClass | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
5.07 | |||
| result | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| stringParam | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| intParam | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
5.39 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Controller\Panel; |
| 6 | |
| 7 | use Celema\Core\Factory\Factory; |
| 8 | use Celema\Core\Response; |
| 9 | use Cosray\Bootstrap; |
| 10 | use Cosray\Cms; |
| 11 | use Cosray\Field\Reference as ReferenceField; |
| 12 | use Cosray\Node\Wrapper; |
| 13 | use Cosray\Schema\Pick; |
| 14 | use ReflectionNamedType; |
| 15 | use ReflectionProperty; |
| 16 | |
| 17 | /** |
| 18 | * JSON node search backing the reference picker. The pickable set is |
| 19 | * derived server-side from the reference field's own schema (never from |
| 20 | * the client): non-deleted, optionally type/filter constrained, any |
| 21 | * publication state, hidden included, current node excluded. Rows whose |
| 22 | * node type is no longer registered are omitted because they cannot be hydrated. |
| 23 | */ |
| 24 | final class Reference extends Panel |
| 25 | { |
| 26 | private const int LIMIT_DEFAULT = 30; |
| 27 | private const int LIMIT_MAX = 100; |
| 28 | |
| 29 | public function search(Cms $cms, Factory $factory): Response |
| 30 | { |
| 31 | $constraints = $this->constraints($this->stringParam('type'), $this->stringParam('field')); |
| 32 | |
| 33 | if ($constraints === null) { |
| 34 | return $this->result($factory, [], false); |
| 35 | } |
| 36 | |
| 37 | $q = $this->stringParam('q'); |
| 38 | $exclude = $this->stringParam('node'); |
| 39 | $offset = $this->intParam('offset', 0, min: 0); |
| 40 | $limit = $this->intParam('limit', self::LIMIT_DEFAULT, min: 1, max: self::LIMIT_MAX); |
| 41 | |
| 42 | $types = $this->registeredTypes($constraints['types']); |
| 43 | |
| 44 | if ($types === []) { |
| 45 | return $this->result($factory, [], false); |
| 46 | } |
| 47 | |
| 48 | $finder = $cms |
| 49 | ->nodes($constraints['where']) |
| 50 | ->deleted(false) |
| 51 | ->published($constraints['published']) |
| 52 | ->hidden($constraints['hidden']) |
| 53 | ->types(...$types) |
| 54 | ->order('changed DESC'); |
| 55 | |
| 56 | if ($exclude !== '') { |
| 57 | $finder->exclude($exclude); |
| 58 | } |
| 59 | |
| 60 | if ($q !== '') { |
| 61 | $finder->searchTitle($q); |
| 62 | } |
| 63 | |
| 64 | $finder->offset($offset)->limit($limit + 1); |
| 65 | |
| 66 | $rows = iterator_to_array($finder, false); |
| 67 | $more = count($rows) > $limit; |
| 68 | |
| 69 | return $this->result( |
| 70 | $factory, |
| 71 | array_map($this->item(...), array_slice($rows, 0, $limit)), |
| 72 | $more, |
| 73 | ); |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Unconstrained node search backing the richtext link picker. A prose |
| 78 | * link is not bound to a schema property, so the pickable set is simply |
| 79 | * every non-deleted node (any registered type, any publication), the current node |
| 80 | * excluded. Kept separate from search() so that method's reflected |
| 81 | * #[Pick] contract (constraints from the field, never the client) stays |
| 82 | * intact. |
| 83 | */ |
| 84 | public function nodes(Cms $cms, Factory $factory): Response |
| 85 | { |
| 86 | $q = $this->stringParam('q'); |
| 87 | $exclude = $this->stringParam('node'); |
| 88 | $offset = $this->intParam('offset', 0, min: 0); |
| 89 | $limit = $this->intParam('limit', self::LIMIT_DEFAULT, min: 1, max: self::LIMIT_MAX); |
| 90 | |
| 91 | $types = $this->registeredTypes(); |
| 92 | |
| 93 | if ($types === []) { |
| 94 | return $this->result($factory, [], false); |
| 95 | } |
| 96 | |
| 97 | $finder = $cms |
| 98 | ->nodes() |
| 99 | ->deleted(false) |
| 100 | ->published(null) |
| 101 | ->hidden(null) |
| 102 | ->types(...$types) |
| 103 | ->order('changed DESC'); |
| 104 | |
| 105 | if ($exclude !== '') { |
| 106 | $finder->exclude($exclude); |
| 107 | } |
| 108 | |
| 109 | if ($q !== '') { |
| 110 | $finder->searchTitle($q); |
| 111 | } |
| 112 | |
| 113 | $finder->offset($offset)->limit($limit + 1); |
| 114 | |
| 115 | $rows = iterator_to_array($finder, false); |
| 116 | $more = count($rows) > $limit; |
| 117 | |
| 118 | return $this->result( |
| 119 | $factory, |
| 120 | array_map($this->item(...), array_slice($rows, 0, $limit)), |
| 121 | $more, |
| 122 | ); |
| 123 | } |
| 124 | |
| 125 | /** |
| 126 | * Resolve titles for already-chosen uids so the control can render its |
| 127 | * selected rows. Chosen values render regardless of the pickable set; |
| 128 | * soft-deleted targets and unregistered node types drop out, and the |
| 129 | * caller's order is kept. |
| 130 | */ |
| 131 | public function labels(Cms $cms, Factory $factory): Response |
| 132 | { |
| 133 | $uids = array_values(array_filter( |
| 134 | array_map('trim', explode(',', $this->stringParam('uids'))), |
| 135 | static fn(string $uid): bool => $uid !== '', |
| 136 | )); |
| 137 | |
| 138 | if ($uids === []) { |
| 139 | return $this->result($factory, [], false); |
| 140 | } |
| 141 | |
| 142 | $types = $this->registeredTypes(); |
| 143 | |
| 144 | if ($types === []) { |
| 145 | return $this->result($factory, [], false); |
| 146 | } |
| 147 | |
| 148 | $byUid = []; |
| 149 | |
| 150 | foreach ($cms |
| 151 | ->nodes() |
| 152 | ->deleted(false) |
| 153 | ->published(null) |
| 154 | ->hidden(null) |
| 155 | ->types(...$types) |
| 156 | ->only(...$uids) as $node) { |
| 157 | $byUid[$node->meta->uid] = $this->item($node); |
| 158 | } |
| 159 | |
| 160 | $ordered = []; |
| 161 | |
| 162 | foreach ($uids as $uid) { |
| 163 | if (!isset($byUid[$uid])) { |
| 164 | continue; |
| 165 | } |
| 166 | |
| 167 | $ordered[] = $byUid[$uid]; |
| 168 | } |
| 169 | |
| 170 | return $this->result($factory, $ordered, false); |
| 171 | } |
| 172 | |
| 173 | private function item(Wrapper $node): array |
| 174 | { |
| 175 | return [ |
| 176 | 'uid' => $node->meta->uid, |
| 177 | 'title' => $node->label(), |
| 178 | 'type' => (string) $node->meta->type->get('handle', ''), |
| 179 | 'typeLabel' => (string) $node->meta->type->get('label', ''), |
| 180 | ]; |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * Read the reference field's declared pickable-set constraints from its |
| 185 | * #[Pick] attribute. Null when the type/field is unknown or the field is |
| 186 | * not a Reference — the caller returns an empty result. A Reference field |
| 187 | * without #[Pick] yields the open defaults (any type, any publication). |
| 188 | * |
| 189 | * @return array{types: list<string>, where: string, published: ?bool, hidden: ?bool}|null |
| 190 | */ |
| 191 | private function constraints(string $typeHandle, string $field): ?array |
| 192 | { |
| 193 | $class = $this->nodeClass($typeHandle); |
| 194 | |
| 195 | if ($class === null || $field === '' || !property_exists($class, $field)) { |
| 196 | return null; |
| 197 | } |
| 198 | |
| 199 | $property = new ReflectionProperty($class, $field); |
| 200 | $propType = $property->getType(); |
| 201 | |
| 202 | if ( |
| 203 | !$propType instanceof ReflectionNamedType |
| 204 | || !is_a($propType->getName(), ReferenceField::class, true) |
| 205 | ) { |
| 206 | return null; |
| 207 | } |
| 208 | |
| 209 | $attributes = $property->getAttributes(Pick::class); |
| 210 | |
| 211 | if ($attributes === []) { |
| 212 | return ['types' => [], 'where' => '', 'published' => null, 'hidden' => null]; |
| 213 | } |
| 214 | |
| 215 | $pick = $attributes[0]->newInstance(); |
| 216 | |
| 217 | return [ |
| 218 | 'types' => $pick->types, |
| 219 | 'where' => $pick->where, |
| 220 | 'published' => $pick->published, |
| 221 | 'hidden' => $pick->hidden, |
| 222 | ]; |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * Limit picker queries to registered node types. Database rows can outlive |
| 227 | * a removed type, but the finder cannot hydrate them without its class. |
| 228 | * |
| 229 | * @param list<string> $requested Class names or handles from #[Pick]. |
| 230 | * @return list<string> |
| 231 | */ |
| 232 | private function registeredTypes(array $requested = []): array |
| 233 | { |
| 234 | $tag = $this->container->tag(Bootstrap::NODE_TAG); |
| 235 | $handles = []; |
| 236 | |
| 237 | foreach ($tag->entries() as $handle) { |
| 238 | $class = $tag->entry($handle)->definition(); |
| 239 | |
| 240 | if (!is_string($class) || !class_exists($class)) { |
| 241 | continue; |
| 242 | } |
| 243 | |
| 244 | if ( |
| 245 | $requested !== [] |
| 246 | && !in_array($class, $requested, true) |
| 247 | && !in_array($handle, $requested, true) |
| 248 | ) { |
| 249 | continue; |
| 250 | } |
| 251 | |
| 252 | $handles[] = $handle; |
| 253 | } |
| 254 | |
| 255 | return $handles; |
| 256 | } |
| 257 | |
| 258 | /** @return class-string|null */ |
| 259 | private function nodeClass(string $handle): ?string |
| 260 | { |
| 261 | if ($handle === '') { |
| 262 | return null; |
| 263 | } |
| 264 | |
| 265 | $tag = $this->container->tag(Bootstrap::NODE_TAG); |
| 266 | |
| 267 | if (!in_array($handle, $tag->entries(), true)) { |
| 268 | return null; |
| 269 | } |
| 270 | |
| 271 | $class = $tag->entry($handle)->definition(); |
| 272 | |
| 273 | return is_string($class) && class_exists($class) ? $class : null; |
| 274 | } |
| 275 | |
| 276 | private function result(Factory $factory, array $nodes, bool $more): Response |
| 277 | { |
| 278 | return Response::create($factory)->json([ |
| 279 | 'ok' => true, |
| 280 | 'nodes' => $nodes, |
| 281 | 'more' => $more, |
| 282 | ]); |
| 283 | } |
| 284 | |
| 285 | private function stringParam(string $key): string |
| 286 | { |
| 287 | $value = $this->request->param($key, ''); |
| 288 | |
| 289 | return is_string($value) ? trim($value) : ''; |
| 290 | } |
| 291 | |
| 292 | private function intParam(string $key, int $default, int $min, ?int $max = null): int |
| 293 | { |
| 294 | $value = $this->request->param($key, (string) $default); |
| 295 | |
| 296 | if (is_int($value)) { |
| 297 | $int = $value; |
| 298 | } elseif (is_string($value) && preg_match('/^-?[0-9]+$/', $value)) { |
| 299 | $int = (int) $value; |
| 300 | } else { |
| 301 | $int = $default; |
| 302 | } |
| 303 | |
| 304 | $int = max($min, $int); |
| 305 | |
| 306 | return $max === null ? $int : min($max, $int); |
| 307 | } |
| 308 | } |