diff --git a/core/components/minishop3/config/routes/manager.php b/core/components/minishop3/config/routes/manager.php index 1b0f0ffe..d7669ea3 100644 --- a/core/components/minishop3/config/routes/manager.php +++ b/core/components/minishop3/config/routes/manager.php @@ -131,6 +131,10 @@ $controller = new \MiniShop3\Controllers\Api\ProductDataController($modx); return $controller->update($params); }); + $router->get('/{id}/categories/tree', function($params) use ($modx) { + $controller = new \MiniShop3\Controllers\Api\ProductDataController($modx); + return $controller->getCategoriesTree($params); + }); }, [ new AuthMiddleware($modx, 'mgr'), diff --git a/core/components/minishop3/controllers/product/create.class.php b/core/components/minishop3/controllers/product/create.class.php index 31ec4e9e..4996cda6 100644 --- a/core/components/minishop3/controllers/product/create.class.php +++ b/core/components/minishop3/controllers/product/create.class.php @@ -78,7 +78,6 @@ public function loadCustomCssJs() $this->addJavascript($assetsUrl . 'js/mgr/misc/sortable/sortable.min.js'); $this->addJavascript($assetsUrl . 'js/mgr/misc/ms3.combo.js'); $this->addJavascript($assetsUrl . 'js/mgr/misc/ms3.utils.js'); - $this->addLastJavascript($assetsUrl . 'js/mgr/product/category.tree.js'); $this->addLastJavascript($assetsUrl . 'js/mgr/product/product.common.js'); $this->addLastJavascript($assetsUrl . 'js/mgr/product/create.js'); diff --git a/core/components/minishop3/controllers/product/update.class.php b/core/components/minishop3/controllers/product/update.class.php index 1365cdf2..3ade60b0 100644 --- a/core/components/minishop3/controllers/product/update.class.php +++ b/core/components/minishop3/controllers/product/update.class.php @@ -55,7 +55,6 @@ public function loadCustomCssJs() $this->addJavascript($assetsUrl . 'js/mgr/misc/ms3.utils.js'); $this->addJavascript($assetsUrl . 'js/mgr/misc/default.grid.js'); $this->addJavascript($assetsUrl . 'js/mgr/misc/default.window.js'); - $this->addLastJavascript($assetsUrl . 'js/mgr/product/category.tree.js'); $this->addLastJavascript($assetsUrl . 'js/mgr/product/links.grid.js'); $this->addLastJavascript($assetsUrl . 'js/mgr/product/links.window.js'); $this->addLastJavascript($assetsUrl . 'js/mgr/product/product.common.js'); diff --git a/core/components/minishop3/src/Controllers/Api/BaseApiController.php b/core/components/minishop3/src/Controllers/Api/BaseApiController.php index 650a267f..90027e2e 100644 --- a/core/components/minishop3/src/Controllers/Api/BaseApiController.php +++ b/core/components/minishop3/src/Controllers/Api/BaseApiController.php @@ -3,6 +3,7 @@ namespace MiniShop3\Controllers\Api; use MiniShop3\Router\Response; +use MiniShop3\Utils\IntArrayDecoder; use MODX\Revolution\modX; /** @@ -67,4 +68,13 @@ protected function isAuthenticated(string $context = 'web'): bool { return $this->modx->user && $this->modx->user->isAuthenticated($context); } + + /** + * @param mixed $input JSON array, comma-separated string, or array of ids + * @return list Deduplicated positive ints. + */ + protected function decodeIntArray($input): array + { + return IntArrayDecoder::decode($input); + } } diff --git a/core/components/minishop3/src/Controllers/Api/Manager/OptionsController.php b/core/components/minishop3/src/Controllers/Api/Manager/OptionsController.php index e10635cd..7a5d2ea0 100644 --- a/core/components/minishop3/src/Controllers/Api/Manager/OptionsController.php +++ b/core/components/minishop3/src/Controllers/Api/Manager/OptionsController.php @@ -2,7 +2,6 @@ namespace MiniShop3\Controllers\Api\Manager; -use MiniShop3\Model\msCategory; use MiniShop3\Model\msCategoryOption; use MiniShop3\Model\msOption; use MiniShop3\Model\msOptionGroup; @@ -10,6 +9,8 @@ use MiniShop3\Router\Response; use MiniShop3\Services\Option\OptionCategoryService; use MiniShop3\Services\Option\OptionService; +use MiniShop3\Utils\IntArrayDecoder; +use MiniShop3\Utils\ResourceCategoryTreeQueryTrait; use MODX\Revolution\modResource; use MODX\Revolution\modX; @@ -23,20 +24,7 @@ */ class OptionsController { - /** FQCN + short name. */ - private const OPTION_TREE_CATEGORY_CLASS_KEYS = [ - msCategory::class, - 'msCategory', - ]; - - private const OPTION_TREE_CONTAINER_CLASS_KEYS = [ - modResource::class, - 'MODX\\Revolution\\modDocument', - 'MODX\\Revolution\\modWebLink', - 'modResource', - 'modDocument', - 'modWebLink', - ]; + use ResourceCategoryTreeQueryTrait; protected modX $modx; protected OptionService $optionService; @@ -360,10 +348,10 @@ public function getTree(array $params = []): array $checkedSet[$catId] = true; } - $treeClassKeysSql = $this->quoteSqlStringList($this->getOptionTreeClassKeys()); - $categoryClassKeysSql = $this->quoteSqlStringList(self::OPTION_TREE_CATEGORY_CLASS_KEYS); - $treeNodeWhere = $this->getOptionTreeNodeSqlFilter('modResource', $treeClassKeysSql, $categoryClassKeysSql); - $childNodeWhere = $this->getOptionTreeNodeSqlFilter('Child', $treeClassKeysSql, $categoryClassKeysSql); + $treeClassKeysSql = $this->quoteSqlStringList($this->getTreeClassKeys()); + $categoryClassKeysSql = $this->quoteSqlStringList($this->treeCategoryClassKeys()); + $treeNodeWhere = $this->getTreeNodeSqlFilter('modResource', $treeClassKeysSql, $categoryClassKeysSql); + $childNodeWhere = $this->getTreeNodeSqlFilter('Child', $treeClassKeysSql, $categoryClassKeysSql); $q = $this->modx->newQuery(modResource::class); $q->leftJoin( @@ -386,7 +374,7 @@ public function getTree(array $params = []): array if ($q->prepare() && $q->stmt->execute()) { while ($row = $q->stmt->fetch(\PDO::FETCH_ASSOC)) { $id = (int)$row['id']; - $selectable = $this->isOptionTreeCategoryClass((string)$row['class_key']); + $selectable = $this->isCategoryClass((string)$row['class_key']); $label = (string)($row['menutitle'] ?: $row['pagetitle']); $nodes[] = [ 'id' => $id, @@ -626,36 +614,6 @@ protected function splitCategoriesPayload($payload): array return [$enabled, $disabled]; } - private function getOptionTreeNodeSqlFilter(string $alias, string $treeClassKeysSql, string $categoryClassKeysSql): string - { - return "(`{$alias}`.`class_key` IN ({$treeClassKeysSql}) " - . "AND (`{$alias}`.`class_key` IN ({$categoryClassKeysSql}) OR `{$alias}`.`isfolder` = 1))"; - } - - /** - * @return string[] - */ - private function getOptionTreeClassKeys(): array - { - return array_values(array_unique(array_merge( - self::OPTION_TREE_CATEGORY_CLASS_KEYS, - self::OPTION_TREE_CONTAINER_CLASS_KEYS - ))); - } - - private function isOptionTreeCategoryClass(string $classKey): bool - { - return in_array($classKey, self::OPTION_TREE_CATEGORY_CLASS_KEYS, true); - } - - /** - * @param string[] $values - */ - private function quoteSqlStringList(array $values): string - { - return implode(', ', array_map(fn(string $value): string => $this->modx->quote($value), $values)); - } - /** * Decode ids array whether it came as JSON string, comma string, or array. * @@ -663,31 +621,6 @@ private function quoteSqlStringList(array $values): string */ protected function decodeIntArray($input): array { - if ($input === null || $input === '') { - return []; - } - - if (is_string($input)) { - $decoded = json_decode($input, true); - if (is_array($decoded)) { - $input = $decoded; - } else { - $input = explode(',', $input); - } - } - - if (!is_array($input)) { - return []; - } - - $out = []; - foreach ($input as $v) { - $v = (int)$v; - if ($v > 0) { - $out[] = $v; - } - } - - return array_values(array_unique($out)); + return IntArrayDecoder::decode($input); } } diff --git a/core/components/minishop3/src/Controllers/Api/ProductDataController.php b/core/components/minishop3/src/Controllers/Api/ProductDataController.php index 4533459d..1d5ac433 100644 --- a/core/components/minishop3/src/Controllers/Api/ProductDataController.php +++ b/core/components/minishop3/src/Controllers/Api/ProductDataController.php @@ -81,4 +81,41 @@ public function update(array $params): Response return Response::error('Failed to save product data: ' . $e->getMessage(), HttpStatus::INTERNAL_SERVER_ERROR); } } + + /** + * GET /api/mgr/product-data/{id}/categories/tree + * + * Lazy msCategory tree for the product Categories tab (Vue). + * + * @param array $params id (product), parent (default 0), parent_category, categories (JSON precheck) + */ + public function getCategoriesTree(array $params): Response + { + $productId = (int)($params['id'] ?? 0); + if (!$productId) { + return Response::error('Product ID is required', HttpStatus::BAD_REQUEST); + } + + $parent = (int)($params['parent'] ?? 0); + $parentCategoryId = (int)($params['parent_category'] ?? 0); + $clientSentCategories = array_key_exists('categories', $params); + $preChecked = $this->decodeIntArray($params['categories'] ?? null); + + try { + /** @var \MiniShop3\Services\Product\ProductCategoryTreeService $service */ + $service = $this->modx->services->get('ms3_product_category_tree'); + $nodes = $service->getTreeNodes( + $parent, + $productId, + $parentCategoryId, + $preChecked, + $clientSentCategories + ); + + return Response::success(['results' => $nodes, 'total' => count($nodes)]); + } catch (\Exception $e) { + $this->modx->log(\MODX\Revolution\modX::LOG_LEVEL_ERROR, '[ProductDataController] ' . $e->getMessage()); + return Response::error('Failed to load category tree: ' . $e->getMessage(), HttpStatus::INTERNAL_SERVER_ERROR); + } + } } diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 04980615..d7a26ea8 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -59,6 +59,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Product\ProductDataService::class, 'interface' => null, ], + 'ms3_product_category_tree' => [ + 'class' => \MiniShop3\Services\Product\ProductCategoryTreeService::class, + 'interface' => null, + ], 'ms3_repeater_field' => [ 'class' => \MiniShop3\Services\ExtraFields\RepeaterFieldService::class, 'interface' => null, diff --git a/core/components/minishop3/src/Services/Product/ProductCategoryTreeService.php b/core/components/minishop3/src/Services/Product/ProductCategoryTreeService.php new file mode 100644 index 00000000..14b34fed --- /dev/null +++ b/core/components/minishop3/src/Services/Product/ProductCategoryTreeService.php @@ -0,0 +1,127 @@ + $preChecked Optional ids from the client hidden field + * @param bool $clientSentCategories When true, checked state follows preChecked only (not stale DB membership) + * @return list> + */ + public function getTreeNodes( + int $parent, + int $productId, + int $parentCategoryId, + array $preChecked = [], + bool $clientSentCategories = false + ): array + { + $checkedSet = $this->buildCheckedSet($parentCategoryId, $preChecked); + $treeClassKeysSql = $this->quoteSqlStringList($this->getTreeClassKeys()); + $categoryClassKeysSql = $this->quoteSqlStringList($this->treeCategoryClassKeys()); + $treeNodeWhere = $this->getTreeNodeSqlFilter('modResource', $treeClassKeysSql, $categoryClassKeysSql); + $childNodeWhere = $this->getTreeNodeSqlFilter('Child', $treeClassKeysSql, $categoryClassKeysSql); + + $c = $this->modx->newQuery(modResource::class); + $c->leftJoin( + modResource::class, + 'Child', + "`modResource`.`id` = `Child`.`parent` AND `Child`.`deleted` = 0 AND {$childNodeWhere}" + ); + if ($productId > 0) { + $c->leftJoin( + msCategoryMember::class, + 'Member', + [ + 'modResource.id = Member.category_id', + 'Member.product_id' => $productId, + ] + ); + $c->select('Member.category_id AS member'); + } + $c->select($this->modx->getSelectColumns(modResource::class, 'modResource', '', [ + 'id', + 'pagetitle', + 'menutitle', + 'parent', + 'published', + 'hidemenu', + 'class_key', + ])); + $c->select('COUNT(Child.id) AS childrenCount'); + $c->where([ + 'modResource.parent' => $parent, + 'modResource.deleted' => 0, + 'modResource.show_in_tree' => true, + ]); + $c->where($treeNodeWhere); + $c->groupby('modResource.id'); + $c->sortby('modResource.menuindex', 'ASC'); + + $nodes = []; + if ($c->prepare() && $c->stmt->execute()) { + while ($row = $c->stmt->fetch(\PDO::FETCH_ASSOC)) { + $id = (int)$row['id']; + $selectable = $this->isCategoryClass((string)$row['class_key']); + $checked = $selectable && ( + $clientSentCategories + ? isset($checkedSet[$id]) + : (!empty($row['member']) || isset($checkedSet[$id])) + ); + $nodes[] = [ + 'id' => $id, + 'label' => (string)($row['menutitle'] ?: $row['pagetitle'] ?? ''), + 'leaf' => (int)$row['childrenCount'] === 0, + 'checked' => $checked, + 'selectable' => $selectable, + 'locked' => $selectable && $id === $parentCategoryId, + 'class_key' => $row['class_key'], + 'published' => (int)$row['published'], + 'hidemenu' => (int)($row['hidemenu'] ?? 0), + ]; + } + } + + return $nodes; + } + + /** + * @param array $preChecked + * @return array + */ + private function buildCheckedSet(int $parentCategoryId, array $preChecked): array + { + $checkedSet = []; + foreach ($preChecked as $catId) { + if (is_numeric($catId)) { + $checkedSet[(int)$catId] = true; + } + } + if ($parentCategoryId > 0) { + $checkedSet[$parentCategoryId] = true; + } + + return $checkedSet; + } +} diff --git a/core/components/minishop3/src/Utils/IntArrayDecoder.php b/core/components/minishop3/src/Utils/IntArrayDecoder.php new file mode 100644 index 00000000..67b5da10 --- /dev/null +++ b/core/components/minishop3/src/Utils/IntArrayDecoder.php @@ -0,0 +1,49 @@ + Deduplicated positive ints, preserving first-seen order. + */ + public static function decode($input): array + { + if ($input === null || $input === '') { + return []; + } + + if (is_string($input)) { + $decoded = json_decode($input, true); + if (is_array($decoded)) { + $input = $decoded; + } else { + $input = explode(',', $input); + } + } + + if (!is_array($input)) { + return []; + } + + $out = []; + foreach ($input as $value) { + if (!is_numeric($value)) { + continue; + } + $id = (int)$value; + if ($id > 0 && !in_array($id, $out, true)) { + $out[] = $id; + } + } + + return $out; + } +} diff --git a/core/components/minishop3/src/Utils/ResourceCategoryTreeQueryTrait.php b/core/components/minishop3/src/Utils/ResourceCategoryTreeQueryTrait.php new file mode 100644 index 00000000..64236786 --- /dev/null +++ b/core/components/minishop3/src/Utils/ResourceCategoryTreeQueryTrait.php @@ -0,0 +1,78 @@ +treeCategoryClassKeys(), + $this->treeContainerClassKeys() + ))); + } + + protected function isCategoryClass(string $classKey): bool + { + return in_array($classKey, $this->treeCategoryClassKeys(), true); + } + + /** + * @param string[] $values + */ + protected function quoteSqlStringList(array $values): string + { + return implode(', ', array_map(fn(string $value): string => $this->modx->quote($value), $values)); + } +} diff --git a/vueManager/src/components/OptionCategoryTree.vue b/vueManager/src/components/OptionCategoryTree.vue index d30318b6..6c4b2c4f 100644 --- a/vueManager/src/components/OptionCategoryTree.vue +++ b/vueManager/src/components/OptionCategoryTree.vue @@ -1,347 +1,22 @@ - - diff --git a/vueManager/src/components/ResourceCategoryTree.vue b/vueManager/src/components/ResourceCategoryTree.vue new file mode 100644 index 00000000..8bd15341 --- /dev/null +++ b/vueManager/src/components/ResourceCategoryTree.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/vueManager/src/components/product/ProductCategoriesTab.vue b/vueManager/src/components/product/ProductCategoriesTab.vue new file mode 100644 index 00000000..312322c0 --- /dev/null +++ b/vueManager/src/components/product/ProductCategoriesTab.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/vueManager/src/components/product/ProductTabs.vue b/vueManager/src/components/product/ProductTabs.vue index 8a130127..9951ee61 100644 --- a/vueManager/src/components/product/ProductTabs.vue +++ b/vueManager/src/components/product/ProductTabs.vue @@ -11,6 +11,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import ProductGallery from '../gallery/ProductGallery.vue' import ProductDataFields from '../ProductDataFields.vue' +import ProductCategoriesTab from './ProductCategoriesTab.vue' import ProductOptionsTab from './ProductOptionsTab.vue' const props = defineProps({ @@ -80,13 +81,8 @@ const tabConfig = computed(() => { tabs.push({ key: 'categories', title: _('ms3_tab_product_categories'), - type: 'extjs', - xtype: 'ms3-tree-categories', - extConfig: { - parent: props.record.parent || 0, - resource: props.record.id || 0, - categories: props.record.categories || [], - }, + type: 'vue', + component: 'ProductCategoriesTab', position: 2, }) } @@ -317,6 +313,15 @@ onBeforeUnmount(() => { + + +