Add skills documentation and misc updates
Add form value persistence across cache revalidation re-renders 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -74,11 +74,19 @@ class Rsx_Breadcrumb_Resolver {
|
|||||||
return generation === Rsx_Breadcrumb_Resolver._generation;
|
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
|
* Get cached chain for a URL
|
||||||
*/
|
*/
|
||||||
static _get_cached_chain(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);
|
return Rsx_Storage.session_get(cache_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +94,7 @@ class Rsx_Breadcrumb_Resolver {
|
|||||||
* Cache a resolved chain
|
* Cache a resolved chain
|
||||||
*/
|
*/
|
||||||
static _cache_chain(url, 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)
|
// Store simplified chain data (url, label, is_active)
|
||||||
const cache_data = chain.map(item => ({
|
const cache_data = chain.map(item => ({
|
||||||
url: item.url,
|
url: item.url,
|
||||||
@@ -250,7 +258,7 @@ class Rsx_Breadcrumb_Resolver {
|
|||||||
*/
|
*/
|
||||||
static clear_cache(url) {
|
static clear_cache(url) {
|
||||||
if (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);
|
Rsx_Storage.session_remove(cache_key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\RSpade\CodeQuality\Rules\JavaScript;
|
|||||||
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
||||||
use App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException;
|
use App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException;
|
||||||
use App\RSpade\Core\Cache\RsxCache;
|
use App\RSpade\Core\Cache\RsxCache;
|
||||||
|
use App\RSpade\Core\Manifest\Manifest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check Component implementations for common AI agent mistakes
|
* Check Component implementations for common AI agent mistakes
|
||||||
@@ -47,8 +48,26 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if not a Component subclass
|
// Skip if no class defined
|
||||||
if (!isset($metadata['extends']) || $metadata['extends'] !== 'Component') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +78,16 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst
|
|||||||
return;
|
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);
|
$lines = explode("\n", $contents);
|
||||||
|
|
||||||
// Check for render() method and incorrect lifecycle methods
|
// 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_create(): Called when component is created\n";
|
||||||
$error_message .= "- on_load(): Called to load async data\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_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 .= "FIX:\n";
|
||||||
$error_message .= "Rename '{$method_name}()' to 'on_{$method_name}()'\n";
|
$error_message .= "Rename '{$method_name}()' to 'on_{$method_name}()'\n";
|
||||||
$error_message .= "==========================================";
|
$error_message .= "==========================================";
|
||||||
|
|
||||||
throw new YoureDoingItWrongException($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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
129
app/RSpade/upstream_changes/form_value_persistence_12_29.txt
Executable file
129
app/RSpade/upstream_changes/form_value_persistence_12_29.txt
Executable file
@@ -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
|
||||||
1314
docs/CLAUDE.archive.12.29.25.md
Executable file
1314
docs/CLAUDE.archive.12.29.25.md
Executable file
File diff suppressed because it is too large
Load Diff
@@ -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.
|
**Note**: SPA pages are the preferred standard. Use Blade only for SEO-critical public pages or authentication flows.
|
||||||
|
|
||||||
```blade
|
Pattern recognition:
|
||||||
@rsx_id('Frontend_Index') {{-- Every view starts with this --}}
|
- `@rsx_id('View_Name')` - required first line
|
||||||
<body class="{{ rsx_body_class() }}"> {{-- Adds view class --}}
|
- `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.
|
**Detailed guidance in `blade-views` skill** - auto-activates when building Blade pages.
|
||||||
**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.
|
## SCSS ARCHITECTURE
|
||||||
|
|
||||||
**BEM**: Child classes use component's exact name as prefix. `.Component_Name { &__element }` → HTML: `<div class="Component_Name__element">` (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
|
**Detailed guidance in `scss` skill** - auto-activates when styling components.
|
||||||
|
|
||||||
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')
|
|
||||||
<!-- Your HTML -->
|
|
||||||
@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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -732,174 +691,27 @@ this.$sid('result_container').component('My_Component', {
|
|||||||
|
|
||||||
## FORM COMPONENTS
|
## FORM COMPONENTS
|
||||||
|
|
||||||
**Form fields** (`<Rsx_Form>` with `$data`, `$controller`, `$method`):
|
Forms use `<Rsx_Form>` with `$data`, `$controller`, `$method` for automatic data binding. Key components: `Form_Field`, `Form_Field_Hidden`, input types (`Text_Input`, `Select_Input`, `Checkbox_Input`).
|
||||||
```blade
|
|
||||||
<Rsx_Form $data="{!! json_encode($form_data) !!}" $controller="Controller" $method="save">
|
|
||||||
<Form_Field $name="email" $label="Email" $required=true>
|
|
||||||
<Text_Input $type="email" />
|
|
||||||
</Form_Field>
|
|
||||||
|
|
||||||
<Form_Field_Hidden $name="id" />
|
Pattern recognition:
|
||||||
</Rsx_Form>
|
- `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
|
**Detailed guidance in `forms` skill** - auto-activates when building forms.
|
||||||
- **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
|
|
||||||
<Text_Input $type="email" $disabled=true />
|
|
||||||
<Select_Input $options="{!! json_encode($options) !!}" $disabled=true />
|
|
||||||
<Checkbox_Input $label="Subscribe" $disabled=true />
|
|
||||||
```
|
|
||||||
|
|
||||||
**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
|
## MODALS
|
||||||
|
|
||||||
### Built-in Dialog Types
|
Built-in dialogs: `Modal.alert()`, `Modal.confirm()`, `Modal.prompt()`, `Modal.select()`, `Modal.error()`.
|
||||||
|
|
||||||
| Method | Returns | Description |
|
Form modals: `Modal.form({title, component, on_submit})` - component must implement `vals()`.
|
||||||
|--------|---------|-------------|
|
|
||||||
| `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
|
Reusable modals: Extend `Modal_Abstract`, implement static `show()`.
|
||||||
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
|
**Detailed guidance in `modals` skill** - auto-activates when building 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 `<div $sid="error_container"></div>`.
|
|
||||||
|
|
||||||
### 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`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -934,46 +746,15 @@ User_Model::create(['email' => $email]);
|
|||||||
|
|
||||||
### Enums
|
### 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
|
**Detailed guidance in `model-enums` skill** - auto-activates when implementing enums.
|
||||||
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
|
### 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.
|
Files upload UNATTACHED → validate → assign via API. Session-based validation prevents cross-user file assignment.
|
||||||
|
|
||||||
```php
|
Pattern recognition:
|
||||||
// Controller: Assign uploaded file
|
- `File_Attachment_Model::find_by_key()` + `can_user_assign_this_file()`
|
||||||
$attachment = File_Attachment_Model::find_by_key($params['photo_key']);
|
- `attach_to()` (single/replaces) vs `add_to()` (multiple/adds)
|
||||||
if ($attachment && $attachment->can_user_assign_this_file()) {
|
- `get_attachment()` / `get_attachments()` for retrieval
|
||||||
$attachment->attach_to($user, 'profile_photo'); // Single (replaces)
|
- `get_url()`, `get_download_url()`, `get_thumbnail_url()`
|
||||||
$attachment->add_to($project, 'documents'); // Multiple (adds)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model: Retrieve attachments
|
**Detailed guidance in `file-attachments` skill** - auto-activates when handling uploads.
|
||||||
$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`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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)
|
**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
|
**Detailed guidance in `date-time` skill** - auto-activates when working with dates/times.
|
||||||
```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`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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
|
## GETTING HELP
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
245
docs/skills/ajax-error-handling/SKILL.md
Executable file
245
docs/skills/ajax-error-handling/SKILL.md
Executable file
@@ -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`
|
||||||
262
docs/skills/background-tasks/SKILL.md
Executable file
262
docs/skills/background-tasks/SKILL.md
Executable file
@@ -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`
|
||||||
149
docs/skills/blade-views/SKILL.md
Executable file
149
docs/skills/blade-views/SKILL.md
Executable file
@@ -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')
|
||||||
|
|
||||||
|
<body class="{{ rsx_body_class() }}">
|
||||||
|
@section('content')
|
||||||
|
<h1>Welcome</h1>
|
||||||
|
@endsection
|
||||||
|
</body>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `@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')
|
||||||
|
<div class="Editor">...</div>
|
||||||
|
@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
|
||||||
|
<Dashboard>
|
||||||
|
<Stats_Panel $user_id="{{ $user->id }}" />
|
||||||
|
<Recent_Activity $limit="10" />
|
||||||
|
</Dashboard>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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`
|
||||||
286
docs/skills/crud-patterns/SKILL.md
Executable file
286
docs/skills/crud-patterns/SKILL.md
Executable file
@@ -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
|
||||||
|
<Define:Contacts_Index_Action tag="div">
|
||||||
|
<div class="d-flex justify-content-between mb-3">
|
||||||
|
<h1>Contacts</h1>
|
||||||
|
<a href="<%= Rsx.Route('Contacts_Edit_Action') %>" class="btn btn-primary">
|
||||||
|
Add Contact
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Contacts_DataGrid />
|
||||||
|
</Define:Contacts_Index_Action>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
<Define:Contacts_View_Action tag="div">
|
||||||
|
<% if (!this.data.contact && !this.data.error) { %>
|
||||||
|
<Loading_Spinner />
|
||||||
|
<% } else if (this.data.error) { %>
|
||||||
|
<Universal_Error_Page_Component $error="<%= this.data.error %>" />
|
||||||
|
<% } else { %>
|
||||||
|
<h1><%= this.data.contact.name %></h1>
|
||||||
|
<p>Email: <%= this.data.contact.email %></p>
|
||||||
|
|
||||||
|
<a href="<%= Rsx.Route('Contacts_Edit_Action', this.data.contact.id) %>"
|
||||||
|
class="btn btn-primary">Edit</a>
|
||||||
|
<% } %>
|
||||||
|
</Define:Contacts_View_Action>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
<Define:Contacts_Edit_Action tag="div">
|
||||||
|
<% if (this.data.is_edit && !this.data.form_data.id && !this.data.error) { %>
|
||||||
|
<Loading_Spinner />
|
||||||
|
<% } else if (this.data.error) { %>
|
||||||
|
<Universal_Error_Page_Component $error="<%= this.data.error %>" />
|
||||||
|
<% } else { %>
|
||||||
|
<h1><%= this.data.is_edit ? 'Edit Contact' : 'Add Contact' %></h1>
|
||||||
|
|
||||||
|
<Rsx_Form $controller="Frontend_Contacts_Controller" $method="save"
|
||||||
|
$data="<%= JSON.stringify(this.data.form_data) %>">
|
||||||
|
<Form_Hidden_Field $name="id" />
|
||||||
|
|
||||||
|
<Form_Field $name="name" $label="Name" $required=true>
|
||||||
|
<Text_Input />
|
||||||
|
</Form_Field>
|
||||||
|
|
||||||
|
<Form_Field $name="email" $label="Email">
|
||||||
|
<Text_Input $type="email" />
|
||||||
|
</Form_Field>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<%= this.data.is_edit ? 'Save Changes' : 'Create Contact' %>
|
||||||
|
</button>
|
||||||
|
</Rsx_Form>
|
||||||
|
<% } %>
|
||||||
|
</Define:Contacts_Edit_Action>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
244
docs/skills/date-time/SKILL.md
Executable file
244
docs/skills/date-time/SKILL.md
Executable file
@@ -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
|
||||||
|
<span><%= Rsx_Time.format_datetime(this.data.record.created_at) %></span>
|
||||||
|
<span><%= Rsx_Time.relative(this.data.record.updated_at) %></span>
|
||||||
|
<span><%= Rsx_Date.format(this.data.record.due_date) %></span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Display
|
||||||
|
|
||||||
|
```jqhtml
|
||||||
|
<% if (Rsx_Date.is_past(this.data.task.due_date)) { %>
|
||||||
|
<span class="text-danger">Overdue</span>
|
||||||
|
<% } %>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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`
|
||||||
252
docs/skills/event-hooks/SKILL.md
Executable file
252
docs/skills/event-hooks/SKILL.md
Executable file
@@ -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, '<p><a><strong>');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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`
|
||||||
300
docs/skills/file-attachments/SKILL.md
Executable file
300
docs/skills/file-attachments/SKILL.md
Executable file
@@ -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
|
||||||
|
<File_Upload
|
||||||
|
$name="document"
|
||||||
|
$accept=".pdf,.doc,.docx"
|
||||||
|
$max_size="10MB"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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) { %>
|
||||||
|
<img src="<%= this.data.user.profile_photo.get_thumbnail_url('cover', 64, 64) %>"
|
||||||
|
alt="Profile photo" />
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% for (const doc of this.data.project.documents) { %>
|
||||||
|
<a href="<%= doc.get_download_url() %>">
|
||||||
|
<%= doc.file_name %>
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
284
docs/skills/forms/SKILL.md
Executable file
284
docs/skills/forms/SKILL.md
Executable file
@@ -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 `<Rsx_Form>` with automatic data binding:
|
||||||
|
|
||||||
|
```jqhtml
|
||||||
|
<Rsx_Form $data="<%= JSON.stringify(this.data.form_data) %>"
|
||||||
|
$controller="Controller" $method="save">
|
||||||
|
<Form_Field $name="email" $label="Email" $required=true>
|
||||||
|
<Text_Input $type="email" />
|
||||||
|
</Form_Field>
|
||||||
|
|
||||||
|
<Form_Hidden_Field $name="id" />
|
||||||
|
</Rsx_Form>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
<Text_Input $type="email" $placeholder="user@example.com" />
|
||||||
|
<Text_Input $type="textarea" $rows="5" />
|
||||||
|
<Text_Input $type="number" $min="0" $max="100" />
|
||||||
|
<Text_Input $prefix="@" $placeholder="username" />
|
||||||
|
<Text_Input $maxlength="100" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Select_Input Formats
|
||||||
|
|
||||||
|
```jqhtml
|
||||||
|
<%-- Simple array --%>
|
||||||
|
<Select_Input $options="<%= JSON.stringify(['Option 1', 'Option 2']) %>" />
|
||||||
|
|
||||||
|
<%-- Value/label objects --%>
|
||||||
|
<Select_Input $options="<%= JSON.stringify([
|
||||||
|
{value: 'opt1', label: 'Option 1'},
|
||||||
|
{value: 'opt2', label: 'Option 2'}
|
||||||
|
]) %>" />
|
||||||
|
|
||||||
|
<%-- From model enum --%>
|
||||||
|
<Select_Input $options="<%= JSON.stringify(Project_Model.status_id__enum_select()) %>" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
<Text_Input $type="email" $disabled=true />
|
||||||
|
<Select_Input $options="..." $disabled=true />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-Column Layouts
|
||||||
|
|
||||||
|
Use Bootstrap grid for multi-column field layouts:
|
||||||
|
|
||||||
|
```jqhtml
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<Form_Field $name="first_name" $label="First Name">
|
||||||
|
<Text_Input />
|
||||||
|
</Form_Field>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<Form_Field $name="last_name" $label="Last Name">
|
||||||
|
<Text_Input />
|
||||||
|
</Form_Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
<Text_Input $seeder="company_name" />
|
||||||
|
<Text_Input $seeder="email" />
|
||||||
|
<Text_Input $seeder="phone" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
202
docs/skills/jquery-extensions/SKILL.md
Executable file
202
docs/skills/jquery-extensions/SKILL.md
Executable file
@@ -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 `<body>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
243
docs/skills/js-decorators/SKILL.md
Executable file
243
docs/skills/js-decorators/SKILL.md
Executable file
@@ -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`
|
||||||
198
docs/skills/migrations/SKILL.md
Executable file
198
docs/skills/migrations/SKILL.md
Executable file
@@ -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`
|
||||||
254
docs/skills/modals/SKILL.md
Executable file
254
docs/skills/modals/SKILL.md
Executable file
@@ -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 `<div $sid="error_container"></div>` 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`
|
||||||
246
docs/skills/model-enums/SKILL.md
Executable file
246
docs/skills/model-enums/SKILL.md
Executable file
@@ -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
|
||||||
|
<span class="badge <%= this.args.project.status_id__badge %>">
|
||||||
|
<%= this.args.project.status_id__label %>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Select_Input
|
||||||
|
$name="status_id"
|
||||||
|
$options="<%= JSON.stringify(Project_Model.status_id__enum_select()) %>"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
287
docs/skills/model-fetch/SKILL.md
Executable file
287
docs/skills/model-fetch/SKILL.md
Executable file
@@ -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`
|
||||||
236
docs/skills/polymorphic/SKILL.md
Executable file
236
docs/skills/polymorphic/SKILL.md
Executable file
@@ -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`
|
||||||
251
docs/skills/scss/SKILL.md
Executable file
251
docs/skills/scss/SKILL.md
Executable file
@@ -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
|
||||||
|
<!-- Correct -->
|
||||||
|
<div class="DataGrid_Kanban__loading">
|
||||||
|
|
||||||
|
<!-- WRONG - kebab-case doesn't match compiled CSS -->
|
||||||
|
<div class="datagrid-kanban__loading"> <!-- No styles! -->
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
<div class="col-mobile-12 col-desktop-6">...</div>
|
||||||
|
<div class="d-mobile-none">Hidden on mobile</div>
|
||||||
|
<div class="mobile-only">Only visible on mobile</div>
|
||||||
|
<div class="hide-tablet">Hidden on tablet only</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<Define:Datagrid_Card>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><%= content('toolbar') %></div>
|
||||||
|
<div class="card-body"><%= content('body') %></div>
|
||||||
|
</div>
|
||||||
|
</Define:Datagrid_Card>
|
||||||
|
|
||||||
|
<!-- Usage -->
|
||||||
|
<Datagrid_Card>
|
||||||
|
<Slot:toolbar><button>Add</button></Slot:toolbar>
|
||||||
|
<Slot:body><My_Datagrid /></Slot:body>
|
||||||
|
</Datagrid_Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
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`
|
||||||
Reference in New Issue
Block a user