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

conedevelopment / root / 15084089635

17 May 2025 10:00AM UTC coverage: 77.93% (+0.04%) from 77.891%
15084089635

push

github

web-flow
Modernize back-end.yml (#240)

3291 of 4223 relevant lines covered (77.93%)

36.04 hits per line

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

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

3
namespace Cone\Root\Fields;
4

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

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

52
    /**
53
     * The relation name on the model.
54
     */
55
    protected Closure|string $relation;
56

57
    /**
58
     * The searchable columns.
59
     */
60
    protected array $searchableColumns = ['id'];
61

62
    /**
63
     * The sortable column.
64
     */
65
    protected string $sortableColumn = 'id';
66

67
    /**
68
     * Indicates if the field should be nullable.
69
     */
70
    protected bool $nullable = false;
71

72
    /**
73
     * The Blade template.
74
     */
75
    protected string $template = 'root::fields.select';
76

77
    /**
78
     * The display resolver callback.
79
     */
80
    protected ?Closure $displayResolver = null;
81

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

87
    /**
88
     * Determine if the field is computed.
89
     */
90
    protected ?Closure $aggregateResolver = null;
91

92
    /**
93
     * Determine whether the relation values are aggregated.
94
     */
95
    protected bool $aggregated = false;
96

97
    /**
98
     * The option group resolver.
99
     */
100
    protected string|Closure|null $groupResolver = null;
101

102
    /**
103
     * Indicates whether the relation is a sub resource.
104
     */
105
    protected bool $asSubResource = false;
106

107
    /**
108
     * The relations to eager load on every query.
109
     */
110
    protected array $with = [];
111

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

117
    /**
118
     * The query scopes.
119
     */
120
    protected static array $scopes = [];
121

122
    /**
123
     * The route key resolver.
124
     */
125
    protected ?Closure $routeKeyNameResolver = null;
126

127
    /**
128
     * Create a new relation field instance.
129
     */
130
    public function __construct(string $label, Closure|string|null $modelAttribute = null, Closure|string|null $relation = null)
198✔
131
    {
132
        parent::__construct($label, $modelAttribute);
198✔
133

134
        $this->relation = $relation ?: $this->getModelAttribute();
198✔
135
    }
136

137
    /**
138
     * Add a new scope for the relation query.
139
     */
140
    public static function scopeQuery(Closure $callback): void
×
141
    {
142
        static::$scopes[static::class][] = $callback;
×
143
    }
144

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

156
        return call_user_func([$model, $this->relation]);
24✔
157
    }
158

159
    /**
160
     * Get the related model name.
161
     */
162
    public function getRelatedName(): string
198✔
163
    {
164
        return __(Str::of($this->getModelAttribute())->singular()->headline()->value());
198✔
165
    }
166

167
    /**
168
     * Get the relation name.
169
     */
170
    public function getRelationName(): string
198✔
171
    {
172
        return $this->relation instanceof Closure
198✔
173
            ? Str::afterLast($this->getModelAttribute(), '.')
198✔
174
            : $this->relation;
198✔
175
    }
176

177
    /**
178
     * Get the URI key.
179
     */
180
    public function getUriKey(): string
198✔
181
    {
182
        return str_replace('.', '-', $this->getRequestKey());
198✔
183
    }
184

185
    /**
186
     * Set the route key name resolver.
187
     */
188
    public function resolveRouteKeyNameUsing(Closure $callback): static
198✔
189
    {
190
        $this->routeKeyNameResolver = $callback;
198✔
191

192
        return $this;
198✔
193
    }
194

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

204
        return call_user_func($callback);
198✔
205
    }
206

207
    /**
208
     * Get the route parameter name.
209
     */
210
    public function getRouteParameterName(): string
12✔
211
    {
212
        return 'field';
12✔
213
    }
214

215
    /**
216
     * Set the as subresource attribute.
217
     */
218
    public function asSubResource(bool $value = true): static
198✔
219
    {
220
        $this->asSubResource = $value;
198✔
221

222
        return $this;
198✔
223
    }
224

225
    /**
226
     * Determine if the relation is a subresource.
227
     */
228
    public function isSubResource(): bool
198✔
229
    {
230
        return $this->asSubResource;
198✔
231
    }
232

233
    /**
234
     * Set the nullable attribute.
235
     */
