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
4 changes: 4 additions & 0 deletions core/components/minishop3/config/routes/manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace MiniShop3\Controllers\Api;

use MiniShop3\Router\Response;
use MiniShop3\Utils\IntArrayDecoder;
use MODX\Revolution\modX;

/**
Expand Down Expand Up @@ -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<int> Deduplicated positive ints.
*/
protected function decodeIntArray($input): array
{
return IntArrayDecoder::decode($input);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

namespace MiniShop3\Controllers\Api\Manager;

use MiniShop3\Model\msCategory;
use MiniShop3\Model\msCategoryOption;
use MiniShop3\Model\msOption;
use MiniShop3\Model\msOptionGroup;
use MiniShop3\Router\HttpStatus;
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;

Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -626,68 +614,13 @@ 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.
*
* @return int[]
*/
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
4 changes: 4 additions & 0 deletions core/components/minishop3/src/ServiceRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

namespace MiniShop3\Services\Product;

use MiniShop3\Model\msCategoryMember;
use MiniShop3\Utils\ResourceCategoryTreeQueryTrait;
use MODX\Revolution\modResource;
use MODX\Revolution\modX;

/**
* Lazy tree nodes for the product Categories tab (Vue manager).
*
* Node visibility matches the options category tree (navigation containers + msCategory).
* Membership and locked parent rules mirror {@see \MiniShop3\Processors\Category\GetNodes}.
*/
class ProductCategoryTreeService
{
use ResourceCategoryTreeQueryTrait;

public function __construct(private modX $modx)
{
}

/**
* @param int $parent Parent resource id (0 = tree roots for current context)
* @param int $productId Product being edited
* @param int $parentCategoryId Product parent resource (always checked, not removable)
* @param array<int|string> $preChecked Optional ids from the client hidden field
* @param bool $clientSentCategories When true, checked state follows preChecked only (not stale DB membership)
* @return list<array<string, mixed>>
*/
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<int|string> $preChecked
* @return array<int, true>
*/
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;
}
}
Loading