Add hierarchical tree view for category selection
Features: - Tree view mode for categories with expand/collapse - Product count badges with clickable preview popover - Select parent with all children button - Client-side tree filtering (refine search) - Keyboard shortcuts: Ctrl+A (select all), Ctrl+D (clear) - View mode switching between tree/list/columns - Tree view as default for categories, respects user preference Backend: - Add previewCategoryProducts and previewCategoryPages AJAX handlers - Support pagination and filtering in category previews Styling: - Consistent count-badge styling across tree and other views - Loading and popover-open states for count badges Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -227,9 +227,15 @@ trait EntitySelector
|
||||
case 'getTargetEntitiesByIds':
|
||||
$this->ajaxGetTargetEntitiesByIds();
|
||||
return true;
|
||||
case 'getTargetEntitiesByIdsBulk':
|
||||
$this->ajaxGetTargetEntitiesByIdsBulk();
|
||||
return true;
|
||||
case 'previewEntitySelector':
|
||||
$this->ajaxPreviewEntitySelector();
|
||||
return true;
|
||||
case 'previewEntitySelectorBulk':
|
||||
$this->ajaxPreviewEntitySelectorBulk();
|
||||
return true;
|
||||
case 'getTargetFilterableAttributes':
|
||||
$this->ajaxGetTargetFilterableAttributes();
|
||||
return true;
|
||||
@@ -251,6 +257,9 @@ trait EntitySelector
|
||||
case 'countConditionMatches':
|
||||
$this->ajaxCountConditionMatches();
|
||||
return true;
|
||||
case 'countConditionMatchesBulk':
|
||||
$this->ajaxCountConditionMatchesBulk();
|
||||
return true;
|
||||
case 'previewConditionItems':
|
||||
$this->ajaxPreviewConditionItems();
|
||||
return true;
|
||||
@@ -266,6 +275,12 @@ trait EntitySelector
|
||||
case 'previewFilterGroupProducts':
|
||||
$this->ajaxPreviewFilterGroupProducts();
|
||||
return true;
|
||||
case 'previewCategoryProducts':
|
||||
$this->ajaxPreviewCategoryProducts();
|
||||
return true;
|
||||
case 'previewCategoryPages':
|
||||
$this->ajaxPreviewCategoryPages();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -321,6 +336,69 @@ trait EntitySelector
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Count products matching multiple conditions in a single request
|
||||
* Receives an array of conditions and returns counts for each
|
||||
*/
|
||||
protected function ajaxCountConditionMatchesBulk()
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$conditionsJson = Tools::getValue('conditions', '[]');
|
||||
$conditions = json_decode($conditionsJson, true);
|
||||
|
||||
if (!is_array($conditions)) {
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Invalid conditions format',
|
||||
'counts' => [],
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$counts = [];
|
||||
$timing = [];
|
||||
|
||||
foreach ($conditions as $conditionId => $condition) {
|
||||
$conditionStart = microtime(true);
|
||||
$method = $condition['method'] ?? '';
|
||||
$values = $condition['values'] ?? [];
|
||||
$blockType = $condition['block_type'] ?? 'products';
|
||||
|
||||
if (empty($method)) {
|
||||
$counts[$conditionId] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($blockType === 'products') {
|
||||
$matchingIds = $this->getProductConditionResolver()->getIdsByMethod($method, $values);
|
||||
$counts[$conditionId] = count($matchingIds);
|
||||
} else {
|
||||
// For non-product entity types, use the query handler
|
||||
$matchingIds = $this->getEntityQueryHandler()->getIdsByMethod($blockType, $method, $values);
|
||||
$counts[$conditionId] = count($matchingIds);
|
||||
}
|
||||
$timing[$conditionId] = round((microtime(true) - $conditionStart) * 1000, 2);
|
||||
}
|
||||
|
||||
$totalTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => true,
|
||||
'counts' => $counts,
|
||||
'timing' => $timing,
|
||||
'total_time_ms' => $totalTime,
|
||||
'condition_count' => count($conditions),
|
||||
]));
|
||||
} catch (\Exception $e) {
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'counts' => [],
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Preview items matching a single condition (method + values)
|
||||
* Delegates to ProductConditionResolver and EntityPreviewHandler
|
||||
@@ -716,6 +794,160 @@ trait EntitySelector
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Preview products in a category
|
||||
* Used by tree view product count click
|
||||
*/
|
||||
protected function ajaxPreviewCategoryProducts()
|
||||
{
|
||||
$categoryId = (int) Tools::getValue('category_id');
|
||||
$limit = (int) Tools::getValue('limit', 10);
|
||||
$offset = (int) Tools::getValue('offset', 0);
|
||||
$query = Tools::getValue('query', '');
|
||||
|
||||
if (!$categoryId) {
|
||||
die(json_encode(['success' => false, 'error' => 'Invalid category ID']));
|
||||
}
|
||||
|
||||
$idLang = (int) Context::getContext()->language->id;
|
||||
$idShop = (int) Context::getContext()->shop->id;
|
||||
|
||||
try {
|
||||
$db = Db::getInstance();
|
||||
|
||||
// Get products in category
|
||||
$sql = new DbQuery();
|
||||
$sql->select('DISTINCT p.id_product');
|
||||
$sql->from('product', 'p');
|
||||
$sql->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . $idShop);
|
||||
$sql->innerJoin('category_product', 'cp', 'cp.id_product = p.id_product AND cp.id_category = ' . $categoryId);
|
||||
$sql->where('ps.active = 1');
|
||||
|
||||
$results = $db->executeS($sql);
|
||||
$productIds = array_column($results, 'id_product');
|
||||
|
||||
// Apply filter if provided
|
||||
if (!empty($query) && !empty($productIds)) {
|
||||
$productIds = $this->getEntityPreviewHandler()->filterProductIdsByQuery($productIds, $query, $idLang, $idShop);
|
||||
}
|
||||
|
||||
$totalCount = count($productIds);
|
||||
|
||||
// Get limited results for preview
|
||||
$previewIds = array_slice($productIds, $offset, $limit);
|
||||
|
||||
// Get product details
|
||||
$items = [];
|
||||
if (!empty($previewIds)) {
|
||||
$productSql = new DbQuery();
|
||||
$productSql->select('p.id_product, pl.name, p.reference, i.id_image, m.name as manufacturer');
|
||||
$productSql->from('product', 'p');
|
||||
$productSql->innerJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . $idLang . ' AND pl.id_shop = ' . $idShop);
|
||||
$productSql->leftJoin('image', 'i', 'i.id_product = p.id_product AND i.cover = 1');
|
||||
$productSql->leftJoin('manufacturer', 'm', 'm.id_manufacturer = p.id_manufacturer');
|
||||
$productSql->where('p.id_product IN (' . implode(',', array_map('intval', $previewIds)) . ')');
|
||||
|
||||
$products = $db->executeS($productSql);
|
||||
|
||||
$productsById = [];
|
||||
foreach ($products as $product) {
|
||||
$imageUrl = null;
|
||||
if ($product['id_image']) {
|
||||
$imageUrl = Context::getContext()->link->getImageLink(
|
||||
Tools::link_rewrite($product['name']),
|
||||
$product['id_product'] . '-' . $product['id_image'],
|
||||
'small_default'
|
||||
);
|
||||
}
|
||||
|
||||
$productsById[(int) $product['id_product']] = [
|
||||
'id' => (int) $product['id_product'],
|
||||
'name' => $product['name'],
|
||||
'reference' => $product['reference'],
|
||||
'manufacturer' => $product['manufacturer'],
|
||||
'image' => $imageUrl
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($previewIds as $id) {
|
||||
if (isset($productsById[(int) $id])) {
|
||||
$items[] = $productsById[(int) $id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
die(json_encode([
|
||||
'success' => true,
|
||||
'items' => $items,
|
||||
'count' => $totalCount,
|
||||
'hasMore' => ($offset + count($items)) < $totalCount
|
||||
]));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
die(json_encode(['success' => false, 'error' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Preview CMS pages in a CMS category
|
||||
* Used by tree view page count click
|
||||
*/
|
||||
protected function ajaxPreviewCategoryPages()
|
||||
{
|
||||
$categoryId = (int) Tools::getValue('category_id');
|
||||
$limit = (int) Tools::getValue('limit', 10);
|
||||
$offset = (int) Tools::getValue('offset', 0);
|
||||
$query = Tools::getValue('query', '');
|
||||
|
||||
if (!$categoryId) {
|
||||
die(json_encode(['success' => false, 'error' => 'Invalid category ID']));
|
||||
}
|
||||
|
||||
$idLang = (int) Context::getContext()->language->id;
|
||||
$idShop = (int) Context::getContext()->shop->id;
|
||||
|
||||
try {
|
||||
$db = Db::getInstance();
|
||||
|
||||
// Get CMS pages in category
|
||||
$sql = new DbQuery();
|
||||
$sql->select('c.id_cms, cl.meta_title');
|
||||
$sql->from('cms', 'c');
|
||||
$sql->innerJoin('cms_shop', 'cs', 'cs.id_cms = c.id_cms AND cs.id_shop = ' . $idShop);
|
||||
$sql->innerJoin('cms_lang', 'cl', 'cl.id_cms = c.id_cms AND cl.id_lang = ' . $idLang . ' AND cl.id_shop = ' . $idShop);
|
||||
$sql->where('c.id_cms_category = ' . $categoryId);
|
||||
$sql->where('c.active = 1');
|
||||
|
||||
if (!empty($query)) {
|
||||
$sql->where('cl.meta_title LIKE \'%' . pSQL($query) . '%\'');
|
||||
}
|
||||
|
||||
$allPages = $db->executeS($sql);
|
||||
$totalCount = count($allPages);
|
||||
|
||||
// Get limited results
|
||||
$pages = array_slice($allPages, $offset, $limit);
|
||||
|
||||
$items = [];
|
||||
foreach ($pages as $page) {
|
||||
$items[] = [
|
||||
'id' => (int) $page['id_cms'],
|
||||
'name' => $page['meta_title']
|
||||
];
|
||||
}
|
||||
|
||||
die(json_encode([
|
||||
'success' => true,
|
||||
'items' => $items,
|
||||
'count' => $totalCount,
|
||||
'hasMore' => ($offset + count($items)) < $totalCount
|
||||
]));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
die(json_encode(['success' => false, 'error' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply modifiers (sort, limit) to product IDs
|
||||
* Delegates to ProductConditionResolver
|
||||
@@ -857,6 +1089,54 @@ trait EntitySelector
|
||||
|
||||
$this->ajaxDie(json_encode($result));
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Get preview counts for all entity types in a single request
|
||||
*/
|
||||
protected function ajaxPreviewEntitySelectorBulk()
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$conditionsJson = Tools::getValue('conditions', '{}');
|
||||
|
||||
$conditions = json_decode($conditionsJson, true);
|
||||
if (!is_array($conditions)) {
|
||||
$conditions = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$timing = [];
|
||||
$counts = [];
|
||||
|
||||
foreach ($conditions as $entityType => $conditionData) {
|
||||
$typeStart = microtime(true);
|
||||
$groups = $conditionData['groups'] ?? [];
|
||||
|
||||
try {
|
||||
$counts[$entityType] = $this->getEntityPreviewHandler()->getPreviewCount($entityType, $groups);
|
||||
} catch (\Exception $e) {
|
||||
$counts[$entityType] = 0;
|
||||
}
|
||||
$timing[$entityType] = round((microtime(true) - $typeStart) * 1000, 2);
|
||||
}
|
||||
|
||||
$totalTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => true,
|
||||
'counts' => $counts,
|
||||
'timing' => $timing,
|
||||
'total_time_ms' => $totalTime,
|
||||
'entity_count' => count($conditions),
|
||||
]));
|
||||
} catch (\Exception $e) {
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'counts' => [],
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Search entities for target conditions
|
||||
*/
|
||||
@@ -977,6 +1257,39 @@ trait EntitySelector
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Get entities by IDs in bulk (multiple entity types in one request)
|
||||
* Accepts: entities = { "products": [1,2,3], "categories": [4,5], ... }
|
||||
* Returns: { "products": [...], "categories": [...], ... }
|
||||
*/
|
||||
protected function ajaxGetTargetEntitiesByIdsBulk()
|
||||
{
|
||||
$entitiesParam = Tools::getValue('entities', '');
|
||||
if (is_string($entitiesParam)) {
|
||||
$entitiesParam = json_decode($entitiesParam, true);
|
||||
}
|
||||
if (!is_array($entitiesParam)) {
|
||||
$entitiesParam = [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$searchEngine = $this->getEntitySearchEngine();
|
||||
|
||||
foreach ($entitiesParam as $entityType => $ids) {
|
||||
if (!is_array($ids) || empty($ids)) {
|
||||
continue;
|
||||
}
|
||||
// Deduplicate and sanitize IDs
|
||||
$ids = array_unique(array_map('intval', $ids));
|
||||
$result[$entityType] = $searchEngine->getByIds($entityType, $ids);
|
||||
}
|
||||
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => true,
|
||||
'entities' => $result,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target blocks configuration
|
||||
*/
|
||||
|
||||
@@ -145,6 +145,29 @@ class EntityPreviewHandler
|
||||
return count($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preview counts for multiple entity types in a single call
|
||||
*
|
||||
* @param array $allConditions Array of conditions keyed by entity type
|
||||
* @return array Counts keyed by entity type
|
||||
*/
|
||||
public function getPreviewCountsBulk(array $allConditions)
|
||||
{
|
||||
$counts = [];
|
||||
|
||||
foreach ($allConditions as $entityType => $conditionData) {
|
||||
$groups = $conditionData['groups'] ?? [];
|
||||
|
||||
try {
|
||||
$counts[$entityType] = $this->getPreviewCount($entityType, $groups);
|
||||
} catch (\Exception $e) {
|
||||
$counts[$entityType] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert snake_case to CamelCase
|
||||
*
|
||||
@@ -1103,8 +1126,9 @@ class EntityPreviewHandler
|
||||
}
|
||||
|
||||
$sql = new DbQuery();
|
||||
$sql->select('c.id_currency, c.name, c.iso_code, c.active, c.conversion_rate, c.sign');
|
||||
$sql->select('c.id_currency, cl.name, c.iso_code, c.active, c.conversion_rate, cl.symbol');
|
||||
$sql->from('currency', 'c');
|
||||
$sql->leftJoin('currency_lang', 'cl', 'cl.id_currency = c.id_currency AND cl.id_lang = ' . (int) $idLang);
|
||||
$sql->where('c.id_currency IN (' . implode(',', array_map('intval', $ids)) . ')');
|
||||
|
||||
$results = Db::getInstance()->executeS($sql);
|
||||
@@ -1119,7 +1143,7 @@ class EntityPreviewHandler
|
||||
'id' => (int) $row['id_currency'],
|
||||
'name' => $row['name'],
|
||||
'iso_code' => $row['iso_code'],
|
||||
'sign' => $row['sign'],
|
||||
'sign' => $row['symbol'],
|
||||
'active' => (bool) $row['active'],
|
||||
'conversion_rate' => (float) $row['conversion_rate'],
|
||||
];
|
||||
|
||||
@@ -691,6 +691,132 @@ class EntitySearchEngine
|
||||
return implode(' > ', array_column($results, 'name'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full category tree with hierarchy
|
||||
*
|
||||
* @param int $idLang Language ID
|
||||
* @param int $idShop Shop ID
|
||||
* @param array $selectedIds Currently selected category IDs
|
||||
* @param bool $activeOnly Only include active categories
|
||||
* @return array Tree structure
|
||||
*/
|
||||
public function getCategoryTree($idLang = null, $idShop = null, array $selectedIds = [], $activeOnly = false)
|
||||
{
|
||||
$idLang = $idLang ?: $this->idLang;
|
||||
$idShop = $idShop ?: $this->idShop;
|
||||
|
||||
$sql = new DbQuery();
|
||||
$sql->select('c.id_category, c.id_parent, cl.name, c.active, c.level_depth, c.position');
|
||||
$sql->select('(SELECT COUNT(cp.id_product) FROM ' . _DB_PREFIX_ . 'category_product cp WHERE cp.id_category = c.id_category) AS product_count');
|
||||
$sql->from('category', 'c');
|
||||
$sql->innerJoin('category_shop', 'cs', 'cs.id_category = c.id_category AND cs.id_shop = ' . (int) $idShop);
|
||||
$sql->leftJoin('category_lang', 'cl', 'cl.id_category = c.id_category AND cl.id_lang = ' . (int) $idLang . ' AND cl.id_shop = ' . (int) $idShop);
|
||||
$sql->where('c.id_parent > 0'); // Exclude root
|
||||
|
||||
if ($activeOnly) {
|
||||
$sql->where('c.active = 1');
|
||||
}
|
||||
|
||||
$sql->orderBy('c.level_depth ASC, c.position ASC');
|
||||
|
||||
$results = Db::getInstance()->executeS($sql);
|
||||
|
||||
if (!$results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Convert selected IDs to lookup set
|
||||
$selectedSet = array_flip($selectedIds);
|
||||
|
||||
// Build flat array with parent references
|
||||
$categories = [];
|
||||
foreach ($results as $row) {
|
||||
$id = (int) $row['id_category'];
|
||||
$categories[$id] = [
|
||||
'id' => $id,
|
||||
'id_parent' => (int) $row['id_parent'],
|
||||
'name' => $row['name'],
|
||||
'active' => (bool) $row['active'],
|
||||
'level_depth' => (int) $row['level_depth'],
|
||||
'position' => (int) $row['position'],
|
||||
'product_count' => (int) $row['product_count'],
|
||||
'selected' => isset($selectedSet[$id]),
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// Build tree structure
|
||||
$tree = [];
|
||||
foreach ($categories as $id => &$category) {
|
||||
$parentId = $category['id_parent'];
|
||||
if (isset($categories[$parentId])) {
|
||||
$categories[$parentId]['children'][] = &$category;
|
||||
} else {
|
||||
// Top-level category (parent is root)
|
||||
$tree[] = &$category;
|
||||
}
|
||||
}
|
||||
unset($category);
|
||||
|
||||
// Sort children by position at each level
|
||||
$this->sortTreeByPosition($tree);
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sort tree children by position
|
||||
*
|
||||
* @param array &$nodes Tree nodes
|
||||
*/
|
||||
protected function sortTreeByPosition(array &$nodes)
|
||||
{
|
||||
usort($nodes, function ($a, $b) {
|
||||
return $a['position'] - $b['position'];
|
||||
});
|
||||
|
||||
foreach ($nodes as &$node) {
|
||||
if (!empty($node['children'])) {
|
||||
$this->sortTreeByPosition($node['children']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all descendant category IDs for given parent IDs
|
||||
*
|
||||
* @param array $parentIds Parent category IDs
|
||||
* @param int $idShop Shop ID
|
||||
* @return array All descendant IDs (including parents)
|
||||
*/
|
||||
public function getCategoryDescendants(array $parentIds, $idShop = null)
|
||||
{
|
||||
if (empty($parentIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$idShop = $idShop ?: $this->idShop;
|
||||
$allIds = $parentIds;
|
||||
|
||||
// Use nleft/nright for efficient descendant lookup
|
||||
$sql = new DbQuery();
|
||||
$sql->select('DISTINCT c2.id_category');
|
||||
$sql->from('category', 'c1');
|
||||
$sql->innerJoin('category', 'c2', 'c2.nleft > c1.nleft AND c2.nright < c1.nright');
|
||||
$sql->innerJoin('category_shop', 'cs', 'cs.id_category = c2.id_category AND cs.id_shop = ' . (int) $idShop);
|
||||
$sql->where('c1.id_category IN (' . implode(',', array_map('intval', $parentIds)) . ')');
|
||||
|
||||
$results = Db::getInstance()->executeS($sql);
|
||||
|
||||
if ($results) {
|
||||
foreach ($results as $row) {
|
||||
$allIds[] = (int) $row['id_category'];
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($allIds);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MANUFACTURERS
|
||||
// =========================================================================
|
||||
@@ -2005,21 +2131,22 @@ class EntitySearchEngine
|
||||
$limit = isset($filters['limit']) ? (int) $filters['limit'] : 20;
|
||||
|
||||
$sql = new DbQuery();
|
||||
$sql->select('DISTINCT c.id_currency, c.name, c.iso_code, c.active, c.conversion_rate, c.sign');
|
||||
$sql->select('DISTINCT c.id_currency, c.iso_code, c.active, c.conversion_rate, cl.name, cl.symbol');
|
||||
$sql->from('currency', 'c');
|
||||
$sql->innerJoin('currency_shop', 'cs', 'cs.id_currency = c.id_currency AND cs.id_shop = ' . (int) $idShop);
|
||||
$sql->leftJoin('currency_lang', 'cl', 'cl.id_currency = c.id_currency AND cl.id_lang = ' . (int) $idLang);
|
||||
|
||||
if (!empty($query)) {
|
||||
$escapedQuery = $this->escapePattern($query);
|
||||
$sql->where('(c.name LIKE \'%' . $escapedQuery . '%\' OR c.iso_code LIKE \'%' . $escapedQuery . '%\' OR c.id_currency = ' . (int) $query . ')');
|
||||
$sql->where('(cl.name LIKE \'%' . $escapedQuery . '%\' OR c.iso_code LIKE \'%' . $escapedQuery . '%\' OR c.id_currency = ' . (int) $query . ')');
|
||||
}
|
||||
|
||||
if (!empty($filters['refine'])) {
|
||||
$refine = $this->escapePattern($filters['refine']);
|
||||
if (!empty($filters['refine_negate'])) {
|
||||
$sql->where('c.name NOT LIKE \'%' . $refine . '%\'');
|
||||
$sql->where('cl.name NOT LIKE \'%' . $refine . '%\'');
|
||||
} else {
|
||||
$sql->where('c.name LIKE \'%' . $refine . '%\'');
|
||||
$sql->where('cl.name LIKE \'%' . $refine . '%\'');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2027,7 +2154,7 @@ class EntitySearchEngine
|
||||
$sql->where('c.active = 1');
|
||||
}
|
||||
|
||||
$sql->orderBy('c.name ASC');
|
||||
$sql->orderBy('cl.name ASC');
|
||||
$sql->limit($limit);
|
||||
|
||||
$results = Db::getInstance()->executeS($sql);
|
||||
@@ -2040,9 +2167,9 @@ class EntitySearchEngine
|
||||
return [
|
||||
'id' => (int) $row['id_currency'],
|
||||
'type' => 'currency',
|
||||
'name' => $row['name'],
|
||||
'name' => $row['name'] ?: $row['iso_code'],
|
||||
'iso_code' => $row['iso_code'],
|
||||
'sign' => $row['sign'],
|
||||
'symbol' => $row['symbol'] ?: $row['iso_code'],
|
||||
'active' => (bool) $row['active'],
|
||||
'conversion_rate' => (float) $row['conversion_rate'],
|
||||
];
|
||||
@@ -2058,18 +2185,19 @@ class EntitySearchEngine
|
||||
$sql->select('COUNT(DISTINCT c.id_currency)');
|
||||
$sql->from('currency', 'c');
|
||||
$sql->innerJoin('currency_shop', 'cs', 'cs.id_currency = c.id_currency AND cs.id_shop = ' . (int) $idShop);
|
||||
$sql->leftJoin('currency_lang', 'cl', 'cl.id_currency = c.id_currency AND cl.id_lang = ' . (int) $idLang);
|
||||
|
||||
if (!empty($query)) {
|
||||
$escapedQuery = $this->escapePattern($query);
|
||||
$sql->where('(c.name LIKE \'%' . $escapedQuery . '%\' OR c.iso_code LIKE \'%' . $escapedQuery . '%\' OR c.id_currency = ' . (int) $query . ')');
|
||||
$sql->where('(cl.name LIKE \'%' . $escapedQuery . '%\' OR c.iso_code LIKE \'%' . $escapedQuery . '%\' OR c.id_currency = ' . (int) $query . ')');
|
||||
}
|
||||
|
||||
if (!empty($filters['refine'])) {
|
||||
$refine = $this->escapePattern($filters['refine']);
|
||||
if (!empty($filters['refine_negate'])) {
|
||||
$sql->where('c.name NOT LIKE \'%' . $refine . '%\'');
|
||||
$sql->where('cl.name NOT LIKE \'%' . $refine . '%\'');
|
||||
} else {
|
||||
$sql->where('c.name LIKE \'%' . $refine . '%\'');
|
||||
$sql->where('cl.name LIKE \'%' . $refine . '%\'');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2090,8 +2218,9 @@ class EntitySearchEngine
|
||||
}
|
||||
|
||||
$sql = new DbQuery();
|
||||
$sql->select('c.id_currency, c.name, c.iso_code, c.sign, c.active');
|
||||
$sql->select('c.id_currency, c.iso_code, c.active, cl.name, cl.symbol');
|
||||
$sql->from('currency', 'c');
|
||||
$sql->leftJoin('currency_lang', 'cl', 'cl.id_currency = c.id_currency AND cl.id_lang = ' . (int) $idLang);
|
||||
$sql->where('c.id_currency IN (' . implode(',', array_map('intval', $ids)) . ')');
|
||||
|
||||
$results = Db::getInstance()->executeS($sql);
|
||||
@@ -2105,9 +2234,9 @@ class EntitySearchEngine
|
||||
$currencies[(int) $row['id_currency']] = [
|
||||
'id' => (int) $row['id_currency'],
|
||||
'type' => 'currency',
|
||||
'name' => $row['name'],
|
||||
'name' => $row['name'] ?: $row['iso_code'],
|
||||
'iso_code' => $row['iso_code'],
|
||||
'sign' => $row['sign'],
|
||||
'symbol' => $row['symbol'] ?: $row['iso_code'],
|
||||
'active' => (bool) $row['active'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ class EntitySelectorRenderer
|
||||
$html .= '<span class="trait-title">' . $this->escapeAttr($config['title']) . '</span>';
|
||||
$html .= '<span class="trait-subtitle">' . $this->escapeAttr($config['subtitle']) . '</span>';
|
||||
$html .= '</div>';
|
||||
$html .= '<span class="trait-total-count" style="display: none;" title="' . $this->trans('Total items targeted') . '"></span>';
|
||||
$html .= '<span class="trait-total-count" style="display: none;" title="' . $this->trans('Total items targeted') . '"><i class="icon-eye"></i> <span class="count-value"></span></span>';
|
||||
$html .= '</div>';
|
||||
$html .= '<div class="trait-header-right">';
|
||||
|
||||
@@ -351,6 +351,7 @@ class EntitySelectorRenderer
|
||||
$html .= '<i class="' . $this->escapeAttr($blockDef['icon']) . '"></i>';
|
||||
$html .= '<span class="tab-label">' . $this->escapeAttr($blockDef['label']) . '</span>';
|
||||
if ($hasData) {
|
||||
// Show loading spinner that will be replaced with actual count
|
||||
$html .= '<span class="tab-badge loading"><i class="icon-spinner icon-spin"></i></span>';
|
||||
}
|
||||
$html .= '</button>';
|
||||
@@ -491,10 +492,12 @@ class EntitySelectorRenderer
|
||||
|
||||
if ($hasGroups) {
|
||||
$renderedGroups = 0;
|
||||
$showEmptyGroups = $this->renderConfig['show_empty_groups'] ?? false;
|
||||
foreach ($groups as $groupIndex => $group) {
|
||||
$includeMethod = $group['include']['method'] ?? 'all';
|
||||
$includeValues = $group['include']['values'] ?? [];
|
||||
if (!$this->isConditionValid($includeMethod, $includeValues, $methods)) {
|
||||
// Skip validation if show_empty_groups is enabled (for pre-populated empty groups)
|
||||
if (!$showEmptyGroups && !$this->isConditionValid($includeMethod, $includeValues, $methods)) {
|
||||
continue;
|
||||
}
|
||||
if ($mode === 'single' && $renderedGroups > 0) {
|
||||
@@ -542,7 +545,7 @@ class EntitySelectorRenderer
|
||||
$html .= '<i class="icon-plus"></i> ' . $this->trans('Add selection group');
|
||||
$html .= '</button>';
|
||||
$html .= '<span class="mpr-info-wrapper" data-details="' . $this->escapeAttr($groupsTooltip) . '">';
|
||||
$html .= '<span class="mpr-icon icon-info link"></span>';
|
||||
$html .= '<i class="material-icons" style="font-size:16px;color:#5bc0de;cursor:pointer;vertical-align:middle">info</i>';
|
||||
$html .= '</span>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
@@ -604,7 +607,7 @@ class EntitySelectorRenderer
|
||||
$html .= '<span class="result-modifiers-title">' . $this->trans('Result modifiers') . '</span>';
|
||||
$html .= '<span class="result-modifiers-hint">' . $this->trans('(optional)') . '</span>';
|
||||
$html .= '<span class="mpr-info-wrapper" data-details="' . $this->escapeAttr($modifiersTooltip) . '">';
|
||||
$html .= '<span class="mpr-icon icon-info link"></span>';
|
||||
$html .= '<i class="material-icons" style="font-size:16px;color:#5bc0de;cursor:pointer;vertical-align:middle">info</i>';
|
||||
$html .= '</span>';
|
||||
$html .= '</div>';
|
||||
|
||||
@@ -771,7 +774,7 @@ class EntitySelectorRenderer
|
||||
$html .= '<span class="method-info-placeholder">';
|
||||
if (!empty($methodHelp)) {
|
||||
$html .= '<span class="mpr-info-wrapper" data-details="' . $this->escapeAttr($methodHelp) . '">';
|
||||
$html .= '<span class="mpr-icon icon-info link"></span>';
|
||||
$html .= '<i class="material-icons" style="font-size:16px;color:#5bc0de;cursor:pointer;vertical-align:middle">info</i>';
|
||||
$html .= '</span>';
|
||||
}
|
||||
$html .= '</span>';
|
||||
@@ -988,7 +991,7 @@ class EntitySelectorRenderer
|
||||
$html .= '<span class="method-info-placeholder">';
|
||||
if (!empty($methodHelp)) {
|
||||
$html .= '<span class="mpr-info-wrapper" data-details="' . $this->escapeAttr($methodHelp) . '">';
|
||||
$html .= '<span class="mpr-icon icon-info link"></span>';
|
||||
$html .= '<i class="material-icons" style="font-size:16px;color:#5bc0de;cursor:pointer;vertical-align:middle">info</i>';
|
||||
$html .= '</span>';
|
||||
}
|
||||
$html .= '</span>';
|
||||
@@ -1044,7 +1047,9 @@ class EntitySelectorRenderer
|
||||
switch ($valueType) {
|
||||
case 'entity_search':
|
||||
$noItemsPlaceholder = $this->trans('No items selected - use search below');
|
||||
$html .= '<div class="chips-wrapper">';
|
||||
$html .= '<div class="entity-chips ' . $chipsClass . '" data-placeholder="' . $this->escapeAttr($noItemsPlaceholder) . '"></div>';
|
||||
$html .= '</div>';
|
||||
$html .= '<div class="entity-search-box">';
|
||||
$html .= '<i class="icon-search entity-search-icon"></i>';
|
||||
$html .= '<input type="text" class="entity-search-input" placeholder="' . $this->trans('Search by name, reference, ID...') . '" autocomplete="off">';
|
||||
@@ -1324,6 +1329,7 @@ class EntitySelectorRenderer
|
||||
'mode' => $config['mode'] ?? 'multi',
|
||||
'blocks' => $enabledBlocks,
|
||||
'ajaxUrl' => $ajaxUrl,
|
||||
'skipInitialCounts' => !empty($config['skip_initial_counts']) || !empty($config['show_empty_groups']),
|
||||
'trans' => [
|
||||
'all' => $this->trans('All'),
|
||||
'include' => $this->trans('Include'),
|
||||
|
||||
@@ -1214,6 +1214,17 @@ class ProductConditionResolver
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if additional_delivery_times column exists (added in PS 1.7.7.0)
|
||||
static $columnExists = null;
|
||||
if ($columnExists === null) {
|
||||
$columns = Db::getInstance()->executeS('SHOW COLUMNS FROM ' . _DB_PREFIX_ . 'product_shop LIKE \'additional_delivery_times\'');
|
||||
$columnExists = !empty($columns);
|
||||
}
|
||||
|
||||
if (!$columnExists) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($settings)) {
|
||||
$settings = [$settings];
|
||||
}
|
||||
|
||||
@@ -844,7 +844,7 @@ trait ScheduleConditions
|
||||
$html .= '<i class="icon-plus"></i> ' . $this->transScheduleConditions('Add selection group');
|
||||
$html .= '</button>';
|
||||
$html .= '<span class="mpr-info-wrapper" data-details="' . htmlspecialchars($groupsTooltip) . '">';
|
||||
$html .= '<span class="mpr-icon icon-info link"></span>';
|
||||
$html .= '<i class="material-icons" style="font-size:16px;color:#5bc0de;cursor:pointer;vertical-align:middle">info</i>';
|
||||
$html .= '</span>';
|
||||
$html .= '</div>';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user