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

@@ -89,48 +89,190 @@
updateChipsVisibility: function($chips) {
var self = this;
var trans = this.config.trans || {};
var $picker = $chips.closest('.value-picker');
var $allChips = $chips.find('.entity-chip');
var totalCount = $allChips.length;
var $toggle = $chips.find('.chips-show-more-toggle');
var isExpanded = $chips.hasClass('chips-expanded');
var trans = this.config.trans || {};
// Remove existing toggle if present
$toggle.remove();
if (totalCount <= this.maxVisibleChips) {
// All chips visible, no toggle needed
$allChips.removeClass('chip-hidden');
$chips.removeClass('chips-expanded chips-collapsed');
// If no chips, remove the wrapper entirely
var $existingWrapper = $chips.closest('.chips-wrapper');
if (totalCount === 0) {
if ($existingWrapper.length) {
// Move chips out of wrapper before removing
$existingWrapper.before($chips);
$existingWrapper.remove();
}
return;
}
// We have more than maxVisibleChips
var hiddenCount = totalCount - this.maxVisibleChips;
// Ensure chips wrapper structure exists
this.ensureChipsWrapper($chips);
if (isExpanded) {
// Show all chips
$allChips.removeClass('chip-hidden');
var $wrapper = $chips.closest('.chips-wrapper');
var $toolbar = $wrapper.find('.chips-toolbar');
var $loadMore = $wrapper.find('.chips-load-more');
// Add collapse toggle
var collapseText = trans.show_less || 'Show less';
$chips.append('<span class="chips-show-more-toggle chips-collapse-toggle">' +
'<i class="icon-chevron-up"></i> ' + collapseText + '</span>');
} else {
// Hide chips beyond maxVisibleChips
$allChips.each(function(index) {
if (index >= self.maxVisibleChips) {
$(this).addClass('chip-hidden');
// Get current search filter
var searchTerm = $toolbar.find('.chips-search-input').val() || '';
searchTerm = searchTerm.toLowerCase().trim();
// Filter and paginate chips
var visibleCount = 0;
var filteredCount = 0;
var isExpanded = $chips.hasClass('chips-expanded');
var maxVisible = isExpanded ? 999999 : (this.maxVisibleChips || 12);
$allChips.each(function() {
var $chip = $(this);
var chipName = ($chip.find('.chip-name').text() || '').toLowerCase();
var matchesFilter = !searchTerm || chipName.indexOf(searchTerm) !== -1;
$chip.removeClass('chip-filtered-out chip-paginated-out');
if (!matchesFilter) {
$chip.addClass('chip-filtered-out');
} else {
filteredCount++;
if (filteredCount > maxVisible) {
$chip.addClass('chip-paginated-out');
} else {
$(this).removeClass('chip-hidden');
visibleCount++;
}
}
});
// Update toolbar (always show when we have chips)
$toolbar.addClass('has-chips');
this.updateChipsToolbar($toolbar, totalCount, filteredCount, searchTerm);
// Update load more button
var hiddenByPagination = filteredCount - visibleCount;
if (hiddenByPagination > 0 && !isExpanded) {
var moreText = (trans.show_more || 'Show {count} more').replace('{count}', hiddenByPagination);
$loadMore.html(
'<button type="button" class="btn-load-more">' +
'<i class="icon-chevron-down"></i> ' + moreText +
'</button>'
).show();
} else if (isExpanded && filteredCount > (this.maxVisibleChips || 12)) {
var lessText = trans.show_less || 'Show less';
$loadMore.html(
'<button type="button" class="btn-load-more">' +
'<i class="icon-chevron-up"></i> ' + lessText +
'</button>'
).show();
} else {
$loadMore.hide();
}
},
ensureChipsWrapper: function($chips) {
// Check if already wrapped
if ($chips.closest('.chips-wrapper').length) {
return;
}
var trans = this.config.trans || {};
var $picker = $chips.closest('.value-picker');
// Create wrapper structure - simple inline toolbar
var wrapperHtml = '<div class="chips-wrapper">' +
'<div class="chips-toolbar">' +
'<i class="icon-search"></i>' +
'<input type="text" class="chips-search-input" placeholder="' + (trans.filter || 'Filter') + '...">' +
'<span class="chips-count"></span>' +
'<button type="button" class="btn-chips-clear" title="' + (trans.clear_all || 'Clear all') + '">' +
'<i class="icon-trash"></i> <span class="clear-text">' + (trans.clear_all || 'Clear all') + '</span>' +
'</button>' +
'</div>' +
'<div class="chips-load-more" style="display:none;"></div>' +
'</div>';
var $wrapper = $(wrapperHtml);
// Insert wrapper before chips and move chips inside
$chips.before($wrapper);
$wrapper.find('.chips-toolbar').after($chips);
$wrapper.append($wrapper.find('.chips-load-more'));
// Bind toolbar events
this.bindChipsToolbarEvents($wrapper);
},
bindChipsToolbarEvents: function($wrapper) {
var self = this;
var $chips = $wrapper.find('.entity-chips');
var searchTimeout;
// Search input
$wrapper.on('input', '.chips-search-input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(function() {
// Collapse when searching to show filtered results from start
$chips.removeClass('chips-expanded');
self.updateChipsVisibility($chips);
}, 150);
});
// Clear all button
$wrapper.on('click', '.btn-chips-clear', function() {
var searchTerm = $wrapper.find('.chips-search-input').val() || '';
var $chipsToRemove;
if (searchTerm.trim()) {
// Remove only filtered (visible) chips
$chipsToRemove = $chips.find('.entity-chip:not(.chip-filtered-out)');
} else {
// Remove all chips
$chipsToRemove = $chips.find('.entity-chip');
}
$chipsToRemove.each(function() {
$(this).find('.chip-remove').trigger('click');
});
// Add expand toggle
var moreText = (trans.show_more || 'Show {count} more').replace('{count}', hiddenCount);
$chips.addClass('chips-collapsed').removeClass('chips-expanded');
$chips.append('<span class="chips-show-more-toggle chips-expand-toggle">' +
'<i class="icon-chevron-down"></i> ' + moreText + '</span>');
// Clear search
$wrapper.find('.chips-search-input').val('');
self.updateChipsVisibility($chips);
});
// Load more / show less
$wrapper.on('click', '.btn-load-more', function() {
if ($chips.hasClass('chips-expanded')) {
$chips.removeClass('chips-expanded');
} else {
$chips.addClass('chips-expanded');
}
self.updateChipsVisibility($chips);
});
},
updateChipsToolbar: function($toolbar, totalCount, filteredCount, searchTerm) {
var trans = this.config.trans || {};
var $count = $toolbar.find('.chips-count');
var $clearBtn = $toolbar.find('.btn-chips-clear');
var $clearText = $clearBtn.find('.clear-text');
// Update count display
if (searchTerm) {
$count.addClass('has-filter').html(
'<span class="count-filtered">' + filteredCount + '</span>' +
'<span class="count-separator">/</span>' +
'<span class="count-total">' + totalCount + '</span>'
);
$clearText.text((trans.clear || 'Clear') + ' ' + filteredCount);
} else {
$count.removeClass('has-filter').html(totalCount);
$clearText.text(trans.clear_all || 'Clear all');
}
// Show/hide clear button
if (searchTerm && filteredCount === 0) {
$clearBtn.hide();
} else if (totalCount > 0) {
$clearBtn.show();
} else {
$clearBtn.hide();
}
},
@@ -172,7 +314,10 @@
}
});
// Now load all entities in bulk for each entity type
// Build bulk request: { entityType: [uniqueIds], ... }
var bulkRequest = {};
var hasEntities = false;
Object.keys(entitiesToLoad).forEach(function(entityType) {
var data = entitiesToLoad[entityType];
if (data.ids.length === 0) return;
@@ -182,69 +327,85 @@
return arr.indexOf(id) === index;
});
$.ajax({
url: self.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getTargetEntitiesByIds',
trait: 'EntitySelector',
entity_type: entityType,
ids: JSON.stringify(uniqueIds)
},
success: function(response) {
if (response.success && response.entities) {
// Build a map of id -> entity for quick lookup
var entityMap = {};
response.entities.forEach(function(entity) {
entityMap[entity.id] = entity;
});
bulkRequest[entityType] = uniqueIds;
hasEntities = true;
});
// Update each picker that requested this entity type
data.pickers.forEach(function(pickerData) {
var $picker = pickerData.$picker;
var $chips = $picker.find('.entity-chips');
var $dataInput = $picker.find('.include-values-data, .exclude-values-data');
var validIds = [];
// Skip AJAX if no entities to load
if (!hasEntities) {
return;
}
// Replace loading chips with real data
pickerData.ids.forEach(function(id) {
var $loadingChip = $chips.find('.entity-chip-loading[data-id="' + id + '"]');
if (entityMap[id]) {
var entity = entityMap[id];
validIds.push(entity.id);
// Create real chip
var html = '<span class="entity-chip" data-id="' + self.escapeAttr(entity.id) + '">';
if (entity.image) {
html += '<span class="chip-icon"><img src="' + self.escapeAttr(entity.image) + '" alt=""></span>';
}
html += '<span class="chip-name">' + self.escapeHtml(entity.name) + '</span>';
html += '<button type="button" class="chip-remove" title="Remove"><i class="icon-times"></i></button>';
html += '</span>';
$loadingChip.replaceWith(html);
} else {
// Entity not found, remove loading chip
$loadingChip.remove();
}
});
// Update chips visibility
self.updateChipsVisibility($chips);
// If some entities were not found, update the hidden input
if (validIds.length !== pickerData.ids.length) {
$dataInput.val(JSON.stringify(validIds));
self.serializeAllBlocks();
}
self.updateBlockStatus($picker.closest('.target-block'));
});
}
// Single bulk AJAX call for all entity types
$.ajax({
url: self.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getTargetEntitiesByIdsBulk',
trait: 'EntitySelector',
entities: JSON.stringify(bulkRequest)
},
success: function(response) {
if (!response.success || !response.entities) {
return;
}
});
// Process each entity type's results
Object.keys(entitiesToLoad).forEach(function(entityType) {
var data = entitiesToLoad[entityType];
var entities = response.entities[entityType] || [];
// Build a map of id -> entity for quick lookup
var entityMap = {};
entities.forEach(function(entity) {
entityMap[entity.id] = entity;
});
// Update each picker that requested this entity type
data.pickers.forEach(function(pickerData) {
var $picker = pickerData.$picker;
var $chips = $picker.find('.entity-chips');
var $dataInput = $picker.find('.include-values-data, .exclude-values-data');
var validIds = [];
// Replace loading chips with real data
pickerData.ids.forEach(function(id) {
var $loadingChip = $chips.find('.entity-chip-loading[data-id="' + id + '"]');
if (entityMap[id]) {
var entity = entityMap[id];
validIds.push(entity.id);
// Create real chip
var html = '<span class="entity-chip" data-id="' + self.escapeAttr(entity.id) + '">';
if (entity.image) {
html += '<span class="chip-icon"><img src="' + self.escapeAttr(entity.image) + '" alt=""></span>';
}
html += '<span class="chip-name">' + self.escapeHtml(entity.name) + '</span>';
html += '<button type="button" class="chip-remove" title="Remove"><i class="icon-times"></i></button>';
html += '</span>';
$loadingChip.replaceWith(html);
} else {
// Entity not found, remove loading chip
$loadingChip.remove();
}
});
// Update chips visibility
self.updateChipsVisibility($chips);
// If some entities were not found, update the hidden input
if (validIds.length !== pickerData.ids.length) {
$dataInput.val(JSON.stringify(validIds));
self.serializeAllBlocks();
}
self.updateBlockStatus($picker.closest('.target-block'));
});
});
}
});
},
@@ -1021,6 +1182,53 @@
this.$wrapper.find('.target-block.active .selection-group').each(function() {
self.updateGroupCounts($(this));
});
},
/**
* Fetch category names by IDs and add chips to the picker
* Used when adding selections from the tree modal
* @param {jQuery} $picker - Picker element
* @param {Array} ids - Category IDs to add
* @param {string} entityType - 'categories' or 'cms_categories'
* @param {Function} callback - Called when done
*/
fetchCategoryNamesAndAddChips: function($picker, ids, entityType, callback) {
var self = this;
if (!ids || ids.length === 0) {
if (typeof callback === 'function') {
callback();
}
return;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getTargetEntitiesByIds',
trait: 'EntitySelector',
entity_type: entityType,
ids: JSON.stringify(ids)
},
success: function(response) {
if (response.success && response.entities) {
response.entities.forEach(function(entity) {
self.addSelectionNoUpdate($picker, entity.id, entity.name, entity);
});
}
if (typeof callback === 'function') {
callback();
}
},
error: function() {
if (typeof callback === 'function') {
callback();
}
}
});
}
};

