Add selection validation and improve tooltip component

- Add validation system to prevent contradicting conditions:
  - Same entity in include/exclude detection
  - Parent-child conflict detection for tree entities
  - Redundant selection prevention
  - Toast notifications for validation errors

- Fix entity icons (employees: briefcase, taxes: calculator)

- Improve tooltip component:
  - Use Material Icons instead of broken FA4 icons
  - Fix positioning using getBoundingClientRect for viewport coords
  - Add click-to-pin functionality with close button
  - Pinned tooltips show X icon and close button in corner

- Add lightweight test suite (31 tests) for validation logic

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 17:05:56 +01:00
parent 7d79273743
commit af5066dd26
16 changed files with 5806 additions and 107 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

@@ -1174,6 +1174,14 @@
$item.toggleClass('selected');
self.serializeAllBlocks($row);
} else {
// Validate selection before adding
var section = self.activeGroup.section;
var validation = self.validateSelection(id, name, section, $item.data());
if (!validation.valid) {
self.showValidationError(validation.error);
return;
}
var currentSelection = self.getCurrentSingleSelection();
if (currentSelection) {
var newEntityType = self.activeGroup.blockType;
@@ -1225,6 +1233,9 @@
e.preventDefault();
if (!self.activeGroup) return;
var section = self.activeGroup.section;
var skippedCount = 0;
// Handle tree view - use pending selections
if (self.viewMode === 'tree') {
if (!self.pendingSelections) self.pendingSelections = [];
@@ -1237,6 +1248,13 @@
var name = $item.data('name');
if (!$item.hasClass('selected')) {
// Validate before adding
var validation = self.validateSelection(id, name, section, $item.data());
if (!validation.valid) {
skippedCount++;
return; // Skip this item
}
$item.addClass('selected');
var exists = self.pendingSelections.some(function(s) {
return parseInt(s.id, 10) === id;
@@ -1247,6 +1265,13 @@
}
});
// Show warning if items were skipped
if (skippedCount > 0) {
var trans = self.config.trans || {};
var msg = (trans.items_skipped_conflicts || '{count} item(s) were skipped due to conflicts.').replace('{count}', skippedCount);
self.showValidationError(msg);
}
// Update count display
var selectedCount = self.$dropdown.find('.tree-item.selected').length;
var totalCount = self.$dropdown.find('.tree-item').length;
@@ -1276,11 +1301,26 @@
if (!$(this).hasClass('selected')) {
var id = $(this).data('id');
var name = $(this).data('name');
// Validate before adding
var validation = self.validateSelection(id, name, section, $(this).data());
if (!validation.valid) {
skippedCount++;
return; // Skip this item
}
self.addSelectionNoUpdate($picker, id, name, $(this).data());
$(this).addClass('selected');
}
});
// Show warning if items were skipped
if (skippedCount > 0) {
var trans = self.config.trans || {};
var msg = (trans.items_skipped_conflicts || '{count} item(s) were skipped due to conflicts.').replace('{count}', skippedCount);
self.showValidationError(msg);
}
var $chips = $picker.find('.entity-chips');
self.updateChipsVisibility($chips);
@@ -1448,6 +1488,14 @@
});
$item.removeClass('selected');
} else {
// Validate selection before adding to pending
var section = self.activeGroup.section;
var validation = self.validateSelection(id, name, section, $item.data());
if (!validation.valid) {
self.showValidationError(validation.error);
return;
}
// Add to pending
var exists = self.pendingSelections.some(function(s) {
return parseInt(s.id, 10) === id;
@@ -1529,19 +1577,41 @@
$btn.find('i').removeClass('icon-minus-square').addClass('icon-plus-square');
$btn.attr('title', trans.select_with_children || 'Select with all children');
} else {
var section = self.activeGroup.section;
var skippedChildren = 0;
if (!$item.hasClass('selected')) {
self.addSelectionNoUpdate($picker, $item.data('id'), $item.data('name'), $item.data());
$item.addClass('selected');
// Validate parent first
var parentValidation = self.validateSelection($item.data('id'), $item.data('name'), section, $item.data());
if (parentValidation.valid) {
self.addSelectionNoUpdate($picker, $item.data('id'), $item.data('name'), $item.data());
$item.addClass('selected');
} else {
self.showValidationError(parentValidation.error);
return; // Can't select parent, abort entire operation
}
}
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');
// Validate each child
var childValidation = self.validateSelection($descendant.data('id'), $descendant.data('name'), section, $descendant.data());
if (childValidation.valid) {
self.addSelectionNoUpdate($picker, $descendant.data('id'), $descendant.data('name'), $descendant.data());
$descendant.addClass('selected');
} else {
skippedChildren++;
}
}
}
// Show warning if children were skipped
if (skippedChildren > 0) {
var skipMsg = (trans.children_skipped_conflicts || '{count} child item(s) were skipped due to conflicts.').replace('{count}', skippedChildren);
self.showValidationError(skipMsg);
}
$btn.find('i').removeClass('icon-plus-square').addClass('icon-minus-square');
$btn.attr('title', trans.deselect_with_children || 'Deselect with all children');
}
@@ -1958,6 +2028,105 @@
self.hideDropdown();
}
});
// Tooltip hover events
this.$wrapper.on('mouseenter', '.mpr-info-wrapper:not(.pinned)', function() {
var $wrapper = $(this);
var content = $wrapper.attr('data-tooltip');
if (!content) return;
// Don't show hover tooltip if another is pinned
if ($('.mpr-tooltip-fixed.pinned').length) return;
// Remove any existing non-pinned tooltip
$('.mpr-tooltip-fixed:not(.pinned)').remove();
// Create tooltip
var $tooltip = $('<div>', { class: 'mpr-tooltip-fixed' }).html(content);
$('body').append($tooltip);
// Use getBoundingClientRect for viewport-relative positioning (fixed)
var rect = $wrapper[0].getBoundingClientRect();
var tooltipWidth = $tooltip.outerWidth();
var tooltipHeight = $tooltip.outerHeight();
var left = rect.left + (rect.width / 2) - (tooltipWidth / 2);
var top = rect.top - tooltipHeight - 8;
// Keep tooltip within viewport
if (left < 10) left = 10;
if (left + tooltipWidth > window.innerWidth - 10) {
left = window.innerWidth - tooltipWidth - 10;
}
if (top < 10) {
top = rect.bottom + 8;
}
$tooltip.css({ top: top, left: left });
});
this.$wrapper.on('mouseleave', '.mpr-info-wrapper:not(.pinned)', function() {
$('.mpr-tooltip-fixed:not(.pinned)').remove();
});
// Click to pin tooltip
this.$wrapper.on('click', '.mpr-info-wrapper', function(e) {
e.preventDefault();
e.stopPropagation();
var $wrapper = $(this);
// If already pinned, unpin and close
if ($wrapper.hasClass('pinned')) {
$wrapper.removeClass('pinned');
$wrapper.find('.material-icons').text('info_outline');
$('.mpr-tooltip-fixed.pinned').remove();
return;
}
// Close any other pinned tooltips
$('.mpr-info-wrapper.pinned').removeClass('pinned').find('.material-icons').text('info_outline');
$('.mpr-tooltip-fixed').remove();
var content = $wrapper.attr('data-tooltip');
if (!content) return;
// Pin this one
$wrapper.addClass('pinned');
$wrapper.find('.material-icons').text('close');
// Create pinned tooltip with close button
var $tooltip = $('<div>', { class: 'mpr-tooltip-fixed pinned' });
var $closeBtn = $('<button>', { class: 'mpr-tooltip-close', type: 'button' })
.append($('<i>', { class: 'material-icons', text: 'close' }));
$tooltip.append($closeBtn).append(content);
$('body').append($tooltip);
// Close button click
$closeBtn.on('click', function() {
$wrapper.removeClass('pinned');
$wrapper.find('.material-icons').text('info_outline');
$tooltip.remove();
});
// Position
var rect = $wrapper[0].getBoundingClientRect();
var tooltipWidth = $tooltip.outerWidth();
var tooltipHeight = $tooltip.outerHeight();
var left = rect.left + (rect.width / 2) - (tooltipWidth / 2);
var top = rect.top - tooltipHeight - 8;
if (left < 10) left = 10;
if (left + tooltipWidth > window.innerWidth - 10) {
left = window.innerWidth - tooltipWidth - 10;
}
if (top < 10) {
top = rect.bottom + 8;
}
$tooltip.css({ top: top, left: left });
});
}
};
@@ -6987,9 +7156,9 @@
if (helpContent) {
var $infoWrapper = $('<span>', {
class: 'mpr-info-wrapper',
'data-details': helpContent
'data-tooltip': helpContent
});
$infoWrapper.append($('<span>', { class: 'mpr-icon icon-info link' }));
$infoWrapper.append($('<i>', { class: 'material-icons', text: 'info_outline' }));
$placeholder.append($infoWrapper);
}
},
@@ -9099,6 +9268,372 @@
})(jQuery);
/**
* Entity Selector - Validation Module
* Conflict detection and prevention for entity selections
* @partial _validation.js
*
* Features:
* - Same entity in include & exclude detection
* - Parent-child conflict detection for tree entities
* - Redundant selection detection
* - Error message display
*/
(function($) {
'use strict';
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
window._EntitySelectorMixins.validation = {
/**
* Validate a selection before adding it
* Returns { valid: true } or { valid: false, error: 'message', type: 'conflict_type' }
*
* @param {number|string} id - Entity ID to validate
* @param {string} name - Entity name (for error messages)
* @param {string} section - 'include' or 'exclude'
* @param {Object} data - Additional data (parent_id, etc.)
* @returns {Object} Validation result
*/
validateSelection: function(id, name, section, data) {
if (!this.activeGroup) {
return { valid: true };
}
var trans = this.config.trans || {};
id = parseInt(id, 10);
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 + '"]');
// Get include chips
var includeIds = this.getChipIds($group.find('.include-picker'));
// Get all exclude chips (from all exclude rows)
var excludeIds = [];
$group.find('.exclude-row').each(function() {
var $excludePicker = $(this).find('.exclude-picker');
var ids = [];
$excludePicker.find('.entity-chip').each(function() {
ids.push(parseInt($(this).data('id'), 10));
});
excludeIds = excludeIds.concat(ids);
});
// 1. Check for same entity in include & exclude
var conflictResult = this.checkIncludeExcludeConflict(id, name, section, includeIds, excludeIds, trans);
if (!conflictResult.valid) {
return conflictResult;
}
// 2. Check for redundant selection (already selected in same section)
var redundantResult = this.checkRedundantSelection(id, name, section, includeIds, excludeIds, trans);
if (!redundantResult.valid) {
return redundantResult;
}
// 3. Check for parent-child conflicts (only for tree entities)
var searchEntity = this.activeGroup.searchEntity;
if (searchEntity === 'categories' || searchEntity === 'cms_categories') {
var treeResult = this.checkTreeConflicts(id, name, section, data, includeIds, excludeIds, trans);
if (!treeResult.valid) {
return treeResult;
}
}
return { valid: true };
},
/**
* Check if entity is in both include and exclude
*/
checkIncludeExcludeConflict: function(id, name, section, includeIds, excludeIds, trans) {
if (section === 'include' && excludeIds.indexOf(id) !== -1) {
return {
valid: false,
type: 'include_exclude_conflict',
error: (trans.error_in_exclude || '"{name}" is already in the exclude list. Remove it from exclude first.').replace('{name}', name)
};
}
if (section === 'exclude' && includeIds.indexOf(id) !== -1) {
return {
valid: false,
type: 'include_exclude_conflict',
error: (trans.error_in_include || '"{name}" is already in the include list. Remove it from include first.').replace('{name}', name)
};
}
return { valid: true };
},
/**
* Check for redundant selection (already selected)
*/
checkRedundantSelection: function(id, name, section, includeIds, excludeIds, trans) {
if (section === 'include' && includeIds.indexOf(id) !== -1) {
return {
valid: false,
type: 'redundant',
error: (trans.error_already_selected || '"{name}" is already selected.').replace('{name}', name)
};
}
if (section === 'exclude' && excludeIds.indexOf(id) !== -1) {
return {
valid: false,
type: 'redundant',
error: (trans.error_already_excluded || '"{name}" is already in an exclude list.').replace('{name}', name)
};
}
return { valid: true };
},
/**
* Check for parent-child conflicts in tree entities
*/
checkTreeConflicts: function(id, name, section, data, includeIds, excludeIds, trans) {
// Need tree data for parent-child lookups
if (!this.treeFlatData) {
return { valid: true };
}
var parentId = data && data.parentId ? parseInt(data.parentId, 10) : null;
// Build lookup for quick access
var lookup = {};
this.treeFlatData.forEach(function(item) {
lookup[parseInt(item.id, 10)] = item;
});
// Get all ancestor IDs
var ancestorIds = this.getAncestorIds(id, lookup);
// Get all descendant IDs
var descendantIds = this.getDescendantIds(id, lookup);
if (section === 'include') {
// Check if any ancestor is excluded
for (var i = 0; i < ancestorIds.length; i++) {
if (excludeIds.indexOf(ancestorIds[i]) !== -1) {
var ancestorName = lookup[ancestorIds[i]] ? lookup[ancestorIds[i]].name : 'Parent';
return {
valid: false,
type: 'parent_excluded',
error: (trans.error_parent_excluded || 'Cannot include "{name}" because its parent "{parent}" is excluded.').replace('{name}', name).replace('{parent}', ancestorName)
};
}
}
// Check if any descendant is excluded
for (var j = 0; j < descendantIds.length; j++) {
if (excludeIds.indexOf(descendantIds[j]) !== -1) {
var descendantName = lookup[descendantIds[j]] ? lookup[descendantIds[j]].name : 'Child';
return {
valid: false,
type: 'child_excluded',
error: (trans.error_child_excluded || 'Cannot include "{name}" because its child "{child}" is excluded. Remove the child from exclude first.').replace('{name}', name).replace('{child}', descendantName)
};
}
}
}
if (section === 'exclude') {
// Check if any ancestor is included
for (var k = 0; k < ancestorIds.length; k++) {
if (includeIds.indexOf(ancestorIds[k]) !== -1) {
var parentName = lookup[ancestorIds[k]] ? lookup[ancestorIds[k]].name : 'Parent';
return {
valid: false,
type: 'parent_included',
error: (trans.error_parent_included || 'Cannot exclude "{name}" because its parent "{parent}" is included. This would create a contradiction.').replace('{name}', name).replace('{parent}', parentName)
};
}
}
// Check if any descendant is included (warning about implicit exclusion)
var includedDescendants = [];
for (var m = 0; m < descendantIds.length; m++) {
if (includeIds.indexOf(descendantIds[m]) !== -1) {
var childName = lookup[descendantIds[m]] ? lookup[descendantIds[m]].name : 'Child';
includedDescendants.push(childName);
}
}
if (includedDescendants.length > 0) {
return {
valid: false,
type: 'children_included',
error: (trans.error_children_included || 'Cannot exclude "{name}" because its children ({children}) are included. Remove them from include first.').replace('{name}', name).replace('{children}', includedDescendants.slice(0, 3).join(', ') + (includedDescendants.length > 3 ? '...' : ''))
};
}
}
return { valid: true };
},
/**
* Get all ancestor IDs for a given item
*/
getAncestorIds: function(id, lookup) {
var ancestors = [];
var current = lookup[id];
while (current && current.parent_id) {
var parentId = parseInt(current.parent_id, 10);
if (parentId && lookup[parentId]) {
ancestors.push(parentId);
current = lookup[parentId];
} else {
break;
}
}
return ancestors;
},
/**
* Get all descendant IDs for a given item
*/
getDescendantIds: function(id, lookup) {
var descendants = [];
var self = this;
// Find direct children
Object.keys(lookup).forEach(function(key) {
var item = lookup[key];
if (parseInt(item.parent_id, 10) === id) {
var childId = parseInt(item.id, 10);
descendants.push(childId);
// Recursively get children's descendants
var childDescendants = self.getDescendantIds(childId, lookup);
descendants = descendants.concat(childDescendants);
}
});
return descendants;
},
/**
* Get chip IDs from a picker
*/
getChipIds: function($picker) {
var ids = [];
$picker.find('.entity-chip').each(function() {
ids.push(parseInt($(this).data('id'), 10));
});
return ids;
},
/**
* Validate pending selections (for tree view bulk operations)
* Returns array of invalid items
*/
validatePendingSelections: function(pendingSelections, section) {
var self = this;
var errors = [];
if (!pendingSelections || !pendingSelections.length) {
return errors;
}
pendingSelections.forEach(function(sel) {
var result = self.validateSelection(sel.id, sel.name, section, sel.data || {});
if (!result.valid) {
errors.push({
id: sel.id,
name: sel.name,
error: result.error,
type: result.type
});
}
});
return errors;
},
/**
* Show validation error toast
*/
showValidationError: function(message) {
var trans = this.config.trans || {};
var title = trans.validation_error || 'Selection Conflict';
// Remove existing toast
$('.es-validation-toast').remove();
// Create toast HTML
var html = '<div class="es-validation-toast">';
html += '<div class="es-toast-icon"><i class="icon-exclamation-triangle"></i></div>';
html += '<div class="es-toast-content">';
html += '<div class="es-toast-title">' + this.escapeHtml(title) + '</div>';
html += '<div class="es-toast-message">' + this.escapeHtml(message) + '</div>';
html += '</div>';
html += '<button type="button" class="es-toast-close"><i class="icon-times"></i></button>';
html += '</div>';
var $toast = $(html);
$('body').append($toast);
// Position near dropdown if visible
if (this.$dropdown && this.$dropdown.hasClass('show')) {
var dropdownOffset = this.$dropdown.offset();
$toast.css({
position: 'fixed',
top: dropdownOffset.top - $toast.outerHeight() - 10,
left: dropdownOffset.left,
zIndex: 10001
});
} else {
$toast.css({
position: 'fixed',
top: 20,
right: 20,
zIndex: 10001
});
}
// Animate in
$toast.hide().fadeIn(200);
// Auto-dismiss after 5 seconds
setTimeout(function() {
$toast.fadeOut(200, function() {
$(this).remove();
});
}, 5000);
// Close button
$toast.on('click', '.es-toast-close', function() {
$toast.fadeOut(200, function() {
$(this).remove();
});
});
},
/**
* Validate and add selection (wrapper that validates before adding)
* Returns true if added successfully, false if validation failed
*/
validateAndAddSelection: function($picker, id, name, data, section) {
var result = this.validateSelection(id, name, section, data || {});
if (!result.valid) {
this.showValidationError(result.error);
return false;
}
// Validation passed, add the selection
this.addSelection($picker, id, name, data);
return true;
}
};
})(jQuery);
/**
* Entity Selector - Core Module
* Factory, initialization, state management

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -24,6 +24,7 @@ const paths = {
'sources/js/admin/entity-selector/_methods.js',
'sources/js/admin/entity-selector/_preview.js',
'sources/js/admin/entity-selector/_tree.js',
'sources/js/admin/entity-selector/_validation.js',
'sources/js/admin/entity-selector/_core.js'
],
scheduleConditions: [

3956
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,8 @@
"build:js": "gulp js",
"watch": "gulp watch",
"watch:css": "gulp watch:scss",
"watch:js": "gulp watch:js"
"watch:js": "gulp watch:js",
"test": "node tests/run-tests.js"
},
"devDependencies": {
"gulp": "^4.0.2",
@@ -17,6 +18,8 @@
"gulp-sass": "^5.1.0",
"gulp-sourcemaps": "^3.0.0",
"gulp-terser": "^2.1.0",
"jquery": "^3.7.1",
"jsdom": "^22.1.0",
"sass": "^1.63.6"
}
}

View File

@@ -1009,6 +1009,14 @@
$item.toggleClass('selected');
self.serializeAllBlocks($row);
} else {
// Validate selection before adding
var section = self.activeGroup.section;
var validation = self.validateSelection(id, name, section, $item.data());
if (!validation.valid) {
self.showValidationError(validation.error);
return;
}
var currentSelection = self.getCurrentSingleSelection();
if (currentSelection) {
var newEntityType = self.activeGroup.blockType;
@@ -1060,6 +1068,9 @@
e.preventDefault();
if (!self.activeGroup) return;
var section = self.activeGroup.section;
var skippedCount = 0;
// Handle tree view - use pending selections
if (self.viewMode === 'tree') {
if (!self.pendingSelections) self.pendingSelections = [];
@@ -1072,6 +1083,13 @@
var name = $item.data('name');
if (!$item.hasClass('selected')) {
// Validate before adding
var validation = self.validateSelection(id, name, section, $item.data());
if (!validation.valid) {
skippedCount++;
return; // Skip this item
}
$item.addClass('selected');
var exists = self.pendingSelections.some(function(s) {
return parseInt(s.id, 10) === id;
@@ -1082,6 +1100,13 @@
}
});
// Show warning if items were skipped
if (skippedCount > 0) {
var trans = self.config.trans || {};
var msg = (trans.items_skipped_conflicts || '{count} item(s) were skipped due to conflicts.').replace('{count}', skippedCount);
self.showValidationError(msg);
}
// Update count display
var selectedCount = self.$dropdown.find('.tree-item.selected').length;
var totalCount = self.$dropdown.find('.tree-item').length;
@@ -1111,11 +1136,26 @@
if (!$(this).hasClass('selected')) {
var id = $(this).data('id');
var name = $(this).data('name');
// Validate before adding
var validation = self.validateSelection(id, name, section, $(this).data());
if (!validation.valid) {
skippedCount++;
return; // Skip this item
}
self.addSelectionNoUpdate($picker, id, name, $(this).data());
$(this).addClass('selected');
}
});
// Show warning if items were skipped
if (skippedCount > 0) {
var trans = self.config.trans || {};
var msg = (trans.items_skipped_conflicts || '{count} item(s) were skipped due to conflicts.').replace('{count}', skippedCount);
self.showValidationError(msg);
}
var $chips = $picker.find('.entity-chips');
self.updateChipsVisibility($chips);
@@ -1283,6 +1323,14 @@
});
$item.removeClass('selected');
} else {
// Validate selection before adding to pending
var section = self.activeGroup.section;
var validation = self.validateSelection(id, name, section, $item.data());
if (!validation.valid) {
self.showValidationError(validation.error);
return;
}
// Add to pending
var exists = self.pendingSelections.some(function(s) {
return parseInt(s.id, 10) === id;
@@ -1364,19 +1412,41 @@
$btn.find('i').removeClass('icon-minus-square').addClass('icon-plus-square');
$btn.attr('title', trans.select_with_children || 'Select with all children');
} else {
var section = self.activeGroup.section;
var skippedChildren = 0;
if (!$item.hasClass('selected')) {
self.addSelectionNoUpdate($picker, $item.data('id'), $item.data('name'), $item.data());
$item.addClass('selected');
// Validate parent first
var parentValidation = self.validateSelection($item.data('id'), $item.data('name'), section, $item.data());
if (parentValidation.valid) {
self.addSelectionNoUpdate($picker, $item.data('id'), $item.data('name'), $item.data());
$item.addClass('selected');
} else {
self.showValidationError(parentValidation.error);
return; // Can't select parent, abort entire operation
}
}
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');
// Validate each child
var childValidation = self.validateSelection($descendant.data('id'), $descendant.data('name'), section, $descendant.data());
if (childValidation.valid) {
self.addSelectionNoUpdate($picker, $descendant.data('id'), $descendant.data('name'), $descendant.data());
$descendant.addClass('selected');
} else {
skippedChildren++;
}
}
}
// Show warning if children were skipped
if (skippedChildren > 0) {
var skipMsg = (trans.children_skipped_conflicts || '{count} child item(s) were skipped due to conflicts.').replace('{count}', skippedChildren);
self.showValidationError(skipMsg);
}
$btn.find('i').removeClass('icon-plus-square').addClass('icon-minus-square');
$btn.attr('title', trans.deselect_with_children || 'Deselect with all children');
}
@@ -1793,6 +1863,105 @@
self.hideDropdown();
}
});
// Tooltip hover events
this.$wrapper.on('mouseenter', '.mpr-info-wrapper:not(.pinned)', function() {
var $wrapper = $(this);
var content = $wrapper.attr('data-tooltip');
if (!content) return;
// Don't show hover tooltip if another is pinned
if ($('.mpr-tooltip-fixed.pinned').length) return;
// Remove any existing non-pinned tooltip
$('.mpr-tooltip-fixed:not(.pinned)').remove();
// Create tooltip
var $tooltip = $('<div>', { class: 'mpr-tooltip-fixed' }).html(content);
$('body').append($tooltip);
// Use getBoundingClientRect for viewport-relative positioning (fixed)
var rect = $wrapper[0].getBoundingClientRect();
var tooltipWidth = $tooltip.outerWidth();
var tooltipHeight = $tooltip.outerHeight();
var left = rect.left + (rect.width / 2) - (tooltipWidth / 2);
var top = rect.top - tooltipHeight - 8;
// Keep tooltip within viewport
if (left < 10) left = 10;
if (left + tooltipWidth > window.innerWidth - 10) {
left = window.innerWidth - tooltipWidth - 10;
}
if (top < 10) {
top = rect.bottom + 8;
}
$tooltip.css({ top: top, left: left });
});
this.$wrapper.on('mouseleave', '.mpr-info-wrapper:not(.pinned)', function() {
$('.mpr-tooltip-fixed:not(.pinned)').remove();
});
// Click to pin tooltip
this.$wrapper.on('click', '.mpr-info-wrapper', function(e) {
e.preventDefault();
e.stopPropagation();
var $wrapper = $(this);
// If already pinned, unpin and close
if ($wrapper.hasClass('pinned')) {
$wrapper.removeClass('pinned');
$wrapper.find('.material-icons').text('info_outline');
$('.mpr-tooltip-fixed.pinned').remove();
return;
}
// Close any other pinned tooltips
$('.mpr-info-wrapper.pinned').removeClass('pinned').find('.material-icons').text('info_outline');
$('.mpr-tooltip-fixed').remove();
var content = $wrapper.attr('data-tooltip');
if (!content) return;
// Pin this one
$wrapper.addClass('pinned');
$wrapper.find('.material-icons').text('close');
// Create pinned tooltip with close button
var $tooltip = $('<div>', { class: 'mpr-tooltip-fixed pinned' });
var $closeBtn = $('<button>', { class: 'mpr-tooltip-close', type: 'button' })
.append($('<i>', { class: 'material-icons', text: 'close' }));
$tooltip.append($closeBtn).append(content);
$('body').append($tooltip);
// Close button click
$closeBtn.on('click', function() {
$wrapper.removeClass('pinned');
$wrapper.find('.material-icons').text('info_outline');
$tooltip.remove();
});
// Position
var rect = $wrapper[0].getBoundingClientRect();
var tooltipWidth = $tooltip.outerWidth();
var tooltipHeight = $tooltip.outerHeight();
var left = rect.left + (rect.width / 2) - (tooltipWidth / 2);
var top = rect.top - tooltipHeight - 8;
if (left < 10) left = 10;
if (left + tooltipWidth > window.innerWidth - 10) {
left = window.innerWidth - tooltipWidth - 10;
}
if (top < 10) {
top = rect.bottom + 8;
}
$tooltip.css({ top: top, left: left });
});
}
};

View File

@@ -584,9 +584,9 @@
if (helpContent) {
var $infoWrapper = $('<span>', {
class: 'mpr-info-wrapper',
'data-details': helpContent
'data-tooltip': helpContent
});
$infoWrapper.append($('<span>', { class: 'mpr-icon icon-info link' }));
$infoWrapper.append($('<i>', { class: 'material-icons', text: 'info_outline' }));
$placeholder.append($infoWrapper);
}
},

View File

@@ -0,0 +1,365 @@
/**
* Entity Selector - Validation Module
* Conflict detection and prevention for entity selections
* @partial _validation.js
*
* Features:
* - Same entity in include & exclude detection
* - Parent-child conflict detection for tree entities
* - Redundant selection detection
* - Error message display
*/
(function($) {
'use strict';
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
window._EntitySelectorMixins.validation = {
/**
* Validate a selection before adding it
* Returns { valid: true } or { valid: false, error: 'message', type: 'conflict_type' }
*
* @param {number|string} id - Entity ID to validate
* @param {string} name - Entity name (for error messages)
* @param {string} section - 'include' or 'exclude'
* @param {Object} data - Additional data (parent_id, etc.)
* @returns {Object} Validation result
*/
validateSelection: function(id, name, section, data) {
if (!this.activeGroup) {
return { valid: true };
}
var trans = this.config.trans || {};
id = parseInt(id, 10);
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 + '"]');
// Get include chips
var includeIds = this.getChipIds($group.find('.include-picker'));
// Get all exclude chips (from all exclude rows)
var excludeIds = [];
$group.find('.exclude-row').each(function() {
var $excludePicker = $(this).find('.exclude-picker');
var ids = [];
$excludePicker.find('.entity-chip').each(function() {
ids.push(parseInt($(this).data('id'), 10));
});
excludeIds = excludeIds.concat(ids);
});
// 1. Check for same entity in include & exclude
var conflictResult = this.checkIncludeExcludeConflict(id, name, section, includeIds, excludeIds, trans);
if (!conflictResult.valid) {
return conflictResult;
}
// 2. Check for redundant selection (already selected in same section)
var redundantResult = this.checkRedundantSelection(id, name, section, includeIds, excludeIds, trans);
if (!redundantResult.valid) {
return redundantResult;
}
// 3. Check for parent-child conflicts (only for tree entities)
var searchEntity = this.activeGroup.searchEntity;
if (searchEntity === 'categories' || searchEntity === 'cms_categories') {
var treeResult = this.checkTreeConflicts(id, name, section, data, includeIds, excludeIds, trans);
if (!treeResult.valid) {
return treeResult;
}
}
return { valid: true };
},
/**
* Check if entity is in both include and exclude
*/
checkIncludeExcludeConflict: function(id, name, section, includeIds, excludeIds, trans) {
if (section === 'include' && excludeIds.indexOf(id) !== -1) {
return {
valid: false,
type: 'include_exclude_conflict',
error: (trans.error_in_exclude || '"{name}" is already in the exclude list. Remove it from exclude first.').replace('{name}', name)
};
}
if (section === 'exclude' && includeIds.indexOf(id) !== -1) {
return {
valid: false,
type: 'include_exclude_conflict',
error: (trans.error_in_include || '"{name}" is already in the include list. Remove it from include first.').replace('{name}', name)
};
}
return { valid: true };
},
/**
* Check for redundant selection (already selected)
*/
checkRedundantSelection: function(id, name, section, includeIds, excludeIds, trans) {
if (section === 'include' && includeIds.indexOf(id) !== -1) {
return {
valid: false,
type: 'redundant',
error: (trans.error_already_selected || '"{name}" is already selected.').replace('{name}', name)
};
}
if (section === 'exclude' && excludeIds.indexOf(id) !== -1) {
return {
valid: false,
type: 'redundant',
error: (trans.error_already_excluded || '"{name}" is already in an exclude list.').replace('{name}', name)
};
}
return { valid: true };
},
/**
* Check for parent-child conflicts in tree entities
*/
checkTreeConflicts: function(id, name, section, data, includeIds, excludeIds, trans) {
// Need tree data for parent-child lookups
if (!this.treeFlatData) {
return { valid: true };
}
var parentId = data && data.parentId ? parseInt(data.parentId, 10) : null;
// Build lookup for quick access
var lookup = {};
this.treeFlatData.forEach(function(item) {
lookup[parseInt(item.id, 10)] = item;
});
// Get all ancestor IDs
var ancestorIds = this.getAncestorIds(id, lookup);
// Get all descendant IDs
var descendantIds = this.getDescendantIds(id, lookup);
if (section === 'include') {
// Check if any ancestor is excluded
for (var i = 0; i < ancestorIds.length; i++) {
if (excludeIds.indexOf(ancestorIds[i]) !== -1) {
var ancestorName = lookup[ancestorIds[i]] ? lookup[ancestorIds[i]].name : 'Parent';
return {
valid: false,
type: 'parent_excluded',
error: (trans.error_parent_excluded || 'Cannot include "{name}" because its parent "{parent}" is excluded.').replace('{name}', name).replace('{parent}', ancestorName)
};
}
}
// Check if any descendant is excluded
for (var j = 0; j < descendantIds.length; j++) {
if (excludeIds.indexOf(descendantIds[j]) !== -1) {
var descendantName = lookup[descendantIds[j]] ? lookup[descendantIds[j]].name : 'Child';
return {
valid: false,
type: 'child_excluded',
error: (trans.error_child_excluded || 'Cannot include "{name}" because its child "{child}" is excluded. Remove the child from exclude first.').replace('{name}', name).replace('{child}', descendantName)
};
}
}
}
if (section === 'exclude') {
// Check if any ancestor is included
for (var k = 0; k < ancestorIds.length; k++) {
if (includeIds.indexOf(ancestorIds[k]) !== -1) {
var parentName = lookup[ancestorIds[k]] ? lookup[ancestorIds[k]].name : 'Parent';
return {
valid: false,
type: 'parent_included',
error: (trans.error_parent_included || 'Cannot exclude "{name}" because its parent "{parent}" is included. This would create a contradiction.').replace('{name}', name).replace('{parent}', parentName)
};
}
}
// Check if any descendant is included (warning about implicit exclusion)
var includedDescendants = [];
for (var m = 0; m < descendantIds.length; m++) {
if (includeIds.indexOf(descendantIds[m]) !== -1) {
var childName = lookup[descendantIds[m]] ? lookup[descendantIds[m]].name : 'Child';
includedDescendants.push(childName);
}
}
if (includedDescendants.length > 0) {
return {
valid: false,
type: 'children_included',
error: (trans.error_children_included || 'Cannot exclude "{name}" because its children ({children}) are included. Remove them from include first.').replace('{name}', name).replace('{children}', includedDescendants.slice(0, 3).join(', ') + (includedDescendants.length > 3 ? '...' : ''))
};
}
}
return { valid: true };
},
/**
* Get all ancestor IDs for a given item
*/
getAncestorIds: function(id, lookup) {
var ancestors = [];
var current = lookup[id];
while (current && current.parent_id) {
var parentId = parseInt(current.parent_id, 10);
if (parentId && lookup[parentId]) {
ancestors.push(parentId);
current = lookup[parentId];
} else {
break;
}
}
return ancestors;
},
/**
* Get all descendant IDs for a given item
*/
getDescendantIds: function(id, lookup) {
var descendants = [];
var self = this;
// Find direct children
Object.keys(lookup).forEach(function(key) {
var item = lookup[key];
if (parseInt(item.parent_id, 10) === id) {
var childId = parseInt(item.id, 10);
descendants.push(childId);
// Recursively get children's descendants
var childDescendants = self.getDescendantIds(childId, lookup);
descendants = descendants.concat(childDescendants);
}
});
return descendants;
},
/**
* Get chip IDs from a picker
*/
getChipIds: function($picker) {
var ids = [];
$picker.find('.entity-chip').each(function() {
ids.push(parseInt($(this).data('id'), 10));
});
return ids;
},
/**
* Validate pending selections (for tree view bulk operations)
* Returns array of invalid items
*/
validatePendingSelections: function(pendingSelections, section) {
var self = this;
var errors = [];
if (!pendingSelections || !pendingSelections.length) {
return errors;
}
pendingSelections.forEach(function(sel) {
var result = self.validateSelection(sel.id, sel.name, section, sel.data || {});
if (!result.valid) {
errors.push({
id: sel.id,
name: sel.name,
error: result.error,
type: result.type
});
}
});
return errors;
},
/**
* Show validation error toast
*/
showValidationError: function(message) {
var trans = this.config.trans || {};
var title = trans.validation_error || 'Selection Conflict';
// Remove existing toast
$('.es-validation-toast').remove();
// Create toast HTML
var html = '<div class="es-validation-toast">';
html += '<div class="es-toast-icon"><i class="icon-exclamation-triangle"></i></div>';
html += '<div class="es-toast-content">';
html += '<div class="es-toast-title">' + this.escapeHtml(title) + '</div>';
html += '<div class="es-toast-message">' + this.escapeHtml(message) + '</div>';
html += '</div>';
html += '<button type="button" class="es-toast-close"><i class="icon-times"></i></button>';
html += '</div>';
var $toast = $(html);
$('body').append($toast);
// Position near dropdown if visible
if (this.$dropdown && this.$dropdown.hasClass('show')) {
var dropdownOffset = this.$dropdown.offset();
$toast.css({
position: 'fixed',
top: dropdownOffset.top - $toast.outerHeight() - 10,
left: dropdownOffset.left,
zIndex: 10001
});
} else {
$toast.css({
position: 'fixed',
top: 20,
right: 20,
zIndex: 10001
});
}
// Animate in
$toast.hide().fadeIn(200);
// Auto-dismiss after 5 seconds
setTimeout(function() {
$toast.fadeOut(200, function() {
$(this).remove();
});
}, 5000);
// Close button
$toast.on('click', '.es-toast-close', function() {
$toast.fadeOut(200, function() {
$(this).remove();
});
});
},
/**
* Validate and add selection (wrapper that validates before adding)
* Returns true if added successfully, false if validation failed
*/
validateAndAddSelection: function($picker, id, name, data, section) {
var result = this.validateSelection(id, name, section, data || {});
if (!result.valid) {
this.showValidationError(result.error);
return false;
}
// Validation passed, add the selection
this.addSelection($picker, id, name, data);
return true;
}
};
})(jQuery);