236
    public function nullable(bool $value = true): static
198✔
237
    {
238
        $this->nullable = $value;
198✔
239

240
        return $this;
198✔
241
    }
242

243
    /**
244
     * Determine if the field is nullable.
245
     */
246
    public function isNullable(): bool
3✔
247
    {
248
        return $this->nullable;
3✔
249
    }
250

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

262
        return parent::filterable($value, $callback);
×
263
    }
264

265
    /**
266
     * {@inheritdoc}
267
     */
268
    public function searchable(bool|Closure $value = true, ?Closure $callback = null, array $columns = ['id']): static
1✔
269
    {
270
        $this->searchableColumns = $columns;
1✔
271

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

283
                return $query;
1✔
284
            });
1✔
285
        };
1✔
286

287
        return parent::searchable($value, $callback);
1✔
288
    }
289

290
    /**
291
     * Get the searchable columns.
292
     */
293
    public function getSearchableColumns(): array
1✔
294
    {
295
        return $this->searchableColumns;
1✔
296
    }
297

298
    /**
299
     * Resolve the filter query.
300
     */
301
    public function resolveSearchQuery(Request $request, Builder $query, mixed $value): Builder
1✔
302
    {
303
        if (! $this->isSearchable()) {
1✔
304
            return parent::resolveSearchQuery($request, $query, $value);
×
305
        }
306

307
        return call_user_func_array($this->searchQueryResolver, [
1✔
308
            $request, $query, $value, $this->getSearchableColumns(),
1✔
309
        ]);
1✔
310
    }
311

312
    /**
313
     * Set the sortable attribute.
314
     */
315
    public function sortable(bool|Closure $value = true, string $column = 'id'): static
1✔
316
    {
317
        $this->sortableColumn = $column;
1✔
318

319
        return parent::sortable($value);
1✔
320
    }
321

322
    /**
323
     * Get the sortable columns.
324
     */
325
    public function getSortableColumn(): string
1✔
326
    {
327
        return $this->sortableColumn;
1✔
328
    }
329

330
    /**
331
     * {@inheritdoc}
332
     */
333
    public function isSortable(): bool
13✔
334
    {
335
        if ($this->isSubResource()) {
13✔
336
            return false;
10✔
337
        }
338

339
        return parent::isSortable();
9✔
340
    }
341

342
    /**
343
     * Set the translatable attribute.
344
     */
345
    public function translatable(bool|Closure $value = false): static
×
346
    {
347
        $this->translatable = false;
×
348

349
        return $this;
×
350
    }
351

352
    /**
353
     * Determine if the field is translatable.
354
     */
355
    public function isTranslatable(): bool
198✔
356
    {
357
        return false;
198✔
358
    }
359

360
    /**
361
     * Set the display resolver.
362
     */
363
    public function display(Closure|string $callback): static
198✔
364
    {
365
        if (is_string($callback)) {
198✔
366
            $callback = static fn (Model $model) => $model->getAttribute($callback);
198✔
367
        }
368

369
        $this->displayResolver = $callback;
198✔
370

371
        return $this;
198✔
372
    }
373

374
    /**
375
     * Resolve the display format or the query result.
376
     */
377
    public function resolveDisplay(Model $related): ?string
8✔
378
    {
379
        if (is_null($this->displayResolver)) {
8✔
380
            $this->display($related->getKeyName());
×
381
        }
382

383
        return call_user_func_array($this->displayResolver, [$related]);
8✔
384
    }
385

386
    /**
387
     * {@inheritdoc}
388
     */
389
    public function getValue(Model $model): mixed
11✔
390
    {
391
        if ($this->aggregated) {
11✔
392
            return parent::getValue($model);
×
393
        }
394

395
        $name = $this->getRelationName();
11✔
396

397
        if ($this->relation instanceof Closure && ! $model->relationLoaded($name)) {
11✔
398
            $model->setRelation($name, call_user_func_array($this->relation, [$model])->getResults());
3✔
399
        }
400

401
        return $model->getAttribute($name);
11✔
402
    }
403

404
    /**
405
     * {@inheritdoc}
406
     */
407
    public function resolveFormat(Request $request, Model $model): ?string
