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

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

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

755 lines
30 KiB
JavaScript
Executable File

/**
* Spa - Single Page Application orchestrator for RSpade
*
* This class manages the Spa lifecycle:
* - Auto-discovers action classes extending Spa_Action
* - Extracts route information from decorator metadata
* - Registers routes with the router
* - Dispatches to the current URL
*
* Initialization happens automatically during the on_app_init phase when
* window.rsxapp.is_spa === true.
*
* Unlike JQHTML, this is a static class (not a Component) following the RS3 pattern.
*/
class Spa {
// Registered routes: { pattern: action_class }
static routes = {};
// Current layout instance
static layout = null;
// Current action instance
static action = null;
// Current route instance
static route = null;
// Current route 'params'
static params = null;
// Flag to prevent re-entrant dispatch
static is_dispatching = false;
// Pending redirect that occurred during dispatch (e.g., in action on_load)
static pending_redirect = null;
// 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
*/
static disable() {
console.warn('[Spa] SPA navigation disabled - browser navigation mode active');
Spa._spa_enabled = false;
}
/**
* Re-enable SPA navigation after it was disabled
* Call this after forms are saved or errors are resolved
*/
static enable() {
console.log('[Spa] SPA navigation enabled');
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
*/
static _on_framework_modules_init() {
// Only initialize Spa if we're in a Spa route
if (!window.rsxapp || !window.rsxapp.is_spa) {
return;
}
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();
// Setup browser integration using History API
// Note: Navigation API evaluated but not mature enough for production use
// See: /docs.dev/SPA_BROWSER_INTEGRATION.md for details
console.log('[Spa] Using History API for browser integration');
Spa.setup_browser_integration();
// Dispatch to current URL (including hash for initial load)
const initial_url = window.location.pathname + window.location.search + window.location.hash;
console_debug('Spa', 'Dispatching to initial URL: ' + initial_url);
Spa.dispatch(initial_url, { history: 'none' });
}
/**
* Discover all classes extending Spa_Action and register their routes
*/
static discover_actions() {
const all_classes = Manifest.get_all_classes();
let action_count = 0;
for (const class_info of all_classes) {
const class_object = class_info.class_object;
const class_name = class_info.class_name;
// Check if this class extends Spa_Action
if (class_object.prototype instanceof Spa_Action || class_object === Spa_Action) {
// Skip the base class itself
if (class_object === Spa_Action) {
continue;
}
// Extract route patterns from decorator metadata
const routes = class_object._spa_routes || [];
if (routes.length === 0) {
console.warn(`Spa: Action ${class_name} has no routes defined`);
continue;
}
// Register each route pattern
for (const pattern of routes) {
Spa.register_route(pattern, class_object);
console_debug('Spa', `Registered route: ${pattern}${class_name}`);
}
action_count++;
}
}
console_debug('Spa', `Discovered ${action_count} action classes`);
}
/**
* Register a route pattern to an action class
*/
static register_route(pattern, action_class) {
// Normalize pattern - remove trailing /index
if (pattern.endsWith('/index')) {
pattern = pattern.slice(0, -6) || '/';
}
// Check for duplicates in dev mode
if (Rsx.is_dev() && Spa.routes[pattern]) {
console.error(`Spa: Duplicate route '${pattern}' - ${action_class.name} conflicts with ${Spa.routes[pattern].name}`);
}
Spa.routes[pattern] = action_class;
}
/**
* Match URL to a route and extract parameters
* Returns: { action_class, args, layout } or null
*/
static match_url_to_route(url) {
// Parse URL to get path and query params
const parsed = Spa.parse_url(url);
let path = parsed.path;
// Normalize path - remove leading/trailing slashes for matching
path = path.substring(1); // Remove leading /
// Remove /index suffix
if (path === 'index' || path.endsWith('/index')) {
path = path.slice(0, -5) || '';
}
// Try exact match first
const exact_pattern = '/' + path;
if (Spa.routes[exact_pattern]) {
return {
action_class: Spa.routes[exact_pattern],
args: parsed.query_params,
layout: Spa.routes[exact_pattern]._spa_layout || 'Default_Layout',
};
}
// Try pattern matching with :param segments
for (const pattern in Spa.routes) {
const match = Spa.match_pattern(path, pattern);
if (match) {
// Merge parameters with correct priority order:
// 1. GET parameters (from query string, lowest priority)
// 2. URL route parameters (extracted from route pattern like :id, highest priority)
// This matches the PHP Dispatcher behavior where route params override GET params
const args = { ...parsed.query_params, ...match };
return {
action_class: Spa.routes[pattern],
args: args,
layout: Spa.routes[pattern]._spa_layout || 'Default_Layout',
};
}
}
// No match found
return null;
}
/**
* Match a path against a pattern with :param segments
* Returns object with extracted params or null if no match
*/
static match_pattern(path, pattern) {
// Remove leading / from both
path = path.replace(/^\//, '');
pattern = pattern.replace(/^\//, '');
// Split into segments
const path_segments = path.split('/');
const pattern_segments = pattern.split('/');
// Must have same number of segments
if (path_segments.length !== pattern_segments.length) {
return null;
}
const params = {};
for (let i = 0; i < pattern_segments.length; i++) {
const pattern_seg = pattern_segments[i];
const path_seg = path_segments[i];
if (pattern_seg.startsWith(':')) {
// This is a parameter - extract it
const param_name = pattern_seg.substring(1);
params[param_name] = decodeURIComponent(path_seg);
} else {
// This is a literal - must match exactly
if (pattern_seg !== path_seg) {
return null;
}
}
}
return params;
}
/**
* Parse URL into components
*/
static parse_url(url) {
let parsed_url;
try {
if (url.startsWith('http://') || url.startsWith('https://')) {
parsed_url = new URL(url);
} else {
parsed_url = new URL(url, window.location.href);
}
} catch (e) {
parsed_url = new URL(window.location.href);
}
const path = parsed_url.pathname;
const search = parsed_url.search;
// Parse query string
const query_params = {};
if (search && search !== '?') {
const query = search.startsWith('?') ? search.substring(1) : search;
const pairs = query.split('&');
for (const pair of pairs) {
const [key, value] = pair.split('=');
if (key) {
query_params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
}
}
}
return { path, search, query_params };
}
/**
* Generate URL from pattern and parameters
*/
static generate_url_from_pattern(pattern, params = {}) {
let url = pattern;
const used_params = new Set();
// Replace :param placeholders
url = url.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, param_name) => {
if (params.hasOwnProperty(param_name)) {
used_params.add(param_name);
return encodeURIComponent(params[param_name]);
}
return '';
});
// Collect unused parameters for query string
const query_params = {};
for (const key in params) {
if (!used_params.has(key)) {
query_params[key] = params[key];
}
}
// Add query string if needed
if (Object.keys(query_params).length > 0) {
const query_string = Object.entries(query_params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
url += '?' + query_string;
}
return url;
}
/**
* Setup browser integration for back/forward and link interception
*
* This implements Phase 1 of browser integration using the History API.
* See: /docs.dev/SPA_BROWSER_INTEGRATION.md for complete documentation.
*
* Key Behaviors:
* - Intercepts clicks on <a> tags for same-domain SPA routes
* - Preserves standard browser behaviors (Ctrl+click, target="_blank", etc.)
* - Handles back/forward navigation with scroll restoration
* - Hash-only changes don't create history entries
* - Defers to server for edge cases (external links, non-SPA routes, etc.)
*/
static setup_browser_integration() {
console_debug('Spa', 'Setting up browser integration (History API mode)');
// Handle browser back/forward buttons
window.addEventListener('popstate', (e) => {
console_debug('Spa', 'popstate event fired (back/forward navigation)');
console.warn('[Spa.dispatch] Handling history popstate event', {
url: window.location.pathname + window.location.search + window.location.hash,
state: e.state
});
// Get target URL (browser has already updated location)
const url = window.location.pathname + window.location.search + window.location.hash;
// Retrieve scroll position from history state
const scroll = e.state?.scroll || null;
// TODO: Form Data Restoration
// Retrieve form data from history state and restore after action renders
// Implementation notes:
// - Get form_data from e.state?.form_data
// - After action on_ready(), find all form inputs
// - Restore values from form_data object
// - Trigger change events for restored fields
// - Handle edge cases:
// * Dynamic forms (loaded via Ajax) - need to wait for form to exist
// * File inputs (cannot be programmatically set for security)
// * Custom components (need vals() method for restoration)
// * Timing (must restore after form renders, possibly in on_ready)
// 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,
force: true
});
});
// Intercept link clicks using event delegation
document.addEventListener('click', (e) => {
// Find <a> tag in event path (handles clicks on child elements)
let link = e.target.closest('a');
if (!link) {
return;
}
const href = link.getAttribute('href');
// Ignore if:
// - No href
// - Ctrl/Cmd/Meta is pressed (open in new tab)
// - Has target attribute
// - Not left click (button 0)
// - Is empty or hash-only (#)
if (!href ||
e.ctrlKey ||
e.metaKey ||
link.getAttribute('target') ||
e.button !== 0 ||
href === '' ||
href === '#') {
return;
}
// Parse URLs for comparison
const current_parsed = Spa.parse_url(window.location.href);
const target_parsed = Spa.parse_url(href);
// If same page (same path + search), let browser handle (causes reload)
// This mimics non-SPA behavior where clicking current page refreshes
if (current_parsed.path === target_parsed.path &&
current_parsed.search === target_parsed.search) {
console_debug('Spa', 'Same page click, letting browser reload');
return;
}
// Only intercept same-domain links
if (current_parsed.host !== target_parsed.host) {
console_debug('Spa', 'External domain link, letting browser handle: ' + href);
return;
}
// Check if target URL matches a Spa route
if (Spa.match_url_to_route(href)) {
// Check if SPA is enabled
if (!Spa._spa_enabled) {
console_debug('Spa', 'SPA disabled, letting browser handle: ' + href);
return; // Don't preventDefault - browser navigates normally
}
console_debug('Spa', 'Intercepting link click: ' + href);
e.preventDefault();
Spa.dispatch(href, { history: 'auto' });
} else {
console_debug('Spa', 'No SPA route match, letting server handle: ' + href);
}
});
}
/**
* Main dispatch method - navigate to a URL
*
* This is the single entry point for all navigation within the SPA.
* Handles route matching, history management, layout/action lifecycle, and scroll restoration.
*
* @param {string} url - Target URL (relative or absolute)
* @param {object} options - Navigation options
* @param {string} options.history - 'auto'|'push'|'replace'|'none' (default: 'auto')
* - 'auto': Push for new URLs, replace for same URL
* - 'push': Always create new history entry
* - 'replace': Replace current history entry
* - '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
// 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;
}
if (Spa.is_dispatching) {
// Already dispatching - queue this as a pending redirect
// This commonly happens when an action redirects in on_load()
console_debug('Spa', 'Nested dispatch detected, queuing pending redirect: ' + url);
Spa.pending_redirect = { url, options };
return;
}
Spa.is_dispatching = true;
try {
const opts = {
history: options.history || 'auto',
scroll: options.scroll || null,
triggers: options.triggers !== false,
};
console_debug('Spa', 'Dispatching to: ' + url + ' (history: ' + opts.history + ')');
// Handle fully qualified URLs
const current_domain = window.location.hostname;
if (url.startsWith('http://') || url.startsWith('https://')) {
try {
const parsed_url = new URL(url);
// Check if different domain
if (parsed_url.hostname !== current_domain) {
// External domain - navigate away
console_debug('Spa', 'External domain, navigating: ' + url);
console.warn('[Spa.dispatch] Executing document.location.href (external domain)', {
url: url,
reason: 'External domain'
});
document.location.href = url;
Spa.is_dispatching = false;
return;
}
// Same domain - strip to relative URL
url = parsed_url.pathname + parsed_url.search + parsed_url.hash;
console_debug('Spa', 'Same domain, stripped to relative: ' + url);
} catch (e) {
console.error('Spa: Invalid URL format:', url);
Spa.is_dispatching = false;
return;
}
}
// Parse the (now relative) URL
const parsed = Spa.parse_url(url);
// CRITICAL: Strip hash from URL before route matching
// Hash represents page state (e.g., DataGrid page number), not routing state
// Hash is preserved in browser URL bar but not used for route matching
const url_without_hash = parsed.path + parsed.search;
console_debug('Spa', 'URL for route matching (hash stripped): ' + url_without_hash);
// Try to match URL to a route (without hash)
const route_match = Spa.match_url_to_route(url_without_hash);
// Check if this is the same URL we're currently on (without hash)
const current_url = window.location.pathname + window.location.search;
const is_same_url = url_without_hash === current_url;
// Same URL navigation with history: 'auto' should reload via server
// This mimics non-SPA behavior where clicking current page refreshes
if (is_same_url && opts.history === 'auto') {
console_debug('Spa', 'Same URL with auto history, letting browser reload');
console.warn('[Spa.dispatch] Executing document.location.href (same URL reload)', {
url: url,
reason: 'Same URL with auto history - mimics browser reload behavior'
});
document.location.href = url;
Spa.is_dispatching = false;
return;
}
if (is_same_url && !route_match) {
// We're being asked to navigate to the current URL, but it doesn't match
// any known route. This shouldn't happen - prevents infinite redirect loop.
Spa.spa_unknown_route_fatal(parsed.path);
Spa.is_dispatching = false;
return;
}
// If no route match and we're not on this URL, let server handle it
if (!route_match) {
console_debug('Spa', 'No route matched, letting server handle: ' + url);
console.warn('[Spa.dispatch] Executing document.location.href (no route match)', {
url: url,
reason: 'URL does not match any registered SPA routes'
});
document.location.href = url;
Spa.is_dispatching = false;
return;
}
console_debug('Spa', 'Route match:', {
action_class: route_match?.action_class?.name,
args: route_match?.args,
layout: route_match?.layout,
});
// Check if action's @spa() attribute matches current SPA bootstrap
const action_spa_controller = route_match.action_class._spa_controller_method;
const current_spa_controller = window.rsxapp.current_controller + '::' + window.rsxapp.current_action;
if (action_spa_controller && action_spa_controller !== current_spa_controller) {
// Different SPA module - let server bootstrap it
console_debug('Spa', 'Different SPA module, letting server handle: ' + url);
console_debug('Spa', ` Action uses: ${action_spa_controller}`);
console_debug('Spa', ` Current SPA: ${current_spa_controller}`);
console.warn('[Spa.dispatch] Executing document.location.href (different SPA module)', {
url: url,
reason: 'Action belongs to different SPA module/bundle',
action_spa_controller: action_spa_controller,
current_spa_controller: current_spa_controller
});
document.location.href = url;
Spa.is_dispatching = false;
return;
}
// Update browser history with scroll position storage
if (opts.history !== 'none') {
// Store current scroll position before navigation
const current_scroll = {
x: window.scrollX || window.pageXOffset,
y: window.scrollY || window.pageYOffset
};
// Build history state object
const state = {
scroll: current_scroll,
form_data: {} // Reserved for future form state restoration
};
// Construct full URL with hash (hash is preserved in browser URL bar)
const new_url = parsed.path + parsed.search + (parsed.hash || '');
if (opts.history === 'push' || (opts.history === 'auto' && !is_same_url)) {
console_debug('Spa', 'Pushing history state');
history.pushState(state, '', new_url);
} else if (opts.history === 'replace' || (opts.history === 'auto' && is_same_url)) {
console_debug('Spa', 'Replacing history state');
history.replaceState(state, '', new_url);
}
}
// Set global Spa state
Spa.route = route_match;
Spa.path = parsed.path;
Spa.params = route_match.args;
// Get layout name and action info
const layout_name = route_match.layout;
const action_class = route_match.action_class;
const action_name = action_class.name;
// Log successful SPA navigation
console.warn('[Spa.dispatch] Executing SPA navigation', {
url: url,
path: parsed.path,
params: route_match.args,
action: action_name,
layout: layout_name,
history_mode: opts.history
});
// Check if we need a new layout
if (!Spa.layout || Spa.layout.constructor.name !== layout_name) {
// Stop old layout if exists (auto-stops children)
if (Spa.layout) {
await Spa.layout.trigger('unload');
Spa.layout.stop();
}
// 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 = $spa_root.component(layout_name, {}).component();
// Wait for layout to be ready
await Spa.layout.ready();
console_debug('Spa', `Created layout: ${layout_name}`);
} else {
// Wait for layout to finish previous action if still loading
await Spa.layout.ready();
}
// Tell layout to run the action
Spa.layout._set_action(action_name, route_match.args, url);
await Spa.layout._run_action();
// Scroll Restoration #1: Immediate (after action starts)
// This occurs synchronously after the action component is created
// May fail if page height is insufficient - that's okay, we'll retry later
if (opts.scroll) {
console_debug('Spa', 'Restoring scroll position (immediate): ' + opts.scroll.x + ', ' + opts.scroll.y);
window.scrollTo(opts.scroll.x, opts.scroll.y);
} else if (opts.scroll === undefined) {
// Default: scroll to top for new navigation (only if scroll not explicitly set)
console_debug('Spa', 'Scrolling to top (new navigation)');
window.scrollTo(0, 0);
}
// If opts.scroll === null, don't scroll (let Navigation API or browser handle it)
// TODO: Scroll Restoration #2 - After action on_ready() completes
// This requires an action lifecycle event system to detect when on_ready() finishes.
// Implementation notes:
// - Listen for action on_ready completion event
// - Only retry scroll if restoration #1 failed (page wasn't tall enough)
// - Check if page height has increased since first attempt
// - Avoid infinite retry loops (track attempts)
// - This ensures scroll restoration works even when content loads asynchronously
//
// Additional context: The action may load data in on_load() that increases page height,
// making the target scroll position accessible. The first restoration happens before
// this content renders, so we need a second attempt after the page is fully ready.
console_debug('Spa', `Rendered ${action_name} in ${layout_name}`);
} catch (error) {
console.error('[Spa] Dispatch error:', error);
// TODO: Better error handling - show error UI to user
throw error;
} finally {
Spa.is_dispatching = false;
// Check if a redirect was queued during this dispatch
if (Spa.pending_redirect) {
const redirect = Spa.pending_redirect;
Spa.pending_redirect = null;
console_debug('Spa', 'Processing pending redirect: ' + redirect.url);
// Execute the queued redirect immediately
// This will destroy the action that triggered the redirect
await Spa.dispatch(redirect.url, redirect.options);
}
}
}
/**
* Fatal error when trying to navigate to unknown route on current URL
* This shouldn't happen - prevents infinite redirect loops
*/
static spa_unknown_route_fatal(path) {
console.error(`Unknown route for path ${path} - this shouldn't happen`);
}
}