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

conedevelopment / root / 17213554131

25 Aug 2025 03:39PM UTC coverage: 78.032% (-0.05%) from 78.079%
17213554131

push

github

iamgergo
wip

7 of 7 new or added lines in 2 files covered. (100.0%)

66 existing lines in 4 files now uncovered.

3307 of 4238 relevant lines covered (78.03%)

35.77 hits per line

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

85.2
/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
     * The option group resolver.
99
     */
100
    protected string|Closure|null $groupResolver = null;
101

102
    /**
103
     * Indicates whether the relation is a sub resource.
104
     */
105
    protected bool $asSubResource = false;
106

107
    /**
108
     * The relations to eager load on every query.
109
     */
110
    protected array $with = [];
111

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

117
    /**
118
     * The query scopes.
119
     */
120
    protected static array $scopes = [];
121

122
    /**
123
     * The route key resolver.
124
     */
125
    protected ?Closure $routeKeyNameResolver = null;
126

127
    /**
128
     * Create a new relation field instance.
129
     */
130
    public function __construct(string $label, Closure|string|null $modelAttribute = null, Closure|string|null $relation = null)
197✔
131
    {
132
        parent::__construct($label, $modelAttribute);
197✔
133

134
        $this->relation = $relation ?: $this->getModelAttribute();
197✔
135
    }
136

137
    /**
138
     * Add a new scope for the relation query.
139
     */
UNCOV
140
    public static function scopeQuery(Closure $callback): void
×
141
    {
UNCOV
142
        static::$scopes[static::class][] = $callback;
×
143
    }
144

145
    /**
146
     * Get the relation instance.
147
     *
148
     * @phpstan-return TRelation
149
     */
150
    public function getRelation(Model $model): EloquentRelation
24✔
151
    {
152
        if ($this->relation instanceof Closure) {
24✔
UNCOV
153
            return call_user_func_array($this->relation, [$model]);
×
154
        }
155

156
        return call_user_func([$model, $this->relation]);
24✔
157
    }
158

159
    /**
160
     * Get the related model name.
161
     */
162
    public function getRelatedName(): string
197✔
163
    {
164
        return __(Str::of($this->getModelAttribute())->singular()->headline()->value());
197✔
165
    }
166

167
    /**
168
     * Get the relation name.
169
     */
170
    public function getRelationName(): string
197✔
171
    {
172
        return $this->relation instanceof Closure
197✔
173
            ? Str::afterLast($this->getModelAttribute(), '.')
197✔
174
            : $this->relation;
197✔
175
    }
176

177
    /**
178
     * Get the URI key.
179
     */
180
    public function getUriKey(): string
197✔
181
    {
182
        return str_replace('.', '-', $this->getRequestKey());
197✔
183
    }
184

185
    /**
186
     * Set the route key name resolver.
187
     */
188
    public function resolveRouteKeyNameUsing(Closure $callback): static
197✔
189
    {
190
        $this->routeKeyNameResolver = $callback;
197✔
191

192
        return $this;
197✔
193
    }
194

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

204
        return call_user_func($callback);
197✔
205
    }
206

207
    /**
208
     * Get the route parameter name.
209
     */
210
    public function getRouteParameterName(): string
12✔
211
    {
212
        return 'field';
12✔
213
    }
214

215
    /**
216
     * Set the as subresource attribute.
217
     */
218
    public function asSubResource(bool $value = true): static
197✔
219
    {
220
        $this->asSubResource = $value;
197✔
221

222
        return $this;
197✔
223
    }
224

225
    /**
226
     * Determine if the relation is a subresource.
227
     */
228
    public function isSubResource(): bool
197✔
229
    {
230
        return $this->asSubResource;
197✔
231
    }
232

233
    /**
234
     * Set the nullable attribute.
235
     */
236
    public function nullable(bool $value = true): static
197✔
237
    {
238
        $this->nullable = $value;
197✔
239

240
        return $this;
197✔
241
    }
242

243
    /**
244
     * Determine if the field is nullable.
245
     */
246
    public function isNullable(): bool
3✔
247
    {
248
        return $this->nullable;
3✔
249
    }
250

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

UNCOV
262
        return parent::filterable($value, $callback);
×
263
    }
264

265
    /**
266
     * {@inheritdoc}
267
     */
268
    public function searchable(bool|Closure $value = true, ?Closure $callback = null, array $columns = ['id']): static
1✔
269
    {
270
        $this->searchableColumns = $columns;
1✔
271

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

283
                return $query;
1✔
284
            });
