Framework updates

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2026-02-01 05:16:45 +00:00
parent f48cda006a
commit 0efdcd4cde
27 changed files with 2970 additions and 153 deletions

405
app/RSpade/Core/Js/Rsx_Portal.js Executable file
View File

@@ -0,0 +1,405 @@
// @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
/**
* Rsx_Portal - Client Portal JavaScript Runtime Utilities
*
* Provides static utility methods for client portal JavaScript code including
* route generation and portal context detection.
*
* Key differences from Rsx:
* - Route() prepends portal domain/prefix
* - Portal-specific context detection
* - Simpler (no event system, fewer utilities)
*
* Usage Examples:
* ```javascript
* // Check if in portal context
* if (Rsx_Portal.is_portal()) { ... }
*
* // Route generation (applies portal prefix/domain)
* const url = Rsx_Portal.Route('Portal_Dashboard_Action');
* // Development: /_portal/dashboard
* // Production: /dashboard (on portal domain)
*
* // Route with parameters
* const url = Rsx_Portal.Route('Portal_Project_View_Action', 123);
* // Development: /_portal/projects/123
* ```
*
* @static
* @global
*/
class Rsx_Portal {
/**
* URL prefix for portal when no dedicated domain configured
* Must match PHP Rsx_Portal::URL_PREFIX
*/
static URL_PREFIX = '/_portal';
/**
* Storage for portal route definitions loaded from bundles
*/
static _routes = {};
/**
* Cached portal context detection result
*/
static _is_portal = null;
// =========================================================================
// Configuration Methods
// =========================================================================
/**
* Get the configured portal domain
*
* @returns {string|null} Domain if configured, null otherwise
*/
static get_domain() {
return window.rsxapp?.portal?.domain || null;
}
/**
* Get the portal URL prefix (used when no domain configured)
*
* @returns {string} The prefix, defaults to '/_portal'
*/
static get_prefix() {
return window.rsxapp?.portal?.prefix || Rsx_Portal.URL_PREFIX;
}
/**
* Check if portal is using a dedicated domain (vs URL prefix)
*
* @returns {boolean} True if dedicated domain is configured
*/
static has_dedicated_domain() {
return !!Rsx_Portal.get_domain();
}
// =========================================================================
// Portal Context Detection
// =========================================================================
/**
* Check if currently in portal context
*
* @returns {boolean} True if this is a portal page
*/
static is_portal() {
if (Rsx_Portal._is_portal !== null) {
return Rsx_Portal._is_portal;
}
// Check rsxapp flag (set by portal bootstrap)
Rsx_Portal._is_portal = !!window.rsxapp?.is_portal;
return Rsx_Portal._is_portal;
}
/**
* Get the current portal user
*
* @returns {Object|null} Portal user data or null if not logged in
*/
static user() {
if (!Rsx_Portal.is_portal()) {
return null;
}
return window.rsxapp?.user || null;
}
// =========================================================================
// Route Generation
// =========================================================================
/**
* Generate URL for a portal route
*
* Similar to Rsx.Route() but:
* - Returns URLs with portal domain or prefix
* - Only works with portal routes
*
* Usage examples:
* ```javascript
* // Portal action route
* const url = Rsx_Portal.Route('Portal_Dashboard_Action');
* // Development: /_portal/dashboard
*
* // Route with integer parameter (sets 'id')
* const url = Rsx_Portal.Route('Portal_Project_View_Action', 123);
* // Development: /_portal/projects/123
*
* // Route with named parameters
* const url = Rsx_Portal.Route('Portal_Project_View_Action', {id: 123, tab: 'files'});
* // Development: /_portal/projects/123?tab=files
*
* // Placeholder route
* const url = Rsx_Portal.Route('Future_Portal_Feature::#index');
* // Returns: #
* ```
*
* @param {string} action Controller class, SPA action, or "Class::method"
* @param {number|Object} [params=null] Route parameters
* @returns {string} The generated URL (includes portal prefix in dev mode)
*/
static Route(action, params = null) {
// Parse action into class_name and action_name
let class_name, action_name;
if (action.includes('::')) {
[class_name, action_name] = action.split('::', 2);
} else {
class_name = action;
action_name = 'index';
}
// Normalize params to object
let params_obj = {};
if (typeof params === 'number') {
params_obj = { id: params };
} else if (typeof params === 'string' && /^\d+$/.test(params)) {
params_obj = { id: parseInt(params, 10) };
} else if (params && typeof params === 'object') {
params_obj = params;
} else if (params !== null && params !== undefined) {
throw new Error('Params must be number, object, or null');
}
// Placeholder route: action starts with # means unimplemented/scaffolding
if (action_name.startsWith('#')) {
return '#';
}
// Check if route exists in portal route definitions
let pattern = null;
if (Rsx_Portal._routes[class_name] && Rsx_Portal._routes[class_name][action_name]) {
const route_patterns = Rsx_Portal._routes[class_name][action_name];
pattern = Rsx_Portal._select_best_route_pattern(route_patterns, params_obj);
if (!pattern) {
const route_list = route_patterns.join(', ');
throw new Error(
`No suitable portal route found for ${class_name}::${action_name} with provided parameters. ` +
`Available routes: ${route_list}`
);
}
} else {
// Not found in portal routes - try SPA action route
pattern = Rsx_Portal._try_spa_action_route(class_name, params_obj);
if (!pattern) {
throw new Error(
`Portal route not found for ${action}. ` +
`Ensure the class has a @portal_route decorator.`
);
}
}
// Generate base URL from pattern
const path = Rsx_Portal._generate_url_from_pattern(pattern, params_obj);
// Apply portal prefix (in dev mode) or return as-is (domain mode)
return Rsx_Portal._apply_portal_base(path);
}
/**
* Apply portal domain or prefix to a path
*
* @param {string} path The route path (e.g., '/dashboard')
* @returns {string} Path with portal prefix (dev) or plain path (domain mode)
* @private
*/
static _apply_portal_base(path) {
// If using dedicated domain, path is relative to that domain
if (Rsx_Portal.has_dedicated_domain()) {
return path;
}
// Development mode: prepend prefix
const prefix = Rsx_Portal.get_prefix();
return prefix + path;
}
/**
* Select the best matching route pattern from available patterns
*
* @param {Array<string>} patterns Array of route patterns
* @param {Object} params_obj Provided parameters
* @returns {string|null} Selected pattern or null if none match
* @private
*/
static _select_best_route_pattern(patterns, params_obj) {
const satisfiable = [];
for (const pattern of patterns) {
// Extract required parameters from pattern
const required_params = [];
const matches = pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
if (matches) {
for (const match of matches) {
required_params.push(match.substring(1));
}
}
// Check if all required parameters are provided
let can_satisfy = true;
for (const required of required_params) {
if (!(required in params_obj)) {
can_satisfy = false;
break;
}
}
if (can_satisfy) {
satisfiable.push({
pattern: pattern,
param_count: required_params.length
});
}
}
if (satisfiable.length === 0) {
return null;
}
// Sort by parameter count descending (most parameters first)
satisfiable.sort((a, b) => b.param_count - a.param_count);
return satisfiable[0].pattern;
}
/**
* Generate URL from route pattern by replacing parameters
*
* @param {string} pattern The route pattern (e.g., '/projects/:id')
* @param {Object} params Parameters to fill into the route
* @returns {string} The generated URL
* @private
*/
static _generate_url_from_pattern(pattern, params) {
// Extract required parameters from the pattern
const required_params = [];
const matches = pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
if (matches) {
for (const match of matches) {
required_params.push(match.substring(1));
}
}
// Check for required parameters
const missing = [];
for (const required of required_params) {
if (!(required in params)) {
missing.push(required);
}
}
if (missing.length > 0) {
throw new Error(`Required parameters [${missing.join(', ')}] are missing for portal route ${pattern}`);
}
// Build the URL by replacing parameters
let url = pattern;
const used_params = {};
for (const param_name of required_params) {
const value = params[param_name];
const encoded_value = encodeURIComponent(value);
url = url.replace(':' + param_name, encoded_value);
used_params[param_name] = true;
}
// Collect extra parameters for query string
const internal_params = ['_loader_title_hint'];
const query_params = {};
for (const key in params) {
if (!used_params[key] && !internal_params.includes(key)) {
query_params[key] = params[key];
}
}
// Append query string if there are extra parameters
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;
}
/**
* Try to find a route pattern for a portal SPA action class
*
* @param {string} class_name The action class name
* @param {Object} params_obj The parameters for route selection
* @returns {string|null} The route pattern or null
* @private
*/
static _try_spa_action_route(class_name, params_obj) {
// Get all classes from manifest
const all_classes = Manifest.get_all_classes();
// Find the class by name
for (const class_info of all_classes) {
if (class_info.class_name === class_name) {
const class_object = class_info.class_object;
// Check if it's a SPA action with portal routes
if (typeof Spa_Action !== 'undefined' &&
class_object.prototype instanceof Spa_Action) {
// Get route patterns from decorator metadata
// Portal SPA actions use @route() decorator (stores in _spa_routes)
// and @portal_spa() decorator (sets _is_portal_spa = true)
const routes = class_object._spa_routes || [];
// Only match if this is a portal SPA action
if (routes.length > 0 && class_object._is_portal_spa) {
const selected = Rsx_Portal._select_best_route_pattern(routes, params_obj);
if (!selected) {
throw new Error(
`No suitable portal route found for SPA action ${class_name} with provided parameters. ` +
`Available routes: ${routes.join(', ')}`
);
}
return selected;
}
}
return null;
}
}
return null;
}
/**
* Define portal routes from bundled data
* Called by generated JavaScript in portal bundles
*
* @param {Object} routes Route definitions object
*/
static _define_routes(routes) {
for (const class_name in routes) {
if (!Rsx_Portal._routes[class_name]) {
Rsx_Portal._routes[class_name] = {};
}
for (const method_name in routes[class_name]) {
Rsx_Portal._routes[class_name][method_name] = routes[class_name][method_name];
}
}
}
/**
* Clear cached state (for testing)
*/
static _clear_cache() {
Rsx_Portal._is_portal = null;
Rsx_Portal._routes = {};
}
}