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:
@@ -23,6 +23,16 @@ import { accessibilityManager } from './AccessibilityManager.js';
|
||||
import { ErrorBoundary } from './ErrorBoundary.js';
|
||||
import { RequestDeduplicator } from './RequestDeduplicator.js';
|
||||
import * as StateSerializer from './StateSerializer.js';
|
||||
import { UIEventHandler } from './UIEventHandler.js';
|
||||
import { triggerManager } from './TriggerManager.js';
|
||||
import { sharedConfig } from './SharedConfig.js';
|
||||
import { tooltipManager } from './TooltipManager.js';
|
||||
import { actionLoadingManager } from './ActionLoadingManager.js';
|
||||
import { LiveComponentUIHelper } from './LiveComponentUIHelper.js';
|
||||
import { LoadingStateManager } from './LoadingStateManager.js';
|
||||
import { ProgressiveEnhancement } from './ProgressiveEnhancement.js';
|
||||
import { urlManager } from './UrlManager.js';
|
||||
import { LiveComponentCoreAttributes, LiveComponentFeatureAttributes, LiveComponentLazyAttributes, toDatasetKey } from '../../core/DataAttributes.js';
|
||||
|
||||
class LiveComponentManager {
|
||||
constructor() {
|
||||
@@ -64,11 +74,21 @@ class LiveComponentManager {
|
||||
// UI Helper
|
||||
this.uiHelper = new LiveComponentUIHelper(this);
|
||||
|
||||
// UI Event Handler
|
||||
this.uiEventHandler = new UIEventHandler(this);
|
||||
this.uiEventHandler.init();
|
||||
|
||||
// Loading State Manager
|
||||
this.loadingStateManager = new LoadingStateManager(
|
||||
this.actionLoadingManager,
|
||||
optimisticStateManager
|
||||
);
|
||||
|
||||
// Progressive Enhancement
|
||||
this.progressiveEnhancement = new ProgressiveEnhancement(this);
|
||||
|
||||
// URL Manager
|
||||
urlManager.init();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,17 +152,24 @@ class LiveComponentManager {
|
||||
|
||||
/**
|
||||
* Initialize LiveComponent
|
||||
*
|
||||
* @param {HTMLElement} element - Component element
|
||||
* @param {Object} options - Initialization options
|
||||
* @param {boolean} options.isolated - If true, component is isolated (Island) and won't receive parent events
|
||||
*/
|
||||
init(element) {
|
||||
const componentId = element.dataset.liveComponent;
|
||||
init(element, options = {}) {
|
||||
const componentId = element.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_COMPONENT)];
|
||||
if (!componentId) return;
|
||||
|
||||
const isIsland = options.isolated === true || element.dataset[toDatasetKey(LiveComponentLazyAttributes.ISLAND_COMPONENT)] === 'true';
|
||||
|
||||
const config = {
|
||||
id: componentId,
|
||||
element,
|
||||
pollInterval: parseInt(element.dataset.pollInterval) || null,
|
||||
pollInterval: parseInt(element.dataset[toDatasetKey(LiveComponentCoreAttributes.POLL_INTERVAL)]) || null,
|
||||
pollTimer: null,
|
||||
observer: null
|
||||
observer: null,
|
||||
isolated: isIsland
|
||||
};
|
||||
|
||||
this.components.set(componentId, config);
|
||||
@@ -162,7 +189,8 @@ class LiveComponentManager {
|
||||
this.setupLifecycleObserver(element, componentId);
|
||||
|
||||
// Setup SSE connection if component has a channel
|
||||
this.setupSseConnection(element, componentId);
|
||||
// Islands have isolated SSE channels (no parent events)
|
||||
this.setupSseConnection(element, componentId, { isolated: isIsland });
|
||||
|
||||
// Setup accessibility features
|
||||
this.setupAccessibility(componentId, element);
|
||||
@@ -170,7 +198,12 @@ class LiveComponentManager {
|
||||
// Initialize tooltips for component
|
||||
this.tooltipManager.initComponent(element);
|
||||
|
||||
console.log(`[LiveComponent] Initialized: ${componentId}`);
|
||||
// Initialize progressive enhancement if not already done
|
||||
if (!this.progressiveEnhancement.initialized) {
|
||||
this.progressiveEnhancement.init();
|
||||
}
|
||||
|
||||
console.log(`[LiveComponent] Initialized: ${componentId}${isIsland ? ' (Island)' : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,7 +211,7 @@ class LiveComponentManager {
|
||||
*/
|
||||
setupAccessibility(componentId, element) {
|
||||
// Create component-specific ARIA live region
|
||||
const politeness = element.dataset.livePolite === 'assertive' ? 'assertive' : 'polite';
|
||||
const politeness = element.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_POLITE)] === 'assertive' ? 'assertive' : 'polite';
|
||||
this.accessibilityManager.createComponentLiveRegion(componentId, element, politeness);
|
||||
|
||||
console.log(`[LiveComponent] Accessibility features enabled for ${componentId}`);
|
||||
@@ -217,9 +250,9 @@ class LiveComponentManager {
|
||||
* Setup SSE connection for component
|
||||
* Automatically connects to SSE if component has data-sse-channel attribute
|
||||
*/
|
||||
setupSseConnection(element, componentId) {
|
||||
setupSseConnection(element, componentId, options = {}) {
|
||||
// Check if component has SSE channel
|
||||
const sseChannel = element.dataset.sseChannel;
|
||||
const sseChannel = element.dataset[toDatasetKey(LiveComponentCoreAttributes.SSE_CHANNEL)];
|
||||
if (!sseChannel) {
|
||||
return; // No SSE channel, skip
|
||||
}
|
||||
@@ -433,14 +466,112 @@ class LiveComponentManager {
|
||||
* Setup action click handlers
|
||||
*/
|
||||
setupActionHandlers(element) {
|
||||
element.querySelectorAll('[data-live-action]').forEach(actionEl => {
|
||||
// Handle form submissions
|
||||
if (actionEl.tagName === 'FORM') {
|
||||
element.querySelectorAll(`[${LiveComponentCoreAttributes.LIVE_ACTION}]`).forEach(actionEl => {
|
||||
// Find the component ID: check if action element has explicit component ID,
|
||||
// otherwise find closest parent component
|
||||
let componentId = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_COMPONENT)];
|
||||
|
||||
if (!componentId) {
|
||||
// Find the innermost (most nested) component element
|
||||
// We need to collect all ancestor elements with data-live-component
|
||||
// and use the first one (which is the innermost/closest one)
|
||||
let current = actionEl.parentElement;
|
||||
const componentElements = [];
|
||||
|
||||
// Collect all ancestor elements with data-live-component
|
||||
while (current && current !== document.body) {
|
||||
if (current.hasAttribute && current.hasAttribute(LiveComponentCoreAttributes.LIVE_COMPONENT)) {
|
||||
componentElements.push(current);
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
// Use the first element (innermost/closest component)
|
||||
if (componentElements.length > 0) {
|
||||
componentId = componentElements[0].dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_COMPONENT)];
|
||||
} else {
|
||||
// Fallback to closest() if manual traversal didn't work
|
||||
const fallbackElement = actionEl.closest(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`);
|
||||
if (fallbackElement) {
|
||||
componentId = fallbackElement.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_COMPONENT)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!componentId) {
|
||||
console.warn('[LiveComponent] No component found for action:', actionEl, actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug logging to help diagnose issues
|
||||
if (import.meta.env.DEV && actionEl.dataset.liveAction === 'togglePreview') {
|
||||
const closestComp = actionEl.closest('[data-live-component]');
|
||||
let current = actionEl.parentElement;
|
||||
const allComponents = [];
|
||||
while (current && current !== document.body) {
|
||||
if (current.hasAttribute && current.hasAttribute(LiveComponentCoreAttributes.LIVE_COMPONENT)) {
|
||||
allComponents.push(current.dataset.liveComponent);
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
console.log('[LiveComponent] Action setup:', {
|
||||
action: actionEl.dataset.liveAction,
|
||||
componentId: componentId,
|
||||
closestComponentId: closestComp?.dataset.liveComponent,
|
||||
allAncestorComponents: allComponents,
|
||||
actionElement: actionEl,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if advanced trigger options are used
|
||||
const hasAdvancedTriggers = actionEl.dataset[toDatasetKey(LiveComponentFeatureAttributes.LC_TRIGGER_DELAY)] ||
|
||||
actionEl.dataset[toDatasetKey(LiveComponentFeatureAttributes.LC_TRIGGER_THROTTLE)] ||
|
||||
actionEl.dataset[toDatasetKey(LiveComponentFeatureAttributes.LC_TRIGGER_ONCE)] === 'true' ||
|
||||
actionEl.dataset[toDatasetKey(LiveComponentFeatureAttributes.LC_TRIGGER_CHANGED)] === 'true' ||
|
||||
actionEl.dataset[toDatasetKey(LiveComponentFeatureAttributes.LC_TRIGGER_FROM)] ||
|
||||
actionEl.dataset[toDatasetKey(LiveComponentFeatureAttributes.LC_TRIGGER_LOAD)] === 'true';
|
||||
|
||||
// Create action handler function
|
||||
const createActionHandler = async (e, paramsOverride = {}) => {
|
||||
const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)];
|
||||
let params = { ...this.extractParams(actionEl), ...paramsOverride };
|
||||
|
||||
// For submit/navigation actions, collect all form values
|
||||
if (action === 'submit' || action === 'nextStep' || action === 'previousStep') {
|
||||
const formValues = this.collectFormValues(element);
|
||||
params = { ...formValues, ...params };
|
||||
}
|
||||
|
||||
// Extract fragments for partial rendering
|
||||
const fragments = this.extractFragments(actionEl);
|
||||
|
||||
await this.executeAction(componentId, action, params, fragments, actionEl);
|
||||
};
|
||||
|
||||
// Use TriggerManager if advanced triggers are present
|
||||
if (hasAdvancedTriggers) {
|
||||
triggerManager.setupTrigger(actionEl, componentId, actionEl.dataset.liveAction, async (e) => {
|
||||
if (actionEl.tagName === 'FORM' && e) {
|
||||
e.preventDefault();
|
||||
const formValues = {};
|
||||
const formData = new FormData(actionEl);
|
||||
for (const [key, value] of formData.entries()) {
|
||||
formValues[key] = value;
|
||||
}
|
||||
await createActionHandler(e, formValues);
|
||||
} else {
|
||||
if (e) e.preventDefault();
|
||||
await createActionHandler(e);
|
||||
}
|
||||
}, actionEl.tagName === 'FORM' ? 'submit' : 'click');
|
||||
// Skip default handler setup - TriggerManager handles it
|
||||
} else {
|
||||
// Handle form submissions (default behavior)
|
||||
if (actionEl.tagName === 'FORM') {
|
||||
actionEl.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const componentId = element.dataset.liveComponent;
|
||||
const action = actionEl.dataset.liveAction;
|
||||
const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)];
|
||||
|
||||
// Collect all form values
|
||||
const formValues = {};
|
||||
@@ -452,44 +583,77 @@ class LiveComponentManager {
|
||||
// Extract fragments for partial rendering
|
||||
const fragments = this.extractFragments(actionEl);
|
||||
|
||||
await this.executeAction(componentId, action, formValues, fragments);
|
||||
await this.executeAction(componentId, action, formValues, fragments, actionEl);
|
||||
});
|
||||
}
|
||||
// Handle input elements with debouncing
|
||||
else if (actionEl.tagName === 'INPUT' || actionEl.tagName === 'TEXTAREA' || actionEl.tagName === 'SELECT') {
|
||||
}
|
||||
// Handle input elements with debouncing
|
||||
else if (actionEl.tagName === 'INPUT' || actionEl.tagName === 'TEXTAREA' || actionEl.tagName === 'SELECT') {
|
||||
// Default debounce for text inputs: 300ms, others: 0ms
|
||||
const defaultDebounce = (actionEl.type === 'text' || actionEl.type === 'email' || actionEl.type === 'url' || actionEl.type === 'tel' || actionEl.tagName === 'TEXTAREA') ? 300 : 0;
|
||||
const debounceMs = parseInt(actionEl.dataset.liveDebounce) ?? defaultDebounce;
|
||||
|
||||
actionEl.addEventListener('input', async (e) => {
|
||||
const componentId = element.dataset.liveComponent;
|
||||
const action = actionEl.dataset.liveAction;
|
||||
const params = this.extractParams(actionEl);
|
||||
// For SELECT elements, use 'change' event (SELECT doesn't fire 'input' event)
|
||||
if (actionEl.tagName === 'SELECT') {
|
||||
actionEl.addEventListener('change', async (e) => {
|
||||
const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)];
|
||||
const params = this.extractParams(actionEl);
|
||||
|
||||
// Add input value to params
|
||||
if (actionEl.type === 'checkbox') {
|
||||
params[actionEl.name || 'value'] = actionEl.checked ? 'yes' : 'no';
|
||||
} else if (actionEl.type === 'radio') {
|
||||
params[actionEl.name || 'value'] = actionEl.value;
|
||||
} else {
|
||||
params[actionEl.name || 'value'] = actionEl.value;
|
||||
}
|
||||
// Add select value to params
|
||||
// Convert snake_case name to camelCase for PHP method parameter binding
|
||||
const name = actionEl.name || 'content_type_id';
|
||||
const camelCaseName = name.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
|
||||
// Use camelCase for parameter name to match PHP method signature
|
||||
params[camelCaseName] = actionEl.value;
|
||||
|
||||
// Also keep original name for backwards compatibility
|
||||
if (name !== camelCaseName) {
|
||||
params[name] = actionEl.value;
|
||||
}
|
||||
|
||||
// Extract fragments for partial rendering
|
||||
const fragments = this.extractFragments(actionEl);
|
||||
console.log('[LiveComponent] SELECT change:', {
|
||||
action,
|
||||
name,
|
||||
camelCaseName,
|
||||
value: actionEl.value,
|
||||
params
|
||||
});
|
||||
|
||||
if (debounceMs > 0) {
|
||||
this.debouncedAction(componentId, action, params, debounceMs, fragments);
|
||||
} else {
|
||||
await this.executeAction(componentId, action, params, fragments);
|
||||
}
|
||||
});
|
||||
// Extract fragments for partial rendering
|
||||
const fragments = this.extractFragments(actionEl);
|
||||
|
||||
await this.executeAction(componentId, action, params, fragments, actionEl);
|
||||
});
|
||||
} else {
|
||||
// For INPUT and TEXTAREA, use 'input' event
|
||||
actionEl.addEventListener('input', async (e) => {
|
||||
const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)];
|
||||
const params = this.extractParams(actionEl);
|
||||
|
||||
// Add input value to params
|
||||
if (actionEl.type === 'checkbox') {
|
||||
params[actionEl.name || 'value'] = actionEl.checked ? 'yes' : 'no';
|
||||
} else if (actionEl.type === 'radio') {
|
||||
params[actionEl.name || 'value'] = actionEl.value;
|
||||
} else {
|
||||
params[actionEl.name || 'value'] = actionEl.value;
|
||||
}
|
||||
|
||||
// Extract fragments for partial rendering
|
||||
const fragments = this.extractFragments(actionEl);
|
||||
|
||||
if (debounceMs > 0) {
|
||||
this.debouncedAction(componentId, action, params, debounceMs, fragments, actionEl);
|
||||
} else {
|
||||
await this.executeAction(componentId, action, params, fragments, actionEl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// For radio buttons and checkboxes, also listen to 'change' event
|
||||
if (actionEl.type === 'radio' || actionEl.type === 'checkbox') {
|
||||
actionEl.addEventListener('change', async (e) => {
|
||||
const componentId = element.dataset.liveComponent;
|
||||
const action = actionEl.dataset.liveAction;
|
||||
const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)];
|
||||
const params = this.extractParams(actionEl);
|
||||
|
||||
if (actionEl.type === 'checkbox') {
|
||||
@@ -498,31 +662,31 @@ class LiveComponentManager {
|
||||
params[actionEl.name || 'value'] = actionEl.value;
|
||||
}
|
||||
|
||||
await this.executeAction(componentId, action, params);
|
||||
await this.executeAction(componentId, action, params, null, actionEl);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Handle button clicks
|
||||
actionEl.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// Handle button clicks (default behavior)
|
||||
actionEl.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const componentId = element.dataset.liveComponent;
|
||||
const action = actionEl.dataset.liveAction;
|
||||
let params = this.extractParams(actionEl);
|
||||
const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)];
|
||||
let params = this.extractParams(actionEl);
|
||||
|
||||
// For submit/navigation actions, collect all form values
|
||||
if (action === 'submit' || action === 'nextStep' || action === 'previousStep') {
|
||||
const formValues = this.collectFormValues(element);
|
||||
console.log('[LiveComponent] Collected form values:', formValues);
|
||||
params = { ...formValues, ...params };
|
||||
console.log('[LiveComponent] Final params:', params);
|
||||
}
|
||||
// For submit/navigation actions, collect all form values
|
||||
if (action === 'submit' || action === 'nextStep' || action === 'previousStep') {
|
||||
const formValues = this.collectFormValues(element);
|
||||
console.log('[LiveComponent] Collected form values:', formValues);
|
||||
params = { ...formValues, ...params };
|
||||
console.log('[LiveComponent] Final params:', params);
|
||||
}
|
||||
|
||||
// Extract fragments for partial rendering
|
||||
const fragments = this.extractFragments(actionEl);
|
||||
// Extract fragments for partial rendering
|
||||
const fragments = this.extractFragments(actionEl);
|
||||
|
||||
await this.executeAction(componentId, action, params, fragments);
|
||||
});
|
||||
await this.executeAction(componentId, action, params, fragments, actionEl);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -530,7 +694,7 @@ class LiveComponentManager {
|
||||
/**
|
||||
* Debounced action execution
|
||||
*/
|
||||
debouncedAction(componentId, action, params, delay, fragments = null) {
|
||||
debouncedAction(componentId, action, params, delay, fragments = null, actionElement = null) {
|
||||
const timerKey = `${componentId}_${action}`;
|
||||
|
||||
// Clear existing timer
|
||||
@@ -540,7 +704,7 @@ class LiveComponentManager {
|
||||
|
||||
// Set new timer
|
||||
const timer = setTimeout(async () => {
|
||||
await this.executeAction(componentId, action, params, fragments);
|
||||
await this.executeAction(componentId, action, params, fragments, actionElement);
|
||||
this.debounceTimers.delete(timerKey);
|
||||
}, delay);
|
||||
|
||||
@@ -636,8 +800,9 @@ class LiveComponentManager {
|
||||
* @param {string} method - Action method name
|
||||
* @param {Object} params - Action parameters
|
||||
* @param {Array<string>} fragments - Optional fragment names for partial rendering
|
||||
* @param {HTMLElement} actionElement - Optional element that triggered the action (for target/swap support)
|
||||
*/
|
||||
async executeAction(componentId, method, params = {}, fragments = null) {
|
||||
async executeAction(componentId, method, params = {}, fragments = null, actionElement = null) {
|
||||
const config = this.components.get(componentId);
|
||||
if (!config) {
|
||||
console.error(`[LiveComponent] Unknown component: ${componentId}`);
|
||||
@@ -661,19 +826,39 @@ class LiveComponentManager {
|
||||
let operationId = null;
|
||||
const startTime = performance.now();
|
||||
|
||||
// Find the correct component element (may differ from config.element for nested components)
|
||||
let componentElement = config.element;
|
||||
if (componentElement.dataset.liveComponent !== componentId) {
|
||||
// If config.element doesn't match, find the correct element
|
||||
componentElement = document.querySelector(`[data-live-component="${componentId}"]`);
|
||||
if (!componentElement) {
|
||||
console.error(`[LiveComponent] Component element not found: ${componentId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state (uses LoadingStateManager for configurable indicators)
|
||||
const loadingConfig = this.loadingStateManager.getConfig(componentId);
|
||||
this.loadingStateManager.showLoading(componentId, config.element, {
|
||||
this.loadingStateManager.showLoading(componentId, componentElement, {
|
||||
fragments,
|
||||
type: loadingConfig.type,
|
||||
optimistic: true // Check optimistic UI first
|
||||
});
|
||||
}, actionElement);
|
||||
|
||||
// Create request promise
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
if (componentElement.dataset.liveComponent !== componentId) {
|
||||
// If config.element doesn't match, find the correct element
|
||||
componentElement = document.querySelector(`[data-live-component="${componentId}"]`);
|
||||
if (!componentElement) {
|
||||
console.error(`[LiveComponent] Component element not found: ${componentId}`);
|
||||
throw new Error(`Component element ${componentId} not found in DOM`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current state from element using StateSerializer
|
||||
const stateWrapper = StateSerializer.getStateFromElement(config.element) || {
|
||||
const stateWrapper = StateSerializer.getStateFromElement(componentElement) || {
|
||||
id: componentId,
|
||||
component: '',
|
||||
data: {},
|
||||
@@ -683,6 +868,45 @@ class LiveComponentManager {
|
||||
// Extract actual state data from wrapper format
|
||||
// Wrapper format: {id, component, data, version}
|
||||
// Server expects just the data object
|
||||
|
||||
// Check if there are pending operations - if so, use the optimistic version
|
||||
const pendingOps = optimisticStateManager.getPendingOperations(componentId);
|
||||
|
||||
// CRITICAL: Always read version from stateWrapper first
|
||||
let currentVersion = 1;
|
||||
if (stateWrapper && typeof stateWrapper.version === 'number') {
|
||||
currentVersion = stateWrapper.version;
|
||||
}
|
||||
|
||||
// If there are pending operations, use the version from the latest optimistic state
|
||||
if (pendingOps.length > 0) {
|
||||
const latestOp = pendingOps[pendingOps.length - 1];
|
||||
if (latestOp.optimisticState && typeof latestOp.optimisticState.version === 'number') {
|
||||
currentVersion = latestOp.optimisticState.version;
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL DEBUG: Always log, even in production
|
||||
console.error('[LiveComponent] VERSION DEBUG START', {
|
||||
stateWrapperVersion: stateWrapper?.version,
|
||||
stateWrapperVersionType: typeof stateWrapper?.version,
|
||||
currentVersion,
|
||||
pendingOpsCount: pendingOps.length,
|
||||
hasStateWrapper: !!stateWrapper,
|
||||
stateWrapperKeys: stateWrapper ? Object.keys(stateWrapper) : []
|
||||
});
|
||||
|
||||
// If there are pending operations, use the version from the latest optimistic state
|
||||
// This ensures we always send the most recent version, even with optimistic updates
|
||||
if (pendingOps.length > 0) {
|
||||
// Get the latest optimistic state version
|
||||
// The optimistic state has version = currentVersion + 1, so we use that
|
||||
const latestOp = pendingOps[pendingOps.length - 1];
|
||||
if (latestOp.optimisticState && latestOp.optimisticState.version) {
|
||||
currentVersion = latestOp.optimisticState.version;
|
||||
}
|
||||
}
|
||||
|
||||
const state = stateWrapper.data || {};
|
||||
|
||||
// Apply optimistic update for immediate UI feedback
|
||||
@@ -706,36 +930,123 @@ class LiveComponentManager {
|
||||
);
|
||||
|
||||
operationId = optimisticStateManager.getPendingOperations(componentId)[0]?.id;
|
||||
|
||||
// Debug logging for version management
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[LiveComponent] Executing ${method} for ${componentId}`, {
|
||||
currentVersion,
|
||||
pendingOpsCount: pendingOps.length,
|
||||
operationId
|
||||
});
|
||||
}
|
||||
|
||||
// Get component-specific CSRF token from element
|
||||
const csrfToken = config.element.dataset.csrfToken;
|
||||
// Get component-specific CSRF token from the component element
|
||||
const csrfToken = componentElement.dataset.csrfToken;
|
||||
if (!csrfToken) {
|
||||
console.error(`[LiveComponent] Missing CSRF token for component: ${componentId}`);
|
||||
console.error('[LiveComponent] Component element:', componentElement);
|
||||
console.error('[LiveComponent] Component element dataset:', componentElement?.dataset);
|
||||
console.error('[LiveComponent] Component element HTML:', componentElement?.outerHTML?.substring(0, 500));
|
||||
throw new Error('CSRF token is required for component actions');
|
||||
}
|
||||
|
||||
// Debug logging for CSRF token
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[LiveComponent] CSRF token for ${componentId}:`, csrfToken?.substring(0, 20) + '...');
|
||||
}
|
||||
|
||||
// Build request body
|
||||
const requestBody = {
|
||||
method,
|
||||
params,
|
||||
state,
|
||||
_csrf_token: csrfToken
|
||||
};
|
||||
|
||||
// CRITICAL: Always ensure version is a valid number
|
||||
// Read version from stateWrapper first, then check pending ops
|
||||
let versionToSend = 1;
|
||||
|
||||
// Try to get version from stateWrapper
|
||||
if (stateWrapper && typeof stateWrapper.version === 'number') {
|
||||
versionToSend = stateWrapper.version;
|
||||
}
|
||||
|
||||
// If there are pending operations, use the version from the latest optimistic state
|
||||
if (pendingOps.length > 0) {
|
||||
const latestOp = pendingOps[pendingOps.length - 1];
|
||||
if (latestOp.optimisticState && typeof latestOp.optimisticState.version === 'number') {
|
||||
versionToSend = latestOp.optimisticState.version;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure versionToSend is always a valid number
|
||||
if (typeof versionToSend !== 'number' || isNaN(versionToSend)) {
|
||||
versionToSend = 1;
|
||||
}
|
||||
|
||||
// CRITICAL DEBUG: Always log, even in production
|
||||
console.error('[LiveComponent] VERSION DEBUG', {
|
||||
stateWrapperVersion: stateWrapper?.version,
|
||||
currentVersion,
|
||||
versionToSend,
|
||||
pendingOpsCount: pendingOps.length,
|
||||
hasStateWrapper: !!stateWrapper
|
||||
});
|
||||
|
||||
// CRITICAL: Build request body with version ALWAYS included as explicit property
|
||||
// Use object literal with explicit version to ensure it's never undefined
|
||||
const requestBody = {};
|
||||
requestBody.method = method;
|
||||
requestBody.params = params;
|
||||
requestBody.state = state;
|
||||
requestBody.version = versionToSend; // CRITICAL: Always send version (never undefined)
|
||||
requestBody._csrf_token = csrfToken;
|
||||
|
||||
// Add fragments parameter if specified
|
||||
if (fragments && Array.isArray(fragments) && fragments.length > 0) {
|
||||
requestBody.fragments = fragments;
|
||||
}
|
||||
|
||||
// CRITICAL: Double-check version is still present after adding fragments
|
||||
if (typeof requestBody.version !== 'number' || isNaN(requestBody.version)) {
|
||||
requestBody.version = versionToSend || 1;
|
||||
}
|
||||
|
||||
// Send AJAX request with CSRF token in body
|
||||
// Component ID is in the URL, not in the body
|
||||
|
||||
// CRITICAL DEBUG: Log the exact request body before sending
|
||||
const requestBodyString = JSON.stringify(requestBody);
|
||||
|
||||
// CRITICAL: Verify version is in JSON string - if not, force add it
|
||||
let finalRequestBody = requestBodyString;
|
||||
if (!requestBodyString.includes('"version"')) {
|
||||
console.error('[LiveComponent] ERROR: Version missing in JSON! Forcing version...', {
|
||||
jsonPreview: requestBodyString.substring(0, 300),
|
||||
versionToSend,
|
||||
requestBodyKeys: Object.keys(requestBody)
|
||||
});
|
||||
// Parse and re-add version
|
||||
try {
|
||||
const parsed = JSON.parse(requestBodyString);
|
||||
parsed.version = versionToSend || 1;
|
||||
finalRequestBody = JSON.stringify(parsed);
|
||||
console.error('[LiveComponent] Corrected JSON preview:', finalRequestBody.substring(0, 300));
|
||||
} catch (e) {
|
||||
console.error('[LiveComponent] Failed to parse/correct JSON:', e);
|
||||
// Last resort: manually add version to JSON string
|
||||
const jsonObj = JSON.parse(requestBodyString);
|
||||
jsonObj.version = versionToSend || 1;
|
||||
finalRequestBody = JSON.stringify(jsonObj);
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Final verification
|
||||
if (!finalRequestBody.includes('"version"')) {
|
||||
console.error('[LiveComponent] CRITICAL ERROR: Version still missing after correction!');
|
||||
}
|
||||
|
||||
const response = await fetch(`/live-component/${componentId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
body: finalRequestBody
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -758,7 +1069,6 @@ class LiveComponentManager {
|
||||
|
||||
// Check for version conflict
|
||||
const serverState = data.state || {};
|
||||
const currentVersion = stateWrapper.version || 1;
|
||||
const serverVersion = serverState.version || 1;
|
||||
|
||||
if (serverVersion !== currentVersion + 1) {
|
||||
@@ -789,18 +1099,54 @@ class LiveComponentManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve target element (supports data-lc-target)
|
||||
const targetElement = this.resolveTarget(actionElement, componentId);
|
||||
if (!targetElement) {
|
||||
throw new Error(`[LiveComponent] Could not resolve target element for component: ${componentId}`);
|
||||
}
|
||||
|
||||
// Get swap strategy from action element or use default
|
||||
const swapStrategy = actionElement?.dataset.lcSwap || 'innerHTML';
|
||||
|
||||
// Handle fragment-based response (partial rendering)
|
||||
if (data.fragments) {
|
||||
// For fragments, always use the component element as container
|
||||
this.updateFragments(config.element, data.fragments);
|
||||
}
|
||||
// Handle full HTML response (fallback or no fragments requested)
|
||||
else if (data.html) {
|
||||
config.element.innerHTML = data.html;
|
||||
this.setupActionHandlers(config.element);
|
||||
// Use swap strategy if specified, otherwise default to innerHTML
|
||||
if (swapStrategy === 'innerHTML') {
|
||||
// Default behavior - maintain backwards compatibility
|
||||
targetElement.innerHTML = data.html;
|
||||
} else {
|
||||
// Use swapElement for other strategies with transitions and scroll
|
||||
const success = this.domPatcher.swapElement(targetElement, data.html, swapStrategy, transition, scrollOptions);
|
||||
if (!success) {
|
||||
console.warn(`[LiveComponent] Swap failed, falling back to innerHTML`);
|
||||
targetElement.innerHTML = data.html;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-setup action handlers on the updated element
|
||||
// For outerHTML swap, targetElement might be replaced, so we need to find it again
|
||||
if (swapStrategy === 'outerHTML') {
|
||||
// Find the new element if it was replaced
|
||||
const newElement = document.querySelector(`[data-live-component="${componentId}"]`);
|
||||
if (newElement) {
|
||||
this.setupActionHandlers(newElement);
|
||||
} else {
|
||||
// Fallback: setup handlers on parent or document
|
||||
this.setupActionHandlers(targetElement.parentElement || document.body);
|
||||
}
|
||||
} else {
|
||||
this.setupActionHandlers(targetElement);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-initialize tooltips after DOM update
|
||||
this.tooltipManager.initComponent(config.element);
|
||||
// Use targetElement for tooltips (might differ from config.element if target was used)
|
||||
this.tooltipManager.initComponent(targetElement);
|
||||
|
||||
// Update component state using StateSerializer
|
||||
if (data.state) {
|
||||
@@ -812,6 +1158,16 @@ class LiveComponentManager {
|
||||
this.handleServerEvents(data.events);
|
||||
}
|
||||
|
||||
// Handle URL updates (data-lc-push-url or data-lc-replace-url)
|
||||
const pushUrl = actionElement?.dataset.lcPushUrl;
|
||||
const replaceUrl = actionElement?.dataset.lcReplaceUrl;
|
||||
|
||||
if (pushUrl) {
|
||||
urlManager.pushUrl(pushUrl, data.title);
|
||||
} else if (replaceUrl) {
|
||||
urlManager.replaceUrl(replaceUrl, data.title);
|
||||
}
|
||||
|
||||
console.log(`[LiveComponent] Action executed: ${componentId}.${method}`, data);
|
||||
|
||||
// Log successful action to DevTools
|
||||
@@ -822,7 +1178,7 @@ class LiveComponentManager {
|
||||
this.requestDeduplicator.cacheResult(componentId, method, params, data);
|
||||
|
||||
// Hide loading state
|
||||
this.loadingStateManager.hideLoading(componentId);
|
||||
this.loadingStateManager.hideLoading(componentId, actionElement);
|
||||
|
||||
return data;
|
||||
|
||||
@@ -844,7 +1200,7 @@ class LiveComponentManager {
|
||||
}
|
||||
|
||||
// Hide loading state on error
|
||||
this.loadingStateManager.hideLoading(componentId);
|
||||
this.loadingStateManager.hideLoading(componentId, null);
|
||||
|
||||
// Handle error via ErrorBoundary
|
||||
await this.errorBoundary.handleError(componentId, method, error, {
|
||||
@@ -860,6 +1216,49 @@ class LiveComponentManager {
|
||||
return this.requestDeduplicator.registerPendingRequest(componentId, method, params, requestPromise);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve target element for action updates
|
||||
*
|
||||
* Supports data-lc-target attribute to specify where updates should be applied.
|
||||
* Falls back to component element if no target is specified or target not found.
|
||||
*
|
||||
* @param {HTMLElement} actionElement - Element that triggered the action
|
||||
* @param {string} componentId - Component ID
|
||||
* @returns {HTMLElement|null} - Target element or null if not found
|
||||
*/
|
||||
resolveTarget(actionElement, componentId) {
|
||||
const targetSelector = actionElement?.dataset.lcTarget;
|
||||
|
||||
// If no target specified, use component element
|
||||
if (!targetSelector) {
|
||||
const config = this.components.get(componentId);
|
||||
return config?.element || null;
|
||||
}
|
||||
|
||||
// Try to find target element
|
||||
let targetElement = null;
|
||||
|
||||
// First try: querySelector from document
|
||||
targetElement = document.querySelector(targetSelector);
|
||||
|
||||
// Second try: querySelector from action element's closest component
|
||||
if (!targetElement && actionElement) {
|
||||
const componentElement = actionElement.closest(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`);
|
||||
if (componentElement) {
|
||||
targetElement = componentElement.querySelector(targetSelector);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use component element if target not found
|
||||
if (!targetElement) {
|
||||
console.warn(`[LiveComponent] Target element not found: ${targetSelector}, falling back to component element`);
|
||||
const config = this.components.get(componentId);
|
||||
return config?.element || null;
|
||||
}
|
||||
|
||||
return targetElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific fragments using DOM patching
|
||||
*
|
||||
@@ -922,12 +1321,15 @@ class LiveComponentManager {
|
||||
// Log event to DevTools
|
||||
this.logEventToDevTools(name, { payload, target }, 'server');
|
||||
|
||||
// Convert payload to object if it's an array (from EventPayload)
|
||||
const eventPayload = Array.isArray(payload) ? payload : (payload || {});
|
||||
|
||||
if (target) {
|
||||
// Targeted event - only for specific component
|
||||
this.dispatchToComponent(target, name, payload);
|
||||
this.dispatchToComponent(target, name, eventPayload);
|
||||
} else {
|
||||
// Broadcast event - to all listening components
|
||||
this.broadcast(name, payload);
|
||||
this.broadcast(name, eventPayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -943,6 +1345,37 @@ class LiveComponentManager {
|
||||
handler.callback(payload);
|
||||
}
|
||||
});
|
||||
|
||||
// Also dispatch as custom DOM event
|
||||
document.dispatchEvent(new CustomEvent(`livecomponent:${eventName}`, {
|
||||
detail: payload
|
||||
}));
|
||||
|
||||
// Dispatch direct event for UI events
|
||||
if (this.isUIEvent(eventName)) {
|
||||
document.dispatchEvent(new CustomEvent(eventName, {
|
||||
detail: payload
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch component event (used for batch operations)
|
||||
* @param {string} componentId - Component ID
|
||||
* @param {string} eventName - Event name
|
||||
* @param {Object} payload - Event payload
|
||||
*/
|
||||
dispatchComponentEvent(componentId, eventName, payload) {
|
||||
// Convert payload to object if needed
|
||||
const eventPayload = Array.isArray(payload) ? payload : (payload || {});
|
||||
|
||||
// Dispatch to component-specific handlers
|
||||
this.dispatchToComponent(componentId, eventName, eventPayload);
|
||||
|
||||
// Also broadcast if no target specified (for UI events)
|
||||
if (this.isUIEvent(eventName)) {
|
||||
this.broadcast(eventName, eventPayload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -955,10 +1388,28 @@ class LiveComponentManager {
|
||||
handler.callback(payload);
|
||||
});
|
||||
|
||||
// Also dispatch as custom DOM event
|
||||
// Dispatch as custom DOM event with livecomponent: prefix for compatibility
|
||||
document.dispatchEvent(new CustomEvent(`livecomponent:${eventName}`, {
|
||||
detail: payload
|
||||
}));
|
||||
|
||||
// Also dispatch direct event (without prefix) for UI events
|
||||
// This allows UIEventHandler to listen to toast:show, modal:show, etc.
|
||||
if (this.isUIEvent(eventName)) {
|
||||
document.dispatchEvent(new CustomEvent(eventName, {
|
||||
detail: payload
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if event name is a UI event
|
||||
* @param {string} eventName - Event name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isUIEvent(eventName) {
|
||||
const uiEventPrefixes = ['toast:', 'modal:'];
|
||||
return uiEventPrefixes.some(prefix => eventName.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1352,7 +1803,7 @@ class LiveComponentManager {
|
||||
this.tooltipManager.cleanupComponent(config.element);
|
||||
|
||||
// Hide any active loading states
|
||||
this.loadingStateManager.hideLoading(componentId);
|
||||
this.loadingStateManager.hideLoading(componentId, null);
|
||||
this.loadingStateManager.clearConfig(componentId);
|
||||
|
||||
// Cleanup UI components
|
||||
@@ -1599,7 +2050,7 @@ window.LiveComponent = LiveComponent;
|
||||
// Auto-initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('[data-live-component]').forEach(el => {
|
||||
document.querySelectorAll(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`).forEach(el => {
|
||||
LiveComponent.init(el);
|
||||
});
|
||||
});
|
||||
@@ -1629,7 +2080,7 @@ export async function init(config = {}, state = {}) {
|
||||
|
||||
// Initialize all regular LiveComponents in the page
|
||||
// This is needed when loaded as a core module (not via data-module attributes)
|
||||
const components = document.querySelectorAll('[data-live-component]');
|
||||
const components = document.querySelectorAll(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`);
|
||||
console.log(`[LiveComponent] Found ${components.length} regular components to initialize`);
|
||||
|
||||
components.forEach(el => {
|
||||
@@ -1637,7 +2088,7 @@ export async function init(config = {}, state = {}) {
|
||||
});
|
||||
|
||||
// Lazy components are automatically initialized by LazyComponentLoader
|
||||
const lazyComponents = document.querySelectorAll('[data-live-component-lazy]');
|
||||
const lazyComponents = document.querySelectorAll(`[${LiveComponentLazyAttributes.LIVE_COMPONENT_LAZY}]`);
|
||||
console.log(`[LiveComponent] Found ${lazyComponents.length} lazy components (will load on demand)`);
|
||||
|
||||
// Log nested component stats
|
||||
@@ -1663,7 +2114,7 @@ export function initElement(element, options = {}) {
|
||||
console.log('[LiveComponent] Initializing element with data-module:', element);
|
||||
|
||||
// Find all LiveComponents within this element (or the element itself)
|
||||
const components = element.querySelectorAll('[data-live-component]');
|
||||
const components = element.querySelectorAll(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`);
|
||||
|
||||
if (components.length === 0 && element.hasAttribute('data-live-component')) {
|
||||
// Element itself is a LiveComponent
|
||||
@@ -1687,6 +2138,7 @@ export { ChunkedUploader } from './ChunkedUploader.js';
|
||||
export { tooltipManager } from './TooltipManager.js';
|
||||
export { actionLoadingManager } from './ActionLoadingManager.js';
|
||||
export { LiveComponentUIHelper } from './LiveComponentUIHelper.js';
|
||||
export { UIEventHandler } from './UIEventHandler.js';
|
||||
export { LoadingStateManager } from './LoadingStateManager.js';
|
||||
|
||||
export default LiveComponent;
|
||||
|
||||
Reference in New Issue
Block a user