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

conedevelopment / root / 19758157679

28 Nov 2025 08:23AM UTC coverage: 76.162% (+0.05%) from 76.113%
19758157679

push

github

iamgergo
fix test

3393 of 4455 relevant lines covered (76.16%)

34.06 hits per line

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

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

3
declare(strict_types=1);
4

5
namespace Cone\Root\Fields;
6

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

197
        return $this;
197✔
198
    }
199

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

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

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

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

227
        return $this;
197✔
228
    }
229

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

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

245
        return $this;
197✔
246
    }
247

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

354
        return $this;
×
355
    }
356

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

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

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

376
        return $this;
197✔
377
    }
378

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

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

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

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

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

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

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

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

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

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

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

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

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

445
        return $value;
1✔
446
    }
447

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

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

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

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

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

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

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

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

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

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

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

515
        return $this;
197✔
516
    }
517

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

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

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

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

549
            $this->aggregated = true;
×
550

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

554
        return $this;
×
555
    }
556

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

566
        return $query;
1✔
567
    }
568

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

576
        return $this;
×
577
    }
578

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

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

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

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

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

626
    /**
627
     * Get the sort key.
628
     */
629
    public function getSortKey(): string
2✔
630
    {
631
        return sprintf('%s_sort', $this->getRequestKey());
2✔
632
    }
633

634
    /**
635
     * The relations to be eagerload.
636
     */
637
    public function with(array $with): static
×
638
    {
639
        $this->with = $with;
×
640

641
        return $this;
×
642
    }
643

644
    /**
645
     * The relation counts to be eagerload.
646
     */
647
    public function withCount(array $withCount): static
×
648
    {
649
        $this->withCount = $withCount;
×
650

651
        return $this;
×
652
    }
653

654
    /**
655
     * Paginate the given query.
656
     */
657
    public function paginate(Request $request, Model $model): LengthAwarePaginator
2✔
658
    {
659
        $relation = $this->getRelation($model);
2✔
660

661
        $this->resolveFilters($request)->apply($request, $relation->getQuery());
2✔
662

663
        return $relation
2✔
664
            ->with($this->with)
2✔
665
            ->withCount($this->withCount)
2✔
666
            ->latest()
2✔
667
            ->paginate(
2✔
668
                perPage: $request->input(
2✔
669
                    $this->getPerPageKey(),
2✔
670
                    $request->isTurboFrameRequest() ? 5 : $relation->getRelated()->getPerPage()
2✔
671
                ),
2✔
672
                pageName: $this->getPageKey()
2✔
673
            )->withQueryString();
2✔
674
    }
675

676
    /**
677
     * Map a related model.
678
     */
679
    public function mapRelated(Request $request, Model $model, Model $related): array
1✔
680
    {
681
        return [
1✔
682
            'id' => $related->getKey(),
1✔
683
            'model' => $related->setRelation('related', $model),
1✔
684
            'url' => $this->relatedUrl($related),
1✔
685
            'fields' => $this->resolveFields($request)
1✔
686
                ->subResource(false)
1✔
687
                ->authorized($request, $related)
1✔
688
                ->visible('index')
1✔
689
                ->mapToDisplay($request, $related),
1✔
690
            'abilities' => $this->mapRelatedAbilities($request, $model, $related),
1✔
691
        ];
1✔
692
    }
693

694
    /**
695
     * Get the model URL.
696
     */
697
    public function modelUrl(Model $model): string
14✔
698
    {
699
        return str_replace('{resourceModel}', $model->exists ? (string) $model->getKey() : 'create', $this->getUri());
14✔
700
    }
701

702
    /**
703
     * Get the related URL.
704
     */
705
    public function relatedUrl(Model $related): string
5✔
706
    {
707
        return sprintf('%s/%s', $this->modelUrl($related->getRelationValue('related')), $related->getKey());
5✔
708
    }
709

710
    /**
711
     * {@inheritdoc}
712
     */
713
    public function persist(Request $request, Model $model, mixed $value): void
7✔
714
    {
715
        if ($this->isSubResource()) {
7✔
716
            $this->resolveFields($request)
4✔
717
                ->authorized($request, $model)
4✔
718
                ->visible($request->isMethod('POST') ? 'create' : 'update')
4✔
719
                ->persist($request, $model);
4✔
720
        } else {
721
            parent::persist($request, $model, $value);
5✔
722
        }
723
    }
724

725
    /**
726
     * Handle the request.
727
     */
728
    public function handleFormRequest(Request $request, Model $model): Response