View File

@@ -204,6 +204,11 @@
$.extend(instance, mixins.preview);
}
// Merge tree mixin
if (mixins.tree) {
$.extend(instance, mixins.tree);
}
return instance;
}

View File

@@ -45,10 +45,10 @@
html += '<i class="icon-sort-alpha-asc"></i>';
html += '</button>';
// View mode selector
// View mode selector - Tree option always present, shown for categories
html += '<select class="view-mode-select" title="View mode">';
html += '<option value="list">' + (trans.view_list || 'List') + '</option>';
html += '<option value="tree" class="tree-view-option" disabled hidden>' + (trans.view_tree || 'Tree') + '</option>';
html += '<option value="tree" class="tree-view-option">' + (trans.view_tree || 'Tree') + '</option>';
html += '<option value="cols-2">2 ' + (trans.cols || 'cols') + '</option>';
html += '<option value="cols-3">3 ' + (trans.cols || 'cols') + '</option>';
html += '<option value="cols-4">4 ' + (trans.cols || 'cols') + '</option>';
@@ -347,10 +347,8 @@
html += '<span class="load-more-of">' + (trans.of || 'of') + ' <span class="remaining-count">0</span> ' + (trans.remaining || 'remaining') + '</span>';
html += '<button type="button" class="btn-load-more"><i class="icon-plus"></i></button>';
html += '</div>';
html += '<div class="dropdown-footer-actions">';
html += '<button type="button" class="btn-cancel-dropdown">' + (trans.cancel || 'Cancel') + ' <kbd>Esc</kbd></button>';
html += '<button type="button" class="btn-confirm-dropdown"><i class="icon-check"></i> ' + (trans.done || 'Done') + ' <kbd>⏎</kbd></button>';
html += '</div>';
html += '<button type="button" class="btn-cancel-dropdown"><i class="icon-times"></i> ' + (trans.cancel || 'Cancel') + ' <kbd>Esc</kbd></button>';
html += '<button type="button" class="btn-confirm-dropdown"><i class="icon-check"></i> ' + (trans.save || 'Save') + ' <kbd></kbd></button>';
html += '</div>';
html += '</div>';
@@ -403,6 +401,9 @@
maxHeight: maxHeight,
zIndex: 10000
});
// Show the dropdown
this.$dropdown.addClass('show');
}
};

View File

