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

conedevelopment / root / 20618156642

31 Dec 2025 11:33AM UTC coverage: 75.542% (-0.5%) from 76.05%
20618156642

push

github

iamgergo
fixes

3 of 3 new or added lines in 1 file covered. (100.0%)

93 existing lines in 4 files now uncovered.

3450 of 4567 relevant lines covered (75.54%)

33.14 hits per line

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

83.68
/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\AsyncRelationController;
15
use Cone\Root\Http\Controllers\RelationController;
16
use Cone\Root\Http\Middleware\Authorize;
17
use Cone\Root\Interfaces\Form;
18
use Cone\Root\Root;
19
use Cone\Root\Support\Alert;
20
use Cone\Root\Traits\AsForm;
21
use Cone\Root\Traits\HasRootEvents;
22
use Cone\Root\Traits\RegistersRoutes;
23
use Cone\Root\Traits\ResolvesActions;
24
use Cone\Root\Traits\ResolvesFields;
25
use Cone\Root\Traits\ResolvesFilters;
26
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
27
use Illuminate\Database\Eloquent\Builder;
28
use Illuminate\Database\Eloquent\Model;
29
use Illuminate\Database\Eloquent\Relations\Relation as EloquentRelation;
30
use Illuminate\Http\Request;
31
use Illuminate\Routing\Events\RouteMatched;
32
use Illuminate\Routing\Route;
33
use Illuminate\Routing\Router;
34
use Illuminate\Support\Collection;
35
use Illuminate\Support\Facades\DB;
36
use Illuminate\Support\Facades\Gate;
37
use Illuminate\Support\Facades\Redirect;
38
use Illuminate\Support\Facades\URL;
39
use Illuminate\Support\Facades\View;
40
use Illuminate\Support\MessageBag;
41
use Illuminate\Support\Str;
42
use Symfony\Component\HttpFoundation\Response;
43
use Throwable;
44

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

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

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

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

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

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

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

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

94
    /**
95
     * Indicates whether the field is async.
96
     */
97
    protected bool $async = false;
98

99
    /**
100
     * Determine if the field is computed.
101
     */
102
    protected ?Closure $aggregateResolver = null;
103

104
    /**
105
     * Determine whether the relation values are aggregated.
106
     */
107
    protected bool $aggregated = false;
108

109
    /**
110
     * The option group resolver.
111
     */
112
    protected string|Closure|null $groupResolver = null;
113

114
    /**
115
     * Indicates whether the relation is a sub resource.
116
     */
117
    protected bool $asSubResource = false;
118

119
    /**
120
     * The relations to eager load on every query.
121
     */
122
    protected array $with = [];
123

124
    /**
125
     * The relations to eager load on every query.
126
     */
127
    protected array $withCount = [];
128

129
    /**
130
     * The query scopes.
131
     */
132
    protected static array $scopes = [];
133

134
    /**
135
     * The route key resolver.
136
     */
137
    protected ?Closure $routeKeyNameResolver = null;
138

139
    /**
140
     * Create a new relation field instance.
141
     */
142
    public function __construct(string $label, Closure|string|null $modelAttribute = null, Closure|string|null $relation = null)
196✔
143
    {
144
        parent::__construct($label, $modelAttribute);
196✔
145

146
        $this->relation = $relation ?: $this->getModelAttribute();
196✔
147
    }
148

149
    /**
150
     * Add a new scope for the relation query.
151
     */
152
    public static function scopeQuery(Closure $callback): void
×
153
    {
154
        static::$scopes[static::class][] = $callback;
×
155
    }
156

157
    /**
158
     * Get the relation instance.
159
     *
160
     * @phpstan-return TRelation
161
     */
162
    public function getRelation(Model $model): EloquentRelation
23✔
163
    {
164
        if ($this->relation instanceof Closure) {
23✔
165
            return call_user_func_array($this->relation, [$model]);
×
166
        }
167

168
        return call_user_func([$model, $this->relation]);
23✔
169
    }
170

171
    /**
172
     * Get the related model name.
173
     */
174
    public function getRelatedName(): string
196✔
175
    {
176
        return __(Str::of($this->getModelAttribute())->singular()->headline()->value());
196✔
177
    }
178

179
    /**
180
     * Get the relation name.
181
     */
182
    public function getRelationName(): string
196✔
183
    {
184
        return $this->relation instanceof Closure
196✔
185
            ? Str::afterLast($this->getModelAttribute(), '.')
196✔
186
            : $this->relation;
196✔
187
    }
188

189
    /**
190
     * Get the URI key.
191
     */
192
    public function getUriKey(): string
196✔
193
    {
194
        return str_replace('.', '-', $this->getRequestKey());
196✔
195
    }
196

197
    /**
198
     * Set the route key name resolver.
199
     */
200
    public function resolveRouteKeyNameUsing(Closure $callback): static
196✔
201
    {
202
        $this->routeKeyNameResolver = $callback;
196✔
203

204
        return $this;
196✔
205
    }
206

207
    /**
208
     * Get the related model's route key name.
209
     */
210
    public function getRouteKeyName(): string
196✔
211
    {
212
        $callback = is_null($this->routeKeyNameResolver)
196✔
213
            ? fn (): string => Str::of($this->getRelationName())->singular()->ucfirst()->prepend('relation')->value()
1✔
214
        : $this->routeKeyNameResolver;
196✔
215

216
        return call_user_func($callback);
196✔
217
    }
218

219
    /**
220
     * Get the route parameter name.
221
     */