4✔
729
    {
730
        $this->validateFormRequest($request, $model);
4✔
731

732
        try {
733
            return DB::transaction(function () use ($request, $model): Response {
4✔
734
                $this->persist($request, $model, $this->getValueForHydrate($request));
4✔
735

736
                $model->save();
4✔
737

738
                if (in_array(HasRootEvents::class, class_uses_recursive($model))) {
4✔
739
                    $model->recordRootEvent(
×
740
                        $model->wasRecentlyCreated ? 'Created' : 'Updated',
×
741
                        $request->user()
×
742
                    );
×
743
                }
744

745
                $this->saved($request, $model);
4✔
746

747
                return $this->formResponse($request, $model);
4✔
748
            });
4✔
749
        } catch (Throwable $exception) {
×
750
            report($exception);
×
751

752
            DB::rollBack();
×
753

754
            throw new SaveFormDataException($exception->getMessage());
×
755
        }
756
    }
757

758
    /**
759
     * Make a form response.
760
     */
761
    public function formResponse(Request $request, Model $model): Response
4✔
762
    {
763
        return Redirect::to($this->relatedUrl($model))
4✔
764
            ->with('alerts.relation-saved', Alert::success(__('The relation has been saved!')));
4✔
765
    }
766

767
    /**
768
     * Handle the saved form event.
769
     */
770
    public function saved(Request $request, Model $model): void
4✔
771
    {
772
        //
773
    }
4✔
774

775
    /**
776
     * Resolve the resource model for a bound value.
777
     */
778
    public function resolveRouteBinding(Request $request, string $id): Model
4✔
779
    {
780
        $parent = $request->route()->parentOfParameter($this->getRouteKeyName());
4✔
781

782
        return $this->getRelation($parent)->findOrFail($id);
4✔
783
    }
784

785
    /**
786
     * Register the routes using the given router.
787
     */
788
    public function registerRoutes(Request $request, Router $router): void
197✔
789
    {
790
        $this->__registerRoutes($request, $router);
197✔
791

792
        $router->prefix($this->getUriKey())->group(function (Router $router) use ($request): void {
197✔
793
            $this->resolveActions($request)->registerRoutes($request, $router);
197✔
794

795
            $router->prefix("{{$this->getRouteKeyName()}}")->group(function (Router $router) use ($request): void {
197✔
796
                $this->resolveFields($request)->registerRoutes($request, $router);
197✔
797
            });
197✔
798
        });
197✔
799

800
        $this->registerRouteConstraints($request, $router);
197✔
801

802
        $this->routesRegistered($request);
197✔
803
    }
804

805
    /**
806
     * Get the route middleware for the registered routes.
807
     */
808
    public function getRouteMiddleware(): array
197✔
809
    {
810
        return [
197✔
811
            sprintf('%s:field,resourceModel,%s', Authorize::class, $this->getRouteKeyName()),
197✔
812
        ];
197✔
813
    }
814

815
    /**
816
     * Handle the routes registered event.
817
     */
818
    protected function routesRegistered(Request $request): void
197✔
819
    {
820
        $uri = $this->getUri();
197✔
821
        $routeKeyName = $this->getRouteKeyName();
197✔
822

823
        Root::instance()->breadcrumbs->patterns([
197✔
824
            $this->getUri() => $this->label,
197✔
825
            sprintf('%s/create', $uri) => __('Add'),
197✔
826
            sprintf('%s/{%s}', $uri, $routeKeyName) => fn (Request $request): string => $this->resolveDisplay($request->route($routeKeyName)),
197✔
827
            sprintf('%s/{%s}/edit', $uri, $routeKeyName) => __('Edit'),
197✔
828
        ]);
197✔
829
    }
830

831
    /**
832
     * Handle the route matched event.
833
     */
834
    public function routeMatched(RouteMatched $event): void
15✔
835
    {
836
        $this->__routeMatched($event);
15✔
837

838
        $controller = $event->route->getController();
15✔
839

840
        $controller->middleware($this->getRouteMiddleware());
15✔
841

842
        $middleware = function (Request $request, Closure $next) use ($event): mixed {
15✔
843
            $ability = match ($event->route->getActionMethod()) {
15✔
844
                'index' => 'viewAny',
2✔
845
                'show' => 'view',
1✔
846
                'create' => 'create',
1✔
847
                'store' => 'create',
2✔
848
                'edit' => 'update',
1✔
849
                'update' => 'update',
2✔
850
                'destroy' => 'delete',
2✔
851
                default => $event->route->getActionMethod(),
4✔
852
            };
15✔
853

854
            Gate::allowIf($this->resolveAbility(
15✔
855
                $ability, $request, $request->route('resourceModel'), $request->route($this->getRouteParameterName())
15✔
856
            ));
15✔
857

858
            return $next($request);
15✔
859
        };
15✔
860

861
        $controller->middleware([$middleware]);
15✔
862
    }
