Add AJAX filtering for preview popover with width lock

- Convert preview filter from client-side JS to server-side AJAX
- Add debounce utility function for 300ms delayed AJAX calls
- Add filter handlers for all 4 preview types (tab, condition, group, filter-group)
- Add filterProductIdsByQuery() method to EntityPreviewHandler
- Update all AJAX handlers to support filter parameter
- Lock popover width when filtering to prevent resize
- Add loading overlay CSS for filter state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 14:07:56 +01:00
parent 6f248605a7
commit b79a89bbb4
11 changed files with 917 additions and 80 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -22,6 +22,24 @@
// 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)
@@ -1784,7 +1802,8 @@
// 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-search-dropdown').length &&
!$(e.target).closest('.target-preview-popover').length) {
self.hideDropdown();
}
});
@@ -7050,6 +7069,7 @@
* @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) {
@@ -7118,16 +7138,37 @@
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();
});
$popover.find('.preview-filter-input').on('input', function() {
var query = $(this).val().toLowerCase().trim();
self.filterPreviewItems(query);
});
// 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() {
@@ -7290,7 +7331,7 @@
},
/**
* Filter preview items by query
* Filter preview items by query (client-side fallback)
*/
filterPreviewItems: function(query) {
if (!this.$previewList) return;
@@ -7316,6 +7357,133 @@
});
},
/**
* 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
*/
@@ -7351,6 +7519,10 @@
this.$previewList = null;
this.previewContext = null;
this.previewOnLoadMore = null;
this.previewOnFilter = null;
this.previewCurrentFilter = '';
this.previewEntityLabel = null;
this.previewLockedWidth = null;
},
// =========================================================================
@@ -7388,6 +7560,57 @@
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);
}
});
},
@@ -7412,19 +7635,25 @@
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: {
ajax: 1,
action: 'previewTargetConditions',
trait: 'TargetConditions',
conditions: JSON.stringify(data),
block_type: blockType,
limit: self.previewLoadedCount + loadCount,
offset: 0
},
data: ajaxData,
success: function(response) {
var items = response.items || response.products || [];
if (response.success && items.length > 0) {
@@ -7491,6 +7720,9 @@
context: { conditionData: conditionData, blockType: blockType },
onLoadMore: function($btn) {
self.loadMoreConditionItems($btn);
},
onFilter: function(query) {
self.filterConditionItems(query);
}
});
} else {
@@ -7505,13 +7737,17 @@
});
},
loadMoreConditionItems: function($btn) {
/**
* AJAX filter handler for condition preview
*/
filterConditionItems: function(query) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.conditionData) return;
var loadCount = this.previewLoadCount || 20;
if (!ctx || !ctx.conditionData) {
self.showFilterLoading(false);
return;
}
$.ajax({
url: this.config.ajaxUrl,
@@ -7524,8 +7760,45 @@
method: ctx.conditionData.method,
values: JSON.stringify(ctx.conditionData.values),
block_type: ctx.blockType,
limit: self.previewLoadedCount + loadCount
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;
@@ -7602,6 +7875,9 @@
context: { groupData: groupData, blockType: blockType, $group: $group },
onLoadMore: function($btn) {
self.loadMoreGroupItems($btn);
},
onFilter: function(query) {
self.filterGroupItems(query);
}
});
} else {
@@ -7616,13 +7892,17 @@
});
},
loadMoreGroupItems: function($btn) {
/**
* AJAX filter handler for group preview
*/
filterGroupItems: function(query) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.groupData) return;
var loadCount = this.previewLoadCount || 20;
if (!ctx || !ctx.groupData) {
self.showFilterLoading(false);
return;
}
$.ajax({
url: this.config.ajaxUrl,
@@ -7634,8 +7914,44 @@
trait: 'EntitySelector',
group_data: JSON.stringify(ctx.groupData),
block_type: ctx.blockType,
limit: self.previewLoadedCount + loadCount
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;
@@ -7690,6 +8006,9 @@
context: { groupId: groupId, groupType: groupType, groupName: groupName },
onLoadMore: function($btn) {
self.loadMoreFilterGroupItems($btn);
},
onFilter: function(query) {
self.filterFilterGroupItems(query);
}
});
} else {
@@ -7704,13 +8023,17 @@
});
},
loadMoreFilterGroupItems: function($btn) {
/**
* AJAX filter handler for filter group preview
*/
filterFilterGroupItems: function(query) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.groupId) return;
var loadCount = this.previewLoadCount || 20;
if (!ctx || !ctx.groupId) {
self.showFilterLoading(false);
return;
}
$.ajax({
url: this.config.ajaxUrl,
@@ -7722,8 +8045,44 @@
trait: 'EntitySelector',
group_id: ctx.groupId,
group_type: ctx.groupType,
limit: self.previewLoadedCount + loadCount
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;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1644,7 +1644,8 @@
// 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-search-dropdown').length &&
!$(e.target).closest('.target-preview-popover').length) {
self.hideDropdown();
}
});

