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

conedevelopment / root / 17089331682

20 Aug 2025 05:21AM UTC coverage: 78.074% (+0.05%) from 78.025%
17089331682

push

github

iamgergo
wip

12 of 13 new or added lines in 3 files covered. (92.31%)

3308 of 4237 relevant lines covered (78.07%)

35.93 hits per line

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

85.07
/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\Traits\AsForm;
19
use Cone\Root\Traits\HasRootEvents;
20
use Cone\Root\Traits\RegistersRoutes;
21
use Cone\Root\Traits\ResolvesActions;
22
use Cone\Root\Traits\ResolvesFields;
23
use Cone\Root\Traits\ResolvesFilters;
24
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
25
use Illuminate\Database\Eloquent\Builder;
26
use Illuminate\Database\Eloquent\Model;
27
use Illuminate\Database\Eloquent\Relations\Relation as EloquentRelation;
28
use Illuminate\Http\Request;
29
use Illuminate\Routing\Events\RouteMatched;
30
use Illuminate\Routing\Route;
31
use Illuminate\Routing\Router;
32
use Illuminate\Support\Collection;
33
use Illuminate\Support\Facades\DB;
34
use Illuminate\Support\Facades\Gate;
35
use Illuminate\Support\Facades\URL;
36
use Illuminate\Support\MessageBag;
37
use Illuminate\Support\Str;
38
use Throwable;
39

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

54
    /**
55
     * The relation name on the model.
56
     */
57
    protected Closure|string $relation;
58

59
    /**
60
     * The searchable columns.
61
     */
62
    protected array $searchableColumns = ['id'];
63

64
    /**
65
     * The sortable column.
66
     */
67
    protected string $sortableColumn = 'id';
68

69
    /**
70
     * Indicates if the field should be nullable.
71
     */
72
    protected bool $nullable = false;
73

74
    /**
75
     * The Blade template.
76
     */
77
    protected string $template = 'root::fields.select';
78

79
    /**
80
     * The display resolver callback.
81
     */
82
    protected ?Closure $displayResolver = null;
83

84
    /**
85
     * The query resolver callback.
86
     */
87
    protected ?Closure $queryResolver = null;
88

89
    /**
90
     * Determine if the field is computed.
91
     */
92
    protected ?Closure $aggregateResolver = null;
93

94
    /**
95
     * The option group resolver.
96
     */
97
    protected string|Closure|null $groupResolver = null;
98

99
    /**
100
     * Indicates whether the relation is a sub resource.
101
     */
102
    protected bool $asSubResource = false;
103

104
    /**
105
     * The relations to eager load on every query.
106
     */
107
    protected array $with = [];
108

109
    /**
110
     * The relations to eager load on every query.
111
     */
112
    protected array $withCount = [];
113

114
    /**
115
     * The query scopes.
116
     */
117
    protected static array $scopes = [];
118

119
    /**
120
     * The route key resolver.
121
     */
122
    protected ?Closure $routeKeyNameResolver = null;
123

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

131
        $this->relation = $relation ?: $this->getModelAttribute();
198✔
132
    }
133

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

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

153
        return call_user_func([$model, $this->relation]);
24✔
154
    }
155

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

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

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

182
    /**
183
     * Set the route key name resolver.
184
     */
185
    public function resolveRouteKeyNameUsing(Closure $callback): static
198✔
186
    {
187
        $this->routeKeyNameResolver = $callback;
198✔
188

189
        return $this;
198✔
190
    }
191

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

201
        return call_user_func($callback);
198✔
202
    }
203

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

212
    /**
213
     * Set the as subresource attribute.
214
     */
215
    public function asSubResource(bool $value = true): static
198✔
216
    {
217
        $this->asSubResource = $value;
198✔
218

219
        return $this;
198✔
220
    }
221

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

230
    /**
231
     * Set the nullable attribute.
232
     */
233
    public function nullable(bool $value = true): static
198✔
234
    {
235
        $this->nullable = $value;
198✔
236

237
        return $this;
198✔
238
    }
239

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

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

259
        return parent::filterable($value, $callback);
×
260
    }
261

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

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

280
                return $query;
1✔
281
            });
