• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

conedevelopment / root / 18216690222

03 Oct 2025 08:07AM UTC coverage: 76.331% (-0.7%) from 77.022%
18216690222

push

github

iamgergo
fixes

14 of 61 new or added lines in 2 files covered. (22.95%)

1 existing line in 1 file now uncovered.

3354 of 4394 relevant lines covered (76.33%)

34.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.79
/src/Fields/Relation.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Cone\Root\Fields;
6

7
use Closure;
8
use Cone\Root\Actions\Action;
9
use Cone\Root\Exceptions\SaveFormDataException;
10
use Cone\Root\Filters\Filter;
11
use Cone\Root\Filters\RenderableFilter;
12
use Cone\Root\Filters\Search;
13
use Cone\Root\Filters\Sort;
14
use Cone\Root\Http\Controllers\RelationController;
15
use Cone\Root\Http\Middleware\Authorize;
16
use Cone\Root\Interfaces\Form;
17
use Cone\Root\Root;
18
use Cone\Root\Support\Alert;
19
use Cone\Root\Traits\AsForm;
20
use Cone\Root\Traits\HasRootEvents;
21
use Cone\Root\Traits\RegistersRoutes;
22
use Cone\Root\Traits\ResolvesActions;
23
use Cone\Root\Traits\ResolvesFields;
24
use Cone\Root\Traits\ResolvesFilters;
25
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
26
use Illuminate\Database\Eloquent\Builder;
27
use Illuminate\Database\Eloquent\Model;
28
use Illuminate\Database\Eloquent\Relations\Relation as EloquentRelation;
29
use Illuminate\Http\Request;
30
use Illuminate\Routing\Events\RouteMatched;
31
use Illuminate\Routing\Route;
32
use Illuminate\Routing\Router;
33
use Illuminate\Support\Collection;
34
use Illuminate\Support\Facades\DB;
35
use Illuminate\Support\Facades\Gate;
36
use Illuminate\Support\Facades\Redirect;
37
use Illuminate\Support\Facades\URL;
38
use Illuminate\Support\MessageBag;
39
use Illuminate\Support\Str;
40
use Symfony\Component\HttpFoundation\Response;
41
use Throwable;
42

43
/**
44
 * @template TRelation of \Illuminate\Database\Eloquent\Relations\Relation
45
 */
