diff --git a/CHANGELOG.md b/CHANGELOG.md index 9105724f..99398c97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ #### ♻️ Рефакторинг +- **Грид товаров категории — option-колонки (#140, #154):** вынесены построение SQL и форматирование строк в `CategoryProductsListService` (`ms3_category_products_list`); DTO `OptionColumnSpec` и `GridOptionColumnResolver` для единой валидации ключа опции и спецификации JOIN; один метод агрегации `GROUP_CONCAT(DISTINCT …)` для SELECT и ORDER BY; `GridConfigService::extractOptionFields` делегирует resolver’у - **Экран заказа — provide/inject вместо props-цепочки (#196):** `provide(ORDER_CONTEXT_KEY)` в `OrderView`, composables `useOrderFormatters`, `useOrderFieldHelpers`, `useOrderLogFormatters`; вкладки получают только данные вкладки через props; безопасный `inject` до деструктуризации - **OrderView разбит на подкомпоненты (#176):** монолитный `OrderView.vue` разделён на `OrderInfoTab`, `OrderProductsTab`, `OrderAddressTab`, `OrderHistoryTab` + вынесен `orderFieldsLayout.css` - **Опции товара:** Map по `modcategory_id` для вкладок, именованный page size комбобокса под `ms3.grid`, документирован GROUP BY diff --git a/core/components/minishop3/lexicon/en/vue.inc.php b/core/components/minishop3/lexicon/en/vue.inc.php index 59131aa2..de9a5703 100644 --- a/core/components/minishop3/lexicon/en/vue.inc.php +++ b/core/components/minishop3/lexicon/en/vue.inc.php @@ -352,6 +352,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}'; @@ -370,6 +371,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 a9df0b06..b2ee2372 100644 --- a/core/components/minishop3/lexicon/ru/vue.inc.php +++ b/core/components/minishop3/lexicon/ru/vue.inc.php @@ -352,6 +352,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}'; @@ -370,6 +371,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..f208b008 100644 --- a/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php +++ b/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php @@ -2,10 +2,10 @@ namespace MiniShop3\Controllers\Api\Manager; -use MiniShop3\Model\msProduct; -use MiniShop3\Model\msProductData; use MiniShop3\Model\msCategory; +use MiniShop3\Model\msProduct; use MiniShop3\Router\Response; +use MiniShop3\Services\Category\CategoryProductsListService; use MiniShop3\Services\FilterConfigManager; use MODX\Revolution\modX; @@ -34,7 +34,7 @@ public function __construct(modX $modx) */ public function getList(array $params = []): array { - $categoryId = (int)($params['id'] ?? 0); + $categoryId = (int) ($params['id'] ?? 0); if (!$categoryId) { return Response::error('Category ID is required', 400)->getData(); @@ -45,121 +45,39 @@ public function getList(array $params = []): array return Response::error('Category not found', 404)->getData(); } - $start = (int)($params['start'] ?? 0); - $limit = (int)($params['limit'] ?? 20); + $start = (int) ($params['start'] ?? 0); + $limit = (int) ($params['limit'] ?? 20); $sortBy = $params['sort'] ?? 'menuindex'; - $sortDir = strtoupper($params['dir'] ?? 'ASC'); - $query = trim($params['query'] ?? ''); - $nested = (bool)($params['nested'] ?? false); + $sortDir = strtoupper((string) ($params['dir'] ?? 'ASC')); + $nested = (bool) ($params['nested'] ?? false); - // Validate sort direction - if (!in_array($sortDir, ['ASC', 'DESC'])) { + if (!in_array($sortDir, ['ASC', 'DESC'], true)) { $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]}%"]); - } - } - - // 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]}%"]); - } - } + $gridConfig = $this->modx->services->get('ms3_grid_config'); + $gridFields = $gridConfig ? $gridConfig->getGridConfig('category-products', true) : []; - // 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]]); - } + /** @var CategoryProductsListService|null $listService */ + $listService = $this->modx->services->get('ms3_category_products_list'); + if (!$listService) { + return Response::error('Category products list service is not available', 500)->getData(); } - // 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); - - // Apply sorting and pagination - $c->sortby($sortBy, $sortDir); - $c->limit($limit, $start); - - // Select fields - $c->select([ - 'msProduct.*', - 'Data.article', - 'Data.price', - 'Data.old_price', - 'Data.weight', - 'Data.image', - 'Data.thumb', - 'Data.vendor_id', - 'Data.made_in', - 'Data.new', - 'Data.popular', - 'Data.favorite', - ]); - - $products = $this->modx->getIterator(msProduct::class, $c); - - $results = []; - foreach ($products as $product) { - $results[] = $this->formatProduct($product, $nested); - } + $page = $listService->getPage( + $categoryId, + $params, + $nested, + $gridFields, + $start, + $limit, + (string) $sortBy, + $sortDir + ); return Response::success([ - 'results' => $results, - 'total' => $total + 'results' => $page['results'], + 'total' => $page['total'], ])->getData(); } @@ -193,7 +111,7 @@ public function getFilters(array $params = []): array */ public function sort(array $params = []): array { - $categoryId = (int)($params['id'] ?? 0); + $categoryId = (int) ($params['id'] ?? 0); $items = $params['items'] ?? []; if (!$categoryId) { @@ -207,8 +125,8 @@ public function sort(array $params = []): array $updated = 0; foreach ($items as $item) { - $productId = (int)($item['id'] ?? 0); - $menuindex = (int)($item['menuindex'] ?? 0); + $productId = (int) ($item['id'] ?? 0); + $menuindex = (int) ($item['menuindex'] ?? 0); if (!$productId) { continue; @@ -216,7 +134,7 @@ public function sort(array $params = []): array $product = $this->modx->getObject(msProduct::class, [ 'id' => $productId, - 'parent' => $categoryId + 'parent' => $categoryId, ]); if ($product) { @@ -228,7 +146,7 @@ public function sort(array $params = []): array } return Response::success([ - 'updated' => $updated + 'updated' => $updated, ], 'Products reordered successfully')->getData(); } @@ -330,7 +248,7 @@ public function multiple(array $params = []): array return Response::success([ 'success' => $success, - 'failed' => $failed + 'failed' => $failed, ], "{$success} products updated")->getData(); } @@ -344,6 +262,7 @@ public function multiple(array $params = []): array public function bulkDelete(array $params = []): array { $params['method'] = 'delete'; + return $this->multiple($params); } @@ -356,8 +275,8 @@ public function bulkDelete(array $params = []): array */ public function publish(array $params = []): array { - $productId = (int)($params['productId'] ?? 0); - $published = isset($params['published']) ? (int)$params['published'] : null; + $productId = (int) ($params['productId'] ?? 0); + $published = isset($params['published']) ? (int) $params['published'] : null; if (!$productId) { return Response::error('Product ID is required', 400)->getData(); @@ -389,83 +308,10 @@ public function publish(array $params = []): array return Response::success([ 'id' => $productId, - 'published' => $published + 'published' => $published, ], $published ? 'Product published' : 'Product unpublished')->getData(); } - /** - * Format product for API response - * - * @param msProduct $product - * @param bool $nested - * @return array - */ - protected function formatProduct(msProduct $product, bool $nested = false): array - { - $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'), - ]; - - // Add category name for nested products - if ($nested && $product->get('parent') != 0) { - $parent = $this->modx->getObject(msCategory::class, $product->get('parent')); - if ($parent) { - $data['category_name'] = $parent->get('pagetitle'); - } - } - - return $data; - } - - /** - * Get all child category IDs recursively - * - * @param int $parentId - * @return array - */ - protected function getChildCategories(int $parentId): array - { - $ids = []; - - $children = $this->modx->getIterator(msCategory::class, [ - 'parent' => $parentId, - 'deleted' => 0, - 'class_key' => msCategory::class, - ]); - - foreach ($children as $child) { - $childId = $child->get('id'); - $ids[] = $childId; - $ids = array_merge($ids, $this->getChildCategories($childId)); - } - - return $ids; - } - /** * Get default filters configuration * diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index f940756a..e5191be3 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -186,6 +186,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\GridConfigService::class, 'interface' => null, ], + 'ms3_category_products_list' => [ + 'class' => \MiniShop3\Services\Category\CategoryProductsListService::class, + 'interface' => null, + ], 'ms3_filter_config' => [ 'class' => \MiniShop3\Services\FilterConfigManager::class, 'interface' => null, diff --git a/core/components/minishop3/src/Services/Category/CategoryProductsListService.php b/core/components/minishop3/src/Services/Category/CategoryProductsListService.php new file mode 100644 index 00000000..06c370ee --- /dev/null +++ b/core/components/minishop3/src/Services/Category/CategoryProductsListService.php @@ -0,0 +1,291 @@ +> $gridFields Full grid config rows (visible or all) + * @param array $params Request query params (filters, query, …) + * + * @return array{results: list>, total: int} + */ + public function getPage( + int $categoryId, + array $params, + bool $nested, + array $gridFields, + int $start, + int $limit, + string $sortBy, + string $sortDir, + ): array { + $optionSpecs = GridOptionColumnResolver::resolve($gridFields); + + $c = $this->buildProductListQuery($categoryId, $params, $nested, $optionSpecs); + + $countQuery = $this->buildProductListQuery($categoryId, $params, $nested, $optionSpecs); + $countQuery->select('COUNT(DISTINCT msProduct.id)'); + $countQuery->prepare(); + $countQuery->stmt->execute(); + $total = (int) $countQuery->stmt->fetchColumn(); + + $sortField = $this->mapSortField($sortBy, $optionSpecs); + $c->sortby($sortField, $sortDir); + $c->limit($limit, $start); + + $selectParts = [ + 'msProduct.*', + 'Data.article', + 'Data.price', + 'Data.old_price', + 'Data.weight', + 'Data.image', + 'Data.thumb', + 'Data.vendor_id', + 'Data.made_in', + 'Data.new', + 'Data.popular', + 'Data.favorite', + ]; + foreach ($optionSpecs as $spec) { + $selectParts[] = $this->aggregateOptionValueSql($spec->alias) . " AS `{$spec->fieldName}`"; + } + $c->select($selectParts); + if ($optionSpecs !== []) { + $c->groupby('msProduct.id'); + } + + $c->prepare(); + $rows = $c->stmt->execute() ? $c->stmt->fetchAll(\PDO::FETCH_ASSOC) : []; + + $optionFieldNames = array_map(static fn (OptionColumnSpec $s) => $s->fieldName, $optionSpecs); + $results = []; + foreach ($rows as $row) { + $results[] = $this->formatProductRow($row, $nested, $optionFieldNames); + } + + return [ + 'results' => $results, + 'total' => $total, + ]; + } + + /** + * Single aggregate expression for an option alias (SELECT list + ORDER BY under ONLY_FULL_GROUP_BY). + */ + private function aggregateOptionValueSql(string $alias): string + { + return "GROUP_CONCAT(DISTINCT `{$alias}`.value)"; + } + + /** + * @param list $optionSpecs + */ + private function mapSortField(string $sortBy, array $optionSpecs): string + { + foreach ($optionSpecs as $spec) { + if ($spec->fieldName === $sortBy) { + return $this->aggregateOptionValueSql($spec->alias); + } + } + $productFields = ['id', 'pagetitle', 'menuindex', 'published', 'createdon', 'editedon']; + if (in_array($sortBy, $productFields, true)) { + return "msProduct.{$sortBy}"; + } + $dataFields = ['article', 'price', 'old_price', 'weight', 'vendor_id', 'made_in']; + if (in_array($sortBy, $dataFields, true)) { + return "Data.{$sortBy}"; + } + + return "msProduct.{$sortBy}"; + } + + /** + * @param list $optionSpecs + */ + private function buildProductListQuery(int $categoryId, array $params, bool $nested, array $optionSpecs): xPDOQuery + { + $query = trim((string) ($params['query'] ?? '')); + $c = $this->modx->newQuery(msProduct::class); + $c->innerJoin(msProductData::class, 'Data', 'msProduct.id = Data.id'); + + foreach ($optionSpecs as $spec) { + $alias = $spec->alias; + $key = $this->quoteOptionKeyForJoinCondition($spec->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 ($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 ($optionSpecs as $spec) { + $paramKey = 'filter_' . $spec->fieldName; + if (isset($params[$paramKey]) && $params[$paramKey] !== '') { + $c->where(["`{$spec->alias}`.value:LIKE" => "%{$params[$paramKey]}%"]); + } + } + + if (!isset($params['deleted']) || $params['deleted'] === '') { + $c->where(['msProduct.deleted' => 0]); + } + + return $c; + } + + /** + * Option keys are validated to [a-z0-9_]; still escape single quotes for SQL string literals. + */ + private function quoteOptionKeyForJoinCondition(string $key): string + { + return str_replace("'", "''", $key); + } + + /** + * @return list + */ + private function getChildCategories(int $parentId): array + { + $ids = []; + + $children = $this->modx->getIterator(msCategory::class, [ + 'parent' => $parentId, + 'deleted' => 0, + 'class_key' => msCategory::class, + ]); + + foreach ($children as $child) { + $childId = (int) $child->get('id'); + $ids[] = $childId; + $ids = array_merge($ids, $this->getChildCategories($childId)); + } + + return $ids; + } + + /** + * @param list $optionFieldNames Allowed option field names (whitelist) + * + * @return array + */ + private function formatProductRow(array $row, bool $nested, array $optionFieldNames): array + { + $id = (int) $row['id']; + $data = [ + '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'), + ]; + + $allowedOptionFields = array_flip($optionFieldNames); + foreach ($row as $key => $value) { + if (!array_key_exists($key, $data) && isset($allowedOptionFields[$key])) { + $data[$key] = $value; + } + } + + if ($nested && (int) ($row['parent'] ?? 0) !== 0) { + $parent = $this->modx->getObject(msCategory::class, (int) $row['parent']); + if ($parent) { + $data['category_name'] = $parent->get('pagetitle'); + } + } + + return $data; + } +} diff --git a/core/components/minishop3/src/Services/Grid/GridOptionColumnResolver.php b/core/components/minishop3/src/Services/Grid/GridOptionColumnResolver.php new file mode 100644 index 00000000..7a7aca18 --- /dev/null +++ b/core/components/minishop3/src/Services/Grid/GridOptionColumnResolver.php @@ -0,0 +1,26 @@ +> $gridFields + * + * @return list + */ + public static function resolve(array $gridFields): array + { + $out = []; + foreach ($gridFields as $field) { + $spec = OptionColumnSpec::tryFromGridField($field); + if ($spec !== null) { + $out[] = $spec; + } + } + + return $out; + } +} diff --git a/core/components/minishop3/src/Services/Grid/OptionColumnSpec.php b/core/components/minishop3/src/Services/Grid/OptionColumnSpec.php new file mode 100644 index 00000000..28783ed4 --- /dev/null +++ b/core/components/minishop3/src/Services/Grid/OptionColumnSpec.php @@ -0,0 +1,65 @@ + $field Grid field row (merged config from GridConfigService) + */ + public static function tryFromGridField(array $field): ?self + { + if (($field['type'] ?? 'model') !== 'option') { + return null; + } + + $option = $field['option'] ?? null; + if (!is_array($option) || empty($option['key'])) { + return null; + } + + $key = (string) $option['key']; + if (!self::isValidOptionKey($key)) { + return null; + } + + $name = $field['name'] ?? null; + if ($name === null || $name === '') { + return null; + } + + return new self((string) $name, $key, 'opt_' . $key); + } + + public static function isValidOptionKey(string $key): bool + { + return (bool) preg_match(self::OPTION_KEY_PATTERN, $key); + } + + /** + * @return array{fieldName: string, key: string, alias: string} + */ + public function toJoinDescriptor(): array + { + return [ + 'fieldName' => $this->fieldName, + 'key' => $this->key, + 'alias' => $this->alias, + ]; + } +} diff --git a/core/components/minishop3/src/Services/GridConfigService.php b/core/components/minishop3/src/Services/GridConfigService.php index 052a635c..8fbc081b 100644 --- a/core/components/minishop3/src/Services/GridConfigService.php +++ b/core/components/minishop3/src/Services/GridConfigService.php @@ -2,8 +2,10 @@ namespace MiniShop3\Services; -use MODX\Revolution\modX; use MiniShop3\Model\msGridField; +use MiniShop3\Services\Grid\GridOptionColumnResolver; +use MiniShop3\Services\Grid\OptionColumnSpec; +use MODX\Revolution\modX; /** * Service for managing grid configurations @@ -177,6 +179,8 @@ public function saveGridConfig(string $gridKey, array $fields): bool 'relation', // computed type 'computed', + // option type + 'option', // badge type 'source_field', 'color_field', // datetime type @@ -349,6 +353,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 @@ -466,6 +477,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 @@ -722,6 +739,44 @@ 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 = (string) $option['key']; + if (!OptionColumnSpec::isValidOptionKey($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 + { + return array_map( + static fn (OptionColumnSpec $spec) => $spec->toJoinDescriptor(), + GridOptionColumnResolver::resolve($gridFields) + ); + } + /** * 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 d28d48d8..24c66a8f 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: '', }, @@ -80,6 +83,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' }, @@ -176,6 +180,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 @@ -233,6 +238,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 @@ -435,6 +441,13 @@ async function addField() { }, } break + case 'option': + data.config = { + option: { + key: newField.value.config.option?.key || '', + }, + } + break case 'computed': data.config = { computed: { @@ -514,6 +527,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 @@ -603,6 +617,8 @@ function openEditDialog(field, index) { className: '', } + const optionConfig = field.option || { key: '' } + editingField.value = { field_name: field.name, label: field.label || '', @@ -615,6 +631,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' }, @@ -673,6 +690,13 @@ async function saveEdit() { }, } break + case 'option': + data.config = { + option: { + key: editingField.value.config.option?.key || '', + }, + } + break case 'computed': data.config = { computed: { @@ -755,6 +779,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 @@ -1022,6 +1047,17 @@ onMounted(() => { {{ _('relation_hint') }} +
+ + + {{ _('option_key_hint') }} +
+
+
+ + + {{ _('option_key_hint') }} +
+