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

conedevelopment / root / 20694177656

04 Jan 2026 02:13PM UTC coverage: 74.594% (-0.5%) from 75.049%
20694177656

push

github

iamgergo
version

3491 of 4680 relevant lines covered (74.59%)

32.44 hits per line

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

84.02
/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', str_replace(['.', '->'], '-', $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
        $key = Str::afterLast($this->getRequestKey(), '.');
3✔
559

560
        $filter->setKey(sprintf('%s_%s', $key, $filter->getKey()));
3✔
561
    }
562

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

570
        return $this;
196✔
571
    }
572

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

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

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

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

608
            $this->aggregated = true;
×
609

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

613
        return $this;
×
614
    }
615

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

625
        return $query;
1✔
626
    }
627

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

635
        return $this;
×
636
    }
637

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

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

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

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

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

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

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

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

716
        return $this;
×
717
    }
718

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

726
        return $this;
×
727
    }
728

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

736
        $this->resolveFilters($request)->apply($request, $relation->getQuery());
2✔
737

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

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

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

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

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

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

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

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

825
                $model->save();
4✔
826

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

834
                $this->saved($request, $model);
4✔
835

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1215
    /**
1216
     * Get the filter representation of the field.
1217
     */
1218
    public function toFilter(): Filter
×
1219
    {
1220
        return new class($this) extends RenderableFilter
×
1221
        {
×
1222
            protected Relation $field;
1223

1224
            public function __construct(Relation $field)
1225
            {
1226
                parent::__construct($field->getModelAttribute());
×
1227

1228
                $this->field = $field;
×
1229
            }
1230

1231
            public function apply(Request $request, Builder $query, mixed $value): Builder
1232
            {
1233
                return $this->field->resolveFilterQuery($request, $query, $value);
×
1234
            }
1235

1236
            public function toField(): Field
1237
            {
1238
                return Select::make($this->field->getLabel(), $this->getRequestKey())
×
1239
                    ->value(fn (Request $request): mixed => $this->getValue($request))
×
1240
                    ->nullable()
×
1241
                    ->options(function (Request $request, Model $model): array {
×
1242
                        return array_column(
×
1243
                            $this->field->resolveOptions($request, $model),
×
1244
                            'label',
×
1245
                            'value',
×
1246
                        );
×
1247
                    });
×
1248
            }
1249
        };
×
1250
    }
1251
}
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