222
    public function getRouteParameterName(): string
11✔
223
    {
224
        return 'field';
11✔
225
    }
226

227
    /**
228
     * Get the modal key.
229
     */
230
    public function getModalKey(): string
2✔
231
    {
232
        return sprintf('relation-field-%s', $this->getModelAttribute());
2✔
233
    }
234

235
    /**
236
     * Set the async attribute.
237
     */
238
    public function async(bool $value = true): static
196✔
239
    {
240
        $this->async = $value;
196✔
241

242
        $this->template = $value ? 'root::fields.relation' : 'root::fields.select';
196✔
243

244
        return $this;
196✔
245
    }
246

247
    /**
248
     * Determine if the field is async.
249
     */
250
    public function isAsync(): bool
196✔
251
    {
252
        return $this->async;
196✔
253
    }
254

255
    /**
256
     * Set the as subresource attribute.
257
     */
258
    public function asSubResource(bool $value = true): static
196✔
259
    {
260
        $this->asSubResource = $value;
196✔
261

262
        if ($value) {
196✔
263
            $this->async(false);
196✔
264
        }
265

266
        return $this;
196✔
267
    }
268

269
    /**
270
     * Determine if the relation is a subresource.
271
     */
272
    public function isSubResource(): bool
196✔
273
    {
274
        return $this->asSubResource;
196✔
275
    }
276

277
    /**
278
     * Set the nullable attribute.
279
     */
280
    public function nullable(bool $value = true): static
196✔
281
    {
282
        $this->nullable = $value;
196✔
283

284
        return $this;
196✔
285
    }
286

287
    /**
288
     * Determine if the field is nullable.
289
     */
290
    public function isNullable(): bool
2✔
291
    {
292
        return $this->nullable;
2✔
293
    }
294

295
    /**
296
     * Set the filterable attribute.
297
     */
298
    public function filterable(bool|Closure $value = true, ?Closure $callback = null): static
×
299
    {
300
        $callback ??= function (Request $request, Builder $query, mixed $value): Builder {
301
            return $query->whereHas($this->getModelAttribute(), static function (Builder $query) use ($value): Builder {
×
302
                return $query->whereKey($value);
×
303
            });
×
304
        };
305

306
        return parent::filterable($value, $callback);
×
307
    }
308

309
    /**
310
     * {@inheritdoc}
311
     */
312
    public function searchable(bool|Closure $value = true, ?Closure $callback = null, array $columns = ['id']): static
1✔
313
    {
314
        $this->searchableColumns = $columns;
1✔
315

316
        $callback ??= function (Request $request, Builder $query, mixed $value, array $attributes): Builder {
1✔
317
            return $query->has($this->getModelAttribute(), '>=', 1, 'or', static function (Builder $query) use ($attributes, $value): Builder {
1✔
318
                foreach ($attributes as $attribute) {
1✔
319
                    $query->where(
1✔
320
                        $query->qualifyColumn($attribute),
1✔
321
                        'like',
1✔
322
                        "%{$value}%",
1✔
323
                        $attributes[0] === $attribute ? 'and' : 'or'
1✔
324
                    );
1✔
325
                }
326

327
                return $query;
1✔
328
            });
1✔
329
        };
1✔
330

331
        return parent::searchable($value, $callback);
1✔
332
    }
333

334
    /**
335
     * Get the searchable columns.
336
     */
337
    public function getSearchableColumns(): array
1✔
338
    {
339
        return $this->searchableColumns;
1✔
340
    }
341

342
    /**
343
     * Resolve the filter query.
344
     */
345
    public function resolveSearchQuery(Request $request, Builder $query, mixed $value): Builder
1✔
346
    {
347
        if (! $this->isSearchable()) {
1✔
348
            return parent::resolveSearchQuery($request, $query, $value);
×
349
        }
350

351
        return call_user_func_array($this->searchQueryResolver, [
1✔
352
            $request, $query, $value, $this->getSearchableColumns(),
1✔
353
        ]);
1✔
354
    }
355

356
    /**
357
     * Set the sortable attribute.
358
     */
359
    public function sortable(bool|Closure $value = true, string $column = 'id'): static
1✔
360
    {
361
        $this->sortableColumn = $column;
1✔
362

363
        return parent::sortable($value);
1✔
364
    }
365

366
    /**
367
     * Get the sortable columns.
368
     */
369
    public function getSortableColumn(): string
1✔
370
    {
371
        return $this->sortableColumn;
1✔
372
    }
373

374
    /**
375
     * {@inheritdoc}
376
     */
377
    public function isSortable(): bool
12✔
378
    {
379
        if ($this->isSubResource()) {
12✔
380
            return false;
10✔
381
        }
382

383
        return parent::isSortable();
8✔
384
    }
385

386
    /**
387
     * Set the translatable attribute.
388
     */
389
    public function translatable(bool|Closure $value = false): static
×
390
    {
391
        $this->translatable = false;
×
392

393
        return $this;
×
394
    }
395

396
    /**
397
     * Determine if the field is translatable.
398
     */
399
    public function isTranslatable(): bool
196✔
400
    {
401
        return false;
196✔
402
    }
403

404
    /**
405
     * Set the display resolver.
406
     */
407
    public function display(Closure|string $callback): static
196✔
408
    {
409
        if (is_string($callback)) {
196✔
410
            $callback = static fn (Model $model) => $model->getAttribute($callback);
196✔
411
        }
412

413
        $this->displayResolver = $callback;
196✔
414

415
        return $this;
196✔
416
    }