1✔
285
        };
1✔
286

287
        return parent::searchable($value, $callback);
1✔
288
    }
289

290
    /**
291
     * Get the searchable columns.
292
     */
293
    public function getSearchableColumns(): array
1✔
294
    {
295
        return $this->searchableColumns;
1✔
296
    }
297

298
    /**
299
     * Resolve the filter query.
300
     */
301
    public function resolveSearchQuery(Request $request, Builder $query, mixed $value): Builder
1✔
302
    {
303
        if (! $this->isSearchable()) {
1✔
UNCOV
304
            return parent::resolveSearchQuery($request, $query, $value);
×
305
        }
306

307
        return call_user_func_array($this->searchQueryResolver, [
1✔
308
            $request, $query, $value, $this->getSearchableColumns(),
1✔
309
        ]);
1✔
310
    }
311

312
    /**
313
     * Set the sortable attribute.
314
     */
315
    public function sortable(bool|Closure $value = true, string $column = 'id'): static
1✔
316
    {
317
        $this->sortableColumn = $column;
1✔
318

319
        return parent::sortable($value);
1✔
320
    }
321

322
    /**
323
     * Get the sortable columns.
324
     */
325
    public function getSortableColumn(): string
1✔
326
    {
327
        return $this->sortableColumn;
1✔
328
    }
329

330
    /**
331
     * {@inheritdoc}
332
     */
333
    public function isSortable(): bool
13✔
334
    {
335
        if ($this->isSubResource()) {
13✔
336
            return false;
10✔
337
        }
338

339
        return parent::isSortable();
9✔
340
    }
341

342
    /**
343
     * Set the translatable attribute.
344
     */
UNCOV
345
    public function translatable(bool|Closure $value = false): static
×
346
    {
UNCOV
347
        $this->translatable = false;
×
348

UNCOV
349
        return $this;
×
350
    }
351

352
    /**
353
     * Determine if the field is translatable.
354
     */
355
    public function isTranslatable(): bool
197✔
356
    {
357
        return false;
197✔
358
    }
359

360
    /**
361
     * Set the display resolver.
362
     */
363
    public function display(Closure|string $callback): static
197✔
364
    {
365
        if (is_string($callback)) {
197✔
366
            $callback = static fn (Model $model) => $model->getAttribute($callback);
197✔
367
        }
368

369
        $this->displayResolver = $callback;
197✔
370

371
        return $this;
197✔
372
    }
373

374
    /**
375
     * Resolve the display format or the query result.
376
     */
377
    public function resolveDisplay(Model $related): ?string
8✔
378
    {
379
        if (is_null($this->displayResolver)) {
8✔
UNCOV
380
            $this->display($related->getKeyName());
×
381
        }
382

383
        return call_user_func_array($this->displayResolver, [$related]);
8✔
384
    }
385

386
    /**
387
     * {@inheritdoc}
388
     */
389
    public function getValue(Model $model): mixed
11✔
390
    {
391
        if (is_callable($this->aggregateResolver)) {
11✔
UNCOV
392
            return parent::getValue($model);
×
393
        }
394

395
        $name = $this->getRelationName();
11✔
396

397
        if ($this->relation instanceof Closure && ! $model->relationLoaded($name)) {
11✔
398
            $model->setRelation($name, call_user_func_array($this->relation, [$model])->getResults());
3✔
399
        }
400

401
        return $model->getAttribute($name);
11✔
402
    }
403

404
    /**
405
     * {@inheritdoc}
406
     */
407
    public function resolveFormat(Request $request, Model $model): ?string
6✔
408
    {
409
        if (is_null($this->formatResolver)) {
6✔
410
            $this->formatResolver = function (Request $request, Model $model): mixed {
6✔
411
                $default = $this->getValue($model);
6✔
412

413
                if (is_callable($this->aggregateResolver)) {
6✔
UNCOV
414
                    return (string) $default;
×
415
                }
416

417
                return Collection::wrap($default)
6✔
418
                    ->map(fn (Model $related): ?string => $this->formatRelated($request, $model, $related))
6✔
419
                    ->filter()
6✔
420
                    ->join(', ');
6✔
421
            };
6✔
422
        }
423

424
        return parent::resolveFormat($request, $model);
6✔
425
    }
426

427
    /**
428
     * Format the related model.
429
     */
430
    public function formatRelated(Request $request, Model $model, Model $related): ?string