6✔
408
    {
409
        if (is_null($this->formatResolver)) {
6✔
410
            $this->formatResolver = function (Request $request, Model $model): mixed {
6✔
411
                $default = $this->getValue($model);
6✔
412

413
                if ($this->aggregated) {
6✔
414
                    return $default;
×
415
                }
416

417
                return Collection::wrap($default)->map(fn (Model $related): ?string => $this->formatRelated($request, $model, $related))->filter()->join(', ');
6✔
418
            };
6✔
419
        }
420

421
        return parent::resolveFormat($request, $model);
6✔
422
    }
423

424
    /**
425
     * Format the related model.
426
     */
427
    public function formatRelated(Request $request, Model $model, Model $related): ?string
1✔
428
    {
429
        $resource = Root::instance()->resources->forModel($related);
1✔
430

431
        $value = $this->resolveDisplay($related);
1✔
432

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

437
        return $value;
1✔
438
    }
439

440
    /**
441
     * Define the filters for the object.
442
     */
443
    public function filters(Request $request): array
2✔
444
    {
445
        $fields = $this->resolveFields($request)->authorized($request);
2✔
446

447
        $searchables = $fields->searchable();
2✔
448

449
        $sortables = $fields->sortable();
2✔
450

451
        $filterables = $fields->filterable();
2✔
452

453
        return array_values(array_filter([
2✔
454
            $searchables->isNotEmpty() ? new Search($searchables) : null,
2✔
455
            $sortables->isNotEmpty() ? new Sort($sortables) : null,
2✔
456
            ...$filterables->map->toFilter()->all(),
2✔
457
        ]));
2✔
458
    }
459

460
    /**
461
     * Handle the callback for the field resolution.
462
     */
463
    protected function resolveField(Request $request, Field $field): void
198✔
464
    {
465
        if ($this->isSubResource()) {
198✔
466
            $field->setAttribute('form', $this->modelAttribute);
198✔
467
            $field->resolveErrorsUsing(fn (Request $request): MessageBag => $this->errors($request));
198✔
468
        } else {
469
            $field->setAttribute('form', $this->getAttribute('form'));
×
470
            $field->resolveErrorsUsing($this->errorsResolver);
×
471
        }
472

473
        if ($field instanceof Relation) {
198✔
474
            $field->resolveRouteKeyNameUsing(
198✔
475
                fn (): string => Str::of($field->getRelationName())->singular()->ucfirst()->prepend($this->getRouteKeyName())->value()
198✔
476
            );
198✔
477
        }
478
    }
479

480
    /**
481
     * Handle the callback for the field resolution.
482
     */
483
    protected function resolveAction(Request $request, Action $action): void
×
484
    {
485
        $action->withQuery(function (Request $request): Builder {
×
486
            $model = $request->route('resourceModel');
×
487

488
            return $this->resolveFilters($request)->apply($request, $this->getRelation($model)->getQuery());
×
489
        });
×
490
    }
491

492
    /**
493
     * Handle the callback for the filter resolution.
494
     */
495
    protected function resolveFilter(Request $request, Filter $filter): void
3✔
496
    {
497
        $filter->setKey(sprintf('%s_%s', $this->getRequestKey(), $filter->getKey()));
3✔
498
    }
499

500
    /**
501
     * Set the query resolver.
502
     */
503
    public function withRelatableQuery(Closure $callback): static
198✔
504
    {
505
        $this->queryResolver = $callback;
198✔
506

507
        return $this;
198✔
508
    }
509

510
    /**
511
     * Resolve the related model's eloquent query.
512
     */
513
    public function resolveRelatableQuery(Request $request, Model $model): Builder
11✔
514
    {
515
        $query = $this->getRelation($model)
11✔
516
            ->getRelated()
11✔
517
            ->newQuery()
11✔
518
            ->with($this->with)
11✔
519
            ->withCount($this->withCount);
11✔
520

521
        foreach (static::$scopes[static::class] ?? [] as $scope) {
11✔
522
            $query = call_user_func_array($scope, [$request, $query, $model]);
×
523
        }
524

525
        return $query
11✔
526
            ->when(! is_null($this->queryResolver), fn (Builder $query): Builder => call_user_func_array($this->queryResolver, [$request, $query, $model]));
11✔
527
    }
528

529
    /**
530
     * Aggregate relation values.
531
     */
532
    public function aggregate(string $fn = 'count', string $column = '*'): static
