Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
75 / 75
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
RowTypes
100.00% covered (success)
100.00%
75 / 75
100.00% covered (success)
100.00%
11 / 11
26
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%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 finalizeRowFields
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 reviewRowFields
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 rowShape
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 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    abstract public function allows(string $type): bool;
24
25    /** The word for one row in messages: 'entry' or 'block'. */
26    abstract protected function rowKind(): string;
27
28    /** @param class-string $type */
29    abstract protected function assertRowField(string $type, Definition $definition): void;
30
31    /** Applied to every row field after `init()`. */
32    protected function configureRowField(Field $field, Definition $definition): void
33    {
34        unset($field, $definition);
35    }
36
37    /**
38     * @param class-string $type
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>, fieldsets: list<array>}
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 class-string $type
134     * @param array<string, mixed> $value
135     * @return array<string, array>
136     */
137    protected function rowStructure(string $type, array $value): array
138    {
139        $structure = [];
140
141        foreach ($this->rowFieldsFor($type) as $name => $field) {
142            $fieldData = $value[$name] ?? null;
143            $fieldValue = is_array($fieldData) ? $fieldData['value'] ?? null : null;
144            $fieldStructure = $field->structure($fieldValue);
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     * rows arrive here with an allowed `type` and array `fields`.
162     *
163     * @param array<string, mixed> $values
164     */
165    protected function finalizeRowFields(mixed $value, array $values): mixed
166    {
167        $result = $this->rowShape((string) $values['type'])->validate(is_array($value) ? $value : []);
168
169        return $result->valid() ? $result->values() : $value;
170    }
171
172    protected function reviewRowFields(Review $review): void
173    {
174        foreach ($review->values() as $index => $row) {
175            $result = $this->rowShape((string) $row['type'])->validate(is_array($row['fields']) ? $row['fields'] : []);
176
177            if ($result->valid()) {
178                continue;
179            }
180
181            foreach ($result->issues() as $issue) {
182                $review->addError(
183                    [$index, 'fields', ...$issue->path],
184                    $issue->message,
185                    $issue->code,
186                    $issue->params,
187                );
188            }
189        }
190    }
191
192    /** @param class-string $type */
193    protected function rowShape(string $type): Shape
194    {
195        $shape = Shapes::create();
196
197        foreach ($this->rowFieldsFor($type) as $name => $field) {
198            $shape
199                ->add($name, $field->shape())
200                ->optional()
201                ->nullable()
202                ->prepare(Prepare::nullAsEmpty(...));
203        }
204
205        return $shape;
206    }
207
208    protected function nodeTypes(): Types
209    {
210        return $this->services()->types;
211    }
212
213    private function fieldKind(): string
214    {
215        return basename(str_replace('\\', '/', self::class));
216    }
217}