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:
2026-01-31 15:03:51 +01:00
parent b79a89bbb4
commit 7d79273743
37 changed files with 4620 additions and 1913 deletions

View File

@@ -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
*/

View File

@@ -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'],
];

View File

@@ -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'],
];
}

View File

@@ -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'),

View File

@@ -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];
}

View File

@@ -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>';