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

conedevelopment / root / 20606811462

30 Dec 2025 10:01PM UTC coverage: 76.05% (-0.1%) from 76.188%
20606811462

push

github

iamgergo
async relation fileds

96 of 124 new or added lines in 6 files covered. (77.42%)

23 existing lines in 2 files now uncovered.

3439 of 4522 relevant lines covered (76.05%)

33.72 hits per line

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

83.43
/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)
197✔
143
    {
144
        parent::__construct($label, $modelAttribute);
197✔
145

146
        $this->relation = $relation ?: $this->getModelAttribute();
197✔
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
24✔
163
    {
164
        if ($this->relation instanceof Closure) {
24✔
165
            return call_user_func_array($this->relation, [$model]);
×
166
        }
167

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

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

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

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

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

204
        return $this;
197✔
205
    }
206

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

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

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

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

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

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

244
        return $this;
197✔
245
    }
246

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

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

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

266
        return $this;
197✔
267
    }
268

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

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

284
        return $this;
197✔
285
    }
286

287
    /**
288
     * Determine if the field is nullable.
289
     */
290
    public function isNullable(): bool
3✔
291
    {
292
        return $this->nullable;
3✔
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
13✔
378
    {
379
        if ($this->isSubResource()) {
13✔
380
            return false;
10✔
381
        }
382

383
        return parent::isSortable();
9✔
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
197✔
400
    {
401
        return false;
197✔
402
    }
403

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

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

415
        return $this;
197✔
416
    }
417

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

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

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

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

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

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

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

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

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

468
        return parent::resolveFormat($request, $model);
6✔
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
2✔
491
    {
492
        $fields = $this->resolveFields($request)->authorized($request);
2✔
493

494
        $searchables = match (true) {
2✔
495
            $this->isAsync() => new Fields(array_map(
2✔
496
                fn (string $column): Hidden => Hidden::make($this->getRelationName(), $column)->searchable(),
2✔
497
                $this->getSearchableColumns()
2✔
498
            )),
2✔
499
            default => $fields->searchable(),
2✔
500
        };
2✔
501

502
        $sortables = $fields->sortable();
2✔
503

504
        $filterables = $fields->filterable();
2✔
505

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

513
    /**
514
     * Handle the callback for the field resolution.
515
     */
516
    protected function resolveField(Request $request, Field $field): void
197✔
517
    {
518
        if ($this->isSubResource()) {
197✔
519
            $field->setAttribute('form', $this->modelAttribute);
197✔
520
            $field->resolveErrorsUsing(fn (Request $request): MessageBag => $this->errors($request));
197✔
521
        } else {
522
            $field->setAttribute('form', $this->getAttribute('form'));
×
523
            $field->resolveErrorsUsing($this->errorsResolver);
×
524
        }
525

526
        if ($field instanceof Relation) {
197✔
527
            $field->resolveRouteKeyNameUsing(
197✔
528
                fn (): string => Str::of($field->getRelationName())->singular()->ucfirst()->prepend($this->getRouteKeyName())->value()
197✔
529
            );
197✔
530
        }
531
    }
532

533
    /**
534
     * Handle the callback for the field resolution.
535
     */
536
    protected function resolveAction(Request $request, Action $action): void
×
537
    {
538
        $action->withQuery(function (Request $request): Builder {
×
539
            $model = $request->route('resourceModel');
×
540

541
            return $this->resolveFilters($request)->apply($request, $this->getRelation($model)->getQuery());
×
542
        });
×
543
    }
544

545
    /**
546
     * Handle the callback for the filter resolution.
547
     */
548
    protected function resolveFilter(Request $request, Filter $filter): void
3✔
549
    {
550
        $filter->setKey(sprintf('%s_%s', $this->getRequestKey(), $filter->getKey()));
3✔
551
    }
552

553
    /**
554
     * Set the query resolver.
555
     */
556
    public function withRelatableQuery(Closure $callback): static
197✔
557
    {
558
        $this->queryResolver = $callback;
197✔
559

560
        return $this;
197✔
561
    }
562

563
    /**
564
     * Resolve the related model's eloquent query.
565
     */
566
    public function resolveRelatableQuery(Request $request, Model $model): Builder
11✔
567
    {
568
        $query = $this->getRelation($model)
11✔
569
            ->getRelated()
11✔
570
            ->newQuery()
11✔
571
            ->with($this->with)
11✔
572
            ->withCount($this->withCount);
11✔
573

574
        foreach (static::$scopes[static::class] ?? [] as $scope) {
11✔
575
            $query = call_user_func_array($scope, [$request, $query, $model]);
×
576
        }
577

578
        return $query->when(
11✔
579
            ! is_null($this->queryResolver),
11✔
580
            function (Builder $query) use ($request, $model): Builder {
11✔
NEW
581
                return call_user_func_array($this->queryResolver, [$request, $query, $model]);
×
582
            }
11✔
583
        );
11✔
584
    }
585

586
    /**
587
     * Aggregate relation values.
588
     */
589
    public function aggregate(string $fn = 'count', string $column = '*'): static
×
590
    {
591
        $this->aggregateResolver = function (Request $request, Builder $query) use ($fn, $column): Builder {
592
            $this->setModelAttribute(sprintf(
×
593
                '%s_%s%s', $this->getRelationName(),
×
594
                $fn,
×
595
                $column === '*' ? '' : sprintf('_%s', $column)
×
596
            ));
×
597

598
            $this->aggregated = true;
×
599

600
            return $query->withAggregate($this->getRelationName(), $column, $fn);
×
601
        };
602

603
        return $this;
×
604
    }
605

606
    /**
607
     * Resolve the aggregate query.
608
     */
609
    public function resolveAggregate(Request $request, Builder $query): Builder
1✔
610
    {
611
        if (! is_null($this->aggregateResolver)) {
1✔
612
            $query = call_user_func_array($this->aggregateResolver, [$request, $query]);
×
613
        }
614

615
        return $query;
1✔
616
    }
617

618
    /**
619
     * Set the group resolver attribute.
620
     */
621
    public function groupOptionsBy(string|Closure $key): static
×
622
    {
623
        $this->groupResolver = $key;
×
624

625
        return $this;
×
626
    }
627

628
    /**
629
     * Resolve the options for the field.
630
     */
631
    public function resolveOptions(Request $request, Model $model): array
3✔
632
    {
633
        $options = match (true) {
3✔
634
            $this->isAsync() => Collection::wrap($this->resolveValue($request, $model)),
3✔
635
            default => $this->resolveRelatableQuery($request, $model)->get(),
3✔
636
        };
3✔
637

638
        return $options->when(
3✔
639
            ! is_null($this->groupResolver),
3✔
640
            function (Collection $collection) use ($request, $model): Collection {
3✔
NEW
641
                return $collection->groupBy($this->groupResolver)
×
NEW
642
                    ->map(function (Collection $group, string $key) use ($request, $model): array {
×
NEW
643
                        return [
×
NEW
644
                            'label' => $key,
×
NEW
645
                            'options' => $group->map(function (Model $related) use ($request, $model): array {
×
NEW
646
                                return $this->toOption($request, $model, $related);
×
NEW
647
                            })->all(),
×
NEW
648
                        ];
×
NEW
649
                    });
×
650
            },
3✔
651
            function (Collection $collection) use ($request, $model): Collection {
3✔
652
                return $collection->map(function (Model $related) use ($request, $model): array {
3✔
653
                    return $this->toOption($request, $model, $related);
3✔
654
                });
3✔
655
            }
3✔
656
        )->toArray();
3✔
657
    }
658

659
    /**
660
     * Make a new option instance.
661
     */
662
    public function newOption(Model $related, string $label): Option
2✔
663
    {
664
        return new Option($related->getKey(), $label);
2✔
665
    }
666

667
    /**
668
     * Get the per page options.
669
     */
670
    public function getPerPageOptions(): array
2✔
671
    {
672
        return [5, 10, 15, 25];
2✔
673
    }
674

675
    /**
676
     * Get the per page key.
677
     */
678
    public function getPerPageKey(): string
2✔
679
    {
680
        return sprintf('%s_per_page', $this->getRequestKey());
2✔
681
    }
682

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

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

699
    /**
700
     * The relations to be eagerload.
701
     */
702
    public function with(array $with): static
×
703
    {
UNCOV
704
        $this->with = $with;
×
705

UNCOV
706
        return $this;
×
707
    }
708

709
    /**
710
     * The relation counts to be eagerload.
711
     */
712
    public function withCount(array $withCount): static
×
713
    {
UNCOV
714
        $this->withCount = $withCount;
×
715

UNCOV
716
        return $this;
×
717
    }
718

719
    /**
720
     * Paginate the given query.
721
     */
722
    public function paginate(Request $request, Model $model): LengthAwarePaginator
2✔
723
    {
724
        $relation = $this->getRelation($model);
2✔
725

726
        $this->resolveFilters($request)->apply($request, $relation->getQuery());
2✔
727

728
        return $relation
2✔
729
            ->with($this->with)
2✔
730
            ->withCount($this->withCount)
2✔
731
            ->latest()
2✔
732
            ->paginate(
2✔
733
                perPage: $request->input(
2✔
734
                    $this->getPerPageKey(),
2✔
735
                    $request->isTurboFrameRequest() ? 5 : $relation->getRelated()->getPerPage()
2✔
736
                ),
2✔
737
                pageName: $this->getPageKey()
2✔
738
            )->withQueryString();
2✔
739
    }
740

741
    /**
742
     * Paginate the relatable models.
743
     */
744
    public function paginateRelatable(Request $request, Model $model): LengthAwarePaginator
1✔
745
    {
746
        return $this->resolveFilters($request)
1✔
747
            ->apply($request, $this->resolveRelatableQuery($request, $model))
1✔
748
            ->paginate($request->input('per_page'))
1✔
749
            ->withQueryString()
1✔
750
            ->through(function (Model $related) use ($request, $model): array {
1✔
NEW
751
                return $this->toOption($request, $model, $related);
×
752
            });
1✔
753
    }
754

755
    /**
756
     * Map a related model.
757
     */
758
    public function mapRelated(Request $request, Model $model, Model $related): array
1✔
759
    {
760
        return [
1✔
761
            'id' => $related->getKey(),
1✔
762
            'model' => $related->setRelation('related', $model),
1✔
763
            'url' => $this->relatedUrl($related),
1✔
764
            'fields' => $this->resolveFields($request)
1✔
765
                ->subResource(false)
1✔
766
                ->authorized($request, $related)
1✔
767
                ->visible('index')
1✔
768
                ->mapToDisplay($request, $related),
1✔
769
            'abilities' => $this->mapRelatedAbilities($request, $model, $related),
1✔
770
        ];
1✔
771
    }
772

773
    /**
774
     * Get the model URL.
775
     */
776
    public function modelUrl(Model $model): string
14✔
777
    {
778
        return str_replace('{resourceModel}', $model->exists ? (string) $model->getKey() : 'create', $this->getUri());
14✔
779
    }
780

781
    /**
782
     * Get the related URL.
783
     */
784
    public function relatedUrl(Model $related): string
5✔
785
    {
786
        return sprintf('%s/%s', $this->modelUrl($related->getRelationValue('related')), $related->getKey());
5✔
787
    }
788

789
    /**
790
     * {@inheritdoc}
791
     */
792
    public function persist(Request $request, Model $model, mixed $value): void
7✔
793
    {
794
        if ($this->isSubResource()) {
7✔
795
            $this->resolveFields($request)
4✔
796
                ->authorized($request, $model)
4✔
797
                ->visible($request->isMethod('POST') ? 'create' : 'update')
4✔
798
                ->persist($request, $model);
4✔
799
        } else {
800
            parent::persist($request, $model, $value);
5✔
801
        }
802
    }
803

804
    /**
805
     * Handle the request.
806
     */
807
    public function handleFormRequest(Request $request, Model $model): Response
4✔
808
    {
809
        $this->validateFormRequest($request, $model);
4✔
810

811
        try {
812
            return DB::transaction(function () use ($request, $model): Response {
4✔
813
                $this->persist($request, $model, $this->getValueForHydrate($request));
4✔
814

815
                $model->save();
4✔
816

817
                if (in_array(HasRootEvents::class, class_uses_recursive($model))) {
4✔
UNCOV
818
                    $model->recordRootEvent(
×
UNCOV
819
                        $model->wasRecentlyCreated ? 'Created' : 'Updated',
×
UNCOV
820
                        $request->user()
×
UNCOV
821
                    );
×
822
                }
823

824
                $this->saved($request, $model);
4✔
825

826
                return $this->formResponse($request, $model);
4✔
827
            });
4✔
UNCOV
828
        } catch (Throwable $exception) {
×
829
            report($exception);
×
830

UNCOV
831
            DB::rollBack();
×
832

UNCOV
833
            throw new SaveFormDataException($exception->getMessage());
×
834
        }
835
    }
836

837
    /**
838
     * Make a form response.
839
     */
840
    public function formResponse(Request $request, Model $model): Response
4✔
841
    {
842
        return Redirect::to($this->relatedUrl($model))
4✔
843
            ->with('alerts.relation-saved', Alert::success(__('The relation has been saved!')));
4✔
844
    }
845

846
    /**
847
     * Handle the saved form event.
848
     */
849
    public function saved(Request $request, Model $model): void
4✔
850
    {
851
        //
852
    }
4✔
853

854
    /**
855
     * Resolve the resource model for a bound value.
856
     */
857
    public function resolveRouteBinding(Request $request, string $id): Model
4✔
858
    {
859
        $parent = $request->route()->parentOfParameter($this->getRouteKeyName());
4✔
860

861
        return $this->getRelation($parent)->findOrFail($id);
4✔
862
    }
863

864
    /**
865
     * Register the routes using the given router.
866
     */
867
    public function registerRoutes(Request $request, Router $router): void
197✔
868
    {
869
        $this->__registerRoutes($request, $router);
197✔
870

871
        $router->prefix($this->getUriKey())->group(function (Router $router) use ($request): void {
197✔
872
            $this->resolveActions($request)->registerRoutes($request, $router);
197✔
873

874
            $router->prefix("{{$this->getRouteKeyName()}}")->group(function (Router $router) use ($request): void {
197✔
875
                $this->resolveFields($request)->registerRoutes($request, $router);
197✔
876
            });
197✔
877
        });
197✔
878

879
        $this->registerRouteConstraints($request, $router);
197✔
880

881
        $this->routesRegistered($request);
197✔
882
    }
883

884
    /**
885
     * Get the route middleware for the registered routes.
886
     */
887
    public function getRouteMiddleware(): array
197✔
888
    {
889
        return [
197✔
890
            sprintf('%s:field,resourceModel,%s', Authorize::class, $this->getRouteKeyName()),
197✔
891
        ];
197✔
892
    }
893

894
    /**
895
     * Handle the routes registered event.
896
     */
897
    protected function routesRegistered(Request $request): void
197✔
898
    {
899
        $uri = $this->getUri();
197✔
900
        $routeKeyName = $this->getRouteKeyName();
197✔
901

902
        Root::instance()->breadcrumbs->patterns([
197✔
903
            $this->getUri() => $this->label,
197✔
904
            sprintf('%s/create', $uri) => __('Add'),
197✔
905
            sprintf('%s/{%s}', $uri, $routeKeyName) => fn (Request $request): string => $this->resolveDisplay($request->route($routeKeyName)),
197✔
906
            sprintf('%s/{%s}/edit', $uri, $routeKeyName) => __('Edit'),
197✔
907
        ]);
197✔
908
    }
909

910
    /**
911
     * Handle the route matched event.
912
     */
913
    public function routeMatched(RouteMatched $event): void
15✔
914
    {
915
        $this->__routeMatched($event);
15✔
916

917
        $controller = $event->route->getController();
15✔
918

919
        $controller->middleware($this->getRouteMiddleware());
15✔
920

921
        $middleware = function (Request $request, Closure $next) use ($event): mixed {
15✔
922
            $ability = match ($event->route->getActionMethod()) {
15✔
923
                'index' => 'viewAny',
2✔
924
                'show' => 'view',
1✔
925
                'create' => 'create',
1✔
926
                'store' => 'create',
2✔
927
                'edit' => 'update',
1✔
928
                'update' => 'update',
2✔
929
                'destroy' => 'delete',
2✔
930
                default => $event->route->getActionMethod(),
4✔
931
            };
15✔
932

933
            Gate::allowIf($this->resolveAbility(
15✔
934
                $ability, $request, $request->route('resourceModel'), $request->route($this->getRouteParameterName())
15✔
935
            ));
15✔
936

937
            return $next($request);
15✔
938
        };
15✔
939

940
        $controller->middleware([$middleware]);
15✔
941
    }
942

943
    /**
944
     * Resolve the ability.
945
     */
946
    public function resolveAbility(string $ability, Request $request, Model $model, ...$arguments): bool
16✔
947
    {
948
        $policy = Gate::getPolicyFor($model);
16✔
949

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

952
        return is_null($policy)
16✔
953
            || ! is_callable([$policy, $ability])
16✔
954
            || Gate::allows($ability, [$model, ...$arguments]);
16✔
955
    }
956

957
    /**
958
     * Map the relation abilities.
959
     */
960
    public function mapRelationAbilities(Request $request, Model $model): array
6✔
961
    {
962
        return [
6✔
963
            'viewAny' => $this->resolveAbility('viewAny', $request, $model),
6✔
964
            'create' => $this->resolveAbility('create', $request, $model),
6✔
965
        ];
6✔
966
    }
967

968
    /**
969
     * Map the related model abilities.
970
     */
971
    public function mapRelatedAbilities(Request $request, Model $model, Model $related): array
4✔
972
    {
973
        return [
4✔
974
            'view' => $this->resolveAbility('view', $request, $model, $related),
4✔
975
            'update' => $this->resolveAbility('update', $request, $model, $related),
4✔
976
            'restore' => $this->resolveAbility('restore', $request, $model, $related),
4✔
977
            'delete' => $this->resolveAbility('delete', $request, $model, $related),
4✔
978
            'forceDelete' => $this->resolveAbility('forceDelete', $request, $model, $related),
4✔
979
        ];
4✔
980
    }
981

982
    /**
983
     * Get the relation controller class.
984
     */
985
    public function getRelationController(): string
197✔
986
    {
987
        return RelationController::class;
197✔
988
    }
989

990
    /**
991
     * Register the routes.
992
     */
993
    public function routes(Router $router): void
197✔
994
    {
995
        if ($this->isAsync()) {
197✔
NEW
996
            $router->get('/search', AsyncRelationController::class);
×
997
        }
998

999
        if ($this->isSubResource()) {
197✔
1000
            $router->get('/', [$this->getRelationController(), 'index']);
197✔
1001
            $router->get('/create', [$this->getRelationController(), 'create']);
197✔
1002
            $router->get("/{{$this->getRouteKeyName()}}", [$this->getRelationController(), 'show']);
197✔
1003
            $router->post('/', [$this->getRelationController(), 'store']);
197✔
1004
            $router->get("/{{$this->getRouteKeyName()}}/edit", [$this->getRelationController(), 'edit']);
197✔
1005
            $router->patch("/{{$this->getRouteKeyName()}}", [$this->getRelationController(), 'update']);
197✔
1006
            $router->delete("/{{$this->getRouteKeyName()}}", [$this->getRelationController(), 'destroy']);
197✔
1007
        }
1008
    }
1009

1010
    /**
1011
     * Register the route constraints.
1012
     */
1013
    public function registerRouteConstraints(Request $request, Router $router): void
197✔
1014
    {
1015
        $router->bind($this->getRouteKeyName(), fn (string $id, Route $route): Model => match ($id) {
197✔
1016
            'create' => $this->getRelation($route->parentOfParameter($this->getRouteKeyName()))->make(),
6✔
1017
            default => $this->resolveRouteBinding($router->getCurrentRequest(), $id),
6✔
1018
        });
197✔
1019
    }
1020

1021
    /**
1022
     * Parse the given query string.
1023
     */
1024
    public function parseQueryString(string $url): array
6✔
1025
    {
1026
        $query = parse_url($url, PHP_URL_QUERY) ?: '';
6✔
1027

1028
        parse_str($query, $result);
6✔
1029

1030
        return array_filter($result, fn (string $key): bool => str_starts_with($key, $this->getRequestKey()), ARRAY_FILTER_USE_KEY);
6✔
1031
    }
1032

1033
    /**
1034
     * Get the option representation of the model and the related model.
1035
     */
1036
    public function toOption(Request $request, Model $model, Model $related): array
5✔
1037
    {
1038
        $value = $this->resolveValue($request, $model);
5✔
1039

1040
        $option = $this->newOption($related, $this->resolveDisplay($related))
5✔
1041
            ->selected(! is_null($value) && ($value instanceof Model ? $value->is($related) : $value->contains($related)))
5✔
1042
            ->setAttribute('id', sprintf('%s-%s', $this->getModelAttribute(), $related->getKey()))
5✔
1043
            ->setAttribute('readonly', $this->getAttribute('readonly', false))
5✔
1044
            ->setAttribute('disabled', $this->getAttribute('disabled', false))
5✔
1045
            ->setAttribute('name', match (true) {
5✔
1046
                $value instanceof Collection => sprintf('%s[]', $this->getModelAttribute()),
5✔
1047
                default => $this->getModelAttribute(),
5✔
1048
            })
5✔
1049
            ->toArray();
5✔
1050

1051
        return array_merge($option, [
5✔
1052
            'html' => match (true) {
5✔
1053
                $this->isAsync() => View::make('root::fields.relation-option', $option)->render(),
5✔
1054
                default => '',
5✔
1055
            },
5✔
1056
        ]);
5✔
1057
    }
1058

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

1085
    /**
1086
     * Get the sub resource representation of the relation
1087
     */
1088
    public function toSubResource(Request $request, Model $model): array
6✔
1089
    {
1090
        return array_merge($this->toArray(), [
6✔
1091
            'key' => $this->modelAttribute,
6✔
1092
            'baseUrl' => $this->modelUrl($model),
6✔
1093
            'url' => URL::query($this->modelUrl($model), $this->parseQueryString($request->fullUrl())),
6✔
1094
            'modelName' => $this->getRelatedName(),
6✔
1095
            'abilities' => $this->mapRelationAbilities($request, $model),
6✔
1096
        ]);
6✔
1097
    }
1098

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

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

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

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

1202
    /**
1203
     * Get the filter representation of the field.
1204
     */
UNCOV
1205
    public function toFilter(): Filter
×
1206
    {
UNCOV
1207
        return new class($this) extends RenderableFilter
×
UNCOV
1208
        {
×
1209
            protected Relation $field;
1210

1211
            public function __construct(Relation $field)
1212
            {
UNCOV
1213
                parent::__construct($field->getModelAttribute());
×
1214

UNCOV
1215
                $this->field = $field;
×
1216
            }
1217

1218
            public function apply(Request $request, Builder $query, mixed $value): Builder
1219
            {
UNCOV
1220
                return $this->field->resolveFilterQuery($request, $query, $value);
×
1221
            }
1222

1223
            public function toField(): Field
1224
            {
1225
                return Select::make($this->field->getLabel(), $this->getRequestKey())
×
1226
                    ->value(fn (Request $request): mixed => $this->getValue($request))
×
1227
                    ->nullable()
×
1228
                    ->options(function (Request $request, Model $model): array {
×
1229
                        return array_column(
×
1230
                            $this->field->resolveOptions($request, $model),
×
UNCOV
1231
                            'label',
×
1232
                            'value',
×
UNCOV
1233
                        );
×
UNCOV
1234
                    });
×
1235
            }
UNCOV
1236
        };
×
1237
    }
1238
}
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