Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
83 / 83
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
1 / 1
RowTypes
100.00% covered (success)
100.00%
83 / 83
100.00% covered (success)
100.00%
12 / 12
32
100.00% covered (success)
100.00%
1 / 1
 allows
n/a
0 / 0
n/a
0 / 0
0
 rowKind
n/a
0 / 0
n/a
0 / 0
0
 assertRowField
n/a
0 / 0
n/a
0 / 0
0
 configureRowField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rowFieldsFor
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 buildRowFields
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 orderedRowFields
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 rowTypeProperties
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 rowStructure
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 finalizeRowFields
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 reviewRowFields
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 rowShape
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 rowHandle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 nodeTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fieldKind
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Field;
6
7use Celema\Sire\Review;
8use Celema\Sire\Shape;
9use Cosray\Exception\RuntimeException;
10use Cosray\Node\Types;
11use Cosray\Validation\Prepare;
12use Cosray\Validation\Shapes;
13use Cosray\Value\ValueContext;
14
15/**
16 * Shared by fields whose value is a list of typed rows (Entries, Blocks).
17 * A row type is a class whose Field properties declare the row's schema;
18 * its fields are built, ordered, validated and serialized the same way
19 * for both fields.
20 */
21trait RowTypes
22{
23    /** @phpstan-assert-if-true class-string $type */
24    abstract public function allows(string $type): bool;
25
26    /** The word for one row in messages: 'entry' or 'block'. */
27    abstract protected function rowKind(): string;
28
29    /** @param class-string $type */
30    abstract protected function assertRowField(string $type, Definition $definition): void;
31
32    /** Applied to every row field after `init()`. */
33    protected function configureRowField(Field $field, Definition $definition): void
34    {
35        unset($field, $definition);
36    }
37
38    /**
39     * @param array<string, mixed> $data
40     * @return array<string, Field>
41     */
42    protected function rowFieldsFor(string $type, array $data = []): array
43    {
44        if (!$this->allows($type)) {
45            throw new RuntimeException(
46                "{$this->fieldKind()} field '{$this->name}' does not allow {$this->rowKind()} type '{$type}'",
47            );
48        }
49
50        return $this->orderedRowFields($type, $this->buildRowFields($type, $data));
51    }
52
53    /**
54     * @param class-string $type
55     * @param array<string, mixed> $data
56     * @return array<string, Field>
57     */
58    protected function buildRowFields(string $type, array $data): array
59    {
60        $fields = [];
61
62        foreach (Definitions::for($type)->fields() as $definition) {
63            $this->assertRowField($type, $definition);
64            $name = $definition->name;
65            $fieldData = $data[$name] ?? [];
66
67            if (!is_array($fieldData)) {
68                $fieldData = [];
69            }
70
71            $fieldClass = $definition->type;
72            $field = new $fieldClass($name, $this->owner, new ValueContext($name, $fieldData));
73            $field->init($this->services(), $definition->property);
74            $this->configureRowField($field, $definition);
75            $fields[$name] = $field;
76        }
77
78        return $fields;
79    }
80
81    /**
82     * @param class-string $type
83     * @param array<string, Field> $fields
84     * @return array<string, Field>
85     */
86    protected function orderedRowFields(string $type, array $fields): array
87    {
88        $order = $this->nodeTypes()->get($type, 'fieldOrder');
89
90        if (!is_array($order)) {
91            return $fields;
92        }
93
94        // The node schema validated the order against the type's fields.
95        $ordered = [];
96
97        foreach ($order as $name) {
98            $ordered[$name] = $fields[$name];
99        }
100
101        return [...$ordered, ...array_diff_key($fields, $ordered)];
102    }
103
104    /**
105     * The per-type field table carried in the control descriptor: the
106     * editor views render rows and templates from it, and the form patch
107     * casts submitted rows against it.
108     *
109     * @param class-string $type
110     * @return array{type: class-string, label: string, fields: list<array<string, mixed>>, fieldsets: list<array<string, mixed>>}
111     */
112    protected function rowTypeProperties(string $type): array
113    {
114        $fields = $this->rowFieldsFor($type);
115
116        return [
117            'type' => $type,
118            'label' => __((string) $this->nodeTypes()->get($type, 'label')),
119            'fields' => array_values(array_map(
120                static fn(Field $field): array => $field->properties(),
121                $fields,
122            )),
123            'fieldsets' => Fieldsets::serialize(
124                Definitions::for($type)->fieldsets(),
125                array_keys($fields),
126                $fields,
127                $type,
128            ),
129        ];
130    }
131
132    /**
133     * @param array<string, mixed> $value
134     * @return array<string, array<string, mixed>>
135     */
136    protected function rowStructure(string $type, array $value): array
137    {
138        $structure = [];
139
140        foreach ($this->rowFieldsFor($type) as $name => $field) {
141            $fieldData = $value[$name] ?? null;
142            $fieldStructure = is_array($fieldData) && array_key_exists('value', $fieldData)
143                ? $field->structure($fieldData['value'])
144                : $field->structure();
145
146            if (is_array($fieldData)) {
147                $structure[$name] = array_replace_recursive($fieldStructure, $fieldData);
148                $structure[$name]['type'] = $fieldStructure['type'];
149
150                continue;
151            }
152
153            $structure[$name] = $fieldStructure;
154        }
155
156        return $structure;
157    }
158
159    /**
160     * Sire runs finalize and review callbacks on rule-clean data only, so
161     * a row's `type` is allowed unless it is empty. A row without one â€” a
162     * split of a Blocks field â€” has no fields of its own; the field's
163     * review reports whatever else is wrong with it.
164     *
165     * @param array<string, mixed> $values
166     */
167    protected function finalizeRowFields(mixed $value, array $values): mixed
168    {
169        $type = $values['type'] ?? '';
170
171        if (!is_string($type) || $type === '') {
172            return $value;
173        }
174
175        $result = $this->rowShape($type)->validate(is_array($value) ? $value : []);
176
177        return $result->valid() ? $result->values() : $value;
178    }
179
180    protected function reviewRowFields(Review $review): void
181    {
182        foreach ($review->values() as $index => $row) {
183            $type = $row['type'] ?? '';
184
185            if (!is_string($type) || $type === '') {
186                continue;
187            }
188
189            $result = $this->rowShape($type)->validate(is_array($row['fields'] ?? null) ? $row['fields'] : []);
190
191            if ($result->valid()) {
192                continue;
193            }
194
195            foreach ($result->issues() as $issue) {
196                $review->addError(
197                    [$index, 'fields', ...$issue->path],
198                    $issue->message,
199                    $issue->code,
200                    $issue->params,
201                );
202            }
203        }
204    }
205
206    protected function rowShape(string $type): Shape
207    {
208        $shape = Shapes::create();
209
210        foreach ($this->rowFieldsFor($type) as $name => $field) {
211            $shape
212                ->add($name, $field->shape())
213                ->optional()
214                ->nullable()
215                ->prepare(Prepare::nullAsEmpty(...));
216        }
217
218        return $shape;
219    }
220
221    /** A row type's `#[Handle]`, otherwise derived from its class name. */
222    public function rowHandle(string $type): string
223    {
224        /** @var class-string $type */
225        return (string) $this->nodeTypes()->get($type, 'handle');
226    }
227
228    protected function nodeTypes(): Types
229    {
230        return $this->services()->types;
231    }
232
233    private function fieldKind(): string
234    {
235        return basename(str_replace('\\', '/', self::class));
236    }
237}