Files
prestashop-entity-selector/assets/js/admin/entity-selector.js
myprestarocks 7d79273743 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>
2026-01-31 15:03:51 +01:00

9377 lines
407 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Entity Selector - Utilities Module
* Helper functions: escape, validation, icons, search history
* @partial _utils.js (must be loaded first)
*
* EXTRACTION SOURCE: assets/js/admin/entity-selector.js
* Lines: 7552-7570 (escapeHtml, escapeAttr)
* 7577-7590 (getEntityTypeLabel)
* 6289-6350 (validate, showValidationError, clearValidationError)
* 7115-7137 (showRangeInputError)
* 7728-7745 (getBlockMode, isBlockSingleMode)
* 7707-7723 (getCurrentSingleSelection)
* 5411-5467 (search history methods)
*/
(function($) {
'use strict';
// Create mixin namespace
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
// Utility functions mixin
window._EntitySelectorMixins.utils = {
/**
* Debounce function - delays execution until after wait milliseconds
* @param {Function} func - Function to debounce
* @param {number} wait - Milliseconds to wait
* @returns {Function} Debounced function
*/
debounce: function(func, wait) {
var timeout;
return function() {
var context = this;
var args = arguments;
clearTimeout(timeout);
timeout = setTimeout(function() {
func.apply(context, args);
}, wait);
};
},
escapeHtml: function(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
escapeAttr: function(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
getEntityTypeIcon: function(entityType) {
var icons = {
'products': 'icon-shopping-cart',
'categories': 'icon-folder-open',
'manufacturers': 'icon-building',
'suppliers': 'icon-truck',
'attributes': 'icon-list-alt',
'features': 'icon-tags',
'cms': 'icon-file-text',
'cms_categories': 'icon-folder'
};
return icons[entityType] || 'icon-cube';
},
getEntityTypeLabel: function(entityType) {
var trans = this.config.trans || {};
var labels = {
'products': trans.product || 'Product',
'categories': trans.category || 'Category',
'manufacturers': trans.manufacturer || 'Manufacturer',
'suppliers': trans.supplier || 'Supplier',
'attributes': trans.attribute || 'Attribute',
'features': trans.feature || 'Feature',
'cms': trans.cms_page || 'CMS Page',
'cms_categories': trans.cms_category || 'CMS Category'
};
return labels[entityType] || entityType;
},
validate: function() {
var isRequired = this.$wrapper.data('required') === 1 || this.$wrapper.data('required') === '1';
if (!isRequired) return true;
var hasData = false;
this.$wrapper.find('.target-block').each(function() {
if ($(this).find('.selection-group').length > 0) {
hasData = true;
return false;
}
});
if (!hasData) {
this.showValidationError();
return false;
}
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';
this.$wrapper.find('.trait-validation-error').remove();
var $error = $('<div>', {
class: 'trait-validation-error',
html: '<i class="icon-warning"></i> ' + message
});
this.$wrapper.find('.condition-trait-header').after($error);
$('html, body').animate({ scrollTop: this.$wrapper.offset().top - 100 }, 300);
if (!this.$wrapper.find('.condition-trait-body').is(':visible')) {
this.$wrapper.find('.condition-trait-body').slideDown(200);
this.$wrapper.removeClass('collapsed');
}
},
clearValidationError: function() {
this.$wrapper.removeClass('has-validation-error');
this.$wrapper.find('.trait-validation-error').remove();
},
getBlockMode: function(blockType) {
var blockDef = this.config.blocks[blockType];
return (blockDef && blockDef.mode) ? blockDef.mode : 'multi';
},
isBlockSingleMode: function(blockType) {
return this.getBlockMode(blockType) === 'single';
},
getCurrentSingleSelection: function() {
if ((this.config.mode || 'multi') !== 'single') return null;
var $chip = this.$wrapper.find('.entity-chips .entity-chip').first();
if ($chip.length) {
var $block = $chip.closest('.target-block');
return {
name: $chip.find('.chip-name').text() || $chip.data('id'),
entityType: $block.data('block-type') || 'item'
};
}
return null;
},
/**
* Check if entity type supports tree browsing
*/
supportsTreeBrowsing: function(entityType) {
return entityType === 'categories' || entityType === 'cms_categories';
}
};
})(jQuery);
/**
* Entity Selector - Events Module
* All event binding and handlers
* @partial _events.js
*
* Contains event handlers for:
* - Tab switching
* - Block/group collapse toggle
* - Dropdown open/close
* - Search input handling
* - Item selection/deselection
* - Group add/remove
* - Exclude row add/remove
* - Method select changes
* - Filter panel toggles
* - Keyboard shortcuts (Ctrl+A, Ctrl+D, Esc, Enter)
* - Load more pagination
* - Sort controls
* - View mode switching
* - Tree view events
* - Preview badge clicks
* - Pattern tag interactions
* - Combination picker events
* - Group modifier events
*/
(function($) {
'use strict';
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
window._EntitySelectorMixins.events = {
bindEvents: function() {
var self = this;
// Tab switching
this.$wrapper.on('click', '.target-block-tab', function(e) {
e.preventDefault();
var blockType = $(this).data('blockType');
self.switchToBlock(blockType);
});
// Tab badge click for preview popover (toggle)
this.$wrapper.on('click', '.target-block-tab .tab-badge', function(e) {
e.stopPropagation();
e.preventDefault();
var $tab = $(this).closest('.target-block-tab');
var $badge = $(this);
if ($badge.hasClass('popover-open')) {
self.hidePreviewPopover();
} else {
self.showPreviewPopover($tab);
}
});
// Condition count badge click for preview popover
this.$wrapper.on('click', '.condition-match-count.clickable', function(e) {
e.stopPropagation();
e.preventDefault();
var $badge = $(this);
if ($badge.hasClass('popover-open')) {
self.hidePreviewPopover();
} else {
self.showConditionPreviewPopover($badge);
}
});
// Group count badge click for preview popover
this.$wrapper.on('click', '.group-count-badge.clickable', function(e) {
e.stopPropagation();
e.preventDefault();
var $badge = $(this);
if ($badge.hasClass('popover-open')) {
self.hidePreviewPopover();
} else {
self.showGroupPreviewPopover($badge);
}
});
// 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 &&
!$(e.target).closest('.tab-badge').length &&
!$(e.target).closest('.condition-match-count').length &&
!$(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('.trait-total-count').length) {
self.hidePreviewPopover();
}
});
// Block-level collapse toggle (click on header)
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('.trait-total-count').length) {
return;
}
var $body = self.$wrapper.find('.condition-trait-body');
$body.stop(true, true);
if ($body.is(':visible')) {
$body.slideUp(200);
self.$wrapper.addClass('collapsed');
} else {
$body.slideDown(200);
self.$wrapper.removeClass('collapsed');
}
});
// Group-level collapse toggle (click on group header or toggle icon)
this.$wrapper.on('click', '.group-header', function(e) {
if ($(e.target).closest('.btn-remove-group, .group-name-input').length) {
return;
}
if (self.$wrapper.data('mode') === 'single') {
return;
}
var $group = $(this).closest('.selection-group');
$group.toggleClass('collapsed');
});
// Toggle all groups (single button that switches between expand/collapse)
this.$wrapper.on('click', '.trait-header-actions .btn-toggle-groups', function(e) {
e.preventDefault();
e.stopPropagation();
var $btn = $(this);
var currentState = $btn.attr('data-state') || 'collapsed';
var trans = self.config.trans || {};
if (currentState === 'collapsed') {
self.$wrapper.find('.selection-group').removeClass('collapsed');
$btn.attr('data-state', 'expanded');
$btn.attr('title', trans.collapse_all || 'Collapse all groups');
$btn.find('i').removeClass('icon-expand').addClass('icon-compress');
} else {
self.$wrapper.find('.selection-group').addClass('collapsed');
$btn.attr('data-state', 'collapsed');
$btn.attr('title', trans.expand_all || 'Expand all groups');
$btn.find('i').removeClass('icon-compress').addClass('icon-expand');
}
});
// Show all toggle change (legacy checkbox)
this.$wrapper.on('change', '.trait-show-all-toggle .show-all-checkbox', function(e) {
e.stopPropagation();
var isChecked = $(this).prop('checked');
if (isChecked) {
self.clearAllConditions();
}
});
// Target switch change (PrestaShop native switch)
this.$wrapper.on('change', '.target-switch-toggle', function(e) {
e.stopPropagation();
var value = $(this).val();
if (value === '1') {
self.clearAllConditions();
self.$wrapper.find('.condition-trait-body').slideUp(200);
self.$wrapper.addClass('collapsed');
} else {
self.$wrapper.find('.condition-trait-body').slideDown(200);
self.$wrapper.removeClass('collapsed');
}
});
// Add group
this.$wrapper.on('click', '.btn-add-group', function(e) {
e.preventDefault();
var $block = $(this).closest('.target-block');
var blockType = $block.data('blockType');
self.addGroup($block, blockType);
});
// Remove group
this.$wrapper.on('click', '.btn-remove-group', function(e) {
e.preventDefault();
var $group = $(this).closest('.selection-group');
var $block = $(this).closest('.target-block');
self.removeGroup($group, $block);
});
// Group name input - stop propagation to prevent collapse
this.$wrapper.on('click focus', '.group-name-input', function(e) {
e.stopPropagation();
});
// Group name change
this.$wrapper.on('change blur', '.group-name-input', function() {
var $input = $(this);
var $group = $input.closest('.selection-group');
var name = $.trim($input.val());
$group.attr('data-group-name', name);
self.serializeAllBlocks();
});
// Add exceptions (first exclude row)
this.$wrapper.on('click', '.btn-add-exclude', function(e) {
e.preventDefault();
var $group = $(this).closest('.selection-group');
var $block = $(this).closest('.target-block');
self.addFirstExcludeRow($group, $block);
});
// Add another exclude row
this.$wrapper.on('click', '.btn-add-another-exclude', function(e) {
e.preventDefault();
var $group = $(this).closest('.selection-group');
var $block = $(this).closest('.target-block');
self.addExcludeRow($group, $block);
});
// Remove individual exclude row
this.$wrapper.on('click', '.btn-remove-exclude-row', function(e) {
e.preventDefault();
var $excludeRow = $(this).closest('.exclude-row');
var $group = $(this).closest('.selection-group');
var $block = $(this).closest('.target-block');
self.removeExcludeRow($excludeRow, $group, $block);
});
// Include method change
this.$wrapper.on('change', '.include-method-select', function() {
self.hideDropdown();
var $group = $(this).closest('.selection-group');
var $block = $(this).closest('.target-block');
var $row = $group.find('.group-include');
var blockType = $block.data('blockType');
var blockDef = self.config.blocks[blockType] || {};
var methods = blockDef.selection_methods || {};
var $option = $(this).find('option:selected');
var valueType = $option.data('valueType') || 'none';
var searchEntity = $option.data('searchEntity') || '';
var methodOptions = $option.data('options') || null;
var $oldPicker = $group.find('.include-picker');
var newPickerHtml = self.buildValuePickerHtml('include', valueType, searchEntity, methods);
$oldPicker.replaceWith(newPickerHtml);
if (valueType === 'select' && methodOptions) {
var $newPicker = $group.find('.include-picker');
var $select = $newPicker.find('.select-value-input');
$select.empty();
$.each(methodOptions, function(key, label) {
$select.append('<option value="' + self.escapeAttr(key) + '">' + self.escapeHtml(label) + '</option>');
});
}
if (valueType === 'multi_select_tiles' && methodOptions) {
var $newPicker = $group.find('.include-picker');
var isExclusive = $option.data('exclusive') === true;
self.populateTiles($newPicker, methodOptions, isExclusive);
}
if (valueType === 'multi_numeric_range') {
var $newPicker = $group.find('.include-picker');
var step = $option.data('step');
var min = $option.data('min');
self.applyRangeInputConstraints($newPicker, step, min);
}
if (valueType === 'combination_attributes') {
var $newPicker = $group.find('.include-picker');
self.loadCombinationAttributeGroups($newPicker);
}
var selectedMethod = $(this).val();
self.updateMethodInfoPlaceholder($group.find('.method-selector-wrapper'), selectedMethod, blockType);
self.updateBlockStatus($block);
self.serializeAllBlocks($row);
});
// Exclude method change (within an exclude row)
this.$wrapper.on('change', '.exclude-method-select', function() {
self.hideDropdown();
var $excludeRow = $(this).closest('.exclude-row');
var $group = $(this).closest('.selection-group');
var $block = $(this).closest('.target-block');
var blockType = $block.data('blockType');
var blockDef = self.config.blocks[blockType] || {};
var methods = blockDef.selection_methods || {};
var $option = $(this).find('option:selected');
var valueType = $option.data('valueType') || 'entity_search';
var searchEntity = $option.data('searchEntity') || blockType;
var methodOptions = $option.data('options') || null;
var $oldPicker = $excludeRow.find('.exclude-picker');
var newPickerHtml = self.buildValuePickerHtml('exclude', valueType, searchEntity, methods);
$oldPicker.replaceWith(newPickerHtml);
if (valueType === 'select' && methodOptions) {
var $newPicker = $excludeRow.find('.exclude-picker');
var $select = $newPicker.find('.select-value-input');
$select.empty();
$.each(methodOptions, function(key, label) {
$select.append('<option value="' + self.escapeAttr(key) + '">' + self.escapeHtml(label) + '</option>');
});
}
if (valueType === 'multi_select_tiles' && methodOptions) {
var $newPicker = $excludeRow.find('.exclude-picker');
var isExclusive = $option.data('exclusive') === true;
self.populateTiles($newPicker, methodOptions, isExclusive);
}
if (valueType === 'multi_numeric_range') {
var $newPicker = $excludeRow.find('.exclude-picker');
var step = $option.data('step');
var min = $option.data('min');
self.applyRangeInputConstraints($newPicker, step, min);
}
if (valueType === 'combination_attributes') {
var $newPicker = $excludeRow.find('.exclude-picker');
self.loadCombinationAttributeGroups($newPicker);
}
var selectedMethod = $(this).val();
self.updateMethodInfoPlaceholder($excludeRow.find('.exclude-header-row'), selectedMethod, blockType);
self.serializeAllBlocks($excludeRow);
});
// Handle pattern input Enter key - adds pattern as tag
this.$wrapper.on('keydown', '.pattern-input', function(e) {
if (e.keyCode === 13) {
e.preventDefault();
var $btn = $(this).closest('.draft-tag').find('.btn-add-pattern');
$btn.click();
}
});
// Handle add pattern button click (in draft tag)
this.$wrapper.on('click', '.draft-tag .btn-add-pattern', function(e) {
e.preventDefault();
e.stopPropagation();
var $draftTag = $(this).closest('.draft-tag');
var $picker = $draftTag.closest('.value-picker');
var $row = $draftTag.closest('.group-include, .exclude-row');
var $input = $draftTag.find('.pattern-input');
var pattern = $.trim($input.val());
if (pattern) {
var caseSensitive = $draftTag.attr('data-case-sensitive') === '1';
self.addPatternTag($picker, pattern, caseSensitive);
$input.val('').focus();
$draftTag.find('.pattern-match-count').removeClass('count-found count-zero').hide();
$draftTag.find('.pattern-match-count .count-value').text('');
self.serializeAllBlocks($row);
}
});
// Handle pattern input live typing - update match count in draft tag
this.$wrapper.on('input', '.pattern-input', function() {
var $input = $(this);
var $draftTag = $input.closest('.draft-tag');
if (!$draftTag.length) return;
var pattern = $.trim($input.val());
if ($input.data('countTimeout')) {
clearTimeout($input.data('countTimeout'));
}
var $matchCount = $draftTag.find('.pattern-match-count');
if (!pattern) {
$matchCount.removeClass('count-found count-zero').hide();
$matchCount.find('.count-value').text('');
var $group = $draftTag.closest('.selection-group');
if ($group.length) {
self.updateGroupTotalCount($group);
}
return;
}
var timeout = setTimeout(function() {
var caseSensitive = $draftTag.attr('data-case-sensitive') === '1';
self.updateDraftTagCount($draftTag, pattern, caseSensitive);
}, 300);
$input.data('countTimeout', timeout);
});
// Handle pattern tag remove
this.$wrapper.on('click', '.pattern-tag .btn-remove-pattern', function(e) {
e.preventDefault();
e.stopPropagation();
var $row = $(this).closest('.group-include, .exclude-row');
$(this).closest('.pattern-tag').remove();
self.serializeAllBlocks($row);
});
// Handle pattern tag case-sensitivity toggle
this.$wrapper.on('click', '.pattern-tag .btn-toggle-case', function(e) {
e.preventDefault();
e.stopPropagation();
var $tag = $(this).closest('.pattern-tag');
var $btn = $(this);
var trans = self.config.trans || {};
var isDraftTag = $tag.hasClass('draft-tag');
var isCaseSensitive = $tag.data('caseSensitive') === 1 || $tag.data('caseSensitive') === '1' || $tag.attr('data-case-sensitive') === '1';
var newCaseSensitive = !isCaseSensitive;
$tag.data('caseSensitive', newCaseSensitive ? 1 : 0);
$tag.attr('data-case-sensitive', newCaseSensitive ? '1' : '0');
$tag.toggleClass('case-sensitive', newCaseSensitive);
$btn.find('.case-icon').text(newCaseSensitive ? 'Aa' : 'aa');
var caseTitle = newCaseSensitive
? (trans.case_sensitive || 'Case sensitive - click to toggle')
: (trans.case_insensitive || 'Case insensitive - click to toggle');
$btn.attr('title', caseTitle);
if (isDraftTag) {
var pattern = $.trim($tag.find('.pattern-input').val());
if (pattern) {
self.updateDraftTagCount($tag, pattern, newCaseSensitive);
}
} else {
var $row = $tag.closest('.group-include, .exclude-row');
self.serializeAllBlocks($row);
}
});
// Handle pattern match count click - show preview modal
this.$wrapper.on('click', '.pattern-match-count', function(e) {
e.preventDefault();
e.stopPropagation();
var $matchCount = $(this);
var count = $matchCount.data('count');
var pattern = $matchCount.data('pattern');
var entityType = $matchCount.data('entityType');
var caseSensitive = $matchCount.data('caseSensitive');
if (!count || count <= 0 || !pattern) {
return;
}
self.showPatternPreviewModal(pattern, entityType, caseSensitive, count);
});
// Handle pattern tag edit (click on tag text)
this.$wrapper.on('click', '.pattern-tag .pattern-tag-text', function(e) {
e.preventDefault();
var $tag = $(this).closest('.pattern-tag');
if ($tag.hasClass('editing')) return;
var currentPattern = $tag.data('pattern');
var $editInput = $('<input type="text" class="pattern-tag-edit">').val(currentPattern);
var $saveBtn = $('<button type="button" class="btn-pattern-save" title="Save"><i class="icon-check"></i></button>');
var $cancelBtn = $('<button type="button" class="btn-pattern-cancel" title="Cancel"><i class="icon-times"></i></button>');
var $editActions = $('<span class="pattern-edit-actions"></span>').append($saveBtn, $cancelBtn);
$tag.addClass('editing').find('.pattern-tag-text').hide();
$tag.find('.btn-remove-pattern').hide();
$tag.prepend($editActions).prepend($editInput);
$editInput.focus().select();
$editInput.on('keydown', function(ev) {
if (ev.keyCode === 13) {
ev.preventDefault();
$saveBtn.click();
} else if (ev.keyCode === 27) {
ev.preventDefault();
$cancelBtn.click();
}
});
});
// Pattern edit - Save button
this.$wrapper.on('click', '.pattern-tag .btn-pattern-save', function(e) {
e.preventDefault();
e.stopPropagation();
var $tag = $(this).closest('.pattern-tag');
var $editInput = $tag.find('.pattern-tag-edit');
var currentPattern = $tag.data('pattern');
var newPattern = $.trim($editInput.val());
if (newPattern && newPattern !== currentPattern) {
$tag.data('pattern', newPattern);
$tag.find('.pattern-tag-text').text(newPattern);
}
$editInput.remove();
$tag.find('.pattern-edit-actions').remove();
$tag.removeClass('editing').find('.pattern-tag-text, .btn-remove-pattern').show();
var $row = $tag.closest('.group-include, .exclude-row');
self.serializeAllBlocks($row);
});
// Pattern edit - Cancel button
this.$wrapper.on('click', '.pattern-tag .btn-pattern-cancel', function(e) {
e.preventDefault();
e.stopPropagation();
var $tag = $(this).closest('.pattern-tag');
$tag.find('.pattern-tag-edit').remove();
$tag.find('.pattern-edit-actions').remove();
$tag.removeClass('editing').find('.pattern-tag-text, .btn-remove-pattern').show();
});
// Handle mpr-info-wrapper tooltip with fixed positioning
this.$wrapper.on('mouseenter', '.mpr-info-wrapper[data-details]', function() {
var $wrapper = $(this);
if ($wrapper.data('tooltip-active')) return;
var content = $wrapper.attr('data-details');
var tooltipClass = $wrapper.attr('data-tooltip-class') || '';
var $tooltip = $('<div>', { class: 'mpr-tooltip mpr-tooltip-fixed ' + tooltipClass, html: content });
$('body').append($tooltip);
$wrapper.data('tooltip-active', true);
var offset = $wrapper.offset();
var triggerWidth = $wrapper.outerWidth();
var tooltipWidth = $tooltip.outerWidth();
var tooltipHeight = $tooltip.outerHeight();
var left = offset.left + (triggerWidth / 2) - (tooltipWidth / 2);
var top = offset.top - tooltipHeight - 10;
if (left < 10) left = 10;
if (left + tooltipWidth > $(window).width() - 10) {
left = $(window).width() - tooltipWidth - 10;
}
$tooltip.css({
position: 'fixed',
left: left + 'px',
top: (top - $(window).scrollTop()) + 'px'
});
$wrapper.data('tooltip-el', $tooltip);
});
this.$wrapper.on('mouseleave', '.mpr-info-wrapper[data-details]', function() {
var $wrapper = $(this);
var $tooltip = $wrapper.data('tooltip-el');
if ($tooltip) {
$tooltip.remove();
}
$wrapper.data('tooltip-active', false);
$wrapper.data('tooltip-el', null);
});
// Handle numeric range input changes
this.$wrapper.on('change', '.range-min-input, .range-max-input', function() {
var $row = $(this).closest('.group-include, .exclude-row');
self.serializeAllBlocks($row);
});
// Handle date range input changes
this.$wrapper.on('change', '.date-from-input, .date-to-input', function() {
var $row = $(this).closest('.group-include, .exclude-row');
self.serializeAllBlocks($row);
});
// Handle select value changes
this.$wrapper.on('change', '.select-value-input', function() {
var $row = $(this).closest('.group-include, .exclude-row');
self.serializeAllBlocks($row);
});
// Handle multi-range add button click
this.$wrapper.on('click', '.btn-add-range', function(e) {
e.preventDefault();
var $picker = $(this).closest('.value-picker');
var $row = $(this).closest('.group-include, .exclude-row');
var $container = $picker.find('.multi-range-container');
var $chipsContainer = $container.find('.multi-range-chips');
var $minInput = $container.find('.range-min-input');
var $maxInput = $container.find('.range-max-input');
var minVal = $minInput.val().trim();
var maxVal = $maxInput.val().trim();
if (minVal === '' && maxVal === '') {
return;
}
var step = parseFloat($minInput.attr('step')) || 0.01;
var minAllowed = $minInput.attr('min');
var hasMinConstraint = typeof minAllowed !== 'undefined' && minAllowed !== '';
minAllowed = hasMinConstraint ? parseFloat(minAllowed) : null;
var minNum = minVal !== '' ? parseFloat(minVal) : null;
var maxNum = maxVal !== '' ? parseFloat(maxVal) : null;
if (hasMinConstraint) {
if (minNum !== null && minNum < minAllowed) {
self.showRangeInputError($minInput, self.config.trans.min_value_error || 'Minimum value is ' + minAllowed);
return;
}
if (maxNum !== null && maxNum < minAllowed) {
self.showRangeInputError($maxInput, self.config.trans.min_value_error || 'Minimum value is ' + minAllowed);
return;
}
}
if (minNum !== null && maxNum !== null && minNum > maxNum) {
self.showRangeInputError($minInput, self.config.trans.min_greater_than_max || 'Min cannot be greater than max');
return;
}
var decimals = step < 1 ? String(step).split('.')[1].length : 0;
if (minNum !== null) {
if (step >= 1) {
minNum = Math.round(minNum);
} else {
minNum = parseFloat(minNum.toFixed(decimals));
}
minVal = String(minNum);
}
if (maxNum !== null) {
if (step >= 1) {
maxNum = Math.round(maxNum);
} else {
maxNum = parseFloat(maxNum.toFixed(decimals));
}
maxVal = String(maxNum);
}
var chipText = '';
if (minVal !== '' && maxVal !== '') {
chipText = minVal + ' - ' + maxVal;
} else if (minVal !== '') {
chipText = '≥ ' + minVal;
} else {
chipText = '≤ ' + maxVal;
}
var $chip = $('<span>', {
class: 'range-chip',
'data-min': minVal,
'data-max': maxVal
});
$chip.append($('<span>', { class: 'range-chip-text', text: chipText }));
$chip.append($('<button>', {
type: 'button',
class: 'btn-remove-range',
html: '<i class="icon-times"></i>'
}));
$chipsContainer.append($chip);
$minInput.val('');
$maxInput.val('');
self.serializeAllBlocks($row);
});
// Handle multi-range chip removal
this.$wrapper.on('click', '.btn-remove-range', function(e) {
e.preventDefault();
e.stopPropagation();
var $chip = $(this).closest('.range-chip');
var $row = $chip.closest('.group-include, .exclude-row');
$chip.remove();
self.serializeAllBlocks($row);
});
// Handle Enter key in multi-range inputs
this.$wrapper.on('keydown', '.multi-range-container .range-min-input, .multi-range-container .range-max-input', function(e) {
if (e.keyCode === 13) {
e.preventDefault();
$(this).closest('.multi-range-container').find('.btn-add-range').click();
}
});
// Handle multi-select tile clicks
this.$wrapper.on('click', '.tile-option', function(e) {
e.preventDefault();
var $tile = $(this);
var $container = $tile.closest('.multi-select-tiles');
var $row = $tile.closest('.group-include, .exclude-row');
var isExclusive = $container.attr('data-exclusive') === 'true';
if (isExclusive) {
if ($tile.hasClass('selected')) {
$tile.removeClass('selected');
} else {
$container.find('.tile-option').removeClass('selected');
$tile.addClass('selected');
}
} else {
$tile.toggleClass('selected');
}
self.serializeAllBlocks($row);
});
// Handle combination attribute value toggle
this.$wrapper.on('click', '.comb-attr-value', function(e) {
e.preventDefault();
var $value = $(this);
var $row = $value.closest('.group-include, .exclude-row');
var $picker = $value.closest('.value-picker');
$value.toggleClass('selected');
self.updateCombinationData($picker);
self.serializeAllBlocks($row);
});
// Handle combination mode toggle
this.$wrapper.on('change', '.comb-mode-radio', function() {
var $picker = $(this).closest('.value-picker');
var $row = $(this).closest('.group-include, .exclude-row');
self.updateCombinationData($picker);
self.serializeAllBlocks($row);
});
// Handle combination select all
this.$wrapper.on('click', '.comb-select-all', function(e) {
e.preventDefault();
var $group = $(this).closest('.comb-attr-group');
var $picker = $(this).closest('.value-picker');
var $row = $(this).closest('.group-include, .exclude-row');
$group.find('.comb-attr-value:visible').addClass('selected');
self.updateCombinationData($picker);
self.serializeAllBlocks($row);
});
// Handle combination select none
this.$wrapper.on('click', '.comb-select-none', function(e) {
e.preventDefault();
var $group = $(this).closest('.comb-attr-group');
var $picker = $(this).closest('.value-picker');
var $row = $(this).closest('.group-include, .exclude-row');
$group.find('.comb-attr-value').removeClass('selected');
self.updateCombinationData($picker);
self.serializeAllBlocks($row);
});
// Handle combination attribute search/filter
this.$wrapper.on('input', '.comb-attr-search', function() {
var query = $(this).val().toLowerCase().trim();
var $group = $(this).closest('.comb-attr-group');
$group.find('.comb-attr-value').each(function() {
var name = $(this).data('name') || '';
if (!query || name.indexOf(query) !== -1) {
$(this).show();
} else {
$(this).hide();
}
});
});
// Handle group-level modifier toggle
this.$wrapper.on('click', '.btn-toggle-modifiers', function(e) {
e.preventDefault();
var $btn = $(this);
var $modifiers = $btn.closest('.group-modifiers');
var $content = $modifiers.find('.group-modifiers-content');
$content.slideToggle(150, function() {
$modifiers.toggleClass('expanded', $content.is(':visible'));
});
});
// Handle group-level modifier changes
this.$wrapper.on('change input', '.group-modifier-limit', function() {
var $group = $(this).closest('.selection-group');
var $limitInput = $(this);
var limit = parseInt($limitInput.val(), 10);
var $badge = $group.find('.group-header .group-count-badge');
var finalCount = $badge.data('finalCount') || 0;
var $previewBadge = $group.find('.group-preview-badge .preview-count');
if ($previewBadge.length && finalCount > 0) {
var displayCount = (limit > 0 && limit < finalCount) ? limit : finalCount;
$previewBadge.text(displayCount);
}
self.updateModifierButtonState($group);
self.serializeAllBlocks();
self.refreshGroupPreviewIfOpen($group);
});
// Sort dropdown change
this.$wrapper.on('change', '.group-modifier-sort', function() {
var $group = $(this).closest('.selection-group');
self.serializeAllBlocks();
self.refreshGroupPreviewIfOpen($group);
});
// Sort direction button click
this.$wrapper.on('click', '.group-modifiers .btn-sort-dir', function(e) {
e.preventDefault();
var $btn = $(this);
var $group = $btn.closest('.selection-group');
var currentDir = $btn.data('dir') || 'DESC';
var newDir = (currentDir === 'DESC') ? 'ASC' : 'DESC';
$btn.data('dir', newDir);
$btn.attr('data-dir', newDir);
var $icon = $btn.find('i');
if (newDir === 'ASC') {
$icon.removeClass('icon-sort-amount-desc').addClass('icon-sort-amount-asc');
} else {
$icon.removeClass('icon-sort-amount-asc').addClass('icon-sort-amount-desc');
}
self.serializeAllBlocks();
self.refreshGroupPreviewIfOpen($group);
});
// Group preview badge click
this.$wrapper.on('click', '.group-preview-badge.clickable', function(e) {
e.preventDefault();
e.stopPropagation();
var $badge = $(this);
var $group = $badge.closest('.selection-group');
var $block = $badge.closest('.target-block');
var blockType = $block.data('blockType');
if ($badge.hasClass('popover-open')) {
self.hidePreviewPopover();
return;
}
self.showGroupPreviewPopover($badge, $group, blockType);
});
// Search input focus
this.$wrapper.on('focus', '.entity-search-input', function() {
var $picker = $(this).closest('.value-picker');
var $group = $(this).closest('.selection-group');
var $block = $(this).closest('.target-block');
var blockType = $block.data('blockType');
var groupIndex = parseInt($group.data('groupIndex'), 10);
var section = $picker.hasClass('include-picker') ? 'include' : 'exclude';
var searchEntity = $picker.attr('data-search-entity') || blockType;
var excludeIndex = null;
if (section === 'exclude') {
var $excludeRow = $(this).closest('.exclude-row');
if ($excludeRow.length) {
excludeIndex = parseInt($excludeRow.data('excludeIndex'), 10);
}
}
var entityChanged = self.activeGroup && self.activeGroup.searchEntity !== searchEntity;
if (entityChanged) {
self.searchResults = [];
self.searchOffset = 0;
self.searchQuery = '';
self.viewMode = 'list';
self.resetFiltersWithoutSearch();
self.$dropdown.find('.dropdown-results').empty();
self.$dropdown.find('.filter-panel').removeClass('show');
self.$dropdown.find('.btn-toggle-filters').removeClass('active');
}
self.activeGroup = {
blockType: blockType,
groupIndex: groupIndex,
section: section,
excludeIndex: excludeIndex,
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();
self.updateFilterPanelForEntity(searchEntity);
if (searchEntity === 'products') {
self.loadFilterableData();
}
self.positionDropdown($(this));
// For tree view mode on categories, load category tree instead of search
if (self.viewMode === 'tree' && (searchEntity === 'categories' || searchEntity === 'cms_categories')) {
self.loadCategoryTree();
return;
}
self.performSearch();
});
// Search input typing
this.$wrapper.on('input', '.entity-search-input', function() {
var query = $(this).val().trim();
self.searchQuery = query;
self.searchOffset = 0;
if (self.viewMode === 'tree') {
self.filterCategoryTree(query);
return;
}
clearTimeout(self.searchTimeout);
self.searchTimeout = setTimeout(function() {
self.performSearch();
}, 300);
});
// History item click
this.$dropdown.on('click', '.history-item', function(e) {
e.preventDefault();
e.stopPropagation();
var query = $(this).data('query');
if (query && self.activeGroup) {
var $input = self.$wrapper.find('.entity-search-input:focus');
if (!$input.length) {
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 + '"]');
$input = $group.find('.entity-search-input').first();
}
$input.val(query);
self.searchQuery = query;
self.searchOffset = 0;
self.performSearch();
}
});
// Delete history item
this.$dropdown.on('click', '.history-item .btn-delete-history', function(e) {
e.preventDefault();
e.stopPropagation();
var $item = $(this).closest('.history-item');
var query = $item.data('query');
if (query && self.activeGroup) {
self.removeFromSearchHistory(self.activeGroup.searchEntity, query);
$item.fadeOut(150, function() {
$(this).remove();
if (!self.$dropdown.find('.history-item').length) {
self.performSearch();
}
});
}
});
// 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');
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 (isSelected) {
self.removeSelection($picker, id);
$item.toggleClass('selected');
self.serializeAllBlocks($row);
} else {
var currentSelection = self.getCurrentSingleSelection();
if (currentSelection) {
var newEntityType = self.activeGroup.blockType;
self.showReplaceConfirmation(currentSelection, { name: name, entityType: newEntityType }, function() {
self.addSelection($picker, id, name, $item.data());
$item.addClass('selected');
self.serializeAllBlocks($row);
});
} else {
self.addSelection($picker, id, name, $item.data());
$item.toggleClass('selected');
self.serializeAllBlocks($row);
}
}
});
// Chip remove
this.$wrapper.on('click', '.chip-remove', function(e) {
e.stopPropagation();
var $chip = $(this).closest('.entity-chip');
var $picker = $(this).closest('.value-picker');
var $row = $(this).closest('.group-include, .exclude-row');
var id = $chip.data('id');
self.removeSelection($picker, id);
self.serializeAllBlocks($row);
if (self.$dropdown && self.$dropdown.hasClass('show')) {
self.$dropdown.find('.dropdown-item[data-id="' + id + '"]').removeClass('selected');
}
});
// Chips show more/less toggle
this.$wrapper.on('click', '.chips-show-more-toggle', function(e) {
e.stopPropagation();
var $chips = $(this).closest('.entity-chips');
if ($chips.hasClass('chips-expanded')) {
$chips.removeClass('chips-expanded').addClass('chips-collapsed');
} else {
$chips.addClass('chips-expanded').removeClass('chips-collapsed');
}
self.updateChipsVisibility($chips);
});
// Select All
this.$dropdown.on('click', '.btn-select-all', function(e) {
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;
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;
}
var $visibleItems = self.$dropdown.find('.dropdown-item:visible');
$visibleItems.each(function() {
if (!$(this).hasClass('selected')) {
var id = $(this).data('id');
var name = $(this).data('name');
self.addSelectionNoUpdate($picker, id, name, $(this).data());
$(this).addClass('selected');
}
});
var $chips = $picker.find('.entity-chips');
self.updateChipsVisibility($chips);
self.serializeAllBlocks($row);
});
// Clear selection
this.$dropdown.on('click', '.btn-clear-selection', function(e) {
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;
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;
}
var $chips = $picker.find('.entity-chips');
$chips.empty().removeClass('chips-expanded chips-collapsed');
self.$dropdown.find('.dropdown-item').removeClass('selected');
self.serializeAllBlocks($row);
});
// 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 - 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();
});
// Load more
this.$dropdown.on('click', '.btn-load-more', function(e) {
e.preventDefault();
if (self.isLoading) return;
var loadCount = parseInt(self.$dropdown.find('.load-more-select').val(), 10) || 20;
self.searchOffset = self.searchResults.length;
self.loadMoreCount = loadCount;
self.performSearch(true);
});
// Sort field change
this.$dropdown.on('change', '.sort-field-select', function() {
self.currentSort.field = $(this).val();
self.refreshSearch();
});
// Sort direction toggle
this.$dropdown.on('click', '.btn-sort-dir', function(e) {
e.preventDefault();
var $btn = $(this);
var currentDir = $btn.data('dir');
var newDir = currentDir === 'ASC' ? 'DESC' : 'ASC';
$btn.data('dir', newDir);
$btn.find('i').attr('class', newDir === 'ASC' ? 'icon-sort-alpha-asc' : 'icon-sort-alpha-desc');
self.currentSort.dir = newDir;
self.refreshSearch();
});
// Tree view: Toggle expand/collapse
this.$dropdown.on('click', '.category-tree .tree-toggle', function(e) {
e.stopPropagation();
var $item = $(this).closest('.tree-item');
var $allItems = self.$dropdown.find('.tree-item');
$item.toggleClass('collapsed');
var isCollapsed = $item.hasClass('collapsed');
$(this).find('i').toggleClass('icon-caret-down', !isCollapsed)
.toggleClass('icon-caret-right', isCollapsed);
var descendants = self.findTreeDescendants($item, $allItems);
for (var i = 0; i < descendants.length; i++) {
$(descendants[i]).toggle(!isCollapsed);
}
});
// 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, .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 = parseInt($item.data('id'), 10);
var name = $item.data('name');
var isSelected = $item.hasClass('selected');
if (!self.activeGroup) return;
if (!self.pendingSelections) self.pendingSelections = [];
var $allItems = self.$dropdown.find('.tree-item');
var updateCount = function() {
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 > 0 ? ' (' + selectedCount + ' selected)' : ''));
self.updateSelectChildrenButtons($allItems);
};
if (isSelected) {
// Remove from pending
self.pendingSelections = self.pendingSelections.filter(function(s) {
return parseInt(s.id, 10) !== id;
});
$item.removeClass('selected');
} else {
// 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()
});
}
$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);
}
});
// Tree view: Select/Deselect with children button (toggle)
this.$dropdown.on('click', '.category-tree .btn-select-children', function(e) {
e.stopPropagation();
var $btn = $(this);
var $item = $btn.closest('.tree-item');
var $allItems = self.$dropdown.find('.tree-item');
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;
}
var descendants = self.findTreeDescendants($item, $allItems);
var allSelected = $item.hasClass('selected');
for (var i = 0; i < descendants.length && allSelected; i++) {
if (!$(descendants[i]).hasClass('selected')) {
allSelected = false;
}
}
var trans = self.config.trans || {};
if (allSelected) {
self.removeSelection($picker, $item.data('id'));
$item.removeClass('selected');
for (var j = 0; j < descendants.length; j++) {
var $child = $(descendants[j]);
self.removeSelection($picker, $child.data('id'));
$child.removeClass('selected');
}
$btn.find('i').removeClass('icon-minus-square').addClass('icon-plus-square');
$btn.attr('title', trans.select_with_children || 'Select with all children');
} else {
if (!$item.hasClass('selected')) {
self.addSelectionNoUpdate($picker, $item.data('id'), $item.data('name'), $item.data());
$item.addClass('selected');
}
for (var k = 0; k < descendants.length; k++) {
var $descendant = $(descendants[k]);
if (!$descendant.hasClass('selected')) {
self.addSelectionNoUpdate($picker, $descendant.data('id'), $descendant.data('name'), $descendant.data());
$descendant.addClass('selected');
}
}
$btn.find('i').removeClass('icon-plus-square').addClass('icon-minus-square');
$btn.attr('title', trans.deselect_with_children || 'Deselect with all children');
}
var $chips = $picker.find('.entity-chips');
self.updateChipsVisibility($chips);
self.serializeAllBlocks($row);
self.updateSelectChildrenButtons($allItems);
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 > 0 ? ' (' + selectedCount + ' selected)' : ''));
});
// Tree view: Expand all
this.$dropdown.on('click', '.category-tree .btn-expand-all', function(e) {
e.preventDefault();
self.$dropdown.find('.tree-item').removeClass('collapsed').show();
self.$dropdown.find('.tree-toggle i').removeClass('icon-caret-right').addClass('icon-caret-down');
});
// Tree view: Collapse all
this.$dropdown.on('click', '.category-tree .btn-collapse-all', function(e) {
e.preventDefault();
var minLevel = Infinity;
self.$dropdown.find('.tree-item').each(function() {
var level = parseInt($(this).data('level'), 10);
if (level < minLevel) minLevel = level;
});
self.$dropdown.find('.tree-item').each(function() {
var $item = $(this);
var level = parseInt($item.data('level'), 10);
var hasChildren = $item.hasClass('has-children');
if (level === minLevel) {
if (hasChildren) {
$item.addClass('collapsed');
$item.find('.tree-toggle i').removeClass('icon-caret-down').addClass('icon-caret-right');
}
$item.show();
} else {
$item.hide();
}
});
});
// Refine search input
this.$dropdown.on('keyup', '.refine-input', function() {
var query = $(this).val().trim();
self.refineQuery = query;
self.$dropdown.find('.btn-clear-refine').toggle(query.length > 0);
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);
});
// Clear refine search
this.$dropdown.on('click', '.btn-clear-refine', function(e) {
e.preventDefault();
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();
});
// Toggle refine negate (NOT contains)
this.$dropdown.on('click', '.btn-refine-negate', function(e) {
e.preventDefault();
self.refineNegate = !self.refineNegate;
$(this).toggleClass('active', self.refineNegate);
var trans = self.config.trans || {};
var placeholder = self.refineNegate
? (trans.refine_exclude || 'Exclude...')
: (trans.refine_short || 'Refine...');
self.$dropdown.find('.refine-input').attr('placeholder', placeholder);
if (self.refineQuery) {
self.refreshSearch();
}
});
// Toggle filter panel
this.$dropdown.on('click', '.btn-toggle-filters', function(e) {
e.preventDefault();
var $panel = self.$dropdown.find('.filter-panel');
$panel.toggleClass('show');
$(this).toggleClass('active', $panel.hasClass('show'));
if ($panel.hasClass('show') && self.activeGroup) {
self.updateFilterPanelForEntity(self.activeGroup.searchEntity);
}
});
// Show search history
this.$dropdown.on('click', '.btn-show-history', function(e) {
e.preventDefault();
$(this).toggleClass('active');
if ($(this).hasClass('active') && self.activeGroup) {
self.showSearchHistory(self.activeGroup.searchEntity);
} else {
self.performSearch();
}
});
// Quick filter checkboxes
this.$dropdown.on('change', '.filter-in-stock', function() {
self.filters.inStock = $(this).is(':checked');
self.refreshSearch();
});
this.$dropdown.on('change', '.filter-discounted', function() {
self.filters.discounted = $(this).is(':checked');
self.refreshSearch();
});
// Price range filter
this.$dropdown.on('change', '.filter-price-min, .filter-price-max', function() {
var $panel = self.$dropdown.find('.filter-panel');
self.filters.priceMin = $panel.find('.filter-price-min').val() || null;
self.filters.priceMax = $panel.find('.filter-price-max').val() || null;
self.refreshSearch();
});
// Entity-specific filters: Product count range
this.$dropdown.on('change', '.filter-product-count-min, .filter-product-count-max', function() {
var $row = $(this).closest('.filter-row');
self.filters.productCountMin = $row.find('.filter-product-count-min').val() || null;
self.filters.productCountMax = $row.find('.filter-product-count-max').val() || null;
self.refreshSearch();
});
// Entity-specific filters: Sales range
this.$dropdown.on('change', '.filter-sales-min, .filter-sales-max', function() {
var $row = $(this).closest('.filter-row');
self.filters.salesMin = $row.find('.filter-sales-min').val() || null;
self.filters.salesMax = $row.find('.filter-sales-max').val() || null;
self.refreshSearch();
});
// Entity-specific filters: Turnover/revenue range
this.$dropdown.on('change', '.filter-turnover-min, .filter-turnover-max', function() {
var $row = $(this).closest('.filter-row');
self.filters.turnoverMin = $row.find('.filter-turnover-min').val() || null;
self.filters.turnoverMax = $row.find('.filter-turnover-max').val() || null;
self.refreshSearch();
});
// Entity-specific filters: Date added range
this.$dropdown.on('change', '.filter-date-add-from, .filter-date-add-to', function() {
var $row = $(this).closest('.filter-row');
self.filters.dateAddFrom = $row.find('.filter-date-add-from').val() || null;
self.filters.dateAddTo = $row.find('.filter-date-add-to').val() || null;
self.refreshSearch();
});
// Entity-specific filters: Last product date range
this.$dropdown.on('change', '.filter-last-product-from, .filter-last-product-to', function() {
var $row = $(this).closest('.filter-row');
self.filters.lastProductFrom = $row.find('.filter-last-product-from').val() || null;
self.filters.lastProductTo = $row.find('.filter-last-product-to').val() || null;
self.refreshSearch();
});
// Entity-specific filters: Depth (categories)
this.$dropdown.on('change', '.filter-depth-select', function() {
self.filters.depth = $(this).val() || null;
self.refreshSearch();
});
// Entity-specific filters: Has products (categories)
this.$dropdown.on('change', '.filter-has-products', function() {
self.filters.hasProducts = $(this).is(':checked');
self.refreshSearch();
});
// Entity-specific filters: Has description (categories)
this.$dropdown.on('change', '.filter-has-description', function() {
self.filters.hasDescription = $(this).is(':checked');
self.refreshSearch();
});
// Entity-specific filters: Has image (categories)
this.$dropdown.on('change', '.filter-has-image', function() {
self.filters.hasImage = $(this).is(':checked');
self.refreshSearch();
});
// Entity-specific filters: Active only
this.$dropdown.on('change', '.filter-active-only', function() {
self.filters.activeOnly = $(this).is(':checked');
self.refreshSearch();
});
// Entity-specific filters: Attribute group select
this.$dropdown.on('change', '.filter-attribute-group-select', function() {
self.filters.attributeGroup = $(this).val() || null;
self.refreshSearch();
});
// Entity-specific filters: Feature group select
this.$dropdown.on('change', '.filter-feature-group-select', function() {
self.filters.featureGroup = $(this).val() || null;
self.refreshSearch();
});
// Entity-specific filters: Color attributes only
this.$dropdown.on('change', '.filter-is-color', function() {
self.filters.isColor = $(this).is(':checked');
self.refreshSearch();
});
// Entity-specific filters: Custom feature values only
this.$dropdown.on('change', '.filter-is-custom', function() {
self.filters.isCustom = $(this).is(':checked');
self.refreshSearch();
});
// Entity-specific filters: CMS indexable
this.$dropdown.on('change', '.filter-indexable', function() {
self.filters.indexable = $(this).is(':checked');
self.refreshSearch();
});
// Clear entity-specific filters
this.$dropdown.on('click', '.filter-row-entity-categories .btn-clear-filters, .filter-row-entity-manufacturers .btn-clear-filters, .filter-row-entity-suppliers .btn-clear-filters, .filter-row-entity-attributes .btn-clear-filters, .filter-row-entity-features .btn-clear-filters, .filter-row-entity-cms .btn-clear-filters, .filter-row-entity-cms-categories .btn-clear-filters', function(e) {
e.preventDefault();
var $row = $(this).closest('.filter-row');
$row.find('input[type="number"]').val('');
$row.find('input[type="date"]').val('');
$row.find('select').val('');
$row.find('input[type="checkbox"]').prop('checked', false);
$row.find('.filter-active-only').prop('checked', true);
self.filters.productCountMin = null;
self.filters.productCountMax = null;
self.filters.salesMin = null;
self.filters.salesMax = null;
self.filters.turnoverMin = null;
self.filters.turnoverMax = null;
self.filters.depth = null;
self.filters.hasProducts = false;
self.filters.hasDescription = false;
self.filters.hasImage = false;
self.filters.activeOnly = true;
self.filters.attributeGroup = null;
self.filters.featureGroup = null;
self.filters.isColor = false;
self.filters.isCustom = false;
self.filters.indexable = false;
self.filters.dateAddFrom = null;
self.filters.dateAddTo = null;
self.filters.lastProductFrom = null;
self.filters.lastProductTo = null;
self.refreshSearch();
});
// Toggle filter group - show values
this.$dropdown.on('click', '.filter-group-toggle', function(e) {
// Ignore clicks on the preview badge
if ($(e.target).closest('.toggle-count.clickable').length) {
return;
}
e.preventDefault();
var $btn = $(this);
var groupId = $btn.data('group-id');
var type = $btn.data('type');
var isActive = $btn.hasClass('active');
self.$dropdown.find('.filter-group-toggle').removeClass('active');
if (isActive) {
self.hideFilterGroupValues();
} else {
$btn.addClass('active');
self.showFilterGroupValues(groupId, type);
}
});
// Filter group toggle count badge click for preview popover
this.$dropdown.on('click', '.filter-group-toggle .toggle-count.clickable', function(e) {
e.stopPropagation();
e.preventDefault();
var $badge = $(this);
var groupId = $badge.data('groupId');
var groupType = $badge.data('type');
var groupName = $badge.data('groupName');
if ($badge.hasClass('popover-open')) {
self.hidePreviewPopover();
} else {
self.showFilterGroupPreviewPopover($badge, groupId, groupType, groupName);
}
});
// 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
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')
.addClass('view-' + mode.replace('cols-', 'cols-'));
// 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 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);
}
}
});
// Close values row
this.$dropdown.on('click', '.btn-close-values', function(e) {
e.preventDefault();
e.stopPropagation();
self.hideFilterGroupValues();
});
// Toggle filter chip (attribute/feature value)
this.$dropdown.on('click', '.filter-chip', function(e) {
e.preventDefault();
e.stopPropagation();
var $chip = $(this);
var id = parseInt($chip.data('id'), 10);
var isAttribute = $chip.hasClass('filter-attr-chip');
var filterArray = isAttribute ? self.filters.attributes : self.filters.features;
var index = filterArray.indexOf(id);
if (index === -1) {
filterArray.push(id);
$chip.addClass('active');
} else {
filterArray.splice(index, 1);
$chip.removeClass('active');
}
self.updateFilterToggleStates();
self.refreshSearch();
});
// Clear all filters
this.$dropdown.on('click', '.filter-row-quick .btn-clear-filters', function(e) {
e.preventDefault();
self.clearFilters();
});
// Click outside to close
$(document).on('click', function(e) {
if (!$(e.target).closest('.value-picker').length &&
!$(e.target).closest('.target-search-dropdown').length &&
!$(e.target).closest('.target-preview-popover').length) {
self.hideDropdown();
}
});
// Keyboard shortcuts
$(document).on('keydown', function(e) {
if (!self.$dropdown || !self.$dropdown.hasClass('show')) return;
// 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 (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');
return false;
}
if (e.key === 'Escape') {
e.preventDefault();
self.hideDropdown();
} else if (e.key === 'Enter') {
e.preventDefault();
self.hideDropdown();
}
});
}
};
})(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('.target-search-dropdown').remove();
var trans = this.config.trans || {};
var html = '<div class="target-search-dropdown view-list">';
// Header with results count, actions, sort controls, view mode
html += '<div class="dropdown-header">';
html += '<span class="results-count">0 results</span>';
html += '<div class="dropdown-actions">';
// Select all / Clear buttons with keyboard shortcuts
html += '<button type="button" class="btn-select-all" title="' + (trans.select_all || 'Select all visible') + '">';
html += '<i class="icon-check-square-o"></i> ' + (trans.all || 'All') + ' <kbd>Ctrl+A</kbd>';
html += '</button>';
html += '<button type="button" class="btn-clear-selection" title="' + (trans.clear_selection || 'Clear selection') + '">';
html += '<i class="icon-square-o"></i> ' + (trans.clear || 'Clear') + ' <kbd>Ctrl+D</kbd>';
html += '</button>';
// Sort controls
html += '<div class="sort-controls">';
html += '<select class="sort-field-select" title="Sort by">';
html += '<option value="name">' + (trans.sort_name || 'Name') + '</option>';
html += '<option value="id">' + (trans.sort_id || 'ID') + '</option>';
html += '<option value="position">' + (trans.sort_position || 'Position') + '</option>';
html += '<option value="popularity">' + (trans.sort_popularity || 'Popularity') + '</option>';
html += '<option value="selected">' + (trans.sort_selected || 'Selected') + '</option>';
html += '</select>';
html += '<button type="button" class="btn-sort-dir" data-dir="ASC" title="Sort direction">';
html += '<i class="icon-sort-alpha-asc"></i>';
html += '</button>';
// 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">' + (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>';
html += '<option value="cols-5">5 ' + (trans.cols || 'cols') + '</option>';
html += '<option value="cols-6">6 ' + (trans.cols || 'cols') + '</option>';
html += '<option value="cols-7">7 ' + (trans.cols || 'cols') + '</option>';
html += '<option value="cols-8">8 ' + (trans.cols || 'cols') + '</option>';
html += '</select>';
html += '</div>'; // End sort-controls
// Refine search
html += '<div class="refine-compact">';
html += '<button type="button" class="btn-refine-negate" title="' + (trans.exclude_matches || 'Exclude matches (NOT contains)') + '"><i class="icon-ban"></i></button>';
html += '<input type="text" class="refine-input" placeholder="' + (trans.refine_short || 'Refine...') + '">';
html += '<button type="button" class="btn-clear-refine" style="display:none;"><i class="icon-times"></i></button>';
html += '</div>';
// Filter toggle button
html += '<button type="button" class="btn-toggle-filters" title="' + (trans.toggle_filters || 'Filters') + '">';
html += '<i class="icon-filter"></i>';
html += '</button>';
// History button
html += '<button type="button" class="btn-show-history" title="' + (trans.recent_searches || 'Recent searches') + '">';
html += '<i class="icon-clock-o"></i>';
html += '</button>';
html += '</div>'; // End dropdown-actions
html += '</div>'; // End dropdown-header
// Filter panel
html += '<div class="filter-panel">';
// Quick filters row (for products)
html += '<div class="filter-row filter-row-quick" data-entity="products">';
html += '<label class="filter-label"><input type="checkbox" class="filter-in-stock"> ' + (trans.in_stock || 'In stock') + '</label>';
html += '<label class="filter-label"><input type="checkbox" class="filter-discounted"> ' + (trans.discounted || 'On sale') + '</label>';
// Price range
html += '<div class="filter-price-range">';
html += '<span class="filter-price-label">' + (trans.price || 'Price') + ':</span>';
html += '<input type="number" class="filter-price-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="0.01">';
html += '<span class="filter-price-sep">-</span>';
html += '<input type="number" class="filter-price-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="0.01">';
html += '</div>';
html += '<button type="button" class="btn-clear-filters" title="' + (trans.clear_filters || 'Clear filters') + '">';
html += '<i class="icon-times"></i>';
html += '</button>';
html += '</div>';
// Attribute/Feature filter toggles for products
html += '<div class="filter-row filter-row-attributes" data-entity="products" style="display:none;">';
html += '<span class="filter-row-label"><i class="icon-tags"></i> ' + (trans.attributes || 'Attributes') + ':</span>';
html += '<div class="filter-attributes-container"></div>';
html += '</div>';
html += '<div class="filter-row filter-row-values filter-row-attr-values" data-type="attribute" style="display:none;">';
html += '<div class="filter-values-container"></div>';
html += '</div>';
html += '<div class="filter-row filter-row-features" data-entity="products" style="display:none;">';
html += '<span class="filter-row-label"><i class="icon-list-ul"></i> ' + (trans.features || 'Features') + ':</span>';
html += '<div class="filter-features-container"></div>';
html += '</div>';
html += '<div class="filter-row filter-row-values filter-row-feat-values" data-type="feature" style="display:none;">';
html += '<div class="filter-values-container"></div>';
html += '</div>';
// Entity-specific filters: Categories
html += '<div class="filter-row filter-row-entity-categories filter-row-multi" data-entity="categories" style="display:none;">';
html += '<div class="filter-subrow">';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-cubes"></i> ' + (trans.product_count || 'Products') + ':</span>';
html += '<input type="number" class="filter-product-count-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-product-count-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-shopping-cart"></i> ' + (trans.total_sales || 'Sales') + ':</span>';
html += '<input type="number" class="filter-sales-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-sales-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-money"></i> ' + (trans.turnover || 'Revenue') + ':</span>';
html += '<input type="number" class="filter-turnover-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-turnover-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<label class="filter-label"><input type="checkbox" class="filter-active-only" checked> ' + (trans.active_only || 'Active only') + '</label>';
html += '</div>';
html += '<div class="filter-subrow">';
html += '<div class="filter-select-group">';
html += '<span class="filter-select-label"><i class="icon-sitemap"></i> ' + (trans.depth || 'Depth') + ':</span>';
html += '<select class="filter-depth-select">';
html += '<option value="">' + (trans.all_levels || 'All levels') + '</option>';
html += '<option value="1">' + (trans.level || 'Level') + ' 1 (' + (trans.root || 'Root') + ')</option>';
html += '<option value="2">' + (trans.level || 'Level') + ' 2</option>';
html += '<option value="3">' + (trans.level || 'Level') + ' 3</option>';
html += '<option value="4">' + (trans.level || 'Level') + ' 4+</option>';
html += '</select>';
html += '</div>';
html += '<label class="filter-label"><input type="checkbox" class="filter-has-products"> ' + (trans.has_products || 'Has products') + '</label>';
html += '<label class="filter-label"><input type="checkbox" class="filter-has-description"> ' + (trans.has_description || 'Has description') + '</label>';
html += '<label class="filter-label"><input type="checkbox" class="filter-has-image"> ' + (trans.has_image || 'Has image') + '</label>';
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
html += '</div>';
html += '</div>';
// Entity-specific filters: Manufacturers
html += '<div class="filter-row filter-row-entity-manufacturers filter-row-multi" data-entity="manufacturers" style="display:none;">';
html += '<div class="filter-subrow">';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-cubes"></i> ' + (trans.product_count || 'Products') + ':</span>';
html += '<input type="number" class="filter-product-count-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-product-count-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-shopping-cart"></i> ' + (trans.total_sales || 'Sales') + ':</span>';
html += '<input type="number" class="filter-sales-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-sales-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-money"></i> ' + (trans.turnover || 'Revenue') + ':</span>';
html += '<input type="number" class="filter-turnover-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-turnover-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<label class="filter-label"><input type="checkbox" class="filter-active-only" checked> ' + (trans.active_only || 'Active only') + '</label>';
html += '</div>';
html += '<div class="filter-subrow">';
html += '<div class="filter-date-group">';
html += '<span class="filter-date-label"><i class="icon-calendar"></i> ' + (trans.date_added || 'Added') + ':</span>';
html += '<input type="date" class="filter-date-add-from" title="' + (trans.from || 'From') + '">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="date" class="filter-date-add-to" title="' + (trans.to || 'To') + '">';
html += '</div>';
html += '<div class="filter-date-group">';
html += '<span class="filter-date-label"><i class="icon-clock-o"></i> ' + (trans.last_product || 'Last product') + ':</span>';
html += '<input type="date" class="filter-last-product-from" title="' + (trans.from || 'From') + '">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="date" class="filter-last-product-to" title="' + (trans.to || 'To') + '">';
html += '</div>';
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
html += '</div>';
html += '</div>';
// Entity-specific filters: Suppliers
html += '<div class="filter-row filter-row-entity-suppliers filter-row-multi" data-entity="suppliers" style="display:none;">';
html += '<div class="filter-subrow">';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-cubes"></i> ' + (trans.product_count || 'Products') + ':</span>';
html += '<input type="number" class="filter-product-count-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-product-count-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-shopping-cart"></i> ' + (trans.total_sales || 'Sales') + ':</span>';
html += '<input type="number" class="filter-sales-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-sales-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-money"></i> ' + (trans.turnover || 'Revenue') + ':</span>';
html += '<input type="number" class="filter-turnover-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-turnover-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<label class="filter-label"><input type="checkbox" class="filter-active-only" checked> ' + (trans.active_only || 'Active only') + '</label>';
html += '</div>';
html += '<div class="filter-subrow">';
html += '<div class="filter-date-group">';
html += '<span class="filter-date-label"><i class="icon-calendar"></i> ' + (trans.date_added || 'Added') + ':</span>';
html += '<input type="date" class="filter-date-add-from" title="' + (trans.from || 'From') + '">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="date" class="filter-date-add-to" title="' + (trans.to || 'To') + '">';
html += '</div>';
html += '<div class="filter-date-group">';
html += '<span class="filter-date-label"><i class="icon-clock-o"></i> ' + (trans.last_product || 'Last product') + ':</span>';
html += '<input type="date" class="filter-last-product-from" title="' + (trans.from || 'From') + '">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="date" class="filter-last-product-to" title="' + (trans.to || 'To') + '">';
html += '</div>';
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
html += '</div>';
html += '</div>';
// Entity-specific filters: Attributes
html += '<div class="filter-row filter-row-entity-attributes filter-row-multi" data-entity="attributes" style="display:none;">';
html += '<div class="filter-subrow">';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-cubes"></i> ' + (trans.product_count || 'Products') + ':</span>';
html += '<input type="number" class="filter-product-count-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-product-count-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-shopping-cart"></i> ' + (trans.total_sales || 'Sales') + ':</span>';
html += '<input type="number" class="filter-sales-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-sales-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-money"></i> ' + (trans.turnover || 'Revenue') + ':</span>';
html += '<input type="number" class="filter-turnover-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-turnover-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '</div>';
html += '<div class="filter-subrow">';
html += '<div class="filter-select-group">';
html += '<span class="filter-select-label"><i class="icon-tags"></i> ' + (trans.attribute_group || 'Group') + ':</span>';
html += '<select class="filter-attribute-group-select">';
html += '<option value="">' + (trans.all_groups || 'All groups') + '</option>';
html += '</select>';
html += '</div>';
html += '<label class="filter-label"><input type="checkbox" class="filter-is-color"> ' + (trans.color_only || 'Color attributes') + '</label>';
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
html += '</div>';
html += '</div>';
// Entity-specific filters: Features
html += '<div class="filter-row filter-row-entity-features filter-row-multi" data-entity="features" style="display:none;">';
html += '<div class="filter-subrow">';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-cubes"></i> ' + (trans.product_count || 'Products') + ':</span>';
html += '<input type="number" class="filter-product-count-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-product-count-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-shopping-cart"></i> ' + (trans.total_sales || 'Sales') + ':</span>';
html += '<input type="number" class="filter-sales-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-sales-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '<div class="filter-range-group">';
html += '<span class="filter-range-label"><i class="icon-money"></i> ' + (trans.turnover || 'Revenue') + ':</span>';
html += '<input type="number" class="filter-turnover-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
html += '<span class="filter-range-sep">-</span>';
html += '<input type="number" class="filter-turnover-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
html += '</div>';
html += '</div>';
html += '<div class="filter-subrow">';
html += '<div class="filter-select-group">';
html += '<span class="filter-select-label"><i class="icon-list-ul"></i> ' + (trans.feature_group || 'Group') + ':</span>';
html += '<select class="filter-feature-group-select">';
html += '<option value="">' + (trans.all_groups || 'All groups') + '</option>';
html += '</select>';
html += '</div>';
html += '<label class="filter-label"><input type="checkbox" class="filter-is-custom"> ' + (trans.custom_only || 'Custom values') + '</label>';
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
html += '</div>';
html += '</div>';
// Entity-specific filters: CMS Pages
html += '<div class="filter-row filter-row-entity-cms" data-entity="cms" style="display:none;">';
html += '<label class="filter-label"><input type="checkbox" class="filter-active-only" checked> ' + (trans.active_only || 'Active only') + '</label>';
html += '<label class="filter-label"><input type="checkbox" class="filter-indexable"> ' + (trans.indexable || 'Indexable') + '</label>';
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
html += '</div>';
// Entity-specific filters: CMS Categories
html += '<div class="filter-row filter-row-entity-cms-categories" data-entity="cms_categories" style="display:none;">';
html += '<label class="filter-label"><input type="checkbox" class="filter-active-only" checked> ' + (trans.active_only || 'Active only') + '</label>';
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
html += '</div>';
html += '</div>'; // End filter-panel
// Results header for list view (product columns)
html += '<div class="results-header">';
html += '<span class="header-spacer"></span>';
html += '<span class="header-col header-col-name">' + (trans.product || 'Product') + '</span>';
html += '<span class="header-col header-col-price">' + (trans.price || 'Price') + '</span>';
html += '<span class="header-col header-col-sale">' + (trans.sale || 'Sale') + '</span>';
html += '<span class="header-col header-col-stock">' + (trans.stock || 'Stock') + '</span>';
html += '<span class="header-col header-col-sales">' + (trans.sold || 'Sold') + '</span>';
html += '</div>';
// Results
html += '<div class="dropdown-results"></div>';
// Footer
html += '<div class="dropdown-footer">';
html += '<div class="load-more-controls" style="display:none;">';
html += '<span class="load-more-label">' + (trans.load || 'Load') + '</span>';
html += '<select class="load-more-select">';
html += '<option value="10">10</option>';
html += '<option value="20" selected>20</option>';
html += '<option value="50">50</option>';
html += '<option value="100">100</option>';
html += '</select>';
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 += '<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>';
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 - 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;
}
}
}
$.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('.target-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 = '<div class="no-results"><i class="icon-search"></i> ' + (trans.no_results || 'No results found') + '</div>';
} 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 += '<div class="' + itemClass + '" ';
html += 'data-id="' + self.escapeAttr(item.id) + '" ';
html += 'data-name="' + self.escapeAttr(item.name) + '"';
if (item.image) html += ' data-image="' + self.escapeAttr(item.image) + '"';
if (item.subtitle) html += ' data-subtitle="' + self.escapeAttr(item.subtitle) + '"';
html += '>';
html += '<span class="result-checkbox"><i class="icon-check"></i></span>';
if (item.image) {
html += '<div class="result-image"><img src="' + self.escapeAttr(item.image) + '" alt=""></div>';
} else {
// Entity-specific icons
var iconClass = 'icon-cube'; // default
var searchEntity = self.activeGroup ? self.activeGroup.searchEntity : null;
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 += '<div class="result-icon"><i class="' + iconClass + '"></i></div>';
}
html += '<div class="result-info">';
html += '<div class="result-name">' + self.escapeHtml(item.name) + '</div>';
if (item.subtitle) {
// Split multi-line subtitles into separate divs for styling
var subtitleLines = item.subtitle.split('\n');
html += '<div class="result-subtitle">';
subtitleLines.forEach(function(line, idx) {
var lineClass = idx === 0 ? 'subtitle-line subtitle-line-primary' : 'subtitle-line subtitle-line-secondary';
html += '<div class="' + lineClass + '">' + self.escapeHtml(line) + '</div>';
});
html += '</div>';
}
html += '</div>';
// Add product-specific columns (price, sale price, stock, sold)
if (item.type === 'product') {
if (isListView) {
// List view: full columns
// Regular price
html += '<div class="result-col result-col-price">';
html += '<span class="col-value">' + (item.regular_price_formatted || item.price_formatted || '') + '</span>';
html += '</div>';
// Sale price (only if discounted)
if (item.has_discount) {
html += '<div class="result-col result-col-sale">';
html += '<span class="col-value">' + (item.price_formatted || '') + '</span>';
html += '</div>';
} else {
html += '<div class="result-col result-col-sale"></div>';
}
// Stock column
var stockClass = item.stock_status === 'out_of_stock' ? 'stock-out' :
(item.stock_status === 'low_stock' ? 'stock-low' : 'stock-ok');
html += '<div class="result-col result-col-stock">';
html += '<span class="col-value ' + stockClass + '">' + (item.stock_qty !== undefined ? item.stock_qty : '') + '</span>';
html += '</div>';
// Sales column
html += '<div class="result-col result-col-sales">';
html += '<span class="col-value">' + (item.sales_qty !== undefined ? item.sales_qty : '0') + '</span>';
html += '</div>';
} else {
// Grid view: compact info line
var gridStockClass = item.stock_status === 'out_of_stock' ? 'stock-out' :
(item.stock_status === 'low_stock' ? 'stock-low' : '');
html += '<div class="result-grid-info">';
html += '<span class="grid-price">' + (item.price_formatted || '') + '</span>';
if (item.stock_qty !== undefined) {
html += '<span class="grid-stock ' + gridStockClass + '">' + item.stock_qty + ' qty</span>';
}
if (item.has_discount) {
html += '<span class="grid-discount">-' + (item.discount_percent || '') + '%</span>';
}
html += '</div>';
}
}
html += '</div>';
});
}
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('<option value="' + remaining + '" data-all="true">' + (trans.all || 'All') + ' (' + remaining + ')</option>');
}
}
// 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 = '<div class="search-history-list">';
for (var i = 0; i < history.length; i++) {
var query = history[i];
html += '<div class="history-item" data-query="' + this.escapeAttr(query) + '">';
html += '<i class="icon-clock-o"></i>';
html += '<span class="history-query">' + this.escapeHtml(query) + '</span>';
html += '<button type="button" class="btn-delete-history" title="' + (trans.remove || 'Remove') + '">';
html += '<i class="icon-times"></i>';
html += '</button>';
html += '</div>';
}
html += '</div>';
$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
};
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('');
}
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
};
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('');
}
// 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();
}
},
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('<option value="' + group.id + '">' + self.escapeHtml(group.name) + ' (' + group.count + ')</option>');
});
}
}
});
},
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('<option value="' + group.id + '">' + self.escapeHtml(group.name) + ' (' + group.count + ')</option>');
});
}
}
});
}
};
})(jQuery);
/**
* Entity Selector - Filters Module
* Filter panel, filter state management
* @partial _filters.js
*
* EXTRACTION SOURCE: assets/js/admin/entity-selector.js
* Lines: 6605-6758 (filter methods)
*
* Contains:
* - clearFilters() - Reset all filters
* - resetFiltersWithoutSearch() - Reset without triggering search
* - updateFilterPanelForEntity() - Show/hide filters based on entity type
* - loadFilterableData() - Load attributes/features for filter panel
* - renderFilterDropdowns() - Render attribute/feature group toggles
* - showFilterGroupValues() - Show values for a filter group
* - hideFilterGroupValues() - Hide filter values row
* - updateFilterToggleStates() - Update toggle states based on selections
*/
(function($) {
'use strict';
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
window._EntitySelectorMixins.filters = {
clearFilters: 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
};
if (this.$dropdown) {
var trans = this.config.trans || {};
this.$dropdown.find('.refine-input').val('');
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, .filter-price-max').val('');
this.$dropdown.find('.filter-attr-chip, .filter-feat-chip').removeClass('active');
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-depth-select').val('');
this.$dropdown.find('.filter-has-products').prop('checked', false);
this.$dropdown.find('.filter-active-only').prop('checked', true);
}
this.refreshSearch();
},
resetFiltersWithoutSearch: function() {
// Same as clearFilters but doesn't trigger search
// Used when switching entity types
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
};
},
updateFilterPanelForEntity: function(entityType) {
if (!this.$dropdown) {
return;
}
var $panel = this.$dropdown.find('.filter-panel');
// Hide all entity-specific filter rows
$panel.find('.filter-row').hide();
// Show filters for current entity type
$panel.find('.filter-row[data-entity="' + entityType + '"]').show();
$panel.find('.filter-row-entity-' + entityType.replace('_', '-')).show();
// 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');
}
},
loadFilterableData: function() {
var self = this;
if (this.filterableData) {
this.renderFilterDropdowns();
return;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
data: {
ajax: 1,
action: 'getTargetFilterableAttributes',
trait: 'EntitySelector'
},
dataType: 'json',
success: function(response) {
if (response.success && response.data) {
self.filterableData = response.data;
self.renderFilterDropdowns();
}
}
});
},
renderFilterDropdowns: function() {
if (!this.$dropdown || !this.filterableData) return;
var self = this;
// Render attribute group toggle buttons
var $attrContainer = this.$dropdown.find('.filter-attributes-container');
$attrContainer.empty();
if (this.filterableData.attributes && this.filterableData.attributes.length > 0) {
this.filterableData.attributes.forEach(function(group) {
var html = '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '">';
html += '<span class="toggle-name">' + group.name + '</span>';
if (group.count !== undefined) {
html += '<span class="toggle-count clickable" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '"><i class="icon-eye"></i> ' + group.count + '</span>';
}
html += '</button>';
$attrContainer.append(html);
});
this.$dropdown.find('.filter-row-attributes').show();
}
// Render feature group toggle buttons
var $featContainer = this.$dropdown.find('.filter-features-container');
$featContainer.empty();
if (this.filterableData.features && this.filterableData.features.length > 0) {
this.filterableData.features.forEach(function(group) {
var html = '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '">';
html += '<span class="toggle-name">' + group.name + '</span>';
if (group.count !== undefined) {
html += '<span class="toggle-count clickable" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '"><i class="icon-eye"></i> ' + group.count + '</span>';
}
html += '</button>';
$featContainer.append(html);
});
this.$dropdown.find('.filter-row-features').show();
}
},
showFilterGroupValues: function(groupId, type) {
if (!this.filterableData) return;
var self = this;
var groups = type === 'attribute' ? this.filterableData.attributes : this.filterableData.features;
var group = groups.find(function(g) { return g.id == groupId; });
if (!group) return;
// Hide all values rows first, then show the correct one
this.$dropdown.find('.filter-row-values').hide();
// Target the correct values row based on type
var valuesRowClass = type === 'attribute' ? '.filter-row-attr-values' : '.filter-row-feat-values';
var $filterRowValues = this.$dropdown.find(valuesRowClass);
var $valuesContainer = $filterRowValues.find('.filter-values-container');
$valuesContainer.empty();
// Add group label
var html = '<span class="filter-values-label">' + group.name + ':</span>';
// Add chips
group.values.forEach(function(val) {
var isActive = type === 'attribute'
? self.filters.attributes.indexOf(val.id) !== -1
: self.filters.features.indexOf(val.id) !== -1;
var activeClass = isActive ? ' active' : '';
var chipClass = type === 'attribute' ? 'filter-attr-chip' : 'filter-feat-chip';
var colorStyle = val.color ? ' style="--chip-color: ' + val.color + '"' : '';
var colorClass = val.color ? ' has-color' : '';
html += '<button type="button" class="filter-chip ' + chipClass + activeClass + colorClass + '" data-id="' + val.id + '" data-group-id="' + groupId + '"' + colorStyle + '>';
if (val.color) {
html += '<span class="chip-color-dot"></span>';
}
html += '<span class="chip-name">' + val.name + '</span>';
if (val.count !== undefined) {
html += '<span class="chip-count">(' + val.count + ')</span>';
}
html += '</button>';
});
$valuesContainer.html(html);
// Add close button as sibling (outside filter-values-container, inside filter-row-values)
$filterRowValues.find('.btn-close-values').remove();
$filterRowValues.append('<button type="button" class="btn-close-values"><i class="icon-times"></i></button>');
$filterRowValues.show();
// Scroll into view if needed
var rowValues = $filterRowValues[0];
if (rowValues) {
rowValues.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
},
hideFilterGroupValues: function() {
this.$dropdown.find('.filter-row-values').hide();
this.$dropdown.find('.filter-group-toggle').removeClass('active');
},
updateFilterToggleStates: function() {
if (!this.$dropdown || !this.filterableData) return;
var self = this;
// Update attribute group toggles
if (this.filterableData.attributes) {
this.filterableData.attributes.forEach(function(group) {
var $toggle = self.$dropdown.find('.filter-group-toggle[data-group-id="' + group.id + '"][data-type="attribute"]');
var hasActiveInGroup = group.values.some(function(val) {
return self.filters.attributes.indexOf(val.id) !== -1;
});
$toggle.toggleClass('has-selection', hasActiveInGroup);
});
}
// Update feature group toggles
if (this.filterableData.features) {
this.filterableData.features.forEach(function(group) {
var $toggle = self.$dropdown.find('.filter-group-toggle[data-group-id="' + group.id + '"][data-type="feature"]');
var hasActiveInGroup = group.values.some(function(val) {
return self.filters.features.indexOf(val.id) !== -1;
});
$toggle.toggleClass('has-selection', hasActiveInGroup);
});
}
}
};
})(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);
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('.target-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');
}
// Clear tab badges (since we're clearing other blocks)
this.$wrapper.find('.target-block-tab .tab-badge').remove();
this.$wrapper.find('.target-block-tab').removeClass('has-data');
} 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;
}
var html = '<span class="entity-chip" data-id="' + this.escapeAttr(id) + '">';
if (data && data.image) {
html += '<span class="chip-icon"><img src="' + this.escapeAttr(data.image) + '" alt=""></span>';
}
html += '<span class="chip-name">' + this.escapeHtml(name) + '</span>';
html += '<button type="button" class="chip-remove" title="Remove"><i class="icon-times"></i></button>';
html += '</span>';
$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;
// 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;
}
// Ensure chips wrapper structure exists
this.ensureChipsWrapper($chips);
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');
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');
});
// 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();
}
},
// =========================================================================
// 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('.target-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;
}
// 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'));
});
});
}
});
},
/**
* 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 = $('<span>', {
class: 'range-chip',
'data-min': range.min !== null ? range.min : '',
'data-max': range.max !== null ? range.max : ''
});
$chip.append($('<span>', { class: 'range-chip-text', text: chipText }));
$chip.append($('<button>', {
type: 'button',
class: 'btn-remove-range',
html: '<i class="icon-times"></i>'
}));
$chipsContainer.append($chip);
});
return;
}
if (valueType === 'multi_select_tiles') {
if (!Array.isArray(values) || values.length === 0) return;
values.forEach(function(key) {
$picker.find('.tile-option[data-value="' + key + '"]').addClass('selected');
});
return;
}
if (valueType === 'combination_attributes') {
if (typeof values !== 'object' || values === null || Object.keys(values).length === 0) {
self.loadCombinationAttributeGroups($picker);
return;
}
$dataInput.val(JSON.stringify(values));
self.loadCombinationAttributeGroups($picker);
return;
}
if (!values.length) {
return;
}
if (valueType === 'pattern') {
values.forEach(function(item) {
if (typeof item === 'string' && item) {
self.addPatternTag($picker, item, false);
} else if (item && item.pattern) {
self.addPatternTag($picker, item.pattern, item.caseSensitive === true);
}
});
return;
}
// For entity_search type - show loading placeholders and collect for bulk load
var searchEntity = $picker.attr('data-search-entity') || blockType;
var $chips = $picker.find('.entity-chips');
// Get icon for entity type
var entityIcon = this.getEntityTypeIcon(searchEntity);
// Show loading placeholders with entity-specific icons
values.forEach(function(id) {
var html = '<span class="entity-chip entity-chip-loading" data-id="' + self.escapeAttr(id) + '">';
html += '<span class="chip-icon"><i class="' + entityIcon + ' icon-spin-pulse"></i></span>';
html += '<span class="chip-name">Loading...</span>';
html += '</span>';
$chips.append(html);
});
// Collect for bulk load
if (!entitiesToLoad[searchEntity]) {
entitiesToLoad[searchEntity] = { ids: [], pickers: [] };
}
entitiesToLoad[searchEntity].ids = entitiesToLoad[searchEntity].ids.concat(values);
entitiesToLoad[searchEntity].pickers.push({
$picker: $picker,
ids: values
});
},
loadPickerValues: function($picker, blockType) {
// This function is now only used for dynamic loading (not initial load)
// Initial load uses collectPickerEntities + bulk AJAX
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 values = [];
try {
values = JSON.parse($dataInput.val() || '[]');
} catch (e) {
return;
}
// Handle empty/missing values based on type
if (valueType === 'multi_numeric_range') {
// For multi_numeric_range, values is an array of {min, max} objects
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 = $('<span>', {
class: 'range-chip',
'data-min': range.min !== null ? range.min : '',
'data-max': range.max !== null ? range.max : ''
});
$chip.append($('<span>', { class: 'range-chip-text', text: chipText }));
$chip.append($('<button>', {
type: 'button',
class: 'btn-remove-range',
html: '<i class="icon-times"></i>'
}));
$chipsContainer.append($chip);
});
return;
}
if (valueType === 'multi_select_tiles') {
// For multi_select_tiles, values is an array of selected keys
if (!Array.isArray(values) || values.length === 0) return;
values.forEach(function(key) {
$picker.find('.tile-option[data-value="' + key + '"]').addClass('selected');
});
return;
}
if (valueType === 'combination_attributes') {
// For combination_attributes, values is an object: { groupId: [valueId1, valueId2], ... }
if (typeof values !== 'object' || values === null || Object.keys(values).length === 0) {
// Still need to load the attribute groups UI
self.loadCombinationAttributeGroups($picker);
return;
}
// Store data in hidden input for later restoration
$dataInput.val(JSON.stringify(values));
// Load attribute groups and values will be restored after loading
self.loadCombinationAttributeGroups($picker);
return;
}
if (!values.length) return;
// Handle pattern type - load as tags
// Supports both old format (array of strings) and new format (array of {pattern, caseSensitive})
if (valueType === 'pattern') {
values.forEach(function(item) {
if (typeof item === 'string' && item) {
// Old format: just a string (default to case-insensitive)
self.addPatternTag($picker, item, false);
} else if (item && item.pattern) {
// New format: object with pattern and caseSensitive
self.addPatternTag($picker, item.pattern, item.caseSensitive === true);
}
});
return;
}
// Handle entity_search type - load via AJAX
var searchEntity = $picker.attr('data-search-entity') || blockType;
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getTargetEntitiesByIds',
trait: 'EntitySelector',
entity_type: searchEntity,
ids: JSON.stringify(values)
},
success: function(response) {
if (response.success && response.entities) {
// Track which IDs were actually found (entities may have been deleted)
var validIds = [];
response.entities.forEach(function(entity) {
// Use addSelectionNoUpdate to avoid multiple visibility updates
self.addSelectionNoUpdate($picker, entity.id, entity.name, entity);
validIds.push(entity.id);
});
// Update chips visibility once after all chips are added
var $chips = $picker.find('.entity-chips');
self.updateChipsVisibility($chips);
// If some entities were not found, update the hidden input to remove orphaned IDs
if (validIds.length !== values.length) {
$dataInput.val(JSON.stringify(validIds));
// Re-serialize to update the main form data
self.serializeAllBlocks();
}
self.updateBlockStatus($picker.closest('.target-block'));
}
}
});
},
// =========================================================================
// Pattern Tag Methods
// =========================================================================
/**
* Add a pattern tag to the pattern chips container
*/
addPatternTag: function($wrapper, pattern, caseSensitive) {
var trans = this.config.trans || {};
var $chipsContainer = $wrapper.find('.pattern-chips');
// Default to case-insensitive (false)
var isCaseSensitive = caseSensitive === true;
var caseTitle = isCaseSensitive
? (trans.case_sensitive || 'Case sensitive - click to toggle')
: (trans.case_insensitive || 'Case insensitive - click to toggle');
var html = '<div class="pattern-tag' + (isCaseSensitive ? ' case-sensitive' : '') + '" data-pattern="' + this.escapeAttr(pattern) + '" data-case-sensitive="' + (isCaseSensitive ? '1' : '0') + '">';
html += '<button type="button" class="btn-toggle-case" title="' + this.escapeAttr(caseTitle) + '">';
html += '<span class="case-icon">' + (isCaseSensitive ? 'Aa' : 'aa') + '</span>';
html += '</button>';
html += '<span class="pattern-tag-text">' + this.escapeHtml(pattern) + '</span>';
html += '<button type="button" class="btn-remove-pattern" title="' + this.escapeAttr(trans.remove_pattern || 'Remove pattern') + '"><i class="icon-trash"></i></button>';
html += '</div>';
$chipsContainer.append(html);
},
/**
* Get all pattern tags from a wrapper
* Returns array of objects: { pattern: string, caseSensitive: boolean }
*/
getPatternTags: function($wrapper) {
var patterns = [];
// Exclude draft-tag which is the input field, not a saved pattern
$wrapper.find('.pattern-tag:not(.draft-tag)').each(function() {
var pattern = $(this).data('pattern');
var caseSensitive = $(this).data('caseSensitive') === 1 || $(this).data('caseSensitive') === '1';
if (pattern) {
patterns.push({
pattern: pattern,
caseSensitive: caseSensitive
});
}
});
return patterns;
},
/**
* Update the match count displayed in the draft tag while typing
* Shows live preview with current case sensitivity setting
*/
updateDraftTagCount: function($draftTag, pattern, caseSensitive) {
var self = this;
var $matchCount = $draftTag.find('.pattern-match-count');
var $countValue = $matchCount.find('.count-value');
// Get entity type from block
var $block = $draftTag.closest('.target-block');
var entityType = $block.data('blockType') || 'products';
// Show loading - keep eye icon, update count value
$countValue.html('<i class="icon-spinner icon-spin"></i>');
$matchCount.show();
// Store pattern for click handler
$matchCount.data('pattern', pattern);
$matchCount.data('caseSensitive', caseSensitive);
$matchCount.data('entityType', entityType);
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'countPatternMatches',
trait: 'EntitySelector',
pattern: pattern,
field: 'name',
entity_type: entityType,
case_sensitive: caseSensitive ? 1 : 0
},
success: function(response) {
if (response.success) {
var count = parseInt(response.count, 10) || 0;
$countValue.text(count);
$matchCount.show();
// Add visual feedback based on count
$matchCount.removeClass('count-zero count-found');
$matchCount.addClass(count === 0 ? 'count-zero' : 'count-found');
// Store count for preview
$matchCount.data('count', count);
// Update group total count to reflect draft pattern in calculation
var $group = $draftTag.closest('.selection-group');
if ($group.length) {
self.updateGroupTotalCount($group);
}
} else {
$countValue.text('?');
$matchCount.show();
}
},
error: function() {
$countValue.text('?');
$matchCount.show();
}
});
},
/**
* Update condition count with a pending pattern (typed but not yet added as tag)
* This shows a live preview of what the count would be if the user pressed Enter
*/
updateConditionCountWithPendingPattern: function($row, pendingPattern) {
var self = this;
var trans = this.config.trans || {};
// Find the count element - in method-selector-wrapper for include, in exclude-header-row for exclude
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';
// Only process for pattern value types
if (valueType !== 'pattern') {
return;
}
// Get existing pattern tags
var values = this.getPatternTags($picker);
// Add the pending pattern as a temporary tag (case-insensitive by default)
if (pendingPattern) {
values.push({ pattern: pendingPattern, caseSensitive: false });
}
if (values.length === 0) {
$countEl.hide();
return;
}
var $block = $row.closest('.target-block');
var 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,
blockType: blockType,
isExclude: isExclude
});
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'countConditionMatches',
trait: 'EntitySelector',
method: method,
values: JSON.stringify(values),
block_type: blockType
},
success: function(response) {
if (response && response.success) {
var count = response.count || 0;
$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();
}
} else {
$countEl.hide().removeClass('clickable');
}
},
error: function() {
$countEl.hide().removeClass('clickable');
}
});
},
/**
* Fetch pattern match count via AJAX
*/
fetchPatternMatchCount: function($picker, pattern, $countEl) {
// Determine field type from method select
// Check if we're in an exclude row first, then fall back to include
var $excludeRow = $picker.closest('.exclude-row');
var $methodSelect;
if ($excludeRow.length) {
$methodSelect = $excludeRow.find('.exclude-method-select');
} else {
var $group = $picker.closest('.selection-group');
$methodSelect = $group.find('.include-method-select');
}
var method = $methodSelect.val() || '';
var field = method.indexOf('reference') !== -1 ? 'reference' : 'name';
// Get entity type from block
var $block = $picker.closest('.target-block');
var entityType = $block.data('blockType') || 'products';
// Show loading state
$countEl.find('.preview-count').html('<i class="icon-spinner icon-spin"></i>');
$countEl.removeClass('clickable no-matches').show();
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'countPatternMatches',
trait: 'EntitySelector',
pattern: pattern,
field: field,
entity_type: entityType,
case_sensitive: 0
},
success: function(response) {
if (response && response.success) {
var count = response.count || 0;
$countEl.find('.preview-count').text(count);
$countEl.removeClass('no-matches clickable').show();
if (count === 0) {
$countEl.addClass('no-matches');
} else {
$countEl.addClass('clickable');
}
} else {
$countEl.hide();
}
},
error: function() {
$countEl.hide();
}
});
},
// =========================================================================
// Picker Value Extraction
// =========================================================================
/**
* Get values from a picker based on its type
*/
getPickerValues: function($picker, valueType) {
switch (valueType) {
case 'entity_search':
var ids = [];
$picker.find('.entity-chip').each(function() {
var id = $(this).data('id');
if (id) ids.push(id);
});
return ids;
case 'pattern':
var patternValues = this.getPatternTags($picker);
// Also include draft pattern if it has content (not yet added as tag)
var $draftPatternInput = $picker.find('.draft-tag .pattern-input');
var draftPatternVal = $.trim($draftPatternInput.val());
if (draftPatternVal) {
var draftCaseSens = $draftPatternInput.closest('.draft-tag').attr('data-case-sensitive') === '1';
patternValues.push({
pattern: draftPatternVal,
caseSensitive: draftCaseSens
});
}
return patternValues;
case 'numeric_range':
var min = $picker.find('.range-min-input').val();
var max = $picker.find('.range-max-input').val();
return { min: min || null, max: max || null };
case 'date_range':
var from = $picker.find('.date-from-input').val();
var to = $picker.find('.date-to-input').val();
return { from: from || null, to: to || null };
case 'select':
return [$picker.find('.select-value-input').val()];
case 'boolean':
return [true];
default:
return [];
}
},
// =========================================================================
// Count/Status Updates
// =========================================================================
/**
* Fetch and update condition match count for a row (include or exclude)
*/
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
var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row > .condition-match-count').first();
if (!$countEl.length) return;
// Determine if this is an include or exclude row
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;
}
// Get the picker and extract values
var $picker = isExclude
? $row.find('.exclude-picker')
: $row.find('.include-picker');
var valueType = $picker.data('valueType') || 'none';
var values = this.getPickerValues($picker, valueType);
// Don't count if no values (except for boolean/all methods)
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;
}
// Get block type
var $block = $row.closest('.target-block');
var 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,
blockType: blockType,
isExclude: isExclude
});
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'countConditionMatches',
trait: 'EntitySelector',
method: method,
values: JSON.stringify(values),
block_type: blockType
},
success: function(response) {
if (response && response.success) {
var count = response.count || 0;
$countEl.removeClass('no-matches clickable');
if (count === 0) {
$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();
}
} else {
$countEl.hide().removeClass('clickable');
}
},
error: function() {
$countEl.hide().removeClass('clickable');
}
});
},
/**
* Update all condition counts in a group
*/
updateGroupCounts: function($group) {
var self = this;
// Update include count
var $include = $group.find('.group-include');
if ($include.length) {
this.updateConditionCount($include);
}
// Update each exclude row count
$group.find('.exclude-row').each(function() {
self.updateConditionCount($(this));
});
// Update group total count (include - excludes)
this.updateGroupTotalCount($group);
},
/**
* Update the group total count badge (include - excludes)
* Also updates the limit input placeholder
*/
updateGroupTotalCount: function($group) {
var self = this;
var $block = $group.closest('.target-block');
var blockType = $block.data('blockType') || 'products';
var $badge = $group.find('.group-header .group-count-badge');
var $limitInput = $group.find('.group-modifier-limit');
// Build group data for AJAX
var groupData = this.serializeGroup($group, blockType);
// Check if include has valid data
if (!groupData.include || !groupData.include.method) {
$badge.hide();
$limitInput.attr('placeholder', '');
return;
}
// Show loading
$badge.html('<i class="icon-spinner icon-spin"></i>').show();
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'countGroupItems',
trait: 'EntitySelector',
group_data: JSON.stringify(groupData),
block_type: blockType
},
success: function(response) {
if (response && response.success) {
var finalCount = response.final_count || 0;
var excludeCount = response.exclude_count || 0;
// Update badge with eye icon and count
var badgeHtml = '<i class="icon-eye"></i> ' + finalCount;
if (excludeCount > 0) {
badgeHtml += ' <span class="exclude-info">(-' + excludeCount + ')</span>';
}
$badge.html(badgeHtml);
$badge.addClass('clickable').show();
// Store group data on badge for preview popover
$badge.data('groupData', groupData);
$badge.data('blockType', blockType);
$badge.data('finalCount', finalCount);
// Update limit placeholder with the count
$limitInput.attr('placeholder', finalCount);
// Also update the group-preview-badge count (apply limit if set)
var $previewBadge = $group.find('.group-preview-badge .preview-count');
if ($previewBadge.length) {
var limit = parseInt($limitInput.val(), 10);
var displayCount = (limit > 0 && limit < finalCount) ? limit : finalCount;
$previewBadge.text(displayCount);
}
} else {
$badge.hide().removeClass('clickable');
$limitInput.attr('placeholder', '');
}
},
error: function() {
$badge.hide();
$limitInput.attr('placeholder', '');
}
});
},
/**
* Update all condition counts for all visible groups
*/
updateAllConditionCounts: function() {
var self = this;
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();
}
}
});
}
};
})(jQuery);
/**
* Entity Selector - Groups Module
* Selection group management, serialization, block/tab management
* @partial _groups.js
*
* Contains:
* - Group management: addGroup, removeGroup, clearAllConditions
* - Block/Tab: switchToBlock, updateTabBadges, updateBlockStatus
* - Serialization: serializeGroup, serializeAllBlocks, getBlockGroups
* - Counts: fetchProductCount, updateHeaderTotalCount, updateAllConditionCounts
* - Excludes: addFirstExcludeRow, addExcludeRow, removeExcludeRow
* - Validation: validate, showValidationError, clearValidationError
*/
(function($) {
'use strict';
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
window._EntitySelectorMixins.groups = {
addGroup: function($block, blockType) {
var $container = $block.find('.groups-container');
var trans = this.config.trans || {};
var blockDef = this.config.blocks[blockType] || {};
var methods = blockDef.selection_methods || {};
// Remove empty state
$container.find('.groups-empty-state').remove();
// Get next group index
var maxIndex = -1;
$container.find('.selection-group').each(function() {
var idx = parseInt($(this).data('groupIndex'), 10);
if (idx > maxIndex) maxIndex = idx;
});
var groupIndex = maxIndex + 1;
// Build method options with optgroups
var methodOptions = this.buildMethodOptions(methods, false);
// Build exclude method options (no "all") with optgroups
var excludeMethodOptions = this.buildMethodOptions(methods, true);
var defaultGroupName = (trans.group || 'Group') + ' ' + (groupIndex + 1);
var html = '<div class="selection-group" data-group-index="' + groupIndex + '" data-group-name="">';
// Group header
html += '<div class="group-header">';
html += '<span class="group-collapse-toggle"><i class="icon-chevron-up"></i></span>';
html += '<span class="group-name-wrapper">';
html += '<input type="text" class="group-name-input" value="" placeholder="' + defaultGroupName + '" title="' + (trans.click_to_name || 'Click to name this group') + '">';
html += '<span class="group-count-badge" style="display:none;"><i class="icon-spinner icon-spin"></i></span>';
html += '</span>';
html += '<button type="button" class="btn-remove-group" title="' + (trans.remove_group || 'Remove group') + '">';
html += '<i class="icon-trash"></i>';
html += '</button>';
html += '</div>';
// Group body (collapsible content)
html += '<div class="group-body">';
// Include section
html += '<div class="group-include">';
html += '<div class="section-row">';
html += '<div class="method-selector-wrapper">';
html += '<select class="include-method-select">' + methodOptions + '</select>';
html += '<span class="condition-match-count no-matches"><i class="icon-eye"></i> <span class="preview-count">0</span></span>';
html += '<span class="method-info-placeholder"></span>';
html += '</div>';
var noItemsText = trans.no_items_selected || 'No items selected - use search below';
html += '<div class="value-picker include-picker" style="display:none;" data-search-entity="' + blockType + '">';
html += '<div class="entity-chips include-chips" data-placeholder="' + noItemsText + '"></div>';
html += '<div class="entity-search-box">';
html += '<i class="icon-search entity-search-icon"></i>';
html += '<input type="text" class="entity-search-input" placeholder="' + (trans.search_placeholder || 'Search by name, reference, ID...') + '" autocomplete="off">';
html += '<span class="search-loading" style="display:none;"><i class="icon-spinner icon-spin"></i></span>';
html += '</div>';
html += '<input type="hidden" class="include-values-data" value="[]">';
html += '</div>';
html += '</div>';
html += '</div>';
// Excludes section (collapsed by default)
html += '<div class="group-excludes">';
html += '<button type="button" class="btn-add-exclude">';
html += '<i class="icon-plus"></i> ' + (trans.add_exceptions || 'Add exceptions');
html += '</button>';
html += '</div>';
// Group-level modifiers (limit & sort)
html += '<div class="group-modifiers">';
html += '<span class="modifier-inline modifier-limit">';
html += '<span class="modifier-label">' + (trans.limit || 'Limit') + '</span>';
html += '<input type="number" class="group-modifier-limit" placeholder="" min="1" step="1" title="' + (trans.limit_tooltip || 'Max items to return (empty = all)') + '">';
html += '</span>';
html += '<span class="modifier-inline modifier-sort">';
html += '<span class="modifier-label">' + (trans.sort || 'Sort') + '</span>';
html += '<select class="group-modifier-sort">';
html += '<option value="sales" selected>' + (trans.sort_bestsellers || 'Best sellers') + '</option>';
html += '<option value="date_add">' + (trans.sort_newest || 'Newest') + '</option>';
html += '<option value="price">' + (trans.sort_price || 'Price') + '</option>';
html += '<option value="name">' + (trans.sort_name || 'Name') + '</option>';
html += '<option value="position">' + (trans.sort_position || 'Position') + '</option>';
html += '<option value="quantity">' + (trans.sort_stock || 'Stock quantity') + '</option>';
html += '<option value="random">' + (trans.sort_random || 'Random') + '</option>';
html += '</select>';
html += '<button type="button" class="btn-sort-dir" data-dir="DESC" title="' + (trans.sort_direction || 'Sort direction') + '">';
html += '<i class="icon-sort-amount-desc"></i>';
html += '</button>';
html += '</span>';
html += '<span class="group-preview-badge clickable" title="' + (trans.preview_results || 'Preview results') + '">';
html += '<i class="icon-eye"></i> <span class="preview-count"></span>';
html += '</span>';
html += '</div>';
html += '</div>'; // Close group-body
html += '</div>'; // Close selection-group
$container.append(html);
// Find the new group and set method to "all" by default
var $newGroup = $container.find('.selection-group[data-group-index="' + groupIndex + '"]');
// Enhance the method select with styled dropdown
this.enhanceMethodSelect($newGroup.find('.include-method-select'));
$newGroup.find('.include-method-select').val('all').trigger('change');
this.updateBlockStatus($block);
this.serializeAllBlocks();
},
removeGroup: function($group, $block) {
$group.remove();
var $container = $block.find('.groups-container');
var remainingGroups = $container.find('.selection-group').length;
if (remainingGroups === 0) {
var emptyText = this.getEmptyStateText($block);
var emptyHtml = '<div class="groups-empty-state">';
emptyHtml += '<span class="empty-state-text">' + emptyText + '</span>';
emptyHtml += '</div>';
$container.html(emptyHtml);
}
this.updateBlockStatus($block);
this.serializeAllBlocks();
// Update tab badges and header total count
this.updateTabBadges();
},
clearAllConditions: function() {
var self = this;
// Remove all groups from all blocks
this.$wrapper.find('.target-block').each(function() {
var $block = $(this);
var $container = $block.find('.groups-container');
// Remove all groups
$container.find('.selection-group').remove();
// Show empty state
var emptyText = self.getEmptyStateText($block);
var emptyHtml = '<div class="groups-empty-state">';
emptyHtml += '<span class="empty-state-text">' + emptyText + '</span>';
emptyHtml += '</div>';
$container.html(emptyHtml);
self.updateBlockStatus($block);
});
// Update serialized data
this.serializeAllBlocks();
// Update tab badges and header count
this.updateTabBadges();
// Also update header total count immediately (since all cleared)
this.updateHeaderTotalCount();
},
switchToBlock: function(blockType) {
// Update tabs
this.$wrapper.find('.target-block-tab').removeClass('active');
this.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]').addClass('active');
// Update blocks
this.$wrapper.find('.target-block').removeClass('active').hide();
this.$wrapper.find('.target-block[data-block-type="' + blockType + '"]').addClass('active').show();
// Close dropdown if open
this.hideDropdown();
},
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');
var $block = self.$wrapper.find('.target-block[data-block-type="' + blockType + '"]');
var groupCount = $block.find('.selection-group').length;
// Update or add badge
var $badge = $tab.find('.tab-badge');
if (groupCount > 0) {
// Show loading state first
if ($badge.length) {
$badge.addClass('loading').html('<i class="icon-spinner icon-spin"></i>');
} else {
$tab.append('<span class="tab-badge loading"><i class="icon-spinner icon-spin"></i></span>');
}
$tab.addClass('has-data');
blockTypesWithData.push(blockType);
} else {
$badge.remove();
$tab.removeClass('has-data');
}
});
// 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() {
var $switch = this.$wrapper.find('.prestashop-switch');
if (!$switch.length) {
return;
}
// Check if any block has data
var hasData = false;
this.$wrapper.find('.target-block').each(function() {
if ($(this).find('.selection-group').length > 0) {
hasData = true;
return false; // break
}
});
// Update switch: value="1" is "Everyone/All/None", value="0" is "Specific/Selected"
if (hasData) {
$switch.find('input[value="0"]').prop('checked', true);
} else {
$switch.find('input[value="1"]').prop('checked', true);
}
},
/**
* 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 = {};
// Read from hidden input (contains full saved data or freshly serialized data)
var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]');
var savedData = {};
try {
savedData = JSON.parse($hiddenInput.val() || '{}');
} catch (e) {
savedData = {};
}
// Get groups for the requested block type
var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : [];
if (groups.length === 0) {
$tab.find('.tab-badge').remove();
$tab.removeClass('has-data');
$tab.removeData('previewData');
return;
}
// Show loading state
var $badge = $tab.find('.tab-badge');
if (!$badge.length) {
$badge = $('<span class="tab-badge loading"><i class="icon-spinner icon-spin"></i></span>');
$tab.append($badge);
} else {
$badge.addClass('loading').html('<i class="icon-spinner icon-spin"></i>');
}
$tab.addClass('has-data');
data[blockType] = { groups: groups };
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewEntitySelector',
trait: 'EntitySelector',
conditions: JSON.stringify(data),
block_type: blockType,
limit: 10
},
success: function(response) {
if (response.success) {
var $badge = $tab.find('.tab-badge');
$badge.removeClass('loading').html('<i class="icon-eye"></i> ' + response.count);
// Store preview data for popover
$tab.data('previewData', response);
// 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(xhr, status, error) {
console.error('[EntitySelector] AJAX error for', blockType, ':', status, error);
$tab.find('.tab-badge').remove();
self.updateHeaderTotalCount();
}
});
},
updateHeaderTotalCount: function() {
var self = this;
var total = 0;
// Sum up all tab badge counts
this.$wrapper.find('.target-block-tab .tab-badge').each(function() {
var $badge = $(this);
if (!$badge.hasClass('loading')) {
var count = parseInt($badge.text(), 10);
if (!isNaN(count)) {
total += count;
}
}
});
var $totalBadge = this.$wrapper.find('.trait-total-count');
if (total > 0) {
$totalBadge.find('.count-value').text(total);
$totalBadge.show();
} else {
$totalBadge.hide();
}
// Update show-all toggle state
this.updateShowAllToggle();
},
updateShowAllToggle: function() {
var $toggle = this.$wrapper.find('.trait-show-all-toggle');
if (!$toggle.length) return;
var $checkbox = $toggle.find('.show-all-checkbox');
var hasData = this.$wrapper.find('.target-block-tab.has-data').length > 0;
// If there's data, uncheck (not showing to all), otherwise check
$checkbox.prop('checked', !hasData);
},
updateBlockStatus: function($block) {
var $status = $block.find('.block-status');
var blockType = $block.data('blockType');
var blockDef = this.config.blocks[blockType] || {};
var trans = this.config.trans || {};
var groups = this.getBlockGroups($block);
if (groups.length === 0) {
var emptyMeansAll = this.config.emptyMeansAll !== false;
if (emptyMeansAll) {
$status.text((trans.all || 'All') + ' ' + (blockDef.entity_label_plural || 'items'));
} else {
$status.text(trans.nothing_selected || 'Nothing selected');
}
} else {
$status.text(groups.length + ' ' + (groups.length === 1 ? (trans.group || 'group') : (trans.groups || 'groups')));
}
},
getEmptyStateText: function($block) {
var blockType = $block.data('blockType');
var blockMode = $block.data('mode') || 'multi';
var blockDef = this.config.blocks[blockType] || {};
var trans = this.config.trans || {};
var emptyMeansAll = this.config.emptyMeansAll !== false;
if (blockMode === 'single') {
return trans.no_item_selected || 'No item selected';
}
if (emptyMeansAll) {
return (trans.all || 'All') + ' ' + (blockDef.entity_label_plural || 'items') + ' ' + (trans.included || 'included');
}
return trans.nothing_selected || 'Nothing selected';
},
serializeGroup: function($group, blockType) {
var self = this;
// Include
var includeMethod = $group.find('.include-method-select').val() || 'all';
var $includePicker = $group.find('.include-picker');
var includeValues = this.getPickerValues($includePicker);
// Excludes (multiple rows)
var excludes = [];
var $excludesSection = $group.find('.group-excludes.has-excludes');
if ($excludesSection.length) {
$group.find('.exclude-row').each(function() {
var $row = $(this);
var excludeMethod = $row.find('.exclude-method-select').val() || null;
var $excludePicker = $row.find('.exclude-picker');
var excludeValues = self.getPickerValues($excludePicker);
if (excludeMethod && excludeValues && (Array.isArray(excludeValues) ? excludeValues.length > 0 : true)) {
excludes.push({
method: excludeMethod,
values: excludeValues
});
}
});
}
var groupData = {
include: {
method: includeMethod,
values: includeValues
}
};
if (excludes.length > 0) {
groupData.excludes = excludes;
}
// Add modifiers if present
var modifiers = this.getGroupModifiers($group);
if (modifiers.limit || modifiers.sort_by) {
groupData.modifiers = modifiers;
}
return groupData;
},
serializeAllBlocks: function($changedRow) {
var self = this;
var data = {};
this.$wrapper.find('.target-block').each(function() {
var $block = $(this);
var blockType = $block.data('blockType');
var groups = self.getBlockGroups($block);
// Groups now contain their own modifiers, no block-level modifiers
if (groups.length > 0) {
data[blockType] = { groups: groups };
}
self.updateBlockStatus($block);
});
// Update hidden input first
var $input = this.$wrapper.find('input[name="' + this.config.name + '"]');
$input.val(JSON.stringify(data));
// Then update tab badges (reads from hidden input)
this.updateTabBadges();
// Debounced update of condition count - only for changed row if specified
if (this.countUpdateTimeout) {
clearTimeout(this.countUpdateTimeout);
}
this.countUpdateTimeout = setTimeout(function() {
if ($changedRow && $changedRow.length) {
// Update the specific row that changed
self.updateConditionCount($changedRow);
// Also update the group total count (include - excludes)
var $group = $changedRow.closest('.selection-group');
if ($group.length) {
self.updateGroupTotalCount($group);
}
} else {
// Fallback: update all counts (initial load, structure changes)
self.updateAllConditionCounts();
}
}, 500);
},
getBlockGroups: function($block) {
var self = this;
var groups = [];
$block.find('.selection-group').each(function() {
var $group = $(this);
// Include
var includeMethod = $group.find('.include-method-select').val() || 'all';
var $includePicker = $group.find('.include-picker');
var includeValues = self.getPickerValues($includePicker);
// Skip groups with invalid include conditions (e.g., "specific products" with none selected)
if (!self.isConditionValid(includeMethod, includeValues, $includePicker)) {
return true; // continue to next group
}
// Excludes (multiple rows) - only include valid ones
var excludes = [];
var $excludesSection = $group.find('.group-excludes.has-excludes');
if ($excludesSection.length) {
$group.find('.exclude-row').each(function() {
var $row = $(this);
var excludeMethod = $row.find('.exclude-method-select').val() || null;
var $excludePicker = $row.find('.exclude-picker');
var excludeValues = self.getPickerValues($excludePicker);
// Only include valid exclude conditions
if (excludeMethod && self.isConditionValid(excludeMethod, excludeValues, $excludePicker)) {
excludes.push({
method: excludeMethod,
values: excludeValues
});
}
});
}
var groupData = {
include: {
method: includeMethod,
values: includeValues
}
};
// Group name (optional, for organizational purposes)
var groupName = $.trim($group.attr('data-group-name') || '');
if (groupName) {
groupData.name = groupName;
}
if (excludes.length > 0) {
groupData.excludes = excludes;
}
// Group-level modifiers
var modifiers = self.getGroupModifiers($group);
if (modifiers.limit || modifiers.sort_by) {
groupData.modifiers = modifiers;
}
groups.push(groupData);
});
return groups;
},
getGroupModifiers: function($group) {
var limit = $group.find('.group-modifier-limit').val();
var sortBy = $group.find('.group-modifier-sort').val() || 'sales';
var $sortDirBtn = $group.find('.group-modifiers .btn-sort-dir');
var sortDir = $sortDirBtn.data('dir') || 'DESC';
return {
limit: limit ? parseInt(limit, 10) : null,
sort_by: sortBy || null,
sort_dir: sortDir || 'DESC'
};
},
getPickerValues: function($picker) {
var valueType = $picker.attr('data-value-type') || 'entity_search';
var values = [];
switch (valueType) {
case 'entity_search':
$picker.find('.entity-chip').each(function() {
var id = $(this).data('id');
values.push(isNaN(id) ? id : Number(id));
});
break;
case 'pattern':
values = this.getPatternTags($picker);
// Also include draft pattern if it has content (not yet added as tag)
var $draftInput = $picker.find('.draft-tag .pattern-input');
var draftPattern = $.trim($draftInput.val());
if (draftPattern) {
var draftCaseSensitive = $draftInput.closest('.draft-tag').attr('data-case-sensitive') === '1';
values.push({
pattern: draftPattern,
caseSensitive: draftCaseSensitive
});
}
break;
case 'numeric_range':
var min = $picker.find('.range-min-input').val();
var max = $picker.find('.range-max-input').val();
if (min !== '' || max !== '') {
values = {
min: min !== '' ? parseFloat(min) : null,
max: max !== '' ? parseFloat(max) : null
};
}
break;
case 'date_range':
var from = $picker.find('.date-from-input').val();
var to = $picker.find('.date-to-input').val();
if (from || to) {
values = {
from: from || null,
to: to || null
};
}
break;
case 'select':
var selectVal = $picker.find('.select-value-input').val();
if (selectVal) {
values = [selectVal];
}
break;
case 'boolean':
values = [true];
break;
case 'multi_numeric_range':
var ranges = [];
$picker.find('.range-chip').each(function() {
var $chip = $(this);
var minVal = $chip.data('min');
var maxVal = $chip.data('max');
ranges.push({
min: minVal !== '' && minVal !== undefined ? parseFloat(minVal) : null,
max: maxVal !== '' && maxVal !== undefined ? parseFloat(maxVal) : null
});
});
if (ranges.length > 0) {
values = ranges;
}
break;
case 'multi_select_tiles':
$picker.find('.tile-option.selected').each(function() {
values.push($(this).data('value'));
});
break;
case 'combination_attributes':
// Returns object: { mode: 'products'|'combinations', attributes: { groupId: [valueId1, valueId2], ... } }
var combAttrs = {};
$picker.find('.comb-attr-value.selected').each(function() {
var groupId = $(this).data('groupId').toString();
var valueId = $(this).data('valueId');
if (!combAttrs[groupId]) {
combAttrs[groupId] = [];
}
combAttrs[groupId].push(valueId);
});
if (Object.keys(combAttrs).length > 0) {
// Get mode: from radio if toggle exists, otherwise from config
var $combPicker = $picker.find('.combination-attributes-picker');
var configMode = $combPicker.data('combinationMode') || this.config.combinationMode || 'products';
var combMode;
if (configMode === 'toggle') {
combMode = $picker.find('.comb-mode-radio:checked').val() || 'products';
} else {
combMode = configMode;
}
values = {
mode: combMode,
attributes: combAttrs
};
}
break;
}
return values;
},
isConditionValid: function(method, values, $picker) {
// 'all' method never needs values
if (method === 'all') {
return true;
}
// Boolean methods are always valid (the value is implicit true)
var valueType = $picker.attr('data-value-type') || 'entity_search';
if (valueType === 'boolean') {
return true;
}
// For other methods, check if values are meaningful
if (Array.isArray(values)) {
return values.length > 0;
}
// For object values (ranges, combination_attributes), check if meaningful
if (typeof values === 'object' && values !== null) {
// Special handling for combination_attributes: { mode, attributes }
if (valueType === 'combination_attributes' && values.attributes !== undefined) {
return Object.keys(values.attributes).length > 0;
}
// For ranges and other objects, check if at least one bound is set
return Object.keys(values).some(function(key) {
return values[key] !== null && values[key] !== '';
});
}
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() {
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;
}
});
});
// If no conditions, nothing to do
if (Object.keys(conditions).length === 0) {
return;
}
// 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');
}
});
}
});
},
/**
* 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 null;
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 null;
}
var $picker = isExclude
? $row.find('.exclude-picker')
: $row.find('.include-picker');
var valueType = $picker.data('valueType') || 'none';
var values = this.getPickerValues($picker, valueType);
// Don't count if no values (except for boolean/all methods)
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) {
$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) {
$countEl.hide();
return;
}
if (!blockType) {
var $block = $row.closest('.target-block');
blockType = $block.data('blockType') || 'products';
}
$countEl.find('.preview-count').html('<i class="icon-spinner icon-spin"></i>');
$countEl.removeClass('clickable no-matches').show();
$countEl.data('conditionData', {
method: method,
values: values,
blockType: blockType,
isExclude: isExclude
});
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'countConditionMatches',
trait: 'EntitySelector',
method: method,
values: JSON.stringify(values),
block_type: blockType
},
success: function(response) {
if (response && response.success) {
var count = response.count || 0;
$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();
}
} else {
$countEl.hide().removeClass('clickable');
}
},
error: function() {
$countEl.hide().removeClass('clickable');
}
});
},
updateGroupTotalCount: function($group) {
var self = this;
var $block = $group.closest('.target-block');
var blockType = $block.data('blockType') || 'products';
var $badge = $group.find('.group-header .group-count-badge');
var $limitInput = $group.find('.group-modifier-limit');
// Build group data for AJAX
var groupData = this.serializeGroup($group, blockType);
// Check if include has valid data
if (!groupData.include || !groupData.include.method) {
$badge.hide();
$limitInput.attr('placeholder', '');
return;
}
// Show loading
$badge.html('<i class="icon-spinner icon-spin"></i>').show();
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'countGroupItems',
trait: 'EntitySelector',
group_data: JSON.stringify(groupData),
block_type: blockType
},
success: function(response) {
if (response && response.success) {
var finalCount = response.final_count || 0;
var excludeCount = response.exclude_count || 0;
// Update badge with eye icon and count
var badgeHtml = '<i class="icon-eye"></i> ' + finalCount;
if (excludeCount > 0) {
badgeHtml += ' <span class="exclude-info">(-' + excludeCount + ')</span>';
}
$badge.html(badgeHtml);
$badge.addClass('clickable').show();
// Store group data on badge for preview popover
$badge.data('groupData', groupData);
$badge.data('blockType', blockType);
$badge.data('finalCount', finalCount);
// Update limit placeholder with the count
$limitInput.attr('placeholder', finalCount);
// Also update the group-preview-badge count (apply limit if set)
var $previewBadge = $group.find('.group-preview-badge .preview-count');
if ($previewBadge.length) {
var limit = parseInt($limitInput.val(), 10);
var displayCount = (limit > 0 && limit < finalCount) ? limit : finalCount;
$previewBadge.text(displayCount);
}
} else {
$badge.hide().removeClass('clickable');
$limitInput.attr('placeholder', '');
}
},
error: function() {
$badge.hide();
$limitInput.attr('placeholder', '');
}
});
},
// Exclude row management
addFirstExcludeRow: function($group, $block) {
var $excludesDiv = $group.find('.group-excludes');
var trans = this.config.trans || {};
// Build the full excludes structure with first row
var html = '<div class="except-separator">';
html += '<span class="except-label"><i class="icon-ban"></i> ' + (trans.except || 'EXCEPT') + '</span>';
html += '</div>';
html += '<div class="exclude-rows-container">';
html += this.buildExcludeRowHtml($block, 0);
html += '</div>';
html += '<button type="button" class="btn-add-another-exclude">';
html += '<i class="icon-plus"></i> ' + (trans.add_another_exception || 'Add another exception');
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();
},
addExcludeRow: function($group, $block) {
var $container = $group.find('.exclude-rows-container');
// Get next exclude index
var maxIndex = -1;
$container.find('.exclude-row').each(function() {
var idx = parseInt($(this).data('excludeIndex'), 10);
if (idx > maxIndex) maxIndex = idx;
});
var excludeIndex = maxIndex + 1;
var html = this.buildExcludeRowHtml($block, excludeIndex);
$container.append(html);
// Enhance the exclude method select with styled dropdown
var $newRow = $container.find('.exclude-row[data-exclude-index="' + excludeIndex + '"]');
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();
},
buildExcludeRowHtml: function($block, excludeIndex) {
var blockType = $block.data('blockType');
var blockDef = this.config.blocks[blockType] || {};
var methods = blockDef.selection_methods || {};
var trans = this.config.trans || {};
// Build exclude method options with optgroups (no "all")
var excludeMethodOptions = this.buildMethodOptions(methods, true);
// Find first non-all method for default search entity
var firstSearchEntity = blockType;
var firstValueType = 'entity_search';
$.each(methods, function(methodKey, methodDef) {
if (methodKey === 'all') return true;
firstSearchEntity = methodDef.search_entity || blockType;
firstValueType = methodDef.value_type || 'entity_search';
return false; // break
});
var html = '<div class="exclude-row" data-exclude-index="' + excludeIndex + '">';
// Header row with method select wrapped in method-selector-wrapper (same as include)
html += '<div class="exclude-header-row">';
html += '<div class="method-selector-wrapper">';
html += '<select class="exclude-method-select">' + excludeMethodOptions + '</select>';
html += '<span class="condition-match-count no-matches"><i class="icon-eye"></i> <span class="preview-count">0</span></span>';
html += '<span class="method-info-placeholder"></span>';
html += '</div>';
html += '<button type="button" class="btn-remove-exclude-row" title="' + (trans.remove_this_exception || 'Remove this exception') + '">';
html += '<i class="icon-trash"></i>';
html += '</button>';
html += '</div>';
// Value picker based on first method's value type
html += this.buildValuePickerHtml('exclude', firstValueType, firstSearchEntity, methods);
html += '</div>';
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(
'<button type="button" class="btn-add-exclude">' +
'<i class="icon-plus"></i> ' + (trans.add_exceptions || 'Add exceptions') +
'</button>'
);
// Unlock the method selector since no excludes exist
this.updateMethodSelectorLock($group, false);
}
this.serializeAllBlocks();
},
// Method options building
buildMethodOptions: function(methods, excludeAll) {
var self = this;
var trans = this.config.trans || {};
var html = '';
// Group labels
var groupLabels = {
'select_by': trans.select_by || 'Select by...',
'filter_by': trans.filter_by || 'Filter by...'
};
// Separate methods by group
var grouped = {};
var ungrouped = {};
$.each(methods, function(methodKey, methodDef) {
if (excludeAll && methodKey === 'all') return true; // skip
var group = methodDef.group || '';
if (group) {
if (!grouped[group]) {
grouped[group] = {};
}
grouped[group][methodKey] = methodDef;
} else {
ungrouped[methodKey] = methodDef;
}
});
// Render ungrouped options first
$.each(ungrouped, function(methodKey, methodDef) {
html += self.buildMethodOption(methodKey, methodDef);
});
// Render grouped options with optgroups
$.each(grouped, function(groupKey, groupMethods) {
var groupLabel = groupLabels[groupKey] || groupKey.replace(/_/g, ' ');
html += '<optgroup label="' + self.escapeAttr(groupLabel) + '">';
$.each(groupMethods, function(methodKey, methodDef) {
html += self.buildMethodOption(methodKey, methodDef);
});
html += '</optgroup>';
});
return html;
},
buildMethodOption: function(methodKey, methodDef) {
var html = '<option value="' + this.escapeAttr(methodKey) + '"';
html += ' data-value-type="' + this.escapeAttr(methodDef.value_type || 'none') + '"';
if (methodDef.icon) {
html += ' data-icon="' + this.escapeAttr(methodDef.icon) + '"';
}
if (methodDef.search_entity) {
html += ' data-search-entity="' + this.escapeAttr(methodDef.search_entity) + '"';
}
if (methodDef.options) {
html += ' data-options="' + this.escapeAttr(JSON.stringify(methodDef.options)) + '"';
}
if (methodDef.exclusive) {
html += ' data-exclusive="true"';
}
if (typeof methodDef.step !== 'undefined') {
html += ' data-step="' + this.escapeAttr(methodDef.step) + '"';
}
if (typeof methodDef.min !== 'undefined') {
html += ' data-min="' + this.escapeAttr(methodDef.min) + '"';
}
html += '>' + this.escapeHtml(methodDef.label) + '</option>';
return html;
},
buildValuePickerHtml: function(section, valueType, searchEntity, methods) {
var trans = this.config.trans || {};
var pickerClass = section + '-picker';
var chipsClass = section + '-chips';
var dataClass = section + '-values-data';
var html = '';
if (valueType === 'none') {
html = '<div class="value-picker ' + pickerClass + '" style="display:none;" data-search-entity="" data-value-type="none">';
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
html += '</div>';
return html;
}
html = '<div class="value-picker ' + pickerClass + '" data-search-entity="' + this.escapeAttr(searchEntity) + '" data-value-type="' + this.escapeAttr(valueType) + '">';
switch (valueType) {
case 'entity_search':
var noItemsText = trans.no_items_selected || 'No items selected - use search below';
html += '<div class="entity-chips ' + chipsClass + '" data-placeholder="' + this.escapeAttr(noItemsText) + '"></div>';
html += '<div class="entity-search-box">';
html += '<i class="icon-search entity-search-icon"></i>';
html += '<input type="text" class="entity-search-input" placeholder="' + this.escapeAttr(trans.search_placeholder || 'Search by name, reference, ID...') + '" autocomplete="off">';
html += '<span class="search-loading" style="display:none;"><i class="icon-spinner icon-spin"></i></span>';
html += '</div>';
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
break;
case 'pattern':
// Build tooltip content for data-details attribute
var tooltipContent = '<strong>' + this.escapeHtml(trans.pattern_help_title || 'Pattern Syntax') + '</strong>';
tooltipContent += '<div class="pattern-help-content">';
tooltipContent += '<div class="pattern-help-item"><code>*</code> <span>' + this.escapeHtml(trans.pattern_help_wildcard || 'any text (wildcard)') + '</span></div>';
tooltipContent += '<div class="pattern-help-item"><code>{number}</code> <span>' + this.escapeHtml(trans.pattern_help_number || 'any number (e.g. 100, 250)') + '</span></div>';
tooltipContent += '<div class="pattern-help-item"><code>{letter}</code> <span>' + this.escapeHtml(trans.pattern_help_letter || 'single letter (A-Z)') + '</span></div>';
tooltipContent += '</div>';
tooltipContent += '<div class="pattern-help-examples">';
tooltipContent += '<strong>' + this.escapeHtml(trans.pattern_help_examples || 'Examples:') + '</strong>';
tooltipContent += '<div class="pattern-example"><code>*cotton*</code> <span>' + this.escapeHtml(trans.pattern_example_1 || 'contains "cotton"') + '</span></div>';
tooltipContent += '<div class="pattern-example"><code>iPhone {number} Pro*</code> <span>' + this.escapeHtml(trans.pattern_example_2 || 'matches "iPhone 15 Pro Max"') + '</span></div>';
tooltipContent += '<div class="pattern-example"><code>Size {letter}</code> <span>' + this.escapeHtml(trans.pattern_example_3 || 'matches "Size M", "Size L"') + '</span></div>';
tooltipContent += '</div>';
var noPatternText = trans.no_patterns || 'No patterns - press Enter to add';
html += '<div class="entity-chips pattern-chips" data-placeholder="' + this.escapeAttr(noPatternText) + '"></div>';
html += '<div class="pattern-input-row">';
// Draft tag styled exactly like saved tags, with input instead of text span
html += '<div class="pattern-tag draft-tag" data-case-sensitive="0">';
html += '<button type="button" class="btn-toggle-case" title="' + this.escapeAttr(trans.case_insensitive || 'Case insensitive - click to toggle') + '"><span class="case-icon">aa</span></button>';
html += '<input type="text" class="pattern-input" value="" placeholder="' + this.escapeAttr(trans.enter_pattern || 'e.g. *cotton*') + '">';
html += '<span class="pattern-match-count" title="' + this.escapeAttr(trans.click_to_preview || 'Click to preview matches') + '"><i class="icon-eye"></i> <span class="count-value"></span></span>';
html += '<button type="button" class="btn-add-pattern" title="' + this.escapeAttr(trans.add_pattern || 'Add pattern (Enter)') + '"><i class="icon-plus"></i></button>';
html += '</div>';
html += '<span class="mpr-info-wrapper" data-details="' + this.escapeAttr(tooltipContent) + '">';
html += '<span class="mpr-icon icon-info link"></span>';
html += '</span>';
html += '</div>';
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
break;
case 'numeric_range':
html += '<div class="numeric-range-box">';
html += '<input type="number" class="range-min-input" value="" placeholder="' + this.escapeAttr(trans.min || 'Min') + '" step="0.01">';
html += '<span class="range-separator">-</span>';
html += '<input type="number" class="range-max-input" value="" placeholder="' + this.escapeAttr(trans.max || 'Max') + '" step="0.01">';
html += '</div>';
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
break;
case 'multi_numeric_range':
html += '<div class="multi-range-container">';
html += '<div class="multi-range-chips"></div>';
html += '<div class="multi-range-input-row">';
html += '<input type="number" class="range-min-input" value="" placeholder="' + this.escapeAttr(trans.min || 'Min') + '" step="0.01">';
html += '<span class="range-separator">-</span>';
html += '<input type="number" class="range-max-input" value="" placeholder="' + this.escapeAttr(trans.max || 'Max') + '" step="0.01">';
html += '<button type="button" class="btn-add-range" title="' + this.escapeAttr(trans.add_range || 'Add range') + '"><i class="icon-plus"></i></button>';
html += '</div>';
html += '</div>';
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
break;
case 'multi_select_tiles':
html += '<div class="multi-select-tiles">';
// Tiles will be populated based on method options
html += '</div>';
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
break;
case 'date_range':
html += '<div class="date-range-box">';
html += '<input type="date" class="date-from-input" value="">';
html += '<span class="range-separator">-</span>';
html += '<input type="date" class="date-to-input" value="">';
html += '</div>';
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
break;
case 'select':
html += '<div class="select-input-box">';
html += '<select class="select-value-input"></select>';
html += '</div>';
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
break;
case 'boolean':
html += '<div class="boolean-input-box">';
html += '<span class="boolean-label">' + this.escapeHtml(trans.yes || 'Yes') + '</span>';
html += '</div>';
html += '<input type="hidden" class="' + dataClass + '" value="[true]">';
break;
case 'combination_attributes':
// Build tooltip content
var combTooltip = '<strong>' + this.escapeHtml(trans.combination_help_title || 'Combination Targeting') + '</strong>';
combTooltip += '<div class="combination-help-content">';
combTooltip += '<p>' + this.escapeHtml(trans.combination_help_desc || 'Select attributes to target specific product combinations.') + '</p>';
combTooltip += '<p><strong>' + this.escapeHtml(trans.combination_help_logic || 'Logic:') + '</strong></p>';
combTooltip += '<ul>';
combTooltip += '<li>' + this.escapeHtml(trans.combination_help_within || 'Within group: OR (Red OR Blue)') + '</li>';
combTooltip += '<li>' + this.escapeHtml(trans.combination_help_between || 'Between groups: AND (Color AND Size)') + '</li>';
combTooltip += '</ul>';
combTooltip += '</div>';
// Combination mode from config: 'products', 'combinations', or 'toggle'
var combMode = this.config.combinationMode || 'products';
var showModeToggle = (combMode === 'toggle');
var defaultMode = showModeToggle ? 'products' : combMode;
html += '<div class="combination-attributes-picker" data-combination-mode="' + this.escapeAttr(combMode) + '">';
// Mode toggle: only show when config is 'toggle'
if (showModeToggle) {
html += '<div class="combination-mode-toggle">';
html += '<label class="combination-mode-option">';
html += '<input type="radio" name="' + this.escapeAttr(section) + '_comb_mode_' + Date.now() + '" class="comb-mode-radio" value="products" checked>';
html += '<span class="mode-label">' + this.escapeHtml(trans.comb_mode_products || 'Products with these combinations') + '</span>';
html += '</label>';
html += '<label class="combination-mode-option">';
html += '<input type="radio" name="' + this.escapeAttr(section) + '_comb_mode_' + Date.now() + '" class="comb-mode-radio" value="combinations">';
html += '<span class="mode-label">' + this.escapeHtml(trans.comb_mode_combinations || 'Only these exact combinations') + '</span>';
html += '</label>';
html += '</div>';
}
html += '<div class="combination-groups-container">';
html += '<span class="combination-loading"><i class="icon-spinner icon-spin"></i> ' + this.escapeHtml(trans.loading || 'Loading...') + '</span>';
html += '</div>';
html += '</div>';
// Store mode along with attributes: { mode: 'products'|'combinations', attributes: { groupId: [valueIds] } }
html += '<input type="hidden" class="' + dataClass + '" value=\'{"mode":"' + defaultMode + '","attributes":{}}\'>';
break;
default:
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
break;
}
html += '</div>';
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('.target-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 = $('<div>', {
class: 'trait-validation-error',
html: '<i class="icon-warning"></i> ' + 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').is(':visible')) {
this.$wrapper.find('.condition-trait-body').slideDown(200);
this.$wrapper.removeClass('collapsed');
}
},
clearValidationError: function() {
this.$wrapper.removeClass('has-validation-error');
this.$wrapper.find('.trait-validation-error').remove();
}
};
})(jQuery);
/**
* Entity Selector - Methods Module
* Method dropdown rendering, value pickers, combination picker
* @partial _methods.js
*
* EXTRACTION SOURCE: assets/js/admin/entity-selector.js
* Lines: 6760-6848 (initMethodDropdowns, enhanceMethodSelect)
* 6849-7051 (showMethodDropdownMenu, buildMethodDropdownMenuHtml, closeMethodDropdownMenu)
* 7053-7138 (populateTiles, applyRangeInputConstraints, showRangeInputError)
* 7139-7380 (combination picker methods)
* 7382-7550 (updateMethodInfoPlaceholder, getBuiltInMethodHelp)
* 7748-7888 (buildSortOptions, updateModifierButtonState, updateMethodSelectorLock)
*
* Contains:
* - initMethodDropdowns() - Initialize styled dropdowns
* - enhanceMethodSelect() - Convert select to styled dropdown
* - showMethodDropdownMenu() - Show method selection menu
* - buildMethodDropdownMenuHtml() - Build menu HTML
* - closeMethodDropdownMenu() - Close dropdown menu
* - updateMethodTrigger() - Update trigger display
* - populateTiles() - Build multi-select tiles
* - applyRangeInputConstraints() - Set numeric input constraints
* - showRangeInputError() - Display validation error
* - loadCombinationAttributeGroups() - Load attribute groups for picker
* - loadCombinationAttributeValues() - Load values for attribute group
* - restoreCombinationSelections() - Restore saved combination state
* - updateCombinationData() - Save combination selection
* - updateCombinationGroupCounts() - Update selection counts
* - updateMethodInfoPlaceholder() - Show method help
* - getBuiltInMethodHelp() - Get help text for methods
* - buildSortOptions() - Build sort dropdown options
* - updateModifierButtonState() - Update modifier toggle state
* - updateMethodSelectorLock() - Lock/unlock method selector
*/
(function($) {
'use strict';
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
window._EntitySelectorMixins.methods = {
/**
* Initialize styled method dropdowns for all method selects
*/
initMethodDropdowns: function() {
var self = this;
this.$wrapper.find('.include-method-select').each(function() {
self.enhanceMethodSelect($(this));
});
this.$wrapper.find('.exclude-method-select').each(function() {
self.enhanceMethodSelect($(this));
});
this.initMethodInfoPlaceholders();
},
/**
* Initialize info placeholders for all existing method selects
*/
initMethodInfoPlaceholders: function() {
var self = this;
this.$wrapper.find('.selection-group').each(function() {
var $group = $(this);
var $block = $group.closest('.target-block');
var blockType = $block.data('blockType') || 'products';
// Include method info
var includeMethod = $group.find('.include-method-select').val() || 'all';
self.updateMethodInfoPlaceholder($group.find('.method-selector-wrapper'), includeMethod, blockType);
// Exclude methods info
$group.find('.exclude-row').each(function() {
var $row = $(this);
var excludeMethod = $row.find('.exclude-method-select').val();
if (excludeMethod) {
self.updateMethodInfoPlaceholder($row.find('.method-selector-wrapper'), excludeMethod, blockType);
}
});
});
},
/**
* Enhance a single method select with styled dropdown
*/
enhanceMethodSelect: function($select) {
var self = this;
if (!$select.length || $select.data('methodDropdownInit')) {
return;
}
$select.data('methodDropdownInit', true);
$select.addClass('method-select-hidden');
var $selectedOption = $select.find('option:selected');
var selectedIcon = $selectedOption.data('icon') || 'icon-caret-down';
var selectedLabel = $selectedOption.text();
var triggerHtml = '<div class="method-dropdown-trigger">';
triggerHtml += '<i class="' + this.escapeAttr(selectedIcon) + ' method-trigger-icon"></i>';
triggerHtml += '<span class="method-trigger-label">' + this.escapeHtml(selectedLabel) + '</span>';
triggerHtml += '<i class="icon-caret-down method-trigger-caret"></i>';
triggerHtml += '</div>';
var $trigger = $(triggerHtml);
$select.after($trigger);
$trigger.on('click', function(e) {
e.preventDefault();
e.stopPropagation();
var $wrapper = $select.closest('.method-selector-wrapper');
if ($wrapper.hasClass('selector-locked')) {
return;
}
self.showMethodDropdownMenu($select, $trigger);
});
$select.on('change.methodDropdown', function() {
self.updateMethodTrigger($select, $trigger);
});
},
/**
* Update the trigger display to match current selection
*/
updateMethodTrigger: function($select, $trigger) {
var $selectedOption = $select.find('option:selected');
var selectedIcon = $selectedOption.data('icon') || 'icon-caret-down';
var selectedLabel = $selectedOption.text();
$trigger.find('.method-trigger-icon').attr('class', selectedIcon + ' method-trigger-icon');
$trigger.find('.method-trigger-label').text(selectedLabel);
},
/**
* Show the method dropdown menu
*/
showMethodDropdownMenu: function($select, $trigger) {
var self = this;
this.closeMethodDropdownMenu();
var menuHtml = this.buildMethodDropdownMenuHtml($select);
var $menu = $(menuHtml);
var triggerOffset = $trigger.offset();
var triggerWidth = $trigger.outerWidth();
var triggerHeight = $trigger.outerHeight();
$menu.css({
position: 'absolute',
top: triggerOffset.top + triggerHeight + 2,
left: triggerOffset.left,
minWidth: triggerWidth,
zIndex: 10001
});
$('body').append($menu);
this.$methodDropdownMenu = $menu;
this.$methodDropdownSelect = $select;
this.$methodDropdownTrigger = $trigger;
$menu.on('click', '.method-dropdown-item', function(e) {
e.preventDefault();
e.stopPropagation();
var value = $(this).data('value');
$select.val(value).trigger('change');
self.closeMethodDropdownMenu();
});
$(document).on('click.methodDropdown', function(e) {
if (!$(e.target).closest('.method-dropdown-menu, .method-dropdown-trigger').length) {
self.closeMethodDropdownMenu();
}
});
$(document).on('keydown.methodDropdown', function(e) {
if (e.keyCode === 27) {
self.closeMethodDropdownMenu();
}
});
},
/**
* Build the dropdown menu HTML
*/
buildMethodDropdownMenuHtml: function($select) {
var self = this;
var html = '<div class="method-dropdown-menu">';
// Render ungrouped options first
$select.children('option').each(function() {
var $el = $(this);
var icon = $el.data('icon') || 'icon-asterisk';
var label = $el.text();
var value = $el.val();
var isSelected = $el.is(':selected');
html += '<div class="method-dropdown-item' + (isSelected ? ' selected' : '') + '" data-value="' + self.escapeAttr(value) + '">';
html += '<i class="' + self.escapeAttr(icon) + ' method-item-icon"></i>';
html += '<span class="method-item-label">' + self.escapeHtml(label) + '</span>';
if (isSelected) {
html += '<i class="icon-check method-item-check"></i>';
}
html += '</div>';
});
// Render optgroups
$select.children('optgroup').each(function() {
var $optgroup = $(this);
var groupLabel = $optgroup.attr('label') || '';
html += '<div class="method-dropdown-optgroup">';
html += '<div class="method-optgroup-label">' + self.escapeHtml(groupLabel) + '</div>';
html += '<div class="method-optgroup-items">';
$optgroup.children('option').each(function() {
var $el = $(this);
var icon = $el.data('icon') || 'icon-cog';
var label = $el.text();
var value = $el.val();
var isSelected = $el.is(':selected');
html += '<div class="method-dropdown-item' + (isSelected ? ' selected' : '') + '" data-value="' + self.escapeAttr(value) + '">';
html += '<i class="' + self.escapeAttr(icon) + ' method-item-icon"></i>';
html += '<span class="method-item-label">' + self.escapeHtml(label) + '</span>';
if (isSelected) {
html += '<i class="icon-check method-item-check"></i>';
}
html += '</div>';
});
html += '</div>'; // close items
html += '</div>'; // close optgroup
});
html += '</div>';
return html;
},
/**
* Close the method dropdown menu
*/
closeMethodDropdownMenu: function() {
if (this.$methodDropdownMenu) {
this.$methodDropdownMenu.remove();
this.$methodDropdownMenu = null;
}
this.$methodDropdownSelect = null;
this.$methodDropdownTrigger = null;
$(document).off('click.methodDropdown keydown.methodDropdown');
},
/**
* Populate tiles for multi_select_tiles value picker
*/
populateTiles: function($picker, options, exclusive) {
var self = this;
var $container = $picker.find('.multi-select-tiles');
$container.empty();
if (exclusive) {
$container.attr('data-exclusive', 'true');
} else {
$container.removeAttr('data-exclusive');
}
$.each(options, function(key, optData) {
var label = typeof optData === 'object' ? optData.label : optData;
var icon = typeof optData === 'object' && optData.icon ? optData.icon : null;
var color = typeof optData === 'object' && optData.color ? optData.color : null;
var tileClass = 'tile-option';
if (color) {
tileClass += ' tile-color-' + color;
}
var $tile = $('<button>', {
type: 'button',
class: tileClass,
'data-value': key
});
if (icon) {
$tile.append($('<i>', { class: icon }));
}
$tile.append($('<span>', { class: 'tile-label', text: label }));
$container.append($tile);
});
},
/**
* Apply step/min constraints to numeric range inputs
*/
applyRangeInputConstraints: function($picker, step, min) {
var $inputs = $picker.find('.range-min-input, .range-max-input');
if (typeof step !== 'undefined' && step !== null) {
$inputs.attr('step', step);
} else {
$inputs.attr('step', 'any');
}
if (typeof min !== 'undefined' && min !== null) {
$inputs.attr('min', min);
} else {
$inputs.removeAttr('min');
}
},
/**
* Show error message on range input
*/
showRangeInputError: function($input, message) {
var $container = $input.closest('.multi-range-input-row');
$container.find('.range-input-error').remove();
$container.find('.range-min-input, .range-max-input').removeClass('has-error');
$input.addClass('has-error');
var $error = $('<span>', {
class: 'range-input-error',
text: message
});
$container.append($error);
setTimeout(function() {
$input.removeClass('has-error');
$error.fadeOut(200, function() {
$(this).remove();
});
}, 3000);
},
/**
* Load attribute groups for combination picker
*/
loadCombinationAttributeGroups: function($picker) {
var self = this;
var trans = this.config.trans || {};
var $container = $picker.find('.combination-groups-container');
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getAttributeGroups',
trait: 'TargetConditions'
},
success: function(response) {
$container.empty();
if (!response.success || !response.groups || response.groups.length === 0) {
$container.html('<span class="combination-empty">' +
self.escapeHtml(trans.no_attribute_groups || 'No attribute groups found') +
'</span>');
return;
}
response.groups.forEach(function(group) {
var $groupDiv = $('<div>', {
class: 'comb-attr-group',
'data-group-id': group.id
});
var $groupHeader = $('<div>', { class: 'comb-attr-group-header' });
$groupHeader.append($('<span>', {
class: 'comb-attr-group-name',
text: group.name
}));
$groupHeader.append($('<span>', {
class: 'comb-attr-group-count',
text: '0'
}));
var $toolbar = $('<div>', { class: 'comb-attr-toolbar' });
$toolbar.append($('<button>', {
type: 'button',
class: 'comb-toolbar-btn comb-select-all',
title: trans.select_all || 'Select all',
html: '<i class="icon-check-square-o"></i>'
}));
$toolbar.append($('<button>', {
type: 'button',
class: 'comb-toolbar-btn comb-select-none',
title: trans.clear || 'Clear',
html: '<i class="icon-square-o"></i>'
}));
$toolbar.append($('<input>', {
type: 'text',
class: 'comb-attr-search',
placeholder: trans.filter_results || 'Filter...'
}));
var $valuesContainer = $('<div>', {
class: 'comb-attr-values',
'data-loaded': 'false'
});
$valuesContainer.append($('<span>', {
class: 'comb-attr-loading',
html: '<i class="icon-spinner icon-spin"></i>'
}));
$groupDiv.append($groupHeader);
$groupDiv.append($toolbar);
$groupDiv.append($valuesContainer);
$container.append($groupDiv);
self.loadCombinationAttributeValues($picker, group.id, $valuesContainer);
});
},
error: function() {
$container.html('<span class="combination-error">' +
self.escapeHtml(trans.error_loading || 'Error loading attribute groups') +
'</span>');
}
});
},
/**
* Load attribute values for a specific group
*/
loadCombinationAttributeValues: function($picker, groupId, $container) {
var self = this;
var trans = this.config.trans || {};
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getAttributeValues',
trait: 'TargetConditions',
id_attribute_group: groupId
},
success: function(response) {
$container.empty();
$container.attr('data-loaded', 'true');
if (!response.success || !response.values || response.values.length === 0) {
$container.html('<span class="comb-attr-empty">' +
self.escapeHtml(trans.no_values || 'No values') +
'</span>');
return;
}
response.values.forEach(function(value) {
var productCount = parseInt(value.product_count) || 0;
var $valueBtn = $('<button>', {
type: 'button',
class: 'comb-attr-value',
'data-value-id': value.id,
'data-group-id': groupId,
'data-name': value.name.toLowerCase()
});
$valueBtn.append($('<span>', {
class: 'comb-attr-value-name',
text: value.name
}));
if (productCount > 0) {
$valueBtn.append($('<span>', {
class: 'comb-attr-value-count',
text: productCount
}));
}
$container.append($valueBtn);
});
self.restoreCombinationSelections($picker);
},
error: function() {
$container.html('<span class="comb-attr-error">' +
self.escapeHtml(trans.error_loading || 'Error') +
'</span>');
}
});
},
/**
* Restore previously selected combination values from hidden input
*/
restoreCombinationSelections: function($picker) {
var $dataInput = $picker.find('.include-values-data, .exclude-values-data').first();
var dataStr = $dataInput.val() || '{}';
var data;
try {
data = JSON.parse(dataStr);
} catch (e) {
return;
}
var attributes = data.attributes || data;
var mode = data.mode || 'products';
$picker.find('.comb-mode-radio[value="' + mode + '"]').prop('checked', true);
$.each(attributes, function(groupId, valueIds) {
if (!Array.isArray(valueIds)) return;
valueIds.forEach(function(valueId) {
$picker.find('.comb-attr-value[data-group-id="' + groupId + '"][data-value-id="' + valueId + '"]')
.addClass('selected');
});
});
this.updateCombinationGroupCounts($picker);
},
/**
* Update hidden input with current combination selections
*/
updateCombinationData: function($picker) {
var attributes = {};
$picker.find('.comb-attr-value.selected').each(function() {
var groupId = $(this).data('groupId').toString();
var valueId = $(this).data('valueId');
if (!attributes[groupId]) {
attributes[groupId] = [];
}
attributes[groupId].push(valueId);
});
var $combPicker = $picker.find('.combination-attributes-picker');
var configMode = $combPicker.data('combinationMode') || this.config.combinationMode || 'products';
var mode;
if (configMode === 'toggle') {
mode = $picker.find('.comb-mode-radio:checked').val() || 'products';
} else {
mode = configMode;
}
var data = {
mode: mode,
attributes: attributes
};
var $dataInput = $picker.find('.include-values-data, .exclude-values-data').first();
$dataInput.val(JSON.stringify(data));
this.updateCombinationGroupCounts($picker);
},
/**
* Update the count badges on each attribute group
*/
updateCombinationGroupCounts: function($picker) {
$picker.find('.comb-attr-group').each(function() {
var $group = $(this);
var count = $group.find('.comb-attr-value.selected').length;
$group.find('.comb-attr-group-count').text(count);
if (count > 0) {
$group.addClass('has-selections');
} else {
$group.removeClass('has-selections');
}
});
},
/**
* Update the info placeholder based on method and block type
*/
updateMethodInfoPlaceholder: function($headerRow, method, blockType) {
var $placeholder = $headerRow.find('.method-info-placeholder');
if (!$placeholder.length) return;
$placeholder.empty();
var methodHelp = this.config.methodHelp || {};
var blockHelp = methodHelp[blockType] || methodHelp['products'] || {};
var helpContent = blockHelp[method] || this.getBuiltInMethodHelp(method);
if (helpContent) {
var $infoWrapper = $('<span>', {
class: 'mpr-info-wrapper',
'data-details': helpContent
});
$infoWrapper.append($('<span>', { class: 'mpr-icon icon-info link' }));
$placeholder.append($infoWrapper);
}
},
/**
* Get built-in help content for targeting methods
*/
getBuiltInMethodHelp: function(method) {
var trans = this.config.trans || {};
var html = '';
switch (method) {
case 'all':
html = '<strong>' + this.escapeHtml(trans.help_all_title || 'All Items') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_all_desc || 'Selects all items without any filtering.') + '</p>';
break;
case 'specific':
html = '<strong>' + this.escapeHtml(trans.help_specific_title || 'Specific Items') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_specific_desc || 'Search and select individual items by name, reference, or ID.') + '</p>';
break;
case 'by_category':
html = '<strong>' + this.escapeHtml(trans.help_category_title || 'By Category') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_category_desc || 'Select items belonging to specific categories. Includes subcategories.') + '</p>';
break;
case 'by_manufacturer':
html = '<strong>' + this.escapeHtml(trans.help_manufacturer_title || 'By Manufacturer') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_manufacturer_desc || 'Select items from specific manufacturers/brands.') + '</p>';
break;
case 'by_supplier':
html = '<strong>' + this.escapeHtml(trans.help_supplier_title || 'By Supplier') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_supplier_desc || 'Select items from specific suppliers.') + '</p>';
break;
case 'by_tag':
html = '<strong>' + this.escapeHtml(trans.help_tag_title || 'By Tag') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_tag_desc || 'Select items with specific tags assigned.') + '</p>';
break;
case 'by_attribute':
html = '<strong>' + this.escapeHtml(trans.help_attribute_title || 'By Attribute') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_attribute_desc || 'Select items with specific attribute values (e.g., Color: Red).') + '</p>';
break;
case 'by_feature':
html = '<strong>' + this.escapeHtml(trans.help_feature_title || 'By Feature') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_feature_desc || 'Select items with specific feature values (e.g., Material: Cotton).') + '</p>';
break;
case 'by_combination':
html = '<strong>' + this.escapeHtml(trans.help_combination_title || 'Combination Targeting') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_combination_desc || 'Select items by combination attributes.') + '</p>';
html += '<p><strong>' + this.escapeHtml(trans.help_combination_logic || 'Logic:') + '</strong></p>';
html += '<ul>';
html += '<li>' + this.escapeHtml(trans.help_combination_within || 'Within group: OR (Red OR Blue)') + '</li>';
html += '<li>' + this.escapeHtml(trans.help_combination_between || 'Between groups: AND (Color AND Size)') + '</li>';
html += '</ul>';
break;
case 'by_carrier':
html = '<strong>' + this.escapeHtml(trans.help_carrier_title || 'By Carrier') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_carrier_desc || 'Select items available with specific carriers.') + '</p>';
break;
case 'by_condition':
html = '<strong>' + this.escapeHtml(trans.help_condition_title || 'By Condition') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_condition_desc || 'Filter by product condition: New, Used, or Refurbished.') + '</p>';
break;
case 'by_visibility':
html = '<strong>' + this.escapeHtml(trans.help_visibility_title || 'By Visibility') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_visibility_desc || 'Filter by where products are visible in the store.') + '</p>';
break;
case 'by_active_status':
html = '<strong>' + this.escapeHtml(trans.help_active_title || 'By Active Status') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_active_desc || 'Filter by whether products are enabled or disabled.') + '</p>';
break;
case 'by_stock_status':
html = '<strong>' + this.escapeHtml(trans.help_stock_title || 'By Stock Status') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_stock_desc || 'Filter by stock availability: In stock, Out of stock, or Low stock.') + '</p>';
break;
case 'by_on_sale':
case 'by_has_specific_price':
case 'by_is_virtual':
case 'by_is_pack':
case 'by_has_combinations':
case 'by_available_for_order':
case 'by_online_only':
case 'by_has_related':
case 'by_has_customization':
case 'by_has_attachments':
case 'by_has_additional_shipping':
html = '<strong>' + this.escapeHtml(trans.help_boolean_title || 'Yes/No Filter') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_boolean_desc || 'Filter products by this property.') + '</p>';
break;
case 'by_name_pattern':
case 'by_reference_pattern':
case 'by_description_pattern':
case 'by_long_description_pattern':
case 'by_ean13_pattern':
case 'by_upc_pattern':
case 'by_isbn_pattern':
case 'by_mpn_pattern':
case 'by_meta_title_pattern':
case 'by_meta_description_pattern':
html = '<strong>' + this.escapeHtml(trans.help_pattern_title || 'Pattern Matching') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_pattern_desc || 'Match text using patterns with wildcards.') + '</p>';
html += '<div><code>*</code> ' + this.escapeHtml(trans.help_pattern_wildcard || 'any text') + '</div>';
html += '<div><code>{number}</code> ' + this.escapeHtml(trans.help_pattern_number || 'any number') + '</div>';
html += '<div><code>{letter}</code> ' + this.escapeHtml(trans.help_pattern_letter || 'single letter A-Z') + '</div>';
break;
case 'by_id_range':
case 'by_price_range':
case 'by_weight_range':
case 'by_quantity_range':
case 'by_position_range':
html = '<strong>' + this.escapeHtml(trans.help_range_title || 'Numeric Range') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_range_desc || 'Filter by numeric values within specified ranges.') + '</p>';
html += '<p>' + this.escapeHtml(trans.help_range_tip || 'Leave min or max empty for open-ended ranges.') + '</p>';
break;
case 'by_date_added':
case 'by_date_updated':
html = '<strong>' + this.escapeHtml(trans.help_date_title || 'Date Range') + '</strong>';
html += '<p>' + this.escapeHtml(trans.help_date_desc || 'Filter by date within a specific period.') + '</p>';
break;
default:
break;
}
return html;
},
/**
* Build sort options HTML for a specific block type
*/
buildSortOptions: function(blockType) {
var options = [];
switch (blockType) {
case 'products':
options = [
{ value: 'sales', label: 'Best sellers' },
{ value: 'date_add', label: 'Newest' },
{ value: 'price', label: 'Price' },
{ value: 'name', label: 'Name' },
{ value: 'position', label: 'Position' },
{ value: 'quantity', label: 'Stock quantity' },
{ value: 'random', label: 'Random' }
];
break;
case 'categories':
options = [
{ value: 'name', label: 'Name' },
{ value: 'position', label: 'Position' },
{ value: 'product_count', label: 'Product count' },
{ value: 'total_sales', label: 'Best sellers' },
{ value: 'newest_products', label: 'Newest products' },
{ value: 'date_add', label: 'Creation date' },
{ value: 'random', label: 'Random' }
];
break;
case 'manufacturers':
case 'suppliers':
options = [
{ value: 'name', label: 'Name' },
{ value: 'product_count', label: 'Product count' },
{ value: 'total_sales', label: 'Best sellers' },
{ value: 'newest_products', label: 'Newest products' },
{ value: 'random', label: 'Random' }
];
break;
case 'cms':
case 'cms_categories':
options = [
{ value: 'name', label: 'Name' },
{ value: 'position', label: 'Position' },
{ value: 'random', label: 'Random' }
];
break;
default:
options = [
{ value: 'name', label: 'Name' },
{ value: 'random', label: 'Random' }
];
}
var html = '';
for (var i = 0; i < options.length; i++) {
html += '<option value="' + this.escapeAttr(options[i].value) + '">' +
this.escapeHtml(options[i].label) + '</option>';
}
return html;
},
/**
* Update the modifier toggle button state
*/
updateModifierButtonState: function($group) {
var limit = $group.find('.group-modifier-limit').val();
var sortBy = $group.find('.group-modifier-sort').val();
var $modifiers = $group.find('.group-modifiers');
var $btn = $group.find('.btn-toggle-modifiers');
var trans = this.config.trans || {};
$btn.find('.modifier-summary').remove();
if (limit || sortBy) {
$modifiers.addClass('has-values');
var summary = [];
if (limit) {
summary.push((trans.top || 'Top') + ' ' + limit);
}
if (sortBy) {
var sortLabel = $group.find('.group-modifier-sort option:selected').text();
summary.push(sortLabel);
}
var $arrow = $btn.find('.toggle-arrow');
$('<span class="modifier-summary">' + this.escapeHtml(summary.join(', ')) + '</span>').insertBefore($arrow);
} else {
$modifiers.removeClass('has-values');
}
},
/**
* Lock/unlock method selector when excludes are present
*/
updateMethodSelectorLock: function($group, locked) {
var $select = $group.find('.include-method-select');
var $wrapper = $select.closest('.method-selector-wrapper');
var trans = this.config.trans || {};
if (locked) {
$select.prop('disabled', true);
if (!$wrapper.length) {
$select.wrap('<div class="method-selector-wrapper"></div>');
$wrapper = $select.parent('.method-selector-wrapper');
}
$wrapper.addClass('selector-locked');
if (!$wrapper.find('.lock-indicator').length) {
var lockHtml = '<span class="mpr-info-wrapper lock-indicator">' +
'<i class="icon-lock"></i>' +
'<span class="mpr-tooltip">' +
(trans.remove_excludes_first || 'Remove all exceptions to change selection type') +
'</span>' +
'</span>';
var $countEl = $wrapper.find('.condition-match-count');
if ($countEl.length) {
$countEl.before(lockHtml);
} else {
$wrapper.append(lockHtml);
}
}
} else {
$select.prop('disabled', false);
if ($wrapper.length) {
$wrapper.removeClass('selector-locked');
$wrapper.find('.mpr-info-wrapper.lock-indicator').remove();
} else {
$select.siblings('.mpr-info-wrapper.lock-indicator').remove();
}
}
}
};
})(jQuery);
/**
* Entity Selector - Preview Module
* Reusable preview popover component with filter and load more
* @partial _preview.js
*/
(function($) {
'use strict';
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
window._EntitySelectorMixins.preview = {
// =========================================================================
// HEADER & TOGGLE UPDATES
// =========================================================================
updateHeaderTotalCount: function() {
var self = this;
var total = 0;
this.$wrapper.find('.target-block-tab .tab-badge').each(function() {
var $badge = $(this);
if (!$badge.hasClass('loading')) {
var count = parseInt($badge.text(), 10);
if (!isNaN(count)) {
total += count;
}
}
});
var $totalBadge = this.$wrapper.find('.trait-total-count');
if (total > 0) {
$totalBadge.text(total).show();
} else {
$totalBadge.hide();
}
this.updateShowAllToggle();
},
updateShowAllToggle: function() {
var $toggle = this.$wrapper.find('.trait-show-all-toggle');
if (!$toggle.length) return;
var $checkbox = $toggle.find('.show-all-checkbox');
var hasData = this.$wrapper.find('.target-block-tab.has-data').length > 0;
$checkbox.prop('checked', !hasData);
},
// =========================================================================
// REUSABLE PREVIEW POPOVER COMPONENT
// =========================================================================
/**
* Create and show a reusable preview popover
* @param {Object} options Configuration options
* @param {jQuery} options.$badge - The badge element to position against
* @param {Array} options.items - Array of items to display
* @param {number} options.totalCount - Total count of items
* @param {boolean} options.hasMore - Whether more items are available
* @param {string} options.entityLabel - Label for items (e.g., "products")
* @param {string} options.previewType - Type identifier (e.g., "condition", "filter-group")
* @param {Function} options.onLoadMore - Callback when load more is clicked
* @param {Function} options.onFilter - Callback for AJAX filtering (receives query string)
* @param {Object} options.context - Context data for load more
*/
createPreviewPopover: function(options) {
var self = this;
var trans = this.config.trans || {};
var $badge = options.$badge;
var items = options.items || [];
var totalCount = options.totalCount || 0;
var hasMore = options.hasMore || false;
var entityLabel = options.entityLabel || 'products';
var previewType = options.previewType || 'default';
// Build popover HTML
var html = '<div class="target-preview-popover preview-type-' + previewType + '">';
// Header with count and close button
html += '<div class="preview-header">';
html += '<span class="preview-count">' + totalCount + ' ' + entityLabel + '</span>';
html += '<button type="button" class="preview-close"><i class="icon-times"></i></button>';
html += '</div>';
// Filter input
html += '<div class="preview-filter">';
html += '<input type="text" class="preview-filter-input" placeholder="' + (trans.filter_results || 'Filter results...') + '">';
html += '</div>';
// Items list
if (items.length > 0) {
html += '<div class="preview-list">';
html += this.renderPreviewItems(items);
html += '</div>';
// Load more footer with select dropdown
if (hasMore) {
var remaining = totalCount - items.length;
html += '<div class="preview-footer">';
html += '<div class="load-more-controls">';
html += '<span class="load-more-label">' + (trans.load || 'Load') + '</span>';
html += '<select class="load-more-select">';
if (remaining >= 10) html += '<option value="10">10</option>';
if (remaining >= 20) html += '<option value="20" selected>20</option>';
if (remaining >= 50) html += '<option value="50">50</option>';
if (remaining >= 100) html += '<option value="100">100</option>';
html += '<option value="' + remaining + '">' + (trans.all || 'All') + ' (' + remaining + ')</option>';
html += '</select>';
html += '<span class="load-more-of">' + (trans.of || 'of') + ' <span class="remaining-count">' + remaining + '</span> ' + (trans.remaining || 'remaining') + '</span>';
html += '<button type="button" class="btn-load-more"><i class="icon-plus"></i></button>';
html += '</div>';
html += '</div>';
}
} else {
html += '<div class="preview-empty">' + (trans.no_preview || 'No items to preview') + '</div>';
}
html += '</div>';
// Create and append popover
var $popover = $(html);
$('body').append($popover);
// Store references
this.$previewPopover = $popover;
this.$previewList = $popover.find('.preview-list');
this.previewLoadedCount = items.length;
this.previewTotalCount = totalCount;
this.previewContext = options.context || {};
this.previewOnLoadMore = options.onLoadMore || null;
this.previewOnFilter = options.onFilter || null;
this.previewCurrentFilter = '';
this.previewEntityLabel = entityLabel;
// Event handlers
$popover.find('.preview-close').on('click', function() {
self.hidePreviewPopover();
});
// Filter input with AJAX support
var $filterInput = $popover.find('.preview-filter-input');
if (options.onFilter) {
// Use AJAX filtering with debounce
var debouncedFilter = this.debounce(function(query) {
self.previewCurrentFilter = query;
self.showFilterLoading(true);
options.onFilter.call(self, query);
}, 300);
$filterInput.on('input', function() {
var query = $(this).val().trim();
if (query === self.previewCurrentFilter) return;
debouncedFilter(query);
});
} else {
// Fallback to client-side filtering
$filterInput.on('input', function() {
var query = $(this).val().toLowerCase().trim();
self.filterPreviewItems(query);
});
}
if (options.onLoadMore) {
$popover.find('.btn-load-more').on('click', function() {
var $btn = $(this);
var $controls = $btn.closest('.load-more-controls');
var $select = $controls.find('.load-more-select');
if ($btn.hasClass('loading')) return;
$btn.addClass('loading');
$btn.find('i').removeClass('icon-plus').addClass('icon-spinner icon-spin');
$select.prop('disabled', true);
// Get selected load count
var loadCount = parseInt($select.val(), 10) || 20;
self.previewLoadCount = loadCount;
options.onLoadMore.call(self, $btn);
});
}
// Position popover below badge
var badgeOffset = $badge.offset();
var badgeHeight = $badge.outerHeight();
var badgeWidth = $badge.outerWidth();
var popoverWidth = $popover.outerWidth();
var leftPos = badgeOffset.left + (badgeWidth / 2) - (popoverWidth / 2);
var minLeft = 10;
var maxLeft = $(window).width() - popoverWidth - 10;
leftPos = Math.max(minLeft, Math.min(leftPos, maxLeft));
$popover.css({
position: 'absolute',
top: badgeOffset.top + badgeHeight + 8,
left: leftPos,
zIndex: 10000
});
// Show with transition
$popover.addClass('show');
return $popover;
},
/**
* Update popover after loading more items
*/
updatePreviewPopover: function(items, hasMore) {
var trans = this.config.trans || {};
// Update list
this.$previewList.html(this.renderPreviewItems(items));
this.previewLoadedCount = items.length;
// Update or remove load more controls
var $footer = this.$previewPopover.find('.preview-footer');
if (hasMore) {
var remaining = this.previewTotalCount - items.length;
var $controls = $footer.find('.load-more-controls');
var $btn = $controls.find('.btn-load-more');
var $select = $controls.find('.load-more-select');
// Reset button state
$btn.removeClass('loading');
$btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
$select.prop('disabled', false);
// Update remaining count
$controls.find('.remaining-count').text(remaining);
// Update select options
$select.empty();
if (remaining >= 10) $select.append('<option value="10">10</option>');
if (remaining >= 20) $select.append('<option value="20" selected>20</option>');
if (remaining >= 50) $select.append('<option value="50">50</option>');
if (remaining >= 100) $select.append('<option value="100">100</option>');
$select.append('<option value="' + remaining + '">' + (trans.all || 'All') + ' (' + remaining + ')</option>');
} else {
$footer.remove();
}
// Re-apply filter if active
var filterQuery = this.$previewPopover.find('.preview-filter-input').val();
if (filterQuery) {
this.filterPreviewItems(filterQuery.toLowerCase().trim());
}
},
/**
* Render preview items HTML with consistent format
*/
renderPreviewItems: function(items) {
var self = this;
var html = '';
for (var i = 0; i < items.length; i++) {
var item = items[i];
var itemClass = 'preview-item';
if (item.isCombination) itemClass += ' is-combination';
// Build data attributes for filtering
var dataAttrs = '';
dataAttrs += ' data-name="' + this.escapeAttr((item.name || '').toLowerCase()) + '"';
dataAttrs += ' data-ref="' + this.escapeAttr((item.reference || '').toLowerCase()) + '"';
if (item.attributes) {
dataAttrs += ' data-attrs="' + this.escapeAttr((item.attributes || '').toLowerCase()) + '"';
}
html += '<div class="' + itemClass + '"' + dataAttrs + '>';
// Image or placeholder
if (item.image) {
html += '<img src="' + this.escapeAttr(item.image) + '" class="preview-item-image" alt="">';
} else {
html += '<div class="preview-item-icon"><i class="material-icons">inventory_2</i></div>';
}
// Info section
html += '<div class="preview-item-info">';
html += '<div class="preview-item-name">' + this.escapeHtml(item.name || 'Unnamed') + '</div>';
// Meta line (reference, manufacturer, category, attributes)
var meta = [];
if (item.reference) {
meta.push('Ref: ' + item.reference);
}
if (item.manufacturer) {
meta.push(item.manufacturer);
}
if (item.category) {
meta.push(item.category);
}
if (item.attributes) {
meta.push(item.attributes);
}
if (meta.length > 0) {
html += '<div class="preview-item-meta">' + this.escapeHtml(meta.join(' • ')) + '</div>';
}
html += '</div>'; // .preview-item-info
// Price column (always show if available)
if (typeof item.price !== 'undefined' && item.price !== null) {
html += '<div class="preview-item-price">' + this.formatPrice(item.price) + '</div>';
} else if (item.price_formatted) {
html += '<div class="preview-item-price">' + this.escapeHtml(item.price_formatted) + '</div>';
}
// Status badge if inactive
if (typeof item.active !== 'undefined' && !item.active) {
html += '<span class="preview-item-badge badge-inactive">Inactive</span>';
}
html += '</div>'; // .preview-item
}
return html;
},
/**
* Filter preview items by query (client-side fallback)
*/
filterPreviewItems: function(query) {
if (!this.$previewList) return;
var $items = this.$previewList.find('.preview-item');
if (!query) {
$items.show();
return;
}
$items.each(function() {
var $item = $(this);
var name = $item.data('name') || '';
var ref = $item.data('ref') || '';
var attrs = $item.data('attrs') || '';
var matches = name.indexOf(query) !== -1 ||
ref.indexOf(query) !== -1 ||
attrs.indexOf(query) !== -1;
$item.toggle(matches);
});
},
/**
* Show/hide loading indicator during AJAX filter
*/
showFilterLoading: function(show) {
if (!this.$previewPopover) return;
var $list = this.$previewList;
if (!$list) return;
if (show) {
// Lock the popover width before filtering to prevent resize
if (!this.previewLockedWidth) {
this.previewLockedWidth = this.$previewPopover.outerWidth();
this.$previewPopover.css('width', this.previewLockedWidth + 'px');
}
$list.addClass('filtering');
// Add overlay if not exists
if (!$list.find('.filter-loading-overlay').length) {
$list.append('<div class="filter-loading-overlay"><i class="icon-spinner icon-spin"></i></div>');
}
} else {
$list.removeClass('filtering');
$list.find('.filter-loading-overlay').remove();
}
},
/**
* Update preview popover with filtered AJAX results
* @param {Object} response - AJAX response with items, count, hasMore
*/
updatePreviewPopoverFiltered: function(response) {
var trans = this.config.trans || {};
this.showFilterLoading(false);
if (!response.success) {
return;
}
var items = response.items || [];
var filteredCount = response.count || 0;
var hasMore = response.hasMore || false;
// Update header count to show filtered count
var $header = this.$previewPopover.find('.preview-header');
var entityLabel = this.previewEntityLabel || 'items';
$header.find('.preview-count').text(filteredCount + ' ' + entityLabel);
// Update list
if (items.length > 0) {
this.$previewList.html(this.renderPreviewItems(items));
this.previewLoadedCount = items.length;
this.previewTotalCount = filteredCount;
} else {
var noResultsText = trans.no_filter_results || 'No matching items found';
this.$previewList.html('<div class="preview-empty">' + noResultsText + '</div>');
this.previewLoadedCount = 0;
this.previewTotalCount = 0;
}
// Update or create footer for load more
var $footer = this.$previewPopover.find('.preview-footer');
if (hasMore && items.length > 0) {
var remaining = filteredCount - items.length;
if ($footer.length) {
var $controls = $footer.find('.load-more-controls');
var $btn = $controls.find('.btn-load-more');
var $select = $controls.find('.load-more-select');
$btn.removeClass('loading');
$btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
$select.prop('disabled', false);
$controls.find('.remaining-count').text(remaining);
$select.empty();
if (remaining >= 10) $select.append('<option value="10">10</option>');
if (remaining >= 20) $select.append('<option value="20" selected>20</option>');
if (remaining >= 50) $select.append('<option value="50">50</option>');
if (remaining >= 100) $select.append('<option value="100">100</option>');
$select.append('<option value="' + remaining + '">' + (trans.all || 'All') + ' (' + remaining + ')</option>');
} else {
// Create footer
var footerHtml = '<div class="preview-footer">';
footerHtml += '<div class="load-more-controls">';
footerHtml += '<span class="load-more-label">' + (trans.load || 'Load') + '</span>';
footerHtml += '<select class="load-more-select">';
if (remaining >= 10) footerHtml += '<option value="10">10</option>';
if (remaining >= 20) footerHtml += '<option value="20" selected>20</option>';
if (remaining >= 50) footerHtml += '<option value="50">50</option>';
if (remaining >= 100) footerHtml += '<option value="100">100</option>';
footerHtml += '<option value="' + remaining + '">' + (trans.all || 'All') + ' (' + remaining + ')</option>';
footerHtml += '</select>';
footerHtml += '<span class="load-more-of">' + (trans.of || 'of') + ' <span class="remaining-count">' + remaining + '</span> ' + (trans.remaining || 'remaining') + '</span>';
footerHtml += '<button type="button" class="btn-load-more"><i class="icon-plus"></i></button>';
footerHtml += '</div>';
footerHtml += '</div>';
var $newFooter = $(footerHtml);
this.$previewList.after($newFooter);
// Rebind load more click
var self = this;
if (this.previewOnLoadMore) {
$newFooter.find('.btn-load-more').on('click', function() {
var $btn = $(this);
var $controls = $btn.closest('.load-more-controls');
var $select = $controls.find('.load-more-select');
if ($btn.hasClass('loading')) return;
$btn.addClass('loading');
$btn.find('i').removeClass('icon-plus').addClass('icon-spinner icon-spin');
$select.prop('disabled', true);
var loadCount = parseInt($select.val(), 10) || 20;
self.previewLoadCount = loadCount;
self.previewOnLoadMore.call(self, $btn);
});
}
}
} else {
$footer.remove();
}
},
/**
* Format price for display
*/
formatPrice: function(price) {
if (typeof price !== 'number') {
price = parseFloat(price) || 0;
}
// Use currency format from config if available
var currencySign = (this.config && this.config.currency_sign) || '€';
var currencyFormat = (this.config && this.config.currency_format) || 'right';
var formatted = price.toFixed(2);
if (currencyFormat === 'left') {
return currencySign + ' ' + formatted;
} else {
return formatted + ' ' + currencySign;
}
},
/**
* Hide and clean up preview popover
*/
hidePreviewPopover: function() {
if (this.$activeBadge) {
this.$activeBadge.removeClass('popover-open loading');
this.$activeBadge = null;
}
if (this.$previewPopover) {
this.$previewPopover.remove();
this.$previewPopover = null;
}
this.$previewList = null;
this.previewContext = null;
this.previewOnLoadMore = null;
this.previewOnFilter = null;
this.previewCurrentFilter = '';
this.previewEntityLabel = null;
this.previewLockedWidth = null;
},
// =========================================================================
// TAB PREVIEW (Block tab badge click)
// =========================================================================
showPreviewPopover: function($tab) {
var self = this;
var previewData = $tab.data('previewData');
if (!previewData) {
return;
}
this.hidePreviewPopover();
var $badge = $tab.find('.tab-badge');
$badge.addClass('popover-open');
this.$activeBadge = $badge;
var items = previewData.items || previewData.products || [];
var blockType = $tab.data('blockType');
var blockConfig = this.config.blocks && this.config.blocks[blockType] ? this.config.blocks[blockType] : {};
var entityLabelPlural = blockConfig.entity_label_plural || 'items';
this.previewBlockType = blockType;
this.createPreviewPopover({
$badge: $badge,
items: items,
totalCount: previewData.count,
hasMore: previewData.hasMore,
entityLabel: entityLabelPlural,
previewType: 'tab',
context: { $tab: $tab, blockType: blockType },
onLoadMore: function($btn) {
self.loadMoreTabPreviewItems($tab, $btn);
},
onFilter: function(query) {
self.filterTabPreviewItems($tab, query);
}
});
},
/**
* AJAX filter handler for tab preview
*/
filterTabPreviewItems: function($tab, query) {
var self = this;
var blockType = this.previewBlockType;
var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]');
var savedData = {};
try {
savedData = JSON.parse($hiddenInput.val() || '{}');
} catch (e) {
self.showFilterLoading(false);
return;
}
var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : [];
if (groups.length === 0) {
self.showFilterLoading(false);
return;
}
var data = {};
data[blockType] = { groups: groups };
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewTargetConditions',
trait: 'TargetConditions',
conditions: JSON.stringify(data),
block_type: blockType,
filter: query,
limit: 20,
offset: 0
},
success: function(response) {
self.updatePreviewPopoverFiltered(response);
},
error: function() {
self.showFilterLoading(false);
}
});
},
loadMoreTabPreviewItems: function($tab, $btn) {
var self = this;
var blockType = this.previewBlockType;
var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]');
var savedData = {};
try {
savedData = JSON.parse($hiddenInput.val() || '{}');
} catch (e) {
return;
}
var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : [];
if (groups.length === 0) return;
var data = {};
data[blockType] = { groups: groups };
var loadCount = this.previewLoadCount || 20;
// Include current filter in load more request
var ajaxData = {
ajax: 1,
action: 'previewTargetConditions',
trait: 'TargetConditions',
conditions: JSON.stringify(data),
block_type: blockType,
limit: self.previewLoadedCount + loadCount,
offset: 0
};
if (self.previewCurrentFilter) {
ajaxData.filter = self.previewCurrentFilter;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: ajaxData,
success: function(response) {
var items = response.items || response.products || [];
if (response.success && items.length > 0) {
$tab.data('previewData', response);
self.previewTotalCount = response.count;
self.updatePreviewPopover(items, response.hasMore);
}
},
error: function() {
var $controls = $btn.closest('.load-more-controls');
var $select = $controls.find('.load-more-select');
$btn.removeClass('loading');
$btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
$select.prop('disabled', false);
}
});
},
// =========================================================================
// CONDITION PREVIEW (Single condition badge click)
// =========================================================================
showConditionPreviewPopover: function($badge) {
var self = this;
var conditionData = $badge.data('conditionData');
if (!conditionData) {
return;
}
this.hidePreviewPopover();
$badge.addClass('popover-open loading');
this.$activeBadge = $badge;
var blockType = conditionData.blockType || 'products';
var blockConfig = this.config.blocks && this.config.blocks[blockType] ? this.config.blocks[blockType] : {};
var entityLabelPlural = blockConfig.entity_label_plural || 'products';
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewConditionItems',
trait: 'EntitySelector',
method: conditionData.method,
values: JSON.stringify(conditionData.values),
block_type: blockType,
limit: 10
},
success: function(response) {
$badge.removeClass('loading');
if (response.success) {
self.createPreviewPopover({
$badge: $badge,
items: response.items || [],
totalCount: response.count,
hasMore: response.hasMore,
entityLabel: entityLabelPlural,
previewType: 'condition',
context: { conditionData: conditionData, blockType: blockType },
onLoadMore: function($btn) {
self.loadMoreConditionItems($btn);
},
onFilter: function(query) {
self.filterConditionItems(query);
}
});
} else {
$badge.removeClass('popover-open');
self.$activeBadge = null;
}
},
error: function() {
$badge.removeClass('loading popover-open');
self.$activeBadge = null;
}
});
},
/**
* AJAX filter handler for condition preview
*/
filterConditionItems: function(query) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.conditionData) {
self.showFilterLoading(false);
return;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewConditionItems',
trait: 'EntitySelector',
method: ctx.conditionData.method,
values: JSON.stringify(ctx.conditionData.values),
block_type: ctx.blockType,
filter: query,
limit: 20
},
success: function(response) {
self.updatePreviewPopoverFiltered(response);
},
error: function() {
self.showFilterLoading(false);
}
});
},
loadMoreConditionItems: function($btn) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.conditionData) return;
var loadCount = this.previewLoadCount || 20;
// Include current filter in load more request
var ajaxData = {
ajax: 1,
action: 'previewConditionItems',
trait: 'EntitySelector',
method: ctx.conditionData.method,
values: JSON.stringify(ctx.conditionData.values),
block_type: ctx.blockType,
limit: self.previewLoadedCount + loadCount
};
if (self.previewCurrentFilter) {
ajaxData.filter = self.previewCurrentFilter;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: ajaxData,
success: function(response) {
if (response.success) {
self.previewTotalCount = response.count;
self.updatePreviewPopover(response.items || [], response.hasMore);
}
},
error: function() {
var $controls = $btn.closest('.load-more-controls');
var $select = $controls.find('.load-more-select');
$btn.removeClass('loading');
$btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
$select.prop('disabled', false);
}
});
},
// =========================================================================
// GROUP PREVIEW (Selection group badge click)
// =========================================================================
showGroupPreviewPopover: function($badge, $group, blockType) {
var self = this;
if (!$group) {
$group = $badge.closest('.selection-group');
}
if (!blockType) {
var $block = $badge.closest('.target-block');
blockType = $block.data('blockType') || 'products';
}
var groupData = $badge.data('groupData');
if (!groupData) {
groupData = this.serializeGroup($group, blockType);
}
if (!groupData || !groupData.include) {
return;
}
this.hidePreviewPopover();
$badge.addClass('popover-open loading');
this.$activeBadge = $badge;
var blockConfig = this.config.blocks && this.config.blocks[blockType] ? this.config.blocks[blockType] : {};
var entityLabelPlural = blockConfig.entity_label_plural || 'products';
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewGroupItems',
trait: 'EntitySelector',
group_data: JSON.stringify(groupData),
block_type: blockType,
limit: 10
},
success: function(response) {
$badge.removeClass('loading');
if (response.success) {
self.createPreviewPopover({
$badge: $badge,
items: response.items || [],
totalCount: response.count,
hasMore: response.hasMore,
entityLabel: entityLabelPlural,
previewType: 'group',
context: { groupData: groupData, blockType: blockType, $group: $group },
onLoadMore: function($btn) {
self.loadMoreGroupItems($btn);
},
onFilter: function(query) {
self.filterGroupItems(query);
}
});
} else {
$badge.removeClass('popover-open');
self.$activeBadge = null;
}
},
error: function() {
$badge.removeClass('loading popover-open');
self.$activeBadge = null;
}
});
},
/**
* AJAX filter handler for group preview
*/
filterGroupItems: function(query) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.groupData) {
self.showFilterLoading(false);
return;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewGroupItems',
trait: 'EntitySelector',
group_data: JSON.stringify(ctx.groupData),
block_type: ctx.blockType,
filter: query,
limit: 20
},
success: function(response) {
self.updatePreviewPopoverFiltered(response);
},
error: function() {
self.showFilterLoading(false);
}
});
},
loadMoreGroupItems: function($btn) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.groupData) return;
var loadCount = this.previewLoadCount || 20;
// Include current filter in load more request
var ajaxData = {
ajax: 1,
action: 'previewGroupItems',
trait: 'EntitySelector',
group_data: JSON.stringify(ctx.groupData),
block_type: ctx.blockType,
limit: self.previewLoadedCount + loadCount
};
if (self.previewCurrentFilter) {
ajaxData.filter = self.previewCurrentFilter;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: ajaxData,
success: function(response) {
if (response.success) {
self.previewTotalCount = response.count;
self.updatePreviewPopover(response.items || [], response.hasMore);
}
},
error: function() {
$btn.removeClass('loading');
$btn.find('.load-more-text').show();
$btn.find('.load-more-loading').hide();
}
});
},
// =========================================================================
// FILTER GROUP PREVIEW (Attribute/Feature group toggle badge)
// =========================================================================
showFilterGroupPreviewPopover: function($badge, groupId, groupType, groupName) {
var self = this;
this.hidePreviewPopover();
$badge.addClass('popover-open loading');
this.$activeBadge = $badge;
var entityLabelPlural = 'products';
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewFilterGroupProducts',
trait: 'EntitySelector',
group_id: groupId,
group_type: groupType,
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: 'filter-group',
context: { groupId: groupId, groupType: groupType, groupName: groupName },
onLoadMore: function($btn) {
self.loadMoreFilterGroupItems($btn);
},
onFilter: function(query) {
self.filterFilterGroupItems(query);
}
});
} else {
$badge.removeClass('popover-open');
self.$activeBadge = null;
}
},
error: function() {
$badge.removeClass('loading popover-open');
self.$activeBadge = null;
}
});
},
/**
* AJAX filter handler for filter group preview
*/
filterFilterGroupItems: function(query) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.groupId) {
self.showFilterLoading(false);
return;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewFilterGroupProducts',
trait: 'EntitySelector',
group_id: ctx.groupId,
group_type: ctx.groupType,
filter: query,
limit: 20
},
success: function(response) {
self.updatePreviewPopoverFiltered(response);
},
error: function() {
self.showFilterLoading(false);
}
});
},
loadMoreFilterGroupItems: function($btn) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.groupId) return;
var loadCount = this.previewLoadCount || 20;
// Include current filter in load more request
var ajaxData = {
ajax: 1,
action: 'previewFilterGroupProducts',
trait: 'EntitySelector',
group_id: ctx.groupId,
group_type: ctx.groupType,
limit: self.previewLoadedCount + loadCount
};
if (self.previewCurrentFilter) {
ajaxData.filter = self.previewCurrentFilter;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: ajaxData,
success: function(response) {
if (response.success) {
self.previewTotalCount = response.count;
self.updatePreviewPopover(response.items || [], response.hasMore);
}
},
error: function() {
$btn.removeClass('loading');
$btn.find('.load-more-text').show();
$btn.find('.load-more-loading').hide();
}
});
},
// =========================================================================
// 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)
// =========================================================================
showPatternPreviewModal: function(pattern, entityType, caseSensitive, count) {
var self = this;
var trans = this.config.trans || {};
var blockConfig = this.config.blocks && this.config.blocks[entityType] ? this.config.blocks[entityType] : {};
var entityLabelPlural = blockConfig.entity_label_plural || 'items';
var entityLabelSingular = blockConfig.entity_label || 'item';
var html = '<div class="pattern-preview-modal-overlay">';
html += '<div class="pattern-preview-modal">';
html += '<div class="pattern-preview-header">';
html += '<span class="pattern-preview-title">';
html += '<i class="icon-eye"></i> ' + (trans.preview || 'Preview') + ': <code>' + this.escapeHtml(pattern) + '</code>';
html += '</span>';
html += '<span class="pattern-preview-count">' + count + ' ' + (count === 1 ? entityLabelSingular : entityLabelPlural) + '</span>';
html += '<button type="button" class="pattern-preview-close"><i class="icon-times"></i></button>';
html += '</div>';
html += '<div class="pattern-preview-content">';
html += '<div class="pattern-preview-loading"><i class="icon-spinner icon-spin"></i> ' + (trans.loading || 'Loading...') + '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
var $modal = $(html);
$('body').append($modal);
$modal.find('.pattern-preview-close').on('click', function() {
$modal.remove();
});
$modal.on('click', function(e) {
if ($(e.target).hasClass('pattern-preview-modal-overlay')) {
$modal.remove();
}
});
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewPatternMatches',
trait: 'TargetConditions',
pattern: pattern,
entity_type: entityType,
case_sensitive: caseSensitive ? 1 : 0,
limit: 50
},
success: function(response) {
if (response.success && response.items) {
var items = response.items;
var listHtml = '<div class="pattern-preview-list">';
if (items.length === 0) {
listHtml += '<div class="pattern-preview-empty">' + (trans.no_matches || 'No matches found') + '</div>';
} else {
for (var i = 0; i < items.length; i++) {
var item = items[i];
listHtml += '<div class="pattern-preview-item">';
if (item.image) {
listHtml += '<img src="' + self.escapeAttr(item.image) + '" alt="" class="preview-item-image">';
}
listHtml += '<span class="preview-item-name">' + self.escapeHtml(item.name) + '</span>';
if (item.id) {
listHtml += '<span class="preview-item-id">#' + item.id + '</span>';
}
listHtml += '</div>';
}
if (count > items.length) {
listHtml += '<div class="pattern-preview-more">... ' + (trans.and || 'and') + ' ' + (count - items.length) + ' ' + (trans.more || 'more') + '</div>';
}
}
listHtml += '</div>';
$modal.find('.pattern-preview-content').html(listHtml);
} else {
$modal.find('.pattern-preview-content').html('<div class="pattern-preview-error">' + (trans.error_loading || 'Error loading preview') + '</div>');
}
},
error: function() {
$modal.find('.pattern-preview-content').html('<div class="pattern-preview-error">' + (trans.error_loading || 'Error loading preview') + '</div>');
}
});
},
// =========================================================================
// HELPER METHODS
// =========================================================================
refreshGroupPreviewIfOpen: function($group) {
// Check if preview is for this group and refresh if needed
if (!this.$activeBadge || !this.$previewPopover) {
return;
}
},
/**
* Escape HTML special characters
*/
escapeHtml: function(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
/**
* Escape attribute value
*/
escapeAttr: function(str) {
if (!str) return '';
return String(str)
.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);
}
};
})(jQuery);
/**
* 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);
/**
* Entity Selector - Core Module
* Factory, initialization, state management
* @partial _core.js
*
* IMPORTANT: This file must be loaded LAST in the concatenation order
* as it combines all mixins from other partials.
*
* EXTRACTION SOURCE: assets/js/admin/entity-selector.js
* Lines: 15-55 (createTargetConditionsInstance, state variables)
* 56-110 (init method)
* 108-132 (observeNewSelects)
* 7889-7951 (Factory object, window export, document ready)
*
* Contains:
* - createTargetConditionsInstance() - Factory function
* - State variable initialization
* - init() - Main initialization method
* - observeNewSelects() - MutationObserver for dynamic selects
* - loadExistingSelections() - Restore saved state
* - TargetConditions factory object
* - window.TargetConditions export
* - Document ready auto-initialization
*/
(function($) {
'use strict';
/**
* Create a new TargetConditions instance
* Each instance is independent and manages its own wrapper/state
*/
function createTargetConditionsInstance() {
// Base instance object with state variables
var instance = {
config: {},
$wrapper: null,
$dropdown: null,
activeGroup: null, // { blockType, groupIndex, section: 'include'|'exclude' }
searchTimeout: null,
searchResults: [],
searchTotal: 0,
searchOffset: 0,
searchQuery: '',
isLoading: false,
loadMoreCount: 20,
// Sort, filter, view state
viewMode: 'list',
currentSort: { field: 'name', dir: 'ASC' },
refineQuery: '',
refineNegate: false,
filters: {
inStock: false,
discounted: false,
priceMin: null,
priceMax: null,
attributes: [],
features: []
},
filterableData: null,
// Search history
searchHistory: {},
searchHistoryMax: 10,
searchHistoryKey: 'targetConditionsSearchHistory',
// Chips visibility
maxVisibleChips: 20,
// Method dropdown references
$methodDropdownMenu: null,
$methodDropdownSelect: null,
$methodDropdownTrigger: null,
// Preview state
$previewPopover: null,
$activeBadge: null,
$previewList: null,
previewLoadedCount: 0,
previewBlockType: null,
allPreviewData: null,
// Count update timeout
countUpdateTimeout: null,
init: function(options) {
this.config = $.extend({
id: 'target-conditions',
name: 'target_conditions',
namePrefix: 'target_',
mode: 'multi', // Global mode: 'multi' or 'single'
blocks: {},
ajaxUrl: '',
trans: {}
}, options);
this.$wrapper = $('[data-entity-selector-id="' + this.config.id + '"]');
if (!this.$wrapper.length) {
return;
}
// Global single mode - hide "Add Group" buttons
if (this.config.mode === 'single') {
this.$wrapper.find('.btn-add-group').hide();
this.$wrapper.find('.group-excludes').hide();
this.$wrapper.find('.group-modifiers').hide();
}
// Add fullwidth class to parent form-group
var $formGroup = this.$wrapper.closest('.form-group');
$formGroup.addClass('condition-trait-fullwidth');
$formGroup.find('.col-lg-offset-3').removeClass('col-lg-offset-3');
this.createDropdown();
this.bindEvents();
this.loadExistingSelections();
this.loadSearchHistory();
// Initialize styled method dropdowns
this.initMethodDropdowns();
// Watch for dynamically added selects
this.observeNewSelects();
// Update counts on page load
var self = this;
setTimeout(function() {
self.updateTabBadges();
self.updateAllConditionCounts();
}, 100);
},
observeNewSelects: function() {
var self = this;
if (typeof MutationObserver === 'undefined') {
return;
}
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.addedNodes.length) {
$(mutation.addedNodes).find('.include-method-select, .exclude-method-select').each(function() {
self.enhanceMethodSelect($(this));
});
}
});
});
observer.observe(this.$wrapper[0], {
childList: true,
subtree: true
});
},
loadExistingSelections: function() {
// TODO: Extract full implementation from original
// Reads JSON from hidden input and populates chips
}
};
// Merge all mixins into the instance
// Each mixin adds its methods to window._EntitySelectorMixins
var mixins = window._EntitySelectorMixins || {};
// Merge utils mixin
if (mixins.utils) {
$.extend(instance, mixins.utils);
}
// Merge events mixin
if (mixins.events) {
$.extend(instance, mixins.events);
}
// Merge dropdown mixin
if (mixins.dropdown) {
$.extend(instance, mixins.dropdown);
}
// Merge search mixin
if (mixins.search) {
$.extend(instance, mixins.search);
}
// Merge filters mixin
if (mixins.filters) {
$.extend(instance, mixins.filters);
}
// Merge chips mixin
if (mixins.chips) {
$.extend(instance, mixins.chips);
}
// Merge groups mixin
if (mixins.groups) {
$.extend(instance, mixins.groups);
}
// Merge methods mixin
if (mixins.methods) {
$.extend(instance, mixins.methods);
}
// Merge preview mixin
if (mixins.preview) {
$.extend(instance, mixins.preview);
}
// Merge tree mixin
if (mixins.tree) {
$.extend(instance, mixins.tree);
}
return instance;
}
// Factory object for creating and managing instances
var TargetConditions = {
instances: [],
// Create and initialize a new instance
create: function(options) {
var instance = createTargetConditionsInstance();
instance.init(options);
this.instances.push(instance);
return instance;
},
// For backwards compatibility - init creates a new instance
init: function(options) {
return this.create(options);
},
// Validate all instances - returns true if all valid
validateAll: function() {
var allValid = true;
for (var i = 0; i < this.instances.length; i++) {
if (!this.instances[i].validate()) {
allValid = false;
}
}
return allValid;
}
};
// Export to window
window.TargetConditions = TargetConditions;
// Auto-initialize on document ready
$(document).ready(function() {
// Auto-initialize from data-config attributes on wrapper elements
$('[data-entity-selector-id]').each(function() {
var configData = $(this).data('config');
if (configData) {
TargetConditions.create(configData);
}
});
// Tips box toggle handler
$(document).on('click', '.target-tips-box .tips-header', function(e) {
e.preventDefault();
$(this).closest('.target-tips-box').toggleClass('expanded');
});
// Form submission validation for required target conditions
$(document).on('submit', 'form', function(e) {
var $form = $(this);
if ($form.find('.target-conditions-trait[data-required]').length > 0) {
if (!TargetConditions.validateAll()) {
e.preventDefault();
return false;
}
}
});
});
})(jQuery);