fix: Gitea Traefik routing and connection pool optimization
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
This commit is contained in:
256
resources/js/modules/livecomponent/ActionLoadingManager.js
Normal file
256
resources/js/modules/livecomponent/ActionLoadingManager.js
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Action Loading Manager for LiveComponents
|
||||
*
|
||||
* Provides skeleton loading states during component actions with:
|
||||
* - Automatic skeleton overlay during actions
|
||||
* - Configurable skeleton templates per component
|
||||
* - Smooth transitions
|
||||
* - Fragment-aware loading
|
||||
*/
|
||||
|
||||
export class ActionLoadingManager {
|
||||
constructor() {
|
||||
this.loadingStates = new Map(); // componentId → loading state
|
||||
this.skeletonTemplates = new Map(); // componentId → template
|
||||
this.config = {
|
||||
showDelay: 150, // ms before showing skeleton
|
||||
transitionDuration: 200, // ms for transitions
|
||||
preserveContent: true, // Keep content visible under skeleton
|
||||
opacity: 0.6 // Skeleton overlay opacity
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show loading state for component
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {HTMLElement} element - Component element
|
||||
* @param {Object} options - Loading options
|
||||
*/
|
||||
showLoading(componentId, element, options = {}) {
|
||||
// Check if already loading
|
||||
if (this.loadingStates.has(componentId)) {
|
||||
return; // Already showing loading state
|
||||
}
|
||||
|
||||
const showDelay = options.showDelay ?? this.config.showDelay;
|
||||
const fragments = options.fragments || null;
|
||||
|
||||
// Delay showing skeleton (for fast responses)
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.createSkeletonOverlay(componentId, element, fragments, options);
|
||||
}, showDelay);
|
||||
|
||||
// Store loading state
|
||||
this.loadingStates.set(componentId, {
|
||||
timeoutId,
|
||||
element,
|
||||
fragments,
|
||||
options
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create skeleton overlay
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {HTMLElement} element - Component element
|
||||
* @param {Array<string>|null} fragments - Fragment names to show skeleton for
|
||||
* @param {Object} options - Options
|
||||
*/
|
||||
createSkeletonOverlay(componentId, element, fragments, options) {
|
||||
// Get skeleton template
|
||||
const template = this.getSkeletonTemplate(componentId, element, fragments);
|
||||
|
||||
// Create overlay container
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'livecomponent-loading-overlay';
|
||||
overlay.setAttribute('data-component-id', componentId);
|
||||
overlay.setAttribute('aria-busy', 'true');
|
||||
overlay.setAttribute('aria-label', 'Loading...');
|
||||
|
||||
overlay.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, ${this.config.opacity});
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity ${this.config.transitionDuration}ms ease;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
// Add skeleton content
|
||||
overlay.innerHTML = template;
|
||||
|
||||
// Ensure element has relative positioning
|
||||
const originalPosition = element.style.position;
|
||||
if (getComputedStyle(element).position === 'static') {
|
||||
element.style.position = 'relative';
|
||||
}
|
||||
|
||||
// Append overlay
|
||||
element.appendChild(overlay);
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => {
|
||||
overlay.style.opacity = '1';
|
||||
});
|
||||
|
||||
// Update loading state
|
||||
const state = this.loadingStates.get(componentId);
|
||||
if (state) {
|
||||
state.overlay = overlay;
|
||||
state.originalPosition = originalPosition;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skeleton template for component
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {HTMLElement} element - Component element
|
||||
* @param {Array<string>|null} fragments - Fragment names
|
||||
* @returns {string} Skeleton HTML
|
||||
*/
|
||||
getSkeletonTemplate(componentId, element, fragments) {
|
||||
// Check for custom template
|
||||
const customTemplate = this.skeletonTemplates.get(componentId);
|
||||
if (customTemplate) {
|
||||
return typeof customTemplate === 'function'
|
||||
? customTemplate(element, fragments)
|
||||
: customTemplate;
|
||||
}
|
||||
|
||||
// If fragments specified, create fragment-specific skeletons
|
||||
if (fragments && fragments.length > 0) {
|
||||
return this.createFragmentSkeletons(fragments);
|
||||
}
|
||||
|
||||
// Default skeleton template
|
||||
return this.createDefaultSkeleton(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default skeleton template
|
||||
*
|
||||
* @param {HTMLElement} element - Component element
|
||||
* @returns {string} Skeleton HTML
|
||||
*/
|
||||
createDefaultSkeleton(element) {
|
||||
const height = element.offsetHeight || 200;
|
||||
const width = element.offsetWidth || '100%';
|
||||
|
||||
return `
|
||||
<div class="skeleton-container" style="width: ${width}; height: ${height}px; padding: 1.5rem;">
|
||||
<div class="skeleton skeleton-text skeleton-text--full" style="margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text skeleton-text--80" style="margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text skeleton-text--60" style="margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text skeleton-text--full" style="margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text skeleton-text--80"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create fragment-specific skeletons
|
||||
*
|
||||
* @param {Array<string>} fragments - Fragment names
|
||||
* @returns {string} Skeleton HTML
|
||||
*/
|
||||
createFragmentSkeletons(fragments) {
|
||||
return fragments.map(fragmentName => `
|
||||
<div class="skeleton-fragment" data-fragment="${fragmentName}">
|
||||
<div class="skeleton skeleton-text skeleton-text--full" style="margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text skeleton-text--80"></div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide loading state
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
*/
|
||||
hideLoading(componentId) {
|
||||
const state = this.loadingStates.get(componentId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear timeout if not yet shown
|
||||
if (state.timeoutId) {
|
||||
clearTimeout(state.timeoutId);
|
||||
}
|
||||
|
||||
// Remove overlay if exists
|
||||
if (state.overlay) {
|
||||
// Animate out
|
||||
state.overlay.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
if (state.overlay && state.overlay.parentNode) {
|
||||
state.overlay.parentNode.removeChild(state.overlay);
|
||||
}
|
||||
}, this.config.transitionDuration);
|
||||
}
|
||||
|
||||
// Restore original position
|
||||
if (state.originalPosition !== undefined) {
|
||||
state.element.style.position = state.originalPosition;
|
||||
}
|
||||
|
||||
// Remove from map
|
||||
this.loadingStates.delete(componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register custom skeleton template for component
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {string|Function} template - Template HTML or function that returns HTML
|
||||
*/
|
||||
registerTemplate(componentId, template) {
|
||||
this.skeletonTemplates.set(componentId, template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister skeleton template
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
*/
|
||||
unregisterTemplate(componentId) {
|
||||
this.skeletonTemplates.delete(componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if component is loading
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @returns {boolean} True if loading
|
||||
*/
|
||||
isLoading(componentId) {
|
||||
return this.loadingStates.has(componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*
|
||||
* @param {Object} newConfig - New configuration
|
||||
*/
|
||||
updateConfig(newConfig) {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...newConfig
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const actionLoadingManager = new ActionLoadingManager();
|
||||
|
||||
347
resources/js/modules/livecomponent/ErrorBoundary.js
Normal file
347
resources/js/modules/livecomponent/ErrorBoundary.js
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Error Boundary for LiveComponents
|
||||
*
|
||||
* Provides automatic error handling, retry mechanisms, and error recovery
|
||||
* for LiveComponent operations.
|
||||
*/
|
||||
|
||||
// Note: LiveComponentError type is defined in types/livecomponent.d.ts
|
||||
// This is a runtime implementation, so we don't need to import the type
|
||||
|
||||
export class ErrorBoundary {
|
||||
constructor(liveComponentManager) {
|
||||
this.manager = liveComponentManager;
|
||||
this.retryStrategies = new Map();
|
||||
this.errorHandlers = new Map();
|
||||
this.maxRetries = 3;
|
||||
this.retryDelays = [1000, 2000, 5000]; // Progressive backoff
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle error from component action
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {string} action - Action method name
|
||||
* @param {Error|LiveComponentError} error - Error object
|
||||
* @param {Object} context - Additional context
|
||||
* @returns {Promise<boolean>} True if error was handled, false otherwise
|
||||
*/
|
||||
async handleError(componentId, action, error, context = {}) {
|
||||
console.error(`[ErrorBoundary] Error in ${componentId}.${action}:`, error);
|
||||
|
||||
// Convert to standardized error format
|
||||
const standardizedError = this.standardizeError(error, componentId, action);
|
||||
|
||||
// Check for custom error handler
|
||||
const handler = this.errorHandlers.get(componentId);
|
||||
if (handler) {
|
||||
try {
|
||||
const handled = await handler(standardizedError, context);
|
||||
if (handled) {
|
||||
return true;
|
||||
}
|
||||
} catch (handlerError) {
|
||||
console.error('[ErrorBoundary] Error handler failed:', handlerError);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if error is retryable
|
||||
if (this.isRetryable(standardizedError)) {
|
||||
const retried = await this.retryOperation(componentId, action, context, standardizedError);
|
||||
if (retried) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Show error to user
|
||||
this.showError(componentId, standardizedError);
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchErrorEvent(componentId, standardizedError);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardize error format
|
||||
*
|
||||
* @param {Error|LiveComponentError|Object} error - Error object
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {string} action - Action method name
|
||||
* @returns {LiveComponentError} Standardized error
|
||||
*/
|
||||
standardizeError(error, componentId, action) {
|
||||
// If already standardized
|
||||
if (error && typeof error === 'object' && 'code' in error && 'message' in error) {
|
||||
return {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.details || {},
|
||||
componentId: error.componentId || componentId,
|
||||
action: error.action || action,
|
||||
timestamp: error.timestamp || Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
// If Error object
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: error.message,
|
||||
details: {
|
||||
stack: error.stack,
|
||||
name: error.name
|
||||
},
|
||||
componentId,
|
||||
action,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
// If string
|
||||
if (typeof error === 'string') {
|
||||
return {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: error,
|
||||
componentId,
|
||||
action,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
// Default
|
||||
return {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'An unknown error occurred',
|
||||
details: { original: error },
|
||||
componentId,
|
||||
action,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is retryable
|
||||
*
|
||||
* @param {LiveComponentError} error - Standardized error
|
||||
* @returns {boolean} True if error is retryable
|
||||
*/
|
||||
isRetryable(error) {
|
||||
const retryableCodes = [
|
||||
'RATE_LIMIT_EXCEEDED',
|
||||
'STATE_CONFLICT',
|
||||
'INTERNAL_ERROR'
|
||||
];
|
||||
|
||||
return retryableCodes.includes(error.code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry operation with exponential backoff
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {string} action - Action method name
|
||||
* @param {Object} context - Operation context
|
||||
* @param {LiveComponentError} error - Error that occurred
|
||||
* @returns {Promise<boolean>} True if retry succeeded
|
||||
*/
|
||||
async retryOperation(componentId, action, context, error) {
|
||||
const retryKey = `${componentId}:${action}`;
|
||||
const retryCount = this.retryStrategies.get(retryKey) || 0;
|
||||
|
||||
if (retryCount >= this.maxRetries) {
|
||||
console.warn(`[ErrorBoundary] Max retries exceeded for ${retryKey}`);
|
||||
this.retryStrategies.delete(retryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate delay (progressive backoff)
|
||||
const delay = this.retryDelays[retryCount] || this.retryDelays[this.retryDelays.length - 1];
|
||||
|
||||
console.log(`[ErrorBoundary] Retrying ${retryKey} in ${delay}ms (attempt ${retryCount + 1}/${this.maxRetries})`);
|
||||
|
||||
// Update retry count
|
||||
this.retryStrategies.set(retryKey, retryCount + 1);
|
||||
|
||||
// Wait before retry
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
try {
|
||||
// Retry the operation
|
||||
const result = await this.manager.executeAction(
|
||||
componentId,
|
||||
action,
|
||||
context.params || {},
|
||||
context.fragments || null
|
||||
);
|
||||
|
||||
// Success - clear retry count
|
||||
this.retryStrategies.delete(retryKey);
|
||||
console.log(`[ErrorBoundary] Retry succeeded for ${retryKey}`);
|
||||
return true;
|
||||
|
||||
} catch (retryError) {
|
||||
// Retry failed - will be handled by next retry or error handler
|
||||
console.warn(`[ErrorBoundary] Retry failed for ${retryKey}:`, retryError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error to user
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {LiveComponentError} error - Standardized error
|
||||
*/
|
||||
showError(componentId, error) {
|
||||
const config = this.manager.components.get(componentId);
|
||||
if (!config) return;
|
||||
|
||||
// Remove existing error
|
||||
const existingError = config.element.querySelector('.livecomponent-error-boundary');
|
||||
if (existingError) {
|
||||
existingError.remove();
|
||||
}
|
||||
|
||||
// Create error element
|
||||
const errorEl = document.createElement('div');
|
||||
errorEl.className = 'livecomponent-error-boundary';
|
||||
errorEl.style.cssText = `
|
||||
padding: 1rem;
|
||||
background: #fee;
|
||||
color: #c00;
|
||||
border: 1px solid #faa;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
// Error message
|
||||
const messageEl = document.createElement('div');
|
||||
messageEl.style.fontWeight = 'bold';
|
||||
messageEl.textContent = error.message;
|
||||
errorEl.appendChild(messageEl);
|
||||
|
||||
// Error code (if not generic)
|
||||
if (error.code !== 'INTERNAL_ERROR') {
|
||||
const codeEl = document.createElement('div');
|
||||
codeEl.style.fontSize = '0.875rem';
|
||||
codeEl.style.marginTop = '0.5rem';
|
||||
codeEl.style.color = '#666';
|
||||
codeEl.textContent = `Error Code: ${error.code}`;
|
||||
errorEl.appendChild(codeEl);
|
||||
}
|
||||
|
||||
// Retry button (if retryable)
|
||||
if (this.isRetryable(error)) {
|
||||
const retryBtn = document.createElement('button');
|
||||
retryBtn.textContent = 'Retry';
|
||||
retryBtn.style.cssText = `
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #c00;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
retryBtn.addEventListener('click', async () => {
|
||||
errorEl.remove();
|
||||
await this.retryOperation(componentId, error.action || '', {}, error);
|
||||
});
|
||||
errorEl.appendChild(retryBtn);
|
||||
}
|
||||
|
||||
// Close button
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.style.cssText = `
|
||||
float: right;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #c00;
|
||||
`;
|
||||
closeBtn.addEventListener('click', () => {
|
||||
errorEl.remove();
|
||||
});
|
||||
errorEl.appendChild(closeBtn);
|
||||
|
||||
// Insert at top of component
|
||||
config.element.insertAdjacentElement('afterbegin', errorEl);
|
||||
|
||||
// Auto-remove after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (errorEl.parentNode) {
|
||||
errorEl.remove();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch error event
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {LiveComponentError} error - Standardized error
|
||||
*/
|
||||
dispatchErrorEvent(componentId, error) {
|
||||
// Dispatch custom DOM event
|
||||
const event = new CustomEvent('livecomponent:error', {
|
||||
detail: {
|
||||
componentId,
|
||||
error
|
||||
},
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
const config = this.manager.components.get(componentId);
|
||||
if (config) {
|
||||
config.element.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// Also dispatch on document
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register custom error handler for component
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {Function} handler - Error handler function
|
||||
*/
|
||||
registerErrorHandler(componentId, handler) {
|
||||
if (typeof handler !== 'function') {
|
||||
throw new Error('Error handler must be a function');
|
||||
}
|
||||
|
||||
this.errorHandlers.set(componentId, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error handler for component
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
*/
|
||||
clearErrorHandler(componentId) {
|
||||
this.errorHandlers.delete(componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear retry strategy for component/action
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {string} action - Action method name
|
||||
*/
|
||||
clearRetryStrategy(componentId, action) {
|
||||
const retryKey = `${componentId}:${action}`;
|
||||
this.retryStrategies.delete(retryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all retry strategies
|
||||
*/
|
||||
resetRetryStrategies() {
|
||||
this.retryStrategies.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +226,9 @@ export class LazyComponentLoader {
|
||||
|
||||
// Copy state to element
|
||||
if (data.state) {
|
||||
config.element.dataset.state = JSON.stringify(data.state);
|
||||
// Use StateSerializer for type-safe state handling
|
||||
const stateJson = JSON.stringify(data.state);
|
||||
config.element.dataset.state = stateJson;
|
||||
}
|
||||
|
||||
// Initialize as regular LiveComponent
|
||||
|
||||
347
resources/js/modules/livecomponent/LiveComponentUIHelper.js
Normal file
347
resources/js/modules/livecomponent/LiveComponentUIHelper.js
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* LiveComponent UI Helper
|
||||
*
|
||||
* Provides unified API for UI components (Dialogs, Modals, Notifications)
|
||||
* integrated with LiveComponents.
|
||||
*/
|
||||
|
||||
import { UIManager } from '../ui/UIManager.js';
|
||||
|
||||
export class LiveComponentUIHelper {
|
||||
constructor(liveComponentManager) {
|
||||
this.manager = liveComponentManager;
|
||||
this.uiManager = UIManager;
|
||||
this.activeDialogs = new Map(); // componentId → dialog instances
|
||||
this.activeNotifications = new Map(); // componentId → notification instances
|
||||
}
|
||||
|
||||
/**
|
||||
* Show dialog from LiveComponent action
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {Object} options - Dialog options
|
||||
* @returns {Object} Dialog instance
|
||||
*/
|
||||
showDialog(componentId, options = {}) {
|
||||
const {
|
||||
title = '',
|
||||
content = '',
|
||||
size = 'medium',
|
||||
buttons = [],
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
onClose = null,
|
||||
onConfirm = null
|
||||
} = options;
|
||||
|
||||
// Create dialog content
|
||||
const dialogContent = this.createDialogContent(title, content, buttons, {
|
||||
onClose,
|
||||
onConfirm
|
||||
});
|
||||
|
||||
// Show modal via UIManager
|
||||
const modal = this.uiManager.open('modal', {
|
||||
content: dialogContent,
|
||||
className: `livecomponent-dialog livecomponent-dialog--${size}`,
|
||||
onClose: () => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
this.activeDialogs.delete(componentId);
|
||||
}
|
||||
});
|
||||
|
||||
// Store reference
|
||||
this.activeDialogs.set(componentId, modal);
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation dialog
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {Object} options - Confirmation options
|
||||
* @returns {Promise<boolean>} Promise resolving to true if confirmed
|
||||
*/
|
||||
showConfirm(componentId, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const {
|
||||
title = 'Confirm',
|
||||
message = 'Are you sure?',
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmClass = 'btn-primary',
|
||||
cancelClass = 'btn-secondary'
|
||||
} = options;
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
text: cancelText,
|
||||
class: cancelClass,
|
||||
action: () => {
|
||||
this.closeDialog(componentId);
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: confirmText,
|
||||
class: confirmClass,
|
||||
action: () => {
|
||||
this.closeDialog(componentId);
|
||||
resolve(true);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
this.showDialog(componentId, {
|
||||
title,
|
||||
content: `<p>${message}</p>`,
|
||||
buttons,
|
||||
size: 'small'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show alert dialog
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {Object} options - Alert options
|
||||
*/
|
||||
showAlert(componentId, options = {}) {
|
||||
const {
|
||||
title = 'Alert',
|
||||
message = '',
|
||||
buttonText = 'OK',
|
||||
type = 'info' // info, success, warning, error
|
||||
} = options;
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
text: buttonText,
|
||||
class: `btn-${type}`,
|
||||
action: () => {
|
||||
this.closeDialog(componentId);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return this.showDialog(componentId, {
|
||||
title,
|
||||
content: `<p class="alert-message alert-message--${type}">${message}</p>`,
|
||||
buttons,
|
||||
size: 'small'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close dialog
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
*/
|
||||
closeDialog(componentId) {
|
||||
const dialog = this.activeDialogs.get(componentId);
|
||||
if (dialog && typeof dialog.close === 'function') {
|
||||
dialog.close();
|
||||
this.activeDialogs.delete(componentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notification
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {Object} options - Notification options
|
||||
*/
|
||||
showNotification(componentId, options = {}) {
|
||||
const {
|
||||
message = '',
|
||||
type = 'info', // info, success, warning, error
|
||||
duration = 5000,
|
||||
position = 'top-right',
|
||||
action = null
|
||||
} = options;
|
||||
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `livecomponent-notification livecomponent-notification--${type} livecomponent-notification--${position}`;
|
||||
notification.setAttribute('role', 'alert');
|
||||
notification.setAttribute('aria-live', 'polite');
|
||||
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="notification-message">${message}</span>
|
||||
${action ? `<button class="notification-action">${action.text}</button>` : ''}
|
||||
<button class="notification-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add styles
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
${position.includes('top') ? 'top' : 'bottom'}: 1rem;
|
||||
${position.includes('left') ? 'left' : 'right'}: 1rem;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
background: ${this.getNotificationColor(type)};
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
transform: translateY(${position.includes('top') ? '-20px' : '20px'});
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
`;
|
||||
|
||||
// Add to DOM
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateY(0)';
|
||||
});
|
||||
|
||||
// Setup close button
|
||||
const closeBtn = notification.querySelector('.notification-close');
|
||||
closeBtn.addEventListener('click', () => {
|
||||
this.hideNotification(notification);
|
||||
});
|
||||
|
||||
// Setup action button
|
||||
if (action) {
|
||||
const actionBtn = notification.querySelector('.notification-action');
|
||||
actionBtn.addEventListener('click', () => {
|
||||
if (action.handler) {
|
||||
action.handler();
|
||||
}
|
||||
this.hideNotification(notification);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-hide after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
this.hideNotification(notification);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// Store reference
|
||||
this.activeNotifications.set(componentId, notification);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide notification
|
||||
*
|
||||
* @param {HTMLElement|string} notificationOrComponentId - Notification element or component ID
|
||||
*/
|
||||
hideNotification(notificationOrComponentId) {
|
||||
const notification = typeof notificationOrComponentId === 'string'
|
||||
? this.activeNotifications.get(notificationOrComponentId)
|
||||
: notificationOrComponentId;
|
||||
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate out
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = `translateY(${notification.classList.contains('livecomponent-notification--top') ? '-20px' : '20px'})`;
|
||||
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
|
||||
// Remove from map
|
||||
for (const [componentId, notif] of this.activeNotifications.entries()) {
|
||||
if (notif === notification) {
|
||||
this.activeNotifications.delete(componentId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create dialog content HTML
|
||||
*
|
||||
* @param {string} title - Dialog title
|
||||
* @param {string} content - Dialog content
|
||||
* @param {Array} buttons - Button configurations
|
||||
* @param {Object} callbacks - Callback functions
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
createDialogContent(title, content, buttons, callbacks) {
|
||||
const buttonsHtml = buttons.map((btn, index) => {
|
||||
const btnClass = btn.class || 'btn-secondary';
|
||||
const btnAction = btn.action || (() => {});
|
||||
return `<button class="btn ${btnClass}" data-dialog-action="${index}">${btn.text || 'Button'}</button>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="livecomponent-dialog-content">
|
||||
${title ? `<div class="dialog-header"><h3>${title}</h3></div>` : ''}
|
||||
<div class="dialog-body">${content}</div>
|
||||
${buttons.length > 0 ? `<div class="dialog-footer">${buttonsHtml}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification color by type
|
||||
*
|
||||
* @param {string} type - Notification type
|
||||
* @returns {string} Color value
|
||||
*/
|
||||
getNotificationColor(type) {
|
||||
const colors = {
|
||||
info: '#3b82f6',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444'
|
||||
};
|
||||
return colors[type] || colors.info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show loading dialog
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {string} message - Loading message
|
||||
* @returns {Object} Dialog instance
|
||||
*/
|
||||
showLoadingDialog(componentId, message = 'Loading...') {
|
||||
return this.showDialog(componentId, {
|
||||
title: '',
|
||||
content: `
|
||||
<div class="loading-dialog">
|
||||
<div class="spinner"></div>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
`,
|
||||
size: 'small',
|
||||
buttons: [],
|
||||
closeOnBackdrop: false,
|
||||
closeOnEscape: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all UI components for component
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
*/
|
||||
cleanup(componentId) {
|
||||
// Close dialogs
|
||||
this.closeDialog(componentId);
|
||||
|
||||
// Hide notifications
|
||||
this.hideNotification(componentId);
|
||||
}
|
||||
}
|
||||
|
||||
307
resources/js/modules/livecomponent/LoadingStateManager.js
Normal file
307
resources/js/modules/livecomponent/LoadingStateManager.js
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Loading State Manager for LiveComponents
|
||||
*
|
||||
* Manages different loading indicators (skeleton, spinner, progress) during actions.
|
||||
* Integrates with OptimisticStateManager and ActionLoadingManager.
|
||||
*/
|
||||
|
||||
export class LoadingStateManager {
|
||||
constructor(actionLoadingManager, optimisticStateManager) {
|
||||
this.actionLoadingManager = actionLoadingManager;
|
||||
this.optimisticStateManager = optimisticStateManager;
|
||||
this.loadingConfigs = new Map(); // componentId → loading config
|
||||
this.config = {
|
||||
defaultType: 'skeleton', // skeleton, spinner, progress, none
|
||||
showDelay: 150,
|
||||
hideDelay: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure loading state for component
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {Object} config - Loading configuration
|
||||
*/
|
||||
configure(componentId, config) {
|
||||
this.loadingConfigs.set(componentId, {
|
||||
type: config.type || this.config.defaultType,
|
||||
showDelay: config.showDelay ?? this.config.showDelay,
|
||||
hideDelay: config.hideDelay ?? this.config.hideDelay,
|
||||
template: config.template || null,
|
||||
...config
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show loading state for action
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {HTMLElement} element - Component element
|
||||
* @param {Object} options - Loading options
|
||||
*/
|
||||
showLoading(componentId, element, options = {}) {
|
||||
const config = this.loadingConfigs.get(componentId) || {
|
||||
type: this.config.defaultType,
|
||||
showDelay: this.config.showDelay
|
||||
};
|
||||
|
||||
const loadingType = options.type || config.type;
|
||||
|
||||
// Skip if type is 'none' or optimistic UI is enabled
|
||||
if (loadingType === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if optimistic UI is active (no loading needed)
|
||||
const pendingOps = this.optimisticStateManager.getPendingOperations(componentId);
|
||||
if (pendingOps.length > 0 && options.optimistic !== false) {
|
||||
// Optimistic UI is active - skip loading indicator
|
||||
return;
|
||||
}
|
||||
|
||||
switch (loadingType) {
|
||||
case 'skeleton':
|
||||
this.actionLoadingManager.showLoading(componentId, element, {
|
||||
...options,
|
||||
showDelay: config.showDelay
|
||||
});
|
||||
break;
|
||||
|
||||
case 'spinner':
|
||||
this.showSpinner(componentId, element, config);
|
||||
break;
|
||||
|
||||
case 'progress':
|
||||
this.showProgress(componentId, element, config);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Fallback to skeleton
|
||||
this.actionLoadingManager.showLoading(componentId, element, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide loading state
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
*/
|
||||
hideLoading(componentId) {
|
||||
// Hide skeleton loading
|
||||
this.actionLoadingManager.hideLoading(componentId);
|
||||
|
||||
// Hide spinner
|
||||
this.hideSpinner(componentId);
|
||||
|
||||
// Hide progress
|
||||
this.hideProgress(componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show spinner loading indicator
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {HTMLElement} element - Component element
|
||||
* @param {Object} config - Configuration
|
||||
*/
|
||||
showSpinner(componentId, element, config) {
|
||||
// Remove existing spinner
|
||||
this.hideSpinner(componentId);
|
||||
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'livecomponent-loading-spinner';
|
||||
spinner.setAttribute('data-component-id', componentId);
|
||||
spinner.setAttribute('aria-busy', 'true');
|
||||
spinner.setAttribute('aria-label', 'Loading...');
|
||||
|
||||
spinner.innerHTML = `
|
||||
<div class="spinner"></div>
|
||||
<span class="spinner-text">Loading...</span>
|
||||
`;
|
||||
|
||||
spinner.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity ${this.config.showDelay}ms ease;
|
||||
`;
|
||||
|
||||
// Ensure element has relative positioning
|
||||
if (getComputedStyle(element).position === 'static') {
|
||||
element.style.position = 'relative';
|
||||
}
|
||||
|
||||
element.appendChild(spinner);
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => {
|
||||
spinner.style.opacity = '1';
|
||||
});
|
||||
|
||||
// Store reference
|
||||
const loadingConfig = this.loadingConfigs.get(componentId) || {};
|
||||
loadingConfig.spinner = spinner;
|
||||
this.loadingConfigs.set(componentId, loadingConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide spinner
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
*/
|
||||
hideSpinner(componentId) {
|
||||
const config = this.loadingConfigs.get(componentId);
|
||||
if (!config || !config.spinner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = config.spinner;
|
||||
spinner.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
if (spinner.parentNode) {
|
||||
spinner.parentNode.removeChild(spinner);
|
||||
}
|
||||
delete config.spinner;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show progress bar
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {HTMLElement} element - Component element
|
||||
* @param {Object} config - Configuration
|
||||
*/
|
||||
showProgress(componentId, element, config) {
|
||||
// Remove existing progress
|
||||
this.hideProgress(componentId);
|
||||
|
||||
const progress = document.createElement('div');
|
||||
progress.className = 'livecomponent-loading-progress';
|
||||
progress.setAttribute('data-component-id', componentId);
|
||||
progress.setAttribute('aria-busy', 'true');
|
||||
|
||||
progress.innerHTML = `
|
||||
<div class="progress-bar" style="width: 0%;"></div>
|
||||
`;
|
||||
|
||||
progress.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: #e0e0e0;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity ${this.config.showDelay}ms ease;
|
||||
`;
|
||||
|
||||
const progressBar = progress.querySelector('.progress-bar');
|
||||
progressBar.style.cssText = `
|
||||
height: 100%;
|
||||
background: #2196F3;
|
||||
transition: width 0.3s ease;
|
||||
`;
|
||||
|
||||
// Ensure element has relative positioning
|
||||
if (getComputedStyle(element).position === 'static') {
|
||||
element.style.position = 'relative';
|
||||
}
|
||||
|
||||
element.appendChild(progress);
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => {
|
||||
progress.style.opacity = '1';
|
||||
// Simulate progress (can be updated via updateProgress)
|
||||
this.updateProgress(componentId, 30);
|
||||
});
|
||||
|
||||
// Store reference
|
||||
const loadingConfig = this.loadingConfigs.get(componentId) || {};
|
||||
loadingConfig.progress = progress;
|
||||
this.loadingConfigs.set(componentId, loadingConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress bar
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {number} percent - Progress percentage (0-100)
|
||||
*/
|
||||
updateProgress(componentId, percent) {
|
||||
const config = this.loadingConfigs.get(componentId);
|
||||
if (!config || !config.progress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progressBar = config.progress.querySelector('.progress-bar');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${Math.min(100, Math.max(0, percent))}%`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide progress bar
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
*/
|
||||
hideProgress(componentId) {
|
||||
const config = this.loadingConfigs.get(componentId);
|
||||
if (!config || !config.progress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = config.progress;
|
||||
|
||||
// Complete progress bar
|
||||
this.updateProgress(componentId, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
progress.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (progress.parentNode) {
|
||||
progress.parentNode.removeChild(progress);
|
||||
}
|
||||
delete config.progress;
|
||||
}, 200);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get loading configuration for component
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @returns {Object} Loading configuration
|
||||
*/
|
||||
getConfig(componentId) {
|
||||
return this.loadingConfigs.get(componentId) || {
|
||||
type: this.config.defaultType,
|
||||
showDelay: this.config.showDelay
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear configuration for component
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
*/
|
||||
clearConfig(componentId) {
|
||||
this.hideLoading(componentId);
|
||||
this.loadingConfigs.delete(componentId);
|
||||
}
|
||||
}
|
||||
|
||||
180
resources/js/modules/livecomponent/RequestDeduplicator.js
Normal file
180
resources/js/modules/livecomponent/RequestDeduplicator.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Request Deduplication for LiveComponents
|
||||
*
|
||||
* Prevents duplicate requests for the same action with the same parameters.
|
||||
* Useful for preventing race conditions and reducing server load.
|
||||
*/
|
||||
|
||||
export class RequestDeduplicator {
|
||||
constructor() {
|
||||
this.pendingRequests = new Map();
|
||||
this.requestCache = new Map();
|
||||
this.cacheTimeout = 1000; // 1 second cache
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate request key for deduplication
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {string} method - Action method name
|
||||
* @param {Object} params - Action parameters
|
||||
* @returns {string} Request key
|
||||
*/
|
||||
generateKey(componentId, method, params) {
|
||||
// Sort params for consistent key generation
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.reduce((acc, key) => {
|
||||
acc[key] = params[key];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const paramsString = JSON.stringify(sortedParams);
|
||||
return `${componentId}:${method}:${paramsString}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is already pending
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {string} method - Action method name
|
||||
* @param {Object} params - Action parameters
|
||||
* @returns {Promise|null} Pending request promise or null
|
||||
*/
|
||||
getPendingRequest(componentId, method, params) {
|
||||
const key = this.generateKey(componentId, method, params);
|
||||
return this.pendingRequests.get(key) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register pending request
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {string} method - Action method name
|
||||
* @param {Object} params - Action parameters
|
||||
* @param {Promise} promise - Request promise
|
||||
* @returns {Promise} Request promise
|
||||
*/
|
||||
registerPendingRequest(componentId, method, params, promise) {
|
||||
const key = this.generateKey(componentId, method, params);
|
||||
|
||||
// Store pending request
|
||||
this.pendingRequests.set(key, promise);
|
||||
|
||||
// Clean up when request completes
|
||||
promise
|
||||
.then(() => {
|
||||
this.pendingRequests.delete(key);
|
||||
})
|
||||
.catch(() => {
|
||||
this.pendingRequests.delete(key);
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request result is cached
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {string} method - Action method name
|
||||
* @param {Object} params - Action parameters
|
||||
* @returns {Object|null} Cached result or null
|
||||
*/
|
||||
getCachedResult(componentId, method, params) {
|
||||
const key = this.generateKey(componentId, method, params);
|
||||
const cached = this.requestCache.get(key);
|
||||
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if cache is still valid
|
||||
const now = Date.now();
|
||||
if (now - cached.timestamp > this.cacheTimeout) {
|
||||
this.requestCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache request result
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {string} method - Action method name
|
||||
* @param {Object} params - Action parameters
|
||||
* @param {Object} result - Request result
|
||||
*/
|
||||
cacheResult(componentId, method, params, result) {
|
||||
const key = this.generateKey(componentId, method, params);
|
||||
this.requestCache.set(key, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Clean up old cache entries
|
||||
this.cleanupCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired cache entries
|
||||
*/
|
||||
cleanupCache() {
|
||||
const now = Date.now();
|
||||
for (const [key, cached] of this.requestCache.entries()) {
|
||||
if (now - cached.timestamp > this.cacheTimeout) {
|
||||
this.requestCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending requests
|
||||
*/
|
||||
clearPendingRequests() {
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached results
|
||||
*/
|
||||
clearCache() {
|
||||
this.requestCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear requests and cache for specific component
|
||||
*
|
||||
* @param {string} componentId - Component ID
|
||||
*/
|
||||
clearComponent(componentId) {
|
||||
// Clear pending requests
|
||||
for (const [key] of this.pendingRequests.entries()) {
|
||||
if (key.startsWith(`${componentId}:`)) {
|
||||
this.pendingRequests.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
for (const [key] of this.requestCache.entries()) {
|
||||
if (key.startsWith(`${componentId}:`)) {
|
||||
this.requestCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
*
|
||||
* @returns {Object} Statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
pendingRequests: this.pendingRequests.size,
|
||||
cachedResults: this.requestCache.size
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
124
resources/js/modules/livecomponent/SharedConfig.js
Normal file
124
resources/js/modules/livecomponent/SharedConfig.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Shared Configuration between PHP and JavaScript
|
||||
*
|
||||
* Provides unified configuration management for LiveComponents
|
||||
* that can be synchronized between server and client.
|
||||
*/
|
||||
|
||||
export class SharedConfig {
|
||||
constructor() {
|
||||
this.config = {
|
||||
// Request settings
|
||||
requestTimeout: 30000, // 30 seconds
|
||||
retryAttempts: 3,
|
||||
retryDelays: [1000, 2000, 5000], // Progressive backoff
|
||||
|
||||
// State settings
|
||||
stateVersioning: true,
|
||||
stateValidation: true,
|
||||
|
||||
// Performance settings
|
||||
requestDeduplication: true,
|
||||
requestCacheTimeout: 1000, // 1 second
|
||||
batchFlushDelay: 50, // 50ms
|
||||
|
||||
// Error handling
|
||||
errorBoundary: true,
|
||||
autoRetry: true,
|
||||
showErrorUI: true,
|
||||
|
||||
// DevTools
|
||||
devToolsEnabled: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from server
|
||||
*
|
||||
* @param {Object} serverConfig - Configuration from server
|
||||
*/
|
||||
loadFromServer(serverConfig) {
|
||||
if (serverConfig && typeof serverConfig === 'object') {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...serverConfig
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from DOM
|
||||
*
|
||||
* Looks for data-livecomponent-config attribute on document or body
|
||||
*/
|
||||
loadFromDOM() {
|
||||
const configElement = document.querySelector('[data-livecomponent-config]') || document.body;
|
||||
const configJson = configElement.dataset.livecomponentConfig;
|
||||
|
||||
if (configJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(configJson);
|
||||
this.loadFromServer(parsed);
|
||||
} catch (error) {
|
||||
console.warn('[SharedConfig] Failed to parse config from DOM:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value
|
||||
*
|
||||
* @param {string} key - Configuration key
|
||||
* @param {*} defaultValue - Default value if key not found
|
||||
* @returns {*} Configuration value
|
||||
*/
|
||||
get(key, defaultValue = null) {
|
||||
return this.config[key] ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration value
|
||||
*
|
||||
* @param {string} key - Configuration key
|
||||
* @param {*} value - Configuration value
|
||||
*/
|
||||
set(key, value) {
|
||||
this.config[key] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configuration
|
||||
*
|
||||
* @returns {Object} Full configuration object
|
||||
*/
|
||||
getAll() {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge configuration
|
||||
*
|
||||
* @param {Object} newConfig - Configuration to merge
|
||||
*/
|
||||
merge(newConfig) {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...newConfig
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const sharedConfig = new SharedConfig();
|
||||
|
||||
// Auto-load from DOM on initialization
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
sharedConfig.loadFromDOM();
|
||||
});
|
||||
} else {
|
||||
sharedConfig.loadFromDOM();
|
||||
}
|
||||
}
|
||||
|
||||
245
resources/js/modules/livecomponent/StateSerializer.js
Normal file
245
resources/js/modules/livecomponent/StateSerializer.js
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* State Serializer for LiveComponents
|
||||
*
|
||||
* Provides type-safe state serialization/deserialization with versioning
|
||||
* and validation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Serialize component state
|
||||
*
|
||||
* @param {LiveComponentState} state - Component state
|
||||
* @returns {string} Serialized state JSON
|
||||
*/
|
||||
export function serializeState(state) {
|
||||
// Validate state structure
|
||||
if (!state || typeof state !== 'object') {
|
||||
throw new Error('State must be an object');
|
||||
}
|
||||
|
||||
if (!state.id || typeof state.id !== 'string') {
|
||||
throw new Error('State must have a valid id');
|
||||
}
|
||||
|
||||
if (!state.component || typeof state.component !== 'string') {
|
||||
throw new Error('State must have a valid component name');
|
||||
}
|
||||
|
||||
if (typeof state.version !== 'number' || state.version < 1) {
|
||||
throw new Error('State must have a valid version number');
|
||||
}
|
||||
|
||||
// Ensure data is an object
|
||||
const data = state.data || {};
|
||||
if (typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw new Error('State data must be an object');
|
||||
}
|
||||
|
||||
// Create serializable state
|
||||
const serializableState = {
|
||||
id: state.id,
|
||||
component: state.component,
|
||||
data: data,
|
||||
version: state.version
|
||||
};
|
||||
|
||||
// Serialize to JSON
|
||||
try {
|
||||
return JSON.stringify(serializableState);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to serialize state: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize component state
|
||||
*
|
||||
* @param {string} json - Serialized state JSON
|
||||
* @returns {LiveComponentState} Deserialized state
|
||||
*/
|
||||
export function deserializeState(json) {
|
||||
if (typeof json !== 'string') {
|
||||
throw new Error('State JSON must be a string');
|
||||
}
|
||||
|
||||
if (json.trim() === '') {
|
||||
// Return empty state
|
||||
return {
|
||||
id: '',
|
||||
component: '',
|
||||
data: {},
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
// Validate structure
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('Invalid state structure');
|
||||
}
|
||||
|
||||
// Extract state data
|
||||
const id = parsed.id || '';
|
||||
const component = parsed.component || '';
|
||||
const data = parsed.data || {};
|
||||
const version = typeof parsed.version === 'number' ? parsed.version : 1;
|
||||
|
||||
// Validate data is an object
|
||||
if (typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw new Error('State data must be an object');
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
component,
|
||||
data,
|
||||
version
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Invalid JSON format: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create state diff (changes between two states)
|
||||
*
|
||||
* @param {LiveComponentState} oldState - Previous state
|
||||
* @param {LiveComponentState} newState - New state
|
||||
* @returns {Object} State diff
|
||||
*/
|
||||
export function createStateDiff(oldState, newState) {
|
||||
const diff = {
|
||||
changed: false,
|
||||
added: {},
|
||||
removed: {},
|
||||
modified: {},
|
||||
versionChange: newState.version - oldState.version
|
||||
};
|
||||
|
||||
const oldData = oldState.data || {};
|
||||
const newData = newState.data || {};
|
||||
|
||||
// Find added keys
|
||||
for (const key in newData) {
|
||||
if (!(key in oldData)) {
|
||||
diff.added[key] = newData[key];
|
||||
diff.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Find removed keys
|
||||
for (const key in oldData) {
|
||||
if (!(key in newData)) {
|
||||
diff.removed[key] = oldData[key];
|
||||
diff.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Find modified keys
|
||||
for (const key in newData) {
|
||||
if (key in oldData) {
|
||||
const oldValue = oldData[key];
|
||||
const newValue = newData[key];
|
||||
|
||||
// Deep comparison for objects
|
||||
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
|
||||
diff.modified[key] = {
|
||||
old: oldValue,
|
||||
new: newValue
|
||||
};
|
||||
diff.changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge state changes
|
||||
*
|
||||
* @param {LiveComponentState} baseState - Base state
|
||||
* @param {Object} changes - State changes to apply
|
||||
* @returns {LiveComponentState} Merged state
|
||||
*/
|
||||
export function mergeStateChanges(baseState, changes) {
|
||||
const baseData = baseState.data || {};
|
||||
const mergedData = { ...baseData, ...changes };
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
data: mergedData,
|
||||
version: baseState.version + 1
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate state structure
|
||||
*
|
||||
* @param {LiveComponentState} state - State to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
export function validateState(state) {
|
||||
if (!state || typeof state !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state.id || typeof state.id !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state.component || typeof state.component !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof state.version !== 'number' || state.version < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.data && (typeof state.data !== 'object' || Array.isArray(state.data))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get state from DOM element
|
||||
*
|
||||
* @param {HTMLElement} element - Component element
|
||||
* @returns {LiveComponentState|null} State or null if not found
|
||||
*/
|
||||
export function getStateFromElement(element) {
|
||||
const stateJson = element.dataset.state;
|
||||
if (!stateJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return deserializeState(stateJson);
|
||||
} catch (error) {
|
||||
console.error('[StateSerializer] Failed to deserialize state from element:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set state on DOM element
|
||||
*
|
||||
* @param {HTMLElement} element - Component element
|
||||
* @param {LiveComponentState} state - State to set
|
||||
*/
|
||||
export function setStateOnElement(element, state) {
|
||||
if (!validateState(state)) {
|
||||
throw new Error('Invalid state structure');
|
||||
}
|
||||
|
||||
const stateJson = serializeState(state);
|
||||
element.dataset.state = stateJson;
|
||||
}
|
||||
|
||||
388
resources/js/modules/livecomponent/TooltipManager.js
Normal file
388
resources/js/modules/livecomponent/TooltipManager.js
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Tooltip Manager for LiveComponents
|
||||
*
|
||||
* Provides tooltip functionality for LiveComponent elements with:
|
||||
* - Automatic positioning
|
||||
* - Validation error tooltips
|
||||
* - Accessibility support
|
||||
* - Smooth animations
|
||||
* - Multiple positioning strategies
|
||||
*/
|
||||
|
||||
export class TooltipManager {
|
||||
constructor() {
|
||||
this.tooltips = new Map(); // element → tooltip element
|
||||
this.activeTooltip = null;
|
||||
this.config = {
|
||||
delay: 300, // ms before showing tooltip
|
||||
hideDelay: 100, // ms before hiding tooltip
|
||||
maxWidth: 300, // px
|
||||
offset: 8, // px offset from element
|
||||
animationDuration: 200, // ms
|
||||
zIndex: 10000
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize tooltip for element
|
||||
*
|
||||
* @param {HTMLElement} element - Element to attach tooltip to
|
||||
* @param {Object} options - Tooltip options
|
||||
*/
|
||||
init(element, options = {}) {
|
||||
// Get tooltip content from data attributes or options
|
||||
const content = options.content ||
|
||||
element.dataset.tooltip ||
|
||||
element.getAttribute('title') ||
|
||||
element.getAttribute('aria-label') ||
|
||||
'';
|
||||
|
||||
if (!content) {
|
||||
return; // No tooltip content
|
||||
}
|
||||
|
||||
// Remove native title attribute to prevent default tooltip
|
||||
if (element.hasAttribute('title')) {
|
||||
element.dataset.originalTitle = element.getAttribute('title');
|
||||
element.removeAttribute('title');
|
||||
}
|
||||
|
||||
// Get positioning
|
||||
const position = options.position ||
|
||||
element.dataset.tooltipPosition ||
|
||||
'top';
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners(element, content, position, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for tooltip
|
||||
*
|
||||
* @param {HTMLElement} element - Element
|
||||
* @param {string} content - Tooltip content
|
||||
* @param {string} position - Position (top, bottom, left, right)
|
||||
* @param {Object} options - Additional options
|
||||
*/
|
||||
setupEventListeners(element, content, position, options) {
|
||||
let showTimeout;
|
||||
let hideTimeout;
|
||||
|
||||
const showTooltip = () => {
|
||||
clearTimeout(hideTimeout);
|
||||
showTimeout = setTimeout(() => {
|
||||
this.show(element, content, position, options);
|
||||
}, this.config.delay);
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
clearTimeout(showTimeout);
|
||||
hideTimeout = setTimeout(() => {
|
||||
this.hide(element);
|
||||
}, this.config.hideDelay);
|
||||
};
|
||||
|
||||
// Mouse events
|
||||
element.addEventListener('mouseenter', showTooltip);
|
||||
element.addEventListener('mouseleave', hideTooltip);
|
||||
element.addEventListener('focus', showTooltip);
|
||||
element.addEventListener('blur', hideTooltip);
|
||||
|
||||
// Touch events (for mobile)
|
||||
element.addEventListener('touchstart', showTooltip, { passive: true });
|
||||
element.addEventListener('touchend', hideTooltip, { passive: true });
|
||||
|
||||
// Store cleanup function
|
||||
element._tooltipCleanup = () => {
|
||||
clearTimeout(showTimeout);
|
||||
clearTimeout(hideTimeout);
|
||||
element.removeEventListener('mouseenter', showTooltip);
|
||||
element.removeEventListener('mouseleave', hideTooltip);
|
||||
element.removeEventListener('focus', showTooltip);
|
||||
element.removeEventListener('blur', hideTooltip);
|
||||
element.removeEventListener('touchstart', showTooltip);
|
||||
element.removeEventListener('touchend', hideTooltip);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show tooltip
|
||||
*
|
||||
* @param {HTMLElement} element - Element
|
||||
* @param {string} content - Tooltip content
|
||||
* @param {string} position - Position
|
||||
* @param {Object} options - Additional options
|
||||
*/
|
||||
show(element, content, position, options = {}) {
|
||||
// Hide existing tooltip
|
||||
if (this.activeTooltip) {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
// Create tooltip element
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'livecomponent-tooltip';
|
||||
tooltip.setAttribute('role', 'tooltip');
|
||||
tooltip.setAttribute('aria-hidden', 'false');
|
||||
tooltip.textContent = content;
|
||||
|
||||
// Apply styles
|
||||
tooltip.style.cssText = `
|
||||
position: absolute;
|
||||
max-width: ${this.config.maxWidth}px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
z-index: ${this.config.zIndex};
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity ${this.config.animationDuration}ms ease;
|
||||
word-wrap: break-word;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
`;
|
||||
|
||||
// Add to DOM
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
// Position tooltip
|
||||
this.positionTooltip(tooltip, element, position);
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => {
|
||||
tooltip.style.opacity = '1';
|
||||
});
|
||||
|
||||
// Store references
|
||||
this.tooltips.set(element, tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
|
||||
// Set aria-describedby on element
|
||||
const tooltipId = `tooltip-${Date.now()}`;
|
||||
tooltip.id = tooltipId;
|
||||
element.setAttribute('aria-describedby', tooltipId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position tooltip relative to element
|
||||
*
|
||||
* @param {HTMLElement} tooltip - Tooltip element
|
||||
* @param {HTMLElement} element - Target element
|
||||
* @param {string} position - Position (top, bottom, left, right)
|
||||
*/
|
||||
positionTooltip(tooltip, element, position) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
const scrollX = window.scrollX || window.pageXOffset;
|
||||
const scrollY = window.scrollY || window.pageYOffset;
|
||||
const offset = this.config.offset;
|
||||
|
||||
let top, left;
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
top = rect.top + scrollY - tooltipRect.height - offset;
|
||||
left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
|
||||
break;
|
||||
case 'bottom':
|
||||
top = rect.bottom + scrollY + offset;
|
||||
left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
|
||||
break;
|
||||
case 'left':
|
||||
top = rect.top + scrollY + (rect.height / 2) - (tooltipRect.height / 2);
|
||||
left = rect.left + scrollX - tooltipRect.width - offset;
|
||||
break;
|
||||
case 'right':
|
||||
top = rect.top + scrollY + (rect.height / 2) - (tooltipRect.height / 2);
|
||||
left = rect.right + scrollX + offset;
|
||||
break;
|
||||
default:
|
||||
top = rect.top + scrollY - tooltipRect.height - offset;
|
||||
left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
|
||||
}
|
||||
|
||||
// Keep tooltip within viewport
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Horizontal adjustment
|
||||
if (left < scrollX) {
|
||||
left = scrollX + 8;
|
||||
} else if (left + tooltipRect.width > scrollX + viewportWidth) {
|
||||
left = scrollX + viewportWidth - tooltipRect.width - 8;
|
||||
}
|
||||
|
||||
// Vertical adjustment
|
||||
if (top < scrollY) {
|
||||
top = scrollY + 8;
|
||||
} else if (top + tooltipRect.height > scrollY + viewportHeight) {
|
||||
top = scrollY + viewportHeight - tooltipRect.height - 8;
|
||||
}
|
||||
|
||||
tooltip.style.top = `${top}px`;
|
||||
tooltip.style.left = `${left}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide tooltip
|
||||
*
|
||||
* @param {HTMLElement} element - Element (optional, hides active tooltip if not provided)
|
||||
*/
|
||||
hide(element = null) {
|
||||
const tooltip = element ? this.tooltips.get(element) : this.activeTooltip;
|
||||
|
||||
if (!tooltip) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate out
|
||||
tooltip.style.opacity = '0';
|
||||
|
||||
// Remove after animation
|
||||
setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.parentNode.removeChild(tooltip);
|
||||
}
|
||||
|
||||
// Remove aria-describedby
|
||||
if (element) {
|
||||
element.removeAttribute('aria-describedby');
|
||||
}
|
||||
|
||||
// Clean up references
|
||||
if (element) {
|
||||
this.tooltips.delete(element);
|
||||
}
|
||||
if (this.activeTooltip === tooltip) {
|
||||
this.activeTooltip = null;
|
||||
}
|
||||
}, this.config.animationDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show validation error tooltip
|
||||
*
|
||||
* @param {HTMLElement} element - Form element
|
||||
* @param {string} message - Error message
|
||||
*/
|
||||
showValidationError(element, message) {
|
||||
// Remove existing error tooltip
|
||||
this.hideValidationError(element);
|
||||
|
||||
// Show error tooltip with error styling
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'livecomponent-tooltip livecomponent-tooltip--error';
|
||||
tooltip.setAttribute('role', 'alert');
|
||||
tooltip.textContent = message;
|
||||
|
||||
tooltip.style.cssText = `
|
||||
position: absolute;
|
||||
max-width: ${this.config.maxWidth}px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
z-index: ${this.config.zIndex};
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity ${this.config.animationDuration}ms ease;
|
||||
word-wrap: break-word;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
`;
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
this.positionTooltip(tooltip, element, 'top');
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => {
|
||||
tooltip.style.opacity = '1';
|
||||
});
|
||||
|
||||
// Store reference
|
||||
element._validationTooltip = tooltip;
|
||||
|
||||
// Add error class to element
|
||||
element.classList.add('livecomponent-error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide validation error tooltip
|
||||
*
|
||||
* @param {HTMLElement} element - Form element
|
||||
*/
|
||||
hideValidationError(element) {
|
||||
const tooltip = element._validationTooltip;
|
||||
if (!tooltip) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate out
|
||||
tooltip.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.parentNode.removeChild(tooltip);
|
||||
}
|
||||
delete element._validationTooltip;
|
||||
element.classList.remove('livecomponent-error');
|
||||
}, this.config.animationDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all tooltips in component
|
||||
*
|
||||
* @param {HTMLElement} container - Component container
|
||||
*/
|
||||
initComponent(container) {
|
||||
// Find all elements with tooltip attributes
|
||||
const elements = container.querySelectorAll('[data-tooltip], [title], [aria-label]');
|
||||
elements.forEach(element => {
|
||||
this.init(element);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup tooltips for component
|
||||
*
|
||||
* @param {HTMLElement} container - Component container
|
||||
*/
|
||||
cleanupComponent(container) {
|
||||
const elements = container.querySelectorAll('[data-tooltip], [title], [aria-label]');
|
||||
elements.forEach(element => {
|
||||
// Hide tooltip
|
||||
this.hide(element);
|
||||
|
||||
// Cleanup event listeners
|
||||
if (element._tooltipCleanup) {
|
||||
element._tooltipCleanup();
|
||||
delete element._tooltipCleanup;
|
||||
}
|
||||
|
||||
// Restore original title
|
||||
if (element.dataset.originalTitle) {
|
||||
element.setAttribute('title', element.dataset.originalTitle);
|
||||
delete element.dataset.originalTitle;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*
|
||||
* @param {Object} newConfig - New configuration
|
||||
*/
|
||||
updateConfig(newConfig) {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...newConfig
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const tooltipManager = new TooltipManager();
|
||||
|
||||
@@ -20,6 +20,9 @@ import { ComponentFileUploader } from './ComponentFileUploader.js';
|
||||
import { FileUploadWidget } from './FileUploadWidget.js';
|
||||
import { optimisticStateManager } from './OptimisticStateManager.js';
|
||||
import { accessibilityManager } from './AccessibilityManager.js';
|
||||
import { ErrorBoundary } from './ErrorBoundary.js';
|
||||
import { RequestDeduplicator } from './RequestDeduplicator.js';
|
||||
import * as StateSerializer from './StateSerializer.js';
|
||||
|
||||
class LiveComponentManager {
|
||||
constructor() {
|
||||
@@ -42,6 +45,30 @@ class LiveComponentManager {
|
||||
|
||||
// DevTools Integration
|
||||
this.devTools = null; // Will be set by DevTools when available
|
||||
|
||||
// Error Handling
|
||||
this.errorBoundary = new ErrorBoundary(this);
|
||||
|
||||
// Request Deduplication
|
||||
this.requestDeduplicator = new RequestDeduplicator();
|
||||
|
||||
// Shared Configuration
|
||||
this.config = sharedConfig;
|
||||
|
||||
// Tooltip Manager
|
||||
this.tooltipManager = tooltipManager;
|
||||
|
||||
// Action Loading Manager
|
||||
this.actionLoadingManager = actionLoadingManager;
|
||||
|
||||
// UI Helper
|
||||
this.uiHelper = new LiveComponentUIHelper(this);
|
||||
|
||||
// Loading State Manager
|
||||
this.loadingStateManager = new LoadingStateManager(
|
||||
this.actionLoadingManager,
|
||||
optimisticStateManager
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +167,9 @@ class LiveComponentManager {
|
||||
// Setup accessibility features
|
||||
this.setupAccessibility(componentId, element);
|
||||
|
||||
// Initialize tooltips for component
|
||||
this.tooltipManager.initComponent(element);
|
||||
|
||||
console.log(`[LiveComponent] Initialized: ${componentId}`);
|
||||
}
|
||||
|
||||
@@ -282,9 +312,9 @@ class LiveComponentManager {
|
||||
this.setupFileUploadHandlers(config.element);
|
||||
}
|
||||
|
||||
// Update state
|
||||
// Update state using StateSerializer
|
||||
if (state) {
|
||||
config.element.dataset.state = JSON.stringify(state);
|
||||
StateSerializer.setStateOnElement(config.element, state);
|
||||
}
|
||||
|
||||
// Restore focus after update
|
||||
@@ -614,18 +644,46 @@ class LiveComponentManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for pending duplicate request
|
||||
const pendingRequest = this.requestDeduplicator.getPendingRequest(componentId, method, params);
|
||||
if (pendingRequest) {
|
||||
console.log(`[LiveComponent] Deduplicating request: ${componentId}.${method}`);
|
||||
return await pendingRequest;
|
||||
}
|
||||
|
||||
// Check for cached result
|
||||
const cachedResult = this.requestDeduplicator.getCachedResult(componentId, method, params);
|
||||
if (cachedResult) {
|
||||
console.log(`[LiveComponent] Using cached result: ${componentId}.${method}`);
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
let operationId = null;
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Get current state from element
|
||||
const stateJson = config.element.dataset.state || '{}';
|
||||
const stateWrapper = JSON.parse(stateJson);
|
||||
// Show loading state (uses LoadingStateManager for configurable indicators)
|
||||
const loadingConfig = this.loadingStateManager.getConfig(componentId);
|
||||
this.loadingStateManager.showLoading(componentId, config.element, {
|
||||
fragments,
|
||||
type: loadingConfig.type,
|
||||
optimistic: true // Check optimistic UI first
|
||||
});
|
||||
|
||||
// Create request promise
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
// Get current state from element using StateSerializer
|
||||
const stateWrapper = StateSerializer.getStateFromElement(config.element) || {
|
||||
id: componentId,
|
||||
component: '',
|
||||
data: {},
|
||||
version: 1
|
||||
};
|
||||
|
||||
// Extract actual state data from wrapper format
|
||||
// Wrapper format: {id, component, data, version}
|
||||
// Server expects just the data object
|
||||
const state = stateWrapper.data || stateWrapper;
|
||||
const state = stateWrapper.data || {};
|
||||
|
||||
// Apply optimistic update for immediate UI feedback
|
||||
// This updates the UI before server confirmation
|
||||
@@ -741,9 +799,12 @@ class LiveComponentManager {
|
||||
this.setupActionHandlers(config.element);
|
||||
}
|
||||
|
||||
// Update component state
|
||||
// Re-initialize tooltips after DOM update
|
||||
this.tooltipManager.initComponent(config.element);
|
||||
|
||||
// Update component state using StateSerializer
|
||||
if (data.state) {
|
||||
config.element.dataset.state = JSON.stringify(data.state);
|
||||
StateSerializer.setStateOnElement(config.element, data.state);
|
||||
}
|
||||
|
||||
// Handle server events
|
||||
@@ -753,29 +814,50 @@ class LiveComponentManager {
|
||||
|
||||
console.log(`[LiveComponent] Action executed: ${componentId}.${method}`, data);
|
||||
|
||||
// Log successful action to DevTools
|
||||
const endTime = performance.now();
|
||||
this.logActionToDevTools(componentId, method, params, startTime, endTime, true);
|
||||
// Log successful action to DevTools
|
||||
const endTime = performance.now();
|
||||
this.logActionToDevTools(componentId, method, params, startTime, endTime, true);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[LiveComponent] Action failed:`, error);
|
||||
// Cache successful result
|
||||
this.requestDeduplicator.cacheResult(componentId, method, params, data);
|
||||
|
||||
// Log failed action to DevTools
|
||||
const endTime = performance.now();
|
||||
this.logActionToDevTools(componentId, method, params, startTime, endTime, false, error.message);
|
||||
// Hide loading state
|
||||
this.loadingStateManager.hideLoading(componentId);
|
||||
|
||||
// Rollback optimistic update on error
|
||||
if (operationId) {
|
||||
const snapshot = optimisticStateManager.getSnapshot(componentId);
|
||||
if (snapshot) {
|
||||
config.element.dataset.state = JSON.stringify(snapshot);
|
||||
optimisticStateManager.clearPendingOperations(componentId);
|
||||
optimisticStateManager.clearSnapshot(componentId);
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[LiveComponent] Action failed:`, error);
|
||||
|
||||
// Log failed action to DevTools
|
||||
const endTime = performance.now();
|
||||
this.logActionToDevTools(componentId, method, params, startTime, endTime, false, error.message);
|
||||
|
||||
// Rollback optimistic update on error
|
||||
if (operationId) {
|
||||
const snapshot = optimisticStateManager.getSnapshot(componentId);
|
||||
if (snapshot) {
|
||||
StateSerializer.setStateOnElement(config.element, snapshot);
|
||||
optimisticStateManager.clearPendingOperations(componentId);
|
||||
optimisticStateManager.clearSnapshot(componentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.handleError(componentId, error);
|
||||
}
|
||||
// Hide loading state on error
|
||||
this.loadingStateManager.hideLoading(componentId);
|
||||
|
||||
// Handle error via ErrorBoundary
|
||||
await this.errorBoundary.handleError(componentId, method, error, {
|
||||
params,
|
||||
fragments
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
// Register pending request for deduplication
|
||||
return this.requestDeduplicator.registerPendingRequest(componentId, method, params, requestPromise);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1154,9 +1236,9 @@ class LiveComponentManager {
|
||||
this.setupFileUploadHandlers(config.element);
|
||||
}
|
||||
|
||||
// Update component state
|
||||
// Update component state using StateSerializer
|
||||
if (data.state) {
|
||||
config.element.dataset.state = JSON.stringify(data.state);
|
||||
StateSerializer.setStateOnElement(config.element, data.state);
|
||||
}
|
||||
|
||||
// Handle server events
|
||||
@@ -1260,6 +1342,22 @@ class LiveComponentManager {
|
||||
// Cleanup accessibility features
|
||||
this.accessibilityManager.cleanup(componentId);
|
||||
|
||||
// Cleanup error handler
|
||||
this.errorBoundary.clearErrorHandler(componentId);
|
||||
|
||||
// Cleanup request deduplication
|
||||
this.requestDeduplicator.clearComponent(componentId);
|
||||
|
||||
// Cleanup tooltips
|
||||
this.tooltipManager.cleanupComponent(config.element);
|
||||
|
||||
// Hide any active loading states
|
||||
this.loadingStateManager.hideLoading(componentId);
|
||||
this.loadingStateManager.clearConfig(componentId);
|
||||
|
||||
// Cleanup UI components
|
||||
this.uiHelper.cleanup(componentId);
|
||||
|
||||
// Remove from registry
|
||||
this.components.delete(componentId);
|
||||
|
||||
@@ -1413,9 +1511,9 @@ class LiveComponentManager {
|
||||
this.setupActionHandlers(config.element);
|
||||
}
|
||||
|
||||
// Update state
|
||||
// Update state using StateSerializer
|
||||
if (result.state) {
|
||||
config.element.dataset.state = JSON.stringify(result.state);
|
||||
StateSerializer.setStateOnElement(config.element, result.state);
|
||||
}
|
||||
|
||||
// Dispatch events
|
||||
@@ -1584,4 +1682,11 @@ export { ComponentPlayground };
|
||||
export { ComponentFileUploader } from './ComponentFileUploader.js';
|
||||
export { FileUploadWidget } from './FileUploadWidget.js';
|
||||
export { ChunkedUploader } from './ChunkedUploader.js';
|
||||
|
||||
// Export UI integration modules
|
||||
export { tooltipManager } from './TooltipManager.js';
|
||||
export { actionLoadingManager } from './ActionLoadingManager.js';
|
||||
export { LiveComponentUIHelper } from './LiveComponentUIHelper.js';
|
||||
export { LoadingStateManager } from './LoadingStateManager.js';
|
||||
|
||||
export default LiveComponent;
|
||||
|
||||
Reference in New Issue
Block a user