Enhance refactor commands with controller-aware Route() updates and fix code quality violations
Add semantic token highlighting for 'that' variable and comment file references in VS Code extension Add Phone_Text_Input and Currency_Input components with formatting utilities Implement client widgets, form standardization, and soft delete functionality Add modal scroll lock and update documentation Implement comprehensive modal system with form integration and validation Fix modal component instantiation using jQuery plugin API Implement modal system with responsive sizing, queuing, and validation support Implement form submission with validation, error handling, and loading states Implement country/state selectors with dynamic data loading and Bootstrap styling Revert Rsx::Route() highlighting in Blade/PHP files Target specific PHP scopes for Rsx::Route() highlighting in Blade Expand injection selector for Rsx::Route() highlighting Add custom syntax highlighting for Rsx::Route() and Rsx.Route() calls Update jqhtml packages to v2.2.165 Add bundle path validation for common mistakes (development mode only) Create Ajax_Select_Input widget and Rsx_Reference_Data controller Create Country_Select_Input widget with default country support Initialize Tom Select on Select_Input widgets Add Tom Select bundle for enhanced select dropdowns Implement ISO 3166 geographic data system for country/region selection Implement widget-based form system with disabled state support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -52,3 +52,5 @@ GATEKEEPER_TITLE="Development Preview"
|
||||
GATEKEEPER_SUBTITLE="This is a restricted development preview site. Please enter the access password to continue."
|
||||
|
||||
SSR_FPC_ENABLED=true
|
||||
LOG_BROWSER_ERRORS=false
|
||||
AJAX_DISABLE_BATCHING=true
|
||||
|
||||
47
app/RSpade/Bundles/Tom_Select_Bundle.php
Executable file
47
app/RSpade/Bundles/Tom_Select_Bundle.php
Executable file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Bundles;
|
||||
|
||||
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
||||
|
||||
/**
|
||||
* Tom Select CDN Bundle
|
||||
*
|
||||
* Provides Tom Select (modern select2 alternative) via CDN.
|
||||
* Tom Select is a lightweight, vanilla JS library for enhanced select boxes
|
||||
* with search, ajax, tagging, and accessibility features.
|
||||
*
|
||||
* Features:
|
||||
* - Text search/filtering
|
||||
* - Ajax/remote data loading
|
||||
* - Custom value creation (tagging)
|
||||
* - Multi-select support
|
||||
* - Excellent accessibility (ARIA, screen readers)
|
||||
* - Bootstrap-friendly theming
|
||||
*/
|
||||
class Tom_Select_Bundle extends Rsx_Bundle_Abstract
|
||||
{
|
||||
/**
|
||||
* Define the bundle configuration
|
||||
*
|
||||
* @return array Bundle configuration
|
||||
*/
|
||||
public static function define(): array
|
||||
{
|
||||
return [
|
||||
'include' => [], // No local files
|
||||
'cdn_assets' => [
|
||||
'css' => [
|
||||
[
|
||||
'url' => 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.default.min.css',
|
||||
],
|
||||
],
|
||||
'js' => [
|
||||
[
|
||||
'url' => 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/js/tom-select.complete.min.js',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -233,18 +233,18 @@ class HardcodedInternalUrl_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
if ($is_jqhtml) {
|
||||
// JavaScript version for .jqhtml files using <%= %> syntax
|
||||
if (empty($params)) {
|
||||
return "<%= Rsx.Route('{$class_name}', '{$method_name}').url() %>";
|
||||
return "<%= Rsx.Route('{$class_name}', '{$method_name}') %>";
|
||||
} else {
|
||||
$params_json = json_encode($params, JSON_UNESCAPED_SLASHES);
|
||||
return "<%= Rsx.Route('{$class_name}', '{$method_name}').url({$params_json}) %>";
|
||||
return "<%= Rsx.Route('{$class_name}', '{$method_name}', {$params_json}) %>";
|
||||
}
|
||||
} else {
|
||||
// PHP version for .blade.php files
|
||||
if (empty($params)) {
|
||||
return "{{ Rsx::Route('{$class_name}', '{$method_name}')->url() }}";
|
||||
return "{{ Rsx::Route('{$class_name}', '{$method_name}') }}";
|
||||
} else {
|
||||
$params_str = $this->_format_php_array($params);
|
||||
return "{{ Rsx::Route('{$class_name}', '{$method_name}')->url({$params_str}) }}";
|
||||
return "{{ Rsx::Route('{$class_name}', '{$method_name}', {$params_str}) }}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,10 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
return;
|
||||
}
|
||||
|
||||
// Get original file content for extracting actual controller/method names
|
||||
// (The $contents parameter may be sanitized with strings replaced by spaces)
|
||||
$original_contents = file_get_contents($file_path);
|
||||
|
||||
// Pattern to match Rsx::Route and Rsx.Route calls (NOT plain Route())
|
||||
// Matches both single and double parameter versions:
|
||||
// - Rsx::Route('Controller') // PHP, defaults to 'index'
|
||||
@@ -122,11 +126,15 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
|
||||
// First check two-parameter calls
|
||||
if (preg_match_all($pattern_two_params, $contents, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
// Also match against original content to get real controller/method names
|
||||
preg_match_all($pattern_two_params, $original_contents, $original_matches, PREG_OFFSET_CAPTURE);
|
||||
|
||||
foreach ($matches[0] as $index => $match) {
|
||||
$full_match = $match[0];
|
||||
$offset = $match[1];
|
||||
$controller = $matches[1][$index][0];
|
||||
$method = $matches[2][$index][0];
|
||||
// Extract controller and method from ORIGINAL content, not sanitized
|
||||
$controller = $original_matches[1][$index][0] ?? $matches[1][$index][0];
|
||||
$method = $original_matches[2][$index][0] ?? $matches[2][$index][0];
|
||||
|
||||
// Skip if contains template variables like {$variable}
|
||||
if (str_contains($controller, '{$') || str_contains($controller, '${') ||
|
||||
@@ -147,9 +155,9 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
// Calculate line number
|
||||
$line_number = substr_count(substr($contents, 0, $offset), "\n") + 1;
|
||||
|
||||
// Extract the line for snippet
|
||||
$lines = explode("\n", $contents);
|
||||
$code_snippet = isset($lines[$line_number - 1]) ? trim($lines[$line_number - 1]) : $full_match;
|
||||
// Extract the line for snippet - use ORIGINAL file content, not sanitized
|
||||
$original_lines = explode("\n", $original_contents);
|
||||
$code_snippet = isset($original_lines[$line_number - 1]) ? trim($original_lines[$line_number - 1]) : $full_match;
|
||||
|
||||
// Build suggestion
|
||||
$suggestion = $this->build_suggestion($controller, $method);
|
||||
@@ -167,6 +175,9 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
|
||||
// Then check single-parameter calls (avoiding overlap with two-parameter calls)
|
||||
if (preg_match_all($pattern_one_param, $contents, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
// Also match against original content to get real controller names
|
||||
preg_match_all($pattern_one_param, $original_contents, $original_matches, PREG_OFFSET_CAPTURE);
|
||||
|
||||
foreach ($matches[0] as $index => $match) {
|
||||
$full_match = $match[0];
|
||||
$offset = $match[1];
|
||||
@@ -178,7 +189,8 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
continue; // This is a two-parameter call, already handled above
|
||||
}
|
||||
|
||||
$controller = $matches[1][$index][0];
|
||||
// Extract controller from ORIGINAL content, not sanitized
|
||||
$controller = $original_matches[1][$index][0] ?? $matches[1][$index][0];
|
||||
$method = 'index'; // Default to 'index'
|
||||
|
||||
// Skip if contains template variables like {$variable}
|
||||
@@ -194,9 +206,9 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
// Calculate line number
|
||||
$line_number = substr_count(substr($contents, 0, $offset), "\n") + 1;
|
||||
|
||||
// Extract the line for snippet
|
||||
$lines = explode("\n", $contents);
|
||||
$code_snippet = isset($lines[$line_number - 1]) ? trim($lines[$line_number - 1]) : $full_match;
|
||||
// Extract the line for snippet - use ORIGINAL file content, not sanitized
|
||||
$original_lines = explode("\n", $original_contents);
|
||||
$code_snippet = isset($original_lines[$line_number - 1]) ? trim($original_lines[$line_number - 1]) : $full_match;
|
||||
|
||||
// Build suggestion
|
||||
$suggestion = $this->build_suggestion($controller, $method);
|
||||
|
||||
@@ -119,18 +119,40 @@ class DefensiveCoding_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 4: Guard clauses like: Rsx && Rsx.method()
|
||||
if (preg_match('/(\w+)\s*&&\s*\1\.\w+/i', $line, $matches)) {
|
||||
// Pattern 4: Guard clauses like: Rsx && Rsx.method() or component && component.val
|
||||
if (preg_match('/\b(\w+)\s*&&\s*\1\.\w+/i', $line, $matches)) {
|
||||
$variable = $matches[1];
|
||||
|
||||
// Skip common boolean variable patterns
|
||||
if (!preg_match('/^(is|has|can|should|will|did|was)[A-Z]/', $variable)) {
|
||||
$fix_message = "DETECTED PATTERN: if ({$variable} && {$variable}.method)\n\n" .
|
||||
"This pattern has TWO possible meanings:\n" .
|
||||
"1. DEFENSIVE CODING (violation): Checking for something that MUST exist\n" .
|
||||
"2. DUCK TYPING (acceptable): Checking for optional capabilities\n\n" .
|
||||
"FOR LLM AGENTS - YOU MUST NOT DECIDE:\n" .
|
||||
"This rule requires human judgment. You MUST:\n" .
|
||||
"1. Analyze the context and determine which scenario applies\n" .
|
||||
"2. Present BOTH options:\n" .
|
||||
" - If defensive: Remove check, let it fail loud\n" .
|
||||
" - If duck typing: Use 'in' operator: if ('method' in {$variable})\n" .
|
||||
"3. Provide your recommendation with reasoning\n" .
|
||||
"4. WAIT for user decision\n" .
|
||||
"5. NEVER apply a fix without user confirmation\n\n" .
|
||||
"EXAMPLES:\n\n" .
|
||||
"DEFENSIVE CODING (remove the check):\n" .
|
||||
" if (Rsx && Rsx.Route(...)) // BAD - Rsx is core, must exist\n" .
|
||||
" if (component && component.render()) // BAD if render is required method\n\n" .
|
||||
"DUCK TYPING (use 'in' operator):\n" .
|
||||
" if (component && component.val) // Change to: if ('val' in component)\n" .
|
||||
" if (obj && obj.serialize) // Change to: if ('serialize' in obj)\n\n" .
|
||||
"NOTE: Core guaranteed classes (Rsx, Modal, Jqhtml_Component, etc.) should NEVER be checked - let failures happen loudly during development.";
|
||||
|
||||
$this->add_violation(
|
||||
$file_path,
|
||||
$line_number,
|
||||
"Defensive coding violation: Guard clause checking if '{$variable}' exists. All classes and variables must be assumed to exist. Code should fail loudly if something is undefined.",
|
||||
trim($line),
|
||||
"Remove the guard clause. Use '{$variable}.method()' directly.",
|
||||
$fix_message,
|
||||
'high'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -271,10 +271,10 @@ class HardcodedUrlInRedirect_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
protected function _generate_rsx_suggestion(string $class_name, string $method_name, array $params): string
|
||||
{
|
||||
if (empty($params)) {
|
||||
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}')->url());";
|
||||
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}'));";
|
||||
} else {
|
||||
$params_str = $this->_format_php_array($params);
|
||||
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}')->url({$params_str}));";
|
||||
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}', {$params_str}));";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
159
app/RSpade/Commands/Database/Seed_Geographic_Data_Command.php
Executable file
159
app/RSpade/Commands/Database/Seed_Geographic_Data_Command.php
Executable file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Commands\Database;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Sokil\IsoCodes\IsoCodesFactory;
|
||||
use App\RSpade\Core\Models\Country_Model;
|
||||
use App\RSpade\Core\Models\Region_Model;
|
||||
|
||||
/**
|
||||
* Seed geographic data (countries and regions) from ISO 3166 standards
|
||||
*
|
||||
* Uses sokil/php-isocodes library to populate countries (ISO 3166-1)
|
||||
* and regions/subdivisions (ISO 3166-2) tables.
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Seed_Geographic_Data_Command extends Command
|
||||
{
|
||||
protected $signature = 'rsx:seed:geographic-data';
|
||||
|
||||
protected $description = 'Import countries and regions from ISO 3166 standards';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Importing geographic data from ISO 3166 standards...');
|
||||
$this->newLine();
|
||||
|
||||
// Initialize ISO codes library
|
||||
$isoCodes = new IsoCodesFactory();
|
||||
|
||||
// Seed countries
|
||||
$this->seedCountries($isoCodes);
|
||||
|
||||
// Seed regions
|
||||
$this->seedRegions($isoCodes);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Geographic data import completed successfully!');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function seedCountries(IsoCodesFactory $isoCodes)
|
||||
{
|
||||
$this->info('[Countries] Loading ISO 3166-1 data...');
|
||||
|
||||
$countries = $isoCodes->getCountries();
|
||||
$imported_alpha2_codes = [];
|
||||
$added = 0;
|
||||
$updated = 0;
|
||||
|
||||
foreach ($countries as $country) {
|
||||
$alpha2 = $country->getAlpha2();
|
||||
$imported_alpha2_codes[] = $alpha2;
|
||||
|
||||
$existing = Country_Model::where('alpha2', $alpha2)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->alpha2 = $alpha2;
|
||||
$existing->alpha3 = $country->getAlpha3();
|
||||
$existing->numeric = $country->getNumericCode();
|
||||
$existing->name = $country->getName();
|
||||
$existing->common_name = $country->getLocalName() !== $country->getName()
|
||||
? $country->getLocalName()
|
||||
: null;
|
||||
$existing->enabled = true;
|
||||
$existing->save();
|
||||
$updated++;
|
||||
} else {
|
||||
$model = new Country_Model();
|
||||
$model->alpha2 = $alpha2;
|
||||
$model->alpha3 = $country->getAlpha3();
|
||||
$model->numeric = $country->getNumericCode();
|
||||
$model->name = $country->getName();
|
||||
$model->common_name = $country->getLocalName() !== $country->getName()
|
||||
? $country->getLocalName()
|
||||
: null;
|
||||
$model->enabled = true;
|
||||
$model->save();
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
|
||||
// Disable countries not in import
|
||||
$disabled = Country_Model::whereNotIn('alpha2', $imported_alpha2_codes)
|
||||
->where('enabled', true)
|
||||
->update(['enabled' => false]);
|
||||
|
||||
$this->line("[Countries] Added: {$added}, Updated: {$updated}, Disabled: {$disabled}");
|
||||
}
|
||||
|
||||
private function seedRegions(IsoCodesFactory $isoCodes)
|
||||
{
|
||||
$this->info('[Regions] Loading ISO 3166-2 subdivisions...');
|
||||
|
||||
$subdivisions = $isoCodes->getSubdivisions();
|
||||
$imported_keys = [];
|
||||
$added = 0;
|
||||
$updated = 0;
|
||||
|
||||
foreach ($subdivisions as $subdivision) {
|
||||
$code = $subdivision->getCode();
|
||||
|
||||
// Extract country code from subdivision code (e.g., "US-CA" -> "US")
|
||||
$parts = explode('-', $code);
|
||||
if (count($parts) < 2) {
|
||||
// Skip malformed codes
|
||||
continue;
|
||||
}
|
||||
|
||||
$country_alpha2 = $parts[0];
|
||||
|
||||
// Only import if country exists
|
||||
if (!Country_Model::where('alpha2', $country_alpha2)->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imported_keys[] = $country_alpha2 . '|' . $code;
|
||||
|
||||
$existing = Region_Model::where('country_alpha2', $country_alpha2)
|
||||
->where('code', $code)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->code = $code;
|
||||
$existing->country_alpha2 = $country_alpha2;
|
||||
$existing->name = $subdivision->getName();
|
||||
$existing->type = $subdivision->getType();
|
||||
$existing->enabled = true;
|
||||
$existing->save();
|
||||
$updated++;
|
||||
} else {
|
||||
$model = new Region_Model();
|
||||
$model->code = $code;
|
||||
$model->country_alpha2 = $country_alpha2;
|
||||
$model->name = $subdivision->getName();
|
||||
$model->type = $subdivision->getType();
|
||||
$model->enabled = true;
|
||||
$model->save();
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
|
||||
// Disable regions not in import
|
||||
$disabled = 0;
|
||||
$all_regions = Region_Model::where('enabled', true)->get();
|
||||
|
||||
foreach ($all_regions as $region) {
|
||||
$key = $region->country_alpha2 . '|' . $region->code;
|
||||
if (!in_array($key, $imported_keys)) {
|
||||
$region->enabled = false;
|
||||
$region->save();
|
||||
$disabled++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->line("[Regions] Added: {$added}, Updated: {$updated}, Disabled: {$disabled}");
|
||||
}
|
||||
}
|
||||
@@ -337,4 +337,93 @@ class FileUpdater
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update controller references in Route() calls and Ajax endpoints
|
||||
* Only processes files in rsx/ directory that are in the manifest
|
||||
*
|
||||
* @param string $old_class Old controller class name
|
||||
* @param string $new_class New controller class name
|
||||
* @return int Number of files updated
|
||||
*/
|
||||
public function update_controller_route_references(string $old_class, string $new_class): int
|
||||
{
|
||||
$updated_count = 0;
|
||||
|
||||
// Get all files from manifest in rsx/ directory
|
||||
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
|
||||
|
||||
foreach ($manifest as $relative_path => $metadata) {
|
||||
// Only process files in rsx/ directory
|
||||
if (!str_starts_with($relative_path, 'rsx/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extension = $metadata['extension'] ?? '';
|
||||
$file_path = base_path($relative_path);
|
||||
|
||||
if (!file_exists($file_path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file_path);
|
||||
$updated_content = $content;
|
||||
|
||||
// Apply replacements based on file type
|
||||
if ($extension === 'js' || $extension === 'jqhtml') {
|
||||
// Replace Rsx.Route('OLD_CLASS' and Rsx.Route("OLD_CLASS"
|
||||
$updated_content = preg_replace(
|
||||
'/\bRsx\.Route\([\'"]' . preg_quote($old_class, '/') . '[\'"]/',
|
||||
'Rsx.Route(\'' . $new_class . '\'',
|
||||
$updated_content
|
||||
);
|
||||
}
|
||||
|
||||
if ($extension === 'jqhtml' || $extension === 'blade.php') {
|
||||
// Replace $controller="OLD_CLASS"
|
||||
$updated_content = preg_replace(
|
||||
'/(\s\$controller=["\'])' . preg_quote($old_class, '/') . '(["\'])/',
|
||||
'$1' . $new_class . '$2',
|
||||
$updated_content
|
||||
);
|
||||
}
|
||||
|
||||
if ($extension === 'jqhtml') {
|
||||
// Replace unquoted attribute assignments: $attr=OLD_CLASS followed by space, dot, or >
|
||||
// Pattern: (attribute name)=(controller name)(space|dot|>)
|
||||
$updated_content = preg_replace(
|
||||
'/(\$[\w_]+)=' . preg_quote($old_class, '/') . '\b(?=[\s.>])/',
|
||||
'$1=' . $new_class,
|
||||
$updated_content
|
||||
);
|
||||
}
|
||||
|
||||
if ($extension === 'js') {
|
||||
// Replace Ajax endpoint calls: OLD_CLASS.method(
|
||||
// Pattern: (whitespace|;|(|[)OLD_CLASS.
|
||||
$updated_content = preg_replace(
|
||||
'/(?<=[\s;(\[])' . preg_quote($old_class, '/') . '\b(?=\.)/',
|
||||
$new_class,
|
||||
$updated_content
|
||||
);
|
||||
}
|
||||
|
||||
if ($extension === 'php' || $extension === 'blade.php') {
|
||||
// Replace Rsx::Route('OLD_CLASS' and Rsx::Route("OLD_CLASS"
|
||||
$updated_content = preg_replace(
|
||||
'/\bRsx::Route\([\'"]' . preg_quote($old_class, '/') . '[\'"]/',
|
||||
'Rsx::Route(\'' . $new_class . '\'',
|
||||
$updated_content
|
||||
);
|
||||
}
|
||||
|
||||
// Write if changed
|
||||
if ($updated_content !== $content) {
|
||||
file_put_contents($file_path, $updated_content);
|
||||
$updated_count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $updated_count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +340,106 @@ class MethodUpdater
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update controller action references in Route() calls and Ajax endpoints
|
||||
* Only processes files in rsx/ directory that are in the manifest
|
||||
*
|
||||
* @param string $class_name Controller class name
|
||||
* @param string $old_action Old action/method name
|
||||
* @param string $new_action New action/method name
|
||||
* @return int Number of files updated
|
||||
*/
|
||||
public function update_controller_action_route_references(string $class_name, string $old_action, string $new_action): int
|
||||
{
|
||||
$updated_count = 0;
|
||||
|
||||
// Get all files from manifest in rsx/ directory
|
||||
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
|
||||
|
||||
foreach ($manifest as $relative_path => $metadata) {
|
||||
// Only process files in rsx/ directory
|
||||
if (!str_starts_with($relative_path, 'rsx/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extension = $metadata['extension'] ?? '';
|
||||
$file_path = base_path($relative_path);
|
||||
|
||||
if (!file_exists($file_path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file_path);
|
||||
$updated_content = $content;
|
||||
|
||||
// Apply replacements based on file type
|
||||
if ($extension === 'js' || $extension === 'jqhtml') {
|
||||
// Replace Rsx.Route('CLASS', 'OLD_ACTION'
|
||||
// Pattern matches with optional whitespace between parameters
|
||||
$updated_content = preg_replace(
|
||||
'/\bRsx\.Route\(\s*[\'"]' . preg_quote($class_name, '/') . '[\'"]\s*,\s*[\'"]' . preg_quote($old_action, '/') . '[\'"]/',
|
||||
'Rsx.Route(\'' . $class_name . '\', \'' . $new_action . '\'',
|
||||
$updated_content
|
||||
);
|
||||
}
|
||||
|
||||
if ($extension === 'jqhtml' || $extension === 'blade.php') {
|
||||
// Replace $action="OLD_ACTION" when $controller="CLASS_NAME" is on same line
|
||||
// This uses a callback to check both attributes are present
|
||||
$updated_content = preg_replace_callback(
|
||||
'/^.*$/m',
|
||||
function ($matches) use ($class_name, $old_action, $new_action) {
|
||||
$line = $matches[0];
|
||||
|
||||
// Check if line has both $controller="CLASS_NAME" and $action="OLD_ACTION"
|
||||
$has_controller = preg_match('/\$controller=["\']' . preg_quote($class_name, '/') . '["\']/', $line);
|
||||
$has_action = preg_match('/\$action=["\']' . preg_quote($old_action, '/') . '["\']/', $line);
|
||||
|
||||
if ($has_controller && $has_action) {
|
||||
// Replace the action
|
||||
return preg_replace(
|
||||
'/(\$action=["\'])' . preg_quote($old_action, '/') . '(["\'])/',
|
||||
'$1' . $new_action . '$2',
|
||||
$line
|
||||
);
|
||||
}
|
||||
|
||||
return $line;
|
||||
},
|
||||
$updated_content
|
||||
);
|
||||
}
|
||||
|
||||
if ($extension === 'js') {
|
||||
// Replace Ajax endpoint calls: CLASS.OLD_ACTION(
|
||||
// Pattern: (non-alnum)(CLASS).(METHOD)(non-alnum)
|
||||
$updated_content = preg_replace(
|
||||
'/(?<=[^a-zA-Z0-9_])' . preg_quote($class_name, '/') . '\.' . preg_quote($old_action, '/') . '(?=[^a-zA-Z0-9_])/',
|
||||
$class_name . '.' . $new_action,
|
||||
$updated_content
|
||||
);
|
||||
}
|
||||
|
||||
if ($extension === 'php' || $extension === 'blade.php') {
|
||||
// Replace Rsx::Route('CLASS', 'OLD_ACTION'
|
||||
// Pattern matches with optional whitespace between parameters
|
||||
$updated_content = preg_replace(
|
||||
'/\bRsx::Route\(\s*[\'"]' . preg_quote($class_name, '/') . '[\'"]\s*,\s*[\'"]' . preg_quote($old_action, '/') . '[\'"]/',
|
||||
'Rsx::Route(\'' . $class_name . '\', \'' . $new_action . '\'',
|
||||
$updated_content
|
||||
);
|
||||
}
|
||||
|
||||
// Write if changed
|
||||
if ($updated_content !== $content) {
|
||||
file_put_contents($file_path, $updated_content);
|
||||
$updated_count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $updated_count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write file atomically using temp file
|
||||
*/
|
||||
|
||||
@@ -115,6 +115,18 @@ class RefactorPhpClass_Command extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Step 6.5: Check if this is a controller (subclass of Rsx_Controller)
|
||||
$is_controller = false;
|
||||
try {
|
||||
if (Manifest::php_is_subclass_of($old_class, 'Rsx_Controller_Abstract')) {
|
||||
$is_controller = true;
|
||||
$this->info("");
|
||||
$this->info("<fg=cyan>Detected controller class - will also update Route() references</>");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Class might not be loaded, skip controller check
|
||||
}
|
||||
|
||||
// Step 7: Apply changes
|
||||
$this->info("");
|
||||
$this->info("Applying changes...");
|
||||
@@ -125,6 +137,20 @@ class RefactorPhpClass_Command extends Command
|
||||
$this->info("");
|
||||
$this->info("<fg=green>Successfully updated {$updated_count} file(s)</>");
|
||||
|
||||
// Step 7.5: If controller, do additional Route() replacements
|
||||
if ($is_controller) {
|
||||
$this->info("");
|
||||
$this->info("Updating Route() references in rsx/ files...");
|
||||
|
||||
$route_updated_count = $updater->update_controller_route_references($old_class, $new_class);
|
||||
|
||||
if ($route_updated_count > 0) {
|
||||
$this->info("<fg=green>✓</> Updated Route() references in {$route_updated_count} file(s)");
|
||||
} else {
|
||||
$this->line("<fg=gray>No Route() references found</>");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: Log refactor operation for upstream tracking
|
||||
RefactorLog::log_refactor(
|
||||
"rsx:refactor:rename_php_class {$old_class} {$new_class}",
|
||||
@@ -138,6 +164,22 @@ class RefactorPhpClass_Command extends Command
|
||||
$this->rename_source_file_if_needed($source_path, $new_class, $source_metadata);
|
||||
}
|
||||
|
||||
// Step 10: Final grep for any remaining references
|
||||
$this->info("");
|
||||
$this->info("Checking for remaining references to '{$old_class}'...");
|
||||
|
||||
$remaining_files = $this->grep_remaining_references($old_class);
|
||||
|
||||
if (!empty($remaining_files)) {
|
||||
$this->warn("");
|
||||
$this->warn("Found {old_class} in " . count($remaining_files) . " file(s) (manual review needed):");
|
||||
foreach ($remaining_files as $file) {
|
||||
$this->warn(" - {$file}");
|
||||
}
|
||||
} else {
|
||||
$this->info("<fg=green>✓</> No remaining references found");
|
||||
}
|
||||
|
||||
$this->info("");
|
||||
$this->info("Next steps:");
|
||||
$this->info(" 1. Review changes: git diff");
|
||||
@@ -192,4 +234,49 @@ class RefactorPhpClass_Command extends Command
|
||||
rename($old_absolute, $new_absolute);
|
||||
$this->info("<fg=green>✓</> Renamed: {$current_filename} → {$suggested_filename}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep for remaining references to the old class name in ./rsx
|
||||
*
|
||||
* @param string $old_class Old class name to search for
|
||||
* @return array List of relative file paths where the class name still appears
|
||||
*/
|
||||
protected function grep_remaining_references(string $old_class): array
|
||||
{
|
||||
$rsx_path = base_path('rsx');
|
||||
|
||||
if (!is_dir($rsx_path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use grep to find files containing the old class name
|
||||
// -r = recursive
|
||||
// -l = list files only
|
||||
// --include = only search these file types
|
||||
// -w = match whole word
|
||||
$command = sprintf(
|
||||
'grep -r -l -w %s --include="*.php" --include="*.blade.php" --include="*.js" --include="*.jqhtml" %s 2>/dev/null',
|
||||
escapeshellarg($old_class),
|
||||
escapeshellarg($rsx_path)
|
||||
);
|
||||
|
||||
$output = shell_exec($command);
|
||||
|
||||
if (empty($output)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = explode("\n", trim($output));
|
||||
|
||||
// Convert to relative paths
|
||||
$relative_files = [];
|
||||
foreach ($files as $file) {
|
||||
if (empty($file)) {
|
||||
continue;
|
||||
}
|
||||
$relative_files[] = str_replace(base_path() . '/', '', $file);
|
||||
}
|
||||
|
||||
return $relative_files;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,32 @@ class RenamePhpClassFunction_Command extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3.5: If controller, update Route() references
|
||||
if ($method_was_renamed) {
|
||||
$is_controller = false;
|
||||
try {
|
||||
if (Manifest::php_is_subclass_of($class_name, 'Rsx_Controller_Abstract')) {
|
||||
$is_controller = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Class might not be loaded, skip controller check
|
||||
}
|
||||
|
||||
if ($is_controller) {
|
||||
$this->info("");
|
||||
$this->info("Detected controller class - updating Route() action references...");
|
||||
|
||||
$updater = $updater ?? new MethodUpdater();
|
||||
$route_updated_count = $updater->update_controller_action_route_references($class_name, $old_method, $new_method);
|
||||
|
||||
if ($route_updated_count > 0) {
|
||||
$this->info("<fg=green>✓</> Updated Route() references in {$route_updated_count} file(s)");
|
||||
} else {
|
||||
$this->line("<fg=gray>No Route() action references found</>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: Recursive subclass renaming (unless --skip-subclasses)
|
||||
if (!$skip_subclasses) {
|
||||
$this->info("");
|
||||
@@ -182,6 +208,22 @@ class RenamePhpClassFunction_Command extends Command
|
||||
$class_path
|
||||
);
|
||||
|
||||
// Phase 5: Final grep for any remaining references
|
||||
$this->info("");
|
||||
$this->info("Checking for remaining references to '{$class_name}' and '{$old_method}' on same line...");
|
||||
|
||||
$remaining_files = $this->grep_remaining_method_references($class_name, $old_method);
|
||||
|
||||
if (!empty($remaining_files)) {
|
||||
$this->warn("");
|
||||
$this->warn("Found {$class_name} and {$old_method} on same line in " . count($remaining_files) . " file(s) (manual review needed):");
|
||||
foreach ($remaining_files as $file) {
|
||||
$this->warn(" - {$file}");
|
||||
}
|
||||
} else {
|
||||
$this->info("<fg=green>✓</> No remaining references found");
|
||||
}
|
||||
|
||||
// Success summary
|
||||
$this->info("");
|
||||
$this->info("<fg=green>✓</> Successfully renamed {$class_name}::{$old_method} → {$class_name}::{$new_method}");
|
||||
@@ -193,6 +235,61 @@ class RenamePhpClassFunction_Command extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep for files where both class name and old method name appear on the same line
|
||||
*
|
||||
* @param string $class_name Class name to search for
|
||||
* @param string $old_method Old method name to search for
|
||||
* @return array List of relative file paths where both appear on same line
|
||||
*/
|
||||
protected function grep_remaining_method_references(string $class_name, string $old_method): array
|
||||
{
|
||||
$rsx_path = base_path('rsx');
|
||||
|
||||
if (!is_dir($rsx_path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use grep to find files containing both class name and method name on same line
|
||||
// We'll grep for the class name, then check each result for method name on same line
|
||||
$command = sprintf(
|
||||
'grep -r -l -w %s --include="*.php" --include="*.blade.php" --include="*.js" --include="*.jqhtml" %s 2>/dev/null',
|
||||
escapeshellarg($class_name),
|
||||
escapeshellarg($rsx_path)
|
||||
);
|
||||
|
||||
$output = shell_exec($command);
|
||||
|
||||
if (empty($output)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$candidate_files = explode("\n", trim($output));
|
||||
$matching_files = [];
|
||||
|
||||
// For each file that contains the class name, check if any line has both class and method
|
||||
foreach ($candidate_files as $file) {
|
||||
if (empty($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read file and check each line
|
||||
$content = file_get_contents($file);
|
||||
$lines = explode("\n", $content);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Check if line contains both class name and method name as whole words
|
||||
if (preg_match('/\b' . preg_quote($class_name, '/') . '\b/', $line) &&
|
||||
preg_match('/\b' . preg_quote($old_method, '/') . '\b/', $line)) {
|
||||
$matching_files[] = str_replace(base_path() . '/', '', $file);
|
||||
break; // Found in this file, no need to check more lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($matching_files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a method in a class file and return metadata
|
||||
*
|
||||
|
||||
@@ -13,53 +13,43 @@ use App\RSpade\Core\Ajax\Exceptions\AjaxAuthRequiredException;
|
||||
use App\RSpade\Core\Ajax\Exceptions\AjaxUnauthorizedException;
|
||||
use App\RSpade\Core\Ajax\Exceptions\AjaxFormErrorException;
|
||||
use App\RSpade\Core\Ajax\Exceptions\AjaxFatalErrorException;
|
||||
use App\RSpade\Core\Session\RsxSession;
|
||||
use App\RSpade\Core\Session\Session;
|
||||
use App\RSpade\Core\Debug\Debugger;
|
||||
|
||||
/**
|
||||
* RSX Ajax Debug Command
|
||||
* =======================
|
||||
* RSX Ajax Command
|
||||
* =================
|
||||
*
|
||||
* PURPOSE:
|
||||
* This command allows testing Internal API (Ajax) calls from the command line.
|
||||
* It sets up session context (user_id, site_id) and executes Ajax::call() with
|
||||
* the specified controller, action, and parameters.
|
||||
* Execute Ajax endpoint methods from CLI with JSON output.
|
||||
* Designed for testing, automation, and scripting.
|
||||
*
|
||||
* HOW IT WORKS:
|
||||
* 1. Rotates logs for clean slate debugging
|
||||
* 2. Sets user_id and/or site_id in RsxSession if provided
|
||||
* 3. Calls Ajax::call() with the specified controller and action
|
||||
* 4. Outputs pretty-printed JSON response
|
||||
* 5. Handles special response types (AuthRequired, FormError, etc.)
|
||||
* 6. Rotates logs again after test for clean slate on next run
|
||||
*
|
||||
* KEY FEATURES:
|
||||
* - User context: Use --user-id to test as any user
|
||||
* - Site context: Use --site-id to test with specific site
|
||||
* - JSON parameters: Pass complex data structures as JSON
|
||||
* - Pretty output: Responses are formatted for readability
|
||||
* - Form error handling: Special formatting for validation errors
|
||||
* - Log integration: Automatic log rotation for clean testing
|
||||
* OUTPUT MODES:
|
||||
* 1. Default: Raw JSON response (just the return value)
|
||||
* 2. --debug: Full HTTP-like response with {success, _ajax_return_value, console_debug}
|
||||
* 3. --verbose: Add request context before JSON output
|
||||
*
|
||||
* USAGE EXAMPLES:
|
||||
* php artisan rsx:ajax:debug User_Api get_profile # Basic call
|
||||
* php artisan rsx:ajax:debug User_Api update --args='{"name":"John"}' # With params
|
||||
* php artisan rsx:ajax:debug Admin_Api list_users --user-id=1 # As user 1
|
||||
* php artisan rsx:ajax:debug Site_Api get_stats --site-id=5 # With site 5
|
||||
* php artisan rsx:ajax:debug Auth_Api login --args='{"email":"test@example.com","password":"secret"}'
|
||||
* php artisan rsx:ajax Controller action # Basic call
|
||||
* php artisan rsx:ajax Controller action --args='{"id":1}' # With params
|
||||
* php artisan rsx:ajax Controller action --site-id=1 # With site context
|
||||
* php artisan rsx:ajax Controller action --user-id=1 --site-id=1 # Full context
|
||||
* php artisan rsx:ajax Controller action --debug # HTTP-like response
|
||||
* php artisan rsx:ajax Controller action --verbose --site-id=1 # Show context
|
||||
*
|
||||
* OUTPUT FORMAT:
|
||||
* - Success: Pretty-printed JSON response
|
||||
* - Auth Required: Shows authentication error with login URL
|
||||
* - Unauthorized: Shows authorization error message
|
||||
* - Form Error: Shows validation errors with field details
|
||||
* - Fatal Error: Shows error message and stack trace
|
||||
* - Invalid controller/action: Clear error message
|
||||
* Default: {"records":[...], "total":10}
|
||||
* --debug: {"success":true, "_ajax_return_value":{...}, "console_debug":[...]}
|
||||
* --verbose: Adds "Set site_id to 1" before JSON
|
||||
*
|
||||
* SECURITY:
|
||||
* - Only available in local/development/testing environments
|
||||
* - Throws fatal error if attempted in production
|
||||
* - Bypasses normal HTTP authentication for testing
|
||||
* ERROR HANDLING:
|
||||
* All errors return JSON (never throws to stderr)
|
||||
* {"success":false, "error":"Error message", "error_type":"exception_type"}
|
||||
*
|
||||
* USE CASES:
|
||||
* - Test Ajax endpoints behind auth/site scoping
|
||||
* - Invoke RPC calls from automation scripts
|
||||
* - Debug API responses in production environments
|
||||
*/
|
||||
class Ajax_Debug_Command extends Command
|
||||
{
|
||||
@@ -68,29 +58,27 @@ class Ajax_Debug_Command extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'rsx:ajax:debug
|
||||
{controller : The RSX controller name (e.g., User_Api, Admin_Api)}
|
||||
{action : The action/method name (e.g., get_profile, update)}
|
||||
protected $signature = 'rsx:ajax
|
||||
{controller : The RSX controller name}
|
||||
{action : The action/method name}
|
||||
{--args= : JSON-encoded arguments to pass to the action}
|
||||
{--user-id= : Set user ID for session context}
|
||||
{--site-id= : Set site ID for session context}';
|
||||
{--site-id= : Set site ID for session context}
|
||||
{--debug : Wrap output in HTTP-like response format (success, _ajax_return_value, console_debug)}
|
||||
{--show-context : Show request context before JSON output}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Debug Internal API (Ajax) calls from command line - executes Ajax::call() with session context (development only)';
|
||||
protected $description = 'Execute Ajax endpoints from CLI with JSON output';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
// Check environment - throw fatal error in production
|
||||
if (app()->environment('production')) {
|
||||
throw new \RuntimeException('FATAL: rsx:ajax:debug command is not available in production environment. This is a development-only debugging tool.');
|
||||
}
|
||||
|
||||
// Get command arguments
|
||||
$controller = $this->argument('controller');
|
||||
@@ -102,123 +90,119 @@ class Ajax_Debug_Command extends Command
|
||||
$args_json = $this->option('args');
|
||||
$args = json_decode($args_json, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->error('❌ Invalid JSON in --args parameter: ' . json_last_error_msg());
|
||||
$this->output_json_error('Invalid JSON in --args parameter: ' . json_last_error_msg(), 'invalid_json');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Get user and site IDs
|
||||
// Get options
|
||||
$user_id = $this->option('user-id');
|
||||
$site_id = $this->option('site-id');
|
||||
$debug_mode = $this->option('debug');
|
||||
$show_context = $this->option('show-context');
|
||||
|
||||
// Rotate logs before test to ensure clean slate
|
||||
// Rotate logs before test
|
||||
Debugger::logrotate();
|
||||
|
||||
// Set session context if provided
|
||||
if ($user_id !== null) {
|
||||
RsxSession::set_user_id((int)$user_id);
|
||||
$this->info("✓ Set user_id to {$user_id}");
|
||||
Session::set_user_id((int)$user_id);
|
||||
if ($show_context) {
|
||||
$this->error("Set user_id to {$user_id}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($site_id !== null) {
|
||||
RsxSession::set_site_id((int)$site_id);
|
||||
$this->info("✓ Set site_id to {$site_id}");
|
||||
Session::set_site_id((int)$site_id);
|
||||
if ($show_context) {
|
||||
$this->error("Set site_id to {$site_id}");
|
||||
}
|
||||
}
|
||||
|
||||
// Show what we're calling
|
||||
$this->info("");
|
||||
$this->info("Calling: {$controller}::{$action}");
|
||||
if (!empty($args)) {
|
||||
$this->info("Arguments: " . json_encode($args, JSON_PRETTY_PRINT));
|
||||
}
|
||||
$this->info("");
|
||||
$this->line("─────────────────────────────────────────");
|
||||
$this->info("");
|
||||
|
||||
try {
|
||||
// Call the Ajax method
|
||||
$response = Ajax::internal($controller, $action, $args);
|
||||
|
||||
// Output successful response
|
||||
$this->info("✅ Success Response:");
|
||||
$this->info("");
|
||||
|
||||
// Pretty print the JSON response
|
||||
$json_output = json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$this->line($json_output);
|
||||
// Output response based on mode
|
||||
if ($debug_mode) {
|
||||
// Use shared format_ajax_response method for consistency with HTTP
|
||||
$wrapped_response = Ajax::format_ajax_response($response);
|
||||
$this->output_json($wrapped_response);
|
||||
} else {
|
||||
// Just output the raw response
|
||||
$this->output_json($response);
|
||||
}
|
||||
|
||||
} catch (AjaxAuthRequiredException $e) {
|
||||
// Handle auth required response
|
||||
$this->error("🔒 Authentication Required");
|
||||
$this->error("");
|
||||
$this->error("Login URL: " . $e->getMessage());
|
||||
$this->output_json_error($e->getMessage(), 'auth_required');
|
||||
return 1;
|
||||
|
||||
} catch (AjaxUnauthorizedException $e) {
|
||||
// Handle unauthorized response
|
||||
$this->error("⛔ Unauthorized");
|
||||
$this->error("");
|
||||
$this->error("Message: " . $e->getMessage());
|
||||
$this->output_json_error($e->getMessage(), 'unauthorized');
|
||||
return 1;
|
||||
|
||||
} catch (AjaxFormErrorException $e) {
|
||||
// Handle form error response - format like /_ajax route
|
||||
$this->error("❌ Form Validation Error");
|
||||
$this->error("");
|
||||
|
||||
$details = $e->get_details();
|
||||
|
||||
// Format the response like the /_ajax route does
|
||||
$formatted_response = [
|
||||
$error_response = [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
'error_type' => 'form_error',
|
||||
];
|
||||
|
||||
// Add any additional details from the exception
|
||||
$details = $e->get_details();
|
||||
if (!empty($details)) {
|
||||
foreach ($details as $key => $value) {
|
||||
if ($key !== 'message') {
|
||||
$formatted_response[$key] = $value;
|
||||
}
|
||||
}
|
||||
$error_response['details'] = $details;
|
||||
}
|
||||
|
||||
$json_output = json_encode($formatted_response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$this->line($json_output);
|
||||
$this->output_json($error_response);
|
||||
return 1;
|
||||
|
||||
} catch (AjaxFatalErrorException $e) {
|
||||
// Handle fatal error response
|
||||
$this->error("💀 Fatal Error");
|
||||
$this->error("");
|
||||
$this->error("Message: " . $e->getMessage());
|
||||
$this->error("");
|
||||
$this->error("Stack Trace:");
|
||||
$this->line($e->getTraceAsString());
|
||||
$this->output_json_error($e->getMessage(), 'fatal_error', [
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
return 1;
|
||||
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// Handle invalid controller/action
|
||||
$this->error("❌ Invalid Request");
|
||||
$this->error("");
|
||||
$this->error($e->getMessage());
|
||||
$this->output_json_error($e->getMessage(), 'invalid_argument');
|
||||
return 1;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Handle unexpected exceptions
|
||||
$this->error("💥 Unexpected Error");
|
||||
$this->error("");
|
||||
$this->error("Exception: " . get_class($e));
|
||||
$this->error("Message: " . $e->getMessage());
|
||||
$this->error("");
|
||||
$this->error("Stack Trace:");
|
||||
$this->line($e->getTraceAsString());
|
||||
$this->output_json_error($e->getMessage(), get_class($e), [
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Rotate logs after test to clean slate for next run
|
||||
// Rotate logs after test
|
||||
Debugger::logrotate();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output JSON to stdout (pretty-printed)
|
||||
*/
|
||||
protected function output_json($data): void
|
||||
{
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$this->line($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output error as JSON
|
||||
*/
|
||||
protected function output_json_error(string $message, string $error_type, array $extra = []): void
|
||||
{
|
||||
$error = [
|
||||
'success' => false,
|
||||
'error' => $message,
|
||||
'error_type' => $error_type,
|
||||
];
|
||||
|
||||
foreach ($extra as $key => $value) {
|
||||
$error[$key] = $value;
|
||||
}
|
||||
|
||||
$this->output_json($error);
|
||||
}
|
||||
}
|
||||
112
app/RSpade/Commands/Rsx/Task_List_Command.php
Executable file
112
app/RSpade/Commands/Rsx/Task_List_Command.php
Executable file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
/**
|
||||
* CODING CONVENTION:
|
||||
* This file follows the coding convention where variable_names and function_names
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Commands\Rsx;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
|
||||
/**
|
||||
* RSX Task List Command
|
||||
* ===================
|
||||
*
|
||||
* Lists all available tasks organized by service
|
||||
*/
|
||||
class Task_List_Command extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'rsx:task:list';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'List all available tasks';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$manifest = Manifest::get_all();
|
||||
$services = [];
|
||||
|
||||
// Find all services and their tasks
|
||||
foreach ($manifest as $file_path => $info) {
|
||||
// Skip non-PHP files or files without classes
|
||||
if (!isset($info['class']) || !isset($info['fqcn'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if class extends Rsx_Service_Abstract
|
||||
if (!isset($info['extends']) || $info['extends'] !== 'Rsx_Service_Abstract') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$service_name = $info['class'];
|
||||
$service_tasks = [];
|
||||
|
||||
// Find methods with Task attribute
|
||||
if (isset($info['public_static_methods'])) {
|
||||
foreach ($info['public_static_methods'] as $method_name => $method_info) {
|
||||
if (!isset($method_info['attributes'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look for Task attribute
|
||||
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
|
||||
if ($attr_name === 'Task' || str_ends_with($attr_name, '\\Task')) {
|
||||
// Extract description from first attribute instance
|
||||
$description = '';
|
||||
if (!empty($attr_instances) && isset($attr_instances[0]['arguments'][0])) {
|
||||
$description = $attr_instances[0]['arguments'][0];
|
||||
}
|
||||
|
||||
$service_tasks[] = [
|
||||
'name' => $method_name,
|
||||
'description' => $description
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($service_tasks)) {
|
||||
$services[$service_name] = $service_tasks;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($services)) {
|
||||
$this->info('No tasks found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Display tasks grouped by service
|
||||
$this->info('Available Tasks:');
|
||||
$this->newLine();
|
||||
|
||||
foreach ($services as $service_name => $tasks) {
|
||||
$this->line(" <fg=cyan>{$service_name}</>");
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
$task_name = str_pad($task['name'], 25);
|
||||
$description = $task['description'] ?: '(no description)';
|
||||
$this->line(" <fg=green>{$task_name}</> - {$description}");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
191
app/RSpade/Commands/Rsx/Task_Run_Command.php
Executable file
191
app/RSpade/Commands/Rsx/Task_Run_Command.php
Executable file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
/**
|
||||
* CODING CONVENTION:
|
||||
* This file follows the coding convention where variable_names and function_names
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Commands\Rsx;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\RSpade\Core\Task\Task;
|
||||
|
||||
/**
|
||||
* RSX Task Run Command
|
||||
* ====================
|
||||
*
|
||||
* PURPOSE:
|
||||
* Execute task methods from CLI with JSON output.
|
||||
* Designed for background jobs, seeders, maintenance, and automation.
|
||||
*
|
||||
* OUTPUT MODES:
|
||||
* 1. Default: Raw JSON response (just the return value)
|
||||
* 2. --debug: Wrapped response with {success, result}
|
||||
*
|
||||
* USAGE EXAMPLES:
|
||||
* php artisan rsx:task:run Service task_name # Basic execution
|
||||
* php artisan rsx:task:run Service task_name --param=value # With parameters
|
||||
* php artisan rsx:task:run Service task_name --flag # Boolean parameter
|
||||
* php artisan rsx:task:run Service task_name --debug # Wrapped response
|
||||
*
|
||||
* PARAMETER HANDLING:
|
||||
* All --key=value options become $params['key'] = 'value'
|
||||
* Boolean flags: --flag becomes $params['flag'] = true
|
||||
* JSON values: --data='{"foo":"bar"}' is parsed automatically
|
||||
*
|
||||
* ERROR HANDLING:
|
||||
* All errors return JSON (never throws to stderr)
|
||||
* {"success":false, "error":"Error message", "error_type":"exception_type"}
|
||||
*
|
||||
* USE CASES:
|
||||
* - Run database seeders
|
||||
* - Execute maintenance tasks
|
||||
* - Generate reports
|
||||
* - Process background jobs
|
||||
*/
|
||||
class Task_Run_Command extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'rsx:task:run
|
||||
{service : The RSX service name}
|
||||
{task : The task/method name}
|
||||
{--debug : Wrap output in formatted response (success, result)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Execute tasks from CLI with JSON output';
|
||||
|
||||
/**
|
||||
* Get the console command arguments and options.
|
||||
* Allow any options to be passed through to tasks.
|
||||
*/
|
||||
protected function getOptions()
|
||||
{
|
||||
// Parse parent options first
|
||||
$options = parent::getOptions();
|
||||
|
||||
// Allow any additional options by not validating them
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the command to accept any options
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
parent::configure();
|
||||
|
||||
// Tell Symfony to ignore unknown options
|
||||
$this->ignoreValidationErrors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
// Get command arguments
|
||||
$service = $this->argument('service');
|
||||
$task = $this->argument('task');
|
||||
$debug_mode = $this->option('debug');
|
||||
|
||||
// Parse all remaining options as task parameters
|
||||
// We need to manually parse from $_SERVER['argv'] because Laravel validates options
|
||||
$params = [];
|
||||
$skip_builtins = ['help', 'quiet', 'verbose', 'version', 'ansi', 'no-ansi', 'no-interaction', 'env', 'debug'];
|
||||
|
||||
// Parse from raw argv
|
||||
$argv = $_SERVER['argv'] ?? [];
|
||||
for ($i = 0; $i < count($argv); $i++) {
|
||||
$arg = $argv[$i];
|
||||
|
||||
// Check if this is an option
|
||||
if (!str_starts_with($arg, '--')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove -- prefix
|
||||
$option_string = substr($arg, 2);
|
||||
|
||||
// Parse key=value or just key
|
||||
if (str_contains($option_string, '=')) {
|
||||
[$key, $value] = explode('=', $option_string, 2);
|
||||
} else {
|
||||
$key = $option_string;
|
||||
$value = true; // Boolean flag
|
||||
}
|
||||
|
||||
// Skip built-in Laravel options
|
||||
if (in_array($key, $skip_builtins)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to parse JSON values
|
||||
if (is_string($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
|
||||
$decoded = json_decode($value, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$value = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// Store parameter
|
||||
$params[$key] = $value;
|
||||
}
|
||||
|
||||
try {
|
||||
// Execute the task
|
||||
$response = Task::internal($service, $task, $params);
|
||||
|
||||
// Output response based on mode
|
||||
if ($debug_mode) {
|
||||
$wrapped_response = Task::format_task_response($response);
|
||||
$this->output_json($wrapped_response);
|
||||
} else {
|
||||
// Just output the raw response
|
||||
$this->output_json($response);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->output_json_error($e->getMessage(), get_class($e), [
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output JSON to stdout (pretty-printed)
|
||||
*/
|
||||
protected function output_json($data): void
|
||||
{
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$this->line($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output error as JSON
|
||||
*/
|
||||
protected function output_json_error(string $message, string $error_type, array $extra = []): void
|
||||
{
|
||||
$error = [
|
||||
'success' => false,
|
||||
'error' => $message,
|
||||
'error_type' => $error_type,
|
||||
];
|
||||
|
||||
foreach ($extra as $key => $value) {
|
||||
$error[$key] = $value;
|
||||
}
|
||||
|
||||
$this->output_json($error);
|
||||
}
|
||||
}
|
||||
@@ -262,16 +262,34 @@ class Ajax
|
||||
throw new Exception("Method {$action_name} in {$controller_class} must have Ajax_Endpoint annotation");
|
||||
}
|
||||
|
||||
// Extract params from request body
|
||||
$ajax_params = [];
|
||||
|
||||
// First check for JSON-encoded params field (old format)
|
||||
$params_json = $request->input('params');
|
||||
if ($params_json) {
|
||||
$ajax_params = json_decode($params_json, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new Exception('Invalid JSON in params: ' . json_last_error_msg());
|
||||
}
|
||||
} else {
|
||||
// Get all request input (form-urlencoded or JSON body)
|
||||
$all_input = $request->all();
|
||||
|
||||
// Remove route parameters (controller, action, etc)
|
||||
$ajax_params = array_diff_key($all_input, array_flip(['controller', 'action', '_method', '_route', '_handler']));
|
||||
}
|
||||
|
||||
// Call pre_dispatch if it exists
|
||||
$response = null;
|
||||
if (method_exists($controller_class, 'pre_dispatch')) {
|
||||
$response = $controller_class::pre_dispatch($request, $params);
|
||||
$response = $controller_class::pre_dispatch($request, $ajax_params);
|
||||
}
|
||||
|
||||
// If pre_dispatch returned something, use that as response
|
||||
if ($response === null) {
|
||||
// Call the actual method
|
||||
$response = $controller_class::$action_name($request, $params);
|
||||
$response = $controller_class::$action_name($request, $ajax_params);
|
||||
}
|
||||
|
||||
// Handle special response types
|
||||
@@ -279,18 +297,8 @@ class Ajax
|
||||
return static::_handle_browser_special_response($response);
|
||||
}
|
||||
|
||||
// Wrap normal response in success JSON
|
||||
$json_response = [
|
||||
'success' => true,
|
||||
'_ajax_return_value' => $response,
|
||||
];
|
||||
|
||||
// Add console debug messages if any
|
||||
$console_messages = \App\RSpade\Core\Debug\Debugger::_get_console_messages();
|
||||
if (!empty($console_messages)) {
|
||||
// Messages are now structured as [channel, [arguments]]
|
||||
$json_response['console_debug'] = $console_messages;
|
||||
}
|
||||
// Wrap response using shared formatting method
|
||||
$json_response = static::format_ajax_response($response);
|
||||
|
||||
return response()->json($json_response);
|
||||
}
|
||||
@@ -361,6 +369,33 @@ class Ajax
|
||||
return static::$ajax_response_mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Ajax response with standard wrapper structure
|
||||
*
|
||||
* This method creates the standardized response format used by both
|
||||
* HTTP Ajax endpoints and CLI Ajax commands. Ensures consistency across
|
||||
* all Ajax response types.
|
||||
*
|
||||
* @param mixed $response The Ajax method return value
|
||||
* @return array Formatted response array with success, _ajax_return_value, and optional console_debug
|
||||
*/
|
||||
public static function format_ajax_response($response): array
|
||||
{
|
||||
$json_response = [
|
||||
'success' => true,
|
||||
'_ajax_return_value' => $response,
|
||||
];
|
||||
|
||||
// Add console debug messages if any
|
||||
$console_messages = \App\RSpade\Core\Debug\Debugger::_get_console_messages();
|
||||
if (!empty($console_messages)) {
|
||||
// Messages are now structured as [channel, [arguments]]
|
||||
$json_response['console_debug'] = $console_messages;
|
||||
}
|
||||
|
||||
return $json_response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward compatibility alias for internal()
|
||||
* @deprecated Use internal() instead
|
||||
|
||||
@@ -577,6 +577,27 @@ class BundleCompiler
|
||||
);
|
||||
}
|
||||
|
||||
// Validate path format (development mode only)
|
||||
if (!app()->environment('production') && is_string($item)) {
|
||||
if (str_starts_with($item, 'system/app/') || str_starts_with($item, '/system/app/')) {
|
||||
throw new RuntimeException(
|
||||
"'system/app/' is not a valid RSpade path. Use 'app/' instead.\n" .
|
||||
"Invalid path: {$item}\n" .
|
||||
"Correct path: " . str_replace('system/app/', 'app/', $item) . "\n\n" .
|
||||
'From the bundle perspective, framework code is at app/, not system/app/'
|
||||
);
|
||||
}
|
||||
|
||||
if (str_starts_with($item, 'system/rsx/') || str_starts_with($item, '/system/rsx/')) {
|
||||
throw new RuntimeException(
|
||||
"'system/rsx/' is not a valid RSpade path. Use 'rsx/' instead.\n" .
|
||||
"Invalid path: {$item}\n" .
|
||||
"Correct path: " . str_replace('system/rsx/', 'rsx/', $item) . "\n\n" .
|
||||
'From the bundle perspective, application code is at rsx/, not system/rsx/'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise treat as file/directory path
|
||||
$path = base_path($item);
|
||||
|
||||
@@ -1805,7 +1826,14 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
||||
foreach ($files_to_concat as $file) {
|
||||
// Use babel-transformed version if it exists, otherwise use original
|
||||
$file_to_use = $this->babel_file_mapping[$file] ?? $file;
|
||||
$cmd_parts[] = escapeshellarg($file_to_use);
|
||||
|
||||
// If this is a babel-transformed file, pass metadata to concat-js
|
||||
// Format: babel_file_path::original_file_path
|
||||
if (isset($this->babel_file_mapping[$file])) {
|
||||
$cmd_parts[] = escapeshellarg($file_to_use . '::' . $file);
|
||||
} else {
|
||||
$cmd_parts[] = escapeshellarg($file_to_use);
|
||||
}
|
||||
}
|
||||
$cmd = implode(' ', $cmd_parts);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class Core_Bundle extends Rsx_Bundle_Abstract
|
||||
'include' => [
|
||||
__DIR__,
|
||||
'app/RSpade/Core/Js',
|
||||
'app/RSpade/Core/Data',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -82,19 +82,37 @@ async function concatenateFiles() {
|
||||
|
||||
// Process each input file
|
||||
for (const inputFile of inputFiles) {
|
||||
if (!fs.existsSync(inputFile)) {
|
||||
console.error(`Error: Input file not found: ${inputFile}`);
|
||||
// Check if this is babel-transformed file with metadata
|
||||
// Format: babel_file_path::original_file_path
|
||||
let actualFile = inputFile;
|
||||
let displayPath = null;
|
||||
let isBabel = false;
|
||||
|
||||
if (inputFile.includes('::')) {
|
||||
const parts = inputFile.split('::');
|
||||
actualFile = parts[0]; // The babel-transformed file to read
|
||||
displayPath = parts[1]; // The original source file for display
|
||||
isBabel = true;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(actualFile)) {
|
||||
console.error(`Error: Input file not found: ${actualFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = fs.readFileSync(inputFile, 'utf-8');
|
||||
const content = fs.readFileSync(actualFile, 'utf-8');
|
||||
|
||||
// Generate relative path for better source map references
|
||||
const relativePath = path.relative(process.cwd(), inputFile);
|
||||
const relativePath = displayPath
|
||||
? path.relative(process.cwd(), displayPath)
|
||||
: path.relative(process.cwd(), actualFile);
|
||||
|
||||
// Add file separator comment
|
||||
rootNode.add(`/* === ${relativePath} === */\n`);
|
||||
// Add file separator comment with (babel) suffix if transformed
|
||||
const banner = isBabel
|
||||
? `/* === ${relativePath} (babel) === */\n`
|
||||
: `/* === ${relativePath} === */\n`;
|
||||
rootNode.add(banner);
|
||||
|
||||
// Extract sourcemap if present
|
||||
const { content: cleanContent, map } = extractSourceMap(content, relativePath);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top shadow-sm">
|
||||
<div class="container-fluid">
|
||||
{{-- Brand --}}
|
||||
<a class="navbar-brand fw-bold" href="{{ Rsx::Route('{{ module_class }}_Index_Controller')->url() }}">
|
||||
<a class="navbar-brand fw-bold" href="{{ Rsx::Route('{{ module_class }}_Index_Controller') }}">
|
||||
{{ module_title }}
|
||||
</a>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
--}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ request()->is('{{ module_name }}') || request()->is('{{ module_name }}/index') ? 'active' : '' }}"
|
||||
href="{{ Rsx::Route('{{ module_class }}_Index_Controller')->url() }}">
|
||||
href="{{ Rsx::Route('{{ module_class }}_Index_Controller') }}">
|
||||
<i class="bi bi-house-door"></i> Home
|
||||
</a>
|
||||
</li>
|
||||
@@ -47,7 +47,7 @@
|
||||
{{--
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ request()->is('{{ module_name }}/feature*') ? 'active' : '' }}"
|
||||
href="{{ Rsx::Route('{{ module_class }}_Feature_Controller')->url() }}">
|
||||
href="{{ Rsx::Route('{{ module_class }}_Feature_Controller') }}">
|
||||
<i class="bi bi-star"></i> Feature
|
||||
</a>
|
||||
</li>
|
||||
@@ -71,13 +71,13 @@
|
||||
<i class="bi bi-gear"></i> Settings
|
||||
</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ Rsx::Route('Login_Index_Controller', '#logout')->url() }}">
|
||||
<li><a class="dropdown-item" href="{{ Rsx::Route('Login_Index_Controller', '#logout') }}">
|
||||
<i class="bi bi-box-arrow-right"></i> Sign Out
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@else
|
||||
<a class="btn btn-outline-light btn-sm" href="{{ Rsx::Route('Login_Index_Controller', '#show_login')->url() }}">
|
||||
<a class="btn btn-outline-light btn-sm" href="{{ Rsx::Route('Login_Index_Controller', '#show_login') }}">
|
||||
Login
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
--}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ request()->is('{{ module_name }}/{{ submodule_name }}') || request()->is('{{ module_name }}/{{ submodule_name }}/index') ? 'active' : '' }}"
|
||||
href="{{ Rsx::Route('{{ controller_prefix }}_Index_Controller')->url() }}">
|
||||
href="{{ Rsx::Route('{{ controller_prefix }}_Index_Controller') }}">
|
||||
<i class="bi bi-house"></i> Overview
|
||||
</a>
|
||||
</li>
|
||||
@@ -31,7 +31,7 @@
|
||||
{{--
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ request()->is('{{ module_name }}/{{ submodule_name }}/feature*') ? 'active' : '' }}"
|
||||
href="{{ Rsx::Route('{{ controller_prefix }}_Feature_Controller')->url() }}">
|
||||
href="{{ Rsx::Route('{{ controller_prefix }}_Feature_Controller') }}">
|
||||
<i class="bi bi-star"></i> Feature
|
||||
</a>
|
||||
</li>
|
||||
@@ -53,7 +53,7 @@
|
||||
<h1 class="h3 mb-0">@yield('page_title', '{{ submodule_title }}')</h1>
|
||||
<nav aria-label="breadcrumb" class="mt-2">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ Rsx::Route('{{ module_class }}_Index_Controller')->url() }}">{{ module_title }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ Rsx::Route('{{ module_class }}_Index_Controller') }}">{{ module_title }}</a></li>
|
||||
<li class="breadcrumb-item">{{ submodule_title }}</li>
|
||||
@yield('breadcrumb_items')
|
||||
</ol>
|
||||
|
||||
0
app/RSpade/Core/Console/Commands/.placeholder
Executable file
0
app/RSpade/Core/Console/Commands/.placeholder
Executable file
@@ -210,15 +210,21 @@ class Controller_BundleIntegration extends BundleIntegration_Abstract
|
||||
foreach ($api_methods as $method_name => $method_info) {
|
||||
$content .= " /**\n";
|
||||
$content .= " * Call {$controller_name}::{$method_name} via Ajax.call()\n";
|
||||
$content .= " * @param {...*} args - Arguments to pass to the method\n";
|
||||
$content .= " * @param {Object} params - Parameters object to pass to the method\n";
|
||||
$content .= " * @returns {Promise<*>}\n";
|
||||
$content .= " */\n";
|
||||
$content .= " static async {$method_name}(...args) {\n";
|
||||
$content .= " return Ajax.call('{$controller_name}', '{$method_name}', args);\n";
|
||||
$content .= " static async {$method_name}(params = {}) {\n";
|
||||
$content .= " return Ajax.call('/_ajax/{$controller_name}/{$method_name}', params);\n";
|
||||
$content .= " }\n\n";
|
||||
}
|
||||
|
||||
$content .= "}\n";
|
||||
$content .= "}\n\n";
|
||||
|
||||
// Add path properties after class definition
|
||||
$content .= "// Path properties for type-safe routing\n";
|
||||
foreach ($api_methods as $method_name => $method_info) {
|
||||
$content .= "{$controller_name}.{$method_name}.path = '/_ajax/{$controller_name}/{$method_name}';\n";
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
@@ -42,17 +42,17 @@ abstract class Rsx_Controller_Abstract extends BaseController
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a view with data
|
||||
*
|
||||
* @param string $view View name
|
||||
* @param array $data Data to pass to view
|
||||
* @return \Illuminate\View\View
|
||||
*/
|
||||
protected function view($view, $data = [])
|
||||
{
|
||||
return view($view, $data);
|
||||
}
|
||||
// /**
|
||||
// * Render a view with data
|
||||
// *
|
||||
// * @param string $view View name
|
||||
// * @param array $data Data to pass to view
|
||||
// * @return \Illuminate\View\View
|
||||
// */
|
||||
// protected function view($view, $data = [])
|
||||
// {
|
||||
// return view($view, $data);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Return a redirect response
|
||||
|
||||
58
app/RSpade/Core/Data/Rsx_Reference_Data_Controller.php
Executable file
58
app/RSpade/Core/Data/Rsx_Reference_Data_Controller.php
Executable file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Data;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
|
||||
use App\RSpade\Core\Models\Country_Model;
|
||||
use App\RSpade\Core\Models\Region_Model;
|
||||
|
||||
/**
|
||||
* RSX:USE
|
||||
* Rsx_Reference_Data_Controller - Provides reference data for forms (countries, states, etc.)
|
||||
*
|
||||
* Ajax endpoints that return standardized reference data for dropdowns and forms.
|
||||
* Returns data in format compatible with Select_Input widgets: [{value: 'code', label: 'name'}, ...]
|
||||
*
|
||||
* Usage from widgets:
|
||||
* let countries = await Rsx_Reference_Data_Controller.countries();
|
||||
* let states = await Rsx_Reference_Data_Controller.states({country: 'US'});
|
||||
*/
|
||||
class Rsx_Reference_Data_Controller extends Rsx_Controller_Abstract
|
||||
{
|
||||
/**
|
||||
* Get list of all countries with ISO codes
|
||||
* Queries Country_Model database for ISO 3166-1 country data
|
||||
* Returns array of {value: country_code, label: country_name} sorted alphabetically
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function countries(Request $request, array $params = []): array
|
||||
{
|
||||
return Country_Model::enabled()
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn($c) => ['value' => $c->alpha2, 'label' => $c->name])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of states/provinces for a country
|
||||
* Queries Region_Model database for ISO 3166-2 subdivision data
|
||||
*
|
||||
* @param Request $request
|
||||
* @param array $params - Expected: ['country' => 'US']
|
||||
* @return array
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function states(Request $request, array $params = []): array
|
||||
{
|
||||
$country = $params['country'] ?? 'US';
|
||||
|
||||
return Region_Model::where('country_alpha2', $country)
|
||||
->enabled()
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn($r) => ['value' => $r->code, 'label' => $r->name])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
1
app/RSpade/Core/Data/refactor.list
Executable file
1
app/RSpade/Core/Data/refactor.list
Executable file
@@ -0,0 +1 @@
|
||||
[2025-10-30 05:37:44] rsx:refactor:rename_php_class Rsx_Reference_Data Rsx_Reference_Data_Controller
|
||||
@@ -227,6 +227,7 @@ abstract class Rsx_Site_Model_Abstract extends Rsx_Model_Abstract
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Boot the model and add global scope for site_id
|
||||
*/
|
||||
@@ -287,6 +288,11 @@ abstract class Rsx_Site_Model_Abstract extends Rsx_Model_Abstract
|
||||
// After retrieving records, validate they belong to current site
|
||||
static::retrieved(function ($model) {
|
||||
if (static::$apply_site_scope) {
|
||||
// Skip check if site_id wasn't selected (developer using ->get(['specific', 'columns']))
|
||||
if (!isset($model->site_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$current_site_id = static::get_current_site_id();
|
||||
|
||||
// This shouldn't happen if global scope is working, but double-check
|
||||
|
||||
@@ -166,7 +166,7 @@ class Dispatcher
|
||||
$params = array_merge($request->query->all(), $extra_params);
|
||||
|
||||
// Generate proper URL using Rsx::Route
|
||||
$proper_url = Rsx::Route($controller_name, $action_name)->url($params);
|
||||
$proper_url = Rsx::Route($controller_name, $action_name, $params);
|
||||
|
||||
console_debug('DISPATCH', 'Redirecting GET to proper route:', $proper_url);
|
||||
|
||||
@@ -1100,7 +1100,7 @@ class Dispatcher
|
||||
"Auth redirect_to must be array ['Controller', 'action'] on {$handler_class}::{$handler_method}"
|
||||
);
|
||||
}
|
||||
$url = Rsx::Route($redirect_to[0], $redirect_to[1] ?? 'index')->url();
|
||||
$url = Rsx::Route($redirect_to[0], $redirect_to[1] ?? 'index');
|
||||
if ($message) {
|
||||
Rsx::flash_error($message);
|
||||
}
|
||||
|
||||
@@ -246,20 +246,16 @@ try {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Add comment header with file information
|
||||
const header = `/* Transformed from: ${hashPath} (hash: ${fileHash}) */\n`;
|
||||
const output = header + result.code;
|
||||
|
||||
// Output result
|
||||
// Output result (no banner - concat-js.js handles that)
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify({
|
||||
status: 'success',
|
||||
result: output,
|
||||
result: result.code,
|
||||
file: filePath,
|
||||
hash: fileHash
|
||||
}));
|
||||
} else {
|
||||
console.log(output);
|
||||
console.log(result.code);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,37 +3,143 @@
|
||||
/**
|
||||
* Client-side Ajax class for making API calls to RSX controllers
|
||||
*
|
||||
* Mirrors the PHP Ajax::call (Ajax::internal) functionality for browser-side JavaScript
|
||||
* Automatically batches multiple calls into single HTTP requests to reduce network overhead.
|
||||
* Batches up to 20 calls or flushes after setTimeout(0) debounce.
|
||||
*/
|
||||
class Ajax {
|
||||
/**
|
||||
* Make an AJAX call to an RSX controller action
|
||||
*
|
||||
* All calls are automatically batched using Rsx_Ajax_Batch unless
|
||||
* window.rsxapp.ajax_disable_batching is true (for debugging).
|
||||
*
|
||||
* @param {string} controller - The controller class name (e.g., 'User_Controller')
|
||||
* @param {string} action - The action method name (e.g., 'get_profile')
|
||||
* @param {object} params - Parameters to send to the action
|
||||
* @returns {Promise} - Resolves with the return value, rejects with error
|
||||
* Initialize Ajax system
|
||||
* Called automatically when class is loaded
|
||||
*/
|
||||
static async call(controller, action, params = {}) {
|
||||
// Route through batch system
|
||||
return Rsx_Ajax_Batch.call(controller, action, params);
|
||||
static _on_framework_core_init() {
|
||||
// Queue of pending calls waiting to be batched
|
||||
Ajax._pending_calls = {};
|
||||
|
||||
// Timer for batching flush
|
||||
Ajax._flush_timeout = null;
|
||||
|
||||
// Call counter for generating unique call IDs
|
||||
Ajax._call_counter = 0;
|
||||
|
||||
// Maximum batch size before forcing immediate flush
|
||||
Ajax.MAX_BATCH_SIZE = 20;
|
||||
|
||||
// Debounce time in milliseconds
|
||||
Ajax.DEBOUNCE_MS = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* DEPRECATED: Direct call implementation (preserved for reference)
|
||||
* This is now handled by Rsx_Ajax_Batch
|
||||
* Make an AJAX call to an RSX controller action
|
||||
*
|
||||
* All calls are automatically batched unless window.rsxapp.ajax_disable_batching is true.
|
||||
*
|
||||
* @param {string|object|function} url - The Ajax URL (e.g., '/_ajax/Controller_Name/action_name') or an object/function with a .path property
|
||||
* @param {object} params - Parameters to send to the action
|
||||
* @returns {Promise} - Resolves with the return value, rejects with error
|
||||
*/
|
||||
static async call(url, params = {}) {
|
||||
// If url is an object or function with a .path property, use that as the URL
|
||||
if (url && typeof url === 'object' && url.path) {
|
||||
url = url.path;
|
||||
} else if (url && typeof url === 'function' && url.path) {
|
||||
url = url.path;
|
||||
}
|
||||
|
||||
// Validate url is a non-empty string
|
||||
if (typeof url !== 'string' || url.length === 0) {
|
||||
throw new Error('Ajax.call() requires a non-empty string URL or an object/function with a .path property');
|
||||
}
|
||||
|
||||
// Extract controller and action from URL
|
||||
const { controller, action } = Ajax.ajax_url_to_controller_action(url);
|
||||
|
||||
console.log('Ajax:', controller, action, params);
|
||||
|
||||
// Check if batching is disabled for debugging
|
||||
if (window.rsxapp && window.rsxapp.ajax_disable_batching) {
|
||||
return Ajax._call_direct(controller, action, params);
|
||||
}
|
||||
|
||||
return Ajax._call_batch(controller, action, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a batched Ajax call
|
||||
* @private
|
||||
*/
|
||||
static _call_batch(controller, action, params = {}) {
|
||||
console.log('Ajax Batch:', controller, action, params);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Generate call key for deduplication
|
||||
const call_key = Ajax._generate_call_key(controller, action, params);
|
||||
|
||||
// Check if this exact call is already pending
|
||||
if (Ajax._pending_calls[call_key]) {
|
||||
const existing_call = Ajax._pending_calls[call_key];
|
||||
|
||||
// If call already completed (cached), return immediately
|
||||
if (existing_call.is_complete) {
|
||||
if (existing_call.is_error) {
|
||||
reject(existing_call.error);
|
||||
} else {
|
||||
resolve(existing_call.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Call is pending, add this promise to callbacks
|
||||
existing_call.callbacks.push({ resolve, reject });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new pending call
|
||||
const call_id = Ajax._call_counter++;
|
||||
const pending_call = {
|
||||
call_id: call_id,
|
||||
call_key: call_key,
|
||||
controller: controller,
|
||||
action: action,
|
||||
params: params,
|
||||
callbacks: [{ resolve, reject }],
|
||||
is_complete: false,
|
||||
is_error: false,
|
||||
result: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Add to pending queue
|
||||
Ajax._pending_calls[call_key] = pending_call;
|
||||
|
||||
// Count pending calls
|
||||
const pending_count = Object.keys(Ajax._pending_calls).filter((key) => !Ajax._pending_calls[key].is_complete).length;
|
||||
|
||||
// If we've hit the batch size limit, flush immediately
|
||||
if (pending_count >= Ajax.MAX_BATCH_SIZE) {
|
||||
clearTimeout(Ajax._flush_timeout);
|
||||
Ajax._flush_timeout = null;
|
||||
Ajax._flush_pending_calls();
|
||||
} else {
|
||||
// Schedule batch flush with debounce
|
||||
clearTimeout(Ajax._flush_timeout);
|
||||
Ajax._flush_timeout = setTimeout(() => {
|
||||
Ajax._flush_pending_calls();
|
||||
}, Ajax.DEBOUNCE_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a direct (non-batched) Ajax call
|
||||
* @private
|
||||
*/
|
||||
static async _call_direct(controller, action, params = {}) {
|
||||
// Build the endpoint URL
|
||||
// Construct URL from controller and action
|
||||
const url = `/_ajax/${controller}/${action}`;
|
||||
|
||||
// Log the AJAX call using console_debug
|
||||
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
|
||||
Debugger.console_debug('AJAX', `Calling ${controller}.${action}`, params);
|
||||
Debugger.console_debug('AJAX', `Calling ${controller}.${action} (unbatched)`, params);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -42,27 +148,24 @@ class Ajax {
|
||||
method: 'POST',
|
||||
data: params,
|
||||
dataType: 'json',
|
||||
__local_integration: true, // Bypass $.ajax override - this is the official Ajax endpoint pattern
|
||||
__local_integration: true, // Bypass $.ajax override
|
||||
success: (response) => {
|
||||
// Handle console_debug messages if present
|
||||
// Handle console_debug messages
|
||||
if (response.console_debug && Array.isArray(response.console_debug)) {
|
||||
response.console_debug.forEach((msg) => {
|
||||
// Messages must be structured as [channel, [arguments]]
|
||||
if (!Array.isArray(msg) || msg.length !== 2) {
|
||||
throw new Error('Invalid console_debug message format - expected [channel, [arguments]]');
|
||||
}
|
||||
const [channel, args] = msg;
|
||||
// Output with channel as first argument, then spread the arguments
|
||||
console.log(channel, ...args);
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the response was successful
|
||||
if (response.success === true) {
|
||||
// Process the return value to instantiate any ORM models
|
||||
const processedValue = Rsx_Js_Model._instantiate_models_recursive(response._ajax_return_value);
|
||||
// Return the processed value
|
||||
resolve(processedValue);
|
||||
// @JS-AJAX-02-EXCEPTION - Unwrap server responses with _ajax_return_value
|
||||
const processed_value = Rsx_Js_Model._instantiate_models_recursive(response._ajax_return_value);
|
||||
resolve(processed_value);
|
||||
} else {
|
||||
// Handle error responses
|
||||
const error_type = response.error_type || 'unknown_error';
|
||||
@@ -75,7 +178,6 @@ class Ajax {
|
||||
console.error(
|
||||
'The user is no longer authenticated, this is a placeholder for future code which handles this scenario.'
|
||||
);
|
||||
// Create an error object similar to PHP exceptions
|
||||
const auth_error = new Error(reason);
|
||||
auth_error.type = 'auth_required';
|
||||
auth_error.details = details;
|
||||
@@ -93,7 +195,6 @@ class Ajax {
|
||||
break;
|
||||
|
||||
case 'response_form_error':
|
||||
// Form validation errors
|
||||
const form_error = new Error(reason);
|
||||
form_error.type = 'form_error';
|
||||
form_error.details = details;
|
||||
@@ -101,7 +202,6 @@ class Ajax {
|
||||
break;
|
||||
|
||||
case 'response_fatal_error':
|
||||
// Fatal errors
|
||||
const fatal_error = new Error(reason);
|
||||
fatal_error.type = 'fatal_error';
|
||||
fatal_error.details = details;
|
||||
@@ -118,7 +218,6 @@ class Ajax {
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown error type
|
||||
const generic_error = new Error(reason);
|
||||
generic_error.type = error_type;
|
||||
generic_error.details = details;
|
||||
@@ -128,25 +227,7 @@ class Ajax {
|
||||
}
|
||||
},
|
||||
error: (xhr, status, error) => {
|
||||
// Handle network or server errors
|
||||
let error_message = 'Network or server error';
|
||||
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
error_message = xhr.responseJSON.message;
|
||||
} else if (xhr.responseText) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
if (response.message) {
|
||||
error_message = response.message;
|
||||
}
|
||||
} catch (e) {
|
||||
// If response is not JSON, use the status text
|
||||
error_message = `${status}: ${error}`;
|
||||
}
|
||||
} else {
|
||||
error_message = `${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;
|
||||
@@ -169,15 +250,182 @@ class Ajax {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all pending calls by sending batch request
|
||||
* @private
|
||||
*/
|
||||
static async _flush_pending_calls() {
|
||||
// Collect all pending calls
|
||||
const calls_to_send = [];
|
||||
const call_map = {}; // Map call_id to pending_call object
|
||||
|
||||
for (const call_key in Ajax._pending_calls) {
|
||||
const pending_call = Ajax._pending_calls[call_key];
|
||||
|
||||
if (!pending_call.is_complete) {
|
||||
calls_to_send.push({
|
||||
call_id: pending_call.call_id,
|
||||
controller: pending_call.controller,
|
||||
action: pending_call.action,
|
||||
params: pending_call.params,
|
||||
});
|
||||
|
||||
call_map[pending_call.call_id] = pending_call;
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to send
|
||||
if (calls_to_send.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log batch for debugging
|
||||
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
|
||||
Debugger.console_debug(
|
||||
'AJAX_BATCH',
|
||||
`Sending batch of ${calls_to_send.length} calls`,
|
||||
calls_to_send.map((c) => `${c.controller}.${c.action}`)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Send batch request
|
||||
const response = await $.ajax({
|
||||
url: '/_ajax/_batch',
|
||||
method: 'POST',
|
||||
data: { batch_calls: JSON.stringify(calls_to_send) },
|
||||
dataType: 'json',
|
||||
__local_integration: true, // Bypass $.ajax override
|
||||
});
|
||||
|
||||
// Process batch response
|
||||
// Response format: { C_0: {success, _ajax_return_value}, C_1: {...}, ... }
|
||||
for (const response_key in response) {
|
||||
if (!response_key.startsWith('C_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const call_id = parseInt(response_key.substring(2), 10);
|
||||
const call_response = response[response_key];
|
||||
const pending_call = call_map[call_id];
|
||||
|
||||
if (!pending_call) {
|
||||
console.error('Received response for unknown call_id:', call_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle console_debug messages if present
|
||||
if (call_response.console_debug && Array.isArray(call_response.console_debug)) {
|
||||
call_response.console_debug.forEach((msg) => {
|
||||
if (!Array.isArray(msg) || msg.length !== 2) {
|
||||
throw new Error('Invalid console_debug message format - expected [channel, [arguments]]');
|
||||
}
|
||||
const [channel, args] = msg;
|
||||
console.log(channel, ...args);
|
||||
});
|
||||
}
|
||||
|
||||
// Mark call as complete
|
||||
pending_call.is_complete = true;
|
||||
|
||||
// Check if successful
|
||||
if (call_response.success === true) {
|
||||
// @JS-AJAX-02-EXCEPTION - Batch system unwraps server responses with _ajax_return_value
|
||||
const processed_value = Rsx_Js_Model._instantiate_models_recursive(call_response._ajax_return_value);
|
||||
pending_call.result = processed_value;
|
||||
|
||||
// Resolve all callbacks
|
||||
pending_call.callbacks.forEach(({ resolve }) => {
|
||||
resolve(processed_value);
|
||||
});
|
||||
} else {
|
||||
// Handle error
|
||||
const error_type = call_response.error_type || 'unknown_error';
|
||||
const reason = call_response.reason || 'Unknown error occurred';
|
||||
const details = call_response.details || {};
|
||||
|
||||
const error = new Error(reason);
|
||||
error.type = error_type;
|
||||
error.details = details;
|
||||
|
||||
pending_call.is_error = true;
|
||||
pending_call.error = error;
|
||||
|
||||
// Reject all callbacks
|
||||
pending_call.callbacks.forEach(({ reject }) => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (xhr_error) {
|
||||
// Network or server error - reject all pending calls
|
||||
const error_message = Ajax._extract_error_message(xhr_error);
|
||||
const error = new Error(error_message);
|
||||
error.type = 'network_error';
|
||||
|
||||
for (const call_id in call_map) {
|
||||
const pending_call = call_map[call_id];
|
||||
pending_call.is_complete = true;
|
||||
pending_call.is_error = true;
|
||||
pending_call.error = error;
|
||||
|
||||
pending_call.callbacks.forEach(({ reject }) => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Batch Ajax request failed:', error_message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for deduplicating calls
|
||||
* @private
|
||||
*/
|
||||
static _generate_call_key(controller, action, params) {
|
||||
// Create a stable string representation of the call
|
||||
// Sort params keys for consistent hashing
|
||||
const sorted_params = {};
|
||||
Object.keys(params)
|
||||
.sort()
|
||||
.forEach((key) => {
|
||||
sorted_params[key] = params[key];
|
||||
});
|
||||
|
||||
return `${controller}::${action}::${JSON.stringify(sorted_params)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from jQuery XHR object
|
||||
* @private
|
||||
*/
|
||||
static _extract_error_message(xhr) {
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
return xhr.responseJSON.message;
|
||||
} else if (xhr.responseText) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
if (response.message) {
|
||||
return response.message;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON
|
||||
}
|
||||
}
|
||||
|
||||
return `${xhr.status}: ${xhr.statusText || 'Unknown error'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an AJAX URL into controller and action
|
||||
* @param {string} url - URL in format '/_ajax/Controller_Name/action_name'
|
||||
* Supports both /_ajax/ and /_/ URL prefixes
|
||||
* @param {string} url - URL in format '/_ajax/Controller_Name/action_name' or '/_/Controller_Name/action_name'
|
||||
* @returns {Object} Object with {controller: string, action: string}
|
||||
* @throws {Error} If URL doesn't start with /_ajax or has invalid structure
|
||||
* @throws {Error} If URL doesn't start with /_ajax or /_ or has invalid structure
|
||||
*/
|
||||
static ajax_url_to_controller_action(url) {
|
||||
if (!url.startsWith('/_ajax')) {
|
||||
throw new Error(`URL must start with /_ajax, got: ${url}`);
|
||||
if (!url.startsWith('/_ajax') && !url.startsWith('/_/')) {
|
||||
throw new Error(`URL must start with /_ajax or /_, got: ${url}`);
|
||||
}
|
||||
|
||||
const parts = url.split('/').filter((part) => part !== '');
|
||||
@@ -195,4 +443,11 @@ class Ajax {
|
||||
|
||||
return { controller, action };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-initialize static properties when class is first loaded
|
||||
*/
|
||||
static on_core_define() {
|
||||
Ajax._on_framework_core_init();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,13 +249,7 @@ class Debugger {
|
||||
Debugger._console_timer = null;
|
||||
|
||||
try {
|
||||
await $.ajax({
|
||||
url: '/_ajax/Debugger_Controller/log_console_messages',
|
||||
method: 'POST',
|
||||
data: JSON.stringify({ messages: messages }),
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
});
|
||||
return Ajax.call('Debugger_Controller', 'log_console_messages', { messages: messages });
|
||||
} catch (error) {
|
||||
// Silently fail - don't create error loop
|
||||
console.error('Failed to send console_debug messages to server:', error);
|
||||
@@ -276,13 +270,8 @@ class Debugger {
|
||||
Debugger._error_batch_count++;
|
||||
|
||||
try {
|
||||
await $.ajax({
|
||||
url: '/_ajax/Debugger_Controller/log_browser_errors',
|
||||
method: 'POST',
|
||||
data: JSON.stringify({ errors: errors }),
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
});
|
||||
// Debugger_Controller.log_browser_errors({ errors: errors });
|
||||
return Ajax.call('Debugger_Controller', 'log_browser_errors', { errors: errors });
|
||||
} catch (error) {
|
||||
// Silently fail - don't create error loop
|
||||
console.error('Failed to send browser errors to server:', error);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Form utilities for validation and error handling
|
||||
*/
|
||||
class Form {
|
||||
class Form_Utils {
|
||||
/**
|
||||
* Framework initialization hook to register jQuery plugin
|
||||
* Creates $.fn.ajax_submit() for form elements
|
||||
@@ -22,7 +22,7 @@ class Form {
|
||||
|
||||
const { controller, action } = Ajax.ajax_url_to_controller_action(url);
|
||||
|
||||
return Form.ajax_submit($element, controller, action, options);
|
||||
return Form_Utils.ajax_submit($element, controller, action, options);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,36 +57,72 @@ class Form {
|
||||
const $parent = $(parent_selector);
|
||||
|
||||
// Reset the form errors before applying new ones
|
||||
Form.reset_form_errors(parent_selector);
|
||||
Form_Utils.reset_form_errors(parent_selector);
|
||||
|
||||
// Normalize input to standard format
|
||||
const normalized = Form._normalize_errors(errors);
|
||||
const normalized = Form_Utils._normalize_errors(errors);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let animations = [];
|
||||
|
||||
if (normalized.type === 'string') {
|
||||
// Single error message
|
||||
animations = Form._apply_general_errors($parent, normalized.data);
|
||||
animations = Form_Utils._apply_general_errors($parent, normalized.data);
|
||||
} else if (normalized.type === 'array') {
|
||||
// Array of error messages
|
||||
const deduplicated = Form._deduplicate_errors(normalized.data);
|
||||
animations = Form._apply_general_errors($parent, deduplicated);
|
||||
const deduplicated = Form_Utils._deduplicate_errors(normalized.data);
|
||||
animations = Form_Utils._apply_general_errors($parent, deduplicated);
|
||||
} else if (normalized.type === 'fields') {
|
||||
// Field-specific errors
|
||||
const result = Form._apply_field_errors($parent, normalized.data);
|
||||
const result = Form_Utils._apply_field_errors($parent, normalized.data);
|
||||
animations = result.animations;
|
||||
|
||||
// Show unmatched errors as general alert
|
||||
const unmatched_deduplicated = Form._deduplicate_errors(result.unmatched);
|
||||
if (Object.keys(unmatched_deduplicated).length > 0) {
|
||||
const unmatched_animations = Form._apply_general_errors($parent, unmatched_deduplicated);
|
||||
animations.push(...unmatched_animations);
|
||||
// Count matched fields
|
||||
const matched_count = Object.keys(normalized.data).length - Object.keys(result.unmatched).length;
|
||||
const unmatched_deduplicated = Form_Utils._deduplicate_errors(result.unmatched);
|
||||
const unmatched_count = Object.keys(unmatched_deduplicated).length;
|
||||
|
||||
// Show summary alert if there are any field errors (matched or unmatched)
|
||||
if (matched_count > 0 || unmatched_count > 0) {
|
||||
// Build summary message
|
||||
let summary_msg = '';
|
||||
if (matched_count > 0) {
|
||||
summary_msg = matched_count === 1
|
||||
? 'Please correct the error highlighted below.'
|
||||
: 'Please correct the errors highlighted below.';
|
||||
}
|
||||
|
||||
// If there are unmatched errors, add them as a bulleted list
|
||||
if (unmatched_count > 0) {
|
||||
const summary_animations = Form_Utils._apply_combined_error($parent, summary_msg, unmatched_deduplicated);
|
||||
animations.push(...summary_animations);
|
||||
} else {
|
||||
// Just the summary message, no unmatched errors
|
||||
const summary_animations = Form_Utils._apply_general_errors($parent, summary_msg);
|
||||
animations.push(...summary_animations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the promise once all animations are complete
|
||||
Promise.all(animations).then(resolve);
|
||||
Promise.all(animations).then(() => {
|
||||
// Scroll to error container if it exists
|
||||
const $error_container = $parent.find('[data-id="error_container"]').first();
|
||||
if ($error_container.length > 0) {
|
||||
const container_top = $error_container.offset().top;
|
||||
|
||||
// Calculate fixed header offset
|
||||
const fixed_header_height = Form_Utils._get_fixed_header_height();
|
||||
|
||||
// Scroll to position error container 20px below any fixed headers
|
||||
const target_scroll = container_top - fixed_header_height - 20;
|
||||
$('html, body').animate({
|
||||
scrollTop: target_scroll
|
||||
}, 500);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,7 +133,7 @@ class Form {
|
||||
static reset(form_selector) {
|
||||
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
|
||||
|
||||
Form.reset_form_errors(form_selector);
|
||||
Form_Utils.reset_form_errors(form_selector);
|
||||
$form.trigger('reset');
|
||||
}
|
||||
|
||||
@@ -128,9 +164,9 @@ class Form {
|
||||
*/
|
||||
static async ajax_submit(form_selector, controller, action, options = {}) {
|
||||
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
|
||||
const form_data = Form.serialize($form);
|
||||
const form_data = Form_Utils.serialize($form);
|
||||
|
||||
Form.reset_form_errors(form_selector);
|
||||
Form_Utils.reset_form_errors(form_selector);
|
||||
|
||||
try {
|
||||
const response = await Ajax.call(controller, action, form_data);
|
||||
@@ -142,9 +178,9 @@ class Form {
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.type === 'form_error' && error.details) {
|
||||
await Form.apply_form_errors(form_selector, error.details);
|
||||
await Form_Utils.apply_form_errors(form_selector, error.details);
|
||||
} else {
|
||||
await Form.apply_form_errors(form_selector, error.message || 'An error occurred');
|
||||
await Form_Utils.apply_form_errors(form_selector, error.message || 'An error occurred');
|
||||
}
|
||||
|
||||
if (options.on_error) {
|
||||
@@ -200,7 +236,7 @@ class Form {
|
||||
}
|
||||
// Array with object as first element - extract it
|
||||
if (errors.length > 0 && typeof errors[0] === 'object') {
|
||||
return Form._normalize_errors(errors[0]);
|
||||
return Form_Utils._normalize_errors(errors[0]);
|
||||
}
|
||||
// Empty or mixed array
|
||||
return { type: 'array', data: [] };
|
||||
@@ -211,7 +247,7 @@ class Form {
|
||||
// Unwrap {errors: {...}} or {error: {...}}
|
||||
const unwrapped = errors.errors || errors.error;
|
||||
if (unwrapped) {
|
||||
return Form._normalize_errors(unwrapped);
|
||||
return Form_Utils._normalize_errors(unwrapped);
|
||||
}
|
||||
|
||||
// Convert Laravel validator format {field: [msg1, msg2]} to {field: msg1}
|
||||
@@ -299,6 +335,46 @@ class Form {
|
||||
return { animations, unmatched };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies combined error message with summary and unmatched field errors
|
||||
* @param {jQuery} $parent - Parent element containing form
|
||||
* @param {string} summary_msg - Summary message (e.g., "Please correct the errors below")
|
||||
* @param {Object} unmatched_errors - Object of field errors that couldn't be matched to fields
|
||||
* @returns {Array} Array of animation promises
|
||||
* @private
|
||||
*/
|
||||
static _apply_combined_error($parent, summary_msg, unmatched_errors) {
|
||||
const animations = [];
|
||||
const $error_container = $parent.find('[data-id="error_container"]').first();
|
||||
const $target = $error_container.length > 0 ? $error_container : $parent;
|
||||
|
||||
// Create alert with summary message and bulleted list of unmatched errors
|
||||
const $alert = $('<div class="alert alert-danger" role="alert"></div>');
|
||||
|
||||
// Add summary message if provided
|
||||
if (summary_msg) {
|
||||
$('<p class="mb-2"></p>').text(summary_msg).appendTo($alert);
|
||||
}
|
||||
|
||||
// Add unmatched errors as bulleted list
|
||||
if (Object.keys(unmatched_errors).length > 0) {
|
||||
const $list = $('<ul class="mb-0"></ul>');
|
||||
for (const field_name in unmatched_errors) {
|
||||
const error_msg = unmatched_errors[field_name];
|
||||
$('<li></li>').html(error_msg).appendTo($list);
|
||||
}
|
||||
$list.appendTo($alert);
|
||||
}
|
||||
|
||||
if ($error_container.length > 0) {
|
||||
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
|
||||
} else {
|
||||
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
|
||||
}
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies general error messages as alert box
|
||||
* @param {jQuery} $parent - Parent element to prepend alert to
|
||||
@@ -309,10 +385,18 @@ class Form {
|
||||
static _apply_general_errors($parent, messages) {
|
||||
const animations = [];
|
||||
|
||||
// Look for a specific error container div (e.g., in Rsx_Form component)
|
||||
const $error_container = $parent.find('[data-id="error_container"]').first();
|
||||
const $target = $error_container.length > 0 ? $error_container : $parent;
|
||||
|
||||
if (typeof messages === 'string') {
|
||||
// Single error - simple alert without list
|
||||
const $alert = $('<div class="alert alert-danger" role="alert"></div>').text(messages);
|
||||
animations.push($alert.hide().prependTo($parent).fadeIn(300).promise());
|
||||
if ($error_container.length > 0) {
|
||||
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
|
||||
} else {
|
||||
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
|
||||
}
|
||||
} else if (Array.isArray(messages) && messages.length > 0) {
|
||||
// Multiple errors - bulleted list
|
||||
const $alert = $('<div class="alert alert-danger" role="alert"><ul class="mb-0"></ul></div>');
|
||||
@@ -323,17 +407,64 @@ class Form {
|
||||
$('<li></li>').html(text).appendTo($list);
|
||||
});
|
||||
|
||||
animations.push($alert.hide().prependTo($parent).fadeIn(300).promise());
|
||||
if ($error_container.length > 0) {
|
||||
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
|
||||
} else {
|
||||
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
|
||||
}
|
||||
} else if (typeof messages === 'object' && !Array.isArray(messages)) {
|
||||
// Object of unmatched field errors - convert to array
|
||||
const error_list = Object.values(messages)
|
||||
.map((v) => String(v).trim())
|
||||
.filter((v) => v);
|
||||
if (error_list.length > 0) {
|
||||
return Form._apply_general_errors($parent, error_list);
|
||||
return Form_Utils._apply_general_errors($parent, error_list);
|
||||
}
|
||||
}
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the total height of fixed/sticky headers at the top of the page
|
||||
* @returns {number} Total height in pixels of fixed top elements
|
||||
* @private
|
||||
*/
|
||||
static _get_fixed_header_height() {
|
||||
let total_height = 0;
|
||||
|
||||
// Find all fixed or sticky positioned elements
|
||||
$('*').each(function() {
|
||||
const $el = $(this);
|
||||
const position = $el.css('position');
|
||||
|
||||
// Only check fixed or sticky elements
|
||||
if (position !== 'fixed' && position !== 'sticky') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if element is positioned at or near the top
|
||||
const top = parseInt($el.css('top')) || 0;
|
||||
if (top > 50) {
|
||||
return; // Not a top header
|
||||
}
|
||||
|
||||
// Check if element is visible
|
||||
if (!$el.is(':visible')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if element spans significant width (likely a header/navbar)
|
||||
const width = $el.outerWidth();
|
||||
const viewport_width = $(window).width();
|
||||
if (width < viewport_width * 0.5) {
|
||||
return; // Too narrow to be a header
|
||||
}
|
||||
|
||||
// Add this element's height
|
||||
total_height += $el.outerHeight();
|
||||
});
|
||||
|
||||
return total_height;
|
||||
}
|
||||
}
|
||||
@@ -150,11 +150,10 @@ class Rsx {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a route proxy for type-safe URL generation
|
||||
* Generate URL for a controller route
|
||||
*
|
||||
* This method creates a route proxy that can generate URLs for a specific controller action.
|
||||
* The proxy ensures all required route parameters are provided and handles extra parameters
|
||||
* as query string values.
|
||||
* This method generates URLs for controller actions by looking up route patterns
|
||||
* and replacing parameters. It handles both regular routes and Ajax endpoints.
|
||||
*
|
||||
* If the route is not found in the route definitions, a default pattern is used:
|
||||
* `/_/{controller}/{action}` with all parameters appended as query strings.
|
||||
@@ -162,56 +161,130 @@ class Rsx {
|
||||
* Usage examples:
|
||||
* ```javascript
|
||||
* // Simple route without parameters (defaults to 'index' action)
|
||||
* const url = Rsx.Route('Frontend_Index_Controller').url();
|
||||
* const url = Rsx.Route('Frontend_Index_Controller');
|
||||
* // Returns: /dashboard
|
||||
*
|
||||
* // Route with explicit action
|
||||
* const url = Rsx.Route('Frontend_Index_Controller', 'index').url();
|
||||
* const url = Rsx.Route('Frontend_Index_Controller', 'index');
|
||||
* // Returns: /dashboard
|
||||
*
|
||||
* // Route with required parameter
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller').url({id: 'C001'});
|
||||
* // Route with integer parameter (sets 'id')
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', 123);
|
||||
* // Returns: /clients/view/123
|
||||
*
|
||||
* // Route with named parameters (object)
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', {id: 'C001'});
|
||||
* // Returns: /clients/view/C001
|
||||
*
|
||||
* // Route with required and query parameters
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller').url({
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', {
|
||||
* id: 'C001',
|
||||
* tab: 'history'
|
||||
* });
|
||||
* // Returns: /clients/view/C001?tab=history
|
||||
*
|
||||
* // Route not found - uses default pattern
|
||||
* const url = Rsx.Route('Unimplemented_Controller', 'some_action').url({foo: 'bar'});
|
||||
* const url = Rsx.Route('Unimplemented_Controller', 'some_action', {foo: 'bar'});
|
||||
* // Returns: /_/Unimplemented_Controller/some_action?foo=bar
|
||||
*
|
||||
* // Generate absolute URL
|
||||
* const absolute = Rsx.Route('Frontend_Index_Controller').absolute_url();
|
||||
* // Returns: https://example.com/dashboard
|
||||
*
|
||||
* // Navigate to route
|
||||
* Rsx.Route('Frontend_Index_Controller').navigate();
|
||||
* // Redirects browser to /dashboard
|
||||
*
|
||||
* // Check if route is current
|
||||
* if (Rsx.Route('Frontend_Index_Controller').is_current()) {
|
||||
* // This is the currently executing route
|
||||
* }
|
||||
* // Placeholder route
|
||||
* const url = Rsx.Route('Future_Controller', '#index');
|
||||
* // Returns: #
|
||||
* ```
|
||||
*
|
||||
* @param {string} class_name The controller class name (e.g., 'User_Controller')
|
||||
* @param {string} [action_name='index'] The action/method name (defaults to 'index')
|
||||
* @returns {Rsx_Route_Proxy} Route proxy instance for URL generation
|
||||
* @param {string} [action_name='index'] The action/method name (defaults to 'index'). Use '#action' for placeholders.
|
||||
* @param {number|Object} [params=null] Route parameters. Integer sets 'id', object provides named params.
|
||||
* @returns {string} The generated URL
|
||||
*/
|
||||
static Route(class_name, action_name = 'index') {
|
||||
// Check if route exists in definitions
|
||||
if (Rsx._routes[class_name] && Rsx._routes[class_name][action_name]) {
|
||||
const pattern = Rsx._routes[class_name][action_name];
|
||||
return new Rsx_Route_Proxy(class_name, action_name, pattern);
|
||||
static Route(class_name, action_name = 'index', params = null) {
|
||||
// Normalize params to object
|
||||
let params_obj = {};
|
||||
if (typeof params === 'number') {
|
||||
params_obj = { id: params };
|
||||
} 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');
|
||||
}
|
||||
|
||||
// Route not found - use default pattern /_/{controller}/{action}
|
||||
const default_pattern = `/_/${class_name}/${action_name}`;
|
||||
return new Rsx_Route_Proxy(class_name, action_name, default_pattern);
|
||||
// Placeholder route: action starts with # means unimplemented/scaffolding
|
||||
if (action_name.startsWith('#')) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Check if route exists in definitions
|
||||
let pattern;
|
||||
if (Rsx._routes[class_name] && Rsx._routes[class_name][action_name]) {
|
||||
pattern = Rsx._routes[class_name][action_name];
|
||||
} else {
|
||||
// Route not found - use default pattern /_/{controller}/{action}
|
||||
pattern = `/_/${class_name}/${action_name}`;
|
||||
}
|
||||
|
||||
// Generate URL from pattern
|
||||
return Rsx._generate_url_from_pattern(pattern, params_obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL from route pattern by replacing parameters
|
||||
*
|
||||
* @param {string} pattern The route pattern (e.g., '/users/:id/view')
|
||||
* @param {Object} params Parameters to fill into the route
|
||||
* @returns {string} The generated URL
|
||||
*/
|
||||
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) {
|
||||
// Remove the : prefix from each match
|
||||
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 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];
|
||||
// URL encode the value
|
||||
const encoded_value = encodeURIComponent(value);
|
||||
url = url.replace(':' + param_name, encoded_value);
|
||||
used_params[param_name] = true;
|
||||
}
|
||||
|
||||
// Collect any extra parameters for query string
|
||||
const query_params = {};
|
||||
for (const key in params) {
|
||||
if (!used_params[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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,6 +389,8 @@ class Rsx {
|
||||
// All phases complete
|
||||
console_debug('RSX_INIT', 'Initialization complete');
|
||||
|
||||
// TODO: Find a good wait to wait for all jqhtml components to load, then trigger on_ready and on('ready') emulating the top level last syntax that jqhtml components operateas, but as a standard js class (such as a page class). The biggest question is, how do we efficiently choose only the top level jqhtml components. do we only consider components cretaed directly on blade templates? that seams reasonable...
|
||||
|
||||
// Trigger _debug_ready event - this is ONLY for tooling like rsx:debug
|
||||
// DO NOT use this in application code - use on_app_ready() phase instead
|
||||
// This event exists solely for debugging tools that need to run after full initialization
|
||||
@@ -327,4 +402,142 @@ class Rsx {
|
||||
console.error(reason);
|
||||
Rsx.__stopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL hash into key-value object
|
||||
* Handles format: #key=value&key2=value2
|
||||
*
|
||||
* @returns {Object} Parsed hash parameters
|
||||
*/
|
||||
static _parse_hash() {
|
||||
const hash = window.location.hash;
|
||||
if (!hash || hash === '#') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Remove leading # and parse as query string
|
||||
const hash_string = hash.substring(1);
|
||||
const params = {};
|
||||
|
||||
const pairs = hash_string.split('&');
|
||||
for (const pair of pairs) {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key) {
|
||||
params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize object into URL hash format
|
||||
* Produces format: #key=value&key2=value2
|
||||
*
|
||||
* @param {Object} params Key-value pairs to encode
|
||||
* @returns {string} Encoded hash string (with leading #, or empty string)
|
||||
*/
|
||||
static _serialize_hash(params) {
|
||||
const pairs = [];
|
||||
for (const key in params) {
|
||||
const value = params[key];
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return pairs.length > 0 ? '#' + pairs.join('&') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all page state from URL hash
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* const state = Rsx.get_all_page_state();
|
||||
* // Returns: {dg_page: '2', dg_sort: 'name'}
|
||||
* ```
|
||||
*
|
||||
* @returns {Object} All hash parameters as key-value pairs
|
||||
*/
|
||||
static get_all_page_state() {
|
||||
return Rsx._parse_hash();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single value from URL hash state
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* const page = Rsx.get_page_state('dg_page');
|
||||
* // Returns: '2' or null if not set
|
||||
* ```
|
||||
*
|
||||
* @param {string} key The key to retrieve
|
||||
* @returns {string|null} The value or null if not found
|
||||
*/
|
||||
static get_page_state(key) {
|
||||
const state = Rsx._parse_hash();
|
||||
return state[key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set single value in URL hash state (replaces history, doesn't add)
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* Rsx.set_page_state('dg_page', 2);
|
||||
* // URL becomes: http://example.com/page#dg_page=2
|
||||
*
|
||||
* Rsx.set_page_state('dg_page', null); // Remove key
|
||||
* ```
|
||||
*
|
||||
* @param {string} key The key to set
|
||||
* @param {string|number|null} value The value (null/empty removes the key)
|
||||
*/
|
||||
static set_page_state(key, value) {
|
||||
const state = Rsx._parse_hash();
|
||||
|
||||
// Update or remove the key
|
||||
if (value === null || value === undefined || value === '') {
|
||||
delete state[key];
|
||||
} else {
|
||||
state[key] = String(value);
|
||||
}
|
||||
|
||||
// Update URL without adding history
|
||||
const new_hash = Rsx._serialize_hash(state);
|
||||
const url = window.location.pathname + window.location.search + new_hash;
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple values in URL hash state at once
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* Rsx.set_all_page_state({dg_page: 2, dg_sort: 'name'});
|
||||
* // URL becomes: http://example.com/page#dg_page=2&dg_sort=name
|
||||
* ```
|
||||
*
|
||||
* @param {Object} new_state Object with key-value pairs to set
|
||||
*/
|
||||
static set_all_page_state(new_state) {
|
||||
const state = Rsx._parse_hash();
|
||||
|
||||
// Merge new state
|
||||
for (const key in new_state) {
|
||||
const value = new_state[key];
|
||||
if (value === null || value === undefined || value === '') {
|
||||
delete state[key];
|
||||
} else {
|
||||
state[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Update URL without adding history
|
||||
const new_hash = Rsx._serialize_hash(state);
|
||||
const url = window.location.pathname + window.location.search + new_hash;
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
// @FILE-SUBCLASS-01-EXCEPTION
|
||||
|
||||
/**
|
||||
* Rsx_Ajax_Batch - Batches multiple Ajax calls into single HTTP request
|
||||
*
|
||||
* Inspired by RS3's batch API system. All Ajax.call() requests are batched
|
||||
* together into a single POST to /_ajax/_batch. This dramatically reduces
|
||||
* HTTP requests from N to 1-3 per page.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Batches up to 20 calls, then flushes immediately (MAX_BATCH_SIZE)
|
||||
* - New calls after flush go into next batch
|
||||
* - Uses setTimeout(0) debounce when under batch size limit (DEBOUNCE_MS)
|
||||
* - Deduplicates identical calls (same controller/action/params)
|
||||
* - Returns cached results for duplicate calls
|
||||
* - Can be disabled via window.rsxapp.ajax_disable_batching for debugging
|
||||
*/
|
||||
class Rsx_Ajax_Batch {
|
||||
/**
|
||||
* Initialize batch system
|
||||
* Called automatically when class is loaded
|
||||
*/
|
||||
static init() {
|
||||
// Queue of pending calls waiting to be batched
|
||||
Rsx_Ajax_Batch._pending_calls = {};
|
||||
|
||||
// Timer for batching flush
|
||||
Rsx_Ajax_Batch._flush_timeout = null;
|
||||
|
||||
// Call counter for generating unique call IDs
|
||||
Rsx_Ajax_Batch._call_counter = 0;
|
||||
|
||||
// Maximum batch size before forcing immediate flush
|
||||
Rsx_Ajax_Batch.MAX_BATCH_SIZE = 20;
|
||||
|
||||
// Debounce time in milliseconds
|
||||
Rsx_Ajax_Batch.DEBOUNCE_MS = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an Ajax call for batching
|
||||
*
|
||||
* @param {string} controller - Controller class name
|
||||
* @param {string} action - Action method name
|
||||
* @param {object} params - Parameters to send
|
||||
* @returns {Promise} - Resolves with return value or rejects with error
|
||||
*/
|
||||
static call(controller, action, params = {}) {
|
||||
// Check if batching is disabled for debugging
|
||||
if (window.rsxapp && window.rsxapp.ajax_disable_batching) {
|
||||
// Make individual request immediately
|
||||
return Rsx_Ajax_Batch._make_individual_request(controller, action, params);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Generate call key for deduplication
|
||||
// Same controller + action + params = same call
|
||||
const call_key = Rsx_Ajax_Batch._generate_call_key(controller, action, params);
|
||||
|
||||
// Check if this exact call is already pending
|
||||
if (Rsx_Ajax_Batch._pending_calls[call_key]) {
|
||||
const existing_call = Rsx_Ajax_Batch._pending_calls[call_key];
|
||||
|
||||
// If call already completed (cached), return immediately
|
||||
if (existing_call.is_complete) {
|
||||
if (existing_call.is_error) {
|
||||
reject(existing_call.error);
|
||||
} else {
|
||||
resolve(existing_call.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Call is pending, add this promise to callbacks
|
||||
existing_call.callbacks.push({ resolve, reject });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new pending call
|
||||
const call_id = Rsx_Ajax_Batch._call_counter++;
|
||||
const pending_call = {
|
||||
call_id: call_id,
|
||||
call_key: call_key,
|
||||
controller: controller,
|
||||
action: action,
|
||||
params: params,
|
||||
callbacks: [{ resolve, reject }],
|
||||
is_complete: false,
|
||||
is_error: false,
|
||||
result: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Add to pending queue
|
||||
Rsx_Ajax_Batch._pending_calls[call_key] = pending_call;
|
||||
|
||||
// Count pending calls
|
||||
const pending_count = Object.keys(Rsx_Ajax_Batch._pending_calls).filter(
|
||||
(key) => !Rsx_Ajax_Batch._pending_calls[key].is_complete
|
||||
).length;
|
||||
|
||||
// If we've hit the batch size limit, flush immediately
|
||||
if (pending_count >= Rsx_Ajax_Batch.MAX_BATCH_SIZE) {
|
||||
clearTimeout(Rsx_Ajax_Batch._flush_timeout);
|
||||
Rsx_Ajax_Batch._flush_timeout = null;
|
||||
Rsx_Ajax_Batch._flush_pending_calls();
|
||||
} else {
|
||||
// Schedule batch flush with debounce
|
||||
clearTimeout(Rsx_Ajax_Batch._flush_timeout);
|
||||
Rsx_Ajax_Batch._flush_timeout = setTimeout(() => {
|
||||
Rsx_Ajax_Batch._flush_pending_calls();
|
||||
}, Rsx_Ajax_Batch.DEBOUNCE_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all pending calls by sending batch request
|
||||
* @private
|
||||
*/
|
||||
static async _flush_pending_calls() {
|
||||
// Collect all pending calls
|
||||
const calls_to_send = [];
|
||||
const call_map = {}; // Map call_id to pending_call object
|
||||
|
||||
for (const call_key in Rsx_Ajax_Batch._pending_calls) {
|
||||
const pending_call = Rsx_Ajax_Batch._pending_calls[call_key];
|
||||
|
||||
if (!pending_call.is_complete) {
|
||||
calls_to_send.push({
|
||||
call_id: pending_call.call_id,
|
||||
controller: pending_call.controller,
|
||||
action: pending_call.action,
|
||||
params: pending_call.params,
|
||||
});
|
||||
|
||||
call_map[pending_call.call_id] = pending_call;
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to send
|
||||
if (calls_to_send.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log batch for debugging
|
||||
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
|
||||
Debugger.console_debug(
|
||||
'AJAX_BATCH',
|
||||
`Sending batch of ${calls_to_send.length} calls`,
|
||||
calls_to_send.map((c) => `${c.controller}.${c.action}`)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Send batch request
|
||||
const response = await $.ajax({
|
||||
url: '/_ajax/_batch',
|
||||
method: 'POST',
|
||||
data: { batch_calls: JSON.stringify(calls_to_send) },
|
||||
dataType: 'json',
|
||||
__local_integration: true, // Bypass $.ajax override
|
||||
});
|
||||
|
||||
// Process batch response
|
||||
// Response format: { C_0: {success, _ajax_return_value}, C_1: {...}, ... }
|
||||
for (const response_key in response) {
|
||||
if (!response_key.startsWith('C_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const call_id = parseInt(response_key.substring(2), 10);
|
||||
const call_response = response[response_key];
|
||||
const pending_call = call_map[call_id];
|
||||
|
||||
if (!pending_call) {
|
||||
console.error('Received response for unknown call_id:', call_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle console_debug messages if present
|
||||
if (call_response.console_debug && Array.isArray(call_response.console_debug)) {
|
||||
call_response.console_debug.forEach((msg) => {
|
||||
if (!Array.isArray(msg) || msg.length !== 2) {
|
||||
throw new Error('Invalid console_debug message format - expected [channel, [arguments]]');
|
||||
}
|
||||
const [channel, args] = msg;
|
||||
console.log(channel, ...args);
|
||||
});
|
||||
}
|
||||
|
||||
// Mark call as complete
|
||||
pending_call.is_complete = true;
|
||||
|
||||
// Check if successful
|
||||
if (call_response.success === true) {
|
||||
// @JS-AJAX-02-EXCEPTION - Batch system unwraps server responses with _ajax_return_value
|
||||
// Process the return value to instantiate any ORM models
|
||||
const processed_value = Rsx_Js_Model._instantiate_models_recursive(call_response._ajax_return_value);
|
||||
pending_call.result = processed_value;
|
||||
|
||||
// Resolve all callbacks
|
||||
pending_call.callbacks.forEach(({ resolve }) => {
|
||||
resolve(processed_value);
|
||||
});
|
||||
} else {
|
||||
// Handle error
|
||||
const error_type = call_response.error_type || 'unknown_error';
|
||||
const reason = call_response.reason || 'Unknown error occurred';
|
||||
const details = call_response.details || {};
|
||||
|
||||
const error = new Error(reason);
|
||||
error.type = error_type;
|
||||
error.details = details;
|
||||
|
||||
pending_call.is_error = true;
|
||||
pending_call.error = error;
|
||||
|
||||
// Reject all callbacks
|
||||
pending_call.callbacks.forEach(({ reject }) => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (xhr_error) {
|
||||
// Network or server error - reject all pending calls
|
||||
const error_message = Rsx_Ajax_Batch._extract_error_message(xhr_error);
|
||||
const error = new Error(error_message);
|
||||
error.type = 'network_error';
|
||||
|
||||
for (const call_id in call_map) {
|
||||
const pending_call = call_map[call_id];
|
||||
pending_call.is_complete = true;
|
||||
pending_call.is_error = true;
|
||||
pending_call.error = error;
|
||||
|
||||
pending_call.callbacks.forEach(({ reject }) => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Batch Ajax request failed:', error_message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an individual Ajax request (when batching is disabled)
|
||||
* @private
|
||||
*/
|
||||
static async _make_individual_request(controller, action, params) {
|
||||
const url = `/_ajax/${controller}/${action}`;
|
||||
|
||||
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
|
||||
Debugger.console_debug('AJAX', `Calling ${controller}.${action} (unbatched)`, params);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: 'POST',
|
||||
data: params,
|
||||
dataType: 'json',
|
||||
__local_integration: true,
|
||||
success: (response) => {
|
||||
// Handle console_debug messages
|
||||
if (response.console_debug && Array.isArray(response.console_debug)) {
|
||||
response.console_debug.forEach((msg) => {
|
||||
if (!Array.isArray(msg) || msg.length !== 2) {
|
||||
throw new Error('Invalid console_debug message format');
|
||||
}
|
||||
const [channel, args] = msg;
|
||||
console.log(channel, ...args);
|
||||
});
|
||||
}
|
||||
|
||||
if (response.success === true) {
|
||||
// @JS-AJAX-02-EXCEPTION - Batch system unwraps server responses with _ajax_return_value
|
||||
const processed_value = Rsx_Js_Model._instantiate_models_recursive(response._ajax_return_value);
|
||||
resolve(processed_value);
|
||||
} else {
|
||||
const error_type = response.error_type || 'unknown_error';
|
||||
const reason = response.reason || 'Unknown error occurred';
|
||||
const details = response.details || {};
|
||||
|
||||
const error = new Error(reason);
|
||||
error.type = error_type;
|
||||
error.details = details;
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
error: (xhr, status, error) => {
|
||||
const error_message = Rsx_Ajax_Batch._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;
|
||||
reject(network_error);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for deduplicating calls
|
||||
* @private
|
||||
*/
|
||||
static _generate_call_key(controller, action, params) {
|
||||
// Create a stable string representation of the call
|
||||
// Sort params keys for consistent hashing
|
||||
const sorted_params = {};
|
||||
Object.keys(params)
|
||||
.sort()
|
||||
.forEach((key) => {
|
||||
sorted_params[key] = params[key];
|
||||
});
|
||||
|
||||
return `${controller}::${action}::${JSON.stringify(sorted_params)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from jQuery XHR object
|
||||
* @private
|
||||
*/
|
||||
static _extract_error_message(xhr) {
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
return xhr.responseJSON.message;
|
||||
} else if (xhr.responseText) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
if (response.message) {
|
||||
return response.message;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON
|
||||
}
|
||||
}
|
||||
|
||||
return `${xhr.status}: ${xhr.statusText || 'Unknown error'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-initialize static properties when class is first loaded
|
||||
* Called by on_core_define lifecycle hook
|
||||
*/
|
||||
static on_core_define() {
|
||||
Rsx_Ajax_Batch.init();
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,36 @@ class Rsx_Jq_Helpers {
|
||||
return this;
|
||||
};
|
||||
|
||||
// Find related components by searching up the ancestor tree
|
||||
// Like .closest() but searches within ancestors instead of matching them
|
||||
$.fn.closest_sibling = function (selector) {
|
||||
let $current = this;
|
||||
let $parent = $current.parent();
|
||||
|
||||
// Keep going up the tree until we hit body
|
||||
while ($parent.length > 0 && !$parent.is('body')) {
|
||||
// Search within this parent for the selector
|
||||
let $found = $parent.find(selector);
|
||||
if ($found.length > 0) {
|
||||
return $found;
|
||||
}
|
||||
|
||||
// Move up one level
|
||||
$parent = $parent.parent();
|
||||
}
|
||||
|
||||
// If we reached body, search within body as well
|
||||
if ($parent.is('body')) {
|
||||
let $found = $parent.find(selector);
|
||||
if ($found.length > 0) {
|
||||
return $found;
|
||||
}
|
||||
}
|
||||
|
||||
// Return empty jQuery object if nothing found
|
||||
return $();
|
||||
};
|
||||
|
||||
// Override $.ajax to prevent direct AJAX calls to local server
|
||||
// Developers must use the Ajax endpoint pattern: await Controller.method(params)
|
||||
const native_ajax = $.ajax;
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
// @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
|
||||
/**
|
||||
* Rsx_Route_Proxy - Type-safe route URL generator
|
||||
*
|
||||
* SPECIAL ARCHITECTURAL NOTE:
|
||||
* This class intentionally diverges from RSX's standard static-only pattern.
|
||||
* It uses instance properties and methods to enable the syntactic sugar:
|
||||
* let url = Rsx.Route('Controller_Name', 'action_name').url();
|
||||
*
|
||||
* Each call to Rsx.Route() creates a new instance of Rsx_Route_Proxy that
|
||||
* encapsulates the specific route pattern and required parameters. This
|
||||
* allows for clean method chaining and parameter validation.
|
||||
*
|
||||
* WHY INSTANCE-BASED:
|
||||
* - Encapsulates route-specific data (pattern, params) per instance
|
||||
* - Enables fluent interface for URL generation
|
||||
* - Provides type safety by validating params at runtime
|
||||
* - Allows method chaining: .url(), .absolute_url(), .navigate()
|
||||
*
|
||||
* This divergence from static classes is appropriate here because each
|
||||
* route proxy represents a specific route with its own state, not a
|
||||
* collection of utility methods.
|
||||
*
|
||||
* @Instantiatable
|
||||
*/
|
||||
class Rsx_Route_Proxy {
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param {string} class_name The controller class name
|
||||
* @param {string} method_name The action/method name
|
||||
* @param {string} pattern The route pattern
|
||||
*/
|
||||
constructor(class_name, method_name, pattern) {
|
||||
this._class = class_name;
|
||||
this._method = method_name;
|
||||
this._pattern = pattern;
|
||||
this._required_params = [];
|
||||
|
||||
// Extract required parameters from the pattern
|
||||
this._extract_required_params();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract required parameters from the route pattern
|
||||
*/
|
||||
_extract_required_params() {
|
||||
const that = this;
|
||||
// Match all :param patterns in the route
|
||||
const matches = that._pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
|
||||
if (matches) {
|
||||
// Remove the : prefix from each match
|
||||
that._required_params = matches.map((match) => match.substring(1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this route matches the current controller and action
|
||||
*
|
||||
* @returns {boolean} True if this is the current route
|
||||
*/
|
||||
is_current() {
|
||||
const that = this;
|
||||
// Get current controller and action from window.rsxapp if available
|
||||
return that._class === window.rsxapp.current_controller && that._method === window.rsxapp.current_action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a relative URL for the route
|
||||
*
|
||||
* @param {Object} params Parameters to fill into the route
|
||||
* @returns {string} The generated URL
|
||||
* @throws {Error} If required parameters are missing
|
||||
*/
|
||||
url(params = {}) {
|
||||
const that = this;
|
||||
|
||||
// Check if the method name starts with '#' - indicates unimplemented route
|
||||
if (that._method.startsWith('#')) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Check for required parameters
|
||||
const missing = [];
|
||||
for (const required of that._required_params) {
|
||||
if (!(required in params)) {
|
||||
missing.push(required);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Required parameters [${missing.join(', ')}] are missing for route ` + `${that._pattern} on ${that._class}::${that._method}`
|
||||
);
|
||||
}
|
||||
|
||||
// Build the URL by replacing parameters
|
||||
let url = that._pattern;
|
||||
const used_params = {};
|
||||
|
||||
for (const param_name of that._required_params) {
|
||||
const value = params[param_name];
|
||||
// URL encode the value
|
||||
const encoded_value = encodeURIComponent(value);
|
||||
url = url.replace(':' + param_name, encoded_value);
|
||||
used_params[param_name] = true;
|
||||
}
|
||||
|
||||
// Collect any extra parameters for query string
|
||||
const query_params = {};
|
||||
for (const key in params) {
|
||||
if (!used_params[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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an absolute URL for the route
|
||||
*
|
||||
* @param {Object} params Parameters to fill into the route
|
||||
* @returns {string} The generated absolute URL
|
||||
* @throws {Error} If required parameters are missing
|
||||
*/
|
||||
absolute_url(params = {}) {
|
||||
const that = this;
|
||||
|
||||
// Check if the method name starts with '#' - indicates unimplemented route
|
||||
if (that._method.startsWith('#')) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Get the relative URL first
|
||||
const relative_url = that.url(params);
|
||||
|
||||
// Get the current protocol and host
|
||||
const protocol = window.location.protocol;
|
||||
const host = window.location.host; // includes port if non-standard
|
||||
|
||||
return protocol + '//' + host + relative_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the route by setting window.location.href
|
||||
*
|
||||
* @param {Object} params Parameters to fill into the route
|
||||
* @throws {Error} If required parameters are missing
|
||||
*/
|
||||
navigate(params = {}) {
|
||||
const that = this;
|
||||
const url = that.url(params);
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
@@ -38,20 +38,53 @@ function sleep(milliseconds = 0) {
|
||||
*
|
||||
* The function returns a promise that resolves when the next exclusive execution completes.
|
||||
*
|
||||
* @param {function} callback The callback function to be invoked
|
||||
* Usage as function:
|
||||
* const debouncedFn = debounce(myFunction, 250);
|
||||
*
|
||||
* Usage as decorator:
|
||||
* @debounce(250)
|
||||
* myMethod() { ... }
|
||||
*
|
||||
* @param {function|number} callback_or_delay The callback function OR delay when used as decorator
|
||||
* @param {number} delay The delay in milliseconds before subsequent invocations
|
||||
* @param {boolean} immediate if true, the first time the action is called, the callback executes immediately
|
||||
* @returns {function} A function that when invoked, runs the callback immediately and exclusively,
|
||||
*
|
||||
* @decorator
|
||||
*/
|
||||
function debounce(callback, delay, immediate = false) {
|
||||
function debounce(callback_or_delay, delay, immediate = false) {
|
||||
// Decorator usage: @debounce(250) or @debounce(250, true)
|
||||
// First argument is a number (the delay), returns decorator function
|
||||
if (typeof callback_or_delay === 'number') {
|
||||
const decorator_delay = callback_or_delay;
|
||||
const decorator_immediate = delay || false;
|
||||
|
||||
// TC39 decorator form: receives (value, context)
|
||||
return function (value, context) {
|
||||
if (context.kind === 'method') {
|
||||
return debounce_impl(value, decorator_delay, decorator_immediate);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Function usage: debounce(fn, 250)
|
||||
// First argument is a function (the callback)
|
||||
const callback = callback_or_delay;
|
||||
return debounce_impl(callback, delay, immediate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation of debounce logic
|
||||
* @private
|
||||
*/
|
||||
function debounce_impl(callback, delay, immediate = false) {
|
||||
let running = false;
|
||||
let queued = false;
|
||||
let last_end_time = 0; // timestamp of last completed run
|
||||
let timer = null;
|
||||
|
||||
let next_args = [];
|
||||
let next_context = null;
|
||||
let resolve_queue = [];
|
||||
let reject_queue = [];
|
||||
|
||||
@@ -59,15 +92,17 @@ function debounce(callback, delay, immediate = false) {
|
||||
const these_resolves = resolve_queue;
|
||||
const these_rejects = reject_queue;
|
||||
const args = next_args;
|
||||
const context = next_context;
|
||||
|
||||
resolve_queue = [];
|
||||
reject_queue = [];
|
||||
next_args = [];
|
||||
next_context = null;
|
||||
queued = false;
|
||||
running = true;
|
||||
|
||||
try {
|
||||
const result = await callback(...args);
|
||||
const result = await callback.apply(context, args);
|
||||
for (const resolve of these_resolves) resolve(result);
|
||||
} catch (err) {
|
||||
for (const reject of these_rejects) reject(err);
|
||||
@@ -85,6 +120,7 @@ function debounce(callback, delay, immediate = false) {
|
||||
|
||||
return function (...args) {
|
||||
next_args = args;
|
||||
next_context = this;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve_queue.push(resolve);
|
||||
|
||||
@@ -249,6 +249,29 @@ function str(val) {
|
||||
return String(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts numeric strings to numbers, returns all other values unchanged
|
||||
* Used when you need to ensure numeric types but don't want to force
|
||||
* conversion of non-numeric values (which would become 0)
|
||||
* @param {*} val - Value to convert
|
||||
* @returns {*} Number if input was numeric string, otherwise unchanged
|
||||
*/
|
||||
function value_unless_numeric_string_then_numeric_value(val) {
|
||||
// If it's already a number, return it
|
||||
if (typeof val === 'number') {
|
||||
return val;
|
||||
}
|
||||
|
||||
// If it's a string and numeric, convert it
|
||||
if (is_string(val) && is_numeric(val)) {
|
||||
// Use parseFloat to handle both integers and floats
|
||||
return parseFloat(val);
|
||||
}
|
||||
|
||||
// Return everything else unchanged (null, objects, non-numeric strings, etc.)
|
||||
return val;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STRING MANIPULATION FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
@@ -1171,7 +1171,7 @@ class Manifest
|
||||
console_debug('MANIFEST', 'Cache file written successfully', [
|
||||
'path' => $cache_file,
|
||||
'size' => $file_size,
|
||||
'permissions' => $file_perms
|
||||
'permissions' => $file_perms,
|
||||
]);
|
||||
} else {
|
||||
console_debug_force('MANIFEST', 'WARNING: Cache file does not exist after rebuild!', $cache_file);
|
||||
@@ -1262,6 +1262,7 @@ class Manifest
|
||||
|
||||
// Extract just the class name (last part after final backslash)
|
||||
$parts = explode('\\', $class_name);
|
||||
|
||||
return end($parts);
|
||||
}
|
||||
|
||||
@@ -1612,146 +1613,9 @@ class Manifest
|
||||
// ------------------------------------------------------------------------
|
||||
// ---- Private / Protected Methods:
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate JavaScript stub files for PHP controllers with Internal API methods
|
||||
* These stubs enable IDE autocomplete for Ajax.call() invocations from JavaScript
|
||||
*/
|
||||
protected static function _generate_js_api_stubs(): void
|
||||
{
|
||||
$stub_dir = storage_path('rsx-build/js-stubs');
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!is_dir($stub_dir)) {
|
||||
mkdir($stub_dir, 0755, true);
|
||||
}
|
||||
|
||||
// Track generated stub files for cleanup
|
||||
$generated_stubs = [];
|
||||
|
||||
// Process each file
|
||||
foreach (static::$data['data']['files'] as $file_path => &$metadata) {
|
||||
// Skip non-PHP files
|
||||
if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip files without classes or public static methods
|
||||
if (!isset($metadata['class']) || !isset($metadata['public_static_methods'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a controller (extends Rsx_Controller_Abstract)
|
||||
$class_name = $metadata['class'] ?? '';
|
||||
if (!static::php_is_subclass_of($class_name, 'Rsx_Controller_Abstract')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this controller has any Ajax_Endpoint methods
|
||||
$api_methods = [];
|
||||
foreach ($metadata['public_static_methods'] as $method_name => $method_info) {
|
||||
if (!isset($method_info['attributes'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for Ajax_Endpoint attribute
|
||||
$has_api_internal = false;
|
||||
foreach ($method_info['attributes'] as $attr_name => $attr_data) {
|
||||
if ($attr_name === 'Ajax_Endpoint' ||
|
||||
basename(str_replace('\\', '/', $attr_name)) === 'Ajax_Endpoint') {
|
||||
$has_api_internal = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($has_api_internal && isset($method_info['static']) && $method_info['static']) {
|
||||
$api_methods[$method_name] = [
|
||||
'name' => $method_name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// If no API methods, remove js_stub property if it exists and skip
|
||||
if (empty($api_methods)) {
|
||||
if (isset($metadata['js_stub'])) {
|
||||
unset($metadata['js_stub']);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate stub filename and paths
|
||||
$controller_name = $metadata['class'];
|
||||
$stub_filename = static::_sanitize_stub_filename($controller_name) . '.js';
|
||||
$stub_relative_path = 'storage/rsx-build/js-stubs/' . $stub_filename;
|
||||
$stub_full_path = base_path($stub_relative_path);
|
||||
|
||||
// Check if stub needs regeneration
|
||||
$needs_regeneration = true;
|
||||
if (file_exists($stub_full_path)) {
|
||||
// Get mtime of source PHP file
|
||||
$source_mtime = $metadata['mtime'] ?? 0;
|
||||
$stub_mtime = filemtime($stub_full_path);
|
||||
|
||||
// Only regenerate if source is newer than stub
|
||||
if ($stub_mtime >= $source_mtime) {
|
||||
// Also check if the API methods signature has changed
|
||||
// by comparing a hash of the methods
|
||||
$api_methods_hash = md5(json_encode($api_methods));
|
||||
$old_api_hash = $metadata['api_methods_hash'] ?? '';
|
||||
|
||||
if ($api_methods_hash === $old_api_hash) {
|
||||
$needs_regeneration = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store the API methods hash for future comparisons
|
||||
$metadata['api_methods_hash'] = md5(json_encode($api_methods));
|
||||
|
||||
if ($needs_regeneration) {
|
||||
// Generate stub content
|
||||
$stub_content = static::_generate_stub_content($controller_name, $api_methods);
|
||||
|
||||
// Write stub file
|
||||
file_put_contents($stub_full_path, $stub_content);
|
||||
}
|
||||
|
||||
$generated_stubs[] = $stub_filename;
|
||||
|
||||
// Add js_stub property to manifest data (relative path)
|
||||
$metadata['js_stub'] = $stub_relative_path;
|
||||
|
||||
// Add the stub file itself to the manifest
|
||||
// This is critical because storage/rsx-build/js-stubs is not in scan directories
|
||||
$stat = stat($stub_full_path);
|
||||
static::$data['data']['files'][$stub_relative_path] = [
|
||||
'file' => $stub_relative_path,
|
||||
'hash' => sha1_file($stub_full_path),
|
||||
'mtime' => $stat['mtime'],
|
||||
'size' => $stat['size'],
|
||||
'extension' => 'js',
|
||||
'class' => $controller_name,
|
||||
'is_stub' => true, // Mark this as a generated stub
|
||||
'source_controller' => $file_path, // Reference to the source controller
|
||||
];
|
||||
}
|
||||
|
||||
// Clean up orphaned stub files (both from disk and manifest)
|
||||
$existing_stubs = glob($stub_dir . '/*.js');
|
||||
foreach ($existing_stubs as $existing_stub) {
|
||||
$filename = basename($existing_stub);
|
||||
if (!in_array($filename, $generated_stubs)) {
|
||||
// Remove from disk
|
||||
unlink($existing_stub);
|
||||
|
||||
// Remove from manifest
|
||||
$stub_relative_path = 'storage/rsx-build/js-stubs/' . $filename;
|
||||
if (isset(static::$data['data']['files'][$stub_relative_path])) {
|
||||
unset(static::$data['data']['files'][$stub_relative_path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// DEAD CODE REMOVED: _generate_js_api_stubs()
|
||||
// Stub generation now handled by Controller_BundleIntegration
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate JavaScript stub files for ORM models
|
||||
@@ -2093,34 +1957,10 @@ class Manifest
|
||||
return strtolower(str_replace('_', '-', $controller_name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JavaScript stub content for a controller
|
||||
*/
|
||||
private static function _generate_stub_content(string $controller_name, array $api_methods): string
|
||||
{
|
||||
$content = "/**\n";
|
||||
$content .= " * Auto-generated JavaScript stub for {$controller_name}\n";
|
||||
$content .= ' * Generated by RSX Manifest at ' . date('Y-m-d H:i:s') . "\n";
|
||||
$content .= " * DO NOT EDIT - This file is automatically regenerated\n";
|
||||
$content .= " */\n\n";
|
||||
|
||||
$content .= "class {$controller_name} {\n";
|
||||
|
||||
foreach ($api_methods as $method_name => $method_info) {
|
||||
$content .= " /**\n";
|
||||
$content .= " * Call {$controller_name}::{$method_name} via Ajax.call()\n";
|
||||
$content .= " * @param {...*} args - Arguments to pass to the method\n";
|
||||
$content .= " * @returns {Promise<*>}\n";
|
||||
$content .= " */\n";
|
||||
$content .= " static async {$method_name}(...args) {\n";
|
||||
$content .= " return Ajax.call('{$controller_name}', '{$method_name}', args);\n";
|
||||
$content .= " }\n\n";
|
||||
}
|
||||
|
||||
$content .= "}\n";
|
||||
|
||||
return $content;
|
||||
}
|
||||
// ------------------------------------------------------------------------
|
||||
// DEAD CODE REMOVED: _generate_stub_content()
|
||||
// Stub generation now handled by Controller_BundleIntegration
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get or create the kernel instance
|
||||
@@ -2936,6 +2776,7 @@ class Manifest
|
||||
// Check if method calls parent::methodname()
|
||||
// Use the correct file for trait methods
|
||||
$source_file_to_read = $is_from_trait ? $method_file : $file_path;
|
||||
|
||||
try {
|
||||
$method_source = file($source_file_to_read);
|
||||
$start_line = $method->getStartLine() - 1;
|
||||
@@ -3488,6 +3329,7 @@ class Manifest
|
||||
|
||||
$has_route = false;
|
||||
$has_ajax_endpoint = false;
|
||||
$has_task = false;
|
||||
|
||||
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
|
||||
if ($attr_name === 'Route' || str_ends_with($attr_name, '\\Route')) {
|
||||
@@ -3496,17 +3338,26 @@ class Manifest
|
||||
if ($attr_name === 'Ajax_Endpoint' || str_ends_with($attr_name, '\\Ajax_Endpoint')) {
|
||||
$has_ajax_endpoint = true;
|
||||
}
|
||||
if ($attr_name === 'Task' || str_ends_with($attr_name, '\\Task')) {
|
||||
$has_task = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($has_route && $has_ajax_endpoint) {
|
||||
// Check for conflicting attributes
|
||||
$conflicts = [];
|
||||
if ($has_route) $conflicts[] = 'Route';
|
||||
if ($has_ajax_endpoint) $conflicts[] = 'Ajax_Endpoint';
|
||||
if ($has_task) $conflicts[] = 'Task';
|
||||
|
||||
if (count($conflicts) > 1) {
|
||||
$class_name = $metadata['class'] ?? 'Unknown';
|
||||
|
||||
throw new \RuntimeException(
|
||||
"Controller action cannot have both Route and Ajax_Endpoint attributes.\n" .
|
||||
"Method cannot have multiple execution type attributes: " . implode(', ', $conflicts) . "\n" .
|
||||
"Class: {$class_name}\n" .
|
||||
"Method: {$method_name}\n" .
|
||||
"File: {$file_path}\n" .
|
||||
'A controller action must be either a web route OR an AJAX endpoint, not both.'
|
||||
'A method must be either a Route, Ajax_Endpoint, OR Task, not multiple types.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
56
app/RSpade/Core/Models/Country_Model.php
Executable file
56
app/RSpade/Core/Models/Country_Model.php
Executable file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Models;
|
||||
|
||||
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
|
||||
use App\RSpade\Core\Models\Region_Model;
|
||||
|
||||
/**
|
||||
* RSX:USE
|
||||
* Country_Model - ISO 3166-1 country data
|
||||
*
|
||||
* Represents countries with their ISO codes and names.
|
||||
* Data populated from sokil/php-isocodes via rsx:seed:geographic-data command.
|
||||
*/
|
||||
class Country_Model extends Rsx_Model_Abstract
|
||||
{
|
||||
public static $enums = [];
|
||||
|
||||
protected $table = 'countries';
|
||||
|
||||
protected $casts = [
|
||||
'enabled' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all regions (subdivisions) for this country
|
||||
*/
|
||||
public function regions()
|
||||
{
|
||||
return $this->hasMany(Region_Model::class, 'country_alpha2', 'alpha2');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to only enabled countries
|
||||
*/
|
||||
public function scopeEnabled($query)
|
||||
{
|
||||
return $query->where('enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get country by alpha2 code
|
||||
*/
|
||||
public static function findByAlpha2(string $alpha2): ?self
|
||||
{
|
||||
return static::where('alpha2', $alpha2)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get country by alpha3 code
|
||||
*/
|
||||
public static function findByAlpha3(string $alpha3): ?self
|
||||
{
|
||||
return static::where('alpha3', $alpha3)->first();
|
||||
}
|
||||
}
|
||||
56
app/RSpade/Core/Models/Region_Model.php
Executable file
56
app/RSpade/Core/Models/Region_Model.php
Executable file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Models;
|
||||
|
||||
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
|
||||
use App\RSpade\Core\Models\Country_Model;
|
||||
|
||||
/**
|
||||
* RSX:USE
|
||||
* Region_Model - ISO 3166-2 subdivision data (states, provinces, territories)
|
||||
*
|
||||
* Represents geographic subdivisions with their ISO codes and names.
|
||||
* Data populated from sokil/php-isocodes via rsx:seed:geographic-data command.
|
||||
*/
|
||||
class Region_Model extends Rsx_Model_Abstract
|
||||
{
|
||||
public static $enums = [];
|
||||
|
||||
protected $table = 'regions';
|
||||
|
||||
protected $casts = [
|
||||
'enabled' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the country this region belongs to
|
||||
*/
|
||||
public function country()
|
||||
{
|
||||
return $this->belongsTo(Country_Model::class, 'country_alpha2', 'alpha2');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to only enabled regions
|
||||
*/
|
||||
public function scopeEnabled($query)
|
||||
{
|
||||
return $query->where('enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to regions for a specific country
|
||||
*/
|
||||
public function scopeForCountry($query, string $country_alpha2)
|
||||
{
|
||||
return $query->where('country_alpha2', $country_alpha2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get region by code
|
||||
*/
|
||||
public static function findByCode(string $code): ?self
|
||||
{
|
||||
return static::where('code', $code)->first();
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,8 @@ class Rsx_Bundle_Provider extends ServiceProvider
|
||||
$blade->precompiler(function ($value) {
|
||||
// Match @rsx_extends with optional second parameter for variables
|
||||
// Pattern matches both @rsx_extends('id') and @rsx_extends('id', [...])
|
||||
$pattern = '/@rsx_extends\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*(.+?))?\s*\)/';
|
||||
// The 's' modifier allows . to match newlines for multiline arrays
|
||||
$pattern = '/@rsx_extends\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*(.+?))?\s*\)/s';
|
||||
|
||||
return preg_replace_callback($pattern, function ($matches) {
|
||||
$rsx_id = $matches[1];
|
||||
|
||||
@@ -13,7 +13,6 @@ use App\Models\FlashAlert;
|
||||
use RuntimeException;
|
||||
use App\RSpade\Core\Debug\Rsx_Caller_Exception;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Rsx_Route_Proxy;
|
||||
use App\RSpade\Core\Session\Session;
|
||||
|
||||
/**
|
||||
@@ -192,63 +191,70 @@ HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a route proxy for type-safe URL generation
|
||||
* Generate URL for a controller route
|
||||
*
|
||||
* This method creates a route proxy that can generate URLs for a specific controller action.
|
||||
* The proxy ensures all required route parameters are provided and handles extra parameters
|
||||
* as query string values.
|
||||
* This method generates URLs for controller actions by looking up route patterns
|
||||
* and replacing parameters. It handles both regular routes and Ajax endpoints.
|
||||
*
|
||||
* Placeholder Routes:
|
||||
* When the action starts with '#' (e.g., '#index', '#show'), it indicates a placeholder/unimplemented
|
||||
* route for scaffolding purposes. These skip validation and return href="#" to allow incremental
|
||||
* route for scaffolding purposes. These skip validation and return "#" to allow incremental
|
||||
* development without requiring all controllers to exist.
|
||||
*
|
||||
* Usage examples:
|
||||
* ```php
|
||||
* // Simple route without parameters (defaults to 'index' action)
|
||||
* $url = Rsx::Route('Frontend_Index_Controller')->url();
|
||||
* $url = Rsx::Route('Frontend_Index_Controller');
|
||||
* // Returns: /dashboard
|
||||
*
|
||||
* // Route with explicit action
|
||||
* $url = Rsx::Route('Frontend_Index_Controller')->url();
|
||||
* $url = Rsx::Route('Frontend_Index_Controller', 'index');
|
||||
* // Returns: /dashboard
|
||||
*
|
||||
* // Route with required parameter
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller')->url(['id' => 'C001']);
|
||||
* // Route with integer parameter (sets 'id')
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', 123);
|
||||
* // Returns: /clients/view/123
|
||||
*
|
||||
* // Route with named parameters (array)
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', ['id' => 'C001']);
|
||||
* // Returns: /clients/view/C001
|
||||
*
|
||||
* // Route with required and query parameters
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller')->url([
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', [
|
||||
* 'id' => 'C001',
|
||||
* 'tab' => 'history'
|
||||
* ]);
|
||||
* // Returns: /clients/view/C001?tab=history
|
||||
*
|
||||
* // Generate absolute URL
|
||||
* $absolute = Rsx::Route('Frontend_Index_Controller')->absolute_url();
|
||||
* // Returns: https://example.com/dashboard
|
||||
*
|
||||
* // Check if route is current
|
||||
* if (Rsx::Route('Frontend_Index_Controller')->is_current()) {
|
||||
* // This is the currently executing route
|
||||
* }
|
||||
*
|
||||
* // Placeholder route for scaffolding (controller doesn't need to exist)
|
||||
* $url = Rsx::Route('Future_Feature_Controller', '#index')->url();
|
||||
* $url = Rsx::Route('Future_Feature_Controller', '#index');
|
||||
* // Returns: #
|
||||
* ```
|
||||
*
|
||||
* @param string $class_name The controller class name (e.g., 'User_Controller')
|
||||
* @param string $action_name The action/method name (defaults to 'index'). Use '#action' for placeholders.
|
||||
* @return Rsx_Route_Proxy Route proxy instance for URL generation
|
||||
* @param int|array|\stdClass|null $params Route parameters. Integer sets 'id', array/object provides named params.
|
||||
* @return string The generated URL
|
||||
* @throws RuntimeException If class doesn't exist, isn't a controller, method doesn't exist, or lacks Route attribute
|
||||
*/
|
||||
public static function Route($class_name, $action_name = 'index')
|
||||
public static function Route($class_name, $action_name = 'index', $params = null)
|
||||
{
|
||||
// Normalize params to array
|
||||
$params_array = [];
|
||||
if (is_int($params)) {
|
||||
$params_array = ['id' => $params];
|
||||
} elseif (is_array($params)) {
|
||||
$params_array = $params;
|
||||
} elseif ($params instanceof \stdClass) {
|
||||
$params_array = (array) $params;
|
||||
} elseif ($params !== null) {
|
||||
throw new RuntimeException("Params must be integer, array, stdClass, or null");
|
||||
}
|
||||
|
||||
// Placeholder route: action starts with # means unimplemented/scaffolding
|
||||
// Skip all validation and return a placeholder proxy
|
||||
// Skip all validation and return placeholder
|
||||
if (str_starts_with($action_name, '#')) {
|
||||
return new Rsx_Route_Proxy($class_name, $action_name, '#');
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Try to find the class in the manifest
|
||||
@@ -256,7 +262,7 @@ HTML;
|
||||
$metadata = Manifest::php_get_metadata_by_class($class_name);
|
||||
} catch (RuntimeException $e) {
|
||||
// Report error at caller's location (the blade template or PHP code calling Rsx::Route)
|
||||
throw new Rsx_Caller_Exception("Class {$class_name} not found in manifest");
|
||||
throw new Rsx_Caller_Exception("Could not generate route URL: controller class {$class_name} not found");
|
||||
}
|
||||
|
||||
// Verify it extends Rsx_Controller_Abstract
|
||||
@@ -329,10 +335,14 @@ HTML;
|
||||
}
|
||||
}
|
||||
|
||||
// If has Ajax_Endpoint, return AJAX route URL
|
||||
// If has Ajax_Endpoint, return AJAX route URL (no param substitution)
|
||||
if ($has_ajax_endpoint) {
|
||||
$ajax_url = '/_ajax/' . urlencode($class_name) . '/' . urlencode($action_name);
|
||||
return new Rsx_Route_Proxy($class_name, $action_name, $ajax_url);
|
||||
// Add query params if provided
|
||||
if (!empty($params_array)) {
|
||||
$ajax_url .= '?' . http_build_query($params_array);
|
||||
}
|
||||
return $ajax_url;
|
||||
}
|
||||
|
||||
if (!$has_route) {
|
||||
@@ -343,6 +353,68 @@ HTML;
|
||||
throw new Rsx_Caller_Exception("Route attribute on {$class_name}::{$action_name} must have a pattern");
|
||||
}
|
||||
|
||||
return new Rsx_Route_Proxy($class_name, $action_name, $route_pattern);
|
||||
// Generate URL from pattern
|
||||
return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, $action_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL from route pattern by replacing parameters
|
||||
*
|
||||
* @param string $pattern The route pattern (e.g., '/users/:id/view')
|
||||
* @param array $params Parameters to fill into the route
|
||||
* @param string $class_name Controller class name (for error messages)
|
||||
* @param string $action_name Action name (for error messages)
|
||||
* @return string The generated URL
|
||||
* @throws RuntimeException If required parameters are missing
|
||||
*/
|
||||
protected static function _generate_url_from_pattern($pattern, $params, $class_name, $action_name)
|
||||
{
|
||||
// Extract required parameters from the pattern
|
||||
$required_params = [];
|
||||
if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $pattern, $matches)) {
|
||||
$required_params = $matches[1];
|
||||
}
|
||||
|
||||
// Check for required parameters
|
||||
$missing = [];
|
||||
foreach ($required_params as $required) {
|
||||
if (!array_key_exists($required, $params)) {
|
||||
$missing[] = $required;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missing)) {
|
||||
throw new RuntimeException(
|
||||
"Required parameters [" . implode(', ', $missing) . "] are missing for route " .
|
||||
"{$pattern} on {$class_name}::{$action_name}"
|
||||
);
|
||||
}
|
||||
|
||||
// Build the URL by replacing parameters
|
||||
$url = $pattern;
|
||||
$used_params = [];
|
||||
|
||||
foreach ($required_params as $param_name) {
|
||||
$value = $params[$param_name];
|
||||
// URL encode the value
|
||||
$encoded_value = urlencode($value);
|
||||
$url = str_replace(':' . $param_name, $encoded_value, $url);
|
||||
$used_params[$param_name] = true;
|
||||
}
|
||||
|
||||
// Collect any extra parameters for query string
|
||||
$query_params = [];
|
||||
foreach ($params as $key => $value) {
|
||||
if (!isset($used_params[$key])) {
|
||||
$query_params[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Append query string if there are extra parameters
|
||||
if (!empty($query_params)) {
|
||||
$url .= '?' . http_build_query($query_params);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,199 +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;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Rsx_Route_Proxy - Type-safe route URL generator
|
||||
*
|
||||
* This class provides methods for generating URLs from route patterns,
|
||||
* ensuring all required parameters are provided and handling extra
|
||||
* parameters as query strings.
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Rsx_Route_Proxy
|
||||
{
|
||||
/**
|
||||
* @var string The controller class name
|
||||
*/
|
||||
protected string $_class;
|
||||
|
||||
/**
|
||||
* @var string The action/method name
|
||||
*/
|
||||
protected string $_method;
|
||||
|
||||
/**
|
||||
* @var string The route pattern (e.g., '/users/:id/:action')
|
||||
*/
|
||||
protected string $_pattern;
|
||||
|
||||
/**
|
||||
* @var array Required parameters extracted from the pattern
|
||||
*/
|
||||
protected array $_required_params = [];
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $class_name The controller class name
|
||||
* @param string $method_name The action/method name
|
||||
* @param string $pattern The route pattern
|
||||
*/
|
||||
public function __construct(string $class_name, string $method_name, string $pattern)
|
||||
{
|
||||
$this->_class = $class_name;
|
||||
$this->_method = $method_name;
|
||||
$this->_pattern = $pattern;
|
||||
|
||||
// Extract required parameters from the pattern
|
||||
$this->_extract_required_params();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract required parameters from the route pattern
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function _extract_required_params(): void
|
||||
{
|
||||
// Match all :param patterns in the route
|
||||
if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $this->_pattern, $matches)) {
|
||||
$this->_required_params = $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this route matches the current controller and action
|
||||
*
|
||||
* @return bool True if this is the current route
|
||||
*/
|
||||
public function is_current(): bool
|
||||
{
|
||||
$current_controller = \App\RSpade\Core\Rsx::get_current_controller();
|
||||
$current_action = \App\RSpade\Core\Rsx::get_current_action();
|
||||
|
||||
return $current_controller === $this->_class &&
|
||||
$current_action === $this->_method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a relative URL for the route
|
||||
*
|
||||
* @param array $params Parameters to fill into the route
|
||||
* @return string The generated URL
|
||||
* @throws RuntimeException If required parameters are missing
|
||||
*/
|
||||
public function url(array $params = []): string
|
||||
{
|
||||
// Check if the method name starts with '#' - indicates unimplemented route
|
||||
if (str_starts_with($this->_method, '#')) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Check for required parameters
|
||||
$missing = [];
|
||||
foreach ($this->_required_params as $required) {
|
||||
if (!array_key_exists($required, $params)) {
|
||||
$missing[] = $required;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missing)) {
|
||||
throw new RuntimeException(
|
||||
"Required parameters [" . implode(', ', $missing) . "] are missing for route " .
|
||||
"{$this->_pattern} on {$this->_class}::{$this->_method}"
|
||||
);
|
||||
}
|
||||
|
||||
// Build the URL by replacing parameters
|
||||
$url = $this->_pattern;
|
||||
$used_params = [];
|
||||
|
||||
foreach ($this->_required_params as $param_name) {
|
||||
$value = $params[$param_name];
|
||||
// URL encode the value
|
||||
$encoded_value = urlencode($value);
|
||||
$url = str_replace(':' . $param_name, $encoded_value, $url);
|
||||
$used_params[$param_name] = true;
|
||||
}
|
||||
|
||||
// Collect any extra parameters for query string
|
||||
$query_params = [];
|
||||
foreach ($params as $key => $value) {
|
||||
if (!isset($used_params[$key])) {
|
||||
$query_params[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Append query string if there are extra parameters
|
||||
if (!empty($query_params)) {
|
||||
$url .= '?' . http_build_query($query_params);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an absolute URL for the route
|
||||
*
|
||||
* @param array $params Parameters to fill into the route
|
||||
* @return string The generated absolute URL
|
||||
* @throws RuntimeException If required parameters are missing
|
||||
*/
|
||||
public function absolute_url(array $params = []): string
|
||||
{
|
||||
// Check if the method name starts with '#' - indicates unimplemented route
|
||||
if (str_starts_with($this->_method, '#')) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Get the relative URL first
|
||||
$relative_url = $this->url($params);
|
||||
|
||||
// Get the current request to extract protocol and domain
|
||||
$request = request();
|
||||
|
||||
// Build the absolute URL
|
||||
$protocol = $request->secure() ? 'https' : 'http';
|
||||
$host = $request->getHost();
|
||||
$port = $request->getPort();
|
||||
|
||||
// Only include port if it's non-standard
|
||||
$port_suffix = '';
|
||||
if (($protocol === 'http' && $port != 80) || ($protocol === 'https' && $port != 443)) {
|
||||
$port_suffix = ':' . $port;
|
||||
}
|
||||
|
||||
return $protocol . '://' . $host . $port_suffix . $relative_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the route by sending a redirect header
|
||||
*
|
||||
* @param array $params Parameters to fill into the route
|
||||
* @return never
|
||||
* @throws RuntimeException If required parameters are missing
|
||||
*/
|
||||
public function navigate(array $params = []): never
|
||||
{
|
||||
// Check if the method name starts with '#' - indicates unimplemented route
|
||||
if (str_starts_with($this->_method, '#')) {
|
||||
throw new RuntimeException(
|
||||
"Cannot navigate to unimplemented route: {$this->_class}::{$this->_method}"
|
||||
);
|
||||
}
|
||||
|
||||
$url = $this->url($params);
|
||||
|
||||
// Send redirect header
|
||||
header('Location: ' . $url, true, 302);
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
58
app/RSpade/Core/Service/Rsx_Service_Abstract.php
Executable file
58
app/RSpade/Core/Service/Rsx_Service_Abstract.php
Executable file
@@ -0,0 +1,58 @@
|
||||
<?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\Service;
|
||||
|
||||
/**
|
||||
* Base service class for all RSX services
|
||||
*
|
||||
* Services house background tasks that can be executed via CLI, queued, or scheduled.
|
||||
* All RSX services should extend this class and use standard OOP patterns.
|
||||
* Tasks are defined using #[Task] attributes on static methods.
|
||||
*/
|
||||
#[Monoprogenic]
|
||||
abstract class Rsx_Service_Abstract
|
||||
{
|
||||
/**
|
||||
* Pre-task hook called before any task execution
|
||||
* Override in child classes to add pre-task logic
|
||||
*
|
||||
* @param array $params Task parameters
|
||||
* @return mixed|null Return null to continue, or throw exception to halt
|
||||
*/
|
||||
public static function pre_task(array $params = [])
|
||||
{
|
||||
// Default implementation does nothing
|
||||
// Override in child classes to add authentication, validation, logging, etc.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a parameter value with optional default
|
||||
*
|
||||
* @param array $params Parameters array
|
||||
* @param string $key Parameter key
|
||||
* @param mixed $default Default value if not found
|
||||
* @return mixed
|
||||
*/
|
||||
protected static function __param($params, $key, $default = null)
|
||||
{
|
||||
return $params[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a parameter exists
|
||||
*
|
||||
* @param array $params Parameters array
|
||||
* @param string $key Parameter key
|
||||
* @return bool
|
||||
*/
|
||||
protected static function __has_param($params, $key)
|
||||
{
|
||||
return isset($params[$key]);
|
||||
}
|
||||
}
|
||||
128
app/RSpade/Core/Task/Task.php
Executable file
128
app/RSpade/Core/Task/Task.php
Executable file
@@ -0,0 +1,128 @@
|
||||
<?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\Task;
|
||||
|
||||
use Exception;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
|
||||
/**
|
||||
* Task - Unified task execution system
|
||||
*
|
||||
* Handles background task execution:
|
||||
* - Internal PHP task calls (internal method)
|
||||
* - Future: Queue integration, scheduling, progress tracking
|
||||
*/
|
||||
class Task
|
||||
{
|
||||
/**
|
||||
* Execute a task internally from PHP code
|
||||
*
|
||||
* Used for server-side code to invoke tasks without CLI overhead.
|
||||
* This is useful for calling tasks from other tasks, background jobs, etc.
|
||||
*
|
||||
* @param string $rsx_service Service name (e.g., 'Seeder_Service')
|
||||
* @param string $rsx_task Task/method name (e.g., 'seed_clients')
|
||||
* @param array $params Parameters to pass to the task
|
||||
* @return mixed The response from the task method
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function internal($rsx_service, $rsx_task, $params = [])
|
||||
{
|
||||
// Get manifest to find service
|
||||
$manifest = Manifest::get_all();
|
||||
$service_class = null;
|
||||
$file_info = null;
|
||||
|
||||
// Search for service class in manifest
|
||||
foreach ($manifest as $file_path => $info) {
|
||||
// Skip non-PHP files or files without classes
|
||||
if (!isset($info['class']) || !isset($info['fqcn'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if class name matches exactly (without namespace)
|
||||
$class_basename = basename(str_replace('\\', '/', $info['fqcn']));
|
||||
|
||||
if ($class_basename === $rsx_service) {
|
||||
$service_class = $info['fqcn'];
|
||||
$file_info = $info;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$service_class) {
|
||||
throw new Exception("Service class not found: {$rsx_service}");
|
||||
}
|
||||
|
||||
// Check if class exists
|
||||
if (!class_exists($service_class)) {
|
||||
throw new Exception("Service class does not exist: {$service_class}");
|
||||
}
|
||||
|
||||
// Check if it's a subclass of Rsx_Service_Abstract
|
||||
if (!Manifest::php_is_subclass_of($service_class, Rsx_Service_Abstract::class)) {
|
||||
throw new Exception("Service {$service_class} must extend Rsx_Service_Abstract");
|
||||
}
|
||||
|
||||
// Check if method exists and has Task attribute
|
||||
if (!isset($file_info['public_static_methods'][$rsx_task])) {
|
||||
throw new Exception("Task {$rsx_task} not found in service {$service_class}");
|
||||
}
|
||||
|
||||
$method_info = $file_info['public_static_methods'][$rsx_task];
|
||||
$has_task = false;
|
||||
|
||||
// Check for Task attribute in method metadata
|
||||
if (isset($method_info['attributes'])) {
|
||||
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
|
||||
if ($attr_name === 'Task' || str_ends_with($attr_name, '\\Task')) {
|
||||
$has_task = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$has_task) {
|
||||
throw new Exception("Method {$rsx_task} in service {$service_class} must have #[Task] attribute");
|
||||
}
|
||||
|
||||
// Call pre_task() if exists
|
||||
if (method_exists($service_class, 'pre_task')) {
|
||||
$pre_result = $service_class::pre_task($params);
|
||||
if ($pre_result !== null) {
|
||||
// pre_task returned something, use that as response
|
||||
return $pre_result;
|
||||
}
|
||||
}
|
||||
|
||||
// Call the actual task method
|
||||
$response = $service_class::$rsx_task($params);
|
||||
|
||||
// Filter response through JSON encode/decode to remove PHP objects
|
||||
// (similar to Ajax behavior)
|
||||
$filtered_response = json_decode(json_encode($response), true);
|
||||
|
||||
return $filtered_response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format task response for CLI output
|
||||
* Wraps the response in a consistent format
|
||||
*
|
||||
* @param mixed $response Task return value
|
||||
* @return array Formatted response
|
||||
*/
|
||||
public static function format_task_response($response): array
|
||||
{
|
||||
return [
|
||||
'success' => true,
|
||||
'result' => $response,
|
||||
];
|
||||
}
|
||||
}
|
||||
247
app/RSpade/Core/Testing/Rsx_Formdata_Generator_Controller.php
Executable file
247
app/RSpade/Core/Testing/Rsx_Formdata_Generator_Controller.php
Executable file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Testing;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
|
||||
|
||||
/**
|
||||
* RSX:USE
|
||||
* Rsx_Formdata_Generator_Controller - Generate random test data for form fields
|
||||
*
|
||||
* Provides Ajax endpoints that return randomized test data for form seeding.
|
||||
* All methods fatal error in production mode - this is strictly a development tool.
|
||||
*
|
||||
* Usage from widgets:
|
||||
* let value = await Rsx_Formdata_Generator_Controller.first_name();
|
||||
* let email = await Rsx_Formdata_Generator_Controller.email();
|
||||
*/
|
||||
class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||
{
|
||||
private static $first_names = null;
|
||||
private static $last_names = null;
|
||||
private static $cities = null;
|
||||
private static $street_names = null;
|
||||
private static $words = null;
|
||||
|
||||
/**
|
||||
* Check if running in production and throw if true
|
||||
*/
|
||||
private static function __check_production(): void
|
||||
{
|
||||
if (app()->environment() === 'production') {
|
||||
throw new Exception('Rsx_Formdata_Generator_Controller cannot be used in production environment');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load word list from resource file
|
||||
*/
|
||||
private static function __load_wordlist(string $filename): array
|
||||
{
|
||||
$path = __DIR__ . '/resource/' . $filename;
|
||||
if (!file_exists($path)) {
|
||||
throw new Exception("Word list not found: {$filename}");
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
$lines = explode("\n", trim($content));
|
||||
return array_filter($lines, fn($line) => !empty(trim($line)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get random item from word list
|
||||
*/
|
||||
private static function __random_from_list(string $list_name): string
|
||||
{
|
||||
if (self::${$list_name} === null) {
|
||||
self::${$list_name} = self::__load_wordlist($list_name . '.txt');
|
||||
}
|
||||
|
||||
$list = self::${$list_name};
|
||||
return $list[array_rand($list)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random first name
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function first_name(Request $request, array $params = []): string
|
||||
{
|
||||
self::__check_production();
|
||||
return self::__random_from_list('first_names');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random last name
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function last_name(Request $request, array $params = []): string
|
||||
{
|
||||
self::__check_production();
|
||||
return self::__random_from_list('last_names');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random company name
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function company_name(Request $request, array $params = []): string
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
$patterns = [
|
||||
// Word + Industries/Solutions/Systems/Technologies
|
||||
fn() => self::__random_from_list('words') . ' ' . ['Industries', 'Solutions', 'Systems', 'Technologies', 'Corporation', 'Group', 'Enterprises'][rand(0, 6)],
|
||||
// LastName + FirstName + LLC
|
||||
fn() => self::__random_from_list('last_names') . ' ' . self::__random_from_list('first_names') . ' LLC',
|
||||
// Word + Word + Inc
|
||||
fn() => ucfirst(self::__random_from_list('words')) . ' ' . ucfirst(self::__random_from_list('words')) . ' Inc',
|
||||
// City + Industries
|
||||
fn() => self::__random_from_list('cities') . ' ' . ['Consulting', 'Services', 'Partners', 'Associates', 'Ventures'][rand(0, 4)],
|
||||
];
|
||||
|
||||
return $patterns[array_rand($patterns)]();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random street address
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function address(Request $request, array $params = []): string
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
$number = rand(1, 9999);
|
||||
$street = self::__random_from_list('street_names');
|
||||
$suffix = ['Street', 'Avenue', 'Road', 'Drive', 'Lane', 'Way', 'Boulevard', 'Court', 'Place'][rand(0, 8)];
|
||||
|
||||
return "{$number} {$street} {$suffix}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random city name
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function city(Request $request, array $params = []): string
|
||||
{
|
||||
self::__check_production();
|
||||
return self::__random_from_list('cities');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random US state code
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function state(Request $request, array $params = []): string
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
$states = [
|
||||
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
|
||||
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
|
||||
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
|
||||
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
|
||||
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY'
|
||||
];
|
||||
|
||||
return $states[array_rand($states)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random ZIP code
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function zip(Request $request, array $params = []): string
|
||||
{
|
||||
self::__check_production();
|
||||
return str_pad((string)rand(10000, 99999), 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random phone number in 555-XXX-XXXX format
|
||||
* Avoids 555-000-0000 and any sequence containing 911
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function phone(Request $request, array $params = []): string
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
$attempts = 0;
|
||||
do {
|
||||
$middle = str_pad((string)rand(100, 999), 3, '0', STR_PAD_LEFT);
|
||||
$last = str_pad((string)rand(1, 9999), 4, '0', STR_PAD_LEFT);
|
||||
$phone = "555-{$middle}-{$last}";
|
||||
|
||||
$has_911 = strpos($phone, '911') !== false;
|
||||
$is_all_zeros = $phone === '555-000-0000';
|
||||
|
||||
$attempts++;
|
||||
if ($attempts > 100) {
|
||||
// Return safe number after max attempts
|
||||
return '555-' . rand(100, 899) . '-' . rand(1000, 9999);
|
||||
}
|
||||
|
||||
} while ($has_911 || $is_all_zeros);
|
||||
|
||||
return $phone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random email address
|
||||
* Format: wordwordnumbers+test@gmail.com
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function email(Request $request, array $params = []): string
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
$word1 = self::__random_from_list('words');
|
||||
$word2 = self::__random_from_list('words');
|
||||
$numbers = rand(100, 9999);
|
||||
|
||||
return "{$word1}{$word2}{$numbers}+test@gmail.com";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random website URL
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function website(Request $request, array $params = []): string
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
$word1 = self::__random_from_list('words');
|
||||
$word2 = self::__random_from_list('words');
|
||||
$tld = ['com', 'net', 'org', 'io', 'co'][rand(0, 4)];
|
||||
|
||||
return "https://{$word1}{$word2}.{$tld}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random text/paragraph
|
||||
*/
|
||||
#[Ajax_Endpoint]
|
||||
public static function text(Request $request, array $params = []): string
|
||||
{
|
||||
self::__check_production();
|
||||
|
||||
$sentence_count = rand(3, 6);
|
||||
$sentences = [];
|
||||
|
||||
for ($i = 0; $i < $sentence_count; $i++) {
|
||||
$word_count = rand(5, 12);
|
||||
$words = [];
|
||||
|
||||
for ($j = 0; $j < $word_count; $j++) {
|
||||
$words[] = self::__random_from_list('words');
|
||||
}
|
||||
|
||||
$sentence = ucfirst(implode(' ', $words)) . '.';
|
||||
$sentences[] = $sentence;
|
||||
}
|
||||
|
||||
return implode(' ', $sentences);
|
||||
}
|
||||
}
|
||||
4
app/RSpade/Core/Testing/refactor.list
Executable file
4
app/RSpade/Core/Testing/refactor.list
Executable file
@@ -0,0 +1,4 @@
|
||||
[2025-10-30 05:42:23] rsx:refactor:rename_php_class Rsx_Formdata_Generator Rsx_Formdata_Generator_Controller
|
||||
[2025-10-30 06:19:35] rsx:refactor:rename_php_class_function Rsx_Formdata_Generator_Controller check_production __check_production
|
||||
[2025-10-30 06:19:37] rsx:refactor:rename_php_class_function Rsx_Formdata_Generator_Controller load_wordlist __load_wordlist
|
||||
[2025-10-30 06:19:39] rsx:refactor:rename_php_class_function Rsx_Formdata_Generator_Controller random_from_list __random_from_list
|
||||
218
app/RSpade/Core/Testing/resource/cities.txt
Executable file
218
app/RSpade/Core/Testing/resource/cities.txt
Executable file
@@ -0,0 +1,218 @@
|
||||
Springfield
|
||||
Madison
|
||||
Georgetown
|
||||
Franklin
|
||||
Clinton
|
||||
Arlington
|
||||
Ashland
|
||||
Burlington
|
||||
Chester
|
||||
Clayton
|
||||
Dover
|
||||
Dayton
|
||||
Easton
|
||||
Fairview
|
||||
Georgetown
|
||||
Hamilton
|
||||
Hudson
|
||||
Jackson
|
||||
Kingston
|
||||
Lancaster
|
||||
Lincoln
|
||||
Marion
|
||||
Manchester
|
||||
Newport
|
||||
Oxford
|
||||
Portland
|
||||
Princeton
|
||||
Richmond
|
||||
Salem
|
||||
Shelby
|
||||
Somerset
|
||||
Vernon
|
||||
Waverly
|
||||
Webster
|
||||
Westfield
|
||||
Wilmington
|
||||
Winston
|
||||
Ashford
|
||||
Brighton
|
||||
Cambridge
|
||||
Danville
|
||||
Eastwood
|
||||
Fairmont
|
||||
Glendale
|
||||
Hillside
|
||||
Ivywood
|
||||
Jamestown
|
||||
Kirkland
|
||||
Lakewood
|
||||
Midland
|
||||
Newtown
|
||||
Oakland
|
||||
Parkside
|
||||
Redwood
|
||||
Southgate
|
||||
Thornton
|
||||
Unionville
|
||||
Valley
|
||||
Woodland
|
||||
Alton
|
||||
Bedford
|
||||
Carver
|
||||
Dalton
|
||||
Elgin
|
||||
Fletcher
|
||||
Garfield
|
||||
Hartwell
|
||||
Inglewood
|
||||
Jefferson
|
||||
Kensington
|
||||
Lexington
|
||||
Morton
|
||||
Newton
|
||||
Oakdale
|
||||
Preston
|
||||
Quincy
|
||||
Riverside
|
||||
Stanford
|
||||
Trenton
|
||||
Upton
|
||||
Vinton
|
||||
Walton
|
||||
Yardley
|
||||
Zenith
|
||||
Auburn
|
||||
Bristol
|
||||
Carlton
|
||||
Denton
|
||||
Elmwood
|
||||
Fairfield
|
||||
Greenwood
|
||||
Hartfield
|
||||
Ironwood
|
||||
Jasper
|
||||
Kenwood
|
||||
Linwood
|
||||
Maple
|
||||
Norfolk
|
||||
Oakwood
|
||||
Pinewood
|
||||
Riverdale
|
||||
Springdale
|
||||
Thorndale
|
||||
Upland
|
||||
Vista
|
||||
Westwood
|
||||
Yarmouth
|
||||
Zenith
|
||||
Ashton
|
||||
Barton
|
||||
Clifton
|
||||
Dixon
|
||||
Eaton
|
||||
Fulton
|
||||
Grafton
|
||||
Horton
|
||||
Ironton
|
||||
Junction
|
||||
Keaton
|
||||
Lawton
|
||||
Melton
|
||||
Norton
|
||||
Orton
|
||||
Paxton
|
||||
Ralston
|
||||
Seaton
|
||||
Tilton
|
||||
Upton
|
||||
Walton
|
||||
Yeaton
|
||||
Zephyr
|
||||
Beacon
|
||||
Canyon
|
||||
Delta
|
||||
Echo
|
||||
Forest
|
||||
Grove
|
||||
Harbor
|
||||
Island
|
||||
Jordan
|
||||
Knoll
|
||||
Lagoon
|
||||
Meadow
|
||||
North
|
||||
Ocean
|
||||
Plains
|
||||
Ridge
|
||||
Summit
|
||||
Timber
|
||||
Union
|
||||
Valley
|
||||
Waters
|
||||
Zenith
|
||||
Alpine
|
||||
Bluff
|
||||
Crest
|
||||
Desert
|
||||
Eagle
|
||||
Falls
|
||||
Glen
|
||||
Haven
|
||||
Inlet
|
||||
Juniper
|
||||
Knob
|
||||
Lake
|
||||
Mesa
|
||||
North
|
||||
Oaks
|
||||
Park
|
||||
Ranch
|
||||
Shore
|
||||
Trail
|
||||
Union
|
||||
Vista
|
||||
Woods
|
||||
Zenith
|
||||
Anchor
|
||||
Basin
|
||||
Cape
|
||||
Dune
|
||||
Edge
|
||||
Ford
|
||||
Gulf
|
||||
Hill
|
||||
Isle
|
||||
Key
|
||||
Lagoon
|
||||
Mount
|
||||
North
|
||||
Outlet
|
||||
Point
|
||||
River
|
||||
Sound
|
||||
Tides
|
||||
Union
|
||||
View
|
||||
West
|
||||
Zephyr
|
||||
Atlas
|
||||
Brook
|
||||
Creek
|
||||
Dale
|
||||
East
|
||||
Field
|
||||
Glade
|
||||
Heath
|
||||
Knoll
|
||||
Lawn
|
||||
Marsh
|
||||
North
|
||||
Pond
|
||||
Range
|
||||
South
|
||||
Trace
|
||||
Vale
|
||||
West
|
||||
Yard
|
||||
Zone
|
||||
240
app/RSpade/Core/Testing/resource/first_names.txt
Executable file
240
app/RSpade/Core/Testing/resource/first_names.txt
Executable file
@@ -0,0 +1,240 @@
|
||||
Alex
|
||||
Blake
|
||||
Casey
|
||||
Dakota
|
||||
Ellis
|
||||
Finley
|
||||
Gray
|
||||
Harper
|
||||
Indigo
|
||||
Jordan
|
||||
Kai
|
||||
Logan
|
||||
Morgan
|
||||
Noel
|
||||
Owen
|
||||
Parker
|
||||
Quinn
|
||||
Reese
|
||||
Sage
|
||||
Taylor
|
||||
Adrian
|
||||
Blake
|
||||
Cameron
|
||||
Dylan
|
||||
Emerson
|
||||
Flynn
|
||||
Grayson
|
||||
Hayden
|
||||
Ivan
|
||||
Jasper
|
||||
Kyle
|
||||
Lane
|
||||
Mason
|
||||
Nathan
|
||||
Oliver
|
||||
Phoenix
|
||||
Quincy
|
||||
River
|
||||
Shane
|
||||
Tyler
|
||||
Aiden
|
||||
Bella
|
||||
Carter
|
||||
Devin
|
||||
Ethan
|
||||
Fiona
|
||||
Gabriel
|
||||
Hazel
|
||||
Isaac
|
||||
Julia
|
||||
Kevin
|
||||
Luna
|
||||
Miles
|
||||
Nina
|
||||
Oscar
|
||||
Piper
|
||||
Quinn
|
||||
Ruby
|
||||
Sam
|
||||
Tessa
|
||||
Uma
|
||||
Victor
|
||||
Wyatt
|
||||
Xander
|
||||
Yara
|
||||
Zane
|
||||
Ash
|
||||
Bay
|
||||
Cole
|
||||
Drew
|
||||
Eden
|
||||
Fox
|
||||
Gage
|
||||
Hale
|
||||
Iris
|
||||
Jay
|
||||
Knox
|
||||
Lake
|
||||
Max
|
||||
Nova
|
||||
Onyx
|
||||
Penn
|
||||
Rain
|
||||
Sky
|
||||
Tate
|
||||
Vale
|
||||
West
|
||||
York
|
||||
Zara
|
||||
Ace
|
||||
Bay
|
||||
Cruz
|
||||
Dean
|
||||
Echo
|
||||
Faye
|
||||
Glen
|
||||
Hart
|
||||
Isla
|
||||
Jude
|
||||
Kent
|
||||
Leaf
|
||||
Milo
|
||||
Nash
|
||||
Opal
|
||||
Pine
|
||||
Reed
|
||||
Star
|
||||
Theo
|
||||
Vale
|
||||
West
|
||||
Zeke
|
||||
Alba
|
||||
Beau
|
||||
Cleo
|
||||
Dale
|
||||
Eden
|
||||
Fern
|
||||
Glen
|
||||
Hope
|
||||
Ines
|
||||
June
|
||||
Kent
|
||||
Lake
|
||||
Moss
|
||||
Nora
|
||||
Odin
|
||||
Pine
|
||||
Rose
|
||||
Snow
|
||||
Tide
|
||||
Vale
|
||||
Wolf
|
||||
Zinc
|
||||
Alma
|
||||
Beck
|
||||
Clay
|
||||
Dawn
|
||||
East
|
||||
Ford
|
||||
Glen
|
||||
Hope
|
||||
Jade
|
||||
Kale
|
||||
Lane
|
||||
Moss
|
||||
Nash
|
||||
Opal
|
||||
Page
|
||||
Reed
|
||||
Sage
|
||||
Tide
|
||||
Vale
|
||||
Wade
|
||||
Zane
|
||||
Alto
|
||||
Bear
|
||||
Cove
|
||||
Dawn
|
||||
Edge
|
||||
Finn
|
||||
Glen
|
||||
Hill
|
||||
Isle
|
||||
June
|
||||
Lake
|
||||
Moon
|
||||
Navy
|
||||
Peak
|
||||
Rain
|
||||
Sand
|
||||
Tide
|
||||
Vale
|
||||
Wave
|
||||
Zone
|
||||
Arch
|
||||
Beam
|
||||
Cliff
|
||||
Dell
|
||||
East
|
||||
Ford
|
||||
Glen
|
||||
Hill
|
||||
Isle
|
||||
Kent
|
||||
Lake
|
||||
Mill
|
||||
North
|
||||
Peak
|
||||
Reed
|
||||
South
|
||||
Tide
|
||||
Vale
|
||||
West
|
||||
Zinc
|
||||
Alma
|
||||
Beck
|
||||
Cove
|
||||
Dawn
|
||||
Eden
|
||||
Fern
|
||||
Glen
|
||||
Hope
|
||||
Isle
|
||||
Jade
|
||||
Kent
|
||||
Lake
|
||||
Moss
|
||||
Nora
|
||||
Oak
|
||||
Pine
|
||||
Reed
|
||||
Sage
|
||||
Tide
|
||||
Vale
|
||||
Wade
|
||||
York
|
||||
Zane
|
||||
Alto
|
||||
Bay
|
||||
Cove
|
||||
Dale
|
||||
Eden
|
||||
Ford
|
||||
Glen
|
||||
Hope
|
||||
Isle
|
||||
June
|
||||
Kent
|
||||
Lane
|
||||
Moon
|
||||
North
|
||||
Oak
|
||||
Peak
|
||||
Reed
|
||||
Sky
|
||||
Tide
|
||||
Vale
|
||||
West
|
||||
York
|
||||
Zone
|
||||
219
app/RSpade/Core/Testing/resource/last_names.txt
Executable file
219
app/RSpade/Core/Testing/resource/last_names.txt
Executable file
@@ -0,0 +1,219 @@
|
||||
Anderson
|
||||
Baker
|
||||
Carter
|
||||
Davis
|
||||
Edwards
|
||||
Foster
|
||||
Garcia
|
||||
Harris
|
||||
Jackson
|
||||
Kennedy
|
||||
Lopez
|
||||
Martinez
|
||||
Nelson
|
||||
Oliver
|
||||
Parker
|
||||
Quinn
|
||||
Roberts
|
||||
Smith
|
||||
Thompson
|
||||
Turner
|
||||
Adams
|
||||
Bennett
|
||||
Campbell
|
||||
Dixon
|
||||
Ellis
|
||||
Franklin
|
||||
Gray
|
||||
Hansen
|
||||
Ingram
|
||||
Jenkins
|
||||
King
|
||||
Lewis
|
||||
Mitchell
|
||||
Norton
|
||||
Owen
|
||||
Palmer
|
||||
Reed
|
||||
Sanders
|
||||
Taylor
|
||||
Vincent
|
||||
Walker
|
||||
Young
|
||||
Allen
|
||||
Brown
|
||||
Clarke
|
||||
Douglas
|
||||
Evans
|
||||
Fisher
|
||||
Green
|
||||
Hill
|
||||
Irving
|
||||
Jones
|
||||
Kelly
|
||||
Long
|
||||
Morgan
|
||||
Nash
|
||||
Olson
|
||||
Powell
|
||||
Rice
|
||||
Scott
|
||||
Tucker
|
||||
Wagner
|
||||
White
|
||||
Zimmerman
|
||||
Abbott
|
||||
Bishop
|
||||
Chase
|
||||
Drake
|
||||
Elliott
|
||||
Flynn
|
||||
Grant
|
||||
Hayes
|
||||
Irwin
|
||||
James
|
||||
Knox
|
||||
Lane
|
||||
Mason
|
||||
Noble
|
||||
Orton
|
||||
Pierce
|
||||
Rhodes
|
||||
Stone
|
||||
Torres
|
||||
Valdez
|
||||
Warren
|
||||
Xavier
|
||||
York
|
||||
Zeller
|
||||
Archer
|
||||
Bryant
|
||||
Cohen
|
||||
Dean
|
||||
Ellis
|
||||
Ford
|
||||
Griffin
|
||||
Hughes
|
||||
Ingram
|
||||
Jordan
|
||||
Klein
|
||||
Lynch
|
||||
Murphy
|
||||
Nash
|
||||
Osborne
|
||||
Palmer
|
||||
Quinn
|
||||
Russell
|
||||
Shaw
|
||||
Turner
|
||||
Vaughn
|
||||
Wells
|
||||
York
|
||||
Zimmerman
|
||||
Avery
|
||||
Brandt
|
||||
Cross
|
||||
Dale
|
||||
Easton
|
||||
Fields
|
||||
Graham
|
||||
Hale
|
||||
Irons
|
||||
Joyce
|
||||
Kemp
|
||||
Lane
|
||||
Marsh
|
||||
Noble
|
||||
Owens
|
||||
Parks
|
||||
Quinn
|
||||
Rowe
|
||||
Shaw
|
||||
Todd
|
||||
Vale
|
||||
West
|
||||
York
|
||||
Zeller
|
||||
Atlas
|
||||
Blake
|
||||
Crane
|
||||
Drake
|
||||
Flint
|
||||
Grove
|
||||
Holt
|
||||
Knox
|
||||
Lowe
|
||||
Mills
|
||||
North
|
||||
Page
|
||||
Ridge
|
||||
Shaw
|
||||
Todd
|
||||
Vale
|
||||
Wade
|
||||
York
|
||||
Zinc
|
||||
Ash
|
||||
Bay
|
||||
Cole
|
||||
Dale
|
||||
Ford
|
||||
Glen
|
||||
Hill
|
||||
Kent
|
||||
Lake
|
||||
Moon
|
||||
Nash
|
||||
Oak
|
||||
Peak
|
||||
Reed
|
||||
Sky
|
||||
Tide
|
||||
Vale
|
||||
West
|
||||
York
|
||||
Zinc
|
||||
Arch
|
||||
Beck
|
||||
Cove
|
||||
Dale
|
||||
Eden
|
||||
Fern
|
||||
Glen
|
||||
Hope
|
||||
Isle
|
||||
June
|
||||
Kent
|
||||
Lake
|
||||
Moss
|
||||
Nash
|
||||
Oak
|
||||
Pine
|
||||
Reed
|
||||
Sage
|
||||
Tide
|
||||
Vale
|
||||
Wade
|
||||
York
|
||||
Zone
|
||||
Beam
|
||||
Cliff
|
||||
Dell
|
||||
East
|
||||
Ford
|
||||
Glen
|
||||
Hill
|
||||
Isle
|
||||
Kent
|
||||
Lake
|
||||
Mill
|
||||
North
|
||||
Oak
|
||||
Peak
|
||||
Reed
|
||||
South
|
||||
Tide
|
||||
Vale
|
||||
West
|
||||
York
|
||||
Zone
|
||||
210
app/RSpade/Core/Testing/resource/street_names.txt
Executable file
210
app/RSpade/Core/Testing/resource/street_names.txt
Executable file
@@ -0,0 +1,210 @@
|
||||
Main
|
||||
Oak
|
||||
Maple
|
||||
Pine
|
||||
Cedar
|
||||
Elm
|
||||
Washington
|
||||
Park
|
||||
Lake
|
||||
Hill
|
||||
River
|
||||
Spring
|
||||
Forest
|
||||
Meadow
|
||||
Valley
|
||||
Ridge
|
||||
Summit
|
||||
Grove
|
||||
Garden
|
||||
Church
|
||||
School
|
||||
College
|
||||
Union
|
||||
Liberty
|
||||
Franklin
|
||||
Lincoln
|
||||
Madison
|
||||
Jefferson
|
||||
Adams
|
||||
Jackson
|
||||
Harrison
|
||||
Wilson
|
||||
Taylor
|
||||
Grant
|
||||
Hayes
|
||||
Garfield
|
||||
Cleveland
|
||||
Roosevelt
|
||||
Truman
|
||||
Eisenhower
|
||||
Kennedy
|
||||
Johnson
|
||||
Nixon
|
||||
Ford
|
||||
Carter
|
||||
Reagan
|
||||
Bush
|
||||
Clinton
|
||||
Market
|
||||
Water
|
||||
Bridge
|
||||
Mill
|
||||
Station
|
||||
Factory
|
||||
Commerce
|
||||
Industrial
|
||||
Center
|
||||
Plaza
|
||||
Square
|
||||
Circle
|
||||
Court
|
||||
Place
|
||||
Avenue
|
||||
Street
|
||||
Road
|
||||
Drive
|
||||
Lane
|
||||
Way
|
||||
Path
|
||||
Trail
|
||||
Route
|
||||
Highway
|
||||
Parkway
|
||||
Boulevard
|
||||
Terrace
|
||||
View
|
||||
Heights
|
||||
Point
|
||||
Beach
|
||||
Shore
|
||||
Bay
|
||||
Harbor
|
||||
Island
|
||||
Cape
|
||||
Inlet
|
||||
Cove
|
||||
Creek
|
||||
Brook
|
||||
Stream
|
||||
Pond
|
||||
Falls
|
||||
Canyon
|
||||
Valley
|
||||
Mountain
|
||||
Hill
|
||||
Ridge
|
||||
Peak
|
||||
Summit
|
||||
Bluff
|
||||
Cliff
|
||||
Mesa
|
||||
Prairie
|
||||
Plains
|
||||
Meadow
|
||||
Field
|
||||
Garden
|
||||
Orchard
|
||||
Grove
|
||||
Woods
|
||||
Forest
|
||||
Glen
|
||||
Dell
|
||||
Hollow
|
||||
Knoll
|
||||
Slope
|
||||
Hillside
|
||||
Riverside
|
||||
Lakeside
|
||||
Seaside
|
||||
Oceanside
|
||||
Bayside
|
||||
Harborside
|
||||
Creekside
|
||||
Brookside
|
||||
Streamside
|
||||
Pondside
|
||||
Woodland
|
||||
Parkland
|
||||
Highland
|
||||
Lowland
|
||||
Midland
|
||||
Upland
|
||||
Grassland
|
||||
Farmland
|
||||
Ranchland
|
||||
Timberland
|
||||
Wetland
|
||||
North
|
||||
South
|
||||
East
|
||||
West
|
||||
Central
|
||||
Middle
|
||||
Upper
|
||||
Lower
|
||||
New
|
||||
Old
|
||||
Grand
|
||||
Royal
|
||||
Crown
|
||||
Kings
|
||||
Queens
|
||||
Prince
|
||||
Duke
|
||||
Earl
|
||||
Lord
|
||||
Manor
|
||||
Estate
|
||||
Villa
|
||||
Palace
|
||||
Castle
|
||||
Tower
|
||||
Bridge
|
||||
Gate
|
||||
Arch
|
||||
Portal
|
||||
Square
|
||||
Circle
|
||||
Triangle
|
||||
Diamond
|
||||
Star
|
||||
Moon
|
||||
Sun
|
||||
Dawn
|
||||
Dusk
|
||||
Sunrise
|
||||
Sunset
|
||||
Morning
|
||||
Evening
|
||||
Twilight
|
||||
Midnight
|
||||
Noon
|
||||
Spring
|
||||
Summer
|
||||
Autumn
|
||||
Winter
|
||||
January
|
||||
February
|
||||
March
|
||||
April
|
||||
May
|
||||
June
|
||||
July
|
||||
August
|
||||
September
|
||||
October
|
||||
November
|
||||
December
|
||||
First
|
||||
Second
|
||||
Third
|
||||
Fourth
|
||||
Fifth
|
||||
Sixth
|
||||
Seventh
|
||||
Eighth
|
||||
Ninth
|
||||
Tenth
|
||||
Eleventh
|
||||
Twelfth
|
||||
174
app/RSpade/Core/Testing/resource/words.txt
Executable file
174
app/RSpade/Core/Testing/resource/words.txt
Executable file
@@ -0,0 +1,174 @@
|
||||
alpha
|
||||
beta
|
||||
gamma
|
||||
delta
|
||||
echo
|
||||
foxtrot
|
||||
golf
|
||||
hotel
|
||||
india
|
||||
juliet
|
||||
kilo
|
||||
lima
|
||||
mike
|
||||
november
|
||||
oscar
|
||||
papa
|
||||
quebec
|
||||
romeo
|
||||
sierra
|
||||
tango
|
||||
uniform
|
||||
victor
|
||||
whiskey
|
||||
xray
|
||||
yankee
|
||||
zulu
|
||||
amber
|
||||
azure
|
||||
bronze
|
||||
cobalt
|
||||
crimson
|
||||
emerald
|
||||
gold
|
||||
ivory
|
||||
jade
|
||||
khaki
|
||||
lavender
|
||||
magenta
|
||||
navy
|
||||
olive
|
||||
pearl
|
||||
ruby
|
||||
silver
|
||||
teal
|
||||
violet
|
||||
wheat
|
||||
yellow
|
||||
zinc
|
||||
apple
|
||||
banana
|
||||
cherry
|
||||
date
|
||||
fig
|
||||
grape
|
||||
kiwi
|
||||
lemon
|
||||
mango
|
||||
olive
|
||||
peach
|
||||
plum
|
||||
quince
|
||||
raisin
|
||||
tangerine
|
||||
walnut
|
||||
acorn
|
||||
birch
|
||||
cedar
|
||||
daisy
|
||||
fern
|
||||
hazel
|
||||
ivy
|
||||
juniper
|
||||
lotus
|
||||
maple
|
||||
nettle
|
||||
orchid
|
||||
poppy
|
||||
rose
|
||||
sage
|
||||
tulip
|
||||
willow
|
||||
atlas
|
||||
beacon
|
||||
cipher
|
||||
delta
|
||||
enigma
|
||||
falcon
|
||||
harbor
|
||||
icarus
|
||||
jaguar
|
||||
keystone
|
||||
lynx
|
||||
matrix
|
||||
nexus
|
||||
oracle
|
||||
phoenix
|
||||
quantum
|
||||
raven
|
||||
solar
|
||||
titan
|
||||
ultra
|
||||
vertex
|
||||
zenith
|
||||
anchor
|
||||
blade
|
||||
crest
|
||||
drift
|
||||
ember
|
||||
forge
|
||||
ghost
|
||||
haven
|
||||
iron
|
||||
jade
|
||||
knight
|
||||
lunar
|
||||
mirror
|
||||
noble
|
||||
omega
|
||||
prism
|
||||
quest
|
||||
rogue
|
||||
storm
|
||||
thunder
|
||||
unity
|
||||
valor
|
||||
wave
|
||||
xenon
|
||||
zodiac
|
||||
arch
|
||||
bolt
|
||||
chord
|
||||
dawn
|
||||
edge
|
||||
flare
|
||||
glow
|
||||
halo
|
||||
index
|
||||
jewel
|
||||
knot
|
||||
light
|
||||
mesh
|
||||
node
|
||||
orbit
|
||||
pulse
|
||||
quark
|
||||
ray
|
||||
spark
|
||||
trace
|
||||
unity
|
||||
vortex
|
||||
warp
|
||||
zone
|
||||
atom
|
||||
beam
|
||||
core
|
||||
dome
|
||||
flux
|
||||
grid
|
||||
helix
|
||||
ion
|
||||
jet
|
||||
kinetic
|
||||
loop
|
||||
mass
|
||||
neuron
|
||||
optic
|
||||
phase
|
||||
quasar
|
||||
radar
|
||||
scope
|
||||
tempo
|
||||
ultra
|
||||
vector
|
||||
watt
|
||||
@@ -308,8 +308,23 @@ class JqhtmlBladeCompiler
|
||||
// Value is already a PHP expression, keep as is
|
||||
$parsed[$key] = ['type' => 'expression', 'value' => $value];
|
||||
} else {
|
||||
// Regular string value
|
||||
$parsed[$key] = ['type' => 'string', 'value' => $value];
|
||||
// Check if value contains Blade expressions {{ }} or {!! !!}
|
||||
// These need to be extracted and treated as PHP expressions
|
||||
if (is_string($value) && preg_match('/^(\{\{|\{!!)\s*(.+?)\s*(\}\}|!!})$/s', $value, $blade_match)) {
|
||||
// Extract the PHP expression from inside the Blade braces
|
||||
$php_expression = $blade_match[2];
|
||||
|
||||
// For {{ }} (escaped output), wrap in e() helper
|
||||
// For {!! !!} (raw output), use as-is
|
||||
if ($blade_match[1] === '{{') {
|
||||
$php_expression = "e({$php_expression})";
|
||||
}
|
||||
|
||||
$parsed[$key] = ['type' => 'expression', 'value' => $php_expression];
|
||||
} else {
|
||||
// Regular string value
|
||||
$parsed[$key] = ['type' => 'string', 'value' => $value];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,12 @@ class Jqhtml_Integration {
|
||||
components_needing_init.each(function () {
|
||||
const $element = $(this);
|
||||
|
||||
// Skip if element is no longer attached to the document
|
||||
// (may have been removed by a parent component's .empty() call)
|
||||
if (!document.contains($element[0])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any parent has Jqhtml_Component_Init class - skip nested components
|
||||
let parent = $element[0].parentElement;
|
||||
while (parent) {
|
||||
@@ -100,7 +106,7 @@ class Jqhtml_Integration {
|
||||
// Get the updated $element from
|
||||
let component = $element.component(component_name, component_args_filtered);
|
||||
|
||||
component.on('ready', function () {
|
||||
component.on('render', function () {
|
||||
// Recursively collect promises from nested components
|
||||
|
||||
// Getting the updated component here - if the tag name was not div, the element would have been recreated, so we need to get the element set on the component, not from our earlier selector
|
||||
|
||||
@@ -273,7 +273,7 @@ CALLING API METHODS
|
||||
|
||||
ROUTE RESOLUTION
|
||||
PHP:
|
||||
$url = Rsx::Route('User_Controller', 'show')->url(['id' => 5]);
|
||||
$url = Rsx::Route('User_Controller', 'show', ['id' => 5]);
|
||||
// Returns: "/users/5"
|
||||
|
||||
if (Rsx::Route('User_Controller')->is_current()) {
|
||||
|
||||
582
app/RSpade/man/forms_and_widgets.txt
Executable file
582
app/RSpade/man/forms_and_widgets.txt
Executable file
@@ -0,0 +1,582 @@
|
||||
FORMS_AND_WIDGETS(3) RSX Framework Manual FORMS_AND_WIDGETS(3)
|
||||
|
||||
NAME
|
||||
Forms and Widgets - RSX form system with reusable widget components
|
||||
|
||||
SYNOPSIS
|
||||
// Blade form markup
|
||||
<Rsx_Form $data="{{ json_encode($form_data) }}"
|
||||
$action="{{ Rsx::Route('Controller', 'save') }}">
|
||||
|
||||
<Form_Field $name="email" $label="Email Address" $required=true>
|
||||
<Text_Input $type="email" $placeholder="user@example.com" />
|
||||
</Form_Field>
|
||||
|
||||
<Form_Field $name="bio" $label="Biography">
|
||||
<Text_Input $type="textarea" $rows=5 />
|
||||
</Form_Field>
|
||||
|
||||
<button type="button" id="save-btn">Save</button>
|
||||
</Rsx_Form>
|
||||
|
||||
// JavaScript - wire save button to form
|
||||
$('#save-btn').on('click', function() {
|
||||
const $form = $('.Rsx_Form').first();
|
||||
$form.component().submit();
|
||||
});
|
||||
|
||||
DESCRIPTION
|
||||
The RSX form system provides a clean separation between form structure
|
||||
(Rsx_Form), field layout (Form_Field), and input widgets (Text_Input,
|
||||
Select_Input, etc). This architecture enables:
|
||||
|
||||
- Reusable widgets across all forms
|
||||
- Consistent validation error display
|
||||
- Automatic value collection and population
|
||||
- Test data generation via seeders
|
||||
- Read-only/disabled states
|
||||
- Custom field layouts without modifying widget code
|
||||
|
||||
Key Components:
|
||||
- Rsx_Form: Container managing form submission and validation
|
||||
- Form_Field: Layout wrapper providing labels, help text, error display
|
||||
- Widgets: Reusable input components (Text_Input, Select_Input, etc)
|
||||
|
||||
RSX_FORM COMPONENT
|
||||
|
||||
The Rsx_Form component manages form data flow, submission, and validation.
|
||||
|
||||
Required Attributes:
|
||||
$action - Controller method reference for form submission
|
||||
Example: Frontend_Clients_Controller.save
|
||||
|
||||
Optional Attributes:
|
||||
$data - JSON-encoded object with initial form values
|
||||
Used for edit mode to populate fields
|
||||
Example: $data="{{ json_encode($client_data) }}"
|
||||
|
||||
Methods:
|
||||
vals() - Get all form values as object
|
||||
vals(values) - Set all form values from object
|
||||
submit() - Submit form to $action endpoint
|
||||
seed() - Fill all fields with test data (debug mode only)
|
||||
|
||||
Form Discovery:
|
||||
Rsx_Form automatically discovers all widgets using shallowFind('.Widget')
|
||||
and collects values based on their data-name attributes. No registration
|
||||
or manual wiring required.
|
||||
|
||||
Example - Basic Form:
|
||||
|
||||
<Rsx_Form $action="{{ Rsx::Route('Users_Controller', 'save') }}">
|
||||
<Form_Field $name="first_name" $label="First Name">
|
||||
<Text_Input />
|
||||
</Form_Field>
|
||||
|
||||
<Form_Field $name="last_name" $label="Last Name">
|
||||
<Text_Input />
|
||||
</Form_Field>
|
||||
|
||||
<button type="button" id="save-btn">Save</button>
|
||||
</Rsx_Form>
|
||||
|
||||
Example - Edit Mode with Initial Data:
|
||||
|
||||
@php
|
||||
$form_data = [
|
||||
'first_name' => $user->first_name,
|
||||
'last_name' => $user->last_name,
|
||||
'email' => $user->email,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<Rsx_Form $data="{{ json_encode($form_data) }}"
|
||||
$action="{{ Rsx::Route('Users_Controller', 'save') }}">
|
||||
<!-- Fields automatically populated from $data -->
|
||||
</Rsx_Form>
|
||||
|
||||
FORM_FIELD WRAPPER
|
||||
|
||||
Form_Field provides consistent layout for labels, help text, and error
|
||||
display. It wraps a single widget and connects it to the form.
|
||||
|
||||
Required Attributes:
|
||||
$name - Field name for form serialization and error display
|
||||
|
||||
Optional Attributes:
|
||||
$label - Label text displayed above field
|
||||
$required - Boolean, adds red asterisk to label
|
||||
$help - Help text displayed below field
|
||||
|
||||
Responsibilities:
|
||||
- Display label with optional required indicator
|
||||
- Set data-name attribute on child widget
|
||||
- Display validation errors returned from server
|
||||
- Provide consistent spacing and styling
|
||||
|
||||
Example - Basic Field:
|
||||
|
||||
<Form_Field $name="email" $label="Email Address">
|
||||
<Text_Input $type="email" />
|
||||
</Form_Field>
|
||||
|
||||
Example - Required Field with Help Text:
|
||||
|
||||
<Form_Field $name="password"
|
||||
$label="Password"
|
||||
$required=true
|
||||
$help="Must be at least 8 characters">
|
||||
<Text_Input $type="password" />
|
||||
</Form_Field>
|
||||
|
||||
Example - Field with HTML in Label:
|
||||
|
||||
<Form_Field $name="twitter" $label="<i class='bi bi-twitter'></i> Twitter">
|
||||
<Text_Input $prefix="@" />
|
||||
</Form_Field>
|
||||
|
||||
Custom Layout:
|
||||
Form_Field can be extended or replaced with custom jqhtml to change
|
||||
field layout. The only requirement is that the child widget must
|
||||
have the data-name attribute set to the field name.
|
||||
|
||||
Example - Horizontal Layout:
|
||||
|
||||
<Define:Form_Field_Horizontal extends="Form_Field">
|
||||
<div class="row mb-3">
|
||||
<label class="col-md-3 col-form-label">
|
||||
<%!= this.args.label %>
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<%= content() %>
|
||||
<% if (this.has_error()) { %>
|
||||
<div class="invalid-feedback d-block">
|
||||
<%= this.get_error() %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</Define:Form_Field_Horizontal>
|
||||
|
||||
WIDGET INTERFACE
|
||||
|
||||
All form widgets must implement the standard widget interface:
|
||||
|
||||
Required:
|
||||
- CSS class "Widget" on root element
|
||||
- val() method for getting current value
|
||||
- val(value) method for setting value
|
||||
|
||||
Optional:
|
||||
- seed() method for generating test data
|
||||
- Support for $disabled attribute
|
||||
|
||||
Widget Responsibilities:
|
||||
|
||||
1. Value Management
|
||||
Widgets must implement getter/setter via val() method:
|
||||
|
||||
val() {
|
||||
// Getter - return current value
|
||||
if (arguments.length === 0) {
|
||||
return this.$id('input').val();
|
||||
}
|
||||
// Setter - update value
|
||||
else {
|
||||
this.data.value = value || '';
|
||||
this.$id('input').val(this.data.value);
|
||||
}
|
||||
}
|
||||
|
||||
2. Disabled State
|
||||
Widgets should respect $disabled attribute:
|
||||
- Render with disabled HTML attribute
|
||||
- Display grayed-out appearance
|
||||
- Still return value via val() getter
|
||||
- Do not submit in HTML form (handled by browser)
|
||||
|
||||
3. Test Data (Optional)
|
||||
Widgets may implement seed() for debug mode:
|
||||
|
||||
async seed() {
|
||||
if (this.args.seeder) {
|
||||
// Generate test data
|
||||
this.val('Test Value');
|
||||
}
|
||||
}
|
||||
|
||||
BUILT-IN WIDGETS
|
||||
|
||||
Text_Input
|
||||
Basic text input supporting multiple types and textarea.
|
||||
|
||||
Attributes:
|
||||
$type - Input type (text, email, url, tel, number, textarea)
|
||||
$rows - Number of rows for textarea (default: 3)
|
||||
$placeholder - Placeholder text
|
||||
$prefix - Text to prepend (creates input-group)
|
||||
$suffix - Text to append (creates input-group)
|
||||
$min - Minimum value for number inputs
|
||||
$max - Maximum value for number inputs
|
||||
$maxlength - Maximum length for text inputs
|
||||
$disabled - Disable input (grayed out, still returns value)
|
||||
$seeder - Seeder function name for test data
|
||||
|
||||
Examples:
|
||||
|
||||
<Text_Input $type="email" $placeholder="user@example.com" />
|
||||
|
||||
<Text_Input $type="textarea" $rows=5 $placeholder="Enter bio..." />
|
||||
|
||||
<Text_Input $type="number" $min=0 $max=100 />
|
||||
|
||||
<Text_Input $prefix="@" $placeholder="username" />
|
||||
|
||||
<Text_Input $type="url" $disabled=true />
|
||||
|
||||
Select_Input
|
||||
Dropdown select with options.
|
||||
|
||||
Attributes:
|
||||
$options - Array of options (see below)
|
||||
$placeholder - Placeholder option text
|
||||
$disabled - Disable select (grayed out, still returns value)
|
||||
$seeder - Seeder function name for test data
|
||||
|
||||
Options Format:
|
||||
Simple array: ['Option 1', 'Option 2', 'Option 3']
|
||||
|
||||
Object array: [
|
||||
{value: 'opt1', label: 'Option 1'},
|
||||
{value: 'opt2', label: 'Option 2'}
|
||||
]
|
||||
|
||||
From Blade: $options="{{ json_encode($options_array) }}"
|
||||
|
||||
Examples:
|
||||
|
||||
@php
|
||||
$industries = ['Technology', 'Finance', 'Healthcare'];
|
||||
@endphp
|
||||
<Select_Input $options="{{ json_encode($industries) }}"
|
||||
$placeholder="Select Industry..." />
|
||||
|
||||
@php
|
||||
$sizes = [
|
||||
['value' => 'sm', 'label' => 'Small (1-10)'],
|
||||
['value' => 'md', 'label' => 'Medium (11-50)'],
|
||||
['value' => 'lg', 'label' => 'Large (50+)'],
|
||||
];
|
||||
@endphp
|
||||
<Select_Input $options="{{ json_encode($sizes) }}" />
|
||||
|
||||
Checkbox_Input
|
||||
Checkbox with optional label.
|
||||
|
||||
Attributes:
|
||||
$label - Label text displayed next to checkbox
|
||||
$checked_value - Value when checked (default: "1")
|
||||
$unchecked_value - Value when unchecked (default: "0")
|
||||
$disabled - Disable checkbox (grayed out, still returns value)
|
||||
|
||||
Examples:
|
||||
|
||||
<Checkbox_Input $label="Subscribe to newsletter" />
|
||||
|
||||
<Checkbox_Input $label="I agree to terms"
|
||||
$checked_value="yes"
|
||||
$unchecked_value="no" />
|
||||
|
||||
Wysiwyg_Input
|
||||
Rich text editor using Quill.
|
||||
|
||||
Attributes:
|
||||
$placeholder - Placeholder text
|
||||
$disabled - Disable editor (not yet implemented)
|
||||
$seeder - Seeder function name for test data
|
||||
|
||||
Example:
|
||||
|
||||
<Wysiwyg_Input $placeholder="Enter description..." />
|
||||
|
||||
SEEDING TEST DATA
|
||||
|
||||
The seed system generates realistic test data for forms during development.
|
||||
Enabled only when window.rsxapp.debug is true.
|
||||
|
||||
Seed Button:
|
||||
Rsx_Form automatically displays a "Fill Test Data" button in debug mode.
|
||||
Clicking this button calls seed() on all widgets.
|
||||
|
||||
Widget Seeding:
|
||||
Widgets implement seed() to generate appropriate test data:
|
||||
|
||||
async seed() {
|
||||
if (this.args.seeder) {
|
||||
// TODO: Implement Rsx_Random_Values endpoint
|
||||
let value = 'Test ' + (this.args.seeder || 'Value');
|
||||
this.val(value);
|
||||
}
|
||||
}
|
||||
|
||||
Seeder Names:
|
||||
Specify seeder via $seeder attribute:
|
||||
|
||||
<Text_Input $seeder="company_name" />
|
||||
<Text_Input $seeder="email" />
|
||||
<Text_Input $seeder="phone" />
|
||||
|
||||
Future Implementation:
|
||||
Planned Rsx_Random_Values endpoint will provide:
|
||||
- company_name() - Random company names
|
||||
- email() - Random email addresses
|
||||
- phone() - Random phone numbers
|
||||
- first_name() - Random first names
|
||||
- last_name() - Random last names
|
||||
- address() - Random street addresses
|
||||
- city() - Random city names
|
||||
|
||||
DISABLED STATE
|
||||
|
||||
Disabled widgets display as read-only but still participate in form
|
||||
value collection.
|
||||
|
||||
Behavior:
|
||||
- Widget displays grayed-out appearance
|
||||
- User cannot interact with widget
|
||||
- val() getter still returns current value
|
||||
- Value included in form submission via Ajax
|
||||
- HTML disabled attribute prevents browser form submission
|
||||
|
||||
Example - Disable Individual Fields:
|
||||
|
||||
<Form_Field $name="email" $label="Email (Cannot Edit)">
|
||||
<Text_Input $type="email" $disabled=true />
|
||||
</Form_Field>
|
||||
|
||||
Example - Conditional Disable:
|
||||
|
||||
<Form_Field $name="status" $label="Status">
|
||||
<Select_Input $options="{{ json_encode($statuses) }}"
|
||||
$disabled="{{ !$can_edit_status }}" />
|
||||
</Form_Field>
|
||||
|
||||
Use Cases:
|
||||
- Display data that cannot be edited
|
||||
- Show calculated or system-managed values
|
||||
- Enforce permissions (some users see but cannot edit)
|
||||
- Multi-step forms (disable completed steps)
|
||||
|
||||
FORM SUBMISSION
|
||||
|
||||
Form submission uses Ajax to send data to controller methods.
|
||||
|
||||
JavaScript Submission:
|
||||
|
||||
$('#save-btn').on('click', function() {
|
||||
const $form = $('.Rsx_Form').first();
|
||||
const form_component = $form.component();
|
||||
form_component.submit();
|
||||
});
|
||||
|
||||
What Happens:
|
||||
1. Form calls vals() to collect all widget values
|
||||
2. Sends values to this.args.action via Ajax.call()
|
||||
3. Handles response:
|
||||
- Success: Redirect if response.redirect provided
|
||||
- Validation errors: Display errors on fields
|
||||
- General errors: Log to console
|
||||
|
||||
Controller Method:
|
||||
|
||||
#[Ajax_Endpoint]
|
||||
public static function save(Request $request, array $params = []) {
|
||||
// Validation
|
||||
$validated = $request->validate([
|
||||
'email' => 'required|email',
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
// Save data
|
||||
$user = User::create($validated);
|
||||
|
||||
// Return response
|
||||
return [
|
||||
'success' => true,
|
||||
'redirect' => Rsx::Route('Users_Controller', 'view', $user->id),
|
||||
];
|
||||
}
|
||||
|
||||
Validation Errors:
|
||||
|
||||
When validation fails, return errors in format:
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'errors' => [
|
||||
'email' => 'The email field is required.',
|
||||
'name' => 'The name field is required.',
|
||||
],
|
||||
];
|
||||
|
||||
Form automatically displays errors below each field.
|
||||
|
||||
MULTI-COLUMN LAYOUTS
|
||||
|
||||
Use Bootstrap grid for multi-column field layouts:
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<Form_Field $name="first_name" $label="First Name">
|
||||
<Text_Input />
|
||||
</Form_Field>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<Form_Field $name="last_name" $label="Last Name">
|
||||
<Text_Input />
|
||||
</Form_Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<Form_Field $name="city" $label="City">
|
||||
<Text_Input />
|
||||
</Form_Field>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<Form_Field $name="state" $label="State">
|
||||
<Text_Input $maxlength=2 />
|
||||
</Form_Field>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<Form_Field $name="zip" $label="ZIP Code">
|
||||
<Text_Input />
|
||||
</Form_Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
CREATING CUSTOM WIDGETS
|
||||
|
||||
Create custom widgets by implementing the widget interface.
|
||||
|
||||
Example - Rating Widget:
|
||||
|
||||
File: rating_input.jqhtml
|
||||
|
||||
<Define:Rating_Input class="Widget">
|
||||
<div class="rating">
|
||||
<% for (let i = 1; i <= 5; i++) { %>
|
||||
<i $id="star_<%= i %>"
|
||||
class="bi bi-star<%= this.data.value >= i ? '-fill' : '' %>"
|
||||
data-rating="<%= i %>"></i>
|
||||
<% } %>
|
||||
</div>
|
||||
</Define:Rating_Input>
|
||||
|
||||
File: rating_input.js
|
||||
|
||||
class Rating_Input extends Form_Input_Abstract {
|
||||
on_create() {
|
||||
this.data.value = 0;
|
||||
}
|
||||
|
||||
on_ready() {
|
||||
const that = this;
|
||||
this.$.find('[data-rating]').on('click', function() {
|
||||
that.val($(this).data('rating'));
|
||||
});
|
||||
}
|
||||
|
||||
val(value) {
|
||||
if (arguments.length === 0) {
|
||||
return this.data.value;
|
||||
} else {
|
||||
this.data.value = value || 0;
|
||||
// Update star display
|
||||
this.$.find('[data-rating]').each(function() {
|
||||
const rating = $(this).data('rating');
|
||||
$(this).toggleClass('bi-star-fill', rating <= value);
|
||||
$(this).toggleClass('bi-star', rating > value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async seed() {
|
||||
this.val(Math.floor(Math.random() * 5) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
Usage:
|
||||
|
||||
<Form_Field $name="satisfaction" $label="Rate Your Experience">
|
||||
<Rating_Input />
|
||||
</Form_Field>
|
||||
|
||||
EXAMPLES
|
||||
|
||||
Complete Form Example:
|
||||
|
||||
@php
|
||||
$form_data = isset($client) ? [
|
||||
'name' => $client->name,
|
||||
'email' => $client->email,
|
||||
'industry' => $client->industry,
|
||||
'active' => $client->active,
|
||||
] : [];
|
||||
|
||||
$industries = ['Technology', 'Finance', 'Healthcare'];
|
||||
@endphp
|
||||
|
||||
<Rsx_Form $data="{{ json_encode($form_data) }}"
|
||||
$action="{{ Rsx::Route('Clients_Controller', 'save') }}">
|
||||
|
||||
@if (isset($client))
|
||||
<input type="hidden" name="id" value="{{ $client->id }}">
|
||||
@endif
|
||||
|
||||
<Form_Field $name="name" $label="Company Name" $required=true>
|
||||
<Text_Input $seeder="company_name" />
|
||||
</Form_Field>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<Form_Field $name="email" $label="Email">
|
||||
<Text_Input $type="email" $seeder="email" />
|
||||
</Form_Field>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<Form_Field $name="phone" $label="Phone">
|
||||
<Text_Input $type="tel" $seeder="phone" />
|
||||
</Form_Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form_Field $name="industry" $label="Industry">
|
||||
<Select_Input $options="{{ json_encode($industries) }}"
|
||||
$placeholder="Select Industry..." />
|
||||
</Form_Field>
|
||||
|
||||
<Form_Field $name="notes" $label="Notes">
|
||||
<Text_Input $type="textarea" $rows=5 />
|
||||
</Form_Field>
|
||||
|
||||
<Form_Field $name="active" $label=" ">
|
||||
<Checkbox_Input $label="Active Client" />
|
||||
</Form_Field>
|
||||
|
||||
<button type="button" class="btn btn-primary" id="save-btn">
|
||||
Save Client
|
||||
</button>
|
||||
</Rsx_Form>
|
||||
|
||||
<script>
|
||||
$('#save-btn').on('click', function() {
|
||||
$('.Rsx_Form').first().component().submit();
|
||||
});
|
||||
</script>
|
||||
|
||||
SEE ALSO
|
||||
jqhtml(3), ajax(3), validation(3)
|
||||
|
||||
RSX Framework October 2025 FORMS_AND_WIDGETS(3)
|
||||
@@ -92,7 +92,7 @@ TEMPLATE SYNTAX
|
||||
|
||||
Template expressions:
|
||||
<%= expression %> - Escaped HTML output (safe, default)
|
||||
<%== expression %> - Unescaped raw output (pre-sanitized content only)
|
||||
<%!= expression %> - Unescaped raw output (pre-sanitized content only)
|
||||
<% statement; %> - JavaScript statements (loops, conditionals)
|
||||
|
||||
Attributes:
|
||||
@@ -103,6 +103,18 @@ TEMPLATE SYNTAX
|
||||
data-attr="value" - HTML data attributes
|
||||
class="my-class" - CSS classes (merged with component name)
|
||||
|
||||
Conditional Attributes (v2.2.162+):
|
||||
Place if statements directly in attribute context to conditionally apply
|
||||
attributes based on component arguments:
|
||||
|
||||
<input type="text"
|
||||
<% if (this.args.required) { %>required="required"<% } %>
|
||||
<% if (this.args.min !== undefined) { %>min="<%= this.args.min %>"<% } %> />
|
||||
|
||||
Works with static values, interpolated expressions, and multiple conditionals
|
||||
per element. Compiles to Object.assign() with ternary operators at build time.
|
||||
No nested conditionals or else clauses supported - use separate if statements.
|
||||
|
||||
DEFINE TAG CONFIGURATION
|
||||
The <Define> tag supports three types of attributes:
|
||||
|
||||
@@ -350,32 +362,35 @@ CONTROL FLOW AND LOOPS
|
||||
COMPONENT LIFECYCLE
|
||||
Five-stage deterministic lifecycle:
|
||||
|
||||
render → on_render → on_create → on_load → on_ready
|
||||
on_create → render → on_render → on_load → on_ready
|
||||
|
||||
1. render (automatic, top-down)
|
||||
1. on_create() (synchronous, runs BEFORE first render)
|
||||
- Setup default state BEFORE template executes
|
||||
- Initialize this.data properties so template can reference them
|
||||
- Must be synchronous (no async/await)
|
||||
- Perfect for abstract component base classes
|
||||
- Example: this.data.rows = this.data.rows || [];
|
||||
|
||||
2. render (automatic, top-down)
|
||||
- Template executes, DOM created
|
||||
- First render: this.data = {} (empty object)
|
||||
- First render: can safely reference properties set in on_create()
|
||||
- Parent completes before children
|
||||
- Not overridable
|
||||
|
||||
2. on_render() (top-down)
|
||||
3. on_render() (top-down)
|
||||
- Fires immediately after render, BEFORE children ready
|
||||
- Hide uninitialized elements
|
||||
- Set initial visual state
|
||||
- Prevents flash of uninitialized content
|
||||
- Parent completes before children
|
||||
|
||||
3. on_create() (bottom-up)
|
||||
- Quick synchronous setup
|
||||
- Set instance properties
|
||||
- Children complete before parent
|
||||
|
||||
4. on_load() (bottom-up, siblings in parallel)
|
||||
4. on_load() (bottom-up, siblings in parallel, CAN be async)
|
||||
- Load async data
|
||||
- ONLY modify this.data - NEVER DOM
|
||||
- NO child component access
|
||||
- Siblings at same depth execute in parallel
|
||||
- Children complete before parent
|
||||
- If this.data changes, triggers automatic re-render
|
||||
|
||||
5. on_ready() (bottom-up)
|
||||
- All children guaranteed ready
|
||||
@@ -385,22 +400,67 @@ COMPONENT LIFECYCLE
|
||||
- Children complete before parent
|
||||
|
||||
Depth-Ordered Execution:
|
||||
- First: on_create runs before anything else (setup state)
|
||||
- Top-down: render, on_render (parent before children)
|
||||
- Bottom-up: on_create, on_load, on_ready (children before parent)
|
||||
- Bottom-up: on_load, on_ready (children before parent)
|
||||
- Parallel: Siblings at same depth during on_load()
|
||||
|
||||
Critical rules:
|
||||
- Use on_create() to initialize default state before template runs
|
||||
- Never modify DOM in on_load() - only update this.data
|
||||
- on_load() runs in parallel for siblings (DOM unpredictable)
|
||||
- Data changes during load trigger single re-render
|
||||
- Data changes during load trigger automatic re-render
|
||||
- on_create(), on_render(), on_destroy() must be synchronous
|
||||
- on_load() and on_ready() can be async
|
||||
|
||||
ON_CREATE() USE CASES
|
||||
The on_create() method runs BEFORE the first render, making it perfect
|
||||
for initializing default state that templates will reference:
|
||||
|
||||
Example: Preventing "not iterable" errors
|
||||
class DataGrid_Abstract extends Jqhtml_Component {
|
||||
on_create() {
|
||||
// Initialize defaults BEFORE template renders
|
||||
this.data.rows = [];
|
||||
this.data.loading = true;
|
||||
this.data.is_empty = false;
|
||||
}
|
||||
|
||||
async on_ready() {
|
||||
// Later: load actual data
|
||||
await this.load_page(1);
|
||||
}
|
||||
}
|
||||
|
||||
Template can now safely iterate:
|
||||
<% for(let row of this.data.rows) { %>
|
||||
<%= content('row', row); %>
|
||||
<% } %>
|
||||
|
||||
Without on_create(), template would fail with "this.data.rows is not
|
||||
iterable" because this.data starts as {} before on_load() runs.
|
||||
|
||||
Abstract Component Pattern:
|
||||
Use on_create() in abstract base classes to ensure child templates
|
||||
have required properties initialized:
|
||||
|
||||
class Form_Abstract extends Jqhtml_Component {
|
||||
on_create() {
|
||||
// Set defaults that all forms need
|
||||
this.data.fields = this.data.fields || [];
|
||||
this.data.errors = this.data.errors || {};
|
||||
this.data.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
DOUBLE-RENDER PATTERN
|
||||
Components may render TWICE if on_load() modifies this.data:
|
||||
|
||||
1. First render: this.data = {} (empty)
|
||||
2. on_load() populates this.data
|
||||
3. Automatic re-render with populated data
|
||||
4. on_ready() fires after second render (only once)
|
||||
1. on_create() sets defaults: this.data.rows = []
|
||||
2. First render: template uses empty rows array
|
||||
3. on_load() populates this.data.rows with actual data
|
||||
4. Automatic re-render with populated data
|
||||
5. on_ready() fires after second render (only once)
|
||||
|
||||
Use for loading states:
|
||||
<Define:Product_List>
|
||||
|
||||
@@ -139,6 +139,76 @@ JQUERY HELPERS
|
||||
$(this).attr('target', '_blank');
|
||||
}
|
||||
|
||||
Component-Aware DOM Traversal
|
||||
|
||||
.shallowFind(selector)
|
||||
Finds child elements matching the selector that don't have another
|
||||
element of the same class as a parent between them and the component.
|
||||
|
||||
Useful for finding direct widget children in nested component trees
|
||||
without accidentally selecting widgets from nested child components.
|
||||
|
||||
Example:
|
||||
Component_A
|
||||
└── div
|
||||
└── Widget (found)
|
||||
└── span
|
||||
└── Widget (not found - has Widget parent)
|
||||
|
||||
$('.Component_A').shallowFind('.Widget')
|
||||
// Returns only the first Widget
|
||||
|
||||
Use case - Finding form widgets without selecting nested widgets:
|
||||
this.$.shallowFind('.Form_Field').each(function() {
|
||||
// Only processes fields directly in this form,
|
||||
// not fields in nested sub-forms
|
||||
});
|
||||
|
||||
.closest_sibling(selector)
|
||||
Searches for elements within progressively higher ancestor containers.
|
||||
Similar to .closest() but searches within ancestors instead of
|
||||
matching the ancestors themselves. Stops searching at <body> tag.
|
||||
|
||||
Useful for component-to-component communication when components need
|
||||
to find related sibling or cousin components without knowing the
|
||||
exact DOM structure.
|
||||
|
||||
Algorithm:
|
||||
1. Get current element's parent
|
||||
2. Search within parent using parent.find(selector)
|
||||
3. If found, return results
|
||||
4. If not found, move to parent's parent and repeat
|
||||
5. Stop when reaching <body> (searches body but not beyond)
|
||||
6. Return empty jQuery object if nothing found
|
||||
|
||||
Example DOM structure:
|
||||
<body>
|
||||
<div class="form-section">
|
||||
<div class="row">
|
||||
<div class="Country_Select_Input"></div>
|
||||
</div>
|
||||
<div class="another-row">
|
||||
<div class="State_Select_Input"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
$('.Country_Select_Input').closest_sibling('.State_Select_Input')
|
||||
// Finds State_Select_Input by searching up to form-section
|
||||
|
||||
Use case - Country selector updating state selector:
|
||||
on_ready() {
|
||||
if (this.tom_select) {
|
||||
this.tom_select.on('change', () => {
|
||||
const state_input = this.$el.closest_sibling('.State_Select_Input');
|
||||
if (state_input.exists()) {
|
||||
const widget = state_input.component();
|
||||
widget.set_country_code(this.val());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Form Validation
|
||||
|
||||
.checkValidity()
|
||||
|
||||
724
app/RSpade/man/modals.txt
Executable file
724
app/RSpade/man/modals.txt
Executable file
@@ -0,0 +1,724 @@
|
||||
================================================================================
|
||||
MODAL SYSTEM
|
||||
================================================================================
|
||||
|
||||
The Modal system provides a consistent, queue-managed interface for displaying
|
||||
dialogs throughout the application. All modals are managed by the static Modal
|
||||
class, which handles queuing, backdrop management, and user interactions.
|
||||
|
||||
================================================================================
|
||||
BASIC DIALOGS
|
||||
================================================================================
|
||||
|
||||
ALERT
|
||||
-----
|
||||
Show a simple notification message with an OK button.
|
||||
|
||||
await Modal.alert(message)
|
||||
await Modal.alert(title, message)
|
||||
await Modal.alert(title, message, button_label)
|
||||
|
||||
Examples:
|
||||
|
||||
await Modal.alert("File saved successfully");
|
||||
await Modal.alert("Success", "Your changes have been saved");
|
||||
await Modal.alert("Notice", "Operation complete", "Got it");
|
||||
|
||||
Parameters:
|
||||
message - Message text (if only 1 arg) or jQuery element
|
||||
title - Optional title (default: "Notice")
|
||||
button_label - Optional button text (default: "OK")
|
||||
|
||||
Returns: Promise<void>
|
||||
|
||||
|
||||
CONFIRM
|
||||
-------
|
||||
Show a confirmation dialog with Cancel and Confirm buttons.
|
||||
|
||||
let result = await Modal.confirm(message)
|
||||
let result = await Modal.confirm(title, message)
|
||||
let result = await Modal.confirm(title, message, confirm_label, cancel_label)
|
||||
|
||||
Examples:
|
||||
|
||||
if (await Modal.confirm("Delete this item?")) {
|
||||
// User confirmed
|
||||
}
|
||||
|
||||
if (await Modal.confirm("Delete Item", "This cannot be undone")) {
|
||||
// User confirmed
|
||||
}
|
||||
|
||||
Parameters:
|
||||
message - Message text (if 1-2 args) or jQuery element
|
||||
title - Optional title (default: "Confirm")
|
||||
confirm_label - Confirm button text (default: "Confirm")
|
||||
cancel_label - Cancel button text (default: "Cancel")
|
||||
|
||||
Returns: Promise<boolean> - true if confirmed, false if cancelled
|
||||
|
||||
|
||||
PROMPT
|
||||
------
|
||||
Show an input dialog for text entry.
|
||||
|
||||
let value = await Modal.prompt(message)
|
||||
let value = await Modal.prompt(title, message)
|
||||
let value = await Modal.prompt(title, message, default_value)
|
||||
let value = await Modal.prompt(title, message, default_value, multiline)
|
||||
let value = await Modal.prompt(title, message, default_value, multiline, error)
|
||||
|
||||
Examples:
|
||||
|
||||
let name = await Modal.prompt("What is your name?");
|
||||
if (name) {
|
||||
console.log("Hello, " + name);
|
||||
}
|
||||
|
||||
let email = await Modal.prompt("Email", "Enter your email:", "user@example.com");
|
||||
|
||||
let feedback = await Modal.prompt("Feedback", "Enter your feedback:", "", true);
|
||||
|
||||
Rich Content Example:
|
||||
|
||||
const $rich = $('<div>')
|
||||
.append($('<h5 style="color: #2c3e50;">').text('Registration'))
|
||||
.append($('<p>').html('Enter your <strong>full name</strong>'));
|
||||
|
||||
let name = await Modal.prompt($rich);
|
||||
|
||||
Validation Pattern (Lazy Re-prompting):
|
||||
|
||||
let email = '';
|
||||
let error = null;
|
||||
let valid = false;
|
||||
|
||||
while (!valid) {
|
||||
email = await Modal.prompt('Email', 'Enter email:', email, false, error);
|
||||
|
||||
if (email === false) return; // Cancelled
|
||||
|
||||
// Validate
|
||||
if (!email.includes('@')) {
|
||||
error = 'Please enter a valid email address';
|
||||
} else {
|
||||
valid = true;
|
||||
}
|
||||
}
|
||||
|
||||
// email is now valid
|
||||
|
||||
Parameters:
|
||||
message - Prompt message text or jQuery element
|
||||
title - Optional title (default: "Input")
|
||||
default_value - Default input value (default: "")
|
||||
multiline - Show textarea instead of input (default: false)
|
||||
error - Optional error message to display as validation feedback
|
||||
|
||||
Returns: Promise<string|false> - Input value or false if cancelled
|
||||
|
||||
Input Constraints:
|
||||
Standard input: 245px minimum width
|
||||
Textarea input: 315px minimum width
|
||||
Spacing: 36px between message and input field
|
||||
|
||||
Error Display:
|
||||
When error parameter is provided:
|
||||
- Input field marked with .is-invalid class (red border)
|
||||
- Error message displayed below input as .invalid-feedback
|
||||
- Input retains previously entered value
|
||||
- User can correct and resubmit
|
||||
|
||||
|
||||
ERROR
|
||||
-----
|
||||
Show an error message dialog.
|
||||
|
||||
await Modal.error(error)
|
||||
await Modal.error(error, title)
|
||||
|
||||
Examples:
|
||||
|
||||
await Modal.error("File not found");
|
||||
await Modal.error(exception, "Upload Failed");
|
||||
await Modal.error({message: "Invalid format"}, "Error");
|
||||
|
||||
Parameters:
|
||||
error - String, error object, or {message: string}
|
||||
title - Optional title (default: "Error")
|
||||
|
||||
Handles various error formats:
|
||||
- String: "Error message"
|
||||
- Object: {message: "Error"}
|
||||
- Laravel response: {responseJSON: {message: "Error"}}
|
||||
- Field errors: {field: "Error", field2: "Error2"}
|
||||
|
||||
Returns: Promise<void>
|
||||
|
||||
================================================================================
|
||||
CUSTOM MODALS
|
||||
================================================================================
|
||||
|
||||
SHOW
|
||||
----
|
||||
Display a custom modal with specified content and buttons.
|
||||
|
||||
let result = await Modal.show(options)
|
||||
|
||||
Options:
|
||||
title - Modal title (default: "Modal")
|
||||
body - String, HTML, or jQuery element
|
||||
buttons - Array of button definitions (see below)
|
||||
max_width - Maximum width in pixels (default: 800)
|
||||
closable - Allow ESC/backdrop/X to close (default: true)
|
||||
|
||||
Button Definition:
|
||||
{
|
||||
label: "Button Text",
|
||||
value: "return_value",
|
||||
class: "btn-primary", // Bootstrap button class
|
||||
default: true, // Make this the default button
|
||||
callback: async function() {
|
||||
// Optional: perform action and return result
|
||||
return custom_value;
|
||||
}
|
||||
}
|
||||
|
||||
Examples:
|
||||
|
||||
// Two button modal
|
||||
const result = await Modal.show({
|
||||
title: "Choose Action",
|
||||
body: "What would you like to do?",
|
||||
buttons: [
|
||||
{label: "Cancel", value: false, class: "btn-secondary"},
|
||||
{label: "Continue", value: true, class: "btn-primary", default: true}
|
||||
]
|
||||
});
|
||||
|
||||
// Three button modal
|
||||
const result = await Modal.show({
|
||||
title: "Save Changes",
|
||||
body: "How would you like to save?",
|
||||
buttons: [
|
||||
{label: "Cancel", value: false, class: "btn-secondary"},
|
||||
{label: "Save Draft", value: "draft", class: "btn-info"},
|
||||
{label: "Publish", value: "publish", class: "btn-success", default: true}
|
||||
]
|
||||
});
|
||||
|
||||
// jQuery content
|
||||
const $content = $('<div>')
|
||||
.append($('<p>').text('Custom content'))
|
||||
.append($('<ul>').append($('<li>').text('Item 1')));
|
||||
|
||||
await Modal.show({
|
||||
title: "Custom Content",
|
||||
body: $content,
|
||||
buttons: [{label: "Close", value: true, class: "btn-primary"}]
|
||||
});
|
||||
|
||||
Returns: Promise<any> - Value from clicked button (false if cancelled)
|
||||
|
||||
================================================================================
|
||||
FORM MODALS
|
||||
================================================================================
|
||||
|
||||
FORM
|
||||
----
|
||||
Display a form component in a modal with validation support.
|
||||
|
||||
let result = await Modal.form(options)
|
||||
|
||||
Options:
|
||||
component - Component class name (string)
|
||||
component_args - Arguments to pass to component
|
||||
title - Modal title (default: "Form")
|
||||
max_width - Maximum width in pixels (default: 800)
|
||||
closable - Allow ESC/backdrop to close (default: true)
|
||||
submit_label - Submit button text (default: "Submit")
|
||||
cancel_label - Cancel button text (default: "Cancel")
|
||||
on_submit - Callback function (receives form component)
|
||||
|
||||
The on_submit callback pattern:
|
||||
- Receives the form component instance
|
||||
- Call form.vals() to get current values
|
||||
- Perform validation/submission
|
||||
- Return false to keep modal open (for errors)
|
||||
- Return data to close modal and resolve promise
|
||||
|
||||
Simple Example:
|
||||
|
||||
const result = await Modal.form({
|
||||
title: "New User",
|
||||
component: "User_Form",
|
||||
on_submit: async (form) => {
|
||||
const values = form.vals();
|
||||
|
||||
if (!values.name) {
|
||||
await Modal.alert("Name is required");
|
||||
return false; // Keep modal open
|
||||
}
|
||||
|
||||
await sleep(500); // Simulate save
|
||||
return values; // Close modal with data
|
||||
}
|
||||
});
|
||||
|
||||
if (result) {
|
||||
console.log("Saved:", result);
|
||||
}
|
||||
|
||||
Validation Example:
|
||||
|
||||
const result = await Modal.form({
|
||||
title: "Edit Profile",
|
||||
component: "Profile_Form",
|
||||
component_args: {data: user_data},
|
||||
submit_label: "Update",
|
||||
on_submit: async (form) => {
|
||||
const values = form.vals();
|
||||
|
||||
// Server-side validation
|
||||
const response = await User_Controller.update_profile(values);
|
||||
|
||||
if (response.errors) {
|
||||
// Show errors and keep modal open
|
||||
Form_Utils.apply_form_errors(form.$, response.errors);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Success - close and return
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
|
||||
Creating Form Components:
|
||||
|
||||
Your form component must:
|
||||
- Extend Jqhtml_Component
|
||||
- Implement vals() method for getting/setting values
|
||||
- Use standard form HTML with name attributes
|
||||
- Include error container: <div $id="error_container"></div>
|
||||
|
||||
Example form component (my_form.jqhtml):
|
||||
|
||||
<Define:My_Form tag="div">
|
||||
<div $id="error_container"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" $id="name_input" name="name">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" $id="email_input" name="email">
|
||||
</div>
|
||||
</Define:My_Form>
|
||||
|
||||
Example form component class (my_form.js):
|
||||
|
||||
class My_Form extends Jqhtml_Component {
|
||||
on_create() {
|
||||
this.data.values = this.args.data || {};
|
||||
}
|
||||
|
||||
on_ready() {
|
||||
if (this.data.values) {
|
||||
this.vals(this.data.values);
|
||||
}
|
||||
}
|
||||
|
||||
vals(values) {
|
||||
if (values) {
|
||||
// Setter
|
||||
this.$id('name_input').val(values.name || '');
|
||||
this.$id('email_input').val(values.email || '');
|
||||
return null;
|
||||
} else {
|
||||
// Getter
|
||||
return {
|
||||
name: this.$id('name_input').val(),
|
||||
email: this.$id('email_input').val()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Validation Error Handling:
|
||||
|
||||
Form_Utils.apply_form_errors() automatically handles:
|
||||
- Field-specific errors (matched by name attribute)
|
||||
- General error messages
|
||||
- Multiple error formats (string, array, object)
|
||||
- Animated error display
|
||||
- Bootstrap 5 validation classes
|
||||
|
||||
Error format examples:
|
||||
|
||||
// Field errors
|
||||
{
|
||||
name: "Name is required",
|
||||
email: "Invalid email format"
|
||||
}
|
||||
|
||||
// General errors
|
||||
"An error occurred"
|
||||
|
||||
// Array of errors
|
||||
["Error 1", "Error 2"]
|
||||
|
||||
// Laravel format
|
||||
{
|
||||
name: ["Name is required", "Name too short"],
|
||||
email: ["Invalid format"]
|
||||
}
|
||||
|
||||
Returns: Promise<Object|false> - Form data or false if cancelled
|
||||
|
||||
================================================================================
|
||||
SPECIAL MODALS
|
||||
================================================================================
|
||||
|
||||
UNCLOSABLE
|
||||
----------
|
||||
Display a modal that cannot be closed by user (no ESC, backdrop, or X button).
|
||||
Must be closed programmatically.
|
||||
|
||||
Modal.unclosable(message)
|
||||
Modal.unclosable(title, message)
|
||||
|
||||
Examples:
|
||||
|
||||
Modal.unclosable("Processing", "Please wait...");
|
||||
|
||||
setTimeout(() => {
|
||||
Modal.close();
|
||||
}, 3000);
|
||||
|
||||
Parameters:
|
||||
message - Message text
|
||||
title - Optional title (default: "Please Wait")
|
||||
|
||||
Returns: void (does not wait for close)
|
||||
|
||||
Note: Call Modal.close() to dismiss the modal programmatically.
|
||||
|
||||
================================================================================
|
||||
MODAL STATE MANAGEMENT
|
||||
================================================================================
|
||||
|
||||
IS_OPEN
|
||||
-------
|
||||
Check if a modal is currently displayed.
|
||||
|
||||
if (Modal.is_open()) {
|
||||
console.log("Modal is open");
|
||||
}
|
||||
|
||||
Returns: boolean
|
||||
|
||||
GET_CURRENT
|
||||
-----------
|
||||
Get the currently displayed modal instance.
|
||||
|
||||
const modal = Modal.get_current();
|
||||
if (modal) {
|
||||
console.log("Modal instance exists");
|
||||
}
|
||||
|
||||
Returns: Rsx_Modal instance or null
|
||||
|
||||
CLOSE
|
||||
-----
|
||||
Programmatically close the current modal.
|
||||
|
||||
await Modal.close();
|
||||
|
||||
Typically used with unclosable modals or to force-close from external code.
|
||||
|
||||
Returns: Promise<void>
|
||||
|
||||
APPLY_ERRORS
|
||||
------------
|
||||
Apply validation errors to the current modal (if it contains a form).
|
||||
|
||||
Modal.apply_errors({
|
||||
field1: "Error message",
|
||||
field2: "Another error"
|
||||
});
|
||||
|
||||
This is a convenience method that calls Form_Utils.apply_form_errors() on
|
||||
the current modal's body element.
|
||||
|
||||
Parameters:
|
||||
errors - Error object (field: message pairs)
|
||||
|
||||
Returns: void
|
||||
|
||||
================================================================================
|
||||
MODAL QUEUING
|
||||
================================================================================
|
||||
|
||||
The Modal system automatically queues multiple simultaneous modal requests
|
||||
and displays them sequentially:
|
||||
|
||||
// All three modals are queued and shown one after another
|
||||
const p1 = Modal.alert("First");
|
||||
const p2 = Modal.alert("Second");
|
||||
const p3 = Modal.alert("Third");
|
||||
|
||||
await Promise.all([p1, p2, p3]);
|
||||
|
||||
Queuing Behavior:
|
||||
- Single shared backdrop persists across queued modals
|
||||
- 500ms delay between modals (backdrop stays visible)
|
||||
- Backdrop fades in at start of queue
|
||||
- Backdrop fades out when queue is empty
|
||||
- Each modal appears instantly (no fade animation)
|
||||
|
||||
Current Limitations:
|
||||
- All modals treated equally (no priority levels)
|
||||
- No concept of "modal sessions" or grouped interactions
|
||||
- FIFO queue order (first requested, first shown)
|
||||
|
||||
Future Considerations:
|
||||
When implementing real-time notifications or background events, you may
|
||||
need to distinguish between:
|
||||
- User-initiated modal sequences (conversational flow)
|
||||
- Background notifications (should wait for user flow to complete)
|
||||
|
||||
Planned features:
|
||||
- Priority levels for different modal types
|
||||
- Modal sessions to group related interactions
|
||||
- External event blocking during active user sessions
|
||||
|
||||
================================================================================
|
||||
MODAL SIZING
|
||||
================================================================================
|
||||
|
||||
Responsive Sizing:
|
||||
- Desktop: 60% viewport width preferred, max 80%
|
||||
- Mobile: 90% viewport width
|
||||
- Minimum width: 400px desktop, 280px mobile
|
||||
- Minimum height: 260px
|
||||
|
||||
Maximum Width:
|
||||
- Default: 800px
|
||||
- Configurable via max_width option
|
||||
- Examples: 500px (forms), 1200px (data tables)
|
||||
|
||||
Scrolling:
|
||||
- Triggers when content exceeds 80% viewport height
|
||||
- Modal body becomes scrollable
|
||||
- Header and footer remain fixed
|
||||
|
||||
Manual Control:
|
||||
|
||||
Modal.show({
|
||||
max_width: 1200, // Wide modal for tables
|
||||
body: content
|
||||
});
|
||||
|
||||
================================================================================
|
||||
STYLING AND UX
|
||||
================================================================================
|
||||
|
||||
Modal Appearance:
|
||||
- Centered vertically and horizontally
|
||||
- Gray header background (#f8f9fa)
|
||||
- Smaller title font (1rem)
|
||||
- Shorter header padding (0.75rem)
|
||||
- Subtle drop shadow (0 4px 12px rgba(0,0,0,0.15))
|
||||
- Buttons centered horizontally as a group
|
||||
- Modal body text centered (for simple dialogs)
|
||||
|
||||
Animations:
|
||||
- Modal appears instantly (no fade)
|
||||
- Backdrop fades in/out over 250ms
|
||||
- Validation errors fade in over 300ms
|
||||
|
||||
Body Scroll Lock:
|
||||
- Page scrolling disabled when modal open
|
||||
- Scrollbar width calculated and compensated via padding
|
||||
- Prevents layout shift when scrollbar disappears
|
||||
- Original body state restored when modal closes
|
||||
- Managed at backdrop level (first modal locks, last unlocks)
|
||||
|
||||
Accessibility:
|
||||
- ESC key closes modal (if closable)
|
||||
- Backdrop click closes modal (if closable)
|
||||
- Focus management (input fields auto-focus)
|
||||
- Keyboard navigation support
|
||||
|
||||
================================================================================
|
||||
BEST PRACTICES
|
||||
================================================================================
|
||||
|
||||
1. Use Appropriate Dialog Type
|
||||
- alert() - Notifications, information
|
||||
- confirm() - Yes/no decisions
|
||||
- prompt() - Simple text input
|
||||
- form() - Complex forms with validation
|
||||
- show() - Custom requirements
|
||||
|
||||
2. Handle Cancellations
|
||||
Always check for false (cancelled) return values:
|
||||
|
||||
const result = await Modal.confirm("Delete?");
|
||||
if (result === false) {
|
||||
return; // User cancelled
|
||||
}
|
||||
|
||||
3. Validation Feedback
|
||||
Keep modal open for validation errors:
|
||||
|
||||
if (errors) {
|
||||
Form_Utils.apply_form_errors(form.$, errors);
|
||||
return false; // Keep open
|
||||
}
|
||||
|
||||
4. Avoid Nested Modals
|
||||
While technically possible, nested modals create poor UX.
|
||||
Close the first modal before showing a second:
|
||||
|
||||
await Modal.alert("Step 1");
|
||||
await Modal.alert("Step 2"); // Shows after first closes
|
||||
|
||||
5. Loading States
|
||||
For long operations, use unclosable modals:
|
||||
|
||||
Modal.unclosable("Saving", "Please wait...");
|
||||
await save_operation();
|
||||
await Modal.close();
|
||||
|
||||
6. Rich Content
|
||||
Use jQuery elements for formatted content:
|
||||
|
||||
const $content = $('<div>')
|
||||
.append($('<h5>').text('Title'))
|
||||
.append($('<p>').html('<strong>Bold</strong> text'));
|
||||
|
||||
await Modal.alert($content);
|
||||
|
||||
7. Form Component Design
|
||||
- Keep vals() method simple and synchronous
|
||||
- Put async logic in on_submit callback
|
||||
- Use standard HTML form structure
|
||||
- Include error_container div for validation
|
||||
- Match field name attributes to error keys
|
||||
|
||||
================================================================================
|
||||
COMMON PATTERNS
|
||||
================================================================================
|
||||
|
||||
Delete Confirmation:
|
||||
|
||||
const confirmed = await Modal.confirm(
|
||||
"Delete Item",
|
||||
"This action cannot be undone. Are you sure?",
|
||||
"Delete Forever",
|
||||
"Cancel"
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
await Item_Controller.delete(item_id);
|
||||
await Modal.alert("Item deleted successfully");
|
||||
}
|
||||
|
||||
Save with Validation:
|
||||
|
||||
const result = await Modal.form({
|
||||
title: "Edit Profile",
|
||||
component: "Profile_Form",
|
||||
component_args: {data: user},
|
||||
on_submit: async (form) => {
|
||||
const values = form.vals();
|
||||
const response = await User_Controller.save(values);
|
||||
|
||||
if (response.errors) {
|
||||
Form_Utils.apply_form_errors(form.$, response.errors);
|
||||
return false;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
|
||||
if (result) {
|
||||
await Modal.alert("Profile updated successfully");
|
||||
}
|
||||
|
||||
Multi-Step Process:
|
||||
|
||||
const name = await Modal.prompt("What is your name?");
|
||||
if (!name) return;
|
||||
|
||||
const email = await Modal.prompt("Enter your email:");
|
||||
if (!email) return;
|
||||
|
||||
const confirmed = await Modal.confirm(
|
||||
"Confirm Registration",
|
||||
`Register ${name} with ${email}?`
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
await register({name, email});
|
||||
}
|
||||
|
||||
Progressive Disclosure:
|
||||
|
||||
const result = await Modal.show({
|
||||
title: "Choose Action",
|
||||
body: "What would you like to do?",
|
||||
buttons: [
|
||||
{label: "View Details", value: "view"},
|
||||
{label: "Edit", value: "edit"},
|
||||
{label: "Delete", value: "delete", class: "btn-danger"}
|
||||
]
|
||||
});
|
||||
|
||||
if (result === "view") {
|
||||
// Show details modal
|
||||
} else if (result === "edit") {
|
||||
// Show edit form
|
||||
} else if (result === "delete") {
|
||||
// Confirm and delete
|
||||
}
|
||||
|
||||
================================================================================
|
||||
TROUBLESHOOTING
|
||||
================================================================================
|
||||
|
||||
Modal Won't Close
|
||||
- Check if callback returns false (intentionally keeping open)
|
||||
- Verify closable: true option is set
|
||||
- Check for JavaScript errors in callback
|
||||
- Use Modal.close() to force close
|
||||
|
||||
Validation Errors Not Showing
|
||||
- Ensure form has <div $id="error_container"></div>
|
||||
- Verify field name attributes match error keys
|
||||
- Check that fields are wrapped in .form-group containers
|
||||
- Use Form_Utils.apply_form_errors(form.$, errors)
|
||||
|
||||
Form Values Not Saving
|
||||
- Verify vals() method returns correct object
|
||||
- Check that on_submit returns data (not false)
|
||||
- Ensure callback doesn't throw unhandled errors
|
||||
- Test vals() method independently
|
||||
|
||||
Queue Not Working
|
||||
- All modals automatically queue
|
||||
- If backdrop flickers, check for multiple backdrop creation
|
||||
- Verify using Modal.* static methods (not creating instances)
|
||||
|
||||
Component Not Found
|
||||
- Ensure component class name is correct (case-sensitive)
|
||||
- Check that component files are in manifest
|
||||
- Verify component extends Jqhtml_Component
|
||||
- Component must be in /rsx/ directory tree
|
||||
|
||||
================================================================================
|
||||
@@ -12,7 +12,7 @@ DESCRIPTION
|
||||
|
||||
Key differences from Laravel:
|
||||
- Laravel: route('user.profile', $user) using named routes
|
||||
- RSX: Rsx::Route('User_Controller', 'profile')->url(['id' => $user->id])
|
||||
- RSX: Rsx::Route('User_Controller', 'profile', ['id' => $user->id])
|
||||
|
||||
Benefits:
|
||||
- No route name management required
|
||||
@@ -25,30 +25,24 @@ BASIC USAGE
|
||||
PHP Syntax:
|
||||
use App\RSpade\Core\Rsx;
|
||||
|
||||
// Create route proxy (action defaults to 'index')
|
||||
$route = Rsx::Route('Demo_Index_Controller');
|
||||
$route = Rsx::Route('Demo_Index_Controller', 'show');
|
||||
// Generate URLs (returns string directly)
|
||||
$url = Rsx::Route('Demo_Index_Controller'); // /demo
|
||||
$url = Rsx::Route('Demo_Index_Controller', 'show'); // /demo/show
|
||||
$url = Rsx::Route('Demo_Index_Controller', 'show', ['id' => 123]); // /demo/123
|
||||
$url = Rsx::Route('Demo_Index_Controller', 'show', 123); // /demo/123 (shorthand)
|
||||
|
||||
// Generate URLs
|
||||
$url = $route->url(); // /demo
|
||||
$url = $route->url(['id' => 123]); // /demo/123 or /demo?id=123
|
||||
$absolute = $route->absolute_url(); // https://site.com/demo
|
||||
|
||||
// Navigate (redirect)
|
||||
$route->navigate(); // Sends Location header and exits
|
||||
// Use in redirects
|
||||
return redirect(Rsx::Route('Demo_Index_Controller'));
|
||||
|
||||
JavaScript Syntax:
|
||||
// Create route proxy (action defaults to 'index')
|
||||
const route = Rsx.Route('Demo_Index_Controller');
|
||||
const route = Rsx.Route('Demo_Index_Controller', 'show');
|
||||
// Generate URLs (returns string directly)
|
||||
const url = Rsx.Route('Demo_Index_Controller'); // /demo
|
||||
const url = Rsx.Route('Demo_Index_Controller', 'show'); // /demo/show
|
||||
const url = Rsx.Route('Demo_Index_Controller', 'show', {id: 123}); // /demo/123
|
||||
const url = Rsx.Route('Demo_Index_Controller', 'show', 123); // /demo/123 (shorthand)
|
||||
|
||||
// Generate URLs
|
||||
const url = route.url(); // /demo
|
||||
const url = route.url({id: 123}); // /demo/123 or /demo?id=123
|
||||
const absolute = route.absolute_url(); // https://site.com/demo
|
||||
|
||||
// Navigate
|
||||
route.navigate(); // Sets window.location.href
|
||||
// Use in navigation
|
||||
window.location.href = Rsx.Route('Demo_Index_Controller');
|
||||
|
||||
ROUTE PATTERNS
|
||||
Route Definition:
|
||||
@@ -149,15 +143,18 @@ ADVANCED PATTERNS
|
||||
Complex Parameter Examples:
|
||||
// Multiple parameters
|
||||
#[Route('/api/v1/users/:company/:division/:id')]
|
||||
$route->url(['company' => 'acme', 'division' => 'sales', 'id' => 123]);
|
||||
$url = Rsx::Route('Api_V1_Users_Controller', 'show',
|
||||
['company' => 'acme', 'division' => 'sales', 'id' => 123]);
|
||||
// Result: /api/v1/users/acme/sales/123
|
||||
|
||||
// Query parameters for extra values
|
||||
$route->url(['id' => 123, 'format' => 'json', 'include' => 'profile']);
|
||||
$url = Rsx::Route('Demo_Controller', 'show',
|
||||
['id' => 123, 'format' => 'json', 'include' => 'profile']);
|
||||
// Result: /demo/123?format=json&include=profile
|
||||
|
||||
// Complex objects as parameters
|
||||
$route->url(['filter' => ['status' => 'active', 'type' => 'user']]);
|
||||
$url = Rsx::Route('Demo_Controller', 'index',
|
||||
['filter' => ['status' => 'active', 'type' => 'user']]);
|
||||
// Result: /demo?filter[status]=active&filter[type]=user
|
||||
|
||||
Route Groups and Prefixes:
|
||||
@@ -194,8 +191,7 @@ JAVASCRIPT BUNDLE ROUTES
|
||||
Runtime Route Access:
|
||||
// Routes available after bundle loads
|
||||
if (typeof Rsx !== 'undefined') {
|
||||
const route = Rsx.Route('Demo_Index_Controller');
|
||||
const url = route.url();
|
||||
const url = Rsx.Route('Demo_Index_Controller');
|
||||
}
|
||||
|
||||
ERROR HANDLING
|
||||
@@ -218,7 +214,7 @@ DEBUGGING ROUTES
|
||||
php artisan rsx:routes # List all discovered routes
|
||||
|
||||
Test Route Generation:
|
||||
php artisan rsx:debug /demo --eval="Rsx.Route('Demo_Index_Controller').url()"
|
||||
php artisan rsx:debug /demo --eval="Rsx.Route('Demo_Index_Controller')"
|
||||
|
||||
Route Information:
|
||||
php artisan rsx:manifest:show # View route cache in manifest
|
||||
@@ -229,21 +225,21 @@ COMMON PATTERNS
|
||||
public static function handle_form(Request $request, array $params = [])
|
||||
{
|
||||
// Process form...
|
||||
Rsx::Route('Dashboard_Index_Controller')->navigate();
|
||||
return redirect(Rsx::Route('Dashboard_Index_Controller'));
|
||||
}
|
||||
|
||||
AJAX URL Generation:
|
||||
// Generate URLs for AJAX calls
|
||||
const apiUrl = Rsx.Route('Api_User_Controller', 'update').url({id: userId});
|
||||
const apiUrl = Rsx.Route('Api_User_Controller', 'update', {id: userId});
|
||||
fetch(apiUrl, {method: 'POST', body: formData});
|
||||
|
||||
Form Action URLs:
|
||||
// Generate form action URLs
|
||||
<form action="<?= Rsx::Route('User_Profile_Controller', 'update')->url() ?>" method="POST">
|
||||
<form action="<?= Rsx::Route('User_Profile_Controller', 'update') ?>" method="POST">
|
||||
|
||||
Link Generation:
|
||||
// Generate navigation links
|
||||
<a href="<?= Rsx::Route('Dashboard_Index_Controller')->url() ?>">Dashboard</a>
|
||||
<a href="<?= Rsx::Route('Dashboard_Index_Controller') ?>">Dashboard</a>
|
||||
|
||||
TROUBLESHOOTING
|
||||
Route Not Found:
|
||||
|
||||
212
app/RSpade/man/tasks.txt
Executable file
212
app/RSpade/man/tasks.txt
Executable file
@@ -0,0 +1,212 @@
|
||||
RSX TASK SYSTEM
|
||||
================
|
||||
|
||||
The RSX Task system provides a structured way to define and execute background
|
||||
tasks, similar to how Controllers handle HTTP requests but for CLI/background
|
||||
execution.
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Tasks are static methods in Service classes that can be executed from:
|
||||
- Command line (via `rsx:task:run`)
|
||||
- Internal PHP code (via `Task::internal()`)
|
||||
- Future: Queue systems, cron scheduling, progress tracking
|
||||
|
||||
## CREATING SERVICES
|
||||
|
||||
Services are classes that extend `Rsx_Service_Abstract` and live in:
|
||||
/rsx/services/
|
||||
|
||||
Example service structure:
|
||||
|
||||
<?php
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
|
||||
class My_Service extends Rsx_Service_Abstract
|
||||
{
|
||||
#[Task('Description of what this task does')]
|
||||
public static function my_task(array $params = [])
|
||||
{
|
||||
// Task implementation
|
||||
return [
|
||||
'message' => 'Task completed',
|
||||
'data' => 'result data'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
## TASK METHODS
|
||||
|
||||
Tasks must:
|
||||
- Be public static methods
|
||||
- Have the #[Task('description')] attribute
|
||||
- Accept array $params = [] parameter
|
||||
- Return data (will be JSON-encoded for CLI output)
|
||||
|
||||
Task signature:
|
||||
public static function task_name(array $params = [])
|
||||
|
||||
## PARAMETER HANDLING
|
||||
|
||||
Services inherit parameter helpers from Rsx_Service_Abstract:
|
||||
|
||||
protected static function __param($params, $key, $default = null)
|
||||
protected static function __has_param($params, $key)
|
||||
|
||||
Example usage:
|
||||
$name = static::__param($params, 'name', 'Guest');
|
||||
if (static::__has_param($params, 'force')) {
|
||||
// ...
|
||||
}
|
||||
|
||||
## PRE-TASK HOOK
|
||||
|
||||
Override pre_task() to run validation/auth before any task executes:
|
||||
|
||||
public static function pre_task(array $params = [])
|
||||
{
|
||||
if (!RsxAuth::check()) {
|
||||
throw new \Exception("Authentication required");
|
||||
}
|
||||
return null; // Continue to task
|
||||
}
|
||||
|
||||
If pre_task() returns non-null, task execution halts and returns that value.
|
||||
|
||||
## LISTING TASKS
|
||||
|
||||
View all available tasks:
|
||||
php artisan rsx:task:list
|
||||
|
||||
Output shows services and their tasks with descriptions:
|
||||
Service_Test
|
||||
hello_world - Test task with no arguments
|
||||
greet - Test task with optional name parameter
|
||||
calculate - Test task with multiple parameters
|
||||
|
||||
## RUNNING TASKS
|
||||
|
||||
Execute a task from command line:
|
||||
php artisan rsx:task:run Service task_name
|
||||
|
||||
With parameters:
|
||||
php artisan rsx:task:run Service task_name --param=value
|
||||
php artisan rsx:task:run Service greet --name=John
|
||||
|
||||
Boolean flags:
|
||||
php artisan rsx:task:run Service task_name --force
|
||||
|
||||
JSON values (auto-parsed):
|
||||
php artisan rsx:task:run Service task_name --data='{"key":"value"}'
|
||||
|
||||
Debug mode (wrapped response):
|
||||
php artisan rsx:task:run Service task_name --debug
|
||||
|
||||
## OUTPUT MODES
|
||||
|
||||
Default mode - Raw JSON response (just the return value):
|
||||
{
|
||||
"message": "Task completed",
|
||||
"count": 42
|
||||
}
|
||||
|
||||
Debug mode - Wrapped response with success indicator:
|
||||
{
|
||||
"success": true,
|
||||
"result": {
|
||||
"message": "Task completed",
|
||||
"count": 42
|
||||
}
|
||||
}
|
||||
|
||||
## INTERNAL TASK CALLS
|
||||
|
||||
Call tasks from PHP code using Task::internal():
|
||||
|
||||
use App\RSpade\Core\Task\Task;
|
||||
|
||||
$result = Task::internal('Service_Name', 'task_name', [
|
||||
'param1' => 'value1',
|
||||
'param2' => 'value2'
|
||||
]);
|
||||
|
||||
This is useful for:
|
||||
- Composing complex tasks from simpler ones
|
||||
- Calling tasks from controllers
|
||||
- Background job processing
|
||||
|
||||
Example composition:
|
||||
#[Task('Run all seeders')]
|
||||
public static function seed_all(array $params = [])
|
||||
{
|
||||
$clients = Task::internal('Seeder_Service', 'seed_clients', $params);
|
||||
$contacts = Task::internal('Seeder_Service', 'seed_contacts', $params);
|
||||
|
||||
return [
|
||||
'clients' => $clients,
|
||||
'contacts' => $contacts
|
||||
];
|
||||
}
|
||||
|
||||
## ERROR HANDLING
|
||||
|
||||
All errors return JSON (never throws to stderr):
|
||||
{
|
||||
"success": false,
|
||||
"error": "Error message",
|
||||
"error_type": "Exception",
|
||||
"trace": "..."
|
||||
}
|
||||
|
||||
Exit codes:
|
||||
- 0: Success
|
||||
- 1: Error
|
||||
|
||||
## ATTRIBUTE CONFLICTS
|
||||
|
||||
A method cannot have multiple execution type attributes. These conflict:
|
||||
- #[Route] (HTTP routes)
|
||||
- #[Ajax_Endpoint] (Ajax endpoints)
|
||||
- #[Task] (CLI tasks)
|
||||
|
||||
The manifest build will fail if these are mixed on the same method.
|
||||
|
||||
## USE CASES
|
||||
|
||||
Tasks are ideal for:
|
||||
- Database seeders
|
||||
- Data migrations
|
||||
- Report generation
|
||||
- Batch processing
|
||||
- Maintenance operations
|
||||
- Background jobs
|
||||
- Scheduled operations
|
||||
|
||||
Example services:
|
||||
Seeder_Service - Database seeding
|
||||
Report_Service - Generate reports
|
||||
Cleanup_Service - Maintenance tasks
|
||||
Import_Service - Data imports
|
||||
Export_Service - Data exports
|
||||
|
||||
## FUTURE FEATURES (NOT YET IMPLEMENTED)
|
||||
|
||||
The Task system is designed to support future enhancements:
|
||||
- Queue integration (dispatch tasks to Redis/database queue)
|
||||
- Cron scheduling (#[Schedule] attribute)
|
||||
- Progress tracking (long-running tasks report progress)
|
||||
- Task history (log of all task executions)
|
||||
- Task dependencies (ensure X runs before Y)
|
||||
- Parallel execution (run multiple tasks concurrently)
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
See example services:
|
||||
/rsx/services/service_test.php - Basic task examples
|
||||
/rsx/services/seeder_service.php - Database seeding examples
|
||||
|
||||
Test tasks:
|
||||
php artisan rsx:task:list
|
||||
php artisan rsx:task:run Service_Test hello_world
|
||||
php artisan rsx:task:run Service_Test greet --name=Brian
|
||||
php artisan rsx:task:run Service_Test calculate --a=10 --b=5 --op=multiply
|
||||
@@ -88,7 +88,6 @@ class RspadeClassRefactorProvider {
|
||||
});
|
||||
if (!new_class_name) {
|
||||
this.output_channel.appendLine('Refactor cancelled by user');
|
||||
await vscode.commands.executeCommand('workbench.action.closePanel');
|
||||
return;
|
||||
}
|
||||
// Confirm refactoring
|
||||
@@ -96,7 +95,6 @@ class RspadeClassRefactorProvider {
|
||||
'This will rename the class across all usages in all files.', { modal: true }, 'Rename', 'Cancel');
|
||||
if (confirmation !== 'Rename') {
|
||||
this.output_channel.appendLine('Global rename cancelled by user');
|
||||
await vscode.commands.executeCommand('workbench.action.closePanel');
|
||||
return;
|
||||
}
|
||||
// Save all dirty files first
|
||||
@@ -132,25 +130,22 @@ class RspadeClassRefactorProvider {
|
||||
this.output_channel.appendLine('\n=== Refactor Complete ===');
|
||||
// Check if refactor was successful
|
||||
if (result.includes('=== Refactor Complete ===') || result.trim().length > 0) {
|
||||
// Wait 3.5 seconds total (2s + 1.5s), hide panel, then reload files and auto-rename
|
||||
// Wait for filesystem changes to propagate then reload files and auto-rename
|
||||
// Note: Panel is kept open so developer can see results and warnings
|
||||
setTimeout(async () => {
|
||||
await vscode.commands.executeCommand('workbench.action.closePanel');
|
||||
// Wait 500ms for filesystem changes to propagate (Samba/network issues)
|
||||
await this.reload_all_open_files();
|
||||
// Wait another 500ms then check if current file needs renaming
|
||||
setTimeout(async () => {
|
||||
await this.reload_all_open_files();
|
||||
// Wait another 500ms then check if current file needs renaming
|
||||
setTimeout(async () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (editor) {
|
||||
const file_path = editor.document.uri.fsPath;
|
||||
// Only auto-rename if file is in ./rsx
|
||||
if (file_path.includes('/rsx/') || file_path.includes('\\rsx\\')) {
|
||||
await this.auto_rename_provider.check_and_rename(editor.document);
|
||||
}
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (editor) {
|
||||
const file_path = editor.document.uri.fsPath;
|
||||
// Only auto-rename if file is in ./rsx
|
||||
if (file_path.includes('/rsx/') || file_path.includes('\\rsx\\')) {
|
||||
await this.auto_rename_provider.check_and_rename(editor.document);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 500);
|
||||
}, 3500);
|
||||
}, 500);
|
||||
vscode.window.showInformationMessage(`Successfully refactored ${class_info.class_name} to ${new_class_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,119 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CombinedSemanticTokensProvider = void 0;
|
||||
const vscode = __importStar(require("vscode"));
|
||||
const jqhtml_lifecycle_provider_1 = require("./jqhtml_lifecycle_provider");
|
||||
const comment_file_reference_provider_1 = require("./comment_file_reference_provider");
|
||||
const that_variable_provider_1 = require("./that_variable_provider");
|
||||
/**
|
||||
* Combined semantic tokens provider that merges tokens from multiple providers
|
||||
*
|
||||
* VS Code only allows one SemanticTokensLegend per language, so we need to
|
||||
* combine all our providers into one to avoid conflicts.
|
||||
*/
|
||||
class CombinedSemanticTokensProvider {
|
||||
constructor() {
|
||||
this.jqhtml_provider = new jqhtml_lifecycle_provider_1.JqhtmlLifecycleSemanticTokensProvider();
|
||||
this.file_ref_provider = new comment_file_reference_provider_1.CommentFileReferenceSemanticTokensProvider();
|
||||
this.that_provider = new that_variable_provider_1.ThatVariableSemanticTokensProvider();
|
||||
}
|
||||
async provideDocumentSemanticTokens(document) {
|
||||
// Get tokens from all providers
|
||||
const jqhtml_tokens = await this.jqhtml_provider.provideDocumentSemanticTokens(document);
|
||||
const file_ref_tokens = await this.file_ref_provider.provideDocumentSemanticTokens(document);
|
||||
const that_tokens = await this.that_provider.provideDocumentSemanticTokens(document);
|
||||
// Decode all tokens to absolute positions
|
||||
const decoded_tokens = [];
|
||||
// Decode JQHTML tokens (type 0 = conventionMethod, orange)
|
||||
this.decode_tokens(jqhtml_tokens.data, 0, decoded_tokens);
|
||||
// Decode file reference tokens (type 1 = class, teal)
|
||||
this.decode_tokens(file_ref_tokens.data, 1, decoded_tokens);
|
||||
// Decode 'that' tokens (type 2 = macro, dark blue #569CD6 like 'this')
|
||||
this.decode_tokens(that_tokens.data, 2, decoded_tokens);
|
||||
// Sort tokens by line, then by character
|
||||
decoded_tokens.sort((a, b) => {
|
||||
if (a.line !== b.line) {
|
||||
return a.line - b.line;
|
||||
}
|
||||
return a.char - b.char;
|
||||
});
|
||||
// Re-encode tokens with delta encoding
|
||||
const builder = new vscode.SemanticTokensBuilder();
|
||||
for (const token of decoded_tokens) {
|
||||
builder.push(token.line, token.char, token.length, token.type, token.modifiers);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
decode_tokens(data, new_type, output) {
|
||||
let current_line = 0;
|
||||
let current_char = 0;
|
||||
for (let i = 0; i < data.length; i += 5) {
|
||||
const delta_line = data[i];
|
||||
const delta_char = data[i + 1];
|
||||
const length = data[i + 2];
|
||||
const modifiers = data[i + 4];
|
||||
if (delta_line > 0) {
|
||||
current_line += delta_line;
|
||||
current_char = delta_char;
|
||||
}
|
||||
else {
|
||||
current_char += delta_char;
|
||||
}
|
||||
output.push({
|
||||
line: current_line,
|
||||
char: current_char,
|
||||
length: length,
|
||||
type: new_type,
|
||||
modifiers: modifiers
|
||||
});
|
||||
}
|
||||
}
|
||||
decode_tokens_with_modifier(data, new_type, new_modifier, output) {
|
||||
let current_line = 0;
|
||||
let current_char = 0;
|
||||
for (let i = 0; i < data.length; i += 5) {
|
||||
const delta_line = data[i];
|
||||
const delta_char = data[i + 1];
|
||||
const length = data[i + 2];
|
||||
if (delta_line > 0) {
|
||||
current_line += delta_line;
|
||||
current_char = delta_char;
|
||||
}
|
||||
else {
|
||||
current_char += delta_char;
|
||||
}
|
||||
output.push({
|
||||
line: current_line,
|
||||
char: current_char,
|
||||
length: length,
|
||||
type: new_type,
|
||||
modifiers: new_modifier
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.CombinedSemanticTokensProvider = CombinedSemanticTokensProvider;
|
||||
//# sourceMappingURL=combined_semantic_provider.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"combined_semantic_provider.js","sourceRoot":"","sources":["../src/combined_semantic_provider.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AACjC,2EAAoF;AACpF,uFAA+F;AAC/F,qEAA8E;AAU9E;;;;;GAKG;AACH,MAAa,8BAA8B;IAKvC;QACI,IAAI,CAAC,eAAe,GAAG,IAAI,iEAAqC,EAAE,CAAC;QACnE,IAAI,CAAC,iBAAiB,GAAG,IAAI,4EAA0C,EAAE,CAAC;QAC1E,IAAI,CAAC,aAAa,GAAG,IAAI,2DAAkC,EAAE,CAAC;IAClE,CAAC;IAED,KAAK,CAAC,6BAA6B,CAAC,QAA6B;QAC7D,gCAAgC;QAChC,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,eAAe,CAAC,6BAA6B,CAAC,QAAQ,CAAC,CAAC;QACzF,MAAM,eAAe,GAAG,MAAM,IAAI,CAAC,iBAAiB,CAAC,6BAA6B,CAAC,QAAQ,CAAC,CAAC;QAC7F,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,6BAA6B,CAAC,QAAQ,CAAC,CAAC;QAErF,0CAA0C;QAC1C,MAAM,cAAc,GAAmB,EAAE,CAAC;QAE1C,2DAA2D;QAC3D,IAAI,CAAC,aAAa,CAAC,aAAa,CAAC,IAAI,EAAE,CAAC,EAAE,cAAc,CAAC,CAAC;QAE1D,sDAAsD;QACtD,IAAI,CAAC,aAAa,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC,EAAE,cAAc,CAAC,CAAC;QAE5D,uEAAuE;QACvE,IAAI,CAAC,aAAa,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,EAAE,cAAc,CAAC,CAAC;QAExD,yCAAyC;QACzC,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;YACzB,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,IAAI,EAAE;gBACnB,OAAO,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC;aAC1B;YACD,OAAO,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC;QAC3B,CAAC,CAAC,CAAC;QAEH,uCAAuC;QACvC,MAAM,OAAO,GAAG,IAAI,MAAM,CAAC,qBAAqB,EAAE,CAAC;QACnD,KAAK,MAAM,KAAK,IAAI,cAAc,EAAE;YAChC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;SACnF;QAED,OAAO,OAAO,CAAC,KAAK,EAAE,CAAC;IAC3B,CAAC;IAEO,aAAa,CAAC,IAAiB,EAAE,QAAgB,EAAE,MAAsB;QAC7E,IAAI,YAAY,GAAG,CAAC,CAAC;QACrB,IAAI,YAAY,GAAG,CAAC,CAAC;QAErB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE;YACrC,MAAM,UAAU,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;YAC3B,MAAM,UAAU,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAC/B,MAAM,MAAM,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAC3B,MAAM,SAAS,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAE9B,IAAI,UAAU,GAAG,CAAC,EAAE;gBAChB,YAAY,IAAI,UAAU,CAAC;gBAC3B,YAAY,GAAG,UAAU,CAAC;aAC7B;iBAAM;gBACH,YAAY,IAAI,UAAU,CAAC;aAC9B;YAED,MAAM,CAAC,IAAI,CAAC;gBACR,IAAI,EAAE,YAAY;gBAClB,IAAI,EAAE,YAAY;gBAClB,MAAM,EAAE,MAAM;gBACd,IAAI,EAAE,QAAQ;gBACd,SAAS,EAAE,SAAS;aACvB,CAAC,CAAC;SACN;IACL,CAAC;IAEO,2BAA2B,CAAC,IAAiB,EAAE,QAAgB,EAAE,YAAoB,EAAE,MAAsB;QACjH,IAAI,YAAY,GAAG,CAAC,CAAC;QACrB,IAAI,YAAY,GAAG,CAAC,CAAC;QAErB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE;YACrC,MAAM,UAAU,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;YAC3B,MAAM,UAAU,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAC/B,MAAM,MAAM,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAE3B,IAAI,UAAU,GAAG,CAAC,EAAE;gBAChB,YAAY,IAAI,UAAU,CAAC;gBAC3B,YAAY,GAAG,UAAU,CAAC;aAC7B;iBAAM;gBACH,YAAY,IAAI,UAAU,CAAC;aAC9B;YAED,MAAM,CAAC,IAAI,CAAC;gBACR,IAAI,EAAE,YAAY;gBAClB,IAAI,EAAE,YAAY;gBAClB,MAAM,EAAE,MAAM;gBACd,IAAI,EAAE,QAAQ;gBACd,SAAS,EAAE,YAAY;aAC1B,CAAC,CAAC;SACN;IACL,CAAC;CACJ;AAlGD,wEAkGC"}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CommentFileReferenceDefinitionProvider = exports.CommentFileReferenceSemanticTokensProvider = void 0;
|
||||
const vscode = __importStar(require("vscode"));
|
||||
const path = __importStar(require("path"));
|
||||
const fs = __importStar(require("fs"));
|
||||
/**
|
||||
* Check if position is inside a comment
|
||||
*/
|
||||
function is_in_comment(document, position) {
|
||||
const line_text = document.lineAt(position.line).text;
|
||||
const char_pos = position.character;
|
||||
// Check for single-line comment
|
||||
const single_comment_idx = line_text.indexOf('//');
|
||||
if (single_comment_idx !== -1 && single_comment_idx < char_pos) {
|
||||
return true;
|
||||
}
|
||||
// Check for block comment markers
|
||||
const doc_comment_idx = line_text.indexOf('/*');
|
||||
const doc_comment_end_idx = line_text.indexOf('*/');
|
||||
const asterisk_comment = line_text.trim().startsWith('*');
|
||||
if (asterisk_comment || doc_comment_idx !== -1 || doc_comment_end_idx !== -1) {
|
||||
return true;
|
||||
}
|
||||
// Check for multi-line comment by looking at text before position
|
||||
const text_before = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
|
||||
let in_block_comment = false;
|
||||
let i = 0;
|
||||
while (i < text_before.length) {
|
||||
if (text_before.substring(i, i + 2) === '/*') {
|
||||
in_block_comment = true;
|
||||
i += 2;
|
||||
}
|
||||
else if (text_before.substring(i, i + 2) === '*/') {
|
||||
in_block_comment = false;
|
||||
i += 2;
|
||||
}
|
||||
else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return in_block_comment;
|
||||
}
|
||||
/**
|
||||
* Get the word at position, including periods
|
||||
*/
|
||||
function get_word_with_period(document, position) {
|
||||
const line = document.lineAt(position.line);
|
||||
const line_text = line.text;
|
||||
const char = position.character;
|
||||
// Find start of word (alphanumeric, underscore, period, hyphen)
|
||||
let start = char;
|
||||
while (start > 0 && /[a-zA-Z0-9_.-]/.test(line_text[start - 1])) {
|
||||
start--;
|
||||
}
|
||||
// Find end of word
|
||||
let end = char;
|
||||
while (end < line_text.length && /[a-zA-Z0-9_.-]/.test(line_text[end])) {
|
||||
end++;
|
||||
}
|
||||
const word = line_text.substring(start, end);
|
||||
// Must contain a period and not be just a period
|
||||
if (!word.includes('.') || word === '.') {
|
||||
return undefined;
|
||||
}
|
||||
const range = new vscode.Range(new vscode.Position(position.line, start), new vscode.Position(position.line, end));
|
||||
return { word, range };
|
||||
}
|
||||
/**
|
||||
* Check if a file exists in the same directory as the document
|
||||
*/
|
||||
function find_file_in_same_directory(document, filename) {
|
||||
const doc_dir = path.dirname(document.uri.fsPath);
|
||||
const file_path = path.join(doc_dir, filename);
|
||||
if (fs.existsSync(file_path)) {
|
||||
return file_path;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* Provides semantic tokens for file references in comments (light blue like class properties)
|
||||
*/
|
||||
class CommentFileReferenceSemanticTokensProvider {
|
||||
async provideDocumentSemanticTokens(document) {
|
||||
const tokens_builder = new vscode.SemanticTokensBuilder();
|
||||
// Only for JavaScript/TypeScript files
|
||||
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
|
||||
return tokens_builder.build();
|
||||
}
|
||||
const text = document.getText();
|
||||
// Find all words with periods
|
||||
const regex = /\b[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g;
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const start_pos = document.positionAt(match.index);
|
||||
// Skip if not in a comment
|
||||
if (!is_in_comment(document, start_pos)) {
|
||||
continue;
|
||||
}
|
||||
const word = match[0];
|
||||
// Check if file exists in same directory
|
||||
if (find_file_in_same_directory(document, word)) {
|
||||
tokens_builder.push(start_pos.line, start_pos.character, word.length, 0, // token type index for 'class' (teal)
|
||||
0 // token modifiers
|
||||
);
|
||||
}
|
||||
}
|
||||
return tokens_builder.build();
|
||||
}
|
||||
}
|
||||
exports.CommentFileReferenceSemanticTokensProvider = CommentFileReferenceSemanticTokensProvider;
|
||||
/**
|
||||
* Provides "Go to Definition" for file references in comments
|
||||
*/
|
||||
class CommentFileReferenceDefinitionProvider {
|
||||
async provideDefinition(document, position, _token) {
|
||||
// Only for JavaScript/TypeScript files
|
||||
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
|
||||
return undefined;
|
||||
}
|
||||
// Must be in a comment
|
||||
if (!is_in_comment(document, position)) {
|
||||
return undefined;
|
||||
}
|
||||
// Get word with period
|
||||
const word_info = get_word_with_period(document, position);
|
||||
if (!word_info) {
|
||||
return undefined;
|
||||
}
|
||||
// Check if file exists in same directory
|
||||
const file_path = find_file_in_same_directory(document, word_info.word);
|
||||
if (!file_path) {
|
||||
return undefined;
|
||||
}
|
||||
// Return file location
|
||||
const file_uri = vscode.Uri.file(file_path);
|
||||
return new vscode.Location(file_uri, new vscode.Position(0, 0));
|
||||
}
|
||||
}
|
||||
exports.CommentFileReferenceDefinitionProvider = CommentFileReferenceDefinitionProvider;
|
||||
//# sourceMappingURL=comment_file_reference_provider.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"comment_file_reference_provider.js","sourceRoot":"","sources":["../src/comment_file_reference_provider.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AACjC,2CAA6B;AAC7B,uCAAyB;AAEzB;;GAEG;AACH,SAAS,aAAa,CAAC,QAA6B,EAAE,QAAyB;IAC3E,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;IACtD,MAAM,QAAQ,GAAG,QAAQ,CAAC,SAAS,CAAC;IAEpC,gCAAgC;IAChC,MAAM,kBAAkB,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,IAAI,kBAAkB,KAAK,CAAC,CAAC,IAAI,kBAAkB,GAAG,QAAQ,EAAE;QAC5D,OAAO,IAAI,CAAC;KACf;IAED,kCAAkC;IAClC,MAAM,eAAe,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,MAAM,mBAAmB,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,MAAM,gBAAgB,GAAG,SAAS,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;IAE1D,IAAI,gBAAgB,IAAI,eAAe,KAAK,CAAC,CAAC,IAAI,mBAAmB,KAAK,CAAC,CAAC,EAAE;QAC1E,OAAO,IAAI,CAAC;KACf;IAED,kEAAkE;IAClE,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC;IAC5F,IAAI,gBAAgB,GAAG,KAAK,CAAC;IAC7B,IAAI,CAAC,GAAG,CAAC,CAAC;IAEV,OAAO,CAAC,GAAG,WAAW,CAAC,MAAM,EAAE;QAC3B,IAAI,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE;YAC1C,gBAAgB,GAAG,IAAI,CAAC;YACxB,CAAC,IAAI,CAAC,CAAC;SACV;aAAM,IAAI,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE;YACjD,gBAAgB,GAAG,KAAK,CAAC;YACzB,CAAC,IAAI,CAAC,CAAC;SACV;aAAM;YACH,CAAC,EAAE,CAAC;SACP;KACJ;IAED,OAAO,gBAAgB,CAAC;AAC5B,CAAC;AAED;;GAEG;AACH,SAAS,oBAAoB,CAAC,QAA6B,EAAE,QAAyB;IAClF,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC5C,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC;IAC5B,MAAM,IAAI,GAAG,QAAQ,CAAC,SAAS,CAAC;IAEhC,gEAAgE;IAChE,IAAI,KAAK,GAAG,IAAI,CAAC;IACjB,OAAO,KAAK,GAAG,CAAC,IAAI,gBAAgB,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE;QAC7D,KAAK,EAAE,CAAC;KACX;IAED,mBAAmB;IACnB,IAAI,GAAG,GAAG,IAAI,CAAC;IACf,OAAO,GAAG,GAAG,SAAS,CAAC,MAAM,IAAI,gBAAgB,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,EAAE;QACpE,GAAG,EAAE,CAAC;KACT;IAED,MAAM,IAAI,GAAG,SAAS,CAAC,SAAS,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;IAE7C,iDAAiD;IACjD,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,IAAI,KAAK,GAAG,EAAE;QACrC,OAAO,SAAS,CAAC;KACpB;IAED,MAAM,KAAK,GAAG,IAAI,MAAM,CAAC,KAAK,CAC1B,IAAI,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,EACzC,IAAI,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,CAC1C,CAAC;IAEF,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC3B,CAAC;AAED;;GAEG;AACH,SAAS,2BAA2B,CAAC,QAA6B,EAAE,QAAgB;IAChF,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAClD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAE/C,IAAI,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE;QAC1B,OAAO,SAAS,CAAC;KACpB;IAED,OAAO,SAAS,CAAC;AACrB,CAAC;AAED;;GAEG;AACH,MAAa,0CAA0C;IACnD,KAAK,CAAC,6BAA6B,CAAC,QAA6B;QAC7D,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,qBAAqB,EAAE,CAAC;QAE1D,uCAAuC;QACvC,IAAI,QAAQ,CAAC,UAAU,KAAK,YAAY,IAAI,QAAQ,CAAC,UAAU,KAAK,YAAY,EAAE;YAC9E,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;SACjC;QAED,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,EAAE,CAAC;QAEhC,8BAA8B;QAC9B,MAAM,KAAK,GAAG,qCAAqC,CAAC;QACpD,IAAI,KAAK,CAAC;QAEV,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,EAAE;YACxC,MAAM,SAAS,GAAG,QAAQ,CAAC,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAEnD,2BAA2B;YAC3B,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE;gBACrC,SAAS;aACZ;YAED,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YAEtB,yCAAyC;YACzC,IAAI,2BAA2B,CAAC,QAAQ,EAAE,IAAI,CAAC,EAAE;gBAC7C,cAAc,CAAC,IAAI,CACf,SAAS,CAAC,IAAI,EACd,SAAS,CAAC,SAAS,EACnB,IAAI,CAAC,MAAM,EACX,CAAC,EAAE,sCAAsC;gBACzC,CAAC,CAAE,kBAAkB;iBACxB,CAAC;aACL;SACJ;QAED,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;IAClC,CAAC;CACJ;AAvCD,gGAuCC;AAED;;GAEG;AACH,MAAa,sCAAsC;IAC/C,KAAK,CAAC,iBAAiB,CACnB,QAA6B,EAC7B,QAAyB,EACzB,MAAgC;QAEhC,uCAAuC;QACvC,IAAI,QAAQ,CAAC,UAAU,KAAK,YAAY,IAAI,QAAQ,CAAC,UAAU,KAAK,YAAY,EAAE;YAC9E,OAAO,SAAS,CAAC;SACpB;QAED,uBAAuB;QACvB,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,QAAQ,CAAC,EAAE;YACpC,OAAO,SAAS,CAAC;SACpB;QAED,uBAAuB;QACvB,MAAM,SAAS,GAAG,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;QAC3D,IAAI,CAAC,SAAS,EAAE;YACZ,OAAO,SAAS,CAAC;SACpB;QAED,yCAAyC;QACzC,MAAM,SAAS,GAAG,2BAA2B,CAAC,QAAQ,EAAE,SAAS,CAAC,IAAI,CAAC,CAAC;QACxE,IAAI,CAAC,SAAS,EAAE;YACZ,OAAO,SAAS,CAAC;SACpB;QAED,uBAAuB;QACvB,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAC5C,OAAO,IAAI,MAAM,CAAC,QAAQ,CAAC,QAAQ,EAAE,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACpE,CAAC;CACJ;AAhCD,wFAgCC"}
|
||||
@@ -35,7 +35,9 @@ const laravel_completion_provider_1 = require("./laravel_completion_provider");
|
||||
const blade_spacer_1 = require("./blade_spacer");
|
||||
const blade_client_1 = require("./blade_client");
|
||||
const convention_method_provider_1 = require("./convention_method_provider");
|
||||
const comment_file_reference_provider_1 = require("./comment_file_reference_provider");
|
||||
const jqhtml_lifecycle_provider_1 = require("./jqhtml_lifecycle_provider");
|
||||
const combined_semantic_provider_1 = require("./combined_semantic_provider");
|
||||
const php_attribute_provider_1 = require("./php_attribute_provider");
|
||||
const blade_component_provider_1 = require("./blade_component_provider");
|
||||
const auto_rename_provider_1 = require("./auto_rename_provider");
|
||||
@@ -47,6 +49,7 @@ const refactor_code_actions_1 = require("./refactor_code_actions");
|
||||
const class_refactor_provider_1 = require("./class_refactor_provider");
|
||||
const class_refactor_code_actions_1 = require("./class_refactor_code_actions");
|
||||
const sort_class_methods_provider_1 = require("./sort_class_methods_provider");
|
||||
const symlink_redirect_provider_1 = require("./symlink_redirect_provider");
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
let folding_provider;
|
||||
@@ -171,6 +174,10 @@ async function activate(context) {
|
||||
// Register git diff provider
|
||||
const git_diff_provider = new git_diff_provider_1.GitDiffProvider(rspade_root);
|
||||
git_diff_provider.activate(context);
|
||||
// Register symlink redirect provider
|
||||
const symlink_redirect_provider = new symlink_redirect_provider_1.SymlinkRedirectProvider();
|
||||
symlink_redirect_provider.activate(context);
|
||||
console.log('Symlink redirect provider registered - system/rsx/ files will redirect to rsx/');
|
||||
// Register refactor provider
|
||||
const refactor_provider = new refactor_provider_1.RspadeRefactorProvider(formatting_provider);
|
||||
refactor_provider.register(context);
|
||||
@@ -232,7 +239,6 @@ async function activate(context) {
|
||||
context.subscriptions.push(vscode.languages.registerCompletionItemProvider({ language: 'php' }, laravel_completion_provider));
|
||||
console.log('Laravel completion provider registered for PHP files');
|
||||
// Register convention method providers for JavaScript/TypeScript
|
||||
// Note: Semantic tokens are handled by JqhtmlLifecycleSemanticTokensProvider to avoid duplicate registration
|
||||
const convention_hover_provider = new convention_method_provider_1.ConventionMethodHoverProvider();
|
||||
const convention_diagnostic_provider = new convention_method_provider_1.ConventionMethodDiagnosticProvider();
|
||||
const convention_definition_provider = new convention_method_provider_1.ConventionMethodDefinitionProvider();
|
||||
@@ -241,13 +247,20 @@ async function activate(context) {
|
||||
convention_diagnostic_provider.activate(context);
|
||||
console.log('Convention method providers registered for JavaScript/TypeScript');
|
||||
// Register JQHTML lifecycle method providers for JavaScript/TypeScript
|
||||
const jqhtml_semantic_provider = new jqhtml_lifecycle_provider_1.JqhtmlLifecycleSemanticTokensProvider();
|
||||
const jqhtml_hover_provider = new jqhtml_lifecycle_provider_1.JqhtmlLifecycleHoverProvider();
|
||||
const jqhtml_diagnostic_provider = new jqhtml_lifecycle_provider_1.JqhtmlLifecycleDiagnosticProvider();
|
||||
context.subscriptions.push(vscode.languages.registerDocumentSemanticTokensProvider([{ language: 'javascript' }, { language: 'typescript' }], jqhtml_semantic_provider, new vscode.SemanticTokensLegend(['conventionMethod'])));
|
||||
context.subscriptions.push(vscode.languages.registerHoverProvider([{ language: 'javascript' }, { language: 'typescript' }], jqhtml_hover_provider));
|
||||
jqhtml_diagnostic_provider.activate(context);
|
||||
console.log('JQHTML lifecycle providers registered for JavaScript/TypeScript');
|
||||
// Register combined semantic tokens provider for JavaScript/TypeScript
|
||||
// This includes: JQHTML lifecycle methods (orange), file references (teal), 'that' variable (blue)
|
||||
const combined_semantic_provider = new combined_semantic_provider_1.CombinedSemanticTokensProvider();
|
||||
context.subscriptions.push(vscode.languages.registerDocumentSemanticTokensProvider([{ language: 'javascript' }, { language: 'typescript' }], combined_semantic_provider, new vscode.SemanticTokensLegend(['conventionMethod', 'class', 'macro'])));
|
||||
console.log('Combined semantic tokens provider registered (JQHTML lifecycle, file references, that variable)');
|
||||
// Register comment file reference definition provider for JavaScript/TypeScript
|
||||
const comment_file_reference_definition_provider = new comment_file_reference_provider_1.CommentFileReferenceDefinitionProvider();
|
||||
context.subscriptions.push(vscode.languages.registerDefinitionProvider([{ language: 'javascript' }, { language: 'typescript' }], comment_file_reference_definition_provider));
|
||||
console.log('Comment file reference definition provider registered for JavaScript/TypeScript');
|
||||
// Register PHP attribute provider
|
||||
const php_attribute_provider = new php_attribute_provider_1.PhpAttributeSemanticTokensProvider();
|
||||
context.subscriptions.push(vscode.languages.registerDocumentSemanticTokensProvider([{ language: 'php' }], php_attribute_provider, new vscode.SemanticTokensLegend(['conventionMethod'])));
|
||||
@@ -267,12 +280,6 @@ async function activate(context) {
|
||||
definition_provider.clear_status_bar();
|
||||
}));
|
||||
// Register commands
|
||||
context.subscriptions.push(vscode.commands.registerCommand('rspade.toggleFolding', () => {
|
||||
const config = (0, config_1.get_config)();
|
||||
const current = config.get('enableCodeFolding', true);
|
||||
config.update('enableCodeFolding', !current, vscode.ConfigurationTarget.Workspace);
|
||||
vscode.window.showInformationMessage(`RSpade code folding ${!current ? 'enabled' : 'disabled'}`);
|
||||
}));
|
||||
context.subscriptions.push(vscode.commands.registerCommand('rspade.formatPhpFile', async () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (editor && editor.document.languageId === 'php') {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -401,13 +401,20 @@ class JqhtmlLifecycleDiagnosticProvider {
|
||||
const method_body = text.substring(method_body_start, body_end);
|
||||
// Check for violations in method body
|
||||
if (method_name === 'on_create') {
|
||||
// Check for this.data or that.data access
|
||||
const data_access_regex = /(this|that)\.data/g;
|
||||
// Check for this.data or that.data access (reading, not assignment)
|
||||
// Note: on_create() now runs BEFORE first render, so assigning to this.data is valid
|
||||
// We only warn on reading from this.data (accessing properties without assignment)
|
||||
const data_access_regex = /(this|that)\.data\.(\w+)(\s*=)?/g;
|
||||
let data_match;
|
||||
while ((data_match = data_access_regex.exec(method_body)) !== null) {
|
||||
// Skip if this is an assignment (has the = part in capture group 3)
|
||||
if (data_match[3]) {
|
||||
continue;
|
||||
}
|
||||
// This is a read access, not an assignment - warn about it
|
||||
const violation_pos = document.positionAt(method_body_start + data_match.index);
|
||||
const violation_end = document.positionAt(method_body_start + data_match.index + data_match[0].length);
|
||||
diagnostics.push(new vscode.Diagnostic(new vscode.Range(violation_pos, violation_end), `'${data_match[0]}' is populated during on_load, which happens after on_create. Did you mean ${data_match[1]}.args?`, vscode.DiagnosticSeverity.Warning));
|
||||
diagnostics.push(new vscode.Diagnostic(new vscode.Range(violation_pos, violation_end), `'${data_match[0]}' is being read in on_create, but this.data should only be initialized here (assignments like 'this.data.rows = []' are OK)`, vscode.DiagnosticSeverity.Warning));
|
||||
}
|
||||
}
|
||||
if (method_name === 'on_load') {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -32,6 +32,7 @@ const FRAMEWORK_ATTRIBUTES = [
|
||||
'Ajax_Endpoint',
|
||||
'Route',
|
||||
'Auth',
|
||||
'Task',
|
||||
'Relationship',
|
||||
'Monoprogenic',
|
||||
'Instantiatable'
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"php_attribute_provider.js","sourceRoot":"","sources":["../src/php_attribute_provider.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AAEjC;;GAEG;AACH,MAAM,oBAAoB,GAAG;IACzB,eAAe;IACf,OAAO;IACP,MAAM;IACN,cAAc;IACd,cAAc;IACd,gBAAgB;CACnB,CAAC;AAEF;;GAEG;AACH,MAAa,kCAAkC;IAC3C,KAAK,CAAC,6BAA6B,CAAC,QAA6B;QAC7D,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,qBAAqB,EAAE,CAAC;QAE1D,IAAI,QAAQ,CAAC,UAAU,KAAK,KAAK,EAAE;YAC/B,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;SACjC;QAED,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,EAAE,CAAC;QAEhC,iEAAiE;QACjE,KAAK,MAAM,cAAc,IAAI,oBAAoB,EAAE;YAC/C,4EAA4E;YAC5E,+DAA+D;YAC/D,MAAM,KAAK,GAAG,IAAI,MAAM,CAAC,aAAa,cAAc,kBAAkB,EAAE,GAAG,CAAC,CAAC;YAC7E,IAAI,KAAK,CAAC;YAEV,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,EAAE;gBACxC,gEAAgE;gBAChE,MAAM,UAAU,GAAG,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;gBAC5D,MAAM,QAAQ,GAAG,QAAQ,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC;gBAEjD,cAAc,CAAC,IAAI,CACf,QAAQ,CAAC,IAAI,EACb,QAAQ,CAAC,SAAS,EAClB,cAAc,CAAC,MAAM,EACrB,CAAC,EAAE,0CAA0C;gBAC7C,CAAC,CAAE,kBAAkB;iBACxB,CAAC;aACL;SACJ;QAED,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;IAClC,CAAC;CACJ;AAlCD,gFAkCC"}
|
||||
{"version":3,"file":"php_attribute_provider.js","sourceRoot":"","sources":["../src/php_attribute_provider.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AAEjC;;GAEG;AACH,MAAM,oBAAoB,GAAG;IACzB,eAAe;IACf,OAAO;IACP,MAAM;IACN,MAAM;IACN,cAAc;IACd,cAAc;IACd,gBAAgB;CACnB,CAAC;AAEF;;GAEG;AACH,MAAa,kCAAkC;IAC3C,KAAK,CAAC,6BAA6B,CAAC,QAA6B;QAC7D,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,qBAAqB,EAAE,CAAC;QAE1D,IAAI,QAAQ,CAAC,UAAU,KAAK,KAAK,EAAE;YAC/B,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;SACjC;QAED,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,EAAE,CAAC;QAEhC,iEAAiE;QACjE,KAAK,MAAM,cAAc,IAAI,oBAAoB,EAAE;YAC/C,4EAA4E;YAC5E,+DAA+D;YAC/D,MAAM,KAAK,GAAG,IAAI,MAAM,CAAC,aAAa,cAAc,kBAAkB,EAAE,GAAG,CAAC,CAAC;YAC7E,IAAI,KAAK,CAAC;YAEV,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,EAAE;gBACxC,gEAAgE;gBAChE,MAAM,UAAU,GAAG,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;gBAC5D,MAAM,QAAQ,GAAG,QAAQ,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC;gBAEjD,cAAc,CAAC,IAAI,CACf,QAAQ,CAAC,IAAI,EACb,QAAQ,CAAC,SAAS,EAClB,cAAc,CAAC,MAAM,EACrB,CAAC,EAAE,0CAA0C;gBAC7C,CAAC,CAAE,kBAAkB;iBACxB,CAAC;aACL;SACJ;QAED,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;IAClC,CAAC;CACJ;AAlCD,gFAkCC"}
|
||||
@@ -174,14 +174,11 @@ class RspadeRefactorProvider {
|
||||
this.output_channel.appendLine('\n=== Refactor Complete ===');
|
||||
// Check if refactor was successful
|
||||
if (result.includes('=== Refactor Complete ===') || result.trim().length > 0) {
|
||||
// Wait 3.5 seconds total (2s + 1.5s), hide panel, then reload files
|
||||
// Wait for filesystem changes to propagate then reload files
|
||||
// Note: Panel is kept open so developer can see results and warnings
|
||||
setTimeout(async () => {
|
||||
await vscode.commands.executeCommand('workbench.action.closePanel');
|
||||
// Wait 500ms for filesystem changes to propagate (Samba/network issues)
|
||||
setTimeout(async () => {
|
||||
await this.reload_all_open_files();
|
||||
}, 500);
|
||||
}, 3500);
|
||||
await this.reload_all_open_files();
|
||||
}, 500);
|
||||
vscode.window.showInformationMessage(`Successfully refactored ${method_info.class_name}::${method_info.method_name} to ${new_method_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,120 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SymlinkRedirectProvider = void 0;
|
||||
const vscode = __importStar(require("vscode"));
|
||||
const path = __importStar(require("path"));
|
||||
const fs = __importStar(require("fs"));
|
||||
/**
|
||||
* Redirects files opened from system/rsx/ symlink to their real location in rsx/
|
||||
*
|
||||
* The system/rsx/ directory is a symlink to rsx/ for framework compatibility,
|
||||
* but users should always edit files in the real rsx/ directory.
|
||||
*/
|
||||
class SymlinkRedirectProvider {
|
||||
constructor() {
|
||||
this.disposables = [];
|
||||
}
|
||||
activate(context) {
|
||||
// Watch for document opens and switches
|
||||
this.disposables.push(vscode.window.onDidChangeActiveTextEditor(editor => {
|
||||
if (editor) {
|
||||
this.check_and_redirect(editor.document);
|
||||
}
|
||||
}));
|
||||
// Also check when window first opens or tabs change
|
||||
this.disposables.push(vscode.workspace.onDidOpenTextDocument(document => {
|
||||
this.check_and_redirect(document);
|
||||
}));
|
||||
// Check currently active editor immediately
|
||||
if (vscode.window.activeTextEditor) {
|
||||
this.check_and_redirect(vscode.window.activeTextEditor.document);
|
||||
}
|
||||
console.log('[RSpade] Symlink redirect provider activated');
|
||||
}
|
||||
async check_and_redirect(document) {
|
||||
const file_path = document.uri.fsPath;
|
||||
// Check if this is a file in system/rsx/
|
||||
if (!file_path.includes('/system/rsx/') && !file_path.includes('\\system\\rsx\\')) {
|
||||
return; // Not in system/rsx/, no action needed
|
||||
}
|
||||
// Find the workspace folder
|
||||
const workspace_folder = vscode.workspace.getWorkspaceFolder(document.uri);
|
||||
if (!workspace_folder) {
|
||||
return;
|
||||
}
|
||||
const workspace_root = workspace_folder.uri.fsPath;
|
||||
// Extract the path after system/rsx/
|
||||
const system_rsx_pattern = /[\/\\]system[\/\\]rsx[\/\\](.*)/;
|
||||
const match = file_path.match(system_rsx_pattern);
|
||||
if (!match) {
|
||||
return; // Pattern doesn't match
|
||||
}
|
||||
const relative_path = match[1];
|
||||
const real_file = path.join(workspace_root, 'rsx', relative_path);
|
||||
// Check if the real file exists
|
||||
if (!fs.existsSync(real_file)) {
|
||||
// Real file doesn't exist, this might be a framework file or something else
|
||||
return;
|
||||
}
|
||||
console.log(`[RSpade] Redirecting from system/rsx/ symlink to real file:`);
|
||||
console.log(` Symlink: ${file_path}`);
|
||||
console.log(` Real: ${real_file}`);
|
||||
// Check if the symlink version is pinned
|
||||
const is_pinned = document.uri.scheme === 'file' &&
|
||||
vscode.window.tabGroups.activeTabGroup.activeTab?.isPinned;
|
||||
// If pinned, unpin it first
|
||||
if (is_pinned) {
|
||||
await vscode.commands.executeCommand('workbench.action.unpinEditor');
|
||||
}
|
||||
// Open the real file
|
||||
const real_uri = vscode.Uri.file(real_file);
|
||||
const real_document = await vscode.workspace.openTextDocument(real_uri);
|
||||
await vscode.window.showTextDocument(real_document);
|
||||
// If the original was pinned, pin the new one
|
||||
if (is_pinned) {
|
||||
await vscode.commands.executeCommand('workbench.action.pinEditor');
|
||||
}
|
||||
// Close the symlink version (now in background)
|
||||
// Find the tab with the symlink path and close it
|
||||
const tab_groups = vscode.window.tabGroups.all;
|
||||
for (const group of tab_groups) {
|
||||
for (const tab of group.tabs) {
|
||||
if (tab.input instanceof vscode.TabInputText &&
|
||||
tab.input.uri.fsPath === file_path) {
|
||||
await vscode.window.tabGroups.close(tab);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Show brief notification
|
||||
vscode.window.setStatusBarMessage('Redirected from system/rsx/ to rsx/', 2000);
|
||||
}
|
||||
dispose() {
|
||||
this.disposables.forEach(d => d.dispose());
|
||||
}
|
||||
}
|
||||
exports.SymlinkRedirectProvider = SymlinkRedirectProvider;
|
||||
//# sourceMappingURL=symlink_redirect_provider.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"symlink_redirect_provider.js","sourceRoot":"","sources":["../src/symlink_redirect_provider.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AACjC,2CAA6B;AAC7B,uCAAyB;AAEzB;;;;;GAKG;AACH,MAAa,uBAAuB;IAApC;QACY,gBAAW,GAAwB,EAAE,CAAC;IAuGlD,CAAC;IArGU,QAAQ,CAAC,OAAgC;QAC5C,wCAAwC;QACxC,IAAI,CAAC,WAAW,CAAC,IAAI,CACjB,MAAM,CAAC,MAAM,CAAC,2BAA2B,CAAC,MAAM,CAAC,EAAE;YAC/C,IAAI,MAAM,EAAE;gBACR,IAAI,CAAC,kBAAkB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;aAC5C;QACL,CAAC,CAAC,CACL,CAAC;QAEF,oDAAoD;QACpD,IAAI,CAAC,WAAW,CAAC,IAAI,CACjB,MAAM,CAAC,SAAS,CAAC,qBAAqB,CAAC,QAAQ,CAAC,EAAE;YAC9C,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC,CAAC;QACtC,CAAC,CAAC,CACL,CAAC;QAEF,4CAA4C;QAC5C,IAAI,MAAM,CAAC,MAAM,CAAC,gBAAgB,EAAE;YAChC,IAAI,CAAC,kBAAkB,CAAC,MAAM,CAAC,MAAM,CAAC,gBAAgB,CAAC,QAAQ,CAAC,CAAC;SACpE;QAED,OAAO,CAAC,GAAG,CAAC,8CAA8C,CAAC,CAAC;IAChE,CAAC;IAEO,KAAK,CAAC,kBAAkB,CAAC,QAA6B;QAC1D,MAAM,SAAS,GAAG,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC;QAEtC,yCAAyC;QACzC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,cAAc,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,iBAAiB,CAAC,EAAE;YAC/E,OAAO,CAAC,uCAAuC;SAClD;QAED,4BAA4B;QAC5B,MAAM,gBAAgB,GAAG,MAAM,CAAC,SAAS,CAAC,kBAAkB,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QAC3E,IAAI,CAAC,gBAAgB,EAAE;YACnB,OAAO;SACV;QAED,MAAM,cAAc,GAAG,gBAAgB,CAAC,GAAG,CAAC,MAAM,CAAC;QAEnD,qCAAqC;QACrC,MAAM,kBAAkB,GAAG,iCAAiC,CAAC;QAC7D,MAAM,KAAK,GAAG,SAAS,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC;QAElD,IAAI,CAAC,KAAK,EAAE;YACR,OAAO,CAAC,wBAAwB;SACnC;QAED,MAAM,aAAa,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QAC/B,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,KAAK,EAAE,aAAa,CAAC,CAAC;QAElE,gCAAgC;QAChC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE;YAC3B,4EAA4E;YAC5E,OAAO;SACV;QAED,OAAO,CAAC,GAAG,CAAC,6DAA6D,CAAC,CAAC;QAC3E,OAAO,CAAC,GAAG,CAAC,cAAc,SAAS,EAAE,CAAC,CAAC;QACvC,OAAO,CAAC,GAAG,CAAC,cAAc,SAAS,EAAE,CAAC,CAAC;QAEvC,yCAAyC;QACzC,MAAM,SAAS,GAAG,QAAQ,CAAC,GAAG,CAAC,MAAM,KAAK,MAAM;YAC/B,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,SAAS,EAAE,QAAQ,CAAC;QAE5E,4BAA4B;QAC5B,IAAI,SAAS,EAAE;YACX,MAAM,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,8BAA8B,CAAC,CAAC;SACxE;QAED,qBAAqB;QACrB,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAC5C,MAAM,aAAa,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,gBAAgB,CAAC,QAAQ,CAAC,CAAC;QACxE,MAAM,MAAM,CAAC,MAAM,CAAC,gBAAgB,CAAC,aAAa,CAAC,CAAC;QAEpD,8CAA8C;QAC9C,IAAI,SAAS,EAAE;YACX,MAAM,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,4BAA4B,CAAC,CAAC;SACtE;QAED,gDAAgD;QAChD,kDAAkD;QAClD,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC;QAC/C,KAAK,MAAM,KAAK,IAAI,UAAU,EAAE;YAC5B,KAAK,MAAM,GAAG,IAAI,KAAK,CAAC,IAAI,EAAE;gBAC1B,IAAI,GAAG,CAAC,KAAK,YAAY,MAAM,CAAC,YAAY;oBACxC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,KAAK,SAAS,EAAE;oBACpC,MAAM,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;oBACzC,MAAM;iBACT;aACJ;SACJ;QAED,0BAA0B;QAC1B,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,qCAAqC,EAAE,IAAI,CAAC,CAAC;IACnF,CAAC;IAEM,OAAO;QACV,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC;IAC/C,CAAC;CACJ;AAxGD,0DAwGC"}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ThatVariableSemanticTokensProvider = void 0;
|
||||
const vscode = __importStar(require("vscode"));
|
||||
/**
|
||||
* Check if position is inside a string
|
||||
*/
|
||||
function is_in_string(document, position) {
|
||||
const line_text = document.lineAt(position.line).text;
|
||||
const char_pos = position.character;
|
||||
// Simple check: count quotes before position
|
||||
let single_quotes = 0;
|
||||
let double_quotes = 0;
|
||||
for (let i = 0; i < char_pos; i++) {
|
||||
if (line_text[i] === "'" && (i === 0 || line_text[i - 1] !== '\\')) {
|
||||
single_quotes++;
|
||||
}
|
||||
else if (line_text[i] === '"' && (i === 0 || line_text[i - 1] !== '\\')) {
|
||||
double_quotes++;
|
||||
}
|
||||
}
|
||||
// If odd number of quotes, we're inside a string
|
||||
return (single_quotes % 2 === 1) || (double_quotes % 2 === 1);
|
||||
}
|
||||
/**
|
||||
* Check if position is inside a comment
|
||||
*/
|
||||
function is_in_comment(document, position) {
|
||||
const line_text = document.lineAt(position.line).text;
|
||||
const char_pos = position.character;
|
||||
// Check for single-line comment
|
||||
const single_comment_idx = line_text.indexOf('//');
|
||||
if (single_comment_idx !== -1 && single_comment_idx < char_pos) {
|
||||
return true;
|
||||
}
|
||||
// Check for multi-line comment by looking at text before position
|
||||
const text_before = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
|
||||
let in_block_comment = false;
|
||||
let i = 0;
|
||||
while (i < text_before.length) {
|
||||
if (text_before.substring(i, i + 2) === '/*') {
|
||||
in_block_comment = true;
|
||||
i += 2;
|
||||
}
|
||||
else if (text_before.substring(i, i + 2) === '*/') {
|
||||
in_block_comment = false;
|
||||
i += 2;
|
||||
}
|
||||
else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return in_block_comment;
|
||||
}
|
||||
/**
|
||||
* Provides semantic tokens for 'that' variable (dark blue like 'this' keyword)
|
||||
*/
|
||||
class ThatVariableSemanticTokensProvider {
|
||||
async provideDocumentSemanticTokens(document) {
|
||||
const tokens_builder = new vscode.SemanticTokensBuilder();
|
||||
// Only for JavaScript/TypeScript files
|
||||
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
|
||||
return tokens_builder.build();
|
||||
}
|
||||
const text = document.getText();
|
||||
// Find all occurrences of 'that' as a standalone word
|
||||
const regex = /\bthat\b/g;
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const start_pos = document.positionAt(match.index);
|
||||
// Skip if inside a string
|
||||
if (is_in_string(document, start_pos)) {
|
||||
continue;
|
||||
}
|
||||
// Skip if inside a comment
|
||||
if (is_in_comment(document, start_pos)) {
|
||||
continue;
|
||||
}
|
||||
// Highlight 'that' with same blue color as 'this' keyword
|
||||
// Using 'variable' type with 'defaultLibrary' modifier to match language variable color
|
||||
tokens_builder.push(start_pos.line, start_pos.character, 4, // length of 'that'
|
||||
0, // token type index for 'variable'
|
||||
1 // token modifiers: bit 0 = defaultLibrary
|
||||
);
|
||||
}
|
||||
return tokens_builder.build();
|
||||
}
|
||||
}
|
||||
exports.ThatVariableSemanticTokensProvider = ThatVariableSemanticTokensProvider;
|
||||
//# sourceMappingURL=that_variable_provider.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"that_variable_provider.js","sourceRoot":"","sources":["../src/that_variable_provider.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AAEjC;;GAEG;AACH,SAAS,YAAY,CAAC,QAA6B,EAAE,QAAyB;IAC1E,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;IACtD,MAAM,QAAQ,GAAG,QAAQ,CAAC,SAAS,CAAC;IAEpC,6CAA6C;IAC7C,IAAI,aAAa,GAAG,CAAC,CAAC;IACtB,IAAI,aAAa,GAAG,CAAC,CAAC;IAEtB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,EAAE,CAAC,EAAE,EAAE;QAC/B,IAAI,SAAS,CAAC,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,EAAE;YAChE,aAAa,EAAE,CAAC;SACnB;aAAM,IAAI,SAAS,CAAC,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,EAAE;YACvE,aAAa,EAAE,CAAC;SACnB;KACJ;IAED,iDAAiD;IACjD,OAAO,CAAC,aAAa,GAAG,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,aAAa,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;AAClE,CAAC;AAED;;GAEG;AACH,SAAS,aAAa,CAAC,QAA6B,EAAE,QAAyB;IAC3E,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;IACtD,MAAM,QAAQ,GAAG,QAAQ,CAAC,SAAS,CAAC;IAEpC,gCAAgC;IAChC,MAAM,kBAAkB,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,IAAI,kBAAkB,KAAK,CAAC,CAAC,IAAI,kBAAkB,GAAG,QAAQ,EAAE;QAC5D,OAAO,IAAI,CAAC;KACf;IAED,kEAAkE;IAClE,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC;IAC5F,IAAI,gBAAgB,GAAG,KAAK,CAAC;IAC7B,IAAI,CAAC,GAAG,CAAC,CAAC;IAEV,OAAO,CAAC,GAAG,WAAW,CAAC,MAAM,EAAE;QAC3B,IAAI,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE;YAC1C,gBAAgB,GAAG,IAAI,CAAC;YACxB,CAAC,IAAI,CAAC,CAAC;SACV;aAAM,IAAI,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE;YACjD,gBAAgB,GAAG,KAAK,CAAC;YACzB,CAAC,IAAI,CAAC,CAAC;SACV;aAAM;YACH,CAAC,EAAE,CAAC;SACP;KACJ;IAED,OAAO,gBAAgB,CAAC;AAC5B,CAAC;AAED;;GAEG;AACH,MAAa,kCAAkC;IAC3C,KAAK,CAAC,6BAA6B,CAAC,QAA6B;QAC7D,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,qBAAqB,EAAE,CAAC;QAE1D,uCAAuC;QACvC,IAAI,QAAQ,CAAC,UAAU,KAAK,YAAY,IAAI,QAAQ,CAAC,UAAU,KAAK,YAAY,EAAE;YAC9E,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;SACjC;QAED,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,EAAE,CAAC;QAEhC,sDAAsD;QACtD,MAAM,KAAK,GAAG,WAAW,CAAC;QAC1B,IAAI,KAAK,CAAC;QAEV,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,EAAE;YACxC,MAAM,SAAS,GAAG,QAAQ,CAAC,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAEnD,0BAA0B;YAC1B,IAAI,YAAY,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE;gBACnC,SAAS;aACZ;YAED,2BAA2B;YAC3B,IAAI,aAAa,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE;gBACpC,SAAS;aACZ;YAED,0DAA0D;YAC1D,wFAAwF;YACxF,cAAc,CAAC,IAAI,CACf,SAAS,CAAC,IAAI,EACd,SAAS,CAAC,SAAS,EACnB,CAAC,EAAE,mBAAmB;YACtB,CAAC,EAAE,kCAAkC;YACrC,CAAC,CAAE,0CAA0C;aAChD,CAAC;SACL;QAED,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;IAClC,CAAC;CACJ;AAzCD,gFAyCC"}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "rspade-framework",
|
||||
"displayName": "RSpade Framework Support",
|
||||
"description": "VS Code extension for RSpade framework with code folding, formatting, and namespace management",
|
||||
"version": "0.1.186",
|
||||
"version": "0.1.212",
|
||||
"publisher": "rspade",
|
||||
"engines": {
|
||||
"vscode": "^1.74.0"
|
||||
@@ -53,10 +53,6 @@
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"command": "rspade.toggleFolding",
|
||||
"title": "RSpade: Toggle LLMDIRECTIVE Folding"
|
||||
},
|
||||
{
|
||||
"command": "rspade.formatPhpFile",
|
||||
"title": "RSpade: Format PHP File"
|
||||
|
||||
Binary file not shown.
@@ -81,7 +81,6 @@ export class RspadeClassRefactorProvider {
|
||||
|
||||
if (!new_class_name) {
|
||||
this.output_channel.appendLine('Refactor cancelled by user');
|
||||
await vscode.commands.executeCommand('workbench.action.closePanel');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,7 +95,6 @@ export class RspadeClassRefactorProvider {
|
||||
|
||||
if (confirmation !== 'Rename') {
|
||||
this.output_channel.appendLine('Global rename cancelled by user');
|
||||
await vscode.commands.executeCommand('workbench.action.closePanel');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,27 +142,23 @@ export class RspadeClassRefactorProvider {
|
||||
|
||||
// Check if refactor was successful
|
||||
if (result.includes('=== Refactor Complete ===') || result.trim().length > 0) {
|
||||
// Wait 3.5 seconds total (2s + 1.5s), hide panel, then reload files and auto-rename
|
||||
// Wait for filesystem changes to propagate then reload files and auto-rename
|
||||
// Note: Panel is kept open so developer can see results and warnings
|
||||
setTimeout(async () => {
|
||||
await vscode.commands.executeCommand('workbench.action.closePanel');
|
||||
await this.reload_all_open_files();
|
||||
|
||||
// Wait 500ms for filesystem changes to propagate (Samba/network issues)
|
||||
// Wait another 500ms then check if current file needs renaming
|
||||
setTimeout(async () => {
|
||||
await this.reload_all_open_files();
|
||||
|
||||
// Wait another 500ms then check if current file needs renaming
|
||||
setTimeout(async () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (editor) {
|
||||
const file_path = editor.document.uri.fsPath;
|
||||
// Only auto-rename if file is in ./rsx
|
||||
if (file_path.includes('/rsx/') || file_path.includes('\\rsx\\')) {
|
||||
await this.auto_rename_provider.check_and_rename(editor.document);
|
||||
}
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (editor) {
|
||||
const file_path = editor.document.uri.fsPath;
|
||||
// Only auto-rename if file is in ./rsx
|
||||
if (file_path.includes('/rsx/') || file_path.includes('\\rsx\\')) {
|
||||
await this.auto_rename_provider.check_and_rename(editor.document);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 500);
|
||||
}, 3500);
|
||||
}, 500);
|
||||
|
||||
vscode.window.showInformationMessage(
|
||||
`Successfully refactored ${class_info.class_name} to ${new_class_name}`
|
||||
|
||||
118
app/RSpade/resource/vscode_extension/src/combined_semantic_provider.ts
Executable file
118
app/RSpade/resource/vscode_extension/src/combined_semantic_provider.ts
Executable file
@@ -0,0 +1,118 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { JqhtmlLifecycleSemanticTokensProvider } from './jqhtml_lifecycle_provider';
|
||||
import { CommentFileReferenceSemanticTokensProvider } from './comment_file_reference_provider';
|
||||
import { ThatVariableSemanticTokensProvider } from './that_variable_provider';
|
||||
|
||||
interface DecodedToken {
|
||||
line: number;
|
||||
char: number;
|
||||
length: number;
|
||||
type: number;
|
||||
modifiers: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined semantic tokens provider that merges tokens from multiple providers
|
||||
*
|
||||
* VS Code only allows one SemanticTokensLegend per language, so we need to
|
||||
* combine all our providers into one to avoid conflicts.
|
||||
*/
|
||||
export class CombinedSemanticTokensProvider implements vscode.DocumentSemanticTokensProvider {
|
||||
private jqhtml_provider: JqhtmlLifecycleSemanticTokensProvider;
|
||||
private file_ref_provider: CommentFileReferenceSemanticTokensProvider;
|
||||
private that_provider: ThatVariableSemanticTokensProvider;
|
||||
|
||||
constructor() {
|
||||
this.jqhtml_provider = new JqhtmlLifecycleSemanticTokensProvider();
|
||||
this.file_ref_provider = new CommentFileReferenceSemanticTokensProvider();
|
||||
this.that_provider = new ThatVariableSemanticTokensProvider();
|
||||
}
|
||||
|
||||
async provideDocumentSemanticTokens(document: vscode.TextDocument): Promise<vscode.SemanticTokens> {
|
||||
// Get tokens from all providers
|
||||
const jqhtml_tokens = await this.jqhtml_provider.provideDocumentSemanticTokens(document);
|
||||
const file_ref_tokens = await this.file_ref_provider.provideDocumentSemanticTokens(document);
|
||||
const that_tokens = await this.that_provider.provideDocumentSemanticTokens(document);
|
||||
|
||||
// Decode all tokens to absolute positions
|
||||
const decoded_tokens: DecodedToken[] = [];
|
||||
|
||||
// Decode JQHTML tokens (type 0 = conventionMethod, orange)
|
||||
this.decode_tokens(jqhtml_tokens.data, 0, decoded_tokens);
|
||||
|
||||
// Decode file reference tokens (type 1 = class, teal)
|
||||
this.decode_tokens(file_ref_tokens.data, 1, decoded_tokens);
|
||||
|
||||
// Decode 'that' tokens (type 2 = macro, dark blue #569CD6 like 'this')
|
||||
this.decode_tokens(that_tokens.data, 2, decoded_tokens);
|
||||
|
||||
// Sort tokens by line, then by character
|
||||
decoded_tokens.sort((a, b) => {
|
||||
if (a.line !== b.line) {
|
||||
return a.line - b.line;
|
||||
}
|
||||
return a.char - b.char;
|
||||
});
|
||||
|
||||
// Re-encode tokens with delta encoding
|
||||
const builder = new vscode.SemanticTokensBuilder();
|
||||
for (const token of decoded_tokens) {
|
||||
builder.push(token.line, token.char, token.length, token.type, token.modifiers);
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private decode_tokens(data: Uint32Array, new_type: number, output: DecodedToken[]): void {
|
||||
let current_line = 0;
|
||||
let current_char = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i += 5) {
|
||||
const delta_line = data[i];
|
||||
const delta_char = data[i + 1];
|
||||
const length = data[i + 2];
|
||||
const modifiers = data[i + 4];
|
||||
|
||||
if (delta_line > 0) {
|
||||
current_line += delta_line;
|
||||
current_char = delta_char;
|
||||
} else {
|
||||
current_char += delta_char;
|
||||
}
|
||||
|
||||
output.push({
|
||||
line: current_line,
|
||||
char: current_char,
|
||||
length: length,
|
||||
type: new_type,
|
||||
modifiers: modifiers
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private decode_tokens_with_modifier(data: Uint32Array, new_type: number, new_modifier: number, output: DecodedToken[]): void {
|
||||
let current_line = 0;
|
||||
let current_char = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i += 5) {
|
||||
const delta_line = data[i];
|
||||
const delta_char = data[i + 1];
|
||||
const length = data[i + 2];
|
||||
|
||||
if (delta_line > 0) {
|
||||
current_line += delta_line;
|
||||
current_char = delta_char;
|
||||
} else {
|
||||
current_char += delta_char;
|
||||
}
|
||||
|
||||
output.push({
|
||||
line: current_line,
|
||||
char: current_char,
|
||||
length: length,
|
||||
type: new_type,
|
||||
modifiers: new_modifier
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
175
app/RSpade/resource/vscode_extension/src/comment_file_reference_provider.ts
Executable file
175
app/RSpade/resource/vscode_extension/src/comment_file_reference_provider.ts
Executable file
@@ -0,0 +1,175 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
/**
|
||||
* Check if position is inside a comment
|
||||
*/
|
||||
function is_in_comment(document: vscode.TextDocument, position: vscode.Position): boolean {
|
||||
const line_text = document.lineAt(position.line).text;
|
||||
const char_pos = position.character;
|
||||
|
||||
// Check for single-line comment
|
||||
const single_comment_idx = line_text.indexOf('//');
|
||||
if (single_comment_idx !== -1 && single_comment_idx < char_pos) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for block comment markers
|
||||
const doc_comment_idx = line_text.indexOf('/*');
|
||||
const doc_comment_end_idx = line_text.indexOf('*/');
|
||||
const asterisk_comment = line_text.trim().startsWith('*');
|
||||
|
||||
if (asterisk_comment || doc_comment_idx !== -1 || doc_comment_end_idx !== -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for multi-line comment by looking at text before position
|
||||
const text_before = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
|
||||
let in_block_comment = false;
|
||||
let i = 0;
|
||||
|
||||
while (i < text_before.length) {
|
||||
if (text_before.substring(i, i + 2) === '/*') {
|
||||
in_block_comment = true;
|
||||
i += 2;
|
||||
} else if (text_before.substring(i, i + 2) === '*/') {
|
||||
in_block_comment = false;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return in_block_comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the word at position, including periods
|
||||
*/
|
||||
function get_word_with_period(document: vscode.TextDocument, position: vscode.Position): { word: string, range: vscode.Range } | undefined {
|
||||
const line = document.lineAt(position.line);
|
||||
const line_text = line.text;
|
||||
const char = position.character;
|
||||
|
||||
// Find start of word (alphanumeric, underscore, period, hyphen)
|
||||
let start = char;
|
||||
while (start > 0 && /[a-zA-Z0-9_.-]/.test(line_text[start - 1])) {
|
||||
start--;
|
||||
}
|
||||
|
||||
// Find end of word
|
||||
let end = char;
|
||||
while (end < line_text.length && /[a-zA-Z0-9_.-]/.test(line_text[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
const word = line_text.substring(start, end);
|
||||
|
||||
// Must contain a period and not be just a period
|
||||
if (!word.includes('.') || word === '.') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const range = new vscode.Range(
|
||||
new vscode.Position(position.line, start),
|
||||
new vscode.Position(position.line, end)
|
||||
);
|
||||
|
||||
return { word, range };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists in the same directory as the document
|
||||
*/
|
||||
function find_file_in_same_directory(document: vscode.TextDocument, filename: string): string | undefined {
|
||||
const doc_dir = path.dirname(document.uri.fsPath);
|
||||
const file_path = path.join(doc_dir, filename);
|
||||
|
||||
if (fs.existsSync(file_path)) {
|
||||
return file_path;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides semantic tokens for file references in comments (light blue like class properties)
|
||||
*/
|
||||
export class CommentFileReferenceSemanticTokensProvider implements vscode.DocumentSemanticTokensProvider {
|
||||
async provideDocumentSemanticTokens(document: vscode.TextDocument): Promise<vscode.SemanticTokens> {
|
||||
const tokens_builder = new vscode.SemanticTokensBuilder();
|
||||
|
||||
// Only for JavaScript/TypeScript files
|
||||
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
|
||||
return tokens_builder.build();
|
||||
}
|
||||
|
||||
const text = document.getText();
|
||||
|
||||
// Find all words with periods
|
||||
const regex = /\b[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const start_pos = document.positionAt(match.index);
|
||||
|
||||
// Skip if not in a comment
|
||||
if (!is_in_comment(document, start_pos)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const word = match[0];
|
||||
|
||||
// Check if file exists in same directory
|
||||
if (find_file_in_same_directory(document, word)) {
|
||||
tokens_builder.push(
|
||||
start_pos.line,
|
||||
start_pos.character,
|
||||
word.length,
|
||||
0, // token type index for 'class' (teal)
|
||||
0 // token modifiers
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens_builder.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides "Go to Definition" for file references in comments
|
||||
*/
|
||||
export class CommentFileReferenceDefinitionProvider implements vscode.DefinitionProvider {
|
||||
async provideDefinition(
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position,
|
||||
_token: vscode.CancellationToken
|
||||
): Promise<vscode.Definition | undefined> {
|
||||
// Only for JavaScript/TypeScript files
|
||||
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Must be in a comment
|
||||
if (!is_in_comment(document, position)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get word with period
|
||||
const word_info = get_word_with_period(document, position);
|
||||
if (!word_info) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if file exists in same directory
|
||||
const file_path = find_file_in_same_directory(document, word_info.word);
|
||||
if (!file_path) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Return file location
|
||||
const file_uri = vscode.Uri.file(file_path);
|
||||
return new vscode.Location(file_uri, new vscode.Position(0, 0));
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import { get_config } from './config';
|
||||
import { LaravelCompletionProvider } from './laravel_completion_provider';
|
||||
import { blade_spacer } from './blade_spacer';
|
||||
import { init_blade_language_config } from './blade_client';
|
||||
import { ConventionMethodSemanticTokensProvider, ConventionMethodHoverProvider, ConventionMethodDiagnosticProvider, ConventionMethodDefinitionProvider } from './convention_method_provider';
|
||||
import { JqhtmlLifecycleSemanticTokensProvider, JqhtmlLifecycleHoverProvider, JqhtmlLifecycleDiagnosticProvider } from './jqhtml_lifecycle_provider';
|
||||
import { ConventionMethodHoverProvider, ConventionMethodDiagnosticProvider, ConventionMethodDefinitionProvider } from './convention_method_provider';
|
||||
import { CommentFileReferenceDefinitionProvider } from './comment_file_reference_provider';
|
||||
import { JqhtmlLifecycleHoverProvider, JqhtmlLifecycleDiagnosticProvider } from './jqhtml_lifecycle_provider';
|
||||
import { CombinedSemanticTokensProvider } from './combined_semantic_provider';
|
||||
import { PhpAttributeSemanticTokensProvider } from './php_attribute_provider';
|
||||
import { BladeComponentSemanticTokensProvider } from './blade_component_provider';
|
||||
import { AutoRenameProvider } from './auto_rename_provider';
|
||||
@@ -22,6 +24,7 @@ import { RspadeRefactorCodeActionsProvider } from './refactor_code_actions';
|
||||
import { RspadeClassRefactorProvider } from './class_refactor_provider';
|
||||
import { RspadeClassRefactorCodeActionsProvider } from './class_refactor_code_actions';
|
||||
import { RspadeSortClassMethodsProvider } from './sort_class_methods_provider';
|
||||
import { SymlinkRedirectProvider } from './symlink_redirect_provider';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -176,6 +179,11 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const git_diff_provider = new GitDiffProvider(rspade_root);
|
||||
git_diff_provider.activate(context);
|
||||
|
||||
// Register symlink redirect provider
|
||||
const symlink_redirect_provider = new SymlinkRedirectProvider();
|
||||
symlink_redirect_provider.activate(context);
|
||||
console.log('Symlink redirect provider registered - system/rsx/ files will redirect to rsx/');
|
||||
|
||||
// Register refactor provider
|
||||
const refactor_provider = new RspadeRefactorProvider(formatting_provider);
|
||||
refactor_provider.register(context);
|
||||
@@ -288,7 +296,6 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
console.log('Laravel completion provider registered for PHP files');
|
||||
|
||||
// Register convention method providers for JavaScript/TypeScript
|
||||
// Note: Semantic tokens are handled by JqhtmlLifecycleSemanticTokensProvider to avoid duplicate registration
|
||||
const convention_hover_provider = new ConventionMethodHoverProvider();
|
||||
const convention_diagnostic_provider = new ConventionMethodDiagnosticProvider();
|
||||
const convention_definition_provider = new ConventionMethodDefinitionProvider();
|
||||
@@ -312,18 +319,9 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
console.log('Convention method providers registered for JavaScript/TypeScript');
|
||||
|
||||
// Register JQHTML lifecycle method providers for JavaScript/TypeScript
|
||||
const jqhtml_semantic_provider = new JqhtmlLifecycleSemanticTokensProvider();
|
||||
const jqhtml_hover_provider = new JqhtmlLifecycleHoverProvider();
|
||||
const jqhtml_diagnostic_provider = new JqhtmlLifecycleDiagnosticProvider();
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerDocumentSemanticTokensProvider(
|
||||
[{ language: 'javascript' }, { language: 'typescript' }],
|
||||
jqhtml_semantic_provider,
|
||||
new vscode.SemanticTokensLegend(['conventionMethod'])
|
||||
)
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerHoverProvider(
|
||||
[{ language: 'javascript' }, { language: 'typescript' }],
|
||||
@@ -335,6 +333,32 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
console.log('JQHTML lifecycle providers registered for JavaScript/TypeScript');
|
||||
|
||||
// Register combined semantic tokens provider for JavaScript/TypeScript
|
||||
// This includes: JQHTML lifecycle methods (orange), file references (teal), 'that' variable (blue)
|
||||
const combined_semantic_provider = new CombinedSemanticTokensProvider();
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerDocumentSemanticTokensProvider(
|
||||
[{ language: 'javascript' }, { language: 'typescript' }],
|
||||
combined_semantic_provider,
|
||||
new vscode.SemanticTokensLegend(['conventionMethod', 'class', 'macro'])
|
||||
)
|
||||
);
|
||||
|
||||
console.log('Combined semantic tokens provider registered (JQHTML lifecycle, file references, that variable)');
|
||||
|
||||
// Register comment file reference definition provider for JavaScript/TypeScript
|
||||
const comment_file_reference_definition_provider = new CommentFileReferenceDefinitionProvider();
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerDefinitionProvider(
|
||||
[{ language: 'javascript' }, { language: 'typescript' }],
|
||||
comment_file_reference_definition_provider
|
||||
)
|
||||
);
|
||||
|
||||
console.log('Comment file reference definition provider registered for JavaScript/TypeScript');
|
||||
|
||||
// Register PHP attribute provider
|
||||
const php_attribute_provider = new PhpAttributeSemanticTokensProvider();
|
||||
|
||||
@@ -376,15 +400,6 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
);
|
||||
|
||||
// Register commands
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('rspade.toggleFolding', () => {
|
||||
const config = get_config();
|
||||
const current = config.get<boolean>('enableCodeFolding', true);
|
||||
config.update('enableCodeFolding', !current, vscode.ConfigurationTarget.Workspace);
|
||||
vscode.window.showInformationMessage(`RSpade code folding ${!current ? 'enabled' : 'disabled'}`);
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('rspade.formatPhpFile', async () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
|
||||
@@ -477,18 +477,26 @@ export class JqhtmlLifecycleDiagnosticProvider {
|
||||
|
||||
// Check for violations in method body
|
||||
if (method_name === 'on_create') {
|
||||
// Check for this.data or that.data access
|
||||
const data_access_regex = /(this|that)\.data/g;
|
||||
// Check for this.data or that.data access (reading, not assignment)
|
||||
// Note: on_create() now runs BEFORE first render, so assigning to this.data is valid
|
||||
// We only warn on reading from this.data (accessing properties without assignment)
|
||||
const data_access_regex = /(this|that)\.data\.(\w+)(\s*=)?/g;
|
||||
let data_match;
|
||||
|
||||
while ((data_match = data_access_regex.exec(method_body)) !== null) {
|
||||
// Skip if this is an assignment (has the = part in capture group 3)
|
||||
if (data_match[3]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This is a read access, not an assignment - warn about it
|
||||
const violation_pos = document.positionAt(method_body_start + data_match.index);
|
||||
const violation_end = document.positionAt(method_body_start + data_match.index + data_match[0].length);
|
||||
|
||||
diagnostics.push(
|
||||
new vscode.Diagnostic(
|
||||
new vscode.Range(violation_pos, violation_end),
|
||||
`'${data_match[0]}' is populated during on_load, which happens after on_create. Did you mean ${data_match[1]}.args?`,
|
||||
`'${data_match[0]}' is being read in on_create, but this.data should only be initialized here (assignments like 'this.data.rows = []' are OK)`,
|
||||
vscode.DiagnosticSeverity.Warning
|
||||
)
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ const FRAMEWORK_ATTRIBUTES = [
|
||||
'Ajax_Endpoint',
|
||||
'Route',
|
||||
'Auth',
|
||||
'Task',
|
||||
'Relationship',
|
||||
'Monoprogenic',
|
||||
'Instantiatable'
|
||||
|
||||
@@ -194,15 +194,11 @@ export class RspadeRefactorProvider {
|
||||
|
||||
// Check if refactor was successful
|
||||
if (result.includes('=== Refactor Complete ===') || result.trim().length > 0) {
|
||||
// Wait 3.5 seconds total (2s + 1.5s), hide panel, then reload files
|
||||
// Wait for filesystem changes to propagate then reload files
|
||||
// Note: Panel is kept open so developer can see results and warnings
|
||||
setTimeout(async () => {
|
||||
await vscode.commands.executeCommand('workbench.action.closePanel');
|
||||
|
||||
// Wait 500ms for filesystem changes to propagate (Samba/network issues)
|
||||
setTimeout(async () => {
|
||||
await this.reload_all_open_files();
|
||||
}, 500);
|
||||
}, 3500);
|
||||
await this.reload_all_open_files();
|
||||
}, 500);
|
||||
|
||||
vscode.window.showInformationMessage(
|
||||
`Successfully refactored ${method_info.class_name}::${method_info.method_name} to ${new_method_name}`
|
||||
|
||||
115
app/RSpade/resource/vscode_extension/src/symlink_redirect_provider.ts
Executable file
115
app/RSpade/resource/vscode_extension/src/symlink_redirect_provider.ts
Executable file
@@ -0,0 +1,115 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
/**
|
||||
* Redirects files opened from system/rsx/ symlink to their real location in rsx/
|
||||
*
|
||||
* The system/rsx/ directory is a symlink to rsx/ for framework compatibility,
|
||||
* but users should always edit files in the real rsx/ directory.
|
||||
*/
|
||||
export class SymlinkRedirectProvider {
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
|
||||
public activate(context: vscode.ExtensionContext) {
|
||||
// Watch for document opens and switches
|
||||
this.disposables.push(
|
||||
vscode.window.onDidChangeActiveTextEditor(editor => {
|
||||
if (editor) {
|
||||
this.check_and_redirect(editor.document);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Also check when window first opens or tabs change
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidOpenTextDocument(document => {
|
||||
this.check_and_redirect(document);
|
||||
})
|
||||
);
|
||||
|
||||
// Check currently active editor immediately
|
||||
if (vscode.window.activeTextEditor) {
|
||||
this.check_and_redirect(vscode.window.activeTextEditor.document);
|
||||
}
|
||||
|
||||
console.log('[RSpade] Symlink redirect provider activated');
|
||||
}
|
||||
|
||||
private async check_and_redirect(document: vscode.TextDocument) {
|
||||
const file_path = document.uri.fsPath;
|
||||
|
||||
// Check if this is a file in system/rsx/
|
||||
if (!file_path.includes('/system/rsx/') && !file_path.includes('\\system\\rsx\\')) {
|
||||
return; // Not in system/rsx/, no action needed
|
||||
}
|
||||
|
||||
// Find the workspace folder
|
||||
const workspace_folder = vscode.workspace.getWorkspaceFolder(document.uri);
|
||||
if (!workspace_folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace_root = workspace_folder.uri.fsPath;
|
||||
|
||||
// Extract the path after system/rsx/
|
||||
const system_rsx_pattern = /[\/\\]system[\/\\]rsx[\/\\](.*)/;
|
||||
const match = file_path.match(system_rsx_pattern);
|
||||
|
||||
if (!match) {
|
||||
return; // Pattern doesn't match
|
||||
}
|
||||
|
||||
const relative_path = match[1];
|
||||
const real_file = path.join(workspace_root, 'rsx', relative_path);
|
||||
|
||||
// Check if the real file exists
|
||||
if (!fs.existsSync(real_file)) {
|
||||
// Real file doesn't exist, this might be a framework file or something else
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[RSpade] Redirecting from system/rsx/ symlink to real file:`);
|
||||
console.log(` Symlink: ${file_path}`);
|
||||
console.log(` Real: ${real_file}`);
|
||||
|
||||
// Check if the symlink version is pinned
|
||||
const is_pinned = document.uri.scheme === 'file' &&
|
||||
vscode.window.tabGroups.activeTabGroup.activeTab?.isPinned;
|
||||
|
||||
// If pinned, unpin it first
|
||||
if (is_pinned) {
|
||||
await vscode.commands.executeCommand('workbench.action.unpinEditor');
|
||||
}
|
||||
|
||||
// Open the real file
|
||||
const real_uri = vscode.Uri.file(real_file);
|
||||
const real_document = await vscode.workspace.openTextDocument(real_uri);
|
||||
await vscode.window.showTextDocument(real_document);
|
||||
|
||||
// If the original was pinned, pin the new one
|
||||
if (is_pinned) {
|
||||
await vscode.commands.executeCommand('workbench.action.pinEditor');
|
||||
}
|
||||
|
||||
// Close the symlink version (now in background)
|
||||
// Find the tab with the symlink path and close it
|
||||
const tab_groups = vscode.window.tabGroups.all;
|
||||
for (const group of tab_groups) {
|
||||
for (const tab of group.tabs) {
|
||||
if (tab.input instanceof vscode.TabInputText &&
|
||||
tab.input.uri.fsPath === file_path) {
|
||||
await vscode.window.tabGroups.close(tab);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show brief notification
|
||||
vscode.window.setStatusBarMessage('Redirected from system/rsx/ to rsx/', 2000);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.disposables.forEach(d => d.dispose());
|
||||
}
|
||||
}
|
||||
103
app/RSpade/resource/vscode_extension/src/that_variable_provider.ts
Executable file
103
app/RSpade/resource/vscode_extension/src/that_variable_provider.ts
Executable file
@@ -0,0 +1,103 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
/**
|
||||
* Check if position is inside a string
|
||||
*/
|
||||
function is_in_string(document: vscode.TextDocument, position: vscode.Position): boolean {
|
||||
const line_text = document.lineAt(position.line).text;
|
||||
const char_pos = position.character;
|
||||
|
||||
// Simple check: count quotes before position
|
||||
let single_quotes = 0;
|
||||
let double_quotes = 0;
|
||||
|
||||
for (let i = 0; i < char_pos; i++) {
|
||||
if (line_text[i] === "'" && (i === 0 || line_text[i - 1] !== '\\')) {
|
||||
single_quotes++;
|
||||
} else if (line_text[i] === '"' && (i === 0 || line_text[i - 1] !== '\\')) {
|
||||
double_quotes++;
|
||||
}
|
||||
}
|
||||
|
||||
// If odd number of quotes, we're inside a string
|
||||
return (single_quotes % 2 === 1) || (double_quotes % 2 === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if position is inside a comment
|
||||
*/
|
||||
function is_in_comment(document: vscode.TextDocument, position: vscode.Position): boolean {
|
||||
const line_text = document.lineAt(position.line).text;
|
||||
const char_pos = position.character;
|
||||
|
||||
// Check for single-line comment
|
||||
const single_comment_idx = line_text.indexOf('//');
|
||||
if (single_comment_idx !== -1 && single_comment_idx < char_pos) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for multi-line comment by looking at text before position
|
||||
const text_before = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
|
||||
let in_block_comment = false;
|
||||
let i = 0;
|
||||
|
||||
while (i < text_before.length) {
|
||||
if (text_before.substring(i, i + 2) === '/*') {
|
||||
in_block_comment = true;
|
||||
i += 2;
|
||||
} else if (text_before.substring(i, i + 2) === '*/') {
|
||||
in_block_comment = false;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return in_block_comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides semantic tokens for 'that' variable (dark blue like 'this' keyword)
|
||||
*/
|
||||
export class ThatVariableSemanticTokensProvider implements vscode.DocumentSemanticTokensProvider {
|
||||
async provideDocumentSemanticTokens(document: vscode.TextDocument): Promise<vscode.SemanticTokens> {
|
||||
const tokens_builder = new vscode.SemanticTokensBuilder();
|
||||
|
||||
// Only for JavaScript/TypeScript files
|
||||
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
|
||||
return tokens_builder.build();
|
||||
}
|
||||
|
||||
const text = document.getText();
|
||||
|
||||
// Find all occurrences of 'that' as a standalone word
|
||||
const regex = /\bthat\b/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const start_pos = document.positionAt(match.index);
|
||||
|
||||
// Skip if inside a string
|
||||
if (is_in_string(document, start_pos)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if inside a comment
|
||||
if (is_in_comment(document, start_pos)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Highlight 'that' with same blue color as 'this' keyword
|
||||
// Using 'variable' type with 'defaultLibrary' modifier to match language variable color
|
||||
tokens_builder.push(
|
||||
start_pos.line,
|
||||
start_pos.character,
|
||||
4, // length of 'that'
|
||||
0, // token type index for 'variable'
|
||||
1 // token modifiers: bit 0 = defaultLibrary
|
||||
);
|
||||
}
|
||||
|
||||
return tokens_builder.build();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
"laravel/sanctum": "^3.3",
|
||||
"laravel/tinker": "^2.8",
|
||||
"nikic/php-parser": "^5.6",
|
||||
"sokil/php-isocodes": "^4.0",
|
||||
"sokil/php-isocodes-db-i18n": "^4.0",
|
||||
"spatie/ignition": "^1.15",
|
||||
"spatie/laravel-ignition": "2.9.0",
|
||||
"spatie/laravel-sitemap": "^7.0"
|
||||
|
||||
103
composer.lock
generated
103
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "3ef757224d67f26a3a2f53d339242d1f",
|
||||
"content-hash": "a9f0aa22360539b35117939a2f310b66",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -3711,6 +3711,107 @@
|
||||
},
|
||||
"time": "2025-06-25T14:20:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sokil/php-isocodes",
|
||||
"version": "4.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sokil/php-isocodes.git",
|
||||
"reference": "e8287cd6afaf2d663f63def3977c0aa92b85da25"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sokil/php-isocodes/zipball/e8287cd6afaf2d663f63def3977c0aa92b85da25",
|
||||
"reference": "e8287cd6afaf2d663f63def3977c0aa92b85da25",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-gettext": "*",
|
||||
"infection/infection": ">=0.11.5",
|
||||
"php-coveralls/php-coveralls": "^2.1",
|
||||
"phpmd/phpmd": "@stable",
|
||||
"phpunit/phpunit": ">=7.5.20",
|
||||
"sokil/php-isocodes-db-i18n": "^4.0.0",
|
||||
"squizlabs/php_codesniffer": "^3.4",
|
||||
"symfony/translation": "^4.4.17|^5.2",
|
||||
"vimeo/psalm": "^4.3"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gettext": "Required for gettext translation driver",
|
||||
"phpbench/phpbench": "Required to run benchmarks",
|
||||
"sokil/php-isocodes-db-i18n": "If frequent database updates is not necessary, and database with localization is required.",
|
||||
"sokil/php-isocodes-db-only": "If frequent database updates is not necessary, and only database without localization is required.",
|
||||
"symfony/translation": "Translation driver by Symfont project"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Sokil\\IsoCodes\\": [
|
||||
"src/"
|
||||
]
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Dmytro Sokil",
|
||||
"email": "dmytro.sokil@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "ISO country, subdivision, language, currency and script definitions and their translations. Based on pythons pycountry and Debian's iso-codes.",
|
||||
"support": {
|
||||
"issues": "https://github.com/sokil/php-isocodes/issues",
|
||||
"source": "https://github.com/sokil/php-isocodes/tree/4.4.0"
|
||||
},
|
||||
"time": "2025-09-16T18:13:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sokil/php-isocodes-db-i18n",
|
||||
"version": "4.0.27",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sokil/php-isocodes-db-i18n.git",
|
||||
"reference": "8854934be5f7fb7d7a1c93db938791c5135e2db6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sokil/php-isocodes-db-i18n/zipball/8854934be5f7fb7d7a1c93db938791c5135e2db6",
|
||||
"reference": "8854934be5f7fb7d7a1c93db938791c5135e2db6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"php": ">=7.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"sokil/php-isocodes": "^4.1.1"
|
||||
},
|
||||
"type": "library",
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Dmytro Sokil",
|
||||
"email": "dmytro.sokil@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Database and internationalisation filed for ISO country, subdivision, language, currency and script definitions and their translations. Based on pythons pycountry and Debian's iso-codes.",
|
||||
"support": {
|
||||
"issues": "https://github.com/sokil/php-isocodes-db-i18n/issues",
|
||||
"source": "https://github.com/sokil/php-isocodes-db-i18n/tree/4.0.27"
|
||||
},
|
||||
"time": "2025-10-06T17:14:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/backtrace",
|
||||
"version": "1.8.1",
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Manifest Modules
|
||||
@@ -277,15 +276,14 @@ return [
|
||||
// Base directories to scan (relative to base_path())
|
||||
// Can be directories (will be scanned recursively) or individual files
|
||||
'scan_directories' => [
|
||||
'rsx', // Main RSX application directory (symlinked to ../rsx)
|
||||
'app/RSpade/Core', // Core framework classes (runtime essentials)
|
||||
'app/RSpade/Modules', // Manifest support modules
|
||||
'app/RSpade/Integrations', // Integration modules (Jqhtml, Scss, etc.)
|
||||
'app/RSpade/Bundles', // Third-party bundles
|
||||
'app/RSpade/Core/Providers', // Service providers
|
||||
'app/RSpade/CodeQuality', // Code quality rules and checks
|
||||
'app/RSpade/Testing', // Testing framework classes
|
||||
'app/RSpade/temp', // Framework developer testing directory
|
||||
'rsx', // Main RSX application directory (symlinked to ../rsx)
|
||||
'app/RSpade/Core', // Core framework classes (runtime essentials)
|
||||
'app/RSpade/Modules', // Manifest support modules
|
||||
'app/RSpade/Integrations', // Integration modules (Jqhtml, Scss, etc.)
|
||||
'app/RSpade/Bundles', // Third-party bundles
|
||||
'app/RSpade/CodeQuality', // Code quality rules and checks
|
||||
'app/RSpade/Testing', // Testing framework classes
|
||||
'app/RSpade/temp', // Framework developer testing directory
|
||||
],
|
||||
|
||||
// Specific filenames to exclude from manifest scanning (anywhere in tree)
|
||||
@@ -416,7 +414,6 @@ return [
|
||||
// Note: Private fields (#private) use native browser support, not transpiled
|
||||
],
|
||||
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Console Debug Configuration
|
||||
@@ -483,5 +480,4 @@ return [
|
||||
// Playwright generation timeout in milliseconds
|
||||
'generation_timeout' => env('SSR_FPC_TIMEOUT', 30000),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -186,6 +186,31 @@
|
||||
"created_at": "2025-10-14T17:23:51+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe add_experience_id_to_sessions"
|
||||
},
|
||||
"2025_10_28_025035_create_projects_table.php": {
|
||||
"created_at": "2025-10-28T02:50:35+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe create_projects_table"
|
||||
},
|
||||
"2025_10_28_025113_create_tasks_table.php": {
|
||||
"created_at": "2025-10-28T02:51:13+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe create_tasks_table"
|
||||
},
|
||||
"2025_10_28_043559_create_clients_table.php": {
|
||||
"created_at": "2025-10-28T04:35:59+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe create_clients_table"
|
||||
},
|
||||
"2025_10_28_223500_create_geographic_data_tables.php": {
|
||||
"created_at": "2025-10-28T22:35:00+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe create_geographic_data_tables"
|
||||
},
|
||||
"2025_10_29_034934_add_soft_deletes_to_core_tables.php": {
|
||||
"created_at": "2025-10-29T03:49:34+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe add_soft_deletes_to_core_tables"
|
||||
}
|
||||
}
|
||||
}
|
||||
69
database/migrations/2025_10_28_025035_create_projects_table.php
Executable file
69
database/migrations/2025_10_28_025035_create_projects_table.php
Executable file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* IMPORTANT: Use raw MySQL queries for clarity and auditability
|
||||
* ✅ DB::statement() with raw SQL
|
||||
* ❌ Schema::create() with Blueprint
|
||||
*
|
||||
* REQUIRED: ALL tables MUST have: id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY
|
||||
* No exceptions - every table needs this exact ID column (SIGNED for easier migrations)
|
||||
*
|
||||
* Integer types: Use BIGINT for all integers, TINYINT(1) for booleans only
|
||||
* Never use unsigned - all integers should be signed
|
||||
*
|
||||
* Migrations must be self-contained - no Model/Service references
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
DB::statement("
|
||||
CREATE TABLE projects (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
site_id BIGINT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description LONGTEXT,
|
||||
client_id BIGINT NOT NULL,
|
||||
client_department_id BIGINT NULL,
|
||||
contact_id BIGINT NULL,
|
||||
status BIGINT NOT NULL DEFAULT 1,
|
||||
priority BIGINT NOT NULL DEFAULT 2,
|
||||
start_date DATE NULL,
|
||||
due_date DATE NULL,
|
||||
completed_date DATE NULL,
|
||||
budget DECIMAL(15, 2) NULL,
|
||||
notes LONGTEXT,
|
||||
created_by BIGINT NULL,
|
||||
owner_user_id BIGINT NULL,
|
||||
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
updated_by BIGINT NULL,
|
||||
|
||||
INDEX idx_site_id (site_id),
|
||||
INDEX idx_client_id (client_id),
|
||||
INDEX idx_client_department_id (client_department_id),
|
||||
INDEX idx_contact_id (contact_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_priority (priority),
|
||||
INDEX idx_created_by (created_by),
|
||||
INDEX idx_owner_user_id (owner_user_id),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_updated_at (updated_at),
|
||||
INDEX idx_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* down() method is prohibited in RSpade framework
|
||||
* Migrations should only move forward, never backward
|
||||
* You may remove this comment as soon as you see it and understand.
|
||||
*/
|
||||
};
|
||||
64
database/migrations/2025_10_28_025113_create_tasks_table.php
Executable file
64
database/migrations/2025_10_28_025113_create_tasks_table.php
Executable file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* IMPORTANT: Use raw MySQL queries for clarity and auditability
|
||||
* ✅ DB::statement() with raw SQL
|
||||
* ❌ Schema::create() with Blueprint
|
||||
*
|
||||
* REQUIRED: ALL tables MUST have: id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY
|
||||
* No exceptions - every table needs this exact ID column (SIGNED for easier migrations)
|
||||
*
|
||||
* Integer types: Use BIGINT for all integers, TINYINT(1) for booleans only
|
||||
* Never use unsigned - all integers should be signed
|
||||
*
|
||||
* Migrations must be self-contained - no Model/Service references
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
DB::statement("
|
||||
CREATE TABLE tasks (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
site_id BIGINT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description LONGTEXT,
|
||||
taskable_type VARCHAR(255) NOT NULL,
|
||||
taskable_id BIGINT NOT NULL,
|
||||
status BIGINT NOT NULL DEFAULT 1,
|
||||
priority BIGINT NOT NULL DEFAULT 2,
|
||||
due_date DATE NULL,
|
||||
completed_date DATE NULL,
|
||||
assigned_to_user_id BIGINT NULL,
|
||||
notes LONGTEXT,
|
||||
created_by BIGINT NULL,
|
||||
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
updated_by BIGINT NULL,
|
||||
|
||||
INDEX idx_site_id (site_id),
|
||||
INDEX idx_taskable (taskable_type, taskable_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_priority (priority),
|
||||
INDEX idx_assigned_to_user_id (assigned_to_user_id),
|
||||
INDEX idx_created_by (created_by),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_updated_at (updated_at),
|
||||
INDEX idx_title (title)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* down() method is prohibited in RSpade framework
|
||||
* Migrations should only move forward, never backward
|
||||
* You may remove this comment as soon as you see it and understand.
|
||||
*/
|
||||
};
|
||||
58
database/migrations/2025_10_28_043559_create_clients_table.php
Executable file
58
database/migrations/2025_10_28_043559_create_clients_table.php
Executable file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* IMPORTANT: Use raw MySQL queries for clarity and auditability
|
||||
* ✅ DB::statement() with raw SQL
|
||||
* ❌ Schema::create() with Blueprint
|
||||
*
|
||||
* REQUIRED: ALL tables MUST have: id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY
|
||||
* No exceptions - every table needs this exact ID column (SIGNED for easier migrations)
|
||||
*
|
||||
* Integer types: Use BIGINT for all integers, TINYINT(1) for booleans only
|
||||
* Never use unsigned - all integers should be signed
|
||||
*
|
||||
* Migrations must be self-contained - no Model/Service references
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
// Add new columns to existing clients table
|
||||
DB::statement("
|
||||
ALTER TABLE clients
|
||||
ADD COLUMN email VARCHAR(255) NULL AFTER website,
|
||||
ADD COLUMN fax VARCHAR(50) NULL AFTER phone,
|
||||
ADD COLUMN address_street VARCHAR(255) NULL COMMENT 'renamed from address',
|
||||
ADD COLUMN address_country VARCHAR(100) NULL DEFAULT 'USA',
|
||||
ADD COLUMN industry VARCHAR(100) NULL,
|
||||
ADD COLUMN company_size VARCHAR(20) NULL COMMENT '1-10, 11-50, 51-200, 201-500, 501-1000, 1000+',
|
||||
ADD COLUMN established_year BIGINT NULL,
|
||||
ADD COLUMN revenue_range VARCHAR(50) NULL,
|
||||
ADD COLUMN facebook_url VARCHAR(255) NULL,
|
||||
ADD COLUMN twitter_handle VARCHAR(100) NULL,
|
||||
ADD COLUMN linkedin_url VARCHAR(255) NULL,
|
||||
ADD COLUMN instagram_handle VARCHAR(100) NULL,
|
||||
ADD COLUMN tags JSON NULL,
|
||||
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active, inactive, prospect, archived',
|
||||
ADD COLUMN preferred_contact_method VARCHAR(20) NULL DEFAULT 'email' COMMENT 'email, phone, text, any',
|
||||
ADD COLUMN newsletter_opt_in TINYINT(1) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
ADD INDEX idx_email (email),
|
||||
ADD INDEX idx_status (status),
|
||||
ADD INDEX idx_deleted_at (deleted_at)
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* down() method is prohibited in RSpade framework
|
||||
* Migrations should only move forward, never backward
|
||||
* You may remove this comment as soon as you see it and understand.
|
||||
*/
|
||||
};
|
||||
76
database/migrations/2025_10_28_223500_create_geographic_data_tables.php
Executable file
76
database/migrations/2025_10_28_223500_create_geographic_data_tables.php
Executable file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* IMPORTANT: Use raw MySQL queries for clarity and auditability
|
||||
* ✅ DB::statement() with raw SQL
|
||||
* ❌ Schema::create() with Blueprint
|
||||
*
|
||||
* REQUIRED: ALL tables MUST have: id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY
|
||||
* No exceptions - every table needs this exact ID column (SIGNED for easier migrations)
|
||||
*
|
||||
* Integer types: Use BIGINT for all integers, TINYINT(1) for booleans only
|
||||
* Never use unsigned - all integers should be signed
|
||||
*
|
||||
* Migrations must be self-contained - no Model/Service references
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
// Countries table - ISO 3166-1 data
|
||||
DB::statement("
|
||||
CREATE TABLE countries (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
alpha2 CHAR(2) NOT NULL UNIQUE COMMENT 'ISO 3166-1 alpha-2 code (US, CA, GB)',
|
||||
alpha3 CHAR(3) NOT NULL COMMENT 'ISO 3166-1 alpha-3 code (USA, CAN, GBR)',
|
||||
`numeric` CHAR(3) NOT NULL COMMENT 'ISO 3166-1 numeric code (840, 124, 826)',
|
||||
name VARCHAR(255) NOT NULL COMMENT 'Official country name',
|
||||
common_name VARCHAR(255) NULL COMMENT 'Common name if different from official',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Active status - disable instead of delete to preserve FKs',
|
||||
created_at TIMESTAMP NULL DEFAULT NULL,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_alpha2 (alpha2),
|
||||
INDEX idx_alpha3 (alpha3),
|
||||
INDEX idx_enabled (enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
|
||||
// Regions table - ISO 3166-2 subdivisions (states, provinces, territories, etc.)
|
||||
DB::statement("
|
||||
CREATE TABLE regions (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
code VARCHAR(10) NOT NULL COMMENT 'ISO 3166-2 code (US-CA, CA-ON, GB-ENG)',
|
||||
country_alpha2 CHAR(2) NOT NULL COMMENT 'Foreign key to countries.alpha2',
|
||||
name VARCHAR(255) NOT NULL COMMENT 'Subdivision name (California, Ontario, England)',
|
||||
type VARCHAR(50) NULL COMMENT 'Type: state, province, territory, country, etc.',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Active status - disable instead of delete',
|
||||
created_at TIMESTAMP NULL DEFAULT NULL,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_country (country_alpha2),
|
||||
INDEX idx_code (code),
|
||||
INDEX idx_enabled (enabled),
|
||||
UNIQUE KEY unique_country_code (country_alpha2, code),
|
||||
CONSTRAINT fk_regions_country
|
||||
FOREIGN KEY (country_alpha2)
|
||||
REFERENCES countries(alpha2)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
|
||||
// Uncomment after verifying seeder works:
|
||||
// \Illuminate\Support\Facades\Artisan::call('rsx:seed:geographic-data');
|
||||
}
|
||||
|
||||
/**
|
||||
* down() method is prohibited in RSpade framework
|
||||
* Migrations should only move forward, never backward
|
||||
* You may remove this comment as soon as you see it and understand.
|
||||
*/
|
||||
};
|
||||
44
database/migrations/2025_10_29_034934_add_soft_deletes_to_core_tables.php
Executable file
44
database/migrations/2025_10_29_034934_add_soft_deletes_to_core_tables.php
Executable file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* IMPORTANT: Use raw MySQL queries for clarity and auditability
|
||||
* ✅ DB::statement("ALTER TABLE core_tables ADD COLUMN new_field VARCHAR(255)")
|
||||
* ❌ Schema::table() with Blueprint
|
||||
*
|
||||
* Migrations must be self-contained - no Model/Service references
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
// Note: clients table already has soft delete columns from previous migration
|
||||
|
||||
// Add soft delete columns to contacts table
|
||||
DB::statement("ALTER TABLE contacts ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL AFTER updated_by");
|
||||
DB::statement("ALTER TABLE contacts ADD COLUMN deleted_by BIGINT NULL DEFAULT NULL AFTER deleted_at");
|
||||
DB::statement("ALTER TABLE contacts ADD INDEX idx_deleted_at (deleted_at)");
|
||||
|
||||
// Add soft delete columns to projects table
|
||||
DB::statement("ALTER TABLE projects ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL AFTER updated_by");
|
||||
DB::statement("ALTER TABLE projects ADD COLUMN deleted_by BIGINT NULL DEFAULT NULL AFTER deleted_at");
|
||||
DB::statement("ALTER TABLE projects ADD INDEX idx_deleted_at (deleted_at)");
|
||||
|
||||
// Add soft delete columns to tasks table
|
||||
DB::statement("ALTER TABLE tasks ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL AFTER updated_by");
|
||||
DB::statement("ALTER TABLE tasks ADD COLUMN deleted_by BIGINT NULL DEFAULT NULL AFTER deleted_at");
|
||||
DB::statement("ALTER TABLE tasks ADD INDEX idx_deleted_at (deleted_at)");
|
||||
}
|
||||
|
||||
/**
|
||||
* down() method is prohibited in RSpade framework
|
||||
* Migrations should only move forward, never backward
|
||||
* You may remove this comment as soon as you see it and understand.
|
||||
*/
|
||||
};
|
||||
@@ -40,6 +40,24 @@ This separation ensures:
|
||||
|
||||
## CRITICAL RULES
|
||||
|
||||
### 🔴 RSpade Builds Automatically - NEVER RUN BUILD COMMANDS
|
||||
|
||||
**RSpade is an INTERPRETED framework** - like Python or PHP, changes are automatically detected and compiled on-the-fly. There is NO manual build step.
|
||||
|
||||
**ABSOLUTELY FORBIDDEN** (unless explicitly instructed):
|
||||
- `npm run compile` / `npm run build` - **DO NOT EXIST**
|
||||
- `bin/publish` - Creates releases for OTHER developers (not for testing YOUR changes)
|
||||
- `rsx:bundle:compile` - Bundles compile automatically in dev mode
|
||||
- `rsx:manifest:build` - Manifest rebuilds automatically in dev mode
|
||||
- ANY command with "build", "compile", or "publish"
|
||||
|
||||
**How it works**:
|
||||
1. Edit JS/SCSS/PHP files
|
||||
2. Refresh browser
|
||||
3. Changes are live (< 1 second)
|
||||
|
||||
**If you find yourself wanting to run build commands**: STOP. You're doing something wrong. Changes are already live.
|
||||
|
||||
### 🔴 Framework Updates
|
||||
|
||||
```bash
|
||||
@@ -94,6 +112,14 @@ Classes are namespacing tools. Use static unless instances needed (models, resou
|
||||
|
||||
**Correct workflow**: Edit → Save → Reload browser → See changes (< 1 second)
|
||||
|
||||
### 🔴 Trust Code Quality Rules
|
||||
|
||||
Each `rsx:check` rule has remediation text that tells AI assistants exactly what to do:
|
||||
- Some rules say "fix immediately"
|
||||
- Some rules say "present options and wait for decision"
|
||||
|
||||
AI should follow the rule's guidance precisely. Rules are deliberately written and well-reasoned.
|
||||
|
||||
---
|
||||
|
||||
## NAMING CONVENTIONS
|
||||
@@ -185,10 +211,12 @@ class Frontend_Controller extends Rsx_Controller_Abstract
|
||||
|
||||
```php
|
||||
// PHP
|
||||
Rsx::Route('User_Controller', 'show')->url(['id' => 123]);
|
||||
Rsx::Route('User_Controller', 'show', ['id' => 123]);
|
||||
Rsx::Route('User_Controller', 'show', 123); // Integer shorthand for 'id'
|
||||
|
||||
// JavaScript (identical)
|
||||
Rsx.Route('User_Controller', 'show').url({id: 123});
|
||||
Rsx.Route('User_Controller', 'show', {id: 123});
|
||||
Rsx.Route('User_Controller', 'show', 123); // Integer shorthand for 'id'
|
||||
```
|
||||
|
||||
---
|
||||
@@ -286,7 +314,11 @@ For mechanical thinkers who see structure, not visuals. Write `<User_Card>` not
|
||||
<button class="Save_Button Jqhtml_Component btn btn-primary">Save</button>
|
||||
```
|
||||
|
||||
**Interpolation**: `<%= escaped %>` | `<%== unescaped %>` | `<% javascript %>`
|
||||
**Interpolation**: `<%= escaped %>` | `<%!= unescaped %>` | `<% javascript %>`
|
||||
|
||||
**Conditional Attributes** (v2.2.162+): Apply attributes conditionally using `<% if (condition) { %>attr="value"<% } %>`
|
||||
directly in attribute context. Works with static values, interpolations, and multiple conditions per element.
|
||||
Example: `<input <% if (this.args.required) { %>required="required"<% } %> />`
|
||||
|
||||
### 🔴 CRITICAL on_load() Rules
|
||||
|
||||
@@ -314,13 +346,22 @@ class User_Card extends Jqhtml_Component {
|
||||
|
||||
### Lifecycle
|
||||
|
||||
1. **render** → Template executes, `this.data = {}` (empty)
|
||||
2. **on_render()** → Hide uninitialized UI (sync)
|
||||
3. **on_create()** → Quick setup (sync)
|
||||
1. **on_create()** → Setup default state BEFORE template (sync)
|
||||
2. **render** → Template executes with initialized state
|
||||
3. **on_render()** → Hide uninitialized UI (sync)
|
||||
4. **on_load()** → Fetch data into `this.data` (async)
|
||||
5. **on_ready()** → DOM manipulation safe (async)
|
||||
|
||||
**Double-render**: If `on_load()` modifies `this.data`, component renders twice (empty → populated).
|
||||
**on_create() now runs first** - Initialize `this.data` properties here so templates can safely reference them:
|
||||
|
||||
```javascript
|
||||
on_create() {
|
||||
this.data.rows = []; // Prevents "not iterable" errors
|
||||
this.data.loading = true; // Template can check loading state
|
||||
}
|
||||
```
|
||||
|
||||
**Double-render**: If `on_load()` modifies `this.data`, component renders twice (defaults → populated).
|
||||
|
||||
### Loading Pattern
|
||||
|
||||
@@ -372,6 +413,94 @@ For advanced topics: `php artisan rsx:man jqhtml`
|
||||
|
||||
---
|
||||
|
||||
## FORM COMPONENTS
|
||||
|
||||
Form components use the **vals() dual-mode pattern** for getting/setting values:
|
||||
|
||||
```javascript
|
||||
class My_Form extends Jqhtml_Component {
|
||||
vals(values) {
|
||||
if (values) {
|
||||
// Setter - populate form
|
||||
this.$id('name').val(values.name || '');
|
||||
this.$id('email').val(values.email || '');
|
||||
return null;
|
||||
} else {
|
||||
// Getter - extract values
|
||||
return {
|
||||
name: this.$id('name').val(),
|
||||
email: this.$id('email').val()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Validation**: `Form_Utils.apply_form_errors(form.$, errors)` - Matches by `name` attribute.
|
||||
|
||||
---
|
||||
|
||||
## MODALS
|
||||
|
||||
**Basic dialogs**:
|
||||
```javascript
|
||||
await Modal.alert("File saved");
|
||||
if (await Modal.confirm("Delete?")) { /* confirmed */ }
|
||||
let name = await Modal.prompt("Enter name:");
|
||||
```
|
||||
|
||||
**Form modals**:
|
||||
```javascript
|
||||
const result = await Modal.form({
|
||||
title: "Edit User",
|
||||
component: "User_Form",
|
||||
component_args: {data: user},
|
||||
on_submit: async (form) => {
|
||||
const values = form.vals();
|
||||
const response = await User_Controller.save(values);
|
||||
|
||||
if (response.errors) {
|
||||
Form_Utils.apply_form_errors(form.$, response.errors);
|
||||
return false; // Keep open
|
||||
}
|
||||
|
||||
return response.data; // Close and return
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Requirements**: Form component must implement `vals()` and include `<div $id="error_container"></div>`.
|
||||
|
||||
Details: `php artisan rsx:man modals`
|
||||
|
||||
---
|
||||
|
||||
## JQUERY EXTENSIONS
|
||||
|
||||
RSpade extends jQuery with utility methods:
|
||||
|
||||
**Element existence**: `$('.element').exists()` instead of `.length > 0`
|
||||
|
||||
**Component traversal**: `this.$.shallowFind('.Widget')` - Finds child elements matching selector that don't have another element of the same class as a parent between them and the component. Prevents selecting widgets from nested child components.
|
||||
|
||||
```javascript
|
||||
// Use case: Finding form widgets without selecting nested widgets
|
||||
this.$.shallowFind('.Form_Field').each(function() {
|
||||
// Only processes fields directly in this form,
|
||||
// not fields in nested sub-forms
|
||||
});
|
||||
```
|
||||
|
||||
**Sibling component lookup**: `$('.element').closest_sibling('.Widget')` - Searches for elements within progressively higher ancestors. Like `.closest()` but searches within ancestors instead of matching them. Stops at body tag. Useful for component-to-component communication.
|
||||
|
||||
**Form validation**: `$('form').checkValidity()` instead of `$('form')[0].checkValidity()`
|
||||
|
||||
**Click override**: `.click()` automatically calls `e.preventDefault()`. Use `.click_allow_default()` for native behavior.
|
||||
|
||||
For complete details: `php artisan rsx:man jquery`
|
||||
|
||||
---
|
||||
|
||||
## MODELS & DATABASE
|
||||
|
||||
### No Mass Assignment
|
||||
@@ -756,8 +885,9 @@ The include array auto-detects:
|
||||
|
||||
### Execution Order
|
||||
|
||||
- **First:** on_create() runs before anything else (setup state)
|
||||
- **Top-down:** render, on_render (parent before children)
|
||||
- **Bottom-up:** on_create, on_load, on_ready (children before parent)
|
||||
- **Bottom-up:** on_load, on_ready (children before parent)
|
||||
- **Parallel:** Siblings at same depth process simultaneously during on_load()
|
||||
|
||||
### this.args vs this.data
|
||||
@@ -825,6 +955,13 @@ public static function get_user_data(Request $request, array $params = [])
|
||||
}
|
||||
```
|
||||
|
||||
**Testing Ajax endpoints**: `php artisan rsx:ajax Controller action --site-id=1 --args='{"id":1}'`
|
||||
|
||||
Test endpoints behind auth/site scoping or invoke RPC calls from scripts. JSON-only output.
|
||||
- Default: Raw response
|
||||
- `--debug`: HTTP-like wrapper
|
||||
- `--show-context`: Display context before JSON
|
||||
|
||||
### Model Fetch Security
|
||||
|
||||
```php
|
||||
@@ -929,6 +1066,16 @@ console_debug('AJAX', 'Request sent', url, params);
|
||||
- **DuplicateCaseFilesRule** - Detect same-name different-case files (critical)
|
||||
- **MassAssignmentRule** - Prohibit $fillable arrays
|
||||
|
||||
### Trust the Rule Text
|
||||
|
||||
Each rule's remediation message specifies exactly how to handle violations:
|
||||
- What the problem is
|
||||
- Why it matters
|
||||
- How to fix it
|
||||
- Whether to fix autonomously or present options
|
||||
|
||||
**For AI assistants**: Follow the rule's guidance precisely. Don't override with "common sense" - the rule text is authoritative and deliberately written.
|
||||
|
||||
## MAIN_ABSTRACT MIDDLEWARE (EXPANDED)
|
||||
|
||||
### Execution Order
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user