×
533
    {
534
        $this->aggregateResolver = function (Request $request, Builder $query) use ($fn, $column): Builder {
535
            $this->setModelAttribute(sprintf(
×
536
                '%s_%s%s', $this->getRelationName(),
×
537
                $fn,
×
538
                $column === '*' ? '' : sprintf('_%s', $column)
×
539
            ));
×
540

541
            $this->aggregated = true;
×
542

543
            return $query->withAggregate($this->getRelationName(), $column, $fn);
×
544
        };
545

546
        return $this;
×
547
    }
548

549
    /**
550
     * Resolve the aggregate query.
551
     */
552
    public function resolveAggregate(Request $request, Builder $query): Builder
1✔
553
    {
554
        if (! is_null($this->aggregateResolver)) {
1✔
555
            $query = call_user_func_array($this->aggregateResolver, [$request, $query]);
×
556
        }
557

558
        return $query;
1✔
559
    }
560

561
    /**
562
     * Set the group resolver attribute.
563
     */
564
    public function groupOptionsBy(string|Closure $key): static
×
565
    {
566
        $this->groupResolver = $key;
×
567

568
        return $this;
×
569
    }
570

571
    /**
572
     * Resolve the options for the field.
573
     */
574
    public function resolveOptions(Request $request, Model $model): array
3✔
575
    {
576
        return $this->resolveRelatableQuery($request, $model)
3✔
577
            ->get()
3✔
578
            ->when(! is_null($this->groupResolver), fn (Collection $collection): Collection => $collection->groupBy($this->groupResolver)
3✔
579
                ->map(fn (Collection $group, string $key): array => [
3✔
580
                    'label' => $key,
3✔
581
                    'options' => $group->map(fn (Model $related): array => $this->toOption($request, $model, $related))->all(),
3✔
582
                ]), fn (Collection $collection): Collection => $collection->map(fn (Model $related): array => $this->toOption($request, $model, $related)))
3✔
583
            ->toArray();
3✔
584
    }
585

586
    /**
587
     * Make a new option instance.
588
     */
589
    public function newOption(Model $related, string $label): Option
2✔
590
    {
591
        return new Option($related->getKey(), $label);
2✔
592
    }
593

594
    /**
595
     * Get the per page options.
596
     */
597
    public function getPerPageOptions(): array
2✔
598
    {
599
        return [5, 10, 15, 25];
2✔
600
    }
601

602
    /**
603
     * Get the per page key.
604
     */
605
    public function getPerPageKey(): string
2✔
606
    {
607
        return sprintf('%s_per_page', $this->getRequestKey());
2✔
608
    }
609

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

618
    /**
619
     * The relations to be eagerload.
620
     */
621
    public function with(array $with): static
×
622
    {
623
        $this->with = $with;
×
624

625
        return $this;
×
626
    }
627

628
    /**
629
     * The relation counts to be eagerload.
630
     */
631
    public function withCount(array $withCount): static
×
632
    {
633
        $this->withCount = $withCount;
×
634

635
        return $this;
×
636
    }
637

638
    /**
639
     * Paginate the given query.
640
     */
641
    public function paginate(Request $request, Model $model): LengthAwarePaginator
2✔
642
    {
643
        $relation = $this->getRelation($model);
2✔
644

645
        $this->resolveFilters($request)->apply($request, $relation->getQuery());
2✔
646

647
        return $relation
2✔
648
            ->with($this->with)
2✔
649
            ->withCount($this->withCount)
2✔
650
            ->latest()
2✔
651
            ->paginate(
2✔
652
                $request->input(
2✔
653
                    $this->getPerPageKey(),
2✔
654
                    $request->isTurboFrameRequest() ? 5 : $relation->getRelated()->getPerPage()
2✔
655
                )
2✔
656
            )->withQueryString();
2✔
657
    }
658

659
    /**
660
     * Map a related model.
661
     */
662
    public function mapRelated(Request $request, Model $model, Model $related): array
2✔
663
    {
664
        return [
2✔
665
            'id' => $related->getKey(),
2✔
666
            'url' => $this->relatedUrl($model, $related),
2✔
667
            'model' => $related->setRelation('related', $model),
2✔
668
            'fields' => $this->resolveFields($request)
2✔
669
                ->subResource(false)
2✔
670
                ->authorized($request, $related)
2✔
671
                ->visible('index')
2✔
672
                ->mapToDisplay($request, $related),
2✔
673
            'abilities' => $this->mapRelatedAbilities($request, $model, $related),
2✔
674
        ];
2✔
675
    }
