Add selection validation and improve tooltip component

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

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

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

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long