Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.91% covered (warning)
87.91%
80 / 91
69.23% covered (warning)
69.23%
9 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Entries
87.91% covered (warning)
87.91%
80 / 91
69.23% covered (warning)
69.23%
9 / 13
32.70
0.00% covered (danger)
0.00%
0 / 1
 control
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 value
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 structure
82.61% covered (warning)
82.61%
19 / 23
0.00% covered (danger)
0.00%
0 / 1
8.34
 shape
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 allow
64.29% covered (warning)
64.29%
9 / 14
0.00% covered (danger)
0.00%
0 / 1
7.64
 allowedEntryTypes
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 entryFields
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 entryFieldsFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 properties
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 allows
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rowKind
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 assertRowField
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 requireAllowedEntryTypes
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Field;
6
7use Celema\Sire\Shape;
8use Cosray\Exception\RuntimeException;
9use Cosray\Validation\Prepare;
10use Cosray\Validation\Shapes;
11use Cosray\Value\Entries as EntriesValue;
12
13class Entries extends Field implements Capability\Limitable
14{
15    use Capability\IsLimitable;
16    use RowTypes;
17
18    /** @var list<class-string> */
19    protected array $allowedEntryTypes = [];
20
21    public function control(): Control
22    {
23        $this->requireAllowedEntryTypes();
24
25        $control = Control::entries()->prop('entryTypes', array_map(
26            $this->rowTypeProperties(...),
27            $this->allowedEntryTypes,
28        ));
29
30        if ($this->limitMin > 0) {
31            $control = $control->prop('min', $this->limitMin);
32        }
33
34        if ($this->limitMax >= 1) {
35            $control = $control->prop('max', $this->limitMax);
36        }
37
38        return $control;
39    }
40
41    public function value(): EntriesValue
42    {
43        $this->requireAllowedEntryTypes();
44
45        return new EntriesValue($this->owner, $this, $this->valueContext);
46    }
47
48    public function structure(mixed $value = null): array
49    {
50        $this->requireAllowedEntryTypes();
51        $value ??= $this->valueContext->data['value'][self::NEUTRAL_LOCALE] ?? $this->default ?? [];
52
53        if (!is_array($value)) {
54            $value = [];
55        }
56
57        $structures = [];
58
59        foreach ($value as $entryData) {
60            if (!is_array($entryData)) {
61                continue;
62            }
63
64            $type = $entryData['type'] ?? null;
65
66            if (!is_string($type) || !$this->allows($type)) {
67                continue;
68            }
69
70            $entryValue = $entryData['fields'] ?? [];
71
72            if (!is_array($entryValue)) {
73                $entryValue = [];
74            }
75
76            $structures[] = [
77                'uid' => is_string($entryData['uid'] ?? null) ? $entryData['uid'] : null,
78                'type' => $type,
79                'fields' => $this->rowStructure($type, $entryValue),
80            ];
81        }
82
83        return [
84            'type' => $this::class,
85            'value' => [self::NEUTRAL_LOCALE => $structures],
86        ];
87    }
88
89    public function shape(): Shape
90    {
91        $this->requireAllowedEntryTypes();
92
93        $shape = Shapes::create();
94        $this->addType($shape);
95
96        $itemShape = Shapes::list();
97        $itemShape
98            ->add('uid', 'string')
99            ->rules('required');
100        $itemShape
101            ->add('type', 'string')
102            ->rules('required', 'in:' . implode(',', $this->allowedEntryTypes));
103        $itemShape
104            ->add('fields', Shapes::create())
105            ->rules('required')
106            ->finalize($this->finalizeRowFields(...));
107        $itemShape->review($this->reviewRowFields(...));
108
109        $value = $shape
110            ->add('value', $this->zxxShape($itemShape, $this->limitValidators()))
111            ->rules(...$this->validators)
112            ->prepare(Prepare::nullAsEmpty(...));
113
114        if (!$this->isRequired()) {
115            $value->optional()->nullable();
116        }
117
118        $this->addMeta($shape);
119
120        return $shape;
121    }
122
123    /** @param class-string ...$types */
124    public function allow(string ...$types): static
125    {
126        if ($types === []) {
127            throw new RuntimeException('Entries fields require at least one allowed entry type');
128        }
129
130        foreach ($types as $type) {
131            if (!class_exists($type)) {
132                throw new RuntimeException("Entries field '{$this->name}' allows unknown entry type '{$type}'");
133            }
134
135            if ($type === self::class || is_subclass_of($type, self::class)) {
136                throw new RuntimeException(
137                    "Entries field '{$this->name}' entry type '{$type}' must not extend Entries",
138                );
139            }
140        }
141
142        $this->allowedEntryTypes = array_values(array_unique([
143            ...$this->allowedEntryTypes,
144            ...$types,
145        ]));
146
147        return $this;
148    }
149
150    /** @return list<class-string> */
151    public function allowedEntryTypes(): array
152    {
153        $this->requireAllowedEntryTypes();
154
155        return $this->allowedEntryTypes;
156    }
157
158    /** @return array<string, Field> */
159    public function entryFields(?string $type = null): array
160    {
161        $this->requireAllowedEntryTypes();
162        $type ??= $this->allowedEntryTypes[0];
163
164        return $this->rowFieldsFor($type);
165    }
166
167    /**
168     * @param class-string $type
169     * @param array<string, mixed> $data
170     * @return array<string, Field>
171     */
172    public function entryFieldsFor(string $type, array $data = []): array
173    {
174        return $this->rowFieldsFor($type, $data);
175    }
176
177    public function properties(): array
178    {
179        $result = parent::properties();
180        $result['type'] = Entries::class;
181
182        return $result;
183    }
184
185    public function allows(string $type): bool
186    {
187        return in_array($type, $this->allowedEntryTypes, true);
188    }
189
190    protected function rowKind(): string
191    {
192        return 'entry';
193    }
194
195    /**
196     * Nested typed repeaters are rejected: rows renumber against one
197     * `[data-repeater]` base, so a repeater inside a row cannot be
198     * renumbered yet.
199     */
200    protected function assertRowField(string $type, Definition $definition): void
201    {
202        foreach ([self::class => 'entries', Blocks::class => 'blocks'] as $class => $kind) {
203            if (is_a($definition->type, $class, true)) {
204                throw new RuntimeException(
205                    "Entries field '{$this->name}' cannot contain nested {$kind} field"
206                        . " '{$definition->name}' in entry type '{$type}'",
207                );
208            }
209        }
210    }
211
212    protected function requireAllowedEntryTypes(): void
213    {
214        if ($this->allowedEntryTypes === []) {
215            throw new RuntimeException("Entries field '{$this->name}' requires #[Allows(...)]");
216        }
217    }
218}