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

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;