676

677
    /**
678
     * Get the model URL.
679
     */
680
    public function modelUrl(Model $model): string
14✔
681
    {
682
        return str_replace('{resourceModel}', $model->exists ? $model->getKey() : 'create', $this->getUri());
14✔
683
    }
684

685
    /**
686
     * Get the related URL.
687
     */
688
    public function relatedUrl(Model $model, Model $related): string
8✔
689
    {
690
        return sprintf('%s/%s', $this->modelUrl($model), $related->getKey());
8✔
691
    }
692

693
    /**
694
     * {@inheritdoc}
695
     */
696
    public function persist(Request $request, Model $model, mixed $value): void
7✔
697
    {
698
        if ($this->isSubResource()) {
7✔
699
            $this->resolveFields($request)
4✔
700
                ->authorized($request, $model)
4✔
701
                ->visible($request->isMethod('POST') ? 'create' : 'update')
4✔
702
                ->persist($request, $model);
4✔
703
        } else {
704
            parent::persist($request, $model, $value);
5✔
705
        }
706
    }
707

708
    /**
709
     * Handle the request.
710
     */
711
    public function handleFormRequest(Request $request, Model $model): void
4✔
712
    {
713
        $this->validateFormRequest($request, $model);
4✔
714

715
        try {
716
            DB::beginTransaction();
4✔
717

718
            $this->persist($request, $model, $this->getValueForHydrate($request));
4✔
719

720
            $model->save();
4✔
721

722
            if (in_array(HasRootEvents::class, class_uses_recursive($model))) {
4✔
723
                $model->recordRootEvent(
×
724
                    $model->wasRecentlyCreated ? 'Created' : 'Updated',
×
725
                    $request->user()
×
726
                );
×
727
            }
728

729
            $this->saved($request, $model);
4✔
730

731
            DB::commit();
4✔
732
        } catch (Throwable $exception) {
×
733
            report($exception);
×
734

735
            DB::rollBack();
×
736

737
            throw new SaveFormDataException($exception->getMessage());
×
738
        }
739
    }
740

741
    /**
742
     * Handle the saved form event.
743
     */
744
    public function saved(Request $request, Model $model): void
4✔
745
    {
746
        //
747
    }
4✔
748

749
    /**
750
     * Resolve the resource model for a bound value.
751
     */
752
    public function resolveRouteBinding(Request $request, string $id): Model
4✔
753
    {
754
        return $this->getRelation($request->route()->parentOfParameter($this->getRouteKeyName()))->findOrFail($id);
4✔
755
    }
756

757
    /**
758
     * Register the routes using the given router.
759
     */
760
    public function registerRoutes(Request $request, Router $router): void
198✔
761
    {
762
        $this->__registerRoutes($request, $router);
198✔
763

764
        $router->prefix($this->getUriKey())->group(function (Router $router) use ($request): void {
198✔
765
            $this->resolveActions($request)->registerRoutes($request, $router);
198✔
766

767
            $router->prefix("{{$this->getRouteKeyName()}}")->group(function (Router $router) use ($request): void {
198✔
768
                $this->resolveFields($request)->registerRoutes($request, $router);
198✔
769
            });
198✔
770
        });
198✔
771

772
        $this->registerRouteConstraints($request, $router);
198✔
773

774
        $this->routesRegistered($request);
198✔
775
    }
776

777
    /**
778
     * Get the route middleware for the registered routes.
779
     */
780
    public function getRouteMiddleware(): array
198✔
781
    {
782
        return [
198✔
783
            sprintf('%s:field,resourceModel,%s', Authorize::class, $this->getRouteKeyName()),
198✔
784
        ];
198✔
785
    }
786

787
    /**
788
     * Handle the routes registered event.
789
     */
790
    protected function routesRegistered(Request $request): void