417

418
    /**
419
     * Resolve the display format or the query result.
420
     */
421
    public function resolveDisplay(Model $related): ?string
5✔
422
    {
423
        if (is_null($this->displayResolver)) {
5✔
424
            $this->display($related->getKeyName());
×
425
        }
426

427
        return (string) call_user_func_array($this->displayResolver, [$related]);
5✔
428
    }
429

430
    /**
431
     * {@inheritdoc}
432
     */
433
    public function getValue(Model $model): mixed
10✔
434
    {
435
        if ($this->aggregated) {
10✔
436
            return parent::getValue($model);
×
437
        }
438

439
        $name = $this->getRelationName();
10✔
440

441
        if ($this->relation instanceof Closure && ! $model->relationLoaded($name)) {
10✔
442
            $model->setRelation($name, call_user_func_array($this->relation, [$model])->getResults());
3✔
443
        }
444

445
        return $model->getAttribute($name);
10✔
446
    }
447

448
    /**
449
     * {@inheritdoc}
450
     */
451
    public function resolveFormat(Request $request, Model $model): ?string
5✔
452
    {
453
        if (is_null($this->formatResolver)) {
5✔
454
            $this->formatResolver = function (Request $request, Model $model): mixed {
5✔
455
                $default = $this->getValue($model);
5✔
456

457
                if ($this->aggregated) {
5✔
458
                    return (string) $default;
×
459
                }
460

461
                return Collection::wrap($default)
5✔
462
                    ->map(fn (Model $related): ?string => $this->formatRelated($request, $model, $related))
5✔
463
                    ->filter()
5✔
464
                    ->join(', ');
5✔
465
            };
5✔
466
        }
467

468
        return parent::resolveFormat($request, $model);
5✔
469
    }
470

471
    /**
472
     * Format the related model.
473
     */
474
    public function formatRelated(Request $request, Model $model, Model $related): ?string
1✔
475
    {
476
        $resource = Root::instance()->resources->forModel($related);
1✔
477

478
        $value = $this->resolveDisplay($related);
1✔
479

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

484
        return $value;
1✔
485
    }
486

487
    /**
488
     * Define the filters for the object.
489
     */
490
    public function filters(Request $request): array
4✔
491
    {
492
        $fields = $this->resolveFields($request)->authorized($request);
4✔
493

494
        $searchables = match (true) {
4✔
495
            $this->isAsync() => $this->mapAsyncSearchableFields($request),
4✔
496
            default => $fields->searchable(),
2✔
497
        };
4✔
498

499
        $sortables = $fields->sortable();
4✔
500

501
        $filterables = $fields->filterable();
4✔
502

503
        return array_values(array_filter([
4✔
504
            $searchables->isNotEmpty() ? new Search($searchables) : null,
4✔
505
            $sortables->isNotEmpty() ? new Sort($sortables) : null,
4✔
506
            ...$filterables->map->toFilter()->all(),
4✔
507
        ]));
4✔
508
    }
509

510
    /**
511
     * Map the async searchable fields.
512
     */
UNCOV
513
    protected function mapAsyncSearchableFields(Request $request): Fields
×
514
    {
UNCOV
515
        return new Fields(array_map(
×
UNCOV
516
            fn (string $column): Hidden => Hidden::make($this->getRelationName(), $column)->searchable(),
×
UNCOV
517
            $this->getSearchableColumns()
×
UNCOV
518
        ));
×
519
    }
520

521
    /**
522
     * Handle the callback for the field resolution.
523
     */
524
    protected function resolveField(Request $request, Field $field): void
196✔
525
    {
526
        if ($this->isSubResource()) {
196✔
527
            $field->setAttribute('form', $this->modelAttribute);
196✔
528
            $field->resolveErrorsUsing(fn (Request $request): MessageBag => $this->errors($request));
196✔
529
        } else {
UNCOV
530
            $field->setAttribute('form', $this->getAttribute('form'));
×
UNCOV
531
            $field->resolveErrorsUsing($this->errorsResolver);
×
532
        }
533

534
        if ($field instanceof Relation) {
196✔
535
            $field->resolveRouteKeyNameUsing(
196✔
536
                fn (): string => Str::of($field->getRelationName())->singular()->ucfirst()->prepend($this->getRouteKeyName())->value()
196✔
537
            );
196✔
538
        }
539
    }
540

541
    /**
542
     * Handle the callback for the field resolution.
543
     */
UNCOV
544
    protected function resolveAction(Request $request, Action $action): void
×
545
    {
UNCOV
546
        $action->withQuery(function (Request $request): Builder {
×
UNCOV
547
            $model = $request->route('resourceModel');
×
548

UNCOV
549
            return $this->resolveFilters($request)->apply($request, $this->getRelation($model)->getQuery());
×
UNCOV
550
        });
×
551
    }
552

553
    /**
554
     * Handle the callback for the filter resolution.
555
     */
556
    protected function resolveFilter(Request $request, Filter $filter): void
3✔
557
    {
558
        $filter->setKey(sprintf('%s_%s', $this->getRequestKey(), $filter->getKey()));
3✔
559
    }
560

561
    /**
562
     * Set the query resolver.
563
     */
564
    public function withRelatableQuery(Closure $callback): static
196✔
565
    {
566
        $this->queryResolver = $callback;
196✔
567

568
        return $this;
196✔
569
    }
570