1✔
282
        };
1✔
283

284
        return parent::searchable($value, $callback);
1✔
285
    }
286

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

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

304
        return call_user_func_array($this->searchQueryResolver, [
1✔
305
            $request, $query, $value, $this->getSearchableColumns(),
1✔
306
        ]);
1✔
307
    }
308

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

316
        return parent::sortable($value);
1✔
317
    }
318

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

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

336
        return parent::isSortable();
9✔
337
    }
338

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

346
        return $this;
×
347
    }
348

349
    /**
350
     * Determine if the field is translatable.
351
     */
352
    public function isTranslatable(): bool
198✔
353
    {
354
        return false;
198✔
355
    }
356

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

366
        $this->displayResolver = $callback;
198✔
367

368
        return $this;
198✔
369
    }
370

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

380
        return call_user_func_array($this->displayResolver, [$related]);
8✔
381
    }
382

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

392
        $name = $this->getRelationName();
11✔
393

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

398
        return $model->getAttribute($name);
11✔
399
    }
400

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

410
                if (is_callable($this->aggregateResolver)) {
6✔
NEW
411
                    return (string) $default;
×
412
                }
413

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

421
        return parent::resolveFormat($request, $model);
6✔
422
    }
423

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

431
        $value = $this->resolveDisplay($related);
1✔
432

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

437
        return $value;
1✔
438
    }
439

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

447
        $searchables = $fields->searchable();
2✔
448

449
        $sortables = $fields->sortable();
2✔
450

451
        $filterables = $fields->filterable();
2✔
452

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

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

473
        if ($field instanceof Relation) {
198✔
474
            $field->resolveRouteKeyNameUsing(
198✔
475
                fn (): string => Str::of($field->getRelationName())->singular()->ucfirst()->prepend($this->getRouteKeyName())->value()
198✔
476
            );
198✔
477
        }
478
    }
479

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

488
            return $this->resolveFilters($request)->apply($request, $this->getRelation($model)->getQuery());
×
489
        });
×
490
    }
491

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

500
    /**
501
     * Set the query resolver.
502
     */
503
    public function withRelatableQuery(Closure $callback): static
198✔
504
    {
505
        $this->queryResolver = $callback;
198✔
506

507
        return $this;
198✔
508
    }
509

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

521
        foreach (static::$scopes[static::class] ?? [] as $scope) {
11✔
522
            $query = call_user_func_array($scope, [$request, $query, $model]);
×
523
        }
524

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

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

541
            return $query->withAggregate($this->getRelationName(), $column, $fn);
×
542
        };
543

544
        return $this;
×
545
    }
546

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

556
        return $query;
1✔
557
    }
558

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

566
        return $this;
×
567
    }
568

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

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

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

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

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

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

623
        return $this;
×
624
    }
625

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

633
        return $this;
×
634
    }
635

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

643
        $this->resolveFilters($request)->apply($request, $relation->getQuery());
2✔
644

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

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

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

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

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

706
    /**
707
     * Handle the request.
708
     */
709
    public function handleFormRequest(Request $request, Model $model): void
4✔
710
    {
711
        $this->validateFormRequest($request, $model);
4✔
712

713
        try {
714
            DB::beginTransaction();
4✔
715

716
            $this->persist($request, $model, $this->getValueForHydrate($request));
4✔
717

718
            $model->save();
4✔
719

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

727
            $this->saved($request, $model);
4✔
728

729
            DB::commit();
4✔
730
        } catch (Throwable $exception) {
×
731
            report($exception);
×
732

733
            DB::rollBack();
×
734

735
            throw new SaveFormDataException($exception->getMessage());
×
736
        }
737
    }
738

739
    /**
740
     * Handle the saved form event.
741
     */
742
    public function saved(Request $request, Model $model): void
4✔
743
    {
744
        //
745
    }
4✔
746

747
    /**
748
     * Resolve the resource model for a bound value.
749
     */
750
    public function resolveRouteBinding(Request $request, string $id): Model
4✔
751
    {
752
        return $this->getRelation($request->route()->parentOfParameter($this->getRouteKeyName()))->findOrFail($id);
4✔
753
    }
