Fix contact add link to use Contacts_Edit_Action SPA route

Add multi-route support for controllers and SPA actions
Add screenshot feature to rsx:debug and convert contacts edit to SPA

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-11-20 19:55:49 +00:00
parent 0e19c811e3
commit ff77724e2b
11 changed files with 555 additions and 61 deletions

View File

@@ -28,10 +28,13 @@ class Route_ManifestSupport extends ManifestSupport_Abstract
*/
public static function process(array &$manifest_data): void
{
// Initialize routes key
// Initialize routes structures
if (!isset($manifest_data['data']['routes'])) {
$manifest_data['data']['routes'] = [];
}
if (!isset($manifest_data['data']['routes_by_target'])) {
$manifest_data['data']['routes_by_target'] = [];
}
// Look for Route attributes - must check all namespaces since Route is not a real class
// PHP attributes without an import will use the current namespace
@@ -99,8 +102,8 @@ class Route_ManifestSupport extends ManifestSupport_Abstract
);
}
// Store route with flat structure
$manifest_data['data']['routes'][$pattern] = [
// Store route with flat structure (for dispatcher)
$route_data = [
'methods' => array_map('strtoupper', (array) $methods),
'type' => $type,
'class' => $item['fqcn'] ?? $item['class'],
@@ -108,7 +111,17 @@ class Route_ManifestSupport extends ManifestSupport_Abstract
'name' => $name,
'file' => $item['file'],
'require' => $require_attrs,
'pattern' => $pattern,
];
$manifest_data['data']['routes'][$pattern] = $route_data;
// Also store by target for URL generation (group multiple routes per controller method)
$target = $item['class'] . '::' . $item['method'];
if (!isset($manifest_data['data']['routes_by_target'][$target])) {
$manifest_data['data']['routes_by_target'][$target] = [];
}
$manifest_data['data']['routes_by_target'][$target][] = $route_data;
}
}
}
@@ -116,5 +129,6 @@ class Route_ManifestSupport extends ManifestSupport_Abstract
// Sort routes alphabetically by path to ensure deterministic behavior and prevent race condition bugs
ksort($manifest_data['data']['routes']);
ksort($manifest_data['data']['routes_by_target']);
}
}

View File