1✔
431
    {
432
        $resource = Root::instance()->resources->forModel($related);
1✔
433

434
        $value = $this->resolveDisplay($related);
1✔
435

436
        if (! is_null($resource) && $related->exists && $resource->resolveAbility('view', $request, $related)) {
1✔
437
            $value = sprintf('<a href="%s" data-turbo-frame="_top">%s</a>', $resource->modelUrl($related), $value);
1✔
438
        }
439

440
        return $value;
1✔
441
    }
442

443
    /**
444
     * Define the filters for the object.
445
     */
446
    public function filters(Request $request): array
2✔
447
    {
448
        $fields = $this->resolveFields($request)->authorized($request);
2✔
449

450
        $searchables = $fields->searchable();
2✔
451

452
        $sortables = $fields->sortable();
2✔
453

454
        $filterables = $fields->filterable();
2✔
455

456
        return array_values(array_filter([
2✔
457
            $searchables->isNotEmpty() ? new Search($searchables) : null,
2✔
458
            $sortables->isNotEmpty() ? new Sort($sortables) : null,
2✔
459
            ...$filterables->map->toFilter()->all(),
2✔
460
        ]));
2✔
461
    }
462

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

476
        if ($field instanceof Relation) {
197✔
477
            $field->resolveRouteKeyNameUsing(
197✔
478
                fn (): string => Str::of($field->getRelationName())->singular()->ucfirst()->prepend($this->getRouteKeyName())->value()
197✔
479
            );
197✔
480
        }
481
    }
482

483
    /**
484
     * Handle the callback for the field resolution.
485
     */
486
    protected function resolveAction(Request $request, Action $action): void
×
487
    {
488
        $action->withQuery(function (Request $request): Builder {
×
489
            $model = $request->route('resourceModel');
×
490

UNCOV
491
            return $this->resolveFilters($request)->apply($request, $this->getRelation($model)->getQuery());
×
UNCOV
492
        });
×
493
    }
494

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

503
    /**
504
     * Set the query resolver.
505
     */
506
    public function withRelatableQuery(Closure $callback): static
197✔
507
    {
508
        $this->queryResolver = $callback;
197✔
509

510
        return $this;
197✔
511
    }
512

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

524
        foreach (static::$scopes[static::class] ?? [] as $scope) {
11✔
UNCOV
525
            $query = call_user_func_array($scope, [$request, $query, $model]);
×
526
        }
527

528
        return $query
11✔
529
            ->when(! is_null($this->queryResolver), fn (Builder $query): Builder => call_user_func_array($this->queryResolver, [$request, $query, $model]));
11✔
530
    }
531

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

544
            return $query->withAggregate($this->getRelationName(), $column, $fn);
×
545
        };
546

UNCOV
547
        return $this;
×
548
    }
549

550
    /**
551
     * Resolve the aggregate query.
552
     */
553
    public function resolveAggregate(Request $request, Builder $query): Builder
1✔
554
    {
555
        if (! is_null($this->aggregateResolver)) {
1✔
UNCOV
556
            $query = call_user_func_array($this->aggregateResolver, [$request, $query]);
×
557
        }
558

559
        return $query;
1✔
560
    }
561

562
    /**
563
     * Set the group resolver attribute.
564
     */
UNCOV
565
    public function groupOptionsBy(string|Closure $key): static
×
566
    {
UNCOV
567
        $this->groupResolver = $key;
×
568

UNCOV
569
        return $this;
×
570
    }
571

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

587
    /**
588
     * Make a new option instance.
589
     */
590
    public function newOption(Model $related, string $label): Option
2✔
591
    {
592
        return new Option($related->getKey(), $label);
2✔
593
    }
594

595
    /**
596
     * Get the per page options.
597
     */
598
    public function getPerPageOptions(): array
2✔
599
    {
600
        return [5, 10, 15, 25];
2✔
601
    }
602

603
    /**
604
     * Get the per page key.
605
     */
606
    public function getPerPageKey(): string
2✔
607
    {
608
        return sprintf('%s_per_page', $this->getRequestKey());
2✔
609
    }
610

611
    /**
612
     * Get the sort key.
613
     */
614
    public function getSortKey(): string
2✔
615
    {
616
        return sprintf('%s_sort', $this->getRequestKey());
2✔
617
    }
618

619
    /**
620
     * The relations to be eagerload.
621
     */
UNCOV
622
    public function with(array $with): static
×
623
    {
UNCOV
624
        $this->with = $with;
×
625

UNCOV
626
        return $this;
×
627
    }
628

629
    /**
630
     * The relation counts to be eagerload.
631
     */
