Issues (71)

src/Flexible.php (1 issue)

1
<?php
2
3
namespace NovaFlexibleContent;
4
5
use Illuminate\Support\Str;
6
use Laravel\Nova\Contracts\Downloadable;
7
use Laravel\Nova\Fields\Field;
8
use Laravel\Nova\Fields\SupportsDependentFields;
9
use Laravel\Nova\Http\Requests\NovaRequest;
10
use NovaFlexibleContent\Http\ScopedRequest;
11
use NovaFlexibleContent\Layouts\Collections\GroupsCollection;
12
use NovaFlexibleContent\Layouts\Collections\LayoutsCollection as LayoutsCollection;
13
use NovaFlexibleContent\Layouts\Layout;
14
use NovaFlexibleContent\Nova\Fields\TraitsForFlexible\HasGroupRemovingConfirmation;
15
use NovaFlexibleContent\Nova\Fields\TraitsForFlexible\HasGroupsLimits;
16
use NovaFlexibleContent\Nova\Fields\TraitsForFlexible\HasLayoutsMenu;
17
use NovaFlexibleContent\Nova\Fields\TraitsForFlexible\HasOriginalModel;
18
use NovaFlexibleContent\Nova\Fields\TraitsForFlexible\HasPreset;
19
use NovaFlexibleContent\Nova\Fields\TraitsForFlexible\HasResolver;
20
21
class Flexible extends Field implements Downloadable
22
{
23
    use SupportsDependentFields;
24
    use HasResolver;
25
    use HasLayoutsMenu;
26
    use HasGroupsLimits;
27
    use HasGroupRemovingConfirmation;
28
    use HasPreset;
29
    use HasOriginalModel;
30
31
    /**
32
     * The field's component.
33
     *
34
     * @var string
35
     */
36
    public $component = 'flexible-content';
37
38
    /**
39
     * @inerhitDoc
40
     */
41
    public $showOnIndex = false;
42
43
    /**
44
     * The available layouts as collection.
45
     */
46
    protected LayoutsCollection $layouts;
47
48
    /**
49
     * The currently created layout groups.
50
     */
51
    protected GroupsCollection $groups;
52
53
    /**
54
     * All the validated attributes
55
     */
56
    protected static array $validatedKeys = [];
57
58
    /**
59
     * Create a fresh flexible field instance
60
     *
61
     * @param string $name
62
     * @param string|null $attribute
63
     * @param mixed|null $resolveCallback
64
     * @return void
65
     */
66 15
    public function __construct($name, $attribute = null, $resolveCallback = null)
67
    {
68 15
        $this->layouts = new LayoutsCollection();
69 15
        $this->groups  = new GroupsCollection();
70
71 15
        parent::__construct($name, $attribute, $resolveCallback);
72
73 15
        foreach (class_uses_recursive($this) as $trait) {
74
75 15
            if (method_exists($this, $method = 'initialize' . class_basename($trait))) {
76 15
                $this->{$method}();
77
            }
78
        }
79
80 15
        $this->layoutsMenuButton(__('Add ' . Str::singular(Str::lcfirst(trim($this->name)))));
81
    }
82
83
    /**
84
     * Get the field layouts.
85
     */
86 1
    public function layouts(): LayoutsCollection
87
    {
88 1
        return $this->layouts;
89
    }
90
91
    /**
92
     * Get the field groups.
93
     */
94 7
    public function groups(): GroupsCollection
95
    {
96 7
        return $this->groups;
97
    }
98
99
    /**
100
     * Register a new layout.
101
     *
102
     * @deprecated Please use alternative useLayout
103
     */
104
    public function addLayout(...$arguments): static
105
    {
106
        $count = count($arguments);
107
108
        if ($count > 1) {
109
            $layout = new Layout(...$arguments);
110
        } else {
111
            $layout = $arguments[0];
112
        }
113
114
        return $this->useLayout($layout);
115
    }
116
117
    /**
118
     * Register a new layout.
119
     */
120 15
    public function useLayout(Layout|string $layout): static
121
    {
122 15
        if (!is_a($layout, Layout::class, true)) {
123
            return $this;
124
        }
125
126 15
        if (is_string($layout)) {
127 10
            $layout = new $layout();
128
        }
129
130 15
        $this->registerLayout($layout);
131
132 15
        return $this;
133
    }
134
135
    /**
136
     * Push a layout instance into the layouts collection.
137
     */
138 15
    protected function registerLayout(Layout $layout)
139
    {
140 15
        $this->layouts->push($layout);
141
142 15
        return $this->withMeta(['layouts' => $this->layouts]);
143
    }
144
145
    /**
146
     * Resolve the field's value.
147
     *
148
     * @param mixed $resource
149
     * @param string|null $attribute
150
     * @return void
151
     */
152 10
    public function resolve($resource, $attribute = null): void
153
    {
154 10
        $attribute = $attribute ?? $this->attribute;
155
156 10
        $this->registerOriginModel($resource);
157
158 10
        $this->buildGroups($resource, $attribute);
159
160 10
        $this->value = $this->resolveGroups($this->groups);
161
    }
162
163
    /**
164
     * Resolve the field's value for display on index and detail views.
165
     *
166
     * @param mixed $resource
167
     * @param string|null $attribute
168
     * @return void
169
     */
170 4
    public function resolveForDisplay($resource, $attribute = null)
171
    {
172 4
        $attribute = $attribute ?? $this->attribute;
173
174 4
        $this->registerOriginModel($resource);
0 ignored issues
show
Deprecated Code introduced by
The function NovaFlexibleContent\Flex...::registerOriginModel() has been deprecated. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

174
        /** @scrutinizer ignore-deprecated */ $this->registerOriginModel($resource);
Loading history...
175
176 4
        $this->buildGroups($resource, $attribute);
177
178 4
        $this->value = $this->resolveGroupsForDisplay($this->groups);
179
    }
180
181
    /**
182
     * Check showing on detail.
183
     *
184
     * @param NovaRequest $request
185
     * @param             $resource
186
     * @return bool
187
     */
188
    public function isShownOnDetail(NovaRequest $request, $resource): bool
189
    {
190
        $this->layouts = $this->layouts->each(function (Layout $layout) use ($request, $resource) {
191
            $layout->filterForDetail($request, $resource);
192
        });
193
194
        return parent::isShownOnDetail($request, $resource);
195
    }
196
197
    /**
198
     * Hydrate the given attribute on the model based on the incoming request.
199
     *
200
     * @param \Laravel\Nova\Http\Requests\NovaRequest $request
201
     * @param string $requestAttribute
202
     * @param object $model
203
     * @param string $attribute
204
     * @return null|\Closure
205
     */
206 4
    protected function fillAttribute(NovaRequest $request, $requestAttribute, $model, $attribute)
207
    {
208 4
        if (!$request->exists($requestAttribute)) {
209 3
            return;
210
        }
211
212 3
        $attribute = $attribute ?? $this->attribute;
213
214 3
        $this->registerOriginModel($model);
215
216 3
        $this->buildGroups($model, $attribute);
217
218 3
        $callbacks = GroupsCollection::make($this->syncAndFillGroups($request, $requestAttribute, $model));
219
220 3
        $this->reFillValue($model, $attribute);
221
222 3
        if ($callbacks->isEmpty()) {
223 3
            return;
224
        }
225
226
        return function () use ($callbacks) {
227
            $callbacks->each->__invoke();
228
        };
229
    }
230
231 4
    public function reFillValue($model, ?string $attribute = null): static
232
    {
233 4
        $attribute   = $attribute ?? $this->attribute;
234 4
        $this->value = $this->resolver->set($model, $attribute, $this->groups);
235
236 4
        return $this;
237
    }
238
239
    /**
240
     * Process an incoming POST Request
241
     */
242 3
    protected function syncAndFillGroups(NovaRequest $request, string $requestAttribute, $model): array
243
    {
244 3
        if (!($raw = $this->extractValueFromRequest($request, $requestAttribute))) {
245
            $this->fireRemoveCallbacks(GroupsCollection::make(), $request, $model);
246
            $this->groups = GroupsCollection::make();
247
248
            return [];
249
        }
250
251 3
        $callbacks = [];
252 3
        $newGroups = GroupsCollection::make($raw)->map(function ($item, $key) use ($request, &$callbacks) {
253 3
            $layout     = $item['layout'];
254 3
            $key        = $item['key'];
255 3
            $attributes = $item['attributes'];
256
257 3
            $group = $this->findGroup($key) ?? $this->newGroup($layout, $key);
258
259 3
            $group->setCollapsed((bool)($item['collapsed'] ?? false));
260 3
            $scope     = ScopedRequest::scopeFrom($request, $attributes, $key);
261 3
            $callbacks = array_merge($callbacks, $group->fillFromRequest($scope));
262
263 3
            return $group;
264 3
        })->filter();
265
266 3
        $this->fireRemoveCallbacks($newGroups, $request, $model);
267
268 3
        $this->groups = $newGroups;
269
270 3
        return $callbacks;
271
    }
272
273
    /**
274
     * Fire's the remove callbacks on the layouts
275
     *
276
     * @param $newGroups GroupsCollection This should be (all) the new groups to bne compared against to find the
277
     *                   removed groups
278
     */
279 3
    protected function fireRemoveCallbacks(GroupsCollection $newGroups, NovaRequest $request, $model): static
280
    {
281 3
        $newGroupKeys = $newGroups->map(function ($item) {
282 3
            return $item->inUseKey();
283 3
        });
284
285 3
        $this->groups()->filter(function ($item) use ($newGroupKeys) {
286
            // Return only removed groups.
287
            return !$newGroupKeys->contains($item->inUseKey());
288 3
        })->fireRemoveCallback($this, $request, $model);
289
290 3
        return $this;
291
    }
292
293
    /**
294
     * Find the flexible's value in given request.
295
     */
296 6
    protected function extractValueFromRequest(NovaRequest $request, string $attribute): ?array
297
    {
298 6
        $value = $request->input($attribute);
299
300 6
        if (!$value) {
301 3
            return null;
302
        }
303
304 5
        if (!is_array($value)) {
305
            throw new \Exception('Unable to parse incoming Flexible content, data should be an array.');
306
        }
307
308 5
        return $value;
309
    }
310
311
    /**
312
     * Resolve all contained groups and their fields.
313
     */
314 10
    protected function resolveGroups(GroupsCollection $groups): GroupsCollection
315
    {
316 10
        return $groups->map(function (Layout $group) {
317 4
            return $group->getResolved();
318 10
        });
319
    }
320
321
    /**
322
     * Resolve all contained groups and their fields for display on index and
323
     * detail views.
324
     */
325 4
    protected function resolveGroupsForDisplay(GroupsCollection $groups): GroupsCollection
326
    {
327 4
        return $groups->map(function ($group) {
328 4
            return $group->getResolvedForDisplay();
329 4
        });
330
    }
331
332
    /**
333
     * Define the field's actual layout groups (as "base models") based
334
     * on the field's current model & attribute
335
     *
336
     * @param mixed $resource
337
     * @param string $attribute
338
     * @return GroupsCollection
339
     */
340 13
    protected function buildGroups($resource, string $attribute): GroupsCollection
341
    {
342 13
        return $this->groups = $this->resolver->get($resource, $attribute, $this->layouts);
343
    }
344
345 3
    public function findGroup(string $groupKey): ?Layout
346
    {
347 3
        return $this->groups->first(fn (Layout $group) => $group->isUseKey($groupKey));
348
    }
349
350 4
    public function findGroupRecursive(string $groupKey): ?Layout
351
    {
352
        /** @var Layout $group */
353 4
        foreach ($this->groups as $group) {
354 4
            if ($group->isUseKey($groupKey)) {
355 3
                return $group;
356
            }
357 4
            if ($foundSubsequenceGroup = $group->findFlexibleGroupRecursive($groupKey)) {
358 2
                return $foundSubsequenceGroup;
359
            }
360
        }
361
362 2
        return null;
363
    }
364
365
    /**
366
     * @return bool - true if group found and false if not found.
367
     */
368 1
    public function setAttributeRecursive(string $groupKey, string $fieldKey, mixed $newValue = null): bool
369
    {
370 1
        $isUpdated = false;
371
372 1
        $this->groups
373 1
            ->each(function (Layout $group) use ($groupKey, $fieldKey, $newValue, &$isUpdated) {
374 1
                if ($group->isUseKey($groupKey)) {
375
                    $group->setAttribute($fieldKey, $newValue);
376
                    $isUpdated = true;
377
378
                    // Break loop
379
                    return false;
380
                }
381
382 1
                if ($group->findGroupRecursiveAndSetAttribute($groupKey, $fieldKey, $newValue)) {
383 1
                    $isUpdated = true;
384
385
                    // Break loop
386 1
                    return false;
387
                }
388 1
            });
389
390 1
        return $isUpdated;
391
    }
392
393
    /**
394
     * Create a new group based on its key and layout.
395
     */
396 5
    protected function newGroup(string $layout, string $key): ?Layout
397
    {
398 5
        return $this->layouts->find($layout)?->duplicate($key);
399
    }
400
401
    /**
402
     * Get the creation rules for this field & its contained fields.
403
     *
404
     * @param \Laravel\Nova\Http\Requests\NovaRequest $request
405
     * @return array
406
     */
407
    public function getCreationRules(NovaRequest $request): array
408
    {
409
        return array_merge_recursive(
410
            parent::getCreationRules($request),
411
            $this->getFlexibleRules($request, 'creation')
412
        );
413
    }
414
415
    /**
416
     * Get the update rules for this field & its contained fields.
417
     *
418
     * @param \Laravel\Nova\Http\Requests\NovaRequest $request
419
     * @return array
420
     * @throws \Exception
421
     */
422 6
    public function getUpdateRules(NovaRequest $request): array
423
    {
424 6
        return array_merge_recursive(
425 6
            parent::getUpdateRules($request),
426 6
            $this->getFlexibleRules($request, 'update')
427 6
        );
428
    }
429
430
    /**
431
     * Retrieve contained fields rules and assign them to nested array attributes.
432
     *
433
     * @param NovaRequest $request
434
     * @param string|null $type
435
     * @return array
436
     * @throws \Exception
437
     */
438 6
    protected function getFlexibleRules(NovaRequest $request, ?string $type = null): array
439
    {
440
441 6
        if (!($value = $this->extractValueFromRequest($request, $this->attribute))) {
442 3
            return [];
443
        }
444
445 5
        $rules = $this->generateRules($request, $value, $type);
446
447 5
        if (!is_a($request, ScopedRequest::class)) {
448
            // We're not in a nested flexible, meaning we're
449
            // assuming the field is located at the root of
450
            // the model's attributes. Therefore, we should now
451
            // register all the collected validation rules for later
452
            // reference (see Http\TransformsFlexibleErrors).
453 5
            static::registerValidationKeys($rules);
454
455
            // Then, transform the rules into an array that's actually
456
            // usable by Laravel's Validator.
457 5
            $rules = array_map(fn ($field) => $field['rules'], $rules);
458
        }
459
460 5
        return $rules;
461
    }
462
463
    /**
464
     * Format all contained fields rules and return them.
465
     *
466
     * @param NovaRequest $request
467
     * @param array $value
468
     * @param string|null $type
469
     * @return array
470
     */
471 5
    protected function generateRules(NovaRequest $request, array $value = [], ?string $type = null): array
472
    {
473 5
        return GroupsCollection::make($value)->map(function ($item, $key) use ($request, $type) {
474 5
            $group = $this->newGroup($item['layout'], $item['key']);
475
476 5
            if (!$group) {
477
                return [];
478
            }
479
480 5
            $scope = ScopedRequest::scopeFrom($request, $item['attributes'], $item['key']);
481
482 5
            return $group->generateRules($scope, "{$this->attribute}.{$key}", $type);
483 5
        })
484 5
            ->collapse()
485 5
            ->all();
486
    }
487
488
    /**
489
     * Add validation keys to the valdiatedKeys register, which will be
490
     * used for transforming validation errors later in the request cycle.
491
     *
492
     * @param array $rules
493
     * @return void
494
     */
495 5
    protected static function registerValidationKeys(array $rules): void
496
    {
497 5
        static::$validatedKeys = array_merge(
498 5
            static::$validatedKeys,
499 5
            array_filter(array_map(fn ($field) => $field['attribute'] ?? null, $rules))
500 5
        );
501
    }
502
503
    /**
504
     * Return a previously registered validation key
505
     *
506
     * @param string $key
507
     * @return null|\NovaFlexibleContent\Http\FlexibleAttribute
508
     */
509 1
    public static function getValidationKey(string $key): ?Http\FlexibleAttribute
510
    {
511 1
        return static::$validatedKeys[$key] ?? null;
512
    }
513
514
}
515