/** * Generic ActionHandler Module * * Provides reusable event delegation-based action handling with: * - Automatic CSRF token injection * - Loading state management * - Confirmation dialogs * - Toast integration * - URL template processing * - Error handling */ export class ActionHandler { constructor(containerSelector, options = {}) { this.containerSelector = containerSelector; this.container = null; this.options = { csrfTokenSelector: options.csrfTokenSelector || '[data-live-component]', toastHandler: options.toastHandler || null, confirmationHandler: options.confirmationHandler || null, loadingTexts: options.loadingTexts || {}, autoRefresh: options.autoRefresh !== false, refreshHandler: options.refreshHandler || null, ...options }; this.handlers = new Map(); this.init(); } /** * Initialize event delegation */ init() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => this.setupEventDelegation()); } else { this.setupEventDelegation(); } } /** * Setup event delegation on container */ setupEventDelegation() { this.container = document.querySelector(this.containerSelector); if (!this.container) { console.warn(`[ActionHandler] Container not found: ${this.containerSelector}`); return; } this.container.addEventListener('click', (e) => { const button = e.target.closest('[data-action]'); if (!button) return; const action = button.dataset.action; if (action) { e.preventDefault(); this.handleAction(action, button); } }); } /** * Register a named action handler */ registerHandler(name, config) { this.handlers.set(name, config); } /** * Handle action from button click */ async handleAction(action, element) { // Check if handler is registered const handlerName = element.dataset.actionHandler || 'default'; const handler = this.handlers.get(handlerName); // Get action configuration const config = this.getActionConfig(action, element, handler); // If handler provides URL template but config doesn't have URL, use template if (!config.url && handler?.urlTemplate) { config.url = handler.urlTemplate; } if (!config.url) { console.warn(`[ActionHandler] No URL found for action: ${action}`, { element, handlerName, config }); return; } // Show confirmation if required if (config.confirm) { const confirmed = await this.showConfirmation(config.confirm); if (!confirmed) { return; } } // Set loading state const loadingText = config.loadingText || this.options.loadingTexts[action] || 'Processing...'; this.showLoading(element, loadingText); try { // Process URL template first (needed for both window and fetch) let url = this.processUrlTemplate(config.url, element); // Handle special actions (like logs that open in new window) if (config.type === 'window') { // Ensure URL is absolute for window.open (relative URLs don't work reliably) if (!url.startsWith('http://') && !url.startsWith('https://')) { url = `${window.location.origin}${url}`; } window.open(url, config.windowTarget || '_blank'); this.resetLoading(element); return; } // Handle inline expandable content (like logs in table rows) if (config.type === 'inline') { const containerId = element.dataset.actionParamId; const logsRow = document.querySelector(`[data-logs-container="${containerId}"]`); const logsContent = logsRow?.querySelector(`[data-logs-content="${containerId}"]`); if (!logsRow || !logsContent) { console.warn(`[ActionHandler] Logs row not found for container: ${containerId}`); this.resetLoading(element); return; } // Toggle visibility const isVisible = logsRow.style.display !== 'none'; if (isVisible) { // Collapse logsRow.style.display = 'none'; const icon = element.querySelector('.logs-icon'); if (icon) { icon.textContent = '📋'; } element.classList.remove('active'); this.resetLoading(element); return; } // Expand and load logs logsRow.style.display = ''; const icon = element.querySelector('.logs-icon'); if (icon) { icon.textContent = '📖'; } element.classList.add('active'); // Check if already loaded if (logsContent.dataset.loaded === 'true') { this.resetLoading(element); return; } // Ensure URL is absolute if (!url.startsWith('http://') && !url.startsWith('https://')) { url = `${window.location.origin}${url}`; } // Fetch logs try { const response = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json', ...config.headers } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); this.resetLoading(element); if (data.success && data.data?.logs) { // Render logs logsContent.innerHTML = `
${this.escapeHtml(data.data.logs)}
`; logsContent.dataset.loaded = 'true'; } else { logsContent.innerHTML = '
Failed to load logs
'; } } catch (error) { this.resetLoading(element); logsContent.innerHTML = `
Error loading logs: ${this.escapeHtml(error.message)}
`; } return; } // Get CSRF token const { token, formId } = this.getCsrfToken(element); // Make request const response = await fetch(url, { method: config.method || 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token, ...config.headers }, body: JSON.stringify({ _form_id: formId, _token: token, ...config.body }) }); const data = await response.json(); if (data.success !== false) { // Success const successMessage = config.successToast || config.successMessage || (handler?.successMessages?.[action]) || `Action ${action} completed successfully`; this.showToast(successMessage, 'success'); // Auto refresh if enabled if (this.options.autoRefresh && config.autoRefresh !== false) { this.refresh(); } } else { // Error from server const errorMessage = config.errorToast || config.errorMessage || (handler?.errorMessages?.[action]) || data.message || `Failed to ${action}`; this.showToast(errorMessage, 'error'); this.resetLoading(element); } } catch (error) { this.handleError(error, element, config); } } /** * Get action configuration from element attributes and handler */ getActionConfig(action, element, handler) { const config = { action: action, url: element.dataset.actionUrl, handler: element.dataset.actionHandler, method: element.dataset.actionMethod || 'POST', confirm: element.dataset.actionConfirm, loadingText: element.dataset.actionLoadingText, successToast: element.dataset.actionSuccessToast, errorToast: element.dataset.actionErrorToast, type: element.dataset.actionType, windowTarget: element.dataset.actionWindowTarget, autoRefresh: element.dataset.actionAutoRefresh !== 'false', headers: {}, body: {} }; // Merge with handler config if available if (handler) { // Don't override explicit URL, but use template if no URL set if (!config.url && handler.urlTemplate) { config.url = handler.urlTemplate; } if (handler.confirmations?.[action]) { config.confirm = config.confirm || handler.confirmations[action]; } if (handler.loadingTexts?.[action]) { config.loadingText = config.loadingText || handler.loadingTexts[action]; } if (handler.successMessages?.[action]) { config.successToast = config.successToast || handler.successMessages[action]; } if (handler.errorMessages?.[action]) { config.errorToast = config.errorToast || handler.errorMessages[action]; } } // Extract parameters from data-action-param-* attributes for request body Object.keys(element.dataset).forEach(key => { if (key.startsWith('actionParam')) { // Convert camelCase to lowercase (e.g., actionParamId -> id) const paramName = key.replace(/^actionParam/, '').replace(/([A-Z])/g, '-$1').toLowerCase(); config.body[paramName] = element.dataset[key]; } }); return config; } /** * Process URL template with placeholders */ processUrlTemplate(url, element) { if (!url) return url; // Replace {action} placeholder url = url.replace('{action}', element.dataset.action || ''); // Replace {id} placeholder - check multiple sources if (url.includes('{id}')) { // Try data-action-param-id (camelCase from dataset) const id = element.dataset.actionParamId || // Try data-container-id (for backward compatibility) element.dataset.containerId || // Try data-id element.dataset.id || // Try data-action-id element.dataset.actionId || // Try data-action-param-id as kebab-case attribute element.getAttribute('data-action-param-id'); if (id) { url = url.replace(/\{id\}/g, id); } else { console.warn(`[ActionHandler] Missing ID for URL template: ${url}`, element); } } // Replace {param:name} placeholders url = url.replace(/\{param:(\w+)\}/g, (match, paramName) => { // Try camelCase (dataset converts kebab-case to camelCase) const camelCase = `actionParam${paramName.charAt(0).toUpperCase() + paramName.slice(1)}`; const value = element.dataset[camelCase] || element.dataset[`actionParam${paramName}`] || element.dataset[paramName] || // Try kebab-case attribute element.getAttribute(`data-action-param-${paramName}`); return value || match; }); // Replace {data:name} placeholders url = url.replace(/\{data:(\w+)\}/g, (match, attrName) => { // Try camelCase from dataset const camelCase = attrName.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); return element.dataset[camelCase] || element.dataset[attrName] || element.getAttribute(`data-${attrName}`) || match; }); return url; } /** * Get CSRF token from various sources */ getCsrfToken(element) { // Try element itself if (element.dataset.csrfToken) { return { token: element.dataset.csrfToken, formId: this.getFormId(element) }; } // Try closest LiveComponent const componentElement = element.closest(this.options.csrfTokenSelector); if (componentElement?.dataset.csrfToken) { return { token: componentElement.dataset.csrfToken, formId: componentElement.dataset.liveComponent ? `livecomponent:${componentElement.dataset.liveComponent}` : this.getFormId(element) }; } // Try container if (this.container?.dataset.csrfToken) { return { token: this.container.dataset.csrfToken, formId: this.getFormId(element) }; } // Try meta tag const metaToken = document.querySelector('meta[name="csrf-token"]'); if (metaToken) { return { token: metaToken.content, formId: this.getFormId(element) }; } // Fallback return { token: '', formId: this.getFormId(element) }; } /** * Generate form ID for CSRF validation */ getFormId(element) { // If LiveComponent available, use component ID const componentElement = element.closest('[data-live-component]'); if (componentElement?.dataset.liveComponent) { return `livecomponent:${componentElement.dataset.liveComponent}`; } // Generate from URL const url = element.dataset.actionUrl; if (url) { // Extract path from URL const path = url.split('?')[0]; return `action:${path}`; } // Fallback return `action:${element.dataset.action}`; } /** * Show loading state on button */ showLoading(element, text) { element.dataset.originalText = element.textContent; element.disabled = true; element.textContent = text; element.style.opacity = '0.6'; element.style.cursor = 'not-allowed'; element.classList.add('action-loading'); } /** * Reset loading state */ resetLoading(element) { element.disabled = false; element.textContent = element.dataset.originalText || element.textContent; element.style.opacity = '1'; element.style.cursor = 'pointer'; element.classList.remove('action-loading'); } /** * Show confirmation dialog */ async showConfirmation(message) { if (this.options.confirmationHandler) { return await this.options.confirmationHandler(message); } return confirm(message); } /** * Show toast notification */ showToast(message, type = 'info') { if (this.options.toastHandler) { this.options.toastHandler(message, type); return; } // Try LiveComponentUIHelper if (window.LiveComponentUIHelper) { const componentElement = document.querySelector(this.options.csrfTokenSelector); const componentId = componentElement?.dataset.liveComponent || 'global'; window.LiveComponentUIHelper.showNotification(componentId, { message: message, type: type, duration: 5000 }); return; } // Try global showToast if (typeof window.showToast === 'function') { window.showToast(message, type); return; } // Fallback: Simple alert if (type === 'error') { alert(`Error: ${message}`); } else { console.log(`[${type.toUpperCase()}] ${message}`); } } /** * Refresh page or component */ refresh() { if (this.options.refreshHandler) { this.options.refreshHandler(); return; } // Try LiveComponent refresh const componentElement = document.querySelector(this.options.csrfTokenSelector); if (componentElement?.dataset.liveComponent) { const componentId = componentElement.dataset.liveComponent; if (window.LiveComponent && typeof window.LiveComponent.refresh === 'function') { window.LiveComponent.refresh(componentId); return; } } // Fallback: Reload page setTimeout(() => window.location.reload(), 1000); } /** * Handle errors */ handleError(error, element, config) { console.error('[ActionHandler] Error:', error); const errorMessage = config.errorToast || config.errorMessage || `Error: ${error.message}`; this.showToast(errorMessage, 'error'); this.resetLoading(element); } /** * Escape HTML to prevent XSS */ escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } }