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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
2
assets/js/admin/entity-selector.min.js
vendored
2
assets/js/admin/entity-selector.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user