754

755
    /**
756
     * Register the routes using the given router.
757
     */
758
    public function registerRoutes(Request $request, Router $router): void
198✔
759
    {
760
        $this->__registerRoutes($request, $router);
198✔
761

762
        $router->prefix($this->getUriKey())->group(function (Router $router) use ($request): void {
198✔
763
            $this->resolveActions($request)->registerRoutes($request, $router);
198✔
764

765
            $router->prefix("{{$this->getRouteKeyName()}}")->group(function (Router $router) use ($request): void {
198✔
766
                $this->resolveFields($request)->registerRoutes($request, $router);
198✔
767
            });
198✔
768
        });
198✔
769

770
        $this->registerRouteConstraints($request, $router);
198✔
771

772
        $this->routesRegistered($request);
198✔
773
    }
774

775
    /**
776
     * Get the route middleware for the registered routes.
777
     */
778
    public function getRouteMiddleware(): array
198✔
779
    {
780
        return [
198✔
781
            sprintf('%s:field,resourceModel,%s', Authorize::class, $this->getRouteKeyName()),
198✔
782
        ];
198✔
783
    }
784

785
    /**
786
     * Handle the routes registered event.
787
     */
788
    protected function routesRegistered(Request $request): void
198✔
789
    {
790
        $uri = $this->getUri();
198✔
791
        $routeKeyName = $this->getRouteKeyName();
198✔
792

793
        Root::instance()->breadcrumbs->patterns([
198✔
794
            $this->getUri() => $this->label,
198✔
795
            sprintf('%s/create', $uri) => __('Add'),
198✔
796
            sprintf('%s/{%s}', $uri, $routeKeyName) => fn (Request $request): string => $this->resolveDisplay($request->route($routeKeyName)),
198✔
797
            sprintf('%s/{%s}/edit', $uri, $routeKeyName) => __('Edit'),
198✔
798
        ]);
198✔
799
    }
800

801
    /**
802
     * Handle the route matched event.
803
     */
804
    public function routeMatched(RouteMatched $event): void
15✔
805
    {
806
        $this->__routeMatched($event);
15✔
807

808
        $controller = $event->route->getController();
15✔
809

810
        $controller->middleware($this->getRouteMiddleware());
15✔
811

812
        $middleware = function (Request $request, Closure $next) use ($event): mixed {
15✔
813
            $ability = match ($event->route->getActionMethod()) {
15✔
814
                'index' => 'viewAny',
2✔
815
                'show' => 'view',
1✔
816
                'create' => 'create',
1✔
817
                'store' => 'create',
2✔
818
                'edit' => 'update',
1✔
819
                'update' => 'update',
2✔
820
                'destroy' => 'delete',
2✔
821
                default => $event->route->getActionMethod(),
4✔
822
            };
15✔
823

824
            Gate::allowIf($this->resolveAbility(
15✔
825
                $ability, $request, $request->route('resourceModel'), $request->route($this->getRouteParameterName())
15✔
826
            ));
15✔
827

828
            return $next($request);
15✔
829
        };
15✔
830

831
        $controller->middleware([$middleware]);
15✔
832
    }
833

834
    /**
835
     * Resolve the ability.
836
     */
837
    public function resolveAbility(string $ability, Request $request, Model $model, ...$arguments): bool
16✔
838
    {
839
        $policy = Gate::getPolicyFor($model);
16✔
840

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

843
        return is_null($policy)
16✔
844
            || ! is_callable([$policy, $ability])
16✔
845
            || Gate::allows($ability, [$model, ...$arguments]);
16✔
846
    }
847

848
    /**
849
     * Map the relation abilities.
850
     */
851
    public function mapRelationAbilities(Request $request, Model $model): array
6✔
852
    {
853
        return [
6✔
854
            'viewAny' => $this->resolveAbility('viewAny', $request, $model),
6✔
855
            'create' => $this->resolveAbility('create', $request, $model),
6✔
856
        ];
6✔
857
    }
858

859
    /**
860
     * Map the related model abilities.
861
     */
862
    public function mapRelatedAbilities(Request $request, Model $model, Model $related): array
