Files
rspade_system/app/RSpade/Core/Js/Ajax.js
root 78553d4edf Fix code quality violations for publish
Remove unused blade settings pages not linked from UI
Convert remaining frontend pages to SPA actions
Convert settings user_settings and general to SPA actions
Convert settings profile pages to SPA actions
Convert contacts and projects add/edit pages to SPA actions
Convert clients add/edit page to SPA action with loading pattern
Refactor component scoped IDs from $id to $sid
Fix jqhtml comment syntax and implement universal error component system
Update all application code to use new unified error system
Remove all backwards compatibility - unified error system complete
Phase 5: Remove old response classes
Phase 3-4: Ajax response handler sends new format, old helpers deprecated
Phase 2: Add client-side unified error foundation
Phase 1: Add server-side unified error foundation
Add unified Ajax error response system with constants

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 04:35:01 +00:00

502 lines
19 KiB
JavaScript
Executable File

// @FILE-SUBCLASS-01-EXCEPTION
/**
* Client-side Ajax class for making API calls to RSX controllers
*
* Automatically batches multiple calls into single HTTP requests to reduce network overhead.
* Batches up to 20 calls or flushes after setTimeout(0) debounce.
*/
class Ajax {
// Error code constants (must match server-side Ajax::ERROR_* constants)
static ERROR_VALIDATION = 'validation';
static ERROR_NOT_FOUND = 'not_found';
static ERROR_UNAUTHORIZED = 'unauthorized';
static ERROR_AUTH_REQUIRED = 'auth_required';
static ERROR_FATAL = 'fatal';
static ERROR_GENERIC = 'generic';
static ERROR_SERVER = 'server_error'; // Client-generated (HTTP 500)
static ERROR_NETWORK = 'network_error'; // Client-generated (connection failed)
/**
* Initialize Ajax system
* Called automatically when class is loaded
*/
static _on_framework_core_init() {
// Queue of pending calls waiting to be batched
Ajax._pending_calls = {};
// Timer for batching flush
Ajax._flush_timeout = null;
// Call counter for generating unique call IDs
Ajax._call_counter = 0;
// Maximum batch size before forcing immediate flush
Ajax.MAX_BATCH_SIZE = 20;
// Debounce time in milliseconds
Ajax.DEBOUNCE_MS = 0;
// Track promises from Ajax calls to detect uncaught rejections
Ajax._tracked_promises = new WeakSet();
// Set up global unhandled rejection handler for Ajax errors
window.addEventListener('unhandledrejection', async (event) => {
// Only handle rejections from Ajax promises
if (Ajax._tracked_promises.has(event.promise)) {
event.preventDefault(); // Prevent browser's default "Uncaught (in promise)" error
const error = event.reason;
console.error('Uncaught Ajax error:', error);
// Show Modal.error() for uncaught Ajax errors
if (typeof Modal !== 'undefined' && Modal.error) {
await Modal.error(error, 'Uncaught Ajax Error');
}
}
});
}
/**
* Make an AJAX call to an RSX controller action
*
* All calls are automatically batched unless window.rsxapp.ajax_disable_batching is true.
*
* @param {string|object|function} url - The Ajax URL (e.g., '/_ajax/Controller_Name/action_name') or an object/function with a .path property
* @param {object} params - Parameters to send to the action
* @returns {Promise} - Resolves with the return value, rejects with error
*/
static async call(url, params = {}) {
// If url is an object or function with a .path property, use that as the URL
if (url && typeof url === 'object' && url.path) {
url = url.path;
} else if (url && typeof url === 'function' && url.path) {
url = url.path;
}
// Validate url is a non-empty string
if (typeof url !== 'string' || url.length === 0) {
throw new Error('Ajax.call() requires a non-empty string URL or an object/function with a .path property');
}
// Extract controller and action from URL
const { controller, action } = Ajax.ajax_url_to_controller_action(url);
console.log('Ajax:', controller, action, params);
// Check if batching is disabled for debugging
let promise;
if (window.rsxapp && window.rsxapp.ajax_disable_batching) {
promise = Ajax._call_direct(controller, action, params);
} else {
promise = Ajax._call_batch(controller, action, params);
}
// Track this promise for unhandled rejection detection
Ajax._tracked_promises.add(promise);
return promise;
}
/**
* Make a batched Ajax call
* @private
*/
static _call_batch(controller, action, params = {}) {
console.log('Ajax Batch:', controller, action, params);
return new Promise((resolve, reject) => {
// Generate call key for deduplication
const call_key = Ajax._generate_call_key(controller, action, params);
// Check if this exact call is already pending
if (Ajax._pending_calls[call_key]) {
const existing_call = Ajax._pending_calls[call_key];
// If call already completed (cached), return immediately
if (existing_call.is_complete) {
if (existing_call.is_error) {
reject(existing_call.error);
} else {
resolve(existing_call.result);
}
return;
}
// Call is pending, add this promise to callbacks
existing_call.callbacks.push({ resolve, reject });
return;
}
// Create new pending call
const call_id = Ajax._call_counter++;
const pending_call = {
call_id: call_id,
call_key: call_key,
controller: controller,
action: action,
params: params,
callbacks: [{ resolve, reject }],
is_complete: false,
is_error: false,
result: null,
error: null,
};
// Add to pending queue
Ajax._pending_calls[call_key] = pending_call;
// Count pending calls
const pending_count = Object.keys(Ajax._pending_calls).filter((key) => !Ajax._pending_calls[key].is_complete).length;
// If we've hit the batch size limit, flush immediately
if (pending_count >= Ajax.MAX_BATCH_SIZE) {
clearTimeout(Ajax._flush_timeout);
Ajax._flush_timeout = null;
Ajax._flush_pending_calls();
} else {
// Schedule batch flush with debounce
clearTimeout(Ajax._flush_timeout);
Ajax._flush_timeout = setTimeout(() => {
Ajax._flush_pending_calls();
}, Ajax.DEBOUNCE_MS);
}
});
}
/**
* Make a direct (non-batched) Ajax call
* @private
*/
static async _call_direct(controller, action, params = {}) {
// Construct URL from controller and action
const url = `/_ajax/${controller}/${action}`;
// Log the AJAX call using console_debug
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
Debugger.console_debug('AJAX', `Calling ${controller}.${action} (unbatched)`, params);
}
return new Promise((resolve, reject) => {
$.ajax({
url: url,
method: 'POST',
data: params,
dataType: 'json',
__local_integration: true, // Bypass $.ajax override
success: (response) => {
// Handle console_debug messages
if (response.console_debug && Array.isArray(response.console_debug)) {
response.console_debug.forEach((msg) => {
if (!Array.isArray(msg) || msg.length !== 2) {
throw new Error('Invalid console_debug message format - expected [channel, [arguments]]');
}
const [channel, args] = msg;
console.log(channel, ...args);
});
}
// Handle flash_alerts from server
if (response.flash_alerts && Array.isArray(response.flash_alerts)) {
Server_Side_Flash.process(response.flash_alerts);
}
// Check if the response was successful
if (response._success === true) {
// @JS-AJAX-02-EXCEPTION - Unwrap server responses with _ajax_return_value
const processed_value = Rsx_Js_Model._instantiate_models_recursive(response._ajax_return_value);
resolve(processed_value);
} else {
// Handle error responses
const error_code = response.error_code || Ajax.ERROR_GENERIC;
const reason = response.reason || 'An error occurred';
const metadata = response.metadata || {};
// Create error object
const error = new Error(reason);
error.code = error_code;
error.metadata = metadata;
// Handle fatal errors specially
if (error_code === Ajax.ERROR_FATAL) {
const fatal_error_data = response.error || {};
error.message = fatal_error_data.error || 'Fatal error occurred';
error.metadata = response.error;
console.error('Ajax error response from server:', response.error);
// Log to server
Debugger.log_error({
message: `Ajax Fatal Error: ${error.message}`,
type: 'ajax_fatal',
endpoint: url,
details: response.error,
});
}
// Log auth errors for debugging
if (error_code === Ajax.ERROR_AUTH_REQUIRED) {
console.error('User is no longer authenticated');
}
if (error_code === Ajax.ERROR_UNAUTHORIZED) {
console.error('User is unauthorized to perform this action');
}
reject(error);
}
},
error: (xhr, status, error) => {
const err = new Error();
// Determine error code based on status
if (xhr.status >= 500) {
// Server error (PHP crashed)
err.code = Ajax.ERROR_SERVER;
err.message = 'A server error occurred. Please try again.';
} else if (xhr.status === 0 || status === 'timeout' || status === 'error') {
// Network error (connection failed)
err.code = Ajax.ERROR_NETWORK;
err.message = 'Could not connect to server. Please check your connection.';
} else {
// Generic error
err.code = Ajax.ERROR_GENERIC;
err.message = Ajax._extract_error_message(xhr);
}
err.metadata = {
status: xhr.status,
statusText: status
};
// Log server errors to server
if (xhr.status >= 500) {
Debugger.log_error({
message: `Ajax Server Error ${xhr.status}: ${err.message}`,
type: 'ajax_server_error',
endpoint: url,
status: xhr.status,
statusText: status,
});
}
reject(err);
},
});
});
}
/**
* Flush all pending calls by sending batch request
* @private
*/
static async _flush_pending_calls() {
// Collect all pending calls
const calls_to_send = [];
const call_map = {}; // Map call_id to pending_call object
for (const call_key in Ajax._pending_calls) {
const pending_call = Ajax._pending_calls[call_key];
if (!pending_call.is_complete) {
calls_to_send.push({
call_id: pending_call.call_id,
controller: pending_call.controller,
action: pending_call.action,
params: pending_call.params,
});
call_map[pending_call.call_id] = pending_call;
}
}
// Nothing to send
if (calls_to_send.length === 0) {
return;
}
// Log batch for debugging
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
Debugger.console_debug(
'AJAX_BATCH',
`Sending batch of ${calls_to_send.length} calls`,
calls_to_send.map((c) => `${c.controller}.${c.action}`)
);
}
try {
// Send batch request
const response = await $.ajax({
url: '/_ajax/_batch',
method: 'POST',
data: { batch_calls: JSON.stringify(calls_to_send) },
dataType: 'json',
__local_integration: true, // Bypass $.ajax override
});
// Process batch response
// Response format: { C_0: {success, _ajax_return_value}, C_1: {...}, ... }
for (const response_key in response) {
if (!response_key.startsWith('C_')) {
continue;
}
const call_id = parseInt(response_key.substring(2), 10);
const call_response = response[response_key];
const pending_call = call_map[call_id];
if (!pending_call) {
console.error('Received response for unknown call_id:', call_id);
continue;
}
// Handle console_debug messages if present
if (call_response.console_debug && Array.isArray(call_response.console_debug)) {
call_response.console_debug.forEach((msg) => {
if (!Array.isArray(msg) || msg.length !== 2) {
throw new Error('Invalid console_debug message format - expected [channel, [arguments]]');
}
const [channel, args] = msg;
console.log(channel, ...args);
});
}
// Mark call as complete
pending_call.is_complete = true;
// Check if successful
if (call_response._success === true) {
// @JS-AJAX-02-EXCEPTION - Batch system unwraps server responses with _ajax_return_value
const processed_value = Rsx_Js_Model._instantiate_models_recursive(call_response._ajax_return_value);
pending_call.result = processed_value;
// Resolve all callbacks
pending_call.callbacks.forEach(({ resolve }) => {
resolve(processed_value);
});
} else {
// Handle error
const error_code = call_response.error_code || Ajax.ERROR_GENERIC;
const error_message = call_response.reason || 'Unknown error occurred';
const metadata = call_response.metadata || {};
const error = new Error(error_message);
error.code = error_code;
error.metadata = metadata;
pending_call.is_error = true;
pending_call.error = error;
// Reject all callbacks
pending_call.callbacks.forEach(({ reject }) => {
reject(error);
});
}
}
} catch (xhr_error) {
// Network or server error - reject all pending calls
const error_message = Ajax._extract_error_message(xhr_error);
const error = new Error(error_message);
error.code = Ajax.ERROR_NETWORK;
error.metadata = {};
for (const call_id in call_map) {
const pending_call = call_map[call_id];
pending_call.is_complete = true;
pending_call.is_error = true;
pending_call.error = error;
pending_call.callbacks.forEach(({ reject }) => {
reject(error);
});
}
console.error('Batch Ajax request failed:', error_message);
}
}
/**
* Generate a unique key for deduplicating calls
* @private
*/
static _generate_call_key(controller, action, params) {
// Create a stable string representation of the call
// Sort params keys for consistent hashing
const sorted_params = {};
Object.keys(params)
.sort()
.forEach((key) => {
sorted_params[key] = params[key];
});
return `${controller}::${action}::${JSON.stringify(sorted_params)}`;
}
/**
* Extract error message from jQuery XHR object
* @private
*/
static _extract_error_message(xhr) {
if (xhr.responseJSON && xhr.responseJSON.message) {
return xhr.responseJSON.message;
} else if (xhr.responseText) {
try {
const response = JSON.parse(xhr.responseText);
if (response.message) {
return response.message;
}
} catch (e) {
// Not JSON
}
}
return `${xhr.status}: ${xhr.statusText || 'Unknown error'}`;
}
/**
* Parses an AJAX URL into controller and action
* Supports both /_ajax/ and /_/ URL prefixes
* @param {string|object|function} url - URL in format '/_ajax/Controller_Name/action_name' or '/_/Controller_Name/action_name', or an object/function with a .path property
* @returns {Object} Object with {controller: string, action: string}
* @throws {Error} If URL doesn't start with /_ajax or /_ or has invalid structure
*/
static ajax_url_to_controller_action(url) {
// If url is an object or function with a .path property, use that as the URL
if (url && typeof url === 'object' && url.path) {
url = url.path;
} else if (url && typeof url === 'function' && url.path) {
url = url.path;
}
// Validate url is a string
if (typeof url !== 'string') {
throw new Error(`URL must be a string or have a .path property, got: ${typeof url}`);
}
if (!url.startsWith('/_ajax') && !url.startsWith('/_/')) {
throw new Error(`URL must start with /_ajax or /_, got: ${url}`);
}
const parts = url.split('/').filter((part) => part !== '');
if (parts.length < 2) {
throw new Error(`Invalid AJAX URL structure: ${url}`);
}
if (parts.length > 3) {
throw new Error(`AJAX URL has too many segments: ${url}`);
}
const controller = parts[1];
const action = parts[2] || 'index';
return { controller, action };
}
/**
* Auto-initialize static properties when class is first loaded
*/
static on_core_define() {
Ajax._on_framework_core_init();
}
}