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