4✔
863
    {
864
        return [
4✔
865
            'view' => $this->resolveAbility('view', $request, $model, $related),
4✔
866
            'update' => $this->resolveAbility('update', $request, $model, $related),
4✔
867
            'restore' => $this->resolveAbility('restore', $request, $model, $related),
4✔
868
            'delete' => $this->resolveAbility('delete', $request, $model, $related),
4✔
869
            'forceDelete' => $this->resolveAbility('forceDelete', $request, $model, $related),
4✔
870
        ];
4✔
871
    }
872

873
    /**
874
     * Register the routes.
875
     */
876
    public function routes(Router $router): void
198✔
877
    {
878
        if ($this->isSubResource()) {
198✔
879
            $router->get('/', [RelationController::class, 'index']);
198✔
880
            $router->get('/create', [RelationController::class, 'create']);
198✔
881
            $router->get("/{{$this->getRouteKeyName()}}", [RelationController::class, 'show']);
198✔
882
            $router->post('/', [RelationController::class, 'store']);
198✔
883
            $router->get("/{{$this->getRouteKeyName()}}/edit", [RelationController::class, 'edit']);
198✔
884
            $router->patch("/{{$this->getRouteKeyName()}}", [RelationController::class, 'update']);
198✔
885
            $router->delete("/{{$this->getRouteKeyName()}}", [RelationController::class, 'destroy']);
198✔
886
        }
887
    }
888

889
    /**
890
     * Register the route constraints.
891
     */
892
    public function registerRouteConstraints(Request $request, Router $router): void
198✔
893
    {
894
        $router->bind($this->getRouteKeyName(), fn (string $id, Route $route): Model => match ($id) {
198✔
895
            'create' => $this->getRelation($route->parentOfParameter($this->getRouteKeyName()))->make(),
6✔
896
            default => $this->resolveRouteBinding($router->getCurrentRequest(), $id),
6✔
897
        });
198✔
898
    }
899

900
    /**
901
     * Parse the given query string.
902
     */
903
    public function parseQueryString(string $url): array
6✔
904
    {
905
        $query = parse_url($url, PHP_URL_QUERY) ?: '';
6✔
906

907
        parse_str($query, $result);
6✔
908

909
        return array_filter($result, fn (string $key): bool => str_starts_with($key, $this->getRequestKey()), ARRAY_FILTER_USE_KEY);
6✔
910
    }
911

912
    /**
913
     * Get the option representation of the model and the related model.
914
     */
915
    public function toOption(Request $request, Model $model, Model $related): array
5✔
916
    {
917
        $value = $this->resolveValue($request, $model);
5✔
918

919
        return $this->newOption($related, $this->resolveDisplay($related))
5✔
920
            ->selected(! is_null($value) && ($value instanceof Model ? $value->is($related) : $value->contains($related)))
5✔
921
            ->toArray();
5✔
922
    }
923

924
    /**
925
     * {@inheritdoc}
926
     */
927
    public function toInput(Request $request, Model $model): array
3✔
928
    {
929
        return array_merge(parent::toInput($request, $model), [
3✔
930
            'nullable' => $this->isNullable(),
3✔
931
            'options' => $this->resolveOptions($request, $model),
3✔
932
        ]);
3✔
933
    }
934

935
    /**
936
     * Get the sub resource representation of the relation
937
     */
938
    public function toSubResource(Request $request, Model $model): array
6✔
939
    {
940
        return array_merge($this->toArray(), [
6✔
941
            'key' => $this->modelAttribute,
6✔
942
            'baseUrl' => $this->modelUrl($model),
6✔
943
            'url' => URL::query($this->modelUrl($model), $this->parseQueryString($request->fullUrl())),
6✔
944
            'modelName' => $this->getRelatedName(),
6✔
945
            'abilities' => $this->mapRelationAbilities($request, $model),
6✔
946
        ]);
6✔
947
    }
948

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

981
    /**
982
     * Get the create representation of the resource.
983
     */
984
    public function toCreate(Request $request, Model $model): array