863

864
    /**
865
     * Resolve the ability.
866
     */
867
    public function resolveAbility(string $ability, Request $request, Model $model, ...$arguments): bool
16✔
868
    {
869
        $policy = Gate::getPolicyFor($model);
16✔
870

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

873
        return is_null($policy)
16✔
874
            || ! is_callable([$policy, $ability])
16✔
875
            || Gate::allows($ability, [$model, ...$arguments]);
16✔
876
    }
877

878
    /**
879
     * Map the relation abilities.
880
     */
881
    public function mapRelationAbilities(Request $request, Model $model): array
6✔
882
    {
883
        return [
6✔
884
            'viewAny' => $this->resolveAbility('viewAny', $request, $model),
6✔
885
            'create' => $this->resolveAbility('create', $request, $model),
6✔
886
        ];
6✔
887
    }
888

889
    /**
890
     * Map the related model abilities.
891
     */
892
    public function mapRelatedAbilities(Request $request, Model $model, Model $related): array
4✔
893
    {
894
        return [
4✔
895
            'view' => $this->resolveAbility('view', $request, $model, $related),
4✔
896
            'update' => $this->resolveAbility('update', $request, $model, $related),
4✔
897
            'restore' => $this->resolveAbility('restore', $request, $model, $related),
4✔
898
            'delete' => $this->resolveAbility('delete', $request, $model, $related),
4✔
899
            'forceDelete' => $this->resolveAbility('forceDelete', $request, $model, $related),
4✔
900
        ];
4✔
901
    }
902

903
    /**
904
     * Register the routes.
905
     */
906
    public function routes(Router $router): void
197✔
907
    {
908
        if ($this->isSubResource()) {
197✔
909
            $router->get('/', [RelationController::class, 'index']);
197✔
910
            $router->get('/create', [RelationController::class, 'create']);
197✔
911
            $router->get("/{{$this->getRouteKeyName()}}", [RelationController::class, 'show']);
197✔
912
            $router->post('/', [RelationController::class, 'store']);
197✔
913
            $router->get("/{{$this->getRouteKeyName()}}/edit", [RelationController::class, 'edit']);
197✔
914
            $router->patch("/{{$this->getRouteKeyName()}}", [RelationController::class, 'update']);
197✔
915
            $router->delete("/{{$this->getRouteKeyName()}}", [RelationController::class, 'destroy']);
197✔
916
        }
917
    }
918

919
    /**
920
     * Register the route constraints.
921
     */
922
    public function registerRouteConstraints(Request $request, Router $router): void
197✔
923
    {
924
        $router->bind($this->getRouteKeyName(), fn (string $id, Route $route): Model => match ($id) {
197✔
925
            'create' => $this->getRelation($route->parentOfParameter($this->getRouteKeyName()))->make(),
6✔
926
            default => $this->resolveRouteBinding($router->getCurrentRequest(), $id),
6✔
927
        });
197✔
928
    }
929

930
    /**
931
     * Parse the given query string.
932
     */
933
    public function parseQueryString(string $url): array
6✔
934
    {
935
        $query = parse_url($url, PHP_URL_QUERY) ?: '';
6✔
936

937
        parse_str($query, $result);
6✔
938

939
        return array_filter($result, fn (string $key): bool => str_starts_with($key, $this->getRequestKey()), ARRAY_FILTER_USE_KEY);
6✔
940
    }
941

942
    /**
943
     * Get the option representation of the model and the related model.
944
     */
945
    public function toOption(Request $request, Model $model, Model $related): array
5✔
946
    {
947
        $value = $this->resolveValue($request, $model);
5✔
948

949
        return $this->newOption($related, $this->resolveDisplay($related))
5✔
950
            ->selected(! is_null($value) && ($value instanceof Model ? $value->is($related) : $value->contains($related)))
5✔
951
            ->toArray();
5✔
952
    }
953

954
    /**
955
     * {@inheritdoc}
956
     */
957
    public function toInput(Request $request, Model $model): array
3✔
958
    {
959
        return array_merge(parent::toInput($request, $model), [
3✔
960
            'nullable' => $this->isNullable(),
3✔
961
            'options' => $this->resolveOptions($request, $model),
3✔
962
        ]);
3✔
963
    }
964

965
    /**
966
     * Get the sub resource representation of the relation
967
     */
968
    public function toSubResource(Request $request, Model $model): array
6✔
969
    {
970
        return array_merge($this->toArray(), [
6✔
971
            'key' => $this->modelAttribute,
6✔
972
            'baseUrl' => $this->modelUrl($model),
6✔
973
            'url' => URL::query($this->modelUrl($model), $this->parseQueryString($request->fullUrl())),
6✔
974
            'modelName' => $this->getRelatedName(),
6✔
975
            'abilities' => $this->mapRelationAbilities($request, $model),
6✔
976
        ]);
6✔
977
    }
