Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/lexicon/en/vue.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}';
Expand All @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/lexicon/ru/vue.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}';
Expand All @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand All @@ -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();
}

Expand Down Expand Up @@ -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) {
Expand All @@ -207,16 +125,16 @@ 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;
}

$product = $this->modx->getObject(msProduct::class, [
'id' => $productId,
'parent' => $categoryId
'parent' => $categoryId,
]);

if ($product) {
Expand All @@ -228,7 +146,7 @@ public function sort(array $params = []): array
}

return Response::success([
'updated' => $updated
'updated' => $updated,
], 'Products reordered successfully')->getData();
}

Expand Down Expand Up @@ -330,7 +248,7 @@ public function multiple(array $params = []): array

return Response::success([
'success' => $success,
'failed' => $failed
'failed' => $failed,
], "{$success} products updated")->getData();
}

Expand All @@ -344,6 +262,7 @@ public function multiple(array $params = []): array
public function bulkDelete(array $params = []): array
{
$params['method'] = 'delete';

return $this->multiple($params);
}

Expand All @@ -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();
Expand Down Expand Up @@ -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
*
Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/src/ServiceRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading