From 005575946241396a72e99b910eb8e8ae90296d63 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Wed, 24 Jun 2026 23:28:26 +0600 Subject: [PATCH] feat(extra-fields): add ms3-key-value field type Introduce configurable key-value extra fields with fixed and free modes, backend validation via KeyValueFieldService, and Vue editor components for manager forms and extra field definitions. Closes #300 --- .../minishop3/lexicon/en/default.inc.php | 1 + .../minishop3/lexicon/en/vue.inc.php | 20 + .../minishop3/lexicon/ru/default.inc.php | 1 + .../minishop3/lexicon/ru/vue.inc.php | 20 + ...0_add_key_value_config_to_extra_fields.php | 35 ++ .../schema/minishop3.mysql.schema.xml | 1 + .../Api/Manager/OrdersController.php | 55 ++- .../src/Model/mysql/msExtraField.php | 6 + .../minishop3/src/ServiceRegistry.php | 4 + .../ExtraFields/KeyValueFieldService.php | 358 ++++++++++++++++++ .../src/Services/ExtraFieldsService.php | 129 ++++++- .../Services/Product/ProductDataService.php | 75 +++- vueManager/src/components/DynamicField.vue | 19 +- .../src/components/ExtraFieldsManager.vue | 54 ++- vueManager/src/components/KeyValueField.vue | 263 +++++++++++++ .../src/components/KeyValueSchemaEditor.vue | 179 +++++++++ .../order/OrderExtraFieldsSection.vue | 5 +- vueManager/src/utils/keyValueField.js | 111 ++++++ 18 files changed, 1286 insertions(+), 50 deletions(-) create mode 100644 core/components/minishop3/migrations/20260622120000_add_key_value_config_to_extra_fields.php create mode 100644 core/components/minishop3/src/Services/ExtraFields/KeyValueFieldService.php create mode 100644 vueManager/src/components/KeyValueField.vue create mode 100644 vueManager/src/components/KeyValueSchemaEditor.vue create mode 100644 vueManager/src/utils/keyValueField.js diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php index 2a79dfe9..8682bdaa 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -171,6 +171,7 @@ $_lang['ms3_err_ae'] = 'This field must be unique'; $_lang['ms3_err_json'] = 'This field requires JSON string'; $_lang['ms3_repeater_validation_error'] = 'Repeater field "[[+field]]": [[+error]]'; +$_lang['ms3_key_value_validation_error'] = 'Key-value field "[[+field]]": [[+error]]'; $_lang['ms3_err_user_nf'] = 'User not found.'; $_lang['ms3_err_order_nf'] = 'Order with this identifier not found.'; diff --git a/core/components/minishop3/lexicon/en/vue.inc.php b/core/components/minishop3/lexicon/en/vue.inc.php index df128018..4129294c 100644 --- a/core/components/minishop3/lexicon/en/vue.inc.php +++ b/core/components/minishop3/lexicon/en/vue.inc.php @@ -161,6 +161,7 @@ $_lang['ms3_vue_xtype_xcheckbox'] = 'Checkbox'; $_lang['ms3_vue_xtype_combo_select'] = 'Dropdown List'; $_lang['ms3_vue_xtype_repeater'] = 'Repeater (rows grid)'; +$_lang['ms3_vue_xtype_key_value'] = 'Key-Value (map)'; $_lang['ms3_vue_xtype_combo_vendor'] = 'Vendor (combo)'; $_lang['ms3_vue_xtype_combo_autocomplete'] = 'Autocomplete (combo)'; $_lang['ms3_vue_xtype_combo_options'] = 'Product Options (chips)'; @@ -186,6 +187,25 @@ $_lang['ms3_vue_repeater_add_row'] = 'Add row'; $_lang['ms3_vue_repeater_no_columns'] = 'Configure repeater columns in extra field settings.'; $_lang['ms3_vue_repeater_drag_hint'] = 'Drag to reorder'; + +// Key-Value field +$_lang['ms3_vue_key_value_schema_label'] = 'Key-Value schema'; +$_lang['ms3_vue_key_value_schema_help'] = 'Define keys for fixed mode or use free mode for any pairs.'; +$_lang['ms3_vue_key_value_mode'] = 'Mode'; +$_lang['ms3_vue_key_value_mode_fixed'] = 'Fixed keys'; +$_lang['ms3_vue_key_value_mode_free'] = 'Free keys'; +$_lang['ms3_vue_key_value_keys'] = 'Keys'; +$_lang['ms3_vue_key_value_add_key'] = 'Add key'; +$_lang['ms3_vue_key_value_key'] = 'Key'; +$_lang['ms3_vue_key_value_label'] = 'Label'; +$_lang['ms3_vue_key_value_value'] = 'Value'; +$_lang['ms3_vue_key_value_add_pair'] = 'Add pair'; +$_lang['ms3_vue_key_value_value_type'] = 'Value type'; +$_lang['ms3_vue_key_value_value_type_string'] = 'String'; +$_lang['ms3_vue_key_value_value_type_number'] = 'Number'; +$_lang['ms3_vue_key_value_required'] = 'Required'; +$_lang['ms3_vue_key_value_no_keys'] = 'No keys defined. Add keys in extra field settings for fixed mode.'; + $_lang['ms3_vue_order_extra_fields'] = 'Additional order fields'; $_lang['ms3_vue_order_address_extra_fields'] = 'Additional address fields'; diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php index 1a01983e..2e7488dd 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -171,6 +171,7 @@ $_lang['ms3_err_ae'] = 'Это поле должно быть уникально'; $_lang['ms3_err_json'] = 'Это поле требует JSON строку'; $_lang['ms3_repeater_validation_error'] = 'Поле повторителя «[[+field]]»: [[+error]]'; +$_lang['ms3_key_value_validation_error'] = 'Поле key-value «[[+field]]»: [[+error]]'; $_lang['ms3_err_user_nf'] = 'Пользователь не найден.'; $_lang['ms3_err_order_nf'] = 'Заказ с таким идентификатором не найден.'; diff --git a/core/components/minishop3/lexicon/ru/vue.inc.php b/core/components/minishop3/lexicon/ru/vue.inc.php index 4a3da770..9d90a453 100644 --- a/core/components/minishop3/lexicon/ru/vue.inc.php +++ b/core/components/minishop3/lexicon/ru/vue.inc.php @@ -161,6 +161,7 @@ $_lang['ms3_vue_xtype_xcheckbox'] = 'Флажок'; $_lang['ms3_vue_xtype_combo_select'] = 'Выпадающий список'; $_lang['ms3_vue_xtype_repeater'] = 'Повторитель (таблица строк)'; +$_lang['ms3_vue_xtype_key_value'] = 'Ключ-Значение (карта)'; $_lang['ms3_vue_xtype_combo_vendor'] = 'Производитель (combo)'; $_lang['ms3_vue_xtype_combo_autocomplete'] = 'Автодополнение (combo)'; $_lang['ms3_vue_xtype_combo_options'] = 'Опции товара (chips)'; @@ -186,6 +187,25 @@ $_lang['ms3_vue_repeater_add_row'] = 'Добавить строку'; $_lang['ms3_vue_repeater_no_columns'] = 'Настройте колонки повторителя в параметрах extra field.'; $_lang['ms3_vue_repeater_drag_hint'] = 'Перетащите для изменения порядка'; + +// Key-Value field +$_lang['ms3_vue_key_value_schema_label'] = 'Схема Ключ-Значение'; +$_lang['ms3_vue_key_value_schema_help'] = 'Определите ключи для фиксированного режима или используйте свободный режим для любых пар.'; +$_lang['ms3_vue_key_value_mode'] = 'Режим'; +$_lang['ms3_vue_key_value_mode_fixed'] = 'Фиксированные ключи'; +$_lang['ms3_vue_key_value_mode_free'] = 'Свободные ключи'; +$_lang['ms3_vue_key_value_keys'] = 'Ключи'; +$_lang['ms3_vue_key_value_add_key'] = 'Добавить ключ'; +$_lang['ms3_vue_key_value_key'] = 'Ключ'; +$_lang['ms3_vue_key_value_label'] = 'Подпись'; +$_lang['ms3_vue_key_value_value'] = 'Значение'; +$_lang['ms3_vue_key_value_add_pair'] = 'Добавить пару'; +$_lang['ms3_vue_key_value_value_type'] = 'Тип значения'; +$_lang['ms3_vue_key_value_value_type_string'] = 'Строка'; +$_lang['ms3_vue_key_value_value_type_number'] = 'Число'; +$_lang['ms3_vue_key_value_required'] = 'Обязательное'; +$_lang['ms3_vue_key_value_no_keys'] = 'Ключи не заданы. Добавьте ключи в параметрах extra field для фиксированного режима.'; + $_lang['ms3_vue_order_extra_fields'] = 'Дополнительные поля заказа'; $_lang['ms3_vue_order_address_extra_fields'] = 'Дополнительные поля адреса'; diff --git a/core/components/minishop3/migrations/20260622120000_add_key_value_config_to_extra_fields.php b/core/components/minishop3/migrations/20260622120000_add_key_value_config_to_extra_fields.php new file mode 100644 index 00000000..5c37f095 --- /dev/null +++ b/core/components/minishop3/migrations/20260622120000_add_key_value_config_to_extra_fields.php @@ -0,0 +1,35 @@ +table('ms3_extra_fields'); + + if (!$table->hasColumn('key_value_config')) { + $table->addColumn('key_value_config', 'text', [ + 'null' => true, + 'after' => 'repeater_config', + 'comment' => 'JSON schema for ms3-key-value field type (mode, keys, value types, required flags)', + ]); + $table->update(); + } + } + + public function down(): void + { + $table = $this->table('ms3_extra_fields'); + + if ($table->hasColumn('key_value_config')) { + $table->removeColumn('key_value_config'); + $table->update(); + } + } +} diff --git a/core/components/minishop3/schema/minishop3.mysql.schema.xml b/core/components/minishop3/schema/minishop3.mysql.schema.xml index 0173f849..36d85a45 100644 --- a/core/components/minishop3/schema/minishop3.mysql.schema.xml +++ b/core/components/minishop3/schema/minishop3.mysql.schema.xml @@ -622,6 +622,7 @@ + diff --git a/core/components/minishop3/src/Controllers/Api/Manager/OrdersController.php b/core/components/minishop3/src/Controllers/Api/Manager/OrdersController.php index ce13d719..37c10979 100644 --- a/core/components/minishop3/src/Controllers/Api/Manager/OrdersController.php +++ b/core/components/minishop3/src/Controllers/Api/Manager/OrdersController.php @@ -13,6 +13,7 @@ use MiniShop3\Model\msPayment; use MiniShop3\Router\HttpStatus; use MiniShop3\Router\Response; +use MiniShop3\Services\ExtraFields\KeyValueFieldService; use MiniShop3\Services\ExtraFields\RepeaterFieldService; use MiniShop3\Services\CustomerDuplicateChecker; use MiniShop3\Services\CustomerFactory; @@ -1793,27 +1794,51 @@ protected function normalizeExtraFieldValue(string $modelClass, string $fieldKey 'active' => true, ]); - if (!$definition || $definition->get('xtype') !== RepeaterFieldService::XTYPE) { + if (!$definition) { return ['ok' => true, 'value' => $value]; } - /** @var RepeaterFieldService $repeaterService */ - $repeaterService = $this->modx->services->get('ms3_repeater_field'); - $config = $repeaterService->parseConfig($definition->get('repeater_config')); + if ($definition->get('xtype') === RepeaterFieldService::XTYPE) { + /** @var RepeaterFieldService $repeaterService */ + $repeaterService = $this->modx->services->get('ms3_repeater_field'); + $config = $repeaterService->parseConfig($definition->get('repeater_config')); - try { - return ['ok' => true, 'value' => $repeaterService->processValue($value, $config)]; - } catch (\InvalidArgumentException $e) { - $this->modx->lexicon->load('minishop3:default'); + try { + return ['ok' => true, 'value' => $repeaterService->processValue($value, $config)]; + } catch (\InvalidArgumentException $e) { + $this->modx->lexicon->load('minishop3:default'); + + return [ + 'ok' => false, + 'message' => $this->modx->lexicon('ms3_repeater_validation_error', [ + 'field' => $fieldKey, + 'error' => $e->getMessage(), + ]), + ]; + } + } + + if ($definition->get('xtype') === KeyValueFieldService::XTYPE) { + /** @var KeyValueFieldService $keyValueService */ + $keyValueService = $this->modx->services->get('ms3_key_value_field'); + $config = $keyValueService->parseConfig($definition->get('key_value_config')); - return [ - 'ok' => false, - 'message' => $this->modx->lexicon('ms3_repeater_validation_error', [ - 'field' => $fieldKey, - 'error' => $e->getMessage(), - ]), - ]; + try { + return ['ok' => true, 'value' => $keyValueService->processValue($value, $config)]; + } catch (\InvalidArgumentException $e) { + $this->modx->lexicon->load('minishop3:default'); + + return [ + 'ok' => false, + 'message' => $this->modx->lexicon('ms3_key_value_validation_error', [ + 'field' => $fieldKey, + 'error' => $e->getMessage(), + ]), + ]; + } } + + return ['ok' => true, 'value' => $value]; } /** diff --git a/core/components/minishop3/src/Model/mysql/msExtraField.php b/core/components/minishop3/src/Model/mysql/msExtraField.php index 216c6700..60561924 100644 --- a/core/components/minishop3/src/Model/mysql/msExtraField.php +++ b/core/components/minishop3/src/Model/mysql/msExtraField.php @@ -29,6 +29,7 @@ class msExtraField extends \MiniShop3\Model\msExtraField 'active' => 0, 'select_options' => null, 'repeater_config' => null, + 'key_value_config' => null, ], 'fieldMeta' => [ 'class' => [ @@ -129,6 +130,11 @@ class msExtraField extends \MiniShop3\Model\msExtraField 'phptype' => 'string', 'null' => true, ], + 'key_value_config' => [ + 'dbtype' => 'text', + 'phptype' => 'string', + 'null' => true, + ], ], 'indexes' => [], 'composites' => [], diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 44840b80..24c8348c 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -67,6 +67,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\ExtraFields\RepeaterFieldService::class, 'interface' => null, ], + 'ms3_key_value_field' => [ + 'class' => \MiniShop3\Services\ExtraFields\KeyValueFieldService::class, + 'interface' => null, + ], 'ms3_product_image' => [ 'class' => \MiniShop3\Services\Product\ProductImageService::class, 'interface' => null, diff --git a/core/components/minishop3/src/Services/ExtraFields/KeyValueFieldService.php b/core/components/minishop3/src/Services/ExtraFields/KeyValueFieldService.php new file mode 100644 index 00000000..5efd42c1 --- /dev/null +++ b/core/components/minishop3/src/Services/ExtraFields/KeyValueFieldService.php @@ -0,0 +1,358 @@ +modx = $modx; + } + + public function isKeyValueXtype(?string $xtype): bool + { + return $xtype === self::XTYPE; + } + + public function defaultConfig(): array + { + return [ + 'mode' => 'fixed', + 'keys' => [], + ]; + } + + /** + * @param mixed $json JSON string or array + * @param string|null $fieldKey Extra field key for log context when config JSON is invalid + */ + public function parseConfig(mixed $json, ?string $fieldKey = null): array + { + $config = $this->defaultConfig(); + + if (is_string($json) && $json !== '') { + $decoded = json_decode($json, true); + if (is_array($decoded)) { + $config = array_merge($config, $decoded); + } else { + $context = $fieldKey !== null && $fieldKey !== '' ? " for field \"{$fieldKey}\"" : ''; + $this->modx->log( + modX::LOG_LEVEL_WARN, + '[ms3-key-value] malformed key_value_config JSON' . $context . ': ' . json_last_error_msg() + ); + } + } elseif (is_array($json)) { + $config = array_merge($config, $json); + } + + if (($config['mode'] ?? '') !== 'free') { + $config['mode'] = 'fixed'; + } + + if (!is_array($config['keys'])) { + $config['keys'] = []; + } + + $normalizedKeys = []; + foreach ($config['keys'] as $item) { + if (!is_array($item)) { + continue; + } + + $normalizedKeys[] = [ + 'key' => trim((string)($item['key'] ?? '')), + 'label' => trim((string)($item['label'] ?? '')), + 'valueType' => ($item['valueType'] ?? 'string') === 'number' ? 'number' : 'string', + 'required' => (bool)($item['required'] ?? false), + ]; + } + $config['keys'] = $normalizedKeys; + + return $config; + } + + public function encodeConfig(array $config): string + { + return json_encode($config, JSON_UNESCAPED_UNICODE); + } + + /** + * @return array{success: bool, message?: string, config?: array} + */ + public function validateConfigSchema(mixed $json): array + { + $config = $this->parseConfig($json); + + if (($config['mode'] ?? 'fixed') === 'fixed' && empty($config['keys'])) { + return ['success' => false, 'message' => 'Key-value fixed mode requires at least one key']; + } + + $keys = []; + foreach ($config['keys'] as $item) { + if (!is_array($item)) { + return ['success' => false, 'message' => 'Invalid key definition']; + } + + $key = trim((string)($item['key'] ?? '')); + if ($key === '') { + return ['success' => false, 'message' => 'Each key definition must include a non-empty key']; + } + + if (in_array($key, $keys, true)) { + return ['success' => false, 'message' => "Duplicate key in schema: {$key}"]; + } + $keys[] = $key; + } + + return ['success' => true, 'config' => $config]; + } + + /** + * @return array + */ + public function decodeValue(mixed $value): array + { + if ($value === null || $value === '') { + return []; + } + + if (is_string($value)) { + $decoded = json_decode($value, true); + if (!is_array($decoded) || array_is_list($decoded)) { + throw new \InvalidArgumentException('Key-value value must be a JSON object'); + } + + return $decoded; + } + + if (is_array($value)) { + if (array_is_list($value)) { + throw new \InvalidArgumentException( + 'Key-value value must be an object map, list arrays are not allowed' + ); + } + + return $value; + } + + throw new \InvalidArgumentException('Key-value value must be an object'); + } + + /** + * @param array $map + * @return array + */ + public function normalizeMap(array $map, array $config): array + { + $mode = ($config['mode'] ?? 'fixed') === 'free' ? 'free' : 'fixed'; + $schemaMap = $this->getSchemaMap($config); + + if ($mode === 'fixed') { + $normalized = []; + foreach ($schemaMap as $key => $item) { + $normalized[$key] = $this->normalizeCellValue($map[$key] ?? '', $item['valueType'] ?? 'string'); + } + + return $normalized; + } + + $normalized = []; + foreach ($map as $rawKey => $rawValue) { + $key = trim((string)$rawKey); + if ($key === '') { + continue; + } + + $valueType = $schemaMap[$key]['valueType'] ?? 'string'; + $normalized[$key] = $this->normalizeCellValue($rawValue, $valueType); + } + + return $normalized; + } + + /** + * @param array $map + * @return array{ok: bool, errors?: list} + */ + public function validateMap(array $map, array $config): array + { + $errors = []; + $mode = ($config['mode'] ?? 'fixed') === 'free' ? 'free' : 'fixed'; + $schemaMap = $this->getSchemaMap($config); + + if ($mode === 'fixed') { + foreach ($schemaMap as $key => $item) { + $required = (bool)($item['required'] ?? false); + if (!$required) { + continue; + } + + $value = $map[$key] ?? null; + if ($value === null || $value === '') { + $errors[] = "Key {$key} is required"; + } + } + + foreach ($map as $key => $value) { + if (!isset($schemaMap[$key])) { + $errors[] = "Key {$key} is not allowed"; + continue; + } + + if ( + ($schemaMap[$key]['valueType'] ?? 'string') === 'number' + && $value !== null + && $value !== '' + && !is_numeric($value) + ) { + $errors[] = "Key {$key} must be numeric"; + } + } + } else { + $seenKeys = []; + foreach (array_keys($map) as $key) { + if (in_array($key, $seenKeys, true)) { + $errors[] = "Duplicate key: {$key}"; + continue; + } + $seenKeys[] = $key; + } + + foreach ($map as $key => $value) { + if (isset($schemaMap[$key]) && ($schemaMap[$key]['valueType'] ?? 'string') === 'number') { + if ($value !== null && $value !== '' && !is_numeric($value)) { + $errors[] = "Key {$key} must be numeric"; + } + } + } + } + + return empty($errors) ? ['ok' => true] : ['ok' => false, 'errors' => $errors]; + } + + /** + * Decode, validate and normalize key-value payload. + * + * @return array + */ + public function processValue(mixed $value, array $config): array + { + $map = $this->decodeValue($value); + $normalized = $this->normalizeMap($map, $config); + $validation = $this->validateMap($normalized, $config); + + if (!$validation['ok']) { + throw new \InvalidArgumentException(implode('; ', $validation['errors'] ?? [])); + } + + return $normalized; + } + + /** + * @return array field key => parsed config + */ + public function getKeyValueFieldsForClass(string $modelClass): array + { + if ($this->fieldsByClass === null) { + $this->fieldsByClass = []; + } + + if (isset($this->fieldsByClass[$modelClass])) { + return $this->fieldsByClass[$modelClass]; + } + + $map = []; + $query = $this->modx->newQuery(msExtraField::class); + $query->where([ + 'class' => $modelClass, + 'xtype' => self::XTYPE, + 'active' => true, + ]); + + foreach ($this->modx->getIterator(msExtraField::class, $query) as $field) { + $key = (string)$field->get('key'); + if ($key === '') { + continue; + } + $map[$key] = $this->parseConfig($field->get('key_value_config'), $key); + } + + $this->fieldsByClass[$modelClass] = $map; + + return $map; + } + + /** + * @return array + */ + private function getSchemaMap(array $config): array + { + $schema = []; + foreach (($config['keys'] ?? []) as $item) { + if (!is_array($item)) { + continue; + } + $key = trim((string)($item['key'] ?? '')); + if ($key === '') { + continue; + } + $schema[$key] = [ + 'key' => $key, + 'label' => trim((string)($item['label'] ?? '')), + 'valueType' => ($item['valueType'] ?? 'string') === 'number' ? 'number' : 'string', + 'required' => (bool)($item['required'] ?? false), + ]; + } + + return $schema; + } + + private function normalizeCellValue(mixed $rawValue, string $valueType): mixed + { + if (is_string($rawValue)) { + $value = trim($rawValue); + } elseif (is_scalar($rawValue) || $rawValue === null) { + $value = trim((string)$rawValue); + } else { + $value = trim((string)json_encode($rawValue, JSON_UNESCAPED_UNICODE)); + } + + if ($valueType === 'number') { + return $this->castNumericValue($value); + } + + return $value; + } + + private function castNumericValue(mixed $value): mixed + { + if ($value === null || $value === '') { + return $value; + } + + if (is_int($value) || is_float($value)) { + return $value; + } + + if (!is_numeric($value)) { + return $value; + } + + $stringValue = trim((string)$value); + if ($stringValue === '') { + return ''; + } + + return str_contains($stringValue, '.') ? (float)$stringValue : (int)$stringValue; + } +} diff --git a/core/components/minishop3/src/Services/ExtraFieldsService.php b/core/components/minishop3/src/Services/ExtraFieldsService.php index 5d4d6677..2653357d 100644 --- a/core/components/minishop3/src/Services/ExtraFieldsService.php +++ b/core/components/minishop3/src/Services/ExtraFieldsService.php @@ -4,6 +4,7 @@ use MiniShop3\Model\msExtraField; use MiniShop3\Model\msProductField; +use MiniShop3\Services\ExtraFields\KeyValueFieldService; use MiniShop3\Services\ExtraFields\RepeaterFieldService; use MiniShop3\Utils\ExtraFields; use MODX\Revolution\modX; @@ -34,6 +35,10 @@ public function createField(array $data): array if ($repeaterError !== null) { return $repeaterError; } + $keyValueError = $this->applyKeyValueConstraints($data); + if ($keyValueError !== null) { + return $keyValueError; + } $validation = $this->validateFieldData($data); if (!$validation['success']) { @@ -64,7 +69,10 @@ public function createField(array $data): array } @unlink($migrationFile); - $this->modx->log(modX::LOG_LEVEL_INFO, "[ExtraFieldsService] Migration file deleted: " . basename($migrationFile)); + $this->modx->log( + modX::LOG_LEVEL_INFO, + "[ExtraFieldsService] Migration file deleted: " . basename($migrationFile) + ); $this->extraFieldsUtil->loadMap(); $this->extraFieldsUtil->clearCache(); @@ -109,7 +117,10 @@ public function deleteField(int $id): array } @unlink($migrationFile); - $this->modx->log(modX::LOG_LEVEL_INFO, "[ExtraFieldsService] Migration file deleted: " . basename($migrationFile)); + $this->modx->log( + modX::LOG_LEVEL_INFO, + "[ExtraFieldsService] Migration file deleted: " . basename($migrationFile) + ); // Only delete msProductField for product-related models if ($field->get('class') === 'MiniShop3\\Model\\msProductData') { @@ -235,7 +246,6 @@ private function runMigrations(): array 'success' => true, 'output' => $outputText ]; - } catch (\Exception $e) { $this->modx->log(modX::LOG_LEVEL_ERROR, '[ExtraFieldsService] Migration error: ' . $e->getMessage()); @@ -260,11 +270,19 @@ private function createProductFieldFromExtra(msExtraField $extraField): void $config = ['select_options' => $extraField->get('select_options')]; } elseif ($extraField->get('xtype') === RepeaterFieldService::XTYPE && $extraField->get('repeater_config')) { $config = [ - 'repeater_config' => $this->getRepeaterFieldService()->parseConfig($extraField->get('repeater_config')), + 'repeater_config' => $this->getRepeaterFieldService()->parseConfig( + $extraField->get('repeater_config') + ), + ]; + } elseif ($extraField->get('xtype') === KeyValueFieldService::XTYPE && $extraField->get('key_value_config')) { + $config = [ + 'key_value_config' => $this->getKeyValueFieldService()->parseConfig( + $extraField->get('key_value_config') + ), ]; } - $width = $extraField->get('xtype') === RepeaterFieldService::XTYPE ? 12 : 6; + $width = $this->isWideFieldXtype($extraField->get('xtype')) ? 12 : 6; $productField->fromArray([ 'name' => $extraField->get('key'), @@ -282,11 +300,15 @@ private function createProductFieldFromExtra(msExtraField $extraField): void ]); if ($productField->save()) { - $this->modx->log(modX::LOG_LEVEL_INFO, - "[ExtraFieldsService] Auto-created msProductField: {$extraField->get('key')}"); + $this->modx->log( + modX::LOG_LEVEL_INFO, + "[ExtraFieldsService] Auto-created msProductField: {$extraField->get('key')}" + ); } else { - $this->modx->log(modX::LOG_LEVEL_WARN, - "[ExtraFieldsService] Failed to auto-create msProductField: {$extraField->get('key')}"); + $this->modx->log( + modX::LOG_LEVEL_WARN, + "[ExtraFieldsService] Failed to auto-create msProductField: {$extraField->get('key')}" + ); } } @@ -314,14 +336,30 @@ public function updateField(int $id, array $data): array } $data['repeater_config'] = $repeaterPayload['repeater_config'] ?? null; } + if (($data['xtype'] ?? $field->get('xtype')) === KeyValueFieldService::XTYPE) { + $keyValuePayload = array_merge($field->toArray(), $data); + $keyValueError = $this->applyKeyValueConstraints($keyValuePayload); + if ($keyValueError !== null) { + return $keyValueError; + } + $data['key_value_config'] = $keyValuePayload['key_value_config'] ?? null; + } - $allowedFields = ['label', 'description', 'xtype', 'active', 'select_options', 'repeater_config']; + $allowedFields = [ + 'label', + 'description', + 'xtype', + 'active', + 'select_options', + 'repeater_config', + 'key_value_config', + ]; // Per-field null semantics: // - `description`, `select_options` (optional text/json): null = clear // - `label`, `xtype`, `active` (required for rendering): null skipped — a partial // payload with `xtype: null` would silently break the field's UI. - $nullClearable = ['description', 'select_options']; + $nullClearable = ['description', 'select_options', 'repeater_config', 'key_value_config']; foreach ($allowedFields as $fieldName) { if (!array_key_exists($fieldName, $data)) { @@ -339,6 +377,9 @@ public function updateField(int $id, array $data): array if ($field->get('xtype') !== RepeaterFieldService::XTYPE) { $field->set('repeater_config', null); } + if ($field->get('xtype') !== KeyValueFieldService::XTYPE) { + $field->set('key_value_config', null); + } if (!$field->save()) { return ['success' => false, 'message' => 'Failed to update field in database']; @@ -378,27 +419,36 @@ private function updateProductFieldFromExtra(msExtraField $extraField): void $config = $productField->get('config') ?: []; if ($extraField->get('xtype') === 'ms3-combo-select') { $config['select_options'] = $extraField->get('select_options') ?: ''; - unset($config['repeater_config']); + unset($config['repeater_config'], $config['key_value_config']); } elseif ($extraField->get('xtype') === RepeaterFieldService::XTYPE) { $config['repeater_config'] = $this->getRepeaterFieldService()->parseConfig( $extraField->get('repeater_config') ); - unset($config['select_options']); - } else { + unset($config['select_options'], $config['key_value_config']); + } elseif ($extraField->get('xtype') === KeyValueFieldService::XTYPE) { + $config['key_value_config'] = $this->getKeyValueFieldService()->parseConfig( + $extraField->get('key_value_config') + ); unset($config['select_options'], $config['repeater_config']); + } else { + unset($config['select_options'], $config['repeater_config'], $config['key_value_config']); } - if ($extraField->get('xtype') === RepeaterFieldService::XTYPE) { + if ($this->isWideFieldXtype($extraField->get('xtype'))) { $productField->set('width', 12); } $productField->set('config', !empty($config) ? $config : null); if ($productField->save()) { - $this->modx->log(modX::LOG_LEVEL_INFO, - "[ExtraFieldsService] Updated msProductField: {$extraField->get('key')}"); + $this->modx->log( + modX::LOG_LEVEL_INFO, + "[ExtraFieldsService] Updated msProductField: {$extraField->get('key')}" + ); } else { - $this->modx->log(modX::LOG_LEVEL_WARN, - "[ExtraFieldsService] Failed to update msProductField: {$extraField->get('key')}"); + $this->modx->log( + modX::LOG_LEVEL_WARN, + "[ExtraFieldsService] Failed to update msProductField: {$extraField->get('key')}" + ); } } } @@ -445,6 +495,34 @@ private function applyRepeaterConstraints(array &$data): ?array return null; } + /** + * @param array $data + * @return array{success: false, message: string}|null + */ + private function applyKeyValueConstraints(array &$data): ?array + { + if (($data['xtype'] ?? '') !== KeyValueFieldService::XTYPE) { + return null; + } + + $data['dbtype'] = 'json'; + $data['phptype'] = 'json'; + $data['precision'] = ''; + $data['null'] = true; + + $configValidation = $this->getKeyValueFieldService()->validateConfigSchema($data['key_value_config'] ?? ''); + if (!$configValidation['success']) { + return [ + 'success' => false, + 'message' => $configValidation['message'] ?? 'Invalid key-value configuration', + ]; + } + + $data['key_value_config'] = $this->getKeyValueFieldService()->encodeConfig($configValidation['config']); + + return null; + } + private function getRepeaterFieldService(): RepeaterFieldService { /** @var RepeaterFieldService $service */ @@ -452,4 +530,17 @@ private function getRepeaterFieldService(): RepeaterFieldService return $service; } + + private function getKeyValueFieldService(): KeyValueFieldService + { + /** @var KeyValueFieldService $service */ + $service = $this->modx->services->get('ms3_key_value_field'); + + return $service; + } + + private function isWideFieldXtype(?string $xtype): bool + { + return in_array($xtype, [RepeaterFieldService::XTYPE, KeyValueFieldService::XTYPE], true); + } } diff --git a/core/components/minishop3/src/Services/Product/ProductDataService.php b/core/components/minishop3/src/Services/Product/ProductDataService.php index 0624f234..966902cf 100644 --- a/core/components/minishop3/src/Services/Product/ProductDataService.php +++ b/core/components/minishop3/src/Services/Product/ProductDataService.php @@ -8,6 +8,7 @@ use MiniShop3\Model\msProductFile; use MiniShop3\Model\msProductLink; use MiniShop3\Model\msProductOption; +use MiniShop3\Services\ExtraFields\KeyValueFieldService; use MiniShop3\Services\ExtraFields\RepeaterFieldService; use MODX\Revolution\modX; @@ -24,6 +25,8 @@ class ProductDataService /** @var array|null */ protected ?array $productRepeaterFields = null; + /** @var array|null */ + protected ?array $productKeyValueFields = null; /** * @param modX $modx @@ -41,6 +44,14 @@ protected function getRepeaterFieldService(): RepeaterFieldService return $service; } + protected function getKeyValueFieldService(): KeyValueFieldService + { + /** @var KeyValueFieldService $service */ + $service = $this->modx->services->get('ms3_key_value_field'); + + return $service; + } + /** * @return array */ @@ -55,6 +66,20 @@ protected function getProductRepeaterFields(): array return $this->productRepeaterFields; } + /** + * @return array + */ + protected function getProductKeyValueFields(): array + { + if ($this->productKeyValueFields === null) { + $this->productKeyValueFields = $this->getKeyValueFieldService()->getKeyValueFieldsForClass( + msProductData::class + ); + } + + return $this->productKeyValueFields; + } + /** * Prepare object before saving * @@ -69,7 +94,9 @@ protected function getProductRepeaterFields(): array public function prepareObject(msProductData $productData): void { $repeaterFields = $this->getProductRepeaterFields(); + $keyValueFields = $this->getProductKeyValueFields(); $repeaterService = $this->getRepeaterFieldService(); + $keyValueService = $this->getKeyValueFieldService(); foreach ($productData->getArraysValues() as $name => $array) { if (isset($repeaterFields[$name])) { @@ -77,6 +104,11 @@ public function prepareObject(msProductData $productData): void $productData->set($name, $normalized); continue; } + if (isset($keyValueFields[$name])) { + $normalized = $keyValueService->processValue($array, $keyValueFields[$name]); + $productData->set($name, $normalized); + continue; + } $array = $productData->prepareOptionValues($array); $productData->set($name, $array); @@ -177,12 +209,13 @@ public function saveOptions(msProductData $productData, ?array $options = null, $optionsExplicit = $options !== null; $repeaterKeys = array_keys($this->getProductRepeaterFields()); + $keyValueKeys = array_keys($this->getProductKeyValueFields()); if ($options === null) { $options = []; foreach ($productData->_fieldMeta as $key => $value) { if ($value['phptype'] === 'json' && !empty($productData->get($key))) { - if (in_array($key, $repeaterKeys, true)) { + if (in_array($key, $repeaterKeys, true) || in_array($key, $keyValueKeys, true)) { continue; } // Use field name as key, not numeric index from array_merge @@ -659,7 +692,8 @@ public function updateProductData(int $productId, array $data): array // user input is lost with no error (#301). $allowedKeys = array_merge( self::$allowedUpdateFields, - array_keys($this->getProductRepeaterFields()) + array_keys($this->getProductRepeaterFields()), + array_keys($this->getProductKeyValueFields()) ); $filtered = array_intersect_key($data, array_flip($allowedKeys)); $resourceData = array_intersect_key($data, array_flip(self::$allowedResourceFields)); @@ -674,6 +708,10 @@ public function updateProductData(int $productId, array $data): array if ($repeaterError !== null) { return ['ok' => false, 'code' => self::ERROR_VALIDATION, 'message' => $repeaterError]; } + $keyValueError = $this->normalizeKeyValueFieldsInPayload($fieldsToUpdate); + if ($keyValueError !== null) { + return ['ok' => false, 'code' => self::ERROR_VALIDATION, 'message' => $keyValueError]; + } $oldValues = []; foreach (array_keys($fieldsToUpdate) as $key) { $oldValues[$key] = $productData->get($key); @@ -732,4 +770,37 @@ protected function normalizeRepeaterFieldsInPayload(array &$payload): ?string return null; } + + /** + * Validate and normalize key-value extra fields in manager API payload. + * + * @param array $payload + */ + protected function normalizeKeyValueFieldsInPayload(array &$payload): ?string + { + $keyValueFields = $this->getProductKeyValueFields(); + if ($keyValueFields === []) { + return null; + } + + $keyValueService = $this->getKeyValueFieldService(); + $this->modx->lexicon->load('minishop3:default'); + + foreach ($keyValueFields as $fieldKey => $config) { + if (!array_key_exists($fieldKey, $payload)) { + continue; + } + + try { + $payload[$fieldKey] = $keyValueService->processValue($payload[$fieldKey], $config); + } catch (\InvalidArgumentException $e) { + return $this->modx->lexicon('ms3_key_value_validation_error', [ + 'field' => $fieldKey, + 'error' => $e->getMessage(), + ]); + } + } + + return null; + } } diff --git a/vueManager/src/components/DynamicField.vue b/vueManager/src/components/DynamicField.vue index 41533117..43941e40 100644 --- a/vueManager/src/components/DynamicField.vue +++ b/vueManager/src/components/DynamicField.vue @@ -198,6 +198,17 @@ /> + + +
@@ -238,9 +249,11 @@ import Textarea from 'primevue/textarea' import ToggleSwitch from 'primevue/toggleswitch' import { computed, ref, watch } from 'vue' +import { getKeyValueConfigFromField, parseKeyValueModelValue, serializeKeyValueForPost } from '../utils/keyValueField.js' import { getRepeaterConfigFromField, parseRepeaterModelValue } from '../utils/repeaterField.js' import AutocompleteCombo from './AutocompleteCombo.vue' import FileBrowser from './FileBrowser.vue' +import KeyValueField from './KeyValueField.vue' import OptionsChips from './OptionsChips.vue' import RepeaterField from './RepeaterField.vue' import VendorCombo from './VendorCombo.vue' @@ -311,7 +324,7 @@ const isFileBrowserXtype = computed(() => { * Determine if field is complex type (requires hidden field with JSON) */ const isComplexField = computed(() => { - const complexTypes = ['combobox', 'datefield', 'colorpicker', 'chips', 'multiselect', 'ms3-repeater'] + const complexTypes = ['combobox', 'datefield', 'colorpicker', 'chips', 'multiselect'] return complexTypes.includes(props.fieldConfig.xtype) }) @@ -346,11 +359,15 @@ const selectOptions = computed(() => { }) const repeaterConfig = computed(() => getRepeaterConfigFromField(props.fieldConfig)) +const keyValueConfig = computed(() => getKeyValueConfigFromField(props.fieldConfig)) function normalizeIncomingValue(value) { if (props.fieldConfig.xtype === 'ms3-repeater') { return parseRepeaterModelValue(value) } + if (props.fieldConfig.xtype === 'ms3-key-value') { + return parseKeyValueModelValue(value) + } return value } diff --git a/vueManager/src/components/ExtraFieldsManager.vue b/vueManager/src/components/ExtraFieldsManager.vue index 48cf3df3..d457bc7d 100644 --- a/vueManager/src/components/ExtraFieldsManager.vue +++ b/vueManager/src/components/ExtraFieldsManager.vue @@ -18,11 +18,17 @@ import { useToast } from 'primevue/usetoast' import { computed, onMounted, ref, watch } from 'vue' import request from '../request.js' +import { + defaultKeyValueConfig, + KEY_VALUE_XTYPE, + parseKeyValueConfig, +} from '../utils/keyValueField.js' import { defaultRepeaterConfig, parseRepeaterConfig, REPEATER_XTYPE, } from '../utils/repeaterField.js' +import KeyValueSchemaEditor from './KeyValueSchemaEditor.vue' import RepeaterSchemaEditor from './RepeaterSchemaEditor.vue' const toast = useToast() @@ -58,6 +64,7 @@ const fieldForm = ref({ active: true, select_options: '', repeater_config: defaultRepeaterConfig(), + key_value_config: defaultKeyValueConfig(), }) /** @@ -94,6 +101,7 @@ const xtypeOptions = computed(() => [ { label: _('ms3_vue_xtype_xcheckbox'), value: 'xcheckbox' }, { label: _('ms3_vue_xtype_combo_select'), value: 'ms3-combo-select' }, { label: _('ms3_vue_xtype_repeater'), value: REPEATER_XTYPE }, + { label: _('ms3_vue_xtype_key_value'), value: KEY_VALUE_XTYPE }, { label: _('ms3_vue_xtype_combo_vendor'), value: 'ms3-combo-vendor' }, { label: _('ms3_vue_xtype_combo_autocomplete'), value: 'ms3-combo-autocomplete' }, { label: _('ms3_vue_xtype_combo_options'), value: 'ms3-combo-options' }, @@ -147,20 +155,27 @@ const indexTypeOptions = computed(() => [ ]) const isRepeaterField = computed(() => fieldForm.value.xtype === REPEATER_XTYPE) +const isKeyValueField = computed(() => fieldForm.value.xtype === KEY_VALUE_XTYPE) watch( () => fieldForm.value.xtype, xtype => { - if (xtype !== REPEATER_XTYPE) { - return - } - - fieldForm.value.dbtype = 'json' - fieldForm.value.phptype = 'json' - fieldForm.value.precision = '' - fieldForm.value.null = true - if (!fieldForm.value.repeater_config?.columns?.length) { - fieldForm.value.repeater_config = defaultRepeaterConfig() + if (xtype === REPEATER_XTYPE) { + fieldForm.value.dbtype = 'json' + fieldForm.value.phptype = 'json' + fieldForm.value.precision = '' + fieldForm.value.null = true + if (!fieldForm.value.repeater_config?.columns?.length) { + fieldForm.value.repeater_config = defaultRepeaterConfig() + } + } else if (xtype === KEY_VALUE_XTYPE) { + fieldForm.value.dbtype = 'json' + fieldForm.value.phptype = 'json' + fieldForm.value.precision = '' + fieldForm.value.null = true + if (!fieldForm.value.key_value_config?.mode) { + fieldForm.value.key_value_config = defaultKeyValueConfig() + } } } ) @@ -169,6 +184,10 @@ function buildRepeaterConfigPayload() { return JSON.stringify(parseRepeaterConfig(fieldForm.value.repeater_config)) } +function buildKeyValueConfigPayload() { + return JSON.stringify(parseKeyValueConfig(fieldForm.value.key_value_config)) +} + /** * Load fields list */ @@ -223,6 +242,7 @@ function openCreateDialog() { active: true, select_options: '', repeater_config: defaultRepeaterConfig(), + key_value_config: defaultKeyValueConfig(), } dialogVisible.value = true @@ -254,6 +274,7 @@ function openEditDialog(field) { active: field.active, select_options: field.select_options || '', repeater_config: parseRepeaterConfig(field.repeater_config), + key_value_config: parseKeyValueConfig(field.key_value_config), } dialogVisible.value = true @@ -316,6 +337,7 @@ async function createField() { fieldForm.value.active === 'true' || fieldForm.value.active === 1, repeater_config: isRepeaterField.value ? buildRepeaterConfigPayload() : '', + key_value_config: isKeyValueField.value ? buildKeyValueConfigPayload() : '', } delete payload.id @@ -367,6 +389,7 @@ async function updateField() { select_options: fieldForm.value.xtype === 'ms3-combo-select' ? fieldForm.value.select_options : '', repeater_config: isRepeaterField.value ? buildRepeaterConfigPayload() : '', + key_value_config: isKeyValueField.value ? buildKeyValueConfigPayload() : '', } const response = await request.put(`/api/mgr/extra-fields/${fieldForm.value.id}`, payload) @@ -720,6 +743,13 @@ onMounted(() => { {{ _('ms3_vue_repeater_schema_help') }}
+ + +
+ + + {{ _('ms3_vue_key_value_schema_help') }} +
@@ -737,7 +767,7 @@ onMounted(() => { option-value="value" :placeholder="_('ms3_vue_dialog_xtype_select')" class="w-full" - :disabled="isEditMode || isRepeaterField" + :disabled="isEditMode || isRepeaterField || isKeyValueField" /> @@ -764,7 +794,7 @@ onMounted(() => { option-value="value" :placeholder="_('ms3_vue_dialog_xtype_select')" class="w-full" - :disabled="isEditMode || isRepeaterField" + :disabled="isEditMode || isRepeaterField || isKeyValueField" /> diff --git a/vueManager/src/components/KeyValueField.vue b/vueManager/src/components/KeyValueField.vue new file mode 100644 index 00000000..b483ff93 --- /dev/null +++ b/vueManager/src/components/KeyValueField.vue @@ -0,0 +1,263 @@ + + + + + diff --git a/vueManager/src/components/KeyValueSchemaEditor.vue b/vueManager/src/components/KeyValueSchemaEditor.vue new file mode 100644 index 00000000..b3e763e0 --- /dev/null +++ b/vueManager/src/components/KeyValueSchemaEditor.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/vueManager/src/components/order/OrderExtraFieldsSection.vue b/vueManager/src/components/order/OrderExtraFieldsSection.vue index 0c75e98e..3d04a7a5 100644 --- a/vueManager/src/components/order/OrderExtraFieldsSection.vue +++ b/vueManager/src/components/order/OrderExtraFieldsSection.vue @@ -4,6 +4,7 @@ import Fieldset from 'primevue/fieldset' import { computed, inject } from 'vue' import { ORDER_CONTEXT_KEY } from '../../composables/orderContext.js' +import { KEY_VALUE_XTYPE } from '../../utils/keyValueField.js' import { REPEATER_XTYPE } from '../../utils/repeaterField.js' import DynamicField from '../DynamicField.vue' @@ -31,13 +32,15 @@ function getFieldConfig(field) { description: field.description || '', config: { repeater_config: field.repeater_config, + key_value_config: field.key_value_config, }, repeater_config: field.repeater_config, + key_value_config: field.key_value_config, } } function getWidthClass(field) { - return field.xtype === REPEATER_XTYPE ? 'col-12' : 'col-6' + return [REPEATER_XTYPE, KEY_VALUE_XTYPE].includes(field.xtype) ? 'col-12' : 'col-6' } diff --git a/vueManager/src/utils/keyValueField.js b/vueManager/src/utils/keyValueField.js new file mode 100644 index 00000000..86c9484f --- /dev/null +++ b/vueManager/src/utils/keyValueField.js @@ -0,0 +1,111 @@ +export const KEY_VALUE_XTYPE = 'ms3-key-value' + +export function defaultKeyValueConfig() { + return { + mode: 'free', // 'free' or 'fixed' + keys: [], // Array of { key: string, label: string, valueType: 'string'|'number', required: boolean } + } +} + +/** + * Example fixed keys config (optional): + * { + * mode: 'fixed', + * keys: [ + * { key: 'width', label: 'Width', valueType: 'number', required: true }, + * { key: 'color', label: 'Color', valueType: 'string', required: false } + * ] + * } + */ + +export function parseKeyValueConfig(raw) { + if (!raw) { + return defaultKeyValueConfig() + } + + let parsed = raw + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw) + } catch { + return defaultKeyValueConfig() + } + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return defaultKeyValueConfig() + } + + const defaults = defaultKeyValueConfig() + + return { + ...defaults, + ...parsed, + keys: Array.isArray(parsed.keys) ? parsed.keys : defaults.keys, + } +} + +export function parseKeyValueModelValue(value) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value + } + if (typeof value === 'string' && value.trim() !== '') { + try { + const parsed = JSON.parse(value) + return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {} + } catch { + return {} + } + } + return {} +} + +export function normalizeKeyValueMap(map, config) { + const schema = parseKeyValueConfig(config) + const normalized = {} + + if (schema.mode === 'fixed') { + for (const keyDef of schema.keys) { + if (!keyDef.key) continue + const value = map?.[keyDef.key] + if (keyDef.valueType === 'number') { + normalized[keyDef.key] = (value === '' || value === null || value === undefined) + ? null + : Number(value) + } else { + normalized[keyDef.key] = value ?? '' + } + } + } else { + // Free mode: just ensure it's a flat object of scalars + if (map && typeof map === 'object' && !Array.isArray(map)) { + for (const [k, v] of Object.entries(map)) { + normalized[k] = v + } + } + } + + return normalized +} + +export function serializeKeyValueForPost(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return '{}' + } + try { + return JSON.stringify(value) + } catch { + return '{}' + } +} + +export function getKeyValueConfigFromField(fieldConfig) { + if (!fieldConfig) { + return defaultKeyValueConfig() + } + return parseKeyValueConfig( + fieldConfig.config?.key_value_config + ?? fieldConfig.key_value_config + ?? fieldConfig.config + ) +}