978

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

1011
    /**
1012
     * Get the create representation of the resource.
1013
     */
1014
    public function toCreate(Request $request, Model $model): array
1✔
1015
    {
1016
        return array_merge($this->toSubResource($request, $model), [
1✔
1017
            'template' => 'root::resources.form',
1✔
1018
            'title' => __('Create :model', ['model' => $this->getRelatedName()]),
1✔
1019
            'model' => $related = $this->getRelation($model)->make()->setRelation('related', $model),
1✔
1020
            'action' => $this->modelUrl($model),
1✔
1021
            'uploads' => $this->hasFileField($request),
1✔
1022
            'method' => 'POST',
1✔
1023
            'fields' => $this->resolveFields($request)
1✔
1024
                ->subResource(false)
1✔
1025
                ->authorized($request, $related)
1✔
1026
                ->visible('create')
1✔
1027
                ->mapToInputs($request, $related),
1✔
1028
        ]);
1✔
1029
    }
1030

1031
    /**
1032
     * Get the edit representation of the
1033
     */
1034
    public function toShow(Request $request, Model $model, Model $related): array
1✔
1035
    {
1036
        return array_merge($this->toSubResource($request, $model), [
1✔
1037
            'template' => 'root::resources.show',
1✔
1038
            'title' => $this->resolveDisplay($related),
1✔
1039
            'model' => $related->setRelation('related', $model),
1✔
1040
            'action' => $this->relatedUrl($related),
1✔
1041
            'fields' => $this->resolveFields($request)
1✔
1042
                ->subResource(false)
1✔
1043
                ->authorized($request, $related)
1✔
1044
                ->visible('show')
1✔
1045
                ->mapToDisplay($request, $related),
1✔
1046
            'actions' => $this->resolveActions($request)
1✔
1047
                ->authorized($request, $related)
1✔
1048
                ->visible('show')
1✔
1049
                ->standalone(false)
1✔
1050
                ->mapToForms($request, $related),
1✔
1051
            'abilities' => array_merge(
1✔
1052
                $this->mapRelationAbilities($request, $model),
1✔
1053
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1054
            ),
1✔
1055
        ]);
1✔
1056
    }
1057

1058
    /**
1059
     * Get the edit representation of the
1060
     */
1061
    public function toEdit(Request $request, Model $model, Model $related): array
1✔
1062
    {
1063
        return array_merge($this->toSubResource($request, $model), [
1✔
1064
            'template' => 'root::resources.form',
1✔
1065
            'title' => __('Edit :model', ['model' => $this->resolveDisplay($related)]),
1✔
1066
            'model' => $related->setRelation('related', $model),
1✔
1067
            'action' => $this->relatedUrl($related),
1✔
1068
            'method' => 'PATCH',
1✔
1069
            'uploads' => $this->hasFileField($request),
1✔
1070
            'fields' => $this->resolveFields($request)
1✔
1071
                ->subResource(false)
1✔
1072
                ->authorized($request, $related)
1✔
1073
                ->visible('update')
1✔
1074
                ->mapToInputs($request, $related),
1✔
1075
            'abilities' => array_merge(
1✔
1076
                $this->mapRelationAbilities($request, $model),
1✔
1077
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1078
            ),
1✔
1079
        ]);
1✔
1080
    }
1081

1082
    /**
1083
     * Get the filter representation of the field.
1084
     */
1085
    public function toFilter(): Filter
×
1086
    {
1087
        return new class($this) extends RenderableFilter
×
1088
        {
×
1089
            protected Relation $field;
1090

1091
            public function __construct(Relation $field)
1092
            {
1093
                parent::__construct($field->getModelAttribute());
×
1094

1095
                $this->field = $field;
×
1096
            }
1097

1098
            public function apply(Request $request, Builder $query, mixed $value): Builder
1099
            {
1100
                return $this->field->resolveFilterQuery($request, $query, $value);
×
1101
            }
1102

1103
            public function toField(): Field
1104
            {
1105
                return Select::make($this->field->getLabel(), $this->getRequestKey())
×
1106
                    ->value(fn (Request $request): mixed => $this->getValue($request))
×
1107
                    ->nullable()
×
1108
                    ->options(function (Request $request, Model $model): array {
×
1109
                        return array_column(
×
1110
                            $this->field->resolveOptions($request, $model),
×
1111
                            'label',
×
1112
                            'value',
×
1113
                        );
×
1114
                    });
×
1115
            }
1116
        };
×
1117
    }
1118
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc