fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled
Some checks failed
Deploy Application / deploy (push) Has been cancelled
This commit is contained in:
534
resources/js/modules/common/ActionHandler.js
Normal file
534
resources/js/modules/common/ActionHandler.js
Normal file
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user