From bd6eb978ebaf9fa3ef14c5d3c58d66b500500ee0 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Mon, 16 Mar 2026 14:04:51 +0600 Subject: [PATCH 1/2] feat(grid): add option field type for category products grid - GridConfigService: validateOptionConfig, extractOptionFields - CategoryProductsController: JOIN msProductOption for option columns - GridFieldsConfig.vue: UI for option type configuration - Lexicons: field_type_option (ru/en) --- .../minishop3/lexicon/en/vue.inc.php | 4 + .../minishop3/lexicon/ru/vue.inc.php | 4 + .../Manager/CategoryProductsController.php | 143 ++++++++++++------ .../src/Services/GridConfigService.php | 71 +++++++++ .../src/components/GridFieldsConfig.vue | 47 ++++++ 5 files changed, 226 insertions(+), 43 deletions(-) diff --git a/core/components/minishop3/lexicon/en/vue.inc.php b/core/components/minishop3/lexicon/en/vue.inc.php index 0db41494..bd40b5f4 100644 --- a/core/components/minishop3/lexicon/en/vue.inc.php +++ b/core/components/minishop3/lexicon/en/vue.inc.php @@ -345,6 +345,7 @@ $_lang['field_type_model'] = 'Model Field'; $_lang['field_type_template'] = 'Template Field'; $_lang['field_type_relation'] = 'Relation Field'; +$_lang['field_type_option'] = 'Product Option'; $_lang['field_type_computed'] = 'Computed Field'; $_lang['field_template'] = 'Template'; $_lang['field_template_placeholder'] = 'Example: {first_name} {last_name}'; @@ -363,6 +364,9 @@ $_lang['relation_aggregation_min'] = 'MIN (minimum)'; $_lang['relation_aggregation_max'] = 'MAX (maximum)'; $_lang['relation_hint'] = 'Specify table name or xPDO model class. JOIN query is executed once for all rows'; +$_lang['option_key'] = 'Option Key'; +$_lang['option_key_placeholder'] = 'Example: length, width, material'; +$_lang['option_key_hint'] = 'Key of the product option from ms3_product_options. Use option_{key} as field name (e.g. option_length)'; $_lang['computed_class_name'] = 'Class'; $_lang['computed_class_name_placeholder'] = 'Example: MiniShop3\\Computed\\DiscountPercent'; $_lang['computed_class_hint'] = 'Class must implement ComputedFieldInterface'; diff --git a/core/components/minishop3/lexicon/ru/vue.inc.php b/core/components/minishop3/lexicon/ru/vue.inc.php index 2893e37e..ddb5a8f6 100644 --- a/core/components/minishop3/lexicon/ru/vue.inc.php +++ b/core/components/minishop3/lexicon/ru/vue.inc.php @@ -345,6 +345,7 @@ $_lang['field_type_model'] = 'Модельное поле'; $_lang['field_type_template'] = 'Шаблонное поле'; $_lang['field_type_relation'] = 'Связанное поле'; +$_lang['field_type_option'] = 'Опция товара'; $_lang['field_type_computed'] = 'Вычисляемое поле'; $_lang['field_template'] = 'Шаблон'; $_lang['field_template_placeholder'] = 'Например: {first_name} {last_name}'; @@ -363,6 +364,9 @@ $_lang['relation_aggregation_min'] = 'MIN (минимум)'; $_lang['relation_aggregation_max'] = 'MAX (максимум)'; $_lang['relation_hint'] = 'Укажите имя таблицы или класс модели xPDO. JOIN запрос выполняется один раз для всех строк'; +$_lang['option_key'] = 'Ключ опции'; +$_lang['option_key_placeholder'] = 'Например: length, width, material'; +$_lang['option_key_hint'] = 'Ключ опции товара из ms3_product_options. Имя поля: option_{key} (например: option_length)'; $_lang['computed_class_name'] = 'Класс'; $_lang['computed_class_name_placeholder'] = 'Например: MiniShop3\\Computed\\DiscountPercent'; $_lang['computed_class_hint'] = 'Класс должен реализовывать ComputedFieldInterface'; diff --git a/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php b/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php index db7bb035..463b4ac5 100644 --- a/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php +++ b/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php @@ -4,6 +4,7 @@ use MiniShop3\Model\msProduct; use MiniShop3\Model\msProductData; +use MiniShop3\Model\msProductOption; use MiniShop3\Model\msCategory; use MiniShop3\Router\Response; use MiniShop3\Services\FilterConfigManager; @@ -57,11 +58,23 @@ public function getList(array $params = []): array $sortDir = 'ASC'; } - // Build query + $gridConfig = $this->modx->services->get('ms3_grid_config'); + $gridFields = $gridConfig ? $gridConfig->getGridConfig('category-products', true) : []; + $optionFields = $gridConfig ? $gridConfig->extractOptionFields($gridFields) : []; + $c = $this->modx->newQuery(msProduct::class); $c->innerJoin(msProductData::class, 'Data', 'msProduct.id = Data.id'); - // class_key filter (getIterator doesn't call addDerivativeCriteria) + foreach ($optionFields as $opt) { + $alias = $opt['alias']; + $key = $opt['key']; + $c->leftJoin( + msProductOption::class, + $alias, + "`{$alias}`.product_id = msProduct.id AND `{$alias}`.key = '{$key}'" + ); + } + $c->where(['msProduct.class_key' => msProduct::class]); // Parent filter @@ -122,20 +135,30 @@ public function getList(array $params = []): array } } + foreach ($optionFields as $opt) { + $paramKey = 'filter_' . $opt['fieldName']; + if (isset($params[$paramKey]) && $params[$paramKey] !== '') { + $filterValue = $this->modx->escape($params[$paramKey]); + $c->where(["`{$opt['alias']}`.value:LIKE" => "%{$filterValue}%"]); + } + } + // Default: hide deleted if not explicitly filtered if (!isset($params['deleted']) || $params['deleted'] === '') { $c->where(['msProduct.deleted' => 0]); } - // Get total count - $total = $this->modx->getCount(msProduct::class, $c); + $countQuery = clone $c; + $countQuery->select('COUNT(DISTINCT msProduct.id)'); + $countQuery->prepare(); + $countQuery->stmt->execute(); + $total = (int)$countQuery->stmt->fetchColumn(); - // Apply sorting and pagination - $c->sortby($sortBy, $sortDir); + $sortField = $this->mapSortField($sortBy, $optionFields); + $c->sortby($sortField, $sortDir); $c->limit($limit, $start); - // Select fields - $c->select([ + $selectParts = [ 'msProduct.*', 'Data.article', 'Data.price', @@ -148,13 +171,18 @@ public function getList(array $params = []): array 'Data.new', 'Data.popular', 'Data.favorite', - ]); + ]; + foreach ($optionFields as $opt) { + $selectParts[] = "`{$opt['alias']}`.value AS `{$opt['fieldName']}`"; + } + $c->select($selectParts); - $products = $this->modx->getIterator(msProduct::class, $c); + $c->prepare(); + $rows = $c->stmt->execute() ? $c->stmt->fetchAll(\PDO::FETCH_ASSOC) : []; $results = []; - foreach ($products as $product) { - $results[] = $this->formatProduct($product, $nested); + foreach ($rows as $row) { + $results[] = $this->formatProduct($row, $nested); } return Response::success([ @@ -394,45 +422,74 @@ public function publish(array $params = []): array } /** - * Format product for API response + * Map sort field to SQL expression (supports option fields) + * + * @param string $sortBy + * @param array $optionFields + * @return string + */ + protected function mapSortField(string $sortBy, array $optionFields): string + { + foreach ($optionFields as $opt) { + if ($opt['fieldName'] === $sortBy) { + return "`{$opt['alias']}`.value"; + } + } + $productFields = ['id', 'pagetitle', 'menuindex', 'published', 'createdon', 'editedon']; + if (in_array($sortBy, $productFields)) { + return "msProduct.{$sortBy}"; + } + $dataFields = ['article', 'price', 'old_price', 'weight', 'vendor_id', 'made_in']; + if (in_array($sortBy, $dataFields)) { + return "Data.{$sortBy}"; + } + return "msProduct.{$sortBy}"; + } + + /** + * Format product row for API response * - * @param msProduct $product + * @param array $row Raw row from query (includes joined option values) * @param bool $nested * @return array */ - protected function formatProduct(msProduct $product, bool $nested = false): array + protected function formatProduct(array $row, bool $nested = false): array { + $id = (int)$row['id']; $data = [ - 'id' => $product->get('id'), - 'pagetitle' => $product->get('pagetitle'), - 'longtitle' => $product->get('longtitle'), - 'alias' => $product->get('alias'), - 'parent' => $product->get('parent'), - 'menuindex' => $product->get('menuindex'), - 'published' => (bool)$product->get('published'), - 'deleted' => (bool)$product->get('deleted'), - 'hidemenu' => (bool)$product->get('hidemenu'), - 'createdon' => $product->get('createdon'), - 'editedon' => $product->get('editedon'), - // Product data - 'article' => $product->get('article'), - 'price' => (float)$product->get('price'), - 'old_price' => (float)$product->get('old_price'), - 'weight' => (float)$product->get('weight'), - 'image' => $product->get('image'), - 'thumb' => $product->get('thumb'), - 'vendor_id' => (int)$product->get('vendor_id'), - 'made_in' => $product->get('made_in'), - 'new' => (bool)$product->get('new'), - 'popular' => (bool)$product->get('popular'), - 'favorite' => (bool)$product->get('favorite'), - // Preview URL - 'preview_url' => $this->modx->makeUrl($product->get('id'), '', '', 'full'), + 'id' => $id, + 'pagetitle' => $row['pagetitle'] ?? '', + 'longtitle' => $row['longtitle'] ?? '', + 'alias' => $row['alias'] ?? '', + 'parent' => (int)($row['parent'] ?? 0), + 'menuindex' => (int)($row['menuindex'] ?? 0), + 'published' => (bool)($row['published'] ?? false), + 'deleted' => (bool)($row['deleted'] ?? false), + 'hidemenu' => (bool)($row['hidemenu'] ?? false), + 'createdon' => $row['createdon'] ?? null, + 'editedon' => $row['editedon'] ?? null, + 'article' => $row['article'] ?? '', + 'price' => (float)($row['price'] ?? 0), + 'old_price' => (float)($row['old_price'] ?? 0), + 'weight' => (float)($row['weight'] ?? 0), + 'image' => $row['image'] ?? '', + 'thumb' => $row['thumb'] ?? '', + 'vendor_id' => (int)($row['vendor_id'] ?? 0), + 'made_in' => $row['made_in'] ?? '', + 'new' => (bool)($row['new'] ?? false), + 'popular' => (bool)($row['popular'] ?? false), + 'favorite' => (bool)($row['favorite'] ?? false), + 'preview_url' => $this->modx->makeUrl($id, '', '', 'full'), ]; - // Add category name for nested products - if ($nested && $product->get('parent') != 0) { - $parent = $this->modx->getObject(msCategory::class, $product->get('parent')); + foreach ($row as $key => $value) { + if (!array_key_exists($key, $data)) { + $data[$key] = $value; + } + } + + if ($nested && ($row['parent'] ?? 0) != 0) { + $parent = $this->modx->getObject(msCategory::class, (int)$row['parent']); if ($parent) { $data['category_name'] = $parent->get('pagetitle'); } diff --git a/core/components/minishop3/src/Services/GridConfigService.php b/core/components/minishop3/src/Services/GridConfigService.php index 33b95ab3..9dca13e1 100644 --- a/core/components/minishop3/src/Services/GridConfigService.php +++ b/core/components/minishop3/src/Services/GridConfigService.php @@ -177,6 +177,8 @@ public function saveGridConfig(string $gridKey, array $fields): bool 'relation', // computed type 'computed', + // option type + 'option', // badge type 'source_field', 'color_field', // datetime type @@ -347,6 +349,13 @@ public function addField(string $gridKey, array $data): array return $validation; } break; + + case 'option': + $validation = $this->validateOptionConfig($config); + if (!$validation['success']) { + return $validation; + } + break; } // Add type to config @@ -464,6 +473,12 @@ public function updateField(string $gridKey, string $fieldName, array $data): ar return $validation; } break; + case 'option': + $validation = $this->validateOptionConfig($config); + if (!$validation['success']) { + return $validation; + } + break; } // Add type to config @@ -720,6 +735,62 @@ protected function validateActionsConfig(array $config): array return ['success' => true]; } + /** + * Validate Option field configuration + * + * @param array $config + * @return array + */ + protected function validateOptionConfig(array $config): array + { + $option = $config['option'] ?? []; + + if (empty($option['key'])) { + return ['success' => false, 'message' => 'option.key is required for option field']; + } + + $key = $option['key']; + if (!preg_match('/^[a-z0-9_]+$/i', $key)) { + return ['success' => false, 'message' => 'option.key must contain only letters, numbers and underscores']; + } + + return ['success' => true]; + } + + /** + * Extract option fields from grid config for JOIN building + * + * @param array $gridFields Array of grid field configs + * @return array List of option field definitions: [['fieldName' => 'option_length', 'key' => 'length', 'alias' => 'opt_length'], ...] + */ + public function extractOptionFields(array $gridFields): array + { + $optionFields = []; + + foreach ($gridFields as $field) { + if (($field['type'] ?? 'model') !== 'option') { + continue; + } + + $option = $field['option'] ?? null; + if (!$option || empty($option['key'])) { + continue; + } + + $key = $option['key']; + $fieldName = $field['name']; + $alias = 'opt_' . $key; + + $optionFields[] = [ + 'fieldName' => $fieldName, + 'key' => $key, + 'alias' => $alias, + ]; + } + + return $optionFields; + } + /** * Extract relation fields from grid config and group by table+foreignKey * for efficient JOIN building diff --git a/vueManager/src/components/GridFieldsConfig.vue b/vueManager/src/components/GridFieldsConfig.vue index 2e1b24cb..2f6effa4 100644 --- a/vueManager/src/components/GridFieldsConfig.vue +++ b/vueManager/src/components/GridFieldsConfig.vue @@ -44,6 +44,9 @@ const newField = ref({ displayField: '', aggregation: null, }, + option: { + key: '', + }, computed: { className: '', }, @@ -79,6 +82,7 @@ const fieldTypeOptions = computed(() => [ { label: _('field_type_model'), value: 'model' }, { label: _('field_type_template'), value: 'template' }, { label: _('field_type_relation'), value: 'relation' }, + { label: _('field_type_option'), value: 'option' }, { label: _('field_type_computed'), value: 'computed' }, { label: _('field_type_image'), value: 'image' }, { label: _('field_type_boolean'), value: 'boolean' }, @@ -162,6 +166,7 @@ async function loadFields() { type: col.type || 'model', template: col.template || '', relation: col.relation || null, + option: col.option || null, computed: col.computed || null, actions: col.actions || null, // Display config @@ -217,6 +222,7 @@ async function saveConfig() { if (field.type) data.type = field.type if (field.template) data.template = field.template if (field.relation) data.relation = field.relation + if (field.option) data.option = field.option if (field.computed) data.computed = field.computed if (field.actions) data.actions = field.actions @@ -412,6 +418,13 @@ async function addField() { }, } break + case 'option': + data.config = { + option: { + key: newField.value.config.option?.key || '', + }, + } + break case 'computed': data.config = { computed: { @@ -486,6 +499,7 @@ async function addField() { type: config.type || 'model', template: config.template || '', relation: config.relation || null, + option: config.option || null, computed: config.computed || null, actions: config.actions || null, // Display config @@ -573,6 +587,8 @@ function openEditDialog(field, index) { className: '', } + const optionConfig = field.option || { key: '' } + editingField.value = { field_name: field.name, label: field.label || '', @@ -585,6 +601,7 @@ function openEditDialog(field, index) { config: { template: field.template || '', relation: relationConfig, + option: optionConfig, computed: computedConfig, actions: field.actions || [ { name: 'edit', handler: 'edit', icon: 'pi-pencil', label: 'edit' }, @@ -641,6 +658,13 @@ async function saveEdit() { }, } break + case 'option': + data.config = { + option: { + key: editingField.value.config.option?.key || '', + }, + } + break case 'computed': data.config = { computed: { @@ -718,6 +742,7 @@ async function saveEdit() { type: config.type || 'model', template: config.template || '', relation: config.relation || null, + option: config.option || null, computed: config.computed || null, actions: config.actions || null, // Display config @@ -973,6 +998,17 @@ onMounted(() => { {{ _('relation_hint') }} +
+ + + {{ _('option_key_hint') }} +
+
+
+ + + {{ _('option_key_hint') }} +
+