Features: - Tree view mode for categories with expand/collapse - Product count badges with clickable preview popover - Select parent with all children button - Client-side tree filtering (refine search) - Keyboard shortcuts: Ctrl+A (select all), Ctrl+D (clear) - View mode switching between tree/list/columns - Tree view as default for categories, respects user preference Backend: - Add previewCategoryProducts and previewCategoryPages AJAX handlers - Support pagination and filtering in category previews Styling: - Consistent count-badge styling across tree and other views - Loading and popover-open states for count badges Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
9377 lines
407 KiB
JavaScript
9377 lines
407 KiB
JavaScript
/**
|
||
* 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, '"')
|
||
.replace(/'/g, ''');
|
||
},
|
||
|
||
escapeAttr: function(str) {
|
||
if (str === null || str === undefined) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.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 = $('<div>', {
|
||
class: 'trait-validation-error',
|
||
html: '<i class="icon-warning"></i> ' + message
|
||
});
|
||
this.$wrapper.find('.condition-trait-header').after($error);
|
||
$('html, body').animate({ scrollTop: this.$wrapper.offset().top - 100 }, 300);
|
||
if (!this.$wrapper.find('.condition-trait-body').is(':visible')) {
|
||
this.$wrapper.find('.condition-trait-body').slideDown(200);
|
||
this.$wrapper.removeClass('collapsed');
|
||
}
|
||
},
|
||
|
||
clearValidationError: function() {
|
||
this.$wrapper.removeClass('has-validation-error');
|
||
this.$wrapper.find('.trait-validation-error').remove();
|
||
},
|
||
|
||
getBlockMode: function(blockType) {
|
||
var blockDef = this.config.blocks[blockType];
|
||
return (blockDef && blockDef.mode) ? blockDef.mode : 'multi';
|
||
},
|
||
|
||
isBlockSingleMode: function(blockType) {
|
||
return this.getBlockMode(blockType) === 'single';
|
||
},
|
||
|
||
getCurrentSingleSelection: function() {
|
||
if ((this.config.mode || 'multi') !== 'single') return null;
|
||
var $chip = this.$wrapper.find('.entity-chips .entity-chip').first();
|
||
if ($chip.length) {
|
||
var $block = $chip.closest('.target-block');
|
||
return {
|
||
name: $chip.find('.chip-name').text() || $chip.data('id'),
|
||
entityType: $block.data('block-type') || 'item'
|
||
};
|
||
}
|
||
return null;
|
||
},
|
||
|
||
/**
|
||
* Check if entity type supports tree browsing
|
||
*/
|
||
supportsTreeBrowsing: function(entityType) {
|
||
return entityType === 'categories' || entityType === 'cms_categories';
|
||
}
|
||
};
|
||
|
||
})(jQuery);
|
||
|
||
/**
|
||
* Entity Selector - Events Module
|
||
* All event binding and handlers
|
||
* @partial _events.js
|
||
*
|
||
* Contains event handlers for:
|
||
* - Tab switching
|
||
* - Block/group collapse toggle
|
||
* - Dropdown open/close
|
||
* - Search input handling
|
||
* - Item selection/deselection
|
||
* - Group add/remove
|
||
* - Exclude row add/remove
|
||
* - Method select changes
|
||
* - Filter panel toggles
|
||
* - Keyboard shortcuts (Ctrl+A, Ctrl+D, Esc, Enter)
|
||
* - Load more pagination
|
||
* - Sort controls
|
||
* - View mode switching
|
||
* - Tree view events
|
||
* - Preview badge clicks
|
||
* - Pattern tag interactions
|
||
* - Combination picker events
|
||
* - Group modifier events
|
||
*/
|
||
|
||
(function($) {
|
||
'use strict';
|
||
|
||
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
|
||
|
||
window._EntitySelectorMixins.events = {
|
||
|
||
bindEvents: function() {
|
||
var self = this;
|
||
|
||
// Tab switching
|
||
this.$wrapper.on('click', '.target-block-tab', function(e) {
|
||
e.preventDefault();
|
||
var blockType = $(this).data('blockType');
|
||
self.switchToBlock(blockType);
|
||
});
|
||
|
||
// Tab badge click for preview popover (toggle)
|
||
this.$wrapper.on('click', '.target-block-tab .tab-badge', function(e) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
|
||
var $tab = $(this).closest('.target-block-tab');
|
||
var $badge = $(this);
|
||
|
||
if ($badge.hasClass('popover-open')) {
|
||
self.hidePreviewPopover();
|
||
} else {
|
||
self.showPreviewPopover($tab);
|
||
}
|
||
});
|
||
|
||
// Condition count badge click for preview popover
|
||
this.$wrapper.on('click', '.condition-match-count.clickable', function(e) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
|
||
var $badge = $(this);
|
||
|
||
if ($badge.hasClass('popover-open')) {
|
||
self.hidePreviewPopover();
|
||
} else {
|
||
self.showConditionPreviewPopover($badge);
|
||
}
|
||
});
|
||
|
||
// Group count badge click for preview popover
|
||
this.$wrapper.on('click', '.group-count-badge.clickable', function(e) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
|
||
var $badge = $(this);
|
||
|
||
if ($badge.hasClass('popover-open')) {
|
||
self.hidePreviewPopover();
|
||
} else {
|
||
self.showGroupPreviewPopover($badge);
|
||
}
|
||
});
|
||
|
||
// Total count badge click for summary popover
|
||
this.$wrapper.on('click', '.trait-total-count', function(e) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
|
||
var $badge = $(this);
|
||
|
||
if ($badge.hasClass('popover-open')) {
|
||
self.hidePreviewPopover();
|
||
} else {
|
||
self.showTotalPreviewPopover($badge);
|
||
}
|
||
});
|
||
|
||
// Close popover when clicking outside
|
||
$(document).on('click', function(e) {
|
||
if (!$(e.target).closest('.target-preview-popover').length &&
|
||
!$(e.target).closest('.tab-badge').length &&
|
||
!$(e.target).closest('.condition-match-count').length &&
|
||
!$(e.target).closest('.group-count-badge').length &&
|
||
!$(e.target).closest('.group-modifiers').length &&
|
||
!$(e.target).closest('.group-preview-badge').length &&
|
||
!$(e.target).closest('.toggle-count.clickable').length &&
|
||
!$(e.target).closest('.trait-total-count').length) {
|
||
self.hidePreviewPopover();
|
||
}
|
||
});
|
||
|
||
// Block-level collapse toggle (click on header)
|
||
this.$wrapper.on('click', '.condition-trait-header', function(e) {
|
||
if ($(e.target).closest('.target-block-tabs').length ||
|
||
$(e.target).closest('.trait-header-actions').length ||
|
||
$(e.target).closest('.prestashop-switch').length ||
|
||
$(e.target).closest('.trait-total-count').length) {
|
||
return;
|
||
}
|
||
var $body = self.$wrapper.find('.condition-trait-body');
|
||
$body.stop(true, true);
|
||
if ($body.is(':visible')) {
|
||
$body.slideUp(200);
|
||
self.$wrapper.addClass('collapsed');
|
||
} else {
|
||
$body.slideDown(200);
|
||
self.$wrapper.removeClass('collapsed');
|
||
}
|
||
});
|
||
|
||
// Group-level collapse toggle (click on group header or toggle icon)
|
||
this.$wrapper.on('click', '.group-header', function(e) {
|
||
if ($(e.target).closest('.btn-remove-group, .group-name-input').length) {
|
||
return;
|
||
}
|
||
if (self.$wrapper.data('mode') === 'single') {
|
||
return;
|
||
}
|
||
var $group = $(this).closest('.selection-group');
|
||
$group.toggleClass('collapsed');
|
||
});
|
||
|
||
// Toggle all groups (single button that switches between expand/collapse)
|
||
this.$wrapper.on('click', '.trait-header-actions .btn-toggle-groups', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var $btn = $(this);
|
||
var currentState = $btn.attr('data-state') || 'collapsed';
|
||
var trans = self.config.trans || {};
|
||
|
||
if (currentState === 'collapsed') {
|
||
self.$wrapper.find('.selection-group').removeClass('collapsed');
|
||
$btn.attr('data-state', 'expanded');
|
||
$btn.attr('title', trans.collapse_all || 'Collapse all groups');
|
||
$btn.find('i').removeClass('icon-expand').addClass('icon-compress');
|
||
} else {
|
||
self.$wrapper.find('.selection-group').addClass('collapsed');
|
||
$btn.attr('data-state', 'collapsed');
|
||
$btn.attr('title', trans.expand_all || 'Expand all groups');
|
||
$btn.find('i').removeClass('icon-compress').addClass('icon-expand');
|
||
}
|
||
});
|
||
|
||
// Show all toggle change (legacy checkbox)
|
||
this.$wrapper.on('change', '.trait-show-all-toggle .show-all-checkbox', function(e) {
|
||
e.stopPropagation();
|
||
var isChecked = $(this).prop('checked');
|
||
if (isChecked) {
|
||
self.clearAllConditions();
|
||
}
|
||
});
|
||
|
||
// Target switch change (PrestaShop native switch)
|
||
this.$wrapper.on('change', '.target-switch-toggle', function(e) {
|
||
e.stopPropagation();
|
||
var value = $(this).val();
|
||
if (value === '1') {
|
||
self.clearAllConditions();
|
||
self.$wrapper.find('.condition-trait-body').slideUp(200);
|
||
self.$wrapper.addClass('collapsed');
|
||
} else {
|
||
self.$wrapper.find('.condition-trait-body').slideDown(200);
|
||
self.$wrapper.removeClass('collapsed');
|
||
}
|
||
});
|
||
|
||
// Add group
|
||
this.$wrapper.on('click', '.btn-add-group', function(e) {
|
||
e.preventDefault();
|
||
var $block = $(this).closest('.target-block');
|
||
var blockType = $block.data('blockType');
|
||
self.addGroup($block, blockType);
|
||
});
|
||
|
||
// Remove group
|
||
this.$wrapper.on('click', '.btn-remove-group', function(e) {
|
||
e.preventDefault();
|
||
var $group = $(this).closest('.selection-group');
|
||
var $block = $(this).closest('.target-block');
|
||
self.removeGroup($group, $block);
|
||
});
|
||
|
||
// Group name input - stop propagation to prevent collapse
|
||
this.$wrapper.on('click focus', '.group-name-input', function(e) {
|
||
e.stopPropagation();
|
||
});
|
||
|
||
// Group name change
|
||
this.$wrapper.on('change blur', '.group-name-input', function() {
|
||
var $input = $(this);
|
||
var $group = $input.closest('.selection-group');
|
||
var name = $.trim($input.val());
|
||
$group.attr('data-group-name', name);
|
||
self.serializeAllBlocks();
|
||
});
|
||
|
||
// Add exceptions (first exclude row)
|
||
this.$wrapper.on('click', '.btn-add-exclude', function(e) {
|
||
e.preventDefault();
|
||
var $group = $(this).closest('.selection-group');
|
||
var $block = $(this).closest('.target-block');
|
||
self.addFirstExcludeRow($group, $block);
|
||
});
|
||
|
||
// Add another exclude row
|
||
this.$wrapper.on('click', '.btn-add-another-exclude', function(e) {
|
||
e.preventDefault();
|
||
var $group = $(this).closest('.selection-group');
|
||
var $block = $(this).closest('.target-block');
|
||
self.addExcludeRow($group, $block);
|
||
});
|
||
|
||
// Remove individual exclude row
|
||
this.$wrapper.on('click', '.btn-remove-exclude-row', function(e) {
|
||
e.preventDefault();
|
||
var $excludeRow = $(this).closest('.exclude-row');
|
||
var $group = $(this).closest('.selection-group');
|
||
var $block = $(this).closest('.target-block');
|
||
self.removeExcludeRow($excludeRow, $group, $block);
|
||
});
|
||
|
||
// Include method change
|
||
this.$wrapper.on('change', '.include-method-select', function() {
|
||
self.hideDropdown();
|
||
|
||
var $group = $(this).closest('.selection-group');
|
||
var $block = $(this).closest('.target-block');
|
||
var $row = $group.find('.group-include');
|
||
var blockType = $block.data('blockType');
|
||
var blockDef = self.config.blocks[blockType] || {};
|
||
var methods = blockDef.selection_methods || {};
|
||
|
||
var $option = $(this).find('option:selected');
|
||
var valueType = $option.data('valueType') || 'none';
|
||
var searchEntity = $option.data('searchEntity') || '';
|
||
var methodOptions = $option.data('options') || null;
|
||
|
||
var $oldPicker = $group.find('.include-picker');
|
||
var newPickerHtml = self.buildValuePickerHtml('include', valueType, searchEntity, methods);
|
||
$oldPicker.replaceWith(newPickerHtml);
|
||
|
||
if (valueType === 'select' && methodOptions) {
|
||
var $newPicker = $group.find('.include-picker');
|
||
var $select = $newPicker.find('.select-value-input');
|
||
$select.empty();
|
||
$.each(methodOptions, function(key, label) {
|
||
$select.append('<option value="' + self.escapeAttr(key) + '">' + self.escapeHtml(label) + '</option>');
|
||
});
|
||
}
|
||
|
||
if (valueType === 'multi_select_tiles' && methodOptions) {
|
||
var $newPicker = $group.find('.include-picker');
|
||
var isExclusive = $option.data('exclusive') === true;
|
||
self.populateTiles($newPicker, methodOptions, isExclusive);
|
||
}
|
||
|
||
if (valueType === 'multi_numeric_range') {
|
||
var $newPicker = $group.find('.include-picker');
|
||
var step = $option.data('step');
|
||
var min = $option.data('min');
|
||
self.applyRangeInputConstraints($newPicker, step, min);
|
||
}
|
||
|
||
if (valueType === 'combination_attributes') {
|
||
var $newPicker = $group.find('.include-picker');
|
||
self.loadCombinationAttributeGroups($newPicker);
|
||
}
|
||
|
||
var selectedMethod = $(this).val();
|
||
self.updateMethodInfoPlaceholder($group.find('.method-selector-wrapper'), selectedMethod, blockType);
|
||
|
||
self.updateBlockStatus($block);
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Exclude method change (within an exclude row)
|
||
this.$wrapper.on('change', '.exclude-method-select', function() {
|
||
self.hideDropdown();
|
||
|
||
var $excludeRow = $(this).closest('.exclude-row');
|
||
var $group = $(this).closest('.selection-group');
|
||
var $block = $(this).closest('.target-block');
|
||
var blockType = $block.data('blockType');
|
||
var blockDef = self.config.blocks[blockType] || {};
|
||
var methods = blockDef.selection_methods || {};
|
||
|
||
var $option = $(this).find('option:selected');
|
||
var valueType = $option.data('valueType') || 'entity_search';
|
||
var searchEntity = $option.data('searchEntity') || blockType;
|
||
var methodOptions = $option.data('options') || null;
|
||
|
||
var $oldPicker = $excludeRow.find('.exclude-picker');
|
||
var newPickerHtml = self.buildValuePickerHtml('exclude', valueType, searchEntity, methods);
|
||
$oldPicker.replaceWith(newPickerHtml);
|
||
|
||
if (valueType === 'select' && methodOptions) {
|
||
var $newPicker = $excludeRow.find('.exclude-picker');
|
||
var $select = $newPicker.find('.select-value-input');
|
||
$select.empty();
|
||
$.each(methodOptions, function(key, label) {
|
||
$select.append('<option value="' + self.escapeAttr(key) + '">' + self.escapeHtml(label) + '</option>');
|
||
});
|
||
}
|
||
|
||
if (valueType === 'multi_select_tiles' && methodOptions) {
|
||
var $newPicker = $excludeRow.find('.exclude-picker');
|
||
var isExclusive = $option.data('exclusive') === true;
|
||
self.populateTiles($newPicker, methodOptions, isExclusive);
|
||
}
|
||
|
||
if (valueType === 'multi_numeric_range') {
|
||
var $newPicker = $excludeRow.find('.exclude-picker');
|
||
var step = $option.data('step');
|
||
var min = $option.data('min');
|
||
self.applyRangeInputConstraints($newPicker, step, min);
|
||
}
|
||
|
||
if (valueType === 'combination_attributes') {
|
||
var $newPicker = $excludeRow.find('.exclude-picker');
|
||
self.loadCombinationAttributeGroups($newPicker);
|
||
}
|
||
|
||
var selectedMethod = $(this).val();
|
||
self.updateMethodInfoPlaceholder($excludeRow.find('.exclude-header-row'), selectedMethod, blockType);
|
||
|
||
self.serializeAllBlocks($excludeRow);
|
||
});
|
||
|
||
// Handle pattern input Enter key - adds pattern as tag
|
||
this.$wrapper.on('keydown', '.pattern-input', function(e) {
|
||
if (e.keyCode === 13) {
|
||
e.preventDefault();
|
||
var $btn = $(this).closest('.draft-tag').find('.btn-add-pattern');
|
||
$btn.click();
|
||
}
|
||
});
|
||
|
||
// Handle add pattern button click (in draft tag)
|
||
this.$wrapper.on('click', '.draft-tag .btn-add-pattern', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var $draftTag = $(this).closest('.draft-tag');
|
||
var $picker = $draftTag.closest('.value-picker');
|
||
var $row = $draftTag.closest('.group-include, .exclude-row');
|
||
var $input = $draftTag.find('.pattern-input');
|
||
var pattern = $.trim($input.val());
|
||
|
||
if (pattern) {
|
||
var caseSensitive = $draftTag.attr('data-case-sensitive') === '1';
|
||
self.addPatternTag($picker, pattern, caseSensitive);
|
||
|
||
$input.val('').focus();
|
||
$draftTag.find('.pattern-match-count').removeClass('count-found count-zero').hide();
|
||
$draftTag.find('.pattern-match-count .count-value').text('');
|
||
|
||
self.serializeAllBlocks($row);
|
||
}
|
||
});
|
||
|
||
// Handle pattern input live typing - update match count in draft tag
|
||
this.$wrapper.on('input', '.pattern-input', function() {
|
||
var $input = $(this);
|
||
var $draftTag = $input.closest('.draft-tag');
|
||
if (!$draftTag.length) return;
|
||
|
||
var pattern = $.trim($input.val());
|
||
|
||
if ($input.data('countTimeout')) {
|
||
clearTimeout($input.data('countTimeout'));
|
||
}
|
||
|
||
var $matchCount = $draftTag.find('.pattern-match-count');
|
||
|
||
if (!pattern) {
|
||
$matchCount.removeClass('count-found count-zero').hide();
|
||
$matchCount.find('.count-value').text('');
|
||
var $group = $draftTag.closest('.selection-group');
|
||
if ($group.length) {
|
||
self.updateGroupTotalCount($group);
|
||
}
|
||
return;
|
||
}
|
||
|
||
var timeout = setTimeout(function() {
|
||
var caseSensitive = $draftTag.attr('data-case-sensitive') === '1';
|
||
self.updateDraftTagCount($draftTag, pattern, caseSensitive);
|
||
}, 300);
|
||
$input.data('countTimeout', timeout);
|
||
});
|
||
|
||
// Handle pattern tag remove
|
||
this.$wrapper.on('click', '.pattern-tag .btn-remove-pattern', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var $row = $(this).closest('.group-include, .exclude-row');
|
||
$(this).closest('.pattern-tag').remove();
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Handle pattern tag case-sensitivity toggle
|
||
this.$wrapper.on('click', '.pattern-tag .btn-toggle-case', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var $tag = $(this).closest('.pattern-tag');
|
||
var $btn = $(this);
|
||
var trans = self.config.trans || {};
|
||
var isDraftTag = $tag.hasClass('draft-tag');
|
||
|
||
var isCaseSensitive = $tag.data('caseSensitive') === 1 || $tag.data('caseSensitive') === '1' || $tag.attr('data-case-sensitive') === '1';
|
||
var newCaseSensitive = !isCaseSensitive;
|
||
|
||
$tag.data('caseSensitive', newCaseSensitive ? 1 : 0);
|
||
$tag.attr('data-case-sensitive', newCaseSensitive ? '1' : '0');
|
||
$tag.toggleClass('case-sensitive', newCaseSensitive);
|
||
|
||
$btn.find('.case-icon').text(newCaseSensitive ? 'Aa' : 'aa');
|
||
var caseTitle = newCaseSensitive
|
||
? (trans.case_sensitive || 'Case sensitive - click to toggle')
|
||
: (trans.case_insensitive || 'Case insensitive - click to toggle');
|
||
$btn.attr('title', caseTitle);
|
||
|
||
if (isDraftTag) {
|
||
var pattern = $.trim($tag.find('.pattern-input').val());
|
||
if (pattern) {
|
||
self.updateDraftTagCount($tag, pattern, newCaseSensitive);
|
||
}
|
||
} else {
|
||
var $row = $tag.closest('.group-include, .exclude-row');
|
||
self.serializeAllBlocks($row);
|
||
}
|
||
});
|
||
|
||
// Handle pattern match count click - show preview modal
|
||
this.$wrapper.on('click', '.pattern-match-count', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
var $matchCount = $(this);
|
||
var count = $matchCount.data('count');
|
||
var pattern = $matchCount.data('pattern');
|
||
var entityType = $matchCount.data('entityType');
|
||
var caseSensitive = $matchCount.data('caseSensitive');
|
||
|
||
if (!count || count <= 0 || !pattern) {
|
||
return;
|
||
}
|
||
|
||
self.showPatternPreviewModal(pattern, entityType, caseSensitive, count);
|
||
});
|
||
|
||
// Handle pattern tag edit (click on tag text)
|
||
this.$wrapper.on('click', '.pattern-tag .pattern-tag-text', function(e) {
|
||
e.preventDefault();
|
||
var $tag = $(this).closest('.pattern-tag');
|
||
if ($tag.hasClass('editing')) return;
|
||
|
||
var currentPattern = $tag.data('pattern');
|
||
|
||
var $editInput = $('<input type="text" class="pattern-tag-edit">').val(currentPattern);
|
||
var $saveBtn = $('<button type="button" class="btn-pattern-save" title="Save"><i class="icon-check"></i></button>');
|
||
var $cancelBtn = $('<button type="button" class="btn-pattern-cancel" title="Cancel"><i class="icon-times"></i></button>');
|
||
var $editActions = $('<span class="pattern-edit-actions"></span>').append($saveBtn, $cancelBtn);
|
||
|
||
$tag.addClass('editing').find('.pattern-tag-text').hide();
|
||
$tag.find('.btn-remove-pattern').hide();
|
||
$tag.prepend($editActions).prepend($editInput);
|
||
$editInput.focus().select();
|
||
|
||
$editInput.on('keydown', function(ev) {
|
||
if (ev.keyCode === 13) {
|
||
ev.preventDefault();
|
||
$saveBtn.click();
|
||
} else if (ev.keyCode === 27) {
|
||
ev.preventDefault();
|
||
$cancelBtn.click();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Pattern edit - Save button
|
||
this.$wrapper.on('click', '.pattern-tag .btn-pattern-save', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var $tag = $(this).closest('.pattern-tag');
|
||
var $editInput = $tag.find('.pattern-tag-edit');
|
||
var currentPattern = $tag.data('pattern');
|
||
var newPattern = $.trim($editInput.val());
|
||
|
||
if (newPattern && newPattern !== currentPattern) {
|
||
$tag.data('pattern', newPattern);
|
||
$tag.find('.pattern-tag-text').text(newPattern);
|
||
}
|
||
$editInput.remove();
|
||
$tag.find('.pattern-edit-actions').remove();
|
||
$tag.removeClass('editing').find('.pattern-tag-text, .btn-remove-pattern').show();
|
||
var $row = $tag.closest('.group-include, .exclude-row');
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Pattern edit - Cancel button
|
||
this.$wrapper.on('click', '.pattern-tag .btn-pattern-cancel', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var $tag = $(this).closest('.pattern-tag');
|
||
$tag.find('.pattern-tag-edit').remove();
|
||
$tag.find('.pattern-edit-actions').remove();
|
||
$tag.removeClass('editing').find('.pattern-tag-text, .btn-remove-pattern').show();
|
||
});
|
||
|
||
// Handle mpr-info-wrapper tooltip with fixed positioning
|
||
this.$wrapper.on('mouseenter', '.mpr-info-wrapper[data-details]', function() {
|
||
var $wrapper = $(this);
|
||
if ($wrapper.data('tooltip-active')) return;
|
||
|
||
var content = $wrapper.attr('data-details');
|
||
var tooltipClass = $wrapper.attr('data-tooltip-class') || '';
|
||
var $tooltip = $('<div>', { class: 'mpr-tooltip mpr-tooltip-fixed ' + tooltipClass, html: content });
|
||
|
||
$('body').append($tooltip);
|
||
$wrapper.data('tooltip-active', true);
|
||
|
||
var offset = $wrapper.offset();
|
||
var triggerWidth = $wrapper.outerWidth();
|
||
var tooltipWidth = $tooltip.outerWidth();
|
||
var tooltipHeight = $tooltip.outerHeight();
|
||
|
||
var left = offset.left + (triggerWidth / 2) - (tooltipWidth / 2);
|
||
var top = offset.top - tooltipHeight - 10;
|
||
|
||
if (left < 10) left = 10;
|
||
if (left + tooltipWidth > $(window).width() - 10) {
|
||
left = $(window).width() - tooltipWidth - 10;
|
||
}
|
||
|
||
$tooltip.css({
|
||
position: 'fixed',
|
||
left: left + 'px',
|
||
top: (top - $(window).scrollTop()) + 'px'
|
||
});
|
||
|
||
$wrapper.data('tooltip-el', $tooltip);
|
||
});
|
||
|
||
this.$wrapper.on('mouseleave', '.mpr-info-wrapper[data-details]', function() {
|
||
var $wrapper = $(this);
|
||
var $tooltip = $wrapper.data('tooltip-el');
|
||
if ($tooltip) {
|
||
$tooltip.remove();
|
||
}
|
||
$wrapper.data('tooltip-active', false);
|
||
$wrapper.data('tooltip-el', null);
|
||
});
|
||
|
||
// Handle numeric range input changes
|
||
this.$wrapper.on('change', '.range-min-input, .range-max-input', function() {
|
||
var $row = $(this).closest('.group-include, .exclude-row');
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Handle date range input changes
|
||
this.$wrapper.on('change', '.date-from-input, .date-to-input', function() {
|
||
var $row = $(this).closest('.group-include, .exclude-row');
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Handle select value changes
|
||
this.$wrapper.on('change', '.select-value-input', function() {
|
||
var $row = $(this).closest('.group-include, .exclude-row');
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Handle multi-range add button click
|
||
this.$wrapper.on('click', '.btn-add-range', function(e) {
|
||
e.preventDefault();
|
||
var $picker = $(this).closest('.value-picker');
|
||
var $row = $(this).closest('.group-include, .exclude-row');
|
||
var $container = $picker.find('.multi-range-container');
|
||
var $chipsContainer = $container.find('.multi-range-chips');
|
||
var $minInput = $container.find('.range-min-input');
|
||
var $maxInput = $container.find('.range-max-input');
|
||
|
||
var minVal = $minInput.val().trim();
|
||
var maxVal = $maxInput.val().trim();
|
||
|
||
if (minVal === '' && maxVal === '') {
|
||
return;
|
||
}
|
||
|
||
var step = parseFloat($minInput.attr('step')) || 0.01;
|
||
var minAllowed = $minInput.attr('min');
|
||
var hasMinConstraint = typeof minAllowed !== 'undefined' && minAllowed !== '';
|
||
minAllowed = hasMinConstraint ? parseFloat(minAllowed) : null;
|
||
|
||
var minNum = minVal !== '' ? parseFloat(minVal) : null;
|
||
var maxNum = maxVal !== '' ? parseFloat(maxVal) : null;
|
||
|
||
if (hasMinConstraint) {
|
||
if (minNum !== null && minNum < minAllowed) {
|
||
self.showRangeInputError($minInput, self.config.trans.min_value_error || 'Minimum value is ' + minAllowed);
|
||
return;
|
||
}
|
||
if (maxNum !== null && maxNum < minAllowed) {
|
||
self.showRangeInputError($maxInput, self.config.trans.min_value_error || 'Minimum value is ' + minAllowed);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (minNum !== null && maxNum !== null && minNum > maxNum) {
|
||
self.showRangeInputError($minInput, self.config.trans.min_greater_than_max || 'Min cannot be greater than max');
|
||
return;
|
||
}
|
||
|
||
var decimals = step < 1 ? String(step).split('.')[1].length : 0;
|
||
if (minNum !== null) {
|
||
if (step >= 1) {
|
||
minNum = Math.round(minNum);
|
||
} else {
|
||
minNum = parseFloat(minNum.toFixed(decimals));
|
||
}
|
||
minVal = String(minNum);
|
||
}
|
||
if (maxNum !== null) {
|
||
if (step >= 1) {
|
||
maxNum = Math.round(maxNum);
|
||
} else {
|
||
maxNum = parseFloat(maxNum.toFixed(decimals));
|
||
}
|
||
maxVal = String(maxNum);
|
||
}
|
||
|
||
var chipText = '';
|
||
if (minVal !== '' && maxVal !== '') {
|
||
chipText = minVal + ' - ' + maxVal;
|
||
} else if (minVal !== '') {
|
||
chipText = '≥ ' + minVal;
|
||
} else {
|
||
chipText = '≤ ' + maxVal;
|
||
}
|
||
|
||
var $chip = $('<span>', {
|
||
class: 'range-chip',
|
||
'data-min': minVal,
|
||
'data-max': maxVal
|
||
});
|
||
$chip.append($('<span>', { class: 'range-chip-text', text: chipText }));
|
||
$chip.append($('<button>', {
|
||
type: 'button',
|
||
class: 'btn-remove-range',
|
||
html: '<i class="icon-times"></i>'
|
||
}));
|
||
|
||
$chipsContainer.append($chip);
|
||
|
||
$minInput.val('');
|
||
$maxInput.val('');
|
||
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Handle multi-range chip removal
|
||
this.$wrapper.on('click', '.btn-remove-range', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var $chip = $(this).closest('.range-chip');
|
||
var $row = $chip.closest('.group-include, .exclude-row');
|
||
$chip.remove();
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Handle Enter key in multi-range inputs
|
||
this.$wrapper.on('keydown', '.multi-range-container .range-min-input, .multi-range-container .range-max-input', function(e) {
|
||
if (e.keyCode === 13) {
|
||
e.preventDefault();
|
||
$(this).closest('.multi-range-container').find('.btn-add-range').click();
|
||
}
|
||
});
|
||
|
||
// Handle multi-select tile clicks
|
||
this.$wrapper.on('click', '.tile-option', function(e) {
|
||
e.preventDefault();
|
||
var $tile = $(this);
|
||
var $container = $tile.closest('.multi-select-tiles');
|
||
var $row = $tile.closest('.group-include, .exclude-row');
|
||
var isExclusive = $container.attr('data-exclusive') === 'true';
|
||
|
||
if (isExclusive) {
|
||
if ($tile.hasClass('selected')) {
|
||
$tile.removeClass('selected');
|
||
} else {
|
||
$container.find('.tile-option').removeClass('selected');
|
||
$tile.addClass('selected');
|
||
}
|
||
} else {
|
||
$tile.toggleClass('selected');
|
||
}
|
||
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Handle combination attribute value toggle
|
||
this.$wrapper.on('click', '.comb-attr-value', function(e) {
|
||
e.preventDefault();
|
||
var $value = $(this);
|
||
var $row = $value.closest('.group-include, .exclude-row');
|
||
var $picker = $value.closest('.value-picker');
|
||
|
||
$value.toggleClass('selected');
|
||
self.updateCombinationData($picker);
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Handle combination mode toggle
|
||
this.$wrapper.on('change', '.comb-mode-radio', function() {
|
||
var $picker = $(this).closest('.value-picker');
|
||
var $row = $(this).closest('.group-include, .exclude-row');
|
||
self.updateCombinationData($picker);
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Handle combination select all
|
||
this.$wrapper.on('click', '.comb-select-all', function(e) {
|
||
e.preventDefault();
|
||
var $group = $(this).closest('.comb-attr-group');
|
||
var $picker = $(this).closest('.value-picker');
|
||
var $row = $(this).closest('.group-include, .exclude-row');
|
||
$group.find('.comb-attr-value:visible').addClass('selected');
|
||
self.updateCombinationData($picker);
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Handle combination select none
|
||
this.$wrapper.on('click', '.comb-select-none', function(e) {
|
||
e.preventDefault();
|
||
var $group = $(this).closest('.comb-attr-group');
|
||
var $picker = $(this).closest('.value-picker');
|
||
var $row = $(this).closest('.group-include, .exclude-row');
|
||
$group.find('.comb-attr-value').removeClass('selected');
|
||
self.updateCombinationData($picker);
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Handle combination attribute search/filter
|
||
this.$wrapper.on('input', '.comb-attr-search', function() {
|
||
var query = $(this).val().toLowerCase().trim();
|
||
var $group = $(this).closest('.comb-attr-group');
|
||
$group.find('.comb-attr-value').each(function() {
|
||
var name = $(this).data('name') || '';
|
||
if (!query || name.indexOf(query) !== -1) {
|
||
$(this).show();
|
||
} else {
|
||
$(this).hide();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Handle group-level modifier toggle
|
||
this.$wrapper.on('click', '.btn-toggle-modifiers', function(e) {
|
||
e.preventDefault();
|
||
var $btn = $(this);
|
||
var $modifiers = $btn.closest('.group-modifiers');
|
||
var $content = $modifiers.find('.group-modifiers-content');
|
||
$content.slideToggle(150, function() {
|
||
$modifiers.toggleClass('expanded', $content.is(':visible'));
|
||
});
|
||
});
|
||
|
||
// Handle group-level modifier changes
|
||
this.$wrapper.on('change input', '.group-modifier-limit', function() {
|
||
var $group = $(this).closest('.selection-group');
|
||
var $limitInput = $(this);
|
||
var limit = parseInt($limitInput.val(), 10);
|
||
var $badge = $group.find('.group-header .group-count-badge');
|
||
var finalCount = $badge.data('finalCount') || 0;
|
||
|
||
var $previewBadge = $group.find('.group-preview-badge .preview-count');
|
||
if ($previewBadge.length && finalCount > 0) {
|
||
var displayCount = (limit > 0 && limit < finalCount) ? limit : finalCount;
|
||
$previewBadge.text(displayCount);
|
||
}
|
||
|
||
self.updateModifierButtonState($group);
|
||
self.serializeAllBlocks();
|
||
self.refreshGroupPreviewIfOpen($group);
|
||
});
|
||
|
||
// Sort dropdown change
|
||
this.$wrapper.on('change', '.group-modifier-sort', function() {
|
||
var $group = $(this).closest('.selection-group');
|
||
self.serializeAllBlocks();
|
||
self.refreshGroupPreviewIfOpen($group);
|
||
});
|
||
|
||
// Sort direction button click
|
||
this.$wrapper.on('click', '.group-modifiers .btn-sort-dir', function(e) {
|
||
e.preventDefault();
|
||
var $btn = $(this);
|
||
var $group = $btn.closest('.selection-group');
|
||
var currentDir = $btn.data('dir') || 'DESC';
|
||
var newDir = (currentDir === 'DESC') ? 'ASC' : 'DESC';
|
||
|
||
$btn.data('dir', newDir);
|
||
$btn.attr('data-dir', newDir);
|
||
|
||
var $icon = $btn.find('i');
|
||
if (newDir === 'ASC') {
|
||
$icon.removeClass('icon-sort-amount-desc').addClass('icon-sort-amount-asc');
|
||
} else {
|
||
$icon.removeClass('icon-sort-amount-asc').addClass('icon-sort-amount-desc');
|
||
}
|
||
|
||
self.serializeAllBlocks();
|
||
self.refreshGroupPreviewIfOpen($group);
|
||
});
|
||
|
||
// Group preview badge click
|
||
this.$wrapper.on('click', '.group-preview-badge.clickable', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var $badge = $(this);
|
||
var $group = $badge.closest('.selection-group');
|
||
var $block = $badge.closest('.target-block');
|
||
var blockType = $block.data('blockType');
|
||
|
||
if ($badge.hasClass('popover-open')) {
|
||
self.hidePreviewPopover();
|
||
return;
|
||
}
|
||
|
||
self.showGroupPreviewPopover($badge, $group, blockType);
|
||
});
|
||
|
||
// Search input focus
|
||
this.$wrapper.on('focus', '.entity-search-input', function() {
|
||
var $picker = $(this).closest('.value-picker');
|
||
var $group = $(this).closest('.selection-group');
|
||
var $block = $(this).closest('.target-block');
|
||
var blockType = $block.data('blockType');
|
||
var groupIndex = parseInt($group.data('groupIndex'), 10);
|
||
var section = $picker.hasClass('include-picker') ? 'include' : 'exclude';
|
||
var searchEntity = $picker.attr('data-search-entity') || blockType;
|
||
|
||
var excludeIndex = null;
|
||
if (section === 'exclude') {
|
||
var $excludeRow = $(this).closest('.exclude-row');
|
||
if ($excludeRow.length) {
|
||
excludeIndex = parseInt($excludeRow.data('excludeIndex'), 10);
|
||
}
|
||
}
|
||
|
||
var entityChanged = self.activeGroup && self.activeGroup.searchEntity !== searchEntity;
|
||
if (entityChanged) {
|
||
self.searchResults = [];
|
||
self.searchOffset = 0;
|
||
self.searchQuery = '';
|
||
self.viewMode = 'list';
|
||
self.resetFiltersWithoutSearch();
|
||
self.$dropdown.find('.dropdown-results').empty();
|
||
self.$dropdown.find('.filter-panel').removeClass('show');
|
||
self.$dropdown.find('.btn-toggle-filters').removeClass('active');
|
||
}
|
||
|
||
self.activeGroup = {
|
||
blockType: blockType,
|
||
groupIndex: groupIndex,
|
||
section: section,
|
||
excludeIndex: excludeIndex,
|
||
searchEntity: searchEntity
|
||
};
|
||
|
||
// Initialize pending selections from current chips
|
||
var $chips = $picker.find('.entity-chips');
|
||
self.pendingSelections = [];
|
||
$chips.find('.entity-chip').each(function() {
|
||
self.pendingSelections.push({
|
||
id: $(this).data('id'),
|
||
name: $(this).find('.chip-name').text(),
|
||
data: $(this).data()
|
||
});
|
||
});
|
||
self.pendingPicker = $picker;
|
||
self.pendingRow = section === 'include' ? $group.find('.group-include') : $group.find('.exclude-row[data-exclude-index="' + excludeIndex + '"]');
|
||
|
||
self.searchOffset = 0;
|
||
self.searchQuery = $(this).val().trim();
|
||
|
||
self.updateFilterPanelForEntity(searchEntity);
|
||
|
||
if (searchEntity === 'products') {
|
||
self.loadFilterableData();
|
||
}
|
||
|
||
self.positionDropdown($(this));
|
||
|
||
// For tree view mode on categories, load category tree instead of search
|
||
if (self.viewMode === 'tree' && (searchEntity === 'categories' || searchEntity === 'cms_categories')) {
|
||
self.loadCategoryTree();
|
||
return;
|
||
}
|
||
|
||
self.performSearch();
|
||
});
|
||
|
||
// Search input typing
|
||
this.$wrapper.on('input', '.entity-search-input', function() {
|
||
var query = $(this).val().trim();
|
||
self.searchQuery = query;
|
||
self.searchOffset = 0;
|
||
|
||
if (self.viewMode === 'tree') {
|
||
self.filterCategoryTree(query);
|
||
return;
|
||
}
|
||
|
||
clearTimeout(self.searchTimeout);
|
||
self.searchTimeout = setTimeout(function() {
|
||
self.performSearch();
|
||
}, 300);
|
||
});
|
||
|
||
// History item click
|
||
this.$dropdown.on('click', '.history-item', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var query = $(this).data('query');
|
||
if (query && self.activeGroup) {
|
||
var $input = self.$wrapper.find('.entity-search-input:focus');
|
||
if (!$input.length) {
|
||
var $block = self.$wrapper.find('.target-block[data-block-type="' + self.activeGroup.blockType + '"]');
|
||
var $group = $block.find('.selection-group[data-group-index="' + self.activeGroup.groupIndex + '"]');
|
||
$input = $group.find('.entity-search-input').first();
|
||
}
|
||
$input.val(query);
|
||
self.searchQuery = query;
|
||
self.searchOffset = 0;
|
||
self.performSearch();
|
||
}
|
||
});
|
||
|
||
// Delete history item
|
||
this.$dropdown.on('click', '.history-item .btn-delete-history', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var $item = $(this).closest('.history-item');
|
||
var query = $item.data('query');
|
||
if (query && self.activeGroup) {
|
||
self.removeFromSearchHistory(self.activeGroup.searchEntity, query);
|
||
$item.fadeOut(150, function() {
|
||
$(this).remove();
|
||
if (!self.$dropdown.find('.history-item').length) {
|
||
self.performSearch();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// Dropdown item click
|
||
this.$dropdown.on('click', '.dropdown-item', function(e) {
|
||
e.preventDefault();
|
||
|
||
// Blur any focused input so Ctrl+A works for select all
|
||
$(document.activeElement).filter('input, textarea').blur();
|
||
|
||
var $item = $(this);
|
||
var id = $item.data('id');
|
||
var name = $item.data('name');
|
||
var isSelected = $item.hasClass('selected');
|
||
|
||
if (!self.activeGroup) return;
|
||
|
||
var $block = self.$wrapper.find('.target-block[data-block-type="' + self.activeGroup.blockType + '"]');
|
||
var $group = $block.find('.selection-group[data-group-index="' + self.activeGroup.groupIndex + '"]');
|
||
var $picker;
|
||
var $row;
|
||
|
||
if (self.activeGroup.section === 'include') {
|
||
$picker = $group.find('.include-picker');
|
||
$row = $group.find('.group-include');
|
||
} else {
|
||
var $excludeRow = $group.find('.exclude-row[data-exclude-index="' + self.activeGroup.excludeIndex + '"]');
|
||
$picker = $excludeRow.find('.exclude-picker');
|
||
$row = $excludeRow;
|
||
}
|
||
|
||
if (isSelected) {
|
||
self.removeSelection($picker, id);
|
||
$item.toggleClass('selected');
|
||
self.serializeAllBlocks($row);
|
||
} else {
|
||
var currentSelection = self.getCurrentSingleSelection();
|
||
if (currentSelection) {
|
||
var newEntityType = self.activeGroup.blockType;
|
||
self.showReplaceConfirmation(currentSelection, { name: name, entityType: newEntityType }, function() {
|
||
self.addSelection($picker, id, name, $item.data());
|
||
$item.addClass('selected');
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
} else {
|
||
self.addSelection($picker, id, name, $item.data());
|
||
$item.toggleClass('selected');
|
||
self.serializeAllBlocks($row);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Chip remove
|
||
this.$wrapper.on('click', '.chip-remove', function(e) {
|
||
e.stopPropagation();
|
||
var $chip = $(this).closest('.entity-chip');
|
||
var $picker = $(this).closest('.value-picker');
|
||
var $row = $(this).closest('.group-include, .exclude-row');
|
||
var id = $chip.data('id');
|
||
|
||
self.removeSelection($picker, id);
|
||
self.serializeAllBlocks($row);
|
||
|
||
if (self.$dropdown && self.$dropdown.hasClass('show')) {
|
||
self.$dropdown.find('.dropdown-item[data-id="' + id + '"]').removeClass('selected');
|
||
}
|
||
});
|
||
|
||
// Chips show more/less toggle
|
||
this.$wrapper.on('click', '.chips-show-more-toggle', function(e) {
|
||
e.stopPropagation();
|
||
var $chips = $(this).closest('.entity-chips');
|
||
|
||
if ($chips.hasClass('chips-expanded')) {
|
||
$chips.removeClass('chips-expanded').addClass('chips-collapsed');
|
||
} else {
|
||
$chips.addClass('chips-expanded').removeClass('chips-collapsed');
|
||
}
|
||
|
||
self.updateChipsVisibility($chips);
|
||
});
|
||
|
||
// Select All
|
||
this.$dropdown.on('click', '.btn-select-all', function(e) {
|
||
e.preventDefault();
|
||
if (!self.activeGroup) return;
|
||
|
||
// Handle tree view - use pending selections
|
||
if (self.viewMode === 'tree') {
|
||
if (!self.pendingSelections) self.pendingSelections = [];
|
||
|
||
// Select all visible (not filtered-out) tree items
|
||
var $visibleTreeItems = self.$dropdown.find('.tree-item:not(.filtered-out)');
|
||
$visibleTreeItems.each(function() {
|
||
var $item = $(this);
|
||
var id = parseInt($item.data('id'), 10);
|
||
var name = $item.data('name');
|
||
|
||
if (!$item.hasClass('selected')) {
|
||
$item.addClass('selected');
|
||
var exists = self.pendingSelections.some(function(s) {
|
||
return parseInt(s.id, 10) === id;
|
||
});
|
||
if (!exists) {
|
||
self.pendingSelections.push({ id: id, name: name, data: $item.data() });
|
||
}
|
||
}
|
||
});
|
||
|
||
// Update count display
|
||
var selectedCount = self.$dropdown.find('.tree-item.selected').length;
|
||
var totalCount = self.$dropdown.find('.tree-item').length;
|
||
var entityType = self.$dropdown.find('.category-tree').data('entity-type') || 'categories';
|
||
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
|
||
self.$dropdown.find('.results-count').text(totalCount + ' ' + categoryLabel + ' (' + selectedCount + ' selected)');
|
||
return;
|
||
}
|
||
|
||
// Handle list view
|
||
var $block = self.$wrapper.find('.target-block[data-block-type="' + self.activeGroup.blockType + '"]');
|
||
var $group = $block.find('.selection-group[data-group-index="' + self.activeGroup.groupIndex + '"]');
|
||
var $picker;
|
||
var $row;
|
||
|
||
if (self.activeGroup.section === 'include') {
|
||
$picker = $group.find('.include-picker');
|
||
$row = $group.find('.group-include');
|
||
} else {
|
||
var $excludeRow = $group.find('.exclude-row[data-exclude-index="' + self.activeGroup.excludeIndex + '"]');
|
||
$picker = $excludeRow.find('.exclude-picker');
|
||
$row = $excludeRow;
|
||
}
|
||
|
||
var $visibleItems = self.$dropdown.find('.dropdown-item:visible');
|
||
$visibleItems.each(function() {
|
||
if (!$(this).hasClass('selected')) {
|
||
var id = $(this).data('id');
|
||
var name = $(this).data('name');
|
||
self.addSelectionNoUpdate($picker, id, name, $(this).data());
|
||
$(this).addClass('selected');
|
||
}
|
||
});
|
||
|
||
var $chips = $picker.find('.entity-chips');
|
||
self.updateChipsVisibility($chips);
|
||
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Clear selection
|
||
this.$dropdown.on('click', '.btn-clear-selection', function(e) {
|
||
e.preventDefault();
|
||
if (!self.activeGroup) return;
|
||
|
||
// Handle tree view - clear pending selections
|
||
if (self.viewMode === 'tree') {
|
||
self.pendingSelections = [];
|
||
self.$dropdown.find('.tree-item').removeClass('selected');
|
||
|
||
// Update count display
|
||
var totalCount = self.$dropdown.find('.tree-item').length;
|
||
var entityType = self.$dropdown.find('.category-tree').data('entity-type') || 'categories';
|
||
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
|
||
self.$dropdown.find('.results-count').text(totalCount + ' ' + categoryLabel);
|
||
return;
|
||
}
|
||
|
||
// Handle list view
|
||
var $block = self.$wrapper.find('.target-block[data-block-type="' + self.activeGroup.blockType + '"]');
|
||
var $group = $block.find('.selection-group[data-group-index="' + self.activeGroup.groupIndex + '"]');
|
||
var $picker;
|
||
var $row;
|
||
|
||
if (self.activeGroup.section === 'include') {
|
||
$picker = $group.find('.include-picker');
|
||
$row = $group.find('.group-include');
|
||
} else {
|
||
var $excludeRow = $group.find('.exclude-row[data-exclude-index="' + self.activeGroup.excludeIndex + '"]');
|
||
$picker = $excludeRow.find('.exclude-picker');
|
||
$row = $excludeRow;
|
||
}
|
||
|
||
var $chips = $picker.find('.entity-chips');
|
||
$chips.empty().removeClass('chips-expanded chips-collapsed');
|
||
self.$dropdown.find('.dropdown-item').removeClass('selected');
|
||
self.serializeAllBlocks($row);
|
||
});
|
||
|
||
// Save - commit pending selections to chips
|
||
this.$dropdown.on('click', '.btn-confirm-dropdown', function(e) {
|
||
e.preventDefault();
|
||
|
||
if (self.pendingPicker && self.pendingSelections) {
|
||
var $chips = self.pendingPicker.find('.entity-chips');
|
||
|
||
// Clear existing chips
|
||
$chips.empty();
|
||
|
||
// Add chips for all pending selections
|
||
self.pendingSelections.forEach(function(sel) {
|
||
self.addSelectionNoUpdate(self.pendingPicker, sel.id, sel.name, sel.data);
|
||
});
|
||
|
||
self.updateChipsVisibility($chips);
|
||
|
||
// Serialize to hidden input
|
||
if (self.pendingRow) {
|
||
self.serializeAllBlocks(self.pendingRow);
|
||
}
|
||
}
|
||
|
||
self.pendingSelections = null;
|
||
self.pendingPicker = null;
|
||
self.pendingRow = null;
|
||
self.hideDropdown();
|
||
});
|
||
|
||
// Cancel - discard pending selections (no changes to chips)
|
||
this.$dropdown.on('click', '.btn-cancel-dropdown', function(e) {
|
||
e.preventDefault();
|
||
|
||
// Just discard pending - chips remain unchanged
|
||
self.pendingSelections = null;
|
||
self.pendingPicker = null;
|
||
self.pendingRow = null;
|
||
self.hideDropdown();
|
||
});
|
||
|
||
// Load more
|
||
this.$dropdown.on('click', '.btn-load-more', function(e) {
|
||
e.preventDefault();
|
||
if (self.isLoading) return;
|
||
|
||
var loadCount = parseInt(self.$dropdown.find('.load-more-select').val(), 10) || 20;
|
||
self.searchOffset = self.searchResults.length;
|
||
self.loadMoreCount = loadCount;
|
||
self.performSearch(true);
|
||
});
|
||
|
||
// Sort field change
|
||
this.$dropdown.on('change', '.sort-field-select', function() {
|
||
self.currentSort.field = $(this).val();
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Sort direction toggle
|
||
this.$dropdown.on('click', '.btn-sort-dir', function(e) {
|
||
e.preventDefault();
|
||
var $btn = $(this);
|
||
var currentDir = $btn.data('dir');
|
||
var newDir = currentDir === 'ASC' ? 'DESC' : 'ASC';
|
||
$btn.data('dir', newDir);
|
||
$btn.find('i').attr('class', newDir === 'ASC' ? 'icon-sort-alpha-asc' : 'icon-sort-alpha-desc');
|
||
self.currentSort.dir = newDir;
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Tree view: Toggle expand/collapse
|
||
this.$dropdown.on('click', '.category-tree .tree-toggle', function(e) {
|
||
e.stopPropagation();
|
||
var $item = $(this).closest('.tree-item');
|
||
var $allItems = self.$dropdown.find('.tree-item');
|
||
|
||
$item.toggleClass('collapsed');
|
||
var isCollapsed = $item.hasClass('collapsed');
|
||
|
||
$(this).find('i').toggleClass('icon-caret-down', !isCollapsed)
|
||
.toggleClass('icon-caret-right', isCollapsed);
|
||
|
||
var descendants = self.findTreeDescendants($item, $allItems);
|
||
for (var i = 0; i < descendants.length; i++) {
|
||
$(descendants[i]).toggle(!isCollapsed);
|
||
}
|
||
});
|
||
|
||
// Tree view: Item click (select/deselect) - PENDING mode
|
||
this.$dropdown.on('click', '.category-tree .tree-item', function(e) {
|
||
if ($(e.target).closest('.tree-toggle, .btn-select-children, .tree-count').length) {
|
||
return;
|
||
}
|
||
|
||
// Blur any focused input so Ctrl+A works for select all
|
||
$(document.activeElement).filter('input, textarea').blur();
|
||
|
||
var $item = $(this);
|
||
var id = parseInt($item.data('id'), 10);
|
||
var name = $item.data('name');
|
||
var isSelected = $item.hasClass('selected');
|
||
|
||
if (!self.activeGroup) return;
|
||
if (!self.pendingSelections) self.pendingSelections = [];
|
||
|
||
var $allItems = self.$dropdown.find('.tree-item');
|
||
|
||
var updateCount = function() {
|
||
var selectedCount = self.$dropdown.find('.tree-item.selected').length;
|
||
var totalCount = self.$dropdown.find('.tree-item').length;
|
||
var entityType = self.$dropdown.find('.category-tree').data('entity-type') || 'categories';
|
||
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
|
||
self.$dropdown.find('.results-count').text(totalCount + ' ' + categoryLabel + (selectedCount > 0 ? ' (' + selectedCount + ' selected)' : ''));
|
||
self.updateSelectChildrenButtons($allItems);
|
||
};
|
||
|
||
if (isSelected) {
|
||
// Remove from pending
|
||
self.pendingSelections = self.pendingSelections.filter(function(s) {
|
||
return parseInt(s.id, 10) !== id;
|
||
});
|
||
$item.removeClass('selected');
|
||
} else {
|
||
// Add to pending
|
||
var exists = self.pendingSelections.some(function(s) {
|
||
return parseInt(s.id, 10) === id;
|
||
});
|
||
if (!exists) {
|
||
self.pendingSelections.push({
|
||
id: id,
|
||
name: name,
|
||
data: $item.data()
|
||
});
|
||
}
|
||
$item.addClass('selected');
|
||
}
|
||
|
||
updateCount();
|
||
});
|
||
|
||
// Tree view: Product/page count click - show preview
|
||
this.$dropdown.on('click', '.category-tree .tree-count.clickable', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var $count = $(this);
|
||
var categoryId = $count.data('category-id');
|
||
var $item = $count.closest('.tree-item');
|
||
var categoryName = $item.data('name');
|
||
var entityType = self.$dropdown.find('.category-tree').data('entity-type') || 'categories';
|
||
|
||
if ($count.hasClass('popover-open')) {
|
||
self.hidePreviewPopover();
|
||
} else {
|
||
self.showCategoryItemsPreview($count, categoryId, categoryName, entityType);
|
||
}
|
||
});
|
||
|
||
// Tree view: Select/Deselect with children button (toggle)
|
||
this.$dropdown.on('click', '.category-tree .btn-select-children', function(e) {
|
||
e.stopPropagation();
|
||
var $btn = $(this);
|
||
var $item = $btn.closest('.tree-item');
|
||
var $allItems = self.$dropdown.find('.tree-item');
|
||
|
||
if (!self.activeGroup) return;
|
||
|
||
var $block = self.$wrapper.find('.target-block[data-block-type="' + self.activeGroup.blockType + '"]');
|
||
var $group = $block.find('.selection-group[data-group-index="' + self.activeGroup.groupIndex + '"]');
|
||
var $picker;
|
||
var $row;
|
||
|
||
if (self.activeGroup.section === 'include') {
|
||
$picker = $group.find('.include-picker');
|
||
$row = $group.find('.group-include');
|
||
} else {
|
||
var $excludeRow = $group.find('.exclude-row[data-exclude-index="' + self.activeGroup.excludeIndex + '"]');
|
||
$picker = $excludeRow.find('.exclude-picker');
|
||
$row = $excludeRow;
|
||
}
|
||
|
||
var descendants = self.findTreeDescendants($item, $allItems);
|
||
|
||
var allSelected = $item.hasClass('selected');
|
||
for (var i = 0; i < descendants.length && allSelected; i++) {
|
||
if (!$(descendants[i]).hasClass('selected')) {
|
||
allSelected = false;
|
||
}
|
||
}
|
||
|
||
var trans = self.config.trans || {};
|
||
|
||
if (allSelected) {
|
||
self.removeSelection($picker, $item.data('id'));
|
||
$item.removeClass('selected');
|
||
|
||
for (var j = 0; j < descendants.length; j++) {
|
||
var $child = $(descendants[j]);
|
||
self.removeSelection($picker, $child.data('id'));
|
||
$child.removeClass('selected');
|
||
}
|
||
|
||
$btn.find('i').removeClass('icon-minus-square').addClass('icon-plus-square');
|
||
$btn.attr('title', trans.select_with_children || 'Select with all children');
|
||
} else {
|
||
if (!$item.hasClass('selected')) {
|
||
self.addSelectionNoUpdate($picker, $item.data('id'), $item.data('name'), $item.data());
|
||
$item.addClass('selected');
|
||
}
|
||
|
||
for (var k = 0; k < descendants.length; k++) {
|
||
var $descendant = $(descendants[k]);
|
||
if (!$descendant.hasClass('selected')) {
|
||
self.addSelectionNoUpdate($picker, $descendant.data('id'), $descendant.data('name'), $descendant.data());
|
||
$descendant.addClass('selected');
|
||
}
|
||
}
|
||
|
||
$btn.find('i').removeClass('icon-plus-square').addClass('icon-minus-square');
|
||
$btn.attr('title', trans.deselect_with_children || 'Deselect with all children');
|
||
}
|
||
|
||
var $chips = $picker.find('.entity-chips');
|
||
self.updateChipsVisibility($chips);
|
||
|
||
self.serializeAllBlocks($row);
|
||
self.updateSelectChildrenButtons($allItems);
|
||
|
||
var selectedCount = self.$dropdown.find('.tree-item.selected').length;
|
||
var totalCount = self.$dropdown.find('.tree-item').length;
|
||
var entityType = self.$dropdown.find('.category-tree').data('entity-type') || 'categories';
|
||
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
|
||
self.$dropdown.find('.results-count').text(totalCount + ' ' + categoryLabel + (selectedCount > 0 ? ' (' + selectedCount + ' selected)' : ''));
|
||
});
|
||
|
||
// Tree view: Expand all
|
||
this.$dropdown.on('click', '.category-tree .btn-expand-all', function(e) {
|
||
e.preventDefault();
|
||
self.$dropdown.find('.tree-item').removeClass('collapsed').show();
|
||
self.$dropdown.find('.tree-toggle i').removeClass('icon-caret-right').addClass('icon-caret-down');
|
||
});
|
||
|
||
// Tree view: Collapse all
|
||
this.$dropdown.on('click', '.category-tree .btn-collapse-all', function(e) {
|
||
e.preventDefault();
|
||
var minLevel = Infinity;
|
||
self.$dropdown.find('.tree-item').each(function() {
|
||
var level = parseInt($(this).data('level'), 10);
|
||
if (level < minLevel) minLevel = level;
|
||
});
|
||
|
||
self.$dropdown.find('.tree-item').each(function() {
|
||
var $item = $(this);
|
||
var level = parseInt($item.data('level'), 10);
|
||
var hasChildren = $item.hasClass('has-children');
|
||
|
||
if (level === minLevel) {
|
||
if (hasChildren) {
|
||
$item.addClass('collapsed');
|
||
$item.find('.tree-toggle i').removeClass('icon-caret-down').addClass('icon-caret-right');
|
||
}
|
||
$item.show();
|
||
} else {
|
||
$item.hide();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Refine search input
|
||
this.$dropdown.on('keyup', '.refine-input', function() {
|
||
var query = $(this).val().trim();
|
||
self.refineQuery = query;
|
||
self.$dropdown.find('.btn-clear-refine').toggle(query.length > 0);
|
||
|
||
clearTimeout(self.refineTimeout);
|
||
self.refineTimeout = setTimeout(function() {
|
||
// For tree view, filter client-side instead of server refresh
|
||
if (self.viewMode === 'tree') {
|
||
self.filterCategoryTree(query);
|
||
return;
|
||
}
|
||
self.refreshSearch();
|
||
}, 300);
|
||
});
|
||
|
||
// Clear refine search
|
||
this.$dropdown.on('click', '.btn-clear-refine', function(e) {
|
||
e.preventDefault();
|
||
self.refineQuery = '';
|
||
self.$dropdown.find('.refine-input').val('');
|
||
$(this).hide();
|
||
// For tree view, filter client-side instead of server refresh
|
||
if (self.viewMode === 'tree') {
|
||
self.filterCategoryTree('');
|
||
return;
|
||
}
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Toggle refine negate (NOT contains)
|
||
this.$dropdown.on('click', '.btn-refine-negate', function(e) {
|
||
e.preventDefault();
|
||
self.refineNegate = !self.refineNegate;
|
||
$(this).toggleClass('active', self.refineNegate);
|
||
|
||
var trans = self.config.trans || {};
|
||
var placeholder = self.refineNegate
|
||
? (trans.refine_exclude || 'Exclude...')
|
||
: (trans.refine_short || 'Refine...');
|
||
self.$dropdown.find('.refine-input').attr('placeholder', placeholder);
|
||
|
||
if (self.refineQuery) {
|
||
self.refreshSearch();
|
||
}
|
||
});
|
||
|
||
// Toggle filter panel
|
||
this.$dropdown.on('click', '.btn-toggle-filters', function(e) {
|
||
e.preventDefault();
|
||
var $panel = self.$dropdown.find('.filter-panel');
|
||
$panel.toggleClass('show');
|
||
$(this).toggleClass('active', $panel.hasClass('show'));
|
||
|
||
if ($panel.hasClass('show') && self.activeGroup) {
|
||
self.updateFilterPanelForEntity(self.activeGroup.searchEntity);
|
||
}
|
||
});
|
||
|
||
// Show search history
|
||
this.$dropdown.on('click', '.btn-show-history', function(e) {
|
||
e.preventDefault();
|
||
$(this).toggleClass('active');
|
||
if ($(this).hasClass('active') && self.activeGroup) {
|
||
self.showSearchHistory(self.activeGroup.searchEntity);
|
||
} else {
|
||
self.performSearch();
|
||
}
|
||
});
|
||
|
||
// Quick filter checkboxes
|
||
this.$dropdown.on('change', '.filter-in-stock', function() {
|
||
self.filters.inStock = $(this).is(':checked');
|
||
self.refreshSearch();
|
||
});
|
||
|
||
this.$dropdown.on('change', '.filter-discounted', function() {
|
||
self.filters.discounted = $(this).is(':checked');
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Price range filter
|
||
this.$dropdown.on('change', '.filter-price-min, .filter-price-max', function() {
|
||
var $panel = self.$dropdown.find('.filter-panel');
|
||
self.filters.priceMin = $panel.find('.filter-price-min').val() || null;
|
||
self.filters.priceMax = $panel.find('.filter-price-max').val() || null;
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Product count range
|
||
this.$dropdown.on('change', '.filter-product-count-min, .filter-product-count-max', function() {
|
||
var $row = $(this).closest('.filter-row');
|
||
self.filters.productCountMin = $row.find('.filter-product-count-min').val() || null;
|
||
self.filters.productCountMax = $row.find('.filter-product-count-max').val() || null;
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Sales range
|
||
this.$dropdown.on('change', '.filter-sales-min, .filter-sales-max', function() {
|
||
var $row = $(this).closest('.filter-row');
|
||
self.filters.salesMin = $row.find('.filter-sales-min').val() || null;
|
||
self.filters.salesMax = $row.find('.filter-sales-max').val() || null;
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Turnover/revenue range
|
||
this.$dropdown.on('change', '.filter-turnover-min, .filter-turnover-max', function() {
|
||
var $row = $(this).closest('.filter-row');
|
||
self.filters.turnoverMin = $row.find('.filter-turnover-min').val() || null;
|
||
self.filters.turnoverMax = $row.find('.filter-turnover-max').val() || null;
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Date added range
|
||
this.$dropdown.on('change', '.filter-date-add-from, .filter-date-add-to', function() {
|
||
var $row = $(this).closest('.filter-row');
|
||
self.filters.dateAddFrom = $row.find('.filter-date-add-from').val() || null;
|
||
self.filters.dateAddTo = $row.find('.filter-date-add-to').val() || null;
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Last product date range
|
||
this.$dropdown.on('change', '.filter-last-product-from, .filter-last-product-to', function() {
|
||
var $row = $(this).closest('.filter-row');
|
||
self.filters.lastProductFrom = $row.find('.filter-last-product-from').val() || null;
|
||
self.filters.lastProductTo = $row.find('.filter-last-product-to').val() || null;
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Depth (categories)
|
||
this.$dropdown.on('change', '.filter-depth-select', function() {
|
||
self.filters.depth = $(this).val() || null;
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Has products (categories)
|
||
this.$dropdown.on('change', '.filter-has-products', function() {
|
||
self.filters.hasProducts = $(this).is(':checked');
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Has description (categories)
|
||
this.$dropdown.on('change', '.filter-has-description', function() {
|
||
self.filters.hasDescription = $(this).is(':checked');
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Has image (categories)
|
||
this.$dropdown.on('change', '.filter-has-image', function() {
|
||
self.filters.hasImage = $(this).is(':checked');
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Active only
|
||
this.$dropdown.on('change', '.filter-active-only', function() {
|
||
self.filters.activeOnly = $(this).is(':checked');
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Attribute group select
|
||
this.$dropdown.on('change', '.filter-attribute-group-select', function() {
|
||
self.filters.attributeGroup = $(this).val() || null;
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Feature group select
|
||
this.$dropdown.on('change', '.filter-feature-group-select', function() {
|
||
self.filters.featureGroup = $(this).val() || null;
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Color attributes only
|
||
this.$dropdown.on('change', '.filter-is-color', function() {
|
||
self.filters.isColor = $(this).is(':checked');
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: Custom feature values only
|
||
this.$dropdown.on('change', '.filter-is-custom', function() {
|
||
self.filters.isCustom = $(this).is(':checked');
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Entity-specific filters: CMS indexable
|
||
this.$dropdown.on('change', '.filter-indexable', function() {
|
||
self.filters.indexable = $(this).is(':checked');
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Clear entity-specific filters
|
||
this.$dropdown.on('click', '.filter-row-entity-categories .btn-clear-filters, .filter-row-entity-manufacturers .btn-clear-filters, .filter-row-entity-suppliers .btn-clear-filters, .filter-row-entity-attributes .btn-clear-filters, .filter-row-entity-features .btn-clear-filters, .filter-row-entity-cms .btn-clear-filters, .filter-row-entity-cms-categories .btn-clear-filters', function(e) {
|
||
e.preventDefault();
|
||
var $row = $(this).closest('.filter-row');
|
||
$row.find('input[type="number"]').val('');
|
||
$row.find('input[type="date"]').val('');
|
||
$row.find('select').val('');
|
||
$row.find('input[type="checkbox"]').prop('checked', false);
|
||
$row.find('.filter-active-only').prop('checked', true);
|
||
|
||
self.filters.productCountMin = null;
|
||
self.filters.productCountMax = null;
|
||
self.filters.salesMin = null;
|
||
self.filters.salesMax = null;
|
||
self.filters.turnoverMin = null;
|
||
self.filters.turnoverMax = null;
|
||
self.filters.depth = null;
|
||
self.filters.hasProducts = false;
|
||
self.filters.hasDescription = false;
|
||
self.filters.hasImage = false;
|
||
self.filters.activeOnly = true;
|
||
self.filters.attributeGroup = null;
|
||
self.filters.featureGroup = null;
|
||
self.filters.isColor = false;
|
||
self.filters.isCustom = false;
|
||
self.filters.indexable = false;
|
||
self.filters.dateAddFrom = null;
|
||
self.filters.dateAddTo = null;
|
||
self.filters.lastProductFrom = null;
|
||
self.filters.lastProductTo = null;
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Toggle filter group - show values
|
||
this.$dropdown.on('click', '.filter-group-toggle', function(e) {
|
||
// Ignore clicks on the preview badge
|
||
if ($(e.target).closest('.toggle-count.clickable').length) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
var $btn = $(this);
|
||
var groupId = $btn.data('group-id');
|
||
var type = $btn.data('type');
|
||
var isActive = $btn.hasClass('active');
|
||
|
||
self.$dropdown.find('.filter-group-toggle').removeClass('active');
|
||
|
||
if (isActive) {
|
||
self.hideFilterGroupValues();
|
||
} else {
|
||
$btn.addClass('active');
|
||
self.showFilterGroupValues(groupId, type);
|
||
}
|
||
});
|
||
|
||
// Filter group toggle count badge click for preview popover
|
||
this.$dropdown.on('click', '.filter-group-toggle .toggle-count.clickable', function(e) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
|
||
var $badge = $(this);
|
||
var groupId = $badge.data('groupId');
|
||
var groupType = $badge.data('type');
|
||
var groupName = $badge.data('groupName');
|
||
|
||
if ($badge.hasClass('popover-open')) {
|
||
self.hidePreviewPopover();
|
||
} else {
|
||
self.showFilterGroupPreviewPopover($badge, groupId, groupType, groupName);
|
||
}
|
||
});
|
||
|
||
// View mode select change
|
||
this.$dropdown.on('change', '.view-mode-select', function() {
|
||
var mode = $(this).val();
|
||
var prevMode = self.viewMode;
|
||
self.viewMode = mode;
|
||
|
||
// Remove all view mode classes and add the new one
|
||
self.$dropdown
|
||
.removeClass('view-list view-tree view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8')
|
||
.addClass('view-' + mode.replace('cols-', 'cols-'));
|
||
|
||
// For tree view, load the category tree (only for categories/cms_categories)
|
||
var searchEntity = self.activeGroup ? self.activeGroup.searchEntity : '';
|
||
if (mode === 'tree' && (searchEntity === 'categories' || searchEntity === 'cms_categories')) {
|
||
self.loadCategoryTree();
|
||
} else if (mode !== 'tree') {
|
||
// If switching FROM tree mode, need to refresh search to load data
|
||
if (prevMode === 'tree') {
|
||
self.refreshSearch();
|
||
} else {
|
||
// Re-render current results with new view mode
|
||
self.renderSearchResults(false);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Close values row
|
||
this.$dropdown.on('click', '.btn-close-values', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
self.hideFilterGroupValues();
|
||
});
|
||
|
||
// Toggle filter chip (attribute/feature value)
|
||
this.$dropdown.on('click', '.filter-chip', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
var $chip = $(this);
|
||
var id = parseInt($chip.data('id'), 10);
|
||
var isAttribute = $chip.hasClass('filter-attr-chip');
|
||
var filterArray = isAttribute ? self.filters.attributes : self.filters.features;
|
||
|
||
var index = filterArray.indexOf(id);
|
||
if (index === -1) {
|
||
filterArray.push(id);
|
||
$chip.addClass('active');
|
||
} else {
|
||
filterArray.splice(index, 1);
|
||
$chip.removeClass('active');
|
||
}
|
||
|
||
self.updateFilterToggleStates();
|
||
self.refreshSearch();
|
||
});
|
||
|
||
// Clear all filters
|
||
this.$dropdown.on('click', '.filter-row-quick .btn-clear-filters', function(e) {
|
||
e.preventDefault();
|
||
self.clearFilters();
|
||
});
|
||
|
||
// Click outside to close
|
||
$(document).on('click', function(e) {
|
||
if (!$(e.target).closest('.value-picker').length &&
|
||
!$(e.target).closest('.target-search-dropdown').length &&
|
||
!$(e.target).closest('.target-preview-popover').length) {
|
||
self.hideDropdown();
|
||
}
|
||
});
|
||
|
||
// Keyboard shortcuts
|
||
$(document).on('keydown', function(e) {
|
||
if (!self.$dropdown || !self.$dropdown.hasClass('show')) return;
|
||
|
||
// Allow default behavior in input/textarea fields
|
||
var isInputFocused = $(document.activeElement).is('input, textarea');
|
||
|
||
// Ctrl+A / Cmd+A - Select All (only when not in input)
|
||
if ((e.ctrlKey || e.metaKey) && e.keyCode === 65) {
|
||
if (isInputFocused) return; // Let browser select text
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
self.$dropdown.find('.btn-select-all').trigger('click');
|
||
return false;
|
||
}
|
||
|
||
// Ctrl+D / Cmd+D - Clear/Deselect all (only when not in input)
|
||
if ((e.ctrlKey || e.metaKey) && e.keyCode === 68) {
|
||
if (isInputFocused) return; // Let browser handle
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
self.$dropdown.find('.btn-clear-selection').trigger('click');
|
||
return false;
|
||
}
|
||
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
self.hideDropdown();
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
self.hideDropdown();
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
})(jQuery);
|
||
|
||
/**
|
||
* Entity Selector - Dropdown Module
|
||
* Search dropdown UI creation and positioning
|
||
* @partial _dropdown.js
|
||
*/
|
||
|
||
(function($) {
|
||
'use strict';
|
||
|
||
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
|
||
|
||
window._EntitySelectorMixins.dropdown = {
|
||
|
||
createDropdown: function() {
|
||
this.$wrapper.find('.target-search-dropdown').remove();
|
||
|
||
var trans = this.config.trans || {};
|
||
|
||
var html = '<div class="target-search-dropdown view-list">';
|
||
|
||
// Header with results count, actions, sort controls, view mode
|
||
html += '<div class="dropdown-header">';
|
||
html += '<span class="results-count">0 results</span>';
|
||
|
||
html += '<div class="dropdown-actions">';
|
||
|
||
// Select all / Clear buttons with keyboard shortcuts
|
||
html += '<button type="button" class="btn-select-all" title="' + (trans.select_all || 'Select all visible') + '">';
|
||
html += '<i class="icon-check-square-o"></i> ' + (trans.all || 'All') + ' <kbd>Ctrl+A</kbd>';
|
||
html += '</button>';
|
||
html += '<button type="button" class="btn-clear-selection" title="' + (trans.clear_selection || 'Clear selection') + '">';
|
||
html += '<i class="icon-square-o"></i> ' + (trans.clear || 'Clear') + ' <kbd>Ctrl+D</kbd>';
|
||
html += '</button>';
|
||
|
||
// Sort controls
|
||
html += '<div class="sort-controls">';
|
||
html += '<select class="sort-field-select" title="Sort by">';
|
||
html += '<option value="name">' + (trans.sort_name || 'Name') + '</option>';
|
||
html += '<option value="id">' + (trans.sort_id || 'ID') + '</option>';
|
||
html += '<option value="position">' + (trans.sort_position || 'Position') + '</option>';
|
||
html += '<option value="popularity">' + (trans.sort_popularity || 'Popularity') + '</option>';
|
||
html += '<option value="selected">' + (trans.sort_selected || 'Selected') + '</option>';
|
||
html += '</select>';
|
||
html += '<button type="button" class="btn-sort-dir" data-dir="ASC" title="Sort direction">';
|
||
html += '<i class="icon-sort-alpha-asc"></i>';
|
||
html += '</button>';
|
||
|
||
// View mode selector - Tree option always present, shown for categories
|
||
html += '<select class="view-mode-select" title="View mode">';
|
||
html += '<option value="list">' + (trans.view_list || 'List') + '</option>';
|
||
html += '<option value="tree" class="tree-view-option">' + (trans.view_tree || 'Tree') + '</option>';
|
||
html += '<option value="cols-2">2 ' + (trans.cols || 'cols') + '</option>';
|
||
html += '<option value="cols-3">3 ' + (trans.cols || 'cols') + '</option>';
|
||
html += '<option value="cols-4">4 ' + (trans.cols || 'cols') + '</option>';
|
||
html += '<option value="cols-5">5 ' + (trans.cols || 'cols') + '</option>';
|
||
html += '<option value="cols-6">6 ' + (trans.cols || 'cols') + '</option>';
|
||
html += '<option value="cols-7">7 ' + (trans.cols || 'cols') + '</option>';
|
||
html += '<option value="cols-8">8 ' + (trans.cols || 'cols') + '</option>';
|
||
html += '</select>';
|
||
html += '</div>'; // End sort-controls
|
||
|
||
// Refine search
|
||
html += '<div class="refine-compact">';
|
||
html += '<button type="button" class="btn-refine-negate" title="' + (trans.exclude_matches || 'Exclude matches (NOT contains)') + '"><i class="icon-ban"></i></button>';
|
||
html += '<input type="text" class="refine-input" placeholder="' + (trans.refine_short || 'Refine...') + '">';
|
||
html += '<button type="button" class="btn-clear-refine" style="display:none;"><i class="icon-times"></i></button>';
|
||
html += '</div>';
|
||
|
||
// Filter toggle button
|
||
html += '<button type="button" class="btn-toggle-filters" title="' + (trans.toggle_filters || 'Filters') + '">';
|
||
html += '<i class="icon-filter"></i>';
|
||
html += '</button>';
|
||
|
||
// History button
|
||
html += '<button type="button" class="btn-show-history" title="' + (trans.recent_searches || 'Recent searches') + '">';
|
||
html += '<i class="icon-clock-o"></i>';
|
||
html += '</button>';
|
||
|
||
html += '</div>'; // End dropdown-actions
|
||
html += '</div>'; // End dropdown-header
|
||
|
||
// Filter panel
|
||
html += '<div class="filter-panel">';
|
||
|
||
// Quick filters row (for products)
|
||
html += '<div class="filter-row filter-row-quick" data-entity="products">';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-in-stock"> ' + (trans.in_stock || 'In stock') + '</label>';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-discounted"> ' + (trans.discounted || 'On sale') + '</label>';
|
||
|
||
// Price range
|
||
html += '<div class="filter-price-range">';
|
||
html += '<span class="filter-price-label">' + (trans.price || 'Price') + ':</span>';
|
||
html += '<input type="number" class="filter-price-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="0.01">';
|
||
html += '<span class="filter-price-sep">-</span>';
|
||
html += '<input type="number" class="filter-price-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="0.01">';
|
||
html += '</div>';
|
||
|
||
html += '<button type="button" class="btn-clear-filters" title="' + (trans.clear_filters || 'Clear filters') + '">';
|
||
html += '<i class="icon-times"></i>';
|
||
html += '</button>';
|
||
html += '</div>';
|
||
|
||
// Attribute/Feature filter toggles for products
|
||
html += '<div class="filter-row filter-row-attributes" data-entity="products" style="display:none;">';
|
||
html += '<span class="filter-row-label"><i class="icon-tags"></i> ' + (trans.attributes || 'Attributes') + ':</span>';
|
||
html += '<div class="filter-attributes-container"></div>';
|
||
html += '</div>';
|
||
html += '<div class="filter-row filter-row-values filter-row-attr-values" data-type="attribute" style="display:none;">';
|
||
html += '<div class="filter-values-container"></div>';
|
||
html += '</div>';
|
||
|
||
html += '<div class="filter-row filter-row-features" data-entity="products" style="display:none;">';
|
||
html += '<span class="filter-row-label"><i class="icon-list-ul"></i> ' + (trans.features || 'Features') + ':</span>';
|
||
html += '<div class="filter-features-container"></div>';
|
||
html += '</div>';
|
||
html += '<div class="filter-row filter-row-values filter-row-feat-values" data-type="feature" style="display:none;">';
|
||
html += '<div class="filter-values-container"></div>';
|
||
html += '</div>';
|
||
|
||
// Entity-specific filters: Categories
|
||
html += '<div class="filter-row filter-row-entity-categories filter-row-multi" data-entity="categories" style="display:none;">';
|
||
html += '<div class="filter-subrow">';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-cubes"></i> ' + (trans.product_count || 'Products') + ':</span>';
|
||
html += '<input type="number" class="filter-product-count-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-product-count-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-shopping-cart"></i> ' + (trans.total_sales || 'Sales') + ':</span>';
|
||
html += '<input type="number" class="filter-sales-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-sales-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-money"></i> ' + (trans.turnover || 'Revenue') + ':</span>';
|
||
html += '<input type="number" class="filter-turnover-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-turnover-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-active-only" checked> ' + (trans.active_only || 'Active only') + '</label>';
|
||
html += '</div>';
|
||
html += '<div class="filter-subrow">';
|
||
html += '<div class="filter-select-group">';
|
||
html += '<span class="filter-select-label"><i class="icon-sitemap"></i> ' + (trans.depth || 'Depth') + ':</span>';
|
||
html += '<select class="filter-depth-select">';
|
||
html += '<option value="">' + (trans.all_levels || 'All levels') + '</option>';
|
||
html += '<option value="1">' + (trans.level || 'Level') + ' 1 (' + (trans.root || 'Root') + ')</option>';
|
||
html += '<option value="2">' + (trans.level || 'Level') + ' 2</option>';
|
||
html += '<option value="3">' + (trans.level || 'Level') + ' 3</option>';
|
||
html += '<option value="4">' + (trans.level || 'Level') + ' 4+</option>';
|
||
html += '</select>';
|
||
html += '</div>';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-has-products"> ' + (trans.has_products || 'Has products') + '</label>';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-has-description"> ' + (trans.has_description || 'Has description') + '</label>';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-has-image"> ' + (trans.has_image || 'Has image') + '</label>';
|
||
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
|
||
// Entity-specific filters: Manufacturers
|
||
html += '<div class="filter-row filter-row-entity-manufacturers filter-row-multi" data-entity="manufacturers" style="display:none;">';
|
||
html += '<div class="filter-subrow">';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-cubes"></i> ' + (trans.product_count || 'Products') + ':</span>';
|
||
html += '<input type="number" class="filter-product-count-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-product-count-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-shopping-cart"></i> ' + (trans.total_sales || 'Sales') + ':</span>';
|
||
html += '<input type="number" class="filter-sales-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-sales-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-money"></i> ' + (trans.turnover || 'Revenue') + ':</span>';
|
||
html += '<input type="number" class="filter-turnover-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-turnover-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-active-only" checked> ' + (trans.active_only || 'Active only') + '</label>';
|
||
html += '</div>';
|
||
html += '<div class="filter-subrow">';
|
||
html += '<div class="filter-date-group">';
|
||
html += '<span class="filter-date-label"><i class="icon-calendar"></i> ' + (trans.date_added || 'Added') + ':</span>';
|
||
html += '<input type="date" class="filter-date-add-from" title="' + (trans.from || 'From') + '">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="date" class="filter-date-add-to" title="' + (trans.to || 'To') + '">';
|
||
html += '</div>';
|
||
html += '<div class="filter-date-group">';
|
||
html += '<span class="filter-date-label"><i class="icon-clock-o"></i> ' + (trans.last_product || 'Last product') + ':</span>';
|
||
html += '<input type="date" class="filter-last-product-from" title="' + (trans.from || 'From') + '">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="date" class="filter-last-product-to" title="' + (trans.to || 'To') + '">';
|
||
html += '</div>';
|
||
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
|
||
// Entity-specific filters: Suppliers
|
||
html += '<div class="filter-row filter-row-entity-suppliers filter-row-multi" data-entity="suppliers" style="display:none;">';
|
||
html += '<div class="filter-subrow">';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-cubes"></i> ' + (trans.product_count || 'Products') + ':</span>';
|
||
html += '<input type="number" class="filter-product-count-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-product-count-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-shopping-cart"></i> ' + (trans.total_sales || 'Sales') + ':</span>';
|
||
html += '<input type="number" class="filter-sales-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-sales-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-money"></i> ' + (trans.turnover || 'Revenue') + ':</span>';
|
||
html += '<input type="number" class="filter-turnover-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-turnover-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-active-only" checked> ' + (trans.active_only || 'Active only') + '</label>';
|
||
html += '</div>';
|
||
html += '<div class="filter-subrow">';
|
||
html += '<div class="filter-date-group">';
|
||
html += '<span class="filter-date-label"><i class="icon-calendar"></i> ' + (trans.date_added || 'Added') + ':</span>';
|
||
html += '<input type="date" class="filter-date-add-from" title="' + (trans.from || 'From') + '">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="date" class="filter-date-add-to" title="' + (trans.to || 'To') + '">';
|
||
html += '</div>';
|
||
html += '<div class="filter-date-group">';
|
||
html += '<span class="filter-date-label"><i class="icon-clock-o"></i> ' + (trans.last_product || 'Last product') + ':</span>';
|
||
html += '<input type="date" class="filter-last-product-from" title="' + (trans.from || 'From') + '">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="date" class="filter-last-product-to" title="' + (trans.to || 'To') + '">';
|
||
html += '</div>';
|
||
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
|
||
// Entity-specific filters: Attributes
|
||
html += '<div class="filter-row filter-row-entity-attributes filter-row-multi" data-entity="attributes" style="display:none;">';
|
||
html += '<div class="filter-subrow">';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-cubes"></i> ' + (trans.product_count || 'Products') + ':</span>';
|
||
html += '<input type="number" class="filter-product-count-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-product-count-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-shopping-cart"></i> ' + (trans.total_sales || 'Sales') + ':</span>';
|
||
html += '<input type="number" class="filter-sales-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-sales-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-money"></i> ' + (trans.turnover || 'Revenue') + ':</span>';
|
||
html += '<input type="number" class="filter-turnover-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-turnover-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
html += '<div class="filter-subrow">';
|
||
html += '<div class="filter-select-group">';
|
||
html += '<span class="filter-select-label"><i class="icon-tags"></i> ' + (trans.attribute_group || 'Group') + ':</span>';
|
||
html += '<select class="filter-attribute-group-select">';
|
||
html += '<option value="">' + (trans.all_groups || 'All groups') + '</option>';
|
||
html += '</select>';
|
||
html += '</div>';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-is-color"> ' + (trans.color_only || 'Color attributes') + '</label>';
|
||
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
|
||
// Entity-specific filters: Features
|
||
html += '<div class="filter-row filter-row-entity-features filter-row-multi" data-entity="features" style="display:none;">';
|
||
html += '<div class="filter-subrow">';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-cubes"></i> ' + (trans.product_count || 'Products') + ':</span>';
|
||
html += '<input type="number" class="filter-product-count-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-product-count-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-shopping-cart"></i> ' + (trans.total_sales || 'Sales') + ':</span>';
|
||
html += '<input type="number" class="filter-sales-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-sales-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '<div class="filter-range-group">';
|
||
html += '<span class="filter-range-label"><i class="icon-money"></i> ' + (trans.turnover || 'Revenue') + ':</span>';
|
||
html += '<input type="number" class="filter-turnover-min" placeholder="' + (trans.min || 'Min') + '" min="0" step="1">';
|
||
html += '<span class="filter-range-sep">-</span>';
|
||
html += '<input type="number" class="filter-turnover-max" placeholder="' + (trans.max || 'Max') + '" min="0" step="1">';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
html += '<div class="filter-subrow">';
|
||
html += '<div class="filter-select-group">';
|
||
html += '<span class="filter-select-label"><i class="icon-list-ul"></i> ' + (trans.feature_group || 'Group') + ':</span>';
|
||
html += '<select class="filter-feature-group-select">';
|
||
html += '<option value="">' + (trans.all_groups || 'All groups') + '</option>';
|
||
html += '</select>';
|
||
html += '</div>';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-is-custom"> ' + (trans.custom_only || 'Custom values') + '</label>';
|
||
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
|
||
// Entity-specific filters: CMS Pages
|
||
html += '<div class="filter-row filter-row-entity-cms" data-entity="cms" style="display:none;">';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-active-only" checked> ' + (trans.active_only || 'Active only') + '</label>';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-indexable"> ' + (trans.indexable || 'Indexable') + '</label>';
|
||
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
|
||
html += '</div>';
|
||
|
||
// Entity-specific filters: CMS Categories
|
||
html += '<div class="filter-row filter-row-entity-cms-categories" data-entity="cms_categories" style="display:none;">';
|
||
html += '<label class="filter-label"><input type="checkbox" class="filter-active-only" checked> ' + (trans.active_only || 'Active only') + '</label>';
|
||
html += '<button type="button" class="btn-clear-filters"><i class="icon-times"></i></button>';
|
||
html += '</div>';
|
||
|
||
html += '</div>'; // End filter-panel
|
||
|
||
// Results header for list view (product columns)
|
||
html += '<div class="results-header">';
|
||
html += '<span class="header-spacer"></span>';
|
||
html += '<span class="header-col header-col-name">' + (trans.product || 'Product') + '</span>';
|
||
html += '<span class="header-col header-col-price">' + (trans.price || 'Price') + '</span>';
|
||
html += '<span class="header-col header-col-sale">' + (trans.sale || 'Sale') + '</span>';
|
||
html += '<span class="header-col header-col-stock">' + (trans.stock || 'Stock') + '</span>';
|
||
html += '<span class="header-col header-col-sales">' + (trans.sold || 'Sold') + '</span>';
|
||
html += '</div>';
|
||
|
||
// Results
|
||
html += '<div class="dropdown-results"></div>';
|
||
|
||
// Footer
|
||
html += '<div class="dropdown-footer">';
|
||
html += '<div class="load-more-controls" style="display:none;">';
|
||
html += '<span class="load-more-label">' + (trans.load || 'Load') + '</span>';
|
||
html += '<select class="load-more-select">';
|
||
html += '<option value="10">10</option>';
|
||
html += '<option value="20" selected>20</option>';
|
||
html += '<option value="50">50</option>';
|
||
html += '<option value="100">100</option>';
|
||
html += '</select>';
|
||
html += '<span class="load-more-of">' + (trans.of || 'of') + ' <span class="remaining-count">0</span> ' + (trans.remaining || 'remaining') + '</span>';
|
||
html += '<button type="button" class="btn-load-more"><i class="icon-plus"></i></button>';
|
||
html += '</div>';
|
||
html += '<button type="button" class="btn-cancel-dropdown"><i class="icon-times"></i> ' + (trans.cancel || 'Cancel') + ' <kbd>Esc</kbd></button>';
|
||
html += '<button type="button" class="btn-confirm-dropdown"><i class="icon-check"></i> ' + (trans.save || 'Save') + ' <kbd>⏎</kbd></button>';
|
||
html += '</div>';
|
||
|
||
html += '</div>';
|
||
|
||
this.$dropdown = $(html);
|
||
$('body').append(this.$dropdown);
|
||
},
|
||
|
||
hideDropdown: function() {
|
||
if (this.$dropdown) {
|
||
this.$dropdown.removeClass('show');
|
||
}
|
||
this.activeGroup = null;
|
||
},
|
||
|
||
positionDropdown: function($input) {
|
||
if (!this.$dropdown) return;
|
||
|
||
var $picker = $input.closest('.value-picker');
|
||
var $searchBox = $input.closest('.entity-search-box');
|
||
|
||
// Get absolute positions (dropdown is appended to body)
|
||
var searchBoxOffset = $searchBox.offset();
|
||
var searchBoxHeight = $searchBox.outerHeight();
|
||
var pickerOffset = $picker.offset();
|
||
var pickerWidth = $picker.outerWidth();
|
||
|
||
// Calculate position relative to document
|
||
var dropdownTop = searchBoxOffset.top + searchBoxHeight + 4;
|
||
var dropdownLeft = pickerOffset.left;
|
||
var dropdownWidth = Math.max(pickerWidth, 400);
|
||
|
||
// Ensure dropdown doesn't overflow the viewport horizontally
|
||
var viewportWidth = $(window).width();
|
||
if (dropdownLeft + dropdownWidth > viewportWidth - 10) {
|
||
dropdownWidth = viewportWidth - dropdownLeft - 10;
|
||
}
|
||
|
||
// Ensure dropdown doesn't overflow viewport vertically
|
||
var viewportHeight = $(window).height();
|
||
var scrollTop = $(window).scrollTop();
|
||
var maxHeight = viewportHeight - (dropdownTop - scrollTop) - 20;
|
||
maxHeight = Math.max(maxHeight, 400);
|
||
|
||
this.$dropdown.css({
|
||
position: 'absolute',
|
||
top: dropdownTop,
|
||
left: dropdownLeft,
|
||
width: dropdownWidth,
|
||
maxHeight: maxHeight,
|
||
zIndex: 10000
|
||
});
|
||
|
||
// Show the dropdown
|
||
this.$dropdown.addClass('show');
|
||
}
|
||
};
|
||
|
||
})(jQuery);
|
||
|
||
/**
|
||
* Entity Selector - Search Module
|
||
* AJAX search, results rendering, category tree, filters, search history
|
||
* @partial _search.js
|
||
*/
|
||
|
||
(function($) {
|
||
'use strict';
|
||
|
||
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
|
||
|
||
window._EntitySelectorMixins.search = {
|
||
|
||
// Category tree cache
|
||
categoryTreeCache: null,
|
||
|
||
/**
|
||
* Perform AJAX search for entities
|
||
*/
|
||
performSearch: function(appendMode) {
|
||
var self = this;
|
||
|
||
if (!this.activeGroup) return;
|
||
|
||
this.isLoading = true;
|
||
|
||
var searchEntity = this.activeGroup.searchEntity;
|
||
|
||
// Build request data with sort and filter params
|
||
var limit = appendMode && this.loadMoreCount ? this.loadMoreCount : 20;
|
||
var requestData = {
|
||
ajax: 1,
|
||
action: 'searchTargetEntities',
|
||
trait: 'EntitySelector',
|
||
entity_type: searchEntity,
|
||
q: this.searchQuery,
|
||
limit: limit,
|
||
offset: appendMode ? this.searchOffset : 0,
|
||
sort_by: this.currentSort ? this.currentSort.field : 'name',
|
||
sort_dir: this.currentSort ? this.currentSort.dir : 'ASC'
|
||
};
|
||
|
||
// Add refine query if present
|
||
if (this.refineQuery) {
|
||
requestData.refine = this.refineQuery;
|
||
if (this.refineNegate) {
|
||
requestData.refine_negate = 1;
|
||
}
|
||
}
|
||
|
||
// Add product-specific filters
|
||
if (searchEntity === 'products' && this.filters) {
|
||
if (this.filters.inStock) {
|
||
requestData.filter_in_stock = 1;
|
||
}
|
||
if (this.filters.discounted) {
|
||
requestData.filter_discounted = 1;
|
||
}
|
||
if (this.filters.priceMin !== null && this.filters.priceMin !== '') {
|
||
requestData.filter_price_min = this.filters.priceMin;
|
||
}
|
||
if (this.filters.priceMax !== null && this.filters.priceMax !== '') {
|
||
requestData.filter_price_max = this.filters.priceMax;
|
||
}
|
||
if (this.filters.attributes && this.filters.attributes.length > 0) {
|
||
requestData.filter_attributes = JSON.stringify(this.filters.attributes);
|
||
}
|
||
if (this.filters.features && this.filters.features.length > 0) {
|
||
requestData.filter_features = JSON.stringify(this.filters.features);
|
||
}
|
||
}
|
||
|
||
// Add entity-specific filters for non-product entities
|
||
if (searchEntity !== 'products' && this.filters) {
|
||
// Product count range (categories, manufacturers, suppliers, attributes, features)
|
||
if (this.filters.productCountMin !== null && this.filters.productCountMin !== '') {
|
||
requestData.filter_product_count_min = this.filters.productCountMin;
|
||
}
|
||
if (this.filters.productCountMax !== null && this.filters.productCountMax !== '') {
|
||
requestData.filter_product_count_max = this.filters.productCountMax;
|
||
}
|
||
|
||
// Category-specific
|
||
if (searchEntity === 'categories') {
|
||
if (this.filters.depth) {
|
||
requestData.filter_depth = this.filters.depth;
|
||
}
|
||
if (this.filters.hasProducts) {
|
||
requestData.filter_has_products = 1;
|
||
}
|
||
if (this.filters.hasDescription) {
|
||
requestData.filter_has_description = 1;
|
||
}
|
||
if (this.filters.hasImage) {
|
||
requestData.filter_has_image = 1;
|
||
}
|
||
if (this.filters.salesMin !== null && this.filters.salesMin !== '') {
|
||
requestData.filter_sales_min = this.filters.salesMin;
|
||
}
|
||
if (this.filters.salesMax !== null && this.filters.salesMax !== '') {
|
||
requestData.filter_sales_max = this.filters.salesMax;
|
||
}
|
||
if (this.filters.turnoverMin !== null && this.filters.turnoverMin !== '') {
|
||
requestData.filter_turnover_min = this.filters.turnoverMin;
|
||
}
|
||
if (this.filters.turnoverMax !== null && this.filters.turnoverMax !== '') {
|
||
requestData.filter_turnover_max = this.filters.turnoverMax;
|
||
}
|
||
if (this.filters.activeOnly) {
|
||
requestData.filter_active = 1;
|
||
}
|
||
}
|
||
|
||
// Manufacturer-specific
|
||
if (searchEntity === 'manufacturers') {
|
||
if (this.filters.salesMin !== null && this.filters.salesMin !== '') {
|
||
requestData.filter_sales_min = this.filters.salesMin;
|
||
}
|
||
if (this.filters.salesMax !== null && this.filters.salesMax !== '') {
|
||
requestData.filter_sales_max = this.filters.salesMax;
|
||
}
|
||
if (this.filters.turnoverMin !== null && this.filters.turnoverMin !== '') {
|
||
requestData.filter_turnover_min = this.filters.turnoverMin;
|
||
}
|
||
if (this.filters.turnoverMax !== null && this.filters.turnoverMax !== '') {
|
||
requestData.filter_turnover_max = this.filters.turnoverMax;
|
||
}
|
||
if (this.filters.dateAddFrom) {
|
||
requestData.filter_date_add_from = this.filters.dateAddFrom;
|
||
}
|
||
if (this.filters.dateAddTo) {
|
||
requestData.filter_date_add_to = this.filters.dateAddTo;
|
||
}
|
||
if (this.filters.lastProductFrom) {
|
||
requestData.filter_last_product_from = this.filters.lastProductFrom;
|
||
}
|
||
if (this.filters.lastProductTo) {
|
||
requestData.filter_last_product_to = this.filters.lastProductTo;
|
||
}
|
||
if (this.filters.activeOnly) {
|
||
requestData.filter_active = 1;
|
||
}
|
||
}
|
||
|
||
// Supplier-specific
|
||
if (searchEntity === 'suppliers') {
|
||
if (this.filters.salesMin !== null && this.filters.salesMin !== '') {
|
||
requestData.filter_sales_min = this.filters.salesMin;
|
||
}
|
||
if (this.filters.salesMax !== null && this.filters.salesMax !== '') {
|
||
requestData.filter_sales_max = this.filters.salesMax;
|
||
}
|
||
if (this.filters.turnoverMin !== null && this.filters.turnoverMin !== '') {
|
||
requestData.filter_turnover_min = this.filters.turnoverMin;
|
||
}
|
||
if (this.filters.turnoverMax !== null && this.filters.turnoverMax !== '') {
|
||
requestData.filter_turnover_max = this.filters.turnoverMax;
|
||
}
|
||
if (this.filters.dateAddFrom) {
|
||
requestData.filter_date_add_from = this.filters.dateAddFrom;
|
||
}
|
||
if (this.filters.dateAddTo) {
|
||
requestData.filter_date_add_to = this.filters.dateAddTo;
|
||
}
|
||
if (this.filters.lastProductFrom) {
|
||
requestData.filter_last_product_from = this.filters.lastProductFrom;
|
||
}
|
||
if (this.filters.lastProductTo) {
|
||
requestData.filter_last_product_to = this.filters.lastProductTo;
|
||
}
|
||
if (this.filters.activeOnly) {
|
||
requestData.filter_active = 1;
|
||
}
|
||
}
|
||
|
||
// Attribute-specific
|
||
if (searchEntity === 'attributes') {
|
||
if (this.filters.salesMin !== null && this.filters.salesMin !== '') {
|
||
requestData.filter_sales_min = this.filters.salesMin;
|
||
}
|
||
if (this.filters.salesMax !== null && this.filters.salesMax !== '') {
|
||
requestData.filter_sales_max = this.filters.salesMax;
|
||
}
|
||
if (this.filters.turnoverMin !== null && this.filters.turnoverMin !== '') {
|
||
requestData.filter_turnover_min = this.filters.turnoverMin;
|
||
}
|
||
if (this.filters.turnoverMax !== null && this.filters.turnoverMax !== '') {
|
||
requestData.filter_turnover_max = this.filters.turnoverMax;
|
||
}
|
||
if (this.filters.attributeGroup) {
|
||
requestData.filter_attribute_group = this.filters.attributeGroup;
|
||
}
|
||
if (this.filters.isColor) {
|
||
requestData.filter_is_color = 1;
|
||
}
|
||
}
|
||
|
||
// Feature-specific
|
||
if (searchEntity === 'features') {
|
||
if (this.filters.salesMin !== null && this.filters.salesMin !== '') {
|
||
requestData.filter_sales_min = this.filters.salesMin;
|
||
}
|
||
if (this.filters.salesMax !== null && this.filters.salesMax !== '') {
|
||
requestData.filter_sales_max = this.filters.salesMax;
|
||
}
|
||
if (this.filters.turnoverMin !== null && this.filters.turnoverMin !== '') {
|
||
requestData.filter_turnover_min = this.filters.turnoverMin;
|
||
}
|
||
if (this.filters.turnoverMax !== null && this.filters.turnoverMax !== '') {
|
||
requestData.filter_turnover_max = this.filters.turnoverMax;
|
||
}
|
||
if (this.filters.featureGroup) {
|
||
requestData.filter_feature_group = this.filters.featureGroup;
|
||
}
|
||
if (this.filters.isCustom) {
|
||
requestData.filter_is_custom = 1;
|
||
}
|
||
}
|
||
|
||
// CMS-specific
|
||
if (searchEntity === 'cms') {
|
||
if (this.filters.activeOnly) {
|
||
requestData.filter_active = 1;
|
||
}
|
||
if (this.filters.indexable) {
|
||
requestData.filter_indexable = 1;
|
||
}
|
||
}
|
||
|
||
// CMS Categories-specific
|
||
if (searchEntity === 'cms_categories') {
|
||
if (this.filters.activeOnly) {
|
||
requestData.filter_active = 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: requestData,
|
||
success: function(response) {
|
||
self.isLoading = false;
|
||
|
||
if (!response.success) return;
|
||
|
||
// Save to search history if query is not empty and has results
|
||
if (self.searchQuery && self.searchQuery.length >= 2 && response.total > 0) {
|
||
self.addToSearchHistory(searchEntity, self.searchQuery);
|
||
}
|
||
|
||
if (appendMode) {
|
||
self.searchResults = self.searchResults.concat(response.results || []);
|
||
} else {
|
||
self.searchResults = response.results || [];
|
||
}
|
||
self.searchTotal = response.total || 0;
|
||
self.searchOffset = appendMode ? self.searchOffset + (response.results || []).length : (response.results || []).length;
|
||
|
||
self.renderSearchResults(appendMode);
|
||
self.$dropdown.addClass('show');
|
||
},
|
||
error: function() {
|
||
self.isLoading = false;
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Render search results in the dropdown
|
||
*/
|
||
renderSearchResults: function(appendMode) {
|
||
var self = this;
|
||
var trans = this.config.trans || {};
|
||
var $container = this.$dropdown.find('.dropdown-results');
|
||
|
||
// Get selected IDs from current picker (to mark as selected)
|
||
// and hidden IDs from sibling exclude pickers with same entity type (to hide completely)
|
||
var selectedIds = [];
|
||
var hiddenIds = [];
|
||
if (this.activeGroup) {
|
||
var $block = this.$wrapper.find('.target-block[data-block-type="' + this.activeGroup.blockType + '"]');
|
||
var $group = $block.find('.selection-group[data-group-index="' + this.activeGroup.groupIndex + '"]');
|
||
var currentSearchEntity = this.activeGroup.searchEntity;
|
||
var currentExcludeIndex = this.activeGroup.excludeIndex;
|
||
|
||
if (this.activeGroup.section === 'include') {
|
||
// For include section, just get current picker's selections
|
||
var $picker = $group.find('.include-picker');
|
||
$picker.find('.entity-chip').each(function() {
|
||
selectedIds.push(String($(this).data('id')));
|
||
});
|
||
} else {
|
||
// For exclude section, get current picker's selections AND
|
||
// collect IDs from sibling exclude rows with same entity type to hide
|
||
var $currentExcludeRow = $group.find('.exclude-row[data-exclude-index="' + currentExcludeIndex + '"]');
|
||
var $currentPicker = $currentExcludeRow.find('.exclude-picker');
|
||
|
||
// Get selected IDs from current exclude row
|
||
$currentPicker.find('.entity-chip').each(function() {
|
||
selectedIds.push(String($(this).data('id')));
|
||
});
|
||
|
||
// Get hidden IDs from OTHER exclude rows with the same entity type
|
||
$group.find('.exclude-row').each(function() {
|
||
var $row = $(this);
|
||
var rowIndex = parseInt($row.data('excludeIndex'), 10);
|
||
|
||
// Skip current exclude row
|
||
if (rowIndex === currentExcludeIndex) return;
|
||
|
||
var $picker = $row.find('.exclude-picker');
|
||
var rowEntityType = $picker.attr('data-search-entity') || self.activeGroup.blockType;
|
||
|
||
// Only collect if same entity type
|
||
if (rowEntityType === currentSearchEntity) {
|
||
$picker.find('.entity-chip').each(function() {
|
||
hiddenIds.push(String($(this).data('id')));
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Check if this is a product search
|
||
var isProductSearch = this.activeGroup && this.activeGroup.searchEntity === 'products';
|
||
var isListView = this.viewMode === 'list';
|
||
|
||
// Show/hide results header for products in list view
|
||
this.$dropdown.find('.results-header').toggle(isProductSearch && isListView);
|
||
|
||
// Build HTML - filter out items that are hidden (selected in sibling exclude rows)
|
||
var visibleResults = this.searchResults.filter(function(item) {
|
||
return hiddenIds.indexOf(String(item.id)) === -1;
|
||
});
|
||
|
||
// Update count (show visible count and total, noting hidden items if any)
|
||
var hiddenCount = this.searchResults.length - visibleResults.length;
|
||
var countText = visibleResults.length + ' / ' + this.searchTotal + ' results';
|
||
if (hiddenCount > 0) {
|
||
countText += ' (' + hiddenCount + ' hidden)';
|
||
}
|
||
this.$dropdown.find('.results-count').text(countText);
|
||
|
||
var html = '';
|
||
if (visibleResults.length === 0 && !appendMode) {
|
||
html = '<div class="no-results"><i class="icon-search"></i> ' + (trans.no_results || 'No results found') + '</div>';
|
||
} else {
|
||
visibleResults.forEach(function(item) {
|
||
var isSelected = selectedIds.indexOf(String(item.id)) !== -1;
|
||
var itemClass = 'dropdown-item' + (isSelected ? ' selected' : '');
|
||
if (item.type === 'product') itemClass += ' result-item-product';
|
||
|
||
html += '<div class="' + itemClass + '" ';
|
||
html += 'data-id="' + self.escapeAttr(item.id) + '" ';
|
||
html += 'data-name="' + self.escapeAttr(item.name) + '"';
|
||
if (item.image) html += ' data-image="' + self.escapeAttr(item.image) + '"';
|
||
if (item.subtitle) html += ' data-subtitle="' + self.escapeAttr(item.subtitle) + '"';
|
||
html += '>';
|
||
|
||
html += '<span class="result-checkbox"><i class="icon-check"></i></span>';
|
||
|
||
if (item.image) {
|
||
html += '<div class="result-image"><img src="' + self.escapeAttr(item.image) + '" alt=""></div>';
|
||
} else {
|
||
// Entity-specific icons
|
||
var iconClass = 'icon-cube'; // default
|
||
var searchEntity = self.activeGroup ? self.activeGroup.searchEntity : null;
|
||
if (searchEntity === 'categories') iconClass = 'icon-folder';
|
||
else if (searchEntity === 'manufacturers') iconClass = 'icon-building';
|
||
else if (searchEntity === 'suppliers') iconClass = 'icon-truck';
|
||
else if (searchEntity === 'attributes') iconClass = 'icon-paint-brush';
|
||
else if (searchEntity === 'features') iconClass = 'icon-list-ul';
|
||
else if (searchEntity === 'cms') iconClass = 'icon-file-text-o';
|
||
else if (searchEntity === 'cms_categories') iconClass = 'icon-folder-o';
|
||
html += '<div class="result-icon"><i class="' + iconClass + '"></i></div>';
|
||
}
|
||
|
||
html += '<div class="result-info">';
|
||
html += '<div class="result-name">' + self.escapeHtml(item.name) + '</div>';
|
||
if (item.subtitle) {
|
||
// Split multi-line subtitles into separate divs for styling
|
||
var subtitleLines = item.subtitle.split('\n');
|
||
html += '<div class="result-subtitle">';
|
||
subtitleLines.forEach(function(line, idx) {
|
||
var lineClass = idx === 0 ? 'subtitle-line subtitle-line-primary' : 'subtitle-line subtitle-line-secondary';
|
||
html += '<div class="' + lineClass + '">' + self.escapeHtml(line) + '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// Add product-specific columns (price, sale price, stock, sold)
|
||
if (item.type === 'product') {
|
||
if (isListView) {
|
||
// List view: full columns
|
||
// Regular price
|
||
html += '<div class="result-col result-col-price">';
|
||
html += '<span class="col-value">' + (item.regular_price_formatted || item.price_formatted || '') + '</span>';
|
||
html += '</div>';
|
||
|
||
// Sale price (only if discounted)
|
||
if (item.has_discount) {
|
||
html += '<div class="result-col result-col-sale">';
|
||
html += '<span class="col-value">' + (item.price_formatted || '') + '</span>';
|
||
html += '</div>';
|
||
} else {
|
||
html += '<div class="result-col result-col-sale"></div>';
|
||
}
|
||
|
||
// Stock column
|
||
var stockClass = item.stock_status === 'out_of_stock' ? 'stock-out' :
|
||
(item.stock_status === 'low_stock' ? 'stock-low' : 'stock-ok');
|
||
html += '<div class="result-col result-col-stock">';
|
||
html += '<span class="col-value ' + stockClass + '">' + (item.stock_qty !== undefined ? item.stock_qty : '') + '</span>';
|
||
html += '</div>';
|
||
|
||
// Sales column
|
||
html += '<div class="result-col result-col-sales">';
|
||
html += '<span class="col-value">' + (item.sales_qty !== undefined ? item.sales_qty : '0') + '</span>';
|
||
html += '</div>';
|
||
} else {
|
||
// Grid view: compact info line
|
||
var gridStockClass = item.stock_status === 'out_of_stock' ? 'stock-out' :
|
||
(item.stock_status === 'low_stock' ? 'stock-low' : '');
|
||
html += '<div class="result-grid-info">';
|
||
html += '<span class="grid-price">' + (item.price_formatted || '') + '</span>';
|
||
if (item.stock_qty !== undefined) {
|
||
html += '<span class="grid-stock ' + gridStockClass + '">' + item.stock_qty + ' qty</span>';
|
||
}
|
||
if (item.has_discount) {
|
||
html += '<span class="grid-discount">-' + (item.discount_percent || '') + '%</span>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
}
|
||
|
||
html += '</div>';
|
||
});
|
||
}
|
||
|
||
if (appendMode) {
|
||
$container.append(html);
|
||
} else {
|
||
$container.html(html);
|
||
}
|
||
|
||
// Show/hide load more controls and update remaining count
|
||
var hasMore = this.searchResults.length < this.searchTotal;
|
||
var $loadMoreControls = this.$dropdown.find('.load-more-controls');
|
||
$loadMoreControls.toggle(hasMore);
|
||
|
||
if (hasMore) {
|
||
var remaining = this.searchTotal - this.searchResults.length;
|
||
$loadMoreControls.find('.remaining-count').text(remaining);
|
||
|
||
// Update "All" option in dropdown
|
||
var $select = $loadMoreControls.find('.load-more-select');
|
||
var $allOption = $select.find('option[data-all="true"]');
|
||
if ($allOption.length) {
|
||
$allOption.val(remaining).text((trans.all || 'All') + ' (' + remaining + ')');
|
||
} else {
|
||
$select.find('option:last').after('<option value="' + remaining + '" data-all="true">' + (trans.all || 'All') + ' (' + remaining + ')</option>');
|
||
}
|
||
}
|
||
|
||
// Ensure dropdown-actions are visible and history button is deactivated
|
||
this.$dropdown.find('.dropdown-actions').show();
|
||
this.$dropdown.find('.btn-show-history').removeClass('active');
|
||
|
||
// Disable history button if no search history for current entity type
|
||
var entityType = this.activeGroup ? this.activeGroup.searchEntity : null;
|
||
var hasHistory = entityType && this.getSearchHistory(entityType).length > 0;
|
||
this.$dropdown.find('.btn-show-history').prop('disabled', !hasHistory);
|
||
},
|
||
|
||
// NOTE: Tree methods (loadCategoryTree, renderCategoryTree, filterCategoryTree,
|
||
// findTreeDescendants, findTreeAncestors, updateSelectChildrenButtons) are
|
||
// defined in _tree.js which is merged later and takes precedence.
|
||
|
||
// =========================================================================
|
||
// Search History
|
||
// =========================================================================
|
||
|
||
loadSearchHistory: function() {
|
||
try {
|
||
var stored = localStorage.getItem(this.searchHistoryKey);
|
||
this.searchHistory = stored ? JSON.parse(stored) : {};
|
||
} catch (e) {
|
||
this.searchHistory = {};
|
||
}
|
||
},
|
||
|
||
saveSearchHistory: function() {
|
||
try {
|
||
localStorage.setItem(this.searchHistoryKey, JSON.stringify(this.searchHistory));
|
||
} catch (e) {
|
||
// localStorage might be full or unavailable
|
||
}
|
||
},
|
||
|
||
addToSearchHistory: function(entityType, query) {
|
||
if (!query || query.length < 2) return;
|
||
|
||
if (!this.searchHistory[entityType]) {
|
||
this.searchHistory[entityType] = [];
|
||
}
|
||
|
||
var history = this.searchHistory[entityType];
|
||
|
||
// Remove if already exists (will re-add at top)
|
||
var existingIndex = history.indexOf(query);
|
||
if (existingIndex !== -1) {
|
||
history.splice(existingIndex, 1);
|
||
}
|
||
|
||
// Add at beginning
|
||
history.unshift(query);
|
||
|
||
// Trim to max
|
||
if (history.length > this.searchHistoryMax) {
|
||
history = history.slice(0, this.searchHistoryMax);
|
||
}
|
||
|
||
this.searchHistory[entityType] = history;
|
||
this.saveSearchHistory();
|
||
},
|
||
|
||
removeFromSearchHistory: function(entityType, query) {
|
||
if (!this.searchHistory[entityType]) return;
|
||
|
||
var index = this.searchHistory[entityType].indexOf(query);
|
||
if (index !== -1) {
|
||
this.searchHistory[entityType].splice(index, 1);
|
||
this.saveSearchHistory();
|
||
}
|
||
},
|
||
|
||
getSearchHistory: function(entityType) {
|
||
return this.searchHistory[entityType] || [];
|
||
},
|
||
|
||
showSearchHistory: function(entityType) {
|
||
var history = this.getSearchHistory(entityType);
|
||
var trans = this.config.trans || {};
|
||
var $container = this.$dropdown.find('.dropdown-results');
|
||
|
||
// Update header
|
||
this.$dropdown.find('.results-count').text(trans.recent_searches || 'Recent searches');
|
||
|
||
// Hide filters, actions, and results header for history view
|
||
this.$dropdown.find('.dropdown-actions').hide();
|
||
this.$dropdown.find('.filter-panel').removeClass('show');
|
||
this.$dropdown.find('.btn-toggle-filters').removeClass('active');
|
||
this.$dropdown.find('.results-header').hide();
|
||
|
||
if (!history.length) {
|
||
// No history - just do a regular search
|
||
this.performSearch();
|
||
return;
|
||
}
|
||
|
||
// Build history items
|
||
var html = '<div class="search-history-list">';
|
||
for (var i = 0; i < history.length; i++) {
|
||
var query = history[i];
|
||
html += '<div class="history-item" data-query="' + this.escapeAttr(query) + '">';
|
||
html += '<i class="icon-clock-o"></i>';
|
||
html += '<span class="history-query">' + this.escapeHtml(query) + '</span>';
|
||
html += '<button type="button" class="btn-delete-history" title="' + (trans.remove || 'Remove') + '">';
|
||
html += '<i class="icon-times"></i>';
|
||
html += '</button>';
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
|
||
$container.html(html);
|
||
this.$dropdown.addClass('show');
|
||
},
|
||
|
||
// =========================================================================
|
||
// Filter Methods
|
||
// =========================================================================
|
||
|
||
refreshSearch: function() {
|
||
// In tree view mode, re-filter the tree instead of doing a flat AJAX search
|
||
if (this.viewMode === 'tree') {
|
||
this.filterCategoryTree(this.searchQuery || '');
|
||
return;
|
||
}
|
||
|
||
this.searchOffset = 0;
|
||
this.loadMoreCount = 20;
|
||
// Reset load more select to default
|
||
if (this.$dropdown) {
|
||
this.$dropdown.find('.load-more-select').val('20');
|
||
// Remove the dynamic "All" option
|
||
this.$dropdown.find('.load-more-select option[data-all="true"]').remove();
|
||
}
|
||
this.performSearch(false);
|
||
},
|
||
|
||
clearFilters: function() {
|
||
this.refineQuery = '';
|
||
this.refineNegate = false;
|
||
this.filters = {
|
||
inStock: false,
|
||
discounted: false,
|
||
priceMin: null,
|
||
priceMax: null,
|
||
attributes: [],
|
||
features: [],
|
||
// Entity-specific filters
|
||
productCountMin: null,
|
||
productCountMax: null,
|
||
salesMin: null,
|
||
salesMax: null,
|
||
turnoverMin: null,
|
||
turnoverMax: null,
|
||
depth: null,
|
||
hasProducts: false,
|
||
hasDescription: false,
|
||
hasImage: false,
|
||
activeOnly: true,
|
||
attributeGroup: null,
|
||
featureGroup: null,
|
||
dateAddFrom: null,
|
||
dateAddTo: null,
|
||
lastProductFrom: null,
|
||
lastProductTo: null
|
||
};
|
||
|
||
if (this.$dropdown) {
|
||
var trans = this.config.trans || {};
|
||
this.$dropdown.find('.refine-input').val('').attr('placeholder', trans.refine_short || 'Refine...');
|
||
this.$dropdown.find('.btn-clear-refine').hide();
|
||
this.$dropdown.find('.btn-refine-negate').removeClass('active');
|
||
this.$dropdown.find('.filter-in-stock').prop('checked', false);
|
||
this.$dropdown.find('.filter-discounted').prop('checked', false);
|
||
this.$dropdown.find('.filter-price-min').val('');
|
||
this.$dropdown.find('.filter-price-max').val('');
|
||
this.$dropdown.find('.filter-attr-chip').removeClass('active');
|
||
this.$dropdown.find('.filter-feat-chip').removeClass('active');
|
||
this.$dropdown.find('.filter-group-toggle').removeClass('active has-selection');
|
||
this.$dropdown.find('.filter-row-values').hide();
|
||
|
||
// Clear entity-specific filter inputs
|
||
this.$dropdown.find('.filter-product-count-min, .filter-product-count-max').val('');
|
||
this.$dropdown.find('.filter-sales-min, .filter-sales-max').val('');
|
||
this.$dropdown.find('.filter-turnover-min, .filter-turnover-max').val('');
|
||
this.$dropdown.find('.filter-date-add-from, .filter-date-add-to').val('');
|
||
this.$dropdown.find('.filter-last-product-from, .filter-last-product-to').val('');
|
||
this.$dropdown.find('.filter-depth-select').val('');
|
||
this.$dropdown.find('.filter-has-products').prop('checked', false);
|
||
this.$dropdown.find('.filter-has-description').prop('checked', false);
|
||
this.$dropdown.find('.filter-has-image').prop('checked', false);
|
||
this.$dropdown.find('.filter-active-only').prop('checked', true);
|
||
this.$dropdown.find('.filter-attribute-group-select, .filter-feature-group-select').val('');
|
||
}
|
||
|
||
this.refreshSearch();
|
||
},
|
||
|
||
// Reset filters without triggering a search (used when switching entity types)
|
||
resetFiltersWithoutSearch: function() {
|
||
this.refineQuery = '';
|
||
this.refineNegate = false;
|
||
this.filters = {
|
||
inStock: false,
|
||
discounted: false,
|
||
priceMin: null,
|
||
priceMax: null,
|
||
attributes: [],
|
||
features: [],
|
||
productCountMin: null,
|
||
productCountMax: null,
|
||
salesMin: null,
|
||
salesMax: null,
|
||
turnoverMin: null,
|
||
turnoverMax: null,
|
||
depth: null,
|
||
hasProducts: false,
|
||
hasDescription: false,
|
||
hasImage: false,
|
||
activeOnly: true,
|
||
attributeGroup: null,
|
||
featureGroup: null,
|
||
dateAddFrom: null,
|
||
dateAddTo: null,
|
||
lastProductFrom: null,
|
||
lastProductTo: null
|
||
};
|
||
|
||
if (this.$dropdown) {
|
||
var trans = this.config.trans || {};
|
||
this.$dropdown.find('.refine-input').val('').attr('placeholder', trans.refine_short || 'Refine...');
|
||
this.$dropdown.find('.btn-clear-refine').hide();
|
||
this.$dropdown.find('.btn-refine-negate').removeClass('active');
|
||
this.$dropdown.find('.filter-in-stock').prop('checked', false);
|
||
this.$dropdown.find('.filter-discounted').prop('checked', false);
|
||
this.$dropdown.find('.filter-price-min').val('');
|
||
this.$dropdown.find('.filter-price-max').val('');
|
||
this.$dropdown.find('.filter-attr-chip').removeClass('active');
|
||
this.$dropdown.find('.filter-feat-chip').removeClass('active');
|
||
this.$dropdown.find('.filter-group-toggle').removeClass('active has-selection');
|
||
this.$dropdown.find('.filter-row-values').hide();
|
||
this.$dropdown.find('.filter-product-count-min, .filter-product-count-max').val('');
|
||
this.$dropdown.find('.filter-sales-min, .filter-sales-max').val('');
|
||
this.$dropdown.find('.filter-turnover-min, .filter-turnover-max').val('');
|
||
this.$dropdown.find('.filter-date-add-from, .filter-date-add-to').val('');
|
||
this.$dropdown.find('.filter-last-product-from, .filter-last-product-to').val('');
|
||
this.$dropdown.find('.filter-depth-select').val('');
|
||
this.$dropdown.find('.filter-has-products').prop('checked', false);
|
||
this.$dropdown.find('.filter-has-description').prop('checked', false);
|
||
this.$dropdown.find('.filter-has-image').prop('checked', false);
|
||
this.$dropdown.find('.filter-active-only').prop('checked', true);
|
||
this.$dropdown.find('.filter-attribute-group-select, .filter-feature-group-select').val('');
|
||
}
|
||
// Note: Does NOT call refreshSearch() - caller handles search/load
|
||
},
|
||
|
||
updateFilterPanelForEntity: function(entityType) {
|
||
if (!this.$dropdown) {
|
||
return;
|
||
}
|
||
|
||
var $panel = this.$dropdown.find('.filter-panel');
|
||
|
||
// Hide all filter rows first
|
||
$panel.find('.filter-row').hide();
|
||
|
||
// Show/hide tree view option based on entity type
|
||
var $treeOption = this.$dropdown.find('.view-mode-select option.tree-view-option');
|
||
if (entityType === 'categories' || entityType === 'cms_categories') {
|
||
$treeOption.prop('disabled', false).prop('hidden', false);
|
||
// Auto-switch to tree view for categories
|
||
if (this.viewMode !== 'tree') {
|
||
this.viewMode = 'tree';
|
||
this.$dropdown.find('.view-mode-select').val('tree');
|
||
this.$dropdown.removeClass('view-list view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8').addClass('view-tree');
|
||
this.loadCategoryTree();
|
||
} else {
|
||
this.loadCategoryTree();
|
||
}
|
||
} else {
|
||
$treeOption.prop('disabled', true).prop('hidden', true);
|
||
// If currently in tree mode, switch back to list
|
||
if (this.viewMode === 'tree') {
|
||
this.viewMode = 'list';
|
||
this.$dropdown.find('.view-mode-select').val('list');
|
||
this.$dropdown.removeClass('view-tree').addClass('view-list');
|
||
}
|
||
}
|
||
|
||
// Show entity-specific filter row (prepare visibility, but don't auto-expand panel)
|
||
if (entityType === 'products') {
|
||
// Prepare the correct rows to be visible when panel is shown
|
||
$panel.find('.filter-row-quick').show();
|
||
// Show attribute/feature rows if we have cached data
|
||
if (this.filterableData) {
|
||
if (this.filterableData.attributes && this.filterableData.attributes.length > 0) {
|
||
this.$dropdown.find('.filter-row-attributes').show();
|
||
}
|
||
if (this.filterableData.features && this.filterableData.features.length > 0) {
|
||
this.$dropdown.find('.filter-row-features').show();
|
||
}
|
||
}
|
||
} else if (entityType === 'categories') {
|
||
$panel.find('.filter-row-entity-categories').show();
|
||
} else if (entityType === 'manufacturers') {
|
||
$panel.find('.filter-row-entity-manufacturers').show();
|
||
} else if (entityType === 'suppliers') {
|
||
$panel.find('.filter-row-entity-suppliers').show();
|
||
} else if (entityType === 'attributes') {
|
||
$panel.find('.filter-row-entity-attributes').show();
|
||
this.loadAttributeGroups();
|
||
} else if (entityType === 'features') {
|
||
$panel.find('.filter-row-entity-features').show();
|
||
} else if (entityType === 'cms') {
|
||
$panel.find('.filter-row-entity-cms').show();
|
||
} else if (entityType === 'cms_categories') {
|
||
$panel.find('.filter-row-entity-cms-categories').show();
|
||
}
|
||
},
|
||
|
||
loadAttributeGroups: function() {
|
||
var self = this;
|
||
var $select = this.$dropdown.find('.filter-attribute-group-select');
|
||
|
||
// Already loaded?
|
||
if ($select.find('option').length > 1) return;
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'getAttributeGroups',
|
||
trait: 'EntitySelector'
|
||
},
|
||
success: function(response) {
|
||
if (response.success && response.groups) {
|
||
$.each(response.groups, function(i, group) {
|
||
$select.append('<option value="' + group.id + '">' + self.escapeHtml(group.name) + ' (' + group.count + ')</option>');
|
||
});
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
loadFeatureGroups: function() {
|
||
var self = this;
|
||
var $select = this.$dropdown.find('.filter-feature-group-select');
|
||
|
||
// Already loaded?
|
||
if ($select.find('option').length > 1) return;
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'getFeatureGroups',
|
||
trait: 'EntitySelector'
|
||
},
|
||
success: function(response) {
|
||
if (response.success && response.groups) {
|
||
$.each(response.groups, function(i, group) {
|
||
$select.append('<option value="' + group.id + '">' + self.escapeHtml(group.name) + ' (' + group.count + ')</option>');
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
})(jQuery);
|
||
|
||
/**
|
||
* Entity Selector - Filters Module
|
||
* Filter panel, filter state management
|
||
* @partial _filters.js
|
||
*
|
||
* EXTRACTION SOURCE: assets/js/admin/entity-selector.js
|
||
* Lines: 6605-6758 (filter methods)
|
||
*
|
||
* Contains:
|
||
* - clearFilters() - Reset all filters
|
||
* - resetFiltersWithoutSearch() - Reset without triggering search
|
||
* - updateFilterPanelForEntity() - Show/hide filters based on entity type
|
||
* - loadFilterableData() - Load attributes/features for filter panel
|
||
* - renderFilterDropdowns() - Render attribute/feature group toggles
|
||
* - showFilterGroupValues() - Show values for a filter group
|
||
* - hideFilterGroupValues() - Hide filter values row
|
||
* - updateFilterToggleStates() - Update toggle states based on selections
|
||
*/
|
||
|
||
(function($) {
|
||
'use strict';
|
||
|
||
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
|
||
|
||
window._EntitySelectorMixins.filters = {
|
||
|
||
clearFilters: function() {
|
||
this.refineQuery = '';
|
||
this.refineNegate = false;
|
||
this.filters = {
|
||
inStock: false,
|
||
discounted: false,
|
||
priceMin: null,
|
||
priceMax: null,
|
||
attributes: [],
|
||
features: [],
|
||
productCountMin: null,
|
||
productCountMax: null,
|
||
salesMin: null,
|
||
salesMax: null,
|
||
turnoverMin: null,
|
||
turnoverMax: null,
|
||
depth: null,
|
||
hasProducts: false,
|
||
hasDescription: false,
|
||
hasImage: false,
|
||
activeOnly: true,
|
||
attributeGroup: null,
|
||
featureGroup: null,
|
||
dateAddFrom: null,
|
||
dateAddTo: null,
|
||
lastProductFrom: null,
|
||
lastProductTo: null
|
||
};
|
||
|
||
if (this.$dropdown) {
|
||
var trans = this.config.trans || {};
|
||
this.$dropdown.find('.refine-input').val('');
|
||
this.$dropdown.find('.btn-refine-negate').removeClass('active');
|
||
this.$dropdown.find('.filter-in-stock').prop('checked', false);
|
||
this.$dropdown.find('.filter-discounted').prop('checked', false);
|
||
this.$dropdown.find('.filter-price-min, .filter-price-max').val('');
|
||
this.$dropdown.find('.filter-attr-chip, .filter-feat-chip').removeClass('active');
|
||
this.$dropdown.find('.filter-product-count-min, .filter-product-count-max').val('');
|
||
this.$dropdown.find('.filter-sales-min, .filter-sales-max').val('');
|
||
this.$dropdown.find('.filter-depth-select').val('');
|
||
this.$dropdown.find('.filter-has-products').prop('checked', false);
|
||
this.$dropdown.find('.filter-active-only').prop('checked', true);
|
||
}
|
||
|
||
this.refreshSearch();
|
||
},
|
||
|
||
resetFiltersWithoutSearch: function() {
|
||
// Same as clearFilters but doesn't trigger search
|
||
// Used when switching entity types
|
||
this.refineQuery = '';
|
||
this.refineNegate = false;
|
||
this.filters = {
|
||
inStock: false,
|
||
discounted: false,
|
||
priceMin: null,
|
||
priceMax: null,
|
||
attributes: [],
|
||
features: [],
|
||
productCountMin: null,
|
||
productCountMax: null,
|
||
salesMin: null,
|
||
salesMax: null,
|
||
turnoverMin: null,
|
||
turnoverMax: null,
|
||
depth: null,
|
||
hasProducts: false,
|
||
hasDescription: false,
|
||
hasImage: false,
|
||
activeOnly: true,
|
||
attributeGroup: null,
|
||
featureGroup: null,
|
||
dateAddFrom: null,
|
||
dateAddTo: null,
|
||
lastProductFrom: null,
|
||
lastProductTo: null
|
||
};
|
||
},
|
||
|
||
updateFilterPanelForEntity: function(entityType) {
|
||
if (!this.$dropdown) {
|
||
return;
|
||
}
|
||
|
||
var $panel = this.$dropdown.find('.filter-panel');
|
||
|
||
// Hide all entity-specific filter rows
|
||
$panel.find('.filter-row').hide();
|
||
|
||
// Show filters for current entity type
|
||
$panel.find('.filter-row[data-entity="' + entityType + '"]').show();
|
||
$panel.find('.filter-row-entity-' + entityType.replace('_', '-')).show();
|
||
|
||
// Show/hide tree view option based on entity type
|
||
var isCategory = (entityType === 'categories' || entityType === 'cms_categories');
|
||
this.$dropdown.find('.tree-view-option').toggle(isCategory);
|
||
|
||
// Default to tree view for categories (only if currently on list mode)
|
||
if (isCategory && this.viewMode === 'list') {
|
||
this.viewMode = 'tree';
|
||
this.$dropdown.find('.view-mode-select').val('tree');
|
||
this.$dropdown.removeClass('view-list view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8').addClass('view-tree');
|
||
} else if (!isCategory && this.viewMode === 'tree') {
|
||
// If switching away from categories while in tree mode, switch to list
|
||
this.viewMode = 'list';
|
||
this.$dropdown.find('.view-mode-select').val('list');
|
||
this.$dropdown.removeClass('view-tree view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8').addClass('view-list');
|
||
}
|
||
},
|
||
|
||
loadFilterableData: function() {
|
||
var self = this;
|
||
|
||
if (this.filterableData) {
|
||
this.renderFilterDropdowns();
|
||
return;
|
||
}
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'getTargetFilterableAttributes',
|
||
trait: 'EntitySelector'
|
||
},
|
||
dataType: 'json',
|
||
success: function(response) {
|
||
if (response.success && response.data) {
|
||
self.filterableData = response.data;
|
||
self.renderFilterDropdowns();
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
renderFilterDropdowns: function() {
|
||
if (!this.$dropdown || !this.filterableData) return;
|
||
|
||
var self = this;
|
||
|
||
// Render attribute group toggle buttons
|
||
var $attrContainer = this.$dropdown.find('.filter-attributes-container');
|
||
$attrContainer.empty();
|
||
|
||
if (this.filterableData.attributes && this.filterableData.attributes.length > 0) {
|
||
this.filterableData.attributes.forEach(function(group) {
|
||
var html = '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '">';
|
||
html += '<span class="toggle-name">' + group.name + '</span>';
|
||
if (group.count !== undefined) {
|
||
html += '<span class="toggle-count clickable" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '"><i class="icon-eye"></i> ' + group.count + '</span>';
|
||
}
|
||
html += '</button>';
|
||
$attrContainer.append(html);
|
||
});
|
||
this.$dropdown.find('.filter-row-attributes').show();
|
||
}
|
||
|
||
// Render feature group toggle buttons
|
||
var $featContainer = this.$dropdown.find('.filter-features-container');
|
||
$featContainer.empty();
|
||
|
||
if (this.filterableData.features && this.filterableData.features.length > 0) {
|
||
this.filterableData.features.forEach(function(group) {
|
||
var html = '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '">';
|
||
html += '<span class="toggle-name">' + group.name + '</span>';
|
||
if (group.count !== undefined) {
|
||
html += '<span class="toggle-count clickable" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '"><i class="icon-eye"></i> ' + group.count + '</span>';
|
||
}
|
||
html += '</button>';
|
||
$featContainer.append(html);
|
||
});
|
||
this.$dropdown.find('.filter-row-features').show();
|
||
}
|
||
},
|
||
|
||
showFilterGroupValues: function(groupId, type) {
|
||
if (!this.filterableData) return;
|
||
|
||
var self = this;
|
||
var groups = type === 'attribute' ? this.filterableData.attributes : this.filterableData.features;
|
||
var group = groups.find(function(g) { return g.id == groupId; });
|
||
|
||
if (!group) return;
|
||
|
||
// Hide all values rows first, then show the correct one
|
||
this.$dropdown.find('.filter-row-values').hide();
|
||
|
||
// Target the correct values row based on type
|
||
var valuesRowClass = type === 'attribute' ? '.filter-row-attr-values' : '.filter-row-feat-values';
|
||
var $filterRowValues = this.$dropdown.find(valuesRowClass);
|
||
var $valuesContainer = $filterRowValues.find('.filter-values-container');
|
||
$valuesContainer.empty();
|
||
|
||
// Add group label
|
||
var html = '<span class="filter-values-label">' + group.name + ':</span>';
|
||
|
||
// Add chips
|
||
group.values.forEach(function(val) {
|
||
var isActive = type === 'attribute'
|
||
? self.filters.attributes.indexOf(val.id) !== -1
|
||
: self.filters.features.indexOf(val.id) !== -1;
|
||
var activeClass = isActive ? ' active' : '';
|
||
var chipClass = type === 'attribute' ? 'filter-attr-chip' : 'filter-feat-chip';
|
||
var colorStyle = val.color ? ' style="--chip-color: ' + val.color + '"' : '';
|
||
var colorClass = val.color ? ' has-color' : '';
|
||
|
||
html += '<button type="button" class="filter-chip ' + chipClass + activeClass + colorClass + '" data-id="' + val.id + '" data-group-id="' + groupId + '"' + colorStyle + '>';
|
||
if (val.color) {
|
||
html += '<span class="chip-color-dot"></span>';
|
||
}
|
||
html += '<span class="chip-name">' + val.name + '</span>';
|
||
if (val.count !== undefined) {
|
||
html += '<span class="chip-count">(' + val.count + ')</span>';
|
||
}
|
||
html += '</button>';
|
||
});
|
||
|
||
$valuesContainer.html(html);
|
||
|
||
// Add close button as sibling (outside filter-values-container, inside filter-row-values)
|
||
$filterRowValues.find('.btn-close-values').remove();
|
||
$filterRowValues.append('<button type="button" class="btn-close-values"><i class="icon-times"></i></button>');
|
||
$filterRowValues.show();
|
||
|
||
// Scroll into view if needed
|
||
var rowValues = $filterRowValues[0];
|
||
if (rowValues) {
|
||
rowValues.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
},
|
||
|
||
hideFilterGroupValues: function() {
|
||
this.$dropdown.find('.filter-row-values').hide();
|
||
this.$dropdown.find('.filter-group-toggle').removeClass('active');
|
||
},
|
||
|
||
updateFilterToggleStates: function() {
|
||
if (!this.$dropdown || !this.filterableData) return;
|
||
|
||
var self = this;
|
||
|
||
// Update attribute group toggles
|
||
if (this.filterableData.attributes) {
|
||
this.filterableData.attributes.forEach(function(group) {
|
||
var $toggle = self.$dropdown.find('.filter-group-toggle[data-group-id="' + group.id + '"][data-type="attribute"]');
|
||
var hasActiveInGroup = group.values.some(function(val) {
|
||
return self.filters.attributes.indexOf(val.id) !== -1;
|
||
});
|
||
$toggle.toggleClass('has-selection', hasActiveInGroup);
|
||
});
|
||
}
|
||
|
||
// Update feature group toggles
|
||
if (this.filterableData.features) {
|
||
this.filterableData.features.forEach(function(group) {
|
||
var $toggle = self.$dropdown.find('.filter-group-toggle[data-group-id="' + group.id + '"][data-type="feature"]');
|
||
var hasActiveInGroup = group.values.some(function(val) {
|
||
return self.filters.features.indexOf(val.id) !== -1;
|
||
});
|
||
$toggle.toggleClass('has-selection', hasActiveInGroup);
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
})(jQuery);
|
||
|
||
/**
|
||
* Entity Selector - Chips Module
|
||
* Entity chip rendering, selection management, and pattern tag handling
|
||
* @partial _chips.js
|
||
*
|
||
* EXTRACTION SOURCE: assets/js/admin/entity-selector.js
|
||
*
|
||
* Contains:
|
||
* - addSelection() / addSelectionNoUpdate() - Add entity chip to picker
|
||
* - removeSelection() - Remove chip and update state
|
||
* - updateChipsVisibility() - Show/hide based on count
|
||
* - loadExistingSelections() - Load saved values on init
|
||
* - collectPickerEntities() / loadPickerValues() - Entity loading helpers
|
||
* - Pattern tag methods: addPatternTag, getPatternTags, updateDraftTagCount
|
||
* - Single mode: getCurrentSingleSelection, showReplaceConfirmation
|
||
* - Count updates: updateConditionCount, updateGroupCounts, updateGroupTotalCount
|
||
*/
|
||
|
||
(function($) {
|
||
'use strict';
|
||
|
||
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
|
||
|
||
window._EntitySelectorMixins.chips = {
|
||
|
||
// =========================================================================
|
||
// Selection Methods (Entity Chips)
|
||
// =========================================================================
|
||
|
||
addSelection: function($picker, id, name, data) {
|
||
this.addSelectionNoUpdate($picker, id, name, data);
|
||
var $chips = $picker.find('.entity-chips');
|
||
this.updateChipsVisibility($chips);
|
||
},
|
||
|
||
addSelectionNoUpdate: function($picker, id, name, data) {
|
||
var $chips = $picker.find('.entity-chips');
|
||
var $block = $picker.closest('.target-block');
|
||
|
||
// Check for global single mode (only ONE item across ALL entity types)
|
||
var globalMode = this.config.mode || 'multi';
|
||
|
||
if (globalMode === 'single') {
|
||
// Clear ALL selections in ALL blocks (across all entity types)
|
||
this.$wrapper.find('.entity-chips .entity-chip').remove();
|
||
// Clear all selected states in dropdown
|
||
if (this.$dropdown) {
|
||
this.$dropdown.find('.dropdown-item.selected, .tree-item.selected').removeClass('selected');
|
||
}
|
||
// Clear tab badges (since we're clearing other blocks)
|
||
this.$wrapper.find('.target-block-tab .tab-badge').remove();
|
||
this.$wrapper.find('.target-block-tab').removeClass('has-data');
|
||
} else {
|
||
// Check if this block is in per-block single mode
|
||
var blockMode = $block.data('mode') || 'multi';
|
||
|
||
// In per-block single mode, clear chips in THIS block only
|
||
if (blockMode === 'single') {
|
||
$chips.find('.entity-chip').remove();
|
||
// Also deselect all items in dropdown
|
||
if (this.$dropdown) {
|
||
this.$dropdown.find('.dropdown-item.selected, .tree-item.selected').removeClass('selected');
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($chips.find('.entity-chip[data-id="' + id + '"]').length) {
|
||
return;
|
||
}
|
||
|
||
var html = '<span class="entity-chip" data-id="' + this.escapeAttr(id) + '">';
|
||
|
||
if (data && data.image) {
|
||
html += '<span class="chip-icon"><img src="' + this.escapeAttr(data.image) + '" alt=""></span>';
|
||
}
|
||
|
||
html += '<span class="chip-name">' + this.escapeHtml(name) + '</span>';
|
||
html += '<button type="button" class="chip-remove" title="Remove"><i class="icon-times"></i></button>';
|
||
html += '</span>';
|
||
|
||
$chips.append(html);
|
||
},
|
||
|
||
removeSelection: function($picker, id) {
|
||
var $chips = $picker.find('.entity-chips');
|
||
$picker.find('.entity-chip[data-id="' + id + '"]').remove();
|
||
this.updateChipsVisibility($chips);
|
||
},
|
||
|
||
updateChipsVisibility: function($chips) {
|
||
var self = this;
|
||
var trans = this.config.trans || {};
|
||
var $picker = $chips.closest('.value-picker');
|
||
var $allChips = $chips.find('.entity-chip');
|
||
var totalCount = $allChips.length;
|
||
|
||
// If no chips, remove the wrapper entirely
|
||
var $existingWrapper = $chips.closest('.chips-wrapper');
|
||
if (totalCount === 0) {
|
||
if ($existingWrapper.length) {
|
||
// Move chips out of wrapper before removing
|
||
$existingWrapper.before($chips);
|
||
$existingWrapper.remove();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Ensure chips wrapper structure exists
|
||
this.ensureChipsWrapper($chips);
|
||
|
||
var $wrapper = $chips.closest('.chips-wrapper');
|
||
var $toolbar = $wrapper.find('.chips-toolbar');
|
||
var $loadMore = $wrapper.find('.chips-load-more');
|
||
|
||
// Get current search filter
|
||
var searchTerm = $toolbar.find('.chips-search-input').val() || '';
|
||
searchTerm = searchTerm.toLowerCase().trim();
|
||
|
||
// Filter and paginate chips
|
||
var visibleCount = 0;
|
||
var filteredCount = 0;
|
||
var isExpanded = $chips.hasClass('chips-expanded');
|
||
var maxVisible = isExpanded ? 999999 : (this.maxVisibleChips || 12);
|
||
|
||
$allChips.each(function() {
|
||
var $chip = $(this);
|
||
var chipName = ($chip.find('.chip-name').text() || '').toLowerCase();
|
||
var matchesFilter = !searchTerm || chipName.indexOf(searchTerm) !== -1;
|
||
|
||
$chip.removeClass('chip-filtered-out chip-paginated-out');
|
||
|
||
if (!matchesFilter) {
|
||
$chip.addClass('chip-filtered-out');
|
||
} else {
|
||
filteredCount++;
|
||
if (filteredCount > maxVisible) {
|
||
$chip.addClass('chip-paginated-out');
|
||
} else {
|
||
visibleCount++;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Update toolbar (always show when we have chips)
|
||
$toolbar.addClass('has-chips');
|
||
this.updateChipsToolbar($toolbar, totalCount, filteredCount, searchTerm);
|
||
|
||
// Update load more button
|
||
var hiddenByPagination = filteredCount - visibleCount;
|
||
if (hiddenByPagination > 0 && !isExpanded) {
|
||
var moreText = (trans.show_more || 'Show {count} more').replace('{count}', hiddenByPagination);
|
||
$loadMore.html(
|
||
'<button type="button" class="btn-load-more">' +
|
||
'<i class="icon-chevron-down"></i> ' + moreText +
|
||
'</button>'
|
||
).show();
|
||
} else if (isExpanded && filteredCount > (this.maxVisibleChips || 12)) {
|
||
var lessText = trans.show_less || 'Show less';
|
||
$loadMore.html(
|
||
'<button type="button" class="btn-load-more">' +
|
||
'<i class="icon-chevron-up"></i> ' + lessText +
|
||
'</button>'
|
||
).show();
|
||
} else {
|
||
$loadMore.hide();
|
||
}
|
||
},
|
||
|
||
ensureChipsWrapper: function($chips) {
|
||
// Check if already wrapped
|
||
if ($chips.closest('.chips-wrapper').length) {
|
||
return;
|
||
}
|
||
|
||
var trans = this.config.trans || {};
|
||
var $picker = $chips.closest('.value-picker');
|
||
|
||
// Create wrapper structure - simple inline toolbar
|
||
var wrapperHtml = '<div class="chips-wrapper">' +
|
||
'<div class="chips-toolbar">' +
|
||
'<i class="icon-search"></i>' +
|
||
'<input type="text" class="chips-search-input" placeholder="' + (trans.filter || 'Filter') + '...">' +
|
||
'<span class="chips-count"></span>' +
|
||
'<button type="button" class="btn-chips-clear" title="' + (trans.clear_all || 'Clear all') + '">' +
|
||
'<i class="icon-trash"></i> <span class="clear-text">' + (trans.clear_all || 'Clear all') + '</span>' +
|
||
'</button>' +
|
||
'</div>' +
|
||
'<div class="chips-load-more" style="display:none;"></div>' +
|
||
'</div>';
|
||
|
||
var $wrapper = $(wrapperHtml);
|
||
|
||
// Insert wrapper before chips and move chips inside
|
||
$chips.before($wrapper);
|
||
$wrapper.find('.chips-toolbar').after($chips);
|
||
$wrapper.append($wrapper.find('.chips-load-more'));
|
||
|
||
// Bind toolbar events
|
||
this.bindChipsToolbarEvents($wrapper);
|
||
},
|
||
|
||
bindChipsToolbarEvents: function($wrapper) {
|
||
var self = this;
|
||
var $chips = $wrapper.find('.entity-chips');
|
||
var searchTimeout;
|
||
|
||
// Search input
|
||
$wrapper.on('input', '.chips-search-input', function() {
|
||
clearTimeout(searchTimeout);
|
||
searchTimeout = setTimeout(function() {
|
||
// Collapse when searching to show filtered results from start
|
||
$chips.removeClass('chips-expanded');
|
||
self.updateChipsVisibility($chips);
|
||
}, 150);
|
||
});
|
||
|
||
// Clear all button
|
||
$wrapper.on('click', '.btn-chips-clear', function() {
|
||
var searchTerm = $wrapper.find('.chips-search-input').val() || '';
|
||
var $chipsToRemove;
|
||
|
||
if (searchTerm.trim()) {
|
||
// Remove only filtered (visible) chips
|
||
$chipsToRemove = $chips.find('.entity-chip:not(.chip-filtered-out)');
|
||
} else {
|
||
// Remove all chips
|
||
$chipsToRemove = $chips.find('.entity-chip');
|
||
}
|
||
|
||
$chipsToRemove.each(function() {
|
||
$(this).find('.chip-remove').trigger('click');
|
||
});
|
||
|
||
// Clear search
|
||
$wrapper.find('.chips-search-input').val('');
|
||
self.updateChipsVisibility($chips);
|
||
});
|
||
|
||
// Load more / show less
|
||
$wrapper.on('click', '.btn-load-more', function() {
|
||
if ($chips.hasClass('chips-expanded')) {
|
||
$chips.removeClass('chips-expanded');
|
||
} else {
|
||
$chips.addClass('chips-expanded');
|
||
}
|
||
self.updateChipsVisibility($chips);
|
||
});
|
||
},
|
||
|
||
updateChipsToolbar: function($toolbar, totalCount, filteredCount, searchTerm) {
|
||
var trans = this.config.trans || {};
|
||
var $count = $toolbar.find('.chips-count');
|
||
var $clearBtn = $toolbar.find('.btn-chips-clear');
|
||
var $clearText = $clearBtn.find('.clear-text');
|
||
|
||
// Update count display
|
||
if (searchTerm) {
|
||
$count.addClass('has-filter').html(
|
||
'<span class="count-filtered">' + filteredCount + '</span>' +
|
||
'<span class="count-separator">/</span>' +
|
||
'<span class="count-total">' + totalCount + '</span>'
|
||
);
|
||
$clearText.text((trans.clear || 'Clear') + ' ' + filteredCount);
|
||
} else {
|
||
$count.removeClass('has-filter').html(totalCount);
|
||
$clearText.text(trans.clear_all || 'Clear all');
|
||
}
|
||
|
||
// Show/hide clear button
|
||
if (searchTerm && filteredCount === 0) {
|
||
$clearBtn.hide();
|
||
} else if (totalCount > 0) {
|
||
$clearBtn.show();
|
||
} else {
|
||
$clearBtn.hide();
|
||
}
|
||
},
|
||
|
||
// =========================================================================
|
||
// Loading/Initialization
|
||
// =========================================================================
|
||
|
||
loadExistingSelections: function() {
|
||
var self = this;
|
||
|
||
// Collect all entity IDs to load, grouped by entity type
|
||
var entitiesToLoad = {}; // { entity_type: { ids: [], pickers: [] } }
|
||
|
||
this.$wrapper.find('.selection-group').each(function() {
|
||
var $group = $(this);
|
||
var $block = $group.closest('.target-block');
|
||
var blockType = $block.data('blockType');
|
||
|
||
// Load include values
|
||
var $includePicker = $group.find('.include-picker');
|
||
self.collectPickerEntities($includePicker, blockType, entitiesToLoad);
|
||
|
||
// Enhance the include method select if not already enhanced
|
||
self.enhanceMethodSelect($group.find('.include-method-select'));
|
||
|
||
// Load exclude values from each exclude row
|
||
$group.find('.exclude-row').each(function() {
|
||
var $excludeRow = $(this);
|
||
self.collectPickerEntities($excludeRow.find('.exclude-picker'), blockType, entitiesToLoad);
|
||
|
||
// Enhance the exclude method select if not already enhanced
|
||
self.enhanceMethodSelect($excludeRow.find('.exclude-method-select'));
|
||
});
|
||
|
||
// Lock method selector if excludes exist
|
||
var hasExcludes = $group.find('.group-excludes.has-excludes').length > 0;
|
||
if (hasExcludes) {
|
||
self.updateMethodSelectorLock($group, true);
|
||
}
|
||
});
|
||
|
||
// Build bulk request: { entityType: [uniqueIds], ... }
|
||
var bulkRequest = {};
|
||
var hasEntities = false;
|
||
|
||
Object.keys(entitiesToLoad).forEach(function(entityType) {
|
||
var data = entitiesToLoad[entityType];
|
||
if (data.ids.length === 0) return;
|
||
|
||
// Deduplicate IDs
|
||
var uniqueIds = data.ids.filter(function(id, index, arr) {
|
||
return arr.indexOf(id) === index;
|
||
});
|
||
|
||
bulkRequest[entityType] = uniqueIds;
|
||
hasEntities = true;
|
||
});
|
||
|
||
// Skip AJAX if no entities to load
|
||
if (!hasEntities) {
|
||
return;
|
||
}
|
||
|
||
// Single bulk AJAX call for all entity types
|
||
$.ajax({
|
||
url: self.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'getTargetEntitiesByIdsBulk',
|
||
trait: 'EntitySelector',
|
||
entities: JSON.stringify(bulkRequest)
|
||
},
|
||
success: function(response) {
|
||
if (!response.success || !response.entities) {
|
||
return;
|
||
}
|
||
|
||
// Process each entity type's results
|
||
Object.keys(entitiesToLoad).forEach(function(entityType) {
|
||
var data = entitiesToLoad[entityType];
|
||
var entities = response.entities[entityType] || [];
|
||
|
||
// Build a map of id -> entity for quick lookup
|
||
var entityMap = {};
|
||
entities.forEach(function(entity) {
|
||
entityMap[entity.id] = entity;
|
||
});
|
||
|
||
// Update each picker that requested this entity type
|
||
data.pickers.forEach(function(pickerData) {
|
||
var $picker = pickerData.$picker;
|
||
var $chips = $picker.find('.entity-chips');
|
||
var $dataInput = $picker.find('.include-values-data, .exclude-values-data');
|
||
var validIds = [];
|
||
|
||
// Replace loading chips with real data
|
||
pickerData.ids.forEach(function(id) {
|
||
var $loadingChip = $chips.find('.entity-chip-loading[data-id="' + id + '"]');
|
||
if (entityMap[id]) {
|
||
var entity = entityMap[id];
|
||
validIds.push(entity.id);
|
||
|
||
// Create real chip
|
||
var html = '<span class="entity-chip" data-id="' + self.escapeAttr(entity.id) + '">';
|
||
if (entity.image) {
|
||
html += '<span class="chip-icon"><img src="' + self.escapeAttr(entity.image) + '" alt=""></span>';
|
||
}
|
||
html += '<span class="chip-name">' + self.escapeHtml(entity.name) + '</span>';
|
||
html += '<button type="button" class="chip-remove" title="Remove"><i class="icon-times"></i></button>';
|
||
html += '</span>';
|
||
|
||
$loadingChip.replaceWith(html);
|
||
} else {
|
||
// Entity not found, remove loading chip
|
||
$loadingChip.remove();
|
||
}
|
||
});
|
||
|
||
// Update chips visibility
|
||
self.updateChipsVisibility($chips);
|
||
|
||
// If some entities were not found, update the hidden input
|
||
if (validIds.length !== pickerData.ids.length) {
|
||
$dataInput.val(JSON.stringify(validIds));
|
||
self.serializeAllBlocks();
|
||
}
|
||
|
||
self.updateBlockStatus($picker.closest('.target-block'));
|
||
});
|
||
});
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Collect entity IDs from a picker for bulk loading
|
||
* Also shows loading placeholders for entity_search types
|
||
*/
|
||
collectPickerEntities: function($picker, blockType, entitiesToLoad) {
|
||
if (!$picker.length) {
|
||
return;
|
||
}
|
||
|
||
var self = this;
|
||
var $dataInput = $picker.find('.include-values-data, .exclude-values-data');
|
||
if (!$dataInput.length) {
|
||
return;
|
||
}
|
||
|
||
var valueType = $picker.attr('data-value-type');
|
||
var rawValue = $dataInput.val() || '[]';
|
||
|
||
var values = [];
|
||
try {
|
||
values = JSON.parse(rawValue);
|
||
} catch (e) {
|
||
return;
|
||
}
|
||
|
||
// Handle non-entity types synchronously
|
||
if (valueType === 'multi_numeric_range') {
|
||
if (!Array.isArray(values) || values.length === 0) return;
|
||
|
||
var $chipsContainer = $picker.find('.multi-range-chips');
|
||
values.forEach(function(range) {
|
||
if (!range || (range.min === null && range.max === null)) return;
|
||
|
||
var chipText = '';
|
||
if (range.min !== null && range.max !== null) {
|
||
chipText = range.min + ' - ' + range.max;
|
||
} else if (range.min !== null) {
|
||
chipText = '≥ ' + range.min;
|
||
} else {
|
||
chipText = '≤ ' + range.max;
|
||
}
|
||
|
||
var $chip = $('<span>', {
|
||
class: 'range-chip',
|
||
'data-min': range.min !== null ? range.min : '',
|
||
'data-max': range.max !== null ? range.max : ''
|
||
});
|
||
$chip.append($('<span>', { class: 'range-chip-text', text: chipText }));
|
||
$chip.append($('<button>', {
|
||
type: 'button',
|
||
class: 'btn-remove-range',
|
||
html: '<i class="icon-times"></i>'
|
||
}));
|
||
|
||
$chipsContainer.append($chip);
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (valueType === 'multi_select_tiles') {
|
||
if (!Array.isArray(values) || values.length === 0) return;
|
||
|
||
values.forEach(function(key) {
|
||
$picker.find('.tile-option[data-value="' + key + '"]').addClass('selected');
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (valueType === 'combination_attributes') {
|
||
if (typeof values !== 'object' || values === null || Object.keys(values).length === 0) {
|
||
self.loadCombinationAttributeGroups($picker);
|
||
return;
|
||
}
|
||
|
||
$dataInput.val(JSON.stringify(values));
|
||
self.loadCombinationAttributeGroups($picker);
|
||
return;
|
||
}
|
||
|
||
if (!values.length) {
|
||
return;
|
||
}
|
||
|
||
if (valueType === 'pattern') {
|
||
values.forEach(function(item) {
|
||
if (typeof item === 'string' && item) {
|
||
self.addPatternTag($picker, item, false);
|
||
} else if (item && item.pattern) {
|
||
self.addPatternTag($picker, item.pattern, item.caseSensitive === true);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// For entity_search type - show loading placeholders and collect for bulk load
|
||
var searchEntity = $picker.attr('data-search-entity') || blockType;
|
||
var $chips = $picker.find('.entity-chips');
|
||
|
||
// Get icon for entity type
|
||
var entityIcon = this.getEntityTypeIcon(searchEntity);
|
||
|
||
// Show loading placeholders with entity-specific icons
|
||
values.forEach(function(id) {
|
||
var html = '<span class="entity-chip entity-chip-loading" data-id="' + self.escapeAttr(id) + '">';
|
||
html += '<span class="chip-icon"><i class="' + entityIcon + ' icon-spin-pulse"></i></span>';
|
||
html += '<span class="chip-name">Loading...</span>';
|
||
html += '</span>';
|
||
$chips.append(html);
|
||
});
|
||
|
||
// Collect for bulk load
|
||
if (!entitiesToLoad[searchEntity]) {
|
||
entitiesToLoad[searchEntity] = { ids: [], pickers: [] };
|
||
}
|
||
entitiesToLoad[searchEntity].ids = entitiesToLoad[searchEntity].ids.concat(values);
|
||
entitiesToLoad[searchEntity].pickers.push({
|
||
$picker: $picker,
|
||
ids: values
|
||
});
|
||
},
|
||
|
||
loadPickerValues: function($picker, blockType) {
|
||
// This function is now only used for dynamic loading (not initial load)
|
||
// Initial load uses collectPickerEntities + bulk AJAX
|
||
if (!$picker.length) return;
|
||
|
||
var self = this;
|
||
var $dataInput = $picker.find('.include-values-data, .exclude-values-data');
|
||
if (!$dataInput.length) return;
|
||
|
||
var valueType = $picker.attr('data-value-type');
|
||
var values = [];
|
||
try {
|
||
values = JSON.parse($dataInput.val() || '[]');
|
||
} catch (e) {
|
||
return;
|
||
}
|
||
|
||
// Handle empty/missing values based on type
|
||
if (valueType === 'multi_numeric_range') {
|
||
// For multi_numeric_range, values is an array of {min, max} objects
|
||
if (!Array.isArray(values) || values.length === 0) return;
|
||
|
||
var $chipsContainer = $picker.find('.multi-range-chips');
|
||
values.forEach(function(range) {
|
||
if (!range || (range.min === null && range.max === null)) return;
|
||
|
||
var chipText = '';
|
||
if (range.min !== null && range.max !== null) {
|
||
chipText = range.min + ' - ' + range.max;
|
||
} else if (range.min !== null) {
|
||
chipText = '≥ ' + range.min;
|
||
} else {
|
||
chipText = '≤ ' + range.max;
|
||
}
|
||
|
||
var $chip = $('<span>', {
|
||
class: 'range-chip',
|
||
'data-min': range.min !== null ? range.min : '',
|
||
'data-max': range.max !== null ? range.max : ''
|
||
});
|
||
$chip.append($('<span>', { class: 'range-chip-text', text: chipText }));
|
||
$chip.append($('<button>', {
|
||
type: 'button',
|
||
class: 'btn-remove-range',
|
||
html: '<i class="icon-times"></i>'
|
||
}));
|
||
|
||
$chipsContainer.append($chip);
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (valueType === 'multi_select_tiles') {
|
||
// For multi_select_tiles, values is an array of selected keys
|
||
if (!Array.isArray(values) || values.length === 0) return;
|
||
|
||
values.forEach(function(key) {
|
||
$picker.find('.tile-option[data-value="' + key + '"]').addClass('selected');
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (valueType === 'combination_attributes') {
|
||
// For combination_attributes, values is an object: { groupId: [valueId1, valueId2], ... }
|
||
if (typeof values !== 'object' || values === null || Object.keys(values).length === 0) {
|
||
// Still need to load the attribute groups UI
|
||
self.loadCombinationAttributeGroups($picker);
|
||
return;
|
||
}
|
||
|
||
// Store data in hidden input for later restoration
|
||
$dataInput.val(JSON.stringify(values));
|
||
|
||
// Load attribute groups and values will be restored after loading
|
||
self.loadCombinationAttributeGroups($picker);
|
||
return;
|
||
}
|
||
|
||
if (!values.length) return;
|
||
|
||
// Handle pattern type - load as tags
|
||
// Supports both old format (array of strings) and new format (array of {pattern, caseSensitive})
|
||
if (valueType === 'pattern') {
|
||
values.forEach(function(item) {
|
||
if (typeof item === 'string' && item) {
|
||
// Old format: just a string (default to case-insensitive)
|
||
self.addPatternTag($picker, item, false);
|
||
} else if (item && item.pattern) {
|
||
// New format: object with pattern and caseSensitive
|
||
self.addPatternTag($picker, item.pattern, item.caseSensitive === true);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Handle entity_search type - load via AJAX
|
||
var searchEntity = $picker.attr('data-search-entity') || blockType;
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'getTargetEntitiesByIds',
|
||
trait: 'EntitySelector',
|
||
entity_type: searchEntity,
|
||
ids: JSON.stringify(values)
|
||
},
|
||
success: function(response) {
|
||
if (response.success && response.entities) {
|
||
// Track which IDs were actually found (entities may have been deleted)
|
||
var validIds = [];
|
||
response.entities.forEach(function(entity) {
|
||
// Use addSelectionNoUpdate to avoid multiple visibility updates
|
||
self.addSelectionNoUpdate($picker, entity.id, entity.name, entity);
|
||
validIds.push(entity.id);
|
||
});
|
||
|
||
// Update chips visibility once after all chips are added
|
||
var $chips = $picker.find('.entity-chips');
|
||
self.updateChipsVisibility($chips);
|
||
|
||
// If some entities were not found, update the hidden input to remove orphaned IDs
|
||
if (validIds.length !== values.length) {
|
||
$dataInput.val(JSON.stringify(validIds));
|
||
// Re-serialize to update the main form data
|
||
self.serializeAllBlocks();
|
||
}
|
||
|
||
self.updateBlockStatus($picker.closest('.target-block'));
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
// =========================================================================
|
||
// Pattern Tag Methods
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Add a pattern tag to the pattern chips container
|
||
*/
|
||
addPatternTag: function($wrapper, pattern, caseSensitive) {
|
||
var trans = this.config.trans || {};
|
||
var $chipsContainer = $wrapper.find('.pattern-chips');
|
||
// Default to case-insensitive (false)
|
||
var isCaseSensitive = caseSensitive === true;
|
||
var caseTitle = isCaseSensitive
|
||
? (trans.case_sensitive || 'Case sensitive - click to toggle')
|
||
: (trans.case_insensitive || 'Case insensitive - click to toggle');
|
||
|
||
var html = '<div class="pattern-tag' + (isCaseSensitive ? ' case-sensitive' : '') + '" data-pattern="' + this.escapeAttr(pattern) + '" data-case-sensitive="' + (isCaseSensitive ? '1' : '0') + '">';
|
||
html += '<button type="button" class="btn-toggle-case" title="' + this.escapeAttr(caseTitle) + '">';
|
||
html += '<span class="case-icon">' + (isCaseSensitive ? 'Aa' : 'aa') + '</span>';
|
||
html += '</button>';
|
||
html += '<span class="pattern-tag-text">' + this.escapeHtml(pattern) + '</span>';
|
||
html += '<button type="button" class="btn-remove-pattern" title="' + this.escapeAttr(trans.remove_pattern || 'Remove pattern') + '"><i class="icon-trash"></i></button>';
|
||
html += '</div>';
|
||
$chipsContainer.append(html);
|
||
},
|
||
|
||
/**
|
||
* Get all pattern tags from a wrapper
|
||
* Returns array of objects: { pattern: string, caseSensitive: boolean }
|
||
*/
|
||
getPatternTags: function($wrapper) {
|
||
var patterns = [];
|
||
// Exclude draft-tag which is the input field, not a saved pattern
|
||
$wrapper.find('.pattern-tag:not(.draft-tag)').each(function() {
|
||
var pattern = $(this).data('pattern');
|
||
var caseSensitive = $(this).data('caseSensitive') === 1 || $(this).data('caseSensitive') === '1';
|
||
if (pattern) {
|
||
patterns.push({
|
||
pattern: pattern,
|
||
caseSensitive: caseSensitive
|
||
});
|
||
}
|
||
});
|
||
return patterns;
|
||
},
|
||
|
||
/**
|
||
* Update the match count displayed in the draft tag while typing
|
||
* Shows live preview with current case sensitivity setting
|
||
*/
|
||
updateDraftTagCount: function($draftTag, pattern, caseSensitive) {
|
||
var self = this;
|
||
var $matchCount = $draftTag.find('.pattern-match-count');
|
||
var $countValue = $matchCount.find('.count-value');
|
||
|
||
// Get entity type from block
|
||
var $block = $draftTag.closest('.target-block');
|
||
var entityType = $block.data('blockType') || 'products';
|
||
|
||
// Show loading - keep eye icon, update count value
|
||
$countValue.html('<i class="icon-spinner icon-spin"></i>');
|
||
$matchCount.show();
|
||
|
||
// Store pattern for click handler
|
||
$matchCount.data('pattern', pattern);
|
||
$matchCount.data('caseSensitive', caseSensitive);
|
||
$matchCount.data('entityType', entityType);
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'countPatternMatches',
|
||
trait: 'EntitySelector',
|
||
pattern: pattern,
|
||
field: 'name',
|
||
entity_type: entityType,
|
||
case_sensitive: caseSensitive ? 1 : 0
|
||
},
|
||
success: function(response) {
|
||
if (response.success) {
|
||
var count = parseInt(response.count, 10) || 0;
|
||
$countValue.text(count);
|
||
$matchCount.show();
|
||
// Add visual feedback based on count
|
||
$matchCount.removeClass('count-zero count-found');
|
||
$matchCount.addClass(count === 0 ? 'count-zero' : 'count-found');
|
||
// Store count for preview
|
||
$matchCount.data('count', count);
|
||
// Update group total count to reflect draft pattern in calculation
|
||
var $group = $draftTag.closest('.selection-group');
|
||
if ($group.length) {
|
||
self.updateGroupTotalCount($group);
|
||
}
|
||
} else {
|
||
$countValue.text('?');
|
||
$matchCount.show();
|
||
}
|
||
},
|
||
error: function() {
|
||
$countValue.text('?');
|
||
$matchCount.show();
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Update condition count with a pending pattern (typed but not yet added as tag)
|
||
* This shows a live preview of what the count would be if the user pressed Enter
|
||
*/
|
||
updateConditionCountWithPendingPattern: function($row, pendingPattern) {
|
||
var self = this;
|
||
var trans = this.config.trans || {};
|
||
|
||
// Find the count element - in method-selector-wrapper for include, in exclude-header-row for exclude
|
||
var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row > .condition-match-count').first();
|
||
if (!$countEl.length) return;
|
||
|
||
var isExclude = $row.hasClass('exclude-row');
|
||
var $methodSelect = isExclude
|
||
? $row.find('.exclude-method-select')
|
||
: $row.find('.include-method-select');
|
||
|
||
var method = $methodSelect.val();
|
||
if (!method) {
|
||
$countEl.hide();
|
||
return;
|
||
}
|
||
|
||
var $picker = isExclude
|
||
? $row.find('.exclude-picker')
|
||
: $row.find('.include-picker');
|
||
|
||
var valueType = $picker.data('valueType') || 'none';
|
||
|
||
// Only process for pattern value types
|
||
if (valueType !== 'pattern') {
|
||
return;
|
||
}
|
||
|
||
// Get existing pattern tags
|
||
var values = this.getPatternTags($picker);
|
||
|
||
// Add the pending pattern as a temporary tag (case-insensitive by default)
|
||
if (pendingPattern) {
|
||
values.push({ pattern: pendingPattern, caseSensitive: false });
|
||
}
|
||
|
||
if (values.length === 0) {
|
||
$countEl.hide();
|
||
return;
|
||
}
|
||
|
||
var $block = $row.closest('.target-block');
|
||
var blockType = $block.data('blockType') || 'products';
|
||
|
||
// Show loading
|
||
$countEl.find('.preview-count').html('<i class="icon-spinner icon-spin"></i>');
|
||
$countEl.removeClass('clickable no-matches').show();
|
||
|
||
// Store condition data on badge for popover
|
||
$countEl.data('conditionData', {
|
||
method: method,
|
||
values: values,
|
||
blockType: blockType,
|
||
isExclude: isExclude
|
||
});
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'countConditionMatches',
|
||
trait: 'EntitySelector',
|
||
method: method,
|
||
values: JSON.stringify(values),
|
||
block_type: blockType
|
||
},
|
||
success: function(response) {
|
||
if (response && response.success) {
|
||
var count = response.count || 0;
|
||
$countEl.removeClass('no-matches clickable');
|
||
if (count === 0) {
|
||
$countEl.find('.preview-count').text(count);
|
||
$countEl.addClass('no-matches').show();
|
||
} else {
|
||
$countEl.find('.preview-count').text(count);
|
||
$countEl.addClass('clickable').show();
|
||
}
|
||
} else {
|
||
$countEl.hide().removeClass('clickable');
|
||
}
|
||
},
|
||
error: function() {
|
||
$countEl.hide().removeClass('clickable');
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Fetch pattern match count via AJAX
|
||
*/
|
||
fetchPatternMatchCount: function($picker, pattern, $countEl) {
|
||
// Determine field type from method select
|
||
// Check if we're in an exclude row first, then fall back to include
|
||
var $excludeRow = $picker.closest('.exclude-row');
|
||
var $methodSelect;
|
||
if ($excludeRow.length) {
|
||
$methodSelect = $excludeRow.find('.exclude-method-select');
|
||
} else {
|
||
var $group = $picker.closest('.selection-group');
|
||
$methodSelect = $group.find('.include-method-select');
|
||
}
|
||
var method = $methodSelect.val() || '';
|
||
var field = method.indexOf('reference') !== -1 ? 'reference' : 'name';
|
||
|
||
// Get entity type from block
|
||
var $block = $picker.closest('.target-block');
|
||
var entityType = $block.data('blockType') || 'products';
|
||
|
||
// Show loading state
|
||
$countEl.find('.preview-count').html('<i class="icon-spinner icon-spin"></i>');
|
||
$countEl.removeClass('clickable no-matches').show();
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'countPatternMatches',
|
||
trait: 'EntitySelector',
|
||
pattern: pattern,
|
||
field: field,
|
||
entity_type: entityType,
|
||
case_sensitive: 0
|
||
},
|
||
success: function(response) {
|
||
if (response && response.success) {
|
||
var count = response.count || 0;
|
||
$countEl.find('.preview-count').text(count);
|
||
$countEl.removeClass('no-matches clickable').show();
|
||
if (count === 0) {
|
||
$countEl.addClass('no-matches');
|
||
} else {
|
||
$countEl.addClass('clickable');
|
||
}
|
||
} else {
|
||
$countEl.hide();
|
||
}
|
||
},
|
||
error: function() {
|
||
$countEl.hide();
|
||
}
|
||
});
|
||
},
|
||
|
||
// =========================================================================
|
||
// Picker Value Extraction
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Get values from a picker based on its type
|
||
*/
|
||
getPickerValues: function($picker, valueType) {
|
||
switch (valueType) {
|
||
case 'entity_search':
|
||
var ids = [];
|
||
$picker.find('.entity-chip').each(function() {
|
||
var id = $(this).data('id');
|
||
if (id) ids.push(id);
|
||
});
|
||
return ids;
|
||
|
||
case 'pattern':
|
||
var patternValues = this.getPatternTags($picker);
|
||
// Also include draft pattern if it has content (not yet added as tag)
|
||
var $draftPatternInput = $picker.find('.draft-tag .pattern-input');
|
||
var draftPatternVal = $.trim($draftPatternInput.val());
|
||
if (draftPatternVal) {
|
||
var draftCaseSens = $draftPatternInput.closest('.draft-tag').attr('data-case-sensitive') === '1';
|
||
patternValues.push({
|
||
pattern: draftPatternVal,
|
||
caseSensitive: draftCaseSens
|
||
});
|
||
}
|
||
return patternValues;
|
||
|
||
case 'numeric_range':
|
||
var min = $picker.find('.range-min-input').val();
|
||
var max = $picker.find('.range-max-input').val();
|
||
return { min: min || null, max: max || null };
|
||
|
||
case 'date_range':
|
||
var from = $picker.find('.date-from-input').val();
|
||
var to = $picker.find('.date-to-input').val();
|
||
return { from: from || null, to: to || null };
|
||
|
||
case 'select':
|
||
return [$picker.find('.select-value-input').val()];
|
||
|
||
case 'boolean':
|
||
return [true];
|
||
|
||
default:
|
||
return [];
|
||
}
|
||
},
|
||
|
||
// =========================================================================
|
||
// Count/Status Updates
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Fetch and update condition match count for a row (include or exclude)
|
||
*/
|
||
updateConditionCount: function($row) {
|
||
var self = this;
|
||
var trans = this.config.trans || {};
|
||
|
||
// Find the count element - in method-selector-wrapper for include, in exclude-header-row for exclude
|
||
var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row > .condition-match-count').first();
|
||
if (!$countEl.length) return;
|
||
|
||
// Determine if this is an include or exclude row
|
||
var isExclude = $row.hasClass('exclude-row');
|
||
var $methodSelect = isExclude
|
||
? $row.find('.exclude-method-select')
|
||
: $row.find('.include-method-select');
|
||
|
||
var method = $methodSelect.val();
|
||
if (!method) {
|
||
$countEl.hide();
|
||
return;
|
||
}
|
||
|
||
// Get the picker and extract values
|
||
var $picker = isExclude
|
||
? $row.find('.exclude-picker')
|
||
: $row.find('.include-picker');
|
||
|
||
var valueType = $picker.data('valueType') || 'none';
|
||
var values = this.getPickerValues($picker, valueType);
|
||
|
||
// Don't count if no values (except for boolean/all methods)
|
||
var hasNoValues = !values ||
|
||
(Array.isArray(values) && values.length === 0) ||
|
||
(typeof values === 'object' && !Array.isArray(values) && (
|
||
// For combination_attributes, check if attributes object is empty
|
||
(valueType === 'combination_attributes' && values.attributes !== undefined && Object.keys(values.attributes).length === 0) ||
|
||
// For other objects, check if completely empty
|
||
(valueType !== 'combination_attributes' && Object.keys(values).length === 0)
|
||
));
|
||
if (valueType !== 'none' && valueType !== 'boolean' && hasNoValues) {
|
||
$countEl.hide();
|
||
return;
|
||
}
|
||
|
||
// Get block type
|
||
var $block = $row.closest('.target-block');
|
||
var blockType = $block.data('blockType') || 'products';
|
||
|
||
// Show loading
|
||
$countEl.find('.preview-count').html('<i class="icon-spinner icon-spin"></i>');
|
||
$countEl.removeClass('clickable no-matches').show();
|
||
|
||
// Store condition data on badge for popover
|
||
$countEl.data('conditionData', {
|
||
method: method,
|
||
values: values,
|
||
blockType: blockType,
|
||
isExclude: isExclude
|
||
});
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'countConditionMatches',
|
||
trait: 'EntitySelector',
|
||
method: method,
|
||
values: JSON.stringify(values),
|
||
block_type: blockType
|
||
},
|
||
success: function(response) {
|
||
if (response && response.success) {
|
||
var count = response.count || 0;
|
||
$countEl.removeClass('no-matches clickable');
|
||
if (count === 0) {
|
||
$countEl.find('.preview-count').text(count);
|
||
$countEl.addClass('no-matches').show();
|
||
} else {
|
||
// Show count, make clickable for preview popover
|
||
$countEl.find('.preview-count').text(count);
|
||
$countEl.addClass('clickable').show();
|
||
}
|
||
} else {
|
||
$countEl.hide().removeClass('clickable');
|
||
}
|
||
},
|
||
error: function() {
|
||
$countEl.hide().removeClass('clickable');
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Update all condition counts in a group
|
||
*/
|
||
updateGroupCounts: function($group) {
|
||
var self = this;
|
||
|
||
// Update include count
|
||
var $include = $group.find('.group-include');
|
||
if ($include.length) {
|
||
this.updateConditionCount($include);
|
||
}
|
||
|
||
// Update each exclude row count
|
||
$group.find('.exclude-row').each(function() {
|
||
self.updateConditionCount($(this));
|
||
});
|
||
|
||
// Update group total count (include - excludes)
|
||
this.updateGroupTotalCount($group);
|
||
},
|
||
|
||
/**
|
||
* Update the group total count badge (include - excludes)
|
||
* Also updates the limit input placeholder
|
||
*/
|
||
updateGroupTotalCount: function($group) {
|
||
var self = this;
|
||
var $block = $group.closest('.target-block');
|
||
var blockType = $block.data('blockType') || 'products';
|
||
var $badge = $group.find('.group-header .group-count-badge');
|
||
var $limitInput = $group.find('.group-modifier-limit');
|
||
|
||
// Build group data for AJAX
|
||
var groupData = this.serializeGroup($group, blockType);
|
||
|
||
// Check if include has valid data
|
||
if (!groupData.include || !groupData.include.method) {
|
||
$badge.hide();
|
||
$limitInput.attr('placeholder', '–');
|
||
return;
|
||
}
|
||
|
||
// Show loading
|
||
$badge.html('<i class="icon-spinner icon-spin"></i>').show();
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'countGroupItems',
|
||
trait: 'EntitySelector',
|
||
group_data: JSON.stringify(groupData),
|
||
block_type: blockType
|
||
},
|
||
success: function(response) {
|
||
if (response && response.success) {
|
||
var finalCount = response.final_count || 0;
|
||
var excludeCount = response.exclude_count || 0;
|
||
|
||
// Update badge with eye icon and count
|
||
var badgeHtml = '<i class="icon-eye"></i> ' + finalCount;
|
||
if (excludeCount > 0) {
|
||
badgeHtml += ' <span class="exclude-info">(-' + excludeCount + ')</span>';
|
||
}
|
||
$badge.html(badgeHtml);
|
||
$badge.addClass('clickable').show();
|
||
|
||
// Store group data on badge for preview popover
|
||
$badge.data('groupData', groupData);
|
||
$badge.data('blockType', blockType);
|
||
$badge.data('finalCount', finalCount);
|
||
|
||
// Update limit placeholder with the count
|
||
$limitInput.attr('placeholder', finalCount);
|
||
|
||
// Also update the group-preview-badge count (apply limit if set)
|
||
var $previewBadge = $group.find('.group-preview-badge .preview-count');
|
||
if ($previewBadge.length) {
|
||
var limit = parseInt($limitInput.val(), 10);
|
||
var displayCount = (limit > 0 && limit < finalCount) ? limit : finalCount;
|
||
$previewBadge.text(displayCount);
|
||
}
|
||
} else {
|
||
$badge.hide().removeClass('clickable');
|
||
$limitInput.attr('placeholder', '–');
|
||
}
|
||
},
|
||
error: function() {
|
||
$badge.hide();
|
||
$limitInput.attr('placeholder', '–');
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Update all condition counts for all visible groups
|
||
*/
|
||
updateAllConditionCounts: function() {
|
||
var self = this;
|
||
this.$wrapper.find('.target-block.active .selection-group').each(function() {
|
||
self.updateGroupCounts($(this));
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Fetch category names by IDs and add chips to the picker
|
||
* Used when adding selections from the tree modal
|
||
* @param {jQuery} $picker - Picker element
|
||
* @param {Array} ids - Category IDs to add
|
||
* @param {string} entityType - 'categories' or 'cms_categories'
|
||
* @param {Function} callback - Called when done
|
||
*/
|
||
fetchCategoryNamesAndAddChips: function($picker, ids, entityType, callback) {
|
||
var self = this;
|
||
|
||
if (!ids || ids.length === 0) {
|
||
if (typeof callback === 'function') {
|
||
callback();
|
||
}
|
||
return;
|
||
}
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'getTargetEntitiesByIds',
|
||
trait: 'EntitySelector',
|
||
entity_type: entityType,
|
||
ids: JSON.stringify(ids)
|
||
},
|
||
success: function(response) {
|
||
if (response.success && response.entities) {
|
||
response.entities.forEach(function(entity) {
|
||
self.addSelectionNoUpdate($picker, entity.id, entity.name, entity);
|
||
});
|
||
}
|
||
if (typeof callback === 'function') {
|
||
callback();
|
||
}
|
||
},
|
||
error: function() {
|
||
if (typeof callback === 'function') {
|
||
callback();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
})(jQuery);
|
||
|
||
/**
|
||
* Entity Selector - Groups Module
|
||
* Selection group management, serialization, block/tab management
|
||
* @partial _groups.js
|
||
*
|
||
* Contains:
|
||
* - Group management: addGroup, removeGroup, clearAllConditions
|
||
* - Block/Tab: switchToBlock, updateTabBadges, updateBlockStatus
|
||
* - Serialization: serializeGroup, serializeAllBlocks, getBlockGroups
|
||
* - Counts: fetchProductCount, updateHeaderTotalCount, updateAllConditionCounts
|
||
* - Excludes: addFirstExcludeRow, addExcludeRow, removeExcludeRow
|
||
* - Validation: validate, showValidationError, clearValidationError
|
||
*/
|
||
|
||
(function($) {
|
||
'use strict';
|
||
|
||
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
|
||
|
||
window._EntitySelectorMixins.groups = {
|
||
|
||
addGroup: function($block, blockType) {
|
||
var $container = $block.find('.groups-container');
|
||
var trans = this.config.trans || {};
|
||
var blockDef = this.config.blocks[blockType] || {};
|
||
var methods = blockDef.selection_methods || {};
|
||
|
||
// Remove empty state
|
||
$container.find('.groups-empty-state').remove();
|
||
|
||
// Get next group index
|
||
var maxIndex = -1;
|
||
$container.find('.selection-group').each(function() {
|
||
var idx = parseInt($(this).data('groupIndex'), 10);
|
||
if (idx > maxIndex) maxIndex = idx;
|
||
});
|
||
var groupIndex = maxIndex + 1;
|
||
|
||
// Build method options with optgroups
|
||
var methodOptions = this.buildMethodOptions(methods, false);
|
||
|
||
// Build exclude method options (no "all") with optgroups
|
||
var excludeMethodOptions = this.buildMethodOptions(methods, true);
|
||
|
||
var defaultGroupName = (trans.group || 'Group') + ' ' + (groupIndex + 1);
|
||
var html = '<div class="selection-group" data-group-index="' + groupIndex + '" data-group-name="">';
|
||
|
||
// Group header
|
||
html += '<div class="group-header">';
|
||
html += '<span class="group-collapse-toggle"><i class="icon-chevron-up"></i></span>';
|
||
html += '<span class="group-name-wrapper">';
|
||
html += '<input type="text" class="group-name-input" value="" placeholder="' + defaultGroupName + '" title="' + (trans.click_to_name || 'Click to name this group') + '">';
|
||
html += '<span class="group-count-badge" style="display:none;"><i class="icon-spinner icon-spin"></i></span>';
|
||
html += '</span>';
|
||
html += '<button type="button" class="btn-remove-group" title="' + (trans.remove_group || 'Remove group') + '">';
|
||
html += '<i class="icon-trash"></i>';
|
||
html += '</button>';
|
||
html += '</div>';
|
||
|
||
// Group body (collapsible content)
|
||
html += '<div class="group-body">';
|
||
|
||
// Include section
|
||
html += '<div class="group-include">';
|
||
html += '<div class="section-row">';
|
||
html += '<div class="method-selector-wrapper">';
|
||
html += '<select class="include-method-select">' + methodOptions + '</select>';
|
||
html += '<span class="condition-match-count no-matches"><i class="icon-eye"></i> <span class="preview-count">0</span></span>';
|
||
html += '<span class="method-info-placeholder"></span>';
|
||
html += '</div>';
|
||
var noItemsText = trans.no_items_selected || 'No items selected - use search below';
|
||
html += '<div class="value-picker include-picker" style="display:none;" data-search-entity="' + blockType + '">';
|
||
html += '<div class="entity-chips include-chips" data-placeholder="' + noItemsText + '"></div>';
|
||
html += '<div class="entity-search-box">';
|
||
html += '<i class="icon-search entity-search-icon"></i>';
|
||
html += '<input type="text" class="entity-search-input" placeholder="' + (trans.search_placeholder || 'Search by name, reference, ID...') + '" autocomplete="off">';
|
||
html += '<span class="search-loading" style="display:none;"><i class="icon-spinner icon-spin"></i></span>';
|
||
html += '</div>';
|
||
html += '<input type="hidden" class="include-values-data" value="[]">';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
|
||
// Excludes section (collapsed by default)
|
||
html += '<div class="group-excludes">';
|
||
html += '<button type="button" class="btn-add-exclude">';
|
||
html += '<i class="icon-plus"></i> ' + (trans.add_exceptions || 'Add exceptions');
|
||
html += '</button>';
|
||
html += '</div>';
|
||
|
||
// Group-level modifiers (limit & sort)
|
||
html += '<div class="group-modifiers">';
|
||
html += '<span class="modifier-inline modifier-limit">';
|
||
html += '<span class="modifier-label">' + (trans.limit || 'Limit') + '</span>';
|
||
html += '<input type="number" class="group-modifier-limit" placeholder="–" min="1" step="1" title="' + (trans.limit_tooltip || 'Max items to return (empty = all)') + '">';
|
||
html += '</span>';
|
||
html += '<span class="modifier-inline modifier-sort">';
|
||
html += '<span class="modifier-label">' + (trans.sort || 'Sort') + '</span>';
|
||
html += '<select class="group-modifier-sort">';
|
||
html += '<option value="sales" selected>' + (trans.sort_bestsellers || 'Best sellers') + '</option>';
|
||
html += '<option value="date_add">' + (trans.sort_newest || 'Newest') + '</option>';
|
||
html += '<option value="price">' + (trans.sort_price || 'Price') + '</option>';
|
||
html += '<option value="name">' + (trans.sort_name || 'Name') + '</option>';
|
||
html += '<option value="position">' + (trans.sort_position || 'Position') + '</option>';
|
||
html += '<option value="quantity">' + (trans.sort_stock || 'Stock quantity') + '</option>';
|
||
html += '<option value="random">' + (trans.sort_random || 'Random') + '</option>';
|
||
html += '</select>';
|
||
html += '<button type="button" class="btn-sort-dir" data-dir="DESC" title="' + (trans.sort_direction || 'Sort direction') + '">';
|
||
html += '<i class="icon-sort-amount-desc"></i>';
|
||
html += '</button>';
|
||
html += '</span>';
|
||
html += '<span class="group-preview-badge clickable" title="' + (trans.preview_results || 'Preview results') + '">';
|
||
html += '<i class="icon-eye"></i> <span class="preview-count"></span>';
|
||
html += '</span>';
|
||
html += '</div>';
|
||
|
||
html += '</div>'; // Close group-body
|
||
|
||
html += '</div>'; // Close selection-group
|
||
|
||
$container.append(html);
|
||
|
||
// Find the new group and set method to "all" by default
|
||
var $newGroup = $container.find('.selection-group[data-group-index="' + groupIndex + '"]');
|
||
|
||
// Enhance the method select with styled dropdown
|
||
this.enhanceMethodSelect($newGroup.find('.include-method-select'));
|
||
|
||
$newGroup.find('.include-method-select').val('all').trigger('change');
|
||
|
||
this.updateBlockStatus($block);
|
||
this.serializeAllBlocks();
|
||
},
|
||
|
||
removeGroup: function($group, $block) {
|
||
$group.remove();
|
||
|
||
var $container = $block.find('.groups-container');
|
||
var remainingGroups = $container.find('.selection-group').length;
|
||
|
||
if (remainingGroups === 0) {
|
||
var emptyText = this.getEmptyStateText($block);
|
||
var emptyHtml = '<div class="groups-empty-state">';
|
||
emptyHtml += '<span class="empty-state-text">' + emptyText + '</span>';
|
||
emptyHtml += '</div>';
|
||
$container.html(emptyHtml);
|
||
}
|
||
|
||
this.updateBlockStatus($block);
|
||
this.serializeAllBlocks();
|
||
|
||
// Update tab badges and header total count
|
||
this.updateTabBadges();
|
||
},
|
||
|
||
clearAllConditions: function() {
|
||
var self = this;
|
||
|
||
// Remove all groups from all blocks
|
||
this.$wrapper.find('.target-block').each(function() {
|
||
var $block = $(this);
|
||
var $container = $block.find('.groups-container');
|
||
|
||
// Remove all groups
|
||
$container.find('.selection-group').remove();
|
||
|
||
// Show empty state
|
||
var emptyText = self.getEmptyStateText($block);
|
||
var emptyHtml = '<div class="groups-empty-state">';
|
||
emptyHtml += '<span class="empty-state-text">' + emptyText + '</span>';
|
||
emptyHtml += '</div>';
|
||
$container.html(emptyHtml);
|
||
|
||
self.updateBlockStatus($block);
|
||
});
|
||
|
||
// Update serialized data
|
||
this.serializeAllBlocks();
|
||
|
||
// Update tab badges and header count
|
||
this.updateTabBadges();
|
||
|
||
// Also update header total count immediately (since all cleared)
|
||
this.updateHeaderTotalCount();
|
||
},
|
||
|
||
switchToBlock: function(blockType) {
|
||
// Update tabs
|
||
this.$wrapper.find('.target-block-tab').removeClass('active');
|
||
this.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]').addClass('active');
|
||
|
||
// Update blocks
|
||
this.$wrapper.find('.target-block').removeClass('active').hide();
|
||
this.$wrapper.find('.target-block[data-block-type="' + blockType + '"]').addClass('active').show();
|
||
|
||
// Close dropdown if open
|
||
this.hideDropdown();
|
||
},
|
||
|
||
updateTabBadges: function() {
|
||
var self = this;
|
||
|
||
// Collect all block types with data and set loading state
|
||
var blockTypesWithData = [];
|
||
this.$wrapper.find('.target-block-tab').each(function() {
|
||
var $tab = $(this);
|
||
var blockType = $tab.data('blockType');
|
||
var $block = self.$wrapper.find('.target-block[data-block-type="' + blockType + '"]');
|
||
var groupCount = $block.find('.selection-group').length;
|
||
|
||
// Update or add badge
|
||
var $badge = $tab.find('.tab-badge');
|
||
if (groupCount > 0) {
|
||
// Show loading state first
|
||
if ($badge.length) {
|
||
$badge.addClass('loading').html('<i class="icon-spinner icon-spin"></i>');
|
||
} else {
|
||
$tab.append('<span class="tab-badge loading"><i class="icon-spinner icon-spin"></i></span>');
|
||
}
|
||
$tab.addClass('has-data');
|
||
blockTypesWithData.push(blockType);
|
||
} else {
|
||
$badge.remove();
|
||
$tab.removeClass('has-data');
|
||
}
|
||
});
|
||
|
||
// Update target switch state based on whether any data exists
|
||
this.updateTargetSwitchState();
|
||
|
||
// Fetch all counts in a single bulk request
|
||
if (blockTypesWithData.length > 0) {
|
||
this.fetchAllCounts(blockTypesWithData);
|
||
}
|
||
},
|
||
|
||
updateTargetSwitchState: function() {
|
||
var $switch = this.$wrapper.find('.prestashop-switch');
|
||
if (!$switch.length) {
|
||
return;
|
||
}
|
||
|
||
// Check if any block has data
|
||
var hasData = false;
|
||
this.$wrapper.find('.target-block').each(function() {
|
||
if ($(this).find('.selection-group').length > 0) {
|
||
hasData = true;
|
||
return false; // break
|
||
}
|
||
});
|
||
|
||
// Update switch: value="1" is "Everyone/All/None", value="0" is "Specific/Selected"
|
||
if (hasData) {
|
||
$switch.find('input[value="0"]').prop('checked', true);
|
||
} else {
|
||
$switch.find('input[value="1"]').prop('checked', true);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Fetch counts for all block types in a single bulk AJAX request
|
||
* @param {Array} blockTypes - Array of block type strings to fetch counts for
|
||
*/
|
||
fetchAllCounts: function(blockTypes) {
|
||
var self = this;
|
||
|
||
// Read saved data from hidden input
|
||
var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]');
|
||
var savedData = {};
|
||
try {
|
||
savedData = JSON.parse($hiddenInput.val() || '{}');
|
||
} catch (e) {
|
||
savedData = {};
|
||
}
|
||
|
||
// Build conditions object for all requested block types
|
||
var conditions = {};
|
||
blockTypes.forEach(function(blockType) {
|
||
var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : [];
|
||
if (groups.length > 0) {
|
||
conditions[blockType] = { groups: groups };
|
||
}
|
||
});
|
||
|
||
// If no valid conditions, remove loading spinners
|
||
if (Object.keys(conditions).length === 0) {
|
||
blockTypes.forEach(function(blockType) {
|
||
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
|
||
$tab.find('.tab-badge').remove();
|
||
$tab.removeClass('has-data');
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Single bulk AJAX request for all counts
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'previewEntitySelectorBulk',
|
||
trait: 'EntitySelector',
|
||
conditions: JSON.stringify(conditions)
|
||
},
|
||
success: function(response) {
|
||
if (response.success && response.counts) {
|
||
// Update each tab with its count
|
||
Object.keys(response.counts).forEach(function(blockType) {
|
||
var count = response.counts[blockType];
|
||
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
|
||
var $badge = $tab.find('.tab-badge');
|
||
|
||
if ($badge.length) {
|
||
$badge.removeClass('loading').html('<i class="icon-eye"></i> ' + count);
|
||
// Store preview data for later popover use
|
||
$tab.data('previewData', { count: count, success: true });
|
||
}
|
||
});
|
||
|
||
// Handle any block types not in response (set count to 0 or remove badge)
|
||
blockTypes.forEach(function(blockType) {
|
||
if (!(blockType in response.counts)) {
|
||
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
|
||
$tab.find('.tab-badge').remove();
|
||
$tab.removeClass('has-data');
|
||
}
|
||
});
|
||
|
||
self.updateHeaderTotalCount();
|
||
} else {
|
||
console.error('[EntitySelector] Bulk preview failed:', response.error || 'Unknown error');
|
||
// Remove loading spinners on error
|
||
blockTypes.forEach(function(blockType) {
|
||
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
|
||
$tab.find('.tab-badge').remove();
|
||
});
|
||
}
|
||
},
|
||
error: function(xhr, status, error) {
|
||
console.error('[EntitySelector] Bulk AJAX error:', status, error);
|
||
// Remove loading spinners on error
|
||
blockTypes.forEach(function(blockType) {
|
||
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
|
||
$tab.find('.tab-badge').remove();
|
||
});
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Fetch count for a single block type (legacy, used for single updates)
|
||
*/
|
||
fetchProductCount: function(blockType, $tab) {
|
||
var self = this;
|
||
var data = {};
|
||
|
||
// Read from hidden input (contains full saved data or freshly serialized data)
|
||
var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]');
|
||
var savedData = {};
|
||
try {
|
||
savedData = JSON.parse($hiddenInput.val() || '{}');
|
||
} catch (e) {
|
||
savedData = {};
|
||
}
|
||
|
||
// Get groups for the requested block type
|
||
var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : [];
|
||
|
||
if (groups.length === 0) {
|
||
$tab.find('.tab-badge').remove();
|
||
$tab.removeClass('has-data');
|
||
$tab.removeData('previewData');
|
||
return;
|
||
}
|
||
|
||
// Show loading state
|
||
var $badge = $tab.find('.tab-badge');
|
||
if (!$badge.length) {
|
||
$badge = $('<span class="tab-badge loading"><i class="icon-spinner icon-spin"></i></span>');
|
||
$tab.append($badge);
|
||
} else {
|
||
$badge.addClass('loading').html('<i class="icon-spinner icon-spin"></i>');
|
||
}
|
||
$tab.addClass('has-data');
|
||
|
||
data[blockType] = { groups: groups };
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'previewEntitySelector',
|
||
trait: 'EntitySelector',
|
||
conditions: JSON.stringify(data),
|
||
block_type: blockType,
|
||
limit: 10
|
||
},
|
||
success: function(response) {
|
||
if (response.success) {
|
||
var $badge = $tab.find('.tab-badge');
|
||
$badge.removeClass('loading').html('<i class="icon-eye"></i> ' + response.count);
|
||
|
||
// Store preview data for popover
|
||
$tab.data('previewData', response);
|
||
|
||
// Update header total count
|
||
self.updateHeaderTotalCount();
|
||
} else {
|
||
console.error('[EntitySelector] Preview failed for', blockType, ':', response.error || 'Unknown error');
|
||
$tab.find('.tab-badge').remove();
|
||
}
|
||
},
|
||
error: function(xhr, status, error) {
|
||
console.error('[EntitySelector] AJAX error for', blockType, ':', status, error);
|
||
$tab.find('.tab-badge').remove();
|
||
self.updateHeaderTotalCount();
|
||
}
|
||
});
|
||
},
|
||
|
||
updateHeaderTotalCount: function() {
|
||
var self = this;
|
||
var total = 0;
|
||
|
||
// Sum up all tab badge counts
|
||
this.$wrapper.find('.target-block-tab .tab-badge').each(function() {
|
||
var $badge = $(this);
|
||
if (!$badge.hasClass('loading')) {
|
||
var count = parseInt($badge.text(), 10);
|
||
if (!isNaN(count)) {
|
||
total += count;
|
||
}
|
||
}
|
||
});
|
||
|
||
var $totalBadge = this.$wrapper.find('.trait-total-count');
|
||
if (total > 0) {
|
||
$totalBadge.find('.count-value').text(total);
|
||
$totalBadge.show();
|
||
} else {
|
||
$totalBadge.hide();
|
||
}
|
||
|
||
// Update show-all toggle state
|
||
this.updateShowAllToggle();
|
||
},
|
||
|
||
updateShowAllToggle: function() {
|
||
var $toggle = this.$wrapper.find('.trait-show-all-toggle');
|
||
if (!$toggle.length) return;
|
||
|
||
var $checkbox = $toggle.find('.show-all-checkbox');
|
||
var hasData = this.$wrapper.find('.target-block-tab.has-data').length > 0;
|
||
|
||
// If there's data, uncheck (not showing to all), otherwise check
|
||
$checkbox.prop('checked', !hasData);
|
||
},
|
||
|
||
updateBlockStatus: function($block) {
|
||
var $status = $block.find('.block-status');
|
||
var blockType = $block.data('blockType');
|
||
var blockDef = this.config.blocks[blockType] || {};
|
||
var trans = this.config.trans || {};
|
||
|
||
var groups = this.getBlockGroups($block);
|
||
|
||
if (groups.length === 0) {
|
||
var emptyMeansAll = this.config.emptyMeansAll !== false;
|
||
if (emptyMeansAll) {
|
||
$status.text((trans.all || 'All') + ' ' + (blockDef.entity_label_plural || 'items'));
|
||
} else {
|
||
$status.text(trans.nothing_selected || 'Nothing selected');
|
||
}
|
||
} else {
|
||
$status.text(groups.length + ' ' + (groups.length === 1 ? (trans.group || 'group') : (trans.groups || 'groups')));
|
||
}
|
||
},
|
||
|
||
getEmptyStateText: function($block) {
|
||
var blockType = $block.data('blockType');
|
||
var blockMode = $block.data('mode') || 'multi';
|
||
var blockDef = this.config.blocks[blockType] || {};
|
||
var trans = this.config.trans || {};
|
||
var emptyMeansAll = this.config.emptyMeansAll !== false;
|
||
|
||
if (blockMode === 'single') {
|
||
return trans.no_item_selected || 'No item selected';
|
||
}
|
||
|
||
if (emptyMeansAll) {
|
||
return (trans.all || 'All') + ' ' + (blockDef.entity_label_plural || 'items') + ' ' + (trans.included || 'included');
|
||
}
|
||
|
||
return trans.nothing_selected || 'Nothing selected';
|
||
},
|
||
|
||
serializeGroup: function($group, blockType) {
|
||
var self = this;
|
||
|
||
// Include
|
||
var includeMethod = $group.find('.include-method-select').val() || 'all';
|
||
var $includePicker = $group.find('.include-picker');
|
||
var includeValues = this.getPickerValues($includePicker);
|
||
|
||
// Excludes (multiple rows)
|
||
var excludes = [];
|
||
var $excludesSection = $group.find('.group-excludes.has-excludes');
|
||
if ($excludesSection.length) {
|
||
$group.find('.exclude-row').each(function() {
|
||
var $row = $(this);
|
||
var excludeMethod = $row.find('.exclude-method-select').val() || null;
|
||
var $excludePicker = $row.find('.exclude-picker');
|
||
var excludeValues = self.getPickerValues($excludePicker);
|
||
|
||
if (excludeMethod && excludeValues && (Array.isArray(excludeValues) ? excludeValues.length > 0 : true)) {
|
||
excludes.push({
|
||
method: excludeMethod,
|
||
values: excludeValues
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
var groupData = {
|
||
include: {
|
||
method: includeMethod,
|
||
values: includeValues
|
||
}
|
||
};
|
||
|
||
if (excludes.length > 0) {
|
||
groupData.excludes = excludes;
|
||
}
|
||
|
||
// Add modifiers if present
|
||
var modifiers = this.getGroupModifiers($group);
|
||
if (modifiers.limit || modifiers.sort_by) {
|
||
groupData.modifiers = modifiers;
|
||
}
|
||
|
||
return groupData;
|
||
},
|
||
|
||
serializeAllBlocks: function($changedRow) {
|
||
var self = this;
|
||
var data = {};
|
||
|
||
this.$wrapper.find('.target-block').each(function() {
|
||
var $block = $(this);
|
||
var blockType = $block.data('blockType');
|
||
var groups = self.getBlockGroups($block);
|
||
|
||
// Groups now contain their own modifiers, no block-level modifiers
|
||
if (groups.length > 0) {
|
||
data[blockType] = { groups: groups };
|
||
}
|
||
|
||
self.updateBlockStatus($block);
|
||
});
|
||
|
||
// Update hidden input first
|
||
var $input = this.$wrapper.find('input[name="' + this.config.name + '"]');
|
||
$input.val(JSON.stringify(data));
|
||
|
||
// Then update tab badges (reads from hidden input)
|
||
this.updateTabBadges();
|
||
|
||
// Debounced update of condition count - only for changed row if specified
|
||
if (this.countUpdateTimeout) {
|
||
clearTimeout(this.countUpdateTimeout);
|
||
}
|
||
this.countUpdateTimeout = setTimeout(function() {
|
||
if ($changedRow && $changedRow.length) {
|
||
// Update the specific row that changed
|
||
self.updateConditionCount($changedRow);
|
||
// Also update the group total count (include - excludes)
|
||
var $group = $changedRow.closest('.selection-group');
|
||
if ($group.length) {
|
||
self.updateGroupTotalCount($group);
|
||
}
|
||
} else {
|
||
// Fallback: update all counts (initial load, structure changes)
|
||
self.updateAllConditionCounts();
|
||
}
|
||
}, 500);
|
||
},
|
||
|
||
getBlockGroups: function($block) {
|
||
var self = this;
|
||
var groups = [];
|
||
|
||
$block.find('.selection-group').each(function() {
|
||
var $group = $(this);
|
||
|
||
// Include
|
||
var includeMethod = $group.find('.include-method-select').val() || 'all';
|
||
var $includePicker = $group.find('.include-picker');
|
||
var includeValues = self.getPickerValues($includePicker);
|
||
|
||
// Skip groups with invalid include conditions (e.g., "specific products" with none selected)
|
||
if (!self.isConditionValid(includeMethod, includeValues, $includePicker)) {
|
||
return true; // continue to next group
|
||
}
|
||
|
||
// Excludes (multiple rows) - only include valid ones
|
||
var excludes = [];
|
||
var $excludesSection = $group.find('.group-excludes.has-excludes');
|
||
if ($excludesSection.length) {
|
||
$group.find('.exclude-row').each(function() {
|
||
var $row = $(this);
|
||
var excludeMethod = $row.find('.exclude-method-select').val() || null;
|
||
var $excludePicker = $row.find('.exclude-picker');
|
||
var excludeValues = self.getPickerValues($excludePicker);
|
||
|
||
// Only include valid exclude conditions
|
||
if (excludeMethod && self.isConditionValid(excludeMethod, excludeValues, $excludePicker)) {
|
||
excludes.push({
|
||
method: excludeMethod,
|
||
values: excludeValues
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
var groupData = {
|
||
include: {
|
||
method: includeMethod,
|
||
values: includeValues
|
||
}
|
||
};
|
||
|
||
// Group name (optional, for organizational purposes)
|
||
var groupName = $.trim($group.attr('data-group-name') || '');
|
||
if (groupName) {
|
||
groupData.name = groupName;
|
||
}
|
||
|
||
if (excludes.length > 0) {
|
||
groupData.excludes = excludes;
|
||
}
|
||
|
||
// Group-level modifiers
|
||
var modifiers = self.getGroupModifiers($group);
|
||
if (modifiers.limit || modifiers.sort_by) {
|
||
groupData.modifiers = modifiers;
|
||
}
|
||
|
||
groups.push(groupData);
|
||
});
|
||
|
||
return groups;
|
||
},
|
||
|
||
getGroupModifiers: function($group) {
|
||
var limit = $group.find('.group-modifier-limit').val();
|
||
var sortBy = $group.find('.group-modifier-sort').val() || 'sales';
|
||
var $sortDirBtn = $group.find('.group-modifiers .btn-sort-dir');
|
||
var sortDir = $sortDirBtn.data('dir') || 'DESC';
|
||
|
||
return {
|
||
limit: limit ? parseInt(limit, 10) : null,
|
||
sort_by: sortBy || null,
|
||
sort_dir: sortDir || 'DESC'
|
||
};
|
||
},
|
||
|
||
getPickerValues: function($picker) {
|
||
var valueType = $picker.attr('data-value-type') || 'entity_search';
|
||
var values = [];
|
||
|
||
switch (valueType) {
|
||
case 'entity_search':
|
||
$picker.find('.entity-chip').each(function() {
|
||
var id = $(this).data('id');
|
||
values.push(isNaN(id) ? id : Number(id));
|
||
});
|
||
break;
|
||
|
||
case 'pattern':
|
||
values = this.getPatternTags($picker);
|
||
// Also include draft pattern if it has content (not yet added as tag)
|
||
var $draftInput = $picker.find('.draft-tag .pattern-input');
|
||
var draftPattern = $.trim($draftInput.val());
|
||
if (draftPattern) {
|
||
var draftCaseSensitive = $draftInput.closest('.draft-tag').attr('data-case-sensitive') === '1';
|
||
values.push({
|
||
pattern: draftPattern,
|
||
caseSensitive: draftCaseSensitive
|
||
});
|
||
}
|
||
break;
|
||
|
||
case 'numeric_range':
|
||
var min = $picker.find('.range-min-input').val();
|
||
var max = $picker.find('.range-max-input').val();
|
||
if (min !== '' || max !== '') {
|
||
values = {
|
||
min: min !== '' ? parseFloat(min) : null,
|
||
max: max !== '' ? parseFloat(max) : null
|
||
};
|
||
}
|
||
break;
|
||
|
||
case 'date_range':
|
||
var from = $picker.find('.date-from-input').val();
|
||
var to = $picker.find('.date-to-input').val();
|
||
if (from || to) {
|
||
values = {
|
||
from: from || null,
|
||
to: to || null
|
||
};
|
||
}
|
||
break;
|
||
|
||
case 'select':
|
||
var selectVal = $picker.find('.select-value-input').val();
|
||
if (selectVal) {
|
||
values = [selectVal];
|
||
}
|
||
break;
|
||
|
||
case 'boolean':
|
||
values = [true];
|
||
break;
|
||
|
||
case 'multi_numeric_range':
|
||
var ranges = [];
|
||
$picker.find('.range-chip').each(function() {
|
||
var $chip = $(this);
|
||
var minVal = $chip.data('min');
|
||
var maxVal = $chip.data('max');
|
||
ranges.push({
|
||
min: minVal !== '' && minVal !== undefined ? parseFloat(minVal) : null,
|
||
max: maxVal !== '' && maxVal !== undefined ? parseFloat(maxVal) : null
|
||
});
|
||
});
|
||
if (ranges.length > 0) {
|
||
values = ranges;
|
||
}
|
||
break;
|
||
|
||
case 'multi_select_tiles':
|
||
$picker.find('.tile-option.selected').each(function() {
|
||
values.push($(this).data('value'));
|
||
});
|
||
break;
|
||
|
||
case 'combination_attributes':
|
||
// Returns object: { mode: 'products'|'combinations', attributes: { groupId: [valueId1, valueId2], ... } }
|
||
var combAttrs = {};
|
||
$picker.find('.comb-attr-value.selected').each(function() {
|
||
var groupId = $(this).data('groupId').toString();
|
||
var valueId = $(this).data('valueId');
|
||
if (!combAttrs[groupId]) {
|
||
combAttrs[groupId] = [];
|
||
}
|
||
combAttrs[groupId].push(valueId);
|
||
});
|
||
if (Object.keys(combAttrs).length > 0) {
|
||
// Get mode: from radio if toggle exists, otherwise from config
|
||
var $combPicker = $picker.find('.combination-attributes-picker');
|
||
var configMode = $combPicker.data('combinationMode') || this.config.combinationMode || 'products';
|
||
var combMode;
|
||
if (configMode === 'toggle') {
|
||
combMode = $picker.find('.comb-mode-radio:checked').val() || 'products';
|
||
} else {
|
||
combMode = configMode;
|
||
}
|
||
values = {
|
||
mode: combMode,
|
||
attributes: combAttrs
|
||
};
|
||
}
|
||
break;
|
||
}
|
||
|
||
return values;
|
||
},
|
||
|
||
isConditionValid: function(method, values, $picker) {
|
||
// 'all' method never needs values
|
||
if (method === 'all') {
|
||
return true;
|
||
}
|
||
|
||
// Boolean methods are always valid (the value is implicit true)
|
||
var valueType = $picker.attr('data-value-type') || 'entity_search';
|
||
if (valueType === 'boolean') {
|
||
return true;
|
||
}
|
||
|
||
// For other methods, check if values are meaningful
|
||
if (Array.isArray(values)) {
|
||
return values.length > 0;
|
||
}
|
||
|
||
// For object values (ranges, combination_attributes), check if meaningful
|
||
if (typeof values === 'object' && values !== null) {
|
||
// Special handling for combination_attributes: { mode, attributes }
|
||
if (valueType === 'combination_attributes' && values.attributes !== undefined) {
|
||
return Object.keys(values.attributes).length > 0;
|
||
}
|
||
// For ranges and other objects, check if at least one bound is set
|
||
return Object.keys(values).some(function(key) {
|
||
return values[key] !== null && values[key] !== '';
|
||
});
|
||
}
|
||
|
||
return false;
|
||
},
|
||
|
||
/**
|
||
* Update all condition counts using a single bulk AJAX request
|
||
*/
|
||
updateAllConditionCounts: function() {
|
||
var self = this;
|
||
var conditions = {};
|
||
var conditionElements = {};
|
||
var conditionIndex = 0;
|
||
|
||
// Collect all conditions from all active groups
|
||
this.$wrapper.find('.target-block.active .selection-group').each(function() {
|
||
var $group = $(this);
|
||
var $block = $group.closest('.target-block');
|
||
var blockType = $block.data('blockType') || 'products';
|
||
|
||
// Process include row
|
||
var $include = $group.find('.group-include');
|
||
if ($include.length) {
|
||
var includeData = self.getConditionData($include, blockType);
|
||
if (includeData) {
|
||
var id = 'c' + conditionIndex++;
|
||
conditions[id] = includeData.condition;
|
||
conditionElements[id] = includeData.$countEl;
|
||
}
|
||
}
|
||
|
||
// Process exclude rows
|
||
$group.find('.exclude-row').each(function() {
|
||
var excludeData = self.getConditionData($(this), blockType);
|
||
if (excludeData) {
|
||
var id = 'c' + conditionIndex++;
|
||
conditions[id] = excludeData.condition;
|
||
conditionElements[id] = excludeData.$countEl;
|
||
}
|
||
});
|
||
});
|
||
|
||
// If no conditions, nothing to do
|
||
if (Object.keys(conditions).length === 0) {
|
||
return;
|
||
}
|
||
|
||
// Make single bulk AJAX request
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'countConditionMatchesBulk',
|
||
trait: 'EntitySelector',
|
||
conditions: JSON.stringify(conditions)
|
||
},
|
||
success: function(response) {
|
||
if (response && response.success && response.counts) {
|
||
// Update each count element with its result
|
||
Object.keys(response.counts).forEach(function(id) {
|
||
var count = response.counts[id] || 0;
|
||
var $countEl = conditionElements[id];
|
||
if ($countEl && $countEl.length) {
|
||
$countEl.removeClass('no-matches clickable');
|
||
if (count === 0) {
|
||
$countEl.find('.preview-count').text(count);
|
||
$countEl.addClass('no-matches').show();
|
||
} else {
|
||
$countEl.find('.preview-count').text(count);
|
||
$countEl.addClass('clickable').show();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
// Note: Group totals are updated on-demand when user interacts, not on initial load
|
||
},
|
||
error: function() {
|
||
// Hide all count elements on error
|
||
Object.keys(conditionElements).forEach(function(id) {
|
||
var $countEl = conditionElements[id];
|
||
if ($countEl && $countEl.length) {
|
||
$countEl.hide().removeClass('clickable');
|
||
}
|
||
});
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Extract condition data from a row for bulk counting
|
||
*/
|
||
getConditionData: function($row, blockType) {
|
||
var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row .condition-match-count').first();
|
||
if (!$countEl.length) return null;
|
||
|
||
var isExclude = $row.hasClass('exclude-row');
|
||
var $methodSelect = isExclude
|
||
? $row.find('.exclude-method-select')
|
||
: $row.find('.include-method-select');
|
||
|
||
var method = $methodSelect.val();
|
||
if (!method) {
|
||
$countEl.hide();
|
||
return null;
|
||
}
|
||
|
||
var $picker = isExclude
|
||
? $row.find('.exclude-picker')
|
||
: $row.find('.include-picker');
|
||
|
||
var valueType = $picker.data('valueType') || 'none';
|
||
var values = this.getPickerValues($picker, valueType);
|
||
|
||
// Don't count if no values (except for boolean/all methods)
|
||
var hasNoValues = !values ||
|
||
(Array.isArray(values) && values.length === 0) ||
|
||
(typeof values === 'object' && !Array.isArray(values) && (
|
||
(valueType === 'combination_attributes' && values.attributes !== undefined && Object.keys(values.attributes).length === 0) ||
|
||
(valueType !== 'combination_attributes' && Object.keys(values).length === 0)
|
||
));
|
||
if (valueType !== 'none' && valueType !== 'boolean' && hasNoValues) {
|
||
$countEl.hide();
|
||
return null;
|
||
}
|
||
|
||
// Show loading spinner
|
||
$countEl.find('.preview-count').html('<i class="icon-spinner icon-spin"></i>');
|
||
$countEl.removeClass('clickable no-matches').show();
|
||
|
||
// Store condition data on badge for popover
|
||
$countEl.data('conditionData', {
|
||
method: method,
|
||
values: values,
|
||
blockType: blockType,
|
||
isExclude: isExclude
|
||
});
|
||
|
||
return {
|
||
condition: {
|
||
method: method,
|
||
values: values,
|
||
block_type: blockType
|
||
},
|
||
$countEl: $countEl
|
||
};
|
||
},
|
||
|
||
updateGroupCounts: function($group) {
|
||
var self = this;
|
||
var $block = $group.closest('.target-block');
|
||
var blockType = $block.data('blockType') || 'products';
|
||
|
||
// Update include count
|
||
var $include = $group.find('.group-include');
|
||
if ($include.length) {
|
||
this.updateConditionCount($include, blockType);
|
||
}
|
||
|
||
// Update each exclude row count
|
||
$group.find('.exclude-row').each(function() {
|
||
self.updateConditionCount($(this), blockType);
|
||
});
|
||
|
||
// Update group total count (include - excludes)
|
||
this.updateGroupTotalCount($group);
|
||
},
|
||
|
||
/**
|
||
* Update a single condition count (used for individual updates after user changes)
|
||
*/
|
||
updateConditionCount: function($row, blockType) {
|
||
var self = this;
|
||
|
||
var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row .condition-match-count').first();
|
||
if (!$countEl.length) return;
|
||
|
||
var isExclude = $row.hasClass('exclude-row');
|
||
var $methodSelect = isExclude
|
||
? $row.find('.exclude-method-select')
|
||
: $row.find('.include-method-select');
|
||
|
||
var method = $methodSelect.val();
|
||
if (!method) {
|
||
$countEl.hide();
|
||
return;
|
||
}
|
||
|
||
var $picker = isExclude
|
||
? $row.find('.exclude-picker')
|
||
: $row.find('.include-picker');
|
||
|
||
var valueType = $picker.data('valueType') || 'none';
|
||
var values = this.getPickerValues($picker, valueType);
|
||
|
||
var hasNoValues = !values ||
|
||
(Array.isArray(values) && values.length === 0) ||
|
||
(typeof values === 'object' && !Array.isArray(values) && (
|
||
(valueType === 'combination_attributes' && values.attributes !== undefined && Object.keys(values.attributes).length === 0) ||
|
||
(valueType !== 'combination_attributes' && Object.keys(values).length === 0)
|
||
));
|
||
if (valueType !== 'none' && valueType !== 'boolean' && hasNoValues) {
|
||
$countEl.hide();
|
||
return;
|
||
}
|
||
|
||
if (!blockType) {
|
||
var $block = $row.closest('.target-block');
|
||
blockType = $block.data('blockType') || 'products';
|
||
}
|
||
|
||
$countEl.find('.preview-count').html('<i class="icon-spinner icon-spin"></i>');
|
||
$countEl.removeClass('clickable no-matches').show();
|
||
|
||
$countEl.data('conditionData', {
|
||
method: method,
|
||
values: values,
|
||
blockType: blockType,
|
||
isExclude: isExclude
|
||
});
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'countConditionMatches',
|
||
trait: 'EntitySelector',
|
||
method: method,
|
||
values: JSON.stringify(values),
|
||
block_type: blockType
|
||
},
|
||
success: function(response) {
|
||
if (response && response.success) {
|
||
var count = response.count || 0;
|
||
$countEl.removeClass('no-matches clickable');
|
||
if (count === 0) {
|
||
$countEl.find('.preview-count').text(count);
|
||
$countEl.addClass('no-matches').show();
|
||
} else {
|
||
$countEl.find('.preview-count').text(count);
|
||
$countEl.addClass('clickable').show();
|
||
}
|
||
} else {
|
||
$countEl.hide().removeClass('clickable');
|
||
}
|
||
},
|
||
error: function() {
|
||
$countEl.hide().removeClass('clickable');
|
||
}
|
||
});
|
||
},
|
||
|
||
updateGroupTotalCount: function($group) {
|
||
var self = this;
|
||
var $block = $group.closest('.target-block');
|
||
var blockType = $block.data('blockType') || 'products';
|
||
var $badge = $group.find('.group-header .group-count-badge');
|
||
var $limitInput = $group.find('.group-modifier-limit');
|
||
|
||
// Build group data for AJAX
|
||
var groupData = this.serializeGroup($group, blockType);
|
||
|
||
// Check if include has valid data
|
||
if (!groupData.include || !groupData.include.method) {
|
||
$badge.hide();
|
||
$limitInput.attr('placeholder', '–');
|
||
return;
|
||
}
|
||
|
||
// Show loading
|
||
$badge.html('<i class="icon-spinner icon-spin"></i>').show();
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'countGroupItems',
|
||
trait: 'EntitySelector',
|
||
group_data: JSON.stringify(groupData),
|
||
block_type: blockType
|
||
},
|
||
success: function(response) {
|
||
if (response && response.success) {
|
||
var finalCount = response.final_count || 0;
|
||
var excludeCount = response.exclude_count || 0;
|
||
|
||
// Update badge with eye icon and count
|
||
var badgeHtml = '<i class="icon-eye"></i> ' + finalCount;
|
||
if (excludeCount > 0) {
|
||
badgeHtml += ' <span class="exclude-info">(-' + excludeCount + ')</span>';
|
||
}
|
||
$badge.html(badgeHtml);
|
||
$badge.addClass('clickable').show();
|
||
|
||
// Store group data on badge for preview popover
|
||
$badge.data('groupData', groupData);
|
||
$badge.data('blockType', blockType);
|
||
$badge.data('finalCount', finalCount);
|
||
|
||
// Update limit placeholder with the count
|
||
$limitInput.attr('placeholder', finalCount);
|
||
|
||
// Also update the group-preview-badge count (apply limit if set)
|
||
var $previewBadge = $group.find('.group-preview-badge .preview-count');
|
||
if ($previewBadge.length) {
|
||
var limit = parseInt($limitInput.val(), 10);
|
||
var displayCount = (limit > 0 && limit < finalCount) ? limit : finalCount;
|
||
$previewBadge.text(displayCount);
|
||
}
|
||
} else {
|
||
$badge.hide().removeClass('clickable');
|
||
$limitInput.attr('placeholder', '–');
|
||
}
|
||
},
|
||
error: function() {
|
||
$badge.hide();
|
||
$limitInput.attr('placeholder', '–');
|
||
}
|
||
});
|
||
},
|
||
|
||
// Exclude row management
|
||
addFirstExcludeRow: function($group, $block) {
|
||
var $excludesDiv = $group.find('.group-excludes');
|
||
var trans = this.config.trans || {};
|
||
|
||
// Build the full excludes structure with first row
|
||
var html = '<div class="except-separator">';
|
||
html += '<span class="except-label"><i class="icon-ban"></i> ' + (trans.except || 'EXCEPT') + '</span>';
|
||
html += '</div>';
|
||
|
||
html += '<div class="exclude-rows-container">';
|
||
html += this.buildExcludeRowHtml($block, 0);
|
||
html += '</div>';
|
||
|
||
html += '<button type="button" class="btn-add-another-exclude">';
|
||
html += '<i class="icon-plus"></i> ' + (trans.add_another_exception || 'Add another exception');
|
||
html += '</button>';
|
||
|
||
$excludesDiv.addClass('has-excludes').html(html);
|
||
|
||
// Enhance the first exclude method select with styled dropdown
|
||
var $firstRow = $excludesDiv.find('.exclude-row[data-exclude-index="0"]');
|
||
var $firstSelect = $firstRow.find('.exclude-method-select');
|
||
this.enhanceMethodSelect($firstSelect);
|
||
|
||
// Update method info placeholder for initial selection
|
||
var blockType = $block.data('blockType');
|
||
var initialMethod = $firstSelect.val();
|
||
this.updateMethodInfoPlaceholder($firstRow.find('.method-selector-wrapper'), initialMethod, blockType);
|
||
|
||
this.updateMethodSelectorLock($group, true);
|
||
this.serializeAllBlocks();
|
||
},
|
||
|
||
addExcludeRow: function($group, $block) {
|
||
var $container = $group.find('.exclude-rows-container');
|
||
|
||
// Get next exclude index
|
||
var maxIndex = -1;
|
||
$container.find('.exclude-row').each(function() {
|
||
var idx = parseInt($(this).data('excludeIndex'), 10);
|
||
if (idx > maxIndex) maxIndex = idx;
|
||
});
|
||
var excludeIndex = maxIndex + 1;
|
||
|
||
var html = this.buildExcludeRowHtml($block, excludeIndex);
|
||
$container.append(html);
|
||
|
||
// Enhance the exclude method select with styled dropdown
|
||
var $newRow = $container.find('.exclude-row[data-exclude-index="' + excludeIndex + '"]');
|
||
var $newSelect = $newRow.find('.exclude-method-select');
|
||
this.enhanceMethodSelect($newSelect);
|
||
|
||
// Update method info placeholder for initial selection
|
||
var blockType = $block.data('blockType');
|
||
var initialMethod = $newSelect.val();
|
||
this.updateMethodInfoPlaceholder($newRow.find('.method-selector-wrapper'), initialMethod, blockType);
|
||
|
||
this.serializeAllBlocks();
|
||
},
|
||
|
||
buildExcludeRowHtml: function($block, excludeIndex) {
|
||
var blockType = $block.data('blockType');
|
||
var blockDef = this.config.blocks[blockType] || {};
|
||
var methods = blockDef.selection_methods || {};
|
||
var trans = this.config.trans || {};
|
||
|
||
// Build exclude method options with optgroups (no "all")
|
||
var excludeMethodOptions = this.buildMethodOptions(methods, true);
|
||
|
||
// Find first non-all method for default search entity
|
||
var firstSearchEntity = blockType;
|
||
var firstValueType = 'entity_search';
|
||
$.each(methods, function(methodKey, methodDef) {
|
||
if (methodKey === 'all') return true;
|
||
firstSearchEntity = methodDef.search_entity || blockType;
|
||
firstValueType = methodDef.value_type || 'entity_search';
|
||
return false; // break
|
||
});
|
||
|
||
var html = '<div class="exclude-row" data-exclude-index="' + excludeIndex + '">';
|
||
|
||
// Header row with method select wrapped in method-selector-wrapper (same as include)
|
||
html += '<div class="exclude-header-row">';
|
||
html += '<div class="method-selector-wrapper">';
|
||
html += '<select class="exclude-method-select">' + excludeMethodOptions + '</select>';
|
||
html += '<span class="condition-match-count no-matches"><i class="icon-eye"></i> <span class="preview-count">0</span></span>';
|
||
html += '<span class="method-info-placeholder"></span>';
|
||
html += '</div>';
|
||
html += '<button type="button" class="btn-remove-exclude-row" title="' + (trans.remove_this_exception || 'Remove this exception') + '">';
|
||
html += '<i class="icon-trash"></i>';
|
||
html += '</button>';
|
||
html += '</div>';
|
||
|
||
// Value picker based on first method's value type
|
||
html += this.buildValuePickerHtml('exclude', firstValueType, firstSearchEntity, methods);
|
||
|
||
html += '</div>';
|
||
|
||
return html;
|
||
},
|
||
|
||
removeExcludeRow: function($excludeRow, $group, $block) {
|
||
var $container = $group.find('.exclude-rows-container');
|
||
var trans = this.config.trans || {};
|
||
|
||
$excludeRow.remove();
|
||
|
||
// Check if there are remaining exclude rows
|
||
var remainingRows = $container.find('.exclude-row').length;
|
||
|
||
if (remainingRows === 0) {
|
||
// Remove entire excludes section and show "Add exceptions" button
|
||
var $excludesDiv = $group.find('.group-excludes');
|
||
$excludesDiv.removeClass('has-excludes').html(
|
||
'<button type="button" class="btn-add-exclude">' +
|
||
'<i class="icon-plus"></i> ' + (trans.add_exceptions || 'Add exceptions') +
|
||
'</button>'
|
||
);
|
||
// Unlock the method selector since no excludes exist
|
||
this.updateMethodSelectorLock($group, false);
|
||
}
|
||
|
||
this.serializeAllBlocks();
|
||
},
|
||
|
||
// Method options building
|
||
buildMethodOptions: function(methods, excludeAll) {
|
||
var self = this;
|
||
var trans = this.config.trans || {};
|
||
var html = '';
|
||
|
||
// Group labels
|
||
var groupLabels = {
|
||
'select_by': trans.select_by || 'Select by...',
|
||
'filter_by': trans.filter_by || 'Filter by...'
|
||
};
|
||
|
||
// Separate methods by group
|
||
var grouped = {};
|
||
var ungrouped = {};
|
||
|
||
$.each(methods, function(methodKey, methodDef) {
|
||
if (excludeAll && methodKey === 'all') return true; // skip
|
||
|
||
var group = methodDef.group || '';
|
||
if (group) {
|
||
if (!grouped[group]) {
|
||
grouped[group] = {};
|
||
}
|
||
grouped[group][methodKey] = methodDef;
|
||
} else {
|
||
ungrouped[methodKey] = methodDef;
|
||
}
|
||
});
|
||
|
||
// Render ungrouped options first
|
||
$.each(ungrouped, function(methodKey, methodDef) {
|
||
html += self.buildMethodOption(methodKey, methodDef);
|
||
});
|
||
|
||
// Render grouped options with optgroups
|
||
$.each(grouped, function(groupKey, groupMethods) {
|
||
var groupLabel = groupLabels[groupKey] || groupKey.replace(/_/g, ' ');
|
||
html += '<optgroup label="' + self.escapeAttr(groupLabel) + '">';
|
||
$.each(groupMethods, function(methodKey, methodDef) {
|
||
html += self.buildMethodOption(methodKey, methodDef);
|
||
});
|
||
html += '</optgroup>';
|
||
});
|
||
|
||
return html;
|
||
},
|
||
|
||
buildMethodOption: function(methodKey, methodDef) {
|
||
var html = '<option value="' + this.escapeAttr(methodKey) + '"';
|
||
html += ' data-value-type="' + this.escapeAttr(methodDef.value_type || 'none') + '"';
|
||
|
||
if (methodDef.icon) {
|
||
html += ' data-icon="' + this.escapeAttr(methodDef.icon) + '"';
|
||
}
|
||
if (methodDef.search_entity) {
|
||
html += ' data-search-entity="' + this.escapeAttr(methodDef.search_entity) + '"';
|
||
}
|
||
if (methodDef.options) {
|
||
html += ' data-options="' + this.escapeAttr(JSON.stringify(methodDef.options)) + '"';
|
||
}
|
||
if (methodDef.exclusive) {
|
||
html += ' data-exclusive="true"';
|
||
}
|
||
if (typeof methodDef.step !== 'undefined') {
|
||
html += ' data-step="' + this.escapeAttr(methodDef.step) + '"';
|
||
}
|
||
if (typeof methodDef.min !== 'undefined') {
|
||
html += ' data-min="' + this.escapeAttr(methodDef.min) + '"';
|
||
}
|
||
|
||
html += '>' + this.escapeHtml(methodDef.label) + '</option>';
|
||
return html;
|
||
},
|
||
|
||
buildValuePickerHtml: function(section, valueType, searchEntity, methods) {
|
||
var trans = this.config.trans || {};
|
||
var pickerClass = section + '-picker';
|
||
var chipsClass = section + '-chips';
|
||
var dataClass = section + '-values-data';
|
||
var html = '';
|
||
|
||
if (valueType === 'none') {
|
||
html = '<div class="value-picker ' + pickerClass + '" style="display:none;" data-search-entity="" data-value-type="none">';
|
||
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
html = '<div class="value-picker ' + pickerClass + '" data-search-entity="' + this.escapeAttr(searchEntity) + '" data-value-type="' + this.escapeAttr(valueType) + '">';
|
||
|
||
switch (valueType) {
|
||
case 'entity_search':
|
||
var noItemsText = trans.no_items_selected || 'No items selected - use search below';
|
||
html += '<div class="entity-chips ' + chipsClass + '" data-placeholder="' + this.escapeAttr(noItemsText) + '"></div>';
|
||
html += '<div class="entity-search-box">';
|
||
html += '<i class="icon-search entity-search-icon"></i>';
|
||
html += '<input type="text" class="entity-search-input" placeholder="' + this.escapeAttr(trans.search_placeholder || 'Search by name, reference, ID...') + '" autocomplete="off">';
|
||
html += '<span class="search-loading" style="display:none;"><i class="icon-spinner icon-spin"></i></span>';
|
||
html += '</div>';
|
||
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
|
||
break;
|
||
|
||
case 'pattern':
|
||
// Build tooltip content for data-details attribute
|
||
var tooltipContent = '<strong>' + this.escapeHtml(trans.pattern_help_title || 'Pattern Syntax') + '</strong>';
|
||
tooltipContent += '<div class="pattern-help-content">';
|
||
tooltipContent += '<div class="pattern-help-item"><code>*</code> <span>' + this.escapeHtml(trans.pattern_help_wildcard || 'any text (wildcard)') + '</span></div>';
|
||
tooltipContent += '<div class="pattern-help-item"><code>{number}</code> <span>' + this.escapeHtml(trans.pattern_help_number || 'any number (e.g. 100, 250)') + '</span></div>';
|
||
tooltipContent += '<div class="pattern-help-item"><code>{letter}</code> <span>' + this.escapeHtml(trans.pattern_help_letter || 'single letter (A-Z)') + '</span></div>';
|
||
tooltipContent += '</div>';
|
||
tooltipContent += '<div class="pattern-help-examples">';
|
||
tooltipContent += '<strong>' + this.escapeHtml(trans.pattern_help_examples || 'Examples:') + '</strong>';
|
||
tooltipContent += '<div class="pattern-example"><code>*cotton*</code> <span>' + this.escapeHtml(trans.pattern_example_1 || 'contains "cotton"') + '</span></div>';
|
||
tooltipContent += '<div class="pattern-example"><code>iPhone {number} Pro*</code> <span>' + this.escapeHtml(trans.pattern_example_2 || 'matches "iPhone 15 Pro Max"') + '</span></div>';
|
||
tooltipContent += '<div class="pattern-example"><code>Size {letter}</code> <span>' + this.escapeHtml(trans.pattern_example_3 || 'matches "Size M", "Size L"') + '</span></div>';
|
||
tooltipContent += '</div>';
|
||
|
||
var noPatternText = trans.no_patterns || 'No patterns - press Enter to add';
|
||
html += '<div class="entity-chips pattern-chips" data-placeholder="' + this.escapeAttr(noPatternText) + '"></div>';
|
||
html += '<div class="pattern-input-row">';
|
||
// Draft tag styled exactly like saved tags, with input instead of text span
|
||
html += '<div class="pattern-tag draft-tag" data-case-sensitive="0">';
|
||
html += '<button type="button" class="btn-toggle-case" title="' + this.escapeAttr(trans.case_insensitive || 'Case insensitive - click to toggle') + '"><span class="case-icon">aa</span></button>';
|
||
html += '<input type="text" class="pattern-input" value="" placeholder="' + this.escapeAttr(trans.enter_pattern || 'e.g. *cotton*') + '">';
|
||
html += '<span class="pattern-match-count" title="' + this.escapeAttr(trans.click_to_preview || 'Click to preview matches') + '"><i class="icon-eye"></i> <span class="count-value"></span></span>';
|
||
html += '<button type="button" class="btn-add-pattern" title="' + this.escapeAttr(trans.add_pattern || 'Add pattern (Enter)') + '"><i class="icon-plus"></i></button>';
|
||
html += '</div>';
|
||
html += '<span class="mpr-info-wrapper" data-details="' + this.escapeAttr(tooltipContent) + '">';
|
||
html += '<span class="mpr-icon icon-info link"></span>';
|
||
html += '</span>';
|
||
html += '</div>';
|
||
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
|
||
break;
|
||
|
||
case 'numeric_range':
|
||
html += '<div class="numeric-range-box">';
|
||
html += '<input type="number" class="range-min-input" value="" placeholder="' + this.escapeAttr(trans.min || 'Min') + '" step="0.01">';
|
||
html += '<span class="range-separator">-</span>';
|
||
html += '<input type="number" class="range-max-input" value="" placeholder="' + this.escapeAttr(trans.max || 'Max') + '" step="0.01">';
|
||
html += '</div>';
|
||
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
|
||
break;
|
||
|
||
case 'multi_numeric_range':
|
||
html += '<div class="multi-range-container">';
|
||
html += '<div class="multi-range-chips"></div>';
|
||
html += '<div class="multi-range-input-row">';
|
||
html += '<input type="number" class="range-min-input" value="" placeholder="' + this.escapeAttr(trans.min || 'Min') + '" step="0.01">';
|
||
html += '<span class="range-separator">-</span>';
|
||
html += '<input type="number" class="range-max-input" value="" placeholder="' + this.escapeAttr(trans.max || 'Max') + '" step="0.01">';
|
||
html += '<button type="button" class="btn-add-range" title="' + this.escapeAttr(trans.add_range || 'Add range') + '"><i class="icon-plus"></i></button>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
|
||
break;
|
||
|
||
case 'multi_select_tiles':
|
||
html += '<div class="multi-select-tiles">';
|
||
// Tiles will be populated based on method options
|
||
html += '</div>';
|
||
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
|
||
break;
|
||
|
||
case 'date_range':
|
||
html += '<div class="date-range-box">';
|
||
html += '<input type="date" class="date-from-input" value="">';
|
||
html += '<span class="range-separator">-</span>';
|
||
html += '<input type="date" class="date-to-input" value="">';
|
||
html += '</div>';
|
||
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
|
||
break;
|
||
|
||
case 'select':
|
||
html += '<div class="select-input-box">';
|
||
html += '<select class="select-value-input"></select>';
|
||
html += '</div>';
|
||
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
|
||
break;
|
||
|
||
case 'boolean':
|
||
html += '<div class="boolean-input-box">';
|
||
html += '<span class="boolean-label">' + this.escapeHtml(trans.yes || 'Yes') + '</span>';
|
||
html += '</div>';
|
||
html += '<input type="hidden" class="' + dataClass + '" value="[true]">';
|
||
break;
|
||
|
||
case 'combination_attributes':
|
||
// Build tooltip content
|
||
var combTooltip = '<strong>' + this.escapeHtml(trans.combination_help_title || 'Combination Targeting') + '</strong>';
|
||
combTooltip += '<div class="combination-help-content">';
|
||
combTooltip += '<p>' + this.escapeHtml(trans.combination_help_desc || 'Select attributes to target specific product combinations.') + '</p>';
|
||
combTooltip += '<p><strong>' + this.escapeHtml(trans.combination_help_logic || 'Logic:') + '</strong></p>';
|
||
combTooltip += '<ul>';
|
||
combTooltip += '<li>' + this.escapeHtml(trans.combination_help_within || 'Within group: OR (Red OR Blue)') + '</li>';
|
||
combTooltip += '<li>' + this.escapeHtml(trans.combination_help_between || 'Between groups: AND (Color AND Size)') + '</li>';
|
||
combTooltip += '</ul>';
|
||
combTooltip += '</div>';
|
||
|
||
// Combination mode from config: 'products', 'combinations', or 'toggle'
|
||
var combMode = this.config.combinationMode || 'products';
|
||
var showModeToggle = (combMode === 'toggle');
|
||
var defaultMode = showModeToggle ? 'products' : combMode;
|
||
|
||
html += '<div class="combination-attributes-picker" data-combination-mode="' + this.escapeAttr(combMode) + '">';
|
||
// Mode toggle: only show when config is 'toggle'
|
||
if (showModeToggle) {
|
||
html += '<div class="combination-mode-toggle">';
|
||
html += '<label class="combination-mode-option">';
|
||
html += '<input type="radio" name="' + this.escapeAttr(section) + '_comb_mode_' + Date.now() + '" class="comb-mode-radio" value="products" checked>';
|
||
html += '<span class="mode-label">' + this.escapeHtml(trans.comb_mode_products || 'Products with these combinations') + '</span>';
|
||
html += '</label>';
|
||
html += '<label class="combination-mode-option">';
|
||
html += '<input type="radio" name="' + this.escapeAttr(section) + '_comb_mode_' + Date.now() + '" class="comb-mode-radio" value="combinations">';
|
||
html += '<span class="mode-label">' + this.escapeHtml(trans.comb_mode_combinations || 'Only these exact combinations') + '</span>';
|
||
html += '</label>';
|
||
html += '</div>';
|
||
}
|
||
html += '<div class="combination-groups-container">';
|
||
html += '<span class="combination-loading"><i class="icon-spinner icon-spin"></i> ' + this.escapeHtml(trans.loading || 'Loading...') + '</span>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
// Store mode along with attributes: { mode: 'products'|'combinations', attributes: { groupId: [valueIds] } }
|
||
html += '<input type="hidden" class="' + dataClass + '" value=\'{"mode":"' + defaultMode + '","attributes":{}}\'>';
|
||
break;
|
||
|
||
default:
|
||
html += '<input type="hidden" class="' + dataClass + '" value="[]">';
|
||
break;
|
||
}
|
||
|
||
html += '</div>';
|
||
return html;
|
||
},
|
||
|
||
// Sort options
|
||
getSortOptionsArray: function(blockType) {
|
||
var trans = this.config.trans || {};
|
||
|
||
switch (blockType) {
|
||
case 'products':
|
||
return [
|
||
{ value: 'sales', label: trans.sort_bestsellers || 'Best sellers' },
|
||
{ value: 'date_add', label: trans.sort_newest || 'Newest' },
|
||
{ value: 'price', label: trans.sort_price || 'Price' },
|
||
{ value: 'name', label: trans.sort_name || 'Name' },
|
||
{ value: 'position', label: trans.sort_position || 'Position' },
|
||
{ value: 'quantity', label: trans.sort_stock || 'Stock quantity' },
|
||
{ value: 'random', label: trans.sort_random || 'Random' }
|
||
];
|
||
case 'categories':
|
||
return [
|
||
{ value: 'name', label: trans.sort_name || 'Name' },
|
||
{ value: 'position', label: trans.sort_position || 'Position' },
|
||
{ value: 'product_count', label: trans.sort_products || 'Products count' },
|
||
{ value: 'date_add', label: trans.sort_newest || 'Newest' }
|
||
];
|
||
default:
|
||
return [
|
||
{ value: 'name', label: trans.sort_name || 'Name' },
|
||
{ value: 'date_add', label: trans.sort_newest || 'Newest' }
|
||
];
|
||
}
|
||
},
|
||
|
||
getSortIconClass: function(sortBy, sortDir) {
|
||
var isAsc = (sortDir === 'ASC');
|
||
|
||
switch (sortBy) {
|
||
case 'name':
|
||
return isAsc ? 'icon-sort-alpha-asc' : 'icon-sort-alpha-desc';
|
||
case 'price':
|
||
case 'quantity':
|
||
case 'product_count':
|
||
return isAsc ? 'icon-sort-numeric-asc' : 'icon-sort-numeric-desc';
|
||
case 'date_add':
|
||
case 'newest_products':
|
||
return isAsc ? 'icon-sort-numeric-asc' : 'icon-sort-numeric-desc';
|
||
case 'sales':
|
||
case 'total_sales':
|
||
return isAsc ? 'icon-sort-amount-asc' : 'icon-sort-amount-desc';
|
||
case 'position':
|
||
return isAsc ? 'icon-sort-numeric-asc' : 'icon-sort-numeric-desc';
|
||
case 'random':
|
||
return 'icon-random';
|
||
default:
|
||
return isAsc ? 'icon-sort-amount-asc' : 'icon-sort-amount-desc';
|
||
}
|
||
},
|
||
|
||
cycleSortOption: function($btn, blockType) {
|
||
var sortOptions = this.getSortOptionsArray(blockType);
|
||
var currentSort = $btn.data('sort') || 'sales';
|
||
var currentDir = $btn.data('dir') || 'DESC';
|
||
|
||
// Find current index
|
||
var currentIndex = -1;
|
||
for (var i = 0; i < sortOptions.length; i++) {
|
||
if (sortOptions[i].value === currentSort) {
|
||
currentIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Cycle: first toggle direction, then move to next sort option
|
||
var newSort, newDir, newLabel;
|
||
if (currentDir === 'DESC') {
|
||
// Toggle to ASC, same sort
|
||
newSort = currentSort;
|
||
newDir = 'ASC';
|
||
} else {
|
||
// Move to next sort option, reset to DESC
|
||
var nextIndex = (currentIndex + 1) % sortOptions.length;
|
||
newSort = sortOptions[nextIndex].value;
|
||
newDir = 'DESC';
|
||
}
|
||
|
||
// Find label for new sort
|
||
for (var j = 0; j < sortOptions.length; j++) {
|
||
if (sortOptions[j].value === newSort) {
|
||
newLabel = sortOptions[j].label;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Update button
|
||
$btn.data('sort', newSort);
|
||
$btn.data('dir', newDir);
|
||
$btn.attr('data-sort', newSort);
|
||
$btn.attr('data-dir', newDir);
|
||
$btn.attr('title', newLabel + ' ' + (newDir === 'DESC' ? '↓' : '↑'));
|
||
$btn.find('i').attr('class', this.getSortIconClass(newSort, newDir));
|
||
},
|
||
|
||
// Validation
|
||
validate: function() {
|
||
var isRequired = this.$wrapper.data('required') === 1 || this.$wrapper.data('required') === '1';
|
||
if (!isRequired) {
|
||
return true;
|
||
}
|
||
|
||
// Check if any block has data (groups with selections)
|
||
var hasData = false;
|
||
this.$wrapper.find('.target-block').each(function() {
|
||
if ($(this).find('.selection-group').length > 0) {
|
||
hasData = true;
|
||
return false; // break
|
||
}
|
||
});
|
||
|
||
if (!hasData) {
|
||
// Show validation error
|
||
this.showValidationError();
|
||
return false;
|
||
}
|
||
|
||
// Valid - remove any previous error
|
||
this.clearValidationError();
|
||
return true;
|
||
},
|
||
|
||
showValidationError: function() {
|
||
this.$wrapper.addClass('has-validation-error');
|
||
var message = this.$wrapper.data('required-message') || 'Please select at least one item';
|
||
|
||
// Remove any existing error
|
||
this.$wrapper.find('.trait-validation-error').remove();
|
||
|
||
// Add error message after header
|
||
var $error = $('<div>', {
|
||
class: 'trait-validation-error',
|
||
html: '<i class="icon-warning"></i> ' + message
|
||
});
|
||
this.$wrapper.find('.condition-trait-header').after($error);
|
||
|
||
// Scroll to error
|
||
$('html, body').animate({
|
||
scrollTop: this.$wrapper.offset().top - 100
|
||
}, 300);
|
||
|
||
// Expand the trait if collapsed
|
||
if (!this.$wrapper.find('.condition-trait-body').is(':visible')) {
|
||
this.$wrapper.find('.condition-trait-body').slideDown(200);
|
||
this.$wrapper.removeClass('collapsed');
|
||
}
|
||
},
|
||
|
||
clearValidationError: function() {
|
||
this.$wrapper.removeClass('has-validation-error');
|
||
this.$wrapper.find('.trait-validation-error').remove();
|
||
}
|
||
};
|
||
|
||
})(jQuery);
|
||
|
||
/**
|
||
* Entity Selector - Methods Module
|
||
* Method dropdown rendering, value pickers, combination picker
|
||
* @partial _methods.js
|
||
*
|
||
* EXTRACTION SOURCE: assets/js/admin/entity-selector.js
|
||
* Lines: 6760-6848 (initMethodDropdowns, enhanceMethodSelect)
|
||
* 6849-7051 (showMethodDropdownMenu, buildMethodDropdownMenuHtml, closeMethodDropdownMenu)
|
||
* 7053-7138 (populateTiles, applyRangeInputConstraints, showRangeInputError)
|
||
* 7139-7380 (combination picker methods)
|
||
* 7382-7550 (updateMethodInfoPlaceholder, getBuiltInMethodHelp)
|
||
* 7748-7888 (buildSortOptions, updateModifierButtonState, updateMethodSelectorLock)
|
||
*
|
||
* Contains:
|
||
* - initMethodDropdowns() - Initialize styled dropdowns
|
||
* - enhanceMethodSelect() - Convert select to styled dropdown
|
||
* - showMethodDropdownMenu() - Show method selection menu
|
||
* - buildMethodDropdownMenuHtml() - Build menu HTML
|
||
* - closeMethodDropdownMenu() - Close dropdown menu
|
||
* - updateMethodTrigger() - Update trigger display
|
||
* - populateTiles() - Build multi-select tiles
|
||
* - applyRangeInputConstraints() - Set numeric input constraints
|
||
* - showRangeInputError() - Display validation error
|
||
* - loadCombinationAttributeGroups() - Load attribute groups for picker
|
||
* - loadCombinationAttributeValues() - Load values for attribute group
|
||
* - restoreCombinationSelections() - Restore saved combination state
|
||
* - updateCombinationData() - Save combination selection
|
||
* - updateCombinationGroupCounts() - Update selection counts
|
||
* - updateMethodInfoPlaceholder() - Show method help
|
||
* - getBuiltInMethodHelp() - Get help text for methods
|
||
* - buildSortOptions() - Build sort dropdown options
|
||
* - updateModifierButtonState() - Update modifier toggle state
|
||
* - updateMethodSelectorLock() - Lock/unlock method selector
|
||
*/
|
||
|
||
(function($) {
|
||
'use strict';
|
||
|
||
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
|
||
|
||
window._EntitySelectorMixins.methods = {
|
||
|
||
/**
|
||
* Initialize styled method dropdowns for all method selects
|
||
*/
|
||
initMethodDropdowns: function() {
|
||
var self = this;
|
||
this.$wrapper.find('.include-method-select').each(function() {
|
||
self.enhanceMethodSelect($(this));
|
||
});
|
||
this.$wrapper.find('.exclude-method-select').each(function() {
|
||
self.enhanceMethodSelect($(this));
|
||
});
|
||
this.initMethodInfoPlaceholders();
|
||
},
|
||
|
||
/**
|
||
* Initialize info placeholders for all existing method selects
|
||
*/
|
||
initMethodInfoPlaceholders: function() {
|
||
var self = this;
|
||
this.$wrapper.find('.selection-group').each(function() {
|
||
var $group = $(this);
|
||
var $block = $group.closest('.target-block');
|
||
var blockType = $block.data('blockType') || 'products';
|
||
|
||
// Include method info
|
||
var includeMethod = $group.find('.include-method-select').val() || 'all';
|
||
self.updateMethodInfoPlaceholder($group.find('.method-selector-wrapper'), includeMethod, blockType);
|
||
|
||
// Exclude methods info
|
||
$group.find('.exclude-row').each(function() {
|
||
var $row = $(this);
|
||
var excludeMethod = $row.find('.exclude-method-select').val();
|
||
if (excludeMethod) {
|
||
self.updateMethodInfoPlaceholder($row.find('.method-selector-wrapper'), excludeMethod, blockType);
|
||
}
|
||
});
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Enhance a single method select with styled dropdown
|
||
*/
|
||
enhanceMethodSelect: function($select) {
|
||
var self = this;
|
||
|
||
if (!$select.length || $select.data('methodDropdownInit')) {
|
||
return;
|
||
}
|
||
$select.data('methodDropdownInit', true);
|
||
|
||
$select.addClass('method-select-hidden');
|
||
|
||
var $selectedOption = $select.find('option:selected');
|
||
var selectedIcon = $selectedOption.data('icon') || 'icon-caret-down';
|
||
var selectedLabel = $selectedOption.text();
|
||
|
||
var triggerHtml = '<div class="method-dropdown-trigger">';
|
||
triggerHtml += '<i class="' + this.escapeAttr(selectedIcon) + ' method-trigger-icon"></i>';
|
||
triggerHtml += '<span class="method-trigger-label">' + this.escapeHtml(selectedLabel) + '</span>';
|
||
triggerHtml += '<i class="icon-caret-down method-trigger-caret"></i>';
|
||
triggerHtml += '</div>';
|
||
|
||
var $trigger = $(triggerHtml);
|
||
$select.after($trigger);
|
||
|
||
$trigger.on('click', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
var $wrapper = $select.closest('.method-selector-wrapper');
|
||
if ($wrapper.hasClass('selector-locked')) {
|
||
return;
|
||
}
|
||
|
||
self.showMethodDropdownMenu($select, $trigger);
|
||
});
|
||
|
||
$select.on('change.methodDropdown', function() {
|
||
self.updateMethodTrigger($select, $trigger);
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Update the trigger display to match current selection
|
||
*/
|
||
updateMethodTrigger: function($select, $trigger) {
|
||
var $selectedOption = $select.find('option:selected');
|
||
var selectedIcon = $selectedOption.data('icon') || 'icon-caret-down';
|
||
var selectedLabel = $selectedOption.text();
|
||
|
||
$trigger.find('.method-trigger-icon').attr('class', selectedIcon + ' method-trigger-icon');
|
||
$trigger.find('.method-trigger-label').text(selectedLabel);
|
||
},
|
||
|
||
/**
|
||
* Show the method dropdown menu
|
||
*/
|
||
showMethodDropdownMenu: function($select, $trigger) {
|
||
var self = this;
|
||
|
||
this.closeMethodDropdownMenu();
|
||
|
||
var menuHtml = this.buildMethodDropdownMenuHtml($select);
|
||
var $menu = $(menuHtml);
|
||
|
||
var triggerOffset = $trigger.offset();
|
||
var triggerWidth = $trigger.outerWidth();
|
||
var triggerHeight = $trigger.outerHeight();
|
||
|
||
$menu.css({
|
||
position: 'absolute',
|
||
top: triggerOffset.top + triggerHeight + 2,
|
||
left: triggerOffset.left,
|
||
minWidth: triggerWidth,
|
||
zIndex: 10001
|
||
});
|
||
|
||
$('body').append($menu);
|
||
this.$methodDropdownMenu = $menu;
|
||
this.$methodDropdownSelect = $select;
|
||
this.$methodDropdownTrigger = $trigger;
|
||
|
||
$menu.on('click', '.method-dropdown-item', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
var value = $(this).data('value');
|
||
$select.val(value).trigger('change');
|
||
self.closeMethodDropdownMenu();
|
||
});
|
||
|
||
$(document).on('click.methodDropdown', function(e) {
|
||
if (!$(e.target).closest('.method-dropdown-menu, .method-dropdown-trigger').length) {
|
||
self.closeMethodDropdownMenu();
|
||
}
|
||
});
|
||
|
||
$(document).on('keydown.methodDropdown', function(e) {
|
||
if (e.keyCode === 27) {
|
||
self.closeMethodDropdownMenu();
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Build the dropdown menu HTML
|
||
*/
|
||
buildMethodDropdownMenuHtml: function($select) {
|
||
var self = this;
|
||
var html = '<div class="method-dropdown-menu">';
|
||
|
||
// Render ungrouped options first
|
||
$select.children('option').each(function() {
|
||
var $el = $(this);
|
||
var icon = $el.data('icon') || 'icon-asterisk';
|
||
var label = $el.text();
|
||
var value = $el.val();
|
||
var isSelected = $el.is(':selected');
|
||
|
||
html += '<div class="method-dropdown-item' + (isSelected ? ' selected' : '') + '" data-value="' + self.escapeAttr(value) + '">';
|
||
html += '<i class="' + self.escapeAttr(icon) + ' method-item-icon"></i>';
|
||
html += '<span class="method-item-label">' + self.escapeHtml(label) + '</span>';
|
||
if (isSelected) {
|
||
html += '<i class="icon-check method-item-check"></i>';
|
||
}
|
||
html += '</div>';
|
||
});
|
||
|
||
// Render optgroups
|
||
$select.children('optgroup').each(function() {
|
||
var $optgroup = $(this);
|
||
var groupLabel = $optgroup.attr('label') || '';
|
||
|
||
html += '<div class="method-dropdown-optgroup">';
|
||
html += '<div class="method-optgroup-label">' + self.escapeHtml(groupLabel) + '</div>';
|
||
html += '<div class="method-optgroup-items">';
|
||
|
||
$optgroup.children('option').each(function() {
|
||
var $el = $(this);
|
||
var icon = $el.data('icon') || 'icon-cog';
|
||
var label = $el.text();
|
||
var value = $el.val();
|
||
var isSelected = $el.is(':selected');
|
||
|
||
html += '<div class="method-dropdown-item' + (isSelected ? ' selected' : '') + '" data-value="' + self.escapeAttr(value) + '">';
|
||
html += '<i class="' + self.escapeAttr(icon) + ' method-item-icon"></i>';
|
||
html += '<span class="method-item-label">' + self.escapeHtml(label) + '</span>';
|
||
if (isSelected) {
|
||
html += '<i class="icon-check method-item-check"></i>';
|
||
}
|
||
html += '</div>';
|
||
});
|
||
|
||
html += '</div>'; // close items
|
||
html += '</div>'; // close optgroup
|
||
});
|
||
|
||
html += '</div>';
|
||
return html;
|
||
},
|
||
|
||
/**
|
||
* Close the method dropdown menu
|
||
*/
|
||
closeMethodDropdownMenu: function() {
|
||
if (this.$methodDropdownMenu) {
|
||
this.$methodDropdownMenu.remove();
|
||
this.$methodDropdownMenu = null;
|
||
}
|
||
this.$methodDropdownSelect = null;
|
||
this.$methodDropdownTrigger = null;
|
||
$(document).off('click.methodDropdown keydown.methodDropdown');
|
||
},
|
||
|
||
/**
|
||
* Populate tiles for multi_select_tiles value picker
|
||
*/
|
||
populateTiles: function($picker, options, exclusive) {
|
||
var self = this;
|
||
var $container = $picker.find('.multi-select-tiles');
|
||
$container.empty();
|
||
|
||
if (exclusive) {
|
||
$container.attr('data-exclusive', 'true');
|
||
} else {
|
||
$container.removeAttr('data-exclusive');
|
||
}
|
||
|
||
$.each(options, function(key, optData) {
|
||
var label = typeof optData === 'object' ? optData.label : optData;
|
||
var icon = typeof optData === 'object' && optData.icon ? optData.icon : null;
|
||
var color = typeof optData === 'object' && optData.color ? optData.color : null;
|
||
|
||
var tileClass = 'tile-option';
|
||
if (color) {
|
||
tileClass += ' tile-color-' + color;
|
||
}
|
||
|
||
var $tile = $('<button>', {
|
||
type: 'button',
|
||
class: tileClass,
|
||
'data-value': key
|
||
});
|
||
|
||
if (icon) {
|
||
$tile.append($('<i>', { class: icon }));
|
||
}
|
||
$tile.append($('<span>', { class: 'tile-label', text: label }));
|
||
|
||
$container.append($tile);
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Apply step/min constraints to numeric range inputs
|
||
*/
|
||
applyRangeInputConstraints: function($picker, step, min) {
|
||
var $inputs = $picker.find('.range-min-input, .range-max-input');
|
||
|
||
if (typeof step !== 'undefined' && step !== null) {
|
||
$inputs.attr('step', step);
|
||
} else {
|
||
$inputs.attr('step', 'any');
|
||
}
|
||
|
||
if (typeof min !== 'undefined' && min !== null) {
|
||
$inputs.attr('min', min);
|
||
} else {
|
||
$inputs.removeAttr('min');
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Show error message on range input
|
||
*/
|
||
showRangeInputError: function($input, message) {
|
||
var $container = $input.closest('.multi-range-input-row');
|
||
|
||
$container.find('.range-input-error').remove();
|
||
$container.find('.range-min-input, .range-max-input').removeClass('has-error');
|
||
|
||
$input.addClass('has-error');
|
||
var $error = $('<span>', {
|
||
class: 'range-input-error',
|
||
text: message
|
||
});
|
||
$container.append($error);
|
||
|
||
setTimeout(function() {
|
||
$input.removeClass('has-error');
|
||
$error.fadeOut(200, function() {
|
||
$(this).remove();
|
||
});
|
||
}, 3000);
|
||
},
|
||
|
||
/**
|
||
* Load attribute groups for combination picker
|
||
*/
|
||
loadCombinationAttributeGroups: function($picker) {
|
||
var self = this;
|
||
var trans = this.config.trans || {};
|
||
var $container = $picker.find('.combination-groups-container');
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'getAttributeGroups',
|
||
trait: 'TargetConditions'
|
||
},
|
||
success: function(response) {
|
||
$container.empty();
|
||
|
||
if (!response.success || !response.groups || response.groups.length === 0) {
|
||
$container.html('<span class="combination-empty">' +
|
||
self.escapeHtml(trans.no_attribute_groups || 'No attribute groups found') +
|
||
'</span>');
|
||
return;
|
||
}
|
||
|
||
response.groups.forEach(function(group) {
|
||
var $groupDiv = $('<div>', {
|
||
class: 'comb-attr-group',
|
||
'data-group-id': group.id
|
||
});
|
||
|
||
var $groupHeader = $('<div>', { class: 'comb-attr-group-header' });
|
||
$groupHeader.append($('<span>', {
|
||
class: 'comb-attr-group-name',
|
||
text: group.name
|
||
}));
|
||
$groupHeader.append($('<span>', {
|
||
class: 'comb-attr-group-count',
|
||
text: '0'
|
||
}));
|
||
|
||
var $toolbar = $('<div>', { class: 'comb-attr-toolbar' });
|
||
$toolbar.append($('<button>', {
|
||
type: 'button',
|
||
class: 'comb-toolbar-btn comb-select-all',
|
||
title: trans.select_all || 'Select all',
|
||
html: '<i class="icon-check-square-o"></i>'
|
||
}));
|
||
$toolbar.append($('<button>', {
|
||
type: 'button',
|
||
class: 'comb-toolbar-btn comb-select-none',
|
||
title: trans.clear || 'Clear',
|
||
html: '<i class="icon-square-o"></i>'
|
||
}));
|
||
$toolbar.append($('<input>', {
|
||
type: 'text',
|
||
class: 'comb-attr-search',
|
||
placeholder: trans.filter_results || 'Filter...'
|
||
}));
|
||
|
||
var $valuesContainer = $('<div>', {
|
||
class: 'comb-attr-values',
|
||
'data-loaded': 'false'
|
||
});
|
||
$valuesContainer.append($('<span>', {
|
||
class: 'comb-attr-loading',
|
||
html: '<i class="icon-spinner icon-spin"></i>'
|
||
}));
|
||
|
||
$groupDiv.append($groupHeader);
|
||
$groupDiv.append($toolbar);
|
||
$groupDiv.append($valuesContainer);
|
||
$container.append($groupDiv);
|
||
|
||
self.loadCombinationAttributeValues($picker, group.id, $valuesContainer);
|
||
});
|
||
},
|
||
error: function() {
|
||
$container.html('<span class="combination-error">' +
|
||
self.escapeHtml(trans.error_loading || 'Error loading attribute groups') +
|
||
'</span>');
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Load attribute values for a specific group
|
||
*/
|
||
loadCombinationAttributeValues: function($picker, groupId, $container) {
|
||
var self = this;
|
||
var trans = this.config.trans || {};
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'getAttributeValues',
|
||
trait: 'TargetConditions',
|
||
id_attribute_group: groupId
|
||
},
|
||
success: function(response) {
|
||
$container.empty();
|
||
$container.attr('data-loaded', 'true');
|
||
|
||
if (!response.success || !response.values || response.values.length === 0) {
|
||
$container.html('<span class="comb-attr-empty">' +
|
||
self.escapeHtml(trans.no_values || 'No values') +
|
||
'</span>');
|
||
return;
|
||
}
|
||
|
||
response.values.forEach(function(value) {
|
||
var productCount = parseInt(value.product_count) || 0;
|
||
var $valueBtn = $('<button>', {
|
||
type: 'button',
|
||
class: 'comb-attr-value',
|
||
'data-value-id': value.id,
|
||
'data-group-id': groupId,
|
||
'data-name': value.name.toLowerCase()
|
||
});
|
||
$valueBtn.append($('<span>', {
|
||
class: 'comb-attr-value-name',
|
||
text: value.name
|
||
}));
|
||
if (productCount > 0) {
|
||
$valueBtn.append($('<span>', {
|
||
class: 'comb-attr-value-count',
|
||
text: productCount
|
||
}));
|
||
}
|
||
$container.append($valueBtn);
|
||
});
|
||
|
||
self.restoreCombinationSelections($picker);
|
||
},
|
||
error: function() {
|
||
$container.html('<span class="comb-attr-error">' +
|
||
self.escapeHtml(trans.error_loading || 'Error') +
|
||
'</span>');
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Restore previously selected combination values from hidden input
|
||
*/
|
||
restoreCombinationSelections: function($picker) {
|
||
var $dataInput = $picker.find('.include-values-data, .exclude-values-data').first();
|
||
var dataStr = $dataInput.val() || '{}';
|
||
var data;
|
||
|
||
try {
|
||
data = JSON.parse(dataStr);
|
||
} catch (e) {
|
||
return;
|
||
}
|
||
|
||
var attributes = data.attributes || data;
|
||
var mode = data.mode || 'products';
|
||
|
||
$picker.find('.comb-mode-radio[value="' + mode + '"]').prop('checked', true);
|
||
|
||
$.each(attributes, function(groupId, valueIds) {
|
||
if (!Array.isArray(valueIds)) return;
|
||
|
||
valueIds.forEach(function(valueId) {
|
||
$picker.find('.comb-attr-value[data-group-id="' + groupId + '"][data-value-id="' + valueId + '"]')
|
||
.addClass('selected');
|
||
});
|
||
});
|
||
|
||
this.updateCombinationGroupCounts($picker);
|
||
},
|
||
|
||
/**
|
||
* Update hidden input with current combination selections
|
||
*/
|
||
updateCombinationData: function($picker) {
|
||
var attributes = {};
|
||
|
||
$picker.find('.comb-attr-value.selected').each(function() {
|
||
var groupId = $(this).data('groupId').toString();
|
||
var valueId = $(this).data('valueId');
|
||
|
||
if (!attributes[groupId]) {
|
||
attributes[groupId] = [];
|
||
}
|
||
attributes[groupId].push(valueId);
|
||
});
|
||
|
||
var $combPicker = $picker.find('.combination-attributes-picker');
|
||
var configMode = $combPicker.data('combinationMode') || this.config.combinationMode || 'products';
|
||
var mode;
|
||
|
||
if (configMode === 'toggle') {
|
||
mode = $picker.find('.comb-mode-radio:checked').val() || 'products';
|
||
} else {
|
||
mode = configMode;
|
||
}
|
||
|
||
var data = {
|
||
mode: mode,
|
||
attributes: attributes
|
||
};
|
||
|
||
var $dataInput = $picker.find('.include-values-data, .exclude-values-data').first();
|
||
$dataInput.val(JSON.stringify(data));
|
||
|
||
this.updateCombinationGroupCounts($picker);
|
||
},
|
||
|
||
/**
|
||
* Update the count badges on each attribute group
|
||
*/
|
||
updateCombinationGroupCounts: function($picker) {
|
||
$picker.find('.comb-attr-group').each(function() {
|
||
var $group = $(this);
|
||
var count = $group.find('.comb-attr-value.selected').length;
|
||
$group.find('.comb-attr-group-count').text(count);
|
||
|
||
if (count > 0) {
|
||
$group.addClass('has-selections');
|
||
} else {
|
||
$group.removeClass('has-selections');
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Update the info placeholder based on method and block type
|
||
*/
|
||
updateMethodInfoPlaceholder: function($headerRow, method, blockType) {
|
||
var $placeholder = $headerRow.find('.method-info-placeholder');
|
||
if (!$placeholder.length) return;
|
||
|
||
$placeholder.empty();
|
||
|
||
var methodHelp = this.config.methodHelp || {};
|
||
var blockHelp = methodHelp[blockType] || methodHelp['products'] || {};
|
||
var helpContent = blockHelp[method] || this.getBuiltInMethodHelp(method);
|
||
|
||
if (helpContent) {
|
||
var $infoWrapper = $('<span>', {
|
||
class: 'mpr-info-wrapper',
|
||
'data-details': helpContent
|
||
});
|
||
$infoWrapper.append($('<span>', { class: 'mpr-icon icon-info link' }));
|
||
$placeholder.append($infoWrapper);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Get built-in help content for targeting methods
|
||
*/
|
||
getBuiltInMethodHelp: function(method) {
|
||
var trans = this.config.trans || {};
|
||
var html = '';
|
||
|
||
switch (method) {
|
||
case 'all':
|
||
html = '<strong>' + this.escapeHtml(trans.help_all_title || 'All Items') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_all_desc || 'Selects all items without any filtering.') + '</p>';
|
||
break;
|
||
|
||
case 'specific':
|
||
html = '<strong>' + this.escapeHtml(trans.help_specific_title || 'Specific Items') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_specific_desc || 'Search and select individual items by name, reference, or ID.') + '</p>';
|
||
break;
|
||
|
||
case 'by_category':
|
||
html = '<strong>' + this.escapeHtml(trans.help_category_title || 'By Category') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_category_desc || 'Select items belonging to specific categories. Includes subcategories.') + '</p>';
|
||
break;
|
||
|
||
case 'by_manufacturer':
|
||
html = '<strong>' + this.escapeHtml(trans.help_manufacturer_title || 'By Manufacturer') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_manufacturer_desc || 'Select items from specific manufacturers/brands.') + '</p>';
|
||
break;
|
||
|
||
case 'by_supplier':
|
||
html = '<strong>' + this.escapeHtml(trans.help_supplier_title || 'By Supplier') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_supplier_desc || 'Select items from specific suppliers.') + '</p>';
|
||
break;
|
||
|
||
case 'by_tag':
|
||
html = '<strong>' + this.escapeHtml(trans.help_tag_title || 'By Tag') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_tag_desc || 'Select items with specific tags assigned.') + '</p>';
|
||
break;
|
||
|
||
case 'by_attribute':
|
||
html = '<strong>' + this.escapeHtml(trans.help_attribute_title || 'By Attribute') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_attribute_desc || 'Select items with specific attribute values (e.g., Color: Red).') + '</p>';
|
||
break;
|
||
|
||
case 'by_feature':
|
||
html = '<strong>' + this.escapeHtml(trans.help_feature_title || 'By Feature') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_feature_desc || 'Select items with specific feature values (e.g., Material: Cotton).') + '</p>';
|
||
break;
|
||
|
||
case 'by_combination':
|
||
html = '<strong>' + this.escapeHtml(trans.help_combination_title || 'Combination Targeting') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_combination_desc || 'Select items by combination attributes.') + '</p>';
|
||
html += '<p><strong>' + this.escapeHtml(trans.help_combination_logic || 'Logic:') + '</strong></p>';
|
||
html += '<ul>';
|
||
html += '<li>' + this.escapeHtml(trans.help_combination_within || 'Within group: OR (Red OR Blue)') + '</li>';
|
||
html += '<li>' + this.escapeHtml(trans.help_combination_between || 'Between groups: AND (Color AND Size)') + '</li>';
|
||
html += '</ul>';
|
||
break;
|
||
|
||
case 'by_carrier':
|
||
html = '<strong>' + this.escapeHtml(trans.help_carrier_title || 'By Carrier') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_carrier_desc || 'Select items available with specific carriers.') + '</p>';
|
||
break;
|
||
|
||
case 'by_condition':
|
||
html = '<strong>' + this.escapeHtml(trans.help_condition_title || 'By Condition') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_condition_desc || 'Filter by product condition: New, Used, or Refurbished.') + '</p>';
|
||
break;
|
||
|
||
case 'by_visibility':
|
||
html = '<strong>' + this.escapeHtml(trans.help_visibility_title || 'By Visibility') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_visibility_desc || 'Filter by where products are visible in the store.') + '</p>';
|
||
break;
|
||
|
||
case 'by_active_status':
|
||
html = '<strong>' + this.escapeHtml(trans.help_active_title || 'By Active Status') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_active_desc || 'Filter by whether products are enabled or disabled.') + '</p>';
|
||
break;
|
||
|
||
case 'by_stock_status':
|
||
html = '<strong>' + this.escapeHtml(trans.help_stock_title || 'By Stock Status') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_stock_desc || 'Filter by stock availability: In stock, Out of stock, or Low stock.') + '</p>';
|
||
break;
|
||
|
||
case 'by_on_sale':
|
||
case 'by_has_specific_price':
|
||
case 'by_is_virtual':
|
||
case 'by_is_pack':
|
||
case 'by_has_combinations':
|
||
case 'by_available_for_order':
|
||
case 'by_online_only':
|
||
case 'by_has_related':
|
||
case 'by_has_customization':
|
||
case 'by_has_attachments':
|
||
case 'by_has_additional_shipping':
|
||
html = '<strong>' + this.escapeHtml(trans.help_boolean_title || 'Yes/No Filter') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_boolean_desc || 'Filter products by this property.') + '</p>';
|
||
break;
|
||
|
||
case 'by_name_pattern':
|
||
case 'by_reference_pattern':
|
||
case 'by_description_pattern':
|
||
case 'by_long_description_pattern':
|
||
case 'by_ean13_pattern':
|
||
case 'by_upc_pattern':
|
||
case 'by_isbn_pattern':
|
||
case 'by_mpn_pattern':
|
||
case 'by_meta_title_pattern':
|
||
case 'by_meta_description_pattern':
|
||
html = '<strong>' + this.escapeHtml(trans.help_pattern_title || 'Pattern Matching') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_pattern_desc || 'Match text using patterns with wildcards.') + '</p>';
|
||
html += '<div><code>*</code> ' + this.escapeHtml(trans.help_pattern_wildcard || 'any text') + '</div>';
|
||
html += '<div><code>{number}</code> ' + this.escapeHtml(trans.help_pattern_number || 'any number') + '</div>';
|
||
html += '<div><code>{letter}</code> ' + this.escapeHtml(trans.help_pattern_letter || 'single letter A-Z') + '</div>';
|
||
break;
|
||
|
||
case 'by_id_range':
|
||
case 'by_price_range':
|
||
case 'by_weight_range':
|
||
case 'by_quantity_range':
|
||
case 'by_position_range':
|
||
html = '<strong>' + this.escapeHtml(trans.help_range_title || 'Numeric Range') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_range_desc || 'Filter by numeric values within specified ranges.') + '</p>';
|
||
html += '<p>' + this.escapeHtml(trans.help_range_tip || 'Leave min or max empty for open-ended ranges.') + '</p>';
|
||
break;
|
||
|
||
case 'by_date_added':
|
||
case 'by_date_updated':
|
||
html = '<strong>' + this.escapeHtml(trans.help_date_title || 'Date Range') + '</strong>';
|
||
html += '<p>' + this.escapeHtml(trans.help_date_desc || 'Filter by date within a specific period.') + '</p>';
|
||
break;
|
||
|
||
default:
|
||
break;
|
||
}
|
||
|
||
return html;
|
||
},
|
||
|
||
/**
|
||
* Build sort options HTML for a specific block type
|
||
*/
|
||
buildSortOptions: function(blockType) {
|
||
var options = [];
|
||
|
||
switch (blockType) {
|
||
case 'products':
|
||
options = [
|
||
{ value: 'sales', label: 'Best sellers' },
|
||
{ value: 'date_add', label: 'Newest' },
|
||
{ value: 'price', label: 'Price' },
|
||
{ value: 'name', label: 'Name' },
|
||
{ value: 'position', label: 'Position' },
|
||
{ value: 'quantity', label: 'Stock quantity' },
|
||
{ value: 'random', label: 'Random' }
|
||
];
|
||
break;
|
||
|
||
case 'categories':
|
||
options = [
|
||
{ value: 'name', label: 'Name' },
|
||
{ value: 'position', label: 'Position' },
|
||
{ value: 'product_count', label: 'Product count' },
|
||
{ value: 'total_sales', label: 'Best sellers' },
|
||
{ value: 'newest_products', label: 'Newest products' },
|
||
{ value: 'date_add', label: 'Creation date' },
|
||
{ value: 'random', label: 'Random' }
|
||
];
|
||
break;
|
||
|
||
case 'manufacturers':
|
||
case 'suppliers':
|
||
options = [
|
||
{ value: 'name', label: 'Name' },
|
||
{ value: 'product_count', label: 'Product count' },
|
||
{ value: 'total_sales', label: 'Best sellers' },
|
||
{ value: 'newest_products', label: 'Newest products' },
|
||
{ value: 'random', label: 'Random' }
|
||
];
|
||
break;
|
||
|
||
case 'cms':
|
||
case 'cms_categories':
|
||
options = [
|
||
{ value: 'name', label: 'Name' },
|
||
{ value: 'position', label: 'Position' },
|
||
{ value: 'random', label: 'Random' }
|
||
];
|
||
break;
|
||
|
||
default:
|
||
options = [
|
||
{ value: 'name', label: 'Name' },
|
||
{ value: 'random', label: 'Random' }
|
||
];
|
||
}
|
||
|
||
var html = '';
|
||
for (var i = 0; i < options.length; i++) {
|
||
html += '<option value="' + this.escapeAttr(options[i].value) + '">' +
|
||
this.escapeHtml(options[i].label) + '</option>';
|
||
}
|
||
|
||
return html;
|
||
},
|
||
|
||
/**
|
||
* Update the modifier toggle button state
|
||
*/
|
||
updateModifierButtonState: function($group) {
|
||
var limit = $group.find('.group-modifier-limit').val();
|
||
var sortBy = $group.find('.group-modifier-sort').val();
|
||
var $modifiers = $group.find('.group-modifiers');
|
||
var $btn = $group.find('.btn-toggle-modifiers');
|
||
var trans = this.config.trans || {};
|
||
|
||
$btn.find('.modifier-summary').remove();
|
||
|
||
if (limit || sortBy) {
|
||
$modifiers.addClass('has-values');
|
||
|
||
var summary = [];
|
||
if (limit) {
|
||
summary.push((trans.top || 'Top') + ' ' + limit);
|
||
}
|
||
if (sortBy) {
|
||
var sortLabel = $group.find('.group-modifier-sort option:selected').text();
|
||
summary.push(sortLabel);
|
||
}
|
||
|
||
var $arrow = $btn.find('.toggle-arrow');
|
||
$('<span class="modifier-summary">' + this.escapeHtml(summary.join(', ')) + '</span>').insertBefore($arrow);
|
||
} else {
|
||
$modifiers.removeClass('has-values');
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Lock/unlock method selector when excludes are present
|
||
*/
|
||
updateMethodSelectorLock: function($group, locked) {
|
||
var $select = $group.find('.include-method-select');
|
||
var $wrapper = $select.closest('.method-selector-wrapper');
|
||
var trans = this.config.trans || {};
|
||
|
||
if (locked) {
|
||
$select.prop('disabled', true);
|
||
|
||
if (!$wrapper.length) {
|
||
$select.wrap('<div class="method-selector-wrapper"></div>');
|
||
$wrapper = $select.parent('.method-selector-wrapper');
|
||
}
|
||
|
||
$wrapper.addClass('selector-locked');
|
||
if (!$wrapper.find('.lock-indicator').length) {
|
||
var lockHtml = '<span class="mpr-info-wrapper lock-indicator">' +
|
||
'<i class="icon-lock"></i>' +
|
||
'<span class="mpr-tooltip">' +
|
||
(trans.remove_excludes_first || 'Remove all exceptions to change selection type') +
|
||
'</span>' +
|
||
'</span>';
|
||
var $countEl = $wrapper.find('.condition-match-count');
|
||
if ($countEl.length) {
|
||
$countEl.before(lockHtml);
|
||
} else {
|
||
$wrapper.append(lockHtml);
|
||
}
|
||
}
|
||
} else {
|
||
$select.prop('disabled', false);
|
||
if ($wrapper.length) {
|
||
$wrapper.removeClass('selector-locked');
|
||
$wrapper.find('.mpr-info-wrapper.lock-indicator').remove();
|
||
} else {
|
||
$select.siblings('.mpr-info-wrapper.lock-indicator').remove();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
})(jQuery);
|
||
|
||
/**
|
||
* Entity Selector - Preview Module
|
||
* Reusable preview popover component with filter and load more
|
||
* @partial _preview.js
|
||
*/
|
||
|
||
(function($) {
|
||
'use strict';
|
||
|
||
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
|
||
|
||
window._EntitySelectorMixins.preview = {
|
||
|
||
// =========================================================================
|
||
// HEADER & TOGGLE UPDATES
|
||
// =========================================================================
|
||
|
||
updateHeaderTotalCount: function() {
|
||
var self = this;
|
||
var total = 0;
|
||
|
||
this.$wrapper.find('.target-block-tab .tab-badge').each(function() {
|
||
var $badge = $(this);
|
||
if (!$badge.hasClass('loading')) {
|
||
var count = parseInt($badge.text(), 10);
|
||
if (!isNaN(count)) {
|
||
total += count;
|
||
}
|
||
}
|
||
});
|
||
|
||
var $totalBadge = this.$wrapper.find('.trait-total-count');
|
||
if (total > 0) {
|
||
$totalBadge.text(total).show();
|
||
} else {
|
||
$totalBadge.hide();
|
||
}
|
||
|
||
this.updateShowAllToggle();
|
||
},
|
||
|
||
updateShowAllToggle: function() {
|
||
var $toggle = this.$wrapper.find('.trait-show-all-toggle');
|
||
if (!$toggle.length) return;
|
||
|
||
var $checkbox = $toggle.find('.show-all-checkbox');
|
||
var hasData = this.$wrapper.find('.target-block-tab.has-data').length > 0;
|
||
|
||
$checkbox.prop('checked', !hasData);
|
||
},
|
||
|
||
// =========================================================================
|
||
// REUSABLE PREVIEW POPOVER COMPONENT
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Create and show a reusable preview popover
|
||
* @param {Object} options Configuration options
|
||
* @param {jQuery} options.$badge - The badge element to position against
|
||
* @param {Array} options.items - Array of items to display
|
||
* @param {number} options.totalCount - Total count of items
|
||
* @param {boolean} options.hasMore - Whether more items are available
|
||
* @param {string} options.entityLabel - Label for items (e.g., "products")
|
||
* @param {string} options.previewType - Type identifier (e.g., "condition", "filter-group")
|
||
* @param {Function} options.onLoadMore - Callback when load more is clicked
|
||
* @param {Function} options.onFilter - Callback for AJAX filtering (receives query string)
|
||
* @param {Object} options.context - Context data for load more
|
||
*/
|
||
createPreviewPopover: function(options) {
|
||
var self = this;
|
||
var trans = this.config.trans || {};
|
||
|
||
var $badge = options.$badge;
|
||
var items = options.items || [];
|
||
var totalCount = options.totalCount || 0;
|
||
var hasMore = options.hasMore || false;
|
||
var entityLabel = options.entityLabel || 'products';
|
||
var previewType = options.previewType || 'default';
|
||
|
||
// Build popover HTML
|
||
var html = '<div class="target-preview-popover preview-type-' + previewType + '">';
|
||
|
||
// Header with count and close button
|
||
html += '<div class="preview-header">';
|
||
html += '<span class="preview-count">' + totalCount + ' ' + entityLabel + '</span>';
|
||
html += '<button type="button" class="preview-close"><i class="icon-times"></i></button>';
|
||
html += '</div>';
|
||
|
||
// Filter input
|
||
html += '<div class="preview-filter">';
|
||
html += '<input type="text" class="preview-filter-input" placeholder="' + (trans.filter_results || 'Filter results...') + '">';
|
||
html += '</div>';
|
||
|
||
// Items list
|
||
if (items.length > 0) {
|
||
html += '<div class="preview-list">';
|
||
html += this.renderPreviewItems(items);
|
||
html += '</div>';
|
||
|
||
// Load more footer with select dropdown
|
||
if (hasMore) {
|
||
var remaining = totalCount - items.length;
|
||
html += '<div class="preview-footer">';
|
||
html += '<div class="load-more-controls">';
|
||
html += '<span class="load-more-label">' + (trans.load || 'Load') + '</span>';
|
||
html += '<select class="load-more-select">';
|
||
if (remaining >= 10) html += '<option value="10">10</option>';
|
||
if (remaining >= 20) html += '<option value="20" selected>20</option>';
|
||
if (remaining >= 50) html += '<option value="50">50</option>';
|
||
if (remaining >= 100) html += '<option value="100">100</option>';
|
||
html += '<option value="' + remaining + '">' + (trans.all || 'All') + ' (' + remaining + ')</option>';
|
||
html += '</select>';
|
||
html += '<span class="load-more-of">' + (trans.of || 'of') + ' <span class="remaining-count">' + remaining + '</span> ' + (trans.remaining || 'remaining') + '</span>';
|
||
html += '<button type="button" class="btn-load-more"><i class="icon-plus"></i></button>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
} else {
|
||
html += '<div class="preview-empty">' + (trans.no_preview || 'No items to preview') + '</div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
|
||
// Create and append popover
|
||
var $popover = $(html);
|
||
$('body').append($popover);
|
||
|
||
// Store references
|
||
this.$previewPopover = $popover;
|
||
this.$previewList = $popover.find('.preview-list');
|
||
this.previewLoadedCount = items.length;
|
||
this.previewTotalCount = totalCount;
|
||
this.previewContext = options.context || {};
|
||
this.previewOnLoadMore = options.onLoadMore || null;
|
||
this.previewOnFilter = options.onFilter || null;
|
||
this.previewCurrentFilter = '';
|
||
this.previewEntityLabel = entityLabel;
|
||
|
||
// Event handlers
|
||
$popover.find('.preview-close').on('click', function() {
|
||
self.hidePreviewPopover();
|
||
});
|
||
|
||
// Filter input with AJAX support
|
||
var $filterInput = $popover.find('.preview-filter-input');
|
||
if (options.onFilter) {
|
||
// Use AJAX filtering with debounce
|
||
var debouncedFilter = this.debounce(function(query) {
|
||
self.previewCurrentFilter = query;
|
||
self.showFilterLoading(true);
|
||
options.onFilter.call(self, query);
|
||
}, 300);
|
||
|
||
$filterInput.on('input', function() {
|
||
var query = $(this).val().trim();
|
||
if (query === self.previewCurrentFilter) return;
|
||
debouncedFilter(query);
|
||
});
|
||
} else {
|
||
// Fallback to client-side filtering
|
||
$filterInput.on('input', function() {
|
||
var query = $(this).val().toLowerCase().trim();
|
||
self.filterPreviewItems(query);
|
||
});
|
||
}
|
||
|
||
if (options.onLoadMore) {
|
||
$popover.find('.btn-load-more').on('click', function() {
|
||
var $btn = $(this);
|
||
var $controls = $btn.closest('.load-more-controls');
|
||
var $select = $controls.find('.load-more-select');
|
||
|
||
if ($btn.hasClass('loading')) return;
|
||
|
||
$btn.addClass('loading');
|
||
$btn.find('i').removeClass('icon-plus').addClass('icon-spinner icon-spin');
|
||
$select.prop('disabled', true);
|
||
|
||
// Get selected load count
|
||
var loadCount = parseInt($select.val(), 10) || 20;
|
||
self.previewLoadCount = loadCount;
|
||
|
||
options.onLoadMore.call(self, $btn);
|
||
});
|
||
}
|
||
|
||
// Position popover below badge
|
||
var badgeOffset = $badge.offset();
|
||
var badgeHeight = $badge.outerHeight();
|
||
var badgeWidth = $badge.outerWidth();
|
||
var popoverWidth = $popover.outerWidth();
|
||
|
||
var leftPos = badgeOffset.left + (badgeWidth / 2) - (popoverWidth / 2);
|
||
var minLeft = 10;
|
||
var maxLeft = $(window).width() - popoverWidth - 10;
|
||
leftPos = Math.max(minLeft, Math.min(leftPos, maxLeft));
|
||
|
||
$popover.css({
|
||
position: 'absolute',
|
||
top: badgeOffset.top + badgeHeight + 8,
|
||
left: leftPos,
|
||
zIndex: 10000
|
||
});
|
||
|
||
// Show with transition
|
||
$popover.addClass('show');
|
||
|
||
return $popover;
|
||
},
|
||
|
||
/**
|
||
* Update popover after loading more items
|
||
*/
|
||
updatePreviewPopover: function(items, hasMore) {
|
||
var trans = this.config.trans || {};
|
||
|
||
// Update list
|
||
this.$previewList.html(this.renderPreviewItems(items));
|
||
this.previewLoadedCount = items.length;
|
||
|
||
// Update or remove load more controls
|
||
var $footer = this.$previewPopover.find('.preview-footer');
|
||
if (hasMore) {
|
||
var remaining = this.previewTotalCount - items.length;
|
||
var $controls = $footer.find('.load-more-controls');
|
||
var $btn = $controls.find('.btn-load-more');
|
||
var $select = $controls.find('.load-more-select');
|
||
|
||
// Reset button state
|
||
$btn.removeClass('loading');
|
||
$btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
|
||
$select.prop('disabled', false);
|
||
|
||
// Update remaining count
|
||
$controls.find('.remaining-count').text(remaining);
|
||
|
||
// Update select options
|
||
$select.empty();
|
||
if (remaining >= 10) $select.append('<option value="10">10</option>');
|
||
if (remaining >= 20) $select.append('<option value="20" selected>20</option>');
|
||
if (remaining >= 50) $select.append('<option value="50">50</option>');
|
||
if (remaining >= 100) $select.append('<option value="100">100</option>');
|
||
$select.append('<option value="' + remaining + '">' + (trans.all || 'All') + ' (' + remaining + ')</option>');
|
||
} else {
|
||
$footer.remove();
|
||
}
|
||
|
||
// Re-apply filter if active
|
||
var filterQuery = this.$previewPopover.find('.preview-filter-input').val();
|
||
if (filterQuery) {
|
||
this.filterPreviewItems(filterQuery.toLowerCase().trim());
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Render preview items HTML with consistent format
|
||
*/
|
||
renderPreviewItems: function(items) {
|
||
var self = this;
|
||
var html = '';
|
||
|
||
for (var i = 0; i < items.length; i++) {
|
||
var item = items[i];
|
||
var itemClass = 'preview-item';
|
||
if (item.isCombination) itemClass += ' is-combination';
|
||
|
||
// Build data attributes for filtering
|
||
var dataAttrs = '';
|
||
dataAttrs += ' data-name="' + this.escapeAttr((item.name || '').toLowerCase()) + '"';
|
||
dataAttrs += ' data-ref="' + this.escapeAttr((item.reference || '').toLowerCase()) + '"';
|
||
if (item.attributes) {
|
||
dataAttrs += ' data-attrs="' + this.escapeAttr((item.attributes || '').toLowerCase()) + '"';
|
||
}
|
||
|
||
html += '<div class="' + itemClass + '"' + dataAttrs + '>';
|
||
|
||
// Image or placeholder
|
||
if (item.image) {
|
||
html += '<img src="' + this.escapeAttr(item.image) + '" class="preview-item-image" alt="">';
|
||
} else {
|
||
html += '<div class="preview-item-icon"><i class="material-icons">inventory_2</i></div>';
|
||
}
|
||
|
||
// Info section
|
||
html += '<div class="preview-item-info">';
|
||
html += '<div class="preview-item-name">' + this.escapeHtml(item.name || 'Unnamed') + '</div>';
|
||
|
||
// Meta line (reference, manufacturer, category, attributes)
|
||
var meta = [];
|
||
if (item.reference) {
|
||
meta.push('Ref: ' + item.reference);
|
||
}
|
||
if (item.manufacturer) {
|
||
meta.push(item.manufacturer);
|
||
}
|
||
if (item.category) {
|
||
meta.push(item.category);
|
||
}
|
||
if (item.attributes) {
|
||
meta.push(item.attributes);
|
||
}
|
||
|
||
if (meta.length > 0) {
|
||
html += '<div class="preview-item-meta">' + this.escapeHtml(meta.join(' • ')) + '</div>';
|
||
}
|
||
|
||
html += '</div>'; // .preview-item-info
|
||
|
||
// Price column (always show if available)
|
||
if (typeof item.price !== 'undefined' && item.price !== null) {
|
||
html += '<div class="preview-item-price">' + this.formatPrice(item.price) + '</div>';
|
||
} else if (item.price_formatted) {
|
||
html += '<div class="preview-item-price">' + this.escapeHtml(item.price_formatted) + '</div>';
|
||
}
|
||
|
||
// Status badge if inactive
|
||
if (typeof item.active !== 'undefined' && !item.active) {
|
||
html += '<span class="preview-item-badge badge-inactive">Inactive</span>';
|
||
}
|
||
|
||
html += '</div>'; // .preview-item
|
||
}
|
||
|
||
return html;
|
||
},
|
||
|
||
/**
|
||
* Filter preview items by query (client-side fallback)
|
||
*/
|
||
filterPreviewItems: function(query) {
|
||
if (!this.$previewList) return;
|
||
|
||
var $items = this.$previewList.find('.preview-item');
|
||
|
||
if (!query) {
|
||
$items.show();
|
||
return;
|
||
}
|
||
|
||
$items.each(function() {
|
||
var $item = $(this);
|
||
var name = $item.data('name') || '';
|
||
var ref = $item.data('ref') || '';
|
||
var attrs = $item.data('attrs') || '';
|
||
|
||
var matches = name.indexOf(query) !== -1 ||
|
||
ref.indexOf(query) !== -1 ||
|
||
attrs.indexOf(query) !== -1;
|
||
|
||
$item.toggle(matches);
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Show/hide loading indicator during AJAX filter
|
||
*/
|
||
showFilterLoading: function(show) {
|
||
if (!this.$previewPopover) return;
|
||
|
||
var $list = this.$previewList;
|
||
if (!$list) return;
|
||
|
||
if (show) {
|
||
// Lock the popover width before filtering to prevent resize
|
||
if (!this.previewLockedWidth) {
|
||
this.previewLockedWidth = this.$previewPopover.outerWidth();
|
||
this.$previewPopover.css('width', this.previewLockedWidth + 'px');
|
||
}
|
||
|
||
$list.addClass('filtering');
|
||
// Add overlay if not exists
|
||
if (!$list.find('.filter-loading-overlay').length) {
|
||
$list.append('<div class="filter-loading-overlay"><i class="icon-spinner icon-spin"></i></div>');
|
||
}
|
||
} else {
|
||
$list.removeClass('filtering');
|
||
$list.find('.filter-loading-overlay').remove();
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Update preview popover with filtered AJAX results
|
||
* @param {Object} response - AJAX response with items, count, hasMore
|
||
*/
|
||
updatePreviewPopoverFiltered: function(response) {
|
||
var trans = this.config.trans || {};
|
||
|
||
this.showFilterLoading(false);
|
||
|
||
if (!response.success) {
|
||
return;
|
||
}
|
||
|
||
var items = response.items || [];
|
||
var filteredCount = response.count || 0;
|
||
var hasMore = response.hasMore || false;
|
||
|
||
// Update header count to show filtered count
|
||
var $header = this.$previewPopover.find('.preview-header');
|
||
var entityLabel = this.previewEntityLabel || 'items';
|
||
$header.find('.preview-count').text(filteredCount + ' ' + entityLabel);
|
||
|
||
// Update list
|
||
if (items.length > 0) {
|
||
this.$previewList.html(this.renderPreviewItems(items));
|
||
this.previewLoadedCount = items.length;
|
||
this.previewTotalCount = filteredCount;
|
||
} else {
|
||
var noResultsText = trans.no_filter_results || 'No matching items found';
|
||
this.$previewList.html('<div class="preview-empty">' + noResultsText + '</div>');
|
||
this.previewLoadedCount = 0;
|
||
this.previewTotalCount = 0;
|
||
}
|
||
|
||
// Update or create footer for load more
|
||
var $footer = this.$previewPopover.find('.preview-footer');
|
||
if (hasMore && items.length > 0) {
|
||
var remaining = filteredCount - items.length;
|
||
if ($footer.length) {
|
||
var $controls = $footer.find('.load-more-controls');
|
||
var $btn = $controls.find('.btn-load-more');
|
||
var $select = $controls.find('.load-more-select');
|
||
|
||
$btn.removeClass('loading');
|
||
$btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
|
||
$select.prop('disabled', false);
|
||
$controls.find('.remaining-count').text(remaining);
|
||
|
||
$select.empty();
|
||
if (remaining >= 10) $select.append('<option value="10">10</option>');
|
||
if (remaining >= 20) $select.append('<option value="20" selected>20</option>');
|
||
if (remaining >= 50) $select.append('<option value="50">50</option>');
|
||
if (remaining >= 100) $select.append('<option value="100">100</option>');
|
||
$select.append('<option value="' + remaining + '">' + (trans.all || 'All') + ' (' + remaining + ')</option>');
|
||
} else {
|
||
// Create footer
|
||
var footerHtml = '<div class="preview-footer">';
|
||
footerHtml += '<div class="load-more-controls">';
|
||
footerHtml += '<span class="load-more-label">' + (trans.load || 'Load') + '</span>';
|
||
footerHtml += '<select class="load-more-select">';
|
||
if (remaining >= 10) footerHtml += '<option value="10">10</option>';
|
||
if (remaining >= 20) footerHtml += '<option value="20" selected>20</option>';
|
||
if (remaining >= 50) footerHtml += '<option value="50">50</option>';
|
||
if (remaining >= 100) footerHtml += '<option value="100">100</option>';
|
||
footerHtml += '<option value="' + remaining + '">' + (trans.all || 'All') + ' (' + remaining + ')</option>';
|
||
footerHtml += '</select>';
|
||
footerHtml += '<span class="load-more-of">' + (trans.of || 'of') + ' <span class="remaining-count">' + remaining + '</span> ' + (trans.remaining || 'remaining') + '</span>';
|
||
footerHtml += '<button type="button" class="btn-load-more"><i class="icon-plus"></i></button>';
|
||
footerHtml += '</div>';
|
||
footerHtml += '</div>';
|
||
|
||
var $newFooter = $(footerHtml);
|
||
this.$previewList.after($newFooter);
|
||
|
||
// Rebind load more click
|
||
var self = this;
|
||
if (this.previewOnLoadMore) {
|
||
$newFooter.find('.btn-load-more').on('click', function() {
|
||
var $btn = $(this);
|
||
var $controls = $btn.closest('.load-more-controls');
|
||
var $select = $controls.find('.load-more-select');
|
||
|
||
if ($btn.hasClass('loading')) return;
|
||
|
||
$btn.addClass('loading');
|
||
$btn.find('i').removeClass('icon-plus').addClass('icon-spinner icon-spin');
|
||
$select.prop('disabled', true);
|
||
|
||
var loadCount = parseInt($select.val(), 10) || 20;
|
||
self.previewLoadCount = loadCount;
|
||
|
||
self.previewOnLoadMore.call(self, $btn);
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
$footer.remove();
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Format price for display
|
||
*/
|
||
formatPrice: function(price) {
|
||
if (typeof price !== 'number') {
|
||
price = parseFloat(price) || 0;
|
||
}
|
||
// Use currency format from config if available
|
||
var currencySign = (this.config && this.config.currency_sign) || '€';
|
||
var currencyFormat = (this.config && this.config.currency_format) || 'right';
|
||
|
||
var formatted = price.toFixed(2);
|
||
|
||
if (currencyFormat === 'left') {
|
||
return currencySign + ' ' + formatted;
|
||
} else {
|
||
return formatted + ' ' + currencySign;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Hide and clean up preview popover
|
||
*/
|
||
hidePreviewPopover: function() {
|
||
if (this.$activeBadge) {
|
||
this.$activeBadge.removeClass('popover-open loading');
|
||
this.$activeBadge = null;
|
||
}
|
||
if (this.$previewPopover) {
|
||
this.$previewPopover.remove();
|
||
this.$previewPopover = null;
|
||
}
|
||
this.$previewList = null;
|
||
this.previewContext = null;
|
||
this.previewOnLoadMore = null;
|
||
this.previewOnFilter = null;
|
||
this.previewCurrentFilter = '';
|
||
this.previewEntityLabel = null;
|
||
this.previewLockedWidth = null;
|
||
},
|
||
|
||
// =========================================================================
|
||
// TAB PREVIEW (Block tab badge click)
|
||
// =========================================================================
|
||
|
||
showPreviewPopover: function($tab) {
|
||
var self = this;
|
||
var previewData = $tab.data('previewData');
|
||
|
||
if (!previewData) {
|
||
return;
|
||
}
|
||
|
||
this.hidePreviewPopover();
|
||
|
||
var $badge = $tab.find('.tab-badge');
|
||
$badge.addClass('popover-open');
|
||
this.$activeBadge = $badge;
|
||
|
||
var items = previewData.items || previewData.products || [];
|
||
var blockType = $tab.data('blockType');
|
||
var blockConfig = this.config.blocks && this.config.blocks[blockType] ? this.config.blocks[blockType] : {};
|
||
var entityLabelPlural = blockConfig.entity_label_plural || 'items';
|
||
|
||
this.previewBlockType = blockType;
|
||
|
||
this.createPreviewPopover({
|
||
$badge: $badge,
|
||
items: items,
|
||
totalCount: previewData.count,
|
||
hasMore: previewData.hasMore,
|
||
entityLabel: entityLabelPlural,
|
||
previewType: 'tab',
|
||
context: { $tab: $tab, blockType: blockType },
|
||
onLoadMore: function($btn) {
|
||
self.loadMoreTabPreviewItems($tab, $btn);
|
||
},
|
||
onFilter: function(query) {
|
||
self.filterTabPreviewItems($tab, query);
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* AJAX filter handler for tab preview
|
||
*/
|
||
filterTabPreviewItems: function($tab, query) {
|
||
var self = this;
|
||
var blockType = this.previewBlockType;
|
||
|
||
var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]');
|
||
var savedData = {};
|
||
try {
|
||
savedData = JSON.parse($hiddenInput.val() || '{}');
|
||
} catch (e) {
|
||
self.showFilterLoading(false);
|
||
return;
|
||
}
|
||
|
||
var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : [];
|
||
if (groups.length === 0) {
|
||
self.showFilterLoading(false);
|
||
return;
|
||
}
|
||
|
||
var data = {};
|
||
data[blockType] = { groups: groups };
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'previewTargetConditions',
|
||
trait: 'TargetConditions',
|
||
conditions: JSON.stringify(data),
|
||
block_type: blockType,
|
||
filter: query,
|
||
limit: 20,
|
||
offset: 0
|
||
},
|
||
success: function(response) {
|
||
self.updatePreviewPopoverFiltered(response);
|
||
},
|
||
error: function() {
|
||
self.showFilterLoading(false);
|
||
}
|
||
});
|
||
},
|
||
|
||
loadMoreTabPreviewItems: function($tab, $btn) {
|
||
var self = this;
|
||
var blockType = this.previewBlockType;
|
||
|
||
var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]');
|
||
var savedData = {};
|
||
try {
|
||
savedData = JSON.parse($hiddenInput.val() || '{}');
|
||
} catch (e) {
|
||
return;
|
||
}
|
||
|
||
var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : [];
|
||
if (groups.length === 0) return;
|
||
|
||
var data = {};
|
||
data[blockType] = { groups: groups };
|
||
|
||
var loadCount = this.previewLoadCount || 20;
|
||
|
||
// Include current filter in load more request
|
||
var ajaxData = {
|
||
ajax: 1,
|
||
action: 'previewTargetConditions',
|
||
trait: 'TargetConditions',
|
||
conditions: JSON.stringify(data),
|
||
block_type: blockType,
|
||
limit: self.previewLoadedCount + loadCount,
|
||
offset: 0
|
||
};
|
||
if (self.previewCurrentFilter) {
|
||
ajaxData.filter = self.previewCurrentFilter;
|
||
}
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: ajaxData,
|
||
success: function(response) {
|
||
var items = response.items || response.products || [];
|
||
if (response.success && items.length > 0) {
|
||
$tab.data('previewData', response);
|
||
self.previewTotalCount = response.count;
|
||
self.updatePreviewPopover(items, response.hasMore);
|
||
}
|
||
},
|
||
error: function() {
|
||
var $controls = $btn.closest('.load-more-controls');
|
||
var $select = $controls.find('.load-more-select');
|
||
$btn.removeClass('loading');
|
||
$btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
|
||
$select.prop('disabled', false);
|
||
}
|
||
});
|
||
},
|
||
|
||
// =========================================================================
|
||
// CONDITION PREVIEW (Single condition badge click)
|
||
// =========================================================================
|
||
|
||
showConditionPreviewPopover: function($badge) {
|
||
var self = this;
|
||
var conditionData = $badge.data('conditionData');
|
||
|
||
if (!conditionData) {
|
||
return;
|
||
}
|
||
|
||
this.hidePreviewPopover();
|
||
|
||
$badge.addClass('popover-open loading');
|
||
this.$activeBadge = $badge;
|
||
|
||
var blockType = conditionData.blockType || 'products';
|
||
var blockConfig = this.config.blocks && this.config.blocks[blockType] ? this.config.blocks[blockType] : {};
|
||
var entityLabelPlural = blockConfig.entity_label_plural || 'products';
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'previewConditionItems',
|
||
trait: 'EntitySelector',
|
||
method: conditionData.method,
|
||
values: JSON.stringify(conditionData.values),
|
||
block_type: blockType,
|
||
limit: 10
|
||
},
|
||
success: function(response) {
|
||
$badge.removeClass('loading');
|
||
|
||
if (response.success) {
|
||
self.createPreviewPopover({
|
||
$badge: $badge,
|
||
items: response.items || [],
|
||
totalCount: response.count,
|
||
hasMore: response.hasMore,
|
||
entityLabel: entityLabelPlural,
|
||
previewType: 'condition',
|
||
context: { conditionData: conditionData, blockType: blockType },
|
||
onLoadMore: function($btn) {
|
||
self.loadMoreConditionItems($btn);
|
||
},
|
||
onFilter: function(query) {
|
||
self.filterConditionItems(query);
|
||
}
|
||
});
|
||
} else {
|
||
$badge.removeClass('popover-open');
|
||
self.$activeBadge = null;
|
||
}
|
||
},
|
||
error: function() {
|
||
$badge.removeClass('loading popover-open');
|
||
self.$activeBadge = null;
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* AJAX filter handler for condition preview
|
||
*/
|
||
filterConditionItems: function(query) {
|
||
var self = this;
|
||
var ctx = this.previewContext;
|
||
|
||
if (!ctx || !ctx.conditionData) {
|
||
self.showFilterLoading(false);
|
||
return;
|
||
}
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'previewConditionItems',
|
||
trait: 'EntitySelector',
|
||
method: ctx.conditionData.method,
|
||
values: JSON.stringify(ctx.conditionData.values),
|
||
block_type: ctx.blockType,
|
||
filter: query,
|
||
limit: 20
|
||
},
|
||
success: function(response) {
|
||
self.updatePreviewPopoverFiltered(response);
|
||
},
|
||
error: function() {
|
||
self.showFilterLoading(false);
|
||
}
|
||
});
|
||
},
|
||
|
||
loadMoreConditionItems: function($btn) {
|
||
var self = this;
|
||
var ctx = this.previewContext;
|
||
|
||
if (!ctx || !ctx.conditionData) return;
|
||
|
||
var loadCount = this.previewLoadCount || 20;
|
||
|
||
// Include current filter in load more request
|
||
var ajaxData = {
|
||
ajax: 1,
|
||
action: 'previewConditionItems',
|
||
trait: 'EntitySelector',
|
||
method: ctx.conditionData.method,
|
||
values: JSON.stringify(ctx.conditionData.values),
|
||
block_type: ctx.blockType,
|
||
limit: self.previewLoadedCount + loadCount
|
||
};
|
||
if (self.previewCurrentFilter) {
|
||
ajaxData.filter = self.previewCurrentFilter;
|
||
}
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: ajaxData,
|
||
success: function(response) {
|
||
if (response.success) {
|
||
self.previewTotalCount = response.count;
|
||
self.updatePreviewPopover(response.items || [], response.hasMore);
|
||
}
|
||
},
|
||
error: function() {
|
||
var $controls = $btn.closest('.load-more-controls');
|
||
var $select = $controls.find('.load-more-select');
|
||
$btn.removeClass('loading');
|
||
$btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
|
||
$select.prop('disabled', false);
|
||
}
|
||
});
|
||
},
|
||
|
||
// =========================================================================
|
||
// GROUP PREVIEW (Selection group badge click)
|
||
// =========================================================================
|
||
|
||
showGroupPreviewPopover: function($badge, $group, blockType) {
|
||
var self = this;
|
||
|
||
if (!$group) {
|
||
$group = $badge.closest('.selection-group');
|
||
}
|
||
|
||
if (!blockType) {
|
||
var $block = $badge.closest('.target-block');
|
||
blockType = $block.data('blockType') || 'products';
|
||
}
|
||
|
||
var groupData = $badge.data('groupData');
|
||
|
||
if (!groupData) {
|
||
groupData = this.serializeGroup($group, blockType);
|
||
}
|
||
|
||
if (!groupData || !groupData.include) {
|
||
return;
|
||
}
|
||
|
||
this.hidePreviewPopover();
|
||
|
||
$badge.addClass('popover-open loading');
|
||
this.$activeBadge = $badge;
|
||
|
||
var blockConfig = this.config.blocks && this.config.blocks[blockType] ? this.config.blocks[blockType] : {};
|
||
var entityLabelPlural = blockConfig.entity_label_plural || 'products';
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'previewGroupItems',
|
||
trait: 'EntitySelector',
|
||
group_data: JSON.stringify(groupData),
|
||
block_type: blockType,
|
||
limit: 10
|
||
},
|
||
success: function(response) {
|
||
$badge.removeClass('loading');
|
||
|
||
if (response.success) {
|
||
self.createPreviewPopover({
|
||
$badge: $badge,
|
||
items: response.items || [],
|
||
totalCount: response.count,
|
||
hasMore: response.hasMore,
|
||
entityLabel: entityLabelPlural,
|
||
previewType: 'group',
|
||
context: { groupData: groupData, blockType: blockType, $group: $group },
|
||
onLoadMore: function($btn) {
|
||
self.loadMoreGroupItems($btn);
|
||
},
|
||
onFilter: function(query) {
|
||
self.filterGroupItems(query);
|
||
}
|
||
});
|
||
} else {
|
||
$badge.removeClass('popover-open');
|
||
self.$activeBadge = null;
|
||
}
|
||
},
|
||
error: function() {
|
||
$badge.removeClass('loading popover-open');
|
||
self.$activeBadge = null;
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* AJAX filter handler for group preview
|
||
*/
|
||
filterGroupItems: function(query) {
|
||
var self = this;
|
||
var ctx = this.previewContext;
|
||
|
||
if (!ctx || !ctx.groupData) {
|
||
self.showFilterLoading(false);
|
||
return;
|
||
}
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'previewGroupItems',
|
||
trait: 'EntitySelector',
|
||
group_data: JSON.stringify(ctx.groupData),
|
||
block_type: ctx.blockType,
|
||
filter: query,
|
||
limit: 20
|
||
},
|
||
success: function(response) {
|
||
self.updatePreviewPopoverFiltered(response);
|
||
},
|
||
error: function() {
|
||
self.showFilterLoading(false);
|
||
}
|
||
});
|
||
},
|
||
|
||
loadMoreGroupItems: function($btn) {
|
||
var self = this;
|
||
var ctx = this.previewContext;
|
||
|
||
if (!ctx || !ctx.groupData) return;
|
||
|
||
var loadCount = this.previewLoadCount || 20;
|
||
|
||
// Include current filter in load more request
|
||
var ajaxData = {
|
||
ajax: 1,
|
||
action: 'previewGroupItems',
|
||
trait: 'EntitySelector',
|
||
group_data: JSON.stringify(ctx.groupData),
|
||
block_type: ctx.blockType,
|
||
limit: self.previewLoadedCount + loadCount
|
||
};
|
||
if (self.previewCurrentFilter) {
|
||
ajaxData.filter = self.previewCurrentFilter;
|
||
}
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: ajaxData,
|
||
success: function(response) {
|
||
if (response.success) {
|
||
self.previewTotalCount = response.count;
|
||
self.updatePreviewPopover(response.items || [], response.hasMore);
|
||
}
|
||
},
|
||
error: function() {
|
||
$btn.removeClass('loading');
|
||
$btn.find('.load-more-text').show();
|
||
$btn.find('.load-more-loading').hide();
|
||
}
|
||
});
|
||
},
|
||
|
||
// =========================================================================
|
||
// FILTER GROUP PREVIEW (Attribute/Feature group toggle badge)
|
||
// =========================================================================
|
||
|
||
showFilterGroupPreviewPopover: function($badge, groupId, groupType, groupName) {
|
||
var self = this;
|
||
|
||
this.hidePreviewPopover();
|
||
|
||
$badge.addClass('popover-open loading');
|
||
this.$activeBadge = $badge;
|
||
|
||
var entityLabelPlural = 'products';
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'previewFilterGroupProducts',
|
||
trait: 'EntitySelector',
|
||
group_id: groupId,
|
||
group_type: groupType,
|
||
limit: 10
|
||
},
|
||
success: function(response) {
|
||
$badge.removeClass('loading');
|
||
|
||
if (response.success) {
|
||
self.createPreviewPopover({
|
||
$badge: $badge,
|
||
items: response.items || [],
|
||
totalCount: response.count || 0,
|
||
hasMore: response.hasMore || false,
|
||
entityLabel: entityLabelPlural,
|
||
previewType: 'filter-group',
|
||
context: { groupId: groupId, groupType: groupType, groupName: groupName },
|
||
onLoadMore: function($btn) {
|
||
self.loadMoreFilterGroupItems($btn);
|
||
},
|
||
onFilter: function(query) {
|
||
self.filterFilterGroupItems(query);
|
||
}
|
||
});
|
||
} else {
|
||
$badge.removeClass('popover-open');
|
||
self.$activeBadge = null;
|
||
}
|
||
},
|
||
error: function() {
|
||
$badge.removeClass('loading popover-open');
|
||
self.$activeBadge = null;
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* AJAX filter handler for filter group preview
|
||
*/
|
||
filterFilterGroupItems: function(query) {
|
||
var self = this;
|
||
var ctx = this.previewContext;
|
||
|
||
if (!ctx || !ctx.groupId) {
|
||
self.showFilterLoading(false);
|
||
return;
|
||
}
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'previewFilterGroupProducts',
|
||
trait: 'EntitySelector',
|
||
group_id: ctx.groupId,
|
||
group_type: ctx.groupType,
|
||
filter: query,
|
||
limit: 20
|
||
},
|
||
success: function(response) {
|
||
self.updatePreviewPopoverFiltered(response);
|
||
},
|
||
error: function() {
|
||
self.showFilterLoading(false);
|
||
}
|
||
});
|
||
},
|
||
|
||
loadMoreFilterGroupItems: function($btn) {
|
||
var self = this;
|
||
var ctx = this.previewContext;
|
||
|
||
if (!ctx || !ctx.groupId) return;
|
||
|
||
var loadCount = this.previewLoadCount || 20;
|
||
|
||
// Include current filter in load more request
|
||
var ajaxData = {
|
||
ajax: 1,
|
||
action: 'previewFilterGroupProducts',
|
||
trait: 'EntitySelector',
|
||
group_id: ctx.groupId,
|
||
group_type: ctx.groupType,
|
||
limit: self.previewLoadedCount + loadCount
|
||
};
|
||
if (self.previewCurrentFilter) {
|
||
ajaxData.filter = self.previewCurrentFilter;
|
||
}
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: ajaxData,
|
||
success: function(response) {
|
||
if (response.success) {
|
||
self.previewTotalCount = response.count;
|
||
self.updatePreviewPopover(response.items || [], response.hasMore);
|
||
}
|
||
},
|
||
error: function() {
|
||
$btn.removeClass('loading');
|
||
$btn.find('.load-more-text').show();
|
||
$btn.find('.load-more-loading').hide();
|
||
}
|
||
});
|
||
},
|
||
|
||
// =========================================================================
|
||
// CATEGORY ITEMS PREVIEW (products/pages in a category)
|
||
// =========================================================================
|
||
|
||
showCategoryItemsPreview: function($badge, categoryId, categoryName, entityType) {
|
||
var self = this;
|
||
|
||
this.hidePreviewPopover();
|
||
|
||
$badge.addClass('popover-open loading');
|
||
this.$activeBadge = $badge;
|
||
|
||
var isProducts = (entityType === 'categories');
|
||
var entityLabelPlural = isProducts ? 'products' : 'pages';
|
||
var action = isProducts ? 'previewCategoryProducts' : 'previewCategoryPages';
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: action,
|
||
trait: 'EntitySelector',
|
||
category_id: categoryId,
|
||
limit: 10
|
||
},
|
||
success: function(response) {
|
||
$badge.removeClass('loading');
|
||
|
||
if (response.success) {
|
||
self.createPreviewPopover({
|
||
$badge: $badge,
|
||
items: response.items || [],
|
||
totalCount: response.count || 0,
|
||
hasMore: response.hasMore || false,
|
||
entityLabel: entityLabelPlural,
|
||
previewType: 'category-items',
|
||
context: { categoryId: categoryId, categoryName: categoryName, entityType: entityType },
|
||
onLoadMore: function($btn) {
|
||
self.loadMoreCategoryItems($btn);
|
||
},
|
||
onFilter: function(query) {
|
||
self.filterCategoryItems(query);
|
||
}
|
||
});
|
||
} else {
|
||
$badge.removeClass('popover-open');
|
||
self.$activeBadge = null;
|
||
}
|
||
},
|
||
error: function() {
|
||
$badge.removeClass('loading popover-open');
|
||
self.$activeBadge = null;
|
||
}
|
||
});
|
||
},
|
||
|
||
loadMoreCategoryItems: function($btn) {
|
||
var self = this;
|
||
var ctx = this.previewContext;
|
||
|
||
if (!ctx || !ctx.categoryId) return;
|
||
|
||
var isProducts = (ctx.entityType === 'categories');
|
||
var action = isProducts ? 'previewCategoryProducts' : 'previewCategoryPages';
|
||
|
||
$btn.prop('disabled', true).find('i').addClass('icon-spin');
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: action,
|
||
trait: 'EntitySelector',
|
||
category_id: ctx.categoryId,
|
||
offset: this.previewOffset,
|
||
limit: 10,
|
||
query: this.previewFilterQuery || ''
|
||
},
|
||
success: function(response) {
|
||
$btn.prop('disabled', false).find('i').removeClass('icon-spin');
|
||
|
||
if (response.success && response.items) {
|
||
self.appendPreviewItems(response.items);
|
||
self.previewOffset += response.items.length;
|
||
|
||
if (!response.hasMore) {
|
||
$btn.hide();
|
||
}
|
||
}
|
||
},
|
||
error: function() {
|
||
$btn.prop('disabled', false).find('i').removeClass('icon-spin');
|
||
}
|
||
});
|
||
},
|
||
|
||
filterCategoryItems: function(query) {
|
||
var self = this;
|
||
var ctx = this.previewContext;
|
||
|
||
if (!ctx || !ctx.categoryId) {
|
||
self.showFilterLoading(false);
|
||
return;
|
||
}
|
||
|
||
var isProducts = (ctx.entityType === 'categories');
|
||
var action = isProducts ? 'previewCategoryProducts' : 'previewCategoryPages';
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: action,
|
||
trait: 'EntitySelector',
|
||
category_id: ctx.categoryId,
|
||
query: query,
|
||
limit: 10
|
||
},
|
||
success: function(response) {
|
||
self.showFilterLoading(false);
|
||
|
||
if (response.success) {
|
||
self.replacePreviewItems(response.items || [], response.count || 0, response.hasMore || false);
|
||
self.previewOffset = response.items ? response.items.length : 0;
|
||
self.previewFilterQuery = query;
|
||
}
|
||
},
|
||
error: function() {
|
||
self.showFilterLoading(false);
|
||
}
|
||
});
|
||
},
|
||
|
||
// =========================================================================
|
||
// PATTERN PREVIEW MODAL (for regex/pattern matching)
|
||
// =========================================================================
|
||
|
||
showPatternPreviewModal: function(pattern, entityType, caseSensitive, count) {
|
||
var self = this;
|
||
var trans = this.config.trans || {};
|
||
|
||
var blockConfig = this.config.blocks && this.config.blocks[entityType] ? this.config.blocks[entityType] : {};
|
||
var entityLabelPlural = blockConfig.entity_label_plural || 'items';
|
||
var entityLabelSingular = blockConfig.entity_label || 'item';
|
||
|
||
var html = '<div class="pattern-preview-modal-overlay">';
|
||
html += '<div class="pattern-preview-modal">';
|
||
html += '<div class="pattern-preview-header">';
|
||
html += '<span class="pattern-preview-title">';
|
||
html += '<i class="icon-eye"></i> ' + (trans.preview || 'Preview') + ': <code>' + this.escapeHtml(pattern) + '</code>';
|
||
html += '</span>';
|
||
html += '<span class="pattern-preview-count">' + count + ' ' + (count === 1 ? entityLabelSingular : entityLabelPlural) + '</span>';
|
||
html += '<button type="button" class="pattern-preview-close"><i class="icon-times"></i></button>';
|
||
html += '</div>';
|
||
html += '<div class="pattern-preview-content">';
|
||
html += '<div class="pattern-preview-loading"><i class="icon-spinner icon-spin"></i> ' + (trans.loading || 'Loading...') + '</div>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
|
||
var $modal = $(html);
|
||
$('body').append($modal);
|
||
|
||
$modal.find('.pattern-preview-close').on('click', function() {
|
||
$modal.remove();
|
||
});
|
||
$modal.on('click', function(e) {
|
||
if ($(e.target).hasClass('pattern-preview-modal-overlay')) {
|
||
$modal.remove();
|
||
}
|
||
});
|
||
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'previewPatternMatches',
|
||
trait: 'TargetConditions',
|
||
pattern: pattern,
|
||
entity_type: entityType,
|
||
case_sensitive: caseSensitive ? 1 : 0,
|
||
limit: 50
|
||
},
|
||
success: function(response) {
|
||
if (response.success && response.items) {
|
||
var items = response.items;
|
||
var listHtml = '<div class="pattern-preview-list">';
|
||
|
||
if (items.length === 0) {
|
||
listHtml += '<div class="pattern-preview-empty">' + (trans.no_matches || 'No matches found') + '</div>';
|
||
} else {
|
||
for (var i = 0; i < items.length; i++) {
|
||
var item = items[i];
|
||
listHtml += '<div class="pattern-preview-item">';
|
||
if (item.image) {
|
||
listHtml += '<img src="' + self.escapeAttr(item.image) + '" alt="" class="preview-item-image">';
|
||
}
|
||
listHtml += '<span class="preview-item-name">' + self.escapeHtml(item.name) + '</span>';
|
||
if (item.id) {
|
||
listHtml += '<span class="preview-item-id">#' + item.id + '</span>';
|
||
}
|
||
listHtml += '</div>';
|
||
}
|
||
|
||
if (count > items.length) {
|
||
listHtml += '<div class="pattern-preview-more">... ' + (trans.and || 'and') + ' ' + (count - items.length) + ' ' + (trans.more || 'more') + '</div>';
|
||
}
|
||
}
|
||
|
||
listHtml += '</div>';
|
||
$modal.find('.pattern-preview-content').html(listHtml);
|
||
} else {
|
||
$modal.find('.pattern-preview-content').html('<div class="pattern-preview-error">' + (trans.error_loading || 'Error loading preview') + '</div>');
|
||
}
|
||
},
|
||
error: function() {
|
||
$modal.find('.pattern-preview-content').html('<div class="pattern-preview-error">' + (trans.error_loading || 'Error loading preview') + '</div>');
|
||
}
|
||
});
|
||
},
|
||
|
||
// =========================================================================
|
||
// HELPER METHODS
|
||
// =========================================================================
|
||
|
||
refreshGroupPreviewIfOpen: function($group) {
|
||
// Check if preview is for this group and refresh if needed
|
||
if (!this.$activeBadge || !this.$previewPopover) {
|
||
return;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Escape HTML special characters
|
||
*/
|
||
escapeHtml: function(str) {
|
||
if (!str) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
},
|
||
|
||
/**
|
||
* Escape attribute value
|
||
*/
|
||
escapeAttr: function(str) {
|
||
if (!str) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
},
|
||
|
||
// =========================================================================
|
||
// TOTAL COUNT PREVIEW (Header total badge click)
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Show preview popover for total count badge
|
||
* Displays a summary of all entity types with their counts
|
||
*/
|
||
showTotalPreviewPopover: function($badge) {
|
||
console.log('[EntitySelector] showTotalPreviewPopover called', { badge: $badge[0] });
|
||
var self = this;
|
||
var trans = this.config.trans || {};
|
||
|
||
this.hidePreviewPopover();
|
||
|
||
$badge.addClass('popover-open');
|
||
this.$activeBadge = $badge;
|
||
|
||
// Collect all entity types with data
|
||
var summaryItems = [];
|
||
console.log('[EntitySelector] Looking for tabs with data...');
|
||
this.$wrapper.find('.target-block-tab.has-data').each(function() {
|
||
var $tab = $(this);
|
||
var blockType = $tab.data('blockType');
|
||
var $tabBadge = $tab.find('.tab-badge');
|
||
var countText = $tabBadge.text().replace(/[^0-9]/g, '');
|
||
var count = parseInt(countText, 10) || 0;
|
||
|
||
if (count > 0) {
|
||
var blockConfig = self.config.blocks && self.config.blocks[blockType] ? self.config.blocks[blockType] : {};
|
||
var icon = $tab.find('.tab-label').prev('i').attr('class') || 'icon-cube';
|
||
var label = $tab.find('.tab-label').text() || blockType;
|
||
|
||
summaryItems.push({
|
||
blockType: blockType,
|
||
label: label,
|
||
icon: icon,
|
||
count: count
|
||
});
|
||
}
|
||
});
|
||
|
||
console.log('[EntitySelector] Summary items collected:', summaryItems);
|
||
|
||
// Build popover HTML
|
||
var totalCount = parseInt($badge.find('.count-value').text(), 10) || 0;
|
||
console.log('[EntitySelector] Building popover, totalCount:', totalCount);
|
||
var popoverHtml = '<div class="target-preview-popover total-preview-popover">';
|
||
popoverHtml += '<div class="preview-popover-header">';
|
||
popoverHtml += '<span class="preview-popover-title">' + (trans.total_summary || 'Selection Summary') + '</span>';
|
||
popoverHtml += '<span class="preview-popover-count">' + totalCount + ' ' + (trans.total_items || 'total items') + '</span>';
|
||
popoverHtml += '</div>';
|
||
popoverHtml += '<div class="preview-popover-body">';
|
||
popoverHtml += '<ul class="total-summary-list">';
|
||
|
||
for (var i = 0; i < summaryItems.length; i++) {
|
||
var item = summaryItems[i];
|
||
popoverHtml += '<li class="total-summary-item" data-block-type="' + item.blockType + '">';
|
||
popoverHtml += '<i class="' + self.escapeAttr(item.icon) + '"></i>';
|
||
popoverHtml += '<span class="summary-item-label">' + self.escapeHtml(item.label) + '</span>';
|
||
popoverHtml += '<span class="summary-item-count">' + item.count + '</span>';
|
||
popoverHtml += '</li>';
|
||
}
|
||
|
||
popoverHtml += '</ul>';
|
||
popoverHtml += '</div>';
|
||
popoverHtml += '</div>';
|
||
|
||
var $popover = $(popoverHtml);
|
||
this.$previewPopover = $popover;
|
||
|
||
// Click on item to switch to that tab
|
||
$popover.on('click', '.total-summary-item', function() {
|
||
var blockType = $(this).data('blockType');
|
||
self.hidePreviewPopover();
|
||
self.switchToBlock(blockType);
|
||
});
|
||
|
||
// Position popover
|
||
$('body').append($popover);
|
||
var badgeOffset = $badge.offset();
|
||
var badgeHeight = $badge.outerHeight();
|
||
var popoverWidth = $popover.outerWidth();
|
||
|
||
$popover.css({
|
||
position: 'absolute',
|
||
top: badgeOffset.top + badgeHeight + 5,
|
||
left: badgeOffset.left - (popoverWidth / 2) + ($badge.outerWidth() / 2),
|
||
zIndex: 10000
|
||
});
|
||
|
||
// Adjust if off screen
|
||
var windowWidth = $(window).width();
|
||
var popoverRight = $popover.offset().left + popoverWidth;
|
||
if (popoverRight > windowWidth - 10) {
|
||
$popover.css('left', windowWidth - popoverWidth - 10);
|
||
}
|
||
if ($popover.offset().left < 10) {
|
||
$popover.css('left', 10);
|
||
}
|
||
|
||
$popover.hide().fadeIn(150);
|
||
}
|
||
};
|
||
|
||
})(jQuery);
|
||
|
||
/**
|
||
* Entity Selector - Category Tree Module
|
||
* Hierarchical tree view for category selection inside the dropdown
|
||
* @partial _tree.js
|
||
*
|
||
* Features:
|
||
* - Expand/collapse individual nodes
|
||
* - Expand all / Collapse all
|
||
* - Select parent with all children button
|
||
* - Visual tree with indentation
|
||
* - Product count display
|
||
* - Search/filter within tree
|
||
*/
|
||
|
||
(function($) {
|
||
'use strict';
|
||
|
||
// Create mixin namespace
|
||
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
|
||
|
||
// Tree mixin
|
||
window._EntitySelectorMixins.tree = {
|
||
|
||
// Tree state
|
||
treeData: null,
|
||
treeFlatData: null,
|
||
|
||
/**
|
||
* Load and display category tree in the dropdown
|
||
* Called when view mode is changed to "tree"
|
||
*/
|
||
loadCategoryTree: function() {
|
||
var self = this;
|
||
var $results = this.$dropdown.find('.dropdown-results');
|
||
var trans = this.config.trans || {};
|
||
var searchEntity = this.activeGroup ? this.activeGroup.searchEntity : 'categories';
|
||
|
||
// Show loading
|
||
$results.html('<div class="tree-loading"><i class="icon-spinner icon-spin"></i> ' +
|
||
this.escapeHtml(trans.loading || 'Loading...') + '</div>');
|
||
|
||
// Fetch tree data
|
||
$.ajax({
|
||
url: this.config.ajaxUrl,
|
||
type: 'POST',
|
||
dataType: 'json',
|
||
data: {
|
||
ajax: 1,
|
||
action: 'getCategoryTree',
|
||
trait: 'EntitySelector',
|
||
entity_type: searchEntity
|
||
},
|
||
success: function(response) {
|
||
if (response.success && response.categories && response.categories.length > 0) {
|
||
self.treeFlatData = response.categories;
|
||
self.treeData = self.buildTreeStructure(response.categories);
|
||
self.renderCategoryTree($results, searchEntity);
|
||
} else {
|
||
$results.html('<div class="dropdown-empty">' +
|
||
self.escapeHtml(trans.no_categories || 'No categories found') + '</div>');
|
||
}
|
||
},
|
||
error: function() {
|
||
$results.html('<div class="dropdown-error">' +
|
||
self.escapeHtml(trans.error_loading || 'Failed to load categories') + '</div>');
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Build nested tree structure from flat array
|
||
* @param {Array} flatData - Flat array with parent_id references
|
||
* @returns {Array} Nested tree structure
|
||
*/
|
||
buildTreeStructure: function(flatData) {
|
||
var lookup = {};
|
||
var tree = [];
|
||
|
||
// Create lookup and initialize children arrays
|
||
flatData.forEach(function(item) {
|
||
lookup[item.id] = $.extend({}, item, { children: [] });
|
||
});
|
||
|
||
// Build tree by assigning children to parents
|
||
flatData.forEach(function(item) {
|
||
var node = lookup[item.id];
|
||
var parentId = parseInt(item.parent_id, 10);
|
||
|
||
if (parentId && lookup[parentId]) {
|
||
lookup[parentId].children.push(node);
|
||
} else {
|
||
tree.push(node);
|
||
}
|
||
});
|
||
|
||
return tree;
|
||
},
|
||
|
||
/**
|
||
* Render the category tree inside dropdown results
|
||
* @param {jQuery} $container - The dropdown-results container
|
||
* @param {string} entityType - 'categories' or 'cms_categories'
|
||
*/
|
||
renderCategoryTree: function($container, entityType) {
|
||
var self = this;
|
||
var trans = this.config.trans || {};
|
||
|
||
// Get currently selected IDs from chips
|
||
var selectedIds = this.getSelectedIdsFromChips();
|
||
|
||
// Build tree HTML
|
||
var html = '<div class="category-tree" data-entity-type="' + this.escapeAttr(entityType) + '">';
|
||
|
||
// Tree toolbar
|
||
html += '<div class="tree-toolbar">';
|
||
html += '<button type="button" class="btn-expand-all" title="' +
|
||
this.escapeAttr(trans.expand_all || 'Expand all') + '">';
|
||
html += '<i class="icon-plus-square-o"></i> ' + this.escapeHtml(trans.expand_all || 'Expand all');
|
||
html += '</button>';
|
||
html += '<button type="button" class="btn-collapse-all" title="' +
|
||
this.escapeAttr(trans.collapse_all || 'Collapse all') + '">';
|
||
html += '<i class="icon-minus-square-o"></i> ' + this.escapeHtml(trans.collapse_all || 'Collapse all');
|
||
html += '</button>';
|
||
html += '</div>';
|
||
|
||
// Tree items
|
||
html += '<div class="tree-items">';
|
||
html += this.renderTreeItems(this.treeData, 0, selectedIds);
|
||
html += '</div>';
|
||
|
||
html += '</div>';
|
||
|
||
$container.html(html);
|
||
|
||
// Update count
|
||
var totalCount = this.treeFlatData ? this.treeFlatData.length : 0;
|
||
var selectedCount = selectedIds.length;
|
||
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
|
||
var countText = totalCount + ' ' + categoryLabel;
|
||
if (selectedCount > 0) {
|
||
countText += ' (' + selectedCount + ' selected)';
|
||
}
|
||
this.$dropdown.find('.results-count').text(countText);
|
||
|
||
// Update select children button states
|
||
this.updateSelectChildrenButtons(this.$dropdown.find('.tree-item'));
|
||
},
|
||
|
||
/**
|
||
* Render tree items recursively
|
||
* @param {Array} nodes - Tree nodes
|
||
* @param {number} level - Current depth level
|
||
* @param {Array} selectedIds - Currently selected IDs
|
||
* @returns {string} HTML string
|
||
*/
|
||
renderTreeItems: function(nodes, level, selectedIds) {
|
||
var self = this;
|
||
var html = '';
|
||
var trans = this.config.trans || {};
|
||
|
||
nodes.forEach(function(node) {
|
||
var hasChildren = node.children && node.children.length > 0;
|
||
var isSelected = selectedIds.indexOf(parseInt(node.id, 10)) !== -1;
|
||
var indent = level * 20;
|
||
|
||
var itemClass = 'tree-item';
|
||
if (hasChildren) itemClass += ' has-children';
|
||
if (isSelected) itemClass += ' selected';
|
||
if (!node.active) itemClass += ' inactive';
|
||
|
||
html += '<div class="' + itemClass + '" data-id="' + node.id + '" ';
|
||
html += 'data-name="' + self.escapeAttr(node.name) + '" ';
|
||
html += 'data-level="' + level + '" ';
|
||
html += 'data-parent-id="' + (node.parent_id || 0) + '">';
|
||
|
||
// Indentation
|
||
html += '<span class="tree-indent" style="width: ' + indent + 'px;"></span>';
|
||
|
||
// Toggle button (expand/collapse)
|
||
if (hasChildren) {
|
||
html += '<span class="tree-toggle"><i class="icon-caret-down"></i></span>';
|
||
// Select with children button (next to toggle on the left)
|
||
html += '<button type="button" class="btn-select-children" title="' +
|
||
self.escapeAttr(trans.select_with_children || 'Select with all children') + '">';
|
||
html += '<i class="icon-check-square-o"></i>';
|
||
html += '</button>';
|
||
} else {
|
||
html += '<span class="tree-toggle tree-leaf"></span>';
|
||
}
|
||
|
||
// Checkbox indicator
|
||
html += '<span class="tree-checkbox"><i class="icon-check"></i></span>';
|
||
|
||
// Category icon
|
||
html += '<span class="tree-icon"><i class="icon-folder"></i></span>';
|
||
|
||
// Name
|
||
html += '<span class="tree-name">' + self.escapeHtml(node.name) + '</span>';
|
||
|
||
// Product/page count with clickable preview
|
||
var itemCount = node.product_count || node.page_count || 0;
|
||
if (itemCount > 0) {
|
||
var countLabel = node.page_count ? (trans.pages || 'pages') : (trans.products || 'products');
|
||
html += '<span class="tree-count clickable" data-category-id="' + node.id + '" ';
|
||
html += 'title="' + self.escapeAttr(itemCount + ' ' + countLabel) + '">';
|
||
html += '<i class="icon-eye"></i> ' + itemCount;
|
||
html += '</span>';
|
||
}
|
||
|
||
// Inactive badge
|
||
if (!node.active) {
|
||
html += '<span class="tree-badge inactive">' +
|
||
self.escapeHtml(trans.inactive || 'Inactive') + '</span>';
|
||
}
|
||
|
||
html += '</div>';
|
||
|
||
// Render children
|
||
if (hasChildren) {
|
||
html += '<div class="tree-children">';
|
||
html += self.renderTreeItems(node.children, level + 1, selectedIds);
|
||
html += '</div>';
|
||
}
|
||
});
|
||
|
||
return html;
|
||
},
|
||
|
||
/**
|
||
* Get selected IDs from the current picker's chips
|
||
* @returns {Array} Array of selected IDs
|
||
*/
|
||
getSelectedIdsFromChips: function() {
|
||
var selectedIds = [];
|
||
|
||
if (!this.activeGroup) return selectedIds;
|
||
|
||
var $block = this.$wrapper.find('.target-block[data-block-type="' + this.activeGroup.blockType + '"]');
|
||
var $group = $block.find('.selection-group[data-group-index="' + this.activeGroup.groupIndex + '"]');
|
||
var $picker;
|
||
|
||
if (this.activeGroup.section === 'include') {
|
||
$picker = $group.find('.include-picker');
|
||
} else {
|
||
var $excludeRow = $group.find('.exclude-row[data-exclude-index="' + this.activeGroup.excludeIndex + '"]');
|
||
$picker = $excludeRow.find('.exclude-picker');
|
||
}
|
||
|
||
$picker.find('.entity-chip').each(function() {
|
||
selectedIds.push(parseInt($(this).data('id'), 10));
|
||
});
|
||
|
||
return selectedIds;
|
||
},
|
||
|
||
/**
|
||
* Filter category tree by search query
|
||
* @param {string} query - Search query
|
||
*/
|
||
filterCategoryTree: function(query) {
|
||
var $tree = this.$dropdown.find('.category-tree');
|
||
if (!$tree.length) return;
|
||
|
||
var $items = $tree.find('.tree-item');
|
||
var $children = $tree.find('.tree-children');
|
||
query = (query || '').toLowerCase().trim();
|
||
|
||
// Remove any inline display styles set by jQuery .toggle()
|
||
$items.css('display', '');
|
||
|
||
if (!query) {
|
||
$items.removeClass('filtered-out filter-match');
|
||
$children.removeClass('filter-expanded');
|
||
return;
|
||
}
|
||
|
||
// Mark all as filtered out first
|
||
$items.addClass('filtered-out').removeClass('filter-match');
|
||
|
||
// Find matching items and show them with their parents
|
||
$items.each(function() {
|
||
var $item = $(this);
|
||
var name = ($item.data('name') || '').toLowerCase();
|
||
|
||
if (name.indexOf(query) !== -1) {
|
||
$item.removeClass('filtered-out');
|
||
|
||
// Show parent containers
|
||
$item.parents('.tree-children').addClass('filter-expanded');
|
||
$item.parents('.tree-item').removeClass('filtered-out');
|
||
|
||
// Show children of matching item
|
||
$item.next('.tree-children').find('.tree-item').removeClass('filtered-out');
|
||
$item.next('.tree-children').addClass('filter-expanded');
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Find all descendant tree items of a given item
|
||
* @param {jQuery} $item - Parent tree item
|
||
* @param {jQuery} $allItems - All tree items (for performance)
|
||
* @returns {Array} Array of descendant jQuery elements
|
||
*/
|
||
findTreeDescendants: function($item, $allItems) {
|
||
var descendants = [];
|
||
var parentId = parseInt($item.data('id'), 10);
|
||
var level = parseInt($item.data('level'), 10);
|
||
|
||
// Find immediate children first
|
||
var $next = $item.next('.tree-children');
|
||
if ($next.length) {
|
||
$next.find('.tree-item').each(function() {
|
||
descendants.push(this);
|
||
});
|
||
}
|
||
|
||
return descendants;
|
||
},
|
||
|
||
/**
|
||
* Update the state of select-children buttons based on selection
|
||
* @param {jQuery} $allItems - All tree items
|
||
*/
|
||
updateSelectChildrenButtons: function($allItems) {
|
||
var self = this;
|
||
var trans = this.config.trans || {};
|
||
|
||
$allItems.filter('.has-children').each(function() {
|
||
var $item = $(this);
|
||
var $btn = $item.find('.btn-select-children');
|
||
if (!$btn.length) return;
|
||
|
||
var $children = $item.next('.tree-children');
|
||
if (!$children.length) return;
|
||
|
||
var $childItems = $children.find('.tree-item');
|
||
var isParentSelected = $item.hasClass('selected');
|
||
var allChildrenSelected = true;
|
||
|
||
$childItems.each(function() {
|
||
if (!$(this).hasClass('selected')) {
|
||
allChildrenSelected = false;
|
||
return false;
|
||
}
|
||
});
|
||
|
||
if (isParentSelected && allChildrenSelected) {
|
||
$btn.find('i').removeClass('icon-plus-square').addClass('icon-minus-square');
|
||
$btn.attr('title', trans.deselect_with_children || 'Deselect with all children');
|
||
} else {
|
||
$btn.find('i').removeClass('icon-minus-square').addClass('icon-plus-square');
|
||
$btn.attr('title', trans.select_with_children || 'Select with all children');
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
})(jQuery);
|
||
|
||
/**
|
||
* Entity Selector - Core Module
|
||
* Factory, initialization, state management
|
||
* @partial _core.js
|
||
*
|
||
* IMPORTANT: This file must be loaded LAST in the concatenation order
|
||
* as it combines all mixins from other partials.
|
||
*
|
||
* EXTRACTION SOURCE: assets/js/admin/entity-selector.js
|
||
* Lines: 15-55 (createTargetConditionsInstance, state variables)
|
||
* 56-110 (init method)
|
||
* 108-132 (observeNewSelects)
|
||
* 7889-7951 (Factory object, window export, document ready)
|
||
*
|
||
* Contains:
|
||
* - createTargetConditionsInstance() - Factory function
|
||
* - State variable initialization
|
||
* - init() - Main initialization method
|
||
* - observeNewSelects() - MutationObserver for dynamic selects
|
||
* - loadExistingSelections() - Restore saved state
|
||
* - TargetConditions factory object
|
||
* - window.TargetConditions export
|
||
* - Document ready auto-initialization
|
||
*/
|
||
|
||
(function($) {
|
||
'use strict';
|
||
|
||
/**
|
||
* Create a new TargetConditions instance
|
||
* Each instance is independent and manages its own wrapper/state
|
||
*/
|
||
function createTargetConditionsInstance() {
|
||
// Base instance object with state variables
|
||
var instance = {
|
||
config: {},
|
||
$wrapper: null,
|
||
$dropdown: null,
|
||
activeGroup: null, // { blockType, groupIndex, section: 'include'|'exclude' }
|
||
searchTimeout: null,
|
||
searchResults: [],
|
||
searchTotal: 0,
|
||
searchOffset: 0,
|
||
searchQuery: '',
|
||
isLoading: false,
|
||
loadMoreCount: 20,
|
||
// Sort, filter, view state
|
||
viewMode: 'list',
|
||
currentSort: { field: 'name', dir: 'ASC' },
|
||
refineQuery: '',
|
||
refineNegate: false,
|
||
filters: {
|
||
inStock: false,
|
||
discounted: false,
|
||
priceMin: null,
|
||
priceMax: null,
|
||
attributes: [],
|
||
features: []
|
||
},
|
||
filterableData: null,
|
||
// Search history
|
||
searchHistory: {},
|
||
searchHistoryMax: 10,
|
||
searchHistoryKey: 'targetConditionsSearchHistory',
|
||
// Chips visibility
|
||
maxVisibleChips: 20,
|
||
// Method dropdown references
|
||
$methodDropdownMenu: null,
|
||
$methodDropdownSelect: null,
|
||
$methodDropdownTrigger: null,
|
||
// Preview state
|
||
$previewPopover: null,
|
||
$activeBadge: null,
|
||
$previewList: null,
|
||
previewLoadedCount: 0,
|
||
previewBlockType: null,
|
||
allPreviewData: null,
|
||
// Count update timeout
|
||
countUpdateTimeout: null,
|
||
|
||
init: function(options) {
|
||
this.config = $.extend({
|
||
id: 'target-conditions',
|
||
name: 'target_conditions',
|
||
namePrefix: 'target_',
|
||
mode: 'multi', // Global mode: 'multi' or 'single'
|
||
blocks: {},
|
||
ajaxUrl: '',
|
||
trans: {}
|
||
}, options);
|
||
|
||
this.$wrapper = $('[data-entity-selector-id="' + this.config.id + '"]');
|
||
|
||
if (!this.$wrapper.length) {
|
||
return;
|
||
}
|
||
|
||
// Global single mode - hide "Add Group" buttons
|
||
if (this.config.mode === 'single') {
|
||
this.$wrapper.find('.btn-add-group').hide();
|
||
this.$wrapper.find('.group-excludes').hide();
|
||
this.$wrapper.find('.group-modifiers').hide();
|
||
}
|
||
|
||
// Add fullwidth class to parent form-group
|
||
var $formGroup = this.$wrapper.closest('.form-group');
|
||
$formGroup.addClass('condition-trait-fullwidth');
|
||
$formGroup.find('.col-lg-offset-3').removeClass('col-lg-offset-3');
|
||
|
||
this.createDropdown();
|
||
this.bindEvents();
|
||
this.loadExistingSelections();
|
||
this.loadSearchHistory();
|
||
|
||
// Initialize styled method dropdowns
|
||
this.initMethodDropdowns();
|
||
|
||
// Watch for dynamically added selects
|
||
this.observeNewSelects();
|
||
|
||
// Update counts on page load
|
||
var self = this;
|
||
setTimeout(function() {
|
||
self.updateTabBadges();
|
||
self.updateAllConditionCounts();
|
||
}, 100);
|
||
},
|
||
|
||
observeNewSelects: function() {
|
||
var self = this;
|
||
|
||
if (typeof MutationObserver === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
var observer = new MutationObserver(function(mutations) {
|
||
mutations.forEach(function(mutation) {
|
||
if (mutation.addedNodes.length) {
|
||
$(mutation.addedNodes).find('.include-method-select, .exclude-method-select').each(function() {
|
||
self.enhanceMethodSelect($(this));
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
observer.observe(this.$wrapper[0], {
|
||
childList: true,
|
||
subtree: true
|
||
});
|
||
},
|
||
|
||
loadExistingSelections: function() {
|
||
// TODO: Extract full implementation from original
|
||
// Reads JSON from hidden input and populates chips
|
||
}
|
||
};
|
||
|
||
// Merge all mixins into the instance
|
||
// Each mixin adds its methods to window._EntitySelectorMixins
|
||
var mixins = window._EntitySelectorMixins || {};
|
||
|
||
// Merge utils mixin
|
||
if (mixins.utils) {
|
||
$.extend(instance, mixins.utils);
|
||
}
|
||
|
||
// Merge events mixin
|
||
if (mixins.events) {
|
||
$.extend(instance, mixins.events);
|
||
}
|
||
|
||
// Merge dropdown mixin
|
||
if (mixins.dropdown) {
|
||
$.extend(instance, mixins.dropdown);
|
||
}
|
||
|
||
// Merge search mixin
|
||
if (mixins.search) {
|
||
$.extend(instance, mixins.search);
|
||
}
|
||
|
||
// Merge filters mixin
|
||
if (mixins.filters) {
|
||
$.extend(instance, mixins.filters);
|
||
}
|
||
|
||
// Merge chips mixin
|
||
if (mixins.chips) {
|
||
$.extend(instance, mixins.chips);
|
||
}
|
||
|
||
// Merge groups mixin
|
||
if (mixins.groups) {
|
||
$.extend(instance, mixins.groups);
|
||
}
|
||
|
||
// Merge methods mixin
|
||
if (mixins.methods) {
|
||
$.extend(instance, mixins.methods);
|
||
}
|
||
|
||
// Merge preview mixin
|
||
if (mixins.preview) {
|
||
$.extend(instance, mixins.preview);
|
||
}
|
||
|
||
// Merge tree mixin
|
||
if (mixins.tree) {
|
||
$.extend(instance, mixins.tree);
|
||
}
|
||
|
||
return instance;
|
||
}
|
||
|
||
// Factory object for creating and managing instances
|
||
var TargetConditions = {
|
||
instances: [],
|
||
|
||
// Create and initialize a new instance
|
||
create: function(options) {
|
||
var instance = createTargetConditionsInstance();
|
||
instance.init(options);
|
||
this.instances.push(instance);
|
||
return instance;
|
||
},
|
||
|
||
// For backwards compatibility - init creates a new instance
|
||
init: function(options) {
|
||
return this.create(options);
|
||
},
|
||
|
||
// Validate all instances - returns true if all valid
|
||
validateAll: function() {
|
||
var allValid = true;
|
||
for (var i = 0; i < this.instances.length; i++) {
|
||
if (!this.instances[i].validate()) {
|
||
allValid = false;
|
||
}
|
||
}
|
||
return allValid;
|
||
}
|
||
};
|
||
|
||
// Export to window
|
||
window.TargetConditions = TargetConditions;
|
||
|
||
// Auto-initialize on document ready
|
||
$(document).ready(function() {
|
||
// Auto-initialize from data-config attributes on wrapper elements
|
||
$('[data-entity-selector-id]').each(function() {
|
||
var configData = $(this).data('config');
|
||
if (configData) {
|
||
TargetConditions.create(configData);
|
||
}
|
||
});
|
||
|
||
// Tips box toggle handler
|
||
$(document).on('click', '.target-tips-box .tips-header', function(e) {
|
||
e.preventDefault();
|
||
$(this).closest('.target-tips-box').toggleClass('expanded');
|
||
});
|
||
|
||
// Form submission validation for required target conditions
|
||
$(document).on('submit', 'form', function(e) {
|
||
var $form = $(this);
|
||
if ($form.find('.target-conditions-trait[data-required]').length > 0) {
|
||
if (!TargetConditions.validateAll()) {
|
||
e.preventDefault();
|
||
return false;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
})(jQuery);
|