46
abstract class Relation extends Field implements Form
47
{
48
    use AsForm;
49
    use RegistersRoutes {
50
        RegistersRoutes::registerRoutes as __registerRoutes;
51
        RegistersRoutes::routeMatched as __routeMatched;
52
    }
53
    use ResolvesActions;
54
    use ResolvesFields;
55
    use ResolvesFilters;
56

57
    /**
58
     * The relation name on the model.
59
     */
60
    protected Closure|string $relation;
61

62
    /**
63
     * The searchable columns.
64
     */
65
    protected array $searchableColumns = ['id'];
66

67
    /**
68
     * The sortable column.
69
     */
70
    protected string $sortableColumn = 'id';
71

72
    /**
73
     * Indicates if the field should be nullable.
74
     */
75
    protected bool $nullable = false;
76

77
    /**
78
     * The Blade template.
79
     */
80
    protected string $template = 'root::fields.select';
81

82
    /**
83
     * The display resolver callback.
84
     */
85
    protected ?Closure $displayResolver = null;
86

87
    /**
88
     * The query resolver callback.
89
     */
90
    protected ?Closure $queryResolver = null;
91

92
    /**
93
     * Determine if the field is computed.
94
     */
95
    protected ?Closure $aggregateResolver = null;
96

97
    /**
98
     * Determine whether the relation values are aggregated.
99
     */
100
    protected bool $aggregated = false;
101

102
    /**
103
     * The option group resolver.
104
     */
105
    protected string|Closure|null $groupResolver = null;
106

107
    /**
108
     * Indicates whether the relation is a sub resource.
109
     */
110
    protected bool $asSubResource = false;
111

112
    /**
113
     * The relations to eager load on every query.
114
     */
115
    protected array $with = [];
116

117
    /**
118
     * The relations to eager load on every query.
119
     */
120
    protected array $withCount = [];
121

122
    /**
123
     * The query scopes.
124
     */
125
    protected static array $scopes = [];
126

127
    /**
128
     * The route key resolver.
129
     */
130
    protected ?Closure $routeKeyNameResolver = null;
131

132
    /**
133
     * Create a new relation field instance.
134
     */
135
    public function __construct(string $label, Closure|string|null $modelAttribute = null, Closure|string|null $relation = null)
197✔
136
    {
137
        parent::__construct($label, $modelAttribute);
197✔
138

139
        $this->relation = $relation ?: $this->getModelAttribute();
197✔
140
    }
141

142
    /**
143
     * Add a new scope for the relation query.
144
     */
145
    public static function scopeQuery(Closure $callback): void
×
146
    {
147
        static::$scopes[static::class][] = $callback;
×
148
    }
149

150
    /**
151
     * Get the relation instance.
152
     *
153
     * @phpstan-return TRelation
154
     */
155
    public function getRelation(Model $model): EloquentRelation
24✔
156
    {
157
        if ($this->relation instanceof Closure) {
24✔
158
            return call_user_func_array($this->relation, [$model]);
×
159
        }
160

161
        return call_user_func([$model, $this->relation]);
24✔
162
    }
163

164
    /**
165
     * Get the related model name.
166
     */
167
    public function getRelatedName(): string
197✔
168
    {
169
        return __(Str::of($this->getModelAttribute())->singular()->headline()->value());
197✔
170
    }
171

172
    /**
173
     * Get the relation name.
174
     */
175
    public function getRelationName(): string
197✔
176
    {
177
        return $this->relation instanceof Closure
197✔
178
            ? Str::afterLast($this->getModelAttribute(), '.')
197✔
179
            : $this->relation;
197✔
180
    }
181

182
    /**
183
     * Get the URI key.
184
     */
185
    public function getUriKey(): string
197✔
186
    {
187
        return str_replace('.', '-', $this->getRequestKey());
197✔
188
    }
189

190
    /**
191
     * Set the route key name resolver.
192
     */
193
    public function resolveRouteKeyNameUsing(Closure $callback): static
197✔
194
    {
195
        $this->routeKeyNameResolver = $callback;
197✔
196

197
        return $this;
197✔
198
    }
199

200
    /**
201
     * Get the related model's route key name.
202
     */
203
    public function getRouteKeyName(): string
197✔
204
    {
205
        $callback = is_null($this->routeKeyNameResolver)
197✔
206
            ? fn (): string => Str::of($this->getRelationName())->singular()->ucfirst()->prepend('relation')->value()
1✔
207
        : $this->routeKeyNameResolver;
197✔
208

209
        return call_user_func($callback);
197✔
210
    }
211

212
    /**
213
     * Get the route parameter name.
214
     */
215
    public function getRouteParameterName(): string
12✔
216
    {
217
        return 'field';
12✔
218
    }
219

220
    /**
221
     * Set the as subresource attribute.
222
     */
223
    public function asSubResource(bool $value = true): static
197✔
224
    {
225
        $this->asSubResource = $value;
197✔
226

227
        return $this;
197✔
228
    }
229

230
    /**
231
     * Determine if the relation is a subresource.
232
     */
233
    public function isSubResource(): bool
197✔
234
    {
235
        return $this->asSubResource;
197✔
236
    }
237

238
    /**
239
     * Set the nullable attribute.
240
     */
241
    public function nullable(bool $value = true): static
197✔
242
    {
243
        $this->nullable = $value;
197✔
244

245
        return $this;
197✔
246
    }
247

248
    /**
249
     * Determine if the field is nullable.
250
     */
251
    public function isNullable(): bool
3✔
252
    {
253
        return $this->nullable;
3✔
254
    }
255

256
    /**
257
     * Set the filterable attribute.
258
     */
259
    public function filterable(bool|Closure $value = true, ?Closure $callback = null): static
×
260
    {
261
        $callback ??= function (Request $request, Builder $query, mixed $value): Builder {
262
            return $query->whereHas($this->getModelAttribute(), static function (Builder $query) use ($value): Builder {
×
263
                return $query->whereKey($value);
×
264
            });
×
265
        };
266

267
        return parent::filterable($value, $callback);
×
268
    }
269

270
    /**
271
     * {@inheritdoc}
272
     */
273
    public function searchable(bool|Closure $value = true, ?Closure $callback = null, array $columns = ['id']): static
1✔
274
    {
275
        $this->searchableColumns = $columns;
1✔
276

277
        $callback ??= function (Request $request, Builder $query, mixed $value, array $attributes): Builder {
1✔
278
            return $query->has($this->getModelAttribute(), '>=', 1, 'or', static function (Builder $query) use ($attributes, $value): Builder {
1✔
279
                foreach ($attributes as $attribute) {
1✔
280
                    $query->where(
1✔
281
                        $query->qualifyColumn($attribute),
1✔
282
                        'like',
1✔
283
                        "%{$value}%",
1✔
284
                        $attributes[0] === $attribute ? 'and' : 'or'
1✔
285
                    );
1✔
286
                }
287

288
                return $query;
1✔
289
            });
1✔
290
        };
1✔
291

292
        return parent::searchable($value, $callback);
1✔
293
    }
294

295
    /**
296
     * Get the searchable columns.
297
     */
298
    public function getSearchableColumns(): array
1✔
299
    {
300
        return $this->searchableColumns;
1✔
301
    }
302

303
    /**
304
     * Resolve the filter query.
305
     */
306
    public function resolveSearchQuery(Request $request, Builder $query, mixed $value): Builder
1✔
307
    {
308
        if (! $this->isSearchable()) {
1✔
309
            return parent::resolveSearchQuery($request, $query, $value);
×
310
        }
311

312
        return call_user_func_array($this->searchQueryResolver, [
1✔
313
            $request, $query, $value, $this->getSearchableColumns(),
1✔
314
        ]);
1✔
315
    }
316

317
    /**
318
     * Set the sortable attribute.
319
     */
320
    public function sortable(bool|Closure $value = true, string $column = 'id'): static
1✔
321
    {
322
        $this->sortableColumn = $column;
1✔
323

324
        return parent::sortable($value);
1✔
325
    }
326

327
    /**
328
     * Get the sortable columns.
329
     */
330
    public function getSortableColumn(): string
1✔
331
    {
332
        return $this->sortableColumn;
1✔
333
    }
334

335
    /**
336
     * {@inheritdoc}
337
     */
338
    public function isSortable(): bool
13✔
339
    {
340
        if ($this->isSubResource()) {
13✔
341
            return false;
10✔
342
        }
343

344
        return parent::isSortable();
9✔
345
    }
346

347
    /**
348
     * Set the translatable attribute.
349
     */
350
    public function translatable(bool|Closure $value = false): static
×
351
    {
352
        $this->translatable = false;
×
353

354
        return $this;
×
355
    }
356

357
    /**
358
     * Determine if the field is translatable.
359
     */
360
    public function isTranslatable(): bool
197✔
361
    {
362
        return false;
197✔
363
    }
364

365
    /**
366
     * Set the display resolver.
367
     */
368
    public function display(Closure|string $callback): static
197✔
369
    {
370
        if (is_string($callback)) {
197✔
371
            $callback = static fn (Model $model) => $model->getAttribute($callback);
197✔
372
        }
373

374
        $this->displayResolver = $callback;
197✔
375

376
        return $this;
197✔
377
    }
378

379
    /**
380
     * Resolve the display format or the query result.
381
     */
382
    public function resolveDisplay(Model $related): ?string
8✔
383
    {
384
        if (is_null($this->displayResolver)) {
8✔
385
            $this->display($related->getKeyName());
×
386
        }
387

388
        return (string) call_user_func_array($this->displayResolver, [$related]);
8✔
389
    }
390

391
    /**
392
     * {@inheritdoc}
393
     */
394
    public function getValue(Model $model): mixed
11✔
395
    {
396
        if ($this->aggregated) {
11✔
397
            return parent::getValue($model);
×
398
        }
399

400
        $name = $this->getRelationName();
11✔
401

402
        if ($this->relation instanceof Closure && ! $model->relationLoaded($name)) {
11✔
403
            $model->setRelation($name, call_user_func_array($this->relation, [$model])->getResults());
3✔
404
        }
405

406
        return $model->getAttribute($name);
11✔
407
    }
408

409
    /**
410
     * {@inheritdoc}
411
     */
412
    public function resolveFormat(Request $request, Model $model): ?string
6✔
413
    {
414
        if (is_null($this->formatResolver)) {
6✔
415
            $this->formatResolver = function (Request $request, Model $model): mixed {
6✔
416
                $default = $this->getValue($model);
6✔
417

418
                if ($this->aggregated) {
6✔
419
                    return (string) $default;
×
420
                }
421

422
                return Collection::wrap($default)
6✔
423
                    ->map(fn (Model $related): ?string => $this->formatRelated($request, $model, $related))
6✔
424
                    ->filter()
6✔
425
                    ->join(', ');
6✔
426
            };
6✔
427
        }
428

429
        return parent::resolveFormat($request, $model);
6✔
430
    }
431

432
    /**
433
     * Format the related model.
434
     */
435
    public function formatRelated(Request $request, Model $model, Model $related): ?string
1✔
436
    {
437
        $resource = Root::instance()->resources->forModel($related);
1✔
438

439
        $value = $this->resolveDisplay($related);
1✔
440

441
        if (! is_null($resource) && $related->exists && $resource->resolveAbility('view', $request, $related)) {
1✔
UNCOV
442
            $value = sprintf('<a href="%s" data-turbo-frame="_top">%s</a>', $resource->modelUrl($related), $value);
×
443
        }
444

445
        return $value;
1✔
446
    }
447

448
    /**
449
     * Define the filters for the object.
450
     */
451
    public function filters(Request $request): array
2✔
452
    {
453
        $fields = $this->resolveFields($request)->authorized($request);
2✔
454

455
        $searchables = $fields->searchable();
2✔
456

457
        $sortables = $fields->sortable();
2✔
458

459
        $filterables = $fields->filterable();
2✔
460

461
        return array_values(array_filter([
2✔
462
            $searchables->isNotEmpty() ? new Search($searchables) : null,
2✔
463
            $sortables->isNotEmpty() ? new Sort($sortables) : null,
2✔
464
            ...$filterables->map->toFilter()->all(),
2✔
465
        ]));
2✔
466
    }
467

468
    /**
469
     * Handle the callback for the field resolution.
470
     */
471
    protected function resolveField(Request $request, Field $field): void
197✔
472
    {
473
        if ($this->isSubResource()) {
197✔
474
            $field->setAttribute('form', $this->modelAttribute);
197✔
475
            $field->resolveErrorsUsing(fn (Request $request): MessageBag => $this->errors($request));
197✔
476
        } else {
477
            $field->setAttribute('form', $this->getAttribute('form'));
×
478
            $field->resolveErrorsUsing($this->errorsResolver);
×
479
        }
480

481
        if ($field instanceof Relation) {
197✔
482
            $field->resolveRouteKeyNameUsing(
197✔
483
                fn (): string => Str::of($field->getRelationName())->singular()->ucfirst()->prepend($this->getRouteKeyName())->value()
197✔
484
            );
197✔
485
        }
486
    }
487

488
    /**
489
     * Handle the callback for the field resolution.
490
     */
491
    protected function resolveAction(Request $request, Action $action): void
×
492
    {
493
        $action->withQuery(function (Request $request): Builder {
×
494
            $model = $request->route('resourceModel');
×
495

496
            return $this->resolveFilters($request)->apply($request, $this->getRelation($model)->getQuery());
×
497
        });
×
498
    }
499

500
    /**
501
     * Handle the callback for the filter resolution.
502
     */
503
    protected function resolveFilter(Request $request, Filter $filter): void
3✔
504
    {
505
        $filter->setKey(sprintf('%s_%s', $this->getRequestKey(), $filter->getKey()));
3✔
506
    }
507

508
    /**
509
     * Set the query resolver.
510
     */
511
    public function withRelatableQuery(Closure $callback): static
197✔
512
    {
513
        $this->queryResolver = $callback;
197✔
514

515
        return $this;
197✔
516
    }
517

518
    /**
519
     * Resolve the related model's eloquent query.
520
     */
521
    public function resolveRelatableQuery(Request $request, Model $model): Builder
11✔
522
    {
523
        $query = $this->getRelation($model)
11✔
524
            ->getRelated()
11✔
525
            ->newQuery()
11✔
526
            ->with($this->with)
11✔
527
            ->withCount($this->withCount);
11✔
528

529
        foreach (static::$scopes[static::class] ?? [] as $scope) {
11✔
530
            $query = call_user_func_array($scope, [$request, $query, $model]);
×
531
        }
532

533
        return $query
11✔
534
            ->when(! is_null($this->queryResolver), fn (Builder $query): Builder => call_user_func_array($this->queryResolver, [$request, $query, $model]));
11✔
535
    }
536

537
    /**
538
     * Aggregate relation values.
539
     */
540
    public function aggregate(string $fn = 'count', string $column = '*'): static
×
541
    {
542
        $this->aggregateResolver = function (Request $request, Builder $query) use ($fn, $column): Builder {
543
            $this->setModelAttribute(sprintf(
×
544
                '%s_%s%s', $this->getRelationName(),
×
545
                $fn,
×
546
                $column === '*' ? '' : sprintf('_%s', $column)
×
547
            ));
×
548

549
            $this->aggregated = true;
×
550

551
            return $query->withAggregate($this->getRelationName(), $column, $fn);
×
552
        };
553

554
        return $this;
×
555
    }
556

557
    /**
558
     * Resolve the aggregate query.
559
     */
560
    public function resolveAggregate(Request $request, Builder $query): Builder
1✔
561
    {
562
        if (! is_null($this->aggregateResolver)) {
1✔
563
            $query = call_user_func_array($this->aggregateResolver, [$request, $query]);
×
564
        }
565

566
        return $query;
1✔
567
    }
568

569
    /**
570
     * Set the group resolver attribute.
571
     */
572
    public function groupOptionsBy(string|Closure $key): static
×
573
    {
574
        $this->groupResolver = $key;
×
575

576
        return $this;
×
577
    }
578

579
    /**
580
     * Resolve the options for the field.
581
     */
582
    public function resolveOptions(Request $request, Model $model): array
3✔
583
    {
584
        return $this->resolveRelatableQuery($request, $model)
3✔
585
            ->get()
3✔
586
            ->when(! is_null($this->groupResolver), fn (Collection $collection): Collection => $collection->groupBy($this->groupResolver)
3✔
587
                ->map(fn (Collection $group, string $key): array => [
3✔
588
                    'label' => $key,
3✔
589
                    'options' => $group->map(fn (Model $related): array => $this->toOption($request, $model, $related))->all(),
3✔
590
                ]), fn (Collection $collection): Collection => $collection->map(fn (Model $related): array => $this->toOption($request, $model, $related)))
3✔
591
            ->toArray();
3✔
592
    }
593

594
    /**
595
     * Make a new option instance.
596
     */
597
    public function newOption(Model $related, string $label): Option
2✔
598
    {
599
        return new Option($related->getKey(), $label);
2✔
600
    }
601

602
    /**
603
     * Get the per page options.
604
     */
605
    public function getPerPageOptions(): array
2✔
606
    {
607
        return [5, 10, 15, 25];
2✔
608
    }
609

610
    /**
611
     * Get the per page key.
612
     */
613
    public function getPerPageKey(): string
2✔
614
    {
615
        return sprintf('%s_per_page', $this->getRequestKey());
2✔
616
    }
617

618
    /**
619
     * Get the sort key.
620
     */
621
    public function getSortKey(): string
2✔
622
    {
623
        return sprintf('%s_sort', $this->getRequestKey());
2✔
624
    }
625

626
    /**
627
     * The relations to be eagerload.
628
     */
629
    public function with(array $with): static
×
630
    {
631
        $this->with = $with;
×
632

633
        return $this;
×
634
    }
635

636
    /**
637
     * The relation counts to be eagerload.
638
     */
639
    public function withCount(array $withCount): static
×
640
    {
641
        $this->withCount = $withCount;
×
642

643
        return $this;
×
644
    }
645

646
    /**
647
     * Paginate the given query.
648
     */
649
    public function paginate(Request $request, Model $model): LengthAwarePaginator
2✔
650
    {
651
        $relation = $this->getRelation($model);
2✔
652

653
        $this->resolveFilters($request)->apply($request, $relation->getQuery());
2✔
654

655
        return $relation
2✔
656
            ->with($this->with)
2✔
657
            ->withCount($this->withCount)
2✔
658
            ->latest()
2✔
659
            ->paginate(
2✔
660
                $request->input(
2✔
661
                    $this->getPerPageKey(),
2✔
662
                    $request->isTurboFrameRequest() ? 5 : $relation->getRelated()->getPerPage()
2✔
663
                )
2✔
664
            )->withQueryString();
2✔
665
    }
666

667
    /**
668
     * Map a related model.
669
     */
670
    public function mapRelated(Request $request, Model $model, Model $related): array
1✔
671
    {
672
        return [
1✔
673
            'id' => $related->getKey(),
1✔
674
            'model' => $related->setRelation('related', $model),
1✔
675
            'url' => $this->relatedUrl($related),
1✔
676
            'fields' => $this->resolveFields($request)
1✔
677
                ->subResource(false)
1✔
678
                ->authorized($request, $related)
1✔
679
                ->visible('index')
1✔
680
                ->mapToDisplay($request, $related),
1✔
681
            'abilities' => $this->mapRelatedAbilities($request, $model, $related),
1✔
682
        ];
1✔
683
    }
684

685
    /**
686
     * Get the model URL.
687
     */
688
    public function modelUrl(Model $model): string
14✔
689
    {
690
        return str_replace('{resourceModel}', $model->exists ? (string) $model->getKey() : 'create', $this->getUri());
14✔
691
    }
692

693
    /**
694
     * Get the related URL.
695
     */
696
    public function relatedUrl(Model $related): string
5✔
697
    {
698
        return sprintf('%s/%s', $this->modelUrl($related->getRelationValue('related')), $related->getKey());
5✔
699
    }
700

701
    /**
702
     * {@inheritdoc}
703
     */
704
    public function persist(Request $request, Model $model, mixed $value): void
7✔
705
    {
706
        if ($this->isSubResource()) {
7✔
707
            $this->resolveFields($request)
4✔
708
                ->authorized($request, $model)
4✔
709
                ->visible($request->isMethod('POST') ? 'create' : 'update')
4✔
710
                ->persist($request, $model);
4✔
711
        } else {
712
            parent::persist($request, $model, $value);
5✔
713
        }
714
    }
715

716
    /**
717
     * Handle the request.
718
     */
719
    public function handleFormRequest(Request $request, Model $model): Response
4✔
720
    {
721
        $this->validateFormRequest($request, $model);
4✔
722

723
        try {
724
            return DB::transaction(function () use ($request, $model): Response {
4✔
725
                $this->persist($request, $model, $this->getValueForHydrate($request));
4✔
726

727
                $model->save();
4✔
728

729
                if (in_array(HasRootEvents::class, class_uses_recursive($model))) {
4✔
730
                    $model->recordRootEvent(
×
731
                        $model->wasRecentlyCreated ? 'Created' : 'Updated',
×
732
                        $request->user()
×
733
                    );
×
734
                }
735

736
                $this->saved($request, $model);
4✔
737

738
                return $this->formResponse($request, $model);
4✔
739
            });
4✔
740
        } catch (Throwable $exception) {
×
741
            report($exception);
×
742

743
            DB::rollBack();
×
744

745
            throw new SaveFormDataException($exception->getMessage());
×
746
        }
747
    }
748

749
    /**
750
     * Make a form response.
751
     */
752
    public function formResponse(Request $request, Model $model): Response
4✔
753
    {
754
        return Redirect::to($this->relatedUrl($model))
4✔
755
            ->with('alerts.relation-saved', Alert::success(__('The relation has been saved!')));
4✔
756
    }
757

758
    /**
759
     * Handle the saved form event.
760
     */
761
    public function saved(Request $request, Model $model): void
4✔
762
    {
763
        //
764
    }
4✔
765

766
    /**
767
     * Resolve the resource model for a bound value.
768
     */
769
    public function resolveRouteBinding(Request $request, string $id): Model
4✔
770
    {
771
        return $this->getRelation($request->route()->parentOfParameter($this->getRouteKeyName()))->findOrFail($id);
4✔
772
    }
773

774
    /**
775
     * Register the routes using the given router.
776
     */
777
    public function registerRoutes(Request $request, Router $router): void
197✔
778
    {
779
        $this->__registerRoutes($request, $router);
197✔
780

781
        $router->prefix($this->getUriKey())->group(function (Router $router) use ($request): void {
197✔
782
            $this->resolveActions($request)->registerRoutes($request, $router);
197✔
783

784
            $router->prefix("{{$this->getRouteKeyName()}}")->group(function (Router $router) use ($request): void {
197✔
785
                $this->resolveFields($request)->registerRoutes($request, $router);
197✔
786
            });
197✔
787
        });
197✔
788

789
        $this->registerRouteConstraints($request, $router);
197✔
790

791
        $this->routesRegistered($request);
197✔
792
    }
793

794
    /**
795
     * Get the route middleware for the registered routes.
796
     */
797
    public function getRouteMiddleware(): array
197✔
798
    {
799
        return [
197✔
800
            sprintf('%s:field,resourceModel,%s', Authorize::class, $this->getRouteKeyName()),
197✔
801
        ];
197✔
802
    }
803

804
    /**
805
     * Handle the routes registered event.
806
     */
807
    protected function routesRegistered(Request $request): void
197✔
808
    {
809
        $uri = $this->getUri();
197✔
810
        $routeKeyName = $this->getRouteKeyName();
197✔
811

812
        Root::instance()->breadcrumbs->patterns([
197✔
813
            $this->getUri() => $this->label,
197✔
814
            sprintf('%s/create', $uri) => __('Add'),
197✔
815
            sprintf('%s/{%s}', $uri, $routeKeyName) => fn (Request $request): string => $this->resolveDisplay($request->route($routeKeyName)),
197✔
816
            sprintf('%s/{%s}/edit', $uri, $routeKeyName) => __('Edit'),
197✔
817
        ]);
197✔
818
    }
819

820
    /**
821
     * Handle the route matched event.
822
     */
823
    public function routeMatched(RouteMatched $event): void
15✔
824
    {
825
        $this->__routeMatched($event);
15✔
826

827
        $controller = $event->route->getController();
15✔
828

829
        $controller->middleware($this->getRouteMiddleware());
15✔
830

831
        $middleware = function (Request $request, Closure $next) use ($event): mixed {
15✔
832
            $ability = match ($event->route->getActionMethod()) {
15✔
833
                'index' => 'viewAny',
2✔
834
                'show' => 'view',
1✔
835
                'create' => 'create',
1✔
836
                'store' => 'create',
2✔
837
                'edit' => 'update',
1✔
838
                'update' => 'update',
2✔
839
                'destroy' => 'delete',
2✔
840
                default => $event->route->getActionMethod(),
4✔
841
            };
15✔
842

843
            Gate::allowIf($this->resolveAbility(
15✔
844
                $ability, $request, $request->route('resourceModel'), $request->route($this->getRouteParameterName())
15✔
845
            ));
15✔
846

847
            return $next($request);
15✔
848
        };
15✔
849

850
        $controller->middleware([$middleware]);
15✔
851
    }
852

853
    /**
854
     * Resolve the ability.
855
     */
856
    public function resolveAbility(string $ability, Request $request, Model $model, ...$arguments): bool
16✔
857
    {
858
        $policy = Gate::getPolicyFor($model);
16✔
859

860
        $ability .= Str::of($this->getModelAttribute())->singular()->studly()->value();
16✔
861

862
        return is_null($policy)
16✔
863
            || ! is_callable([$policy, $ability])
16✔
864
            || Gate::allows($ability, [$model, ...$arguments]);
16✔
865
    }
866

867
    /**
868
     * Map the relation abilities.
869
     */
870
    public function mapRelationAbilities(Request $request, Model $model): array
6✔
871
    {
872
        return [
6✔
873
            'viewAny' => $this->resolveAbility('viewAny', $request, $model),
6✔
874
            'create' => $this->resolveAbility('create', $request, $model),
6✔
875
        ];
6✔
876
    }
877

878
    /**
879
     * Map the related model abilities.
880
     */
881
    public function mapRelatedAbilities(Request $request, Model $model, Model $related): array
4✔
882
    {
883
        return [
4✔
884
            'view' => $this->resolveAbility('view', $request, $model, $related),
4✔
885
            'update' => $this->resolveAbility('update', $request, $model, $related),
4✔
886
            'restore' => $this->resolveAbility('restore', $request, $model, $related),
4✔
887
            'delete' => $this->resolveAbility('delete', $request, $model, $related),
4✔
888
            'forceDelete' => $this->resolveAbility('forceDelete', $request, $model, $related),
4✔
889
        ];
4✔
890
    }
891

892
    /**
893
     * Register the routes.
894
     */
895
    public function routes(Router $router): void
197✔
896
    {
897
        if ($this->isSubResource()) {
197✔
898
            $router->get('/', [RelationController::class, 'index']);
197✔
899
            $router->get('/create', [RelationController::class, 'create']);
197✔
900
            $router->get("/{{$this->getRouteKeyName()}}", [RelationController::class, 'show']);
197✔
901
            $router->post('/', [RelationController::class, 'store']);
197✔
902
            $router->get("/{{$this->getRouteKeyName()}}/edit", [RelationController::class, 'edit']);
197✔
903
            $router->patch("/{{$this->getRouteKeyName()}}", [RelationController::class, 'update']);
197✔
904
            $router->delete("/{{$this->getRouteKeyName()}}", [RelationController::class, 'destroy']);
197✔
905
        }
906
    }
907

908
    /**
909
     * Register the route constraints.
910
     */
911
    public function registerRouteConstraints(Request $request, Router $router): void
197✔
912
    {
913
        $router->bind($this->getRouteKeyName(), fn (string $id, Route $route): Model => match ($id) {
197✔
914
            'create' => $this->getRelation($route->parentOfParameter($this->getRouteKeyName()))->make(),
6✔
915
            default => $this->resolveRouteBinding($router->getCurrentRequest(), $id),
6✔
916
        });
197✔
917
    }
918

919
    /**
920
     * Parse the given query string.
921
     */
922
    public function parseQueryString(string $url): array
6✔
923
    {
924
        $query = parse_url($url, PHP_URL_QUERY) ?: '';
6✔
925

926
        parse_str($query, $result);
6✔
927

928
        return array_filter($result, fn (string $key): bool => str_starts_with($key, $this->getRequestKey()), ARRAY_FILTER_USE_KEY);
6✔
929
    }
930

931
    /**
932
     * Get the option representation of the model and the related model.
933
     */
934
    public function toOption(Request $request, Model $model, Model $related): array
5✔
935
    {
936
        $value = $this->resolveValue($request, $model);
5✔
937

938
        return $this->newOption($related, $this->resolveDisplay($related))
5✔
939
            ->selected(! is_null($value) && ($value instanceof Model ? $value->is($related) : $value->contains($related)))
5✔
940
            ->toArray();
5✔
941
    }
942

943
    /**
944
     * {@inheritdoc}
945
     */
946
    public function toInput(Request $request, Model $model): array
3✔
947
    {
948
        return array_merge(parent::toInput($request, $model), [
3✔
949
            'nullable' => $this->isNullable(),
3✔
950
            'options' => $this->resolveOptions($request, $model),
3✔
951
        ]);
3✔
952
    }
953

954
    /**
955
     * Get the sub resource representation of the relation
956
     */
957
    public function toSubResource(Request $request, Model $model): array
6✔
958
    {
959
        return array_merge($this->toArray(), [
6✔
960
            'key' => $this->modelAttribute,
6✔
961
            'baseUrl' => $this->modelUrl($model),
6✔
962
            'url' => URL::query($this->modelUrl($model), $this->parseQueryString($request->fullUrl())),
6✔
963
            'modelName' => $this->getRelatedName(),
6✔
964
            'abilities' => $this->mapRelationAbilities($request, $model),
6✔
965
        ]);
6✔
966
    }
967

968
    /**
969
     * Get the index representation of the relation.
970
     */
971
    public function toIndex(Request $request, Model $model): array
2✔
972
    {
973
        return array_merge($this->toSubResource($request, $model), [
2✔
974
            'template' => $request->isTurboFrameRequest() ? 'root::resources.relation' : 'root::resources.index',
2✔
975
            'title' => $this->label,
2✔
976
            'model' => $this->getRelation($model)->make()->setRelation('related', $model),
2✔
977
            'standaloneActions' => $this->resolveActions($request)
2✔
978
                ->authorized($request, $model)
2✔
979
                ->standalone()
2✔
980
                ->mapToForms($request, $model),
2✔
981
            'actions' => $this->resolveActions($request)
2✔
982
                ->authorized($request, $model)
2✔
983
                ->visible('index')
2✔
984
                ->standalone(false)
2✔
985
                ->mapToForms($request, $model),
2✔
986
            'data' => $this->paginate($request, $model)->through(fn (Model $related): array => $this->mapRelated($request, $model, $related)),
2✔
987
            'perPageOptions' => $this->getPerPageOptions(),
2✔
988
            'perPageKey' => $this->getPerPageKey(),
2✔
989
            'sortKey' => $this->getSortKey(),
2✔
990
            'filters' => $this->resolveFilters($request)
2✔
991
                ->authorized($request)
2✔
992
                ->renderable()
2✔
993
                ->map(static fn (RenderableFilter $filter): array => $filter->toField()->toInput($request, $model))
2✔
994
                ->all(),
2✔
995
            'activeFilters' => $this->resolveFilters($request)->active($request)->count(),
2✔
996
            'parentUrl' => URL::query($request->server('HTTP_REFERER'), $request->query()),
2✔
997
        ]);
2✔
998
    }
999

1000
    /**
1001
     * Get the create representation of the resource.
1002
     */
1003
    public function toCreate(Request $request, Model $model): array
1✔
1004
    {
1005
        return array_merge($this->toSubResource($request, $model), [
1✔
1006
            'template' => 'root::resources.form',
1✔
1007
            'title' => __('Create :model', ['model' => $this->getRelatedName()]),
1✔
1008
            'model' => $related = $this->getRelation($model)->make()->setRelation('related', $model),
1✔
1009
            'action' => $this->modelUrl($model),
1✔
1010
            'uploads' => $this->hasFileField($request),
1✔
1011
            'method' => 'POST',
1✔
1012
            'fields' => $this->resolveFields($request)
1✔
1013
                ->subResource(false)
1✔
1014
                ->authorized($request, $related)
1✔
1015
                ->visible('create')
1✔
1016
                ->mapToInputs($request, $related),
1✔
1017
        ]);
1✔
1018
    }
1019

1020
    /**
1021
     * Get the edit representation of the
1022
     */
1023
    public function toShow(Request $request, Model $model, Model $related): array
1✔
1024
    {
1025
        return array_merge($this->toSubResource($request, $model), [
1✔
1026
            'template' => 'root::resources.show',
1✔
1027
            'title' => $this->resolveDisplay($related),
1✔
1028
            'model' => $related->setRelation('related', $model),
1✔
1029
            'action' => $this->relatedUrl($related),
1✔
1030
            'fields' => $this->resolveFields($request)
1✔
1031
                ->subResource(false)
1✔
1032
                ->authorized($request, $related)
1✔
1033
                ->visible('show')
1✔
1034
                ->mapToDisplay($request, $related),
1✔
1035
            'actions' => $this->resolveActions($request)
1✔
1036
                ->authorized($request, $related)
1✔
1037
                ->visible('show')
1✔
1038
                ->standalone(false)
1✔
1039
                ->mapToForms($request, $related),
1✔
1040
            'abilities' => array_merge(
1✔
1041
                $this->mapRelationAbilities($request, $model),
1✔
1042
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1043
            ),
1✔
1044
        ]);
1✔
1045
    }
1046

1047
    /**
1048
     * Get the edit representation of the
1049
     */
1050
    public function toEdit(Request $request, Model $model, Model $related): array
1✔
1051
    {
1052
        return array_merge($this->toSubResource($request, $model), [
1✔
1053
            'template' => 'root::resources.form',
1✔
1054
            'title' => __('Edit :model', ['model' => $this->resolveDisplay($related)]),
1✔
1055
            'model' => $related->setRelation('related', $model),
1✔
1056
            'action' => $this->relatedUrl($related),
1✔
1057
            'method' => 'PATCH',
1✔
1058
            'uploads' => $this->hasFileField($request),
1✔
1059
            'fields' => $this->resolveFields($request)
1✔
1060
                ->subResource(false)
1✔
1061
                ->authorized($request, $related)
1✔
1062
                ->visible('update')
1✔
1063
                ->mapToInputs($request, $related),
1✔
1064
            'abilities' => array_merge(
1✔
1065
                $this->mapRelationAbilities($request, $model),
1✔
1066
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1067
            ),
1✔
1068
        ]);
1✔
1069
    }
1070

1071
    /**
1072
     * Get the filter representation of the field.
1073
     */
1074
    public function toFilter(): Filter
×
1075
    {
1076
        return new class($this) extends RenderableFilter
×
1077
        {
×
1078
            protected Relation $field;
1079

1080
            public function __construct(Relation $field)
1081
            {
1082
                parent::__construct($field->getModelAttribute());
×
1083

1084
                $this->field = $field;
×
1085
            }
1086

1087
            public function apply(Request $request, Builder $query, mixed $value): Builder
1088
            {
1089
                return $this->field->resolveFilterQuery($request, $query, $value);
×
1090
            }
1091

1092
            public function toField(): Field
1093
            {
1094
                return Select::make($this->field->getLabel(), $this->getRequestKey())
×
1095
                    ->value(fn (Request $request): mixed => $this->getValue($request))
×
1096
                    ->nullable()
×
1097
                    ->options(function (Request $request, Model $model): array {
×
1098
                        return array_column(
×
1099
                            $this->field->resolveOptions($request, $model),
×
1100
                            'label',
×
1101
                            'value',
×
1102
                        );
×
1103
                    });
×
1104
            }
1105
        };
×
1106
    }
1107
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc