From b7aba67e08bfbbf0c5bb31dfbb2af0db1123ea88 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Wed, 29 Jul 2026 10:05:58 +0600 Subject: [PATCH 1/5] feat: migrate product Categories tab from ExtJS to Vue Replace ms3-tree-categories with a Vue tab backed by a reusable ResourceCategoryTree widget and REST tree endpoint. Closes #113. --- .../minishop3/config/routes/manager.php | 4 + .../controllers/mgr/settings.class.php | 1 + .../controllers/product/create.class.php | 1 - .../controllers/product/update.class.php | 2 +- .../src/Controllers/Api/BaseApiController.php | 33 ++ .../Controllers/Api/ProductDataController.php | 30 ++ .../Product/ProductCategoryTreeService.php | 159 ++++++++ .../src/components/OptionCategoryTree.vue | 345 +---------------- .../src/components/ResourceCategoryTree.vue | 351 ++++++++++++++++++ .../product/ProductCategoriesTab.vue | 71 ++++ .../src/components/product/ProductTabs.vue | 24 +- 11 files changed, 672 insertions(+), 349 deletions(-) create mode 100644 core/components/minishop3/src/Services/Product/ProductCategoryTreeService.php create mode 100644 vueManager/src/components/ResourceCategoryTree.vue create mode 100644 vueManager/src/components/product/ProductCategoriesTab.vue 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/mgr/settings.class.php b/core/components/minishop3/controllers/mgr/settings.class.php index ccea0f54..bff0c1ab 100644 --- a/core/components/minishop3/controllers/mgr/settings.class.php +++ b/core/components/minishop3/controllers/mgr/settings.class.php @@ -49,6 +49,7 @@ public function loadCustomCssJs() $this->addCss($this->ms3->config['assetsUrl'] . 'css/mgr/vue-dist/statuses.min.css'); $this->addCss($this->ms3->config['assetsUrl'] . 'css/mgr/vue-dist/links.min.css'); $this->addCss($this->ms3->config['assetsUrl'] . 'css/mgr/vue-dist/options.min.css'); + $this->addCss($this->ms3->config['assetsUrl'] . 'css/mgr/vue-dist/ResourceCategoryTree.min.css'); // Vue modules with VueTools dependency check $this->addVueModule($this->ms3->config['jsUrl'] . 'mgr/vue-dist/deliveries.min.js'); 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..5f608da3 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'); @@ -64,6 +63,7 @@ public function loadCustomCssJs() // Product Tabs Vue module (contains Properties, Gallery, Categories, Links, Options tabs) $this->addCss($assetsUrl . 'css/mgr/vue-dist/primeicons.min.css'); $this->addCss($assetsUrl . 'css/mgr/vue-dist/product-tabs.min.css'); + $this->addCss($assetsUrl . 'css/mgr/vue-dist/ResourceCategoryTree.min.css'); $this->addCss($assetsUrl . 'css/mgr/vue-dist/DynamicField.min.css'); $this->addVueModule($assetsUrl . 'js/mgr/vue-dist/product-tabs.min.js'); diff --git a/core/components/minishop3/src/Controllers/Api/BaseApiController.php b/core/components/minishop3/src/Controllers/Api/BaseApiController.php index 650a267f..97655fea 100644 --- a/core/components/minishop3/src/Controllers/Api/BaseApiController.php +++ b/core/components/minishop3/src/Controllers/Api/BaseApiController.php @@ -67,4 +67,37 @@ 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 + */ + 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 []; + } + + $result = []; + foreach ($input as $value) { + if (is_numeric($value)) { + $result[] = (int)$value; + } + } + + return $result; + } } diff --git a/core/components/minishop3/src/Controllers/Api/ProductDataController.php b/core/components/minishop3/src/Controllers/Api/ProductDataController.php index 4533459d..15aba92b 100644 --- a/core/components/minishop3/src/Controllers/Api/ProductDataController.php +++ b/core/components/minishop3/src/Controllers/Api/ProductDataController.php @@ -4,6 +4,7 @@ use MiniShop3\Router\HttpStatus; use MiniShop3\Router\Response; +use MiniShop3\Services\Product\ProductCategoryTreeService; /** * API controller for working with product data (msProductData) @@ -81,4 +82,33 @@ 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); + $preChecked = $this->decodeIntArray($params['categories'] ?? null); + + try { + $service = new ProductCategoryTreeService($this->modx); + $nodes = $service->getTreeNodes($parent, $productId, $parentCategoryId, $preChecked); + + 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/Services/Product/ProductCategoryTreeService.php b/core/components/minishop3/src/Services/Product/ProductCategoryTreeService.php new file mode 100644 index 00000000..e0e17f32 --- /dev/null +++ b/core/components/minishop3/src/Services/Product/ProductCategoryTreeService.php @@ -0,0 +1,159 @@ + $preChecked Optional ids from the client hidden field + * @return list> + */ + public function getTreeNodes(int $parent, int $productId, int $parentCategoryId, array $preChecked = []): array + { + $checkedSet = $this->buildCheckedSet($parentCategoryId, $preChecked); + $treeClassKeysSql = $this->quoteSqlStringList($this->getTreeClassKeys()); + $categoryClassKeysSql = $this->quoteSqlStringList(self::CATEGORY_CLASS_KEYS); + $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' => 'Member.category_id']); + } + $c->select($this->modx->getSelectColumns(modResource::class, 'modResource', '', [ + 'id', + 'pagetitle', + 'menutitle', + 'parent', + 'published', + 'hidemenu', + 'class_key', + ])); + $c->select(['childrenCount' => 'COUNT(Child.id)']); + $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 && (!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; + } + + private function getTreeNodeSqlFilter(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 getTreeClassKeys(): array + { + return array_values(array_unique(array_merge( + self::CATEGORY_CLASS_KEYS, + self::CONTAINER_CLASS_KEYS + ))); + } + + private function isCategoryClass(string $classKey): bool + { + return in_array($classKey, self::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)); + } +} 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..16f2c672 --- /dev/null +++ b/vueManager/src/components/ResourceCategoryTree.vue @@ -0,0 +1,351 @@ + + + + + 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(() => { + + +