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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
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
@@ -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
3956
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
365
sources/js/admin/entity-selector/_validation.js
Normal file
365
sources/js/admin/entity-selector/_validation.js
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
87
sources/scss/components/_validation.scss
Normal file
87
sources/scss/components/_validation.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,4 @@
|
||||
@use 'components/method-dropdown';
|
||||
@use 'components/tooltip';
|
||||
@use 'components/tree';
|
||||
@use 'components/validation';
|
||||
|
||||
@@ -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
615
tests/run-tests.js
Normal 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);
|
||||
Reference in New Issue
Block a user