571
    /**
572
     * Resolve the related model's eloquent query.
573
     */
574
    public function resolveRelatableQuery(Request $request, Model $model): Builder
8✔
575
    {
576
        $query = $this->getRelation($model)
8✔
577
            ->getRelated()
8✔
578
            ->newQuery()
8✔
579
            ->with($this->with)
8✔
580
            ->withCount($this->withCount);
8✔
581

582
        foreach (static::$scopes[static::class] ?? [] as $scope) {
8✔
UNCOV
583
            $query = call_user_func_array($scope, [$request, $query, $model]);
×
584
        }
585

586
        return $query->when(
8✔
587
            ! is_null($this->queryResolver),
8✔
588
            function (Builder $query) use ($request, $model): Builder {
8✔
589
                return call_user_func_array($this->queryResolver, [$request, $query, $model]);
×
590
            }
8✔
591
        );
8✔
592
    }
593

594
    /**
595
     * Aggregate relation values.
596
     */
UNCOV
597
    public function aggregate(string $fn = 'count', string $column = '*'): static
×
598
    {
599
        $this->aggregateResolver = function (Request $request, Builder $query) use ($fn, $column): Builder {
600
            $this->setModelAttribute(sprintf(
×
UNCOV
601
                '%s_%s%s', $this->getRelationName(),
×
UNCOV
602
                $fn,
×
603
                $column === '*' ? '' : sprintf('_%s', $column)
×
UNCOV
604
            ));
×
605

UNCOV
606
            $this->aggregated = true;
×
607

UNCOV
608
            return $query->withAggregate($this->getRelationName(), $column, $fn);
×
609
        };
610

UNCOV
611
        return $this;
×
612
    }
613

614
    /**
615
     * Resolve the aggregate query.
616
     */
617
    public function resolveAggregate(Request $request, Builder $query): Builder
1✔
618
    {
619
        if (! is_null($this->aggregateResolver)) {
1✔
UNCOV
620
            $query = call_user_func_array($this->aggregateResolver, [$request, $query]);
×
621
        }
622

623
        return $query;
1✔
624
    }
625

626
    /**
627
     * Set the group resolver attribute.
628
     */
UNCOV
629
    public function groupOptionsBy(string|Closure $key): static
×
630
    {
UNCOV
631
        $this->groupResolver = $key;
×
632

UNCOV
633
        return $this;
×
634
    }
635

636
    /**
637
     * Resolve the options for the field.
638
     */
639
    public function resolveOptions(Request $request, Model $model): array
2✔
640
    {
641
        $options = match (true) {
2✔
642
            $this->isAsync() => Collection::wrap($this->resolveValue($request, $model)),
2✔
643
            default => $this->resolveRelatableQuery($request, $model)->get(),
×
644
        };
2✔
645

646
        return $options->when(
2✔
647
            ! is_null($this->groupResolver),
2✔
648
            function (Collection $collection) use ($request, $model): Collection {
2✔
649
                return $collection->groupBy($this->groupResolver)
×
UNCOV
650
                    ->map(function (Collection $group, string $key) use ($request, $model): array {
×
UNCOV
651
                        return [
×
UNCOV
652
                            'label' => $key,
×
UNCOV
653
                            'options' => $group->map(function (Model $related) use ($request, $model): array {
×
UNCOV
654
                                return $this->toOption($request, $model, $related);
×
UNCOV
655
                            })->all(),
×
UNCOV
656
                        ];
×
UNCOV
657
                    });
×
658
            },
2✔
659
            function (Collection $collection) use ($request, $model): Collection {
2✔
660
                return $collection->map(function (Model $related) use ($request, $model): array {
2✔
UNCOV
661
                    return $this->toOption($request, $model, $related);
×
662
                });
2✔
663
            }
2✔
664
        )->toArray();
2✔
665
    }
666

667
    /**
668
     * Make a new option instance.
669
     */
670
    public function newOption(Model $related, string $label): Option
2✔
671
    {
672
        return new Option($related->getKey(), $label);
2✔
673
    }
674

675
    /**
676
     * Get the per page options.
677
     */
678
    public function getPerPageOptions(): array
2✔
679
    {
680
        return [5, 10, 15, 25];
2✔
681
    }
682

683
    /**
684
     * Get the per page key.
685
     */
686
    public function getPerPageKey(): string
2✔
687
    {
688
        return sprintf('%s_per_page', $this->getRequestKey());
2✔
689
    }
690

691
    /**
692
     * Get the page key.
693
     */
694
    public function getPageKey(): string
2✔
695
    {
696
        return sprintf('%s_page', $this->getRequestKey());
2✔
697
    }
698

699
    /**
700
     * Get the sort key.
701
     */
702
    public function getSortKey(): string
2✔
703
    {
704
        return sprintf('%s_sort', $this->getRequestKey());
2✔
705
    }
706

707
    /**
708
     * The relations to be eagerload.
709
     */
UNCOV
710
    public function with(array $with): static
×
711
    {
712
        $this->with = $with;
×
713

714
        return $this;
×
715
    }
716

717
    /**
718
     * The relation counts to be eagerload.
719
     */
UNCOV
720
    public function withCount(array $withCount): static
×
721
    {
UNCOV
722
        $this->withCount = $withCount;
×
723

UNCOV
724
        return $this;
×
725
    }
726

727
    /**
728
     * Paginate the given query.
729
     */
730
    public function paginate(Request $request, Model $model): LengthAwarePaginator
