diff --git a/app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js b/app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js index 0e9c685fc..33d04413e 100755 --- a/app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js +++ b/app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js @@ -74,11 +74,19 @@ class Rsx_Breadcrumb_Resolver { return generation === Rsx_Breadcrumb_Resolver._generation; } + /** + * Build cache key including build_key for invalidation on deploy + */ + static _cache_key(url) { + const build_key = window.rsxapp?.build_key || ''; + return Rsx_Breadcrumb_Resolver.CACHE_PREFIX + build_key + ':' + url; + } + /** * Get cached chain for a URL */ static _get_cached_chain(url) { - const cache_key = Rsx_Breadcrumb_Resolver.CACHE_PREFIX + url; + const cache_key = Rsx_Breadcrumb_Resolver._cache_key(url); return Rsx_Storage.session_get(cache_key); } @@ -86,7 +94,7 @@ class Rsx_Breadcrumb_Resolver { * Cache a resolved chain */ static _cache_chain(url, chain) { - const cache_key = Rsx_Breadcrumb_Resolver.CACHE_PREFIX + url; + const cache_key = Rsx_Breadcrumb_Resolver._cache_key(url); // Store simplified chain data (url, label, is_active) const cache_data = chain.map(item => ({ url: item.url, @@ -250,7 +258,7 @@ class Rsx_Breadcrumb_Resolver { */ static clear_cache(url) { if (url) { - const cache_key = Rsx_Breadcrumb_Resolver.CACHE_PREFIX + url; + const cache_key = Rsx_Breadcrumb_Resolver._cache_key(url); Rsx_Storage.session_remove(cache_key); } } diff --git a/app/RSpade/CodeQuality/Rules/JavaScript/JqhtmlComponentImplementation_CodeQualityRule.php b/app/RSpade/CodeQuality/Rules/JavaScript/JqhtmlComponentImplementation_CodeQualityRule.php index 81df0adb1..613093091 100644 --- a/app/RSpade/CodeQuality/Rules/JavaScript/JqhtmlComponentImplementation_CodeQualityRule.php +++ b/app/RSpade/CodeQuality/Rules/JavaScript/JqhtmlComponentImplementation_CodeQualityRule.php @@ -5,6 +5,7 @@ namespace App\RSpade\CodeQuality\Rules\JavaScript; use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract; use App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException; use App\RSpade\Core\Cache\RsxCache; +use App\RSpade\Core\Manifest\Manifest; /** * Check Component implementations for common AI agent mistakes @@ -47,8 +48,26 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst return; } - // Skip if not a Component subclass - if (!isset($metadata['extends']) || $metadata['extends'] !== 'Component') { + // Skip if no class defined + if (!isset($metadata['class'])) { + return; + } + + // Check if this is a Component subclass (directly or indirectly) + $is_component_subclass = false; + if (isset($metadata['extends']) && $metadata['extends'] === 'Component') { + $is_component_subclass = true; + } elseif (isset($metadata['extends'])) { + // Check inheritance chain via manifest + try { + $is_component_subclass = Manifest::js_is_subclass_of($metadata['class'], 'Component'); + } catch (\Exception $e) { + // Manifest not ready or class not found - skip + $is_component_subclass = false; + } + } + + if (!$is_component_subclass) { return; } @@ -59,6 +78,16 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst return; } + // Check for on_destroy method using manifest metadata (the correct method is on_stop) + if (!empty($metadata['public_instance_methods'])) { + foreach ($metadata['public_instance_methods'] as $method_name => $method_info) { + if ($method_name === 'on_destroy') { + $line = $method_info['line'] ?? 1; + $this->throw_on_destroy_error($file_path, $line, $metadata['class']); + } + } + } + $lines = explode("\n", $contents); // Check for render() method and incorrect lifecycle methods @@ -151,11 +180,48 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst $error_message .= "- on_create(): Called when component is created\n"; $error_message .= "- on_load(): Called to load async data\n"; $error_message .= "- on_ready(): Called when component is ready in DOM\n"; - $error_message .= "- on_destroy(): Called when component is destroyed\n\n"; + $error_message .= "- on_stop(): Called when component is destroyed\n\n"; $error_message .= "FIX:\n"; $error_message .= "Rename '{$method_name}()' to 'on_{$method_name}()'\n"; $error_message .= "=========================================="; throw new YoureDoingItWrongException($error_message); } + + private function throw_on_destroy_error(string $file_path, int $line_number, string $class_name): void + { + $error_message = "==========================================\n"; + $error_message .= "FATAL: Jqhtml component uses incorrect lifecycle method name\n"; + $error_message .= "==========================================\n\n"; + $error_message .= "File: {$file_path}\n"; + $error_message .= "Line: {$line_number}\n"; + $error_message .= "Class: {$class_name}\n\n"; + $error_message .= "The method 'on_destroy()' does not exist in jqhtml components.\n\n"; + $error_message .= "PROBLEM:\n"; + $error_message .= "The correct lifecycle method for cleanup is 'on_stop()', not 'on_destroy()'.\n"; + $error_message .= "This is a common mistake from other frameworks.\n\n"; + $error_message .= "INCORRECT:\n"; + $error_message .= " class {$class_name} extends Component {\n"; + $error_message .= " on_destroy() {\n"; + $error_message .= " // cleanup code\n"; + $error_message .= " }\n"; + $error_message .= " }\n\n"; + $error_message .= "CORRECT:\n"; + $error_message .= " class {$class_name} extends Component {\n"; + $error_message .= " on_stop() {\n"; + $error_message .= " // cleanup code\n"; + $error_message .= " }\n"; + $error_message .= " }\n\n"; + $error_message .= "LIFECYCLE METHODS:\n"; + $error_message .= "- on_create(): Called when component is created (sync)\n"; + $error_message .= "- on_render(): Called after template renders (sync)\n"; + $error_message .= "- on_load(): Called to load async data\n"; + $error_message .= "- on_ready(): Called when component is ready in DOM\n"; + $error_message .= "- on_stop(): Called when component is destroyed (sync)\n\n"; + $error_message .= "FIX:\n"; + $error_message .= "Rename 'on_destroy()' to 'on_stop()'\n"; + $error_message .= "=========================================="; + + throw new YoureDoingItWrongException($error_message); + } } \ No newline at end of file diff --git a/app/RSpade/upstream_changes/form_value_persistence_12_29.txt b/app/RSpade/upstream_changes/form_value_persistence_12_29.txt new file mode 100755 index 000000000..4b3827e95 --- /dev/null +++ b/app/RSpade/upstream_changes/form_value_persistence_12_29.txt @@ -0,0 +1,129 @@ +FORM VALUE PERSISTENCE - MIGRATION GUIDE +Date: 2025-12-29 + +SUMMARY + + When using stale-while-revalidate caching, forms may render from cache and then + re-render with fresh data after cache revalidation. If a user modifies an input + between these two renders, their changes would be lost. This update adds automatic + tracking and restoration of user-modified form values across re-renders within + the same parent action context. + + The system works by storing changed values on the parent component (or the form + itself if no parent exists). When the form re-renders, cached values are merged + back into the form state before widgets are populated. + +AFFECTED FILES + + Theme form component: + - rsx/theme/components/forms/rsx_form.js + + Widget components (contract requirement): + - Any custom Widget that doesn't fire 'input' events won't participate in caching + +CHANGES REQUIRED + + 1. Add on_render() Method to Rsx_Form + + Add this method after on_create(): + + on_render() { + // Form value persistence across cache revalidation re-renders. + // When a form renders from cache, the user may change inputs before + // the cache revalidates and the form re-renders with fresh data. + // This system caches user changes and re-applies them after re-render. + + // Determine cache storage location and key + const cache_location = this.parent() || this; + const cache_key = this.parent() ? `__formvals_${this._cid}` : '__this_formvals'; + + // Initialize cache if it doesn't exist + if (!cache_location[cache_key]) { + cache_location[cache_key] = {}; + } + const cache = cache_location[cache_key]; + + // If cache has values from prior render, merge into state.values + // These will be applied when on_ready calls vals() + if (Object.keys(cache).length > 0) { + Object.assign(this.state.values, cache); + } + + // Register input listeners on all widgets to track user changes + const that = this; + this.$.shallowFind('.Widget').each(function () { + const $widget = $(this); + const component = $widget.component(); + if (component && 'on' in component) { + const widget_name = $widget.data('name'); + if (widget_name) { + component.on('input', function (comp, value) { + cache[widget_name] = value; + }); + } + } + }); + } + + 2. Widget Contract Requirements + + For form value persistence to work, Widget components must: + + a) Have a data-name attribute (set via $name on Form_Field) + b) Implement component.on('input', callback) event registration + c) Fire the 'input' event when the user changes the value + d) NOT fire 'input' when val() is called programmatically + + Standard framework widgets (Text_Input, Select_Input, Checkbox_Input, etc.) + already meet these requirements. Custom widgets should be verified. + +HOW IT WORKS + + 1. First Render (from cache): + - on_create: parses $data into this.state.values + - on_render: initializes cache on parent, registers input listeners + - on_ready: loads values into widgets via vals() + - User modifies a checkbox + - Input listener fires, stores { checkbox_name: new_value } in cache + + 2. Second Render (cache revalidated): + - on_create: parses fresh $data into this.state.values + - on_render: finds existing cache, merges cached values into this.state.values + - on_ready: loads merged values (server data + user changes) into widgets + - User's checkbox change is preserved + + Cache Storage: + - If form has a parent: stored at parent.__formvals_{form_cid} + - If form has no parent: stored at form.__this_formvals + + Cache Lifecycle: + - Created when form first renders + - Persists as long as parent component exists + - Automatically cleared when parent is destroyed (navigating away) + +CONFIGURATION + + No configuration required. The feature is automatic. + +VERIFICATION + + 1. Open a page with a form that uses stale-while-revalidate caching + + 2. Quickly modify a form input (checkbox, text field, etc.) before + the cache revalidates + + 3. Wait for the form to re-render with fresh data + + 4. Verify that your change is preserved after re-render + + 5. In browser console, you can inspect the cache: + // If form has a parent: + Spa.action.__formvals_form + + // Or check what's stored: + console.log(Object.keys(Spa.action).filter(k => k.startsWith('__formvals'))); + +REFERENCE + + php artisan rsx:man form_conventions + rsx/theme/components/forms/rsx_form.js diff --git a/docs/CLAUDE.archive.12.29.25.md b/docs/CLAUDE.archive.12.29.25.md new file mode 100755 index 000000000..41e89b3a7 --- /dev/null +++ b/docs/CLAUDE.archive.12.29.25.md @@ -0,0 +1,1314 @@ +# RSpade Framework - AI/LLM Development Guide + +**PURPOSE**: Essential directives for AI/LLM assistants developing RSX applications with RSpade. + +## CRITICAL: Questions vs Commands + +- **Questions get answers, NOT actions** - "Is that fire?" gets "Yes" not "Let me run through it". User has a plan, don't take destructive action when asked a question. +- **Commands get implementation** - Clear directives result in code changes + +## What is RSpade? + +**Visual Basic-like development for PHP/Laravel.** Think: VB6 apps → VB6 runtime → Windows = RSX apps → RSpade runtime → Laravel. + +**Philosophy**: RSpade is rebellion against JavaScript fatigue. Like VB6 made Windows programming accessible, RSpade makes web development simple again. No build steps, no config hell, just code and reload. + +**Important**: RSpade is built on Laravel but diverges significantly. Do not assume Laravel patterns work in RSX without verification. + +**Terminology**: **RSpade** = Complete framework | **RSX** = Your application code in `/rsx/` + +--- + +## CRITICAL RULES + +### CRITICAL: RSpade Builds Automatically - NEVER RUN BUILD COMMANDS + +**RSpade is INTERPRETED** - changes compile on-the-fly. NO manual build steps. + +**FORBIDDEN** (unless explicitly instructed): +- `npm run compile/build` - Don't exist +- `bin/publish` - For releases, not testing +- `rsx:bundle:compile` / `rsx:manifest:build` / `rsx:clean` - Automatic +- ANY "build", "compile", or "publish" command + +Edit → Save → Refresh browser → Changes live (< 1 second) + +### Framework Updates + +```bash +php artisan rsx:framework:pull # User-initiated only +``` + +### Fail Loud - No Silent Fallbacks + +**ALWAYS fail visibly.** No redundant fallbacks, silent failures, or alternative code paths. + +```php +// ❌ WRONG +try { $clean = Sanitizer::sanitize($input); } +catch (Exception $e) { $clean = $input; } // DISASTER + +// ✅ CORRECT +$clean = Sanitizer::sanitize($input); // Let it throw +``` + +**SECURITY-CRITICAL**: Never continue after security failures. Only catch expected failures (file uploads, APIs, user input). Let exceptions bubble to global handler. + +### No Defensive Coding + +Core classes ALWAYS exist. Never check. + +```javascript +// ❌ BAD: if (typeof Rsx !== 'undefined') { Rsx.Route('Controller::method') } +// ✅ GOOD: Rsx.Route('Controller::method') +``` + +### Static-First Philosophy + +Classes are namespacing tools. Use static unless instances needed (models, resources). Avoid dependency injection. + +### No Field Aliasing + +**Field names must be identical across all layers.** Database → PHP → JSON → JavaScript: same names, always. + +```php +// ❌ WRONG - renaming during serialization +return ['type_label' => $contact->type_id_label]; + +// ✅ CORRECT - names match source +return ['type_id_label' => $contact->type_id_label]; +``` + +One string everywhere. Grep finds all usages. No mental mapping between layers. + +### Git Workflow - Framework is READ-ONLY + +**NEVER modify `/var/www/html/system/`** - It's like node_modules or the Linux kernel. + +- **App repo**: `/var/www/html/.git` (you control) +- **Framework**: `/var/www/html/system/` (submodule, don't touch) +- **Your code**: `/var/www/html/rsx/` (all changes here) + +**Commit discipline**: ONLY commit when explicitly asked. Commits are milestones, not individual changes. + +### Class Overrides + +To customize framework classes without modifying `/system/`, copy them to `rsx/` with the same class name. The manifest automatically uses your version and renames the framework file to `.upstream`. + +**Common override targets** (copy from `system/app/RSpade/Core/Models/`): +- `User_Model` - Add custom fields, relationships, methods +- `User_Profile_Model` - Extend profile data +- `Site_Model` - Add site-specific settings + +```bash +cp system/app/RSpade/Core/Models/User_Model.php rsx/models/user_model.php +# Edit namespace to Rsx\Models, customize as needed +``` + +Details: `php artisan rsx:man class_override` + +### 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 + +**Enforced by `rsx:check`**: + +| Context | Convention | Example | +|---------|------------|---------| +| PHP Methods/Variables | `underscore_case` | `user_name` | +| PHP Classes | `Like_This` | `User_Controller` | +| JavaScript Classes | `Like_This` | `User_Card` | +| Files | `lowercase_underscore` | `user_controller.php` | +| Database Tables | `lowercase_plural` | `users` | +| Constants | `UPPERCASE` | `MAX_SIZE` | + +### File Prefix Grouping + +Files sharing a common prefix are a related set. When renaming, maintain the grouping across ALL files with that prefix. + +**Example** - `rsx/app/frontend/calendar/`: +``` +frontend_calendar_event.scss +frontend_calendar_event_controller.php +frontend_calendar_event.jqhtml +frontend_calendar_event.js +``` + +**Critical**: Never create same-name different-case files (e.g., `user.php` and `User.php`). + +### Component Naming Pattern + +Input components follow: `{Supertype}_{Variant}_{Supertype}` → e.g., `Select_Country_Input`, `Select_State_Input`, `Select_Ajax_Input` + +--- + +## DIRECTORY STRUCTURE + +``` +/var/www/html/ +├── rsx/ # YOUR CODE +│ ├── app/ # Modules +│ ├── models/ # Database models +│ ├── services/ # Background tasks, external integrations +│ ├── public/ # Static files (web-accessible) +│ ├── resource/ # Framework-ignored +│ └── theme/ # Global assets +└── system/ # FRAMEWORK (read-only) +``` + +**Services**: Extend `Rsx_Service_Abstract` for non-HTTP functionality like scheduled tasks or external system integrations. + +### Special Directories (Path-Agnostic) + +**`resource/`** - ANY directory named this is framework-ignored. Store helpers, docs, third-party code. Exception: `/rsx/resource/config/` IS processed. + +**`public/`** - ANY directory named this is web-accessible, framework-ignored. 5min cache, 30d with `?v=`. + +### Path-Agnostic Loading + +Classes found by name, not path. No imports needed. + +```php +$user = User_Model::find(1); // Framework finds it +// NOT: use Rsx\Models\User_Model; // Auto-generated +``` + +--- + +## CONFIGURATION + +**Two-tier system**: +- **Framework**: `/system/config/rsx.php` (never modify) +- **User**: `/rsx/resource/config/rsx.php` (your overrides) + +Merged via `array_merge_deep()`. Common overrides: `development.auto_rename_files`, `bundle_aliases`, `console_debug`. + +--- + +## ROUTING & CONTROLLERS + +```php +class Frontend_Controller extends Rsx_Controller_Abstract +{ + public static function pre_dispatch(Request $request, array $params = []) + { + if (!Session::is_logged_in()) return response_unauthorized(); + return null; + } + + #[Route('/', methods: ['GET'])] + public static function index(Request $request, array $params = []) + { + return rsx_view('Frontend_Index', [ + 'bundle' => Frontend_Bundle::render() + ]); + } +} +``` + +**Rules**: Only GET/POST. Use `:param` syntax. Manual auth checks in pre_dispatch or method body. + +### Authentication Pattern + +```php +// Controller-wide auth (recommended) +public static function pre_dispatch(Request $request, array $params = []) { + if (!Session::is_logged_in()) return response_unauthorized(); + return null; +} + +``` + +### Type-Safe URLs + +**MANDATORY**: All URLs must be generated using `Rsx::Route()` - hardcoded URLs are forbidden. + +```php +// PHP - Controller (defaults to 'index' method) +Rsx::Route('User_Controller') + +// PHP - Controller with explicit method. Passing an integer for param two implies the param two is 'id' (id=123) +Rsx::Route('User_Controller::show', 123); + +// PHP - With query parameters. Extra params not defined in route itself become query string - automatically URL-encoded +Rsx::Route('Login_Controller::logout', ['redirect' => '/dashboard']); +// Generates: /logout?redirect=%2Fdashboard + +// JavaScript (identical syntax) +Rsx.Route('User_Controller') +Rsx.Route('User_Controller::show', 123); +Rsx.Route('Login_Controller::logout', {redirect: '/dashboard'}); +``` +- **Unimplemented routes**: Prefix method with `#` → `Rsx::Route('Feature::#index')` generates `href="#"` and bypasses validation + +**Enforcement**: `rsx:check` will flag hardcoded URLs like `/login` or `/logout?redirect=...` and require you to use `Rsx::Route()`. Do it right the first time to avoid rework. + +--- + +## SPA (SINGLE PAGE APPLICATION) ROUTING + +Client-side routing for authenticated application areas. One PHP bootstrap controller, multiple JavaScript actions that navigate without page reloads. + +### SPA Components + +**1. PHP Bootstrap Controller** - ONE per module with auth in pre_dispatch +```php +public static function pre_dispatch(Request $request, array $params = []) { + if (!Session::is_logged_in()) return response_unauthorized(); + return null; +} + +#[SPA] +public static function index(Request $request, array $params = []) { + return rsx_view(SPA); +} +``` +One #[SPA] per module at `rsx/app/(module)/(module)_spa_controller::index`. Segregates code by permission level. + +**2. JavaScript Actions (MANY)** +```javascript +@route('/contacts') +@layout('Frontend_Layout') +@spa('Frontend_Spa_Controller::index') +@title('Contacts') // Optional browser title +class Contacts_Index_Action extends Spa_Action { + async on_load() { + this.data.contacts = await Frontend_Contacts_Controller.datagrid_fetch(); + } +} +``` + +**3. Layout** +```javascript +class Frontend_Layout extends Spa_Layout { + on_action(url, action_name, args) { + // Called after action created, before on_ready + // Access this.action immediately + this.update_navigation(url); + } +} +``` + +Layout template must have `$sid="content"` element where actions render. + +### URL Generation & Navigation + +```php +// PHP/JavaScript - same syntax +Rsx::Route('Contacts_Index_Action') // /contacts +Rsx::Route('Contacts_View_Action', 123) // /contacts/123 +``` + +```javascript +Spa.dispatch('/contacts/123'); // Programmatic navigation +Spa.layout // Current layout instance +Spa.action // Current action instance +``` + +### URL Parameters + +```javascript +// URL: /contacts/123?tab=history +@route('/contacts/:id') +class Contacts_View_Action extends Spa_Action { + on_create() { + console.log(this.args.id); // "123" (route param) + console.log(this.args.tab); // "history" (query param) + } +} +``` + +### File Organization + +Pattern: `/rsx/app/(module)/(feature)/` +- **Module**: Major functionality (login, frontend, root) +- **Feature**: Screen within module (contacts, reports, invoices) +- **Submodule**: Feature grouping (settings), often with sublayouts + +``` +/rsx/app/frontend/ # Module +├── Frontend_Spa_Controller.php # Single SPA bootstrap +├── Frontend_Layout.js +├── Frontend_Layout.jqhtml +└── contacts/ # Feature + ├── frontend_contacts_controller.php # Ajax endpoints only + ├── Contacts_Index_Action.js # /contacts + ├── Contacts_Index_Action.jqhtml + ├── Contacts_View_Action.js # /contacts/:id + └── Contacts_View_Action.jqhtml +``` + +**Use SPA for:** Authenticated areas, dashboards, admin panels +**Avoid for:** Public pages (SEO needed), simple static pages + +### Sublayouts + +**Sublayouts** are `Spa_Layout` classes for nested persistent UI (e.g., settings sidebar). Use multiple `@layout` decorators - first is outermost: `@layout('Frontend_Spa_Layout')` then `@layout('Settings_Layout')`. Each must have `$sid="content"`. Layouts persist when unchanged; only differing parts recreated. All receive `on_action(url, action_name, args)` with final action info. + +Details: `php artisan rsx:man spa` + +### View Action Pattern + +Three-state pattern for data-loading actions: + +```javascript +on_create() { + this.data.record = { name: '' }; // Stub + this.data.error_data = null; + this.data.loading = true; +} +async on_load() { + try { this.data.record = await Controller.get({id: this.args.id}); } + catch (e) { this.data.error_data = e; } + this.data.loading = false; +} +``` + +Template: `` → `` → content. Details: `rsx:man view_action_patterns` + +--- + +## CONVERTING BLADE PAGES TO SPA ACTIONS + +For converting server-side Blade pages to client-side SPA actions, see `php artisan rsx:man blade_to_spa`. +The process involves creating Action classes with @route decorators and converting templates from Blade to jqhtml syntax. + +--- + +## BLADE & VIEWS + +**Note**: SPA pages are the preferred standard. Use Blade only for SEO-critical public pages or authentication flows. + +```blade +@rsx_id('Frontend_Index') {{-- Every view starts with this --}} + {{-- Adds view class --}} +``` + +**NO inline styles, scripts, or event handlers** - Use companion `.scss` and `.js` files. +**jqhtml components** work fully in Blade (no slots). + +### SCSS Component-First Architecture + +SCSS in `rsx/app/` and `rsx/theme/components/` must wrap in a single component class matching the jqhtml/blade file. Components auto-render with `class="Component_Name"` on root. `rsx/lib/` is non-visual. `rsx/theme/` (outside components/) holds primitives, variables, Bootstrap overrides. + +**BEM**: Child classes use component's exact name as prefix. `.Component_Name { &__element }` → HTML: `
` (no kebab-case). + +**Variables**: `rsx/theme/variables.scss` - must be included before directory includes in bundles. Multiple SCSS files can target same component if primary file exists. + +Details: `php artisan rsx:man scss` + +### Responsive Breakpoints + +RSX replaces Bootstrap breakpoints with semantic names. **Bootstrap defaults (col-md-6, d-lg-none) do NOT work.** + +| Tier 1 | Range | Tier 2 | Range | +|--------|-------|--------|-------| +| `mobile` | 0-1023px | `phone` | 0-799px | +| `desktop` | 1024px+ | `tablet` | 800-1023px | +| | | `desktop-sm` | 1024-1199px | +| | | `desktop-md` | 1200-1639px | +| | | `desktop-lg` | 1640-2199px | +| | | `desktop-xl` | 2200px+ | + +**SCSS**: `@include mobile { }`, `@include desktop { }`, `@include phone { }`, `@include desktop-xl { }`, etc. +**Classes**: `.col-mobile-6`, `.d-desktop-none`, `.mobile-only`, `.hide-tablet` +**JS**: `Responsive.is_mobile()`, `Responsive.is_phone()`, `Responsive.is_desktop_xl()`, etc. + +Details: `php artisan rsx:man responsive` + +### JavaScript for Blade Pages + +Unlike SPA actions (which use component lifecycle), Blade pages use static `on_app_ready()` with a page guard: + +```javascript +class My_Page { // Matches @rsx_id('My_Page') + static on_app_ready() { + if (!$('.My_Page').exists()) return; // Guard required - fires for ALL pages in bundle + // Page code here + } +} +``` + +### Passing Data to JavaScript + +Use `@rsx_page_data` for page-specific data needed by JavaScript (IDs, config, etc.): + +```blade +@rsx_page_data(['user_id' => $user->id, 'mode' => 'edit']) + +@section('content') + +@endsection +``` + +Access in JavaScript: +```javascript +const user_id = window.rsxapp.page_data.user_id; +``` + +Use when data doesn't belong in DOM attributes. Multiple calls merge together. + +--- + +## BUNDLE SYSTEM + +**One bundle per module (rsx/app/(module)).** Compiles JS/CSS automatically on request - no manual build steps. + +```php +class Frontend_Bundle extends Rsx_Bundle_Abstract +{ + public static function define(): array + { + return [ + 'include' => [ + 'jquery', // Required + 'lodash', // Required + 'rsx/theme/variables.scss', // Order matters + 'rsx/theme', // Everything else from theme - but variables.scss will be first + 'rsx/app/frontend', // Directory - + 'rsx/models', // For JS stubs + ], + ]; + } +} +``` + +```blade + + {!! Frontend_Bundle::render() !!} + +``` + +--- + +## JQHTML COMPONENTS + +### Philosophy + +For mechanical thinkers who see structure, not visuals. Write `` not `
`. Name what things ARE. + +### Template Syntax + +**CRITICAL: `` IS the element, not a wrapper** + +```jqhtml + + + Save + + + + +``` + +**Interpolation**: `<%= escaped %>` | `<%!= unescaped %>` | `<% javascript %>` +**Conditional Attributes** `required="required"<% } %> />` +**Inline Logic**: `<% this.handler = () => action(); %>` then `@click=this.handler` - No JS file needed for simple components +**Event Handlers**: `@click=this.method` (unquoted) - Methods defined inline or in companion .js +**Validation**: `<% if (!this.args.required) throw new Error('Missing arg'); %>` - Fail loud in template + +### Simple Components (No JS File) + +```jqhtml + + <% + if (!this.args.csv_data) throw new Error('csv_data required'); + const rows = this.args.csv_data.split('\n').map(r => r.split(',')); + this.toggle = () => { this.args.expanded = !this.args.expanded; this.render(); }; + %> + + <% for (let row of rows) { %> + <% for (let cell of row) { %><% } %> + <% } %> +
<%= cell %>
+ +
+``` + +Use inline JS for simple transformations/handlers. Create .js file when JS overwhelms template or needs external data. + +### State Management Rules (ENFORCED) + +**Quick Guide:** +- Loading from API? → Use `this.data` in `on_load()` +- Need reload with different params? → Modify `this.args`, call `reload()` +- UI state (toggles, selections)? → Use `this.state` + +**this.args** - Component arguments (read-only in on_load(), modifiable elsewhere) +**this.data** - Ajax-loaded data (writable ONLY in on_create() and on_load()) +**this.state** - Arbitrary component state (modifiable anytime) + +```javascript +// WITH Ajax data +class Users_List extends Component { + on_create() { + this.data.users = []; // Defaults + } + async on_load() { + this.data.users = await User_Controller.fetch({filter: this.args.filter}); + } + on_ready() { + // Change filter → reload + this.args.filter = 'new'; + this.reload(); + } +} + +// WITHOUT Ajax data +class Toggle_Button extends Component { + on_create() { + this.state = {is_open: false}; + } + on_ready() { + this.$.on('click', () => { + this.state.is_open = !this.state.is_open; + }); + } +} +``` + +**on_load() restrictions** (enforced): +- ✅ Read `this.args`, write `this.data` +- ❌ NO DOM access, NO `this.state`, NO modifying `this.args` + +### Lifecycle + +1. **on_create()** → Setup defaults (sync) - `this.data.rows = []; this.data.loading = true;` +2. **render** → Template executes +3. **on_render()** → Hide uninitialized UI (sync) +4. **on_load()** → Fetch data into `this.data` (async) +5. **on_ready()** → DOM manipulation safe (async) + +If `on_load()` modifies `this.data`, component renders twice (defaults → populated). + +### Component API + +**DOM Access:** + +| Method | Returns | Purpose | +|--------|---------|---------| +| `this.$` | jQuery | Root element (NOT `this.$el`) | +| `this.$sid('name')` | jQuery | Child with `$sid="name"` | +| `this.sid('name')` | Component/null | Child component instance | + +**reload() vs render():** +``` +reload() = on_load() → render() → on_ready() ← ALWAYS USE THIS +render() = template only (no on_ready) ← NEVER USE +``` + +After mutations, call `this.reload()` - the server round-trip is intentional: +```javascript +async add_item() { + await Controller.add({name: 'Test'}); + this.reload(); // Refreshes this.data via on_load(), reattaches handlers via on_ready() +} +``` + +**Event handlers** go in `on_ready()` - they auto-reattach after reload. **WRONG:** Event delegation like `this.$.on('click', '[data-sid="btn"]', handler)` to "survive" render calls - use `reload()` instead. + +**this.data rules (enforced):** Writable only in `on_create()` (defaults) and `on_load()` (fetched data). Read-only elsewhere. + +**on_render():** Ignore - use `on_ready()` for post-render work. + +### Loading Pattern + +```javascript +async on_load() { + const result = await Product_Controller.list({page: 1}); + this.data.products = result.products; + this.data.loaded = true; // Simple flag at END +} +``` + +```jqhtml +<% if (!this.data.loaded) { %> + Loading... +<% } else { %> + +<% } %> +``` + +**NEVER call `this.render()` in `on_load()` - automatic re-render happens.** + +### Attributes + +- **`$quoted="string"`** → String literal +- **`$unquoted=expression`** → JavaScript expression +- **`$sid="name"`** → Scoped element ID +- **`attr="<%= expr %>"`** → HTML attribute with interpolation + +**Key restrictions:** +- **`` attributes are static** - No `<%= %>` on the `` tag. For dynamic attributes on the root element, use inline JS: `<% this.$.attr('data-id', this.args.id); %>` +- **`$prefix` = component args, NOT HTML attributes** - `` creates `this.args['data-id']`, not a `data-id` DOM attribute +- **Conditional attributes use if-statements** - `<% if (cond) { %>checked<% } %>` not ternaries + +### Component Access + +**$sid** attribute = "scoped ID" - unique within component instance + +From within component methods: +- **this.$** → jQuery selector for the component element itself +- **this.$sid(name)** → jQuery selector for child element with `$sid="name"` +- **this.sid(name)** → Component instance of child (or null if not a component) +- **$(selector).component()** → Get component instance from jQuery element +- **`await $(selector).component().ready()`** → Await component initialization. Rarely needed - `on_ready()` auto-waits for children created during render. Use for dynamically created components or Blade page JS interaction. + +### Custom Component Events + +Fire: `this.trigger('event_name', data)` | Listen: `this.sid('child').on('event_name', (component, data) => {})` + +**Key difference from jQuery**: Events fired BEFORE handler registration still trigger the callback when registered. This solves component lifecycle timing issues where child events fire before parent registers handlers. Never use `this.$.trigger()` for custom events (enforced by JQHTML-EVENT-01). + +### Dynamic Component Creation + +To dynamically create/replace a component in JavaScript: +```javascript +// Destroys existing component (if any) and creates new one in its place +$(selector).component('Component_Name', { arg1: value1, arg2: value2 }); + +// Example: render a component into a container +this.$sid('result_container').component('My_Component', { + data: myData, + some_option: true +}); +``` + +**Class preservation**: Only PascalCase component names (capital start, no `__`) are replaced. Utility classes (`text-muted`), BEM child classes (`Parent__child`), and all attributes are preserved. + +### Incremental Scaffolding + +**Undefined components work immediately** - they render as div with the component name as a class. + +```blade + + + + +``` + +### Key Pitfalls + +- `` IS the element - use `tag=""` attribute +- `this.data` starts `{}` - set defaults in `on_create()` +- `this.data` writable only in `on_create()` and `on_load()` +- `on_load()`: only `this.args` and `this.data` (no DOM, no `this.state`) +- `this.state` for UI state, `this.args` + `reload()` for refetch +- `Controller.method()` not `$.ajax()` - #[Ajax_Endpoint] auto-callable +- `on_create/render/stop` sync; `this.sid()` → component, `$(el).component()` → component + +--- + +## FORM COMPONENTS + +**Form fields** (`` with `$data`, `$controller`, `$method`): +```blade + + + + + + + +``` + +- **Form_Field** - Standard formatted field with label, errors, help text +- **Form_Field_Hidden** - Single-tag hidden input (extends Form_Field_Abstract) +- **Form_Field_Abstract** - Base class for custom formatting (advanced) + +**Disabled fields**: Use `$disabled=true` attribute on input components to disable fields. Unlike standard HTML, disabled fields still return values via `vals()` (useful for read-only data that should be submitted). + +```blade + + + +``` + +**Form component classes** use the **vals() dual-mode pattern**: + +```javascript +class My_Form extends Component { + vals(values) { + if (values) { + // Setter - populate form + this.$sid('name').val(values.name || ''); + return null; + } else { + // Getter - extract values + return {name: this.$sid('name').val()}; + } + } +} +``` + +**Validation**: `Form_Utils.apply_form_errors(form.$, errors)` - Matches by `name` attribute. + +### Form Conventions (Action/Controller Pattern) + +Forms follow a load/save pattern mirroring traditional Laravel: Action loads data, Controller saves it. + +```javascript +// Action: on_create() sets defaults, on_load() fetches for edit mode +on_create() { + this.data.form_data = { title: '', status_id: Model.STATUS_ACTIVE }; + this.data.is_edit = !!this.args.id; +} +async on_load() { + if (!this.data.is_edit) return; + const record = await My_Model.fetch(this.args.id); + this.data.form_data = { id: record.id, title: record.title }; +} +``` + +```php +// Controller: save() receives all form values, validates, persists +#[Ajax_Endpoint] +public static function save(Request $request, array $params = []) { + if (empty($params['title'])) return response_form_error('Validation failed', ['title' => 'Required']); + $record = $params['id'] ? My_Model::find($params['id']) : new My_Model(); + $record->title = $params['title']; + $record->save(); + return ['redirect' => Rsx::Route('View_Action', $record->id)]; +} +``` + +**Key principles**: form_data must be serializable (plain objects, no models) | Keep load/save in same controller for field alignment | on_load() loads data, on_ready() is UI-only + +Details: `php artisan rsx:man form_conventions` + +### Polymorphic Form Fields + +For fields that can reference multiple model types (e.g., an Activity linked to either a Contact or Project), use JSON-encoded polymorphic values. + +```php +use App\RSpade\Core\Polymorphic_Field_Helper; + +$eventable = Polymorphic_Field_Helper::parse($params['eventable'], [ + Contact_Model::class, + Project_Model::class, +]); + +if ($error = $eventable->validate('Please select an entity')) { + $errors['eventable'] = $error; +} + +$model->eventable_type = $eventable->model; +$model->eventable_id = $eventable->id; +``` + +Client submits: `{"model":"Contact_Model","id":123}`. Always use `Model::class` for the whitelist. + +Details: `php artisan rsx:man polymorphic` + +--- + +## MODALS + +### Built-in Dialog Types + +| Method | Returns | Description | +|--------|---------|-------------| +| `Modal.alert(body)` | `void` | Simple notification | +| `Modal.alert(title, body, buttonLabel?)` | `void` | Alert with title | +| `Modal.confirm(body)` | `boolean` | Yes/no confirmation | +| `Modal.confirm(title, body, confirmLabel?, cancelLabel?)` | `boolean` | Confirmation with labels | +| `Modal.prompt(body)` | `string\|false` | Text input | +| `Modal.prompt(title, body, default?, multiline?)` | `string\|false` | Prompt with options | +| `Modal.select(body, options)` | `string\|false` | Dropdown selection | +| `Modal.select(title, body, options, default?, placeholder?)` | `string\|false` | Select with options | +| `Modal.error(error, title?)` | `void` | Error with red styling | +| `Modal.unclosable(title, body)` | `void` | Modal user cannot close | + +```javascript +await Modal.alert("File saved"); +if (await Modal.confirm("Delete?")) { /* confirmed */ } +const name = await Modal.prompt("Enter name:"); +const choice = await Modal.select("Choose:", [{value: 'a', label: 'A'}, {value: 'b', label: 'B'}]); +await Modal.error("Something went wrong"); +``` + +### Form Modals + +```javascript +const result = await Modal.form({ + title: "Edit User", + component: "User_Form", + component_args: {data: user}, + on_submit: async (form) => { + const response = await User_Controller.save(form.vals()); + if (response.errors) { + Form_Utils.apply_form_errors(form.$, response.errors); + return false; // Keep open + } + return response.data; // Close and return + } +}); +``` + +Form component must implement `vals()` and include `
`. + +### Modal Classes + +For complex/reusable modals, create dedicated classes: + +```javascript +class Add_User_Modal extends Modal_Abstract { + static async show() { + return await Modal.form({...}) || false; + } +} + +// Usage +const user = await Add_User_Modal.show(); +if (user) { + grid.reload(); + await Next_Modal.show(user.id); +} +``` + +Pattern: Extend `Modal_Abstract`, implement static `show()`, return data or `false`. + +Details: `php artisan rsx:man modals` + +--- + +## JQUERY EXTENSIONS + +| Method | Purpose | +|--------|---------| +| `.exists()` | Check element exists (instead of `.length > 0`) | +| `.shallowFind(selector)` | Find children without nested component interference | +| `.closest_sibling(selector)` | Search within ancestor hierarchy | +| `.checkValidity()` | Form validation helper | +| `.click()` | Auto-prevents default | +| `.click_allow_default()` | Native click behavior | + +Details: `php artisan rsx:man jquery` + +--- + +## MODELS & DATABASE + +### No Mass Assignment + +```php +// ✅ CORRECT +$user = new User_Model(); +$user->email = $email; +$user->save(); + +// ❌ WRONG +User_Model::create(['email' => $email]); +``` + +### Enums + +**CRITICAL: Read `php artisan rsx:man enum` for complete documentation before implementing.** + +Integer-backed enums with model-level mapping to constants, labels, and custom properties. Uses BEM-style double underscore naming for magic properties. + +```php +class Project_Model extends Rsx_Model_Abstract { + public static $enums = [ + 'status_id' => [ + 1 => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active', 'badge' => 'bg-success', 'order' => 1], + 2 => ['constant' => 'STATUS_ARCHIVED', 'label' => 'Archived', 'selectable' => false, 'order' => 99], + ], + ]; +} + +// Usage - BEM-style: field__property (double underscore) +$project->status_id = Project_Model::STATUS_ACTIVE; +echo $project->status_id__label; // "Active" +echo $project->status_id__badge; // "bg-success" (custom property) +``` + +**Special properties**: +- `order` - Sort position in dropdowns (default: 0, lower first) +- `selectable` - Include in dropdown options (default: true). Non-selectable items excluded from `field__enum_select()` but still shown if current value. + +**JavaScript static methods** (BEM-style double underscore): +```js +// Get all enum data +Project_Model.status_id__enum() + +// Get specific enum's metadata by ID +Project_Model.status_id__enum(Project_Model.STATUS_ACTIVE).badge // "bg-success" +Project_Model.status_id__enum(2).selectable // false + +// Other methods +Project_Model.status_id__enum_select() // For dropdowns +Project_Model.status_id__enum_labels() // id => label map +Project_Model.status_id__enum_ids() // Array of valid IDs +``` + +**Migration:** Use BIGINT for enum columns, TINYINT(1) for booleans. Run `rsx:migrate:document_models` after adding enums. + +### Model Fetch + +```php +#[Ajax_Endpoint_Model_Fetch] +public static function fetch($id) +{ + if (!Session::is_logged_in()) return false; + return static::find($id); +} +``` + +```javascript +const project = await Project_Model.fetch(1); // Throws if not found +const maybe = await Project_Model.fetch_or_null(999); // Returns null if not found +console.log(project.status_id__label); // Enum properties (BEM-style) +console.log(Project_Model.STATUS_ACTIVE); // Static enum constants + +// Lazy relationships (requires #[Ajax_Endpoint_Model_Fetch] on relationship method) +const client = await project.client(); // belongsTo → Model or null +const tasks = await project.tasks(); // hasMany → Model[] +``` + +**Security**: Both `fetch()` and relationships require `#[Ajax_Endpoint_Model_Fetch]` attribute. Related models must also implement `fetch()` with this attribute. + +**fetch() is for SECURITY, not aliasing**: The `fetch()` method exists to remove private data users shouldn't see. NEVER alias enum properties (e.g., `type_label` instead of `type_id__label`) or format dates server-side. Use the full BEM-style names and format dates on client with `Rsx_Date`/`Rsx_Time`. + +Details: `php artisan rsx:man model_fetch` + +### Migrations + +**Forward-only, no rollbacks. Deterministic transformations against known state.** + +```bash +php artisan make:migration:safe create_users_table +php artisan migrate:begin +php artisan migrate +php artisan migrate:commit +``` + +**NO defensive coding in migrations:** +```php +// ❌ WRONG - conditional logic +$fk_exists = DB::select("SELECT ... FROM information_schema..."); +if (!empty($fk_exists)) { DB::statement("ALTER TABLE foo DROP FOREIGN KEY bar"); } + +// ✅ CORRECT - direct statements +DB::statement("ALTER TABLE foo DROP FOREIGN KEY bar"); +DB::statement("ALTER TABLE foo DROP COLUMN baz"); +``` + +No `IF EXISTS`, no `information_schema` queries, no fallbacks. Know current state, write exact transformation. Failures fail loud - snapshot rollback exists for recovery. + +--- + +## FILE ATTACHMENTS + +Files upload UNATTACHED → validate → assign via API. Session-based validation prevents cross-user file assignment. + +```php +// Controller: Assign uploaded file +$attachment = File_Attachment_Model::find_by_key($params['photo_key']); +if ($attachment && $attachment->can_user_assign_this_file()) { + $attachment->attach_to($user, 'profile_photo'); // Single (replaces) + $attachment->add_to($project, 'documents'); // Multiple (adds) +} + +// Model: Retrieve attachments +$photo = $user->get_attachment('profile_photo'); +$documents = $project->get_attachments('documents'); + +// Display +$photo->get_thumbnail_url('cover', 128, 128); +$photo->get_url(); +$photo->get_download_url(); +``` + +**Endpoints:** `POST /_upload`, `GET /_download/:key`, `GET /_thumbnail/:key/:type/:width/:height` + +Details: `php artisan rsx:man file_upload` + +--- + +## POLYMORPHIC TYPE REFERENCES + +Polymorphic `*_type` columns (fileable_type, taskable_type, etc.) store BIGINT integers in the database for efficiency. The framework maps between integer IDs and class names transparently. + +```php +// Model: Declare type ref columns +protected static $type_ref_columns = ['fileable_type']; + +// Usage: Code works with class name strings - conversion is automatic +$attachment->fileable_type = 'Contact_Model'; // Stored as integer in DB +echo $attachment->fileable_type; // Reads as 'Contact_Model' +``` + +**Simple Names Only**: Always use `class_basename($model)`, never `get_class($model)` (FQCNs) + +Details: `php artisan rsx:man polymorphic` + +--- + +## AJAX ENDPOINTS + +```php +#[Ajax_Endpoint] +public static function method(Request $request, array $params = []) { + return $data; // Success - framework wraps as {_success: true, _ajax_return_value: ...} +} +``` + +**PHP→JS Auto-mapping:** +```php +// PHP: My_Controller class +#[Ajax_Endpoint] +public static function save(Request $request, array $params = []) { + return ['id' => 123]; +} + +// JS: Automatically callable +const result = await My_Controller.save({name: 'Test'}); +console.log(result.id); // 123 +``` + +### Error Responses + +Use `response_error(Ajax::ERROR_CODE, $metadata)`: + +```php +// Not found +return response_error(Ajax::ERROR_NOT_FOUND, 'Project not found'); + +// Validation +return response_error(Ajax::ERROR_VALIDATION, [ + 'email' => 'Invalid', + 'name' => 'Required' +]); + +// Auto-message +return response_error(Ajax::ERROR_UNAUTHORIZED); +``` + +**Codes:** `ERROR_VALIDATION`, `ERROR_NOT_FOUND`, `ERROR_UNAUTHORIZED`, `ERROR_AUTH_REQUIRED`, `ERROR_FATAL`, `ERROR_GENERIC` + +**Client:** +```javascript +try { + const data = await Controller.get(id); +} catch (e) { + if (e.code === Ajax.ERROR_NOT_FOUND) { + // Handle + } else { + alert(e.message); // Generic + } +} +``` + +Unhandled errors auto-show flash alert. + +--- + +## DATA FETCHING (CRITICAL) + +**DEFAULT**: Use `Model.fetch(id)` for all single-record retrieval from JavaScript. + +```javascript +const user = await User_Model.fetch(1); // Throws if not found +const user = await User_Model.fetch_or_null(1); // Returns null if not found +``` + +Requires `#[Ajax_Endpoint_Model_Fetch]` on the model's `fetch()` method. + +Auto-populates enum properties and enables lazy relationship loading. + +**If model not available in JS bundle**: STOP and ask the developer. Bundles should include all models they need (`rsx/models` in include paths). Do not create workaround endpoints without approval. + +**Custom Ajax endpoints require developer approval** and are only for: +- Aggregations, batch operations, or complex result sets +- System/root-only models intentionally excluded from bundle +- Queries beyond simple ID lookup + +Details: `php artisan rsx:man model_fetch` + +--- + +## AUTHENTICATION + +**Always use Session** - Static methods only. Never Laravel Auth or $_SESSION. + +```php +Session::is_logged_in(); // Returns true if user logged in +Session::get_user(); // Returns user model or null +Session::get_user_id(); // Returns user ID or null +Session::get_site(); // Returns site model +Session::get_site_id(); // Returns current site ID +Session::get_session_id(); // Returns session ID +``` + +Sessions persist 365 days. Never implement "Remember Me". + +--- + +## DATE & TIME HANDLING + +**Two Classes - Strict Separation**: `Rsx_Time` (datetimes with timezone) | `Rsx_Date` (calendar dates, no timezone) + +**String-Based Philosophy**: RSpade uses ISO strings, not Carbon objects. Dates are `"2025-12-24"`, datetimes are `"2025-12-24T15:30:00-06:00"`. Same format in PHP, JavaScript, JSON, database queries. No object serialization issues. + +**Model Casts**: `Rsx_Model_Abstract` auto-applies `Rsx_Date_Cast` (DATE columns) and `Rsx_DateTime_Cast` (DATETIME columns). Never define `$casts` with `'date'`, `'datetime'`, or `'timestamp'` - these use Carbon and are blocked by `rsx:check`. + +### Rsx_Time - Moments in Time +```php +use App\RSpade\Core\Time\Rsx_Time; + +Rsx_Time::now(); // Current time in user's timezone +Rsx_Time::now_iso(); // ISO 8601 format: 2025-12-24T15:30:00-06:00 +Rsx_Time::format($datetime); // "Dec 24, 2025 3:30 PM" +Rsx_Time::format_short($datetime); // "Dec 24, 3:30 PM" +Rsx_Time::to_database($datetime); // UTC for storage + +Rsx_Time::get_user_timezone(); // User's timezone or default +``` + +```javascript +Rsx_Time.now(); // Current moment (timezone-aware) +Rsx_Time.format(datetime); // Formatted for display +Rsx_Time.relative(datetime); // "2 hours ago", "in 3 days" +``` + +### Rsx_Date - Calendar Dates +```php +use App\RSpade\Core\Time\Rsx_Date; + +Rsx_Date::today(); // "2025-12-24" (user's timezone) +Rsx_Date::format($date); // "Dec 24, 2025" +Rsx_Date::is_today($date); // Boolean +Rsx_Date::is_past($date); // Boolean +``` + +**Key Principle**: Functions throw if wrong type passed (datetime to date function or vice versa). + +### Server Time Sync +Client time syncs automatically via rsxapp data on page load and AJAX responses. No manual sync required. + +### User Timezone +Stored in `login_users.timezone` (IANA format). Falls back to `config('rsx.datetime.default_timezone')`. + +Details: `php artisan rsx:man time` + +--- + +## JAVASCRIPT DECORATORS + +```javascript +/** @decorator */ +function logCalls(target, key, descriptor) { /* ... */ } + +class Service { + @logCalls + @mutex + async save() { /* ... */ } +} +``` + +--- + +## COMMANDS + +### Module Creation + +```bash +rsx:app:module:create # /name +rsx:app:module:feature:create # /m/f +rsx:app:component:create --name=x # Component +``` + +### Development + +```bash +rsx:check # Code quality +rsx:debug /page # Test routes (see below) +rsx:man # Documentation +db:query "SQL" --json +``` + +### Testing Routes + +**`rsx:debug /path`** - Uses Playwright to render pages with full JS execution. + +```bash +rsx:debug /dashboard --user=1 # Authenticated user +rsx:debug /page --screenshot-path=/tmp/page.png # Capture screenshot +rsx:debug /contacts --eval="$('.btn').click(); await sleep(1000)" # Simulate interaction +rsx:debug / --eval="return Rsx_Time.now_iso()" # Get eval result (use return) +rsx:debug / --console --eval="console.log(Rsx_Date.today())" # Or console.log with --console +``` + +Options: `--user=ID`, `--console`, `--screenshot-path`, `--screenshot-width=mobile|tablet|desktop-*`, `--dump-dimensions=".selector"`, `--eval="js"`, `--help` + +**SPA routes ARE server routes.** If you get 404, the route doesn't exist - check route definitions. Never dismiss as "SPA can't be tested server-side". + +**rsx:debug captures the fully-rendered final DOM state** after all async operations, component lifecycles, and data loading complete. If the DOM doesn't match expectations, it's not a timing issue - what you see is what the user sees. Investigate the actual code, not the capture timing. + +### Debugging + +- **rsx_dump_die()** - Debug output +- **console_debug("CHANNEL", ...)** - Channel logging +- **CONSOLE_DEBUG_FILTER=CHANNEL** - Filter output + +--- + +## ERROR HANDLING + +```php +if (!$expected) { + shouldnt_happen("Class {$expected} missing"); +} +``` + +Use for "impossible" conditions that indicate broken assumptions. + +--- + +## CODE QUALITY + +**Professional UI**: Hover effects ONLY on buttons, links, form fields. Static elements remain static. + +**z-index**: Bootstrap defaults + 1100 (modal children), 1200 (flash alerts), 9000+ (system). Details: `rsx:man zindex` + +Run `rsx:check` before commits. Enforces naming, prohibits animations on non-actionable elements. + +--- + +## KEY REMINDERS + +1. **Fail loud** - No silent failures +2. **Static by default** - Unless instances needed +3. **Path-agnostic** - Reference by name +4. **Bundles required** - For JavaScript +5. **Use Session** - Never Laravel Auth +6. **No mass assignment** - Explicit only +7. **Forward migrations** - No rollbacks +8. **Don't run rsx:clean** - Cache auto-invalidates +9. **All routes need auth checks** - In pre_dispatch() or method body (@auth-exempt for public) + +--- + +## GETTING HELP + +```bash +php artisan rsx:man # Framework documentation +php artisan list rsx # All commands +``` + +**Topics**: bundle_api, jqhtml, routing, migrations, console_debug, model_fetch, vs_code_extension, deployment, framework_divergences + +--- + +## PROJECT DOCUMENTATION + +Project-specific man pages in `/rsx/resource/man/*.txt`. Create when features have non-obvious details or component interactions. See `/rsx/resource/man/CLAUDE.md` for format. diff --git a/docs/CLAUDE.dist.md b/docs/CLAUDE.dist.md index 10ce2c1af..1fbf50c4b 100644 --- a/docs/CLAUDE.dist.md +++ b/docs/CLAUDE.dist.md @@ -410,74 +410,33 @@ The process involves creating Action classes with @route decorators and converti **Note**: SPA pages are the preferred standard. Use Blade only for SEO-critical public pages or authentication flows. -```blade -@rsx_id('Frontend_Index') {{-- Every view starts with this --}} - {{-- Adds view class --}} -``` +Pattern recognition: +- `@rsx_id('View_Name')` - required first line +- `rsx_body_class()` - adds view class for CSS scoping +- `@rsx_page_data()` - pass data to JS +- `on_app_ready()` with page guard for JS (fires for ALL pages in bundle) -**NO inline styles, scripts, or event handlers** - Use companion `.scss` and `.js` files. -**jqhtml components** work fully in Blade (no slots). +**Detailed guidance in `blade-views` skill** - auto-activates when building Blade pages. -### SCSS Component-First Architecture +--- -SCSS in `rsx/app/` and `rsx/theme/components/` must wrap in a single component class matching the jqhtml/blade file. Components auto-render with `class="Component_Name"` on root. `rsx/lib/` is non-visual. `rsx/theme/` (outside components/) holds primitives, variables, Bootstrap overrides. +## SCSS ARCHITECTURE -**BEM**: Child classes use component's exact name as prefix. `.Component_Name { &__element }` → HTML: `
` (no kebab-case). +**Component-first**: Every styled element is a component with scoped SCSS. No generic classes scattered across files. -**Variables**: `rsx/theme/variables.scss` - must be included before directory includes in bundles. Multiple SCSS files can target same component if primary file exists. +**Scoping rules**: +- Files in `rsx/app/` and `rsx/theme/components/` **must** wrap in single component class +- Wrapper class matches JS class or Blade `@rsx_id` +- BEM children use PascalCase: `.Component_Name__element` (NOT kebab-case) -Details: `php artisan rsx:man scss` +**Responsive breakpoints** (Bootstrap defaults do NOT work): +- Tier 1: `mobile` (0-1023px), `desktop` (1024px+) +- Tier 2: `phone`, `tablet`, `desktop-sm`, `desktop-md`, `desktop-lg`, `desktop-xl` +- SCSS: `@include mobile { }`, `@include desktop-xl { }` +- Classes: `.col-mobile-12`, `.d-tablet-none`, `.mobile-only` +- JS: `Responsive.is_mobile()`, `Responsive.is_phone()` -### Responsive Breakpoints - -RSX replaces Bootstrap breakpoints with semantic names. **Bootstrap defaults (col-md-6, d-lg-none) do NOT work.** - -| Tier 1 | Range | Tier 2 | Range | -|--------|-------|--------|-------| -| `mobile` | 0-1023px | `phone` | 0-799px | -| `desktop` | 1024px+ | `tablet` | 800-1023px | -| | | `desktop-sm` | 1024-1199px | -| | | `desktop-md` | 1200-1639px | -| | | `desktop-lg` | 1640-2199px | -| | | `desktop-xl` | 2200px+ | - -**SCSS**: `@include mobile { }`, `@include desktop { }`, `@include phone { }`, `@include desktop-xl { }`, etc. -**Classes**: `.col-mobile-6`, `.d-desktop-none`, `.mobile-only`, `.hide-tablet` -**JS**: `Responsive.is_mobile()`, `Responsive.is_phone()`, `Responsive.is_desktop_xl()`, etc. - -Details: `php artisan rsx:man responsive` - -### JavaScript for Blade Pages - -Unlike SPA actions (which use component lifecycle), Blade pages use static `on_app_ready()` with a page guard: - -```javascript -class My_Page { // Matches @rsx_id('My_Page') - static on_app_ready() { - if (!$('.My_Page').exists()) return; // Guard required - fires for ALL pages in bundle - // Page code here - } -} -``` - -### Passing Data to JavaScript - -Use `@rsx_page_data` for page-specific data needed by JavaScript (IDs, config, etc.): - -```blade -@rsx_page_data(['user_id' => $user->id, 'mode' => 'edit']) - -@section('content') - -@endsection -``` - -Access in JavaScript: -```javascript -const user_id = window.rsxapp.page_data.user_id; -``` - -Use when data doesn't belong in DOM attributes. Multiple calls merge together. +**Detailed guidance in `scss` skill** - auto-activates when styling components. --- @@ -732,174 +691,27 @@ this.$sid('result_container').component('My_Component', { ## FORM COMPONENTS -**Form fields** (`` with `$data`, `$controller`, `$method`): -```blade - - - - +Forms use `` with `$data`, `$controller`, `$method` for automatic data binding. Key components: `Form_Field`, `Form_Field_Hidden`, input types (`Text_Input`, `Select_Input`, `Checkbox_Input`). - - -``` +Pattern recognition: +- `vals()` dual-mode method for get/set +- `Form_Utils.apply_form_errors()` for validation +- Action loads data, Controller saves it +- `$disabled=true` still returns values (unlike HTML) -- **Form_Field** - Standard formatted field with label, errors, help text -- **Form_Field_Hidden** - Single-tag hidden input (extends Form_Field_Abstract) -- **Form_Field_Abstract** - Base class for custom formatting (advanced) - -**Disabled fields**: Use `$disabled=true` attribute on input components to disable fields. Unlike standard HTML, disabled fields still return values via `vals()` (useful for read-only data that should be submitted). - -```blade - - - -``` - -**Form component classes** use the **vals() dual-mode pattern**: - -```javascript -class My_Form extends Component { - vals(values) { - if (values) { - // Setter - populate form - this.$sid('name').val(values.name || ''); - return null; - } else { - // Getter - extract values - return {name: this.$sid('name').val()}; - } - } -} -``` - -**Validation**: `Form_Utils.apply_form_errors(form.$, errors)` - Matches by `name` attribute. - -### Form Conventions (Action/Controller Pattern) - -Forms follow a load/save pattern mirroring traditional Laravel: Action loads data, Controller saves it. - -```javascript -// Action: on_create() sets defaults, on_load() fetches for edit mode -on_create() { - this.data.form_data = { title: '', status_id: Model.STATUS_ACTIVE }; - this.data.is_edit = !!this.args.id; -} -async on_load() { - if (!this.data.is_edit) return; - const record = await My_Model.fetch(this.args.id); - this.data.form_data = { id: record.id, title: record.title }; -} -``` - -```php -// Controller: save() receives all form values, validates, persists -#[Ajax_Endpoint] -public static function save(Request $request, array $params = []) { - if (empty($params['title'])) return response_form_error('Validation failed', ['title' => 'Required']); - $record = $params['id'] ? My_Model::find($params['id']) : new My_Model(); - $record->title = $params['title']; - $record->save(); - return ['redirect' => Rsx::Route('View_Action', $record->id)]; -} -``` - -**Key principles**: form_data must be serializable (plain objects, no models) | Keep load/save in same controller for field alignment | on_load() loads data, on_ready() is UI-only - -Details: `php artisan rsx:man form_conventions` - -### Polymorphic Form Fields - -For fields that can reference multiple model types (e.g., an Activity linked to either a Contact or Project), use JSON-encoded polymorphic values. - -```php -use App\RSpade\Core\Polymorphic_Field_Helper; - -$eventable = Polymorphic_Field_Helper::parse($params['eventable'], [ - Contact_Model::class, - Project_Model::class, -]); - -if ($error = $eventable->validate('Please select an entity')) { - $errors['eventable'] = $error; -} - -$model->eventable_type = $eventable->model; -$model->eventable_id = $eventable->id; -``` - -Client submits: `{"model":"Contact_Model","id":123}`. Always use `Model::class` for the whitelist. - -Details: `php artisan rsx:man polymorphic` +**Detailed guidance in `forms` skill** - auto-activates when building forms. --- ## MODALS -### Built-in Dialog Types +Built-in dialogs: `Modal.alert()`, `Modal.confirm()`, `Modal.prompt()`, `Modal.select()`, `Modal.error()`. -| Method | Returns | Description | -|--------|---------|-------------| -| `Modal.alert(body)` | `void` | Simple notification | -| `Modal.alert(title, body, buttonLabel?)` | `void` | Alert with title | -| `Modal.confirm(body)` | `boolean` | Yes/no confirmation | -| `Modal.confirm(title, body, confirmLabel?, cancelLabel?)` | `boolean` | Confirmation with labels | -| `Modal.prompt(body)` | `string\|false` | Text input | -| `Modal.prompt(title, body, default?, multiline?)` | `string\|false` | Prompt with options | -| `Modal.select(body, options)` | `string\|false` | Dropdown selection | -| `Modal.select(title, body, options, default?, placeholder?)` | `string\|false` | Select with options | -| `Modal.error(error, title?)` | `void` | Error with red styling | -| `Modal.unclosable(title, body)` | `void` | Modal user cannot close | +Form modals: `Modal.form({title, component, on_submit})` - component must implement `vals()`. -```javascript -await Modal.alert("File saved"); -if (await Modal.confirm("Delete?")) { /* confirmed */ } -const name = await Modal.prompt("Enter name:"); -const choice = await Modal.select("Choose:", [{value: 'a', label: 'A'}, {value: 'b', label: 'B'}]); -await Modal.error("Something went wrong"); -``` +Reusable modals: Extend `Modal_Abstract`, implement static `show()`. -### Form Modals - -```javascript -const result = await Modal.form({ - title: "Edit User", - component: "User_Form", - component_args: {data: user}, - on_submit: async (form) => { - const response = await User_Controller.save(form.vals()); - if (response.errors) { - Form_Utils.apply_form_errors(form.$, response.errors); - return false; // Keep open - } - return response.data; // Close and return - } -}); -``` - -Form component must implement `vals()` and include `
`. - -### Modal Classes - -For complex/reusable modals, create dedicated classes: - -```javascript -class Add_User_Modal extends Modal_Abstract { - static async show() { - return await Modal.form({...}) || false; - } -} - -// Usage -const user = await Add_User_Modal.show(); -if (user) { - grid.reload(); - await Next_Modal.show(user.id); -} -``` - -Pattern: Extend `Modal_Abstract`, implement static `show()`, return data or `false`. - -Details: `php artisan rsx:man modals` +**Detailed guidance in `modals` skill** - auto-activates when building modals. --- @@ -934,46 +746,15 @@ User_Model::create(['email' => $email]); ### Enums -**CRITICAL: Read `php artisan rsx:man enum` for complete documentation before implementing.** +Integer-backed enums with model-level mapping. Define in `$enums` array with `constant`, `label`, and custom properties. -Integer-backed enums with model-level mapping to constants, labels, and custom properties. Uses BEM-style double underscore naming for magic properties. +Pattern recognition: +- BEM-style access: `$model->status_id__label`, `$model->status_id__badge` +- JS methods: `Model.status_id__enum()`, `Model.status_id__enum_select()` +- Static constants: `Model::STATUS_ACTIVE` +- Use BIGINT columns, run `rsx:migrate:document_models` after changes -```php -class Project_Model extends Rsx_Model_Abstract { - public static $enums = [ - 'status_id' => [ - 1 => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active', 'badge' => 'bg-success', 'order' => 1], - 2 => ['constant' => 'STATUS_ARCHIVED', 'label' => 'Archived', 'selectable' => false, 'order' => 99], - ], - ]; -} - -// Usage - BEM-style: field__property (double underscore) -$project->status_id = Project_Model::STATUS_ACTIVE; -echo $project->status_id__label; // "Active" -echo $project->status_id__badge; // "bg-success" (custom property) -``` - -**Special properties**: -- `order` - Sort position in dropdowns (default: 0, lower first) -- `selectable` - Include in dropdown options (default: true). Non-selectable items excluded from `field__enum_select()` but still shown if current value. - -**JavaScript static methods** (BEM-style double underscore): -```js -// Get all enum data -Project_Model.status_id__enum() - -// Get specific enum's metadata by ID -Project_Model.status_id__enum(Project_Model.STATUS_ACTIVE).badge // "bg-success" -Project_Model.status_id__enum(2).selectable // false - -// Other methods -Project_Model.status_id__enum_select() // For dropdowns -Project_Model.status_id__enum_labels() // id => label map -Project_Model.status_id__enum_ids() // Array of valid IDs -``` - -**Migration:** Use BIGINT for enum columns, TINYINT(1) for booleans. Run `rsx:migrate:document_models` after adding enums. +**Detailed guidance in `model-enums` skill** - auto-activates when implementing enums. ### Model Fetch @@ -1033,27 +814,13 @@ No `IF EXISTS`, no `information_schema` queries, no fallbacks. Know current stat Files upload UNATTACHED → validate → assign via API. Session-based validation prevents cross-user file assignment. -```php -// Controller: Assign uploaded file -$attachment = File_Attachment_Model::find_by_key($params['photo_key']); -if ($attachment && $attachment->can_user_assign_this_file()) { - $attachment->attach_to($user, 'profile_photo'); // Single (replaces) - $attachment->add_to($project, 'documents'); // Multiple (adds) -} +Pattern recognition: +- `File_Attachment_Model::find_by_key()` + `can_user_assign_this_file()` +- `attach_to()` (single/replaces) vs `add_to()` (multiple/adds) +- `get_attachment()` / `get_attachments()` for retrieval +- `get_url()`, `get_download_url()`, `get_thumbnail_url()` -// Model: Retrieve attachments -$photo = $user->get_attachment('profile_photo'); -$documents = $project->get_attachments('documents'); - -// Display -$photo->get_thumbnail_url('cover', 128, 128); -$photo->get_url(); -$photo->get_download_url(); -``` - -**Endpoints:** `POST /_upload`, `GET /_download/:key`, `GET /_thumbnail/:key/:type/:width/:height` - -Details: `php artisan rsx:man file_upload` +**Detailed guidance in `file-attachments` skill** - auto-activates when handling uploads. --- @@ -1180,48 +947,15 @@ Sessions persist 365 days. Never implement "Remember Me". **Two Classes - Strict Separation**: `Rsx_Time` (datetimes with timezone) | `Rsx_Date` (calendar dates, no timezone) -**String-Based Philosophy**: RSpade uses ISO strings, not Carbon objects. Dates are `"2025-12-24"`, datetimes are `"2025-12-24T15:30:00-06:00"`. Same format in PHP, JavaScript, JSON, database queries. No object serialization issues. +**String-Based**: ISO strings, not Carbon. Never use `$casts` with `'date'`/`'datetime'` - blocked by `rsx:check`. -**Model Casts**: `Rsx_Model_Abstract` auto-applies `Rsx_Date_Cast` (DATE columns) and `Rsx_DateTime_Cast` (DATETIME columns). Never define `$casts` with `'date'`, `'datetime'`, or `'timestamp'` - these use Carbon and are blocked by `rsx:check`. +Pattern recognition: +- `Rsx_Time::now()`, `Rsx_Time::format()`, `Rsx_Time::relative()` +- `Rsx_Date::today()`, `Rsx_Date::format()`, `Rsx_Date::is_past()` +- `Rsx_Time::to_database()` for UTC storage +- Functions throw if wrong type passed -### Rsx_Time - Moments in Time -```php -use App\RSpade\Core\Time\Rsx_Time; - -Rsx_Time::now(); // Current time in user's timezone -Rsx_Time::now_iso(); // ISO 8601 format: 2025-12-24T15:30:00-06:00 -Rsx_Time::format($datetime); // "Dec 24, 2025 3:30 PM" -Rsx_Time::format_short($datetime); // "Dec 24, 3:30 PM" -Rsx_Time::to_database($datetime); // UTC for storage - -Rsx_Time::get_user_timezone(); // User's timezone or default -``` - -```javascript -Rsx_Time.now(); // Current moment (timezone-aware) -Rsx_Time.format(datetime); // Formatted for display -Rsx_Time.relative(datetime); // "2 hours ago", "in 3 days" -``` - -### Rsx_Date - Calendar Dates -```php -use App\RSpade\Core\Time\Rsx_Date; - -Rsx_Date::today(); // "2025-12-24" (user's timezone) -Rsx_Date::format($date); // "Dec 24, 2025" -Rsx_Date::is_today($date); // Boolean -Rsx_Date::is_past($date); // Boolean -``` - -**Key Principle**: Functions throw if wrong type passed (datetime to date function or vice versa). - -### Server Time Sync -Client time syncs automatically via rsxapp data on page load and AJAX responses. No manual sync required. - -### User Timezone -Stored in `login_users.timezone` (IANA format). Falls back to `config('rsx.datetime.default_timezone')`. - -Details: `php artisan rsx:man time` +**Detailed guidance in `date-time` skill** - auto-activates when working with dates/times. --- @@ -1321,6 +1055,33 @@ Run `rsx:check` before commits. Enforces naming, prohibits animations on non-act --- +## SKILLS (Auto-Activated) + +Detailed guidance for specific tasks is available via Claude Code skills. These activate automatically when relevant - no action needed. + +| Skill | Activates When | +|-------|---------------| +| `forms` | Building forms, validation, vals() pattern | +| `modals` | Creating dialogs, Modal.form(), Modal_Abstract | +| `model-enums` | Implementing status_id, type_id, enum properties | +| `file-attachments` | Upload flow, attach_to(), thumbnails | +| `date-time` | Rsx_Time, Rsx_Date, timezone handling | +| `blade-views` | Server-rendered pages, @rsx_id, @rsx_page_data | +| `scss` | Component styling, BEM, responsive breakpoints | +| `ajax-error-handling` | response_error(), Form_Utils, error codes | +| `model-fetch` | Model.fetch(), lazy relationships, #[Ajax_Endpoint_Model_Fetch] | +| `jquery-extensions` | .click() override, .exists(), .shallowFind() | +| `background-tasks` | #[Task], #[Schedule], Task::dispatch() | +| `crud-patterns` | List/view/edit structure, DataGrid, dual-route actions | +| `js-decorators` | @route, @spa, @layout, @mutex, custom decorators | +| `event-hooks` | #[OnEvent], filters, gates, actions | +| `migrations` | Raw SQL, make:migration:safe, forward-only | +| `polymorphic` | Type refs, morphTo, Polymorphic_Field_Helper | + +Skills location: `~/.claude/skills/` (symlinked from `system/docs/skills/`) + +--- + ## GETTING HELP ```bash diff --git a/docs/skills/ajax-error-handling/SKILL.md b/docs/skills/ajax-error-handling/SKILL.md new file mode 100755 index 000000000..3962f9f05 --- /dev/null +++ b/docs/skills/ajax-error-handling/SKILL.md @@ -0,0 +1,245 @@ +--- +name: ajax-error-handling +description: Handling Ajax errors in RSX including response formats, error codes, client-side error display, and Form_Utils. Use when implementing error handling for Ajax calls, working with response_error(), form validation errors, or debugging Ajax failures. +--- + +# RSX Ajax Error Handling + +## Response Architecture + +RSX returns **HTTP 200 for ALL Ajax responses** (success and errors). Success/failure is encoded in the response body via `_success` field. + +```javascript +// Success response +{ + "_success": true, + "_ajax_return_value": { /* your data */ } +} + +// Error response +{ + "_success": false, + "error_code": "validation|not_found|unauthorized|auth_required|fatal", + "reason": "User-friendly message", + "metadata": { /* field errors for validation */ } +} +``` + +**Rationale**: Batch requests need uniform status codes. Always get parseable response body. Non-200 only means "couldn't reach PHP at all". + +--- + +## Error Codes + +| Constant | Purpose | +|----------|---------| +| `Ajax::ERROR_VALIDATION` | Field validation failures | +| `Ajax::ERROR_NOT_FOUND` | Resource not found | +| `Ajax::ERROR_UNAUTHORIZED` | User lacks permission | +| `Ajax::ERROR_AUTH_REQUIRED` | User not logged in | +| `Ajax::ERROR_FATAL` | Uncaught PHP exceptions | + +Constants available in both PHP (`Ajax::ERROR_*`) and JavaScript (`Ajax.ERROR_*`). + +--- + +## Server-Side: Returning Errors + +Use `response_error()` helper - never return `_success` manually: + +```php +#[Ajax_Endpoint] +public static function save(Request $request, array $params = []) { + // Validation error with field-specific messages + if (empty($params['email'])) { + return response_error(Ajax::ERROR_VALIDATION, [ + 'email' => 'Email is required' + ]); + } + + // Not found error + $user = User_Model::find($params['id']); + if (!$user) { + return response_error(Ajax::ERROR_NOT_FOUND, 'User not found'); + } + + // Success - just return data (framework wraps it) + $user->name = $params['name']; + $user->save(); + return ['id' => $user->id]; +} +``` + +### Let Exceptions Bubble + +Don't wrap database/framework operations in try/catch. Let exceptions bubble to the global handler: + +```php +// WRONG - Don't catch framework exceptions +try { + $user->save(); +} catch (Exception $e) { + return ['error' => $e->getMessage()]; +} + +// CORRECT - Let it throw +$user->save(); // Framework catches and formats +``` + +**When try/catch IS appropriate**: File uploads, external API calls, user input parsing (expected failures). + +--- + +## Client-Side: Handling Errors + +Ajax.js automatically unwraps responses: +- `_success: true` → Promise resolves with `_ajax_return_value` +- `_success: false` → Promise rejects with Error object + +```javascript +try { + const user = await User_Controller.get_user(123); + console.log(user.name); // Already unwrapped +} catch (error) { + console.log(error.code); // 'validation', 'not_found', etc. + console.log(error.message); // User-displayable message + console.log(error.metadata); // Field errors for validation +} +``` + +### Automatic Error Display + +Uncaught Ajax errors automatically display via `Modal.error()`: + +```javascript +// No try/catch - error shows in modal automatically +const user = await User_Controller.get_user(123); +``` + +--- + +## Form Error Handling + +### With Rsx_Form (Recommended) + +```javascript +const result = await Modal.form({ + title: 'Add User', + component: 'User_Form', + on_submit: async (form) => { + try { + const result = await Controller.save(form.vals()); + return result; // Success - close modal + } catch (error) { + await form.render_error(error); + return false; // Keep modal open + } + } +}); +``` + +`form.render_error()` handles all error types: +- **validation**: Shows inline field errors + alert for unmatched errors +- **fatal/network/auth**: Shows error in form's error container + +### With Form_Utils (Non-Rsx_Form) + +```javascript +try { + const result = await Controller.save(form_data); +} catch (error) { + if (error.code === Ajax.ERROR_VALIDATION) { + Form_Utils.apply_form_errors($form, error.metadata); + } else { + Rsx.render_error(error, '#error_container'); + } +} +``` + +`Form_Utils.apply_form_errors()`: +- Matches errors to fields by `name` attribute +- Adds `.is-invalid` class and inline error text +- Shows alert ONLY for errors that couldn't match to fields + +--- + +## Error Display Methods + +### Modal.error() - Critical Errors + +```javascript +await Modal.error(error, 'Operation Failed'); +``` + +Red danger modal, can stack over other modals. + +### Rsx.render_error() - Container Display + +```javascript +Rsx.render_error(error, '#error_container'); +Rsx.render_error(error, this.$sid('error')); +``` + +Displays error in any container element. + +--- + +## Developer vs Production Mode + +**Developer Mode** (`IS_DEVELOPER=true`): +- Full exception message +- File path and line number +- SQL queries with parameters +- Stack trace (up to 10 frames) + +**Production Mode**: +- Generic message: "An unexpected error occurred" +- No technical details exposed +- Errors logged server-side + +--- + +## Common Patterns + +### Simple Validation + +```php +#[Ajax_Endpoint] +public static function save(Request $request, array $params = []) { + $errors = []; + + if (empty($params['email'])) { + $errors['email'] = 'Email is required'; + } + if (empty($params['name'])) { + $errors['name'] = 'Name is required'; + } + + if ($errors) { + return response_error(Ajax::ERROR_VALIDATION, $errors); + } + + // ... save logic +} +``` + +### Check Specific Error Type + +```javascript +try { + const data = await Controller.get_data(id); +} catch (error) { + if (error.code === Ajax.ERROR_NOT_FOUND) { + show_not_found_message(); + } else if (error.code === Ajax.ERROR_UNAUTHORIZED) { + redirect_to_login(); + } else { + // Let default handler show modal + throw error; + } +} +``` + +## More Information + +Details: `php artisan rsx:man ajax_error_handling` diff --git a/docs/skills/background-tasks/SKILL.md b/docs/skills/background-tasks/SKILL.md new file mode 100755 index 000000000..cd76d8d76 --- /dev/null +++ b/docs/skills/background-tasks/SKILL.md @@ -0,0 +1,262 @@ +--- +name: background-tasks +description: RSX background task system including Task and Schedule attributes, immediate CLI execution, scheduled cron jobs, and async queued tasks. Use when implementing background jobs, scheduled tasks, Task::dispatch(), or working with Rsx_Service_Abstract. +--- + +# RSX Task System + +## Overview + +RSX provides a unified task execution system with three modes: + +| Mode | Use Case | Tracking | +|------|----------|----------| +| Immediate CLI | Manual/interactive runs | None | +| Scheduled | Recurring cron jobs | Database | +| Queued | Async from application | Full status | + +All tasks are Service methods with `#[Task]` attribute. + +--- + +## Service Structure + +Tasks live in Service classes that extend `Rsx_Service_Abstract`: + +```php +class Report_Service extends Rsx_Service_Abstract +{ + #[Task('Generate monthly report')] + public static function generate_report(Task_Instance $task, array $params = []) + { + $task->log("Starting report generation..."); + + // ... task logic + + return ['status' => 'complete', 'rows' => 1500]; + } +} +``` + +**Location**: `/rsx/services/report_service.php` + +--- + +## Mode 1: Immediate CLI + +Run tasks directly from command line: + +```bash +php artisan rsx:task:run Report_Service generate_report --month=12 --year=2025 +``` + +Characteristics: +- Synchronous execution +- Output to STDOUT +- No timeout enforcement +- No database tracking + +--- + +## Mode 2: Scheduled (Cron) + +Add `#[Schedule]` attribute with cron syntax: + +```php +#[Task('Clean thumbnails daily')] +#[Schedule('0 3 * * *')] // 3am daily +public static function clean_thumbnails(Task_Instance $task, array $params = []) +{ + $task->log("Starting cleanup..."); + // ... cleanup logic +} +``` + +### Cron Syntax Examples + +| Schedule | Meaning | +|----------|---------| +| `0 3 * * *` | Daily at 3am | +| `*/15 * * * *` | Every 15 minutes | +| `0 */6 * * *` | Every 6 hours | +| `0 2 * * 1` | Mondays at 2am | +| `0 0 1 * *` | First of each month | + +### Cron Setup + +Add to system crontab: + +```bash +* * * * * cd /var/www/html && php artisan rsx:task:process +``` + +Characteristics: +- Automatic execution when scheduled +- Debounced (no parallel execution of same task) +- If missed, runs as soon as possible +- Database tracking (next_run_at, started_at, completed_at) + +--- + +## Mode 3: Queued (Async) + +Dispatch tasks from application code: + +```php +#[Task('Transcode video', queue: 'video', timeout: 3600)] +public static function transcode(Task_Instance $task, array $params = []) +{ + $task->set_status('progress', 0); + + // ... transcoding logic + + $task->set_status('progress', 100); + return ['output_path' => $task->get_temp_directory() . '/output.mp4']; +} +``` + +### Dispatching Tasks + +```php +// From controller or other code +$task_id = Task::dispatch('Video_Service', 'transcode', [ + 'video_id' => 123, + 'format' => 'mp4' +]); + +// Check status later +$status = Task::status($task_id); +// Returns: pending, running, complete, failed +``` + +### Task Options + +```php +#[Task( + 'Task description', + queue: 'default', // Queue name (for worker separation) + timeout: 120, // Max execution time in seconds + retries: 3 // Retry count on failure +)] +``` + +--- + +## Task Instance Methods + +The `$task` parameter provides these methods: + +```php +public static function my_task(Task_Instance $task, array $params = []) +{ + // Logging + $task->log("Processing item..."); + $task->info("Informational message"); + $task->warning("Warning message"); + $task->error("Error message"); + + // Progress tracking (queued tasks) + $task->set_status('progress', 50); + + // Temporary directory (cleaned up after task) + $temp_dir = $task->get_temp_directory(); + + // Check if cancellation requested + if ($task->is_cancelled()) { + return ['status' => 'cancelled']; + } + + // Return result + return ['processed' => 100]; +} +``` + +--- + +## Listing Tasks + +```bash +# List all registered tasks +php artisan rsx:task:list + +# List scheduled tasks with next run times +php artisan rsx:task:list --scheduled +``` + +--- + +## Common Patterns + +### Data Export Task + +```php +#[Task('Export contacts to CSV')] +public static function export_contacts(Task_Instance $task, array $params = []) +{ + $site_id = $params['site_id']; + $contacts = Contact_Model::where('site_id', $site_id)->get(); + + $csv = "Name,Email,Phone\n"; + foreach ($contacts as $contact) { + $csv .= "{$contact->name},{$contact->email},{$contact->phone}\n"; + } + + $attachment = File_Attachment_Model::create_from_string( + $csv, + 'contacts-export.csv', + ['site_id' => $site_id] + ); + + return ['file_key' => $attachment->file_key]; +} + +// Dispatch from controller +$task_id = Task::dispatch('Export_Service', 'export_contacts', [ + 'site_id' => Session::get_site_id() +]); +``` + +### Cleanup Task (Scheduled) + +```php +#[Task('Clean old sessions')] +#[Schedule('0 4 * * *')] // 4am daily +public static function clean_sessions(Task_Instance $task, array $params = []) +{ + $deleted = Session_Model::where('expires_at', '<', now()->subDays(30)) + ->delete(); + + $task->log("Deleted {$deleted} expired sessions"); + return ['deleted' => $deleted]; +} +``` + +### Long-Running Task with Progress + +```php +#[Task('Process large dataset', timeout: 3600)] +public static function process_dataset(Task_Instance $task, array $params = []) +{ + $items = Item_Model::where('status', 'pending')->get(); + $total = count($items); + + foreach ($items as $i => $item) { + // Check for cancellation + if ($task->is_cancelled()) { + return ['status' => 'cancelled', 'processed' => $i]; + } + + // Process item + $item->process(); + + // Update progress + $task->set_status('progress', round(($i + 1) / $total * 100)); + } + + return ['processed' => $total]; +} +``` + +## More Information + +Details: `php artisan rsx:man tasks` diff --git a/docs/skills/blade-views/SKILL.md b/docs/skills/blade-views/SKILL.md new file mode 100755 index 000000000..716089e60 --- /dev/null +++ b/docs/skills/blade-views/SKILL.md @@ -0,0 +1,149 @@ +--- +name: blade-views +description: Creating Blade views and server-rendered pages in RSX. Use when building SEO pages, login/auth pages, server-rendered content, working with @rsx_id, @rsx_page_data, or Blade-specific patterns. +--- + +# RSX Blade Views + +**Note**: SPA pages are the preferred standard. Use Blade only for: +- SEO-critical public pages +- Authentication flows (login, password reset) +- Marketing/landing pages + +## Basic View Structure + +Every Blade view starts with `@rsx_id`: + +```blade +@rsx_id('Frontend_Index') + + + @section('content') +

Welcome

+ @endsection + +``` + +- `@rsx_id('View_Name')` - Required first line, identifies the view +- `rsx_body_class()` - Adds view class for CSS scoping + +## View Rules + +- **NO inline styles** - Use companion `.scss` files +- **NO inline scripts** - Use companion `.js` files +- **NO inline event handlers** - Use `on_app_ready()` pattern +- **jqhtml components** work fully in Blade (but no slots) + +--- + +## JavaScript for Blade Pages + +Unlike SPA actions (which use component lifecycle), Blade pages use static `on_app_ready()`: + +```javascript +class My_Page { + static on_app_ready() { + // Guard required - fires for ALL pages in bundle + if (!$('.My_Page').exists()) return; + + // Page initialization code + $('.My_Page .btn').on('click', () => { + // Handle click + }); + } +} +``` + +The guard (`if (!$('.My_Page').exists()) return;`) is essential because `on_app_ready()` fires for every page in the bundle. + +--- + +## Passing Data to JavaScript + +Use `@rsx_page_data` for data needed by JavaScript: + +```blade +@rsx_page_data(['user_id' => $user->id, 'mode' => 'edit', 'config' => $config]) + +@section('content') +
...
+@endsection +``` + +Access in JavaScript: + +```javascript +class Editor { + static on_app_ready() { + if (!$('.Editor').exists()) return; + + const user_id = window.rsxapp.page_data.user_id; + const mode = window.rsxapp.page_data.mode; + const config = window.rsxapp.page_data.config; + } +} +``` + +- Use when data doesn't belong in DOM attributes +- Multiple calls merge together +- Available after page load in `window.rsxapp.page_data` + +--- + +## Using jqhtml Components in Blade + +jqhtml components work in Blade templates: + +```blade + + + + +``` + +**Note**: Blade slots (`@slot`) don't work with jqhtml components. Use component attributes instead. + +--- + +## Controller Pattern for Blade Routes + +Use standard #[Route] controllers with Blade templates: + +```php +/** + * @auth-exempt Login routes are public + */ +class Login_Controller extends Rsx_Controller_Abstract { + #[Route('/login', methods: ['GET', 'POST'])] + public static function index(Request $request, array $params = []) { + if ($request->is_post()) { + // Handle form submission + } + return rsx_view('Login_Index', ['data' => $data]); + } +} +``` + +**Key points**: +- GET/POST in same method +- Returns `rsx_view('Template_Name', $data)` +- jqhtml works but not server-rendered for SEO + +--- + +## Converting Blade to SPA + +When converting server-rendered Blade pages to SPA actions: + +```bash +php artisan rsx:man blade_to_spa +``` + +The process involves: +1. Creating Action classes with `@route` decorators +2. Converting templates from Blade to jqhtml syntax +3. Moving data loading from controller to `on_load()` + +## More Information + +Details: `php artisan rsx:man blade` diff --git a/docs/skills/crud-patterns/SKILL.md b/docs/skills/crud-patterns/SKILL.md new file mode 100755 index 000000000..f868f0dc2 --- /dev/null +++ b/docs/skills/crud-patterns/SKILL.md @@ -0,0 +1,286 @@ +--- +name: crud-patterns +description: Standard CRUD implementation patterns in RSX including directory structure, DataGrid lists, view pages, dual-route edit actions, and three-state loading. Use when building list/view/edit pages, implementing DataGrid, creating add/edit forms, or following RSX CRUD conventions. +--- + +# RSX CRUD Patterns + +## Directory Structure + +Each CRUD feature follows this organization: + +``` +rsx/app/frontend/{feature}/ +├── {feature}_controller.php # Ajax endpoints +├── list/ +│ ├── {Feature}_Index_Action.js # List page +│ ├── {Feature}_Index_Action.jqhtml +│ ├── {feature}_datagrid.php # DataGrid backend +│ └── {feature}_datagrid.jqhtml # DataGrid template +├── view/ +│ ├── {Feature}_View_Action.js # Detail view +│ └── {Feature}_View_Action.jqhtml +└── edit/ + ├── {Feature}_Edit_Action.js # Add/Edit (dual-route) + └── {Feature}_Edit_Action.jqhtml + +rsx/models/{feature}_model.php # With fetch() method +``` + +--- + +## The Three Subdirectories + +| Directory | Purpose | Route Pattern | +|-----------|---------|---------------| +| `list/` | Index with DataGrid | `/contacts` | +| `view/` | Single record detail | `/contacts/:id` | +| `edit/` | Add AND edit form | `/contacts/add`, `/contacts/:id/edit` | + +--- + +## Feature Controller + +The controller provides Ajax endpoints for all operations: + +```php +class Frontend_Contacts_Controller extends Rsx_Controller_Abstract +{ + public static function pre_dispatch(Request $request, array $params = []) + { + if (!Session::is_logged_in()) return response_unauthorized(); + return null; + } + + #[Ajax_Endpoint] + public static function datagrid_fetch(Request $request, array $params = []) + { + return Contacts_DataGrid::fetch($params); + } + + #[Ajax_Endpoint] + public static function save(Request $request, array $params = []) + { + // Validation + if (empty($params['name'])) { + return response_error(Ajax::ERROR_VALIDATION, ['name' => 'Required']); + } + + // Create or update + $contact = $params['id'] ? Contact_Model::find($params['id']) : new Contact_Model(); + $contact->name = $params['name']; + $contact->email = $params['email']; + $contact->save(); + + return ['redirect' => Rsx::Route('Contacts_View_Action', $contact->id)]; + } + + #[Ajax_Endpoint] + public static function delete(Request $request, array $params = []) + { + $contact = Contact_Model::find($params['id']); + $contact->delete(); + return ['redirect' => Rsx::Route('Contacts_Index_Action')]; + } +} +``` + +--- + +## List Page (Index Action) + +```javascript +@route('/contacts') +@layout('Frontend_Layout') +@spa('Frontend_Spa_Controller::index') +class Contacts_Index_Action extends Spa_Action { + async on_load() { + // DataGrid fetches its own data + } +} +``` + +```jqhtml + +
+

Contacts

+ + Add Contact + +
+ + +
+``` + +--- + +## DataGrid Backend + +```php +class Contacts_DataGrid extends DataGrid_Abstract +{ + protected static function query(): Builder + { + return Contact_Model::query() + ->where('site_id', Session::get_site_id()); + } + + protected static function columns(): array + { + return [ + 'name' => ['label' => 'Name', 'sortable' => true], + 'email' => ['label' => 'Email', 'sortable' => true], + 'created_at' => ['label' => 'Created', 'sortable' => true], + ]; + } + + protected static function default_sort(): array + { + return ['name' => 'asc']; + } +} +``` + +--- + +## View Page (Three-State Pattern) + +View pages use the three-state loading pattern: loading → error → content. + +```javascript +@route('/contacts/:id') +@layout('Frontend_Layout') +@spa('Frontend_Spa_Controller::index') +class Contacts_View_Action extends Spa_Action { + on_create() { + this.data.contact = null; + this.data.error = null; + } + + async on_load() { + try { + this.data.contact = await Contact_Model.fetch(this.args.id); + } catch (e) { + this.data.error = e; + } + } +} +``` + +```jqhtml + + <% if (!this.data.contact && !this.data.error) { %> + + <% } else if (this.data.error) { %> + + <% } else { %> +

<%= this.data.contact.name %>

+

Email: <%= this.data.contact.email %>

+ + Edit + <% } %> +
+``` + +--- + +## Edit Page (Dual-Route Action) + +A single action handles both add and edit modes: + +```javascript +@route('/contacts/add') +@route('/contacts/:id/edit') +@layout('Frontend_Layout') +@spa('Frontend_Spa_Controller::index') +class Contacts_Edit_Action extends Spa_Action { + on_create() { + this.data.form_data = { name: '', email: '' }; + this.data.is_edit = !!this.args.id; + this.data.error = null; + } + + async on_load() { + if (!this.data.is_edit) return; + + try { + const contact = await Contact_Model.fetch(this.args.id); + this.data.form_data = { + id: contact.id, + name: contact.name, + email: contact.email + }; + } catch (e) { + this.data.error = e; + } + } +} +``` + +```jqhtml + + <% if (this.data.is_edit && !this.data.form_data.id && !this.data.error) { %> + + <% } else if (this.data.error) { %> + + <% } else { %> +

<%= this.data.is_edit ? 'Edit Contact' : 'Add Contact' %>

+ + + + + + + + + + + + + + + <% } %> +
+``` + +--- + +## Model with fetch() + +```php +class Contact_Model extends Rsx_Model_Abstract +{ + #[Ajax_Endpoint_Model_Fetch] + public static function fetch($id) + { + if (!Session::is_logged_in()) { + return false; + } + + $contact = static::where('site_id', Session::get_site_id()) + ->find($id); + + return $contact ?: false; + } +} +``` + +--- + +## Key Principles + +1. **SPA controllers = Ajax endpoints only** - No page rendering +2. **Single action handles add/edit** - Dual `@route` decorators +3. **Models implement `fetch()`** - With `#[Ajax_Endpoint_Model_Fetch]` +4. **DataGrids extend `DataGrid_Abstract`** - Query + columns + sorting +5. **Three-state pattern** - Loading → Error → Content +6. **form_data must be serializable** - Plain objects, not models + +## More Information + +Details: `php artisan rsx:man crud` diff --git a/docs/skills/date-time/SKILL.md b/docs/skills/date-time/SKILL.md new file mode 100755 index 000000000..d81475bcb --- /dev/null +++ b/docs/skills/date-time/SKILL.md @@ -0,0 +1,244 @@ +--- +name: date-time +description: Working with dates and times in RSX using Rsx_Time and Rsx_Date classes. Use when formatting dates/times for display, handling user timezones, working with datetime columns, converting between formats, or displaying relative times like "2 hours ago". +--- + +# RSX Date & Time Handling + +## Two Classes - Strict Separation + +| Class | Purpose | Example | +|-------|---------| --------| +| `Rsx_Time` | Moments in time (with timezone) | `2025-12-24T15:30:00Z` | +| `Rsx_Date` | Calendar dates (no timezone) | `2025-12-24` | + +**Critical**: Functions throw if wrong type passed (datetime to date function or vice versa). This is intentional - mixing types causes bugs. + +--- + +## Strings, Not Objects + +RSX uses ISO strings, not Carbon objects: +- **Dates**: `"2025-12-24"` +- **Datetimes**: `"2025-12-24T15:30:00Z"` + +Same format in PHP, JavaScript, JSON, and database queries. No serialization surprises. + +```php +$model->created_at // "2025-12-24T15:30:45.123Z" (string) +$model->due_date // "2025-12-24" (string) +Rsx_Time::now_iso() // "2025-12-24T15:30:45.123Z" (string) +``` + +Carbon is used internally for calculations, never exposed externally. + +--- + +## Model Casts (Automatic) + +`Rsx_Model_Abstract` auto-applies casts based on column type: +- DATE columns → `Rsx_Date_Cast` +- DATETIME columns → `Rsx_DateTime_Cast` + +**Never define** `$casts` with `'date'`, `'datetime'`, or `'timestamp'` - these use Carbon and are blocked by `rsx:check`. + +--- + +## Rsx_Time - Moments in Time + +### PHP Usage + +```php +use App\RSpade\Core\Time\Rsx_Time; + +// Current time +Rsx_Time::now(); // Carbon (for calculations only) +Rsx_Time::now_iso(); // ISO 8601: 2025-12-24T15:30:00Z + +// Formatting for display +Rsx_Time::format_datetime($datetime); // "Dec 24, 2025 3:30 PM" +Rsx_Time::format_datetime_with_tz($datetime); // "Dec 24, 2025 3:30 PM CST" +Rsx_Time::format_time($datetime); // "3:30 PM" +Rsx_Time::relative($datetime); // "2 hours ago" + +// Database storage (always UTC) +Rsx_Time::to_database($datetime); // Converts to MySQL format + +// Timezone +Rsx_Time::get_user_timezone(); // User's timezone or default +Rsx_Time::to_user_timezone($datetime); // Convert to user's timezone +``` + +### JavaScript Usage + +```javascript +// Current time (synced with server) +Rsx_Time.now(); // Date object +Rsx_Time.now_iso(); // ISO string + +// Formatting +Rsx_Time.format_datetime(datetime); // "Dec 24, 2025 3:30 PM" +Rsx_Time.format_datetime_with_tz(datetime); // "Dec 24, 2025 3:30 PM CST" +Rsx_Time.format_time(datetime); // "3:30 PM" +Rsx_Time.relative(datetime); // "2 hours ago", "in 3 days" + +// Arithmetic +Rsx_Time.add(datetime, 3600); // Add seconds, returns ISO string +Rsx_Time.subtract(datetime, 3600); // Subtract seconds +``` + +--- + +## Rsx_Date - Calendar Dates + +### PHP Usage + +```php +use App\RSpade\Core\Time\Rsx_Date; + +// Current date +Rsx_Date::today(); // "2025-12-24" (user's timezone) + +// Formatting +Rsx_Date::format($date); // "Dec 24, 2025" + +// Comparisons +Rsx_Date::is_today($date); // Boolean +Rsx_Date::is_past($date); // Boolean +Rsx_Date::is_future($date); // Boolean +Rsx_Date::diff_days($date1, $date2); // Days between +``` + +### JavaScript Usage + +```javascript +// Current date +Rsx_Date.today(); // "2025-12-24" + +// Formatting +Rsx_Date.format(date); // "Dec 24, 2025" + +// Comparisons +Rsx_Date.is_today(date); // Boolean +Rsx_Date.is_past(date); // Boolean +``` + +--- + +## Live Countdown/Countup (JavaScript) + +For real-time updating displays: + +```javascript +// Countdown to future time +const ctrl = Rsx_Time.countdown(this.$sid('timer'), deadline, { + short: true, // "2h 30m" vs "2 hours and 30 minutes" + on_complete: () => this.reload() +}); + +// Stop countdown when leaving page +this.on_stop(() => ctrl.stop()); + +// Countup from past time (elapsed time) +Rsx_Time.countup(this.$sid('elapsed'), started_at, { short: true }); +``` + +--- + +## Server Time Sync + +Client time syncs automatically via `rsxapp` data on page load and AJAX responses. No manual sync required. + +```javascript +// Client always has accurate server time +const server_now = Rsx_Time.now(); // Synced with server, corrects for clock skew +``` + +--- + +## User Timezone + +Stored in `login_users.timezone` (IANA format, e.g., `America/Chicago`). + +Falls back to `config('rsx.datetime.default_timezone')`. + +```php +// Get user's timezone +$tz = Rsx_Time::get_user_timezone(); + +// All Rsx_Time methods automatically use user's timezone for display +``` + +--- + +## Component Expectations + +### Date_Picker Component + +- `val()` returns `"YYYY-MM-DD"` or `null` +- `val(value)` accepts `"YYYY-MM-DD"` or `null` +- **Throws** if passed datetime format +- Display shows localized format (e.g., "Dec 24, 2025") + +### Datetime_Picker Component + +- `val()` returns ISO 8601 string or `null` +- `val(value)` accepts ISO 8601 string or `null` +- **Throws** if passed date-only format +- Display shows localized time in user's timezone + +--- + +## Common Patterns + +### Display in Template + +```jqhtml +<%= Rsx_Time.format_datetime(this.data.record.created_at) %> +<%= Rsx_Time.relative(this.data.record.updated_at) %> +<%= Rsx_Date.format(this.data.record.due_date) %> +``` + +### Conditional Display + +```jqhtml +<% if (Rsx_Date.is_past(this.data.task.due_date)) { %> + Overdue +<% } %> +``` + +### Save to Database + +```php +// Datetime - store in UTC +$record->scheduled_at = Rsx_Time::to_database($params['scheduled_at']); + +// Date - store as-is +$record->due_date = $params['due_date']; // Already "YYYY-MM-DD" +``` + +### Parse User Input + +```php +// If user enters a datetime string +$datetime = Rsx_Time::parse($params['datetime']); // Returns Carbon +$iso = Rsx_Time::to_iso($datetime); // Convert back to string + +// If user enters a date string +$date = Rsx_Date::parse($params['date']); // Returns "YYYY-MM-DD" +``` + +--- + +## Key Rules + +1. **Never use Carbon directly** - RSX uses string-based dates +2. **Never use PHP's date()** - Use Rsx_Time/Rsx_Date +3. **Store datetimes in UTC** - Use `Rsx_Time::to_database()` +4. **Display in user's timezone** - Automatic via Rsx_Time format methods +5. **Dates have no timezone** - Use Rsx_Date for calendar dates +6. **Wrong types throw** - Date functions reject datetimes and vice versa + +## More Information + +Details: `php artisan rsx:man time` diff --git a/docs/skills/event-hooks/SKILL.md b/docs/skills/event-hooks/SKILL.md new file mode 100755 index 000000000..879b32a58 --- /dev/null +++ b/docs/skills/event-hooks/SKILL.md @@ -0,0 +1,252 @@ +--- +name: event-hooks +description: RSX PHP event system with filters, gates, and actions using the #[OnEvent] attribute. Use when extending framework behavior, implementing authorization hooks, transforming data through handlers, or triggering custom events. +--- + +# RSX Event Hooks + +## Overview + +RSX provides an attribute-based event system with three event types: + +| Type | Purpose | Return Handling | +|------|---------|-----------------| +| **Filter** | Transform data through chain | Each handler modifies and returns data | +| **Gate** | Authorization checks | First non-true stops execution | +| **Action** | Side effects | Return values ignored | + +Unlike Laravel's EventServiceProvider, RSX uses `#[OnEvent]` attributes with automatic manifest discovery. + +--- + +## Creating Event Handlers + +Place handler classes in `/rsx/handlers/`: + +```php +namespace Rsx\Handlers; + +class Upload_Handlers +{ + #[OnEvent('file.upload.authorize', priority: 10)] + public static function require_auth($data) + { + if (!Session::is_logged_in()) { + return response()->json(['error' => 'Auth required'], 403); + } + return true; + } + + #[OnEvent('file.upload.params', priority: 20)] + public static function add_metadata($params) + { + $params['uploaded_by'] = Session::get_user_id(); + return $params; + } +} +``` + +Handlers are automatically discovered - no manual registration needed. + +--- + +## Filter Events + +Transform data through a chain of handlers. Each handler receives the result of the previous handler. + +```php +// Trigger +$result = Rsx::trigger_filter('event.name', $data); + +// Handler - MUST return modified data +#[OnEvent('post.content.filter', priority: 10)] +public static function sanitize_html($content) +{ + return strip_tags($content, '

'); +} + +#[OnEvent('post.content.filter', priority: 20)] +public static function add_signature($content) +{ + return $content . "\n\nPosted via RSX"; +} +``` + +**Flow**: Handler 1 (priority 10) → Handler 2 (priority 20) → Final result + +--- + +## Gate Events + +Authorization checks where first non-true response halts execution. + +```php +// Trigger +$result = Rsx::trigger_gate('api.access.authorize', $data); +if ($result !== true) { + return $result; // Access denied +} + +// Handler - return true to allow, anything else to deny +#[OnEvent('api.access.authorize', priority: 10)] +public static function check_auth($data) +{ + if (!Session::is_logged_in()) { + return response()->json(['error' => 'Login required'], 401); + } + return true; +} + +#[OnEvent('api.access.authorize', priority: 20)] +public static function check_permissions($data) +{ + if (!$data['user']->can($data['permission'])) { + return response()->json(['error' => 'Forbidden'], 403); + } + return true; +} +``` + +**Flow**: Handler 1 returns true → Handler 2 returns false → STOPS, returns false + +--- + +## Action Events + +Fire-and-forget side effects. Return values are ignored. + +```php +// Trigger +Rsx::trigger_action('user.created', ['user' => $user]); + +// Handler - no return value needed +#[OnEvent('user.created', priority: 10)] +public static function send_welcome_email($data): void +{ + Email_Service::send_welcome($data['user']); +} + +#[OnEvent('user.created', priority: 20)] +public static function log_signup($data): void +{ + Activity_Model::log('signup', $data['user']->id); +} +``` + +--- + +## Priority + +Handlers execute in priority order (lowest number first): + +```php +#[OnEvent('my.event', priority: 10)] // Runs first +#[OnEvent('my.event', priority: 20)] // Runs second +#[OnEvent('my.event', priority: 30)] // Runs third +``` + +Default priority is 10 if not specified. + +--- + +## Common Use Cases + +### File Upload Authorization + +```php +#[OnEvent('file.upload.authorize', priority: 10)] +public static function require_auth($data) +{ + if (!Session::is_logged_in()) { + return response()->json(['error' => 'Auth required'], 403); + } + return true; +} + +#[OnEvent('file.download.authorize', priority: 10)] +public static function check_file_access($data) +{ + $attachment = $data['attachment']; + if ($attachment->site_id !== Session::get_site_id()) { + return response()->json(['error' => 'Access denied'], 403); + } + return true; +} +``` + +### Transforming Request Data + +```php +#[OnEvent('contact.save.params', priority: 10)] +public static function normalize_phone($params) +{ + if (!empty($params['phone'])) { + $params['phone'] = preg_replace('/[^0-9]/', '', $params['phone']); + } + return $params; +} +``` + +### Logging Actions + +```php +#[OnEvent('project.deleted', priority: 10)] +public static function log_deletion($data): void +{ + Audit_Log::create([ + 'action' => 'project_deleted', + 'entity_id' => $data['project']->id, + 'user_id' => Session::get_user_id(), + ]); +} +``` + +--- + +## Triggering Custom Events + +```php +// In your controller or service +class Project_Controller +{ + #[Ajax_Endpoint] + public static function delete(Request $request, array $params = []) + { + $project = Project_Model::find($params['id']); + + // Gate: Check if deletion allowed + $auth = Rsx::trigger_gate('project.delete.authorize', [ + 'project' => $project, + 'user' => Session::get_user() + ]); + if ($auth !== true) { + return $auth; + } + + $project->delete(); + + // Action: Notify listeners + Rsx::trigger_action('project.deleted', ['project' => $project]); + + return ['success' => true]; + } +} +``` + +--- + +## Handler Organization + +``` +rsx/handlers/ +├── auth_handlers.php # Authentication hooks +├── file_handlers.php # File upload/download hooks +├── notification_handlers.php +└── audit_handlers.php +``` + +Each file can contain multiple handler methods with different event subscriptions. + +## More Information + +Details: `php artisan rsx:man event_hooks` diff --git a/docs/skills/file-attachments/SKILL.md b/docs/skills/file-attachments/SKILL.md new file mode 100755 index 000000000..490f80eab --- /dev/null +++ b/docs/skills/file-attachments/SKILL.md @@ -0,0 +1,300 @@ +--- +name: file-attachments +description: Handling file uploads and attachments in RSX including upload flow, attaching files to models, retrieving attachments, and generating URLs. Use when implementing file uploads, working with File_Attachment_Model, attaching files to records, displaying thumbnails, or handling document downloads. +--- + +# RSX File Attachments + +## Two-Model Architecture + +RSX separates physical storage from logical metadata: + +| Model | Purpose | +|-------|---------| +| `File_Storage_Model` | Physical files on disk (framework model) | +| `File_Attachment_Model` | Logical uploads with metadata (user model) | + +This enables **deduplication** - identical files share physical storage while maintaining separate metadata. + +--- + +## Upload Flow + +Files follow a secure two-step process: + +1. **Upload** - File uploaded UNATTACHED via `POST /_upload` +2. **Attach** - Controller validates and assigns to model + +This session-based validation prevents cross-user file assignment. + +--- + +## Uploading Files + +### Frontend Component + +Use the built-in file upload component: + +```jqhtml + +``` + +### Upload Response + +Upload returns a `file_key` that identifies the unattached file: + +```javascript +// After upload completes +const file_key = upload_component.get_file_key(); +``` + +--- + +## Attaching Files to Models + +### Single Attachment (Replaces) + +For one-to-one relationships (e.g., profile photo): + +```php +#[Ajax_Endpoint] +public static function save_photo(Request $request, array $params = []) { + $user = User_Model::find($params['user_id']); + + $attachment = File_Attachment_Model::find_by_key($params['photo_key']); + if ($attachment && $attachment->can_user_assign_this_file()) { + $attachment->attach_to($user, 'profile_photo'); + } + + return ['success' => true]; +} +``` + +`attach_to()` replaces any existing attachment with that slot name. + +### Multiple Attachments (Adds) + +For one-to-many relationships (e.g., project documents): + +```php +$attachment = File_Attachment_Model::find_by_key($params['document_key']); +if ($attachment && $attachment->can_user_assign_this_file()) { + $attachment->add_to($project, 'documents'); +} +``` + +`add_to()` adds to the collection without removing existing files. + +### Detaching Files + +```php +$attachment->detach(); +``` + +--- + +## Retrieving Attachments + +### Single Attachment + +```php +$photo = $user->get_attachment('profile_photo'); +if ($photo) { + echo $photo->get_url(); +} +``` + +### Multiple Attachments + +```php +$documents = $project->get_attachments('documents'); +foreach ($documents as $doc) { + echo $doc->file_name; +} +``` + +--- + +## Displaying Files + +### Direct URL + +```php +$url = $attachment->get_url(); +// Returns: /_download/{key} +``` + +### Download URL (Forces Download) + +```php +$url = $attachment->get_download_url(); +// Returns: /_download/{key}?download=1 +``` + +### Thumbnail URL + +For images with automatic resizing: + +```php +// Crop to exact dimensions +$url = $attachment->get_thumbnail_url('cover', 128, 128); + +// Fit within dimensions (maintains aspect ratio) +$url = $attachment->get_thumbnail_url('contain', 200, 200); + +// Scale to width, auto height +$url = $attachment->get_thumbnail_url('width', 300); +``` + +Thumbnail types: +- `cover` - Crop to fill exact dimensions +- `contain` - Fit within dimensions +- `width` - Scale to width, maintain aspect ratio + +--- + +## Template Usage + +```jqhtml +<% if (this.data.user.profile_photo) { %> + Profile photo +<% } %> + +<% for (const doc of this.data.project.documents) { %> + + <%= doc.file_name %> + +<% } %> +``` + +--- + +## File Attachment Model Properties + +```php +$attachment->file_name; // Original uploaded filename +$attachment->mime_type; // MIME type (e.g., 'image/jpeg') +$attachment->file_size; // Size in bytes +$attachment->file_key; // Unique identifier +$attachment->created_at; // Upload timestamp +``` + +--- + +## Creating Attachments Programmatically + +### From Disk + +```php +$attachment = File_Attachment_Model::create_from_disk( + '/tmp/import/document.pdf', + [ + 'site_id' => $site->id, + 'filename' => 'imported-document.pdf', + 'fileable_category' => 'import' + ] +); +``` + +### From String Content + +```php +$csv = "Name,Email\nJohn,john@example.com"; +$attachment = File_Attachment_Model::create_from_string( + $csv, + 'export.csv', + ['site_id' => $site->id, 'fileable_category' => 'export'] +); +``` + +### From URL + +```php +$attachment = File_Attachment_Model::create_from_url( + 'https://example.com/logo.png', + ['site_id' => $site->id, 'fileable_category' => 'logo'] +); +``` + +--- + +## Security Considerations + +### Always Validate Before Attaching + +```php +$attachment = File_Attachment_Model::find_by_key($params['file_key']); + +// REQUIRED: Check user can assign this file +if (!$attachment || !$attachment->can_user_assign_this_file()) { + return response_error(Ajax::ERROR_UNAUTHORIZED, 'Invalid file'); +} + +// Now safe to attach +$attachment->attach_to($model, 'slot_name'); +``` + +### File Validation + +Check file type before attaching: + +```php +if (!in_array($attachment->mime_type, ['image/jpeg', 'image/png', 'image/gif'])) { + return response_form_error('Validation failed', ['photo' => 'Must be an image']); +} +``` + +--- + +## Event Hooks for Authorization + +Control access with event hooks: + +```php +// Require auth for uploads +#[OnEvent('file.upload.authorize', priority: 10)] +public static function require_auth($data) { + if (!Session::is_logged_in()) { + return response()->json(['error' => 'Authentication required'], 403); + } + return true; +} + +// Restrict thumbnail access +#[OnEvent('file.thumbnail.authorize', priority: 10)] +public static function check_file_access($data) { + if ($data['attachment']->created_by !== $data['user']?->id) { + return response()->json(['error' => 'Access denied'], 403); + } + return true; +} + +// Additional restrictions for downloads +#[OnEvent('file.download.authorize', priority: 10)] +public static function require_premium($data) { + if (!$data['user']?->has_premium()) { + return response()->json(['error' => 'Premium required'], 403); + } + return true; +} +``` + +--- + +## System Endpoints + +| Endpoint | Purpose | +|----------|---------| +| `POST /_upload` | Upload new file | +| `GET /_download/:key` | Download/view file | +| `GET /_thumbnail/:key/:type/:width/:height` | Get resized image | +| `GET /_icon_by_extension/:extension` | Get file type icon | + +## More Information + +Details: `php artisan rsx:man file_upload` diff --git a/docs/skills/forms/SKILL.md b/docs/skills/forms/SKILL.md new file mode 100755 index 000000000..b7a5f9c1c --- /dev/null +++ b/docs/skills/forms/SKILL.md @@ -0,0 +1,284 @@ +--- +name: forms +description: Building RSX forms with Rsx_Form, Form_Field, input components, data binding, validation, and the vals() pattern. Use when creating forms, handling form submissions, implementing form validation, working with Form_Field or Form_Input components, or implementing polymorphic form fields. +--- + +# RSX Form Components + +## Core Form Structure + +Forms use `` with automatic data binding: + +```jqhtml + + + + + + + +``` + +## Field Components + +| Component | Purpose | +|-----------|---------| +| `Form_Field` | Standard formatted field with label, errors, help text | +| `Form_Hidden_Field` | Single-tag hidden input (extends Form_Field_Abstract) | +| `Form_Field_Abstract` | Base class for custom formatting (advanced) | + +## Input Components + +| Component | Usage | +|-----------|-------| +| `Text_Input` | Text, email, url, tel, number, textarea | +| `Select_Input` | Dropdown with options array | +| `Checkbox_Input` | Checkbox with optional label | +| `Radio_Input` | Radio button group | +| `Wysiwyg_Input` | Rich text editor (Quill) | + +### Text_Input Attributes + +```jqhtml + + + + + +``` + +### Select_Input Formats + +```jqhtml +<%-- Simple array --%> + + +<%-- Value/label objects --%> + + +<%-- From model enum --%> + +``` + +--- + +## Disabled Fields + +Use `$disabled=true` on input components. Unlike standard HTML, disabled fields still return values via `vals()` (useful for read-only data that should be submitted). + +```jqhtml + + +``` + +--- + +## Multi-Column Layouts + +Use Bootstrap grid for multi-column field layouts: + +```jqhtml +

+
+ + + +
+
+ + + +
+
+``` + +--- + +## The vals() Dual-Mode Pattern + +Form components implement `vals()` for get/set: + +```javascript +class My_Form extends Component { + vals(values) { + if (values) { + // Setter - populate form + this.$sid('name').val(values.name || ''); + return null; + } else { + // Getter - extract values + return {name: this.$sid('name').val()}; + } + } +} +``` + +--- + +## Form Validation + +Apply server-side validation errors: + +```javascript +const response = await Controller.save(form.vals()); +if (response.errors) { + Form_Utils.apply_form_errors(form.$, response.errors); +} +``` + +Errors match by `name` attribute on form fields. + +--- + +## Action/Controller Pattern + +Forms follow load/save mirroring traditional Laravel: + +**Action (loads data):** +```javascript +on_create() { + this.data.form_data = { title: '', status_id: Model.STATUS_ACTIVE }; + this.data.is_edit = !!this.args.id; +} +async on_load() { + if (!this.data.is_edit) return; + const record = await My_Model.fetch(this.args.id); + this.data.form_data = { id: record.id, title: record.title }; +} +``` + +**Controller (saves data):** +```php +#[Ajax_Endpoint] +public static function save(Request $request, array $params = []) { + if (empty($params['title'])) { + return response_form_error('Validation failed', ['title' => 'Required']); + } + $record = $params['id'] ? My_Model::find($params['id']) : new My_Model(); + $record->title = $params['title']; + $record->save(); + return ['redirect' => Rsx::Route('View_Action', $record->id)]; +} +``` + +**Key principles:** +- `form_data` must be serializable (plain objects, no models) +- Keep load/save in same controller for field alignment +- `on_load()` loads data, `on_ready()` is UI-only + +--- + +## Repeater Fields + +For arrays of values (relationships, multiple items): + +**Simple repeaters (array of IDs):** +```javascript +// form_data +this.data.form_data = { + client_ids: [1, 5, 12], +}; + +// Controller receives +$params['client_ids'] // [1, 5, 12] + +// Sync +$project->clients()->sync($params['client_ids'] ?? []); +``` + +**Complex repeaters (array of objects):** +```javascript +// form_data +this.data.form_data = { + team_members: [ + {user_id: 1, role_id: 2}, + {user_id: 5, role_id: 1}, + ], +}; + +// Controller receives +$params['team_members'] // [{user_id: 1, role_id: 2}, ...] + +// Sync with pivot data +$project->team()->detach(); +foreach ($params['team_members'] ?? [] as $member) { + $project->team()->attach($member['user_id'], [ + 'role_id' => $member['role_id'], + ]); +} +``` + +--- + +## Test Data (Debug Mode) + +Widgets can implement `seed()` for debug mode test data. Rsx_Form displays "Fill Test Data" button when `window.rsxapp.debug` is true. + +```jqhtml + + + +``` + +--- + +## Creating Custom Input Components + +Extend `Form_Input_Abstract`: + +```javascript +class My_Custom_Input extends Form_Input_Abstract { + on_create() { + // NO on_load() - never use this.data + } + + on_ready() { + // Render elements EMPTY - form calls val(value) to populate AFTER render + } + + // Required: get/set value + val(value) { + if (value !== undefined) { + // Set value + this.$sid('input').val(value); + } else { + // Get value + return this.$sid('input').val(); + } + } +} +``` + +Reference implementations: `Select_Input`, `Text_Input`, `Checkbox_Input` + +--- + +## Polymorphic Form Fields + +For fields that can reference multiple model types: + +```php +use App\RSpade\Core\Polymorphic_Field_Helper; + +$eventable = Polymorphic_Field_Helper::parse($params['eventable'], [ + Contact_Model::class, + Project_Model::class, +]); + +if ($error = $eventable->validate('Please select an entity')) { + $errors['eventable'] = $error; +} + +$model->eventable_type = $eventable->model; +$model->eventable_id = $eventable->id; +``` + +Client submits: `{"model":"Contact_Model","id":123}`. Always use `Model::class` for the whitelist. + +## More Information + +Details: `php artisan rsx:man form_conventions`, `php artisan rsx:man forms_and_widgets` diff --git a/docs/skills/jquery-extensions/SKILL.md b/docs/skills/jquery-extensions/SKILL.md new file mode 100755 index 000000000..414a17bad --- /dev/null +++ b/docs/skills/jquery-extensions/SKILL.md @@ -0,0 +1,202 @@ +--- +name: jquery-extensions +description: RSX jQuery extensions including the click() override that auto-prevents default, existence checks, component-aware traversal, and form validation helpers. Use when working with click handlers, checking element existence, finding sibling components, or understanding why links don't navigate. +--- + +# RSX jQuery Extensions + +## Critical: Click Override + +**RSX overrides jQuery `.click()` to automatically call `e.preventDefault()`** + +This is the most important thing to know. All click handlers prevent default behavior automatically: + +```javascript +// RSX - preventDefault is automatic +$('.btn').click(function(e) { + do_something(); // Link won't navigate, form won't submit +}); + +// Vanilla jQuery - must remember to call it +$('.btn').click(function(e) { + e.preventDefault(); // Easy to forget! + do_something(); +}); +``` + +**Why**: `preventDefault` is correct 95% of the time. Making it automatic eliminates a common source of bugs. + +--- + +## When You Need Native Behavior + +Use `.click_allow_default()` for the rare cases where you want native browser behavior: + +```javascript +// Analytics tracking before navigation +$('a.external').click_allow_default(function(e) { + analytics.track('external_link'); + // Navigation happens after handler +}); + +// Conditional preventDefault +$('button[type=submit]').click_allow_default(function(e) { + if (!validate_form()) { + e.preventDefault(); // Only prevent if invalid + } +}); +``` + +**Valid use cases for `.click_allow_default()`**: +- Analytics tracking before navigation +- Conditional form submission +- Progressive enhancement fallbacks + +**Invalid use cases** (use standard `.click()` instead): +- Opening modals +- Triggering Ajax actions +- Any case where you don't want navigation + +--- + +## Existence Checks + +```javascript +// RSX - cleaner syntax +if ($('.element').exists()) { + // Element is in DOM +} + +// Vanilla jQuery equivalent +if ($('.element').length > 0) { ... } +``` + +--- + +## Visibility and State + +```javascript +// Is element visible (not display:none)? +if ($('.modal').is_visible()) { + $('.modal').fadeOut(); +} + +// Is element attached to DOM? +if ($('.dynamic').is_in_dom()) { + // Element is live in the page +} + +// Is element in viewport? +if ($('.lazy-image').is_in_viewport()) { + load_image($(this)); +} +``` + +--- + +## Component-Aware Traversal + +### shallowFind(selector) + +Finds child elements without descending into nested components of the same type: + +```javascript +// Only finds direct Form_Field children, not fields in nested sub-forms +this.$.shallowFind('.Form_Field').each(function() { + // Process only this form's fields +}); +``` + +Example DOM: +``` +Component_A +└── div + └── Widget (found) + └── span + └── Widget (not found - has Widget parent) +``` + +### closest_sibling(selector) + +Searches for elements within progressively higher ancestor containers. Useful for component-to-component communication: + +```javascript +// Country selector finding its related state selector +this.tom_select.on('change', () => { + const state_input = this.$.closest_sibling('.State_Select_Input'); + if (state_input.exists()) { + state_input.component().set_country_code(this.val()); + } +}); +``` + +Algorithm: +1. Get parent, search within it +2. If not found, move to parent's parent +3. Repeat until found or reaching `` + +--- + +## Form Validation + +```javascript +// Check if form passes HTML5 validation +if ($('form').checkValidity()) { + submit_form(); +} + +// Show browser's native validation UI +if (!$('form').reportValidity()) { + return; // Browser shows validation errors +} + +// Programmatically submit (triggers validation) +$('form').requestSubmit(); +``` + +--- + +## Other Helpers + +```javascript +// Get lowercase tag name +if ($element.tagname() === 'a') { + // It's a link +} + +// Check if link is external +if ($('a').is_external()) { + $(this).attr('target', '_blank'); +} + +// Scroll to bring element into view +$('.error-field').scroll_up_to(300); // 300ms animation +``` + +--- + +## RSX vs Vanilla jQuery Comparison + +| Operation | Vanilla jQuery | RSX | +|-----------|---------------|-----| +| Click with preventDefault | `e.preventDefault()` required | Automatic | +| Existence check | `.length > 0` | `.exists()` | +| Form validation | `$('form')[0].checkValidity()` | `$('form').checkValidity()` | +| Native click behavior | `.click()` | `.click_allow_default()` | + +--- + +## Troubleshooting + +**Problem**: Links not navigating when they should +**Solution**: Use `.click_allow_default()` instead of `.click()` + +**Problem**: Form submitting unexpectedly +**Solution**: This shouldn't happen - `.click()` prevents submission. If using `.click_allow_default()`, add explicit `e.preventDefault()` + +**Problem**: Want to use `.on('click')` to avoid preventDefault +**Solution**: Don't - it defeats the framework's safety. Use `.click_allow_default()` to make intent explicit + +## More Information + +Details: `php artisan rsx:man jquery` diff --git a/docs/skills/js-decorators/SKILL.md b/docs/skills/js-decorators/SKILL.md new file mode 100755 index 000000000..59953a57f --- /dev/null +++ b/docs/skills/js-decorators/SKILL.md @@ -0,0 +1,243 @@ +--- +name: js-decorators +description: RSX JavaScript decorators including @route, @spa, @layout, @mutex, and custom decorator creation. Use when adding route decorators to actions, understanding decorator restrictions, creating custom decorators, or troubleshooting decorator errors. +--- + +# RSX JavaScript Decorators + +## Overview + +RSX uses decorators to enhance static methods. Unlike standard JavaScript, RSX requires explicit whitelisting via `@decorator` marker to prevent arbitrary code injection. + +**Key difference**: Only functions marked with `@decorator` can be used as decorators elsewhere. + +--- + +## Common Framework Decorators + +### @route - SPA Routing + +Defines URL paths for SPA actions: + +```javascript +@route('/contacts') +class Contacts_Index_Action extends Spa_Action { } + +@route('/contacts/:id') +class Contacts_View_Action extends Spa_Action { } + +// Dual routes (add/edit in one action) +@route('/contacts/add') +@route('/contacts/:id/edit') +class Contacts_Edit_Action extends Spa_Action { } +``` + +### @spa - SPA Bootstrap + +Links action to its SPA bootstrap controller: + +```javascript +@route('/contacts') +@spa('Frontend_Spa_Controller::index') +class Contacts_Index_Action extends Spa_Action { } +``` + +### @layout - Layout Assignment + +Assigns layout(s) to action: + +```javascript +@route('/contacts') +@layout('Frontend_Layout') +@spa('Frontend_Spa_Controller::index') +class Contacts_Index_Action extends Spa_Action { } + +// Nested layouts (sublayouts) +@route('/settings/general') +@layout('Frontend_Layout') +@layout('Settings_Layout') +@spa('Frontend_Spa_Controller::index') +class Settings_General_Action extends Spa_Action { } +``` + +### @mutex - Mutual Exclusion + +Prevents concurrent execution of async methods: + +```javascript +class My_Component extends Component { + @mutex() + async save() { + // Only one save() can run at a time + await Controller.save(this.vals()); + } +} +``` + +--- + +## Decorator Restrictions + +### Static Methods Only + +Decorators can only be applied to static methods, not instance methods: + +```javascript +class Example { + @myDecorator + static validMethod() { } // ✅ Valid + + @myDecorator + invalidMethod() { } // ❌ Invalid - instance method +} +``` + +### No Class Name Identifiers in Parameters + +Decorator parameters **must not** use class name identifiers (bundle ordering issues): + +```javascript +// WRONG - class identifier as parameter +@some_decorator(User_Model) +class My_Action extends Spa_Action { } + +// CORRECT - use string literal +@some_decorator('User_Model') +class My_Action extends Spa_Action { } +``` + +**Why**: Bundle compilation doesn't guarantee class definition order for decorator parameters. + +### Multiple Decorators + +Execute in reverse order (bottom to top): + +```javascript +@logExecutionTime // Executes second (outer) +@validateParams // Executes first (inner) +static transform(data) { } +``` + +--- + +## Creating Custom Decorators + +### Basic Decorator + +```javascript +@decorator +function myCustomDecorator(target, key, descriptor) { + const original = descriptor.value; + descriptor.value = function(...args) { + console.log(`Calling ${key}`); + return original.apply(this, args); + }; + return descriptor; +} +``` + +### Using Custom Decorator + +```javascript +class MyClass { + @myCustomDecorator + static myMethod() { + return "result"; + } +} +``` + +--- + +## Common Decorator Patterns + +### Logging + +```javascript +@decorator +function logCalls(target, key, descriptor) { + const original = descriptor.value; + descriptor.value = function(...args) { + console.log(`Calling ${target.name}.${key}`, args); + const result = original.apply(this, args); + console.log(`${target.name}.${key} returned`, result); + return result; + }; + return descriptor; +} +``` + +### Async Error Handling + +```javascript +@decorator +function catchAsync(target, key, descriptor) { + const original = descriptor.value; + descriptor.value = async function(...args) { + try { + return await original.apply(this, args); + } catch (error) { + console.error(`Error in ${key}:`, error); + throw error; + } + }; + return descriptor; +} +``` + +### Rate Limiting + +```javascript +@decorator +function rateLimit(target, key, descriptor) { + const calls = new Map(); + const original = descriptor.value; + descriptor.value = function(...args) { + const now = Date.now(); + const lastCall = calls.get(key) || 0; + if (now - lastCall < 1000) { + throw new Error(`${key} called too frequently`); + } + calls.set(key, now); + return original.apply(this, args); + }; + return descriptor; +} +``` + +--- + +## Decorator Function Signature + +```javascript +function myDecorator(target, key, descriptor) { + // target: The class being decorated + // key: The method name being decorated + // descriptor: Property descriptor for the method + + const original = descriptor.value; + descriptor.value = function(...args) { + // Pre-execution logic + const result = original.apply(this, args); + // Post-execution logic + return result; + }; + return descriptor; +} +``` + +**Important**: Always use `original.apply(this, args)` to preserve context. + +--- + +## Troubleshooting + +| Error | Solution | +|-------|----------| +| "Decorator 'X' not whitelisted" | Add `@decorator` marker to function | +| "Decorator 'X' not found" | Ensure decorator loaded before usage | +| "Decorators can only be applied to static methods" | Change to static or remove decorator | + +## More Information + +Details: `php artisan rsx:man js_decorators` diff --git a/docs/skills/migrations/SKILL.md b/docs/skills/migrations/SKILL.md new file mode 100755 index 000000000..f9b06b032 --- /dev/null +++ b/docs/skills/migrations/SKILL.md @@ -0,0 +1,198 @@ +--- +name: migrations +description: RSX database migrations with raw SQL enforcement, forward-only philosophy, and automatic normalization. Use when creating database tables, understanding migration workflow, or troubleshooting Schema builder violations. +--- + +# RSX Database Migrations + +## Philosophy + +RSX enforces a forward-only migration strategy with raw SQL: + +1. **Forward-only** - No rollbacks, no `down()` methods +2. **Raw SQL only** - Direct MySQL statements, no Schema builder +3. **Fail loud** - Migrations must succeed or fail with clear errors +4. **Snapshot safety** - Development requires database snapshots + +--- + +## Schema Builder is Prohibited + +All migrations **must** use `DB::statement()` with raw SQL: + +```php +// ✅ CORRECT +DB::statement("CREATE TABLE products ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + price DECIMAL(10,2) NOT NULL DEFAULT 0.00 +)"); + +// ❌ WRONG - Schema builder prohibited +Schema::create('products', function (Blueprint $table) { + $table->id(); + $table->string('name'); +}); +``` + +**Prohibited**: `Schema::create()`, `Schema::table()`, `Schema::drop()`, `Blueprint`, `$table->` chains + +--- + +## Development Workflow + +```bash +# 1. Create snapshot (required) +php artisan migrate:begin + +# 2. Create migration +php artisan make:migration:safe create_products_table + +# 3. Write migration with raw SQL +# 4. Run migrations +php artisan migrate + +# 5. If successful +php artisan migrate:commit + +# 6. If failed - auto-rollback to snapshot +``` + +--- + +## Automatic Normalization + +The system auto-normalizes types after migration. You can write simpler SQL: + +| You Write | System Converts To | +|-----------|-------------------| +| `INT` | `BIGINT` | +| `TEXT` | `LONGTEXT` | +| `FLOAT` | `DOUBLE` | +| `TINYINT(1)` | Preserved (boolean) | + +**Auto-added columns** (don't include manually): +- `created_at TIMESTAMP(3)` +- `updated_at TIMESTAMP(3)` +- `created_by BIGINT` +- `updated_by BIGINT` + +--- + +## Migration Examples + +### Simple Table (Recommended) + +```php +public function up() +{ + DB::statement(" + CREATE TABLE products ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + stock_quantity INT NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + category_id INT NULL, + INDEX idx_category (category_id), + INDEX idx_active (is_active) + ) + "); +} +``` + +**Notes**: +- `INT` becomes `BIGINT` automatically +- `TEXT` becomes `LONGTEXT` automatically +- `created_at`/`updated_at` added automatically +- `TINYINT(1)` preserved for booleans + +### Adding Columns + +```php +public function up() +{ + DB::statement(" + ALTER TABLE products + ADD COLUMN sku VARCHAR(50) NULL AFTER name, + ADD COLUMN weight DECIMAL(8,2) NULL, + ADD INDEX idx_sku (sku) + "); +} +``` + +### Foreign Keys + +```php +public function up() +{ + DB::statement(" + ALTER TABLE orders + ADD CONSTRAINT fk_orders_customer + FOREIGN KEY (customer_id) REFERENCES customers(id) + ON DELETE CASCADE + "); +} +``` + +--- + +## Required Table Structure + +**Every table MUST have**: + +```sql +id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY +``` + +This is non-negotiable. Use SIGNED (not UNSIGNED) for easier future migrations. + +--- + +## Foreign Key Columns + +Foreign key columns **must match** the referenced column type exactly: + +```sql +-- If users.id is BIGINT, then: +user_id BIGINT NULL -- ✅ Matches + +-- Column names ending in _id are assumed to be foreign keys +``` + +--- + +## Production Workflow + +```bash +# No snapshot protection in production +php artisan migrate --production +``` + +Ensure migrations are thoroughly tested in development/staging first. + +--- + +## Validation + +The migration validator automatically checks for: +- Schema builder usage +- `down()` methods (auto-removed) +- Proper SQL syntax + +Violations show clear error messages with remediation advice. + +--- + +## Troubleshooting + +| Error | Solution | +|-------|----------| +| "Found forbidden Schema builder usage" | Replace with `DB::statement()` | +| "Validation failed" | Check migration for prohibited patterns | +| Foreign key constraint fails | Ensure column types match exactly | + +## More Information + +Details: `php artisan rsx:man migrations` diff --git a/docs/skills/modals/SKILL.md b/docs/skills/modals/SKILL.md new file mode 100755 index 000000000..d1b312594 --- /dev/null +++ b/docs/skills/modals/SKILL.md @@ -0,0 +1,254 @@ +--- +name: modals +description: Creating modal dialogs in RSX including alerts, confirms, prompts, selects, and form modals. Use when implementing Modal.alert, Modal.confirm, Modal.prompt, Modal.form, creating modal classes extending Modal_Abstract, or building dialog-based user interactions. +--- + +# RSX Modal System + +## Built-in Dialog Types + +All modal methods are async and return appropriate values: + +| Method | Returns | Description | +|--------|---------|-------------| +| `Modal.alert(body)` | `void` | Simple notification | +| `Modal.alert(title, body, buttonLabel?)` | `void` | Alert with title | +| `Modal.confirm(body)` | `boolean` | Yes/no confirmation | +| `Modal.confirm(title, body, confirmLabel?, cancelLabel?)` | `boolean` | Confirmation with labels | +| `Modal.prompt(body)` | `string\|false` | Text input | +| `Modal.prompt(title, body, default?, multiline?)` | `string\|false` | Prompt with options | +| `Modal.select(body, options)` | `string\|false` | Dropdown selection | +| `Modal.select(title, body, options, default?, placeholder?)` | `string\|false` | Select with options | +| `Modal.error(error, title?)` | `void` | Error with red styling | +| `Modal.unclosable(title, body)` | `void` | Modal user cannot close | + +## Basic Usage Examples + +```javascript +// Simple alert +await Modal.alert("File saved successfully"); + +// Alert with title +await Modal.alert("Success", "Your changes have been saved."); + +// Confirmation +if (await Modal.confirm("Are you sure you want to delete this item?")) { + await Controller.delete(id); +} + +// Confirmation with custom labels +const confirmed = await Modal.confirm( + "Delete Project", + "This will permanently delete the project.\n\nThis action cannot be undone.", + "Delete", // confirm button label + "Keep Project" // cancel button label +); + +// Text prompt +const name = await Modal.prompt("Enter your name:"); +if (name) { + // User entered something +} + +// Multiline prompt +const notes = await Modal.prompt("Notes", "Enter description:", "", true); + +// Selection dropdown +const choice = await Modal.select("Choose an option:", [ + {value: 'a', label: 'Option A'}, + {value: 'b', label: 'Option B'} +]); + +// Unclosable modal (for critical operations) +Modal.unclosable("Processing", "Please wait..."); +await long_operation(); +await Modal.close(); // Must close programmatically +``` + +**Text formatting**: Use `\n\n` for paragraph breaks in modal body text. + +--- + +## Form Modals + +For complex data entry, use `Modal.form()`: + +```javascript +const result = await Modal.form({ + title: "Edit User", + component: "User_Form", // Component name (must implement vals()) + component_args: {data: user}, // Args passed to component + max_width: 800, // Width in pixels (default: 800) + on_submit: async (form) => { + const response = await User_Controller.save(form.vals()); + if (response.errors) { + Form_Utils.apply_form_errors(form.$, response.errors); + return false; // Keep modal open + } + return response.data; // Close modal and return data + } +}); + +if (result) { + // Modal closed with data + console.log(result.id); +} +``` + +### Form Component Requirements + +The component used in `Modal.form()` must: +1. Implement `vals()` method (get/set form values) +2. Include `
` for validation errors + +```javascript +class User_Form extends Component { + vals(values) { + if (values) { + this.$sid('name').val(values.name || ''); + this.$sid('email').val(values.email || ''); + return null; + } else { + return { + name: this.$sid('name').val(), + email: this.$sid('email').val() + }; + } + } +} +``` + +--- + +## Modal Classes (Reusable Modals) + +For complex or frequently-used modals, create dedicated classes: + +```javascript +class Add_User_Modal extends Modal_Abstract { + static async show(initial_data = {}) { + return await Modal.form({ + title: 'Add User', + component: 'User_Form', + component_args: {data: initial_data}, + on_submit: async (form) => { + const response = await User_Controller.create(form.vals()); + if (response.errors) { + Form_Utils.apply_form_errors(form.$, response.errors); + return false; + } + return response.data; + } + }) || false; + } +} + +class Edit_User_Modal extends Modal_Abstract { + static async show(user_id) { + // Load data first + const user = await User_Model.fetch(user_id); + return await Modal.form({ + title: 'Edit User', + component: 'User_Form', + component_args: {data: user}, + on_submit: async (form) => { + const response = await User_Controller.update(form.vals()); + if (response.errors) { + Form_Utils.apply_form_errors(form.$, response.errors); + return false; + } + return response.data; + } + }) || false; + } +} +``` + +### Usage Pattern + +```javascript +// Create new user +const new_user = await Add_User_Modal.show(); +if (new_user) { + grid.reload(); +} + +// Edit existing user +const updated_user = await Edit_User_Modal.show(user_id); +if (updated_user) { + // Refresh display +} + +// Chain modals (page JS orchestrates, not modals) +const user = await Add_User_Modal.show(); +if (user) { + await Assign_Role_Modal.show(user.id); +} +``` + +**Pattern**: Extend `Modal_Abstract`, implement static `show()`, return data or `false`. + +--- + +## Modal Options + +Options for `Modal.form()`: + +```javascript +await Modal.form({ + title: "Form Title", + component: "Form_Component", + component_args: {}, + max_width: 800, // Width in pixels (default: 800) + closable: true, // Allow ESC/backdrop/X to close (default: true) + submit_label: "Save", // Submit button text + cancel_label: "Cancel", // Cancel button text + on_submit: async (form) => { /* ... */ } +}); +``` + +Options for `Modal.show()` (custom modals): + +```javascript +await Modal.show({ + title: "Choose Action", + body: "What would you like to do?", // String, HTML, or jQuery element + max_width: 500, // Width in pixels + closable: true, + buttons: [ + {label: "Cancel", value: false, class: "btn-secondary"}, + {label: "Continue", value: true, class: "btn-primary", default: true} + ] +}); +``` + +--- + +## Modal Queuing + +Multiple simultaneous modal requests are queued and shown sequentially: + +```javascript +// All three modals 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]); +``` + +Backdrop persists across queued modals with 500ms delay between. + +--- + +## Best Practices + +1. **Use appropriate type**: `alert()` for info, `confirm()` for decisions, `form()` for complex input +2. **Handle cancellations**: Always check for `false` return value +3. **Modal classes don't chain**: Page JS orchestrates sequences, not modal classes +4. **No UI updates in modals**: Page JS handles post-modal UI updates +5. **Loading states**: Use `Modal.unclosable()` + `Modal.close()` for long operations + +## More Information + +Details: `php artisan rsx:man modals` diff --git a/docs/skills/model-enums/SKILL.md b/docs/skills/model-enums/SKILL.md new file mode 100755 index 000000000..4c8ed42c7 --- /dev/null +++ b/docs/skills/model-enums/SKILL.md @@ -0,0 +1,246 @@ +--- +name: model-enums +description: Implementing model enums in RSX with integer-backed values, constants, labels, and custom properties. Use when adding enum fields to models, working with status_id or type_id columns, accessing enum labels/properties via BEM-style syntax, or populating dropdowns with enum values. +--- + +# RSX Model Enums + +Integer-backed enums with model-level mapping to constants, labels, and custom properties. Uses BEM-style double underscore naming for magic properties. + +## Defining Enums + +```php +class Project_Model extends Rsx_Model_Abstract { + public static $enums = [ + 'status_id' => [ + 1 => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active', 'badge' => 'bg-success', 'order' => 1], + 2 => ['constant' => 'STATUS_ON_HOLD', 'label' => 'On Hold', 'badge' => 'bg-warning', 'order' => 2], + 3 => ['constant' => 'STATUS_ARCHIVED', 'label' => 'Archived', 'selectable' => false, 'order' => 99], + ], + 'priority_id' => [ + 1 => ['constant' => 'PRIORITY_LOW', 'label' => 'Low', 'color' => '#999', 'days' => 30], + 2 => ['constant' => 'PRIORITY_MEDIUM', 'label' => 'Medium', 'color' => '#f90', 'days' => 14], + 3 => ['constant' => 'PRIORITY_HIGH', 'label' => 'High', 'color' => '#f00', 'days' => 7], + ], + ]; +} +``` + +## Required Properties + +| Property | Purpose | +|----------|---------| +| `constant` | Static constant name (generates `Model::STATUS_ACTIVE`) | +| `label` | Human-readable display text | + +## Special Properties + +| Property | Default | Purpose | +|----------|---------|---------| +| `order` | 0 | Sort position in dropdowns (lower first) | +| `selectable` | true | Include in dropdown options | + +Non-selectable items are excluded from `field__enum_select()` but still display correctly when they're the current value. + +## Custom Properties + +Add any properties for business logic - they become accessible via BEM-style syntax: + +```php +'status_id' => [ + 1 => [ + 'constant' => 'STATUS_ACTIVE', + 'label' => 'Active', + 'badge' => 'bg-success', // CSS class + 'icon' => 'fa-check', // Icon class + 'can_edit' => true, // Business rule + 'permissions' => ['edit', 'view'], // Permission list + ], +], +``` + +--- + +## PHP Usage + +```php +// Set using constant +$project->status_id = Project_Model::STATUS_ACTIVE; + +// BEM-style property access (field__property) +echo $project->status_id__label; // "Active" +echo $project->status_id__badge; // "bg-success" +echo $project->status_id__icon; // "fa-check" + +// Business logic flags +if ($project->status_id__can_edit) { + // Allow editing +} +``` + +--- + +## JavaScript Usage + +### Static Constants +```javascript +project.status_id = Project_Model.STATUS_ACTIVE; +``` + +### Instance Property Access +```javascript +const project = await Project_Model.fetch(1); +console.log(project.status_id__label); // "Active" +console.log(project.status_id__badge); // "bg-success" +``` + +### Static Enum Methods + +All use BEM-style double underscore (`field__method()`): + +```javascript +// Get all enum data +Project_Model.status_id__enum() +// Returns: {1: {label: 'Active', badge: 'bg-success', ...}, 2: {...}, ...} + +// Get specific enum's metadata by ID +Project_Model.status_id__enum(Project_Model.STATUS_ACTIVE) +// Returns: {label: 'Active', badge: 'bg-success', order: 1, ...} + +Project_Model.status_id__enum(2).selectable // true or false + +// For dropdown population (respects 'selectable' and 'order') +Project_Model.status_id__enum_select() +// Returns: [{value: 1, label: 'Active'}, {value: 2, label: 'On Hold'}] +// Note: Archived excluded because selectable: false + +// Simple id => label map +Project_Model.status_id__enum_labels() +// Returns: {1: 'Active', 2: 'On Hold', 3: 'Archived'} + +// Array of valid IDs +Project_Model.status_id__enum_ids() +// Returns: [1, 2, 3] +``` + +--- + +## Template Usage + +```jqhtml + + <%= this.args.project.status_id__label %> + + + +``` + +--- + +## Boolean Fields + +For boolean fields, use 0/1 as keys: + +```php +'is_verified' => [ + 0 => ['constant' => 'NOT_VERIFIED', 'label' => 'Not Verified', 'icon' => 'fa-times'], + 1 => ['constant' => 'VERIFIED', 'label' => 'Verified', 'icon' => 'fa-check'], +] +``` + +--- + +## Context-Specific Labels + +Define different labels for different contexts: + +```php +1 => [ + 'constant' => 'STATUS_NEW', + 'label' => 'New Listing', // Backend/default + 'label_frontend' => 'Coming Soon', // Public-facing + 'label_short' => 'New', // Abbreviated +] +``` + +Access: `$item->status__label_frontend` + +--- + +## Permission Systems + +Use custom properties for complex permission logic: + +```php +'role_id' => [ + 1 => [ + 'constant' => 'ROLE_ADMIN', + 'label' => 'Administrator', + 'permissions' => ['users.create', 'users.delete', 'settings.edit'], + 'can_admin_roles' => [2, 3, 4], // Can manage these role IDs + ], + 2 => [ + 'constant' => 'ROLE_MANAGER', + 'label' => 'Manager', + 'permissions' => ['users.view', 'reports.view'], + 'can_admin_roles' => [3, 4], + ], +] +``` + +```php +// Check permissions +if (in_array('users.create', $user->role_id__permissions)) { + // User can create users +} +``` + +--- + +## Anti-Aliasing Policy + +**NEVER alias enum properties in fetch()** - the BEM-style naming exists for grepability: + +```php +// WRONG - Aliasing obscures data source +$data['type_label'] = $record->type_id__label; // BAD + +// RIGHT - Use full BEM-style names in JavaScript +contact.type_id__label // Good - grepable, self-documenting +``` + +The `fetch()` function's purpose is SECURITY (removing private data), not aliasing. + +--- + +## Database Migration + +**Always use BIGINT for enum columns:** + +```php +$table->bigInteger('status_id')->default(Project_Model::STATUS_ACTIVE); +``` + +**Never use:** +- VARCHAR (wastes space) +- MySQL ENUM type (inflexible) +- TINYINT (too small for future expansion) + +--- + +## After Adding Enums + +Run model documentation generator: + +```bash +php artisan rsx:migrate:document_models +``` + +This updates the model's documentation comments with enum information. + +## More Information + +Details: `php artisan rsx:man enum` diff --git a/docs/skills/model-fetch/SKILL.md b/docs/skills/model-fetch/SKILL.md new file mode 100755 index 000000000..634a07b6a --- /dev/null +++ b/docs/skills/model-fetch/SKILL.md @@ -0,0 +1,287 @@ +--- +name: model-fetch +description: Loading model data from JavaScript using Model.fetch() with secure opt-in, authorization, and lazy relationships. Use when implementing fetch() methods on models, loading records from JavaScript, accessing relationships via await, or understanding the Ajax_Endpoint_Model_Fetch attribute. +--- + +# RSX Model Fetch System + +## Overview + +RSX allows JavaScript to securely access ORM models through explicit opt-in. Unlike Laravel's API routes which can expose all fields, RSX requires each model to implement its own `fetch()` method with authorization and data filtering. + +--- + +## Security Model + +- **Explicit Opt-In**: Models must implement `fetch()` with `#[Ajax_Endpoint_Model_Fetch]` +- **No Default Access**: No models are fetchable by default +- **Individual Authorization**: Each model controls who can fetch its records +- **Data Filtering**: Complete control over what data JavaScript receives + +--- + +## Implementing fetch() + +### Basic Implementation + +```php +use Ajax_Endpoint_Model_Fetch; + +class Product_Model extends Rsx_Model_Abstract +{ + #[Ajax_Endpoint_Model_Fetch] + public static function fetch($id) + { + // Authorization check + if (!Session::is_logged_in()) { + return false; + } + + // Fetch single record + $model = static::find($id); + return $model ?: false; + } +} +``` + +### With Data Filtering + +```php +#[Ajax_Endpoint_Model_Fetch] +public static function fetch($id) +{ + if (!Session::is_logged_in()) { + return false; + } + + $user = static::find($id); + if (!$user) { + return false; + } + + // Remove sensitive fields + unset($user->password_hash); + unset($user->remember_token); + + return $user; +} +``` + +### With Authorization Check + +```php +#[Ajax_Endpoint_Model_Fetch] +public static function fetch($id) +{ + $current_user = Session::get_user(); + if (!$current_user) { + return false; + } + + $order = static::find($id); + if (!$order) { + return false; + } + + // Only allow access to own orders or admin users + if ($order->user_id !== $current_user->id && !$current_user->is_admin) { + return false; + } + + return $order; +} +``` + +### Augmented Array Return (Computed Fields) + +```php +#[Ajax_Endpoint_Model_Fetch] +public static function fetch($id) +{ + if (!Session::is_logged_in()) { + return false; + } + + $contact = static::find($id); + if (!$contact) { + return false; + } + + // Start with toArray() to preserve __MODEL for hydration + $data = $contact->toArray(); + + // Add computed fields + $data['full_name'] = $contact->full_name(); + $data['avatar_url'] = $contact->get_avatar_url(); + + return $data; +} +``` + +**Important**: Always use `toArray()` as the base - it preserves `__MODEL` for JavaScript hydration. + +--- + +## JavaScript Usage + +### Fetch Single Record + +```javascript +// Throws if not found +const project = await Project_Model.fetch(123); +console.log(project.name); + +// Returns null if not found +const maybe = await Project_Model.fetch_or_null(999); +if (maybe) { + console.log(maybe.name); +} +``` + +### Enum Properties (BEM-Style) + +```javascript +const project = await Project_Model.fetch(1); + +// Instance properties (from fetched data) +console.log(project.status_id__label); // "Active" +console.log(project.status_id__badge); // "bg-success" + +// Static constants +if (project.status_id === Project_Model.STATUS_ACTIVE) { + // ... +} +``` + +--- + +## Lazy Relationships + +Relationships can be loaded on-demand from JavaScript. The related model must also implement `fetch()` with `#[Ajax_Endpoint_Model_Fetch]`. + +### belongsTo Relationship + +```php +// In Project_Model +#[Ajax_Endpoint_Model_Fetch] +public function client() +{ + return $this->belongsTo(Client_Model::class); +} +``` + +```javascript +const project = await Project_Model.fetch(123); +const client = await project.client(); // Returns Client_Model or null +console.log(client.name); +``` + +### hasMany Relationship + +```php +// In Project_Model +#[Ajax_Endpoint_Model_Fetch] +public function tasks() +{ + return $this->hasMany(Task_Model::class); +} +``` + +```javascript +const project = await Project_Model.fetch(123); +const tasks = await project.tasks(); // Returns Task_Model[] +for (const task of tasks) { + console.log(task.title); +} +``` + +### morphTo Relationship + +```php +// In Activity_Model +#[Ajax_Endpoint_Model_Fetch] +public function subject() +{ + return $this->morphTo(); +} +``` + +```javascript +const activity = await Activity_Model.fetch(1); +const subject = await activity.subject(); // Returns polymorphic model +``` + +--- + +## Return Value Rules + +| Return | Meaning | +|--------|---------| +| Model object | Serialized via `toArray()`, includes `__MODEL` for hydration | +| Array (from `toArray()`) | Preserves `__MODEL`, can add computed fields | +| `false` | Record not found or unauthorized | + +**MUST return `false`** (not `null`) when record is not found or unauthorized. + +--- + +## Anti-Aliasing Policy + +**NEVER alias enum properties in fetch()** - BEM-style naming exists for grepability: + +```php +// WRONG - Aliasing obscures data source +$data['type_label'] = $record->type_id__label; + +// RIGHT - Use full BEM-style names in JavaScript +contact.type_id__label // Grepable, self-documenting +``` + +The `fetch()` method's purpose is **security** (removing private data), not aliasing. + +--- + +## Common Patterns + +### In SPA Action on_load() + +```javascript +async on_load() { + const project = await Project_Model.fetch(this.args.id); + this.data.project = project; +} +``` + +### Loading with Relationships + +```javascript +async on_load() { + const project = await Project_Model.fetch(this.args.id); + const [client, tasks] = await Promise.all([ + project.client(), + project.tasks() + ]); + + this.data.project = project; + this.data.client = client; + this.data.tasks = tasks; +} +``` + +### Conditional Relationship Loading + +```javascript +async on_load() { + const order = await Order_Model.fetch(this.args.id); + this.data.order = order; + + // Only load customer if needed + if (this.args.show_customer) { + this.data.customer = await order.customer(); + } +} +``` + +## More Information + +Details: `php artisan rsx:man model_fetch` diff --git a/docs/skills/polymorphic/SKILL.md b/docs/skills/polymorphic/SKILL.md new file mode 100755 index 000000000..b12772ae4 --- /dev/null +++ b/docs/skills/polymorphic/SKILL.md @@ -0,0 +1,236 @@ +--- +name: polymorphic +description: RSX polymorphic relationships with type references storing integers instead of class names. Use when implementing morphTo relationships, defining type_ref_columns, handling polymorphic form fields, or using polymorphic join helpers. +--- + +# RSX Polymorphic Relationships + +## Overview + +RSX uses a type reference system that stores **integers** in the database but transparently converts to/from class name strings in PHP. + +```php +$activity->eventable_type = 'Contact_Model'; // Stores integer in DB +echo $activity->eventable_type; // Returns "Contact_Model" +``` + +**Benefits**: +- Efficient integer storage (not VARCHAR class names) +- Automatic type discovery +- Transparent conversion +- Laravel morphTo() compatibility + +--- + +## Defining Type Reference Columns + +Declare which columns are type references in your model: + +```php +class Activity_Model extends Rsx_Model_Abstract +{ + protected static $type_ref_columns = ['eventable_type']; + + public function eventable() + { + return $this->morphTo(); + } +} +``` + +The cast is automatically applied - no manual `$casts` needed. + +--- + +## Database Schema + +Type reference columns must be **BIGINT**, not VARCHAR: + +```sql +CREATE TABLE activities ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + eventable_type BIGINT NULL, + eventable_id BIGINT NULL, + action VARCHAR(50) NOT NULL, + INDEX idx_eventable (eventable_type, eventable_id) +); +``` + +--- + +## Usage + +### Setting Values + +```php +$activity = new Activity_Model(); +$activity->eventable_type = 'Contact_Model'; // Use class name +$activity->eventable_id = 123; +$activity->save(); +``` + +### Reading Values + +```php +echo $activity->eventable_type; // "Contact_Model" (string) +$related = $activity->eventable; // Returns Contact_Model instance +``` + +### Querying + +Class names are automatically converted to IDs in WHERE clauses: + +```php +// All work - class names auto-converted +Activity_Model::where('eventable_type', 'Contact_Model')->get(); +Activity_Model::whereIn('eventable_type', ['Contact_Model', 'Project_Model'])->get(); +``` + +--- + +## Polymorphic Join Helpers + +Join tables with polymorphic columns: + +```php +// INNER JOIN - contacts that have attachments +Contact_Model::query() + ->joinMorph('file_attachments', 'fileable') + ->select('contacts.*', 'file_attachments.filename') + ->get(); + +// LEFT JOIN - all contacts, with attachments if they exist +Contact_Model::query() + ->leftJoinMorph('file_attachments', 'fileable') + ->get(); + +// RIGHT JOIN +Contact_Model::query() + ->rightJoinMorph('file_attachments', 'fileable') + ->get(); +``` + +**Parameters**: +- `$table` - Table with polymorphic columns (e.g., 'file_attachments') +- `$morphName` - Column prefix (e.g., 'fileable' for fileable_type/fileable_id) +- `$morphClass` - Optional explicit class (defaults to current model) + +--- + +## Form Handling + +### Client-Side Format + +Polymorphic fields submit as JSON: + +```javascript +eventable={"model":"Contact_Model","id":123} +``` + +### Server-Side Parsing + +```php +use App\RSpade\Core\Polymorphic_Field_Helper; + +#[Ajax_Endpoint] +public static function save(Request $request, array $params = []) +{ + $eventable = Polymorphic_Field_Helper::parse($params['eventable'], [ + Contact_Model::class, + Project_Model::class, + ]); + + // Validate + if ($error = $eventable->validate('Please select an entity')) { + return response_error(Ajax::ERROR_VALIDATION, ['eventable' => $error]); + } + + // Use + $activity = new Activity_Model(); + $activity->eventable_type = $eventable->model; // "Contact_Model" + $activity->eventable_id = $eventable->id; // 123 + $activity->save(); +} +``` + +**Important**: Always use `Model::class` for the whitelist. + +--- + +## Auto-Discovery + +When storing a new class name that isn't in `_type_refs` yet: + +```php +$attachment->fileable_type = 'Custom_Model'; +$attachment->save(); +``` + +RSX will: +1. Verify `Custom_Model` exists and extends `Rsx_Model_Abstract` +2. Create a new `_type_refs` entry with next available ID +3. Store that ID in the column + +Any model can be used without pre-registration. + +--- + +## Common Patterns + +### File Attachments to Multiple Models + +```php +class File_Attachment_Model extends Rsx_Model_Abstract +{ + protected static $type_ref_columns = ['fileable_type']; + + public function fileable() + { + return $this->morphTo(); + } +} + +// Attach to contact +$attachment->fileable_type = 'Contact_Model'; +$attachment->fileable_id = $contact->id; + +// Attach to project +$attachment->fileable_type = 'Project_Model'; +$attachment->fileable_id = $project->id; +``` + +### Activity Log + +```php +class Activity_Model extends Rsx_Model_Abstract +{ + protected static $type_ref_columns = ['subject_type']; + + public function subject() + { + return $this->morphTo(); + } +} + +// Log activity for any model +Activity_Model::log('updated', $contact); // subject_type = 'Contact_Model' +Activity_Model::log('created', $project); // subject_type = 'Project_Model' +``` + +--- + +## Simple Names Only + +Always use simple class names (basename), never FQCNs: + +```php +// ✅ Correct +$activity->eventable_type = 'Contact_Model'; + +// ❌ Wrong - fully qualified +$activity->eventable_type = 'App\\Models\\Contact_Model'; +``` + +## More Information + +Details: `php artisan rsx:man polymorphic` diff --git a/docs/skills/scss/SKILL.md b/docs/skills/scss/SKILL.md new file mode 100755 index 000000000..139e8bd20 --- /dev/null +++ b/docs/skills/scss/SKILL.md @@ -0,0 +1,251 @@ +--- +name: scss +description: SCSS styling architecture in RSX including component scoping, BEM naming, responsive breakpoints, and variables. Use when writing SCSS files, styling components, working with responsive design, or troubleshooting CSS conflicts. +--- + +# RSX SCSS Architecture + +## Component-First Philosophy + +Every styled element is a component with scoped SCSS. No CSS spaghetti - no generic classes like `.page-header` scattered across files. + +**Pattern vs Unique Decision**: +- If you're copy-pasting markup, extract a component +- Reusable structures → shared component with slots +- One-off structures → page-specific component + +--- + +## Directory Rules + +| Location | Purpose | Scoping | +|----------|---------|---------| +| `rsx/app/` | Feature components | Must wrap in component class | +| `rsx/theme/components/` | Shared components | Must wrap in component class | +| `rsx/theme/` (outside components/) | Primitives, variables, Bootstrap overrides | Global | +| `rsx/lib/` | Non-visual utilities | No styles | + +--- + +## Component Scoping (Required) + +SCSS in `rsx/app/` and `rsx/theme/components/` **must** wrap in a single component class: + +```scss +// dashboard_index_action.scss +.Dashboard_Index_Action { + padding: 2rem; + + .card { + margin-bottom: 1rem; + } + + .stats-grid { + display: grid; + gap: 1rem; + } +} +``` + +- Wrapper class matches JS class or Blade `@rsx_id` +- Filename must match associated `.js` or `.blade.php` file +- Components auto-render with `class="Component_Name"` on root + +--- + +## BEM Child Classes + +Child classes use exact PascalCase component name as prefix: + +```scss +.DataGrid_Kanban { + &__loading { /* .DataGrid_Kanban__loading */ } + &__board { /* .DataGrid_Kanban__board */ } + &__column { /* .DataGrid_Kanban__column */ } +} +``` + +```html + +
+ + +
+``` + +**No kebab-case** in component BEM classes. + +--- + +## Variables + +Define in `rsx/theme/variables.scss`. **Check this file before writing new SCSS.** + +In bundles, variables.scss must be included before directory includes: + +```php +'include' => [ + 'rsx/theme/variables.scss', // First + 'rsx/theme', // Then directories + 'rsx/app/frontend', +], +``` + +Variables can be declared outside the wrapper for sharing: + +```scss +// frontend_spa_layout.scss +$sidebar-width: 215px; +$header-height: 57px; + +.Frontend_Spa_Layout { + .sidebar { width: $sidebar-width; } +} +``` + +--- + +## Variables-Only Files + +Files with only `$var: value;` declarations (no selectors) are valid without wrapper: + +```scss +// _variables.scss +$primary-color: #0d6efd; +$border-radius: 0.375rem; +``` + +--- + +## Supplemental SCSS Files + +Split large SCSS by breakpoint or feature: + +``` +frontend_spa_layout.scss # Primary (required) +frontend_spa_layout_mobile.scss # Supplemental +frontend_spa_layout_print.scss # Supplemental +``` + +Supplemental files use the **same wrapper class** as primary: + +```scss +// frontend_spa_layout_mobile.scss +.Frontend_Spa_Layout { + @media (max-width: 768px) { + .sidebar { width: 100%; } + } +} +``` + +--- + +## Responsive Breakpoints + +RSX replaces Bootstrap breakpoints. **Bootstrap's `.col-md-6`, `.d-lg-none` do NOT work.** + +### Tier 1 (Simple) + +| Name | Range | +|------|-------| +| `mobile` | 0-1023px | +| `desktop` | 1024px+ | + +### Tier 2 (Granular) + +| Name | Range | +|------|-------| +| `phone` | 0-799px | +| `tablet` | 800-1023px | +| `desktop-sm` | 1024-1199px | +| `desktop-md` | 1200-1639px | +| `desktop-lg` | 1640-2199px | +| `desktop-xl` | 2200px+ | + +### SCSS Mixins + +```scss +.Component { + padding: 2rem; + + @include mobile { + padding: 1rem; + } + + @include phone { + padding: 0.5rem; + } + + @include desktop-xl { + max-width: 1800px; + } +} +``` + +### Utility Classes + +```html +
...
+
Hidden on mobile
+
Only visible on mobile
+
Hidden on tablet only
+``` + +### JavaScript Detection + +```javascript +if (Responsive.is_mobile()) { + // Mobile behavior +} + +if (Responsive.is_desktop_xl()) { + // Extra large desktop +} +``` + +--- + +## Slot-Based Composition + +Use slots to separate structure from content: + +```jqhtml + +
+
<%= content('toolbar') %>
+
<%= content('body') %>
+
+
+ + + + + + +``` + +Component owns layout/styling; pages provide content via slots. + +--- + +## What Remains Shared (Unscoped) + +Only primitives should be unscoped: +- Buttons (`.btn-primary`, `.btn-secondary`) +- Spacing utilities (`.mb-3`, `.p-2`) +- Typography (`.text-muted`, `.fw-bold`) +- Bootstrap overrides + +Everything else → component-scoped SCSS. + +--- + +## No Exemptions + +There are **no exemptions** to scoping rules for files in `rsx/app/` or `rsx/theme/components/`. If a file can't be associated with a component, it likely belongs in: +- `rsx/theme/base/` for global utilities +- A dedicated partial imported via `@use` + +## More Information + +Details: `php artisan rsx:man scss`, `php artisan rsx:man responsive`