198✔
791
    {
792
        $uri = $this->getUri();
198✔
793
        $routeKeyName = $this->getRouteKeyName();
198✔
794

795
        Root::instance()->breadcrumbs->patterns([
198✔
796
            $this->getUri() => $this->label,
198✔
797
            sprintf('%s/create', $uri) => __('Add'),
198✔
798
            sprintf('%s/{%s}', $uri, $routeKeyName) => fn (Request $request): string => $this->resolveDisplay($request->route($routeKeyName)),
198✔
799
            sprintf('%s/{%s}/edit', $uri, $routeKeyName) => __('Edit'),
198✔
800
        ]);
198✔
801
    }
802

803
    /**
804
     * Handle the route matched event.
805
     */
806
    public function routeMatched(RouteMatched $event): void
15✔
807
    {
808
        $this->__routeMatched($event);
15✔
809

810
        $controller = $event->route->getController();
15✔
811

812
        $controller->middleware($this->getRouteMiddleware());
15✔
813

814
        $middleware = function (Request $request, Closure $next) use ($event): mixed {
15✔
815
            $ability = match ($event->route->getActionMethod()) {
15✔
816
                'index' => 'viewAny',
2✔
817
                'show' => 'view',
1✔
818
                'create' => 'create',
1✔
819
                'store' => 'create',
2✔
820
                'edit' => 'update',
1✔
821
                'update' => 'update',
2✔
822
                'destroy' => 'delete',
2✔
823
                default => $event->route->getActionMethod(),
4✔
824
            };
15✔
825

826
            Gate::allowIf($this->resolveAbility(
15✔
827
                $ability, $request, $request->route('resourceModel'), $request->route($this->getRouteParameterName())
15✔
828
            ));
15✔
829

830
            return $next($request);
15✔
831
        };
15✔
832

833
        $controller->middleware([$middleware]);
15✔
834
    }
835

836
    /**
837
     * Resolve the ability.
838
     */
839
    public function resolveAbility(string $ability, Request $request, Model $model, ...$arguments): bool
16✔
840
    {
841
        $policy = Gate::getPolicyFor($model);
16✔
842

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

845
        return is_null($policy)
16✔
846
            || ! method_exists($policy, $ability)
16✔
847
            || Gate::allows($ability, [$model, ...$arguments]);
16✔
848
    }
849

850
    /**
851
     * Map the relation abilities.
852
     */
853
    public function mapRelationAbilities(Request $request, Model $model): array
6✔
854
    {
855
        return [
6✔
856
            'viewAny' => $this->resolveAbility('viewAny', $request, $model),
6✔
857
            'create' => $this->resolveAbility('create', $request, $model),
6✔
858
        ];
6✔
859
    }
860

861
    /**
862
     * Map the related model abilities.
863
     */
864
    public function mapRelatedAbilities(Request $request, Model $model, Model $related): array
4✔
865
    {
866
        return [
4✔
867
            'view' => $this->resolveAbility('view', $request, $model, $related),
4✔
868
            'update' => $this->resolveAbility('update', $request, $model, $related),
4✔
869
            'restore' => $this->resolveAbility('restore', $request, $model, $related),
4✔
870
            'delete' => $this->resolveAbility('delete', $request, $model, $related),
4✔
871
            'forceDelete' => $this->resolveAbility('forceDelete', $request, $model, $related),
4✔
872
        ];
4✔
873
    }
874

875
    /**
876
     * Register the routes.
877
     */
878
    public function routes(Router $router): void
198✔
879
    {
880
        if ($this->isSubResource()) {
198✔
881
            $router->get('/', [RelationController::class, 'index']);
198✔
882
            $router->get('/create', [RelationController::class, 'create']);
198✔
883
            $router->get("/{{$this->getRouteKeyName()}}", [RelationController::class, 'show']);
198✔
884
            $router->post('/', [RelationController::class, 'store']);
198✔
885
            $router->get("/{{$this->getRouteKeyName()}}/edit", [RelationController::class, 'edit']);
198✔
886
            $router->patch("/{{$this->getRouteKeyName()}}", [RelationController::class, 'update']);
198✔
887
            $router->delete("/{{$this->getRouteKeyName()}}", [RelationController::class, 'destroy']);
198✔
888
        }
889
    }
890

891
    /**
892
     * Register the route constraints.
893
     */
894
    public function registerRouteConstraints(Request $request, Router $router): void