2✔
731
    {
732
        $relation = $this->getRelation($model);
2✔
733

734
        $this->resolveFilters($request)->apply($request, $relation->getQuery());
2✔
735

736
        return $relation
2✔
737
            ->with($this->with)
2✔
738
            ->withCount($this->withCount)
2✔
739
            ->latest()
2✔
740
            ->paginate(
2✔
741
                perPage: $request->input(
2✔
742
                    $this->getPerPageKey(),
2✔
743
                    $request->isTurboFrameRequest() ? 5 : $relation->getRelated()->getPerPage()
2✔
744
                ),
2✔
745
                pageName: $this->getPageKey()
2✔
746
            )->withQueryString();
2✔
747
    }
748

749
    /**
750
     * Paginate the relatable models.
751
     */
752
    public function paginateRelatable(Request $request, Model $model): LengthAwarePaginator
1✔
753
    {
754
        return $this->resolveFilters($request)
1✔
755
            ->apply($request, $this->resolveRelatableQuery($request, $model))
1✔
756
            ->paginate($request->input('per_page'))
1✔
757
            ->withQueryString()
1✔
758
            ->through(function (Model $related) use ($request, $model): array {
1✔
UNCOV
759
                return $this->toOption($request, $model, $related);
×
760
            });
1✔
761
    }
762

763
    /**
764
     * Map a related model.
765
     */
766
    public function mapRelated(Request $request, Model $model, Model $related): array
1✔
767
    {
768
        return [
1✔
769
            'id' => $related->getKey(),
1✔
770
            'model' => $related->setRelation('related', $model),
1✔
771
            'url' => $this->relatedUrl($related),
1✔
772
            'fields' => $this->resolveFields($request)
1✔
773
                ->subResource(false)
1✔
774
                ->authorized($request, $related)
1✔
775
                ->visible('index')
1✔
776
                ->mapToDisplay($request, $related),
1✔
777
            'abilities' => $this->mapRelatedAbilities($request, $model, $related),
1✔
778
        ];
1✔
779
    }
780

781
    /**
782
     * Get the model URL.
783
     */
784
    public function modelUrl(Model $model): string
14✔
785
    {
786
        return str_replace('{resourceModel}', $model->exists ? (string) $model->getKey() : 'create', $this->getUri());
14✔
787
    }
788

789
    /**
790
     * Get the related URL.
791
     */
792
    public function relatedUrl(Model $related): string
5✔
793
    {
794
        return sprintf('%s/%s', $this->modelUrl($related->getRelationValue('related')), $related->getKey());
5✔
795
    }
796

797
    /**
798
     * {@inheritdoc}
799
     */
800
    public function persist(Request $request, Model $model, mixed $value): void
7✔
801
    {
802
        if ($this->isSubResource()) {
7✔
803
            $this->resolveFields($request)
4✔
804
                ->authorized($request, $model)
4✔
805
                ->visible($request->isMethod('POST') ? 'create' : 'update')
4✔
806
                ->persist($request, $model);
4✔
807
        } else {
808
            parent::persist($request, $model, $value);
5✔
809
        }
810
    }
811

812
    /**
813
     * Handle the request.
814
     */
815
    public function handleFormRequest(Request $request, Model $model): Response
4✔
816
    {
817
        $this->validateFormRequest($request, $model);
4✔
818

819
        try {
820
            return DB::transaction(function () use ($request, $model): Response {
4✔
821
                $this->persist($request, $model, $this->getValueForHydrate($request));
4✔
822

823
                $model->save();
4✔
824

825
                if (in_array(HasRootEvents::class, class_uses_recursive($model))) {
4✔
UNCOV
826
                    $model->recordRootEvent(
×
UNCOV
827
                        $model->wasRecentlyCreated ? 'Created' : 'Updated',
×
828
                        $request->user()
×
829
                    );
×
830
                }
831

832
                $this->saved($request, $model);
4✔
833

834
                return $this->formResponse($request, $model);
4✔
835
            });
4✔
UNCOV
836
        } catch (Throwable $exception) {
×
UNCOV
837
            report($exception);
×
838

UNCOV
839
            DB::rollBack();
×
840

UNCOV
841
            throw new SaveFormDataException($exception->getMessage());
×
842
        }
843
    }
844

845
    /**
846
     * Make a form response.
847
     */
848
    public function formResponse(Request $request, Model $model): Response
4✔
849
    {
850
        return Redirect::to($this->relatedUrl($model))
4✔
851
            ->with('alerts.relation-saved', Alert::success(__('The relation has been saved!')));
4✔
852
    }
853

854
    /**
855
     * Handle the saved form event.
856
     */
857
    public function saved(Request $request, Model $model): void
4✔
858
    {
859
        //
860
    }
4✔
861

862
    /**
863
     * Resolve the resource model for a bound value.
864
     */
865
    public function resolveRouteBinding(Request $request, string $id): Model
4✔
866
    {
867
        $parent = $request->route()->parentOfParameter($this->getRouteKeyName());
4✔
868

869
        return $this->getRelation($parent)->findOrFail($id);
4✔
870
    }
871

872
    /**
873
     * Register the routes using the given router.
874
     */
875
    public function registerRoutes(Request $request, Router $router): void