UNCOV
632
    public function withCount(array $withCount): static
×
633
    {
UNCOV
634
        $this->withCount = $withCount;
×
635

UNCOV
636
        return $this;
×
637
    }
638

639
    /**
640
     * Paginate the given query.
641
     */
642
    public function paginate(Request $request, Model $model): LengthAwarePaginator
2✔
643
    {
644
        $relation = $this->getRelation($model);
2✔
645

646
        $this->resolveFilters($request)->apply($request, $relation->getQuery());
2✔
647

648
        return $relation
2✔
649
            ->with($this->with)
2✔
650
            ->withCount($this->withCount)
2✔
651
            ->latest()
2✔
652
            ->paginate(
2✔
653
                $request->input(
2✔
654
                    $this->getPerPageKey(),
2✔
655
                    $request->isTurboFrameRequest() ? 5 : $relation->getRelated()->getPerPage()
2✔
656
                )
2✔
657
            )->withQueryString();
2✔
658
    }
659

660
    /**
661
     * Map a related model.
662
     */
663
    public function mapRelated(Request $request, Model $model, Model $related): array
2✔
664
    {
665
        return [
2✔
666
            'id' => $related->getKey(),
2✔
667
            'model' => $related->setRelation('related', $model),
2✔
668
            'url' => $this->relatedUrl($related),
2✔
669
            'fields' => $this->resolveFields($request)
2✔
670
                ->subResource(false)
2✔
671
                ->authorized($request, $related)
2✔
672
                ->visible('index')
2✔
673
                ->mapToDisplay($request, $related),
2✔
674
            'abilities' => $this->mapRelatedAbilities($request, $model, $related),
2✔
675
        ];
2✔
676
    }
677

678
    /**
679
     * Get the model URL.
680
     */
681
    public function modelUrl(Model $model): string
14✔
682
    {
683
        return str_replace('{resourceModel}', $model->exists ? (string) $model->getKey() : 'create', $this->getUri());
14✔
684
    }
685

686
    /**
687
     * Get the related URL.
688
     */
689
    public function relatedUrl(Model $related): string
8✔
690
    {
691
        return sprintf('%s/%s', $this->modelUrl($related->getRelationValue('related')), $related->getKey());
8✔
692
    }
693

694
    /**
695
     * {@inheritdoc}
696
     */
697
    public function persist(Request $request, Model $model, mixed $value): void
7✔
698
    {
699
        if ($this->isSubResource()) {
7✔
700
            $this->resolveFields($request)
4✔
701
                ->authorized($request, $model)
4✔
702
                ->visible($request->isMethod('POST') ? 'create' : 'update')
4✔
703
                ->persist($request, $model);
4✔
704
        } else {
705
            parent::persist($request, $model, $value);
5✔
706
        }
707
    }
708

709
    /**
710
     * Handle the request.
711
     */
712
    public function handleFormRequest(Request $request, Model $model): Response
4✔
713
    {
714
        $this->validateFormRequest($request, $model);
4✔
715

716
        try {
717
            return DB::transaction(function () use ($request, $model): Response {
4✔
718
                $this->persist($request, $model, $this->getValueForHydrate($request));
4✔
719

720
                $model->save();
4✔
721

722
                if (in_array(HasRootEvents::class, class_uses_recursive($model))) {
4✔
723
                    $model->recordRootEvent(
×
724
                        $model->wasRecentlyCreated ? 'Created' : 'Updated',
×
UNCOV
725
                        $request->user()
×
UNCOV
726
                    );
×
727
                }
728

729
                $this->saved($request, $model);
4✔
730

731
                return $this->formResponse($request, $model);
4✔
732
            });
4✔
733
        } catch (Throwable $exception) {
×
UNCOV
734
            report($exception);
×
735

UNCOV
736
            DB::rollBack();
×
737

UNCOV
738
            throw new SaveFormDataException($exception->getMessage());
×
739
        }
740
    }
741

742
    /**
743
     * Make a form response.
744
     */
745
    public function formResponse(Request $request, Model $model): Response
4✔
746
    {
747
        return Redirect::to($this->relatedUrl($model))
4✔
748
            ->with('alerts.relation-saved', Alert::success(__('The relation has been saved!')));
4✔
749
    }
750

751
    /**
752
     * Handle the saved form event.
753
     */
754
    public function saved(Request $request, Model $model): void
4✔
755
    {
756
        //
757
    }
4✔
758

759
    /**
760
     * Resolve the resource model for a bound value.
761
     */