View File

@@ -63,6 +63,7 @@
* @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) {
@@ -131,16 +132,37 @@
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();
});
$popover.find('.preview-filter-input').on('input', function() {
var query = $(this).val().toLowerCase().trim();
self.filterPreviewItems(query);
});
// 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() {
@@ -303,7 +325,7 @@
},
/**
* Filter preview items by query
* Filter preview items by query (client-side fallback)
*/
filterPreviewItems: function(query) {
if (!this.$previewList) return;
@@ -329,6 +351,133 @@
});
},
/**
* 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
*/
@@ -364,6 +513,10 @@
this.$previewList = null;
this.previewContext = null;
this.previewOnLoadMore = null;
this.previewOnFilter = null;
this.previewCurrentFilter = '';
this.previewEntityLabel = null;
this.previewLockedWidth = null;
},
// =========================================================================
@@ -401,6 +554,57 @@
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);
}
});
},
@@ -425,19 +629,25 @@
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: {
ajax: 1,
action: 'previewTargetConditions',
trait: 'TargetConditions',
conditions: JSON.stringify(data),
block_type: blockType,
limit: self.previewLoadedCount + loadCount,
offset: 0
},
data: ajaxData,
success: function(response) {
var items = response.items || response.products || [];
if (response.success && items.length > 0) {
@@ -504,6 +714,9 @@
context: { conditionData: conditionData, blockType: blockType },
onLoadMore: function($btn) {
self.loadMoreConditionItems($btn);
},
onFilter: function(query) {
self.filterConditionItems(query);
}
});
} else {
@@ -518,13 +731,17 @@
});
},
loadMoreConditionItems: function($btn) {
/**
* AJAX filter handler for condition preview
*/
filterConditionItems: function(query) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.conditionData) return;
var loadCount = this.previewLoadCount || 20;
if (!ctx || !ctx.conditionData) {
self.showFilterLoading(false);
return;
}
$.ajax({
url: this.config.ajaxUrl,
@@ -537,8 +754,45 @@
method: ctx.conditionData.method,
values: JSON.stringify(ctx.conditionData.values),
block_type: ctx.blockType,
limit: self.previewLoadedCount + loadCount
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;
@@ -615,6 +869,9 @@
context: { groupData: groupData, blockType: blockType, $group: $group },
onLoadMore: function($btn) {
self.loadMoreGroupItems($btn);
},
onFilter: function(query) {
self.filterGroupItems(query);
}
});
} else {
@@ -629,13 +886,17 @@
});
},
loadMoreGroupItems: function($btn) {
/**
* AJAX filter handler for group preview
*/
filterGroupItems: function(query) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.groupData) return;
var loadCount = this.previewLoadCount || 20;
if (!ctx || !ctx.groupData) {
self.showFilterLoading(false);
return;
}
$.ajax({
url: this.config.ajaxUrl,
@@ -647,8 +908,44 @@
trait: 'EntitySelector',
group_data: JSON.stringify(ctx.groupData),
block_type: ctx.blockType,
limit: self.previewLoadedCount + loadCount
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;
@@ -703,6 +1000,9 @@
context: { groupId: groupId, groupType: groupType, groupName: groupName },
onLoadMore: function($btn) {
self.loadMoreFilterGroupItems($btn);
},
onFilter: function(query) {
self.filterFilterGroupItems(query);
}
});
} else {
@@ -717,13 +1017,17 @@
});
},
loadMoreFilterGroupItems: function($btn) {
/**
* AJAX filter handler for filter group preview
*/
filterFilterGroupItems: function(query) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.groupId) return;
var loadCount = this.previewLoadCount || 20;
if (!ctx || !ctx.groupId) {
self.showFilterLoading(false);
return;
}
$.ajax({
url: this.config.ajaxUrl,
@@ -735,8 +1039,44 @@
trait: 'EntitySelector',
group_id: ctx.groupId,
group_type: ctx.groupType,
limit: self.previewLoadedCount + loadCount
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;

View File

@@ -22,6 +22,24 @@
// 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)

View File

@@ -84,20 +84,55 @@
display: flex;
flex-direction: column;
gap: $es-spacing-xs;
position: relative;
// Loading overlay for AJAX filtering
&.filtering {
pointer-events: none;
opacity: 0.6;
}
.filter-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba($es-white, 0.7);
z-index: 10;
i {
font-size: 20px;
color: $es-primary;
animation: spin 0.6s linear infinite;
}
}
}
// Filter input section
.preview-filter {
padding: $es-spacing-sm $es-spacing-md;
padding: $es-spacing-sm;
border-bottom: 1px solid $es-border-color;
.preview-filter-input {
@include input-base;
width: 100%;
padding: $es-spacing-sm;
font-size: $es-font-size-sm;
padding: $es-spacing-xs $es-spacing-sm;
font-size: $es-font-size-xs;
line-height: 1.4;
color: $es-text-primary;
background-color: $es-white;
border: 1px solid $es-border-color;
border-radius: $es-radius-sm;
box-sizing: border-box;
&:focus {
border-color: $es-primary;
outline: none;
}
&::placeholder {
color: $es-text-muted;
}

View File

@@ -332,6 +332,7 @@ trait EntitySelector
$blockType = Tools::getValue('block_type', 'products');
$limit = (int) Tools::getValue('limit', 10);
$offset = (int) Tools::getValue('offset', 0);
$filter = Tools::getValue('filter', '');
$values = json_decode($valuesJson, true);
if (!is_array($values)) {
@@ -344,12 +345,19 @@ trait EntitySelector
try {
if ($blockType === 'products') {
$matchingIds = $this->getProductConditionResolver()->getIdsByMethod($method, $values);
// Apply filter if provided
$previewHandler = $this->getEntityPreviewHandler();
if (!empty($filter)) {
$matchingIds = $previewHandler->filterProductIdsByQuery($matchingIds, $filter, $idLang, $idShop);
}
$totalCount = count($matchingIds);
$previewItems = [];
if ($totalCount > 0 && $limit > 0) {
$previewIds = array_slice($matchingIds, $offset, $limit);
$previewItems = $this->getEntityPreviewHandler()->getProductPreviewData($previewIds, $idLang, $idShop);
$previewItems = $previewHandler->getProductPreviewData($previewIds, $idLang, $idShop);
}
$this->ajaxDie(json_encode([
@@ -386,6 +394,7 @@ trait EntitySelector
$blockType = Tools::getValue('block_type', 'products');
$limit = (int) Tools::getValue('limit', 10);
$offset = (int) Tools::getValue('offset', 0);
$filter = Tools::getValue('filter', '');
$groupData = json_decode($groupDataJson, true);
if (!is_array($groupData) || !isset($groupData['include'])) {
@@ -482,6 +491,11 @@ trait EntitySelector
$matchingIds = $resolver->applyModifiers($matchingIds, $modifiers);
}
// Apply filter if provided
if (!empty($filter)) {
$matchingIds = $previewHandler->filterProductIdsByQuery($matchingIds, $filter, $idLang, $idShop);
}
$totalCount = count($matchingIds);
$previewItems = [];
if ($totalCount > 0 && $limit > 0) {
@@ -595,6 +609,7 @@ trait EntitySelector
$groupId = (int) Tools::getValue('group_id');
$groupType = Tools::getValue('group_type'); // 'attribute' or 'feature'
$limit = (int) Tools::getValue('limit', 10);
$filter = Tools::getValue('filter', '');
if (!$groupId || !in_array($groupType, ['attribute', 'feature'])) {
die(json_encode([
@@ -630,30 +645,35 @@ trait EntitySelector
$sql->where('ps.active = 1');
}
// Get total count first
$countSql = clone $sql;
$countResult = $db->executeS($countSql);
$totalCount = count($countResult);
// Get limited results for preview
$sql->limit($limit);
// Get all matching product IDs
$results = $db->executeS($sql);
$productIds = array_column($results, 'id_product');
// Apply filter if provided
if (!empty($filter) && !empty($productIds)) {
$productIds = $this->getEntityPreviewHandler()->filterProductIdsByQuery($productIds, $filter, $idLang, $idShop);
}
$totalCount = count($productIds);
// Get limited results for preview
$previewIds = array_slice($productIds, 0, $limit);
// Get product details for preview
$items = [];
if (!empty($productIds)) {
if (!empty($previewIds)) {
$productSql = new DbQuery();
$productSql->select('p.id_product, pl.name, p.reference, i.id_image, m.name as manufacturer');
$productSql->from('product', 'p');
$productSql->innerJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . $idLang . ' AND pl.id_shop = ' . $idShop);
$productSql->leftJoin('image', 'i', 'i.id_product = p.id_product AND i.cover = 1');
$productSql->leftJoin('manufacturer', 'm', 'm.id_manufacturer = p.id_manufacturer');
$productSql->where('p.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')');
$productSql->limit($limit);
$productSql->where('p.id_product IN (' . implode(',', array_map('intval', $previewIds)) . ')');
$products = $db->executeS($productSql);
// Map products by ID for ordering
$productsById = [];
foreach ($products as $product) {
$imageUrl = null;
if ($product['id_image']) {
@@ -664,7 +684,7 @@ trait EntitySelector
);
}
$items[] = [
$productsById[(int) $product['id_product']] = [
'id' => (int) $product['id_product'],
'name' => $product['name'],
'reference' => $product['reference'],
@@ -672,6 +692,13 @@ trait EntitySelector
'image' => $imageUrl
];
}
// Preserve order from previewIds
foreach ($previewIds as $id) {
if (isset($productsById[(int) $id])) {
$items[] = $productsById[(int) $id];
}
}
}
die(json_encode([

View File

@@ -211,6 +211,63 @@ class EntityPreviewHandler
return $this->buildPreviewResponse($allIds, $items, $limit, $offset);
}
/**
* Filter product IDs by search query (name, reference)
*
* @param array $productIds Product IDs to filter
* @param string $query Search query
* @param int $idLang Language ID
* @param int $idShop Shop ID
* @return array Filtered product IDs (preserves order)
*/
public function filterProductIdsByQuery(array $productIds, $query, $idLang, $idShop)
{
if (empty($productIds) || empty($query)) {
return $productIds;
}
$query = trim($query);
if (strlen($query) < 1) {
return $productIds;
}
$escapedQuery = '%' . pSQL($query) . '%';
$sql = new DbQuery();
$sql->select('DISTINCT p.id_product');
$sql->from('product', 'p');
$sql->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . (int) $idShop);
$sql->leftJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop);
$sql->leftJoin('manufacturer', 'm', 'm.id_manufacturer = p.id_manufacturer');
$sql->where('p.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')');
$sql->where('(
pl.name LIKE \'' . $escapedQuery . '\'
OR p.reference LIKE \'' . $escapedQuery . '\'
OR m.name LIKE \'' . $escapedQuery . '\'
)');
$results = Db::getInstance()->executeS($sql);
if (!$results) {
return [];
}
$matchingIds = [];
foreach ($results as $row) {
$matchingIds[(int) $row['id_product']] = true;
}
// Preserve original order
$filtered = [];
foreach ($productIds as $id) {
if (isset($matchingIds[(int) $id])) {
$filtered[] = (int) $id;
}
}
return $filtered;
}
/**
* Get product preview data
*