196✔
876
    {
877
        $this->__registerRoutes($request, $router);
196✔
878

879
        $router->prefix($this->getUriKey())->group(function (Router $router) use ($request): void {
196✔
880
            $this->resolveActions($request)->registerRoutes($request, $router);
196✔
881

882
            $router->prefix("{{$this->getRouteKeyName()}}")->group(function (Router $router) use ($request): void {
196✔
883
                $this->resolveFields($request)->registerRoutes($request, $router);
196✔
884
            });
196✔
885
        });
196✔
886

887
        $this->registerRouteConstraints($request, $router);
196✔
888

889
        $this->routesRegistered($request);
196✔
890
    }
891

892
    /**
893
     * Get the route middleware for the registered routes.
894
     */
895
    public function getRouteMiddleware(): array
196✔
896
    {
897
        return [
196✔
898
            sprintf('%s:field,resourceModel,%s', Authorize::class, $this->getRouteKeyName()),
196✔
899
        ];
196✔
900
    }
901

902
    /**
903
     * Handle the routes registered event.
904
     */
905
    protected function routesRegistered(Request $request): void
196✔
906
    {
907
        $uri = $this->getUri();
196✔
908
        $routeKeyName = $this->getRouteKeyName();
196✔
909

910
        Root::instance()->breadcrumbs->patterns([
196✔
911
            $this->getUri() => $this->label,
196✔
912
            sprintf('%s/create', $uri) => __('Add'),
196✔
913
            sprintf('%s/{%s}', $uri, $routeKeyName) => fn (Request $request): string => $this->resolveDisplay($request->route($routeKeyName)),
196✔
914
            sprintf('%s/{%s}/edit', $uri, $routeKeyName) => __('Edit'),
196✔
915
        ]);
196✔
916
    }
917

918
    /**
919
     * Handle the route matched event.
920
     */
921
    public function routeMatched(RouteMatched $event): void
14✔
922
    {
923
        $this->__routeMatched($event);
14✔
924

925
        $controller = $event->route->getController();
14✔
926

927
        $controller->middleware($this->getRouteMiddleware());
14✔
928

929
        $middleware = function (Request $request, Closure $next) use ($event): mixed {
14✔
930
            $ability = match ($event->route->getActionMethod()) {
14✔
931
                'index' => 'viewAny',
2✔
932
                'show' => 'view',
1✔
933
                'create' => 'create',
1✔
934
                'store' => 'create',
2✔
935
                'edit' => 'update',
1✔
936
                'update' => 'update',
2✔
937
                'destroy' => 'delete',
2✔
938
                default => $event->route->getActionMethod(),
3✔
939
            };
14✔
940

941
            Gate::allowIf($this->resolveAbility(
14✔
942
                $ability, $request, $request->route('resourceModel'), $request->route($this->getRouteParameterName())
14✔
943
            ));
14✔
944

945
            return $next($request);
14✔
946
        };
14✔
947

948
        $controller->middleware([$middleware]);
14✔
949
    }
950

951
    /**
952
     * Resolve the ability.
953
     */
954
    public function resolveAbility(string $ability, Request $request, Model $model, ...$arguments): bool
15✔
955
    {
956
        $policy = Gate::getPolicyFor($model);
15✔
957

958
        $ability .= Str::of($this->getModelAttribute())->singular()->studly()->value();
15✔
959

960
        return is_null($policy)
15✔
961
            || ! is_callable([$policy, $ability])
15✔
962
            || Gate::allows($ability, [$model, ...$arguments]);
15✔
963
    }
964

965
    /**
966
     * Map the relation abilities.
967
     */
968
    public function mapRelationAbilities(Request $request, Model $model): array
6✔
969
    {
970
        return [
6✔
971
            'viewAny' => $this->resolveAbility('viewAny', $request, $model),
6✔
972
            'create' => $this->resolveAbility('create', $request, $model),
6✔
973
        ];
6✔
974
    }
975

976
    /**
977
     * Map the related model abilities.
978
     */
979
    public function mapRelatedAbilities(Request $request, Model $model, Model $related): array
4✔
980
    {
981
        return [
4✔
982
            'view' => $this->resolveAbility('view', $request, $model, $related),
4✔
983
            'update' => $this->resolveAbility('update', $request, $model, $related),
4✔
984
            'restore' => $this->resolveAbility('restore', $request, $model, $related),
4✔
985
            'delete' => $this->resolveAbility('delete', $request, $model, $related),
4✔
986
            'forceDelete' => $this->resolveAbility('forceDelete', $request, $model, $related),
4✔
987
        ];
4✔
988
    }
989

990
    /**
991
     * Get the relation controller class.
992
     */
993
    public function getRelationController(): string
196✔
994
    {
995
        return RelationController::class;
196✔
996
    }
997

998
    /**
999
     * Register the routes.
1000
     */
1001
    public function routes(Router $router): void
196✔
1002
    {
1003
        if ($this->isAsync()) {
196✔
1004
            $router->get('/search', AsyncRelationController::class);
196✔
1005
        }
1006

1007
        if ($this->isSubResource()) {
196✔
1008
            $router->get('/', [$this->getRelationController(), 'index']);
196✔
1009
            $router->get('/create', [$this->getRelationController(), 'create']);
196✔
1010
            $router->get("/{{$this->getRouteKeyName()}}", [$this->getRelationController(), 'show']);
196✔
1011
            $router->post('/', [$this->getRelationController(), 'store']);
196✔
1012
            $router->get("/{{$this->getRouteKeyName()}}/edit", [$this->getRelationController(), 'edit']);
196✔
1013
            $router->patch("/{{$this->getRouteKeyName()}}", [$this->getRelationController(), 'update']);
196✔
1014
            $router->delete("/{{$this->getRouteKeyName()}}", [$this->getRelationController(), 'destroy']);
196✔
1015
        }
1016
    }
