Files
prestashop-entity-selector/assets/js/admin/entity-selector.js
myprestarocks 6ebf94e15b Add clickable preview popover to filter group toggles
- Add showFilterGroupPreviewPopover method in _preview.js
- Make toggle-count badges clickable with data attributes
- Add event binding for .toggle-count.clickable in _events.js
- Add hover/active/loading styles for clickable toggle-count
- Requires previewFilterGroupProducts AJAX handler in PHP backend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 10:18:42 +00:00

7988 lines
351 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 = {
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;
}
};
})(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();
console.log('[EntitySelector] Tab badge clicked', {
hasLoading: $(this).hasClass('loading'),
hasPopoverOpen: $(this).hasClass('popover-open'),
previewData: $(this).closest('.target-block-tab').data('previewData')
});
var $tab = $(this).closest('.target-block-tab');
var $badge = $(this);
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();
console.log('[EntitySelector] Condition match count clicked', this);
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();
console.log('[EntitySelector] Group count badge clicked', this);
var $badge = $(this);
if ($badge.hasClass('popover-open')) {
self.hidePreviewPopover();
} else {
self.showGroupPreviewPopover($badge);
}
});
// Filter group toggle count badge click for preview popover
$(document).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);
}
});
// 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) {
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) {
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
};
self.searchOffset = 0;
self.searchQuery = $(this).val().trim();
self.updateFilterPanelForEntity(searchEntity);
if (searchEntity === 'products') {
self.loadFilterableData();
}
self.positionDropdown($(this));
if (self.viewMode === 'tree') {
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();
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;
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;
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);
});
// Done/confirm
this.$dropdown.on('click', '.btn-confirm-dropdown', function(e) {
e.preventDefault();
self.hideDropdown();
});
// Cancel
this.$dropdown.on('click', '.btn-cancel-dropdown', function(e) {
e.preventDefault();
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();
});
// View mode change
this.$dropdown.on('change', '.view-mode-select', function() {
var value = $(this).val();
self.viewMode = value;
self.$dropdown.removeClass('view-list view-tree view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8');
self.$dropdown.addClass('view-' + value);
var searchEntity = self.activeGroup ? self.activeGroup.searchEntity : '';
if (value === 'tree' && (searchEntity === 'categories' || searchEntity === 'cms_categories')) {
self.loadCategoryTree();
} else if (value !== 'tree') {
self.performSearch();
}
});
// Tree view: Toggle expand/collapse
this.$dropdown.on('click', '.category-tree .tree-toggle', function(e) {
e.stopPropagation();
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)
this.$dropdown.on('click', '.category-tree .tree-item', function(e) {
if ($(e.target).closest('.tree-toggle, .btn-select-children').length) {
return;
}
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;
}
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) {
self.removeSelection($picker, id);
$item.toggleClass('selected');
self.serializeAllBlocks($row);
updateCount();
} else {
var currentSelection = self.getCurrentSingleSelection();
if (currentSelection) {
var newEntityType = self.activeGroup.blockType;
self.showReplaceConfirmation(currentSelection, { name: name, entityType: newEntityType }, function() {
self.$dropdown.find('.tree-item.selected').removeClass('selected');
self.addSelection($picker, id, name, $item.data());
$item.addClass('selected');
self.serializeAllBlocks($row);
updateCount();
});
} else {
self.addSelection($picker, id, name, $item.data());
$item.toggleClass('selected');
self.serializeAllBlocks($row);
updateCount();
}
}
});
// 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() {
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();
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) {
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);
}
});
// 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) {
self.hideDropdown();
}
});
// Keyboard shortcuts
$(document).on('keydown', function(e) {
if (!self.$dropdown || !self.$dropdown.hasClass('show')) return;
// Ctrl+A / Cmd+A - Select All
if ((e.ctrlKey || e.metaKey) && e.keyCode === 65) {
e.preventDefault();
e.stopPropagation();
self.$dropdown.find('.btn-select-all').trigger('click');
return false;
}
// Ctrl+D / Cmd+D - Clear/Deselect all
if ((e.ctrlKey || e.metaKey) && e.keyCode === 68) {
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
html += '<select class="view-mode-select" title="View mode">';
html += '<option value="list">' + (trans.view_list || 'List') + '</option>';
html += '<option value="tree" class="tree-view-option" disabled hidden>' + (trans.view_tree || 'Tree') + '</option>';
html += '<option value="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 += '<div class="dropdown-footer-actions">';
html += '<button type="button" class="btn-cancel-dropdown">' + (trans.cancel || 'Cancel') + ' <kbd>Esc</kbd></button>';
html += '<button type="button" class="btn-confirm-dropdown"><i class="icon-check"></i> ' + (trans.done || 'Done') + ' <kbd>⏎</kbd></button>';
html += '</div>';
html += '</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
});
}
};
})(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' && isListView) {
// 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>';
}
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);
},
/**
* Load and display category tree view
*/
loadCategoryTree: function() {
var self = this;
var $container = this.$dropdown.find('.dropdown-results');
var entityType = this.activeGroup ? this.activeGroup.searchEntity : 'categories';
// Show the dropdown
this.$dropdown.addClass('show');
// Show loading state
$container.html('<div class="tree-loading"><i class="icon-spinner icon-spin"></i> Loading category tree...</div>');
// Use separate cache for each entity type
var cacheKey = entityType + 'TreeCache';
if (this[cacheKey]) {
this.renderCategoryTree(this[cacheKey], entityType);
return;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getCategoryTree',
trait: 'EntitySelector',
entity_type: entityType
},
success: function(response) {
if (response.success && response.categories) {
self[cacheKey] = response.categories;
self.renderCategoryTree(response.categories, entityType);
} else {
$container.html('<div class="no-results"><i class="icon-warning"></i> Failed to load category tree</div>');
}
},
error: function(xhr, status, error) {
$container.html('<div class="no-results"><i class="icon-warning"></i> Error loading category tree</div>');
}
});
},
/**
* Render category tree structure
*/
renderCategoryTree: function(categories, entityType) {
var self = this;
var trans = this.config.trans || {};
var $container = this.$dropdown.find('.dropdown-results');
var isCmsCategory = entityType === 'cms_categories';
var categoryLabel = isCmsCategory ? 'CMS categories' : 'categories';
// Get selected IDs from current picker
var selectedIds = [];
if (this.activeGroup) {
var $block = this.$wrapper.find('.target-block[data-block-type="' + this.activeGroup.blockType + '"]');
var $group = $block.find('.selection-group[data-group-index="' + this.activeGroup.groupIndex + '"]');
if (this.activeGroup.section === 'include') {
var $picker = $group.find('.include-picker');
$picker.find('.entity-chip').each(function() {
selectedIds.push(parseInt($(this).data('id'), 10));
});
} else {
var $currentExcludeRow = $group.find('.exclude-row[data-exclude-index="' + this.activeGroup.excludeIndex + '"]');
var $currentPicker = $currentExcludeRow.find('.exclude-picker');
$currentPicker.find('.entity-chip').each(function() {
selectedIds.push(parseInt($(this).data('id'), 10));
});
}
}
// Build tree HTML
var html = '<div class="category-tree" data-entity-type="' + this.escapeAttr(entityType) + '">';
html += '<div class="tree-container">';
// Find minimum level (usually 1 or 2)
var minLevel = categories.length > 0 ? categories[0].level : 1;
categories.forEach(function(cat) {
var isSelected = selectedIds.indexOf(cat.id) !== -1;
var indent = (cat.level - minLevel) * 20;
var hasChildren = cat.has_children;
html += '<div class="tree-item' + (isSelected ? ' selected' : '') + (hasChildren ? ' has-children' : '') + '" ';
html += 'data-id="' + cat.id + '" ';
html += 'data-parent-id="' + cat.parent_id + '" ';
html += 'data-level="' + cat.level + '" ';
html += 'data-nleft="' + cat.nleft + '" ';
html += 'data-nright="' + cat.nright + '" ';
html += 'data-name="' + self.escapeAttr(cat.name) + '" ';
html += 'data-subtitle="' + self.escapeAttr(cat.subtitle) + '" ';
html += 'style="padding-left: ' + (indent + 8) + 'px;">';
// Expand/collapse toggle for parents
if (hasChildren) {
html += '<span class="tree-toggle"><i class="icon-caret-down"></i></span>';
} else {
html += '<span class="tree-toggle-placeholder"></span>';
}
// Select children button for parents (on the left, near toggle)
// Hide in single mode - selecting multiple items doesn't make sense there
var isSingleMode = self.config.mode === 'single';
if (hasChildren && !isSingleMode) {
html += '<button type="button" class="btn-select-children" title="' + (trans.select_with_children || 'Select with all children') + '">';
html += '<i class="icon-plus-square"></i>';
html += '</button>';
} else if (!isSingleMode) {
html += '<span class="btn-select-children-placeholder"></span>';
}
// Checkbox
html += '<span class="tree-checkbox"><i class="icon-check"></i></span>';
// Category icon (file icon for CMS categories)
var iconClass = isCmsCategory ? 'icon-file-text-o' : ('icon-folder' + (hasChildren ? '' : '-o'));
html += '<span class="tree-icon"><i class="' + iconClass + '"></i></span>';
// Name and subtitle
html += '<div class="tree-info">';
html += '<span class="tree-name">' + self.escapeHtml(cat.name) + '</span>';
html += '<span class="tree-subtitle">' + self.escapeHtml(cat.subtitle) + '</span>';
html += '</div>';
html += '</div>';
});
html += '</div>'; // tree-container
html += '</div>'; // category-tree
$container.html(html);
// Update results count with appropriate label
var selectedCount = $container.find('.tree-item.selected').length;
this.$dropdown.find('.results-count').text(categories.length + ' ' + categoryLabel + (selectedCount > 0 ? ' (' + selectedCount + ' selected)' : ''));
// Update select-children button states based on initial selection
var $allItems = $container.find('.tree-item');
this.updateSelectChildrenButtons($allItems);
// Hide load more controls in tree view
this.$dropdown.find('.load-more-controls').hide();
},
/**
* Filter category tree by search query (client-side filtering)
*/
filterCategoryTree: function(query) {
var self = this;
var $container = this.$dropdown.find('.category-tree');
if (!$container.length) {
return;
}
var $items = $container.find('.tree-item');
query = query.toLowerCase().trim();
if (!query) {
// Show all items when query is empty
$items.show().removeClass('collapsed');
$container.find('.tree-toggle i').removeClass('icon-caret-right').addClass('icon-caret-down');
return;
}
// First pass: find matching items and their ancestors
var matchingIds = [];
var ancestorIds = [];
$items.each(function() {
var $item = $(this);
var name = ($item.data('name') || '').toLowerCase();
if (name.indexOf(query) !== -1) {
matchingIds.push($item.data('id'));
// Also mark all ancestors using helper (works for both nleft/nright and parent_id)
var ancestors = self.findTreeAncestors($item, $items);
for (var i = 0; i < ancestors.length; i++) {
ancestorIds.push($(ancestors[i]).data('id'));
}
}
});
// Second pass: show/hide items
$items.each(function() {
var $item = $(this);
var id = $item.data('id');
if (matchingIds.indexOf(id) !== -1 || ancestorIds.indexOf(id) !== -1) {
$item.show().removeClass('collapsed');
$item.find('.tree-toggle i').removeClass('icon-caret-right').addClass('icon-caret-down');
} else {
$item.hide();
}
});
// Update count with appropriate label
var visibleCount = $items.filter(':visible').length;
var selectedCount = $items.filter('.selected').length;
var entityType = $container.data('entity-type') || 'categories';
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
this.$dropdown.find('.results-count').text(visibleCount + ' ' + categoryLabel + (selectedCount > 0 ? ' (' + selectedCount + ' selected)' : ''));
},
/**
* Find all descendant tree items of a parent.
* Works with nleft/nright (product categories) or parent_id (CMS categories).
*/
findTreeDescendants: function($parent, $allItems) {
var nleft = parseInt($parent.data('nleft'), 10);
var nright = parseInt($parent.data('nright'), 10);
var parentId = parseInt($parent.data('id'), 10);
var descendants = [];
// If nleft/nright are valid (product categories), use nested set
if (nleft > 0 && nright > 0 && nright > nleft) {
$allItems.each(function() {
var $item = $(this);
var childNleft = parseInt($item.data('nleft'), 10);
var childNright = parseInt($item.data('nright'), 10);
if (childNleft > nleft && childNright < nright) {
descendants.push($item);
}
});
} else {
// CMS categories: use parent_id recursively
var idsToCheck = [parentId];
var processed = {};
while (idsToCheck.length > 0) {
var checkId = idsToCheck.shift();
if (processed[checkId]) continue;
processed[checkId] = true;
$allItems.each(function() {
var $item = $(this);
var itemParentId = parseInt($item.data('parent-id'), 10);
var itemId = parseInt($item.data('id'), 10);
if (itemParentId === checkId && !processed[itemId]) {
descendants.push($item);
idsToCheck.push(itemId);
}
});
}
}
return descendants;
},
/**
* Find all ancestor tree items of an item.
* Works with nleft/nright (product categories) or parent_id (CMS categories).
*/
findTreeAncestors: function($item, $allItems) {
var nleft = parseInt($item.data('nleft'), 10);
var nright = parseInt($item.data('nright'), 10);
var ancestors = [];
// If nleft/nright are valid (product categories), use nested set
if (nleft > 0 && nright > 0) {
$allItems.each(function() {
var $ancestor = $(this);
var ancNleft = parseInt($ancestor.data('nleft'), 10);
var ancNright = parseInt($ancestor.data('nright'), 10);
if (ancNleft < nleft && ancNright > nright) {
ancestors.push($ancestor);
}
});
} else {
// CMS categories: use parent_id chain
var parentId = parseInt($item.data('parent-id'), 10);
var processed = {};
while (parentId > 0 && !processed[parentId]) {
processed[parentId] = true;
$allItems.each(function() {
var $ancestor = $(this);
var ancestorId = parseInt($ancestor.data('id'), 10);
if (ancestorId === parentId) {
ancestors.push($ancestor);
parentId = parseInt($ancestor.data('parent-id'), 10);
return false; // break inner loop
}
});
}
}
return ancestors;
},
/**
* Update all select-children buttons to reflect current selection state.
* Shows minus icon if item and all children are selected, plus icon otherwise.
*/
updateSelectChildrenButtons: function($allItems) {
var self = this;
var trans = this.config.trans || {};
$allItems.filter('.has-children').each(function() {
var $item = $(this);
var $btn = $item.find('.btn-select-children');
if (!$btn.length) return;
var descendants = self.findTreeDescendants($item, $allItems);
// Check if parent and ALL descendants are selected
var allSelected = $item.hasClass('selected');
for (var i = 0; i < descendants.length && allSelected; i++) {
if (!$(descendants[i]).hasClass('selected')) {
allSelected = false;
}
}
// Update button icon and title
if (allSelected && descendants.length > 0) {
$btn.find('i').removeClass('icon-plus-square').addClass('icon-minus-square');
$btn.attr('title', trans.deselect_with_children || 'Deselect with all children');
} else {
$btn.find('i').removeClass('icon-minus-square').addClass('icon-plus-square');
$btn.attr('title', trans.select_with_children || 'Select with all children');
}
});
},
// =========================================================================
// 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();
// Enable/disable tree view option
var $treeOption = this.$dropdown.find('.tree-view-option');
if (entityType === 'categories' || entityType === 'cms_categories') {
$treeOption.prop('disabled', false).show();
} else {
$treeOption.prop('disabled', true).hide();
}
},
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 $allChips = $chips.find('.entity-chip');
var totalCount = $allChips.length;
var $toggle = $chips.find('.chips-show-more-toggle');
var isExpanded = $chips.hasClass('chips-expanded');
var trans = this.config.trans || {};
// Remove existing toggle if present
$toggle.remove();
if (totalCount <= this.maxVisibleChips) {
// All chips visible, no toggle needed
$allChips.removeClass('chip-hidden');
$chips.removeClass('chips-expanded chips-collapsed');
return;
}
// We have more than maxVisibleChips
var hiddenCount = totalCount - this.maxVisibleChips;
if (isExpanded) {
// Show all chips
$allChips.removeClass('chip-hidden');
// Add collapse toggle
var collapseText = trans.show_less || 'Show less';
$chips.append('<span class="chips-show-more-toggle chips-collapse-toggle">' +
'<i class="icon-chevron-up"></i> ' + collapseText + '</span>');
} else {
// Hide chips beyond maxVisibleChips
$allChips.each(function(index) {
if (index >= self.maxVisibleChips) {
$(this).addClass('chip-hidden');
} else {
$(this).removeClass('chip-hidden');
}
});
// Add expand toggle
var moreText = (trans.show_more || 'Show {count} more').replace('{count}', hiddenCount);
$chips.addClass('chips-collapsed').removeClass('chips-expanded');
$chips.append('<span class="chips-show-more-toggle chips-expand-toggle">' +
'<i class="icon-chevron-down"></i> ' + moreText + '</span>');
}
},
// =========================================================================
// 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);
}
});
// Now load all entities in bulk for each entity type
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;
});
$.ajax({
url: self.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getTargetEntitiesByIds',
trait: 'EntitySelector',
entity_type: entityType,
ids: JSON.stringify(uniqueIds)
},
success: function(response) {
if (response.success && response.entities) {
// Build a map of id -> entity for quick lookup
var entityMap = {};
response.entities.forEach(function(entity) {
entityMap[entity.id] = entity;
});
// 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));
});
}
};
})(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;
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');
// Fetch real product count
self.fetchProductCount(blockType, $tab);
} else {
$badge.remove();
$tab.removeClass('has-data');
}
});
// Update target switch state based on whether any data exists
this.updateTargetSwitchState();
},
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);
}
},
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 {
$tab.find('.tab-badge').remove();
}
},
error: function() {
$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.text(total).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;
},
updateAllConditionCounts: function() {
var self = this;
this.$wrapper.find('.target-block.active .selection-group').each(function() {
self.updateGroupCounts($(this));
});
},
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);
},
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');
}
});
},
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);
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 + '"]');
this.enhanceMethodSelect($newRow.find('.exclude-method-select'));
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('.mpr-info-wrapper').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
* Preview popover and pattern preview modal
* @partial _preview.js
*
* EXTRACTION SOURCE: assets/js/admin/entity-selector.js
* Lines: 2906-2942 (updateHeaderTotalCount, updateShowAllToggle)
* 2943-3051 (showPreviewPopover)
* 3053-3076 (renderPreviewItems)
* 3078-3100 (filterPreviewItems)
* 3102-3183 (loadMorePreviewItems)
* 3185-3194 (hidePreviewPopover)
* 3196-3287 (showPatternPreviewModal)
* 3289-3400 (refreshGroupPreviewIfOpen) - estimated
* 7592-7700 (showReplaceConfirmation, getCurrentSingleSelection)
* 7703-7745 (getBlockMode, isBlockSingleMode)
*
* Contains:
* - showPreviewPopover() - Show tab count preview
* - hidePreviewPopover() - Close preview popover
* - renderPreviewItems() - Render item list in preview
* - filterPreviewItems() - Filter preview items by query
* - loadMorePreviewItems() - Load additional preview items
* - showPatternPreviewModal() - Pattern match preview modal
* - refreshGroupPreviewIfOpen() - Refresh preview if open
* - updateHeaderTotalCount() - Update header total badge
* - updateShowAllToggle() - Update show-all checkbox state
* - showReplaceConfirmation() - Single mode replace dialog
* - getCurrentSingleSelection() - Get current selection in single mode
* - getBlockMode() - Get block selection mode
* - isBlockSingleMode() - Check if block is single-select
* - getEntityTypeLabel() - Get human-readable entity label
*/
(function($) {
'use strict';
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
window._EntitySelectorMixins.preview = {
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);
},
showPreviewPopover: function($tab) {
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 || [];
this.previewLoadedCount = items.length;
this.previewBlockType = $tab.data('blockType');
var blockType = this.previewBlockType;
var blockConfig = this.config.blocks && this.config.blocks[blockType] ? this.config.blocks[blockType] : {};
var entityLabelPlural = blockConfig.entity_label_plural || 'items';
var trans = this.config.trans || {};
var html = '<div class="target-preview-popover">';
html += '<div class="preview-header">';
html += '<span class="preview-count">' + previewData.count + ' ' + entityLabelPlural + ' ' + (trans.items_matched || 'matched') + '</span>';
html += '<button type="button" class="preview-close"><i class="icon-times"></i></button>';
html += '</div>';
html += '<div class="preview-filter">';
html += '<input type="text" class="preview-filter-input" placeholder="' + (trans.filter_results || 'Filter results...') + '">';
html += '</div>';
if (items.length > 0) {
html += '<div class="preview-list">';
html += this.renderPreviewItems(items);
html += '</div>';
if (previewData.hasMore) {
var remaining = previewData.count - 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">';
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 += '<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-preview"><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>';
var $popover = $(html);
$('body').append($popover);
this.$previewList = $popover.find('.preview-list');
this.allPreviewData = previewData;
var self = this;
$popover.find('.preview-close').on('click', function() {
self.hidePreviewPopover();
});
$popover.find('.preview-filter-input').on('input', function() {
var query = $(this).val().toLowerCase().trim();
self.filterPreviewItems(query);
});
$popover.find('.btn-load-more-preview').on('click', function() {
self.loadMorePreviewItems($tab, $(this));
});
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
});
// Add show class to trigger visibility
$popover.addClass('show');
this.$previewPopover = $popover;
},
renderPreviewItems: function(products) {
var html = '';
$.each(products, function(i, product) {
var itemClass = 'preview-item' + (product.isCombination ? ' is-combination' : '');
var attrs = (product.attributes || '').toLowerCase();
html += '<div class="' + itemClass + '" data-name="' + (product.name || '').toLowerCase() + '" data-ref="' + (product.reference || '').toLowerCase() + '" data-attrs="' + attrs + '">';
if (product.image) {
html += '<img src="' + product.image + '" alt="" class="preview-image">';
} else {
html += '<span class="preview-image-placeholder"><i class="icon-picture-o"></i></span>';
}
html += '<div class="preview-info">';
html += '<span class="preview-name">' + product.name + '</span>';
if (product.attributes) {
html += '<span class="preview-attributes">' + product.attributes + '</span>';
}
if (product.reference) {
html += '<span class="preview-ref">' + product.reference + '</span>';
}
html += '</div>';
html += '</div>';
});
return html;
},
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') || '';
if (name.indexOf(query) !== -1 || ref.indexOf(query) !== -1 || attrs.indexOf(query) !== -1) {
$item.show();
} else {
$item.hide();
}
});
},
loadMorePreviewItems: function($tab, $btn) {
var self = this;
var blockType = this.previewBlockType;
var $footer = $btn.closest('.preview-footer');
var $select = $footer.find('.load-more-select');
var loadCount = parseInt($select.val(), 10) || 20;
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 };
$btn.prop('disabled', true).find('i').removeClass('icon-plus').addClass('icon-spinner icon-spin');
$select.prop('disabled', true);
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewTargetConditions',
trait: 'TargetConditions',
conditions: JSON.stringify(data),
block_type: blockType,
limit: self.previewLoadedCount + loadCount,
offset: 0
},
success: function(response) {
var items = response.items || response.products || [];
if (response.success && items.length > 0) {
self.$previewList.html(self.renderPreviewItems(items));
self.previewLoadedCount = items.length;
if (response.hasMore) {
var remaining = response.count - items.length;
$footer.find('.remaining-count').text(remaining);
var $allOption = $select.find('option:last');
if ($allOption.val() !== '10' && $allOption.val() !== '20' && $allOption.val() !== '50' && $allOption.val() !== '100') {
$allOption.val(remaining).text(self.config.trans.all + ' (' + remaining + ')');
}
$btn.prop('disabled', false).find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
$select.prop('disabled', false);
} else {
$footer.remove();
}
$tab.data('previewData', response);
self.allPreviewData = response;
var filterQuery = self.$previewPopover.find('.preview-filter-input').val();
if (filterQuery) {
self.filterPreviewItems(filterQuery.toLowerCase().trim());
}
}
},
error: function() {
$btn.prop('disabled', false).find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
$select.prop('disabled', false);
}
});
},
hidePreviewPopover: function() {
if (this.$activeBadge) {
this.$activeBadge.removeClass('popover-open');
this.$activeBadge = null;
}
if (this.$previewPopover) {
this.$previewPopover.remove();
this.$previewPopover = null;
}
},
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>');
}
});
},
refreshGroupPreviewIfOpen: function($group) {
var self = this;
if (!this.$activeBadge || !this.$previewPopover) {
return;
}
// Check if preview is for this group and refresh if needed
},
/**
* Show preview popover for condition match count badge
*/
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 trans = this.config.trans || {};
var blockType = conditionData.blockType || 'products';
var blockConfig = this.config.blocks && this.config.blocks[blockType] ? this.config.blocks[blockType] : {};
var entityLabelPlural = blockConfig.entity_label_plural || 'items';
// Fetch preview items from backend
$.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) {
var items = response.items || [];
self.showItemsPopover($badge, items, response.count, response.hasMore, entityLabelPlural, blockType, 'condition');
}
},
error: function() {
$badge.removeClass('loading popover-open');
self.$activeBadge = null;
}
});
},
/**
* Show preview popover for group count badge
*/
showGroupPreviewPopover: function($badge, $group, blockType) {
var self = this;
// If $group not passed, try to find it
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) {
// Try to serialize from DOM
groupData = this.serializeGroup($group, blockType);
}
if (!groupData || !groupData.include) {
return;
}
this.hidePreviewPopover();
$badge.addClass('popover-open loading');
this.$activeBadge = $badge;
var trans = this.config.trans || {};
var blockConfig = this.config.blocks && this.config.blocks[blockType] ? this.config.blocks[blockType] : {};
var entityLabelPlural = blockConfig.entity_label_plural || 'items';
// Fetch preview items from backend
$.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) {
var items = response.items || [];
self.showItemsPopover($badge, items, response.count, response.hasMore, entityLabelPlural, blockType, 'group');
} else {
$badge.removeClass('popover-open');
self.$activeBadge = null;
}
},
error: function() {
$badge.removeClass('loading popover-open');
self.$activeBadge = null;
}
});
},
/**
* Common popover display for both condition and group previews
*/
showItemsPopover: function($badge, items, totalCount, hasMore, entityLabel, blockType, previewType) {
var self = this;
var trans = this.config.trans || {};
var html = '<div class="target-preview-popover preview-type-' + previewType + '">';
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>';
if (items.length > 0) {
html += '<div class="preview-list">';
html += this.renderPreviewItems(items);
html += '</div>';
if (hasMore) {
var remaining = totalCount - items.length;
html += '<div class="preview-footer">';
html += '<span class="preview-more-info">+ ' + remaining + ' ' + (trans.more || 'more') + '</span>';
html += '</div>';
}
} else {
html += '<div class="preview-empty">' + (trans.no_preview || 'No items to preview') + '</div>';
}
html += '</div>';
var $popover = $(html);
$('body').append($popover);
$popover.find('.preview-close').on('click', function() {
self.hidePreviewPopover();
});
// 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));
var topPos = badgeOffset.top + badgeHeight + 8;
$popover.css({
position: 'absolute',
top: topPos,
left: leftPos,
zIndex: 10000
});
// Add show class for CSS transition
$popover.addClass('show');
this.$previewPopover = $popover;
},
/**
* Render preview items HTML
*/
renderPreviewItems: function(items) {
var html = '';
for (var i = 0; i < items.length; i++) {
var item = items[i];
html += '<div class="preview-item">';
// Image or icon
if (item.image) {
html += '<img src="' + this.escapeHtml(item.image) + '" class="preview-item-image" alt="">';
} else {
html += '<div class="preview-item-icon"><i class="material-icons">inventory_2</i></div>';
}
// Info
html += '<div class="preview-item-info">';
html += '<div class="preview-item-name">' + this.escapeHtml(item.name || 'Unnamed') + '</div>';
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>';
// Price or status
if (typeof item.price !== 'undefined') {
html += '<div class="preview-item-price">' + this.formatPrice(item.price) + '</div>';
}
if (typeof item.active !== 'undefined' && !item.active) {
html += '<span class="preview-item-badge">Inactive</span>';
}
html += '</div>';
}
return html;
},
/**
* Format price for display
*/
formatPrice: function(price) {
if (typeof price !== 'number') {
price = parseFloat(price) || 0;
}
return price.toFixed(2) + ' €';
},
/**
* Show preview popover for filter group toggle (attribute/feature groups)
*/
showFilterGroupPreviewPopover: function($badge, groupId, groupType, groupName) {
var self = this;
this.hidePreviewPopover();
$badge.addClass('popover-open loading');
this.$activeBadge = $badge;
var trans = this.config.trans || {};
var entityLabelPlural = 'products';
// Fetch products matching this attribute/feature group
$.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) {
var items = response.items || [];
var totalCount = response.count || 0;
var hasMore = response.hasMore || false;
self.showFilterGroupItemsPopover($badge, items, totalCount, hasMore, entityLabelPlural, groupName, groupType);
} else {
$badge.removeClass('popover-open');
self.$activeBadge = null;
}
},
error: function() {
$badge.removeClass('loading popover-open');
self.$activeBadge = null;
}
});
},
/**
* Show popover for filter group preview items
*/
showFilterGroupItemsPopover: function($badge, items, totalCount, hasMore, entityLabel, groupName, groupType) {
var self = this;
var trans = this.config.trans || {};
var typeLabel = groupType === 'attribute' ? (trans.attribute || 'Attribute') : (trans.feature || 'Feature');
var html = '<div class="target-preview-popover preview-type-filter-group">';
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>';
if (items.length > 0) {
html += '<div class="preview-list">';
html += this.renderPreviewItems(items);
html += '</div>';
if (hasMore) {
var remaining = totalCount - items.length;
html += '<div class="preview-footer">';
html += '<span class="preview-more-info">+ ' + remaining + ' ' + (trans.more || 'more') + '</span>';
html += '</div>';
}
} else {
html += '<div class="preview-empty">' + (trans.no_preview || 'No items to preview') + '</div>';
}
html += '</div>';
var $popover = $(html);
$('body').append($popover);
$popover.find('.preview-close').on('click', function() {
self.hidePreviewPopover();
});
// 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));
var topPos = badgeOffset.top + badgeHeight + 8;
$popover.css({
position: 'absolute',
top: topPos,
left: leftPos,
zIndex: 10000
});
$popover.addClass('show');
this.$previewPopover = $popover;
}
};
})(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);
}
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);