@@ -45,11 +45,6 @@
this.$wrapper.on('click', '.target-block-tab .tab-badge', function(e) {
e.stopPropagation();
e.preventDefault();
console.log('[EntitySelector] Tab badge clicked', {
hasLoading: $(this).hasClass('loading'),
hasPopoverOpen: $(this).hasClass('popover-open'),
previewData: $(this).closest('.target-block-tab').data('previewData')
});
var $tab = $(this).closest('.target-block-tab');
var $badge = $(this);
@@ -65,7 +60,6 @@
this.$wrapper.on('click', '.condition-match-count.clickable', function(e) {
e.stopPropagation();
e.preventDefault();
console.log('[EntitySelector] Condition match count clicked', this);
var $badge = $(this);
@@ -80,7 +74,6 @@
this.$wrapper.on('click', '.group-count-badge.clickable', function(e) {
e.stopPropagation();
e.preventDefault();
console.log('[EntitySelector] Group count badge clicked', this);
var $badge = $(this);
@@ -91,6 +84,20 @@
}
});
// Total count badge click for summary popover
this.$wrapper.on('click', '.trait-total-count', function(e) {
e.stopPropagation();
e.preventDefault();
var $badge = $(this);
if ($badge.hasClass('popover-open')) {
self.hidePreviewPopover();
} else {
self.showTotalPreviewPopover($badge);
}
});
// Close popover when clicking outside
$(document).on('click', function(e) {
if (!$(e.target).closest('.target-preview-popover').length &&
@@ -99,7 +106,8 @@
!$(e.target).closest('.group-count-badge').length &&
!$(e.target).closest('.group-modifiers').length &&
!$(e.target).closest('.group-preview-badge').length &&
!$(e.target).closest('.toggle-count.clickable').length) {
!$(e.target).closest('.toggle-count.clickable').length &&
!$(e.target).closest('.trait-total-count').length) {
self.hidePreviewPopover();
}
});
@@ -108,7 +116,8 @@
this.$wrapper.on('click', '.condition-trait-header', function(e) {
if ($(e.target).closest('.target-block-tabs').length ||
$(e.target).closest('.trait-header-actions').length ||
$(e.target).closest('.prestashop-switch').length) {
$(e.target).closest('.prestashop-switch').length ||
$(e.target).closest('.trait-total-count').length) {
return;
}
var $body = self.$wrapper.find('.condition-trait-body');
@@ -881,6 +890,19 @@
searchEntity: searchEntity
};
// Initialize pending selections from current chips
var $chips = $picker.find('.entity-chips');
self.pendingSelections = [];
$chips.find('.entity-chip').each(function() {
self.pendingSelections.push({
id: $(this).data('id'),
name: $(this).find('.chip-name').text(),
data: $(this).data()
});
});
self.pendingPicker = $picker;
self.pendingRow = section === 'include' ? $group.find('.group-include') : $group.find('.exclude-row[data-exclude-index="' + excludeIndex + '"]');
self.searchOffset = 0;
self.searchQuery = $(this).val().trim();
@@ -892,7 +914,9 @@
self.positionDropdown($(this));
if (self.viewMode === 'tree') {
// For tree view mode on categories, load category tree instead of search
if (self.viewMode === 'tree' && (searchEntity === 'categories' || searchEntity === 'cms_categories')) {
self.loadCategoryTree();
return;
}
@@ -955,6 +979,10 @@
// Dropdown item click
this.$dropdown.on('click', '.dropdown-item', function(e) {
e.preventDefault();
// Blur any focused input so Ctrl+A works for select all
$(document.activeElement).filter('input, textarea').blur();
var $item = $(this);
var id = $item.data('id');
var name = $item.data('name');
@@ -1032,6 +1060,38 @@
e.preventDefault();
if (!self.activeGroup) return;
// Handle tree view - use pending selections
if (self.viewMode === 'tree') {
if (!self.pendingSelections) self.pendingSelections = [];
// Select all visible (not filtered-out) tree items
var $visibleTreeItems = self.$dropdown.find('.tree-item:not(.filtered-out)');
$visibleTreeItems.each(function() {
var $item = $(this);
var id = parseInt($item.data('id'), 10);
var name = $item.data('name');
if (!$item.hasClass('selected')) {
$item.addClass('selected');
var exists = self.pendingSelections.some(function(s) {
return parseInt(s.id, 10) === id;
});
if (!exists) {
self.pendingSelections.push({ id: id, name: name, data: $item.data() });
}
}
});
// Update count display
var selectedCount = self.$dropdown.find('.tree-item.selected').length;
var totalCount = self.$dropdown.find('.tree-item').length;
var entityType = self.$dropdown.find('.category-tree').data('entity-type') || 'categories';
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
self.$dropdown.find('.results-count').text(totalCount + ' ' + categoryLabel + ' (' + selectedCount + ' selected)');
return;
}
// Handle list view
var $block = self.$wrapper.find('.target-block[data-block-type="' + self.activeGroup.blockType + '"]');
var $group = $block.find('.selection-group[data-group-index="' + self.activeGroup.groupIndex + '"]');
var $picker;
@@ -1067,6 +1127,20 @@
e.preventDefault();
if (!self.activeGroup) return;
// Handle tree view - clear pending selections
if (self.viewMode === 'tree') {
self.pendingSelections = [];
self.$dropdown.find('.tree-item').removeClass('selected');
// Update count display
var totalCount = self.$dropdown.find('.tree-item').length;
var entityType = self.$dropdown.find('.category-tree').data('entity-type') || 'categories';
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
self.$dropdown.find('.results-count').text(totalCount + ' ' + categoryLabel);
return;
}
// Handle list view
var $block = self.$wrapper.find('.target-block[data-block-type="' + self.activeGroup.blockType + '"]');
var $group = $block.find('.selection-group[data-group-index="' + self.activeGroup.groupIndex + '"]');
var $picker;
@@ -1087,15 +1161,43 @@
self.serializeAllBlocks($row);
});
// Done/confirm
// Save - commit pending selections to chips
this.$dropdown.on('click', '.btn-confirm-dropdown', function(e) {
e.preventDefault();
if (self.pendingPicker && self.pendingSelections) {
var $chips = self.pendingPicker.find('.entity-chips');
// Clear existing chips
$chips.empty();
// Add chips for all pending selections
self.pendingSelections.forEach(function(sel) {
self.addSelectionNoUpdate(self.pendingPicker, sel.id, sel.name, sel.data);
});
self.updateChipsVisibility($chips);
// Serialize to hidden input
if (self.pendingRow) {
self.serializeAllBlocks(self.pendingRow);
}
}
self.pendingSelections = null;
self.pendingPicker = null;
self.pendingRow = null;
self.hideDropdown();
});
// Cancel
// Cancel - discard pending selections (no changes to chips)
this.$dropdown.on('click', '.btn-cancel-dropdown', function(e) {
e.preventDefault();
// Just discard pending - chips remain unchanged
self.pendingSelections = null;
self.pendingPicker = null;
self.pendingRow = null;
self.hideDropdown();
});
@@ -1128,21 +1230,6 @@
self.refreshSearch();
});
// View mode change
this.$dropdown.on('change', '.view-mode-select', function() {
var value = $(this).val();
self.viewMode = value;
self.$dropdown.removeClass('view-list view-tree view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8');
self.$dropdown.addClass('view-' + value);
var searchEntity = self.activeGroup ? self.activeGroup.searchEntity : '';
if (value === 'tree' && (searchEntity === 'categories' || searchEntity === 'cms_categories')) {
self.loadCategoryTree();
} else if (value !== 'tree') {
self.performSearch();
}
});
// Tree view: Toggle expand/collapse
this.$dropdown.on('click', '.category-tree .tree-toggle', function(e) {
e.stopPropagation();
@@ -1161,32 +1248,22 @@
}
});
// Tree view: Item click (select/deselect)
// Tree view: Item click (select/deselect) - PENDING mode
this.$dropdown.on('click', '.category-tree .tree-item', function(e) {
if ($(e.target).closest('.tree-toggle, .btn-select-children').length) {
if ($(e.target).closest('.tree-toggle, .btn-select-children, .tree-count').length) {
return;
}
// Blur any focused input so Ctrl+A works for select all
$(document.activeElement).filter('input, textarea').blur();
var $item = $(this);
var id = $item.data('id');
var id = parseInt($item.data('id'), 10);
var name = $item.data('name');
var isSelected = $item.hasClass('selected');
if (!self.activeGroup) return;
var $block = self.$wrapper.find('.target-block[data-block-type="' + self.activeGroup.blockType + '"]');
var $group = $block.find('.selection-group[data-group-index="' + self.activeGroup.groupIndex + '"]');
var $picker;
var $row;
if (self.activeGroup.section === 'include') {
$picker = $group.find('.include-picker');
$row = $group.find('.group-include');
} else {
var $excludeRow = $group.find('.exclude-row[data-exclude-index="' + self.activeGroup.excludeIndex + '"]');
$picker = $excludeRow.find('.exclude-picker');
$row = $excludeRow;
}
if (!self.pendingSelections) self.pendingSelections = [];
var $allItems = self.$dropdown.find('.tree-item');
@@ -1200,27 +1277,43 @@
};
if (isSelected) {
self.removeSelection($picker, id);
$item.toggleClass('selected');
self.serializeAllBlocks($row);
updateCount();
// Remove from pending
self.pendingSelections = self.pendingSelections.filter(function(s) {
return parseInt(s.id, 10) !== id;
});
$item.removeClass('selected');
} else {
var currentSelection = self.getCurrentSingleSelection();
if (currentSelection) {
var newEntityType = self.activeGroup.blockType;
self.showReplaceConfirmation(currentSelection, { name: name, entityType: newEntityType }, function() {
self.$dropdown.find('.tree-item.selected').removeClass('selected');
self.addSelection($picker, id, name, $item.data());
$item.addClass('selected');
self.serializeAllBlocks($row);
updateCount();
// Add to pending
var exists = self.pendingSelections.some(function(s) {
return parseInt(s.id, 10) === id;
});
if (!exists) {
self.pendingSelections.push({
id: id,
name: name,
data: $item.data()
});
} else {
self.addSelection($picker, id, name, $item.data());
$item.toggleClass('selected');
self.serializeAllBlocks($row);
updateCount();
}
$item.addClass('selected');
}
updateCount();
});
// Tree view: Product/page count click - show preview
this.$dropdown.on('click', '.category-tree .tree-count.clickable', function(e) {
e.preventDefault();
e.stopPropagation();
var $count = $(this);
var categoryId = $count.data('category-id');
var $item = $count.closest('.tree-item');
var categoryName = $item.data('name');
var entityType = self.$dropdown.find('.category-tree').data('entity-type') || 'categories';
if ($count.hasClass('popover-open')) {
self.hidePreviewPopover();
} else {
self.showCategoryItemsPreview($count, categoryId, categoryName, entityType);
}
});
@@ -1342,6 +1435,11 @@
clearTimeout(self.refineTimeout);
self.refineTimeout = setTimeout(function() {
// For tree view, filter client-side instead of server refresh
if (self.viewMode === 'tree') {
self.filterCategoryTree(query);
return;
}
self.refreshSearch();
}, 300);
});
@@ -1352,6 +1450,11 @@
self.refineQuery = '';
self.$dropdown.find('.refine-input').val('');
$(this).hide();
// For tree view, filter client-side instead of server refresh
if (self.viewMode === 'tree') {
self.filterCategoryTree('');
return;
}
self.refreshSearch();
});
@@ -1589,6 +1692,7 @@
// View mode select change
this.$dropdown.on('change', '.view-mode-select', function() {
var mode = $(this).val();
var prevMode = self.viewMode;
self.viewMode = mode;
// Remove all view mode classes and add the new one
@@ -1596,12 +1700,18 @@
.removeClass('view-list view-tree view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8')
.addClass('view-' + mode.replace('cols-', 'cols-'));
// For tree view, load the category tree
if (mode === 'tree') {
// For tree view, load the category tree (only for categories/cms_categories)
var searchEntity = self.activeGroup ? self.activeGroup.searchEntity : '';
if (mode === 'tree' && (searchEntity === 'categories' || searchEntity === 'cms_categories')) {
self.loadCategoryTree();
} else {
// Re-render current results with new view mode
self.renderSearchResults(false);
} else if (mode !== 'tree') {
// If switching FROM tree mode, need to refresh search to load data
if (prevMode === 'tree') {
self.refreshSearch();
} else {
// Re-render current results with new view mode
self.renderSearchResults(false);
}
}
});
@@ -1654,16 +1764,21 @@
$(document).on('keydown', function(e) {
if (!self.$dropdown || !self.$dropdown.hasClass('show')) return;
// Ctrl+A / Cmd+A - Select All
// Allow default behavior in input/textarea fields
var isInputFocused = $(document.activeElement).is('input, textarea');
// Ctrl+A / Cmd+A - Select All (only when not in input)
if ((e.ctrlKey || e.metaKey) && e.keyCode === 65) {
if (isInputFocused) return; // Let browser select text
e.preventDefault();
e.stopPropagation();
self.$dropdown.find('.btn-select-all').trigger('click');
return false;
}
// Ctrl+D / Cmd+D - Clear/Deselect all
// Ctrl+D / Cmd+D - Clear/Deselect all (only when not in input)
if ((e.ctrlKey || e.metaKey) && e.keyCode === 68) {
if (isInputFocused) return; // Let browser handle
e.preventDefault();
e.stopPropagation();
self.$dropdown.find('.btn-clear-selection').trigger('click');

View File

@@ -104,7 +104,9 @@
},
updateFilterPanelForEntity: function(entityType) {
if (!this.$dropdown) return;
if (!this.$dropdown) {
return;
}
var $panel = this.$dropdown.find('.filter-panel');
@@ -115,12 +117,20 @@
$panel.find('.filter-row[data-entity="' + entityType + '"]').show();
$panel.find('.filter-row-entity-' + entityType.replace('_', '-')).show();
// Enable/disable tree view option
var $treeOption = this.$dropdown.find('.tree-view-option');
if (entityType === 'categories' || entityType === 'cms_categories') {
$treeOption.prop('disabled', false).show();
} else {
$treeOption.prop('disabled', true).hide();
// Show/hide tree view option based on entity type
var isCategory = (entityType === 'categories' || entityType === 'cms_categories');
this.$dropdown.find('.tree-view-option').toggle(isCategory);
// Default to tree view for categories (only if currently on list mode)
if (isCategory && this.viewMode === 'list') {
this.viewMode = 'tree';
this.$dropdown.find('.view-mode-select').val('tree');
this.$dropdown.removeClass('view-list view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8').addClass('view-tree');
} else if (!isCategory && this.viewMode === 'tree') {
// If switching away from categories while in tree mode, switch to list
this.viewMode = 'list';
this.$dropdown.find('.view-mode-select').val('list');
this.$dropdown.removeClass('view-tree view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8').addClass('view-list');
}
},

View File

@@ -200,6 +200,8 @@
updateTabBadges: function() {
var self = this;
// Collect all block types with data and set loading state
var blockTypesWithData = [];
this.$wrapper.find('.target-block-tab').each(function() {
var $tab = $(this);
var blockType = $tab.data('blockType');
@@ -216,9 +218,7 @@
$tab.append('<span class="tab-badge loading"><i class="icon-spinner icon-spin"></i></span>');
}
$tab.addClass('has-data');
// Fetch real product count
self.fetchProductCount(blockType, $tab);
blockTypesWithData.push(blockType);
} else {
$badge.remove();
$tab.removeClass('has-data');
@@ -227,6 +227,11 @@
// Update target switch state based on whether any data exists
this.updateTargetSwitchState();
// Fetch all counts in a single bulk request
if (blockTypesWithData.length > 0) {
this.fetchAllCounts(blockTypesWithData);
}
},
updateTargetSwitchState: function() {
@@ -252,6 +257,100 @@
}
},
/**
* Fetch counts for all block types in a single bulk AJAX request
* @param {Array} blockTypes - Array of block type strings to fetch counts for
*/
fetchAllCounts: function(blockTypes) {
var self = this;
// Read saved data from hidden input
var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]');
var savedData = {};
try {
savedData = JSON.parse($hiddenInput.val() || '{}');
} catch (e) {
savedData = {};
}
// Build conditions object for all requested block types
var conditions = {};
blockTypes.forEach(function(blockType) {
var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : [];
if (groups.length > 0) {
conditions[blockType] = { groups: groups };
}
});
// If no valid conditions, remove loading spinners
if (Object.keys(conditions).length === 0) {
blockTypes.forEach(function(blockType) {
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
$tab.find('.tab-badge').remove();
$tab.removeClass('has-data');
});
return;
}
// Single bulk AJAX request for all counts
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewEntitySelectorBulk',
trait: 'EntitySelector',
conditions: JSON.stringify(conditions)
},
success: function(response) {
if (response.success && response.counts) {
// Update each tab with its count
Object.keys(response.counts).forEach(function(blockType) {
var count = response.counts[blockType];
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
var $badge = $tab.find('.tab-badge');
if ($badge.length) {
$badge.removeClass('loading').html('<i class="icon-eye"></i> ' + count);
// Store preview data for later popover use
$tab.data('previewData', { count: count, success: true });
}
});
// Handle any block types not in response (set count to 0 or remove badge)
blockTypes.forEach(function(blockType) {
if (!(blockType in response.counts)) {
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
$tab.find('.tab-badge').remove();
$tab.removeClass('has-data');
}
});
self.updateHeaderTotalCount();
} else {
console.error('[EntitySelector] Bulk preview failed:', response.error || 'Unknown error');
// Remove loading spinners on error
blockTypes.forEach(function(blockType) {
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
$tab.find('.tab-badge').remove();
});
}
},
error: function(xhr, status, error) {
console.error('[EntitySelector] Bulk AJAX error:', status, error);
// Remove loading spinners on error
blockTypes.forEach(function(blockType) {
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
$tab.find('.tab-badge').remove();
});
}
});
},
/**
* Fetch count for a single block type (legacy, used for single updates)
*/
fetchProductCount: function(blockType, $tab) {
var self = this;
var data = {};
@@ -310,10 +409,12 @@
// Update header total count
self.updateHeaderTotalCount();
} else {
console.error('[EntitySelector] Preview failed for', blockType, ':', response.error || 'Unknown error');
$tab.find('.tab-badge').remove();
}
},
error: function() {
error: function(xhr, status, error) {
console.error('[EntitySelector] AJAX error for', blockType, ':', status, error);
$tab.find('.tab-badge').remove();
self.updateHeaderTotalCount();
}
@@ -337,7 +438,8 @@
var $totalBadge = this.$wrapper.find('.trait-total-count');
if (total > 0) {
$totalBadge.text(total).show();
$totalBadge.find('.count-value').text(total);
$totalBadge.show();
} else {
$totalBadge.hide();
}
@@ -710,40 +812,98 @@
return false;
},
/**
* Update all condition counts using a single bulk AJAX request
*/
updateAllConditionCounts: function() {
var self = this;
var conditions = {};
var conditionElements = {};
var conditionIndex = 0;
// Collect all conditions from all active groups
this.$wrapper.find('.target-block.active .selection-group').each(function() {
self.updateGroupCounts($(this));
var $group = $(this);
var $block = $group.closest('.target-block');
var blockType = $block.data('blockType') || 'products';
// Process include row
var $include = $group.find('.group-include');
if ($include.length) {
var includeData = self.getConditionData($include, blockType);
if (includeData) {
var id = 'c' + conditionIndex++;
conditions[id] = includeData.condition;
conditionElements[id] = includeData.$countEl;
}
}
// Process exclude rows
$group.find('.exclude-row').each(function() {
var excludeData = self.getConditionData($(this), blockType);
if (excludeData) {
var id = 'c' + conditionIndex++;
conditions[id] = excludeData.condition;
conditionElements[id] = excludeData.$countEl;
}
});
});
},
updateGroupCounts: function($group) {
var self = this;
// Update include count
var $include = $group.find('.group-include');
if ($include.length) {
this.updateConditionCount($include);
// If no conditions, nothing to do
if (Object.keys(conditions).length === 0) {
return;
}
// Update each exclude row count
$group.find('.exclude-row').each(function() {
self.updateConditionCount($(this));
// Make single bulk AJAX request
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'countConditionMatchesBulk',
trait: 'EntitySelector',
conditions: JSON.stringify(conditions)
},
success: function(response) {
if (response && response.success && response.counts) {
// Update each count element with its result
Object.keys(response.counts).forEach(function(id) {
var count = response.counts[id] || 0;
var $countEl = conditionElements[id];
if ($countEl && $countEl.length) {
$countEl.removeClass('no-matches clickable');
if (count === 0) {
$countEl.find('.preview-count').text(count);
$countEl.addClass('no-matches').show();
} else {
$countEl.find('.preview-count').text(count);
$countEl.addClass('clickable').show();
}
}
});
}
// Note: Group totals are updated on-demand when user interacts, not on initial load
},
error: function() {
// Hide all count elements on error
Object.keys(conditionElements).forEach(function(id) {
var $countEl = conditionElements[id];
if ($countEl && $countEl.length) {
$countEl.hide().removeClass('clickable');
}
});
}
});
// Update group total count (include - excludes)
this.updateGroupTotalCount($group);
},
updateConditionCount: function($row) {
var self = this;
var trans = this.config.trans || {};
// Find the count element - in method-selector-wrapper for include, in exclude-header-row for exclude
/**
* Extract condition data from a row for bulk counting
*/
getConditionData: function($row, blockType) {
var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row .condition-match-count').first();
if (!$countEl.length) return;
if (!$countEl.length) return null;
// Determine if this is an include or exclude row
var isExclude = $row.hasClass('exclude-row');
var $methodSelect = isExclude
? $row.find('.exclude-method-select')
@@ -752,10 +912,9 @@
var method = $methodSelect.val();
if (!method) {
$countEl.hide();
return;
return null;
}
// Get the picker and extract values
var $picker = isExclude
? $row.find('.exclude-picker')
: $row.find('.include-picker');
@@ -767,9 +926,87 @@
var hasNoValues = !values ||
(Array.isArray(values) && values.length === 0) ||
(typeof values === 'object' && !Array.isArray(values) && (
// For combination_attributes, check if attributes object is empty
(valueType === 'combination_attributes' && values.attributes !== undefined && Object.keys(values.attributes).length === 0) ||
// For other objects, check if completely empty
(valueType !== 'combination_attributes' && Object.keys(values).length === 0)
));
if (valueType !== 'none' && valueType !== 'boolean' && hasNoValues) {
$countEl.hide();
return null;
}
// Show loading spinner
$countEl.find('.preview-count').html('<i class="icon-spinner icon-spin"></i>');
$countEl.removeClass('clickable no-matches').show();
// Store condition data on badge for popover
$countEl.data('conditionData', {
method: method,
values: values,
blockType: blockType,
isExclude: isExclude
});
return {
condition: {
method: method,
values: values,
block_type: blockType
},
$countEl: $countEl
};
},
updateGroupCounts: function($group) {
var self = this;
var $block = $group.closest('.target-block');
var blockType = $block.data('blockType') || 'products';
// Update include count
var $include = $group.find('.group-include');
if ($include.length) {
this.updateConditionCount($include, blockType);
}
// Update each exclude row count
$group.find('.exclude-row').each(function() {
self.updateConditionCount($(this), blockType);
});
// Update group total count (include - excludes)
this.updateGroupTotalCount($group);
},
/**
* Update a single condition count (used for individual updates after user changes)
*/
updateConditionCount: function($row, blockType) {
var self = this;
var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row .condition-match-count').first();
if (!$countEl.length) return;
var isExclude = $row.hasClass('exclude-row');
var $methodSelect = isExclude
? $row.find('.exclude-method-select')
: $row.find('.include-method-select');
var method = $methodSelect.val();
if (!method) {
$countEl.hide();
return;
}
var $picker = isExclude
? $row.find('.exclude-picker')
: $row.find('.include-picker');
var valueType = $picker.data('valueType') || 'none';
var values = this.getPickerValues($picker, valueType);
var hasNoValues = !values ||
(Array.isArray(values) && values.length === 0) ||
(typeof values === 'object' && !Array.isArray(values) && (
(valueType === 'combination_attributes' && values.attributes !== undefined && Object.keys(values.attributes).length === 0) ||
(valueType !== 'combination_attributes' && Object.keys(values).length === 0)
));
if (valueType !== 'none' && valueType !== 'boolean' && hasNoValues) {
@@ -777,15 +1014,14 @@
return;
}
// Get block type
var $block = $row.closest('.target-block');
var blockType = $block.data('blockType') || 'products';
if (!blockType) {
var $block = $row.closest('.target-block');
blockType = $block.data('blockType') || 'products';
}
// Show loading
$countEl.find('.preview-count').html('<i class="icon-spinner icon-spin"></i>');
$countEl.removeClass('clickable no-matches').show();
// Store condition data on badge for popover
$countEl.data('conditionData', {
method: method,
values: values,
@@ -813,7 +1049,6 @@
$countEl.find('.preview-count').text(count);
$countEl.addClass('no-matches').show();
} else {
// Show count, make clickable for preview popover
$countEl.find('.preview-count').text(count);
$countEl.addClass('clickable').show();
}
@@ -917,6 +1152,17 @@
html += '</button>';
$excludesDiv.addClass('has-excludes').html(html);
// Enhance the first exclude method select with styled dropdown
var $firstRow = $excludesDiv.find('.exclude-row[data-exclude-index="0"]');
var $firstSelect = $firstRow.find('.exclude-method-select');
this.enhanceMethodSelect($firstSelect);
// Update method info placeholder for initial selection
var blockType = $block.data('blockType');
var initialMethod = $firstSelect.val();
this.updateMethodInfoPlaceholder($firstRow.find('.method-selector-wrapper'), initialMethod, blockType);
this.updateMethodSelectorLock($group, true);
this.serializeAllBlocks();
},
@@ -937,7 +1183,13 @@
// Enhance the exclude method select with styled dropdown
var $newRow = $container.find('.exclude-row[data-exclude-index="' + excludeIndex + '"]');
this.enhanceMethodSelect($newRow.find('.exclude-method-select'));
var $newSelect = $newRow.find('.exclude-method-select');
this.enhanceMethodSelect($newSelect);
// Update method info placeholder for initial selection
var blockType = $block.data('blockType');
var initialMethod = $newSelect.val();
this.updateMethodInfoPlaceholder($newRow.find('.method-selector-wrapper'), initialMethod, blockType);
this.serializeAllBlocks();
},

View File

@@ -844,7 +844,7 @@
}
$wrapper.addClass('selector-locked');
if (!$wrapper.find('.mpr-info-wrapper').length) {
if (!$wrapper.find('.lock-indicator').length) {
var lockHtml = '<span class="mpr-info-wrapper lock-indicator">' +
'<i class="icon-lock"></i>' +
'<span class="mpr-tooltip">' +

View File

@@ -1091,6 +1091,145 @@
});
},
// =========================================================================
// CATEGORY ITEMS PREVIEW (products/pages in a category)
// =========================================================================
showCategoryItemsPreview: function($badge, categoryId, categoryName, entityType) {
var self = this;
this.hidePreviewPopover();
$badge.addClass('popover-open loading');
this.$activeBadge = $badge;
var isProducts = (entityType === 'categories');
var entityLabelPlural = isProducts ? 'products' : 'pages';
var action = isProducts ? 'previewCategoryProducts' : 'previewCategoryPages';
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: action,
trait: 'EntitySelector',
category_id: categoryId,
limit: 10
},
success: function(response) {
$badge.removeClass('loading');
if (response.success) {
self.createPreviewPopover({
$badge: $badge,
items: response.items || [],
totalCount: response.count || 0,
hasMore: response.hasMore || false,
entityLabel: entityLabelPlural,
previewType: 'category-items',
context: { categoryId: categoryId, categoryName: categoryName, entityType: entityType },
onLoadMore: function($btn) {
self.loadMoreCategoryItems($btn);
},
onFilter: function(query) {
self.filterCategoryItems(query);
}
});
} else {
$badge.removeClass('popover-open');
self.$activeBadge = null;
}
},
error: function() {
$badge.removeClass('loading popover-open');
self.$activeBadge = null;
}
});
},
loadMoreCategoryItems: function($btn) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.categoryId) return;
var isProducts = (ctx.entityType === 'categories');
var action = isProducts ? 'previewCategoryProducts' : 'previewCategoryPages';
$btn.prop('disabled', true).find('i').addClass('icon-spin');
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: action,
trait: 'EntitySelector',
category_id: ctx.categoryId,
offset: this.previewOffset,
limit: 10,
query: this.previewFilterQuery || ''
},
success: function(response) {
$btn.prop('disabled', false).find('i').removeClass('icon-spin');
if (response.success && response.items) {
self.appendPreviewItems(response.items);
self.previewOffset += response.items.length;
if (!response.hasMore) {
$btn.hide();
}
}
},
error: function() {
$btn.prop('disabled', false).find('i').removeClass('icon-spin');
}
});
},
filterCategoryItems: function(query) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.categoryId) {
self.showFilterLoading(false);
return;
}
var isProducts = (ctx.entityType === 'categories');
var action = isProducts ? 'previewCategoryProducts' : 'previewCategoryPages';
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: action,
trait: 'EntitySelector',
category_id: ctx.categoryId,
query: query,
limit: 10
},
success: function(response) {
self.showFilterLoading(false);
if (response.success) {
self.replacePreviewItems(response.items || [], response.count || 0, response.hasMore || false);
self.previewOffset = response.items ? response.items.length : 0;
self.previewFilterQuery = query;
}
},
error: function() {
self.showFilterLoading(false);
}
});
},
// =========================================================================
// PATTERN PREVIEW MODAL (for regex/pattern matching)
// =========================================================================
@@ -1214,6 +1353,110 @@
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
// =========================================================================
// TOTAL COUNT PREVIEW (Header total badge click)
// =========================================================================
/**
* Show preview popover for total count badge
* Displays a summary of all entity types with their counts
*/
showTotalPreviewPopover: function($badge) {
console.log('[EntitySelector] showTotalPreviewPopover called', { badge: $badge[0] });
var self = this;
var trans = this.config.trans || {};
this.hidePreviewPopover();
$badge.addClass('popover-open');
this.$activeBadge = $badge;
// Collect all entity types with data
var summaryItems = [];
console.log('[EntitySelector] Looking for tabs with data...');
this.$wrapper.find('.target-block-tab.has-data').each(function() {
var $tab = $(this);
var blockType = $tab.data('blockType');
var $tabBadge = $tab.find('.tab-badge');
var countText = $tabBadge.text().replace(/[^0-9]/g, '');
var count = parseInt(countText, 10) || 0;
if (count > 0) {
var blockConfig = self.config.blocks && self.config.blocks[blockType] ? self.config.blocks[blockType] : {};
var icon = $tab.find('.tab-label').prev('i').attr('class') || 'icon-cube';
var label = $tab.find('.tab-label').text() || blockType;
summaryItems.push({
blockType: blockType,
label: label,
icon: icon,
count: count
});
}
});
console.log('[EntitySelector] Summary items collected:', summaryItems);
// Build popover HTML
var totalCount = parseInt($badge.find('.count-value').text(), 10) || 0;
console.log('[EntitySelector] Building popover, totalCount:', totalCount);
var popoverHtml = '<div class="target-preview-popover total-preview-popover">';
popoverHtml += '<div class="preview-popover-header">';
popoverHtml += '<span class="preview-popover-title">' + (trans.total_summary || 'Selection Summary') + '</span>';
popoverHtml += '<span class="preview-popover-count">' + totalCount + ' ' + (trans.total_items || 'total items') + '</span>';
popoverHtml += '</div>';
popoverHtml += '<div class="preview-popover-body">';
popoverHtml += '<ul class="total-summary-list">';
for (var i = 0; i < summaryItems.length; i++) {
var item = summaryItems[i];
popoverHtml += '<li class="total-summary-item" data-block-type="' + item.blockType + '">';
popoverHtml += '<i class="' + self.escapeAttr(item.icon) + '"></i>';
popoverHtml += '<span class="summary-item-label">' + self.escapeHtml(item.label) + '</span>';
popoverHtml += '<span class="summary-item-count">' + item.count + '</span>';
popoverHtml += '</li>';
}
popoverHtml += '</ul>';
popoverHtml += '</div>';
popoverHtml += '</div>';
var $popover = $(popoverHtml);
this.$previewPopover = $popover;
// Click on item to switch to that tab
$popover.on('click', '.total-summary-item', function() {
var blockType = $(this).data('blockType');
self.hidePreviewPopover();
self.switchToBlock(blockType);
});
// Position popover
$('body').append($popover);
var badgeOffset = $badge.offset();
var badgeHeight = $badge.outerHeight();
var popoverWidth = $popover.outerWidth();
$popover.css({
position: 'absolute',
top: badgeOffset.top + badgeHeight + 5,
left: badgeOffset.left - (popoverWidth / 2) + ($badge.outerWidth() / 2),
zIndex: 10000
});
// Adjust if off screen
var windowWidth = $(window).width();
var popoverRight = $popover.offset().left + popoverWidth;
if (popoverRight > windowWidth - 10) {
$popover.css('left', windowWidth - popoverWidth - 10);
}
if ($popover.offset().left < 10) {
$popover.css('left', 10);
}
$popover.hide().fadeIn(150);
}
};

