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

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