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); |
|
|
|
|
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
|
|
|
|
This trait has been deprecated. The supplier of the trait has supplied an explanatory message.
The explanatory message should give you some clue as to whether and when the trait will be removed and what other trait to use instead.