View File

@@ -1,13 +1,12 @@
/**
* Tooltip Component
* Info tooltips and help popovers
* Info tooltips for method help
*/
@use '../variables' as *;
@use '../mixins' as *;
// =============================================================================
// MPR Info Wrapper (hover tooltip trigger)
// Info Wrapper (tooltip trigger)
// =============================================================================
.mpr-info-wrapper {
@@ -16,67 +15,21 @@
position: relative;
cursor: help;
vertical-align: middle;
margin-left: 0.5rem;
}
margin-left: 0.25rem;
// Tooltip (absolute positioned, follows element)
.mpr-info-wrapper .mpr-tooltip {
position: absolute;
background: $es-white;
color: $es-slate-800;
padding: $es-spacing-md $es-spacing-lg;
border-radius: $es-radius-md;
font-size: 13px;
line-height: 1.625;
white-space: normal;
z-index: 1050;
max-width: 350px;
min-width: 200px;
text-align: left;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
box-shadow: rgba(0, 0, 0, 0.12) 0px 1px 1px 0px,
rgba(64, 68, 82, 0.16) 0px 0px 0px 1px,
rgba(64, 68, 82, 0.08) 0px 2px 5px 0px;
// Arrow (border)
&::before {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 9px solid transparent;
border-top-color: rgba(64, 68, 82, 0.16);
.material-icons {
font-size: 16px;
color: $es-text-muted;
transition: color 0.15s ease;
}
// Arrow (fill)
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 8px solid transparent;
border-top-color: $es-white;
}
strong {
display: block;
margin-bottom: 0.5rem;
font-weight: $es-font-weight-semibold;
color: #337ab7;
}
p {
margin: 0;
color: $es-text-secondary;
&:hover .material-icons {
color: $es-primary;
}
}
// =============================================================================
// Fixed Tooltip (appended to body)
// Fixed Tooltip (appended to body on hover)
// =============================================================================
.mpr-tooltip-fixed {
@@ -86,55 +39,69 @@
padding: $es-spacing-md $es-spacing-lg;
border-radius: $es-radius-md;
font-size: 13px;
line-height: 1.625;
line-height: 1.5;
white-space: normal;
z-index: 10500;
max-width: 350px;
min-width: 200px;
max-width: 320px;
min-width: 180px;
text-align: left;
box-shadow: rgba(0, 0, 0, 0.12) 0px 1px 1px 0px,
rgba(64, 68, 82, 0.16) 0px 0px 0px 1px,
rgba(64, 68, 82, 0.08) 0px 2px 5px 0px;
pointer-events: none;
// Pinned tooltip allows interaction
&.pinned {
pointer-events: auto;
padding-right: $es-spacing-xl + 1rem;
}
strong {
display: block;
margin-bottom: 0.5rem;
margin-bottom: 0.375rem;
font-weight: $es-font-weight-semibold;
color: #337ab7;
color: $es-primary;
}
p {
margin: 0;
color: $es-text-secondary;
}
}
// =============================================================================
// Tooltip Content Styling
// =============================================================================
ul {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
.tooltip-list {
margin: 0.5rem 0;
> div {
margin: 0.25rem 0;
padding-left: 0.5rem;
li {
margin: 0.25rem 0;
color: $es-text-secondary;
}
}
}
.tooltip-example {
font-family: monospace;
font-size: 12px;
background: $es-slate-100;
padding: 0.25rem 0.5rem;
// Close button for pinned tooltips
.mpr-tooltip-close {
position: absolute;
top: 0.375rem;
right: 0.375rem;
padding: 0.125rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: $es-radius-sm;
margin: 0.25rem 0;
}
line-height: 1;
transition: background-color 0.15s ease;
.tooltip-logic {
font-size: 11px;
color: $es-text-muted;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid $es-border-color;
.material-icons {
font-size: 16px;
color: $es-text-muted;
}
&:hover {
background: $es-slate-100;
.material-icons {
color: $es-slate-700;
}
}
}

View File

@@ -0,0 +1,87 @@
/**
* Validation Toast Component
* Error notifications for selection conflicts
*/
@use '../variables' as *;
@use '../mixins' as *;
// Validation error toast
.es-validation-toast {
display: flex;
align-items: flex-start;
gap: $es-spacing-sm;
padding: $es-spacing-md;
background: $es-white;
border: 1px solid $es-danger;
border-left: 4px solid $es-danger;
border-radius: $es-radius-md;
box-shadow: $es-shadow-lg;
max-width: 400px;
animation: es-toast-slide-in 0.2s ease-out;
.es-toast-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: $es-danger;
flex-shrink: 0;
i {
font-size: 18px;
}
}
.es-toast-content {
flex: 1;
min-width: 0;
}
.es-toast-title {
font-size: $es-font-size-sm;
font-weight: $es-font-weight-semibold;
color: $es-danger;
margin-bottom: 2px;
}
.es-toast-message {
font-size: $es-font-size-xs;
color: $es-text-secondary;
line-height: 1.4;
}
.es-toast-close {
@include button-reset;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: $es-text-muted;
border-radius: $es-radius-sm;
flex-shrink: 0;
transition: all $es-transition-fast;
&:hover {
background: $es-slate-100;
color: $es-text-primary;
}
i {
font-size: 12px;
}
}
}
@keyframes es-toast-slide-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -29,3 +29,4 @@
@use 'components/method-dropdown';
@use 'components/tooltip';
@use 'components/tree';
@use 'components/validation';

View File

@@ -1319,7 +1319,7 @@ trait EntitySelector
],
'suppliers' => [
'label' => $this->transEntitySelector('Suppliers'),
'icon' => 'icon-truck',
'icon' => 'icon-archive',
'entity_label' => $this->transEntitySelector('supplier'),
'entity_label_plural' => $this->transEntitySelector('suppliers'),
'selection_methods' => $this->getSupplierSelectionMethods(),
@@ -1341,21 +1341,21 @@ trait EntitySelector
// Transactional / System entities
'employees' => [
'label' => $this->transEntitySelector('Employees'),
'icon' => 'icon-user-secret',
'icon' => 'icon-briefcase',
'entity_label' => $this->transEntitySelector('employee'),
'entity_label_plural' => $this->transEntitySelector('employees'),
'selection_methods' => $this->getEmployeeSelectionMethods(),
],
'customers' => [
'label' => $this->transEntitySelector('Customers'),
'icon' => 'icon-users',
'icon' => 'icon-user',
'entity_label' => $this->transEntitySelector('customer'),
'entity_label_plural' => $this->transEntitySelector('customers'),
'selection_methods' => $this->getCustomerSelectionMethods(),
],
'customer_groups' => [
'label' => $this->transEntitySelector('Customer Groups'),
'icon' => 'icon-group',
'icon' => 'icon-users',
'entity_label' => $this->transEntitySelector('customer group'),
'entity_label_plural' => $this->transEntitySelector('customer groups'),
'selection_methods' => $this->getCustomerGroupSelectionMethods(),
@@ -1418,7 +1418,7 @@ trait EntitySelector
],
'taxes' => [
'label' => $this->transEntitySelector('Taxes'),
'icon' => 'icon-money',
'icon' => 'icon-calculator',
'entity_label' => $this->transEntitySelector('tax'),
'entity_label_plural' => $this->transEntitySelector('taxes'),
'selection_methods' => $this->getTaxSelectionMethods(),

615
tests/run-tests.js Normal file
View File

@@ -0,0 +1,615 @@
#!/usr/bin/env node
/**
* Lightweight Test Runner for Entity Selector Validation
* No heavy dependencies - runs directly with Node.js
*/
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
// Setup DOM environment with script execution enabled
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable'
});
const window = dom.window;
const document = window.document;
// Initialize mixin namespace before loading scripts
window._EntitySelectorMixins = {};
// Load jQuery using window.eval to properly share context
const jqueryCode = fs.readFileSync(
path.join(__dirname, '../node_modules/jquery/dist/jquery.min.js'),
'utf8'
);
window.eval(jqueryCode);
// Make $ available in Node context
const $ = window.jQuery;
// Load and execute the validation mixin using window.eval
const validationCode = fs.readFileSync(
path.join(__dirname, '../sources/js/admin/entity-selector/_validation.js'),
'utf8'
);
window.eval(validationCode);
// Get the validation mixin
const validationMixin = window._EntitySelectorMixins.validation;
// Test utilities
let passed = 0;
let failed = 0;
let currentGroup = '';
function describe(name, fn) {
currentGroup = name;
console.log(`\n\x1b[1m${name}\x1b[0m`);
fn();
}
function test(name, fn) {
try {
fn();
passed++;
console.log(` \x1b[32m✓\x1b[0m ${name}`);
} catch (e) {
failed++;
console.log(` \x1b[31m✗\x1b[0m ${name}`);
console.log(` \x1b[31m${e.message}\x1b[0m`);
}
}
function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) {
throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
},
toEqual(expected) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
},
toContain(expected) {
if (typeof actual === 'string') {
if (!actual.includes(expected)) {
throw new Error(`Expected "${actual}" to contain "${expected}"`);
}
} else if (Array.isArray(actual)) {
if (!actual.includes(expected)) {
throw new Error(`Expected array to contain ${expected}`);
}
}
},
toBeDefined() {
if (actual === undefined) {
throw new Error('Expected value to be defined');
}
}
};
}
// Test fixtures
function createValidator() {
const $wrapper = $(`
<div class="entity-selector-wrapper">
<div class="target-block" data-block-type="categories">
<div class="selection-group" data-group-index="0">
<div class="group-include">
<div class="value-picker include-picker">
<div class="entity-chips"></div>
</div>
</div>
<div class="exclude-rows-container">
<div class="exclude-row" data-exclude-index="0">
<div class="value-picker exclude-picker">
<div class="entity-chips"></div>
</div>
</div>
</div>
</div>
</div>
</div>
`);
$(document.body).empty().append($wrapper);
return Object.assign({}, validationMixin, {
$wrapper: $wrapper,
$dropdown: $('<div class="target-search-dropdown"></div>'),
config: { trans: {} },
activeGroup: {
blockType: 'categories',
groupIndex: 0,
section: 'include',
excludeIndex: 0,
searchEntity: 'categories'
},
treeFlatData: null,
escapeHtml: (str) => str,
escapeAttr: (str) => str
});
}
function createMockChips($picker, ids) {
const $chips = $picker.find('.entity-chips');
$chips.empty();
ids.forEach(id => {
$chips.append(`<span class="entity-chip" data-id="${id}"></span>`);
});
}
// =========================================================================
// TESTS
// =========================================================================
console.log('\n\x1b[36m╔════════════════════════════════════════════════════════════╗\x1b[0m');
console.log('\x1b[36m║ Entity Selector Validation Test Suite ║\x1b[0m');
console.log('\x1b[36m╚════════════════════════════════════════════════════════════╝\x1b[0m');
// GROUP 1: Include/Exclude Conflict Detection
describe('Include/Exclude Conflict Detection', () => {
test('TC-001: Block adding to EXCLUDE when entity is in INCLUDE', () => {
const validator = createValidator();
const $includePicker = validator.$wrapper.find('.include-picker');
createMockChips($includePicker, [5]);
validator.activeGroup.section = 'exclude';
const result = validator.validateSelection(5, 'Electronics', 'exclude', {});
expect(result.valid).toBe(false);
expect(result.type).toBe('include_exclude_conflict');
expect(result.error).toContain('already in the include list');
});
test('TC-002: Block adding to INCLUDE when entity is in EXCLUDE', () => {
const validator = createValidator();
const $excludePicker = validator.$wrapper.find('.exclude-picker');
createMockChips($excludePicker, [5]);
validator.activeGroup.section = 'include';
const result = validator.validateSelection(5, 'Electronics', 'include', {});
expect(result.valid).toBe(false);
expect(result.type).toBe('include_exclude_conflict');
expect(result.error).toContain('already in the exclude list');
});
test('TC-003: Allow adding to INCLUDE when entity is NOT in EXCLUDE', () => {
const validator = createValidator();
const $excludePicker = validator.$wrapper.find('.exclude-picker');
createMockChips($excludePicker, [10]);
validator.activeGroup.section = 'include';
const result = validator.validateSelection(5, 'Electronics', 'include', {});
expect(result.valid).toBe(true);
});
test('TC-004: Allow adding to EXCLUDE when entity is NOT in INCLUDE', () => {
const validator = createValidator();
const $includePicker = validator.$wrapper.find('.include-picker');
createMockChips($includePicker, [10]);
validator.activeGroup.section = 'exclude';
const result = validator.validateSelection(5, 'Electronics', 'exclude', {});
expect(result.valid).toBe(true);
});
test('TC-005: Check across multiple exclude rows', () => {
const validator = createValidator();
const $container = validator.$wrapper.find('.exclude-rows-container');
$container.append(`
<div class="exclude-row" data-exclude-index="1">
<div class="value-picker exclude-picker">
<div class="entity-chips">
<span class="entity-chip" data-id="5"></span>
</div>
</div>
</div>
`);
validator.activeGroup.section = 'include';
const result = validator.validateSelection(5, 'Electronics', 'include', {});
expect(result.valid).toBe(false);
expect(result.type).toBe('include_exclude_conflict');
});
});
// GROUP 2: Redundant Selection Detection
describe('Redundant Selection Detection', () => {
test('TC-010: Block duplicate selection in INCLUDE', () => {
const validator = createValidator();
const $includePicker = validator.$wrapper.find('.include-picker');
createMockChips($includePicker, [5]);
validator.activeGroup.section = 'include';
const result = validator.validateSelection(5, 'Electronics', 'include', {});
expect(result.valid).toBe(false);
expect(result.type).toBe('redundant');
expect(result.error).toContain('already selected');
});
test('TC-011: Block duplicate selection in EXCLUDE', () => {
const validator = createValidator();
const $excludePicker = validator.$wrapper.find('.exclude-picker');
createMockChips($excludePicker, [5]);
validator.activeGroup.section = 'exclude';
const result = validator.validateSelection(5, 'Electronics', 'exclude', {});
expect(result.valid).toBe(false);
expect(result.type).toBe('redundant');
expect(result.error).toContain('already in an exclude list');
});
test('TC-012: Allow selection when not duplicate', () => {
const validator = createValidator();
const $includePicker = validator.$wrapper.find('.include-picker');
createMockChips($includePicker, [10]);
validator.activeGroup.section = 'include';
const result = validator.validateSelection(5, 'Electronics', 'include', {});
expect(result.valid).toBe(true);
});
});
// GROUP 3: Parent-Child Conflict Detection
describe('Parent-Child Conflict Detection', () => {
const treeData = [
{ id: 1, name: 'Electronics', parent_id: 0 },
{ id: 2, name: 'Computers', parent_id: 1 },
{ id: 3, name: 'Phones', parent_id: 1 },
{ id: 4, name: 'Laptops', parent_id: 2 },
{ id: 5, name: 'Desktops', parent_id: 2 },
{ id: 6, name: 'Smartphones', parent_id: 3 },
{ id: 7, name: 'Feature Phones', parent_id: 3 }
];
test('TC-020: Block including child when PARENT is EXCLUDED', () => {
const validator = createValidator();
validator.treeFlatData = treeData;
const $excludePicker = validator.$wrapper.find('.exclude-picker');
createMockChips($excludePicker, [2]); // Computers excluded
validator.activeGroup.section = 'include';
const result = validator.validateSelection(4, 'Laptops', 'include', { parentId: 2 });
expect(result.valid).toBe(false);
expect(result.type).toBe('parent_excluded');
expect(result.error).toContain('Computers');
});
test('TC-021: Block including grandchild when GRANDPARENT is EXCLUDED', () => {
const validator = createValidator();
validator.treeFlatData = treeData;
const $excludePicker = validator.$wrapper.find('.exclude-picker');
createMockChips($excludePicker, [1]); // Electronics excluded
validator.activeGroup.section = 'include';
const result = validator.validateSelection(4, 'Laptops', 'include', { parentId: 2 });
expect(result.valid).toBe(false);
expect(result.type).toBe('parent_excluded');
expect(result.error).toContain('Electronics');
});
test('TC-022: Allow including child when SIBLING is EXCLUDED', () => {
const validator = createValidator();
validator.treeFlatData = treeData;
const $excludePicker = validator.$wrapper.find('.exclude-picker');
createMockChips($excludePicker, [5]); // Desktops (sibling) excluded
validator.activeGroup.section = 'include';
const result = validator.validateSelection(4, 'Laptops', 'include', { parentId: 2 });
expect(result.valid).toBe(true);
});
test('TC-030: Block including parent when CHILD is EXCLUDED', () => {
const validator = createValidator();
validator.treeFlatData = treeData;
const $excludePicker = validator.$wrapper.find('.exclude-picker');
createMockChips($excludePicker, [4]); // Laptops excluded
validator.activeGroup.section = 'include';
const result = validator.validateSelection(2, 'Computers', 'include', { parentId: 1 });
expect(result.valid).toBe(false);
expect(result.type).toBe('child_excluded');
expect(result.error).toContain('Laptops');
});
test('TC-031: Block including grandparent when GRANDCHILD is EXCLUDED', () => {
const validator = createValidator();
validator.treeFlatData = treeData;
const $excludePicker = validator.$wrapper.find('.exclude-picker');
createMockChips($excludePicker, [4]); // Laptops excluded
validator.activeGroup.section = 'include';
const result = validator.validateSelection(1, 'Electronics', 'include', { parentId: 0 });
expect(result.valid).toBe(false);
expect(result.type).toBe('child_excluded');
});
test('TC-040: Block excluding child when PARENT is INCLUDED', () => {
const validator = createValidator();
validator.treeFlatData = treeData;
const $includePicker = validator.$wrapper.find('.include-picker');
createMockChips($includePicker, [2]); // Computers included
validator.activeGroup.section = 'exclude';
const result = validator.validateSelection(4, 'Laptops', 'exclude', { parentId: 2 });
expect(result.valid).toBe(false);
expect(result.type).toBe('parent_included');
});
test('TC-050: Block excluding parent when CHILDREN are INCLUDED', () => {
const validator = createValidator();
validator.treeFlatData = treeData;
const $includePicker = validator.$wrapper.find('.include-picker');
createMockChips($includePicker, [4, 5]); // Laptops & Desktops included
validator.activeGroup.section = 'exclude';
const result = validator.validateSelection(2, 'Computers', 'exclude', { parentId: 1 });
expect(result.valid).toBe(false);
expect(result.type).toBe('children_included');
});
test('TC-051: Block excluding grandparent when GRANDCHILDREN are INCLUDED', () => {
const validator = createValidator();
validator.treeFlatData = treeData;
const $includePicker = validator.$wrapper.find('.include-picker');
createMockChips($includePicker, [4]); // Laptops included
validator.activeGroup.section = 'exclude';
const result = validator.validateSelection(1, 'Electronics', 'exclude', { parentId: 0 });
expect(result.valid).toBe(false);
expect(result.type).toBe('children_included');
});
test('TC-052: Allow excluding when no children are included', () => {
const validator = createValidator();
validator.treeFlatData = treeData;
const $includePicker = validator.$wrapper.find('.include-picker');
createMockChips($includePicker, [3]); // Phones (different branch) included
validator.activeGroup.section = 'exclude';
const result = validator.validateSelection(2, 'Computers', 'exclude', { parentId: 1 });
expect(result.valid).toBe(true);
});
});
// GROUP 4: Non-Tree Entities
describe('Non-Tree Entity Validation', () => {
test('TC-060: Products - no tree validation, only include/exclude check', () => {
const validator = createValidator();
validator.activeGroup.searchEntity = 'products';
validator.treeFlatData = null;
const $excludePicker = validator.$wrapper.find('.exclude-picker');
createMockChips($excludePicker, [100]);
validator.activeGroup.section = 'include';
const result = validator.validateSelection(100, 'Product A', 'include', {});
expect(result.valid).toBe(false);
expect(result.type).toBe('include_exclude_conflict');
});
test('TC-061: Manufacturers - allow selection without tree validation', () => {
const validator = createValidator();
validator.activeGroup.searchEntity = 'manufacturers';
validator.treeFlatData = null;
validator.activeGroup.section = 'include';
const result = validator.validateSelection(50, 'Nike', 'include', {});
expect(result.valid).toBe(true);
});
});
// GROUP 5: Edge Cases
describe('Edge Cases', () => {
test('TC-070: Empty selections - allow any selection', () => {
const validator = createValidator();
validator.activeGroup.section = 'include';
const result = validator.validateSelection(1, 'Category A', 'include', {});
expect(result.valid).toBe(true);
});
test('TC-071: No activeGroup - return valid (fail-safe)', () => {
const validator = createValidator();
validator.activeGroup = null;
const result = validator.validateSelection(1, 'Category A', 'include', {});
expect(result.valid).toBe(true);
});
test('TC-072: String ID coercion to integer', () => {
const validator = createValidator();
const $includePicker = validator.$wrapper.find('.include-picker');
$includePicker.find('.entity-chips').append('<span class="entity-chip" data-id="5"></span>');
validator.activeGroup.section = 'include';
const result = validator.validateSelection(5, 'Category', 'include', {});
expect(result.valid).toBe(false);
expect(result.type).toBe('redundant');
});
test('TC-074: Root category (parent_id = 0)', () => {
const validator = createValidator();
validator.treeFlatData = [
{ id: 1, name: 'Root', parent_id: 0 },
{ id: 2, name: 'Child', parent_id: 1 }
];
validator.activeGroup.section = 'include';
const result = validator.validateSelection(1, 'Root', 'include', { parentId: 0 });
expect(result.valid).toBe(true);
});
test('TC-075: Deep nesting (6 levels)', () => {
const validator = createValidator();
validator.treeFlatData = [
{ id: 1, name: 'Level 1', parent_id: 0 },
{ id: 2, name: 'Level 2', parent_id: 1 },
{ id: 3, name: 'Level 3', parent_id: 2 },
{ id: 4, name: 'Level 4', parent_id: 3 },
{ id: 5, name: 'Level 5', parent_id: 4 },
{ id: 6, name: 'Level 6', parent_id: 5 }
];
const $excludePicker = validator.$wrapper.find('.exclude-picker');
createMockChips($excludePicker, [1]); // Root excluded
validator.activeGroup.section = 'include';
const result = validator.validateSelection(6, 'Level 6', 'include', { parentId: 5 });
expect(result.valid).toBe(false);
expect(result.type).toBe('parent_excluded');
});
});
// GROUP 6: Helper Functions
describe('Helper Functions', () => {
test('TC-080: getChipIds returns correct IDs', () => {
const validator = createValidator();
const $picker = validator.$wrapper.find('.include-picker');
createMockChips($picker, [1, 5, 10, 25]);
const ids = validator.getChipIds($picker);
expect(ids).toEqual([1, 5, 10, 25]);
});
test('TC-081: getAncestorIds returns correct ancestors', () => {
const validator = createValidator();
validator.treeFlatData = [
{ id: 1, name: 'Root', parent_id: 0 },
{ id: 2, name: 'Child', parent_id: 1 },
{ id: 3, name: 'Grandchild', parent_id: 2 }
];
const lookup = {};
validator.treeFlatData.forEach(item => {
lookup[item.id] = item;
});
const ancestors = validator.getAncestorIds(3, lookup);
expect(ancestors).toContain(2);
expect(ancestors).toContain(1);
expect(ancestors.length).toBe(2);
});
test('TC-082: getDescendantIds returns correct descendants', () => {
const validator = createValidator();
validator.treeFlatData = [
{ id: 1, name: 'Root', parent_id: 0 },
{ id: 2, name: 'Child A', parent_id: 1 },
{ id: 3, name: 'Child B', parent_id: 1 },
{ id: 4, name: 'Grandchild', parent_id: 2 }
];
const lookup = {};
validator.treeFlatData.forEach(item => {
lookup[item.id] = item;
});
const descendants = validator.getDescendantIds(1, lookup);
expect(descendants).toContain(2);
expect(descendants).toContain(3);
expect(descendants).toContain(4);
expect(descendants.length).toBe(3);
});
});
// GROUP 7: Bulk Validation
describe('Bulk Validation (validatePendingSelections)', () => {
test('TC-090: Returns errors for conflicts', () => {
const validator = createValidator();
const $excludePicker = validator.$wrapper.find('.exclude-picker');
createMockChips($excludePicker, [5]);
const pendingSelections = [
{ id: 1, name: 'Category A', data: {} },
{ id: 5, name: 'Category E', data: {} }, // Conflict
{ id: 10, name: 'Category J', data: {} }
];
const errors = validator.validatePendingSelections(pendingSelections, 'include');
expect(errors.length).toBe(1);
expect(errors[0].id).toBe(5);
expect(errors[0].type).toBe('include_exclude_conflict');
});
test('TC-091: Returns empty for valid selections', () => {
const validator = createValidator();
const pendingSelections = [
{ id: 1, name: 'Category A', data: {} },
{ id: 2, name: 'Category B', data: {} }
];
const errors = validator.validatePendingSelections(pendingSelections, 'include');
expect(errors.length).toBe(0);
});
test('TC-092: Handles empty array', () => {
const validator = createValidator();
const errors = validator.validatePendingSelections([], 'include');
expect(errors.length).toBe(0);
});
test('TC-093: Handles null', () => {
const validator = createValidator();
const errors = validator.validatePendingSelections(null, 'include');
expect(errors.length).toBe(0);
});
});
// =========================================================================
// SUMMARY
// =========================================================================
console.log('\n\x1b[36m══════════════════════════════════════════════════════════════\x1b[0m');
console.log(`\x1b[1mTest Results:\x1b[0m`);
console.log(` \x1b[32m✓ Passed: ${passed}\x1b[0m`);
if (failed > 0) {
console.log(` \x1b[31m✗ Failed: ${failed}\x1b[0m`);
} else {
console.log('\n \x1b[32m🎉 All tests passed!\x1b[0m');
}
console.log(`\n Total: ${passed + failed}`);
console.log('\x1b[36m══════════════════════════════════════════════════════════════\x1b[0m\n');
process.exit(failed > 0 ? 1 : 0);