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..0404526e 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,85 +58,23 @@ public function getList(array $params = []): array $sortDir = 'ASC'; } - // Build query - $c = $this->modx->newQuery(msProduct::class); - $c->innerJoin(msProductData::class, 'Data', 'msProduct.id = Data.id'); - - // class_key filter (getIterator doesn't call addDerivativeCriteria) - $c->where(['msProduct.class_key' => msProduct::class]); - - // Parent filter - if ($nested) { - // Get all child category IDs - $categoryIds = $this->getChildCategories($categoryId); - $categoryIds[] = $categoryId; - $c->where(['msProduct.parent:IN' => $categoryIds]); - } else { - $c->where(['msProduct.parent' => $categoryId]); - } - - // Search filter - if (!empty($query)) { - $c->where([ - 'msProduct.pagetitle:LIKE' => "%{$query}%", - 'OR:Data.article:LIKE' => "%{$query}%", - ]); - } - - // Boolean filters for msProduct fields - $productBooleanFields = ['published', 'deleted', 'hidemenu', 'isfolder']; - foreach ($productBooleanFields as $field) { - if (isset($params[$field]) && $params[$field] !== '') { - $c->where(["msProduct.{$field}" => (int)$params[$field]]); - } - } - - // Boolean filters for msProductData fields - $dataBooleanFields = ['new', 'popular', 'favorite']; - foreach ($dataBooleanFields as $field) { - if (isset($params[$field]) && $params[$field] !== '') { - $c->where(["Data.{$field}" => (int)$params[$field]]); - } - } - - // Text filters for msProduct fields (LIKE search) - $productTextFields = ['pagetitle', 'longtitle', 'alias', 'description', 'introtext', 'content']; - foreach ($productTextFields as $field) { - if (!empty($params[$field])) { - $c->where(["msProduct.{$field}:LIKE" => "%{$params[$field]}%"]); - } - } + $gridConfig = $this->modx->services->get('ms3_grid_config'); + $gridFields = $gridConfig ? $gridConfig->getGridConfig('category-products', true) : []; + $optionFields = $gridConfig ? $gridConfig->extractOptionFields($gridFields) : []; - // Text filters for msProductData fields (LIKE search) - $dataTextFields = ['article', 'made_in']; - foreach ($dataTextFields as $field) { - if (!empty($params[$field])) { - $c->where(["Data.{$field}:LIKE" => "%{$params[$field]}%"]); - } - } + $c = $this->buildProductListQuery($categoryId, $params, $nested, $optionFields); - // Numeric filters for msProductData fields (exact match) - $dataNumericFields = ['price', 'old_price', 'weight', 'vendor_id']; - foreach ($dataNumericFields as $field) { - if (isset($params[$field]) && $params[$field] !== '') { - $c->where(["Data.{$field}" => $params[$field]]); - } - } - - // Default: hide deleted if not explicitly filtered - if (!isset($params['deleted']) || $params['deleted'] === '') { - $c->where(['msProduct.deleted' => 0]); - } + $countQuery = $this->buildProductListQuery($categoryId, $params, $nested, $optionFields); + $countQuery->select('COUNT(DISTINCT msProduct.id)'); + $countQuery->prepare(); + $countQuery->stmt->execute(); + $total = (int)$countQuery->stmt->fetchColumn(); - // Get total count - $total = $this->modx->getCount(msProduct::class, $c); - - // 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 +87,22 @@ public function getList(array $params = []): array 'Data.new', 'Data.popular', 'Data.favorite', - ]); + ]; + foreach ($optionFields as $opt) { + $selectParts[] = "GROUP_CONCAT(DISTINCT `{$opt['alias']}`.value) AS `{$opt['fieldName']}`"; + } + $c->select($selectParts); + if (!empty($optionFields)) { + $c->groupby('msProduct.id'); + } - $products = $this->modx->getIterator(msProduct::class, $c); + $c->prepare(); + $rows = $c->stmt->execute() ? $c->stmt->fetchAll(\PDO::FETCH_ASSOC) : []; + $optionFieldNames = array_column($optionFields, 'fieldName'); $results = []; - foreach ($products as $product) { - $results[] = $this->formatProduct($product, $nested); + foreach ($rows as $row) { + $results[] = $this->formatProduct($row, $nested, $optionFieldNames); } return Response::success([ @@ -394,45 +342,78 @@ public function publish(array $params = []): array } /** - * Format product for API response + * Map sort field to SQL expression (supports option fields) + * + * For option fields uses GROUP_CONCAT to comply with MySQL ONLY_FULL_GROUP_BY. + * + * @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 "GROUP_CONCAT(DISTINCT `{$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 + * @param array $optionFieldNames Allowed option field names (prevents leaking internal xPDO/MySQL columns) * @return array */ - protected function formatProduct(msProduct $product, bool $nested = false): array + protected function formatProduct(array $row, bool $nested = false, array $optionFieldNames = []): 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')); + $allowedOptionFields = array_flip($optionFieldNames); + foreach ($row as $key => $value) { + if (!array_key_exists($key, $data) && isset($allowedOptionFields[$key])) { + $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'); } @@ -441,6 +422,97 @@ protected function formatProduct(msProduct $product, bool $nested = false): arra return $data; } + /** + * Build base product list query with JOINs and filters (no select/sort/limit) + * + * @param int $categoryId + * @param array $params + * @param bool $nested + * @param array $optionFields + * @return \xPDO\Om\xPDOQuery + */ + protected function buildProductListQuery(int $categoryId, array $params, bool $nested, array $optionFields): \xPDO\Om\xPDOQuery + { + $query = trim($params['query'] ?? ''); + $c = $this->modx->newQuery(msProduct::class); + $c->innerJoin(msProductData::class, 'Data', 'msProduct.id = Data.id'); + + 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]); + + if ($nested) { + $categoryIds = $this->getChildCategories($categoryId); + $categoryIds[] = $categoryId; + $c->where(['msProduct.parent:IN' => $categoryIds]); + } else { + $c->where(['msProduct.parent' => $categoryId]); + } + + if (!empty($query)) { + $c->where([ + 'msProduct.pagetitle:LIKE' => "%{$query}%", + 'OR:Data.article:LIKE' => "%{$query}%", + ]); + } + + $productBooleanFields = ['published', 'deleted', 'hidemenu', 'isfolder']; + foreach ($productBooleanFields as $field) { + if (isset($params[$field]) && $params[$field] !== '') { + $c->where(["msProduct.{$field}" => (int)$params[$field]]); + } + } + + $dataBooleanFields = ['new', 'popular', 'favorite']; + foreach ($dataBooleanFields as $field) { + if (isset($params[$field]) && $params[$field] !== '') { + $c->where(["Data.{$field}" => (int)$params[$field]]); + } + } + + $productTextFields = ['pagetitle', 'longtitle', 'alias', 'description', 'introtext', 'content']; + foreach ($productTextFields as $field) { + if (!empty($params[$field])) { + $c->where(["msProduct.{$field}:LIKE" => "%{$params[$field]}%"]); + } + } + + $dataTextFields = ['article', 'made_in']; + foreach ($dataTextFields as $field) { + if (!empty($params[$field])) { + $c->where(["Data.{$field}:LIKE" => "%{$params[$field]}%"]); + } + } + + $dataNumericFields = ['price', 'old_price', 'weight', 'vendor_id']; + foreach ($dataNumericFields as $field) { + if (isset($params[$field]) && $params[$field] !== '') { + $c->where(["Data.{$field}" => $params[$field]]); + } + } + + foreach ($optionFields as $opt) { + $paramKey = 'filter_' . $opt['fieldName']; + if (isset($params[$paramKey]) && $params[$paramKey] !== '') { + $c->where(["`{$opt['alias']}`.value:LIKE" => "%{$params[$paramKey]}%"]); + } + } + + if (!isset($params['deleted']) || $params['deleted'] === '') { + $c->where(['msProduct.deleted' => 0]); + } + + return $c; + } + /** * Get all child category IDs recursively * diff --git a/core/components/minishop3/src/Services/GridConfigService.php b/core/components/minishop3/src/Services/GridConfigService.php index 33b95ab3..b6fdf62a 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,68 @@ 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 + * + * Re-validates option.key on read (defense in depth) — config can be modified directly in DB. + * + * @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']; + if (!preg_match('/^[a-z0-9_]+$/i', $key)) { + continue; + } + + $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') }} +