198✔
895
    {
896
        $router->bind($this->getRouteKeyName(), fn (string $id, Route $route): Model => match ($id) {
198✔
897
            'create' => $this->getRelation($route->parentOfParameter($this->getRouteKeyName()))->make(),
6✔
898
            default => $this->resolveRouteBinding($router->getCurrentRequest(), $id),
6✔
899
        });
198✔
900
    }
901

902
    /**
903
     * Parse the given query string.
904
     */
905
    public function parseQueryString(string $url): array
6✔
906
    {
907
        $query = parse_url($url, PHP_URL_QUERY);
6✔
908

909
        parse_str($query, $result);
6✔
910

911
        return array_filter($result, fn (string $key): bool => str_starts_with($key, $this->getRequestKey()), ARRAY_FILTER_USE_KEY);
6✔
912
    }
913

914
    /**
915
     * Get the option representation of the model and the related model.
916
     */
917
    public function toOption(Request $request, Model $model, Model $related): array
5✔
918
    {
919
        $value = $this->resolveValue($request, $model);
5✔
920

921
        return $this->newOption($related, $this->resolveDisplay($related))
5✔
922
            ->selected(! is_null($value) && ($value instanceof Model ? $value->is($related) : $value->contains($related)))
5✔
923
            ->toArray();
5✔
924
    }
925

926
    /**
927
     * {@inheritdoc}
928
     */
929
    public function toInput(Request $request, Model $model): array
3✔
930
    {
931
        return array_merge(parent::toInput($request, $model), [
3✔
932
            'nullable' => $this->isNullable(),
3✔
933
            'options' => $this->resolveOptions($request, $model),
3✔
934
        ]);
3✔
935
    }
936

937
    /**
938
     * Get the sub resource representation of the relation
939
     */
940
    public function toSubResource(Request $request, Model $model): array
6✔
941
    {
942
        return array_merge($this->toArray(), [
6✔
943
            'key' => $this->modelAttribute,
6✔
944
            'baseUrl' => $this->modelUrl($model),
6✔
945
            'url' => URL::query($this->modelUrl($model), $this->parseQueryString($request->fullUrl())),
6✔
946
            'modelName' => $this->getRelatedName(),
6✔
947
            'abilities' => $this->mapRelationAbilities($request, $model),
6✔
948
        ]);
6✔
949
    }
950

951
    /**
952
     * Get the index representation of the relation.
953
     */
954
    public function toIndex(Request $request, Model $model): array
2✔
955
    {
956
        return array_merge($this->toSubResource($request, $model), [
2✔
957
            'template' => $request->isTurboFrameRequest() ? 'root::resources.relation' : 'root::resources.index',
2✔
958
            'title' => $this->label,
2✔
959
            'model' => $this->getRelation($model)->make()->setRelation('related', $model),
2✔
960
            'standaloneActions' => $this->resolveActions($request)
2✔
961
                ->authorized($request, $model)
2✔
962
                ->standalone()
2✔
963
                ->mapToForms($request, $model),
2✔
964
            'actions' => $this->resolveActions($request)
2✔
965
                ->authorized($request, $model)
2✔
966
                ->visible('index')
2✔
967
                ->standalone(false)
2✔
968
                ->mapToForms($request, $model),
2✔
969
            'data' => $this->paginate($request, $model)->through(fn (Model $related): array => $this->mapRelated($request, $model, $related)),
2✔
970
            'perPageOptions' => $this->getPerPageOptions(),
2✔
971
            'perPageKey' => $this->getPerPageKey(),
2✔
972
            'sortKey' => $this->getSortKey(),
2✔
973
            'filters' => $this->resolveFilters($request)
2✔
974
                ->authorized($request)
2✔
975
                ->renderable()
2✔
976
                ->map(static fn (RenderableFilter $filter): array => $filter->toField()->toInput($request, $model))
2✔
977
                ->all(),
2✔
978
            'activeFilters' => $this->resolveFilters($request)->active($request)->count(),
2✔
979
            'parentUrl' => URL::query($request->server('HTTP_REFERER'), $request->query()),
2✔
980
        ]);
2✔
981
    }
982

983
    /**
984
     * Get the create representation of the resource.
985
     */
986
    public function toCreate(Request $request, Model $model): array
