diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php index 73d32a5f..2a79dfe9 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -170,6 +170,7 @@ $_lang['ms3_err_ns'] = 'This field is required'; $_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_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 31b56923..3ccc32f2 100644 --- a/core/components/minishop3/lexicon/en/vue.inc.php +++ b/core/components/minishop3/lexicon/en/vue.inc.php @@ -160,6 +160,7 @@ $_lang['ms3_vue_xtype_textarea'] = 'Text Area'; $_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_combo_vendor'] = 'Vendor (combo)'; $_lang['ms3_vue_xtype_combo_autocomplete'] = 'Autocomplete (combo)'; $_lang['ms3_vue_xtype_combo_options'] = 'Product Options (chips)'; @@ -169,6 +170,25 @@ $_lang['ms3_vue_select_options_placeholder'] = "value1==First option\nvalue2==Second option\nvalue3==Third option"; $_lang['ms3_vue_select_options_help'] = 'Format: value==label (one per line). If label is not specified, the value will be used.'; +// Repeater field +$_lang['ms3_vue_repeater_schema_label'] = 'Repeater schema'; +$_lang['ms3_vue_repeater_schema_help'] = 'Define columns for each row. Order is saved with automatic rank.'; +$_lang['ms3_vue_repeater_columns'] = 'Columns'; +$_lang['ms3_vue_repeater_add_column'] = 'Add column'; +$_lang['ms3_vue_repeater_column_key'] = 'Key'; +$_lang['ms3_vue_repeater_column_label'] = 'Label'; +$_lang['ms3_vue_repeater_required'] = 'Required'; +$_lang['ms3_vue_repeater_rank_field'] = 'Rank field'; +$_lang['ms3_vue_repeater_min_rows'] = 'Min rows'; +$_lang['ms3_vue_repeater_max_rows'] = 'Max rows'; +$_lang['ms3_vue_repeater_unlimited'] = 'Unlimited'; +$_lang['ms3_vue_repeater_sortable'] = 'Drag-and-drop sorting'; +$_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'; +$_lang['ms3_vue_order_extra_fields'] = 'Additional order fields'; +$_lang['ms3_vue_order_address_extra_fields'] = 'Additional address fields'; + // Database types (dbtype) $_lang['ms3_vue_dbtype_varchar'] = 'VARCHAR (string)'; $_lang['ms3_vue_dbtype_text'] = 'TEXT (text)'; diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php index 2a3334ef..1a01983e 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -170,6 +170,7 @@ $_lang['ms3_err_ns'] = 'Это поле обязательно'; $_lang['ms3_err_ae'] = 'Это поле должно быть уникально'; $_lang['ms3_err_json'] = 'Это поле требует JSON строку'; +$_lang['ms3_repeater_validation_error'] = 'Поле повторителя «[[+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 93520236..b0d5c080 100644 --- a/core/components/minishop3/lexicon/ru/vue.inc.php +++ b/core/components/minishop3/lexicon/ru/vue.inc.php @@ -160,6 +160,7 @@ $_lang['ms3_vue_xtype_textarea'] = 'Текстовая область'; $_lang['ms3_vue_xtype_xcheckbox'] = 'Флажок'; $_lang['ms3_vue_xtype_combo_select'] = 'Выпадающий список'; +$_lang['ms3_vue_xtype_repeater'] = 'Повторитель (таблица строк)'; $_lang['ms3_vue_xtype_combo_vendor'] = 'Производитель (combo)'; $_lang['ms3_vue_xtype_combo_autocomplete'] = 'Автодополнение (combo)'; $_lang['ms3_vue_xtype_combo_options'] = 'Опции товара (chips)'; @@ -169,6 +170,25 @@ $_lang['ms3_vue_select_options_placeholder'] = "value1==Первый вариант\nvalue2==Второй вариант\nvalue3==Третий вариант"; $_lang['ms3_vue_select_options_help'] = 'Формат: значение==подпись (по одному на строку). Если подпись не указана, будет использовано значение.'; +// Repeater field +$_lang['ms3_vue_repeater_schema_label'] = 'Схема повторителя'; +$_lang['ms3_vue_repeater_schema_help'] = 'Определите колонки для каждой строки. Порядок сохраняется с автоматическим rank.'; +$_lang['ms3_vue_repeater_columns'] = 'Колонки'; +$_lang['ms3_vue_repeater_add_column'] = 'Добавить колонку'; +$_lang['ms3_vue_repeater_column_key'] = 'Ключ'; +$_lang['ms3_vue_repeater_column_label'] = 'Подпись'; +$_lang['ms3_vue_repeater_required'] = 'Обязательное'; +$_lang['ms3_vue_repeater_rank_field'] = 'Поле rank'; +$_lang['ms3_vue_repeater_min_rows'] = 'Мин. строк'; +$_lang['ms3_vue_repeater_max_rows'] = 'Макс. строк'; +$_lang['ms3_vue_repeater_unlimited'] = 'Без ограничения'; +$_lang['ms3_vue_repeater_sortable'] = 'Сортировка перетаскиванием'; +$_lang['ms3_vue_repeater_add_row'] = 'Добавить строку'; +$_lang['ms3_vue_repeater_no_columns'] = 'Настройте колонки повторителя в параметрах extra field.'; +$_lang['ms3_vue_repeater_drag_hint'] = 'Перетащите для изменения порядка'; +$_lang['ms3_vue_order_extra_fields'] = 'Дополнительные поля заказа'; +$_lang['ms3_vue_order_address_extra_fields'] = 'Дополнительные поля адреса'; + // Типы данных БД (dbtype) $_lang['ms3_vue_dbtype_varchar'] = 'VARCHAR (строка)'; $_lang['ms3_vue_dbtype_text'] = 'TEXT (текст)'; diff --git a/core/components/minishop3/migrations/20260524120000_add_repeater_config_to_extra_fields.php b/core/components/minishop3/migrations/20260524120000_add_repeater_config_to_extra_fields.php new file mode 100644 index 00000000..6853c727 --- /dev/null +++ b/core/components/minishop3/migrations/20260524120000_add_repeater_config_to_extra_fields.php @@ -0,0 +1,35 @@ +table('ms3_extra_fields'); + + if (!$table->hasColumn('repeater_config')) { + $table->addColumn('repeater_config', 'text', [ + 'null' => true, + 'after' => 'select_options', + 'comment' => 'JSON schema for ms3-repeater field type (columns, rankField, min/max rows)', + ]); + $table->update(); + } + } + + public function down(): void + { + $table = $this->table('ms3_extra_fields'); + + if ($table->hasColumn('repeater_config')) { + $table->removeColumn('repeater_config'); + $table->update(); + } + } +} diff --git a/core/components/minishop3/schema/minishop3.mysql.schema.xml b/core/components/minishop3/schema/minishop3.mysql.schema.xml index ec4c333d..071261a6 100644 --- a/core/components/minishop3/schema/minishop3.mysql.schema.xml +++ b/core/components/minishop3/schema/minishop3.mysql.schema.xml @@ -599,6 +599,7 @@ + diff --git a/core/components/minishop3/src/Controllers/Api/Manager/OrdersController.php b/core/components/minishop3/src/Controllers/Api/Manager/OrdersController.php index 58cec930..5b9263f6 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\RepeaterFieldService; use MiniShop3\Services\CustomerDuplicateChecker; use MiniShop3\Services\CustomerFactory; use MiniShop3\Services\FilterConfigManager; @@ -841,15 +842,29 @@ public function update(array $params = []): array // Handle extra fields for msOrder (stored as real DB columns via Object Extension) $orderExtraFields = $this->getExtraFieldKeys('MiniShop3\\Model\\msOrder'); - foreach ($orderExtraFields as $extraField) { - if (array_key_exists($extraField, $params)) { - $oldValue = $order->get($extraField); - $newValue = $params[$extraField]; - if ($oldValue != $newValue) { - $changedOrderFields[$extraField] = ['old' => $oldValue, 'new' => $newValue]; - } - $order->set($extraField, $newValue); + foreach ($orderExtraFields as $extraFieldKey) { + if (!array_key_exists($extraFieldKey, $params)) { + continue; + } + + $normalized = $this->normalizeExtraFieldValue( + 'MiniShop3\\Model\\msOrder', + $extraFieldKey, + $params[$extraFieldKey] + ); + if (!$normalized['ok']) { + return Response::error( + $normalized['message'], + HttpStatus::UNPROCESSABLE_ENTITY + )->getData(); } + + $newValue = $normalized['value']; + $oldValue = $order->get($extraFieldKey); + if ($oldValue != $newValue) { + $changedOrderFields[$extraFieldKey] = ['old' => $oldValue, 'new' => $newValue]; + } + $order->set($extraFieldKey, $newValue); } $order->set('updatedon', date('Y-m-d H:i:s')); @@ -889,15 +904,29 @@ public function update(array $params = []): array // Handle extra fields for msOrderAddress (stored as real DB columns via Object Extension) $addressExtraFields = $this->getExtraFieldKeys('MiniShop3\\Model\\msOrderAddress'); - foreach ($addressExtraFields as $extraField) { - if (array_key_exists($extraField, $params)) { - $oldValue = $address->get($extraField); - $newValue = $params[$extraField]; - if ($oldValue != $newValue) { - $changedAddressFields[$extraField] = ['old' => $oldValue, 'new' => $newValue]; - } - $address->set($extraField, $newValue); + foreach ($addressExtraFields as $extraFieldKey) { + if (!array_key_exists($extraFieldKey, $params)) { + continue; + } + + $normalized = $this->normalizeExtraFieldValue( + 'MiniShop3\\Model\\msOrderAddress', + $extraFieldKey, + $params[$extraFieldKey] + ); + if (!$normalized['ok']) { + return Response::error( + $normalized['message'], + HttpStatus::UNPROCESSABLE_ENTITY + )->getData(); + } + + $newValue = $normalized['value']; + $oldValue = $address->get($extraFieldKey); + if ($oldValue != $newValue) { + $changedAddressFields[$extraFieldKey] = ['old' => $oldValue, 'new' => $newValue]; } + $address->set($extraFieldKey, $newValue); } $address->save(); @@ -1663,6 +1692,41 @@ protected function formatOrder(array $data): array return $data; } + /** + * @return array{ok: bool, value?: mixed, message?: string} + */ + protected function normalizeExtraFieldValue(string $modelClass, string $fieldKey, mixed $value): array + { + /** @var msExtraField|null $definition */ + $definition = $this->modx->getObject(msExtraField::class, [ + 'class' => $modelClass, + 'key' => $fieldKey, + 'active' => true, + ]); + + if (!$definition || $definition->get('xtype') !== RepeaterFieldService::XTYPE) { + return ['ok' => true, 'value' => $value]; + } + + /** @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'); + + return [ + 'ok' => false, + 'message' => $this->modx->lexicon('ms3_repeater_validation_error', [ + 'field' => $fieldKey, + 'error' => $e->getMessage(), + ]), + ]; + } + } + /** * Get extra field keys for a specific model class * diff --git a/core/components/minishop3/src/Model/mysql/msExtraField.php b/core/components/minishop3/src/Model/mysql/msExtraField.php index d6686fd3..216c6700 100644 --- a/core/components/minishop3/src/Model/mysql/msExtraField.php +++ b/core/components/minishop3/src/Model/mysql/msExtraField.php @@ -28,6 +28,7 @@ class msExtraField extends \MiniShop3\Model\msExtraField 'index_type' => 'NONE', 'active' => 0, 'select_options' => null, + 'repeater_config' => null, ], 'fieldMeta' => [ 'class' => [ @@ -122,7 +123,12 @@ class msExtraField extends \MiniShop3\Model\msExtraField 'dbtype' => 'text', 'phptype' => 'string', 'null' => true, - ] + ], + 'repeater_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 654c500f..423acbca 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -63,6 +63,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Product\ProductDataService::class, 'interface' => null, ], + 'ms3_repeater_field' => [ + 'class' => \MiniShop3\Services\ExtraFields\RepeaterFieldService::class, + 'interface' => null, + ], 'ms3_product_image' => [ 'class' => \MiniShop3\Services\Product\ProductImageService::class, 'interface' => null, diff --git a/core/components/minishop3/src/Services/ExtraFields/RepeaterFieldService.php b/core/components/minishop3/src/Services/ExtraFields/RepeaterFieldService.php new file mode 100644 index 00000000..20208a67 --- /dev/null +++ b/core/components/minishop3/src/Services/ExtraFields/RepeaterFieldService.php @@ -0,0 +1,296 @@ +>|null */ + private ?array $fieldsByClass = null; + + public function __construct(modX $modx) + { + $this->modx = $modx; + } + + public function isRepeaterXtype(?string $xtype): bool + { + return $xtype === self::XTYPE; + } + + public function defaultConfig(): array + { + return [ + 'columns' => [], + 'minRows' => 0, + 'maxRows' => null, + 'sortable' => true, + 'rankField' => 'rank', + ]; + } + + /** + * @param mixed $json JSON string or array + */ + public function parseConfig(mixed $json): array + { + $config = $this->defaultConfig(); + + if (is_string($json) && $json !== '') { + $decoded = json_decode($json, true); + if (is_array($decoded)) { + $config = array_merge($config, $decoded); + } + } elseif (is_array($json)) { + $config = array_merge($config, $json); + } + + if (!is_array($config['columns'])) { + $config['columns'] = []; + } + + if ($config['rankField'] === '' || $config['rankField'] === null) { + $config['rankField'] = 'rank'; + } + + 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 (empty($config['columns'])) { + return ['success' => false, 'message' => 'Repeater requires at least one column']; + } + + $keys = []; + foreach ($config['columns'] as $column) { + if (!is_array($column)) { + return ['success' => false, 'message' => 'Invalid column definition']; + } + $key = trim((string)($column['key'] ?? '')); + if ($key === '') { + return ['success' => false, 'message' => 'Each column must have a key']; + } + if (in_array($key, $keys, true)) { + return ['success' => false, 'message' => "Duplicate column key: {$key}"]; + } + $keys[] = $key; + } + + return ['success' => true, 'config' => $config]; + } + + /** + * @return list> + */ + public function decodeValue(mixed $value): array + { + if ($value === null || $value === '') { + return []; + } + + if (is_string($value)) { + $decoded = json_decode($value, true); + if (!is_array($decoded)) { + throw new \InvalidArgumentException('Repeater value must be a JSON array'); + } + return array_values($decoded); + } + + if (is_array($value)) { + return array_values($value); + } + + throw new \InvalidArgumentException('Repeater value must be an array'); + } + + /** + * @param list> $rows + * @return list> + */ + public function normalizeRows(array $rows, array $config): array + { + $rankField = (string)($config['rankField'] ?? 'rank'); + $maxRows = $config['maxRows'] ?? null; + $normalized = []; + $rank = 0; + + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $cleanRow = $this->sanitizeRow($row, $config); + if ($this->isEmptyRow($cleanRow, $rankField)) { + continue; + } + + $cleanRow[$rankField] = $rank; + $normalized[] = $cleanRow; + $rank++; + + if ($maxRows !== null && count($normalized) >= (int)$maxRows) { + break; + } + } + + return $normalized; + } + + /** + * @param list> $rows + * @return array{ok: bool, errors?: list} + */ + public function validateRows(array $rows, array $config): array + { + $errors = []; + $minRows = (int)($config['minRows'] ?? 0); + $maxRows = $config['maxRows'] ?? null; + + if (count($rows) < $minRows) { + $errors[] = "Minimum {$minRows} rows required"; + } + + if ($maxRows !== null && count($rows) > (int)$maxRows) { + $errors[] = "Maximum {$maxRows} rows allowed"; + } + + foreach ($rows as $index => $row) { + if (!is_array($row)) { + $errors[] = "Row {$index} must be an object"; + continue; + } + + foreach ($config['columns'] ?? [] as $column) { + $key = (string)($column['key'] ?? ''); + if ($key === '') { + continue; + } + + $cellValue = $row[$key] ?? null; + if (!empty($column['required']) && ($cellValue === null || $cellValue === '')) { + $errors[] = "Row {$index}: {$key} is required"; + } + + if (($column['xtype'] ?? 'textfield') === 'numberfield' + && $cellValue !== null + && $cellValue !== '' + && !is_numeric($cellValue) + ) { + $errors[] = "Row {$index}: {$key} must be numeric"; + } + } + } + + return empty($errors) ? ['ok' => true] : ['ok' => false, 'errors' => $errors]; + } + + /** + * Decode, validate and normalize repeater payload. + * + * @return list> + */ + public function processValue(mixed $value, array $config): array + { + $rows = $this->decodeValue($value); + $normalized = $this->normalizeRows($rows, $config); + $validation = $this->validateRows($normalized, $config); + + if (!$validation['ok']) { + throw new \InvalidArgumentException(implode('; ', $validation['errors'] ?? [])); + } + + return $normalized; + } + + /** + * @return array field key => parsed config + */ + public function getRepeaterFieldsForClass(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('repeater_config')); + } + + $this->fieldsByClass[$modelClass] = $map; + + return $map; + } + + /** + * @param array $row + * @return array + */ + private function sanitizeRow(array $row, array $config): array + { + $rankField = (string)($config['rankField'] ?? 'rank'); + $allowedKeys = array_column($config['columns'] ?? [], 'key'); + $clean = []; + + foreach ($allowedKeys as $key) { + if (!is_string($key) || $key === '') { + continue; + } + if (array_key_exists($key, $row)) { + $clean[$key] = $row[$key]; + } + } + + unset($clean[$rankField]); + + return $clean; + } + + /** + * @param array $row + */ + private function isEmptyRow(array $row, string $rankField): bool + { + foreach ($row as $key => $value) { + if ($key === $rankField) { + continue; + } + if ($value !== null && $value !== '') { + return false; + } + } + + return true; + } +} diff --git a/core/components/minishop3/src/Services/ExtraFieldsService.php b/core/components/minishop3/src/Services/ExtraFieldsService.php index 01997fae..8b6a7b34 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\RepeaterFieldService; use MiniShop3\Utils\ExtraFields; use MODX\Revolution\modX; use Phinx\Config\Config; @@ -29,6 +30,11 @@ public function __construct(modX $modx) */ public function createField(array $data): array { + $repeaterError = $this->applyRepeaterConstraints($data); + if ($repeaterError !== null) { + return $repeaterError; + } + $validation = $this->validateFieldData($data); if (!$validation['success']) { return $validation; @@ -252,8 +258,14 @@ private function createProductFieldFromExtra(msExtraField $extraField): void $config = null; if ($extraField->get('xtype') === 'ms3-combo-select' && $extraField->get('select_options')) { $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')), + ]; } + $width = $extraField->get('xtype') === RepeaterFieldService::XTYPE ? 12 : 6; + $productField->fromArray([ 'name' => $extraField->get('key'), 'label' => $extraField->get('label') ?: $extraField->get('key'), @@ -263,7 +275,7 @@ private function createProductFieldFromExtra(msExtraField $extraField): void 'visible' => $extraField->get('active') ? 1 : 0, 'required' => 0, 'sort_order' => 999, - 'width' => 6, + 'width' => $width, 'is_system' => 0, 'is_default' => 0, 'config' => $config, @@ -294,7 +306,16 @@ public function updateField(int $id, array $data): array return ['success' => false, 'message' => 'Field not found']; } - $allowedFields = ['label', 'description', 'xtype', 'active', 'select_options']; + if (($data['xtype'] ?? $field->get('xtype')) === RepeaterFieldService::XTYPE) { + $repeaterPayload = array_merge($field->toArray(), $data); + $repeaterError = $this->applyRepeaterConstraints($repeaterPayload); + if ($repeaterError !== null) { + return $repeaterError; + } + $data['repeater_config'] = $repeaterPayload['repeater_config'] ?? null; + } + + $allowedFields = ['label', 'description', 'xtype', 'active', 'select_options', 'repeater_config']; foreach ($allowedFields as $fieldName) { if (isset($data[$fieldName])) { @@ -302,6 +323,10 @@ public function updateField(int $id, array $data): array } } + if ($field->get('xtype') !== RepeaterFieldService::XTYPE) { + $field->set('repeater_config', null); + } + if (!$field->save()) { return ['success' => false, 'message' => 'Failed to update field in database']; } @@ -340,8 +365,18 @@ private function updateProductFieldFromExtra(msExtraField $extraField): void $config = $productField->get('config') ?: []; if ($extraField->get('xtype') === 'ms3-combo-select') { $config['select_options'] = $extraField->get('select_options') ?: ''; - } else { + unset($config['repeater_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['repeater_config']); + } + + if ($extraField->get('xtype') === RepeaterFieldService::XTYPE) { + $productField->set('width', 12); } $productField->set('config', !empty($config) ? $config : null); @@ -368,4 +403,40 @@ private function deleteProductFieldsByName(string $fieldName): void $this->modx->log(modX::LOG_LEVEL_INFO, "[ExtraFieldsService] Deleted msProductField: {$fieldName}"); } + + /** + * @param array $data + * @return array{success: false, message: string}|null + */ + private function applyRepeaterConstraints(array &$data): ?array + { + if (($data['xtype'] ?? '') !== RepeaterFieldService::XTYPE) { + return null; + } + + $data['dbtype'] = 'json'; + $data['phptype'] = 'json'; + $data['precision'] = ''; + $data['null'] = true; + + $configValidation = $this->getRepeaterFieldService()->validateConfigSchema($data['repeater_config'] ?? ''); + if (!$configValidation['success']) { + return [ + 'success' => false, + 'message' => $configValidation['message'] ?? 'Invalid repeater configuration', + ]; + } + + $data['repeater_config'] = $this->getRepeaterFieldService()->encodeConfig($configValidation['config']); + + return null; + } + + private function getRepeaterFieldService(): RepeaterFieldService + { + /** @var RepeaterFieldService $service */ + $service = $this->modx->services->get('ms3_repeater_field'); + + return $service; + } } diff --git a/core/components/minishop3/src/Services/Product/ProductDataService.php b/core/components/minishop3/src/Services/Product/ProductDataService.php index e3259fa0..47006292 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\RepeaterFieldService; use MODX\Revolution\modX; /** @@ -21,6 +22,9 @@ class ProductDataService /** @var modX */ protected $modx; + /** @var array|null */ + protected ?array $productRepeaterFields = null; + /** * @param modX $modx */ @@ -29,6 +33,28 @@ public function __construct(modX $modx) $this->modx = $modx; } + protected function getRepeaterFieldService(): RepeaterFieldService + { + /** @var RepeaterFieldService $service */ + $service = $this->modx->services->get('ms3_repeater_field'); + + return $service; + } + + /** + * @return array + */ + protected function getProductRepeaterFields(): array + { + if ($this->productRepeaterFields === null) { + $this->productRepeaterFields = $this->getRepeaterFieldService()->getRepeaterFieldsForClass( + 'MiniShop3\\Model\\msProductData' + ); + } + + return $this->productRepeaterFields; + } + /** * Prepare object before saving * @@ -42,7 +68,16 @@ public function __construct(modX $modx) */ public function prepareObject(msProductData $productData): void { + $repeaterFields = $this->getProductRepeaterFields(); + $repeaterService = $this->getRepeaterFieldService(); + foreach ($productData->getArraysValues() as $name => $array) { + if (isset($repeaterFields[$name])) { + $normalized = $repeaterService->processValue($array, $repeaterFields[$name]); + $productData->set($name, $normalized); + continue; + } + $array = $productData->prepareOptionValues($array); $productData->set($name, $array); } @@ -141,10 +176,15 @@ public function saveOptions(msProductData $productData, ?array $options = null, $productId = $productData->get('id'); $optionsExplicit = $options !== null; + $repeaterKeys = array_keys($this->getProductRepeaterFields()); + if ($options === null) { $options = []; foreach ($productData->_fieldMeta as $key => $value) { if ($value['phptype'] === 'json' && !empty($productData->get($key))) { + if (in_array($key, $repeaterKeys, true)) { + continue; + } // Use field name as key, not numeric index from array_merge $options[$key] = $productData->get($key); } @@ -621,6 +661,11 @@ public function updateProductData(int $productId, array $data): array } $fieldsToUpdate = array_intersect_key($filtered, array_flip(self::$allowedUpdateFields)); + + $repeaterError = $this->normalizeRepeaterFieldsInPayload($fieldsToUpdate); + if ($repeaterError !== null) { + return ['ok' => false, 'code' => self::ERROR_VALIDATION, 'message' => $repeaterError]; + } $oldValues = []; foreach (array_keys($fieldsToUpdate) as $key) { $oldValues[$key] = $productData->get($key); @@ -646,4 +691,37 @@ public function updateProductData(int $productId, array $data): array } return ['ok' => true, 'data' => $result]; } + + /** + * Validate and normalize repeater extra fields in manager API payload. + * + * @param array $payload + */ + protected function normalizeRepeaterFieldsInPayload(array &$payload): ?string + { + $repeaterFields = $this->getProductRepeaterFields(); + if ($repeaterFields === []) { + return null; + } + + $repeaterService = $this->getRepeaterFieldService(); + $this->modx->lexicon->load('minishop3:default'); + + foreach ($repeaterFields as $fieldKey => $config) { + if (!array_key_exists($fieldKey, $payload)) { + continue; + } + + try { + $payload[$fieldKey] = $repeaterService->processValue($payload[$fieldKey], $config); + } catch (\InvalidArgumentException $e) { + return $this->modx->lexicon('ms3_repeater_validation_error', [ + 'field' => $fieldKey, + 'error' => $e->getMessage(), + ]); + } + } + + return null; + } } diff --git a/vueManager/src/components/DynamicField.vue b/vueManager/src/components/DynamicField.vue index 88f74e73..a9ef8bf3 100644 --- a/vueManager/src/components/DynamicField.vue +++ b/vueManager/src/components/DynamicField.vue @@ -182,6 +182,14 @@ + + +
@@ -222,9 +230,11 @@ import Textarea from 'primevue/textarea' import ToggleSwitch from 'primevue/toggleswitch' import { computed, ref, watch } from 'vue' +import { getRepeaterConfigFromField, parseRepeaterModelValue } from '../utils/repeaterField.js' import AutocompleteCombo from './AutocompleteCombo.vue' import FileBrowser from './FileBrowser.vue' import OptionsChips from './OptionsChips.vue' +import RepeaterField from './RepeaterField.vue' import VendorCombo from './VendorCombo.vue' const props = defineProps({ @@ -310,12 +320,6 @@ const isExtJSComboField = computed(() => { return props.fieldConfig.xtype.startsWith('ms3-combo-') }) -/** - * Parse select_options string into array for ms3-combo-select - * Format: "value1==label1\nvalue2==label2" or just "value1\nvalue2" - * Note: select_options may be in fieldConfig.config.select_options or fieldConfig.select_options - * depending on how the config was merged in PHP - */ const selectOptions = computed(() => { const optionsString = props.fieldConfig.config?.select_options || props.fieldConfig.select_options || '' @@ -333,6 +337,15 @@ const selectOptions = computed(() => { }) }) +const repeaterConfig = computed(() => getRepeaterConfigFromField(props.fieldConfig)) + +function normalizeIncomingValue(value) { + if (props.fieldConfig.xtype === 'ms3-repeater') { + return parseRepeaterModelValue(value) + } + return value +} + /** * Get ExtJS combo field description */ @@ -371,13 +384,13 @@ const serializedValue = computed(() => { const emit = defineEmits(['update:modelValue', 'blur']) // Local value for v-model -const localValue = ref(props.modelValue) +const localValue = ref(normalizeIncomingValue(props.modelValue)) // Watch for external changes watch( () => props.modelValue, newValue => { - localValue.value = newValue + localValue.value = normalizeIncomingValue(newValue) } ) diff --git a/vueManager/src/components/ExtraFieldsManager.vue b/vueManager/src/components/ExtraFieldsManager.vue index 57e53a57..48cf3df3 100644 --- a/vueManager/src/components/ExtraFieldsManager.vue +++ b/vueManager/src/components/ExtraFieldsManager.vue @@ -15,9 +15,15 @@ import Textarea from 'primevue/textarea' import Toast from 'primevue/toast' import { useConfirm } from 'primevue/useconfirm' import { useToast } from 'primevue/usetoast' -import { computed, onMounted, ref } from 'vue' +import { computed, onMounted, ref, watch } from 'vue' import request from '../request.js' +import { + defaultRepeaterConfig, + parseRepeaterConfig, + REPEATER_XTYPE, +} from '../utils/repeaterField.js' +import RepeaterSchemaEditor from './RepeaterSchemaEditor.vue' const toast = useToast() const confirm = useConfirm() @@ -51,6 +57,7 @@ const fieldForm = ref({ index_type: 'NONE', active: true, select_options: '', + repeater_config: defaultRepeaterConfig(), }) /** @@ -86,6 +93,7 @@ const xtypeOptions = computed(() => [ { label: _('ms3_vue_xtype_textarea'), value: 'textarea' }, { 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_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' }, @@ -138,6 +146,29 @@ const indexTypeOptions = computed(() => [ { label: _('ms3_vue_index_fulltext'), value: 'FULLTEXT' }, ]) +const isRepeaterField = computed(() => fieldForm.value.xtype === REPEATER_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() + } + } +) + +function buildRepeaterConfigPayload() { + return JSON.stringify(parseRepeaterConfig(fieldForm.value.repeater_config)) +} + /** * Load fields list */ @@ -191,6 +222,7 @@ function openCreateDialog() { index_type: 'NONE', active: true, select_options: '', + repeater_config: defaultRepeaterConfig(), } dialogVisible.value = true @@ -221,6 +253,7 @@ function openEditDialog(field) { index_type: field.index_type || 'NONE', active: field.active, select_options: field.select_options || '', + repeater_config: parseRepeaterConfig(field.repeater_config), } dialogVisible.value = true @@ -282,8 +315,11 @@ async function createField() { fieldForm.value.active === true || fieldForm.value.active === 'true' || fieldForm.value.active === 1, + repeater_config: isRepeaterField.value ? buildRepeaterConfigPayload() : '', } + delete payload.id + const response = await request.post('/api/mgr/extra-fields', payload) if (response && response.field) { @@ -330,6 +366,7 @@ async function updateField() { fieldForm.value.active === 1, select_options: fieldForm.value.xtype === 'ms3-combo-select' ? fieldForm.value.select_options : '', + repeater_config: isRepeaterField.value ? buildRepeaterConfigPayload() : '', } const response = await request.put(`/api/mgr/extra-fields/${fieldForm.value.id}`, payload) @@ -676,6 +713,13 @@ onMounted(() => { /> {{ _('ms3_vue_select_options_help') }}
+ + +
+ + + {{ _('ms3_vue_repeater_schema_help') }} +
@@ -693,7 +737,7 @@ onMounted(() => { option-value="value" :placeholder="_('ms3_vue_dialog_xtype_select')" class="w-full" - :disabled="isEditMode" + :disabled="isEditMode || isRepeaterField" /> @@ -705,7 +749,7 @@ onMounted(() => { v-model="fieldForm.precision" :placeholder="_('ms3_vue_dialog_precision_placeholder')" class="w-full" - :disabled="isEditMode" + :disabled="isEditMode || isRepeaterField" /> @@ -720,7 +764,7 @@ onMounted(() => { option-value="value" :placeholder="_('ms3_vue_dialog_xtype_select')" class="w-full" - :disabled="isEditMode" + :disabled="isEditMode || isRepeaterField" /> diff --git a/vueManager/src/components/OrderView.vue b/vueManager/src/components/OrderView.vue index 22fc1153..589f7286 100644 --- a/vueManager/src/components/OrderView.vue +++ b/vueManager/src/components/OrderView.vue @@ -23,6 +23,7 @@ import { useOrderFieldHelpers } from '../composables/useOrderFieldHelpers.js' import { useOrderFormatters } from '../composables/useOrderFormatters.js' import request from '../request.js' import { normalizeOrderPluginTab } from '../utils/orderPluginTab.js' +import { parseRepeaterModelValue, REPEATER_XTYPE } from '../utils/repeaterField.js' import OrderAddressTab from './order/OrderAddressTab.vue' import OrderHistoryTab from './order/OrderHistoryTab.vue' import OrderInfoTab from './order/OrderInfoTab.vue' @@ -246,6 +247,21 @@ function groupFieldsBySection(fields, sections) { /** * Load order data */ +/** + * Parse repeater extra field values from API (string JSON → array). + */ +function hydrateRepeaterExtraFields(fields) { + if (!order.value || !Array.isArray(fields)) { + return + } + + for (const field of fields) { + if (field.xtype === REPEATER_XTYPE && field.key) { + order.value[field.key] = parseRepeaterModelValue(order.value[field.key]) + } + } +} + async function loadOrder() { if (!orderId.value) { toast.add({ @@ -275,6 +291,9 @@ async function loadOrder() { loadAddressExtraFields(), loadOrderCustomer(), ]) + + hydrateRepeaterExtraFields(orderExtraFields.value) + hydrateRepeaterExtraFields(addressExtraFields.value) } catch (error) { console.error('[OrderView] Error loading order:', error) toast.add({ @@ -2137,6 +2156,7 @@ onMounted(async () => { { v-model:selected-customer="selectedCustomer" v-model:create-customer-from-data="createCustomerFromData" :address-fields-by-section="addressFieldsBySection" + :address-extra-fields="addressExtraFields" /> -
+ + +

{{ _('ms3_order_tab_address_model_fields_empty_hint') }}

diff --git a/vueManager/src/components/order/OrderExtraFieldsSection.vue b/vueManager/src/components/order/OrderExtraFieldsSection.vue new file mode 100644 index 00000000..0c75e98e --- /dev/null +++ b/vueManager/src/components/order/OrderExtraFieldsSection.vue @@ -0,0 +1,81 @@ + + + + + + + diff --git a/vueManager/src/components/order/OrderInfoTab.vue b/vueManager/src/components/order/OrderInfoTab.vue index 7dd7998d..1daa6ec4 100644 --- a/vueManager/src/components/order/OrderInfoTab.vue +++ b/vueManager/src/components/order/OrderInfoTab.vue @@ -12,10 +12,12 @@ import Textarea from 'primevue/textarea' import { computed, inject, watch } from 'vue' import { ORDER_CONTEXT_KEY } from '../../composables/orderContext.js' +import OrderExtraFieldsSection from './OrderExtraFieldsSection.vue' import OrderFormActionsBar from './OrderFormActionsBar.vue' const props = defineProps({ orderFieldsBySection: { type: Array, required: true }, + orderExtraFields: { type: Array, default: () => [] }, }) const orderCtx = inject(ORDER_CONTEXT_KEY, null) @@ -90,9 +92,11 @@ const hasOrderFieldSections = computed( () => (props.orderFieldsBySection?.length ?? 0) > 0 ) +const hasOrderExtraFields = computed(() => (props.orderExtraFields?.length ?? 0) > 0) + /** Скрыть «Сохранить»/«Отмена», если нет полей заказа (#182); в режиме создания кнопки нужны. */ const showOrderInfoActions = computed( - () => isCreateMode.value || hasOrderFieldSections.value + () => isCreateMode.value || hasOrderFieldSections.value || hasOrderExtraFields.value ) @@ -270,7 +274,9 @@ const showOrderInfoActions = computed( -
+ + +

{{ _('ms3_order_tab_info_model_fields_empty_hint') }}

diff --git a/vueManager/src/utils/repeaterField.js b/vueManager/src/utils/repeaterField.js new file mode 100644 index 00000000..15ac49b0 --- /dev/null +++ b/vueManager/src/utils/repeaterField.js @@ -0,0 +1,105 @@ +export const REPEATER_XTYPE = 'ms3-repeater' + +export function defaultRepeaterConfig() { + return { + columns: [ + { key: 'name', label: 'Name', xtype: 'textfield', required: true }, + { key: 'value', label: 'Value', xtype: 'textfield', required: false }, + ], + minRows: 0, + maxRows: null, + sortable: true, + rankField: 'rank', + } +} + +export function parseRepeaterConfig(raw) { + if (!raw) { + return defaultRepeaterConfig() + } + + let parsed = raw + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw) + } catch { + return defaultRepeaterConfig() + } + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return defaultRepeaterConfig() + } + + const defaults = defaultRepeaterConfig() + + return { + ...defaults, + ...parsed, + columns: Array.isArray(parsed.columns) && parsed.columns.length + ? parsed.columns + : defaults.columns, + } +} + +export function normalizeRepeaterRows(rows, config) { + const schema = parseRepeaterConfig(config) + const columns = schema.columns || [] + + return (Array.isArray(rows) ? rows : []).map((row, index) => { + const normalized = {} + for (const column of columns) { + if (!column.key) { + continue + } + const value = row?.[column.key] + if (column.xtype === 'numberfield') { + normalized[column.key] = value === '' || value === null || value === undefined + ? null + : Number(value) + } else { + normalized[column.key] = value ?? '' + } + } + normalized._ms3RowId = row?._ms3RowId ?? `row-${index}-${Date.now()}` + return normalized + }) +} + +export function stripRepeaterRowMeta(rows, config) { + const schema = parseRepeaterConfig(config) + const rankField = schema.rankField || 'rank' + + return (Array.isArray(rows) ? rows : []).map(row => { + const clean = { ...row } + delete clean._ms3RowId + delete clean[rankField] + return clean + }) +} + +export function parseRepeaterModelValue(value) { + if (Array.isArray(value)) { + return value + } + if (typeof value === 'string' && value.trim() !== '') { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } + } + return [] +} + +export function getRepeaterConfigFromField(fieldConfig) { + if (!fieldConfig) { + return defaultRepeaterConfig() + } + return parseRepeaterConfig( + fieldConfig.config?.repeater_config + ?? fieldConfig.repeater_config + ?? fieldConfig.config + ) +}