View File

@@ -475,331 +475,9 @@
this.$dropdown.find('.btn-show-history').prop('disabled', !hasHistory);
},
/**
* Load and display category tree view
*/
loadCategoryTree: function() {
var self = this;
var $container = this.$dropdown.find('.dropdown-results');
var entityType = this.activeGroup ? this.activeGroup.searchEntity : 'categories';
// Show the dropdown
this.$dropdown.addClass('show');
// Show loading state
$container.html('<div class="tree-loading"><i class="icon-spinner icon-spin"></i> Loading category tree...</div>');
// Use separate cache for each entity type
var cacheKey = entityType + 'TreeCache';
if (this[cacheKey]) {
this.renderCategoryTree(this[cacheKey], entityType);
return;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getCategoryTree',
trait: 'EntitySelector',
entity_type: entityType
},
success: function(response) {
if (response.success && response.categories) {
self[cacheKey] = response.categories;
self.renderCategoryTree(response.categories, entityType);
} else {
$container.html('<div class="no-results"><i class="icon-warning"></i> Failed to load category tree</div>');
}
},
error: function(xhr, status, error) {
$container.html('<div class="no-results"><i class="icon-warning"></i> Error loading category tree</div>');
}
});
},
/**
* Render category tree structure
*/
renderCategoryTree: function(categories, entityType) {
var self = this;
var trans = this.config.trans || {};
var $container = this.$dropdown.find('.dropdown-results');
var isCmsCategory = entityType === 'cms_categories';
var categoryLabel = isCmsCategory ? 'CMS categories' : 'categories';
// Get selected IDs from current picker
var selectedIds = [];
if (this.activeGroup) {
var $block = this.$wrapper.find('.target-block[data-block-type="' + this.activeGroup.blockType + '"]');
var $group = $block.find('.selection-group[data-group-index="' + this.activeGroup.groupIndex + '"]');
if (this.activeGroup.section === 'include') {
var $picker = $group.find('.include-picker');
$picker.find('.entity-chip').each(function() {
selectedIds.push(parseInt($(this).data('id'), 10));
});
} else {
var $currentExcludeRow = $group.find('.exclude-row[data-exclude-index="' + this.activeGroup.excludeIndex + '"]');
var $currentPicker = $currentExcludeRow.find('.exclude-picker');
$currentPicker.find('.entity-chip').each(function() {
selectedIds.push(parseInt($(this).data('id'), 10));
});
}
}
// Build tree HTML
var html = '<div class="category-tree" data-entity-type="' + this.escapeAttr(entityType) + '">';
html += '<div class="tree-container">';
// Find minimum level (usually 1 or 2)
var minLevel = categories.length > 0 ? categories[0].level : 1;
categories.forEach(function(cat) {
var isSelected = selectedIds.indexOf(cat.id) !== -1;
var indent = (cat.level - minLevel) * 20;
var hasChildren = cat.has_children;
html += '<div class="tree-item' + (isSelected ? ' selected' : '') + (hasChildren ? ' has-children' : '') + '" ';
html += 'data-id="' + cat.id + '" ';
html += 'data-parent-id="' + cat.parent_id + '" ';
html += 'data-level="' + cat.level + '" ';
html += 'data-nleft="' + cat.nleft + '" ';
html += 'data-nright="' + cat.nright + '" ';
html += 'data-name="' + self.escapeAttr(cat.name) + '" ';
html += 'data-subtitle="' + self.escapeAttr(cat.subtitle) + '" ';
html += 'style="padding-left: ' + (indent + 8) + 'px;">';
// Expand/collapse toggle for parents
if (hasChildren) {
html += '<span class="tree-toggle"><i class="icon-caret-down"></i></span>';
} else {
html += '<span class="tree-toggle-placeholder"></span>';
}
// Select children button for parents (on the left, near toggle)
// Hide in single mode - selecting multiple items doesn't make sense there
var isSingleMode = self.config.mode === 'single';
if (hasChildren && !isSingleMode) {
html += '<button type="button" class="btn-select-children" title="' + (trans.select_with_children || 'Select with all children') + '">';
html += '<i class="icon-plus-square"></i>';
html += '</button>';
} else if (!isSingleMode) {
html += '<span class="btn-select-children-placeholder"></span>';
}
// Checkbox
html += '<span class="tree-checkbox"><i class="icon-check"></i></span>';
// Category icon (file icon for CMS categories)
var iconClass = isCmsCategory ? 'icon-file-text-o' : ('icon-folder' + (hasChildren ? '' : '-o'));
html += '<span class="tree-icon"><i class="' + iconClass + '"></i></span>';
// Name and subtitle
html += '<div class="tree-info">';
html += '<span class="tree-name">' + self.escapeHtml(cat.name) + '</span>';
html += '<span class="tree-subtitle">' + self.escapeHtml(cat.subtitle) + '</span>';
html += '</div>';
html += '</div>';
});
html += '</div>'; // tree-container
html += '</div>'; // category-tree
$container.html(html);
// Update results count with appropriate label
var selectedCount = $container.find('.tree-item.selected').length;
this.$dropdown.find('.results-count').text(categories.length + ' ' + categoryLabel + (selectedCount > 0 ? ' (' + selectedCount + ' selected)' : ''));
// Update select-children button states based on initial selection
var $allItems = $container.find('.tree-item');
this.updateSelectChildrenButtons($allItems);
// Hide load more controls in tree view
this.$dropdown.find('.load-more-controls').hide();
},
/**
* Filter category tree by search query (client-side filtering)
*/
filterCategoryTree: function(query) {
var self = this;
var $container = this.$dropdown.find('.category-tree');
if (!$container.length) {
return;
}
var $items = $container.find('.tree-item');
query = query.toLowerCase().trim();
if (!query) {
// Show all items when query is empty
$items.show().removeClass('collapsed');
$container.find('.tree-toggle i').removeClass('icon-caret-right').addClass('icon-caret-down');
return;
}
// First pass: find matching items and their ancestors
var matchingIds = [];
var ancestorIds = [];
$items.each(function() {
var $item = $(this);
var name = ($item.data('name') || '').toLowerCase();
if (name.indexOf(query) !== -1) {
matchingIds.push($item.data('id'));
// Also mark all ancestors using helper (works for both nleft/nright and parent_id)
var ancestors = self.findTreeAncestors($item, $items);
for (var i = 0; i < ancestors.length; i++) {
ancestorIds.push($(ancestors[i]).data('id'));
}
}
});
// Second pass: show/hide items
$items.each(function() {
var $item = $(this);
var id = $item.data('id');
if (matchingIds.indexOf(id) !== -1 || ancestorIds.indexOf(id) !== -1) {
$item.show().removeClass('collapsed');
$item.find('.tree-toggle i').removeClass('icon-caret-right').addClass('icon-caret-down');
} else {
$item.hide();
}
});
// Update count with appropriate label
var visibleCount = $items.filter(':visible').length;
var selectedCount = $items.filter('.selected').length;
var entityType = $container.data('entity-type') || 'categories';
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
this.$dropdown.find('.results-count').text(visibleCount + ' ' + categoryLabel + (selectedCount > 0 ? ' (' + selectedCount + ' selected)' : ''));
},
/**
* Find all descendant tree items of a parent.
* Works with nleft/nright (product categories) or parent_id (CMS categories).
*/
findTreeDescendants: function($parent, $allItems) {
var nleft = parseInt($parent.data('nleft'), 10);
var nright = parseInt($parent.data('nright'), 10);
var parentId = parseInt($parent.data('id'), 10);
var descendants = [];
// If nleft/nright are valid (product categories), use nested set
if (nleft > 0 && nright > 0 && nright > nleft) {
$allItems.each(function() {
var $item = $(this);
var childNleft = parseInt($item.data('nleft'), 10);
var childNright = parseInt($item.data('nright'), 10);
if (childNleft > nleft && childNright < nright) {
descendants.push($item);
}
});
} else {
// CMS categories: use parent_id recursively
var idsToCheck = [parentId];
var processed = {};
while (idsToCheck.length > 0) {
var checkId = idsToCheck.shift();
if (processed[checkId]) continue;
processed[checkId] = true;
$allItems.each(function() {
var $item = $(this);
var itemParentId = parseInt($item.data('parent-id'), 10);
var itemId = parseInt($item.data('id'), 10);
if (itemParentId === checkId && !processed[itemId]) {
descendants.push($item);
idsToCheck.push(itemId);
}
});
}
}
return descendants;
},
/**
* Find all ancestor tree items of an item.
* Works with nleft/nright (product categories) or parent_id (CMS categories).
*/
findTreeAncestors: function($item, $allItems) {
var nleft = parseInt($item.data('nleft'), 10);
var nright = parseInt($item.data('nright'), 10);
var ancestors = [];
// If nleft/nright are valid (product categories), use nested set
if (nleft > 0 && nright > 0) {
$allItems.each(function() {
var $ancestor = $(this);
var ancNleft = parseInt($ancestor.data('nleft'), 10);
var ancNright = parseInt($ancestor.data('nright'), 10);
if (ancNleft < nleft && ancNright > nright) {
ancestors.push($ancestor);
}
});
} else {
// CMS categories: use parent_id chain
var parentId = parseInt($item.data('parent-id'), 10);
var processed = {};
while (parentId > 0 && !processed[parentId]) {
processed[parentId] = true;
$allItems.each(function() {
var $ancestor = $(this);
var ancestorId = parseInt($ancestor.data('id'), 10);
if (ancestorId === parentId) {
ancestors.push($ancestor);
parentId = parseInt($ancestor.data('parent-id'), 10);
return false; // break inner loop
}
});
}
}
return ancestors;
},
/**
* Update all select-children buttons to reflect current selection state.
* Shows minus icon if item and all children are selected, plus icon otherwise.
*/
updateSelectChildrenButtons: function($allItems) {
var self = this;
var trans = this.config.trans || {};
$allItems.filter('.has-children').each(function() {
var $item = $(this);
var $btn = $item.find('.btn-select-children');
if (!$btn.length) return;
var descendants = self.findTreeDescendants($item, $allItems);
// Check if parent and ALL descendants are selected
var allSelected = $item.hasClass('selected');
for (var i = 0; i < descendants.length && allSelected; i++) {
if (!$(descendants[i]).hasClass('selected')) {
allSelected = false;
}
}
// Update button icon and title
if (allSelected && descendants.length > 0) {
$btn.find('i').removeClass('icon-plus-square').addClass('icon-minus-square');
$btn.attr('title', trans.deselect_with_children || 'Deselect with all children');
} else {
$btn.find('i').removeClass('icon-minus-square').addClass('icon-plus-square');
$btn.attr('title', trans.select_with_children || 'Select with all children');
}
});
},
// NOTE: Tree methods (loadCategoryTree, renderCategoryTree, filterCategoryTree,
// findTreeDescendants, findTreeAncestors, updateSelectChildrenButtons) are
// defined in _tree.js which is merged later and takes precedence.
// =========================================================================
// Search History

