/** * Entity Selector - Utilities Module * Helper functions: escape, validation, icons, search history * @partial _utils.js (must be loaded first) * * EXTRACTION SOURCE: assets/js/admin/entity-selector.js * Lines: 7552-7570 (escapeHtml, escapeAttr) * 7577-7590 (getEntityTypeLabel) * 6289-6350 (validate, showValidationError, clearValidationError) * 7115-7137 (showRangeInputError) * 7728-7745 (getBlockMode, isBlockSingleMode) * 7707-7723 (getCurrentSingleSelection) * 5411-5467 (search history methods) */ (function($) { 'use strict'; // Create mixin namespace window._EntitySelectorMixins = window._EntitySelectorMixins || {}; // Utility functions mixin window._EntitySelectorMixins.utils = { /** * Debounce function - delays execution until after wait milliseconds * @param {Function} func - Function to debounce * @param {number} wait - Milliseconds to wait * @returns {Function} Debounced function */ debounce: function(func, wait) { var timeout; return function() { var context = this; var args = arguments; clearTimeout(timeout); timeout = setTimeout(function() { func.apply(context, args); }, wait); }; }, escapeHtml: function(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, escapeAttr: function(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, 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 = $('
', { class: 'trait-validation-error', html: ' ' + message }); this.$wrapper.find('.condition-trait-header').after($error); $('html, body').animate({ scrollTop: this.$wrapper.offset().top - 100 }, 300); if (!this.$wrapper.find('.condition-trait-body').is(':visible')) { this.$wrapper.find('.condition-trait-body').slideDown(200); this.$wrapper.removeClass('collapsed'); } }, clearValidationError: function() { this.$wrapper.removeClass('has-validation-error'); this.$wrapper.find('.trait-validation-error').remove(); }, getBlockMode: function(blockType) { var blockDef = this.config.blocks[blockType]; return (blockDef && blockDef.mode) ? blockDef.mode : 'multi'; }, isBlockSingleMode: function(blockType) { return this.getBlockMode(blockType) === 'single'; }, getCurrentSingleSelection: function() { if ((this.config.mode || 'multi') !== 'single') return null; var $chip = this.$wrapper.find('.entity-chips .entity-chip').first(); if ($chip.length) { var $block = $chip.closest('.target-block'); return { name: $chip.find('.chip-name').text() || $chip.data('id'), entityType: $block.data('block-type') || 'item' }; } return null; }, /** * Check if entity type supports tree browsing */ supportsTreeBrowsing: function(entityType) { return entityType === 'categories' || entityType === 'cms_categories'; } }; })(jQuery); /** * Entity Selector - Events Module * All event binding and handlers * @partial _events.js * * Contains event handlers for: * - Tab switching * - Block/group collapse toggle * - Dropdown open/close * - Search input handling * - Item selection/deselection * - Group add/remove * - Exclude row add/remove * - Method select changes * - Filter panel toggles * - Keyboard shortcuts (Ctrl+A, Ctrl+D, Esc, Enter) * - Load more pagination * - Sort controls * - View mode switching * - Tree view events * - Preview badge clicks * - Pattern tag interactions * - Combination picker events * - Group modifier events */ (function($) { 'use strict'; window._EntitySelectorMixins = window._EntitySelectorMixins || {}; window._EntitySelectorMixins.events = { bindEvents: function() { var self = this; // Tab switching this.$wrapper.on('click', '.target-block-tab', function(e) { e.preventDefault(); var blockType = $(this).data('blockType'); self.switchToBlock(blockType); }); // Tab badge click for preview popover (toggle) this.$wrapper.on('click', '.target-block-tab .tab-badge', function(e) { e.stopPropagation(); e.preventDefault(); var $tab = $(this).closest('.target-block-tab'); var $badge = $(this); if ($badge.hasClass('popover-open')) { self.hidePreviewPopover(); } else { self.showPreviewPopover($tab); } }); // Condition count badge click for preview popover this.$wrapper.on('click', '.condition-match-count.clickable', function(e) { e.stopPropagation(); e.preventDefault(); var $badge = $(this); if ($badge.hasClass('popover-open')) { self.hidePreviewPopover(); } else { self.showConditionPreviewPopover($badge); } }); // Group count badge click for preview popover this.$wrapper.on('click', '.group-count-badge.clickable', function(e) { e.stopPropagation(); e.preventDefault(); var $badge = $(this); if ($badge.hasClass('popover-open')) { self.hidePreviewPopover(); } else { self.showGroupPreviewPopover($badge); } }); // Total count badge click for summary popover this.$wrapper.on('click', '.trait-total-count', function(e) { e.stopPropagation(); e.preventDefault(); var $badge = $(this); if ($badge.hasClass('popover-open')) { self.hidePreviewPopover(); } else { self.showTotalPreviewPopover($badge); } }); // Close popover when clicking outside $(document).on('click', function(e) { if (!$(e.target).closest('.target-preview-popover').length && !$(e.target).closest('.tab-badge').length && !$(e.target).closest('.condition-match-count').length && !$(e.target).closest('.group-count-badge').length && !$(e.target).closest('.group-modifiers').length && !$(e.target).closest('.group-preview-badge').length && !$(e.target).closest('.toggle-count.clickable').length && !$(e.target).closest('.trait-total-count').length) { self.hidePreviewPopover(); } }); // Block-level collapse toggle (click on header) this.$wrapper.on('click', '.condition-trait-header', function(e) { if ($(e.target).closest('.target-block-tabs').length || $(e.target).closest('.trait-header-actions').length || $(e.target).closest('.prestashop-switch').length || $(e.target).closest('.trait-total-count').length) { return; } var $body = self.$wrapper.find('.condition-trait-body'); $body.stop(true, true); if ($body.is(':visible')) { $body.slideUp(200); self.$wrapper.addClass('collapsed'); } else { $body.slideDown(200); self.$wrapper.removeClass('collapsed'); } }); // Group-level collapse toggle (click on group header or toggle icon) this.$wrapper.on('click', '.group-header', function(e) { if ($(e.target).closest('.btn-remove-group, .group-name-input').length) { return; } if (self.$wrapper.data('mode') === 'single') { return; } var $group = $(this).closest('.selection-group'); $group.toggleClass('collapsed'); }); // Toggle all groups (single button that switches between expand/collapse) this.$wrapper.on('click', '.trait-header-actions .btn-toggle-groups', function(e) { e.preventDefault(); e.stopPropagation(); var $btn = $(this); var currentState = $btn.attr('data-state') || 'collapsed'; var trans = self.config.trans || {}; if (currentState === 'collapsed') { self.$wrapper.find('.selection-group').removeClass('collapsed'); $btn.attr('data-state', 'expanded'); $btn.attr('title', trans.collapse_all || 'Collapse all groups'); $btn.find('i').removeClass('icon-expand').addClass('icon-compress'); } else { self.$wrapper.find('.selection-group').addClass('collapsed'); $btn.attr('data-state', 'collapsed'); $btn.attr('title', trans.expand_all || 'Expand all groups'); $btn.find('i').removeClass('icon-compress').addClass('icon-expand'); } }); // Show all toggle change (legacy checkbox) this.$wrapper.on('change', '.trait-show-all-toggle .show-all-checkbox', function(e) { e.stopPropagation(); var isChecked = $(this).prop('checked'); if (isChecked) { self.clearAllConditions(); } }); // Target switch change (PrestaShop native switch) this.$wrapper.on('change', '.target-switch-toggle', function(e) { e.stopPropagation(); var value = $(this).val(); if (value === '1') { self.clearAllConditions(); self.$wrapper.find('.condition-trait-body').slideUp(200); self.$wrapper.addClass('collapsed'); } else { self.$wrapper.find('.condition-trait-body').slideDown(200); self.$wrapper.removeClass('collapsed'); } }); // Add group this.$wrapper.on('click', '.btn-add-group', function(e) { e.preventDefault(); var $block = $(this).closest('.target-block'); var blockType = $block.data('blockType'); self.addGroup($block, blockType); }); // Remove group this.$wrapper.on('click', '.btn-remove-group', function(e) { e.preventDefault(); var $group = $(this).closest('.selection-group'); var $block = $(this).closest('.target-block'); self.removeGroup($group, $block); }); // Group name input - stop propagation to prevent collapse this.$wrapper.on('click focus', '.group-name-input', function(e) { e.stopPropagation(); }); // Group name change this.$wrapper.on('change blur', '.group-name-input', function() { var $input = $(this); var $group = $input.closest('.selection-group'); var name = $.trim($input.val()); $group.attr('data-group-name', name); self.serializeAllBlocks(); }); // Add exceptions (first exclude row) this.$wrapper.on('click', '.btn-add-exclude', function(e) { e.preventDefault(); var $group = $(this).closest('.selection-group'); var $block = $(this).closest('.target-block'); self.addFirstExcludeRow($group, $block); }); // Add another exclude row this.$wrapper.on('click', '.btn-add-another-exclude', function(e) { e.preventDefault(); var $group = $(this).closest('.selection-group'); var $block = $(this).closest('.target-block'); self.addExcludeRow($group, $block); }); // Remove individual exclude row this.$wrapper.on('click', '.btn-remove-exclude-row', function(e) { e.preventDefault(); var $excludeRow = $(this).closest('.exclude-row'); var $group = $(this).closest('.selection-group'); var $block = $(this).closest('.target-block'); self.removeExcludeRow($excludeRow, $group, $block); }); // Include method change this.$wrapper.on('change', '.include-method-select', function() { self.hideDropdown(); var $group = $(this).closest('.selection-group'); var $block = $(this).closest('.target-block'); var $row = $group.find('.group-include'); var blockType = $block.data('blockType'); var blockDef = self.config.blocks[blockType] || {}; var methods = blockDef.selection_methods || {}; var $option = $(this).find('option:selected'); var valueType = $option.data('valueType') || 'none'; var searchEntity = $option.data('searchEntity') || ''; var methodOptions = $option.data('options') || null; var $oldPicker = $group.find('.include-picker'); var newPickerHtml = self.buildValuePickerHtml('include', valueType, searchEntity, methods); $oldPicker.replaceWith(newPickerHtml); if (valueType === 'select' && methodOptions) { var $newPicker = $group.find('.include-picker'); var $select = $newPicker.find('.select-value-input'); $select.empty(); $.each(methodOptions, function(key, label) { $select.append(''); }); } 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(''); }); } 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 = $('').val(currentPattern); var $saveBtn = $(''); var $cancelBtn = $(''); var $editActions = $('').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 = $('
', { 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 = $('', { class: 'range-chip', 'data-min': minVal, 'data-max': maxVal }); $chip.append($('', { class: 'range-chip-text', text: chipText })); $chip.append($(''; html += ''; // Sort controls - options with data-entities attribute for entity-specific filtering html += '
'; html += ''; html += ''; // View mode selector - Tree option always present, shown for categories html += ''; html += '
'; // End sort-controls // Refine search html += '
'; html += ''; html += ''; html += ''; html += '
'; // Filter toggle button html += ''; // History button html += ''; html += '
'; // End dropdown-actions html += '
'; // End dropdown-header // Filter panel html += '
'; // Quick filters row (for products) html += '
'; html += ''; html += ''; // Price range html += '
'; html += '' + (trans.price || 'Price') + ':'; html += ''; html += '-'; html += ''; html += '
'; html += ''; html += '
'; // Attribute/Feature filter toggles for products html += ''; html += ''; html += ''; html += ''; // Entity-specific filters: Categories html += ''; // Entity-specific filters: Manufacturers html += ''; // Entity-specific filters: Suppliers html += ''; // Entity-specific filters: Attributes html += ''; // Entity-specific filters: Features html += ''; // Entity-specific filters: CMS Pages html += ''; // Entity-specific filters: CMS Categories html += ''; html += '
'; // End filter-panel // Results header for list view (product columns) html += '
'; html += ''; html += '' + (trans.product || 'Product') + ''; html += '' + (trans.price || 'Price') + ''; html += '' + (trans.sale || 'Sale') + ''; html += '' + (trans.stock || 'Stock') + ''; html += '' + (trans.sold || 'Sold') + ''; html += '
'; // Results html += ''; // Footer - unified load more + actions html += ''; html += ''; this.$dropdown = $(html); $('body').append(this.$dropdown); }, hideDropdown: function() { if (this.$dropdown) { this.$dropdown.removeClass('show'); } this.activeGroup = null; }, positionDropdown: function($input) { if (!this.$dropdown) return; var $picker = $input.closest('.value-picker'); var $searchBox = $input.closest('.entity-search-box'); // Get absolute positions (dropdown is appended to body) var searchBoxOffset = $searchBox.offset(); var searchBoxHeight = $searchBox.outerHeight(); var pickerOffset = $picker.offset(); var pickerWidth = $picker.outerWidth(); // Calculate position relative to document var dropdownTop = searchBoxOffset.top + searchBoxHeight + 4; var dropdownLeft = pickerOffset.left; var dropdownWidth = Math.max(pickerWidth, 400); // Ensure dropdown doesn't overflow the viewport horizontally var viewportWidth = $(window).width(); if (dropdownLeft + dropdownWidth > viewportWidth - 10) { dropdownWidth = viewportWidth - dropdownLeft - 10; } // Ensure dropdown doesn't overflow viewport vertically var viewportHeight = $(window).height(); var scrollTop = $(window).scrollTop(); var maxHeight = viewportHeight - (dropdownTop - scrollTop) - 20; maxHeight = Math.max(maxHeight, 400); this.$dropdown.css({ position: 'absolute', top: dropdownTop, left: dropdownLeft, width: dropdownWidth, maxHeight: maxHeight, zIndex: 10000 }); // Show the dropdown this.$dropdown.addClass('show'); } }; })(jQuery); /** * Entity Selector - 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 = '
' + (trans.no_results || 'No results found') + '
'; } else { visibleResults.forEach(function(item) { var isSelected = selectedIds.indexOf(String(item.id)) !== -1; var itemClass = 'dropdown-item' + (isSelected ? ' selected' : ''); if (item.type === 'product') itemClass += ' result-item-product'; html += '
'; } 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 += '
'; } html += '
'; html += '
' + self.escapeHtml(item.name) + '
'; if (item.subtitle) { // Split multi-line subtitles into separate divs for styling var subtitleLines = item.subtitle.split('\n'); html += '
'; subtitleLines.forEach(function(line, idx) { var lineClass = idx === 0 ? 'subtitle-line subtitle-line-primary' : 'subtitle-line subtitle-line-secondary'; html += '
' + self.escapeHtml(line) + '
'; }); html += '
'; } html += '
'; // Add product-specific columns (price, sale price, stock, sold) if (item.type === 'product') { if (isListView) { // List view: full columns // Regular price html += '
'; html += '' + (item.regular_price_formatted || item.price_formatted || '') + ''; html += '
'; // Sale price (only if discounted) if (item.has_discount) { html += '
'; html += '' + (item.price_formatted || '') + ''; html += '
'; } else { html += '
'; } // Stock column var stockClass = item.stock_status === 'out_of_stock' ? 'stock-out' : (item.stock_status === 'low_stock' ? 'stock-low' : 'stock-ok'); html += '
'; html += '' + (item.stock_qty !== undefined ? item.stock_qty : '') + ''; html += '
'; // Sales column html += '
'; html += '' + (item.sales_qty !== undefined ? item.sales_qty : '0') + ''; html += '
'; } else { // Grid view: compact info line var gridStockClass = item.stock_status === 'out_of_stock' ? 'stock-out' : (item.stock_status === 'low_stock' ? 'stock-low' : ''); html += '
'; html += '' + (item.price_formatted || '') + ''; if (item.stock_qty !== undefined) { html += '' + item.stock_qty + ' qty'; } if (item.has_discount) { html += '-' + (item.discount_percent || '') + '%'; } html += '
'; } } html += ''; }); } if (appendMode) { $container.append(html); } else { $container.html(html); } // Show/hide load more controls and update remaining count var hasMore = this.searchResults.length < this.searchTotal; var $loadMoreControls = this.$dropdown.find('.load-more-controls'); $loadMoreControls.toggle(hasMore); if (hasMore) { var remaining = this.searchTotal - this.searchResults.length; $loadMoreControls.find('.remaining-count').text(remaining); // Update "All" option in dropdown var $select = $loadMoreControls.find('.load-more-select'); var $allOption = $select.find('option[data-all="true"]'); if ($allOption.length) { $allOption.val(remaining).text((trans.all || 'All') + ' (' + remaining + ')'); } else { $select.find('option:last').after(''); } } // Ensure dropdown-actions are visible and history button is deactivated this.$dropdown.find('.dropdown-actions').show(); this.$dropdown.find('.btn-show-history').removeClass('active'); // Disable history button if no search history for current entity type var entityType = this.activeGroup ? this.activeGroup.searchEntity : null; var hasHistory = entityType && this.getSearchHistory(entityType).length > 0; this.$dropdown.find('.btn-show-history').prop('disabled', !hasHistory); }, // NOTE: Tree methods (loadCategoryTree, renderCategoryTree, filterCategoryTree, // findTreeDescendants, findTreeAncestors, updateSelectChildrenButtons) are // defined in _tree.js which is merged later and takes precedence. // ========================================================================= // Search History // ========================================================================= loadSearchHistory: function() { try { var stored = localStorage.getItem(this.searchHistoryKey); this.searchHistory = stored ? JSON.parse(stored) : {}; } catch (e) { this.searchHistory = {}; } }, saveSearchHistory: function() { try { localStorage.setItem(this.searchHistoryKey, JSON.stringify(this.searchHistory)); } catch (e) { // localStorage might be full or unavailable } }, addToSearchHistory: function(entityType, query) { if (!query || query.length < 2) return; if (!this.searchHistory[entityType]) { this.searchHistory[entityType] = []; } var history = this.searchHistory[entityType]; // Remove if already exists (will re-add at top) var existingIndex = history.indexOf(query); if (existingIndex !== -1) { history.splice(existingIndex, 1); } // Add at beginning history.unshift(query); // Trim to max if (history.length > this.searchHistoryMax) { history = history.slice(0, this.searchHistoryMax); } this.searchHistory[entityType] = history; this.saveSearchHistory(); }, removeFromSearchHistory: function(entityType, query) { if (!this.searchHistory[entityType]) return; var index = this.searchHistory[entityType].indexOf(query); if (index !== -1) { this.searchHistory[entityType].splice(index, 1); this.saveSearchHistory(); } }, getSearchHistory: function(entityType) { return this.searchHistory[entityType] || []; }, showSearchHistory: function(entityType) { var history = this.getSearchHistory(entityType); var trans = this.config.trans || {}; var $container = this.$dropdown.find('.dropdown-results'); // Update header this.$dropdown.find('.results-count').text(trans.recent_searches || 'Recent searches'); // Hide filters, actions, and results header for history view this.$dropdown.find('.dropdown-actions').hide(); this.$dropdown.find('.filter-panel').removeClass('show'); this.$dropdown.find('.btn-toggle-filters').removeClass('active'); this.$dropdown.find('.results-header').hide(); if (!history.length) { // No history - just do a regular search this.performSearch(); return; } // Build history items var html = '
'; for (var i = 0; i < history.length; i++) { var query = history[i]; html += '
'; html += ''; html += '' + this.escapeHtml(query) + ''; html += ''; html += '
'; } html += '
'; $container.html(html); this.$dropdown.addClass('show'); }, // ========================================================================= // Filter Methods // ========================================================================= refreshSearch: function() { // In tree view mode, re-filter the tree instead of doing a flat AJAX search if (this.viewMode === 'tree') { this.filterCategoryTree(this.searchQuery || ''); return; } this.searchOffset = 0; this.loadMoreCount = 20; // Reset load more select to default if (this.$dropdown) { this.$dropdown.find('.load-more-select').val('20'); // Remove the dynamic "All" option this.$dropdown.find('.load-more-select option[data-all="true"]').remove(); } this.performSearch(false); }, clearFilters: function() { this.refineQuery = ''; this.refineNegate = false; this.filters = { inStock: false, discounted: false, priceMin: null, priceMax: null, attributes: [], features: [], // Entity-specific filters productCountMin: null, productCountMax: null, salesMin: null, salesMax: null, turnoverMin: null, turnoverMax: null, depth: null, hasProducts: false, hasDescription: false, hasImage: false, activeOnly: true, attributeGroup: null, featureGroup: null, dateAddFrom: null, dateAddTo: null, lastProductFrom: null, lastProductTo: null }; 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(''); }); } } }); }, loadFeatureGroups: function() { var self = this; var $select = this.$dropdown.find('.filter-feature-group-select'); // Already loaded? if ($select.find('option').length > 1) return; $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'getFeatureGroups', trait: 'EntitySelector' }, success: function(response) { if (response.success && response.groups) { $.each(response.groups, function(i, group) { $select.append(''); }); } } }); } }; })(jQuery); /** * Entity Selector - Filters Module * Filter panel, filter state management * @partial _filters.js * * EXTRACTION SOURCE: assets/js/admin/entity-selector.js * Lines: 6605-6758 (filter methods) * * Contains: * - clearFilters() - Reset all filters * - resetFiltersWithoutSearch() - Reset without triggering search * - updateFilterPanelForEntity() - Show/hide filters based on entity type * - loadFilterableData() - Load attributes/features for filter panel * - renderFilterDropdowns() - Render attribute/feature group toggles * - showFilterGroupValues() - Show values for a filter group * - hideFilterGroupValues() - Hide filter values row * - updateFilterToggleStates() - Update toggle states based on selections */ (function($) { 'use strict'; window._EntitySelectorMixins = window._EntitySelectorMixins || {}; window._EntitySelectorMixins.filters = { clearFilters: function() { this.refineQuery = ''; this.refineNegate = false; this.filters = { inStock: false, discounted: false, priceMin: null, priceMax: null, attributes: [], features: [], productCountMin: null, productCountMax: null, salesMin: null, salesMax: null, turnoverMin: null, turnoverMax: null, depth: null, hasProducts: false, hasDescription: false, hasImage: false, activeOnly: true, attributeGroup: null, featureGroup: null, dateAddFrom: null, dateAddTo: null, lastProductFrom: null, lastProductTo: null }; if (this.$dropdown) { var trans = this.config.trans || {}; this.$dropdown.find('.refine-input').val(''); this.$dropdown.find('.btn-refine-negate').removeClass('active'); this.$dropdown.find('.filter-in-stock').prop('checked', false); this.$dropdown.find('.filter-discounted').prop('checked', false); this.$dropdown.find('.filter-price-min, .filter-price-max').val(''); this.$dropdown.find('.filter-attr-chip, .filter-feat-chip').removeClass('active'); this.$dropdown.find('.filter-product-count-min, .filter-product-count-max').val(''); this.$dropdown.find('.filter-sales-min, .filter-sales-max').val(''); this.$dropdown.find('.filter-depth-select').val(''); this.$dropdown.find('.filter-has-products').prop('checked', false); this.$dropdown.find('.filter-active-only').prop('checked', true); } this.refreshSearch(); }, resetFiltersWithoutSearch: function() { // Same as clearFilters but doesn't trigger search // Used when switching entity types this.refineQuery = ''; this.refineNegate = false; this.filters = { inStock: false, discounted: false, priceMin: null, priceMax: null, attributes: [], features: [], productCountMin: null, productCountMax: null, salesMin: null, salesMax: null, turnoverMin: null, turnoverMax: null, depth: null, hasProducts: false, hasDescription: false, hasImage: false, activeOnly: true, attributeGroup: null, featureGroup: null, dateAddFrom: null, dateAddTo: null, lastProductFrom: null, lastProductTo: null }; }, updateFilterPanelForEntity: function(entityType) { if (!this.$dropdown) { return; } var $panel = this.$dropdown.find('.filter-panel'); // Hide all entity-specific filter rows $panel.find('.filter-row').hide(); // Show filters for current entity type $panel.find('.filter-row[data-entity="' + entityType + '"]').show(); $panel.find('.filter-row-entity-' + entityType.replace('_', '-')).show(); // Show/hide tree view option based on entity type var isCategory = (entityType === 'categories' || entityType === 'cms_categories'); this.$dropdown.find('.tree-view-option').toggle(isCategory); // Default to tree view for categories (only if currently on list mode) if (isCategory && this.viewMode === 'list') { this.viewMode = 'tree'; this.$dropdown.find('.view-mode-select').val('tree'); this.$dropdown.removeClass('view-list view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8').addClass('view-tree'); } else if (!isCategory && this.viewMode === 'tree') { // If switching away from categories while in tree mode, switch to list this.viewMode = 'list'; this.$dropdown.find('.view-mode-select').val('list'); this.$dropdown.removeClass('view-tree view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8').addClass('view-list'); } // Update sort options for entity type this.updateSortOptionsForEntity(entityType); }, /** * Show/hide sort options based on entity type * Options with data-entities attribute are only shown for matching entities */ updateSortOptionsForEntity: function(entityType) { if (!this.$dropdown) { return; } var $select = this.$dropdown.find('.sort-field-select'); var currentValue = $select.val(); var hasCurrentOption = false; $select.find('option').each(function() { var $option = $(this); var entities = $option.data('entities'); // Options without data-entities are universal (always shown) if (!entities) { $option.show(); if ($option.val() === currentValue) { hasCurrentOption = true; } return; } // Check if this entity type is in the allowed list var allowedEntities = entities.split(','); var isAllowed = allowedEntities.indexOf(entityType) !== -1; $option.toggle(isAllowed); if (isAllowed && $option.val() === currentValue) { hasCurrentOption = true; } }); // If current sort field is not available for this entity, reset to 'name' if (!hasCurrentOption) { $select.val('name'); this.currentSort.field = 'name'; } }, 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 = ''; $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 = ''; $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 = '' + group.name + ':'; // 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 += ''; }); $valuesContainer.html(html); // Add close button as sibling (outside filter-values-container, inside filter-row-values) $filterRowValues.find('.btn-close-values').remove(); $filterRowValues.append(''); $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 = ''; if (data && data.image) { html += ''; } html += '' + this.escapeHtml(name) + ''; html += ''; html += ''; $chips.append(html); }, removeSelection: function($picker, id) { var $chips = $picker.find('.entity-chips'); $picker.find('.entity-chip[data-id="' + id + '"]').remove(); this.updateChipsVisibility($chips); }, updateChipsVisibility: function($chips) { var self = this; var trans = this.config.trans || {}; var $picker = $chips.closest('.value-picker'); var $allChips = $chips.find('.entity-chip'); var totalCount = $allChips.length; // If no chips, remove the wrapper entirely var $existingWrapper = $chips.closest('.chips-wrapper'); if (totalCount === 0) { if ($existingWrapper.length) { // Move chips out of wrapper before removing $existingWrapper.before($chips); $existingWrapper.remove(); } return; } // Ensure chips wrapper structure exists this.ensureChipsWrapper($chips); var $wrapper = $chips.closest('.chips-wrapper'); var $toolbar = $wrapper.find('.chips-toolbar'); var $loadMore = $wrapper.find('.chips-load-more'); // Get current search filter var searchTerm = $toolbar.find('.chips-search-input').val() || ''; searchTerm = searchTerm.toLowerCase().trim(); // Filter and paginate chips var visibleCount = 0; var filteredCount = 0; var isExpanded = $chips.hasClass('chips-expanded'); var maxVisible = isExpanded ? 999999 : (this.maxVisibleChips || 12); $allChips.each(function() { var $chip = $(this); var chipName = ($chip.find('.chip-name').text() || '').toLowerCase(); var matchesFilter = !searchTerm || chipName.indexOf(searchTerm) !== -1; $chip.removeClass('chip-filtered-out chip-paginated-out'); if (!matchesFilter) { $chip.addClass('chip-filtered-out'); } else { filteredCount++; if (filteredCount > maxVisible) { $chip.addClass('chip-paginated-out'); } else { visibleCount++; } } }); // Update toolbar (always show when we have chips) $toolbar.addClass('has-chips'); this.updateChipsToolbar($toolbar, totalCount, filteredCount, searchTerm); // Update load more select dropdown var hiddenByPagination = filteredCount - visibleCount; if (hiddenByPagination > 0 && !isExpanded) { var loadText = trans.load || 'Load'; var remainingText = (trans.remaining || '{count} remaining').replace('{count}', hiddenByPagination); var loadMoreHtml = '' + loadText + '' + '' + '' + remainingText + ''; $loadMore.html(loadMoreHtml).show(); } else if (isExpanded && filteredCount > (this.maxVisibleChips || 12)) { var collapseText = trans.collapse || 'Collapse'; $loadMore.html( '' ).show(); } else { $loadMore.hide(); } }, ensureChipsWrapper: function($chips) { // Check if already wrapped if ($chips.closest('.chips-wrapper').length) { return; } var trans = this.config.trans || {}; var $picker = $chips.closest('.value-picker'); // Create wrapper structure - integrated filter toolbar with sort var wrapperHtml = '
' + '
' + '' + '' + '' + '' + '
' + '' + '
'; var $wrapper = $(wrapperHtml); // Insert wrapper before chips and move chips inside $chips.before($wrapper); $wrapper.find('.chips-toolbar').after($chips); $wrapper.append($wrapper.find('.chips-load-more')); // Bind toolbar events this.bindChipsToolbarEvents($wrapper); }, bindChipsToolbarEvents: function($wrapper) { var self = this; var $chips = $wrapper.find('.entity-chips'); var searchTimeout; // Search input $wrapper.on('input', '.chips-search-input', function() { clearTimeout(searchTimeout); searchTimeout = setTimeout(function() { // Collapse when searching to show filtered results from start $chips.removeClass('chips-expanded'); self.updateChipsVisibility($chips); }, 150); }); // Sort select $wrapper.on('change', '.chips-sort-select', function() { var sortBy = $(this).val(); self.sortChips($chips, sortBy); }); // Clear all button $wrapper.on('click', '.btn-chips-clear', function() { var searchTerm = $wrapper.find('.chips-search-input').val() || ''; var $chipsToRemove; if (searchTerm.trim()) { // Remove only filtered (visible) chips $chipsToRemove = $chips.find('.entity-chip:not(.chip-filtered-out)'); } else { // Remove all chips $chipsToRemove = $chips.find('.entity-chip'); } $chipsToRemove.each(function() { $(this).find('.chip-remove').trigger('click'); }); // Clear search $wrapper.find('.chips-search-input').val(''); self.updateChipsVisibility($chips); }); // Load more select dropdown $wrapper.on('change', '.load-more-select', function() { var loadCount = $(this).val(); if (loadCount === 'all') { $chips.addClass('chips-expanded'); self.maxVisibleChips = 999999; } else { self.maxVisibleChips = (self.maxVisibleChips || 12) + parseInt(loadCount, 10); } self.updateChipsVisibility($chips); }); // Collapse button $wrapper.on('click', '.btn-collapse-chips', function() { $chips.removeClass('chips-expanded'); self.maxVisibleChips = 12; self.updateChipsVisibility($chips); }); }, /** * Sort chips by specified criteria */ sortChips: function($chips, sortBy) { var $allChips = $chips.find('.entity-chip'); if ($allChips.length < 2) return; var sorted = $allChips.toArray().sort(function(a, b) { var $a = $(a); var $b = $(b); switch (sortBy) { case 'name_asc': var nameA = ($a.find('.chip-name').text() || '').toLowerCase(); var nameB = ($b.find('.chip-name').text() || '').toLowerCase(); return nameA.localeCompare(nameB); case 'name_desc': var nameA2 = ($a.find('.chip-name').text() || '').toLowerCase(); var nameB2 = ($b.find('.chip-name').text() || '').toLowerCase(); return nameB2.localeCompare(nameA2); case 'added': default: // Keep original DOM order (order added) return 0; } }); // Re-append in sorted order $.each(sorted, function(i, chip) { $chips.append(chip); }); this.updateChipsVisibility($chips); }, updateChipsToolbar: function($toolbar, totalCount, filteredCount, searchTerm) { var trans = this.config.trans || {}; var $count = $toolbar.find('.chips-count'); var $clearBtn = $toolbar.find('.btn-chips-clear'); var $clearText = $clearBtn.find('.clear-text'); // Update count display if (searchTerm) { $count.addClass('has-filter').html( '' + filteredCount + '' + '/' + '' + totalCount + '' ); $clearText.text((trans.clear || 'Clear') + ' ' + filteredCount); } else { $count.removeClass('has-filter').html(totalCount); $clearText.text(trans.clear_all || 'Clear all'); } // Show/hide clear button if (searchTerm && filteredCount === 0) { $clearBtn.hide(); } else if (totalCount > 0) { $clearBtn.show(); } else { $clearBtn.hide(); } }, // ========================================================================= // Loading/Initialization // ========================================================================= loadExistingSelections: function() { var self = this; // Collect all entity IDs to load, grouped by entity type var entitiesToLoad = {}; // { entity_type: { ids: [], pickers: [] } } this.$wrapper.find('.selection-group').each(function() { var $group = $(this); var $block = $group.closest('.target-block'); var blockType = $block.data('blockType'); // Load include values var $includePicker = $group.find('.include-picker'); self.collectPickerEntities($includePicker, blockType, entitiesToLoad); // Enhance the include method select if not already enhanced self.enhanceMethodSelect($group.find('.include-method-select')); // Load exclude values from each exclude row $group.find('.exclude-row').each(function() { var $excludeRow = $(this); self.collectPickerEntities($excludeRow.find('.exclude-picker'), blockType, entitiesToLoad); // Enhance the exclude method select if not already enhanced self.enhanceMethodSelect($excludeRow.find('.exclude-method-select')); }); // Lock method selector if excludes exist var hasExcludes = $group.find('.group-excludes.has-excludes').length > 0; if (hasExcludes) { self.updateMethodSelectorLock($group, true); } }); // Build bulk request: { entityType: [uniqueIds], ... } var bulkRequest = {}; var hasEntities = false; Object.keys(entitiesToLoad).forEach(function(entityType) { var data = entitiesToLoad[entityType]; if (data.ids.length === 0) return; // Deduplicate IDs var uniqueIds = data.ids.filter(function(id, index, arr) { return arr.indexOf(id) === index; }); bulkRequest[entityType] = uniqueIds; hasEntities = true; }); // Skip AJAX if no entities to load if (!hasEntities) { return; } // Single bulk AJAX call for all entity types $.ajax({ url: self.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'getTargetEntitiesByIdsBulk', trait: 'EntitySelector', entities: JSON.stringify(bulkRequest) }, success: function(response) { if (!response.success || !response.entities) { return; } // Process each entity type's results Object.keys(entitiesToLoad).forEach(function(entityType) { var data = entitiesToLoad[entityType]; var entities = response.entities[entityType] || []; // Build a map of id -> entity for quick lookup var entityMap = {}; entities.forEach(function(entity) { entityMap[entity.id] = entity; }); // Update each picker that requested this entity type data.pickers.forEach(function(pickerData) { var $picker = pickerData.$picker; var $chips = $picker.find('.entity-chips'); var $dataInput = $picker.find('.include-values-data, .exclude-values-data'); var validIds = []; // Replace loading chips with real data pickerData.ids.forEach(function(id) { var $loadingChip = $chips.find('.entity-chip-loading[data-id="' + id + '"]'); if (entityMap[id]) { var entity = entityMap[id]; validIds.push(entity.id); // Create real chip var html = ''; if (entity.image) { html += ''; } html += '' + self.escapeHtml(entity.name) + ''; html += ''; html += ''; $loadingChip.replaceWith(html); } else { // Entity not found, remove loading chip $loadingChip.remove(); } }); // Update chips visibility self.updateChipsVisibility($chips); // If some entities were not found, update the hidden input if (validIds.length !== pickerData.ids.length) { $dataInput.val(JSON.stringify(validIds)); self.serializeAllBlocks(); } self.updateBlockStatus($picker.closest('.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 = $('', { class: 'range-chip', 'data-min': range.min !== null ? range.min : '', 'data-max': range.max !== null ? range.max : '' }); $chip.append($('', { class: 'range-chip-text', text: chipText })); $chip.append($(''; html += '' + this.escapeHtml(pattern) + ''; html += ''; html += ''; $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(''); $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(''); $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(''); $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(''); $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('').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 = ' ' + finalCount; if (excludeCount > 0) { badgeHtml += ' (-' + excludeCount + ')'; } $badge.html(badgeHtml); $badge.addClass('clickable').show(); // Store group data on badge for preview popover $badge.data('groupData', groupData); $badge.data('blockType', blockType); $badge.data('finalCount', finalCount); // Update limit placeholder with the count $limitInput.attr('placeholder', finalCount); // Also update the group-preview-badge count (apply limit if set) var $previewBadge = $group.find('.group-preview-badge .preview-count'); if ($previewBadge.length) { var limit = parseInt($limitInput.val(), 10); var displayCount = (limit > 0 && limit < finalCount) ? limit : finalCount; $previewBadge.text(displayCount); } } else { $badge.hide().removeClass('clickable'); $limitInput.attr('placeholder', '–'); } }, error: function() { $badge.hide(); $limitInput.attr('placeholder', '–'); } }); }, /** * Update all condition counts for all visible groups */ updateAllConditionCounts: function() { var self = this; this.$wrapper.find('.target-block.active .selection-group').each(function() { self.updateGroupCounts($(this)); }); }, /** * Fetch category names by IDs and add chips to the picker * Used when adding selections from the tree modal * @param {jQuery} $picker - Picker element * @param {Array} ids - Category IDs to add * @param {string} entityType - 'categories' or 'cms_categories' * @param {Function} callback - Called when done */ fetchCategoryNamesAndAddChips: function($picker, ids, entityType, callback) { var self = this; if (!ids || ids.length === 0) { if (typeof callback === 'function') { callback(); } return; } $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'getTargetEntitiesByIds', trait: 'EntitySelector', entity_type: entityType, ids: JSON.stringify(ids) }, success: function(response) { if (response.success && response.entities) { response.entities.forEach(function(entity) { self.addSelectionNoUpdate($picker, entity.id, entity.name, entity); }); } if (typeof callback === 'function') { callback(); } }, error: function() { if (typeof callback === 'function') { callback(); } } }); } }; })(jQuery); /** * Entity Selector - Groups Module * Selection group management, serialization, block/tab management * @partial _groups.js * * Contains: * - Group management: addGroup, removeGroup, clearAllConditions * - Block/Tab: switchToBlock, updateTabBadges, updateBlockStatus * - Serialization: serializeGroup, serializeAllBlocks, getBlockGroups * - Counts: fetchProductCount, updateHeaderTotalCount, updateAllConditionCounts * - Excludes: addFirstExcludeRow, addExcludeRow, removeExcludeRow * - Validation: validate, showValidationError, clearValidationError */ (function($) { 'use strict'; window._EntitySelectorMixins = window._EntitySelectorMixins || {}; window._EntitySelectorMixins.groups = { addGroup: function($block, blockType) { var $container = $block.find('.groups-container'); var trans = this.config.trans || {}; var blockDef = this.config.blocks[blockType] || {}; var methods = blockDef.selection_methods || {}; // Remove empty state $container.find('.groups-empty-state').remove(); // Get next group index var maxIndex = -1; $container.find('.selection-group').each(function() { var idx = parseInt($(this).data('groupIndex'), 10); if (idx > maxIndex) maxIndex = idx; }); var groupIndex = maxIndex + 1; // Build method options with optgroups var methodOptions = this.buildMethodOptions(methods, false); // Build exclude method options (no "all") with optgroups var excludeMethodOptions = this.buildMethodOptions(methods, true); var defaultGroupName = (trans.group || 'Group') + ' ' + (groupIndex + 1); var html = '
'; // Group header html += '
'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += '
'; // Group body (collapsible content) html += '
'; // Include section html += '
'; html += '
'; html += '
'; html += ''; html += ' 0'; html += ''; html += '
'; var noItemsText = trans.no_items_selected || 'No items selected - use search below'; html += ''; html += '
'; html += '
'; // Excludes section (collapsed by default) html += '
'; html += ''; html += '
'; // Group-level modifiers (limit & sort) html += '
'; html += ''; html += '' + (trans.limit || 'Limit') + ''; html += ''; html += ''; html += ''; html += '' + (trans.sort || 'Sort') + ''; html += ''; html += ''; html += ''; html += ''; html += ' '; html += ''; html += '
'; html += '
'; // Close group-body html += '
'; // 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 = '
'; emptyHtml += '' + emptyText + ''; emptyHtml += '
'; $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 = '
'; emptyHtml += '' + emptyText + ''; emptyHtml += '
'; $container.html(emptyHtml); self.updateBlockStatus($block); }); // Update serialized data this.serializeAllBlocks(); // Update tab badges and header count this.updateTabBadges(); // Also update header total count immediately (since all cleared) this.updateHeaderTotalCount(); }, switchToBlock: function(blockType) { // Update tabs this.$wrapper.find('.target-block-tab').removeClass('active'); this.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]').addClass('active'); // Update blocks this.$wrapper.find('.target-block').removeClass('active').hide(); this.$wrapper.find('.target-block[data-block-type="' + blockType + '"]').addClass('active').show(); // Close dropdown if open this.hideDropdown(); }, updateTabBadges: function() { var self = this; // Collect all block types with data and set loading state var blockTypesWithData = []; this.$wrapper.find('.target-block-tab').each(function() { var $tab = $(this); var blockType = $tab.data('blockType'); var $block = self.$wrapper.find('.target-block[data-block-type="' + blockType + '"]'); var groupCount = $block.find('.selection-group').length; // Update or add badge var $badge = $tab.find('.tab-badge'); if (groupCount > 0) { // Show loading state first if ($badge.length) { $badge.addClass('loading').html(''); } else { $tab.append(''); } $tab.addClass('has-data'); blockTypesWithData.push(blockType); } else { $badge.remove(); $tab.removeClass('has-data'); } }); // Update target switch state based on whether any data exists this.updateTargetSwitchState(); // Fetch all counts in a single bulk request if (blockTypesWithData.length > 0) { this.fetchAllCounts(blockTypesWithData); } }, updateTargetSwitchState: function() { var $switch = this.$wrapper.find('.prestashop-switch'); if (!$switch.length) { return; } // Check if any block has data var hasData = false; this.$wrapper.find('.target-block').each(function() { if ($(this).find('.selection-group').length > 0) { hasData = true; return false; // break } }); // Update switch: value="1" is "Everyone/All/None", value="0" is "Specific/Selected" if (hasData) { $switch.find('input[value="0"]').prop('checked', true); } else { $switch.find('input[value="1"]').prop('checked', true); } }, /** * Fetch counts for all block types in a single bulk AJAX request * @param {Array} blockTypes - Array of block type strings to fetch counts for */ fetchAllCounts: function(blockTypes) { var self = this; // Read saved data from hidden input var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]'); var savedData = {}; try { savedData = JSON.parse($hiddenInput.val() || '{}'); } catch (e) { savedData = {}; } // Build conditions object for all requested block types var conditions = {}; blockTypes.forEach(function(blockType) { var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : []; if (groups.length > 0) { conditions[blockType] = { groups: groups }; } }); // If no valid conditions, remove loading spinners if (Object.keys(conditions).length === 0) { blockTypes.forEach(function(blockType) { var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]'); $tab.find('.tab-badge').remove(); $tab.removeClass('has-data'); }); return; } // Single bulk AJAX request for all counts $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewEntitySelectorBulk', trait: 'EntitySelector', conditions: JSON.stringify(conditions) }, success: function(response) { if (response.success && response.counts) { // Update each tab with its count Object.keys(response.counts).forEach(function(blockType) { var count = response.counts[blockType]; var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]'); var $badge = $tab.find('.tab-badge'); if ($badge.length) { $badge.removeClass('loading').html(' ' + count); // Store preview data for later popover use $tab.data('previewData', { count: count, success: true }); } }); // Handle any block types not in response (set count to 0 or remove badge) blockTypes.forEach(function(blockType) { if (!(blockType in response.counts)) { var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]'); $tab.find('.tab-badge').remove(); $tab.removeClass('has-data'); } }); self.updateHeaderTotalCount(); } else { console.error('[EntitySelector] Bulk preview failed:', response.error || 'Unknown error'); // Remove loading spinners on error blockTypes.forEach(function(blockType) { var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]'); $tab.find('.tab-badge').remove(); }); } }, error: function(xhr, status, error) { console.error('[EntitySelector] Bulk AJAX error:', status, error); // Remove loading spinners on error blockTypes.forEach(function(blockType) { var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]'); $tab.find('.tab-badge').remove(); }); } }); }, /** * Fetch count for a single block type (legacy, used for single updates) */ fetchProductCount: function(blockType, $tab) { var self = this; var data = {}; // Read from hidden input (contains full saved data or freshly serialized data) var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]'); var savedData = {}; try { savedData = JSON.parse($hiddenInput.val() || '{}'); } catch (e) { savedData = {}; } // Get groups for the requested block type var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : []; if (groups.length === 0) { $tab.find('.tab-badge').remove(); $tab.removeClass('has-data'); $tab.removeData('previewData'); return; } // Show loading state var $badge = $tab.find('.tab-badge'); if (!$badge.length) { $badge = $(''); $tab.append($badge); } else { $badge.addClass('loading').html(''); } $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(' ' + response.count); // Store preview data for popover $tab.data('previewData', response); // Update header total count self.updateHeaderTotalCount(); } else { console.error('[EntitySelector] Preview failed for', blockType, ':', response.error || 'Unknown error'); $tab.find('.tab-badge').remove(); } }, error: function(xhr, status, error) { console.error('[EntitySelector] AJAX error for', blockType, ':', status, error); $tab.find('.tab-badge').remove(); self.updateHeaderTotalCount(); } }); }, updateHeaderTotalCount: function() { var self = this; var total = 0; // Sum up all tab badge counts this.$wrapper.find('.target-block-tab .tab-badge').each(function() { var $badge = $(this); if (!$badge.hasClass('loading')) { var count = parseInt($badge.text(), 10); if (!isNaN(count)) { total += count; } } }); var $totalBadge = this.$wrapper.find('.trait-total-count'); if (total > 0) { $totalBadge.find('.count-value').text(total); $totalBadge.show(); } else { $totalBadge.hide(); } // Update show-all toggle state this.updateShowAllToggle(); }, updateShowAllToggle: function() { var $toggle = this.$wrapper.find('.trait-show-all-toggle'); if (!$toggle.length) return; var $checkbox = $toggle.find('.show-all-checkbox'); var hasData = this.$wrapper.find('.target-block-tab.has-data').length > 0; // If there's data, uncheck (not showing to all), otherwise check $checkbox.prop('checked', !hasData); }, updateBlockStatus: function($block) { var $status = $block.find('.block-status'); var blockType = $block.data('blockType'); var blockDef = this.config.blocks[blockType] || {}; var trans = this.config.trans || {}; var groups = this.getBlockGroups($block); if (groups.length === 0) { var emptyMeansAll = this.config.emptyMeansAll !== false; if (emptyMeansAll) { $status.text((trans.all || 'All') + ' ' + (blockDef.entity_label_plural || 'items')); } else { $status.text(trans.nothing_selected || 'Nothing selected'); } } else { $status.text(groups.length + ' ' + (groups.length === 1 ? (trans.group || 'group') : (trans.groups || 'groups'))); } }, getEmptyStateText: function($block) { var blockType = $block.data('blockType'); var blockMode = $block.data('mode') || 'multi'; var blockDef = this.config.blocks[blockType] || {}; var trans = this.config.trans || {}; var emptyMeansAll = this.config.emptyMeansAll !== false; if (blockMode === 'single') { return trans.no_item_selected || 'No item selected'; } if (emptyMeansAll) { return (trans.all || 'All') + ' ' + (blockDef.entity_label_plural || 'items') + ' ' + (trans.included || 'included'); } return trans.nothing_selected || 'Nothing selected'; }, serializeGroup: function($group, blockType) { var self = this; // Include var includeMethod = $group.find('.include-method-select').val() || 'all'; var $includePicker = $group.find('.include-picker'); var includeValues = this.getPickerValues($includePicker); // Excludes (multiple rows) var excludes = []; var $excludesSection = $group.find('.group-excludes.has-excludes'); if ($excludesSection.length) { $group.find('.exclude-row').each(function() { var $row = $(this); var excludeMethod = $row.find('.exclude-method-select').val() || null; var $excludePicker = $row.find('.exclude-picker'); var excludeValues = self.getPickerValues($excludePicker); if (excludeMethod && excludeValues && (Array.isArray(excludeValues) ? excludeValues.length > 0 : true)) { excludes.push({ method: excludeMethod, values: excludeValues }); } }); } var groupData = { include: { method: includeMethod, values: includeValues } }; if (excludes.length > 0) { groupData.excludes = excludes; } // Add modifiers if present var modifiers = this.getGroupModifiers($group); if (modifiers.limit || modifiers.sort_by) { groupData.modifiers = modifiers; } return groupData; }, serializeAllBlocks: function($changedRow) { var self = this; var data = {}; this.$wrapper.find('.target-block').each(function() { var $block = $(this); var blockType = $block.data('blockType'); var groups = self.getBlockGroups($block); // Groups now contain their own modifiers, no block-level modifiers if (groups.length > 0) { data[blockType] = { groups: groups }; } self.updateBlockStatus($block); }); // Update hidden input first var $input = this.$wrapper.find('input[name="' + this.config.name + '"]'); $input.val(JSON.stringify(data)); // Then update tab badges (reads from hidden input) this.updateTabBadges(); // Debounced update of condition count - only for changed row if specified if (this.countUpdateTimeout) { clearTimeout(this.countUpdateTimeout); } this.countUpdateTimeout = setTimeout(function() { if ($changedRow && $changedRow.length) { // Update the specific row that changed self.updateConditionCount($changedRow); // Also update the group total count (include - excludes) var $group = $changedRow.closest('.selection-group'); if ($group.length) { self.updateGroupTotalCount($group); } } else { // Fallback: update all counts (initial load, structure changes) self.updateAllConditionCounts(); } }, 500); }, getBlockGroups: function($block) { var self = this; var groups = []; $block.find('.selection-group').each(function() { var $group = $(this); // Include var includeMethod = $group.find('.include-method-select').val() || 'all'; var $includePicker = $group.find('.include-picker'); var includeValues = self.getPickerValues($includePicker); // Skip groups with invalid include conditions (e.g., "specific products" with none selected) if (!self.isConditionValid(includeMethod, includeValues, $includePicker)) { return true; // continue to next group } // Excludes (multiple rows) - only include valid ones var excludes = []; var $excludesSection = $group.find('.group-excludes.has-excludes'); if ($excludesSection.length) { $group.find('.exclude-row').each(function() { var $row = $(this); var excludeMethod = $row.find('.exclude-method-select').val() || null; var $excludePicker = $row.find('.exclude-picker'); var excludeValues = self.getPickerValues($excludePicker); // Only include valid exclude conditions if (excludeMethod && self.isConditionValid(excludeMethod, excludeValues, $excludePicker)) { excludes.push({ method: excludeMethod, values: excludeValues }); } }); } var groupData = { include: { method: includeMethod, values: includeValues } }; // Group name (optional, for organizational purposes) var groupName = $.trim($group.attr('data-group-name') || ''); if (groupName) { groupData.name = groupName; } if (excludes.length > 0) { groupData.excludes = excludes; } // Group-level modifiers var modifiers = self.getGroupModifiers($group); if (modifiers.limit || modifiers.sort_by) { groupData.modifiers = modifiers; } groups.push(groupData); }); return groups; }, getGroupModifiers: function($group) { var limit = $group.find('.group-modifier-limit').val(); var sortBy = $group.find('.group-modifier-sort').val() || 'sales'; var $sortDirBtn = $group.find('.group-modifiers .btn-sort-dir'); var sortDir = $sortDirBtn.data('dir') || 'DESC'; return { limit: limit ? parseInt(limit, 10) : null, sort_by: sortBy || null, sort_dir: sortDir || 'DESC' }; }, getPickerValues: function($picker) { var valueType = $picker.attr('data-value-type') || 'entity_search'; var values = []; switch (valueType) { case 'entity_search': $picker.find('.entity-chip').each(function() { var id = $(this).data('id'); values.push(isNaN(id) ? id : Number(id)); }); break; case 'pattern': values = this.getPatternTags($picker); // Also include draft pattern if it has content (not yet added as tag) var $draftInput = $picker.find('.draft-tag .pattern-input'); var draftPattern = $.trim($draftInput.val()); if (draftPattern) { var draftCaseSensitive = $draftInput.closest('.draft-tag').attr('data-case-sensitive') === '1'; values.push({ pattern: draftPattern, caseSensitive: draftCaseSensitive }); } break; case 'numeric_range': var min = $picker.find('.range-min-input').val(); var max = $picker.find('.range-max-input').val(); if (min !== '' || max !== '') { values = { min: min !== '' ? parseFloat(min) : null, max: max !== '' ? parseFloat(max) : null }; } break; case 'date_range': var from = $picker.find('.date-from-input').val(); var to = $picker.find('.date-to-input').val(); if (from || to) { values = { from: from || null, to: to || null }; } break; case 'select': var selectVal = $picker.find('.select-value-input').val(); if (selectVal) { values = [selectVal]; } break; case 'boolean': values = [true]; break; case 'multi_numeric_range': var ranges = []; $picker.find('.range-chip').each(function() { var $chip = $(this); var minVal = $chip.data('min'); var maxVal = $chip.data('max'); ranges.push({ min: minVal !== '' && minVal !== undefined ? parseFloat(minVal) : null, max: maxVal !== '' && maxVal !== undefined ? parseFloat(maxVal) : null }); }); if (ranges.length > 0) { values = ranges; } break; case 'multi_select_tiles': $picker.find('.tile-option.selected').each(function() { values.push($(this).data('value')); }); break; case 'combination_attributes': // Returns object: { mode: 'products'|'combinations', attributes: { groupId: [valueId1, valueId2], ... } } var combAttrs = {}; $picker.find('.comb-attr-value.selected').each(function() { var groupId = $(this).data('groupId').toString(); var valueId = $(this).data('valueId'); if (!combAttrs[groupId]) { combAttrs[groupId] = []; } combAttrs[groupId].push(valueId); }); if (Object.keys(combAttrs).length > 0) { // Get mode: from radio if toggle exists, otherwise from config var $combPicker = $picker.find('.combination-attributes-picker'); var configMode = $combPicker.data('combinationMode') || this.config.combinationMode || 'products'; var combMode; if (configMode === 'toggle') { combMode = $picker.find('.comb-mode-radio:checked').val() || 'products'; } else { combMode = configMode; } values = { mode: combMode, attributes: combAttrs }; } break; } return values; }, isConditionValid: function(method, values, $picker) { // 'all' method never needs values if (method === 'all') { return true; } // Boolean methods are always valid (the value is implicit true) var valueType = $picker.attr('data-value-type') || 'entity_search'; if (valueType === 'boolean') { return true; } // For other methods, check if values are meaningful if (Array.isArray(values)) { return values.length > 0; } // For object values (ranges, combination_attributes), check if meaningful if (typeof values === 'object' && values !== null) { // Special handling for combination_attributes: { mode, attributes } if (valueType === 'combination_attributes' && values.attributes !== undefined) { return Object.keys(values.attributes).length > 0; } // For ranges and other objects, check if at least one bound is set return Object.keys(values).some(function(key) { return values[key] !== null && values[key] !== ''; }); } return false; }, /** * Update all condition counts using a single bulk AJAX request */ updateAllConditionCounts: function() { var self = this; var conditions = {}; var conditionElements = {}; var conditionIndex = 0; // Collect all conditions from all active groups this.$wrapper.find('.target-block.active .selection-group').each(function() { var $group = $(this); var $block = $group.closest('.target-block'); var blockType = $block.data('blockType') || 'products'; // Process include row var $include = $group.find('.group-include'); if ($include.length) { var includeData = self.getConditionData($include, blockType); if (includeData) { var id = 'c' + conditionIndex++; conditions[id] = includeData.condition; conditionElements[id] = includeData.$countEl; } } // Process exclude rows $group.find('.exclude-row').each(function() { var excludeData = self.getConditionData($(this), blockType); if (excludeData) { var id = 'c' + conditionIndex++; conditions[id] = excludeData.condition; conditionElements[id] = excludeData.$countEl; } }); }); // If no conditions, nothing to do if (Object.keys(conditions).length === 0) { return; } // Make single bulk AJAX request $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'countConditionMatchesBulk', trait: 'EntitySelector', conditions: JSON.stringify(conditions) }, success: function(response) { if (response && response.success && response.counts) { // Update each count element with its result Object.keys(response.counts).forEach(function(id) { var count = response.counts[id] || 0; var $countEl = conditionElements[id]; if ($countEl && $countEl.length) { $countEl.removeClass('no-matches clickable'); if (count === 0) { $countEl.find('.preview-count').text(count); $countEl.addClass('no-matches').show(); } else { $countEl.find('.preview-count').text(count); $countEl.addClass('clickable').show(); } } }); } // Note: Group totals are updated on-demand when user interacts, not on initial load }, error: function() { // Hide all count elements on error Object.keys(conditionElements).forEach(function(id) { var $countEl = conditionElements[id]; if ($countEl && $countEl.length) { $countEl.hide().removeClass('clickable'); } }); } }); }, /** * Extract condition data from a row for bulk counting */ getConditionData: function($row, blockType) { var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row .condition-match-count').first(); if (!$countEl.length) return null; var isExclude = $row.hasClass('exclude-row'); var $methodSelect = isExclude ? $row.find('.exclude-method-select') : $row.find('.include-method-select'); var method = $methodSelect.val(); if (!method) { $countEl.hide(); return null; } var $picker = isExclude ? $row.find('.exclude-picker') : $row.find('.include-picker'); var valueType = $picker.data('valueType') || 'none'; var values = this.getPickerValues($picker, valueType); // Don't count if no values (except for boolean/all methods) var hasNoValues = !values || (Array.isArray(values) && values.length === 0) || (typeof values === 'object' && !Array.isArray(values) && ( (valueType === 'combination_attributes' && values.attributes !== undefined && Object.keys(values.attributes).length === 0) || (valueType !== 'combination_attributes' && Object.keys(values).length === 0) )); if (valueType !== 'none' && valueType !== 'boolean' && hasNoValues) { $countEl.hide(); return null; } // Show loading spinner $countEl.find('.preview-count').html(''); $countEl.removeClass('clickable no-matches').show(); // Store condition data on badge for popover $countEl.data('conditionData', { method: method, values: values, blockType: blockType, isExclude: isExclude }); return { condition: { method: method, values: values, block_type: blockType }, $countEl: $countEl }; }, updateGroupCounts: function($group) { var self = this; var $block = $group.closest('.target-block'); var blockType = $block.data('blockType') || 'products'; // Update include count var $include = $group.find('.group-include'); if ($include.length) { this.updateConditionCount($include, blockType); } // Update each exclude row count $group.find('.exclude-row').each(function() { self.updateConditionCount($(this), blockType); }); // Update group total count (include - excludes) this.updateGroupTotalCount($group); }, /** * Update a single condition count (used for individual updates after user changes) */ updateConditionCount: function($row, blockType) { var self = this; var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row .condition-match-count').first(); if (!$countEl.length) return; var isExclude = $row.hasClass('exclude-row'); var $methodSelect = isExclude ? $row.find('.exclude-method-select') : $row.find('.include-method-select'); var method = $methodSelect.val(); if (!method) { $countEl.hide(); return; } var $picker = isExclude ? $row.find('.exclude-picker') : $row.find('.include-picker'); var valueType = $picker.data('valueType') || 'none'; var values = this.getPickerValues($picker, valueType); var hasNoValues = !values || (Array.isArray(values) && values.length === 0) || (typeof values === 'object' && !Array.isArray(values) && ( (valueType === 'combination_attributes' && values.attributes !== undefined && Object.keys(values.attributes).length === 0) || (valueType !== 'combination_attributes' && Object.keys(values).length === 0) )); if (valueType !== 'none' && valueType !== 'boolean' && hasNoValues) { $countEl.hide(); return; } if (!blockType) { var $block = $row.closest('.target-block'); blockType = $block.data('blockType') || 'products'; } $countEl.find('.preview-count').html(''); $countEl.removeClass('clickable no-matches').show(); $countEl.data('conditionData', { method: method, values: values, blockType: blockType, isExclude: isExclude }); $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'countConditionMatches', trait: 'EntitySelector', method: method, values: JSON.stringify(values), block_type: blockType }, success: function(response) { if (response && response.success) { var count = response.count || 0; $countEl.removeClass('no-matches clickable'); if (count === 0) { $countEl.find('.preview-count').text(count); $countEl.addClass('no-matches').show(); } else { $countEl.find('.preview-count').text(count); $countEl.addClass('clickable').show(); } } else { $countEl.hide().removeClass('clickable'); } }, error: function() { $countEl.hide().removeClass('clickable'); } }); }, updateGroupTotalCount: function($group) { var self = this; var $block = $group.closest('.target-block'); var blockType = $block.data('blockType') || 'products'; var $badge = $group.find('.group-header .group-count-badge'); var $limitInput = $group.find('.group-modifier-limit'); // Build group data for AJAX var groupData = this.serializeGroup($group, blockType); // Check if include has valid data if (!groupData.include || !groupData.include.method) { $badge.hide(); $limitInput.attr('placeholder', '–'); return; } // Show loading $badge.html('').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 = ' ' + finalCount; if (excludeCount > 0) { badgeHtml += ' (-' + excludeCount + ')'; } $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 = '
'; html += ' ' + (trans.except || 'EXCEPT') + ''; html += '
'; html += '
'; html += this.buildExcludeRowHtml($block, 0); html += '
'; html += ''; $excludesDiv.addClass('has-excludes').html(html); // Enhance the first exclude method select with styled dropdown var $firstRow = $excludesDiv.find('.exclude-row[data-exclude-index="0"]'); var $firstSelect = $firstRow.find('.exclude-method-select'); this.enhanceMethodSelect($firstSelect); // Update method info placeholder for initial selection var blockType = $block.data('blockType'); var initialMethod = $firstSelect.val(); this.updateMethodInfoPlaceholder($firstRow.find('.method-selector-wrapper'), initialMethod, blockType); this.updateMethodSelectorLock($group, true); this.serializeAllBlocks(); }, addExcludeRow: function($group, $block) { var $container = $group.find('.exclude-rows-container'); // Get next exclude index var maxIndex = -1; $container.find('.exclude-row').each(function() { var idx = parseInt($(this).data('excludeIndex'), 10); if (idx > maxIndex) maxIndex = idx; }); var excludeIndex = maxIndex + 1; var html = this.buildExcludeRowHtml($block, excludeIndex); $container.append(html); // Enhance the exclude method select with styled dropdown var $newRow = $container.find('.exclude-row[data-exclude-index="' + excludeIndex + '"]'); var $newSelect = $newRow.find('.exclude-method-select'); this.enhanceMethodSelect($newSelect); // Update method info placeholder for initial selection var blockType = $block.data('blockType'); var initialMethod = $newSelect.val(); this.updateMethodInfoPlaceholder($newRow.find('.method-selector-wrapper'), initialMethod, blockType); this.serializeAllBlocks(); }, buildExcludeRowHtml: function($block, excludeIndex) { var blockType = $block.data('blockType'); var blockDef = this.config.blocks[blockType] || {}; var methods = blockDef.selection_methods || {}; var trans = this.config.trans || {}; // Build exclude method options with optgroups (no "all") var excludeMethodOptions = this.buildMethodOptions(methods, true); // Find first non-all method for default search entity var firstSearchEntity = blockType; var firstValueType = 'entity_search'; $.each(methods, function(methodKey, methodDef) { if (methodKey === 'all') return true; firstSearchEntity = methodDef.search_entity || blockType; firstValueType = methodDef.value_type || 'entity_search'; return false; // break }); var html = '
'; // Header row with method select wrapped in method-selector-wrapper (same as include) html += '
'; html += '
'; html += ''; html += ' 0'; html += ''; html += '
'; html += ''; html += '
'; // Value picker based on first method's value type html += this.buildValuePickerHtml('exclude', firstValueType, firstSearchEntity, methods); html += '
'; return html; }, removeExcludeRow: function($excludeRow, $group, $block) { var $container = $group.find('.exclude-rows-container'); var trans = this.config.trans || {}; $excludeRow.remove(); // Check if there are remaining exclude rows var remainingRows = $container.find('.exclude-row').length; if (remainingRows === 0) { // Remove entire excludes section and show "Add exceptions" button var $excludesDiv = $group.find('.group-excludes'); $excludesDiv.removeClass('has-excludes').html( '' ); // 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 += ''; $.each(groupMethods, function(methodKey, methodDef) { html += self.buildMethodOption(methodKey, methodDef); }); html += ''; }); return html; }, buildMethodOption: function(methodKey, methodDef) { var html = '