1017

1018
    /**
1019
     * Register the route constraints.
1020
     */
1021
    public function registerRouteConstraints(Request $request, Router $router): void
196✔
1022
    {
1023
        $router->bind($this->getRouteKeyName(), fn (string $id, Route $route): Model => match ($id) {
196✔
1024
            'create' => $this->getRelation($route->parentOfParameter($this->getRouteKeyName()))->make(),
6✔
1025
            default => $this->resolveRouteBinding($router->getCurrentRequest(), $id),
6✔
1026
        });
196✔
1027
    }
1028

1029
    /**
1030
     * Parse the given query string.
1031
     */
1032
    public function parseQueryString(string $url): array
6✔
1033
    {
1034
        $query = parse_url($url, PHP_URL_QUERY) ?: '';
6✔
1035

1036
        parse_str($query, $result);
6✔
1037

1038
        return array_filter($result, fn (string $key): bool => str_starts_with($key, $this->getRequestKey()), ARRAY_FILTER_USE_KEY);
6✔
1039
    }
1040

1041
    /**
1042
     * Get the option representation of the model and the related model.
1043
     */
1044
    public function toOption(Request $request, Model $model, Model $related): array
2✔
1045
    {
1046
        $value = $this->resolveValue($request, $model);
2✔
1047

1048
        $option = $this->newOption($related, $this->resolveDisplay($related))
2✔
1049
            ->selected(! is_null($value) && ($value instanceof Model ? $value->is($related) : $value->contains($related)))
2✔
1050
            ->setAttribute('id', sprintf('%s-%s', $this->getModelAttribute(), $related->getKey()))
2✔
1051
            ->setAttribute('readonly', $this->getAttribute('readonly', false))
2✔
1052
            ->setAttribute('disabled', $this->getAttribute('disabled', false))
2✔
1053
            ->setAttribute('name', match (true) {
2✔
1054
                $value instanceof Collection => sprintf('%s[]', $this->getModelAttribute()),
2✔
1055
                default => $this->getModelAttribute(),
2✔
1056
            })
2✔
1057
            ->toArray();
2✔
1058

1059
        return array_merge($option, [
2✔
1060
            'html' => match (true) {
2✔
1061
                $this->isAsync() => View::make('root::fields.relation-option', $option)->render(),
2✔
1062
                default => '',
2✔
1063
            },
2✔
1064
        ]);
2✔
1065
    }
1066

1067
    /**
1068
     * {@inheritdoc}
1069
     */
1070
    public function toInput(Request $request, Model $model): array
2✔
1071
    {
1072
        return array_merge(parent::toInput($request, $model), [
2✔
1073
            'async' => $this->isAsync(),
2✔
1074
            'config' => [
2✔
1075
                'multiple' => $this->resolveValue($request, $model) instanceof Collection,
2✔
1076
            ],
2✔
1077
            'modalKey' => $this->getModalKey(),
2✔
1078
            'nullable' => $this->isNullable(),
2✔
1079
            'options' => $this->resolveOptions($request, $model),
2✔
1080
            'url' => $this->isAsync() ? sprintf('%s/search', $this->modelUrl($model)) : null,
2✔
1081
            'filters' => $this->isAsync()
2✔
1082
                ? $this->resolveFilters($request)
2✔
1083
                    ->authorized($request)
2✔
1084
                    ->renderable()
2✔
1085
                    ->map(static function (RenderableFilter $filter) use ($request, $model): array {
2✔
1086
                        return $filter->toField()->toInput($request, $model);
2✔
1087
                    })
2✔
1088
                    ->all()
2✔
1089
                : [],
1090
        ]);
2✔
1091
    }
1092

1093
    /**
1094
     * Get the sub resource representation of the relation
1095
     */
1096
    public function toSubResource(Request $request, Model $model): array
6✔
1097
    {
1098
        return array_merge($this->toArray(), [
6✔
1099
            'key' => $this->modelAttribute,
6✔
1100
            'baseUrl' => $this->modelUrl($model),
6✔
1101
            'url' => URL::query($this->modelUrl($model), $this->parseQueryString($request->fullUrl())),
6✔
1102
            'modelName' => $this->getRelatedName(),
6✔
1103
            'abilities' => $this->mapRelationAbilities($request, $model),
6✔
1104
        ]);
6✔
1105
    }
1106

1107
    /**
1108
     * Get the index representation of the relation.
1109
     */
1110
    public function toIndex(Request $request, Model $model): array
2✔
1111
    {
1112
        return array_merge($this->toSubResource($request, $model), [
2✔
1113
            'template' => $request->isTurboFrameRequest() ? 'root::resources.relation' : 'root::resources.index',
2✔
1114
            'title' => $this->label,
2✔
1115
            'model' => $this->getRelation($model)->make()->setRelation('related', $model),
2✔
1116
            'standaloneActions' => $this->resolveActions($request)
2✔
1117
                ->authorized($request, $model)
2✔
1118
                ->standalone()
2✔
1119
                ->mapToForms($request, $model),
2✔
1120
            'actions' => $this->resolveActions($request)
2✔
1121
                ->authorized($request, $model)
2✔
1122
                ->visible('index')
2✔
1123
                ->standalone(false)
2✔
1124
                ->mapToForms($request, $model),
2✔
1125
            'data' => $this->paginate($request, $model)->through(fn (Model $related): array => $this->mapRelated($request, $model, $related)),
2✔
1126
            'perPageOptions' => $this->getPerPageOptions(),
2✔
1127
            'perPageKey' => $this->getPerPageKey(),
2✔
1128
            'sortKey' => $this->getSortKey(),
2✔
1129
            'filters' => $this->resolveFilters($request)
2✔
1130
                ->authorized($request)
2✔
1131
                ->renderable()
2✔
1132
                ->map(static fn (RenderableFilter $filter): array => $filter->toField()->toInput($request, $model))
2✔
1133
                ->all(),
2✔
1134
            'activeFilters' => $this->resolveFilters($request)->active($request)->count(),
2✔
1135
            'parentUrl' => URL::query($request->server('HTTP_REFERER'), $request->query()),
2✔
1136
        ]);
2✔
1137
    }