View File

@@ -0,0 +1,359 @@
/**
* Entity Selector - Category Tree Module
* Hierarchical tree view for category selection inside the dropdown
* @partial _tree.js
*
* Features:
* - Expand/collapse individual nodes
* - Expand all / Collapse all
* - Select parent with all children button
* - Visual tree with indentation
* - Product count display
* - Search/filter within tree
*/
(function($) {
'use strict';
// Create mixin namespace
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
// Tree mixin
window._EntitySelectorMixins.tree = {
// Tree state
treeData: null,
treeFlatData: null,
/**
* Load and display category tree in the dropdown
* Called when view mode is changed to "tree"
*/
loadCategoryTree: function() {
var self = this;
var $results = this.$dropdown.find('.dropdown-results');
var trans = this.config.trans || {};
var searchEntity = this.activeGroup ? this.activeGroup.searchEntity : 'categories';
// Show loading
$results.html('<div class="tree-loading"><i class="icon-spinner icon-spin"></i> ' +
this.escapeHtml(trans.loading || 'Loading...') + '</div>');
// Fetch tree data
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getCategoryTree',
trait: 'EntitySelector',
entity_type: searchEntity
},
success: function(response) {
if (response.success && response.categories && response.categories.length > 0) {
self.treeFlatData = response.categories;
self.treeData = self.buildTreeStructure(response.categories);
self.renderCategoryTree($results, searchEntity);
} else {
$results.html('<div class="dropdown-empty">' +
self.escapeHtml(trans.no_categories || 'No categories found') + '</div>');
}
},
error: function() {
$results.html('<div class="dropdown-error">' +
self.escapeHtml(trans.error_loading || 'Failed to load categories') + '</div>');
}
});
},
/**
* Build nested tree structure from flat array
* @param {Array} flatData - Flat array with parent_id references
* @returns {Array} Nested tree structure
*/
buildTreeStructure: function(flatData) {
var lookup = {};
var tree = [];
// Create lookup and initialize children arrays
flatData.forEach(function(item) {
lookup[item.id] = $.extend({}, item, { children: [] });
});
// Build tree by assigning children to parents
flatData.forEach(function(item) {
var node = lookup[item.id];
var parentId = parseInt(item.parent_id, 10);
if (parentId && lookup[parentId]) {
lookup[parentId].children.push(node);
} else {
tree.push(node);
}
});
return tree;
},
/**
* Render the category tree inside dropdown results
* @param {jQuery} $container - The dropdown-results container
* @param {string} entityType - 'categories' or 'cms_categories'
*/
renderCategoryTree: function($container, entityType) {
var self = this;
var trans = this.config.trans || {};
// Get currently selected IDs from chips
var selectedIds = this.getSelectedIdsFromChips();
// Build tree HTML
var html = '<div class="category-tree" data-entity-type="' + this.escapeAttr(entityType) + '">';
// Tree toolbar
html += '<div class="tree-toolbar">';
html += '<button type="button" class="btn-expand-all" title="' +
this.escapeAttr(trans.expand_all || 'Expand all') + '">';
html += '<i class="icon-plus-square-o"></i> ' + this.escapeHtml(trans.expand_all || 'Expand all');
html += '</button>';
html += '<button type="button" class="btn-collapse-all" title="' +
this.escapeAttr(trans.collapse_all || 'Collapse all') + '">';
html += '<i class="icon-minus-square-o"></i> ' + this.escapeHtml(trans.collapse_all || 'Collapse all');
html += '</button>';
html += '</div>';
// Tree items
html += '<div class="tree-items">';
html += this.renderTreeItems(this.treeData, 0, selectedIds);
html += '</div>';
html += '</div>';
$container.html(html);
// Update count
var totalCount = this.treeFlatData ? this.treeFlatData.length : 0;
var selectedCount = selectedIds.length;
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
var countText = totalCount + ' ' + categoryLabel;
if (selectedCount > 0) {
countText += ' (' + selectedCount + ' selected)';
}
this.$dropdown.find('.results-count').text(countText);
// Update select children button states
this.updateSelectChildrenButtons(this.$dropdown.find('.tree-item'));
},
/**
* Render tree items recursively
* @param {Array} nodes - Tree nodes
* @param {number} level - Current depth level
* @param {Array} selectedIds - Currently selected IDs
* @returns {string} HTML string
*/
renderTreeItems: function(nodes, level, selectedIds) {
var self = this;
var html = '';
var trans = this.config.trans || {};
nodes.forEach(function(node) {
var hasChildren = node.children && node.children.length > 0;
var isSelected = selectedIds.indexOf(parseInt(node.id, 10)) !== -1;
var indent = level * 20;
var itemClass = 'tree-item';
if (hasChildren) itemClass += ' has-children';
if (isSelected) itemClass += ' selected';
if (!node.active) itemClass += ' inactive';
html += '<div class="' + itemClass + '" data-id="' + node.id + '" ';
html += 'data-name="' + self.escapeAttr(node.name) + '" ';
html += 'data-level="' + level + '" ';
html += 'data-parent-id="' + (node.parent_id || 0) + '">';
// Indentation
html += '<span class="tree-indent" style="width: ' + indent + 'px;"></span>';
// Toggle button (expand/collapse)
if (hasChildren) {
html += '<span class="tree-toggle"><i class="icon-caret-down"></i></span>';
// Select with children button (next to toggle on the left)
html += '<button type="button" class="btn-select-children" title="' +
self.escapeAttr(trans.select_with_children || 'Select with all children') + '">';
html += '<i class="icon-check-square-o"></i>';
html += '</button>';
} else {
html += '<span class="tree-toggle tree-leaf"></span>';
}
// Checkbox indicator
html += '<span class="tree-checkbox"><i class="icon-check"></i></span>';
// Category icon
html += '<span class="tree-icon"><i class="icon-folder"></i></span>';
// Name
html += '<span class="tree-name">' + self.escapeHtml(node.name) + '</span>';
// Product/page count with clickable preview
var itemCount = node.product_count || node.page_count || 0;
if (itemCount > 0) {
var countLabel = node.page_count ? (trans.pages || 'pages') : (trans.products || 'products');
html += '<span class="tree-count clickable" data-category-id="' + node.id + '" ';
html += 'title="' + self.escapeAttr(itemCount + ' ' + countLabel) + '">';
html += '<i class="icon-eye"></i> ' + itemCount;
html += '</span>';
}
// Inactive badge
if (!node.active) {
html += '<span class="tree-badge inactive">' +
self.escapeHtml(trans.inactive || 'Inactive') + '</span>';
}
html += '</div>';
// Render children
if (hasChildren) {
html += '<div class="tree-children">';
html += self.renderTreeItems(node.children, level + 1, selectedIds);
html += '</div>';
}
});
return html;
},
/**
* Get selected IDs from the current picker's chips
* @returns {Array} Array of selected IDs
*/
getSelectedIdsFromChips: function() {
var selectedIds = [];
if (!this.activeGroup) return selectedIds;
var $block = this.$wrapper.find('.target-block[data-block-type="' + this.activeGroup.blockType + '"]');
var $group = $block.find('.selection-group[data-group-index="' + this.activeGroup.groupIndex + '"]');
var $picker;
if (this.activeGroup.section === 'include') {
$picker = $group.find('.include-picker');
} else {
var $excludeRow = $group.find('.exclude-row[data-exclude-index="' + this.activeGroup.excludeIndex + '"]');
$picker = $excludeRow.find('.exclude-picker');
}
$picker.find('.entity-chip').each(function() {
selectedIds.push(parseInt($(this).data('id'), 10));
});
return selectedIds;
},
/**
* Filter category tree by search query
* @param {string} query - Search query
*/
filterCategoryTree: function(query) {
var $tree = this.$dropdown.find('.category-tree');
if (!$tree.length) return;
var $items = $tree.find('.tree-item');
var $children = $tree.find('.tree-children');
query = (query || '').toLowerCase().trim();
// Remove any inline display styles set by jQuery .toggle()
$items.css('display', '');
if (!query) {
$items.removeClass('filtered-out filter-match');
$children.removeClass('filter-expanded');
return;
}
// Mark all as filtered out first
$items.addClass('filtered-out').removeClass('filter-match');
// Find matching items and show them with their parents
$items.each(function() {
var $item = $(this);
var name = ($item.data('name') || '').toLowerCase();
if (name.indexOf(query) !== -1) {
$item.removeClass('filtered-out');
// Show parent containers
$item.parents('.tree-children').addClass('filter-expanded');
$item.parents('.tree-item').removeClass('filtered-out');
// Show children of matching item
$item.next('.tree-children').find('.tree-item').removeClass('filtered-out');
$item.next('.tree-children').addClass('filter-expanded');
}
});
},
/**
* Find all descendant tree items of a given item
* @param {jQuery} $item - Parent tree item
* @param {jQuery} $allItems - All tree items (for performance)
* @returns {Array} Array of descendant jQuery elements
*/
findTreeDescendants: function($item, $allItems) {
var descendants = [];
var parentId = parseInt($item.data('id'), 10);
var level = parseInt($item.data('level'), 10);
// Find immediate children first
var $next = $item.next('.tree-children');
if ($next.length) {
$next.find('.tree-item').each(function() {
descendants.push(this);
});
}
return descendants;
},
/**
* Update the state of select-children buttons based on selection
* @param {jQuery} $allItems - All tree items
*/
updateSelectChildrenButtons: function($allItems) {
var self = this;
var trans = this.config.trans || {};
$allItems.filter('.has-children').each(function() {
var $item = $(this);
var $btn = $item.find('.btn-select-children');
if (!$btn.length) return;
var $children = $item.next('.tree-children');
if (!$children.length) return;
var $childItems = $children.find('.tree-item');
var isParentSelected = $item.hasClass('selected');
var allChildrenSelected = true;
$childItems.each(function() {
if (!$(this).hasClass('selected')) {
allChildrenSelected = false;
return false;
}
});
if (isParentSelected && allChildrenSelected) {
$btn.find('i').removeClass('icon-plus-square').addClass('icon-minus-square');
$btn.attr('title', trans.deselect_with_children || 'Deselect with all children');
} else {
$btn.find('i').removeClass('icon-minus-square').addClass('icon-plus-square');
$btn.attr('title', trans.select_with_children || 'Select with all children');
}
});
}
};
})(jQuery);

