';
html += this.buildExcludeRowHtml($block, 0);
html += '
';
html += '';
// Header row with method select wrapped in method-selector-wrapper (same as include)
html += '';
// Value picker based on first method's value type
html += this.buildValuePickerHtml('exclude', firstValueType, firstSearchEntity, methods);
html += '
';
return html;
},
removeExcludeRow: function($excludeRow, $group, $block) {
var $container = $group.find('.exclude-rows-container');
var trans = this.config.trans || {};
$excludeRow.remove();
// Check if there are remaining exclude rows
var remainingRows = $container.find('.exclude-row').length;
if (remainingRows === 0) {
// Remove entire excludes section and show "Add exceptions" button
var $excludesDiv = $group.find('.group-excludes');
$excludesDiv.removeClass('has-excludes').html(
'';
switch (valueType) {
case 'entity_search':
var noItemsText = trans.no_items_selected || 'No items selected - use search below';
html += '
';
html += '
';
html += '
' + this.escapeHtml(noItemsText) + '
';
html += '
';
html += '
';
html += '
';
html += '';
html += '';
html += '';
html += '
';
html += '
';
break;
case 'pattern':
// Build tooltip content for data-details attribute
var tooltipContent = '
' + this.escapeHtml(trans.pattern_help_title || 'Pattern Syntax') + '';
tooltipContent += '
';
tooltipContent += '
* ' + this.escapeHtml(trans.pattern_help_wildcard || 'any text (wildcard)') + '
';
tooltipContent += '
{number} ' + this.escapeHtml(trans.pattern_help_number || 'any number (e.g. 100, 250)') + '
';
tooltipContent += '
{letter} ' + this.escapeHtml(trans.pattern_help_letter || 'single letter (A-Z)') + '
';
tooltipContent += '
';
tooltipContent += '
';
tooltipContent += '
' + this.escapeHtml(trans.pattern_help_examples || 'Examples:') + '';
tooltipContent += '
*cotton* ' + this.escapeHtml(trans.pattern_example_1 || 'contains "cotton"') + '
';
tooltipContent += '
iPhone {number} Pro* ' + this.escapeHtml(trans.pattern_example_2 || 'matches "iPhone 15 Pro Max"') + '
';
tooltipContent += '
Size {letter} ' + this.escapeHtml(trans.pattern_example_3 || 'matches "Size M", "Size L"') + '
';
tooltipContent += '
';
var noPatternText = trans.no_patterns || 'No patterns - press Enter to add';
html += '
';
html += '
';
html += '
';
break;
case 'numeric_range':
html += '
';
html += '';
html += '-';
html += '';
html += '
';
html += '
';
break;
case 'multi_numeric_range':
html += '
';
html += '
';
break;
case 'multi_select_tiles':
html += '
';
// Tiles will be populated based on method options
html += '
';
html += '
';
break;
case 'date_range':
html += '
';
html += '';
html += '-';
html += '';
html += '
';
html += '
';
break;
case 'select':
html += '
';
html += '';
html += '
';
html += '
';
break;
case 'boolean':
html += '
';
html += '' + this.escapeHtml(trans.yes || 'Yes') + '';
html += '
';
html += '
';
break;
case 'combination_attributes':
// Build tooltip content
var combTooltip = '
' + this.escapeHtml(trans.combination_help_title || 'Combination Targeting') + '';
combTooltip += '
';
combTooltip += '
' + this.escapeHtml(trans.combination_help_desc || 'Select attributes to target specific product combinations.') + '
';
combTooltip += '
' + this.escapeHtml(trans.combination_help_logic || 'Logic:') + '
';
combTooltip += '
';
combTooltip += '- ' + this.escapeHtml(trans.combination_help_within || 'Within group: OR (Red OR Blue)') + '
';
combTooltip += '- ' + this.escapeHtml(trans.combination_help_between || 'Between groups: AND (Color AND Size)') + '
';
combTooltip += '
';
combTooltip += '
';
// Combination mode from config: 'products', 'combinations', or 'toggle'
var combMode = this.config.combinationMode || 'products';
var showModeToggle = (combMode === 'toggle');
var defaultMode = showModeToggle ? 'products' : combMode;
html += '
';
// Store mode along with attributes: { mode: 'products'|'combinations', attributes: { groupId: [valueIds] } }
html += '
';
break;
default:
html += '
';
break;
}
html += '
';
return html;
},
// Sort options
getSortOptionsArray: function(blockType) {
var trans = this.config.trans || {};
switch (blockType) {
case 'products':
return [
{ value: 'sales', label: trans.sort_bestsellers || 'Best sellers' },
{ value: 'date_add', label: trans.sort_newest || 'Newest' },
{ value: 'price', label: trans.sort_price || 'Price' },
{ value: 'name', label: trans.sort_name || 'Name' },
{ value: 'position', label: trans.sort_position || 'Position' },
{ value: 'quantity', label: trans.sort_stock || 'Stock quantity' },
{ value: 'random', label: trans.sort_random || 'Random' }
];
case 'categories':
return [
{ value: 'name', label: trans.sort_name || 'Name' },
{ value: 'position', label: trans.sort_position || 'Position' },
{ value: 'product_count', label: trans.sort_products || 'Products count' },
{ value: 'date_add', label: trans.sort_newest || 'Newest' }
];
default:
return [
{ value: 'name', label: trans.sort_name || 'Name' },
{ value: 'date_add', label: trans.sort_newest || 'Newest' }
];
}
},
getSortIconClass: function(sortBy, sortDir) {
var isAsc = (sortDir === 'ASC');
switch (sortBy) {
case 'name':
return isAsc ? 'icon-sort-alpha-asc' : 'icon-sort-alpha-desc';
case 'price':
case 'quantity':
case 'product_count':
return isAsc ? 'icon-sort-numeric-asc' : 'icon-sort-numeric-desc';
case 'date_add':
case 'newest_products':
return isAsc ? 'icon-sort-numeric-asc' : 'icon-sort-numeric-desc';
case 'sales':
case 'total_sales':
return isAsc ? 'icon-sort-amount-asc' : 'icon-sort-amount-desc';
case 'position':
return isAsc ? 'icon-sort-numeric-asc' : 'icon-sort-numeric-desc';
case 'random':
return 'icon-random';
default:
return isAsc ? 'icon-sort-amount-asc' : 'icon-sort-amount-desc';
}
},
cycleSortOption: function($btn, blockType) {
var sortOptions = this.getSortOptionsArray(blockType);
var currentSort = $btn.data('sort') || 'sales';
var currentDir = $btn.data('dir') || 'DESC';
// Find current index
var currentIndex = -1;
for (var i = 0; i < sortOptions.length; i++) {
if (sortOptions[i].value === currentSort) {
currentIndex = i;
break;
}
}
// Cycle: first toggle direction, then move to next sort option
var newSort, newDir, newLabel;
if (currentDir === 'DESC') {
// Toggle to ASC, same sort
newSort = currentSort;
newDir = 'ASC';
} else {
// Move to next sort option, reset to DESC
var nextIndex = (currentIndex + 1) % sortOptions.length;
newSort = sortOptions[nextIndex].value;
newDir = 'DESC';
}
// Find label for new sort
for (var j = 0; j < sortOptions.length; j++) {
if (sortOptions[j].value === newSort) {
newLabel = sortOptions[j].label;
break;
}
}
// Update button
$btn.data('sort', newSort);
$btn.data('dir', newDir);
$btn.attr('data-sort', newSort);
$btn.attr('data-dir', newDir);
$btn.attr('title', newLabel + ' ' + (newDir === 'DESC' ? '↓' : '↑'));
$btn.find('i').attr('class', this.getSortIconClass(newSort, newDir));
},
// Validation
validate: function() {
var isRequired = this.$wrapper.data('required') === 1 || this.$wrapper.data('required') === '1';
if (!isRequired) {
return true;
}
// Check if any block has data (groups with selections)
var hasData = false;
this.$wrapper.find('.es-block').each(function() {
if ($(this).find('.selection-group').length > 0) {
hasData = true;
return false; // break
}
});
if (!hasData) {
// Show validation error
this.showValidationError();
return false;
}
// Valid - remove any previous error
this.clearValidationError();
return true;
},
showValidationError: function() {
this.$wrapper.addClass('has-validation-error');
var message = this.$wrapper.data('required-message') || 'Please select at least one item';
// Remove any existing error
this.$wrapper.find('.trait-validation-error').remove();
// Add error message after header
var $error = $('', {
class: 'trait-validation-error',
html: '
' + message
});
this.$wrapper.find('.condition-trait-header').after($error);
// Scroll to error
$('html, body').animate({
scrollTop: this.$wrapper.offset().top - 100
}, 300);
// Expand the trait if collapsed
if (!this.$wrapper.find('.condition-trait-body').hasClass('es-expanded')) {
this.$wrapper.find('.condition-trait-body').addClass('es-expanded');
this.$wrapper.removeClass('collapsed');
}
},
clearValidationError: function() {
this.$wrapper.removeClass('has-validation-error');
this.$wrapper.find('.trait-validation-error').remove();
}
};
})(jQuery);
/**
* Entity Selector - Search Module
* AJAX search, results rendering, category tree, filters, search history
* @partial _search.js
*/
(function($) {
'use strict';
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
window._EntitySelectorMixins.search = {
// Category tree cache
categoryTreeCache: null,
/**
* Perform AJAX search for entities
*/
performSearch: function(appendMode) {
var self = this;
if (!this.activeGroup) return;
this.isLoading = true;
var searchEntity = this.activeGroup.searchEntity;
// Build request data with sort and filter params
var limit = appendMode && this.loadMoreCount ? this.loadMoreCount : 20;
var requestData = {
ajax: 1,
action: 'searchTargetEntities',
trait: 'EntitySelector',
entity_type: searchEntity,
q: this.searchQuery,
limit: limit,
offset: appendMode ? this.searchOffset : 0,
sort_by: this.currentSort ? this.currentSort.field : 'name',
sort_dir: this.currentSort ? this.currentSort.dir : 'ASC'
};
// Add refine query if present
if (this.refineQuery) {
requestData.refine = this.refineQuery;
if (this.refineNegate) {
requestData.refine_negate = 1;
}
}
// Add product-specific filters
if (searchEntity === 'products' && this.filters) {
if (this.filters.inStock) {
requestData.filter_in_stock = 1;
}
if (this.filters.discounted) {
requestData.filter_discounted = 1;
}
if (this.filters.priceMin !== null && this.filters.priceMin !== '') {
requestData.filter_price_min = this.filters.priceMin;
}
if (this.filters.priceMax !== null && this.filters.priceMax !== '') {
requestData.filter_price_max = this.filters.priceMax;
}
if (this.filters.attributes && this.filters.attributes.length > 0) {
requestData.filter_attributes = JSON.stringify(this.filters.attributes);
}
if (this.filters.features && this.filters.features.length > 0) {
requestData.filter_features = JSON.stringify(this.filters.features);
}
}
// Add entity-specific filters for non-product entities
if (searchEntity !== 'products' && this.filters) {
// Product count range (categories, manufacturers, suppliers, attributes, features)
if (this.filters.productCountMin !== null && this.filters.productCountMin !== '') {
requestData.filter_product_count_min = this.filters.productCountMin;
}
if (this.filters.productCountMax !== null && this.filters.productCountMax !== '') {
requestData.filter_product_count_max = this.filters.productCountMax;
}
// Category-specific
if (searchEntity === 'categories') {
if (this.filters.depth) {
requestData.filter_depth = this.filters.depth;
}
if (this.filters.hasProducts) {
requestData.filter_has_products = 1;
}
if (this.filters.hasDescription) {
requestData.filter_has_description = 1;
}
if (this.filters.hasImage) {
requestData.filter_has_image = 1;
}
if (this.filters.salesMin !== null && this.filters.salesMin !== '') {
requestData.filter_sales_min = this.filters.salesMin;
}
if (this.filters.salesMax !== null && this.filters.salesMax !== '') {
requestData.filter_sales_max = this.filters.salesMax;
}
if (this.filters.turnoverMin !== null && this.filters.turnoverMin !== '') {
requestData.filter_turnover_min = this.filters.turnoverMin;
}
if (this.filters.turnoverMax !== null && this.filters.turnoverMax !== '') {
requestData.filter_turnover_max = this.filters.turnoverMax;
}
if (this.filters.activeOnly) {
requestData.filter_active = 1;
}
}
// Manufacturer-specific
if (searchEntity === 'manufacturers') {
if (this.filters.salesMin !== null && this.filters.salesMin !== '') {
requestData.filter_sales_min = this.filters.salesMin;
}
if (this.filters.salesMax !== null && this.filters.salesMax !== '') {
requestData.filter_sales_max = this.filters.salesMax;
}
if (this.filters.turnoverMin !== null && this.filters.turnoverMin !== '') {
requestData.filter_turnover_min = this.filters.turnoverMin;
}
if (this.filters.turnoverMax !== null && this.filters.turnoverMax !== '') {
requestData.filter_turnover_max = this.filters.turnoverMax;
}
if (this.filters.dateAddFrom) {
requestData.filter_date_add_from = this.filters.dateAddFrom;
}
if (this.filters.dateAddTo) {
requestData.filter_date_add_to = this.filters.dateAddTo;
}
if (this.filters.lastProductFrom) {
requestData.filter_last_product_from = this.filters.lastProductFrom;
}
if (this.filters.lastProductTo) {
requestData.filter_last_product_to = this.filters.lastProductTo;
}
if (this.filters.activeOnly) {
requestData.filter_active = 1;
}
}
// Supplier-specific
if (searchEntity === 'suppliers') {
if (this.filters.salesMin !== null && this.filters.salesMin !== '') {
requestData.filter_sales_min = this.filters.salesMin;
}
if (this.filters.salesMax !== null && this.filters.salesMax !== '') {
requestData.filter_sales_max = this.filters.salesMax;
}
if (this.filters.turnoverMin !== null && this.filters.turnoverMin !== '') {
requestData.filter_turnover_min = this.filters.turnoverMin;
}
if (this.filters.turnoverMax !== null && this.filters.turnoverMax !== '') {
requestData.filter_turnover_max = this.filters.turnoverMax;
}
if (this.filters.dateAddFrom) {
requestData.filter_date_add_from = this.filters.dateAddFrom;
}
if (this.filters.dateAddTo) {
requestData.filter_date_add_to = this.filters.dateAddTo;
}
if (this.filters.lastProductFrom) {
requestData.filter_last_product_from = this.filters.lastProductFrom;
}
if (this.filters.lastProductTo) {
requestData.filter_last_product_to = this.filters.lastProductTo;
}
if (this.filters.activeOnly) {
requestData.filter_active = 1;
}
}
// Attribute-specific
if (searchEntity === 'attributes') {
if (this.filters.salesMin !== null && this.filters.salesMin !== '') {
requestData.filter_sales_min = this.filters.salesMin;
}
if (this.filters.salesMax !== null && this.filters.salesMax !== '') {
requestData.filter_sales_max = this.filters.salesMax;
}
if (this.filters.turnoverMin !== null && this.filters.turnoverMin !== '') {
requestData.filter_turnover_min = this.filters.turnoverMin;
}
if (this.filters.turnoverMax !== null && this.filters.turnoverMax !== '') {
requestData.filter_turnover_max = this.filters.turnoverMax;
}
if (this.filters.attributeGroup) {
requestData.filter_attribute_group = this.filters.attributeGroup;
}
if (this.filters.isColor) {
requestData.filter_is_color = 1;
}
}
// Feature-specific
if (searchEntity === 'features') {
if (this.filters.salesMin !== null && this.filters.salesMin !== '') {
requestData.filter_sales_min = this.filters.salesMin;
}
if (this.filters.salesMax !== null && this.filters.salesMax !== '') {
requestData.filter_sales_max = this.filters.salesMax;
}
if (this.filters.turnoverMin !== null && this.filters.turnoverMin !== '') {
requestData.filter_turnover_min = this.filters.turnoverMin;
}
if (this.filters.turnoverMax !== null && this.filters.turnoverMax !== '') {
requestData.filter_turnover_max = this.filters.turnoverMax;
}
if (this.filters.featureGroup) {
requestData.filter_feature_group = this.filters.featureGroup;
}
if (this.filters.isCustom) {
requestData.filter_is_custom = 1;
}
}
// CMS-specific
if (searchEntity === 'cms') {
if (this.filters.activeOnly) {
requestData.filter_active = 1;
}
if (this.filters.indexable) {
requestData.filter_indexable = 1;
}
}
// CMS Categories-specific
if (searchEntity === 'cms_categories') {
if (this.filters.activeOnly) {
requestData.filter_active = 1;
}
}
// Countries-specific
if (searchEntity === 'countries') {
if (this.filters.activeOnly) {
requestData.filter_active = 1;
}
if (this.filters.hasHolidays) {
requestData.filter_has_holidays = 1;
}
if (this.filters.containsStates) {
requestData.filter_contains_states = 1;
}
if (this.filters.zone) {
requestData.filter_zone = this.filters.zone;
}
}
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: requestData,
success: function(response) {
self.isLoading = false;
if (!response.success) return;
// Save to search history if query is not empty and has results
if (self.searchQuery && self.searchQuery.length >= 2 && response.total > 0) {
self.addToSearchHistory(searchEntity, self.searchQuery);
}
if (appendMode) {
self.searchResults = self.searchResults.concat(response.results || []);
} else {
self.searchResults = response.results || [];
}
self.searchTotal = response.total || 0;
self.searchOffset = appendMode ? self.searchOffset + (response.results || []).length : (response.results || []).length;
self.renderSearchResults(appendMode);
self.$dropdown.addClass('show');
},
error: function() {
self.isLoading = false;
}
});
},
/**
* Render search results in the dropdown
*/
renderSearchResults: function(appendMode) {
var self = this;
var trans = this.config.trans || {};
var $container = this.$dropdown.find('.dropdown-results');
// Get selected IDs from current picker (to mark as selected)
// and hidden IDs from sibling exclude pickers with same entity type (to hide completely)
var selectedIds = [];
var hiddenIds = [];
if (this.activeGroup) {
var $block = this.$wrapper.find('.es-block[data-block-type="' + this.activeGroup.blockType + '"]');
var $group = $block.find('.selection-group[data-group-index="' + this.activeGroup.groupIndex + '"]');
var currentSearchEntity = this.activeGroup.searchEntity;
var currentExcludeIndex = this.activeGroup.excludeIndex;
if (this.activeGroup.section === 'include') {
// For include section, just get current picker's selections
var $picker = $group.find('.include-picker');
$picker.find('.entity-chip').each(function() {
selectedIds.push(String($(this).data('id')));
});
} else {
// For exclude section, get current picker's selections AND
// collect IDs from sibling exclude rows with same entity type to hide
var $currentExcludeRow = $group.find('.exclude-row[data-exclude-index="' + currentExcludeIndex + '"]');
var $currentPicker = $currentExcludeRow.find('.exclude-picker');
// Get selected IDs from current exclude row
$currentPicker.find('.entity-chip').each(function() {
selectedIds.push(String($(this).data('id')));
});
// Get hidden IDs from OTHER exclude rows with the same entity type
$group.find('.exclude-row').each(function() {
var $row = $(this);
var rowIndex = parseInt($row.data('excludeIndex'), 10);
// Skip current exclude row
if (rowIndex === currentExcludeIndex) return;
var $picker = $row.find('.exclude-picker');
var rowEntityType = $picker.attr('data-search-entity') || self.activeGroup.blockType;
// Only collect if same entity type
if (rowEntityType === currentSearchEntity) {
$picker.find('.entity-chip').each(function() {
hiddenIds.push(String($(this).data('id')));
});
}
});
}
}
// Check if this is a product search
var isProductSearch = this.activeGroup && this.activeGroup.searchEntity === 'products';
var isListView = this.viewMode === 'list';
// Show/hide results header for products in list view
this.$dropdown.find('.results-header').toggle(isProductSearch && isListView);
// Build HTML - filter out items that are hidden (selected in sibling exclude rows)
var visibleResults = this.searchResults.filter(function(item) {
return hiddenIds.indexOf(String(item.id)) === -1;
});
// Update count (show visible count and total, noting hidden items if any)
var hiddenCount = this.searchResults.length - visibleResults.length;
var countText = visibleResults.length + ' / ' + this.searchTotal + ' results';
if (hiddenCount > 0) {
countText += ' (' + hiddenCount + ' hidden)';
}
this.$dropdown.find('.results-count').text(countText);
var html = '';
if (visibleResults.length === 0 && !appendMode) {
html = '
' + (trans.no_results || 'No results found') + '
';
} else {
visibleResults.forEach(function(item) {
var isSelected = selectedIds.indexOf(String(item.id)) !== -1;
var itemClass = 'dropdown-item' + (isSelected ? ' selected' : '');
if (item.type === 'product') itemClass += ' result-item-product';
html += '
';
html += '
';
var searchEntity = self.activeGroup ? self.activeGroup.searchEntity : null;
// Countries show flags
if (searchEntity === 'countries' && item.iso_code) {
var flagUrl = 'https://flagcdn.com/w40/' + item.iso_code.toLowerCase() + '.png';
html += '
';
} else if (item.image) {
html += '
';
} else {
// Entity-specific icons
var iconClass = 'icon-cube'; // default
if (searchEntity === 'categories') iconClass = 'icon-folder';
else if (searchEntity === 'manufacturers') iconClass = 'icon-building';
else if (searchEntity === 'suppliers') iconClass = 'icon-truck';
else if (searchEntity === 'attributes') iconClass = 'icon-paint-brush';
else if (searchEntity === 'features') iconClass = 'icon-list-ul';
else if (searchEntity === 'cms') iconClass = 'icon-file-text-o';
else if (searchEntity === 'cms_categories') iconClass = 'icon-folder-o';
html += '
';
}
html += '
';
html += '
' + self.escapeHtml(item.name) + '
';
if (item.subtitle) {
// Split multi-line subtitles into separate divs for styling
var subtitleLines = item.subtitle.split('\n');
html += '
';
subtitleLines.forEach(function(line, idx) {
var lineClass = idx === 0 ? 'subtitle-line subtitle-line-primary' : 'subtitle-line subtitle-line-secondary';
html += '
' + self.escapeHtml(line) + '
';
});
html += '
';
}
html += '
';
// Add product-specific columns (price, sale price, stock, sold)
if (item.type === 'product') {
if (isListView) {
// List view: full columns
// Regular price
html += '
';
html += '' + (item.regular_price_formatted || item.price_formatted || '') + '';
html += '
';
// Sale price (only if discounted)
if (item.has_discount) {
html += '
';
html += '' + (item.price_formatted || '') + '';
html += '
';
} else {
html += '
';
}
// Stock column
var stockClass = item.stock_status === 'out_of_stock' ? 'stock-out' :
(item.stock_status === 'low_stock' ? 'stock-low' : 'stock-ok');
html += '
';
html += '' + (item.stock_qty !== undefined ? item.stock_qty : '') + '';
html += '
';
// Sales column
html += '
';
html += '' + (item.sales_qty !== undefined ? item.sales_qty : '0') + '';
html += '
';
} else {
// Grid view: compact info line
var gridStockClass = item.stock_status === 'out_of_stock' ? 'stock-out' :
(item.stock_status === 'low_stock' ? 'stock-low' : '');
html += '
';
html += '' + (item.price_formatted || '') + '';
if (item.stock_qty !== undefined) {
html += '' + item.stock_qty + ' qty';
}
if (item.has_discount) {
html += '-' + (item.discount_percent || '') + '%';
}
html += '
';
}
}
html += '
';
});
}
if (appendMode) {
$container.append(html);
} else {
$container.html(html);
}
// Show/hide load more controls and update remaining count
var hasMore = this.searchResults.length < this.searchTotal;
var $loadMoreControls = this.$dropdown.find('.load-more-controls');
$loadMoreControls.toggle(hasMore);
if (hasMore) {
var remaining = this.searchTotal - this.searchResults.length;
$loadMoreControls.find('.remaining-count').text(remaining);
// Update "All" option in dropdown
var $select = $loadMoreControls.find('.load-more-select');
var $allOption = $select.find('option[data-all="true"]');
if ($allOption.length) {
$allOption.val(remaining).text((trans.all || 'All') + ' (' + remaining + ')');
} else {
$select.find('option:last').after('
');
}
}
// Ensure dropdown-actions are visible and history button is deactivated
this.$dropdown.find('.dropdown-actions').show();
this.$dropdown.find('.btn-show-history').removeClass('active');
// Disable history button if no search history for current entity type
var entityType = this.activeGroup ? this.activeGroup.searchEntity : null;
var hasHistory = entityType && this.getSearchHistory(entityType).length > 0;
this.$dropdown.find('.btn-show-history').prop('disabled', !hasHistory);
},
// NOTE: Tree methods (loadCategoryTree, renderCategoryTree, filterCategoryTree,
// findTreeDescendants, findTreeAncestors, updateSelectChildrenButtons) are
// defined in _tree.js which is merged later and takes precedence.
// =========================================================================
// Search History
// =========================================================================
loadSearchHistory: function() {
try {
var stored = localStorage.getItem(this.searchHistoryKey);
this.searchHistory = stored ? JSON.parse(stored) : {};
} catch (e) {
this.searchHistory = {};
}
},
saveSearchHistory: function() {
try {
localStorage.setItem(this.searchHistoryKey, JSON.stringify(this.searchHistory));
} catch (e) {
// localStorage might be full or unavailable
}
},
addToSearchHistory: function(entityType, query) {
if (!query || query.length < 2) return;
if (!this.searchHistory[entityType]) {
this.searchHistory[entityType] = [];
}
var history = this.searchHistory[entityType];
// Remove if already exists (will re-add at top)
var existingIndex = history.indexOf(query);
if (existingIndex !== -1) {
history.splice(existingIndex, 1);
}
// Add at beginning
history.unshift(query);
// Trim to max
if (history.length > this.searchHistoryMax) {
history = history.slice(0, this.searchHistoryMax);
}
this.searchHistory[entityType] = history;
this.saveSearchHistory();
},
removeFromSearchHistory: function(entityType, query) {
if (!this.searchHistory[entityType]) return;
var index = this.searchHistory[entityType].indexOf(query);
if (index !== -1) {
this.searchHistory[entityType].splice(index, 1);
this.saveSearchHistory();
}
},
getSearchHistory: function(entityType) {
return this.searchHistory[entityType] || [];
},
showSearchHistory: function(entityType) {
var history = this.getSearchHistory(entityType);
var trans = this.config.trans || {};
var $container = this.$dropdown.find('.dropdown-results');
// Update header
this.$dropdown.find('.results-count').text(trans.recent_searches || 'Recent searches');
// Hide filters, actions, and results header for history view
this.$dropdown.find('.dropdown-actions').hide();
this.$dropdown.find('.filter-panel').removeClass('show');
this.$dropdown.find('.btn-toggle-filters').removeClass('active');
this.$dropdown.find('.results-header').hide();
if (!history.length) {
// No history - just do a regular search
this.performSearch();
return;
}
// Build history items
var html = '
';
for (var i = 0; i < history.length; i++) {
var query = history[i];
html += '
';
html += '';
html += '' + this.escapeHtml(query) + '';
html += '';
html += '
';
}
html += '
';
$container.html(html);
this.$dropdown.addClass('show');
},
// =========================================================================
// Filter Methods
// =========================================================================
refreshSearch: function() {
// In tree view mode, re-filter the tree instead of doing a flat AJAX search
if (this.viewMode === 'tree') {
this.filterCategoryTree(this.searchQuery || '');
return;
}
this.searchOffset = 0;
this.loadMoreCount = 20;
// Reset load more select to default
if (this.$dropdown) {
this.$dropdown.find('.load-more-select').val('20');
// Remove the dynamic "All" option
this.$dropdown.find('.load-more-select option[data-all="true"]').remove();
}
this.performSearch(false);
},
clearFilters: function() {
this.refineQuery = '';
this.refineNegate = false;
this.filters = {
inStock: false,
discounted: false,
priceMin: null,
priceMax: null,
attributes: [],
features: [],
// Entity-specific filters
productCountMin: null,
productCountMax: null,
salesMin: null,
salesMax: null,
turnoverMin: null,
turnoverMax: null,
depth: null,
hasProducts: false,
hasDescription: false,
hasImage: false,
activeOnly: true,
attributeGroup: null,
featureGroup: null,
dateAddFrom: null,
dateAddTo: null,
lastProductFrom: null,
lastProductTo: null,
// Country-specific filters
hasHolidays: false,
containsStates: false,
zone: null
};
if (this.$dropdown) {
var trans = this.config.trans || {};
this.$dropdown.find('.refine-input').val('').attr('placeholder', trans.refine_short || 'Refine...');
this.$dropdown.find('.btn-clear-refine').hide();
this.$dropdown.find('.btn-refine-negate').removeClass('active');
this.$dropdown.find('.filter-in-stock').prop('checked', false);
this.$dropdown.find('.filter-discounted').prop('checked', false);
this.$dropdown.find('.filter-price-min').val('');
this.$dropdown.find('.filter-price-max').val('');
this.$dropdown.find('.filter-attr-chip').removeClass('active');
this.$dropdown.find('.filter-feat-chip').removeClass('active');
this.$dropdown.find('.filter-group-toggle').removeClass('active has-selection');
this.$dropdown.find('.filter-row-values').hide();
// Clear entity-specific filter inputs
this.$dropdown.find('.filter-product-count-min, .filter-product-count-max').val('');
this.$dropdown.find('.filter-sales-min, .filter-sales-max').val('');
this.$dropdown.find('.filter-turnover-min, .filter-turnover-max').val('');
this.$dropdown.find('.filter-date-add-from, .filter-date-add-to').val('');
this.$dropdown.find('.filter-last-product-from, .filter-last-product-to').val('');
this.$dropdown.find('.filter-depth-select').val('');
this.$dropdown.find('.filter-has-products').prop('checked', false);
this.$dropdown.find('.filter-has-description').prop('checked', false);
this.$dropdown.find('.filter-has-image').prop('checked', false);
this.$dropdown.find('.filter-active-only').prop('checked', true);
this.$dropdown.find('.filter-attribute-group-select, .filter-feature-group-select').val('');
// Country filters
this.$dropdown.find('.filter-has-holidays').prop('checked', false);
this.$dropdown.find('.filter-contains-states').prop('checked', false);
this.$dropdown.find('.filter-zone-select').val('');
}
this.refreshSearch();
},
// Reset filters without triggering a search (used when switching entity types)
resetFiltersWithoutSearch: function() {
this.refineQuery = '';
this.refineNegate = false;
this.filters = {
inStock: false,
discounted: false,
priceMin: null,
priceMax: null,
attributes: [],
features: [],
productCountMin: null,
productCountMax: null,
salesMin: null,
salesMax: null,
turnoverMin: null,
turnoverMax: null,
depth: null,
hasProducts: false,
hasDescription: false,
hasImage: false,
activeOnly: true,
attributeGroup: null,
featureGroup: null,
dateAddFrom: null,
dateAddTo: null,
lastProductFrom: null,
lastProductTo: null,
// Country-specific filters
hasHolidays: false,
containsStates: false,
zone: null
};
if (this.$dropdown) {
var trans = this.config.trans || {};
this.$dropdown.find('.refine-input').val('').attr('placeholder', trans.refine_short || 'Refine...');
this.$dropdown.find('.btn-clear-refine').hide();
this.$dropdown.find('.btn-refine-negate').removeClass('active');
this.$dropdown.find('.filter-in-stock').prop('checked', false);
this.$dropdown.find('.filter-discounted').prop('checked', false);
this.$dropdown.find('.filter-price-min').val('');
this.$dropdown.find('.filter-price-max').val('');
this.$dropdown.find('.filter-attr-chip').removeClass('active');
this.$dropdown.find('.filter-feat-chip').removeClass('active');
this.$dropdown.find('.filter-group-toggle').removeClass('active has-selection');
this.$dropdown.find('.filter-row-values').hide();
this.$dropdown.find('.filter-product-count-min, .filter-product-count-max').val('');
this.$dropdown.find('.filter-sales-min, .filter-sales-max').val('');
this.$dropdown.find('.filter-turnover-min, .filter-turnover-max').val('');
this.$dropdown.find('.filter-date-add-from, .filter-date-add-to').val('');
this.$dropdown.find('.filter-last-product-from, .filter-last-product-to').val('');
this.$dropdown.find('.filter-depth-select').val('');
this.$dropdown.find('.filter-has-products').prop('checked', false);
this.$dropdown.find('.filter-has-description').prop('checked', false);
this.$dropdown.find('.filter-has-image').prop('checked', false);
this.$dropdown.find('.filter-active-only').prop('checked', true);
this.$dropdown.find('.filter-attribute-group-select, .filter-feature-group-select').val('');
// Country filters
this.$dropdown.find('.filter-has-holidays').prop('checked', false);
this.$dropdown.find('.filter-contains-states').prop('checked', false);
this.$dropdown.find('.filter-zone-select').val('');
}
// Note: Does NOT call refreshSearch() - caller handles search/load
},
updateFilterPanelForEntity: function(entityType) {
if (!this.$dropdown) {
return;
}
var $panel = this.$dropdown.find('.filter-panel');
// Hide all filter rows first
$panel.find('.filter-row').hide();
// Show/hide tree view option based on entity type
var $treeOption = this.$dropdown.find('.view-mode-select option.tree-view-option');
if (entityType === 'categories' || entityType === 'cms_categories') {
$treeOption.prop('disabled', false).prop('hidden', false);
// Auto-switch to tree view for categories
if (this.viewMode !== 'tree') {
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');
this.loadCategoryTree();
} else {
this.loadCategoryTree();
}
} else {
$treeOption.prop('disabled', true).prop('hidden', true);
// If currently in tree mode, switch back to list
if (this.viewMode === 'tree') {
this.viewMode = 'list';
this.$dropdown.find('.view-mode-select').val('list');
this.$dropdown.removeClass('view-tree').addClass('view-list');
}
}
// Show entity-specific filter row (prepare visibility, but don't auto-expand panel)
if (entityType === 'products') {
// Prepare the correct rows to be visible when panel is shown
$panel.find('.filter-row-quick').show();
// Show attribute/feature rows if we have cached data
if (this.filterableData) {
if (this.filterableData.attributes && this.filterableData.attributes.length > 0) {
this.$dropdown.find('.filter-row-attributes').show();
}
if (this.filterableData.features && this.filterableData.features.length > 0) {
this.$dropdown.find('.filter-row-features').show();
}
}
} else if (entityType === 'categories') {
$panel.find('.filter-row-entity-categories').show();
} else if (entityType === 'manufacturers') {
$panel.find('.filter-row-entity-manufacturers').show();
} else if (entityType === 'suppliers') {
$panel.find('.filter-row-entity-suppliers').show();
} else if (entityType === 'attributes') {
$panel.find('.filter-row-entity-attributes').show();
this.loadAttributeGroups();
} else if (entityType === 'features') {
$panel.find('.filter-row-entity-features').show();
} else if (entityType === 'cms') {
$panel.find('.filter-row-entity-cms').show();
} else if (entityType === 'cms_categories') {
$panel.find('.filter-row-entity-cms-categories').show();
} else if (entityType === 'countries') {
$panel.find('.filter-row-entity-countries').show();
this.loadZonesForCountryFilter();
}
},
loadAttributeGroups: function() {
var self = this;
var $select = this.$dropdown.find('.filter-attribute-group-select');
// Already loaded?
if ($select.find('option').length > 1) return;
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getAttributeGroups',
trait: 'EntitySelector'
},
success: function(response) {
if (response.success && response.groups) {
$.each(response.groups, function(i, group) {
$select.append('
');
});
}
}
});
},
loadFeatureGroups: function() {
var self = this;
var $select = this.$dropdown.find('.filter-feature-group-select');
// Already loaded?
if ($select.find('option').length > 1) return;
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getFeatureGroups',
trait: 'EntitySelector'
},
success: function(response) {
if (response.success && response.groups) {
$.each(response.groups, function(i, group) {
$select.append('
');
});
}
}
});
}
};
})(jQuery);
/**
* Entity Selector - Dropdown Module
* Search dropdown UI creation and positioning
* @partial _dropdown.js
*/
(function($) {
'use strict';
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
window._EntitySelectorMixins.dropdown = {
createDropdown: function() {
this.$wrapper.find('.es-search-dropdown').remove();
var trans = this.config.trans || {};
var html = '
';
// Header with results count, actions, sort controls, view mode
html += ''; // End dropdown-header
// Filter panel
html += '
';
// Quick filters row (for products)
html += '
';
// Attribute/Feature filter toggles for products
html += '
';
html += '
' + (trans.attributes || 'Attributes') + ':';
html += '
';
html += '
';
html += '
';
html += '
';
html += '
' + (trans.features || 'Features') + ':';
html += '
';
html += '
';
html += '
';
// Entity-specific filters: Categories
html += '
';
html += '
';
html += '
';
html += '
';
// Entity-specific filters: Manufacturers
html += '
';
html += '
';
html += '
';
html += '
';
// Entity-specific filters: Suppliers
html += '
';
html += '
';
html += '
';
html += '
';
// Entity-specific filters: Attributes
html += '
';
html += '
';
html += '
';
html += '
';
html += ' ' + (trans.attribute_group || 'Group') + ':';
html += '';
html += '
';
html += '
';
html += '
';
html += '
';
html += '
';
// Entity-specific filters: Features
html += '
';
html += '
';
html += '
';
html += '
';
html += ' ' + (trans.feature_group || 'Group') + ':';
html += '';
html += '
';
html += '
';
html += '
';
html += '
';
html += '
';
// Entity-specific filters: CMS Pages
html += '
';
html += '';
html += '';
html += '';
html += '
';
// Entity-specific filters: CMS Categories
html += '
';
html += '';
html += '';
html += '
';
// Entity-specific filters: Countries
html += '
';
html += '
'; // End filter-panel
// Results header for list view (product columns)
html += '';
// Results
html += '
';
// Footer - unified load more + actions
html += '';
html += '
';
this.$dropdown = $(html);
$('body').append(this.$dropdown);
},
hideDropdown: function() {
if (this.$dropdown) {
this.$dropdown.removeClass('show');
}
this.activeGroup = null;
},
positionDropdown: function($input) {
if (!this.$dropdown) return;
var $picker = $input.closest('.value-picker');
var $searchBox = $input.closest('.entity-search-box');
// Get absolute positions (dropdown is appended to body)
var searchBoxOffset = $searchBox.offset();
var searchBoxHeight = $searchBox.outerHeight();
var pickerOffset = $picker.offset();
var pickerWidth = $picker.outerWidth();
// Calculate position relative to document
var dropdownTop = searchBoxOffset.top + searchBoxHeight + 4;
var dropdownLeft = pickerOffset.left;
var dropdownWidth = Math.max(pickerWidth, 400);
// Ensure dropdown doesn't overflow the viewport horizontally
var viewportWidth = $(window).width();
if (dropdownLeft + dropdownWidth > viewportWidth - 10) {
dropdownWidth = viewportWidth - dropdownLeft - 10;
}
// Ensure dropdown doesn't overflow viewport vertically
var viewportHeight = $(window).height();
var scrollTop = $(window).scrollTop();
var maxHeight = viewportHeight - (dropdownTop - scrollTop) - 20;
maxHeight = Math.max(maxHeight, 400);
this.$dropdown.css({
position: 'absolute',
top: dropdownTop,
left: dropdownLeft,
width: dropdownWidth,
maxHeight: maxHeight,
zIndex: 10000
});
// Show the dropdown
this.$dropdown.addClass('show');
}
};
})(jQuery);
/**
* Entity Selector - Chips Module
* Entity chip rendering, selection management, and pattern tag handling
* @partial _chips.js
*
* EXTRACTION SOURCE: assets/js/admin/entity-selector.js
*
* Contains:
* - addSelection() / addSelectionNoUpdate() - Add entity chip to picker
* - removeSelection() - Remove chip and update state
* - updateChipsVisibility() - Show/hide based on count
* - loadExistingSelections() - Load saved values on init
* - collectPickerEntities() / loadPickerValues() - Entity loading helpers
* - Pattern tag methods: addPatternTag, getPatternTags, updateDraftTagCount
* - Single mode: getCurrentSingleSelection, showReplaceConfirmation
* - Count updates: updateConditionCount, updateGroupCounts, updateGroupTotalCount
*/
(function($) {
'use strict';
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
window._EntitySelectorMixins.chips = {
// =========================================================================
// Selection Methods (Entity Chips)
// =========================================================================
addSelection: function($picker, id, name, data) {
this.addSelectionNoUpdate($picker, id, name, data);
if (this.config.mode !== 'single') {
var $chips = $picker.find('.entity-chips');
this.updateChipsVisibility($chips);
}
},
addSelectionNoUpdate: function($picker, id, name, data) {
var $chips = $picker.find('.entity-chips');
var $block = $picker.closest('.es-block');
// Check for global single mode (only ONE item across ALL entity types)
var globalMode = this.config.mode || 'multi';
if (globalMode === 'single') {
// Clear ALL selections in ALL blocks (across all entity types)
this.$wrapper.find('.entity-chips .entity-chip').remove();
// Clear all selected states in dropdown
if (this.$dropdown) {
this.$dropdown.find('.dropdown-item.selected, .tree-item.selected').removeClass('selected');
}
} else {
// Check if this block is in per-block single mode
var blockMode = $block.data('mode') || 'multi';
// In per-block single mode, clear chips in THIS block only
if (blockMode === 'single') {
$chips.find('.entity-chip').remove();
// Also deselect all items in dropdown
if (this.$dropdown) {
this.$dropdown.find('.dropdown-item.selected, .tree-item.selected').removeClass('selected');
}
}
}
if ($chips.find('.entity-chip[data-id="' + id + '"]').length) {
return;
}
// Remove empty state placeholder
var hadEmpty = $chips.find('.chips-empty-state').length > 0;
$chips.find('.chips-empty-state').remove();
// Check if this is a country entity (for flag and holiday preview)
var blockType = $block.data('blockType') || '';
var searchEntity = $picker.attr('data-search-entity') || blockType;
var isCountry = (searchEntity === 'countries');
var html = '
';
// Icon: flag for countries, image if available, or entity type icon
if (isCountry && data && data.iso_code) {
html += '
';
} else if (data && data.image) {
html += '
';
} else {
// Entity type icon from block config
var blockIcon = '';
if (this.config && this.config.blocks) {
var bt = $block.data('blockType') || '';
var blockDef = this.config.blocks[bt];
if (blockDef && blockDef.icon) {
blockIcon = blockDef.icon;
}
}
if (blockIcon) {
html += '';
}
}
html += '' + this.escapeHtml(name) + '';
// Country: add holiday preview button
if (isCountry) {
html += '';
}
html += '';
html += '';
$chips.append(html);
},
removeSelection: function($picker, id) {
var $chips = $picker.find('.entity-chips');
$picker.find('.entity-chip[data-id="' + id + '"]').remove();
this.updateChipsVisibility($chips);
},
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;
// Ensure wrapper always exists
this.ensureChipsWrapper($chips);
if (totalCount === 0) {
// Show empty state, hide toolbar
var placeholder = $chips.data('placeholder') || trans.no_items_selected || 'No items selected';
if (!$chips.find('.chips-empty-state').length) {
$chips.html('
' + self.escapeHtml(placeholder) + '');
}
var $wrapper = $chips.closest('.chips-wrapper');
if ($wrapper.length) {
$wrapper.find('.chips-toolbar').removeClass('has-chips').hide();
$wrapper.find('.chips-load-more').hide();
}
return;
}
// Has chips — show toolbar, remove empty state
$chips.find('.chips-empty-state').remove();
var $wrapper = $chips.closest('.chips-wrapper');
var $toolbar = $wrapper.find('.chips-toolbar');
var $loadMore = $wrapper.find('.chips-load-more');
// 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 {
visibleCount++;
}
}
});
// Update toolbar (always show when we have chips)
$toolbar.addClass('has-chips').show();
this.updateChipsToolbar($toolbar, totalCount, filteredCount, searchTerm);
// Update load more select dropdown
var hiddenByPagination = filteredCount - visibleCount;
if (hiddenByPagination > 0 && !isExpanded) {
var loadText = trans.load || 'Load';
var remainingText = (trans.remaining || '{count} remaining').replace('{count}', hiddenByPagination);
var loadMoreHtml = '
' + loadText + '' +
'
' +
'
' + remainingText + '';
$loadMore.html(loadMoreHtml).show();
} else if (isExpanded && filteredCount > (this.maxVisibleChips || 12)) {
var collapseText = trans.collapse || 'Collapse';
$loadMore.html(
'
'
).show();
} else {
$loadMore.hide();
}
},
ensureChipsWrapper: function($chips) {
// Always create wrapper if missing
var $wrapper = $chips.closest('.chips-wrapper');
if (!$wrapper.length) {
$chips.wrap('
');
$wrapper = $chips.closest('.chips-wrapper');
$wrapper.prepend('
');
$wrapper.append('
');
}
// Skip toolbar POPULATION for single mode
if (this.config.mode === 'single') {
return;
}
var $block = $chips.closest('.es-block');
if ($block.data('mode') === 'single') {
return;
}
// If toolbar already populated, nothing to do
var $toolbar = $wrapper.find('.chips-toolbar');
if ($toolbar.children().length) {
return;
}
// Populate toolbar content
var trans = this.config.trans || {};
var toolbarHtml =
'
' +
'
' +
'
' +
'
';
$toolbar.html(toolbarHtml);
// 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);
});
// Sort select
$wrapper.on('change', '.chips-sort-select', function() {
var sortBy = $(this).val();
self.sortChips($chips, sortBy);
});
// 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');
});
// Clear search
$wrapper.find('.chips-search-input').val('');
self.updateChipsVisibility($chips);
});
// Load more select dropdown
$wrapper.on('change', '.load-more-select', function() {
var loadCount = $(this).val();
if (loadCount === 'all') {
$chips.addClass('chips-expanded');
self.maxVisibleChips = 999999;
} else {
self.maxVisibleChips = (self.maxVisibleChips || 12) + parseInt(loadCount, 10);
}
self.updateChipsVisibility($chips);
});
// Collapse button
$wrapper.on('click', '.btn-collapse-chips', function() {
$chips.removeClass('chips-expanded');
self.maxVisibleChips = 12;
self.updateChipsVisibility($chips);
});
},
/**
* Sort chips by specified criteria
*/
sortChips: function($chips, sortBy) {
var $allChips = $chips.find('.entity-chip');
if ($allChips.length < 2) return;
var sorted = $allChips.toArray().sort(function(a, b) {
var $a = $(a);
var $b = $(b);
switch (sortBy) {
case 'name_asc':
var nameA = ($a.find('.chip-name').text() || '').toLowerCase();
var nameB = ($b.find('.chip-name').text() || '').toLowerCase();
return nameA.localeCompare(nameB);
case 'name_desc':
var nameA2 = ($a.find('.chip-name').text() || '').toLowerCase();
var nameB2 = ($b.find('.chip-name').text() || '').toLowerCase();
return nameB2.localeCompare(nameA2);
case 'added':
default:
// Keep original DOM order (order added)
return 0;
}
});
// Re-append in sorted order
$.each(sorted, function(i, chip) {
$chips.append(chip);
});
this.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(
'
' + filteredCount + '' +
'
/' +
'
' + totalCount + ''
);
$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();
}
},
// =========================================================================
// Loading/Initialization
// =========================================================================
loadExistingSelections: function() {
var self = this;
// Collect all entity IDs to load, grouped by entity type
var entitiesToLoad = {}; // { entity_type: { ids: [], pickers: [] } }
this.$wrapper.find('.selection-group').each(function() {
var $group = $(this);
var $block = $group.closest('.es-block');
var blockType = $block.data('blockType');
// Load include values
var $includePicker = $group.find('.include-picker');
self.collectPickerEntities($includePicker, blockType, entitiesToLoad);
// Enhance the include method select if not already enhanced
self.enhanceMethodSelect($group.find('.include-method-select'));
// Load exclude values from each exclude row
$group.find('.exclude-row').each(function() {
var $excludeRow = $(this);
self.collectPickerEntities($excludeRow.find('.exclude-picker'), blockType, entitiesToLoad);
// Enhance the exclude method select if not already enhanced
self.enhanceMethodSelect($excludeRow.find('.exclude-method-select'));
});
// Lock method selector if excludes exist
var hasExcludes = $group.find('.group-excludes.has-excludes').length > 0;
if (hasExcludes) {
self.updateMethodSelectorLock($group, true);
}
});
// 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;
// Deduplicate IDs
var uniqueIds = data.ids.filter(function(id, index, arr) {
return arr.indexOf(id) === index;
});
bulkRequest[entityType] = uniqueIds;
hasEntities = true;
});
// Skip AJAX if no entities to load
if (!hasEntities) {
return;
}
// 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;
}
try {
// 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 = [];
// Check if this is a country entity
var isCountry = (entityType === 'countries');
// 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 = '
';
// Icon: flag, image, or entity type icon
if (isCountry && entity.iso_code) {
html += '
';
} else if (entity.image) {
html += '
';
} else {
var bt = $block.data('blockType') || '';
var blockDef = self.config.blocks && self.config.blocks[bt];
if (blockDef && blockDef.icon) {
html += '';
}
}
html += '' + self.escapeHtml(entity.name) + '';
// Country: add holiday preview button
if (isCountry) {
html += '';
}
html += '';
html += '';
$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('.es-block'));
});
});
// Update condition counts after chips are loaded (for holiday counts, etc.)
self.updateAllConditionCounts();
} catch (e) {
console.error('[EntitySelector] Error processing AJAX response:', e);
}
},
error: function(xhr, status, error) {
console.error('[EntitySelector] AJAX request failed:', status, error, xhr.responseText);
}
});
},
/**
* Collect entity IDs from a picker for bulk loading
* Also shows loading placeholders for entity_search types
*/
collectPickerEntities: function($picker, blockType, entitiesToLoad) {
if (!$picker.length) {
return;
}
var self = this;
var $dataInput = $picker.find('.include-values-data, .exclude-values-data');
if (!$dataInput.length) {
return;
}
var valueType = $picker.attr('data-value-type');
var rawValue = $dataInput.val() || '[]';
var values = [];
try {
values = JSON.parse(rawValue);
} catch (e) {
return;
}
// Handle non-entity types synchronously
if (valueType === 'multi_numeric_range') {
if (!Array.isArray(values) || values.length === 0) return;
var $chipsContainer = $picker.find('.multi-range-chips');
values.forEach(function(range) {
if (!range || (range.min === null && range.max === null)) return;
var chipText = '';
if (range.min !== null && range.max !== null) {
chipText = range.min + ' - ' + range.max;
} else if (range.min !== null) {
chipText = '≥ ' + range.min;
} else {
chipText = '≤ ' + range.max;
}
var $chip = $('
', {
class: 'range-chip',
'data-min': range.min !== null ? range.min : '',
'data-max': range.max !== null ? range.max : ''
});
$chip.append($('', { class: 'range-chip-text', text: chipText }));
$chip.append($('