@@ -248,7 +248,7 @@ class Rsx {
pattern = Rsx._routes[class_name][action_name];
} else {
// Not found in PHP routes - check if it's a SPA action
pattern = Rsx._try_spa_action_route(class_name);
pattern = Rsx._try_spa_action_route(class_name, params_obj);
if (!pattern) {
// Route not found - use default pattern /_/{controller}/{action}
@@ -261,6 +261,60 @@ class Rsx {
return Rsx._generate_url_from_pattern(pattern, params_obj);
}
/**
* Select the best matching route pattern from available patterns based on provided parameters
*
* Selection algorithm:
* 1. Filter patterns where all required parameters can be satisfied by provided params
* 2. Among satisfiable patterns, prioritize those with MORE parameters (more specific)
* 3. If tie, any pattern works (deterministic by using first match)
*
* @param {Array<string>} patterns Array of route patterns
* @param {Object} params_obj Provided parameters
* @returns {string|null} Selected pattern or null if none match
*/
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) {
// Remove the : prefix from each match
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 the pattern with the most parameters
return satisfiable[0].pattern;
}
/**
* Generate URL from route pattern by replacing parameters
*
@@ -327,9 +381,10 @@ class Rsx {
* Returns the route pattern or null if not found
*
* @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
*/
static _try_spa_action_route(class_name) {
static _try_spa_action_route(class_name, params_obj) {
// Get all classes from manifest
const all_classes = Manifest.get_all_classes();
@@ -346,8 +401,18 @@ class Rsx {
const routes = class_object._spa_routes || [];
if (routes.length > 0) {
// Return the first route pattern
return routes[0];
// Select best matching route based on parameters
const selected = Rsx._select_best_route_pattern(routes, params_obj);
if (!selected) {
// Routes exist but none are satisfiable
throw new Error(
`No suitable route found for SPA action ${class_name} with provided parameters. ` +
`Available routes: ${routes.join(', ')}`
);
}
return selected;
}
}

View File

@@ -260,22 +260,11 @@ class Rsx
shouldnt_happen("Method {$class_name}::{$action_name} in public_static_methods is not static - extraction bug");
}
// Check for Route or Ajax_Endpoint attribute
$has_route = false;
// Check for Ajax_Endpoint attribute
$has_ajax_endpoint = false;
$route_pattern = null;
if (isset($method_info['attributes'])) {
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
if ($attr_name === 'Route' || str_ends_with($attr_name, '\\Route')) {
$has_route = true;
// Get the route pattern from the first instance
if (!empty($attr_instances)) {
$route_args = $attr_instances[0];
$route_pattern = $route_args[0] ?? ($route_args['pattern'] ?? null);
}
break;
}
if ($attr_name === 'Ajax_Endpoint' || str_ends_with($attr_name, '\\Ajax_Endpoint')) {
$has_ajax_endpoint = true;
break;
@@ -293,17 +282,29 @@ class Rsx
return $ajax_url;
}
if (!$has_route) {
// Not a controller method with Route/Ajax - check if it's a SPA action class
// Look up routes in manifest using routes_by_target
$target = $class_name . '::' . $action_name;
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['routes_by_target'][$target])) {
// Not a controller method with Route - check if it's a SPA action class
return static::_try_spa_action_route($class_name, $params_array);
}
if (!$route_pattern) {
throw new Rsx_Caller_Exception("Route attribute on {$class_name}::{$action_name} must have a pattern");
$routes = $manifest['data']['routes_by_target'][$target];
// Select best matching route based on provided parameters
$selected_route = static::_select_best_route($routes, $params_array);
if (!$selected_route) {
throw new Rsx_Caller_Exception(
"No suitable route found for {$class_name}::{$action_name} with provided parameters. " .
"Available routes: " . implode(', ', array_column($routes, 'pattern'))
);
}
// Generate URL from pattern
return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, $action_name);
// Generate URL from selected pattern
return static::_generate_url_from_pattern($selected_route['pattern'], $params_array, $class_name, $action_name);
}
/**
@@ -329,43 +330,82 @@ class Rsx
throw new Rsx_Caller_Exception("JavaScript class {$class_name} must extend Spa_Action to generate routes");
}
// Get the file path for this JS class
try {
$file_path = Manifest::js_find_class($class_name);
} catch (\RuntimeException $e) {
throw new Rsx_Caller_Exception("SPA action class {$class_name} not found in manifest");
// Look up routes in manifest using routes_by_target
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['routes_by_target'][$class_name])) {
throw new Rsx_Caller_Exception("SPA action {$class_name} has no registered routes in manifest");
}
// Get file metadata which contains decorator information
try {
$file_data = Manifest::get_file($file_path);
} catch (\RuntimeException $e) {
throw new Rsx_Caller_Exception("File metadata not found for SPA action {$class_name}");
$routes = $manifest['data']['routes_by_target'][$class_name];
// Select best matching route based on provided parameters
$selected_route = static::_select_best_route($routes, $params_array);
if (!$selected_route) {
throw new Rsx_Caller_Exception(
"No suitable route found for SPA action {$class_name} with provided parameters. " .
"Available routes: " . implode(', ', array_column($routes, 'pattern'))
);
}
// Extract route pattern from decorators
// JavaScript files have 'decorators' array in their metadata
// Format: [[0 => 'route', 1 => ['/contacts']], ...]
$route_pattern = null;
if (isset($file_data['decorators']) && is_array($file_data['decorators'])) {
foreach ($file_data['decorators'] as $decorator) {
// Decorator format: [0 => 'decorator_name', 1 => [arguments]]
if (isset($decorator[0]) && $decorator[0] === 'route') {
// First argument is the route pattern
if (isset($decorator[1][0])) {
$route_pattern = $decorator[1][0];
break;
}
// Generate URL from selected pattern
return static::_generate_url_from_pattern($selected_route['pattern'], $params_array, $class_name, '(SPA action)');
}
/**
* Select the best matching route from available routes based on provided parameters
*
* Selection algorithm:
* 1. Filter routes where all required parameters can be satisfied by provided params
* 2. Among satisfiable routes, prioritize those with MORE parameters (more specific)
* 3. If tie, any route works (deterministic by using first match)
*
* @param array $routes Array of route data from manifest
* @param array $params_array Provided parameters
* @return array|null Selected route data or null if none match
*/
protected static function _select_best_route(array $routes, array $params_array): ?array
{
$satisfiable = [];
foreach ($routes as $route) {
$pattern = $route['pattern'];
// Extract required parameters from pattern
$required_params = [];
if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $pattern, $matches)) {
$required_params = $matches[1];
}
// Check if all required parameters are provided
$can_satisfy = true;
foreach ($required_params as $required) {
if (!array_key_exists($required, $params_array)) {
$can_satisfy = false;
break;
}
}
if ($can_satisfy) {
$satisfiable[] = [
'route' => $route,
'param_count' => count($required_params),
];
}
}
if (!$route_pattern) {
throw new Rsx_Caller_Exception("SPA action {$class_name} must have @route() decorator with pattern");
if (empty($satisfiable)) {
return null;
}
// Generate URL from pattern using same logic as regular routes
return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, '(SPA action)');
// Sort by parameter count descending (most parameters first)
usort($satisfiable, function ($a, $b) {
return $b['param_count'] <=> $a['param_count'];
});
// Return the route with the most parameters
return $satisfiable[0]['route'];
}
/**

View File

@@ -90,6 +90,9 @@ class Spa_Layout extends Component {
// Create new action component
console.log('[Spa_Layout] Creating action:', action_name, 'with args:', args);
console.log('[Spa_Layout] Args keys:', Object.keys(args || {}));
console.warn(args);
const action = $content.component(action_name, args).component();
// Store reference

View File

@@ -30,10 +30,13 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract
*/
public static function process(array &$manifest_data): void
{
// Initialize routes key if not already set
// Initialize routes structures if not already set
if (!isset($manifest_data['data']['routes'])) {
$manifest_data['data']['routes'] = [];
}
if (!isset($manifest_data['data']['routes_by_target'])) {
$manifest_data['data']['routes_by_target'] = [];
}
// Get all files to look up PHP controller metadata
$files = $manifest_data['data']['files'];
@@ -114,8 +117,8 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract
);
}
// Store route with unified structure
$manifest_data['data']['routes'][$route_pattern] = [
// Store route with unified structure (for dispatcher)
$route_data = [
'methods' => ['GET'], // Spa routes are always GET
'type' => 'spa',
'class' => $php_controller_fqcn,
@@ -124,7 +127,17 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract
'file' => $php_controller_file,
'require' => $require_attrs,
'js_action_class' => $class_name,
'pattern' => $route_pattern,
];
$manifest_data['data']['routes'][$route_pattern] = $route_data;
// Also store by target for URL generation (group multiple routes per action class)
$target = $class_name; // For SPA, target is the JS action class name
if (!isset($manifest_data['data']['routes_by_target'][$target])) {
$manifest_data['data']['routes_by_target'][$target] = [];
}
$manifest_data['data']['routes_by_target'][$target][] = $route_data;
}
}
}