762
    public function resolveRouteBinding(Request $request, string $id): Model
4✔
763
    {
764
        return $this->getRelation($request->route()->parentOfParameter($this->getRouteKeyName()))->findOrFail($id);
4✔
765
    }
766

767
    /**
768
     * Register the routes using the given router.
769
     */
770
    public function registerRoutes(Request $request, Router $router): void
197✔
771
    {
772
        $this->__registerRoutes($request, $router);
197✔
773

774
        $router->prefix($this->getUriKey())->group(function (Router $router) use ($request): void {
197✔
775
            $this->resolveActions($request)->registerRoutes($request, $router);
197✔
776

777
            $router->prefix("{{$this->getRouteKeyName()}}")->group(function (Router $router) use ($request): void {
197✔
778
                $this->resolveFields($request)->registerRoutes($request, $router);
197✔
779
            });
197✔
780
        });
197✔
781

782
        $this->registerRouteConstraints($request, $router);
197✔
783

784
        $this->routesRegistered($request);
197✔
785
    }
786

787
    /**
788
     * Get the route middleware for the registered routes.
789
     */
790
    public function getRouteMiddleware(): array
197✔
791
    {
792
        return [
197✔
793
            sprintf('%s:field,resourceModel,%s', Authorize::class, $this->getRouteKeyName()),
197✔
794
        ];
197✔
795
    }
796

797
    /**
798
     * Handle the routes registered event.
799
     */
800
    protected function routesRegistered(Request $request): void
197✔
801
    {
802
        $uri = $this->getUri();
197✔
803
        $routeKeyName = $this->getRouteKeyName();
197✔
804

805
        Root::instance()->breadcrumbs->patterns([
197✔
806
            $this->getUri() => $this->label,
197✔
807
            sprintf('%s/create', $uri) => __('Add'),
197✔
808
            sprintf('%s/{%s}', $uri, $routeKeyName) => fn (Request $request): string => $this->resolveDisplay($request->route($routeKeyName)),
197✔
809
            sprintf('%s/{%s}/edit', $uri, $routeKeyName) => __('Edit'),
197✔
810
        ]);
197✔
811
    }
812

813
    /**
814
     * Handle the route matched event.
815
     */
816
    public function routeMatched(RouteMatched $event): void
15✔
817
    {
818
        $this->__routeMatched($event);
15✔
819

820
        $controller = $event->route->getController();
15✔
821

822
        $controller->middleware($this->getRouteMiddleware());
15✔
823

824
        $middleware = function (Request $request, Closure $next) use ($event): mixed {
15✔
825
            $ability = match ($event->route->getActionMethod()) {
15✔
826
                'index' => 'viewAny',
2✔
827
                'show' => 'view',
1✔
828
                'create' => 'create',
1✔
829
                'store' => 'create',
2✔
830
                'edit' => 'update',
1✔
831
                'update' => 'update',
2✔
832
                'destroy' => 'delete',
2✔
833
                default => $event->route->getActionMethod(),
4✔
834
            };
15✔
835

836
            Gate::allowIf($this->resolveAbility(
15✔
837
                $ability, $request, $request->route('resourceModel'), $request->route($this->getRouteParameterName())
15✔
838
            ));
15✔
839

840
            return $next($request);
15✔
841
        };
15✔
842

843
        $controller->middleware([$middleware]);
15✔
844
    }
845

846
    /**
847
     * Resolve the ability.
848
     */
849
    public function resolveAbility(string $ability, Request $request, Model $model, ...$arguments): bool
16✔
850
    {
851
        $policy = Gate::getPolicyFor($model);
16✔
852

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

855
        return is_null($policy)
16✔
856
            || ! is_callable([$policy, $ability])
16✔
857
            || Gate::allows($ability, [$model, ...$arguments]);
16✔
858
    }
859

860
    /**
861
     * Map the relation abilities.
862
     */
863
    public function mapRelationAbilities(Request $request, Model $model): array
6✔
864
    {
865
        return [
6✔
866
            'viewAny' => $this->resolveAbility('viewAny', $request, $model),
6✔
867
            'create' => $this->resolveAbility('create', $request, $model),
6✔
868
        ];
6✔
869
    }
870

871
    /**
872
     * Map the related model abilities.
873
     */
874
    public function mapRelatedAbilities(Request $request, Model $model, Model $related): array
