Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
161 / 161
100.00% covered (success)
100.00%
23 / 23
CRAP
100.00% covered (success)
100.00%
1 / 1
Blocks
100.00% covered (success)
100.00%
161 / 161
100.00% covered (success)
100.00%
23 / 23
63
100.00% covered (success)
100.00%
1 / 1
 control
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 blockMetaControl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 allow
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 common
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 commonSelection
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 allows
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allowedBlockTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 blockFields
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 blockFieldsFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 blockHandle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supportedTranslateModes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 value
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 structure
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 shape
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
6
 rowKind
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assertRowField
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 configureRowField
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 blockTypeProperties
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 unmarked
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 structureMap
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 rowStructures
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
10
 rowsShape
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 reviewRows
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Field;
6
7use Celema\Sire\Extra;
8use Celema\Sire\Review;
9use Celema\Sire\Shape;
10use Cosray\Block\Layout;
11use Cosray\Contract\Block;
12use Cosray\Exception\RuntimeException;
13use Cosray\Schema\Tools;
14use Cosray\Schema\TranslateMode;
15use Cosray\Validation\Prepare;
16use Cosray\Validation\Shapes;
17use Cosray\Value\Blocks as BlocksValue;
18
19/**
20 * A list of typed rows with a grid layout. Block types are classes
21 * implementing Cosray\Contract\Block; without `#[Allows]` the field
22 * offers the registry's default list, without `#[Columns]` it is a
23 * stacked one-column list.
24 */
25class Blocks extends Field implements Capability\Translatable, Capability\Blocks\Resizable, Capability\ToolsAware
26{
27    use Capability\IsTranslatable;
28    use Capability\Blocks\IsResizable;
29    use Capability\IsToolsAware;
30    use RowTypes;
31
32    public const int COMMON_LIMIT = 6;
33
34    /** @var list<class-string<Block>> */
35    protected array $allowedBlockTypes = [];
36
37    /** @var list<class-string<Block>> */
38    protected array $commonTypes = [];
39
40    public function control(): Control
41    {
42        return Control::blocks()
43            ->prop('commonTypes', self::commonSelection($this->commonTypes, $this->allowedBlockTypes(), $this->name))
44            ->prop('blockTypes', array_map($this->blockTypeProperties(...), $this->allowedBlockTypes()))
45            ->prop('columns', $this->columns)
46            ->prop('min', $this->min)
47            ->prop('responsive', $this->responsive->value)
48            ->prop('meta', $this->blockMetaControl());
49    }
50
51    /**
52     * The block meta dialog: the same group for every block type, patched
53     * on save like a field's meta.
54     */
55    protected function blockMetaControl(): Control
56    {
57        return Control::group([
58            ['key' => 'class', 'label' => __('block:class'), 'control' => Control::text()],
59            ['key' => 'id', 'label' => __('block:id'), 'control' => Control::text()],
60        ]);
61    }
62
63    /** @param class-string<Block> ...$types */
64    public function allow(string ...$types): static
65    {
66        foreach ($types as $type) {
67            if (!class_exists($type)) {
68                throw new RuntimeException("Blocks field '{$this->name}' allows unknown block type '{$type}'");
69            }
70
71            if (!is_a($type, Block::class, true)) {
72                throw new RuntimeException(
73                    "Blocks field '{$this->name}' block type '{$type}' must implement " . Block::class,
74                );
75            }
76        }
77
78        $this->allowedBlockTypes = array_values(array_unique([
79            ...$this->allowedBlockTypes,
80            ...$types,
81        ]));
82
83        return $this;
84    }
85
86    public function common(string ...$types): static
87    {
88        foreach ($types as $type) {
89            if (!class_exists($type)) {
90                throw new RuntimeException("Blocks field '{$this->name}' has unknown common type '{$type}'");
91            }
92
93            if (!is_a($type, Block::class, true)) {
94                throw new RuntimeException(
95                    "Blocks field '{$this->name}' common type '{$type}' must implement " . Block::class,
96                );
97            }
98        }
99
100        $this->commonTypes = $types;
101
102        return $this;
103    }
104
105    /**
106     * The short menu's types in order: an explicit list checked against
107     * the allowed types, or the first six allowed ones.
108     *
109     * @param list<string> $common
110     * @param list<string> $allowed
111     * @return list<string>
112     */
113    public static function commonSelection(array $common, array $allowed, string $field): array
114    {
115        $common = array_values(array_unique($common));
116
117        if ($common === []) {
118            return array_slice($allowed, 0, self::COMMON_LIMIT);
119        }
120
121        if (count($common) > self::COMMON_LIMIT) {
122            throw new RuntimeException(
123                "Blocks field '{$field}' may have at most " . self::COMMON_LIMIT . ' distinct common types',
124            );
125        }
126
127        foreach ($common as $type) {
128            if (!in_array($type, $allowed, true)) {
129                throw new RuntimeException("Blocks field '{$field}' common type '{$type}' is not allowed");
130            }
131        }
132
133        return $common;
134    }
135
136    public function allows(string $type): bool
137    {
138        return in_array($type, $this->allowedBlockTypes(), true);
139    }
140
141    /**
142     * The types this field offers: the `#[Allows]` list, otherwise the
143     * registry's default list.
144     *
145     * @return list<class-string<Block>>
146     */
147    public function allowedBlockTypes(): array
148    {
149        return $this->allowedBlockTypes === [] ? $this->services()->blocks->all() : $this->allowedBlockTypes;
150    }
151
152    /** @return array<string, Field> */
153    public function blockFields(?string $type = null): array
154    {
155        $type ??= $this->allowedBlockTypes()[0]
156            ?? throw new RuntimeException("Blocks field '{$this->name}' offers no block types");
157
158        return $this->rowFieldsFor($type);
159    }
160
161    /**
162     * @param class-string<Block> $type
163     * @param array<string, mixed> $data
164     * @return array<string, Field>
165     */
166    public function blockFieldsFor(string $type, array $data = []): array
167    {
168        return $this->rowFieldsFor($type, $data);
169    }
170
171    /**
172     * The `data-type` value of a block type: its `#[Handle]`, otherwise
173     * derived from the class name.
174     *
175     * @param class-string<Block> $type
176     */
177    public function blockHandle(string $type): string
178    {
179        return (string) $this->nodeTypes()->get($type, 'handle');
180    }
181
182    /** @return list<TranslateMode> */
183    protected function supportedTranslateModes(): array
184    {
185        return [TranslateMode::Symmetric, TranslateMode::Asymmetric];
186    }
187
188    public function value(): BlocksValue
189    {
190        return new BlocksValue($this->owner, $this, $this->valueContext);
191    }
192
193    public function structure(mixed $value = null): array
194    {
195        $value ??= $this->valueContext->data['value'] ?? $this->default ?? [];
196
197        return [
198            'type' => $this::class,
199            'value' => $this->structureMap(is_array($value) ? $value : []),
200        ];
201    }
202
203    public function shape(): Shape
204    {
205        $shape = Shapes::create();
206        $this->addType($shape);
207        $rows = $this->rowsShape();
208
209        if ($this->isAsymmetricallyTranslated()) {
210            $locales = $this->owner->locales();
211            $defaultLocale = $locales->getDefault()->id;
212            $i18nShape = Shapes::create();
213
214            foreach ($locales as $locale) {
215                $localeField = $i18nShape
216                    ->add($locale->id, $rows)
217                    ->label($this->valueLabel($locale))
218                    ->prepare(Prepare::nullAsEmpty(...));
219
220                if ($this->isRequired() && $locale->id === $defaultLocale) {
221                    $localeField->rules('required');
222                } else {
223                    $localeField->optional()->nullable();
224                }
225            }
226
227            $value = $shape
228                ->add('value', $i18nShape)
229                ->rules(...$this->validators)
230                ->prepare(Prepare::nullAsEmpty(...));
231        } else {
232            $value = $shape
233                ->add('value', $this->zxxShape($rows, $this->validators))
234                ->prepare(Prepare::nullAsEmpty(...));
235        }
236
237        if (!$this->isRequired()) {
238            $value->optional()->nullable();
239        }
240
241        $this->addMeta($shape);
242
243        return $shape;
244    }
245
246    protected function rowKind(): string
247    {
248        return 'block';
249    }
250
251    /** Nested typed repeaters are rejected, as on entry types. */
252    protected function assertRowField(string $type, Definition $definition): void
253    {
254        foreach ([Entries::class => 'entries', self::class => 'blocks'] as $class => $kind) {
255            if (is_a($definition->type, $class, true)) {
256                throw new RuntimeException(
257                    "Blocks field '{$this->name}' cannot contain nested {$kind} field"
258                        . " '{$definition->name}' in block type '{$type}'",
259                );
260            }
261        }
262    }
263
264    /**
265     * The translation mode rule: only a symmetric list translates its
266     * sub-fields â€” a per-locale list already translates the whole block,
267     * an untranslated list nothing. The field's `#[Tools]` feeds every
268     * richtext sub-field that declares none of its own.
269     */
270    protected function configureRowField(Field $field, Definition $definition): void
271    {
272        if ($field instanceof Capability\Translatable && !$this->isSymmetricallyTranslated()) {
273            $field->translate(null);
274        }
275
276        if (
277            $field instanceof Capability\ToolsAware
278            && $this->tools !== []
279            && $definition->property->getAttributes(Tools::class) === []
280        ) {
281            $field->tools(...$this->tools);
282        }
283    }
284
285    /**
286     * @param class-string<Block> $type
287     * @return array{type: class-string, handle: string, label: string, icon: ?array, labels: bool, fields: list<array>, fieldsets: list<array>}
288     */
289    protected function blockTypeProperties(string $type): array
290    {
291        $properties = $this->rowTypeProperties($type);
292        $visible = array_filter(
293            $properties['fields'],
294            static fn(array $field): bool => !($field['hidden'] ?? false),
295        );
296
297        return [
298            'type' => $type,
299            'handle' => $this->blockHandle($type),
300            'label' => $properties['label'],
301            'icon' => $this->nodeTypes()->get($type, 'icon'),
302            // A block with one field says what that field is; the label
303            // below the block's own would only repeat it. #[Labels] keeps it.
304            'labels' =>
305                count($visible) !== 1
306                    || (bool) $this->nodeTypes()->get($type, 'labels', false),
307            'fields' => array_map(self::unmarked(...), $properties['fields']),
308            'fieldsets' => $properties['fieldsets'],
309        ];
310    }
311
312    /**
313     * A block marks none of its fields as required. Reaching for a block is
314     * what makes its content mandatory, so the mark tells the editor nothing
315     * it does not already see. Only the panel's view of the field loses it â€”
316     * the shape still validates, built from the field itself.
317     *
318     * @param array<string, mixed> $field
319     * @return array<string, mixed>
320     */
321    private static function unmarked(array $field): array
322    {
323        unset($field['required']);
324
325        return $field;
326    }
327
328    /**
329     * A plain list lands in the shared or default-locale list; a locale
330     * map is taken per locale.
331     *
332     * @return array<string, list<array>>
333     */
334    private function structureMap(array $value): array
335    {
336        if ($this->isAsymmetricallyTranslated()) {
337            $map = array_is_list($value) ? [$this->owner->defaultLocale()->id => $value] : $value;
338            $result = [];
339
340            foreach ($this->owner->locales() as $locale) {
341                $result[$locale->id] = $this->rowStructures($map[$locale->id] ?? []);
342            }
343
344            return $result;
345        }
346
347        $rows = array_is_list($value) ? $value : $value[self::NEUTRAL_LOCALE] ?? [];
348
349        return [self::NEUTRAL_LOCALE => $this->rowStructures($rows)];
350    }
351
352    /** @return list<array> */
353    private function rowStructures(mixed $rows): array
354    {
355        $result = [];
356
357        foreach (is_array($rows) ? $rows : [] as $row) {
358            if (!is_array($row)) {
359                continue;
360            }
361
362            $type = $row['type'] ?? null;
363
364            if (!is_string($type) || !$this->allows($type)) {
365                continue;
366            }
367
368            $fields = is_array($row['fields'] ?? null) ? $row['fields'] : [];
369            $structure = [
370                'uid' => is_string($row['uid'] ?? null) ? $row['uid'] : null,
371                'type' => $type,
372                'layout' => Layout::normalize($row['layout'] ?? null, $this->columns, $this->min)->array(),
373                'fields' => $this->rowStructure($type, $fields),
374            ];
375
376            if (is_array($row['meta'] ?? null) && $row['meta'] !== []) {
377                $structure['meta'] = $row['meta'];
378            }
379
380            $result[] = $structure;
381        }
382
383        return $result;
384    }
385
386    /**
387     * One row: uid, an allowed type, a layout inside the field's bounds
388     * (out-of-range values are rejected, not clamped, so a programmatic
389     * write fails loudly), the type's fields and the block meta.
390     */
391    private function rowsShape(): Shape
392    {
393        $layout = Shapes::create();
394        $layout->add('colspan', 'int')->rules('required', "min:{$this->min}", "max:{$this->columns}");
395        $layout->add('rowspan', 'int')->rules('required', 'min:1', 'max:' . Layout::MAX_ROWSPAN);
396        $layout->add('indent', 'int')->rules('required', 'min:0', 'max:' . ($this->columns - $this->min));
397
398        $rows = Shapes::list();
399        $rows->add('uid', 'string')->rules('required');
400        $rows
401            ->add('type', 'string')
402            ->rules('required', 'in:' . implode(',', $this->allowedBlockTypes()));
403        $rows->add('layout', $layout)->rules('required');
404        $rows
405            ->add('fields', Shapes::create())
406            ->rules('required')
407            ->finalize($this->finalizeRowFields(...));
408        $rows->add('meta', Shapes::create()->extra(Extra::Allow))->optional()->nullable();
409        $rows->review($this->reviewRows(...));
410
411        return $rows;
412    }
413
414    private function reviewRows(Review $review): void
415    {
416        $this->reviewRowFields($review);
417
418        foreach ($review->values() as $index => $row) {
419            if (((int) $row['layout']['colspan'] + (int) $row['layout']['indent']) > $this->columns) {
420                $review->addError([$index, 'layout', 'indent'], __('block:invalid-indent'));
421            }
422        }
423    }
424}