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