4✔
875
    {
876
        return [
4✔
877
            'view' => $this->resolveAbility('view', $request, $model, $related),
4✔
878
            'update' => $this->resolveAbility('update', $request, $model, $related),
4✔
879
            'restore' => $this->resolveAbility('restore', $request, $model, $related),
4✔
880
            'delete' => $this->resolveAbility('delete', $request, $model, $related),
4✔
881
            'forceDelete' => $this->resolveAbility('forceDelete', $request, $model, $related),
4✔
882
        ];
4✔
883
    }
884

885
    /**
886
     * Register the routes.
887
     */
888
    public function routes(Router $router): void
197✔
889
    {
890
        if ($this->isSubResource()) {
197✔
891
            $router->get('/', [RelationController::class, 'index']);
197✔
892
            $router->get('/create', [RelationController::class, 'create']);
197✔
893
            $router->get("/{{$this->getRouteKeyName()}}", [RelationController::class, 'show']);
197✔
894
            $router->post('/', [RelationController::class, 'store']);
197✔
895
            $router->get("/{{$this->getRouteKeyName()}}/edit", [RelationController::class, 'edit']);
197✔
896
            $router->patch("/{{$this->getRouteKeyName()}}", [RelationController::class, 'update']);
197✔
897
            $router->delete("/{{$this->getRouteKeyName()}}", [RelationController::class, 'destroy']);
197✔
898
        }
899
    }
900

901
    /**
902
     * Register the route constraints.
903
     */
904
    public function registerRouteConstraints(Request $request, Router $router): void
197✔
905
    {
906
        $router->bind($this->getRouteKeyName(), fn (string $id, Route $route): Model => match ($id) {
197✔
907
            'create' => $this->getRelation($route->parentOfParameter($this->getRouteKeyName()))->make(),
6✔
908
            default => $this->resolveRouteBinding($router->getCurrentRequest(), $id),
6✔
909
        });
197✔
910
    }
911

912
    /**
913
     * Parse the given query string.
914
     */
915
    public function parseQueryString(string $url): array
6✔
916
    {
917
        $query = parse_url($url, PHP_URL_QUERY) ?: '';
6✔
918

919
        parse_str($query, $result);
6✔
920

921
        return array_filter($result, fn (string $key): bool => str_starts_with($key, $this->getRequestKey()), ARRAY_FILTER_USE_KEY);
6✔
922
    }
923

924
    /**
925
     * Get the option representation of the model and the related model.
926
     */
927
    public function toOption(Request $request, Model $model, Model $related): array
5✔
928
    {
929
        $value = $this->resolveValue($request, $model);
5✔
930

931
        return $this->newOption($related, $this->resolveDisplay($related))
5✔
932
            ->selected(! is_null($value) && ($value instanceof Model ? $value->is($related) : $value->contains($related)))
5✔
933
            ->toArray();
5✔
934
    }
935

936
    /**
937
     * {@inheritdoc}
938
     */
939
    public function toInput(Request $request, Model $model): array
3✔
940
    {
941
        return array_merge(parent::toInput($request, $model), [
3✔
942
            'nullable' => $this->isNullable(),
3✔
943
            'options' => $this->resolveOptions($request, $model),
3✔
944
        ]);
3✔
945
    }
946

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

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

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

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

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

1064
    /**
1065
     * Get the filter representation of the field.
1066
     */
UNCOV
1067
    public function toFilter(): Filter
×
1068
    {
UNCOV
1069
        return new class($this) extends RenderableFilter
×
1070
        {
×
1071
            protected Relation $field;
1072

1073
            public function __construct(Relation $field)
1074
            {
1075
                parent::__construct($field->getModelAttribute());
×
1076

1077
                $this->field = $field;
×
1078
            }
1079

1080
            public function apply(Request $request, Builder $query, mixed $value): Builder
1081
            {
1082
                return $this->field->resolveFilterQuery($request, $query, $value);
×
1083
            }
1084

1085
            public function toField(): Field
1086
            {
UNCOV
1087
                return Select::make($this->field->getLabel(), $this->getRequestKey())
×
UNCOV
1088
                    ->value(fn (Request $request): mixed => $this->getValue($request))
×
UNCOV
1089
                    ->nullable()
×
UNCOV
1090
                    ->options(function (Request $request, Model $model): array {
×
UNCOV
1091
                        return array_column(
×
UNCOV
1092
                            $this->field->resolveOptions($request, $model),
×
UNCOV
1093
                            'label',
×
UNCOV
1094
                            'value',
×
UNCOV
1095
                        );
×
UNCOV
1096
                    });
×
1097
            }
UNCOV
1098
        };
×
1099
    }
1100
}
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

© 2026 Coveralls, Inc