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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user