1✔
985
    {
986
        return array_merge($this->toSubResource($request, $model), [
1✔
987
            'template' => 'root::resources.form',
1✔
988
            'title' => __('Create :model', ['model' => $this->getRelatedName()]),
1✔
989
            'model' => $related = $this->getRelation($model)->make()->setRelation('related', $model),
1✔
990
            'action' => $this->modelUrl($model),
1✔
991
            'uploads' => $this->hasFileField($request),
1✔
992
            'method' => 'POST',
1✔
993
            'fields' => $this->resolveFields($request)
1✔
994
                ->subResource(false)
1✔
995
                ->authorized($request, $related)
1✔
996
                ->visible('create')
1✔
997
                ->mapToInputs($request, $related),
1✔
998
        ]);
1✔
999
    }
1000

1001
    /**
1002
     * Get the edit representation of the
1003
     */
1004
    public function toShow(Request $request, Model $model, Model $related): array
1✔
1005
    {
1006
        return array_merge($this->toSubResource($request, $model), [
1✔
1007
            'template' => 'root::resources.show',
1✔
1008
            'title' => $this->resolveDisplay($related),
1✔
1009
            'model' => $related->setRelation('related', $model),
1✔
1010
            'action' => $this->relatedUrl($model, $related),
1✔
1011
            'fields' => $this->resolveFields($request)
1✔
1012
                ->subResource(false)
1✔
1013
                ->authorized($request, $related)
1✔
1014
                ->visible('show')
1✔
1015
                ->mapToDisplay($request, $related),
1✔
1016
            'actions' => $this->resolveActions($request)
1✔
1017
                ->authorized($request, $related)
1✔
1018
                ->visible('show')
1✔
1019
                ->standalone(false)
1✔
1020
                ->mapToForms($request, $related),
1✔
1021
            'abilities' => array_merge(
1✔
1022
                $this->mapRelationAbilities($request, $model),
1✔
1023
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1024
            ),
1✔
1025
        ]);
1✔
1026
    }
1027

1028
    /**
1029
     * Get the edit representation of the
1030
     */
1031
    public function toEdit(Request $request, Model $model, Model $related): array
1✔
1032
    {
1033
        return array_merge($this->toSubResource($request, $model), [
1✔
1034
            'template' => 'root::resources.form',
1✔
1035
            'title' => __('Edit :model', ['model' => $this->resolveDisplay($related)]),
1✔
1036
            'model' => $related->setRelation('related', $model),
1✔
1037
            'action' => $this->relatedUrl($model, $related),
1✔
1038
            'method' => 'PATCH',
1✔
1039
            'uploads' => $this->hasFileField($request),
1✔
1040
            'fields' => $this->resolveFields($request)
1✔
1041
                ->subResource(false)
1✔
1042
                ->authorized($request, $related)
1✔
1043
                ->visible('update')
1✔
1044
                ->mapToInputs($request, $related),
1✔
1045
            'abilities' => array_merge(
1✔
1046
                $this->mapRelationAbilities($request, $model),
1✔
1047
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1048
            ),
1✔
1049
        ]);
1✔
1050
    }
1051

1052
    /**
1053
     * Get the filter representation of the field.
1054
     */
1055
    public function toFilter(): Filter
×
1056
    {
1057
        return new class($this) extends RenderableFilter
×
1058
        {
×
1059
            protected Relation $field;
1060

1061
            public function __construct(Relation $field)
1062
            {
1063
                parent::__construct($field->getModelAttribute());
×
1064

1065
                $this->field = $field;
×
1066
            }
1067

1068
            public function apply(Request $request, Builder $query, mixed $value): Builder
1069
            {
1070
                return $this->field->resolveFilterQuery($request, $query, $value);
×
1071
            }
1072

1073
            public function toField(): Field
1074
            {
1075
                return Select::make($this->field->getLabel(), $this->getRequestKey())
×
1076
                    ->value(fn (Request $request): mixed => $this->getValue($request))
×
1077
                    ->nullable()
×
1078
                    ->options(function (Request $request, Model $model): array {
×
1079
                        return array_column(
×
1080
                            $this->field->resolveOptions($request, $model),
×
1081
                            'label',
×
1082
                            'value',
×
1083
                        );
×
1084
                    });
×
1085
            }
1086
        };
×
1087
    }
1088
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc