Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.67% covered (success)
94.67%
71 / 75
85.71% covered (warning)
85.71%
18 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Field
94.67% covered (success)
94.67%
71 / 75
85.71% covered (warning)
85.71%
18 / 21
39.23
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
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 value
n/a
0 / 0
n/a
0 / 0
0
 structure
n/a
0 / 0
n/a
0 / 0
0
 shape
n/a
0 / 0
n/a
0 / 0
0
 isset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 services
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stored
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 control
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 metaControl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 properties
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 localize
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 localizeOption
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 getFileStructure
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getSimpleStructure
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 addType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addMeta
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 metaShape
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 valueLabel
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 zxxShape
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 scalarValue
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 scalarValueMap
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 listValueMap
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Field;
6
7use Celema\Sire\Contract;
8use Celema\Sire\Extra;
9use Celema\Sire\Shape;
10use Cosray\Exception\RuntimeException;
11use Cosray\Field\Schema\Handler;
12use Cosray\Locale;
13use Cosray\Validation\Shapes;
14use Cosray\Value\Value;
15use Cosray\Value\ValueContext;
16use Override;
17use ReflectionProperty;
18
19abstract class Field implements
20    Capability\Defaultable,
21    Capability\Describable,
22    Capability\Hidable,
23    Capability\Immutable,
24    Capability\Labelable,
25    Capability\Requirable,
26    Capability\Resizable,
27    Capability\Validatable
28{
29    use Capability\IsRequirable;
30    use Capability\IsLabelable;
31    use Capability\IsDescribable;
32    use Capability\IsHidable;
33    use Capability\IsImmutable;
34    use Capability\IsDefaultable;
35    use Capability\IsResizable;
36    use Capability\IsValidatable;
37
38    public const string NEUTRAL_LOCALE = 'zxx';
39
40    public readonly string $type;
41
42    /** @var list<array{object, Handler}> */
43    protected array $meta = [];
44
45    protected ?Services $services = null;
46
47    /**
48     * The stored data as-is, unaffected by When deactivation.
49     *
50     * @var array<string, mixed>
51     */
52    protected array $stored = [];
53
54    final public function __construct(
55        public readonly string $name,
56        public readonly Owner $owner,
57        protected readonly ValueContext $valueContext,
58    ) {
59        $this->type = $this::class;
60    }
61
62    #[Override]
63    public function __toString(): string
64    {
65        return $this->value()->__toString();
66    }
67
68    abstract public function value(): Value;
69
70    /** @return array<string, mixed> */
71    abstract public function structure(mixed $value = null): array;
72
73    abstract public function shape(): Shape;
74
75    public function isset(): bool
76    {
77        return $this->value()->isset();
78    }
79
80    /** @param array<string, mixed> $stored */
81    public function init(
82        Services $services,
83        ?ReflectionProperty $property = null,
84        array $stored = [],
85    ): void {
86        $this->services = $services;
87        $this->stored = $stored;
88
89        if ($property === null) {
90            return;
91        }
92
93        foreach ($property->getAttributes() as $attr) {
94            $instance = $attr->newInstance();
95            $handler = $services->schemas->getHandler($instance);
96
97            if ($handler === null) {
98                continue;
99            }
100
101            $handler->apply($instance, $this);
102            $this->meta[] = [$instance, $handler];
103        }
104    }
105
106    public function services(): Services
107    {
108        return $this->services ?? throw new RuntimeException("Field '{$this->name}' is not initialized");
109    }
110
111    /**
112     * The stored data as-is â€” the deliberate bypass around When
113     * deactivation for consumers that need the dormant value.
114     *
115     * @return array<string, mixed>
116     */
117    public function stored(): array
118    {
119        return $this->stored;
120    }
121
122    public function control(): Control
123    {
124        return Control::text();
125    }
126
127    /**
128     * Describes the editor UI for the field's meta map â€” a group whose
129     * sub-control keys name the meta entries. Null means the field has
130     * no user-editable meta.
131     */
132    public function metaControl(): ?Control
133    {
134        return null;
135    }
136
137    /** @return array<string, mixed> */
138    public function properties(): array
139    {
140        $properties = [
141            'name' => $this->name,
142            'type' => $this::class,
143            'control' => $this->control()->resolve($this->services()->controls)->array(),
144        ];
145
146        $metaControl = $this->metaControl();
147
148        if ($metaControl !== null) {
149            $properties['metaControl'] = $metaControl->resolve($this->services()->controls)->array();
150        }
151
152        foreach ($this->meta as [$meta, $handler]) {
153            $properties = array_merge($properties, $handler->properties($meta, $this));
154        }
155
156        return $this->localize($properties);
157    }
158
159    /**
160     * Translate the field's display strings for the active locale at emit time.
161     * The schema (and thus the raw attribute strings) stays untouched because
162     * it is cached per class; only the serialized copy is localized.
163     *
164     * @param array<string, mixed> $properties
165     * @return array<string, mixed>
166     */
167    private function localize(array $properties): array
168    {
169        foreach (['label', 'description', 'placeholder'] as $key) {
170            $value = $properties[$key] ?? null;
171
172            if (is_string($value)) {
173                $properties[$key] = __($value);
174            }
175        }
176
177        $options = $properties['options'] ?? null;
178
179        if (is_array($options)) {
180            $properties['options'] = array_map($this->localizeOption(...), $options);
181        }
182
183        return $properties;
184    }
185
186    /**
187     * Translate a select option's label. Plain string options are left as-is
188     * (their value doubles as the label and must stay stable); labelled options
189     * `{value, label}` get their label translated.
190     */
191    private function localizeOption(mixed $option): mixed
192    {
193        if (!is_array($option)) {
194            return $option;
195        }
196
197        $label = $option['label'] ?? null;
198
199        return is_string($label) ? [...$option, 'label' => __($label)] : $option;
200    }
201
202    /** @return array<string, mixed> */
203    public function getFileStructure(string $type, mixed $value = null): array
204    {
205        unset($type);
206
207        return [
208            'type' => $this::class,
209            'value' => $this->listValueMap($value),
210        ];
211    }
212
213    /** @return array<string, mixed> */
214    public function getSimpleStructure(string $type, mixed $value = null): array
215    {
216        unset($type);
217
218        return [
219            'type' => $this::class,
220            'value' => $this->scalarValueMap($value),
221        ];
222    }
223
224    protected function addType(Shape $shape): void
225    {
226        $shape->add('type', 'string')->rules('required', 'in:' . $this::class);
227    }
228
229    protected function addMeta(Shape $shape): void
230    {
231        $shape->add('meta', $this->metaShape())->optional()->nullable();
232    }
233
234    protected function metaShape(): Shape
235    {
236        return Shapes::create()->extra(Extra::Allow);
237    }
238
239    /**
240     * The name a validation message calls this field by.
241     *
242     * Rules run on the entry inside the value map, one level below the field
243     * itself, and sire labels an issue with the key it validated â€” without
244     * this a failing title reports "zxx must be at least 3 characters".
245     */
246    protected function valueLabel(?Locale $locale = null): string
247    {
248        $label = __($this->getLabel() ?? $this->name);
249
250        if ($locale === null || count($this->owner->locales()) < 2) {
251            return $label;
252        }
253
254        return $label . ' (' . $locale->title . ')';
255    }
256
257    /** @param list<string> $validators */
258    protected function zxxShape(string|Contract\Validator $valueShape, array $validators = []): Shape
259    {
260        $shape = Shapes::create();
261        $field = $shape
262            ->add(self::NEUTRAL_LOCALE, $valueShape)
263            ->label($this->valueLabel())
264            ->rules(...$validators);
265
266        if (!$this->isRequired()) {
267            $field->optional()->nullable();
268        }
269
270        return $shape;
271    }
272
273    protected function scalarValue(mixed $value = null): mixed
274    {
275        return $value ?? $this->default;
276    }
277
278    /** @return array<mixed> */
279    protected function scalarValueMap(mixed $value = null): array
280    {
281        $value ??= $this->default;
282
283        if (is_array($value) && array_key_exists(self::NEUTRAL_LOCALE, $value)) {
284            return $value;
285        }
286
287        return [self::NEUTRAL_LOCALE => $value];
288    }
289
290    /** @return array<mixed> */
291    protected function listValueMap(mixed $value = null): array
292    {
293        $value ??= $this->default ?? [];
294
295        if (is_array($value) && array_key_exists(self::NEUTRAL_LOCALE, $value)) {
296            return $value;
297        }
298
299        return [self::NEUTRAL_LOCALE => is_array($value) ? $value : []];
300    }
301}