Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
81.05% |
355 / 438 |
|
46.88% |
15 / 32 |
CRAP | |
0.00% |
0 / 1 |
| Editor | |
81.05% |
355 / 438 |
|
46.88% |
15 / 32 |
207.56 | |
0.00% |
0 / 1 |
| edit | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
2 | |||
| create | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
2 | |||
| save | |
98.18% |
54 / 55 |
|
0.00% |
0 / 1 |
8 | |||
| store | |
70.21% |
33 / 47 |
|
0.00% |
0 / 1 |
11.14 | |||
| delete | |
96.43% |
27 / 28 |
|
0.00% |
0 / 1 |
4 | |||
| paths | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
20 | |||
| createPaths | |
85.71% |
18 / 21 |
|
0.00% |
0 / 1 |
5.07 | |||
| complete | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| refuseIncomplete | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
| issues | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
2.00 | |||
| applyForm | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| applySettings | |
57.14% |
8 / 14 |
|
0.00% |
0 / 1 |
20.52 | |||
| previewPath | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
42 | |||
| blueprint | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| submittedUid | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| collection | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| canCreate | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
3.01 | |||
| parentNode | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| blueprintHandles | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| actor | |
50.00% |
2 / 4 |
|
0.00% |
0 / 1 |
4.12 | |||
| types | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| queryState | |
92.59% |
25 / 27 |
|
0.00% |
0 / 1 |
9.03 | |||
| generatedPaths | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| pathSourceFields | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| editorContext | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
1 | |||
| nodeMeta | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| displayDate | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
5.25 | |||
| userLabel | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
7.48 | |||
| intParam | |
63.64% |
7 / 11 |
|
0.00% |
0 / 1 |
9.36 | |||
| openParam | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
| stringParam | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| navigation | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Controller\Panel; |
| 6 | |
| 7 | use Celema\Core\Exception\HttpBadRequest; |
| 8 | use Celema\Core\Exception\HttpConflict; |
| 9 | use Celema\Core\Exception\HttpNotFound; |
| 10 | use Celema\Core\Factory\Factory; |
| 11 | use Celema\Core\Request; |
| 12 | use Celema\Core\Response; |
| 13 | use Celema\Sire\Issue; |
| 14 | use Celema\Wire\Creator; |
| 15 | use Cosray\Actor; |
| 16 | use Cosray\Bootstrap; |
| 17 | use Cosray\Cms; |
| 18 | use Cosray\Collection as CmsCollection; |
| 19 | use Cosray\Collection\Listing; |
| 20 | use Cosray\Context; |
| 21 | use Cosray\Exception\RuntimeException; |
| 22 | use Cosray\Navigation; |
| 23 | use Cosray\Node\Factory as NodeFactory; |
| 24 | use Cosray\Node\PathManager; |
| 25 | use Cosray\Node\RoutePathGenerator; |
| 26 | use Cosray\Node\Serializer; |
| 27 | use Cosray\Node\Store; |
| 28 | use Cosray\Node\Types; |
| 29 | use Cosray\Node\Wrapper; |
| 30 | use Cosray\Panel\CollectionQuery; |
| 31 | use Cosray\Panel\CollectionUrls; |
| 32 | use Cosray\Panel\FormPatch; |
| 33 | use Cosray\Panel\System; |
| 34 | use DateTimeImmutable; |
| 35 | use IntlDateFormatter; |
| 36 | use Throwable; |
| 37 | |
| 38 | final class Editor extends Panel |
| 39 | { |
| 40 | private const int LIMIT_DEFAULT = 50; |
| 41 | private const int LIMIT_MAX = 250; |
| 42 | |
| 43 | public function edit(Context $context, Cms $cms, string $collection, string $node): array |
| 44 | { |
| 45 | [$name, $obj] = $this->collection($collection); |
| 46 | $query = $this->queryState($obj); |
| 47 | $result = $cms->node->byUid($node, published: null); |
| 48 | |
| 49 | if (!$result) { |
| 50 | throw new HttpNotFound($this->request); |
| 51 | } |
| 52 | |
| 53 | $nodeObj = Wrapper::unwrap($result); |
| 54 | $serializer = new Serializer( |
| 55 | $this->types(), |
| 56 | $cms->nodeFactory()->uid(), |
| 57 | $context->assets(), |
| 58 | $context->paths(), |
| 59 | ); |
| 60 | $data = $serializer->read( |
| 61 | $nodeObj, |
| 62 | NodeFactory::dataFor($nodeObj), |
| 63 | NodeFactory::fieldNamesFor($nodeObj), |
| 64 | ); |
| 65 | |
| 66 | return $this->editorContext( |
| 67 | mode: 'edit', |
| 68 | name: $name, |
| 69 | collection: $collection, |
| 70 | node: $data, |
| 71 | query: $query, |
| 72 | context: $context, |
| 73 | generatedPaths: $this->generatedPaths($context, $nodeObj, $data), |
| 74 | pathSourceFields: $this->pathSourceFields($context, $nodeObj), |
| 75 | meta: $this->nodeMeta($data), |
| 76 | ); |
| 77 | } |
| 78 | |
| 79 | public function create(Context $context, Cms $cms, string $collection, string $type): array |
| 80 | { |
| 81 | [$name, $obj] = $this->collection($collection); |
| 82 | $query = $this->queryState($obj); |
| 83 | |
| 84 | if (!$this->canCreate($obj, $type, $query->parent)) { |
| 85 | throw new HttpNotFound($this->request); |
| 86 | } |
| 87 | |
| 88 | [$nodeObj, $data] = $this->blueprint($cms, $context, $type); |
| 89 | |
| 90 | return $this->editorContext( |
| 91 | mode: 'create', |
| 92 | name: $name, |
| 93 | collection: $collection, |
| 94 | node: $data, |
| 95 | query: $query, |
| 96 | context: $context, |
| 97 | generatedPaths: $this->generatedPaths($context, $nodeObj, $data), |
| 98 | pathSourceFields: $this->pathSourceFields($context, $nodeObj), |
| 99 | ); |
| 100 | } |
| 101 | |
| 102 | public function save( |
| 103 | Context $context, |
| 104 | Cms $cms, |
| 105 | Factory $factory, |
| 106 | string $collection, |
| 107 | string $node, |
| 108 | ): Response|array { |
| 109 | [, $obj] = $this->collection($collection); |
| 110 | $query = $this->queryState($obj); |
| 111 | $result = $cms->node->byUid($node, published: null); |
| 112 | |
| 113 | if (!$result) { |
| 114 | throw new HttpNotFound($this->request); |
| 115 | } |
| 116 | |
| 117 | $nodeObj = Wrapper::unwrap($result); |
| 118 | $serializer = new Serializer( |
| 119 | $this->types(), |
| 120 | $cms->nodeFactory()->uid(), |
| 121 | $context->assets(), |
| 122 | $context->paths(), |
| 123 | ); |
| 124 | $data = $serializer->read( |
| 125 | $nodeObj, |
| 126 | NodeFactory::dataFor($nodeObj), |
| 127 | NodeFactory::fieldNamesFor($nodeObj), |
| 128 | ); |
| 129 | |
| 130 | $form = $this->formData(); |
| 131 | |
| 132 | if (!$this->complete($form)) { |
| 133 | return $this->refuseIncomplete($data); |
| 134 | } |
| 135 | |
| 136 | $data = $this->applyForm($data, $form); |
| 137 | $store = new Store( |
| 138 | $context->db, |
| 139 | new PathManager(), |
| 140 | $this->types(), |
| 141 | $cms->nodeFactory()->uid(), |
| 142 | factory: $cms->nodeFactory(), |
| 143 | cms: $cms, |
| 144 | context: $context, |
| 145 | ); |
| 146 | $links = new CollectionUrls($this->panelPath(), $collection, $query); |
| 147 | $htmx = $this->request->hasHeader('HX-Request'); |
| 148 | |
| 149 | try { |
| 150 | $store->save($nodeObj, $data, $context->locales(), $this->actor()); |
| 151 | } catch (HttpBadRequest $e) { |
| 152 | if (!$htmx) { |
| 153 | // Non-htmx fallback follows the PRG pattern; errors are |
| 154 | // reported through the htmx path the panel always uses. |
| 155 | return Response::create($factory)->redirect($links->edit($node), 303); |
| 156 | } |
| 157 | |
| 158 | $payload = is_array($e->payload()) ? $e->payload() : []; |
| 159 | |
| 160 | return [ |
| 161 | 'saved' => false, |
| 162 | 'message' => (string) ($payload['message'] ?? __('node:invalid-data')), |
| 163 | 'errors' => $this->issues($payload), |
| 164 | 'published' => (bool) ($data['published'] ?? false), |
| 165 | 'renderable' => (bool) ($data['type']['renderable'] ?? false), |
| 166 | 'preview' => null, |
| 167 | ]; |
| 168 | } |
| 169 | |
| 170 | if (!$htmx) { |
| 171 | return Response::create($factory)->redirect($links->edit($node), 303); |
| 172 | } |
| 173 | |
| 174 | return [ |
| 175 | 'saved' => true, |
| 176 | 'message' => __('editor:saved'), |
| 177 | 'errors' => [], |
| 178 | 'published' => (bool) ($data['published'] ?? false), |
| 179 | 'renderable' => (bool) ($data['type']['renderable'] ?? false), |
| 180 | 'preview' => ($form['preview'] ?? null) === '1' ? $this->previewPath($cms, $node) : null, |
| 181 | ]; |
| 182 | } |
| 183 | |
| 184 | public function store( |
| 185 | Context $context, |
| 186 | Cms $cms, |
| 187 | Factory $factory, |
| 188 | string $collection, |
| 189 | string $type, |
| 190 | ): Response|array { |
| 191 | [, $obj] = $this->collection($collection); |
| 192 | $query = $this->queryState($obj); |
| 193 | |
| 194 | if (!$this->canCreate($obj, $type, $query->parent)) { |
| 195 | throw new HttpNotFound($this->request); |
| 196 | } |
| 197 | |
| 198 | [$nodeObj, $data] = $this->blueprint($cms, $context, $type); |
| 199 | |
| 200 | $form = $this->formData(); |
| 201 | |
| 202 | if (!$this->complete($form)) { |
| 203 | return $this->refuseIncomplete($data); |
| 204 | } |
| 205 | |
| 206 | $patch = new FormPatch($data['fields']); |
| 207 | $submitted = $form['content'] ?? []; |
| 208 | $data['content'] = $patch->content( |
| 209 | $data['content'], |
| 210 | is_array($submitted) ? $submitted : [], |
| 211 | ); |
| 212 | $data = $this->applySettings($data, $form); |
| 213 | |
| 214 | $uid = $this->submittedUid($form); |
| 215 | |
| 216 | if ($uid !== null) { |
| 217 | // Honour the uid the create form pre-generated so media uploaded |
| 218 | // to node/<uid>/ before the first save resolves to the node. |
| 219 | $data['uid'] = $uid; |
| 220 | } |
| 221 | |
| 222 | if ($query->parent !== null) { |
| 223 | $data['parent'] = $query->parent; |
| 224 | } |
| 225 | |
| 226 | $store = new Store( |
| 227 | $context->db, |
| 228 | new PathManager(), |
| 229 | $this->types(), |
| 230 | $cms->nodeFactory()->uid(), |
| 231 | factory: $cms->nodeFactory(), |
| 232 | cms: $cms, |
| 233 | context: $context, |
| 234 | ); |
| 235 | $links = new CollectionUrls($this->panelPath(), $collection, $query); |
| 236 | |
| 237 | try { |
| 238 | $result = $store->create($nodeObj, $data, $context->locales(), $this->actor()); |
| 239 | } catch (HttpBadRequest $e) { |
| 240 | if (!$this->request->hasHeader('HX-Request')) { |
| 241 | return Response::create($factory)->redirect($links->create($type), 303); |
| 242 | } |
| 243 | |
| 244 | $payload = is_array($e->payload()) ? $e->payload() : []; |
| 245 | |
| 246 | return [ |
| 247 | 'saved' => false, |
| 248 | 'message' => (string) ($payload['message'] ?? __('node:invalid-data')), |
| 249 | 'errors' => $this->issues($payload), |
| 250 | 'published' => (bool) ($data['published'] ?? false), |
| 251 | 'renderable' => (bool) ($data['type']['renderable'] ?? false), |
| 252 | 'preview' => null, |
| 253 | ]; |
| 254 | } |
| 255 | |
| 256 | // The redirect swaps the fresh edit form in; htmx follows it. |
| 257 | return Response::create($factory)->redirect( |
| 258 | $links->edit((string) ($result['uid'] ?? $data['uid'])), |
| 259 | 303, |
| 260 | ); |
| 261 | } |
| 262 | |
| 263 | public function delete( |
| 264 | Context $context, |
| 265 | Cms $cms, |
| 266 | Factory $factory, |
| 267 | string $collection, |
| 268 | string $node, |
| 269 | ): Response|array { |
| 270 | [, $obj] = $this->collection($collection); |
| 271 | $query = $this->queryState($obj); |
| 272 | $result = $cms->node->byUid($node, published: null); |
| 273 | |
| 274 | if (!$result) { |
| 275 | throw new HttpNotFound($this->request); |
| 276 | } |
| 277 | |
| 278 | $nodeObj = Wrapper::unwrap($result); |
| 279 | $store = new Store( |
| 280 | $context->db, |
| 281 | new PathManager(), |
| 282 | $this->types(), |
| 283 | $cms->nodeFactory()->uid(), |
| 284 | factory: $cms->nodeFactory(), |
| 285 | cms: $cms, |
| 286 | context: $context, |
| 287 | ); |
| 288 | |
| 289 | try { |
| 290 | $store->delete($nodeObj, $this->actor()); |
| 291 | } catch (HttpConflict $e) { |
| 292 | // Rendered through the editor-save view: the refusal lands in |
| 293 | // the status chip, the form stays as it is. |
| 294 | $payload = is_array($e->payload()) ? $e->payload() : []; |
| 295 | |
| 296 | return [ |
| 297 | 'saved' => false, |
| 298 | 'message' => (string) ($payload['message'] ?? __('node:has-children')), |
| 299 | 'errors' => [], |
| 300 | 'published' => (bool) $result->meta->get('published'), |
| 301 | 'renderable' => (bool) $this->types()->get($nodeObj::class, 'renderable', false), |
| 302 | 'preview' => null, |
| 303 | ]; |
| 304 | } |
| 305 | |
| 306 | $links = new CollectionUrls($this->panelPath(), $collection, $query); |
| 307 | |
| 308 | return Response::create($factory)->redirect($links->collection(), 303); |
| 309 | } |
| 310 | |
| 311 | public function paths(Context $context, Cms $cms, string $collection, string $node): array |
| 312 | { |
| 313 | [, $obj] = $this->collection($collection); |
| 314 | $query = $this->queryState($obj); |
| 315 | $result = $cms->node->byUid($node, published: null); |
| 316 | |
| 317 | if (!$result) { |
| 318 | throw new HttpNotFound($this->request); |
| 319 | } |
| 320 | |
| 321 | $nodeObj = Wrapper::unwrap($result); |
| 322 | $links = new CollectionUrls($this->panelPath(), $collection, $query); |
| 323 | $pathsUrl = $links->paths($node); |
| 324 | |
| 325 | if (!(bool) $this->types()->get($nodeObj::class, 'routable', false)) { |
| 326 | return ['paths' => [], 'submitted' => [], 'pathsUrl' => $pathsUrl]; |
| 327 | } |
| 328 | |
| 329 | $serializer = new Serializer( |
| 330 | $this->types(), |
| 331 | $cms->nodeFactory()->uid(), |
| 332 | $context->assets(), |
| 333 | $context->paths(), |
| 334 | ); |
| 335 | $data = $serializer->read( |
| 336 | $nodeObj, |
| 337 | NodeFactory::dataFor($nodeObj), |
| 338 | NodeFactory::fieldNamesFor($nodeObj), |
| 339 | ); |
| 340 | $form = $this->formData(); |
| 341 | $data = $this->applyForm($data, $form); |
| 342 | |
| 343 | $generator = new RoutePathGenerator($context->db, $this->types()); |
| 344 | $submitted = is_array($form['paths'] ?? null) ? $form['paths'] : []; |
| 345 | |
| 346 | return [ |
| 347 | 'paths' => $generator->preview($nodeObj::class, $data, $context->locales()), |
| 348 | 'submitted' => $submitted, |
| 349 | 'pathsUrl' => $pathsUrl, |
| 350 | ]; |
| 351 | } |
| 352 | |
| 353 | /** Route-path preview for a not-yet-saved node, built from its blueprint. */ |
| 354 | public function createPaths(Context $context, Cms $cms, string $collection, string $type): array |
| 355 | { |
| 356 | [, $obj] = $this->collection($collection); |
| 357 | $query = $this->queryState($obj); |
| 358 | |
| 359 | if (!$this->canCreate($obj, $type, $query->parent)) { |
| 360 | throw new HttpNotFound($this->request); |
| 361 | } |
| 362 | |
| 363 | $links = new CollectionUrls($this->panelPath(), $collection, $query); |
| 364 | $pathsUrl = $links->createPaths($type); |
| 365 | [$nodeObj, $data] = $this->blueprint($cms, $context, $type); |
| 366 | |
| 367 | if (!(bool) $this->types()->get($nodeObj::class, 'routable', false)) { |
| 368 | return ['paths' => [], 'submitted' => [], 'pathsUrl' => $pathsUrl]; |
| 369 | } |
| 370 | |
| 371 | $form = $this->formData(); |
| 372 | $data = $this->applyForm($data, $form); |
| 373 | $uid = $this->submittedUid($form); |
| 374 | |
| 375 | if ($uid !== null) { |
| 376 | $data['uid'] = $uid; |
| 377 | } |
| 378 | |
| 379 | $generator = new RoutePathGenerator($context->db, $this->types()); |
| 380 | $submitted = is_array($form['paths'] ?? null) ? $form['paths'] : []; |
| 381 | |
| 382 | return [ |
| 383 | 'paths' => $generator->preview($nodeObj::class, $data, $context->locales()), |
| 384 | 'submitted' => $submitted, |
| 385 | 'pathsUrl' => $pathsUrl, |
| 386 | ]; |
| 387 | } |
| 388 | |
| 389 | /** |
| 390 | * The editor form renders a sentinel input as its LAST control; a |
| 391 | * submission without it lost its tail — typically a form-encoded POST |
| 392 | * silently truncated by PHP's max_input_vars, or a mangled JSON body. |
| 393 | */ |
| 394 | private function complete(array $form): bool |
| 395 | { |
| 396 | return ($form['_complete'] ?? null) === '1'; |
| 397 | } |
| 398 | |
| 399 | /** |
| 400 | * Saving an incomplete submission would silently delete the missing |
| 401 | * fields (entries rows are replaced wholesale), so this is a hard |
| 402 | * stop, not a validation issue. |
| 403 | */ |
| 404 | private function refuseIncomplete(array $data): array |
| 405 | { |
| 406 | if (!$this->request->hasHeader('HX-Request')) { |
| 407 | throw new HttpBadRequest( |
| 408 | $this->request, |
| 409 | payload: ['message' => __('editor:incomplete-form')], |
| 410 | ); |
| 411 | } |
| 412 | |
| 413 | return [ |
| 414 | 'saved' => false, |
| 415 | 'message' => __('editor:incomplete-form'), |
| 416 | 'errors' => [], |
| 417 | 'published' => (bool) ($data['published'] ?? false), |
| 418 | 'renderable' => (bool) ($data['type']['renderable'] ?? false), |
| 419 | 'preview' => null, |
| 420 | ]; |
| 421 | } |
| 422 | |
| 423 | /** |
| 424 | * The issues behind a rejected save, reduced to what the error box |
| 425 | * renders: the message plus the data path locating the field — the |
| 426 | * client resolves the path to the matching form control. |
| 427 | * |
| 428 | * @param array<string, mixed> $payload |
| 429 | * @return list<array{message: string, path: list<string|int>}> |
| 430 | */ |
| 431 | private function issues(array $payload): array |
| 432 | { |
| 433 | $issues = $payload['errors'] ?? null; |
| 434 | |
| 435 | if (!is_array($issues)) { |
| 436 | return []; |
| 437 | } |
| 438 | |
| 439 | return array_values(array_map( |
| 440 | static fn(Issue $issue): array => [ |
| 441 | 'message' => $issue->message, |
| 442 | 'path' => $issue->path, |
| 443 | ], |
| 444 | array_filter($issues, static fn(mixed $issue): bool => $issue instanceof Issue), |
| 445 | )); |
| 446 | } |
| 447 | |
| 448 | /** Apply the submitted editor form (content patch + settings). */ |
| 449 | private function applyForm(array $data, array $form): array |
| 450 | { |
| 451 | $patch = new FormPatch($data['fields']); |
| 452 | $submitted = $form['content'] ?? []; |
| 453 | $data['content'] = $patch->content( |
| 454 | $data['content'], |
| 455 | is_array($submitted) ? $submitted : [], |
| 456 | ); |
| 457 | |
| 458 | return $this->applySettings($data, $form); |
| 459 | } |
| 460 | |
| 461 | private function applySettings(array $data, array $form): array |
| 462 | { |
| 463 | if (array_key_exists('handle', $form)) { |
| 464 | $data['handle'] = is_string($form['handle']) ? $form['handle'] : null; |
| 465 | } |
| 466 | |
| 467 | if (is_array($form['paths'] ?? null)) { |
| 468 | $paths = is_array($data['paths'] ?? null) ? $data['paths'] : []; |
| 469 | |
| 470 | foreach ($form['paths'] as $locale => $path) { |
| 471 | if (is_string($locale) && is_string($path)) { |
| 472 | $paths[$locale] = $path; |
| 473 | } |
| 474 | } |
| 475 | |
| 476 | $data['paths'] = $paths; |
| 477 | } |
| 478 | |
| 479 | foreach (['published', 'hidden'] as $flag) { |
| 480 | if (array_key_exists($flag, $form)) { |
| 481 | $data[$flag] = in_array($form[$flag], ['1', 'on', true], true); |
| 482 | } |
| 483 | } |
| 484 | |
| 485 | if (($form['publish'] ?? null) === '1') { |
| 486 | $data['published'] = true; |
| 487 | } |
| 488 | |
| 489 | return $data; |
| 490 | } |
| 491 | |
| 492 | /** The public path the preview overlay loads after a save. */ |
| 493 | private function previewPath(Cms $cms, string $uid): ?string |
| 494 | { |
| 495 | $result = $cms->node->byUid($uid, published: null); |
| 496 | |
| 497 | if (!$result) { |
| 498 | return null; |
| 499 | } |
| 500 | |
| 501 | $node = Wrapper::unwrap($result); |
| 502 | $paths = NodeFactory::dataFor($node)['paths'] ?? []; |
| 503 | |
| 504 | foreach (is_array($paths) ? $paths : [] as $path) { |
| 505 | if (is_string($path) && trim($path) !== '') { |
| 506 | return $path; |
| 507 | } |
| 508 | } |
| 509 | |
| 510 | return null; |
| 511 | } |
| 512 | |
| 513 | /** |
| 514 | * Build a blueprint node object and its serialized create payload. |
| 515 | * |
| 516 | * @return array{0: object, 1: array<string, mixed>} |
| 517 | */ |
| 518 | private function blueprint(Cms $cms, Context $context, string $type): array |
| 519 | { |
| 520 | $class = $this->container |
| 521 | ->tag(Bootstrap::NODE_TAG) |
| 522 | ->entry($type) |
| 523 | ->definition(); |
| 524 | $factory = $cms->nodeFactory(); |
| 525 | $node = $factory->blueprint($class, $context, $cms); |
| 526 | $serializer = new Serializer($this->types(), $factory->uid()); |
| 527 | $data = $serializer->blueprint($node, NodeFactory::fieldNamesFor($node), $context->locales()); |
| 528 | |
| 529 | return [$node, $data]; |
| 530 | } |
| 531 | |
| 532 | /** |
| 533 | * The uid the create form pre-generated, if it is a well-formed handle; |
| 534 | * used only in create mode so uploads and the saved node share one uid. |
| 535 | */ |
| 536 | private function submittedUid(array $form): ?string |
| 537 | { |
| 538 | $uid = $form['uid'] ?? null; |
| 539 | |
| 540 | if (is_string($uid) && preg_match('/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/', $uid)) { |
| 541 | return $uid; |
| 542 | } |
| 543 | |
| 544 | return null; |
| 545 | } |
| 546 | |
| 547 | /** @return array{string, CmsCollection} */ |
| 548 | private function collection(string $collection): array |
| 549 | { |
| 550 | try { |
| 551 | $ref = $this->navigation()->ref($collection); |
| 552 | } catch (RuntimeException $e) { |
| 553 | throw new HttpNotFound($this->request, previous: $e); |
| 554 | } |
| 555 | |
| 556 | $creator = new Creator($this->container); |
| 557 | $obj = $creator->create( |
| 558 | $ref->class, |
| 559 | predefinedTypes: [Request::class => $this->request], |
| 560 | ); |
| 561 | assert($obj instanceof CmsCollection, 'The editor route must resolve a collection'); |
| 562 | |
| 563 | return [$ref->meta->label, $obj]; |
| 564 | } |
| 565 | |
| 566 | private function canCreate(CmsCollection $collection, string $type, ?string $parent): bool |
| 567 | { |
| 568 | if ($parent === null) { |
| 569 | return in_array($type, $this->blueprintHandles($collection), true); |
| 570 | } |
| 571 | |
| 572 | if (!$collection->listMeta->showChildren) { |
| 573 | return false; |
| 574 | } |
| 575 | |
| 576 | $lister = new Listing($collection, $this->types()); |
| 577 | $childHandles = array_column( |
| 578 | $lister->childBlueprints($this->parentNode($collection, $parent)), |
| 579 | 'slug', |
| 580 | ); |
| 581 | |
| 582 | return in_array($type, $childHandles, true); |
| 583 | } |
| 584 | |
| 585 | private function parentNode(CmsCollection $collection, string $uid): Wrapper |
| 586 | { |
| 587 | $node = $collection->cms?->node->byUid($uid, published: null); |
| 588 | |
| 589 | if (!$node) { |
| 590 | throw new HttpNotFound($this->request); |
| 591 | } |
| 592 | |
| 593 | return $node; |
| 594 | } |
| 595 | |
| 596 | /** @return list<string> */ |
| 597 | private function blueprintHandles(CmsCollection $collection): array |
| 598 | { |
| 599 | $types = $this->types(); |
| 600 | $handles = []; |
| 601 | |
| 602 | foreach ($collection->blueprints() as $blueprint) { |
| 603 | $handles[] = (string) $types->get($blueprint, 'handle'); |
| 604 | } |
| 605 | |
| 606 | return $handles; |
| 607 | } |
| 608 | |
| 609 | private function actor(): Actor |
| 610 | { |
| 611 | try { |
| 612 | $id = $this->request->get('session')->authenticatedUserId(); |
| 613 | } catch (Throwable) { |
| 614 | $id = null; |
| 615 | } |
| 616 | |
| 617 | return $id ? new Actor((int) $id) : Actor::system(); |
| 618 | } |
| 619 | |
| 620 | private function types(): Types |
| 621 | { |
| 622 | $types = $this->container->get(Types::class); |
| 623 | assert($types instanceof Types, 'The node type service must be available'); |
| 624 | |
| 625 | return $types; |
| 626 | } |
| 627 | |
| 628 | private function queryState(CmsCollection $collection): CollectionQuery |
| 629 | { |
| 630 | $offset = $this->intParam('offset', 0, min: 0); |
| 631 | $limit = $this->intParam('limit', self::LIMIT_DEFAULT, min: 1, max: self::LIMIT_MAX); |
| 632 | $dir = strtolower($this->stringParam('dir')); |
| 633 | |
| 634 | if ($dir !== '' && !in_array($dir, ['asc', 'desc'], true)) { |
| 635 | throw new HttpBadRequest($this->request); |
| 636 | } |
| 637 | |
| 638 | $parent = $this->stringParam('parent'); |
| 639 | $parent = $parent === '' ? null : $parent; |
| 640 | $view = $this->stringParam('view'); |
| 641 | $open = $this->openParam('open'); |
| 642 | $defaultView = $collection->listMeta->showChildren && $parent === null ? 'tree' : 'list'; |
| 643 | |
| 644 | if ($view === '') { |
| 645 | $view = $defaultView; |
| 646 | } |
| 647 | |
| 648 | if (!in_array($view, ['tree', 'list'], true)) { |
| 649 | throw new HttpBadRequest($this->request); |
| 650 | } |
| 651 | |
| 652 | if (!$collection->listMeta->showChildren) { |
| 653 | $open = []; |
| 654 | } |
| 655 | |
| 656 | return new CollectionQuery( |
| 657 | q: $this->stringParam('q'), |
| 658 | sort: $this->stringParam('sort'), |
| 659 | dir: $dir, |
| 660 | offset: $offset, |
| 661 | limit: $limit, |
| 662 | parent: $parent, |
| 663 | view: $view, |
| 664 | open: $open, |
| 665 | defaultView: $defaultView, |
| 666 | ); |
| 667 | } |
| 668 | |
| 669 | /** |
| 670 | * The route paths the current node would generate, previewed in the |
| 671 | * settings pane. Empty for non-routable types and create mode. |
| 672 | * |
| 673 | * @param array<string, mixed> $data |
| 674 | * @return array<string, string> |
| 675 | */ |
| 676 | private function generatedPaths(Context $context, object $node, array $data): array |
| 677 | { |
| 678 | if (!(bool) $this->types()->get($node::class, 'routable', false)) { |
| 679 | return []; |
| 680 | } |
| 681 | |
| 682 | return new RoutePathGenerator($context->db, $this->types()) |
| 683 | ->preview($node::class, $data, $context->locales()); |
| 684 | } |
| 685 | |
| 686 | /** |
| 687 | * The node's own content-field names its route template references, so |
| 688 | * the editor marks exactly those inputs to live-refresh the path preview. |
| 689 | * |
| 690 | * @return list<string> |
| 691 | */ |
| 692 | private function pathSourceFields(Context $context, object $node): array |
| 693 | { |
| 694 | return new RoutePathGenerator($context->db, $this->types()) |
| 695 | ->referencedFields($this->types()->get($node::class, 'route')); |
| 696 | } |
| 697 | |
| 698 | private function editorContext( |
| 699 | string $mode, |
| 700 | string $name, |
| 701 | string $collection, |
| 702 | array $node, |
| 703 | CollectionQuery $query, |
| 704 | Context $context, |
| 705 | array $generatedPaths = [], |
| 706 | array $pathSourceFields = [], |
| 707 | array $meta = [], |
| 708 | ): array { |
| 709 | $locales = array_map( |
| 710 | static fn($locale) => [ |
| 711 | 'id' => $locale->id, |
| 712 | 'title' => $locale->title, |
| 713 | 'fallback' => $locale->fallback, |
| 714 | ], |
| 715 | iterator_to_array($context->locales(), false), |
| 716 | ); |
| 717 | |
| 718 | return $this->context([ |
| 719 | 'mode' => $mode, |
| 720 | 'name' => $name, |
| 721 | 'slug' => $collection, |
| 722 | 'node' => $node, |
| 723 | 'locales' => $locales, |
| 724 | 'defaultLocale' => $context->locales()->getDefault()->id, |
| 725 | 'system' => new System($this->config, $context->locales())->payload(), |
| 726 | 'queryState' => $query, |
| 727 | 'links' => new CollectionUrls($this->panelPath(), $collection, $query), |
| 728 | 'generatedPaths' => $generatedPaths, |
| 729 | 'pathSourceFields' => $pathSourceFields, |
| 730 | 'meta' => $meta, |
| 731 | ]); |
| 732 | } |
| 733 | |
| 734 | /** |
| 735 | * The inspector's fact rows: creation date in the panel locale and the |
| 736 | * last editor's display name. Only an existing node has either. |
| 737 | * |
| 738 | * @return array{created: ?string, editor: ?string} |
| 739 | */ |
| 740 | private function nodeMeta(array $data): array |
| 741 | { |
| 742 | return [ |
| 743 | 'created' => $this->displayDate($data['created'] ?? null), |
| 744 | 'editor' => $this->userLabel($data['editor'] ?? null), |
| 745 | ]; |
| 746 | } |
| 747 | |
| 748 | private function displayDate(mixed $value): ?string |
| 749 | { |
| 750 | if (!is_string($value) || trim($value) === '') { |
| 751 | return null; |
| 752 | } |
| 753 | |
| 754 | $timezone = $this->config->app->timezone; |
| 755 | |
| 756 | try { |
| 757 | $date = new DateTimeImmutable($value, $timezone); |
| 758 | } catch (Throwable) { |
| 759 | return null; |
| 760 | } |
| 761 | |
| 762 | $formatter = new IntlDateFormatter( |
| 763 | $this->localeId(), |
| 764 | IntlDateFormatter::MEDIUM, |
| 765 | IntlDateFormatter::NONE, |
| 766 | $timezone, |
| 767 | ); |
| 768 | $formatted = $formatter->format($date->getTimestamp()); |
| 769 | |
| 770 | return $formatted === false ? null : $formatted; |
| 771 | } |
| 772 | |
| 773 | private function userLabel(mixed $user): ?string |
| 774 | { |
| 775 | if (!is_array($user)) { |
| 776 | return null; |
| 777 | } |
| 778 | |
| 779 | $data = $user['data'] ?? null; |
| 780 | |
| 781 | if (is_string($data)) { |
| 782 | $data = json_decode($data, true); |
| 783 | } |
| 784 | |
| 785 | $candidates = [ |
| 786 | is_array($data) ? $data['name'] ?? null : null, |
| 787 | $user['username'] ?? null, |
| 788 | $user['email'] ?? null, |
| 789 | ]; |
| 790 | |
| 791 | foreach ($candidates as $candidate) { |
| 792 | if (is_string($candidate) && trim($candidate) !== '') { |
| 793 | return $candidate; |
| 794 | } |
| 795 | } |
| 796 | |
| 797 | return null; |
| 798 | } |
| 799 | |
| 800 | private function intParam( |
| 801 | string $key, |
| 802 | int $default, |
| 803 | int $min, |
| 804 | ?int $max = null, |
| 805 | ): int { |
| 806 | $value = $this->request->param($key, (string) $default); |
| 807 | |
| 808 | if (is_int($value)) { |
| 809 | $int = $value; |
| 810 | } elseif (is_string($value) && preg_match('/^-?[0-9]+$/', $value)) { |
| 811 | $int = (int) $value; |
| 812 | } else { |
| 813 | throw new HttpBadRequest($this->request); |
| 814 | } |
| 815 | |
| 816 | if ($int < $min) { |
| 817 | throw new HttpBadRequest($this->request); |
| 818 | } |
| 819 | |
| 820 | if ($max !== null && $int > $max) { |
| 821 | throw new HttpBadRequest($this->request); |
| 822 | } |
| 823 | |
| 824 | return $int; |
| 825 | } |
| 826 | |
| 827 | /** @return list<string> */ |
| 828 | private function openParam(string $key): array |
| 829 | { |
| 830 | $value = $this->request->param($key, ''); |
| 831 | |
| 832 | if (!is_string($value)) { |
| 833 | throw new HttpBadRequest($this->request); |
| 834 | } |
| 835 | |
| 836 | $open = []; |
| 837 | |
| 838 | foreach (explode(',', $value) as $uid) { |
| 839 | $uid = trim($uid); |
| 840 | |
| 841 | if ($uid !== '' && !in_array($uid, $open, true)) { |
| 842 | $open[] = $uid; |
| 843 | } |
| 844 | } |
| 845 | |
| 846 | return $open; |
| 847 | } |
| 848 | |
| 849 | private function stringParam(string $key): string |
| 850 | { |
| 851 | $value = $this->request->param($key, ''); |
| 852 | |
| 853 | if (!is_string($value)) { |
| 854 | throw new HttpBadRequest($this->request); |
| 855 | } |
| 856 | |
| 857 | return trim($value); |
| 858 | } |
| 859 | |
| 860 | private function navigation(): Navigation |
| 861 | { |
| 862 | $navigation = $this->container->get(Navigation::class); |
| 863 | assert($navigation instanceof Navigation, 'The navigation service must be available'); |
| 864 | |
| 865 | return $navigation; |
| 866 | } |
| 867 | } |