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

conedevelopment / root / 14660198594

25 Apr 2025 08:21AM UTC coverage: 79.539% (+0.2%) from 79.298%
14660198594

push

github

iamgergo
filters rework

35 of 37 new or added lines in 3 files covered. (94.59%)

2589 of 3255 relevant lines covered (79.54%)

36.05 hits per line

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

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

3
namespace Cone\Root\Fields;
4

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

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

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

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

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

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

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

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

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

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

92
    /**
93
     * Determine whether the relation values are aggregated.
94
     */
95
    protected bool $aggregated = false;
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)
131
    {
132
        parent::__construct($label, $modelAttribute);
198✔
133

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

137
    /**
138
     * Add a new scope for the relation query.
139
     */
140
    public static function scopeQuery(Closure $callback): void
141
    {
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
151
    {
152
        if ($this->relation instanceof Closure) {
24✔
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
163
    {
164
        return __(Str::of($this->getModelAttribute())->singular()->headline()->value());
198✔
165
    }
166

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

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

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

192
        return $this;
198✔
193
    }
194

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

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

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

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

222
        return $this;
198✔
223
    }
224

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

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

240
        return $this;
198✔
241
    }
242

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

251
    /**
252
     * {@inheritdoc}
253
     */
254
    public function searchable(bool|Closure $value = true, ?Closure $callback = null, array $columns = ['id']): static
255
    {
256
        $this->searchableColumns = $columns;
1✔
257

258
        $callback ??= function (Request $request, Builder $query, mixed $value, array $attributes): Builder {
1✔
259
            return $query->has($this->getModelAttribute(), '>=', 1, 'or', static function (Builder $query) use ($attributes, $value): Builder {
1✔
260
                foreach ($attributes as $attribute) {
1✔
261
                    $query->where(
1✔
262
                        $query->qualifyColumn($attribute),
1✔
263
                        'like',
1✔
264
                        "%{$value}%",
1✔
265
                        $attributes[0] === $attribute ? 'and' : 'or'
1✔
266
                    );
1✔
267
                }
268

269
                return $query;
1✔
270
            });
1✔
271
        };
1✔
272

273
        return parent::searchable($value, $callback);
1✔
274
    }
275

276
    /**
277
     * Get the searchable columns.
278
     */
279
    public function getSearchableColumns(): array
280
    {
281
        return $this->searchableColumns;
1✔
282
    }
283

284
    /**
285
     * Resolve the filter query.
286
     */
287
    public function resolveFilterQuery(Request $request, Builder $query, mixed $value): Builder
288
    {
289
        if (! $this->isFilterable()) {
1✔
NEW
290
            return parent::resolveFilterQuery($request, $query, $value);
×
291
        }
292

293
        return call_user_func_array($this->filterQueryResolver, [
1✔
294
            $request, $query, $value, $this->getSearchableColumns(),
1✔
295
        ]);
1✔
296
    }
297

298
    /**
299
     * Set the sortable attribute.
300
     */
301
    public function sortable(bool|Closure $value = true, string $column = 'id'): static
302
    {
303
        $this->sortableColumn = $column;
1✔
304

305
        return parent::sortable($value);
1✔
306
    }
307

308
    /**
309
     * Get the sortable columns.
310
     */
311
    public function getSortableColumn(): string
312
    {
313
        return $this->sortableColumn;
1✔
314
    }
315

316
    /**
317
     * {@inheritdoc}
318
     */
319
    public function isSortable(): bool
320
    {
321
        if ($this->isSubResource()) {
13✔
322
            return false;
10✔
323
        }
324

325
        return parent::isSortable();
9✔
326
    }
327

328
    /**
329
     * Set the translatable attribute.
330
     */
331
    public function translatable(bool|Closure $value = false): static
332
    {
333
        $this->translatable = false;
×
334

335
        return $this;
×
336
    }
337

338
    /**
339
     * Determine if the field is translatable.
340
     */
341
    public function isTranslatable(): bool
342
    {
343
        return false;
198✔
344
    }
345

346
    /**
347
     * Set the display resolver.
348
     */
349
    public function display(Closure|string $callback): static
350
    {
351
        if (is_string($callback)) {
198✔
352
            $callback = static fn (Model $model) => $model->getAttribute($callback);
198✔
353
        }
354

355
        $this->displayResolver = $callback;
198✔
356

357
        return $this;
198✔
358
    }
359

360
    /**
361
     * Resolve the display format or the query result.
362
     */
363
    public function resolveDisplay(Model $related): ?string
364
    {
365
        if (is_null($this->displayResolver)) {
8✔
366
            $this->display($related->getKeyName());
×
367
        }
368

369
        return call_user_func_array($this->displayResolver, [$related]);
8✔
370
    }
371

372
    /**
373
     * {@inheritdoc}
374
     */
375
    public function getValue(Model $model): mixed
376
    {
377
        if ($this->aggregated) {
11✔
378
            return parent::getValue($model);
×
379
        }
380

381
        $name = $this->getRelationName();
11✔
382

383
        if ($this->relation instanceof Closure && ! $model->relationLoaded($name)) {
11✔
384
            $model->setRelation($name, call_user_func_array($this->relation, [$model])->getResults());
3✔
385
        }
386

387
        return $model->getAttribute($name);
11✔
388
    }
389

390
    /**
391
     * {@inheritdoc}
392
     */
393
    public function resolveFormat(Request $request, Model $model): ?string
394
    {
395
        if (is_null($this->formatResolver)) {
6✔
396
            $this->formatResolver = function (Request $request, Model $model): mixed {
6✔
397
                $default = $this->getValue($model);
6✔
398

399
                if ($this->aggregated) {
6✔
400
                    return $default;
×
401
                }
402

403
                return Collection::wrap($default)->map(fn (Model $related): ?string => $this->formatRelated($request, $model, $related))->filter()->join(', ');
6✔
404
            };
6✔
405
        }
406

407
        return parent::resolveFormat($request, $model);
6✔
408
    }
409

410
    /**
411
     * Format the related model.
412
     */
413
    public function formatRelated(Request $request, Model $model, Model $related): ?string
414
    {
415
        $resource = Root::instance()->resources->forModel($related);
1✔
416

417
        $value = $this->resolveDisplay($related);
1✔
418

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

423
        return $value;
1✔
424
    }
425

426
    /**
427
     * Define the filters for the object.
428
     */
429
    public function filters(Request $request): array
430
    {
431
        $fields = $this->resolveFields($request)->authorized($request);
2✔
432

433
        $searchables = $fields->searchable();
2✔
434

435
        $sortables = $fields->sortable();
2✔
436

437
        return array_values(array_filter([
2✔
438
            $searchables->isNotEmpty() ? new Search($searchables) : null,
2✔
439
            $sortables->isNotEmpty() ? new Sort($sortables) : null,
2✔
440
        ]));
2✔
441
    }
442

443
    /**
444
     * Handle the callback for the field resolution.
445
     */
446
    protected function resolveField(Request $request, Field $field): void
447
    {
448
        if ($this->isSubResource()) {
198✔
449
            $field->setAttribute('form', $this->modelAttribute);
198✔
450
            $field->resolveErrorsUsing(fn (Request $request): MessageBag => $this->errors($request));
198✔
451
        } else {
452
            $field->setAttribute('form', $this->getAttribute('form'));
×
453
            $field->resolveErrorsUsing($this->errorsResolver);
×
454
        }
455

456
        if ($field instanceof Relation) {
198✔
457
            $field->resolveRouteKeyNameUsing(
198✔
458
                fn (): string => Str::of($field->getRelationName())->singular()->ucfirst()->prepend($this->getRouteKeyName())->value()
198✔
459
            );
198✔
460
        }
461
    }
462

463
    /**
464
     * Handle the callback for the field resolution.
465
     */
466
    protected function resolveAction(Request $request, Action $action): void
467
    {
468
        $action->withQuery(function (Request $request): Builder {
×
469
            $model = $request->route('resourceModel');
×
470

471
            return $this->resolveFilters($request)->apply($request, $this->getRelation($model)->getQuery());
×
472
        });
×
473
    }
474

475
    /**
476
     * Handle the callback for the filter resolution.
477
     */
478
    protected function resolveFilter(Request $request, Filter $filter): void
479
    {
480
        $filter->setKey(sprintf('%s_%s', $this->getRequestKey(), $filter->getKey()));
3✔
481
    }
482

483
    /**
484
     * Set the query resolver.
485
     */
486
    public function withRelatableQuery(Closure $callback): static
487
    {
488
        $this->queryResolver = $callback;
198✔
489

490
        return $this;
198✔
491
    }
492

493
    /**
494
     * Resolve the related model's eloquent query.
495
     */
496
    public function resolveRelatableQuery(Request $request, Model $model): Builder
497
    {
498
        $query = $this->getRelation($model)
11✔
499
            ->getRelated()
11✔
500
            ->newQuery()
11✔
501
            ->with($this->with)
11✔
502
            ->withCount($this->withCount);
11✔
503

504
        foreach (static::$scopes[static::class] ?? [] as $scope) {
11✔
505
            $query = call_user_func_array($scope, [$request, $query, $model]);
×
506
        }
507

508
        return $query
11✔
509
            ->when(! is_null($this->queryResolver), fn (Builder $query): Builder => call_user_func_array($this->queryResolver, [$request, $query, $model]));
11✔
510
    }
511

512
    /**
513
     * Aggregate relation values.
514
     */
515
    public function aggregate(string $fn = 'count', string $column = '*'): static
516
    {
517
        $this->aggregateResolver = function (Request $request, Builder $query) use ($fn, $column): Builder {
518
            $this->setModelAttribute(sprintf(
×
519
                '%s_%s%s', $this->getRelationName(),
×
520
                $fn,
×
521
                $column === '*' ? '' : sprintf('_%s', $column)
×
522
            ));
×
523

524
            $this->aggregated = true;
×
525

526
            return $query->withAggregate($this->getRelationName(), $column, $fn);
×
527
        };
528

529
        return $this;
×
530
    }
531

532
    /**
533
     * Resolve the aggregate query.
534
     */
535
    public function resolveAggregate(Request $request, Builder $query): Builder
536
    {
537
        if (! is_null($this->aggregateResolver)) {
1✔
538
            $query = call_user_func_array($this->aggregateResolver, [$request, $query]);
×
539
        }
540

541
        return $query;
1✔
542
    }
543

544
    /**
545
     * Set the group resolver attribute.
546
     */
547
    public function groupOptionsBy(string|Closure $key): static
548
    {
549
        $this->groupResolver = $key;
×
550

551
        return $this;
×
552
    }
553

554
    /**
555
     * Resolve the options for the field.
556
     */
557
    public function resolveOptions(Request $request, Model $model): array
558
    {
559
        return $this->resolveRelatableQuery($request, $model)
3✔
560
            ->get()
3✔
561
            ->when(! is_null($this->groupResolver), fn (Collection $collection): Collection => $collection->groupBy($this->groupResolver)
3✔
562
                ->map(fn (Collection $group, string $key): array => [
3✔
563
                    'label' => $key,
3✔
564
                    'options' => $group->map(fn (Model $related): array => $this->toOption($request, $model, $related))->all(),
3✔
565
                ]), fn (Collection $collection): Collection => $collection->map(fn (Model $related): array => $this->toOption($request, $model, $related)))
3✔
566
            ->toArray();
3✔
567
    }
568

569
    /**
570
     * Make a new option instance.
571
     */
572
    public function newOption(Model $related, string $label): Option
573
    {
574
        return new Option($related->getKey(), $label);
2✔
575
    }
576

577
    /**
578
     * Get the per page options.
579
     */
580
    public function getPerPageOptions(): array
581
    {
582
        return [5, 10, 15, 25];
2✔
583
    }
584

585
    /**
586
     * Get the per page key.
587
     */
588
    public function getPerPageKey(): string
589
    {
590
        return sprintf('%s_per_page', $this->getRequestKey());
2✔
591
    }
592

593
    /**
594
     * Get the sort key.
595
     */
596
    public function getSortKey(): string
597
    {
598
        return sprintf('%s_sort', $this->getRequestKey());
2✔
599
    }
600

601
    /**
602
     * The relations to be eagerload.
603
     */
604
    public function with(array $with): static
605
    {
606
        $this->with = $with;
×
607

608
        return $this;
×
609
    }
610

611
    /**
612
     * The relation counts to be eagerload.
613
     */
614
    public function withCount(array $withCount): static
615
    {
616
        $this->withCount = $withCount;
×
617

618
        return $this;
×
619
    }
620

621
    /**
622
     * Paginate the given query.
623
     */
624
    public function paginate(Request $request, Model $model): LengthAwarePaginator
625
    {
626
        $relation = $this->getRelation($model);
2✔
627

628
        $this->resolveFilters($request)->apply($request, $relation->getQuery());
2✔
629

630
        return $relation
2✔
631
            ->with($this->with)
2✔
632
            ->withCount($this->withCount)
2✔
633
            ->latest()
2✔
634
            ->paginate(
2✔
635
                $request->input(
2✔
636
                    $this->getPerPageKey(),
2✔
637
                    $request->isTurboFrameRequest() ? 5 : $relation->getRelated()->getPerPage()
2✔
638
                )
2✔
639
            )->withQueryString();
2✔
640
    }
641

642
    /**
643
     * Map a related model.
644
     */
645
    public function mapRelated(Request $request, Model $model, Model $related): array
646
    {
647
        return [
2✔
648
            'id' => $related->getKey(),
2✔
649
            'url' => $this->relatedUrl($model, $related),
2✔
650
            'model' => $related->setRelation('related', $model),
2✔
651
            'fields' => $this->resolveFields($request)
2✔
652
                ->subResource(false)
2✔
653
                ->authorized($request, $related)
2✔
654
                ->visible('index')
2✔
655
                ->mapToDisplay($request, $related),
2✔
656
            'abilities' => $this->mapRelatedAbilities($request, $model, $related),
2✔
657
        ];
2✔
658
    }
659

660
    /**
661
     * Get the model URL.
662
     */
663
    public function modelUrl(Model $model): string
664
    {
665
        return str_replace('{resourceModel}', $model->exists ? $model->getKey() : 'create', $this->getUri());
14✔
666
    }
667

668
    /**
669
     * Get the related URL.
670
     */
671
    public function relatedUrl(Model $model, Model $related): string
672
    {
673
        return sprintf('%s/%s', $this->modelUrl($model), $related->getKey());
8✔
674
    }
675

676
    /**
677
     * {@inheritdoc}
678
     */
679
    public function persist(Request $request, Model $model, mixed $value): void
680
    {
681
        if ($this->isSubResource()) {
7✔
682
            $this->resolveFields($request)
4✔
683
                ->authorized($request, $model)
4✔
684
                ->visible($request->isMethod('POST') ? 'create' : 'update')
4✔
685
                ->persist($request, $model);
4✔
686
        } else {
687
            parent::persist($request, $model, $value);
5✔
688
        }
689
    }
690

691
    /**
692
     * Handle the request.
693
     */
694
    public function handleFormRequest(Request $request, Model $model): void
695
    {
696
        $this->validateFormRequest($request, $model);
4✔
697

698
        try {
699
            DB::beginTransaction();
4✔
700

701
            $this->persist($request, $model, $this->getValueForHydrate($request));
4✔
702

703
            $model->save();
4✔
704

705
            if (in_array(HasRootEvents::class, class_uses_recursive($model))) {
4✔
706
                $model->recordRootEvent(
×
707
                    $model->wasRecentlyCreated ? 'Created' : 'Updated',
×
708
                    $request->user()
×
709
                );
×
710
            }
711

712
            $this->saved($request, $model);
4✔
713

714
            DB::commit();
4✔
715
        } catch (Throwable $exception) {
×
716
            report($exception);
×
717

718
            DB::rollBack();
×
719

720
            throw new SaveFormDataException($exception->getMessage());
×
721
        }
722
    }
723

724
    /**
725
     * Handle the saved form event.
726
     */
727
    public function saved(Request $request, Model $model): void
728
    {
729
        //
730
    }
4✔
731

732
    /**
733
     * Resolve the resource model for a bound value.
734
     */
735
    public function resolveRouteBinding(Request $request, string $id): Model
736
    {
737
        return $this->getRelation($request->route()->parentOfParameter($this->getRouteKeyName()))->findOrFail($id);
4✔
738
    }
739

740
    /**
741
     * Register the routes using the given router.
742
     */
743
    public function registerRoutes(Request $request, Router $router): void
744
    {
745
        $this->__registerRoutes($request, $router);
198✔
746

747
        $router->prefix($this->getUriKey())->group(function (Router $router) use ($request): void {
198✔
748
            $this->resolveActions($request)->registerRoutes($request, $router);
198✔
749

750
            $router->prefix("{{$this->getRouteKeyName()}}")->group(function (Router $router) use ($request): void {
198✔
751
                $this->resolveFields($request)->registerRoutes($request, $router);
198✔
752
            });
198✔
753
        });
198✔
754

755
        $this->registerRouteConstraints($request, $router);
198✔
756

757
        $this->routesRegistered($request);
198✔
758
    }
759

760
    /**
761
     * Get the route middleware for the registered routes.
762
     */
763
    public function getRouteMiddleware(): array
764
    {
765
        return [
198✔
766
            sprintf('%s:field,resourceModel,%s', Authorize::class, $this->getRouteKeyName()),
198✔
767
        ];
198✔
768
    }
769

770
    /**
771
     * Handle the routes registered event.
772
     */
773
    protected function routesRegistered(Request $request): void
774
    {
775
        $uri = $this->getUri();
198✔
776
        $routeKeyName = $this->getRouteKeyName();
198✔
777

778
        Root::instance()->breadcrumbs->patterns([
198✔
779
            $this->getUri() => $this->label,
198✔
780
            sprintf('%s/create', $uri) => __('Add'),
198✔
781
            sprintf('%s/{%s}', $uri, $routeKeyName) => fn (Request $request): string => $this->resolveDisplay($request->route($routeKeyName)),
198✔
782
            sprintf('%s/{%s}/edit', $uri, $routeKeyName) => __('Edit'),
198✔
783
        ]);
198✔
784
    }
785

786
    /**
787
     * Handle the route matched event.
788
     */
789
    public function routeMatched(RouteMatched $event): void
790
    {
791
        $this->__routeMatched($event);
15✔
792

793
        $controller = $event->route->getController();
15✔
794

795
        $controller->middleware($this->getRouteMiddleware());
15✔
796

797
        $middleware = function (Request $request, Closure $next) use ($event): mixed {
15✔
798
            $ability = match ($event->route->getActionMethod()) {
15✔
799
                'index' => 'viewAny',
2✔
800
                'show' => 'view',
1✔
801
                'create' => 'create',
1✔
802
                'store' => 'create',
2✔
803
                'edit' => 'update',
1✔
804
                'update' => 'update',
2✔
805
                'destroy' => 'delete',
2✔
806
                default => $event->route->getActionMethod(),
4✔
807
            };
15✔
808

809
            Gate::allowIf($this->resolveAbility(
15✔
810
                $ability, $request, $request->route('resourceModel'), $request->route($this->getRouteParameterName())
15✔
811
            ));
15✔
812

813
            return $next($request);
15✔
814
        };
15✔
815

816
        $controller->middleware([$middleware]);
15✔
817
    }
818

819
    /**
820
     * Resolve the ability.
821
     */
822
    public function resolveAbility(string $ability, Request $request, Model $model, ...$arguments): bool
823
    {
824
        $policy = Gate::getPolicyFor($model);
16✔
825

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

828
        return is_null($policy)
16✔
829
            || ! method_exists($policy, $ability)
16✔
830
            || Gate::allows($ability, [$model, ...$arguments]);
16✔
831
    }
832

833
    /**
834
     * Map the relation abilities.
835
     */
836
    public function mapRelationAbilities(Request $request, Model $model): array
837
    {
838
        return [
6✔
839
            'viewAny' => $this->resolveAbility('viewAny', $request, $model),
6✔
840
            'create' => $this->resolveAbility('create', $request, $model),
6✔
841
        ];
6✔
842
    }
843

844
    /**
845
     * Map the related model abilities.
846
     */
847
    public function mapRelatedAbilities(Request $request, Model $model, Model $related): array
848
    {
849
        return [
4✔
850
            'view' => $this->resolveAbility('view', $request, $model, $related),
4✔
851
            'update' => $this->resolveAbility('update', $request, $model, $related),
4✔
852
            'restore' => $this->resolveAbility('restore', $request, $model, $related),
4✔
853
            'delete' => $this->resolveAbility('delete', $request, $model, $related),
4✔
854
            'forceDelete' => $this->resolveAbility('forceDelete', $request, $model, $related),
4✔
855
        ];
4✔
856
    }
857

858
    /**
859
     * Register the routes.
860
     */
861
    public function routes(Router $router): void
862
    {
863
        if ($this->isSubResource()) {
198✔
864
            $router->get('/', [RelationController::class, 'index']);
198✔
865
            $router->get('/create', [RelationController::class, 'create']);
198✔
866
            $router->get("/{{$this->getRouteKeyName()}}", [RelationController::class, 'show']);
198✔
867
            $router->post('/', [RelationController::class, 'store']);
198✔
868
            $router->get("/{{$this->getRouteKeyName()}}/edit", [RelationController::class, 'edit']);
198✔
869
            $router->patch("/{{$this->getRouteKeyName()}}", [RelationController::class, 'update']);
198✔
870
            $router->delete("/{{$this->getRouteKeyName()}}", [RelationController::class, 'destroy']);
198✔
871
        }
872
    }
873

874
    /**
875
     * Register the route constraints.
876
     */
877
    public function registerRouteConstraints(Request $request, Router $router): void
878
    {
879
        $router->bind($this->getRouteKeyName(), fn (string $id, Route $route): Model => match ($id) {
198✔
880
            'create' => $this->getRelation($route->parentOfParameter($this->getRouteKeyName()))->make(),
6✔
881
            default => $this->resolveRouteBinding($router->getCurrentRequest(), $id),
6✔
882
        });
198✔
883
    }
884

885
    /**
886
     * Parse the given query string.
887
     */
888
    public function parseQueryString(string $url): array
889
    {
890
        $query = parse_url($url, PHP_URL_QUERY);
6✔
891

892
        parse_str($query, $result);
6✔
893

894
        return array_filter($result, fn (string $key): bool => str_starts_with($key, $this->getRequestKey()), ARRAY_FILTER_USE_KEY);
6✔
895
    }
896

897
    /**
898
     * Get the option representation of the model and the related model.
899
     */
900
    public function toOption(Request $request, Model $model, Model $related): array
901
    {
902
        $value = $this->resolveValue($request, $model);
5✔
903

904
        return $this->newOption($related, $this->resolveDisplay($related))
5✔
905
            ->selected(! is_null($value) && ($value instanceof Model ? $value->is($related) : $value->contains($related)))
5✔
906
            ->toArray();
5✔
907
    }
908

909
    /**
910
     * {@inheritdoc}
911
     */
912
    public function toInput(Request $request, Model $model): array
913
    {
914
        return array_merge(parent::toInput($request, $model), [
3✔
915
            'nullable' => $this->isNullable(),
3✔
916
            'options' => $this->resolveOptions($request, $model),
3✔
917
        ]);
3✔
918
    }
919

920
    /**
921
     * Get the sub resource representation of the relation
922
     */
923
    public function toSubResource(Request $request, Model $model): array
924
    {
925
        return array_merge($this->toArray(), [
6✔
926
            'key' => $this->modelAttribute,
6✔
927
            'baseUrl' => $this->modelUrl($model),
6✔
928
            'url' => URL::query($this->modelUrl($model), $this->parseQueryString($request->fullUrl())),
6✔
929
            'modelName' => $this->getRelatedName(),
6✔
930
            'abilities' => $this->mapRelationAbilities($request, $model),
6✔
931
        ]);
6✔
932
    }
933

934
    /**
935
     * Get the index representation of the relation.
936
     */
937
    public function toIndex(Request $request, Model $model): array
938
    {
939
        return array_merge($this->toSubResource($request, $model), [
2✔
940
            'template' => $request->isTurboFrameRequest() ? 'root::resources.relation' : 'root::resources.index',
2✔
941
            'title' => $this->label,
2✔
942
            'model' => $this->getRelation($model)->make()->setRelation('related', $model),
2✔
943
            'standaloneActions' => $this->resolveActions($request)
2✔
944
                ->authorized($request, $model)
2✔
945
                ->standalone()
2✔
946
                ->mapToForms($request, $model),
2✔
947
            'actions' => $this->resolveActions($request)
2✔
948
                ->authorized($request, $model)
2✔
949
                ->visible('index')
2✔
950
                ->standalone(false)
2✔
951
                ->mapToForms($request, $model),
2✔
952
            'data' => $this->paginate($request, $model)->through(fn (Model $related): array => $this->mapRelated($request, $model, $related)),
2✔
953
            'perPageOptions' => $this->getPerPageOptions(),
2✔
954
            'perPageKey' => $this->getPerPageKey(),
2✔
955
            'sortKey' => $this->getSortKey(),
2✔
956
            'filters' => $this->resolveFilters($request)
2✔
957
                ->authorized($request)
2✔
958
                ->renderable()
2✔
959
                ->map(static fn (RenderableFilter $filter): array => $filter->toField()->toInput($request, $model))
2✔
960
                ->all(),
2✔
961
            'activeFilters' => $this->resolveFilters($request)->active($request)->count(),
2✔
962
            'parentUrl' => URL::query($request->server('HTTP_REFERER'), $request->query()),
2✔
963
        ]);
2✔
964
    }
965

966
    /**
967
     * Get the create representation of the resource.
968
     */
969
    public function toCreate(Request $request, Model $model): array
970
    {
971
        return array_merge($this->toSubResource($request, $model), [
1✔
972
            'template' => 'root::resources.form',
1✔
973
            'title' => __('Create :model', ['model' => $this->getRelatedName()]),
1✔
974
            'model' => $related = $this->getRelation($model)->make()->setRelation('related', $model),
1✔
975
            'action' => $this->modelUrl($model),
1✔
976
            'uploads' => $this->hasFileField($request),
1✔
977
            'method' => 'POST',
1✔
978
            'fields' => $this->resolveFields($request)
1✔
979
                ->subResource(false)
1✔
980
                ->authorized($request, $related)
1✔
981
                ->visible('create')
1✔
982
                ->mapToInputs($request, $related),
1✔
983
        ]);
1✔
984
    }
985

986
    /**
987
     * Get the edit representation of the
988
     */
989
    public function toShow(Request $request, Model $model, Model $related): array
990
    {
991
        return array_merge($this->toSubResource($request, $model), [
1✔
992
            'template' => 'root::resources.show',
1✔
993
            'title' => $this->resolveDisplay($related),
1✔
994
            'model' => $related->setRelation('related', $model),
1✔
995
            'action' => $this->relatedUrl($model, $related),
1✔
996
            'fields' => $this->resolveFields($request)
1✔
997
                ->subResource(false)
1✔
998
                ->authorized($request, $related)
1✔
999
                ->visible('show')
1✔
1000
                ->mapToDisplay($request, $related),
1✔
1001
            'actions' => $this->resolveActions($request)
1✔
1002
                ->authorized($request, $related)
1✔
1003
                ->visible('show')
1✔
1004
                ->standalone(false)
1✔
1005
                ->mapToForms($request, $related),
1✔
1006
            'abilities' => array_merge(
1✔
1007
                $this->mapRelationAbilities($request, $model),
1✔
1008
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1009
            ),
1✔
1010
        ]);
1✔
1011
    }
1012

1013
    /**
1014
     * Get the edit representation of the
1015
     */
1016
    public function toEdit(Request $request, Model $model, Model $related): array
1017
    {
1018
        return array_merge($this->toSubResource($request, $model), [
1✔
1019
            'template' => 'root::resources.form',
1✔
1020
            'title' => __('Edit :model', ['model' => $this->resolveDisplay($related)]),
1✔
1021
            'model' => $related->setRelation('related', $model),
1✔
1022
            'action' => $this->relatedUrl($model, $related),
1✔
1023
            'method' => 'PATCH',
1✔
1024
            'uploads' => $this->hasFileField($request),
1✔
1025
            'fields' => $this->resolveFields($request)
1✔
1026
                ->subResource(false)
1✔
1027
                ->authorized($request, $related)
1✔
1028
                ->visible('update')
1✔
1029
                ->mapToInputs($request, $related),
1✔
1030
            'abilities' => array_merge(
1✔
1031
                $this->mapRelationAbilities($request, $model),
1✔
1032
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1033
            ),
1✔
1034
        ]);
1✔
1035
    }
1036
}
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