View File

@@ -151,6 +151,13 @@
};
}
return null;
},
/**
* Check if entity type supports tree browsing
*/
supportsTreeBrowsing: function(entityType) {
return entityType === 'categories' || entityType === 'cms_categories';
}
};

View File

@@ -196,6 +196,12 @@
box-shadow: 0 2px 8px rgba($bg, 0.4);
}
// Focus state - maintain styled appearance
&:focus {
outline: none;
box-shadow: 0 0 0 2px rgba($bg, 0.3), 0 2px 8px rgba($bg, 0.4);
}
// Loading state - spinner icon replaces eye
&.loading {
cursor: wait;

View File

@@ -1,8 +1,14 @@
/**
* Entity Selector Variables
* Bootstrap 4 compatible values for PrestaShop admin theme
*
* Imports shared variables from prestashop-admin package
* and maps them to $es-* prefixed aliases for this package
*/
// Import shared variables from prestashop-admin
@use '../../../prestashop-admin/assets/scss/variables' as admin;
// =============================================================================
// Base Colors
// =============================================================================
@@ -10,38 +16,38 @@
$es-white: #ffffff !default;
$es-black: #000000 !default;
// Primary (PrestaShop admin accent)
$es-primary: #25b9d7 !default;
// Primary (from prestashop-admin)
$es-primary: admin.$primary !default;
$es-primary-hover: #1a9ab7 !default;
$es-primary-light: rgba(37, 185, 215, 0.1) !default;
// Semantic colors (Bootstrap 4 aligned)
$es-success: #28a745 !default;
// Semantic colors (from prestashop-admin)
$es-success: admin.$success !default;
$es-success-light: #d4edda !default;
$es-success-dark: #1e7e34 !default;
$es-danger: #dc3545 !default;
$es-danger: admin.$danger !default;
$es-danger-light: #f8d7da !default;
$es-danger-dark: #bd2130 !default;
$es-warning: #ffc107 !default;
$es-warning: admin.$warning !default;
$es-warning-light: #fff3cd !default;
$es-info: #17a2b8 !default;
$es-info: admin.$info !default;
$es-info-light: #d1ecf1 !default;
// =============================================================================
// Gray Scale (Bootstrap 4)
// =============================================================================
$es-gray-100: #f8f9fa !default;
$es-gray-100: admin.$light !default;
$es-gray-200: #e9ecef !default;
$es-gray-300: #dee2e6 !default;
$es-gray-300: admin.$border-color !default;
$es-gray-400: #ced4da !default;
$es-gray-500: #adb5bd !default;
$es-gray-600: #6c757d !default;
$es-gray-600: admin.$secondary !default;
$es-gray-700: #495057 !default;
$es-gray-800: #343a40 !default;
$es-gray-800: admin.$dark !default;
$es-gray-900: #212529 !default;
// Slate (subtle variations)
@@ -73,7 +79,7 @@ $es-bg-hover: $es-gray-200 !default;
$es-bg-active: $es-gray-200 !default;
$es-bg-body: $es-white !default;
$es-border-color: $es-gray-300 !default;
$es-border-color: admin.$border-color !default;
$es-border-light: $es-gray-200 !default;
$es-border-dark: $es-gray-400 !default;
@@ -83,22 +89,22 @@ $es-text-muted: $es-gray-600 !default;
$es-text-light: $es-gray-500 !default;
// =============================================================================
// Spacing (Bootstrap 4 compatible)
// Spacing (Bootstrap 4 compatible, derived from admin.$spacer)
// =============================================================================
$es-spacing-xs: 0.25rem !default; // 4px
$es-spacing-sm: 0.5rem !default; // 8px
$es-spacing-md: 1rem !default; // 16px
$es-spacing-lg: 1.5rem !default; // 24px
$es-spacing-xl: 2rem !default; // 32px
$es-spacing-xs: admin.$spacer * 0.25 !default; // 4px
$es-spacing-sm: admin.$spacer * 0.5 !default; // 8px
$es-spacing-md: admin.$spacer !default; // 16px
$es-spacing-lg: admin.$spacer * 1.5 !default; // 24px
$es-spacing-xl: admin.$spacer * 2 !default; // 32px
// =============================================================================
// Border Radius (Bootstrap 4 compatible)
// Border Radius (from prestashop-admin)
// =============================================================================
$es-radius-sm: 0.2rem !default;
$es-radius-md: 0.25rem !default;
$es-radius-lg: 0.3rem !default;
$es-radius-sm: admin.$border-radius-sm !default;
$es-radius-md: admin.$border-radius !default;
$es-radius-lg: admin.$border-radius-lg !default;
$es-radius-xl: 0.5rem !default;
$es-radius-full: 50rem !default;

View File

@@ -9,19 +9,169 @@
.target-conditions-trait,
.entity-selector-trait {
// Chips container wrapper with toolbar
.chips-wrapper {
display: flex;
flex-direction: column;
margin-top: $es-spacing-sm;
background: $es-slate-50;
border: 1px solid $es-border-color;
border-radius: $es-radius-md;
overflow: hidden;
}
// Chips toolbar - search and actions
.chips-toolbar {
display: none; // Hidden by default, shown via JS when chips exist
align-items: center;
gap: $es-spacing-sm;
padding: $es-spacing-sm $es-spacing-md;
background: $es-white;
border-bottom: 1px solid $es-border-color;
&.has-chips {
display: flex;
}
// Search icon
> i {
color: $es-text-muted;
font-size: 14px;
flex-shrink: 0;
}
}
.chips-search-input,
input.chips-search-input,
input.chips-search-input[type="text"] {
flex: 1 !important;
min-width: 80px !important;
max-width: none !important;
width: auto !important;
height: auto !important;
padding: 0 !important;
border: none !important;
border-bottom: 1px solid transparent !important;
border-radius: 0 !important;
background: transparent !important;
font-size: $es-font-size-sm !important;
color: $es-text-primary;
box-shadow: none !important;
transition: border-color $es-transition-fast;
&:focus {
outline: none !important;
border: none !important;
border-bottom: 1px solid $es-primary !important;
box-shadow: none !important;
}
&::placeholder {
color: $es-text-muted;
}
}
.chips-count {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
background: $es-primary;
color: $es-white;
font-size: $es-font-size-xs;
font-weight: $es-font-weight-semibold;
border-radius: $es-radius-full;
white-space: nowrap;
&.has-filter {
background: $es-cyan-500;
}
.count-filtered {
font-weight: $es-font-weight-bold;
}
.count-separator {
opacity: 0.7;
}
}
.chips-actions {
display: flex;
align-items: center;
gap: $es-spacing-xs;
margin-left: auto;
}
.btn-chips-clear {
@include button-reset;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
color: $es-white;
font-size: $es-font-size-xs;
font-weight: $es-font-weight-semibold;
background: $es-danger;
border-radius: $es-radius-sm;
transition: all $es-transition-fast;
&:hover {
background: darken($es-danger, 8%);
}
i {
font-size: 11px;
}
}
// Chips container
.entity-chips {
display: flex;
flex-wrap: wrap;
gap: $es-spacing-xs;
padding: $es-spacing-sm 0;
min-height: 32px;
padding: $es-spacing-md;
min-height: 40px;
max-height: 300px;
overflow-y: auto;
&:empty {
display: none;
}
}
// Load more button
.chips-load-more {
display: flex;
align-items: center;
justify-content: center;
padding: $es-spacing-sm $es-spacing-md;
background: $es-white;
border-top: 1px solid $es-border-color;
.btn-load-more {
@include button-reset;
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
color: $es-white;
font-size: $es-font-size-sm;
font-weight: $es-font-weight-semibold;
background: $es-primary;
border-radius: $es-radius-sm;
transition: all $es-transition-fast;
&:hover {
background: $es-primary-hover;
}
i {
font-size: 12px;
}
}
}
// Individual chip
.entity-chip {
display: inline-flex;
@@ -33,7 +183,6 @@
font-size: $es-font-size-xs;
font-weight: $es-font-weight-medium;
border-radius: $es-radius-full;
max-width: 200px;
transition: all $es-transition-fast;
&:hover {
@@ -44,6 +193,12 @@
&.has-image {
padding-left: 0.25rem;
}
// Hidden by search filter or pagination
&.chip-filtered-out,
&.chip-paginated-out {
display: none;
}
}
.chip-image {
@@ -55,14 +210,26 @@
}
.chip-icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: $es-text-muted;
flex-shrink: 0;
// Product/entity images inside chip
img {
width: 20px;
height: 20px;
object-fit: cover;
border-radius: $es-radius-sm;
}
}
.chip-text,
.chip-name {
@include text-truncate;
// Show full name, no truncation
word-break: break-word;
}
.chip-remove {

View File

@@ -117,7 +117,7 @@
// Results container
.dropdown-results {
padding: $es-spacing-sm;
padding: 0 $es-spacing-sm;
}
// Results count text
@@ -609,80 +609,7 @@
}
}
.tree-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: $es-text-muted;
cursor: pointer;
transition: transform $es-transition-fast;
i {
font-size: 12px;
}
}
.tree-toggle-placeholder {
width: 16px;
height: 16px;
}
.btn-select-children {
@include button-reset;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: $es-primary;
border-radius: $es-radius-sm;
transition: all $es-transition-fast;
&:hover {
background: $es-primary-light;
}
i {
font-size: 12px;
}
}
.btn-select-children-placeholder {
width: 18px;
height: 18px;
}
.tree-checkbox {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 16px;
height: 16px;
border: 2px solid $es-border-dark;
border-radius: 3px;
transition: all $es-transition-fast;
i {
display: none;
font-size: 10px;
color: $es-white;
}
}
.tree-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
color: $es-text-muted;
i {
font-size: 14px;
}
}
// tree-toggle, btn-select-children, tree-checkbox, tree-icon styles in _tree.scss
.tree-info {
display: flex;
@@ -1521,14 +1448,11 @@ body > .target-search-dropdown,
color: $es-text-muted;
}
.dropdown-footer-actions {
display: flex;
align-items: center;
gap: $es-spacing-sm;
}
.btn-cancel-dropdown {
@include button-reset;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
font-size: $es-font-size-sm;
color: $es-text-secondary;
@@ -1539,6 +1463,12 @@ body > .target-search-dropdown,
&:hover {
background: $es-bg-hover;
color: $es-danger;
border-color: $es-danger;
}
i {
font-size: 10px;
}
kbd {
@@ -1566,6 +1496,11 @@ body > .target-search-dropdown,
&:hover {
background: $es-primary-hover;
border-color: $es-primary-hover;
}
i {
font-size: 10px;
}
kbd {
@@ -1702,7 +1637,7 @@ body > .target-search-dropdown,
// Results container
.dropdown-results {
padding: $es-spacing-sm;
padding: 0 $es-spacing-sm;
background: $es-white;
min-height: 200px;
}
@@ -1768,16 +1703,16 @@ body > .target-search-dropdown,
}
}
// View mode classes (applied to dropdown container)
&.view-cols-2 .dropdown-results { @include grid-columns(2); }
&.view-cols-3 .dropdown-results { @include grid-columns(3); }
&.view-cols-4 .dropdown-results { @include grid-columns(4); }
&.view-cols-5 .dropdown-results { @include grid-columns(5); }
&.view-cols-6 .dropdown-results { @include grid-columns(6); }
&.view-cols-7 .dropdown-results { @include grid-columns(7); }
&.view-cols-8 .dropdown-results { @include grid-columns(8); }
// View mode classes (applied to dropdown container) - no gap/padding for shared borders
&.view-cols-2 .dropdown-results { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
&.view-cols-3 .dropdown-results { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
&.view-cols-4 .dropdown-results { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
&.view-cols-5 .dropdown-results { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
&.view-cols-6 .dropdown-results { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
&.view-cols-7 .dropdown-results { display: grid; grid-template-columns: repeat(7, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
&.view-cols-8 .dropdown-results { display: grid; grid-template-columns: repeat(8, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
// Grid view item styling (compact cards)
// Grid view item styling (compact cards with shared borders)
&.view-cols-2,
&.view-cols-3,
&.view-cols-4,
@@ -1789,9 +1724,11 @@ body > .target-search-dropdown,
flex-direction: column;
align-items: center;
text-align: center;
padding: 0;
border: 1px solid $es-border-color;
border-radius: $es-radius-sm;
padding: $es-spacing-sm;
border: none;
border-right: 1px solid $es-border-color;
border-bottom: 1px solid $es-border-color;
border-radius: 0;
.result-checkbox {
position: absolute;
@@ -1861,6 +1798,15 @@ body > .target-search-dropdown,
}
}
// Remove right border from last item in each row (per column count)
&.view-cols-2 .dropdown-results .dropdown-item:nth-child(2n) { border-right: none; }
&.view-cols-3 .dropdown-results .dropdown-item:nth-child(3n) { border-right: none; }
&.view-cols-4 .dropdown-results .dropdown-item:nth-child(4n) { border-right: none; }
&.view-cols-5 .dropdown-results .dropdown-item:nth-child(5n) { border-right: none; }
&.view-cols-6 .dropdown-results .dropdown-item:nth-child(6n) { border-right: none; }
&.view-cols-7 .dropdown-results .dropdown-item:nth-child(7n) { border-right: none; }
&.view-cols-8 .dropdown-results .dropdown-item:nth-child(8n) { border-right: none; }
// Smaller items for higher column counts
&.view-cols-5,
&.view-cols-6,
@@ -2175,80 +2121,7 @@ body > .target-search-dropdown,
}
}
.tree-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: $es-text-muted;
cursor: pointer;
transition: transform $es-transition-fast;
i {
font-size: 12px;
}
}
.tree-toggle-placeholder {
width: 16px;
height: 16px;
}
.btn-select-children {
@include button-reset;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: $es-primary;
border-radius: $es-radius-sm;
transition: all $es-transition-fast;
&:hover {
background: $es-primary-light;
}
i {
font-size: 12px;
}
}
.btn-select-children-placeholder {
width: 18px;
height: 18px;
}
.tree-checkbox {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 16px;
height: 16px;
border: 2px solid $es-border-dark;
border-radius: 3px;
transition: all $es-transition-fast;
i {
display: none;
font-size: 10px;
color: $es-white;
}
}
.tree-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
color: $es-text-muted;
i {
font-size: 14px;
}
}
// tree-toggle, btn-select-children, tree-checkbox, tree-icon styles in _tree.scss
.tree-info {
display: flex;
@@ -2280,7 +2153,7 @@ body > .target-search-dropdown,
.dropdown-results {
max-height: 400px;
overflow-y: auto;
padding: $es-spacing-sm;
padding: 0 $es-spacing-sm;
@include custom-scrollbar;
}
@@ -2498,6 +2371,7 @@ body > .target-search-dropdown,
.entity-search-icon {
color: $es-text-muted;
flex-shrink: 0;
margin-left: $es-spacing-xs;
}
// Override Bootstrap/parent form input styles

View File

@@ -319,22 +319,13 @@
border-top: 1px dashed $es-border-color;
}
// Legacy exclude-rows (if used elsewhere)
.exclude-rows {
display: flex;
flex-direction: column;
gap: $es-spacing-sm;
}
.exclude-row {
display: flex;
align-items: flex-start;
gap: $es-spacing-sm;
padding: $es-spacing-sm;
background: rgba($es-danger, 0.05);
border: 1px solid rgba($es-danger, 0.2);
border-radius: $es-radius-md;
}
.exclude-row-content {
flex: 1;
}
@@ -534,9 +525,13 @@
}
}
// Group include section
// Group include section - green accent to distinguish from exclude
.group-include {
margin-bottom: $es-spacing-md;
padding: $es-spacing-sm;
background: rgba($es-success, 0.03);
border: 1px solid rgba($es-success, 0.2);
border-radius: $es-radius-md;
}
.section-row {
@@ -565,32 +560,81 @@
cursor: pointer;
}
// Lock indicator for method selector (when excludes are present)
.selector-locked {
.include-method-select {
opacity: 0.7;
cursor: not-allowed;
}
}
.lock-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: $es-warning;
cursor: help;
i {
font-size: 14px;
}
.mpr-tooltip {
display: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
padding: $es-spacing-xs $es-spacing-sm;
background: $es-slate-800;
color: $es-white;
font-size: $es-font-size-xs;
font-weight: $es-font-weight-normal;
white-space: nowrap;
border-radius: $es-radius-sm;
z-index: 100;
}
&:hover .mpr-tooltip {
display: block;
}
}
// Group excludes section
.group-excludes {
margin-top: $es-spacing-md;
&.has-excludes {
padding-top: $es-spacing-md;
border-top: 1px dashed $es-border-color;
}
}
.except-separator {
display: flex;
align-items: center;
margin-bottom: $es-spacing-sm;
gap: $es-spacing-sm;
margin: 0 0 $es-spacing-sm 0;
// Lines on both sides
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: rgba($es-danger, 0.3);
}
}
.except-label {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
padding: 0.25rem 0.75rem;
background: $es-danger-light;
color: $es-danger;
font-size: $es-font-size-xs;
font-weight: $es-font-weight-semibold;
border-radius: $es-radius-sm;
border-radius: $es-radius-full;
white-space: nowrap;
flex-shrink: 0;
i {
font-size: 10px;
@@ -604,17 +648,36 @@
}
.exclude-row {
display: flex;
flex-direction: column;
padding: $es-spacing-sm;
background: rgba($es-danger, 0.03);
border: 1px solid rgba($es-danger, 0.15);
border-radius: $es-radius-md;
// Value picker inside exclude row - full width
.value-picker {
width: 100%;
margin-top: $es-spacing-sm;
}
}
.exclude-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $es-spacing-sm;
margin-bottom: $es-spacing-sm;
width: 100%;
.method-selector-wrapper {
flex: 1;
}
// Delete button at the far right
.btn-remove-exclude-row {
flex-shrink: 0;
margin-left: auto;
}
}
.btn-remove-exclude-row {
@@ -680,9 +743,14 @@
gap: 0.375rem;
}
// Common height for all modifier controls
$modifier-height: 26px;
.group-modifier-limit {
width: 60px;
padding: 0.25rem 0.5rem;
width: 50px;
max-width: 50px;
height: $modifier-height;
padding: 0 0.375rem;
font-size: $es-font-size-xs;
text-align: center;
border: 1px solid $es-border-color;
@@ -694,8 +762,59 @@
}
}
// Sort modifier - input group style (select + button glued together)
.modifier-sort {
gap: 0; // Remove gap to glue select + button together
.modifier-label {
margin-right: 0.375rem; // Keep space between label and input group
}
.group-modifier-sort {
height: $modifier-height;
padding: 0 0.5rem;
font-size: $es-font-size-xs;
border: 1px solid $es-border-color;
border-radius: $es-radius-sm 0 0 $es-radius-sm;
border-right: none;
cursor: pointer;
&:focus {
border-color: $es-primary;
outline: none;
position: relative;
z-index: 1;
}
}
.btn-sort-dir {
@include button-reset;
display: flex;
align-items: center;
justify-content: center;
width: $modifier-height;
height: $modifier-height;
color: $es-text-muted;
background: $es-slate-100;
border: 1px solid $es-border-color;
border-radius: 0 $es-radius-sm $es-radius-sm 0;
transition: all $es-transition-fast;
&:hover {
background: $es-slate-200;
color: $es-text-secondary;
}
i {
font-size: 11px;
}
}
}
// Fallback for elements outside .modifier-sort context
.group-modifier-sort {
padding: 0.25rem 0.5rem;
height: $modifier-height;
padding: 0 0.5rem;
font-size: $es-font-size-xs;
border: 1px solid $es-border-color;
border-radius: $es-radius-sm;
@@ -712,8 +831,8 @@
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: $modifier-height;
height: $modifier-height;
color: $es-text-muted;
border: 1px solid $es-border-color;
border-radius: $es-radius-sm;

View File

@@ -494,3 +494,92 @@
max-width: 100px;
}
}
// =============================================================================
// Total Summary Popover (header total badge click)
// =============================================================================
.total-preview-popover {
min-width: 240px;
max-width: 320px;
.preview-popover-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $es-spacing-sm $es-spacing-md;
background: $es-bg-header;
border-bottom: 1px solid $es-border-color;
.preview-popover-title {
font-weight: $es-font-weight-semibold;
color: $es-text-primary;
font-size: $es-font-size-sm;
}
.preview-popover-count {
font-size: $es-font-size-xs;
color: $es-text-muted;
}
}
.preview-popover-body {
padding: $es-spacing-xs 0;
}
.total-summary-list {
list-style: none;
margin: 0;
padding: 0;
}
.total-summary-item {
display: flex;
align-items: center;
gap: $es-spacing-sm;
padding: $es-spacing-sm $es-spacing-md;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: $es-slate-50;
}
i {
width: 18px;
text-align: center;
color: $es-text-muted;
font-size: 14px;
}
.summary-item-label {
flex: 1;
font-size: $es-font-size-sm;
color: $es-text-primary;
}
.summary-item-count {
font-size: $es-font-size-sm;
font-weight: $es-font-weight-semibold;
color: $es-primary;
background: rgba($es-primary, 0.1);
padding: 2px 8px;
border-radius: $es-radius-sm;
}
}
}
// Make trait-total-count clickable
.trait-total-count {
cursor: pointer;
transition: all 0.15s ease;
&:hover {
opacity: 0.8;
}
&.popover-open {
opacity: 0.9;
}
}