1138

1139
    /**
1140
     * Get the create representation of the resource.
1141
     */
1142
    public function toCreate(Request $request, Model $model): array
1✔
1143
    {
1144
        return array_merge($this->toSubResource($request, $model), [
1✔
1145
            'template' => 'root::resources.form',
1✔
1146
            'title' => __('Create :model', ['model' => $this->getRelatedName()]),
1✔
1147
            'model' => $related = $this->getRelation($model)->make()->setRelation('related', $model),
1✔
1148
            'action' => $this->modelUrl($model),
1✔
1149
            'uploads' => $this->hasFileField($request),
1✔
1150
            'method' => 'POST',
1✔
1151
            'fields' => $this->resolveFields($request)
1✔
1152
                ->subResource(false)
1✔
1153
                ->authorized($request, $related)
1✔
1154
                ->visible('create')
1✔
1155
                ->mapToInputs($request, $related),
1✔
1156
        ]);
1✔
1157
    }
1158

1159
    /**
1160
     * Get the edit representation of the
1161
     */
1162
    public function toShow(Request $request, Model $model, Model $related): array
1✔
1163
    {
1164
        return array_merge($this->toSubResource($request, $model), [
1✔
1165
            'template' => 'root::resources.show',
1✔
1166
            'title' => $this->resolveDisplay($related),
1✔
1167
            'model' => $related->setRelation('related', $model),
1✔
1168
            'action' => $this->relatedUrl($related),
1✔
1169
            'fields' => $this->resolveFields($request)
1✔
1170
                ->subResource(false)
1✔
1171
                ->authorized($request, $related)
1✔
1172
                ->visible('show')
1✔
1173
                ->mapToDisplay($request, $related),
1✔
1174
            'actions' => $this->resolveActions($request)
1✔
1175
                ->authorized($request, $related)
1✔
1176
                ->visible('show')
1✔
1177
                ->standalone(false)
1✔
1178
                ->mapToForms($request, $related),
1✔
1179
            'abilities' => array_merge(
1✔
1180
                $this->mapRelationAbilities($request, $model),
1✔
1181
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1182
            ),
1✔
1183
        ]);
1✔
1184
    }
1185

1186
    /**
1187
     * Get the edit representation of the
1188
     */
1189
    public function toEdit(Request $request, Model $model, Model $related): array
1✔
1190
    {
1191
        return array_merge($this->toSubResource($request, $model), [
1✔
1192
            'template' => 'root::resources.form',
1✔
1193
            'title' => __('Edit :model', ['model' => $this->resolveDisplay($related)]),
1✔
1194
            'model' => $related->setRelation('related', $model),
1✔
1195
            'action' => $this->relatedUrl($related),
1✔
1196
            'method' => 'PATCH',
1✔
1197
            'uploads' => $this->hasFileField($request),
1✔
1198
            'fields' => $this->resolveFields($request)
1✔
1199
                ->subResource(false)
1✔
1200
                ->authorized($request, $related)
1✔
1201
                ->visible('update')
1✔
1202
                ->mapToInputs($request, $related),
1✔
1203
            'abilities' => array_merge(
1✔
1204
                $this->mapRelationAbilities($request, $model),
1✔
1205
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1206
            ),
1✔
1207
        ]);
1✔
1208
    }
1209

1210
    /**
1211
     * Get the filter representation of the field.
1212
     */
1213
    public function toFilter(): Filter
×
1214
    {
1215
        return new class($this) extends RenderableFilter
×
UNCOV
1216
        {
×
1217
            protected Relation $field;
1218

1219
            public function __construct(Relation $field)
1220
            {
UNCOV
1221
                parent::__construct($field->getModelAttribute());
×
1222

UNCOV
1223
                $this->field = $field;
×
1224
            }
1225

1226
            public function apply(Request $request, Builder $query, mixed $value): Builder
1227
            {
1228
                return $this->field->resolveFilterQuery($request, $query, $value);
×
1229
            }
1230

1231
            public function toField(): Field
1232
            {
1233
                return Select::make($this->field->getLabel(), $this->getRequestKey())
×
1234
                    ->value(fn (Request $request): mixed => $this->getValue($request))
×
UNCOV
1235
                    ->nullable()
×
1236
                    ->options(function (Request $request, Model $model): array {
×
UNCOV
1237
                        return array_column(
×
UNCOV
1238
                            $this->field->resolveOptions($request, $model),
×
UNCOV
1239
                            'label',
×
UNCOV
1240
                            'value',
×
UNCOV
1241
                        );
×
UNCOV
1242
                    });
×
1243
            }
UNCOV
1244
        };
×
1245
    }
1246
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc