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

conedevelopment / root / 20656991146

02 Jan 2026 11:33AM UTC coverage: 74.719% (-0.7%) from 75.421%
20656991146

push

github

iamgergo
fix

3458 of 4628 relevant lines covered (74.72%)

32.75 hits per line

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

82.22
/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
     */
513
    protected function mapAsyncSearchableFields(Request $request): Fields
×
514
    {
515
        return new Fields(array_map(
×
516
            fn (string $column): Hidden => Hidden::make($this->getRelationName(), $column)->searchable(),
×
517
            $this->getSearchableColumns()
×
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 {
530
            $field->setAttribute('form', $this->getAttribute('form'));
×
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
     */
544
    protected function resolveAction(Request $request, Action $action): void
×
545
    {
546
        $action->withQuery(function (Request $request): Builder {
×
547
            $model = $request->route('resourceModel');
×
548

549
            return $this->resolveFilters($request)->apply($request, $this->getRelation($model)->getQuery());
×
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✔
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
     */
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(
×
601
                '%s_%s%s', $this->getRelationName(),
×
602
                $fn,
×
603
                $column === '*' ? '' : sprintf('_%s', $column)
×
604
            ));
×
605

606
            $this->aggregated = true;
×
607

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

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✔
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
     */
629
    public function groupOptionsBy(string|Closure $key): static
×
630
    {
631
        $this->groupResolver = $key;
×
632

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)
×
650
                    ->map(function (Collection $group, string $key) use ($request, $model): array {
×
651
                        return [
×
652
                            'label' => $key,
×
653
                            'options' => $group->map(function (Model $related) use ($request, $model): array {
×
654
                                return $this->toOption($request, $model, $related);
×
655
                            })->all(),
×
656
                        ];
×
657
                    });
×
658
            },
2✔
659
            function (Collection $collection) use ($request, $model): Collection {
2✔
660
                return $collection->map(function (Model $related) use ($request, $model): array {
2✔
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
     */
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
     */
720
    public function withCount(array $withCount): static
×
721
    {
722
        $this->withCount = $withCount;
×
723

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✔
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✔
826
                    $model->recordRootEvent(
×
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✔
836
        } catch (Throwable $exception) {
×
837
            report($exception);
×
838

839
            throw new SaveFormDataException($exception->getMessage());
×
840
        }
841
    }
842

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

852
    /**
853
     * Hydrate the model with the request data.
854
     */
855
    public function handleHydrateRequest(Request $request, Model $model, Model $related): void
×
856
    {
857
        DB::transaction(function () use ($request, $model, $related): void {
×
858
            $related->setRelation('related', $model);
×
859

860
            $this->resolveFields($request)
×
861
                ->authorized($request, $related)
×
862
                ->visible($request->isMethod('POST') ? 'create' : 'update')
×
863
                ->subResource(false)
×
864
                ->each(static function (Field $field) use ($request, $related): void {
×
865
                    $field->resolveHydrate($request, $related, $field->getValueForHydrate($request));
×
866
                });
×
867
        });
×
868
    }
869

870
    /**
871
     * Handle the saved form event.
872
     */
873
    public function saved(Request $request, Model $model): void
4✔
874
    {
875
        //
876
    }
4✔
877

878
    /**
879
     * Resolve the resource model for a bound value.
880
     */
881
    public function resolveRouteBinding(Request $request, string $id): Model
4✔
882
    {
883
        $parent = $request->route()->parentOfParameter($this->getRouteKeyName());
4✔
884

885
        return $this->getRelation($parent)->findOrFail($id);
4✔
886
    }
887

888
    /**
889
     * Register the routes using the given router.
890
     */
891
    public function registerRoutes(Request $request, Router $router): void
196✔
892
    {
893
        $this->__registerRoutes($request, $router);
196✔
894

895
        $router->prefix($this->getUriKey())->group(function (Router $router) use ($request): void {
196✔
896
            $this->resolveActions($request)->registerRoutes($request, $router);
196✔
897

898
            $router->prefix("{{$this->getRouteKeyName()}}")->group(function (Router $router) use ($request): void {
196✔
899
                $this->resolveFields($request)->registerRoutes($request, $router);
196✔
900
            });
196✔
901
        });
196✔
902

903
        $this->registerRouteConstraints($request, $router);
196✔
904

905
        $this->routesRegistered($request);
196✔
906
    }
907

908
    /**
909
     * Get the route middleware for the registered routes.
910
     */
911
    public function getRouteMiddleware(): array
196✔
912
    {
913
        return [
196✔
914
            sprintf('%s:field,resourceModel,%s', Authorize::class, $this->getRouteKeyName()),
196✔
915
        ];
196✔
916
    }
917

918
    /**
919
     * Handle the routes registered event.
920
     */
921
    protected function routesRegistered(Request $request): void
196✔
922
    {
923
        $uri = $this->getUri();
196✔
924
        $routeKeyName = $this->getRouteKeyName();
196✔
925

926
        Root::instance()->breadcrumbs->patterns([
196✔
927
            $this->getUri() => $this->label,
196✔
928
            sprintf('%s/create', $uri) => __('Add'),
196✔
929
            sprintf('%s/{%s}', $uri, $routeKeyName) => fn (Request $request): string => $this->resolveDisplay($request->route($routeKeyName)),
196✔
930
            sprintf('%s/{%s}/edit', $uri, $routeKeyName) => __('Edit'),
196✔
931
        ]);
196✔
932
    }
933

934
    /**
935
     * Handle the route matched event.
936
     */
937
    public function routeMatched(RouteMatched $event): void
14✔
938
    {
939
        $this->__routeMatched($event);
14✔
940

941
        $controller = $event->route->getController();
14✔
942

943
        $controller->middleware($this->getRouteMiddleware());
14✔
944

945
        $middleware = function (Request $request, Closure $next) use ($event): mixed {
14✔
946
            $ability = match ($event->route->getActionMethod()) {
14✔
947
                'index' => 'viewAny',
2✔
948
                'show' => 'view',
1✔
949
                'create' => 'create',
1✔
950
                'store' => 'create',
2✔
951
                'edit' => 'update',
1✔
952
                'update' => 'update',
2✔
953
                'destroy' => 'delete',
2✔
954
                default => $event->route->getActionMethod(),
3✔
955
            };
14✔
956

957
            Gate::allowIf($this->resolveAbility(
14✔
958
                $ability, $request, $request->route('resourceModel'), $request->route($this->getRouteParameterName())
14✔
959
            ));
14✔
960

961
            return $next($request);
14✔
962
        };
14✔
963

964
        $controller->middleware([$middleware]);
14✔
965
    }
966

967
    /**
968
     * Resolve the ability.
969
     */
970
    public function resolveAbility(string $ability, Request $request, Model $model, ...$arguments): bool
15✔
971
    {
972
        $policy = Gate::getPolicyFor($model);
15✔
973

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

976
        return is_null($policy)
15✔
977
            || ! is_callable([$policy, $ability])
15✔
978
            || Gate::allows($ability, [$model, ...$arguments]);
15✔
979
    }
980

981
    /**
982
     * Map the relation abilities.
983
     */
984
    public function mapRelationAbilities(Request $request, Model $model): array
6✔
985
    {
986
        return [
6✔
987
            'viewAny' => $this->resolveAbility('viewAny', $request, $model),
6✔
988
            'create' => $this->resolveAbility('create', $request, $model),
6✔
989
        ];
6✔
990
    }
991

992
    /**
993
     * Map the related model abilities.
994
     */
995
    public function mapRelatedAbilities(Request $request, Model $model, Model $related): array
4✔
996
    {
997
        return [
4✔
998
            'view' => $this->resolveAbility('view', $request, $model, $related),
4✔
999
            'update' => $this->resolveAbility('update', $request, $model, $related),
4✔
1000
            'restore' => $this->resolveAbility('restore', $request, $model, $related),
4✔
1001
            'delete' => $this->resolveAbility('delete', $request, $model, $related),
4✔
1002
            'forceDelete' => $this->resolveAbility('forceDelete', $request, $model, $related),
4✔
1003
        ];
4✔
1004
    }
1005

1006
    /**
1007
     * Get the relation controller class.
1008
     */
1009
    public function getRelationController(): string
196✔
1010
    {
1011
        return RelationController::class;
196✔
1012
    }
1013

1014
    /**
1015
     * Register the routes.
1016
     */
1017
    public function routes(Router $router): void
196✔
1018
    {
1019
        if ($this->isAsync()) {
196✔
1020
            $router->get('/search', AsyncRelationController::class);
196✔
1021
        }
1022

1023
        if ($this->isSubResource()) {
196✔
1024
            $router->get('/', [$this->getRelationController(), 'index']);
196✔
1025
            $router->get('/create', [$this->getRelationController(), 'create']);
196✔
1026
            $router->get("/{{$this->getRouteKeyName()}}", [$this->getRelationController(), 'show']);
196✔
1027
            $router->post('/', [$this->getRelationController(), 'store']);
196✔
1028
            $router->get("/{{$this->getRouteKeyName()}}/edit", [$this->getRelationController(), 'edit']);
196✔
1029
            $router->patch("/{{$this->getRouteKeyName()}}", [$this->getRelationController(), 'update']);
196✔
1030
            $router->delete("/{{$this->getRouteKeyName()}}", [$this->getRelationController(), 'destroy']);
196✔
1031
            $router->match(['POST', 'PATCH'], "/{{$this->getRouteKeyName()}}/hydrate", [$this->getRelationController(), 'hydrate']);
196✔
1032
        }
1033
    }
1034

1035
    /**
1036
     * Register the route constraints.
1037
     */
1038
    public function registerRouteConstraints(Request $request, Router $router): void
196✔
1039
    {
1040
        $router->bind($this->getRouteKeyName(), fn (string $id, Route $route): Model => match ($id) {
196✔
1041
            'create' => $this->getRelation($route->parentOfParameter($this->getRouteKeyName()))->make(),
6✔
1042
            default => $this->resolveRouteBinding($router->getCurrentRequest(), $id),
6✔
1043
        });
196✔
1044
    }
1045

1046
    /**
1047
     * Parse the given query string.
1048
     */
1049
    public function parseQueryString(string $url): array
6✔
1050
    {
1051
        $query = parse_url($url, PHP_URL_QUERY) ?: '';
6✔
1052

1053
        parse_str($query, $result);
6✔
1054

1055
        return array_filter($result, fn (string $key): bool => str_starts_with($key, $this->getRequestKey()), ARRAY_FILTER_USE_KEY);
6✔
1056
    }
1057

1058
    /**
1059
     * Get the option representation of the model and the related model.
1060
     */
1061
    public function toOption(Request $request, Model $model, Model $related): array
2✔
1062
    {
1063
        $value = $this->resolveValue($request, $model);
2✔
1064

1065
        $option = $this->newOption($related, $this->resolveDisplay($related))
2✔
1066
            ->selected(! is_null($value) && ($value instanceof Model ? $value->is($related) : $value->contains($related)))
2✔
1067
            ->setAttribute('id', sprintf('%s-%s', $this->getModelAttribute(), $related->getKey()))
2✔
1068
            ->setAttribute('readonly', $this->getAttribute('readonly', false))
2✔
1069
            ->setAttribute('disabled', $this->getAttribute('disabled', false))
2✔
1070
            ->setAttribute('name', match (true) {
2✔
1071
                $value instanceof Collection => sprintf('%s[]', $this->getModelAttribute()),
2✔
1072
                default => $this->getModelAttribute(),
2✔
1073
            })
2✔
1074
            ->toArray();
2✔
1075

1076
        return array_merge($option, [
2✔
1077
            'html' => match (true) {
2✔
1078
                $this->isAsync() => View::make('root::fields.relation-option', $option)->render(),
2✔
1079
                default => '',
2✔
1080
            },
2✔
1081
        ]);
2✔
1082
    }
1083

1084
    /**
1085
     * {@inheritdoc}
1086
     */
1087
    public function toInput(Request $request, Model $model): array
2✔
1088
    {
1089
        return array_merge(parent::toInput($request, $model), [
2✔
1090
            'async' => $this->isAsync(),
2✔
1091
            'config' => [
2✔
1092
                'multiple' => $this->resolveValue($request, $model) instanceof Collection,
2✔
1093
            ],
2✔
1094
            'modalKey' => $this->getModalKey(),
2✔
1095
            'nullable' => $this->isNullable(),
2✔
1096
            'options' => $this->resolveOptions($request, $model),
2✔
1097
            'url' => $this->isAsync() ? sprintf('%s/search', $this->modelUrl($model)) : null,
2✔
1098
            'filters' => $this->isAsync()
2✔
1099
                ? $this->resolveFilters($request)
2✔
1100
                    ->authorized($request)
2✔
1101
                    ->renderable()
2✔
1102
                    ->map(static function (RenderableFilter $filter) use ($request, $model): array {
2✔
1103
                        return $filter->toField()->toInput($request, $model);
2✔
1104
                    })
2✔
1105
                    ->all()
2✔
1106
                : [],
1107
        ]);
2✔
1108
    }
1109

1110
    /**
1111
     * Get the sub resource representation of the relation
1112
     */
1113
    public function toSubResource(Request $request, Model $model): array
6✔
1114
    {
1115
        return array_merge($this->toArray(), [
6✔
1116
            'key' => $this->modelAttribute,
6✔
1117
            'baseUrl' => $this->modelUrl($model),
6✔
1118
            'url' => URL::query($this->modelUrl($model), $this->parseQueryString($request->fullUrl())),
6✔
1119
            'modelName' => $this->getRelatedName(),
6✔
1120
            'abilities' => $this->mapRelationAbilities($request, $model),
6✔
1121
        ]);
6✔
1122
    }
1123

1124
    /**
1125
     * Get the index representation of the relation.
1126
     */
1127
    public function toIndex(Request $request, Model $model): array
2✔
1128
    {
1129
        return array_merge($this->toSubResource($request, $model), [
2✔
1130
            'template' => $request->isTurboFrameRequest() ? 'root::resources.relation' : 'root::resources.index',
2✔
1131
            'title' => $this->label,
2✔
1132
            'model' => $this->getRelation($model)->make()->setRelation('related', $model),
2✔
1133
            'standaloneActions' => $this->resolveActions($request)
2✔
1134
                ->authorized($request, $model)
2✔
1135
                ->standalone()
2✔
1136
                ->mapToForms($request, $model),
2✔
1137
            'actions' => $this->resolveActions($request)
2✔
1138
                ->authorized($request, $model)
2✔
1139
                ->visible('index')
2✔
1140
                ->standalone(false)
2✔
1141
                ->mapToForms($request, $model),
2✔
1142
            'data' => $this->paginate($request, $model)->through(fn (Model $related): array => $this->mapRelated($request, $model, $related)),
2✔
1143
            'perPageOptions' => $this->getPerPageOptions(),
2✔
1144
            'perPageKey' => $this->getPerPageKey(),
2✔
1145
            'sortKey' => $this->getSortKey(),
2✔
1146
            'filters' => $this->resolveFilters($request)
2✔
1147
                ->authorized($request)
2✔
1148
                ->renderable()
2✔
1149
                ->map(static fn (RenderableFilter $filter): array => $filter->toField()->toInput($request, $model))
2✔
1150
                ->all(),
2✔
1151
            'activeFilters' => $this->resolveFilters($request)->active($request)->count(),
2✔
1152
            'parentUrl' => URL::query($request->server('HTTP_REFERER'), $request->query()),
2✔
1153
        ]);
2✔
1154
    }
1155

1156
    /**
1157
     * Get the create representation of the resource.
1158
     */
1159
    public function toCreate(Request $request, Model $model): array
1✔
1160
    {
1161
        return array_merge($this->toSubResource($request, $model), [
1✔
1162
            'template' => 'root::resources.form',
1✔
1163
            'title' => __('Create :model', ['model' => $this->getRelatedName()]),
1✔
1164
            'model' => $related = $this->getRelation($model)->make()->setRelation('related', $model),
1✔
1165
            'action' => $this->modelUrl($model),
1✔
1166
            'hydrateUrl' => sprintf('%s/create/hydrate', $this->modelUrl($model)),
1✔
1167
            'uploads' => $this->hasFileField($request),
1✔
1168
            'method' => 'POST',
1✔
1169
            'fields' => $this->resolveFields($request)
1✔
1170
                ->subResource(false)
1✔
1171
                ->authorized($request, $related)
1✔
1172
                ->visible('create')
1✔
1173
                ->mapToInputs($request, $related),
1✔
1174
        ]);
1✔
1175
    }
1176

1177
    /**
1178
     * Get the edit representation of the
1179
     */
1180
    public function toShow(Request $request, Model $model, Model $related): array
1✔
1181
    {
1182
        return array_merge($this->toSubResource($request, $model), [
1✔
1183
            'template' => 'root::resources.show',
1✔
1184
            'title' => $this->resolveDisplay($related),
1✔
1185
            'model' => $related->setRelation('related', $model),
1✔
1186
            'action' => $this->relatedUrl($related),
1✔
1187
            'fields' => $this->resolveFields($request)
1✔
1188
                ->subResource(false)
1✔
1189
                ->authorized($request, $related)
1✔
1190
                ->visible('show')
1✔
1191
                ->mapToDisplay($request, $related),
1✔
1192
            'actions' => $this->resolveActions($request)
1✔
1193
                ->authorized($request, $related)
1✔
1194
                ->visible('show')
1✔
1195
                ->standalone(false)
1✔
1196
                ->mapToForms($request, $related),
1✔
1197
            'abilities' => array_merge(
1✔
1198
                $this->mapRelationAbilities($request, $model),
1✔
1199
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1200
            ),
1✔
1201
        ]);
1✔
1202
    }
1203

1204
    /**
1205
     * Get the edit representation of the
1206
     */
1207
    public function toEdit(Request $request, Model $model, Model $related): array
1✔
1208
    {
1209
        return array_merge($this->toSubResource($request, $model), [
1✔
1210
            'template' => 'root::resources.form',
1✔
1211
            'title' => __('Edit :model', ['model' => $this->resolveDisplay($related)]),
1✔
1212
            'model' => $related->setRelation('related', $model),
1✔
1213
            'action' => $this->relatedUrl($related),
1✔
1214
            'hydrateUrl' => sprintf('%s/hydrate', $this->relatedUrl($related)),
1✔
1215
            'method' => 'PATCH',
1✔
1216
            'uploads' => $this->hasFileField($request),
1✔
1217
            'fields' => $this->resolveFields($request)
1✔
1218
                ->subResource(false)
1✔
1219
                ->authorized($request, $related)
1✔
1220
                ->visible('update')
1✔
1221
                ->mapToInputs($request, $related),
1✔
1222
            'abilities' => array_merge(
1✔
1223
                $this->mapRelationAbilities($request, $model),
1✔
1224
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1225
            ),
1✔
1226
        ]);
1✔
1227
    }
1228

1229
    /**
1230
     * Get the filter representation of the field.
1231
     */
1232
    public function toFilter(): Filter
×
1233
    {
1234
        return new class($this) extends RenderableFilter
×
1235
        {
×
1236
            protected Relation $field;
1237

1238
            public function __construct(Relation $field)
1239
            {
1240
                parent::__construct($field->getModelAttribute());
×
1241

1242
                $this->field = $field;
×
1243
            }
1244

1245
            public function apply(Request $request, Builder $query, mixed $value): Builder
1246
            {
1247
                return $this->field->resolveFilterQuery($request, $query, $value);
×
1248
            }
1249

1250
            public function toField(): Field
1251
            {
1252
                return Select::make($this->field->getLabel(), $this->getRequestKey())
×
1253
                    ->value(fn (Request $request): mixed => $this->getValue($request))
×
1254
                    ->nullable()
×
1255
                    ->options(function (Request $request, Model $model): array {
×
1256
                        return array_column(
×
1257
                            $this->field->resolveOptions($request, $model),
×
1258
                            'label',
×
1259
                            'value',
×
1260
                        );
×
1261
                    });
×
1262
            }
1263
        };
×
1264
    }
1265
}
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