Some checks failed
Deploy Application / deploy (push) Has been cancelled
535 lines
18 KiB
JavaScript
535 lines
18 KiB
JavaScript
/**
|
|
* 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 = `<pre class="docker-logs"><code>${this.escapeHtml(data.data.logs)}</code></pre>`;
|
|
logsContent.dataset.loaded = 'true';
|
|
} else {
|
|
logsContent.innerHTML = '<div class="alert alert-error">Failed to load logs</div>';
|
|
}
|
|
} catch (error) {
|
|
this.resetLoading(element);
|
|
logsContent.innerHTML = `<div class="alert alert-error">Error loading logs: ${this.escapeHtml(error.message)}</div>`;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|