*/ public function optionsFor(FormField|FormFieldLibrary $owner): Collection { $type = $this->ownerTypeFor($owner); return FormFieldOption::query() ->where('owner_type', $type) ->where('owner_id', $owner->getKey()) ->orderBy('sort_order') ->get(); } /** * Replace the full option set on an owner transactionally. Validates * every spec's shape (value / label / sort_order / translations / * uniqueness) before any write lands. * * @param list}> $specs * @return Collection */ public function replaceOptions(FormField|FormFieldLibrary $owner, array $specs): Collection { $this->assertSpecsValid($specs); $ownerType = $this->ownerTypeFor($owner); return DB::transaction(function () use ($owner, $ownerType, $specs): Collection { FormFieldOption::query() ->withoutGlobalScopes() ->where('owner_type', $ownerType) ->where('owner_id', $owner->getKey()) ->delete(); foreach ($specs as $spec) { FormFieldOption::query()->withoutGlobalScopes()->create([ 'owner_type' => $ownerType, 'owner_id' => $owner->getKey(), 'value' => $spec['value'], 'label' => $spec['label'], 'sort_order' => $spec['sort_order'], 'translations' => $this->normaliseTranslations($spec['translations'] ?? null), ]); } $fresh = $this->optionsFor($owner); if ($owner instanceof FormField) { $owner->logFieldChange('field.options_replaced', [ 'options' => $this->toJsonShape($fresh), ]); } return $fresh; }); } /** * Row-copy from a library entry to a freshly-inserted field * (addendum Q3 row-copy mandate). Every column is preserved; only * `owner_type` / `owner_id` change. No activity-log emit — the * wrapping library-insert emits its own field-creation event. * * @return Collection */ public function copyOptions(FormFieldLibrary|FormField $from, FormField|FormFieldLibrary $to): Collection { $rows = $this->optionsFor($from); if ($rows->isEmpty()) { return $rows; } $toType = $this->ownerTypeFor($to); return DB::transaction(function () use ($rows, $to, $toType): Collection { foreach ($rows as $row) { FormFieldOption::query()->withoutGlobalScopes()->create([ 'owner_type' => $toType, 'owner_id' => $to->getKey(), 'value' => $row->value, 'label' => $row->label, 'sort_order' => $row->sort_order, 'translations' => is_array($row->translations) && $row->translations !== [] ? $row->translations : null, ]); } return $this->optionsFor($to); }); } /** * Serialise an option collection into the rich-shape array consumed * by snapshot writer, API resources, and FilterRegistryController. * Returns the mapped list (possibly empty); callers handle * empty-collection → `null` emit at the resource boundary. * * @param Collection $options * @return list}> */ public function toJsonShape(Collection $options): array { return $options ->sortBy('sort_order') ->values() ->map(fn (FormFieldOption $option) => $option->toJsonShape()) ->all(); } /** * Public spec-shape gate — used by FormRequests (WS-5d commit 3) to * reject malformed specs at the HTTP boundary before any write * lands. * * @param list> $specs */ public function assertSpecsValid(array $specs): void { $seenValues = []; foreach ($specs as $index => $spec) { if (! is_array($spec)) { throw new InvalidOptionSpecException( "Option spec at index {$index} must be an array.", ); } $value = $spec['value'] ?? null; if (! is_string($value) || $value === '' || strlen($value) > 255) { throw new InvalidOptionSpecException( "Option spec at index {$index}: 'value' is required, must be a non-empty string ≤255 chars.", ); } $label = $spec['label'] ?? null; if (! is_string($label) || $label === '' || strlen($label) > 255) { throw new InvalidOptionSpecException( "Option spec at index {$index}: 'label' is required, must be a non-empty string ≤255 chars.", ); } if (! array_key_exists('sort_order', $spec) || ! is_int($spec['sort_order']) || $spec['sort_order'] < 0) { throw new InvalidOptionSpecException( "Option spec at index {$index}: 'sort_order' is required, must be a non-negative integer.", ); } if (array_key_exists('translations', $spec) && $spec['translations'] !== null) { $translations = $spec['translations']; if (! is_array($translations)) { throw new InvalidOptionSpecException( "Option spec at index {$index}: 'translations' must be an associative array of locale ⇒ string.", ); } foreach ($translations as $locale => $translated) { if (! is_string($locale) || preg_match(self::LOCALE_PATTERN, $locale) !== 1) { throw new InvalidOptionSpecException( "Option spec at index {$index}: invalid locale key '{$locale}' (expected BCP-47 short form e.g. 'nl', 'en_GB').", ); } if (! is_string($translated) || $translated === '' || strlen($translated) > 255) { throw new InvalidOptionSpecException( "Option spec at index {$index} locale '{$locale}': translated label must be a non-empty string ≤255 chars.", ); } } } if (in_array($value, $seenValues, true)) { throw new InvalidOptionSpecException( "Option spec at index {$index}: duplicate value '{$value}' within the spec list.", ); } $seenValues[] = $value; } } private function ownerTypeFor(FormField|FormFieldLibrary $owner): string { return $owner instanceof FormField ? 'form_field' : 'form_field_library'; } /** * Empty translation bags are stored as NULL — keeps query semantics * clean and avoids whitespace differences between equivalent rows. * * @param array|null $translations * @return array|null */ private function normaliseTranslations(?array $translations): ?array { if ($translations === null || $translations === []) { return null; } return $translations; } }