View File

@@ -82,14 +82,25 @@
// Hidden select (for form submission)
.method-select-hidden {
position: absolute;
opacity: 0;
pointer-events: none;
width: 0;
height: 0;
position: absolute !important;
opacity: 0 !important;
pointer-events: none !important;
width: 0 !important;
height: 0 !important;
overflow: hidden !important;
}
}
// Global fallback for hidden method selects
.method-select-hidden {
position: absolute !important;
opacity: 0 !important;
pointer-events: none !important;
width: 0 !important;
height: 0 !important;
overflow: hidden !important;
}
// =============================================================================
// Method Dropdown Menu (appended to body, outside trait wrappers)
// =============================================================================

View File

@@ -109,44 +109,6 @@
}
}
// =============================================================================
// MPR Icon (SVG mask icons)
// =============================================================================
.mpr-icon {
display: inline-block;
width: 16px;
height: 16px;
background-color: $es-slate-600;
vertical-align: middle;
cursor: pointer;
transition: background-color 0.2s;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
&:hover {
background-color: #5bc0de;
}
&.link {
background-color: #5bc0de;
&:hover {
background-color: #337ab7;
}
}
}
// Info icon
.mpr-icon.icon-info {
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 2.5H5A2.5 2.5 0 0 0 2.5 5v6A2.5 2.5 0 0 0 5 13.5h6a2.5 2.5 0 0 0 2.5-2.5V5A2.5 2.5 0 0 0 11 2.5ZM5 1a4 4 0 0 0-4 4v6a4 4 0 0 0 4 4h6a4 4 0 0 0 4-4V5a4 4 0 0 0-4-4H5Z' fill='%23414552'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.25 8A.75.75 0 0 1 7 7.25h1.25A.75.75 0 0 1 9 8v3.5a.75.75 0 0 1-1.5 0V8.75H7A.75.75 0 0 1 6.25 8Z' fill='%23414552'/%3E%3Cpath d='M6.75 5a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0Z' fill='%23414552'/%3E%3C/svg%3E");
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 2.5H5A2.5 2.5 0 0 0 2.5 5v6A2.5 2.5 0 0 0 5 13.5h6a2.5 2.5 0 0 0 2.5-2.5V5A2.5 2.5 0 0 0 11 2.5ZM5 1a4 4 0 0 0-4 4v6a4 4 0 0 0 4 4h6a4 4 0 0 0 4-4V5a4 4 0 0 0-4-4H5Z' fill='%23414552'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.25 8A.75.75 0 0 1 7 7.25h1.25A.75.75 0 0 1 9 8v3.5a.75.75 0 0 1-1.5 0V8.75H7A.75.75 0 0 1 6.25 8Z' fill='%23414552'/%3E%3Cpath d='M6.75 5a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0Z' fill='%23414552'/%3E%3C/svg%3E");
}
// =============================================================================
// Tooltip Content Styling
// =============================================================================

