Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.40% covered (success)
99.40%
165 / 166
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
FormPatch
99.40% covered (success)
99.40%
165 / 166
88.89% covered (warning)
88.89%
8 / 9
85
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 content
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 entry
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
14
 meta
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 cast
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
21
 entries
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 blocks
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
11
 rows
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
21
 rowTypes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Panel;
6
7use Closure;
8use Cosray\Block\Layout;
9use Cosray\Uid;
10
11/**
12 * Patches stored node content with submitted editor form data.
13 *
14 * The form is a per-field patch, never a reconstruction: only fields the
15 * form actually carries are replaced, unknown keys inside the stored
16 * content survive untouched. Primitive leaves are cast according to the
17 * field's control descriptor; rich fields submit their complete value
18 * (and optionally meta) as one JSON string under the [json] key.
19 */
20final class FormPatch
21{
22    /** @param list<array> $fields field property payloads incl. control descriptors */
23    public function __construct(
24        private readonly array $fields,
25        private readonly Uid $uid = new Uid(Uid::ALPHABET_LOWERCASE_WORD_SAFE, 13),
26    ) {}
27
28    public function content(array $stored, array $submitted): array
29    {
30        foreach ($this->fields as $field) {
31            $name = $field['name'] ?? null;
32
33            if (!is_string($name) || !is_array($submitted[$name] ?? null)) {
34                continue;
35            }
36
37            $entry = $stored[$name] ?? ['type' => $field['type'] ?? null, 'value' => []];
38            $patched = $this->entry(
39                $field['control'] ?? [],
40                $field['metaControl'] ?? null,
41                $entry,
42                $submitted[$name],
43            );
44
45            if ($patched !== null) {
46                $stored[$name] = $patched;
47            }
48        }
49
50        return $stored;
51    }
52
53    private function entry(array $control, ?array $metaControl, array $entry, array $submitted): ?array
54    {
55        $json = $submitted['json'] ?? null;
56
57        if (is_string($json)) {
58            $decoded = json_decode($json, true);
59
60            if (!is_array($decoded)) {
61                return null;
62            }
63
64            if (array_key_exists('value', $decoded)) {
65                $entry['value'] = $decoded['value'];
66            }
67
68            if (array_key_exists('meta', $decoded)) {
69                $entry['meta'] = $decoded['meta'];
70            }
71
72            // Format envelope of structured richtext values.
73            foreach (['format', 'version'] as $key) {
74                if (isset($decoded[$key])) {
75                    $entry[$key] = $decoded[$key];
76                }
77            }
78
79            return $entry;
80        }
81
82        $changed = false;
83        $value = $submitted['value'] ?? null;
84
85        if (is_array($value)) {
86            $stored = is_array($entry['value'] ?? null) ? $entry['value'] : [];
87
88            foreach ($value as $locale => $raw) {
89                $stored[$locale] = $this->cast($control, $raw, $stored[$locale] ?? null);
90            }
91
92            $entry['value'] = $stored;
93            $changed = true;
94        }
95
96        $meta = $submitted['meta'] ?? null;
97
98        if (is_array($meta) && is_array($metaControl)) {
99            $entry['meta'] = $this->meta(
100                $metaControl,
101                is_array($entry['meta'] ?? null) ? $entry['meta'] : [],
102                $meta,
103            );
104            $changed = true;
105        }
106
107        return $changed ? $entry : null;
108    }
109
110    /**
111     * Replace the meta entries the metaControl group knows; unknown
112     * stored meta keys survive.
113     */
114    private function meta(array $metaControl, array $stored, array $submitted): array
115    {
116        foreach ($metaControl['props']['fields'] ?? [] as $sub) {
117            $key = $sub['key'] ?? null;
118
119            if (!is_string($key) || !is_array($submitted[$key] ?? null)) {
120                continue;
121            }
122
123            $map = is_array($stored[$key] ?? null) ? $stored[$key] : [];
124
125            foreach ($submitted[$key] as $locale => $raw) {
126                $map[$locale] = $this->cast($sub['control'] ?? [], $raw, $map[$locale] ?? null);
127            }
128
129            $stored[$key] = $map;
130        }
131
132        return $stored;
133    }
134
135    private function cast(array $control, mixed $raw, mixed $stored): mixed
136    {
137        $name = $control['name'] ?? '';
138        $props = $control['props'] ?? [];
139
140        if ($name === 'group') {
141            // Replace only the keys the descriptor knows; anything else
142            // stored inside the group survives.
143            $result = is_array($stored) ? $stored : [];
144
145            foreach ($props['fields'] ?? [] as $sub) {
146                $key = $sub['key'] ?? null;
147
148                if (!is_string($key) || !is_array($raw) || !array_key_exists($key, $raw)) {
149                    continue;
150                }
151
152                $result[$key] = $this->cast($sub['control'] ?? [], $raw[$key], $result[$key] ?? null);
153            }
154
155            return $result;
156        }
157
158        if ($name === 'repeater') {
159            // Lists are replaced wholesale; index gaps left by removed
160            // rows are normalized away.
161            $item = $props['item'] ?? [];
162
163            return array_map(
164                fn(mixed $rawItem): mixed => $this->cast($item, $rawItem, null),
165                is_array($raw) ? array_values($raw) : [],
166            );
167        }
168
169        if ($name === 'entries' || $name === 'blocks') {
170            $rows = is_array($raw) ? array_values($raw) : [];
171            $stored = is_array($stored) ? $stored : [];
172
173            return $name === 'entries'
174                ? $this->entries($props, $rows, $stored)
175                : $this->blocks($props, $rows, $stored);
176        }
177
178        return match ($name) {
179            'checkbox' => $raw === '1' || $raw === 'on' || $raw === true,
180            'number' => is_numeric($raw) ? (float) $raw : null,
181            default => is_scalar($raw) ? (string) $raw : null,
182        };
183    }
184
185    private function entries(array $props, array $rows, array $stored): array
186    {
187        return $this->rows(
188            self::rowTypes($props['entryTypes'] ?? []),
189            $rows,
190            $stored,
191            static fn(array $storedRow, array $row, string $uid, string $type, array $fields): array => [
192                ...$storedRow,
193                'uid' => $uid,
194                'type' => $type,
195                'fields' => $fields,
196            ],
197        );
198    }
199
200    /**
201     * Block rows add the layout â€” ints clamped into the field's grid, so
202     * a stored out-of-range value the editor loaded saves back clamped,
203     * where the shape would reject it â€” and the block meta map, patched
204     * like a field's meta against the descriptor's meta group.
205     */
206    private function blocks(array $props, array $rows, array $stored): array
207    {
208        $columns = is_int($props['columns'] ?? null) && $props['columns'] > 0 ? $props['columns'] : 1;
209        $min = is_int($props['min'] ?? null) && $props['min'] > 0 ? min($props['min'], $columns) : 1;
210        $metaControl = is_array($props['meta'] ?? null) ? $props['meta'] : null;
211
212        return $this->rows(
213            self::rowTypes($props['blockTypes'] ?? []),
214            $rows,
215            $stored,
216            function (array $storedRow, array $row, string $uid, string $type, array $fields) use (
217                $columns,
218                $min,
219                $metaControl,
220            ): array {
221                $layout = [
222                    ...(is_array($storedRow['layout'] ?? null) ? $storedRow['layout'] : []),
223                    ...(is_array($row['layout'] ?? null) ? $row['layout'] : []),
224                ];
225                $result = [
226                    ...$storedRow,
227                    'uid' => $uid,
228                    'type' => $type,
229                    'layout' => Layout::normalize($layout, $columns, $min)->array(),
230                    'fields' => $fields,
231                ];
232
233                if ($metaControl !== null && is_array($row['meta'] ?? null)) {
234                    $result['meta'] = $this->meta(
235                        $metaControl,
236                        is_array($storedRow['meta'] ?? null) ? $storedRow['meta'] : [],
237                        $row['meta'],
238                    );
239                }
240
241                return $result;
242            },
243        );
244    }
245
246    /**
247     * Rows are replaced wholesale like a repeater, but each row's fields
248     * are patched like a group: rows are matched to their stored
249     * counterpart by uid, so unknown keys survive edits and reorders.
250     * `$build` assembles the row from the matched stored row (empty when
251     * the type changed), the submitted row, the uid and the patched fields.
252     *
253     * @param array<string, array> $types row type descriptors keyed by class
254     * @param Closure(array, array, string, string, array): array $build
255     */
256    private function rows(array $types, array $rows, array $stored, Closure $build): array
257    {
258        $byUid = [];
259
260        foreach ($stored as $storedRow) {
261            $uid = is_array($storedRow) ? $storedRow['uid'] ?? null : null;
262
263            if (is_string($uid) && $uid !== '') {
264                $byUid[$uid] = $storedRow;
265            }
266        }
267
268        $result = [];
269
270        foreach ($rows as $row) {
271            if (!is_array($row)) {
272                continue;
273            }
274
275            $type = $row['type'] ?? null;
276
277            if (!is_string($type) || !isset($types[$type])) {
278                continue;
279            }
280
281            $uid = $row['uid'] ?? null;
282
283            if (!is_string($uid) || $uid === '') {
284                // The client fills fresh uids on stamped rows; this is the
285                // safety net for rows arriving without one.
286                $uid = $this->uid->generate();
287            }
288
289            $storedRow = $byUid[$uid] ?? [];
290
291            if (($storedRow['type'] ?? null) !== $type) {
292                $storedRow = [];
293            }
294
295            $fields = is_array($storedRow['fields'] ?? null) ? $storedRow['fields'] : [];
296            $submitted = is_array($row['fields'] ?? null) ? $row['fields'] : [];
297
298            foreach ($types[$type]['fields'] ?? [] as $sub) {
299                $subName = $sub['name'] ?? null;
300
301                if (!is_string($subName) || !is_array($submitted[$subName] ?? null)) {
302                    continue;
303                }
304
305                $entry = is_array($fields[$subName] ?? null)
306                    ? $fields[$subName]
307                    : ['type' => $sub['type'] ?? null, 'value' => []];
308                $patched = $this->entry(
309                    is_array($sub['control'] ?? null) ? $sub['control'] : [],
310                    is_array($sub['metaControl'] ?? null) ? $sub['metaControl'] : null,
311                    $entry,
312                    $submitted[$subName],
313                );
314
315                if ($patched !== null) {
316                    $fields[$subName] = $patched;
317                }
318            }
319
320            $result[] = $build($storedRow, $row, $uid, $type, $fields);
321        }
322
323        return $result;
324    }
325
326    /** @return array<string, array> */
327    private static function rowTypes(mixed $types): array
328    {
329        $result = [];
330
331        foreach (is_array($types) ? $types : [] as $type) {
332            if (is_array($type) && is_string($type['type'] ?? null)) {
333                $result[$type['type']] = $type;
334            }
335        }
336
337        return $result;
338    }
339}