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>
This commit is contained in:
@@ -180,11 +180,11 @@ class Component_Create_Command extends Command
|
||||
$display_name = str_replace('_Component', '', $class_name);
|
||||
|
||||
return <<<JQHTML
|
||||
<!--
|
||||
<%--
|
||||
{$class_name}
|
||||
|
||||
\$button_text="Click Me" - Text shown on the button
|
||||
-->
|
||||
--%>
|
||||
<Define:{$class_name} style="border: 1px solid black;">
|
||||
<h4>{$display_name}</h4>
|
||||
<div \$id="hello_world" style="font-weight: bold; display: none;">
|
||||
|
||||
@@ -29,11 +29,42 @@ use App\RSpade\Core\Response\Rsx_Response_Abstract;
|
||||
*/
|
||||
class Ajax
|
||||
{
|
||||
// Error code constants
|
||||
const ERROR_VALIDATION = 'validation';
|
||||
const ERROR_NOT_FOUND = 'not_found';
|
||||
const ERROR_UNAUTHORIZED = 'unauthorized';
|
||||
const ERROR_AUTH_REQUIRED = 'auth_required';
|
||||
const ERROR_FATAL = 'fatal';
|
||||
const ERROR_GENERIC = 'generic';
|
||||
const ERROR_SERVER = 'server_error'; // Client-generated (HTTP 500)
|
||||
const ERROR_NETWORK = 'network_error'; // Client-generated (connection failed)
|
||||
|
||||
/**
|
||||
* Flag to indicate AJAX response mode for error handlers
|
||||
*/
|
||||
protected static bool $ajax_response_mode = false;
|
||||
|
||||
/**
|
||||
* Get default message for error code
|
||||
*
|
||||
* @param string $error_code One of the ERROR_* constants
|
||||
* @return string Default user-friendly message
|
||||
*/
|
||||
public static function get_default_message(string $error_code): string
|
||||
{
|
||||
return match ($error_code) {
|
||||
self::ERROR_VALIDATION => 'Please correct the errors below',
|
||||
self::ERROR_NOT_FOUND => 'The requested record was not found',
|
||||
self::ERROR_UNAUTHORIZED => 'You do not have permission to perform this action',
|
||||
self::ERROR_AUTH_REQUIRED => 'Please log in to continue',
|
||||
self::ERROR_FATAL => 'A fatal error has occurred',
|
||||
self::ERROR_SERVER => 'A server error occurred. Please try again.',
|
||||
self::ERROR_NETWORK => 'Could not connect to server. Please check your connection.',
|
||||
self::ERROR_GENERIC => 'An error has occurred',
|
||||
default => 'An error has occurred',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an internal API method directly from PHP code
|
||||
*
|
||||
@@ -157,19 +188,26 @@ class Ajax
|
||||
$details = $response->get_details();
|
||||
|
||||
switch ($type) {
|
||||
case 'response_auth_required':
|
||||
case self::ERROR_AUTH_REQUIRED:
|
||||
throw new AjaxAuthRequiredException($reason);
|
||||
case 'response_unauthorized':
|
||||
|
||||
case self::ERROR_UNAUTHORIZED:
|
||||
throw new AjaxUnauthorizedException($reason);
|
||||
case 'response_form_error':
|
||||
|
||||
case self::ERROR_VALIDATION:
|
||||
case self::ERROR_NOT_FOUND:
|
||||
throw new AjaxFormErrorException($reason, $details);
|
||||
case 'fatal':
|
||||
|
||||
case self::ERROR_FATAL:
|
||||
$message = $reason;
|
||||
if (!empty($details)) {
|
||||
$message .= ' - ' . json_encode($details);
|
||||
}
|
||||
|
||||
throw new AjaxFatalErrorException($message);
|
||||
|
||||
case self::ERROR_GENERIC:
|
||||
throw new Exception($reason);
|
||||
|
||||
default:
|
||||
throw new Exception("Unknown RSX response type: {$type}");
|
||||
}
|
||||
@@ -324,18 +362,14 @@ class Ajax
|
||||
throw new Exception($message);
|
||||
}
|
||||
|
||||
// Build error response based on type
|
||||
// Build error response
|
||||
$json_response = [
|
||||
'_success' => false,
|
||||
'error_type' => $type,
|
||||
'error_code' => $response->get_error_code(),
|
||||
'reason' => $response->get_reason(),
|
||||
'metadata' => $response->get_metadata(),
|
||||
];
|
||||
|
||||
// Add details for form errors
|
||||
if ($type === 'response_form_error') {
|
||||
$json_response['details'] = $response->get_details();
|
||||
}
|
||||
|
||||
// Add console debug messages if any
|
||||
$console_messages = \App\RSpade\Core\Debug\Debugger::_get_console_messages();
|
||||
if (!empty($console_messages)) {
|
||||
@@ -395,7 +429,7 @@ class Ajax
|
||||
if ((array_key_exists('_success', $response) && is_bool($response['_success'])) ||
|
||||
(array_key_exists('success', $response) && is_bool($response['success']))) {
|
||||
$wrong_way = "return ['_success' => false, 'message' => 'Error'];";
|
||||
$right_way_validation = "return response_form_error('Validation failed', ['email' => 'Invalid']);";
|
||||
$right_way_validation = "return response_error(Ajax::ERROR_VALIDATION, ['email' => 'Invalid email']);";
|
||||
$right_way_success = "return ['user_id' => 123, 'data' => [...]];\n// Framework wraps: {_success: true, _ajax_return_value: {...}}";
|
||||
$right_way_exception = "// Let exceptions bubble - framework handles them\n\$user->save(); // Don't wrap in try/catch";
|
||||
|
||||
|
||||
@@ -101,30 +101,33 @@ class Ajax_Batch_Controller extends Rsx_Controller_Abstract
|
||||
} catch (Exceptions\AjaxAuthRequiredException $e) {
|
||||
$responses["C_{$call_id}"] = [
|
||||
'success' => false,
|
||||
'error_type' => 'response_auth_required',
|
||||
'error_code' => Ajax::ERROR_AUTH_REQUIRED,
|
||||
'reason' => $e->getMessage(),
|
||||
'metadata' => [],
|
||||
];
|
||||
|
||||
} catch (Exceptions\AjaxUnauthorizedException $e) {
|
||||
$responses["C_{$call_id}"] = [
|
||||
'success' => false,
|
||||
'error_type' => 'response_unauthorized',
|
||||
'error_code' => Ajax::ERROR_UNAUTHORIZED,
|
||||
'reason' => $e->getMessage(),
|
||||
'metadata' => [],
|
||||
];
|
||||
|
||||
} catch (Exceptions\AjaxFormErrorException $e) {
|
||||
$responses["C_{$call_id}"] = [
|
||||
'success' => false,
|
||||
'error_type' => 'response_form_error',
|
||||
'error_code' => Ajax::ERROR_VALIDATION,
|
||||
'reason' => $e->getMessage(),
|
||||
'details' => $e->get_details(),
|
||||
'metadata' => $e->get_details(),
|
||||
];
|
||||
|
||||
} catch (Exceptions\AjaxFatalErrorException $e) {
|
||||
$responses["C_{$call_id}"] = [
|
||||
'success' => false,
|
||||
'error_type' => 'fatal',
|
||||
'error_code' => Ajax::ERROR_FATAL,
|
||||
'reason' => $e->getMessage(),
|
||||
'metadata' => [],
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -2176,16 +2176,20 @@ JS;
|
||||
foreach ($file_info['public_static_methods'] ?? [] as $method_name => $method_data) {
|
||||
foreach ($method_data['attributes'] ?? [] as $attr_name => $attr_instances) {
|
||||
if ($attr_name === 'Route') {
|
||||
// Get the first route pattern (index 0 is the first argument)
|
||||
// Collect all route patterns for this method (supports multiple #[Route] attributes)
|
||||
foreach ($attr_instances as $instance) {
|
||||
$route_pattern = $instance[0] ?? null;
|
||||
if ($route_pattern) {
|
||||
console_debug('BUNDLE', " Found route: {$class_name}::{$method_name} => {$route_pattern}");
|
||||
// Store route info
|
||||
// Initialize arrays if needed
|
||||
if (!isset($routes[$class_name])) {
|
||||
$routes[$class_name] = [];
|
||||
}
|
||||
$routes[$class_name][$method_name] = $route_pattern;
|
||||
if (!isset($routes[$class_name][$method_name])) {
|
||||
$routes[$class_name][$method_name] = [];
|
||||
}
|
||||
// Append route pattern to array
|
||||
$routes[$class_name][$method_name][] = $route_pattern;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2231,15 +2235,19 @@ JS;
|
||||
foreach ($file_info['public_static_methods'] ?? [] as $method_name => $method_data) {
|
||||
foreach ($method_data['attributes'] ?? [] as $attr_name => $attr_instances) {
|
||||
if ($attr_name === 'Route') {
|
||||
// Get the first route pattern (index 0 is the first argument)
|
||||
// Collect all route patterns for this method (supports multiple #[Route] attributes)
|
||||
foreach ($attr_instances as $instance) {
|
||||
$route_pattern = $instance[0] ?? null;
|
||||
if ($route_pattern) {
|
||||
// Store route info
|
||||
// Initialize arrays if needed
|
||||
if (!isset($routes[$class_name])) {
|
||||
$routes[$class_name] = [];
|
||||
}
|
||||
$routes[$class_name][$method_name] = $route_pattern;
|
||||
if (!isset($routes[$class_name][$method_name])) {
|
||||
$routes[$class_name][$method_name] = [];
|
||||
}
|
||||
// Append route pattern to array
|
||||
$routes[$class_name][$method_name][] = $route_pattern;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class Rsx_Reference_Data_Controller extends Rsx_Controller_Abstract
|
||||
* Returns array of {value: country_code, label: country_name} sorted alphabetically
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function countries(Request $request, array $params = []): array
|
||||
public static function countries(Request $request, array $params = [])
|
||||
{
|
||||
return Country_Model::enabled()
|
||||
->orderBy('name')
|
||||
@@ -41,10 +41,9 @@ class Rsx_Reference_Data_Controller extends Rsx_Controller_Abstract
|
||||
*
|
||||
* @param Request $request
|
||||
* @param array $params - Expected: ['country' => 'US']
|
||||
* @return array
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function states(Request $request, array $params = []): array
|
||||
public static function states(Request $request, array $params = [])
|
||||
{
|
||||
$country = $params['country'] ?? 'US';
|
||||
|
||||
|
||||
@@ -19,10 +19,9 @@ class Debugger_Controller extends Rsx_Controller_Abstract
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param array $params
|
||||
* @return array
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function log_console_messages(Request $request, array $params = []): array
|
||||
public static function log_console_messages(Request $request, array $params = [])
|
||||
{
|
||||
return Debugger::log_console_messages($request, $params);
|
||||
}
|
||||
@@ -32,10 +31,9 @@ class Debugger_Controller extends Rsx_Controller_Abstract
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param array $params
|
||||
* @return array
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function log_browser_errors(Request $request, array $params = []): array
|
||||
public static function log_browser_errors(Request $request, array $params = [])
|
||||
{
|
||||
return Debugger::log_browser_errors($request, $params);
|
||||
}
|
||||
|
||||
@@ -782,7 +782,7 @@ class Dispatcher
|
||||
}
|
||||
|
||||
// Handle authentication required
|
||||
if ($type === 'response_auth_required') {
|
||||
if ($type === \App\RSpade\Core\Ajax\Ajax::ERROR_AUTH_REQUIRED) {
|
||||
if ($redirect_url) {
|
||||
Rsx::flash_error($reason);
|
||||
|
||||
@@ -793,7 +793,7 @@ class Dispatcher
|
||||
}
|
||||
|
||||
// Handle unauthorized
|
||||
if ($type === 'response_unauthorized') {
|
||||
if ($type === \App\RSpade\Core\Ajax\Ajax::ERROR_UNAUTHORIZED) {
|
||||
if ($redirect_url) {
|
||||
Rsx::flash_error($reason);
|
||||
|
||||
@@ -803,8 +803,8 @@ class Dispatcher
|
||||
throw new Exception($reason);
|
||||
}
|
||||
|
||||
// Handle form error
|
||||
if ($type === 'response_form_error') {
|
||||
// Handle validation and not found errors
|
||||
if ($type === \App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION || $type === \App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND) {
|
||||
// Only redirect if this was a POST request
|
||||
if (request()->isMethod('POST')) {
|
||||
Rsx::flash_error($reason);
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
* 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
|
||||
@@ -198,81 +208,70 @@ class Ajax {
|
||||
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 || {};
|
||||
const error_code = response.error_code || Ajax.ERROR_GENERIC;
|
||||
const reason = response.reason || 'An error occurred';
|
||||
const metadata = response.metadata || {};
|
||||
|
||||
// Handle specific error types
|
||||
switch (error_type) {
|
||||
case 'fatal':
|
||||
// Fatal PHP error with full error details
|
||||
const fatal_error_data = response.error || {};
|
||||
const error_message = fatal_error_data.error || 'Fatal error occurred';
|
||||
// Create error object
|
||||
const error = new Error(reason);
|
||||
error.code = error_code;
|
||||
error.metadata = metadata;
|
||||
|
||||
console.error('Ajax error response from server:', response.error);
|
||||
// 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;
|
||||
|
||||
const fatal_error = new Error(error_message);
|
||||
fatal_error.type = 'fatal';
|
||||
fatal_error.details = response.error;
|
||||
console.error('Ajax error response from server:', response.error);
|
||||
|
||||
// Log to server if browser error logging is enabled
|
||||
Debugger.log_error({
|
||||
message: `Ajax Fatal Error: ${error_message}`,
|
||||
type: 'ajax_fatal',
|
||||
endpoint: url,
|
||||
details: response.error,
|
||||
});
|
||||
|
||||
reject(fatal_error);
|
||||
break;
|
||||
|
||||
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;
|
||||
|
||||
default:
|
||||
const generic_error = new Error(reason);
|
||||
generic_error.type = error_type;
|
||||
generic_error.details = details;
|
||||
reject(generic_error);
|
||||
break;
|
||||
// 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 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;
|
||||
const err = new Error();
|
||||
|
||||
// Log server errors (500+) to the server if browser error logging is enabled
|
||||
// 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}: ${error_message}`,
|
||||
message: `Ajax Server Error ${xhr.status}: ${err.message}`,
|
||||
type: 'ajax_server_error',
|
||||
endpoint: url,
|
||||
status: xhr.status,
|
||||
@@ -280,7 +279,7 @@ class Ajax {
|
||||
});
|
||||
}
|
||||
|
||||
reject(network_error);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -376,26 +375,13 @@ class Ajax {
|
||||
});
|
||||
} else {
|
||||
// Handle error
|
||||
const error_type = call_response.error_type || 'unknown_error';
|
||||
let error_message;
|
||||
let error_details;
|
||||
|
||||
if (error_type === 'fatal' && call_response.error) {
|
||||
// Fatal PHP error with full error details
|
||||
const fatal_error_data = call_response.error;
|
||||
error_message = fatal_error_data.error || 'Fatal error occurred';
|
||||
error_details = call_response.error;
|
||||
|
||||
console.error('Ajax error response from server:', call_response.error);
|
||||
} else {
|
||||
// Other error types
|
||||
error_message = call_response.reason || 'Unknown error occurred';
|
||||
error_details = call_response.details || {};
|
||||
}
|
||||
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.type = error_type;
|
||||
error.details = error_details;
|
||||
error.code = error_code;
|
||||
error.metadata = metadata;
|
||||
|
||||
pending_call.is_error = true;
|
||||
pending_call.error = error;
|
||||
@@ -410,7 +396,8 @@ class Ajax {
|
||||
// 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';
|
||||
error.code = Ajax.ERROR_NETWORK;
|
||||
error.metadata = {};
|
||||
|
||||
for (const call_id in call_map) {
|
||||
const pending_call = call_map[call_id];
|
||||
|
||||
@@ -30,8 +30,39 @@ class Debugger {
|
||||
// Check if browser error logging is enabled
|
||||
if (window.rsxapp && window.rsxapp.log_browser_errors) {
|
||||
// Listen for unhandled exceptions from Rsx event system
|
||||
Rsx.on('unhandled_exception', function (error_data) {
|
||||
Debugger._handle_browser_error(error_data);
|
||||
Rsx.on('unhandled_exception', function (payload) {
|
||||
// Extract exception from payload
|
||||
const exception = payload.exception;
|
||||
|
||||
// Normalize exception to error data object
|
||||
// Contract: exception can be Error object or string
|
||||
let errorData = {};
|
||||
|
||||
if (exception instanceof Error) {
|
||||
// Extract properties from Error object
|
||||
errorData.message = exception.message;
|
||||
errorData.stack = exception.stack;
|
||||
errorData.filename = exception.filename;
|
||||
errorData.lineno = exception.lineno;
|
||||
errorData.colno = exception.colno;
|
||||
errorData.type = 'exception';
|
||||
} else if (typeof exception === 'string') {
|
||||
// Plain string message
|
||||
errorData.message = exception;
|
||||
errorData.type = 'manual';
|
||||
} else if (exception && typeof exception === 'object') {
|
||||
// Object with message property (structured error)
|
||||
errorData = exception;
|
||||
if (!errorData.type) {
|
||||
errorData.type = 'manual';
|
||||
}
|
||||
} else {
|
||||
// Default case for unknown types
|
||||
errorData.message = String(exception);
|
||||
errorData.type = 'unknown';
|
||||
}
|
||||
|
||||
Debugger._handle_browser_error(errorData);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
193
app/RSpade/Core/Js/Exception_Handler.js
Executable file
193
app/RSpade/Core/Js/Exception_Handler.js
Executable file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Exception_Handler
|
||||
*
|
||||
* Centralized exception display logic for unhandled exceptions.
|
||||
* Decides whether to show exception in SPA layout (debug mode) or as flash alert.
|
||||
*
|
||||
* Architecture:
|
||||
* - window error/unhandledrejection → Rsx._handle_unhandled_exception()
|
||||
* - Rsx triggers 'unhandled_exception' event (for logging via Debugger.js)
|
||||
* - Rsx disables SPA navigation
|
||||
* - Rsx calls Exception_Handler.display_unhandled_exception()
|
||||
* - Exception_Handler checks conditions and shows either:
|
||||
* a) Debug error box in layout (if SPA, debug mode, action loading)
|
||||
* b) Flash alert (when layout unavailable)
|
||||
*
|
||||
* Display Conditions for Layout Debug UI:
|
||||
* 1. Must be in SPA mode (window.rsxapp.is_spa)
|
||||
* 2. Must be in debug mode (window.rsxapp.debug)
|
||||
* 3. Spa.layout must exist with show_debug_exception() method
|
||||
* 4. Spa.layout.action must exist
|
||||
* 5. Action must still be loading (Spa.layout._action_is_loading === true)
|
||||
* 6. Developer has not suppressed display (suppress_display() not called)
|
||||
*
|
||||
* If conditions fail, falls back to flash alert.
|
||||
* If layout display throws, falls back to flash alert.
|
||||
*/
|
||||
class Exception_Handler {
|
||||
|
||||
/**
|
||||
* Developer-controlled flag to suppress all exception display UI
|
||||
* @type {boolean}
|
||||
*/
|
||||
static _suppress_display = false;
|
||||
|
||||
/**
|
||||
* Timestamp of last flash alert for rate limiting
|
||||
* @type {number}
|
||||
*/
|
||||
static _last_exception_flash = 0;
|
||||
|
||||
/**
|
||||
* Register exception handler during framework initialization
|
||||
* Called automatically by framework - do not call manually
|
||||
*/
|
||||
static _on_framework_core_init() {
|
||||
// Listen for all unhandled exceptions to display them
|
||||
Rsx.on('unhandled_exception', function(payload) {
|
||||
// Extract exception and metadata from payload
|
||||
const exception = payload.exception;
|
||||
const meta = payload.meta || {};
|
||||
|
||||
Exception_Handler.display_unhandled_exception(exception, meta);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress all exception display UI
|
||||
* Use this when you want to handle exceptions completely custom
|
||||
*/
|
||||
static suppress_display() {
|
||||
Exception_Handler._suppress_display = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume exception display UI after suppressing
|
||||
*/
|
||||
static resume_display() {
|
||||
Exception_Handler._suppress_display = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display an unhandled exception using the most appropriate method
|
||||
*
|
||||
* Decision tree:
|
||||
* 1. If developer suppressed display → do nothing
|
||||
* 2. Log to console if from global handler (not already logged by catcher)
|
||||
* 3. If in SPA, debug mode, layout exists, action not ready → show in layout
|
||||
* 4. Otherwise → show flash alert
|
||||
*
|
||||
* @param {Error|string} exception - The exception to display
|
||||
* @param {Object} meta - Metadata about exception source
|
||||
* @param {string} meta.source - 'window_error', 'unhandled_rejection', or undefined
|
||||
*/
|
||||
static display_unhandled_exception(exception, meta = {}) {
|
||||
// Developer explicitly suppressed all display
|
||||
if (Exception_Handler._suppress_display) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log to console if from global handler (true unhandled error)
|
||||
// Skip if manually triggered - already logged by the catcher
|
||||
if (meta.source === 'window_error' || meta.source === 'unhandled_rejection') {
|
||||
console.error('[Exception_Handler] Unhandled exception:', exception);
|
||||
}
|
||||
|
||||
// Try to show in layout if all conditions met
|
||||
if (Exception_Handler._should_show_in_layout()) {
|
||||
try {
|
||||
Spa.layout.show_debug_exception(exception);
|
||||
return; // Successfully shown in layout, don't show flash
|
||||
} catch (e) {
|
||||
// Failed to show in layout (maybe $content doesn't exist)
|
||||
// Fall through to flash alert
|
||||
console.warn('[Exception_Handler] Failed to show exception in layout:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Show as flash alert when layout display unavailable
|
||||
Exception_Handler._show_flash_alert(exception);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should attempt to show exception in SPA layout
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static _should_show_in_layout() {
|
||||
// Must be in SPA mode
|
||||
if (!window.rsxapp || !window.rsxapp.is_spa) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be in debug mode
|
||||
if (!window.rsxapp.debug) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Spa must be loaded
|
||||
if (typeof Spa === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Layout must exist and have the display method
|
||||
if (!Spa.layout || typeof Spa.layout.show_debug_exception !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Action must exist
|
||||
if (!Spa.layout.action) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if action is still loading
|
||||
// During action load, Spa_Layout sets _action_is_loading flag
|
||||
if (!Spa.layout._action_is_loading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show exception as flash alert (rate limited)
|
||||
*
|
||||
* @param {Error|string} exception
|
||||
*/
|
||||
static _show_flash_alert(exception) {
|
||||
// Flash_Alert must be available
|
||||
if (typeof Flash_Alert === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract message from Error object or string
|
||||
let error_text;
|
||||
if (exception instanceof Error) {
|
||||
error_text = exception.message;
|
||||
} else if (typeof exception === 'string') {
|
||||
error_text = exception;
|
||||
} else if (exception && typeof exception === 'object' && exception.message) {
|
||||
error_text = exception.message;
|
||||
} else {
|
||||
error_text = 'Unknown error';
|
||||
}
|
||||
|
||||
// Rate limit: max 1 flash alert per second
|
||||
const now = Date.now();
|
||||
if (now - Exception_Handler._last_exception_flash >= 1000) {
|
||||
Exception_Handler._last_exception_flash = now;
|
||||
|
||||
// Truncate long messages in debug mode, show generic message in production
|
||||
let message;
|
||||
if (window.rsxapp && window.rsxapp.debug) {
|
||||
message = error_text.length > 300
|
||||
? error_text.substring(0, 300) + '...'
|
||||
: error_text;
|
||||
} else {
|
||||
message = 'An unhandled error has occurred, you may need to refresh your page';
|
||||
}
|
||||
|
||||
Flash_Alert.error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,9 +50,6 @@ class Rsx {
|
||||
// Gets set to true to interupt startup sequence
|
||||
static __stopped = false;
|
||||
|
||||
// Timestamp of last flash alert for unhandled exceptions (rate limiting)
|
||||
static _last_exception_flash = 0;
|
||||
|
||||
// Initialize event handlers storage
|
||||
static _init_events() {
|
||||
if (typeof Rsx._event_handlers === 'undefined') {
|
||||
@@ -113,70 +110,101 @@ class Rsx {
|
||||
/**
|
||||
* Setup global unhandled exception handlers
|
||||
* Must be called before framework initialization begins
|
||||
*
|
||||
* Exception Event Contract:
|
||||
* -------------------------
|
||||
* When exceptions occur, they are broadcast via:
|
||||
* Rsx.trigger('unhandled_exception', { exception, meta })
|
||||
*
|
||||
* Event payload structure:
|
||||
* {
|
||||
* exception: Error|string, // The exception (Error object or string)
|
||||
* meta: {
|
||||
* source: 'window_error'|'unhandled_rejection'|undefined
|
||||
* // source = undefined means manually triggered (already logged by catcher)
|
||||
* // source = 'window_error' or 'unhandled_rejection' means needs logging
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* The exception can be:
|
||||
* 1. An Error object (preferred) - Has .message, .stack, .filename, .lineno properties
|
||||
* 2. A string - Plain error message text
|
||||
*
|
||||
* Consumers should handle both formats:
|
||||
* ```javascript
|
||||
* Rsx.on('unhandled_exception', function(payload) {
|
||||
* const exception = payload.exception;
|
||||
* const meta = payload.meta || {};
|
||||
*
|
||||
* let message;
|
||||
* if (exception instanceof Error) {
|
||||
* message = exception.message;
|
||||
* // Can also access: exception.stack, exception.filename, exception.lineno
|
||||
* } else if (typeof exception === 'string') {
|
||||
* message = exception;
|
||||
* } else {
|
||||
* message = String(exception);
|
||||
* }
|
||||
*
|
||||
* // Only log if from global handler (not already logged)
|
||||
* if (meta.source === 'window_error' || meta.source === 'unhandled_rejection') {
|
||||
* console.error('[Handler]', exception);
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Exception Flow:
|
||||
* - window error/unhandledrejection → _handle_unhandled_exception(exception, {source})
|
||||
* - Triggers 'unhandled_exception' event with exception and metadata
|
||||
* - Debugger.js: Logs to server
|
||||
* - Exception_Handler: Logs to console (if from global handler) and displays
|
||||
* - Disables SPA navigation
|
||||
*/
|
||||
static _setup_exception_handlers() {
|
||||
// Handle uncaught JavaScript errors
|
||||
window.addEventListener('error', function (event) {
|
||||
Rsx._handle_unhandled_exception({
|
||||
message: event.message,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
stack: event.error ? event.error.stack : null,
|
||||
type: 'error',
|
||||
error: event.error,
|
||||
});
|
||||
// Pass the Error object directly if available, otherwise create one
|
||||
const exception = event.error || new Error(event.message);
|
||||
// Attach additional metadata if not already present
|
||||
if (!exception.filename) exception.filename = event.filename;
|
||||
if (!exception.lineno) exception.lineno = event.lineno;
|
||||
if (!exception.colno) exception.colno = event.colno;
|
||||
|
||||
Rsx._handle_unhandled_exception(exception, { source: 'window_error' });
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
window.addEventListener('unhandledrejection', function (event) {
|
||||
Rsx._handle_unhandled_exception({
|
||||
message: event.reason ? event.reason.message || String(event.reason) : 'Unhandled promise rejection',
|
||||
stack: event.reason && event.reason.stack ? event.reason.stack : null,
|
||||
type: 'unhandledrejection',
|
||||
error: event.reason,
|
||||
});
|
||||
// event.reason can be Error, string, or any value
|
||||
const exception = event.reason instanceof Error
|
||||
? event.reason
|
||||
: new Error(event.reason ? String(event.reason) : 'Unhandled promise rejection');
|
||||
|
||||
Rsx._handle_unhandled_exception(exception, { source: 'unhandled_rejection' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal handler for unhandled exceptions
|
||||
* Triggers event, shows flash alert (rate limited), disables SPA, and logs to console
|
||||
* Triggers event and disables SPA
|
||||
* Display and logging handled by Exception_Handler listening to the event
|
||||
*
|
||||
* @param {Error|string|Object} exception - Exception object, string, or object with message
|
||||
* @param {Object} meta - Metadata about exception source
|
||||
* @param {string} meta.source - 'window_error', 'unhandled_rejection', or undefined for manual triggers
|
||||
*/
|
||||
static _handle_unhandled_exception(error_data) {
|
||||
// Always log to console
|
||||
console.error('[Rsx] Unhandled exception:', error_data);
|
||||
|
||||
// Trigger event for listeners (e.g., Debugger for server logging)
|
||||
Rsx.trigger('unhandled_exception', error_data);
|
||||
static _handle_unhandled_exception(exception, meta = {}) {
|
||||
// Trigger event for listeners:
|
||||
// - Debugger.js: Logs to server
|
||||
// - Exception_Handler: Logs to console and displays error (layout or flash alert)
|
||||
// Pass exception and metadata (source info for logging decisions)
|
||||
Rsx.trigger('unhandled_exception', { exception, meta });
|
||||
|
||||
// Disable SPA navigation if in SPA mode
|
||||
// This allows user to navigate away from broken page using normal browser navigation
|
||||
if (typeof Spa !== 'undefined' && window.rsxapp && window.rsxapp.is_spa) {
|
||||
Spa.disable();
|
||||
}
|
||||
|
||||
// Show flash alert (rate limited to 1 per second)
|
||||
const now = Date.now();
|
||||
if (now - Rsx._last_exception_flash >= 1000) {
|
||||
Rsx._last_exception_flash = now;
|
||||
|
||||
// Determine message based on dev/prod mode
|
||||
let message;
|
||||
if (window.rsxapp && window.rsxapp.debug) {
|
||||
// Dev mode: Show actual error (shortened to 300 chars)
|
||||
const error_text = error_data.message || 'Unknown error';
|
||||
message = error_text.length > 300 ? error_text.substring(0, 300) + '...' : error_text;
|
||||
} else {
|
||||
// Production mode: Generic message
|
||||
message = 'An unhandled error has occurred, you may need to refresh your page';
|
||||
}
|
||||
|
||||
// Show flash alert if Flash_Alert is available
|
||||
if (typeof Flash_Alert !== 'undefined') {
|
||||
Flash_Alert.error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log to server that an event happened
|
||||
@@ -317,7 +345,19 @@ class Rsx {
|
||||
// Check if route exists in PHP controller definitions
|
||||
let pattern;
|
||||
if (Rsx._routes[class_name] && Rsx._routes[class_name][action_name]) {
|
||||
pattern = Rsx._routes[class_name][action_name];
|
||||
const route_patterns = Rsx._routes[class_name][action_name];
|
||||
|
||||
// Route patterns are always arrays (even for single routes)
|
||||
pattern = Rsx._select_best_route_pattern(route_patterns, params_obj);
|
||||
|
||||
if (!pattern) {
|
||||
// Route exists but no pattern satisfies the provided parameters
|
||||
const route_list = route_patterns.join(', ');
|
||||
throw new Error(
|
||||
`No suitable route found for ${class_name}::${action_name} with provided parameters. ` +
|
||||
`Available routes: ${route_list}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Not found in PHP routes - check if it's a SPA action
|
||||
pattern = Rsx._try_spa_action_route(class_name, params_obj);
|
||||
|
||||
@@ -2647,6 +2647,25 @@ class Manifest
|
||||
$method_data['parameters'] = $parameters;
|
||||
}
|
||||
|
||||
// Extract return type if available
|
||||
$return_type = $method->getReturnType();
|
||||
if ($return_type !== null) {
|
||||
if ($return_type instanceof \ReflectionUnionType) {
|
||||
// Union type (e.g., "array|null")
|
||||
$method_data['return_type'] = [
|
||||
'type' => 'union',
|
||||
'types' => array_map(fn($t) => $t->getName(), $return_type->getTypes()),
|
||||
'nullable' => $return_type->allowsNull()
|
||||
];
|
||||
} else {
|
||||
// Single type (e.g., "array", "string", "int")
|
||||
$method_data['return_type'] = [
|
||||
'type' => $return_type->getName(),
|
||||
'nullable' => $return_type->allowsNull()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$public_static_methods[$method->getName()] = $method_data;
|
||||
}
|
||||
|
||||
@@ -3173,6 +3192,37 @@ class Manifest
|
||||
'A method must be either a Route, Ajax_Endpoint, OR Task, not multiple types.'
|
||||
);
|
||||
}
|
||||
|
||||
// Check Ajax_Endpoint methods don't have return types
|
||||
if ($has_ajax_endpoint && isset($method_info['return_type'])) {
|
||||
$class_name = $metadata['class'] ?? 'Unknown';
|
||||
$return_type_info = $method_info['return_type'];
|
||||
|
||||
// Format return type for error message
|
||||
if (isset($return_type_info['type']) && $return_type_info['type'] === 'union') {
|
||||
$type_display = implode('|', $return_type_info['types']);
|
||||
} else {
|
||||
$type_display = $return_type_info['type'] ?? 'unknown';
|
||||
if (!empty($return_type_info['nullable'])) {
|
||||
$type_display = '?' . $type_display;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
"Ajax endpoint has forbidden return type declaration: {$type_display}\n" .
|
||||
"Class: {$class_name}\n" .
|
||||
"Method: {$method_name}\n" .
|
||||
"File: {$file_path}\n\n" .
|
||||
"Ajax endpoints must NOT declare return types because they need flexibility to return:\n" .
|
||||
"- Array data (success case)\n" .
|
||||
"- Form_Error_Response (validation errors)\n" .
|
||||
"- Redirect_Response (redirects)\n" .
|
||||
"- Other response types as needed\n\n" .
|
||||
"Solution: Remove the return type declaration from this method.\n" .
|
||||
"Change: public static function {$method_name}(...): {$type_display}\n" .
|
||||
"To: public static function {$method_name}(...)\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* CODING CONVENTION:
|
||||
* This file follows the coding convention where variable_names and function_names
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Core\Response;
|
||||
|
||||
use App\RSpade\Core\Response\Rsx_Response_Abstract;
|
||||
|
||||
/**
|
||||
* Authentication required response
|
||||
*/
|
||||
class Auth_Required_Response extends Rsx_Response_Abstract
|
||||
{
|
||||
public function __construct(string $reason = "Authentication Required", ?string $redirect = "/login")
|
||||
{
|
||||
$this->reason = $reason;
|
||||
$this->redirect = $redirect;
|
||||
$this->details = [];
|
||||
}
|
||||
|
||||
public function get_type(): string
|
||||
{
|
||||
return 'response_auth_required';
|
||||
}
|
||||
}
|
||||
63
app/RSpade/Core/Response/Error_Response.php
Executable file
63
app/RSpade/Core/Response/Error_Response.php
Executable file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
/**
|
||||
* CODING CONVENTION:
|
||||
* This file follows the coding convention where variable_names and function_names
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Core\Response;
|
||||
|
||||
use App\RSpade\Core\Ajax\Ajax;
|
||||
use App\RSpade\Core\Response\Rsx_Response_Abstract;
|
||||
|
||||
/**
|
||||
* Unified error response
|
||||
*
|
||||
* Replaces Form_Error_Response, Auth_Required_Response, Unauthorized_Response, etc.
|
||||
*/
|
||||
class Error_Response extends Rsx_Response_Abstract
|
||||
{
|
||||
protected string $error_code;
|
||||
protected array $metadata;
|
||||
|
||||
public function __construct(string $error_code, $metadata = null)
|
||||
{
|
||||
$this->error_code = $error_code;
|
||||
|
||||
// Normalize metadata to array
|
||||
if ($metadata === null) {
|
||||
$this->metadata = [];
|
||||
} elseif (is_string($metadata)) {
|
||||
$this->metadata = ['message' => $metadata];
|
||||
} elseif (is_array($metadata)) {
|
||||
$this->metadata = $metadata;
|
||||
} else {
|
||||
$this->metadata = ['message' => (string)$metadata];
|
||||
}
|
||||
|
||||
// Set reason from message or use default
|
||||
if (isset($this->metadata['message'])) {
|
||||
$this->reason = $this->metadata['message'];
|
||||
} else {
|
||||
$this->reason = Ajax::get_default_message($error_code);
|
||||
}
|
||||
|
||||
$this->details = $this->metadata;
|
||||
$this->redirect = null;
|
||||
}
|
||||
|
||||
public function get_type(): string
|
||||
{
|
||||
return $this->error_code;
|
||||
}
|
||||
|
||||
public function get_error_code(): string
|
||||
{
|
||||
return $this->error_code;
|
||||
}
|
||||
|
||||
public function get_metadata(): array
|
||||
{
|
||||
return $this->metadata;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* CODING CONVENTION:
|
||||
* This file follows the coding convention where variable_names and function_names
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Core\Response;
|
||||
|
||||
use App\RSpade\Core\Response\Rsx_Response_Abstract;
|
||||
|
||||
/**
|
||||
* Fatal error response
|
||||
*/
|
||||
class Fatal_Error_Response extends Rsx_Response_Abstract
|
||||
{
|
||||
public function __construct(string $reason = "An error has occurred.", array $details = [])
|
||||
{
|
||||
$this->reason = $reason;
|
||||
$this->redirect = null;
|
||||
$this->details = $details;
|
||||
}
|
||||
|
||||
public function get_type(): string
|
||||
{
|
||||
return 'fatal';
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* CODING CONVENTION:
|
||||
* This file follows the coding convention where variable_names and function_names
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Core\Response;
|
||||
|
||||
use App\RSpade\Core\Response\Rsx_Response_Abstract;
|
||||
|
||||
/**
|
||||
* Form error response
|
||||
*/
|
||||
class Form_Error_Response extends Rsx_Response_Abstract
|
||||
{
|
||||
public function __construct(string $reason = "An error has occurred.", array $details = [])
|
||||
{
|
||||
$this->reason = $reason;
|
||||
$this->redirect = null;
|
||||
$this->details = $details;
|
||||
}
|
||||
|
||||
public function get_type(): string
|
||||
{
|
||||
return 'response_form_error';
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* CODING CONVENTION:
|
||||
* This file follows the coding convention where variable_names and function_names
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Core\Response;
|
||||
|
||||
use App\RSpade\Core\Response\Rsx_Response_Abstract;
|
||||
|
||||
/**
|
||||
* Unauthorized response
|
||||
*/
|
||||
class Unauthorized_Response extends Rsx_Response_Abstract
|
||||
{
|
||||
public function __construct(string $reason = "Unauthorized", ?string $redirect = null)
|
||||
{
|
||||
$this->reason = $reason;
|
||||
$this->redirect = $redirect;
|
||||
$this->details = [];
|
||||
}
|
||||
|
||||
public function get_type(): string
|
||||
{
|
||||
return 'response_unauthorized';
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ class Spa {
|
||||
// Flag to track if SPA is enabled (can be disabled on errors or dirty forms)
|
||||
static _spa_enabled = true;
|
||||
|
||||
// Timer ID for 30-minute auto-disable
|
||||
static _spa_timeout_timer = null;
|
||||
|
||||
/**
|
||||
* Disable SPA navigation - all navigation becomes full page loads
|
||||
* Call this when errors occur or forms are dirty
|
||||
@@ -55,6 +58,52 @@ class Spa {
|
||||
Spa._spa_enabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start 30-minute timeout to auto-disable SPA
|
||||
* Prevents users from working with stale code for more than 30 minutes
|
||||
*/
|
||||
static _start_spa_timeout() {
|
||||
// 30-minute timeout to auto-disable SPA navigation
|
||||
//
|
||||
// WHY: When the application is deployed with updated code, users who have the
|
||||
// SPA already loaded in their browser will continue using the old JavaScript
|
||||
// bundle indefinitely. This can cause:
|
||||
// - API mismatches (stale client code calling updated server endpoints)
|
||||
// - Missing features or UI changes
|
||||
// - Bugs from stale client-side logic
|
||||
//
|
||||
// FUTURE: A future version of RSpade will use WebSockets to trigger all clients
|
||||
// to automatically reload their pages on deploy. However, this timeout serves as
|
||||
// a secondary line of defense against:
|
||||
// - Failures in the WebSocket notification system
|
||||
// - Memory leaks in long-running SPA sessions
|
||||
// - Other unforeseen issues that may arise
|
||||
// This ensures that users will eventually and periodically get a fresh state,
|
||||
// regardless of any other system failures.
|
||||
//
|
||||
// SOLUTION: After 30 minutes, automatically disable SPA navigation. The next
|
||||
// forward navigation (link click, manual dispatch) will do a full page reload,
|
||||
// fetching the new bundle. Back/forward buttons continue to work via SPA
|
||||
// (force: true) to preserve form state and scroll position.
|
||||
//
|
||||
// 30 MINUTES: Chosen as a balance between:
|
||||
// - Short enough that users don't work with stale code for too long
|
||||
// - Long enough that users aren't interrupted during active work sessions
|
||||
//
|
||||
// TODO: Make this timeout value configurable by developers via:
|
||||
// - window.rsxapp.spa_timeout_minutes (set in PHP)
|
||||
// - Default to 30 if not specified
|
||||
// - Allow 0 to disable timeout entirely (for dev/testing)
|
||||
const timeout_ms = 30 * 60 * 1000;
|
||||
|
||||
Spa._spa_timeout_timer = setTimeout(() => {
|
||||
console.warn('[Spa] 30-minute timeout reached - disabling SPA navigation');
|
||||
Spa.disable();
|
||||
}, timeout_ms);
|
||||
|
||||
console_debug('Spa', '30-minute auto-disable timer started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework module initialization hook called during framework boot
|
||||
* Only runs when window.rsxapp.is_spa === true
|
||||
@@ -67,6 +116,9 @@ class Spa {
|
||||
|
||||
console_debug('Spa', 'Initializing Spa system');
|
||||
|
||||
// Start 30-minute auto-disable timer
|
||||
Spa._start_spa_timeout();
|
||||
|
||||
// Discover and register all action classes
|
||||
Spa.discover_actions();
|
||||
|
||||
@@ -324,13 +376,6 @@ class Spa {
|
||||
// Get target URL (browser has already updated location)
|
||||
const url = window.location.pathname + window.location.search + window.location.hash;
|
||||
|
||||
// If SPA is disabled, still handle back/forward as SPA navigation
|
||||
// (We can't convert existing history entries to full page loads)
|
||||
// Only forward navigation (link clicks) will become full page loads
|
||||
if (!Spa._spa_enabled) {
|
||||
console_debug('Spa', 'SPA disabled but handling popstate as SPA navigation (back/forward)');
|
||||
}
|
||||
|
||||
// Retrieve scroll position from history state
|
||||
const scroll = e.state?.scroll || null;
|
||||
|
||||
@@ -349,9 +394,11 @@ class Spa {
|
||||
// const form_data = e.state?.form_data || {};
|
||||
|
||||
// Dispatch without modifying history (we're already at the target URL)
|
||||
// Force SPA dispatch even if disabled - popstate navigates to cached history state
|
||||
Spa.dispatch(url, {
|
||||
history: 'none',
|
||||
scroll: scroll
|
||||
scroll: scroll,
|
||||
force: true
|
||||
});
|
||||
});
|
||||
|
||||
@@ -433,10 +480,12 @@ class Spa {
|
||||
* - 'none': Don't modify history (used for back/forward)
|
||||
* @param {object|null} options.scroll - Scroll position {x, y} to restore (default: null = scroll to top)
|
||||
* @param {boolean} options.triggers - Fire before/after dispatch events (default: true)
|
||||
* @param {boolean} options.force - Force SPA dispatch even if disabled (used by popstate) (default: false)
|
||||
*/
|
||||
static async dispatch(url, options = {}) {
|
||||
// Check if SPA is disabled - do full page load
|
||||
if (!Spa._spa_enabled) {
|
||||
// Exception: popstate events always attempt SPA dispatch (force: true)
|
||||
if (!Spa._spa_enabled && !options.force) {
|
||||
console.warn('[Spa.dispatch] SPA disabled, forcing full page load');
|
||||
document.location.href = url;
|
||||
return;
|
||||
@@ -621,12 +670,18 @@ class Spa {
|
||||
Spa.layout.stop();
|
||||
}
|
||||
|
||||
// Clear body and create new layout
|
||||
$('body').empty();
|
||||
$('body').attr('class', '');
|
||||
// Clear spa-root and create new layout
|
||||
// Note: We target #spa-root instead of body to preserve global UI containers
|
||||
// (Flash_Alert, modals, tooltips, etc. that append to body)
|
||||
const $spa_root = $('#spa-root');
|
||||
if (!$spa_root.length) {
|
||||
throw new Error('[Spa] #spa-root element not found - check Spa_App.blade.php');
|
||||
}
|
||||
$spa_root.empty();
|
||||
$spa_root.attr('class', '');
|
||||
|
||||
// Create layout using component system
|
||||
Spa.layout = $('body').component(layout_name, {}).component();
|
||||
Spa.layout = $spa_root.component(layout_name, {}).component();
|
||||
|
||||
// Wait for layout to be ready
|
||||
await Spa.layout.ready();
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<meta content="ie=edge" http-equiv="X-UA-Compatible">
|
||||
|
||||
{{-- Bundle includes --}}
|
||||
{!! Frontend_Bundle::render() !!}
|
||||
{{-- Bundle includes - dynamically rendered based on SPA controller --}}
|
||||
{!! $bundle::render() !!}
|
||||
</head>
|
||||
|
||||
<body class="{{ rsx_body_class() }}">
|
||||
|
||||
<div id="spa-root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -26,7 +26,53 @@ class Spa_Layout extends Component {
|
||||
* @returns {jQuery} The content element
|
||||
*/
|
||||
$content() {
|
||||
return this.$id('content');
|
||||
return this.$sid('content');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a debug exception message at the top of the content area
|
||||
* Prepends a styled error box with the exception message
|
||||
*
|
||||
* @param {string|Error} exception - Error message or Error object to display
|
||||
*/
|
||||
show_debug_exception(exception) {
|
||||
const $content = this.$content();
|
||||
if (!$content || !$content.length) {
|
||||
console.error('[Spa_Layout] Cannot show debug exception: content element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract message from Error object or use string directly
|
||||
let message;
|
||||
if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
} else if (typeof exception === 'string') {
|
||||
message = exception;
|
||||
} else if (exception && typeof exception === 'object' && exception.message) {
|
||||
message = exception.message;
|
||||
} else {
|
||||
message = String(exception);
|
||||
}
|
||||
|
||||
// Create error box with inline styles (no framework dependencies)
|
||||
const error_html = `
|
||||
<div style="border: 2px solid #dc3545; background-color: #ffe6e6; color: #000; padding: 15px; margin-bottom: 20px;">
|
||||
<strong>Fatal Error:</strong> ${this._escape_html(message)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Prepend to content area
|
||||
$content.prepend(error_html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @private
|
||||
*/
|
||||
_escape_html(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +123,9 @@ class Spa_Layout extends Component {
|
||||
// Clear content area
|
||||
$content.empty();
|
||||
|
||||
// Mark action as loading for exception display logic
|
||||
this._action_is_loading = true;
|
||||
|
||||
try {
|
||||
// Get the action class to check for @title decorator
|
||||
const action_class = Manifest.get_class_by_name(action_name);
|
||||
@@ -110,20 +159,15 @@ class Spa_Layout extends Component {
|
||||
|
||||
// Wait for action to be ready
|
||||
await action.ready();
|
||||
} catch (error) {
|
||||
// Action lifecycle failed - log error, trigger event, disable SPA, show error UI
|
||||
console.error('[Spa_Layout] Action lifecycle failed:', error);
|
||||
|
||||
// Trigger global exception event (goes to Debugger for server logging, Flash_Alert, etc)
|
||||
if (typeof Rsx !== 'undefined') {
|
||||
Rsx.trigger('unhandled_exception', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
type: 'action_lifecycle_error',
|
||||
action_name: action_name,
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
// Mark action as done loading
|
||||
this._action_is_loading = false;
|
||||
} catch (error) {
|
||||
// Mark action as done loading (even though it failed)
|
||||
this._action_is_loading = false;
|
||||
|
||||
// Action lifecycle failed - log and trigger event
|
||||
console.error('[Spa_Layout] Action lifecycle failed:', error);
|
||||
|
||||
// Disable SPA so forward navigation becomes full page loads
|
||||
// (Back/forward still work as SPA to allow user to navigate away)
|
||||
@@ -131,16 +175,12 @@ class Spa_Layout extends Component {
|
||||
Spa.disable();
|
||||
}
|
||||
|
||||
// Show error UI in content area so user can navigate away
|
||||
$content.html(`
|
||||
<div class="alert alert-danger m-4">
|
||||
<h4>Page Failed to Load</h4>
|
||||
<p>An error occurred while loading this page. You can navigate back or to another page.</p>
|
||||
<p class="mb-0">
|
||||
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
|
||||
</p>
|
||||
</div>
|
||||
`);
|
||||
// Trigger global exception event
|
||||
// Exception_Handler will decide how to display (layout or flash alert)
|
||||
// Pass payload with exception (no meta.source = already logged above)
|
||||
if (typeof Rsx !== 'undefined') {
|
||||
Rsx.trigger('unhandled_exception', { exception: error, meta: {} });
|
||||
}
|
||||
|
||||
// Don't re-throw - allow navigation to continue working
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||
* Generate random first name
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function first_name(Request $request, array $params = []): string
|
||||
public static function first_name(Request $request, array $params = [])
|
||||
{
|
||||
self::__check_production();
|
||||
return self::__random_from_list('first_names');
|
||||
@@ -77,7 +77,7 @@ class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||
* Generate random last name
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function last_name(Request $request, array $params = []): string
|
||||
public static function last_name(Request $request, array $params = [])
|
||||
{
|
||||
self::__check_production();
|
||||
return self::__random_from_list('last_names');
|
||||
@@ -87,7 +87,7 @@ class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||
* Generate random company name
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function company_name(Request $request, array $params = []): string
|
||||
public static function company_name(Request $request, array $params = [])
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
@@ -109,7 +109,7 @@ class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||
* Generate random street address
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function address(Request $request, array $params = []): string
|
||||
public static function address(Request $request, array $params = [])
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
@@ -124,7 +124,7 @@ class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||
* Generate random city name
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function city(Request $request, array $params = []): string
|
||||
public static function city(Request $request, array $params = [])
|
||||
{
|
||||
self::__check_production();
|
||||
return self::__random_from_list('cities');
|
||||
@@ -134,7 +134,7 @@ class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||
* Generate random US state code
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function state(Request $request, array $params = []): string
|
||||
public static function state(Request $request, array $params = [])
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
@@ -153,7 +153,7 @@ class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||
* Generate random ZIP code
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function zip(Request $request, array $params = []): string
|
||||
public static function zip(Request $request, array $params = [])
|
||||
{
|
||||
self::__check_production();
|
||||
return str_pad((string)rand(10000, 99999), 5, '0', STR_PAD_LEFT);
|
||||
@@ -164,7 +164,7 @@ class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||
* Avoids 555-000-0000 and any sequence containing 911
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function phone(Request $request, array $params = []): string
|
||||
public static function phone(Request $request, array $params = [])
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
@@ -193,7 +193,7 @@ class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||
* Format: wordwordnumbers+test@gmail.com
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function email(Request $request, array $params = []): string
|
||||
public static function email(Request $request, array $params = [])
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
@@ -208,7 +208,7 @@ class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||
* Generate random website URL
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function website(Request $request, array $params = []): string
|
||||
public static function website(Request $request, array $params = [])
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
@@ -223,7 +223,7 @@ class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||
* Generate random text/paragraph
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function text(Request $request, array $params = []): string
|
||||
public static function text(Request $request, array $params = [])
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
|
||||
@@ -1060,66 +1060,15 @@ function rsx_body_class()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an authentication required response
|
||||
* Create a unified error response
|
||||
*
|
||||
* Returns a special response object that Dispatch and Ajax_Endpoint handle differently:
|
||||
* - HTTP requests: Sets flash alert and redirects to login
|
||||
* - AJAX requests: Returns JSON error with success:false
|
||||
*
|
||||
* @param string $reason The reason authentication is required
|
||||
* @param string|null $redirect The URL to redirect to (default: /login)
|
||||
* @return \App\RSpade\Core\Response\Auth_Required_Response
|
||||
* @param string $error_code One of Ajax::ERROR_* constants
|
||||
* @param string|array|null $metadata Error message (string) or structured data (array)
|
||||
* @return \App\RSpade\Core\Response\Error_Response
|
||||
*/
|
||||
function response_auth_required($reason = 'Authentication Required', $redirect = '/login')
|
||||
function response_error(string $error_code, $metadata = null)
|
||||
{
|
||||
return new \App\RSpade\Core\Response\Auth_Required_Response($reason, $redirect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an unauthorized response
|
||||
*
|
||||
* Returns a special response object that Dispatch and Ajax_Endpoint handle differently:
|
||||
* - HTTP requests: Sets flash alert and redirects or throws exception
|
||||
* - AJAX requests: Returns JSON error with success:false
|
||||
*
|
||||
* @param string $reason The reason for unauthorized access
|
||||
* @param string|null $redirect The URL to redirect to (null throws exception)
|
||||
* @return \App\RSpade\Core\Response\Unauthorized_Response
|
||||
*/
|
||||
function response_unauthorized($reason = 'Unauthorized', $redirect = null)
|
||||
{
|
||||
return new \App\RSpade\Core\Response\Unauthorized_Response($reason, $redirect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a form error response
|
||||
*
|
||||
* Returns a special response object that Dispatch and Ajax_Endpoint handle differently:
|
||||
* - HTTP requests: Sets flash alert and redirects back to same URL as GET
|
||||
* - AJAX requests: Returns JSON error with success:false and details
|
||||
*
|
||||
* @param string $reason The error message
|
||||
* @param array $details Additional error details
|
||||
* @return \App\RSpade\Core\Response\Form_Error_Response
|
||||
*/
|
||||
function response_form_error($reason = 'An error has occurred.', $details = [])
|
||||
{
|
||||
return new \App\RSpade\Core\Response\Form_Error_Response($reason, $details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fatal error response
|
||||
*
|
||||
* Returns a special response object that always throws an exception
|
||||
* in both HTTP and AJAX contexts
|
||||
*
|
||||
* @param string $reason The error message
|
||||
* @param array $details Additional error details
|
||||
* @return \App\RSpade\Core\Response\Fatal_Error_Response
|
||||
*/
|
||||
function response_fatal_error($reason = 'An error has occurred.', $details = [])
|
||||
{
|
||||
return new \App\RSpade\Core\Response\Fatal_Error_Response($reason, $details);
|
||||
return new \App\RSpade\Core\Response\Error_Response($error_code, $metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,7 @@ SYNOPSIS
|
||||
|
||||
on_ready() {
|
||||
// All children ready, safe for DOM manipulation
|
||||
this.$id('edit').on('click', () => this.edit());
|
||||
this.$sid('edit').on('click', () => this.edit());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +83,10 @@ TEMPLATE SYNTAX
|
||||
|
||||
<Define:User_Card>
|
||||
<div class="card">
|
||||
<img $id="avatar" src="<%= this.data.avatar %>" />
|
||||
<h3 $id="name"><%= this.data.name %></h3>
|
||||
<p $id="email"><%= this.data.email %></p>
|
||||
<button $id="edit">Edit</button>
|
||||
<img $sid="avatar" src="<%= this.data.avatar %>" />
|
||||
<h3 $sid="name"><%= this.data.name %></h3>
|
||||
<p $sid="email"><%= this.data.email %></p>
|
||||
<button $sid="edit">Edit</button>
|
||||
</div>
|
||||
</Define:User_Card>
|
||||
|
||||
@@ -94,9 +94,10 @@ TEMPLATE SYNTAX
|
||||
<%= expression %> - Escaped HTML output (safe, default)
|
||||
<%!= expression %> - Unescaped raw output (pre-sanitized content only)
|
||||
<% statement; %> - JavaScript statements (loops, conditionals)
|
||||
<%-- comment --%> - JQHTML comments (not HTML <!-- --> comments)
|
||||
|
||||
Attributes:
|
||||
$id="name" - Scoped ID (becomes id="name:component_id")
|
||||
$sid="name" - Scoped ID (becomes id="name:component_id")
|
||||
$attr=value - Component parameter (becomes this.args.attr)
|
||||
Note: Also creates data-attr HTML attribute
|
||||
@event=this.method - Event binding (⚠️ verify functionality)
|
||||
@@ -255,7 +256,7 @@ THIS.ARGS VS THIS.DATA
|
||||
Lifecycle Restrictions (ENFORCED):
|
||||
- on_create(): Can modify this.data (set defaults)
|
||||
- on_load(): Can ONLY access this.args and this.data
|
||||
Cannot access this.$, this.$id(), or any other properties
|
||||
Cannot access this.$, this.$sid(), or any other properties
|
||||
Can modify this.data freely
|
||||
- on_ready() / event handlers: Can modify this.args, read this.data
|
||||
CANNOT modify this.data (frozen)
|
||||
@@ -286,7 +287,7 @@ THIS.ARGS VS THIS.DATA
|
||||
|
||||
on_ready() {
|
||||
// Modify state, then reload
|
||||
this.$id('filter_btn').on('click', () => {
|
||||
this.$sid('filter_btn').on('click', () => {
|
||||
this.args.filter = 'active'; // Change state
|
||||
this.reload(); // Re-fetch with new state
|
||||
});
|
||||
@@ -375,6 +376,50 @@ CONTROL FLOW AND LOOPS
|
||||
%>
|
||||
<p>Total: $<%= total.toFixed(2) %></p>
|
||||
|
||||
COMMENTS IN TEMPLATES
|
||||
JQHTML uses its own comment syntax, NOT HTML comments:
|
||||
|
||||
Correct - JQHTML comments (parser removes, never in output):
|
||||
<%--
|
||||
This is a JQHTML comment
|
||||
Completely removed during compilation
|
||||
Perfect for component documentation
|
||||
--%>
|
||||
|
||||
Incorrect - HTML comments (parser DOES NOT remove):
|
||||
<!--
|
||||
This is an HTML comment
|
||||
Parser treats this as literal HTML
|
||||
Will appear in rendered output
|
||||
Still processes JQHTML directives inside!
|
||||
-->
|
||||
|
||||
Critical difference:
|
||||
HTML comments <!-- --> do NOT block JQHTML directive execution.
|
||||
Code inside HTML comments will still execute, just like PHP code
|
||||
inside HTML comments in .php files still executes.
|
||||
|
||||
WRONG - This WILL execute:
|
||||
<!-- <% dangerous_code(); %> -->
|
||||
|
||||
CORRECT - This will NOT execute:
|
||||
<%-- <% safe_code(); %> --%>
|
||||
|
||||
Component docblocks:
|
||||
Use JQHTML comments at the top of component templates:
|
||||
|
||||
<%--
|
||||
User_Card_Component
|
||||
|
||||
Displays user profile information in a card layout.
|
||||
|
||||
$user_id - ID of user to display
|
||||
$show_avatar - Whether to show profile photo (default: true)
|
||||
--%>
|
||||
<Define:User_Card_Component>
|
||||
<!-- Component template here -->
|
||||
</Define:User_Card_Component>
|
||||
|
||||
COMPONENT LIFECYCLE
|
||||
Five-stage deterministic lifecycle:
|
||||
|
||||
@@ -403,7 +448,7 @@ COMPONENT LIFECYCLE
|
||||
4. on_load() (bottom-up, siblings in parallel, CAN be async)
|
||||
- Load async data based on this.args
|
||||
- ONLY access this.args and this.data (RESTRICTED)
|
||||
- CANNOT access this.$, this.$id(), or any other properties
|
||||
- CANNOT access this.$, this.$sid(), or any other properties
|
||||
- ONLY modify this.data - NEVER DOM
|
||||
- NO child component access
|
||||
- Siblings at same depth execute in parallel
|
||||
@@ -642,7 +687,7 @@ JAVASCRIPT COMPONENT CLASS
|
||||
on_ready() {
|
||||
// All children ready, safe for DOM
|
||||
// Attach event handlers
|
||||
this.$id('select_all').on('click', () => this.select_all());
|
||||
this.$sid('select_all').on('click', () => this.select_all());
|
||||
this.$.animate({opacity: 1}, 300);
|
||||
}
|
||||
|
||||
@@ -846,12 +891,12 @@ $REDRAWABLE ATTRIBUTE - LIGHTWEIGHT COMPONENTS
|
||||
converts the element into a <Redrawable> component:
|
||||
|
||||
<!-- Write this: -->
|
||||
<div $redrawable $id="counter">
|
||||
<div $redrawable $sid="counter">
|
||||
Count: <%= this.data.count %>
|
||||
</div>
|
||||
|
||||
<!-- Parser transforms to: -->
|
||||
<Redrawable data-tag="div" $id="counter">
|
||||
<Redrawable data-tag="div" $sid="counter">
|
||||
Count: <%= this.data.count %>
|
||||
</Redrawable>
|
||||
|
||||
@@ -867,12 +912,12 @@ $REDRAWABLE ATTRIBUTE - LIGHTWEIGHT COMPONENTS
|
||||
async increment_counter() {
|
||||
this.data.count++;
|
||||
// Re-render only the counter element, not entire dashboard
|
||||
this.render('counter'); // Finds child with $id="counter"
|
||||
this.render('counter'); // Finds child with $sid="counter"
|
||||
}
|
||||
}
|
||||
|
||||
render(id) Delegation Syntax:
|
||||
- this.render('counter') finds child with $id="counter"
|
||||
- this.render('counter') finds child with $sid="counter"
|
||||
- Verifies element is a component (has $redrawable or is proper
|
||||
component class)
|
||||
- Calls its render() method to update only that element
|
||||
@@ -881,7 +926,7 @@ $REDRAWABLE ATTRIBUTE - LIGHTWEIGHT COMPONENTS
|
||||
- Parent component's DOM remains unchanged
|
||||
|
||||
Error Handling:
|
||||
- Clear error if $id doesn't exist in children
|
||||
- Clear error if $sid doesn't exist in children
|
||||
- Clear error if element isn't configured as component
|
||||
- Guides developers to correct usage patterns
|
||||
|
||||
@@ -922,7 +967,7 @@ LIFECYCLE MANIPULATION METHODS
|
||||
}
|
||||
|
||||
on_ready() {
|
||||
this.$id('filter_btn').on('click', async () => {
|
||||
this.$sid('filter_btn').on('click', async () => {
|
||||
this.args.filter = 'active'; // Update state
|
||||
await this.reload(); // Re-fetch with new state
|
||||
});
|
||||
@@ -1047,26 +1092,26 @@ DOM UTILITIES
|
||||
jQuery wrapped component root element
|
||||
This is genuine jQuery - all methods work directly
|
||||
|
||||
this.$id(name)
|
||||
this.$sid(name)
|
||||
Get scoped element as jQuery object
|
||||
Example: this.$id('edit') gets element with $id="edit"
|
||||
Example: this.$sid('edit') gets element with $sid="edit"
|
||||
Returns jQuery object, NOT component instance
|
||||
|
||||
this.id(name)
|
||||
this.sid(name)
|
||||
Get scoped child component instance directly
|
||||
Example: this.id('my_component') gets component instance
|
||||
Example: this.sid('my_component') gets component instance
|
||||
Returns component instance, NOT jQuery object
|
||||
|
||||
CRITICAL: this.$id() vs this.id() distinction
|
||||
- this.$id('foo') → jQuery object (for DOM manipulation)
|
||||
- this.id('foo') → Component instance (for calling methods)
|
||||
CRITICAL: this.$sid() vs this.sid() distinction
|
||||
- this.$sid('foo') → jQuery object (for DOM manipulation)
|
||||
- this.sid('foo') → Component instance (for calling methods)
|
||||
|
||||
Common mistake:
|
||||
const comp = this.id('foo').component(); // ❌ WRONG
|
||||
const comp = this.id('foo'); // ✅ CORRECT
|
||||
const comp = this.sid('foo').component(); // ❌ WRONG
|
||||
const comp = this.sid('foo'); // ✅ CORRECT
|
||||
|
||||
Getting component from jQuery:
|
||||
const $elem = this.$id('foo');
|
||||
const $elem = this.$sid('foo');
|
||||
const comp = $elem.component(); // ✅ CORRECT (jQuery → component)
|
||||
|
||||
this.data
|
||||
@@ -1105,13 +1150,13 @@ NESTING COMPONENTS
|
||||
on_load, on_ready).
|
||||
|
||||
SCOPED IDS
|
||||
Use $id attribute for component-scoped element IDs:
|
||||
Use $sid attribute for component-scoped element IDs:
|
||||
|
||||
Template:
|
||||
<Define:User_Card>
|
||||
<h3 $id="title">Name</h3>
|
||||
<p $id="email">Email</p>
|
||||
<button $id="edit_btn">Edit</button>
|
||||
<h3 $sid="title">Name</h3>
|
||||
<p $sid="email">Email</p>
|
||||
<button $sid="edit_btn">Edit</button>
|
||||
</Define:User_Card>
|
||||
|
||||
Rendered HTML (automatic scoping):
|
||||
@@ -1121,13 +1166,13 @@ SCOPED IDS
|
||||
<button id="edit_btn:c123">Edit</button>
|
||||
</div>
|
||||
|
||||
Access with this.$id():
|
||||
Access with this.$sid():
|
||||
class User_Card extends Jqhtml_Component {
|
||||
on_ready() {
|
||||
// Use logical name
|
||||
this.$id('title').text('John Doe');
|
||||
this.$id('email').text('john@example.com');
|
||||
this.$id('edit_btn').on('click', () => this.edit());
|
||||
this.$sid('title').text('John Doe');
|
||||
this.$sid('email').text('john@example.com');
|
||||
this.$sid('edit_btn').on('click', () => this.edit());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1148,12 +1193,12 @@ EXAMPLES
|
||||
|
||||
on_ready() {
|
||||
// Attach event handlers after data loaded
|
||||
this.$id('buy').on('click', async () => {
|
||||
this.$sid('buy').on('click', async () => {
|
||||
await Cart.add(this.data.id);
|
||||
this.$id('buy').text('Added!').prop('disabled', true);
|
||||
this.$sid('buy').text('Added!').prop('disabled', true);
|
||||
});
|
||||
|
||||
this.$id('favorite').on('click', () => {
|
||||
this.$sid('favorite').on('click', () => {
|
||||
this.$.toggleClass('favorited');
|
||||
});
|
||||
}
|
||||
@@ -1208,19 +1253,19 @@ EXAMPLES
|
||||
}
|
||||
|
||||
validate() {
|
||||
const email = this.$id('email').val();
|
||||
const email = this.$sid('email').val();
|
||||
if (!email.includes('@')) {
|
||||
this.$id('error').text('Invalid email');
|
||||
this.$sid('error').text('Invalid email');
|
||||
return false;
|
||||
}
|
||||
this.$id('error').text('');
|
||||
this.$sid('error').text('');
|
||||
return true;
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const data = {
|
||||
email: this.$id('email').val(),
|
||||
message: this.$id('message').val(),
|
||||
email: this.$sid('email').val(),
|
||||
message: this.$sid('message').val(),
|
||||
};
|
||||
|
||||
await fetch('/contact', {
|
||||
@@ -1391,6 +1436,70 @@ CONTENT AND SLOTS
|
||||
- Form patterns with customizable field sets
|
||||
- Any component hierarchy with shared structure
|
||||
|
||||
TEMPLATE-ONLY COMPONENTS
|
||||
Components can exist as .jqhtml files without a companion .js file.
|
||||
This is fine for simple display-only components that just render
|
||||
input data with conditionals - no lifecycle hooks or event handlers
|
||||
needed beyond what's possible inline.
|
||||
|
||||
When to use template-only:
|
||||
- Component just displays data passed via arguments
|
||||
- Only needs simple conditionals in the template
|
||||
- No complex event handling beyond simple button clicks
|
||||
- Mentally easier than creating a separate .js file
|
||||
|
||||
Inline Event Handlers:
|
||||
Define handlers in template code, reference with @event syntax:
|
||||
|
||||
<Define:Retry_Button>
|
||||
<% this.handle_click = () => window.location.reload(); %>
|
||||
<button class="btn btn-primary" @click=this.handle_click>
|
||||
Retry
|
||||
</button>
|
||||
</Define:Retry_Button>
|
||||
|
||||
Note: @event values must be UNQUOTED (not @click="this.method").
|
||||
|
||||
Inline Argument Validation:
|
||||
Throw errors early if required arguments are missing:
|
||||
|
||||
<Define:User_Badge>
|
||||
<%
|
||||
if (!this.args.user_id) {
|
||||
throw new Error('User_Badge: $user_id is required');
|
||||
}
|
||||
if (!this.args.name) {
|
||||
throw new Error('User_Badge: $name is required');
|
||||
}
|
||||
%>
|
||||
<span class="badge"><%= this.args.name %></span>
|
||||
</Define:User_Badge>
|
||||
|
||||
Complete Example (error page component):
|
||||
<%--
|
||||
Not_Found_Error_Page_Component
|
||||
Displays when a record cannot be found.
|
||||
--%>
|
||||
<Define:Not_Found_Error_Page_Component class="text-center py-5">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill text-warning"
|
||||
style="font-size: 4rem;"></i>
|
||||
</div>
|
||||
<h3 class="mb-3"><%= this.args.record_type %> Not Found</h3>
|
||||
<p class="text-muted mb-4">
|
||||
The <%= this.args.record_type.toLowerCase() %> you are
|
||||
looking for does not exist or has been deleted.
|
||||
</p>
|
||||
<a href="<%= this.args.back_url %>" class="btn btn-primary">
|
||||
<%= this.args.back_label %>
|
||||
</a>
|
||||
</Define:Not_Found_Error_Page_Component>
|
||||
|
||||
This pattern is not necessarily "best practice" for complex components,
|
||||
but it works well and is pragmatic for simple display components. If
|
||||
the component needs lifecycle hooks, state management, or complex
|
||||
event handling, create a companion .js file instead.
|
||||
|
||||
INTEGRATION WITH RSX
|
||||
JQHTML automatically integrates with the RSX framework:
|
||||
- Templates discovered by manifest system during build
|
||||
|
||||
230
app/RSpade/man/view_action_patterns.txt
Executable file
230
app/RSpade/man/view_action_patterns.txt
Executable file
@@ -0,0 +1,230 @@
|
||||
VIEW_ACTION_PATTERNS(3) RSX Manual VIEW_ACTION_PATTERNS(3)
|
||||
|
||||
NAME
|
||||
view_action_patterns - Best practices for SPA view actions with
|
||||
dynamic content loading
|
||||
|
||||
SYNOPSIS
|
||||
Recommended pattern for view/detail pages that load a single record:
|
||||
|
||||
Action Class (Feature_View_Action.js):
|
||||
@route('/feature/view/:id')
|
||||
@layout('Frontend_Spa_Layout')
|
||||
@spa('Frontend_Spa_Controller::index')
|
||||
@title('Feature Details')
|
||||
class Feature_View_Action extends Spa_Action {
|
||||
on_create() {
|
||||
this.data.record = { name: '' };
|
||||
this.data.error_data = null;
|
||||
this.data.loading = true;
|
||||
}
|
||||
|
||||
async on_load() {
|
||||
try {
|
||||
this.data.record = await Controller.get({
|
||||
id: this.args.id
|
||||
});
|
||||
} catch (e) {
|
||||
this.data.error_data = e;
|
||||
}
|
||||
this.data.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
Template (Feature_View_Action.jqhtml):
|
||||
<Define:Feature_View_Action>
|
||||
<Page>
|
||||
<% if (this.data.loading) { %>
|
||||
<Loading_Spinner $message="Loading..." />
|
||||
<% } else if (this.data.error_data) { %>
|
||||
<Universal_Error_Page_Component
|
||||
$error_data="<%= this.data.error_data %>"
|
||||
$record_type="Feature"
|
||||
$back_label="Go back to Features"
|
||||
$back_url="<%= Rsx.Route('Feature_Index_Action') %>"
|
||||
/>
|
||||
<% } else { %>
|
||||
<!-- Normal content here -->
|
||||
<% } %>
|
||||
</Page>
|
||||
</Define:Feature_View_Action>
|
||||
|
||||
DESCRIPTION
|
||||
This document describes the recommended pattern for building view/detail
|
||||
pages in RSpade SPA applications. The pattern provides:
|
||||
|
||||
- Loading state with spinner during data fetch
|
||||
- Automatic error handling for all Ajax error types
|
||||
- Clean three-state template (loading → error → content)
|
||||
- Consistent user experience across all view pages
|
||||
|
||||
This is an opinionated best practice from the RSpade starter template.
|
||||
Developers are free to implement alternatives, but this pattern handles
|
||||
common cases well and provides a consistent structure.
|
||||
|
||||
THE THREE-STATE PATTERN
|
||||
Every view action has exactly three possible states:
|
||||
|
||||
1. LOADING - Data is being fetched
|
||||
- Show Loading_Spinner component
|
||||
- Prevents flash of empty/broken content
|
||||
- User knows something is happening
|
||||
|
||||
2. ERROR - Data fetch failed
|
||||
- Show Universal_Error_Page_Component
|
||||
- Automatically routes to appropriate error display
|
||||
- Handles not found, unauthorized, server errors, etc.
|
||||
|
||||
3. SUCCESS - Data loaded successfully
|
||||
- Show normal page content
|
||||
- Safe to access this.data.record properties
|
||||
|
||||
Template structure:
|
||||
<% if (this.data.loading) { %>
|
||||
<!-- State 1: Loading -->
|
||||
<% } else if (this.data.error_data) { %>
|
||||
<!-- State 2: Error -->
|
||||
<% } else { %>
|
||||
<!-- State 3: Success -->
|
||||
<% } %>
|
||||
|
||||
ACTION CLASS STRUCTURE
|
||||
on_create() - Initialize defaults
|
||||
on_create() {
|
||||
// Stub object prevents undefined errors during first render
|
||||
this.data.record = { name: '' };
|
||||
|
||||
// Error holder - null means no error
|
||||
this.data.error_data = null;
|
||||
|
||||
// Start in loading state
|
||||
this.data.loading = true;
|
||||
}
|
||||
|
||||
Key points:
|
||||
- Initialize this.data.record with empty stub matching expected shape
|
||||
- Prevents "cannot read property of undefined" during initial render
|
||||
- Set loading=true so spinner shows immediately
|
||||
|
||||
async on_load() - Fetch data with error handling
|
||||
async on_load() {
|
||||
try {
|
||||
this.data.record = await Controller.get({
|
||||
id: this.args.id
|
||||
});
|
||||
} catch (e) {
|
||||
this.data.error_data = e;
|
||||
}
|
||||
this.data.loading = false;
|
||||
}
|
||||
|
||||
Key points:
|
||||
- Wrap Ajax call in try/catch
|
||||
- Store caught error in this.data.error_data (not re-throw)
|
||||
- Always set loading=false in finally or after try/catch
|
||||
- Error object has .code, .message, .metadata from Ajax system
|
||||
|
||||
UNIVERSAL ERROR COMPONENT
|
||||
The Universal_Error_Page_Component automatically displays the right
|
||||
error UI based on error.code:
|
||||
|
||||
Required arguments:
|
||||
$error_data - The error object from catch block
|
||||
$record_type - Human name: "Project", "Contact", "User"
|
||||
$back_label - Button text: "Go back to Projects"
|
||||
$back_url - Where back button navigates
|
||||
|
||||
Error types handled:
|
||||
Ajax.ERROR_NOT_FOUND → "Project Not Found" with back button
|
||||
Ajax.ERROR_UNAUTHORIZED → "Access Denied" message
|
||||
Ajax.ERROR_AUTH_REQUIRED → "Login Required" with login button
|
||||
Ajax.ERROR_SERVER → "Server Error" with retry button
|
||||
Ajax.ERROR_NETWORK → "Connection Error" with retry button
|
||||
Ajax.ERROR_VALIDATION → Field error list
|
||||
Ajax.ERROR_GENERIC → Generic error with retry
|
||||
|
||||
HANDLING SPECIFIC ERRORS DIFFERENTLY
|
||||
Sometimes you need custom handling for specific error types while
|
||||
letting others go to the universal handler:
|
||||
|
||||
async on_load() {
|
||||
try {
|
||||
this.data.record = await Controller.get({id: this.args.id});
|
||||
} catch (e) {
|
||||
if (e.code === Ajax.ERROR_NOT_FOUND) {
|
||||
// Custom handling: redirect to create page
|
||||
Spa.dispatch(Rsx.Route('Feature_Create_Action'));
|
||||
return;
|
||||
}
|
||||
// All other errors: use universal handler
|
||||
this.data.error_data = e;
|
||||
}
|
||||
this.data.loading = false;
|
||||
}
|
||||
|
||||
Or in the template for different error displays:
|
||||
|
||||
<% } else if (this.data.error_data) { %>
|
||||
<% if (this.data.error_data.code === Ajax.ERROR_NOT_FOUND) { %>
|
||||
<!-- Custom not-found UI -->
|
||||
<div class="text-center">
|
||||
<p>This project doesn't exist yet.</p>
|
||||
<a href="..." class="btn btn-primary">Create It</a>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<!-- Standard error handling -->
|
||||
<Universal_Error_Page_Component ... />
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
|
||||
LOADING SPINNER
|
||||
The Loading_Spinner component provides consistent loading UI:
|
||||
|
||||
<Loading_Spinner />
|
||||
<Loading_Spinner $message="Loading project details..." />
|
||||
|
||||
Located at: rsx/theme/components/feedback/loading_spinner.jqhtml
|
||||
|
||||
COMPLETE EXAMPLE
|
||||
From rsx/app/frontend/projects/Projects_View_Action.js:
|
||||
|
||||
@route('/projects/view/:id')
|
||||
@layout('Frontend_Spa_Layout')
|
||||
@spa('Frontend_Spa_Controller::index')
|
||||
@title('Project Details')
|
||||
class Projects_View_Action extends Spa_Action {
|
||||
on_create() {
|
||||
this.data.project = { name: '' };
|
||||
this.data.error_data = null;
|
||||
this.data.loading = true;
|
||||
}
|
||||
|
||||
async on_load() {
|
||||
try {
|
||||
this.data.project = await Frontend_Projects_Controller
|
||||
.get_project({ id: this.args.id });
|
||||
} catch (e) {
|
||||
this.data.error_data = e;
|
||||
}
|
||||
this.data.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
The template uses the three-state pattern with full page content
|
||||
in the success state. See the actual file for complete template.
|
||||
|
||||
WHEN TO USE THIS PATTERN
|
||||
Use for:
|
||||
- Detail/view pages loading a single record by ID
|
||||
- Edit pages that need to load existing data
|
||||
- Any page where initial data might not exist or be accessible
|
||||
|
||||
Not needed for:
|
||||
- List pages (DataGrid handles its own loading/error states)
|
||||
- Create pages (no existing data to load)
|
||||
- Static pages without dynamic data
|
||||
|
||||
SEE ALSO
|
||||
spa(3), ajax_error_handling(3), jqhtml(3)
|
||||
|
||||
RSX Framework 2025-11-21 VIEW_ACTION_PATTERNS(3)
|
||||
Reference in New Issue
Block a user