1✔
987
    {
988
        return array_merge($this->toSubResource($request, $model), [
1✔
989
            'template' => 'root::resources.form',
1✔
990
            'title' => __('Create :model', ['model' => $this->getRelatedName()]),
1✔
991
            'model' => $related = $this->getRelation($model)->make()->setRelation('related', $model),
1✔
992
            'action' => $this->modelUrl($model),
1✔
993
            'uploads' => $this->hasFileField($request),
1✔
994
            'method' => 'POST',
1✔
995
            'fields' => $this->resolveFields($request)
1✔
996
                ->subResource(false)
1✔
997
                ->authorized($request, $related)
1✔
998
                ->visible('create')
1✔
999
                ->mapToInputs($request, $related),
1✔
1000
        ]);
1✔
1001
    }
1002

1003
    /**
1004
     * Get the edit representation of the
1005
     */
1006
    public function toShow(Request $request, Model $model, Model $related): array
1✔
1007
    {
1008
        return array_merge($this->toSubResource($request, $model), [
1✔
1009
            'template' => 'root::resources.show',
1✔
1010
            'title' => $this->resolveDisplay($related),
1✔
1011
            'model' => $related->setRelation('related', $model),
1✔
1012
            'action' => $this->relatedUrl($model, $related),
1✔
1013
            'fields' => $this->resolveFields($request)
1✔
1014
                ->subResource(false)
1✔
1015
                ->authorized($request, $related)
1✔
1016
                ->visible('show')
1✔
1017
                ->mapToDisplay($request, $related),
1✔
1018
            'actions' => $this->resolveActions($request)
1✔
1019
                ->authorized($request, $related)
1✔
1020
                ->visible('show')
1✔
1021
                ->standalone(false)
1✔
1022
                ->mapToForms($request, $related),
1✔
1023
            'abilities' => array_merge(
1✔
1024
                $this->mapRelationAbilities($request, $model),
1✔
1025
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1026
            ),
1✔
1027
        ]);
1✔
1028
    }
1029

1030
    /**
1031
     * Get the edit representation of the
1032
     */
1033
    public function toEdit(Request $request, Model $model, Model $related): array
1✔
1034
    {
1035
        return array_merge($this->toSubResource($request, $model), [
1✔
1036
            'template' => 'root::resources.form',
1✔
1037
            'title' => __('Edit :model', ['model' => $this->resolveDisplay($related)]),
1✔
1038
            'model' => $related->setRelation('related', $model),
1✔
1039
            'action' => $this->relatedUrl($model, $related),
1✔
1040
            'method' => 'PATCH',
1✔
1041
            'uploads' => $this->hasFileField($request),
1✔
1042
            'fields' => $this->resolveFields($request)
1✔
1043
                ->subResource(false)
1✔
1044
                ->authorized($request, $related)
1✔
1045
                ->visible('update')
1✔
1046
                ->mapToInputs($request, $related),
1✔
1047
            'abilities' => array_merge(
1✔
1048
                $this->mapRelationAbilities($request, $model),
1✔
1049
                $this->mapRelatedAbilities($request, $model, $related)
1✔
1050
            ),
1✔
1051
        ]);
1✔
1052
    }
1053

1054
    /**
1055
     * Get the filter representation of the field.
1056
     */
1057
    public function toFilter(): Filter
×
1058
    {
1059
        return new class($this) extends RenderableFilter
×
1060
        {
×
1061
            protected Relation $field;
1062

1063
            public function __construct(Relation $field)
1064
            {
1065
                parent::__construct($field->getModelAttribute());
×
1066

1067
                $this->field = $field;
×
1068
            }
1069

1070
            public function apply(Request $request, Builder $query, mixed $value): Builder
1071
            {
1072
                return $this->field->resolveFilterQuery($request, $query, $value);
×
1073
            }
1074

1075
            public function toField(): Field
1076
            {
1077
                return Select::make($this->field->getLabel(), $this->getRequestKey())
×
1078
                    ->value(fn (Request $request): mixed => $this->getValue($request))
×
1079
                    ->nullable()
×
1080
                    ->options(function (Request $request, Model $model): array {
×
1081
                        return array_column(
×
1082
                            $this->field->resolveOptions($request, $model),
×
1083
                            'label',
×
1084
                            'value',
×
1085
                        );
×
1086
                    });
×
1087
            }
1088
        };
×
1089
    }
1090
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc