Add semantic token highlighting for 'that' variable and comment file references in VS Code extension Add Phone_Text_Input and Currency_Input components with formatting utilities Implement client widgets, form standardization, and soft delete functionality Add modal scroll lock and update documentation Implement comprehensive modal system with form integration and validation Fix modal component instantiation using jQuery plugin API Implement modal system with responsive sizing, queuing, and validation support Implement form submission with validation, error handling, and loading states Implement country/state selectors with dynamic data loading and Bootstrap styling Revert Rsx::Route() highlighting in Blade/PHP files Target specific PHP scopes for Rsx::Route() highlighting in Blade Expand injection selector for Rsx::Route() highlighting Add custom syntax highlighting for Rsx::Route() and Rsx.Route() calls Update jqhtml packages to v2.2.165 Add bundle path validation for common mistakes (development mode only) Create Ajax_Select_Input widget and Rsx_Reference_Data controller Create Country_Select_Input widget with default country support Initialize Tom Select on Select_Input widgets Add Tom Select bundle for enhanced select dropdowns Implement ISO 3166 geographic data system for country/region selection Implement widget-based form system with disabled state support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
454 lines
17 KiB
JavaScript
Executable File
454 lines
17 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 {
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
if (window.rsxapp && window.rsxapp.ajax_disable_batching) {
|
|
return Ajax._call_direct(controller, action, params);
|
|
}
|
|
|
|
return Ajax._call_batch(controller, action, params);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
});
|
|
}
|
|
|
|
// 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_type = response.error_type || 'unknown_error';
|
|
const reason = response.reason || 'Unknown error occurred';
|
|
const details = response.details || {};
|
|
|
|
// Handle specific error types
|
|
switch (error_type) {
|
|
case 'response_auth_required':
|
|
console.error(
|
|
'The user is no longer authenticated, this is a placeholder for future code which handles this scenario.'
|
|
);
|
|
const auth_error = new Error(reason);
|
|
auth_error.type = 'auth_required';
|
|
auth_error.details = details;
|
|
reject(auth_error);
|
|
break;
|
|
|
|
case 'response_unauthorized':
|
|
console.error(
|
|
'The user is unauthorized to perform this action, this is a placeholder for future code which handles this scenario.'
|
|
);
|
|
const unauth_error = new Error(reason);
|
|
unauth_error.type = 'unauthorized';
|
|
unauth_error.details = details;
|
|
reject(unauth_error);
|
|
break;
|
|
|
|
case 'response_form_error':
|
|
const form_error = new Error(reason);
|
|
form_error.type = 'form_error';
|
|
form_error.details = details;
|
|
reject(form_error);
|
|
break;
|
|
|
|
case 'response_fatal_error':
|
|
const fatal_error = new Error(reason);
|
|
fatal_error.type = 'fatal_error';
|
|
fatal_error.details = details;
|
|
|
|
// Log to server if browser error logging is enabled
|
|
Debugger.log_error({
|
|
message: `Ajax Fatal Error: ${reason}`,
|
|
type: 'ajax_fatal',
|
|
endpoint: url,
|
|
details: details,
|
|
});
|
|
|
|
reject(fatal_error);
|
|
break;
|
|
|
|
default:
|
|
const generic_error = new Error(reason);
|
|
generic_error.type = error_type;
|
|
generic_error.details = details;
|
|
reject(generic_error);
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
error: (xhr, status, error) => {
|
|
const error_message = Ajax._extract_error_message(xhr);
|
|
const network_error = new Error(error_message);
|
|
network_error.type = 'network_error';
|
|
network_error.status = xhr.status;
|
|
network_error.statusText = status;
|
|
|
|
// Log server errors (500+) to the server if browser error logging is enabled
|
|
if (xhr.status >= 500) {
|
|
Debugger.log_error({
|
|
message: `Ajax Server Error ${xhr.status}: ${error_message}`,
|
|
type: 'ajax_server_error',
|
|
endpoint: url,
|
|
status: xhr.status,
|
|
statusText: status,
|
|
});
|
|
}
|
|
|
|
reject(network_error);
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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_type = call_response.error_type || 'unknown_error';
|
|
const reason = call_response.reason || 'Unknown error occurred';
|
|
const details = call_response.details || {};
|
|
|
|
const error = new Error(reason);
|
|
error.type = error_type;
|
|
error.details = details;
|
|
|
|
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.type = 'network_error';
|
|
|
|
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} url - URL in format '/_ajax/Controller_Name/action_name' or '/_/Controller_Name/action_name'
|
|
* @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.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();
|
|
}
|
|
}
|