View File

@@ -0,0 +1,342 @@
/**
* Category Tree Component
* Hierarchical tree view for category selection inside dropdown
*/
@use '../variables' as *;
@use '../mixins' as *;
// Category tree container (inside dropdown)
.category-tree {
display: flex;
flex-direction: column;
}
// Tree toolbar inside dropdown
.category-tree .tree-toolbar {
display: flex;
align-items: center;
gap: $es-spacing-sm;
padding: $es-spacing-xs $es-spacing-sm;
background: $es-slate-50;
border-bottom: 1px solid $es-border-light;
flex-shrink: 0;
.btn-expand-all,
.btn-collapse-all {
@include button-reset;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: $es-spacing-xs $es-spacing-sm;
font-size: $es-font-size-xs;
font-weight: $es-font-weight-medium;
color: $es-text-secondary;
background: $es-white;
border: 1px solid $es-border-color;
border-radius: $es-radius-sm;
transition: all $es-transition-fast;
&:hover {
background: $es-slate-100;
border-color: $es-slate-300;
}
i {
font-size: 12px;
}
}
}
// Tree items container
.category-tree .tree-items {
padding: 0;
}
// Tree item
.tree-item {
display: flex;
align-items: center;
gap: $es-spacing-xs;
padding: $es-spacing-xs $es-spacing-sm;
cursor: pointer;
transition: background $es-transition-fast;
border-radius: 0;
&:hover {
background: $es-slate-100;
}
&.selected {
background: $es-primary-light;
.tree-name {
font-weight: $es-font-weight-semibold;
color: $es-primary;
}
.tree-checkbox {
color: $es-primary;
i {
opacity: 1;
}
}
}
&.inactive {
opacity: 0.6;
.tree-name {
font-style: italic;
}
}
&.filtered-out {
display: none;
}
&.filter-match {
background: $es-warning-light;
&.selected {
background: $es-primary-light;
}
}
}
// All tree element styles nested under .category-tree for specificity
.category-tree {
// Tree indentation
.tree-indent {
flex-shrink: 0;
}
// Tree toggle (expand/collapse)
.tree-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
box-sizing: border-box;
color: $es-text-secondary;
flex-shrink: 0;
border-radius: $es-radius-sm;
transition: all $es-transition-fast;
cursor: pointer;
&:hover {
background: $es-slate-200;
color: $es-text-primary;
}
&.tree-leaf {
cursor: default;
visibility: hidden;
&:hover {
background: transparent;
}
}
i {
font-size: 10px;
transition: transform $es-transition-fast;
}
}
.tree-item.collapsed > .tree-toggle i {
transform: rotate(-90deg);
}
// Tree checkbox indicator - 12x12 to match PrestaShop admin standards
.tree-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
box-sizing: border-box;
flex-shrink: 0;
border: 1px solid $es-border-color;
border-radius: 2px;
background: $es-white;
i {
font-size: 8px;
opacity: 0;
color: $es-white;
transition: opacity $es-transition-fast;
}
}
.tree-item.selected .tree-checkbox {
background: $es-primary;
border-color: $es-primary;
i {
opacity: 1;
}
}
// Tree icon
.tree-icon {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
box-sizing: border-box;
color: $es-text-muted;
flex-shrink: 0;
i {
font-size: 12px; // match visual weight of other icons
}
}
.tree-item.selected .tree-icon {
color: $es-primary;
}
// Tree name
.tree-name {
flex: 1;
font-size: $es-font-size-sm;
color: $es-text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Tree product/page count with preview
.tree-count {
@include count-badge($es-primary);
height: 18px;
min-width: 18px;
padding: 0 $es-spacing-sm;
i {
font-size: 10px;
}
&.clickable {
&.loading {
pointer-events: none;
i {
animation: spin 1s linear infinite;
}
}
&.popover-open {
background: darken($es-primary, 10%);
}
}
}
// Select children button - positioned on the left next to toggle
.btn-select-children {
@include button-reset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
box-sizing: border-box;
color: $es-text-muted;
border-radius: $es-radius-sm;
opacity: 0.3;
transition: all $es-transition-fast;
flex-shrink: 0;
i {
font-size: 14px; // larger to visually match other icons
}
&:hover {
color: $es-primary;
opacity: 1;
}
}
.tree-item:hover .btn-select-children {
opacity: 0.6;
}
// Tree badge (inactive, etc.)
.tree-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem $es-spacing-xs;
font-size: 9px;
font-weight: $es-font-weight-semibold;
text-transform: uppercase;
letter-spacing: 0.025em;
border-radius: $es-radius-sm;
flex-shrink: 0;
&.inactive {
color: $es-warning;
background: $es-warning-light;
}
}
// Tree children container
.tree-children {
display: block;
&.filter-expanded {
display: block !important;
}
}
.tree-item.collapsed + .tree-children {
display: none;
}
// Filtering - must be inside .category-tree for specificity
.tree-item.filtered-out {
display: none !important;
}
} // end .category-tree
// Loading/empty/error states
.category-tree .tree-loading,
.category-tree .dropdown-empty,
.category-tree .dropdown-error {
display: flex;
align-items: center;
justify-content: center;
padding: $es-spacing-xl;
color: $es-text-muted;
font-size: $es-font-size-sm;
i {
margin-right: $es-spacing-sm;
}
}
.category-tree .dropdown-error {
color: $es-danger;
}
// Tree view mode in dropdown
.target-search-dropdown.view-tree {
.dropdown-results {
padding: 0;
}
.category-tree {
max-height: 100%;
overflow-y: auto;
@include custom-scrollbar;
}
.tree-items {
max-height: calc(100% - 40px);
overflow-y: auto;
@include custom-scrollbar;
}
}

View File

@@ -42,10 +42,16 @@
}
}
// Separation between chips and search box
.chips-wrapper + .entity-search-box {
margin-top: $es-spacing-md;
}
.entity-search-icon {
color: $es-text-muted;
font-size: 14px;
flex-shrink: 0;
margin-left: $es-spacing-xs;
}
// Override parent form's max-width on search input
@@ -85,6 +91,31 @@
}
}
// Browse tree button (for categories)
.btn-browse-tree {
@include button-reset;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-left: auto;
color: $es-primary;
background: $es-primary-light;
border-radius: $es-radius-sm;
flex-shrink: 0;
transition: all $es-transition-fast;
&:hover {
background: $es-primary;
color: $es-white;
}
i {
font-size: 14px;
}
}
// Numeric range box
.numeric-range-box,
.multi-range-input-row {

View File

@@ -28,3 +28,4 @@
@use 'components/combinations';
@use 'components/method-dropdown';
@use 'components/tooltip';
@use 'components/tree';