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:
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.
|
||||
|
||||
```blade
|
||||
@rsx_id('Frontend_Index') {{-- Every view starts with this --}}
|
||||
<body class="{{ rsx_body_class() }}"> {{-- Adds view class --}}
|
||||
```
|
||||
Pattern recognition:
|
||||
- `@rsx_id('View_Name')` - required first line
|
||||
- `rsx_body_class()` - adds view class for CSS scoping
|
||||
- `@rsx_page_data()` - pass data to JS
|
||||
- `on_app_ready()` with page guard for JS (fires for ALL pages in bundle)
|
||||
|
||||
**NO inline styles, scripts, or event handlers** - Use companion `.scss` and `.js` files.
|
||||
**jqhtml components** work fully in Blade (no slots).
|
||||
**Detailed guidance in `blade-views` skill** - auto-activates when building Blade pages.
|
||||
|
||||
### SCSS Component-First Architecture
|
||||
---
|
||||
|
||||
SCSS in `rsx/app/` and `rsx/theme/components/` must wrap in a single component class matching the jqhtml/blade file. Components auto-render with `class="Component_Name"` on root. `rsx/lib/` is non-visual. `rsx/theme/` (outside components/) holds primitives, variables, Bootstrap overrides.
|
||||
## SCSS ARCHITECTURE
|
||||
|
||||
**BEM**: Child classes use component's exact name as prefix. `.Component_Name { &__element }` → HTML: `<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
|
||||
|
||||
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.
|
||||
**Detailed guidance in `scss` skill** - auto-activates when styling components.
|
||||
|
||||
---
|
||||
|
||||
@@ -732,174 +691,27 @@ this.$sid('result_container').component('My_Component', {
|
||||
|
||||
## FORM COMPONENTS
|
||||
|
||||
**Form fields** (`<Rsx_Form>` with `$data`, `$controller`, `$method`):
|
||||
```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>
|
||||
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`).
|
||||
|
||||
<Form_Field_Hidden $name="id" />
|
||||
</Rsx_Form>
|
||||
```
|
||||
Pattern recognition:
|
||||
- `vals()` dual-mode method for get/set
|
||||
- `Form_Utils.apply_form_errors()` for validation
|
||||
- Action loads data, Controller saves it
|
||||
- `$disabled=true` still returns values (unlike HTML)
|
||||
|
||||
- **Form_Field** - Standard formatted field with label, errors, help text
|
||||
- **Form_Field_Hidden** - Single-tag hidden input (extends Form_Field_Abstract)
|
||||
- **Form_Field_Abstract** - Base class for custom formatting (advanced)
|
||||
|
||||
**Disabled fields**: Use `$disabled=true` attribute on input components to disable fields. Unlike standard HTML, disabled fields still return values via `vals()` (useful for read-only data that should be submitted).
|
||||
|
||||
```blade
|
||||
<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`
|
||||
**Detailed guidance in `forms` skill** - auto-activates when building forms.
|
||||
|
||||
---
|
||||
|
||||
## MODALS
|
||||
|
||||
### Built-in Dialog Types
|
||||
Built-in dialogs: `Modal.alert()`, `Modal.confirm()`, `Modal.prompt()`, `Modal.select()`, `Modal.error()`.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `Modal.alert(body)` | `void` | Simple notification |
|
||||
| `Modal.alert(title, body, buttonLabel?)` | `void` | Alert with title |
|
||||
| `Modal.confirm(body)` | `boolean` | Yes/no confirmation |
|
||||
| `Modal.confirm(title, body, confirmLabel?, cancelLabel?)` | `boolean` | Confirmation with labels |
|
||||
| `Modal.prompt(body)` | `string\|false` | Text input |
|
||||
| `Modal.prompt(title, body, default?, multiline?)` | `string\|false` | Prompt with options |
|
||||
| `Modal.select(body, options)` | `string\|false` | Dropdown selection |
|
||||
| `Modal.select(title, body, options, default?, placeholder?)` | `string\|false` | Select with options |
|
||||
| `Modal.error(error, title?)` | `void` | Error with red styling |
|
||||
| `Modal.unclosable(title, body)` | `void` | Modal user cannot close |
|
||||
Form modals: `Modal.form({title, component, on_submit})` - component must implement `vals()`.
|
||||
|
||||
```javascript
|
||||
await Modal.alert("File saved");
|
||||
if (await Modal.confirm("Delete?")) { /* confirmed */ }
|
||||
const name = await Modal.prompt("Enter name:");
|
||||
const choice = await Modal.select("Choose:", [{value: 'a', label: 'A'}, {value: 'b', label: 'B'}]);
|
||||
await Modal.error("Something went wrong");
|
||||
```
|
||||
Reusable modals: Extend `Modal_Abstract`, implement static `show()`.
|
||||
|
||||
### Form Modals
|
||||
|
||||
```javascript
|
||||
const result = await Modal.form({
|
||||
title: "Edit User",
|
||||
component: "User_Form",
|
||||
component_args: {data: user},
|
||||
on_submit: async (form) => {
|
||||
const response = await User_Controller.save(form.vals());
|
||||
if (response.errors) {
|
||||
Form_Utils.apply_form_errors(form.$, response.errors);
|
||||
return false; // Keep open
|
||||
}
|
||||
return response.data; // Close and return
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Form component must implement `vals()` and include `<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`
|
||||
**Detailed guidance in `modals` skill** - auto-activates when building modals.
|
||||
|
||||
---
|
||||
|
||||
@@ -934,46 +746,15 @@ User_Model::create(['email' => $email]);
|
||||
|
||||
### Enums
|
||||
|
||||
**CRITICAL: Read `php artisan rsx:man enum` for complete documentation before implementing.**
|
||||
Integer-backed enums with model-level mapping. Define in `$enums` array with `constant`, `label`, and custom properties.
|
||||
|
||||
Integer-backed enums with model-level mapping to constants, labels, and custom properties. Uses BEM-style double underscore naming for magic properties.
|
||||
Pattern recognition:
|
||||
- BEM-style access: `$model->status_id__label`, `$model->status_id__badge`
|
||||
- JS methods: `Model.status_id__enum()`, `Model.status_id__enum_select()`
|
||||
- Static constants: `Model::STATUS_ACTIVE`
|
||||
- Use BIGINT columns, run `rsx:migrate:document_models` after changes
|
||||
|
||||
```php
|
||||
class Project_Model extends Rsx_Model_Abstract {
|
||||
public static $enums = [
|
||||
'status_id' => [
|
||||
1 => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active', 'badge' => 'bg-success', 'order' => 1],
|
||||
2 => ['constant' => 'STATUS_ARCHIVED', 'label' => 'Archived', 'selectable' => false, 'order' => 99],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Usage - BEM-style: field__property (double underscore)
|
||||
$project->status_id = Project_Model::STATUS_ACTIVE;
|
||||
echo $project->status_id__label; // "Active"
|
||||
echo $project->status_id__badge; // "bg-success" (custom property)
|
||||
```
|
||||
|
||||
**Special properties**:
|
||||
- `order` - Sort position in dropdowns (default: 0, lower first)
|
||||
- `selectable` - Include in dropdown options (default: true). Non-selectable items excluded from `field__enum_select()` but still shown if current value.
|
||||
|
||||
**JavaScript static methods** (BEM-style double underscore):
|
||||
```js
|
||||
// Get all enum data
|
||||
Project_Model.status_id__enum()
|
||||
|
||||
// Get specific enum's metadata by ID
|
||||
Project_Model.status_id__enum(Project_Model.STATUS_ACTIVE).badge // "bg-success"
|
||||
Project_Model.status_id__enum(2).selectable // false
|
||||
|
||||
// Other methods
|
||||
Project_Model.status_id__enum_select() // For dropdowns
|
||||
Project_Model.status_id__enum_labels() // id => label map
|
||||
Project_Model.status_id__enum_ids() // Array of valid IDs
|
||||
```
|
||||
|
||||
**Migration:** Use BIGINT for enum columns, TINYINT(1) for booleans. Run `rsx:migrate:document_models` after adding enums.
|
||||
**Detailed guidance in `model-enums` skill** - auto-activates when implementing enums.
|
||||
|
||||
### Model Fetch
|
||||
|
||||
@@ -1033,27 +814,13 @@ No `IF EXISTS`, no `information_schema` queries, no fallbacks. Know current stat
|
||||
|
||||
Files upload UNATTACHED → validate → assign via API. Session-based validation prevents cross-user file assignment.
|
||||
|
||||
```php
|
||||
// Controller: Assign uploaded file
|
||||
$attachment = File_Attachment_Model::find_by_key($params['photo_key']);
|
||||
if ($attachment && $attachment->can_user_assign_this_file()) {
|
||||
$attachment->attach_to($user, 'profile_photo'); // Single (replaces)
|
||||
$attachment->add_to($project, 'documents'); // Multiple (adds)
|
||||
}
|
||||
Pattern recognition:
|
||||
- `File_Attachment_Model::find_by_key()` + `can_user_assign_this_file()`
|
||||
- `attach_to()` (single/replaces) vs `add_to()` (multiple/adds)
|
||||
- `get_attachment()` / `get_attachments()` for retrieval
|
||||
- `get_url()`, `get_download_url()`, `get_thumbnail_url()`
|
||||
|
||||
// Model: Retrieve attachments
|
||||
$photo = $user->get_attachment('profile_photo');
|
||||
$documents = $project->get_attachments('documents');
|
||||
|
||||
// Display
|
||||
$photo->get_thumbnail_url('cover', 128, 128);
|
||||
$photo->get_url();
|
||||
$photo->get_download_url();
|
||||
```
|
||||
|
||||
**Endpoints:** `POST /_upload`, `GET /_download/:key`, `GET /_thumbnail/:key/:type/:width/:height`
|
||||
|
||||
Details: `php artisan rsx:man file_upload`
|
||||
**Detailed guidance in `file-attachments` skill** - auto-activates when handling uploads.
|
||||
|
||||
---
|
||||
|
||||
@@ -1180,48 +947,15 @@ Sessions persist 365 days. Never implement "Remember Me".
|
||||
|
||||
**Two Classes - Strict Separation**: `Rsx_Time` (datetimes with timezone) | `Rsx_Date` (calendar dates, no timezone)
|
||||
|
||||
**String-Based Philosophy**: RSpade uses ISO strings, not Carbon objects. Dates are `"2025-12-24"`, datetimes are `"2025-12-24T15:30:00-06:00"`. Same format in PHP, JavaScript, JSON, database queries. No object serialization issues.
|
||||
**String-Based**: ISO strings, not Carbon. Never use `$casts` with `'date'`/`'datetime'` - blocked by `rsx:check`.
|
||||
|
||||
**Model Casts**: `Rsx_Model_Abstract` auto-applies `Rsx_Date_Cast` (DATE columns) and `Rsx_DateTime_Cast` (DATETIME columns). Never define `$casts` with `'date'`, `'datetime'`, or `'timestamp'` - these use Carbon and are blocked by `rsx:check`.
|
||||
Pattern recognition:
|
||||
- `Rsx_Time::now()`, `Rsx_Time::format()`, `Rsx_Time::relative()`
|
||||
- `Rsx_Date::today()`, `Rsx_Date::format()`, `Rsx_Date::is_past()`
|
||||
- `Rsx_Time::to_database()` for UTC storage
|
||||
- Functions throw if wrong type passed
|
||||
|
||||
### Rsx_Time - Moments in Time
|
||||
```php
|
||||
use App\RSpade\Core\Time\Rsx_Time;
|
||||
|
||||
Rsx_Time::now(); // Current time in user's timezone
|
||||
Rsx_Time::now_iso(); // ISO 8601 format: 2025-12-24T15:30:00-06:00
|
||||
Rsx_Time::format($datetime); // "Dec 24, 2025 3:30 PM"
|
||||
Rsx_Time::format_short($datetime); // "Dec 24, 3:30 PM"
|
||||
Rsx_Time::to_database($datetime); // UTC for storage
|
||||
|
||||
Rsx_Time::get_user_timezone(); // User's timezone or default
|
||||
```
|
||||
|
||||
```javascript
|
||||
Rsx_Time.now(); // Current moment (timezone-aware)
|
||||
Rsx_Time.format(datetime); // Formatted for display
|
||||
Rsx_Time.relative(datetime); // "2 hours ago", "in 3 days"
|
||||
```
|
||||
|
||||
### Rsx_Date - Calendar Dates
|
||||
```php
|
||||
use App\RSpade\Core\Time\Rsx_Date;
|
||||
|
||||
Rsx_Date::today(); // "2025-12-24" (user's timezone)
|
||||
Rsx_Date::format($date); // "Dec 24, 2025"
|
||||
Rsx_Date::is_today($date); // Boolean
|
||||
Rsx_Date::is_past($date); // Boolean
|
||||
```
|
||||
|
||||
**Key Principle**: Functions throw if wrong type passed (datetime to date function or vice versa).
|
||||
|
||||
### Server Time Sync
|
||||
Client time syncs automatically via rsxapp data on page load and AJAX responses. No manual sync required.
|
||||
|
||||
### User Timezone
|
||||
Stored in `login_users.timezone` (IANA format). Falls back to `config('rsx.datetime.default_timezone')`.
|
||||
|
||||
Details: `php artisan rsx:man time`
|
||||
**Detailed guidance in `date-time` skill** - auto-activates when working with dates/times.
|
||||
|
||||
---
|
||||
|
||||
@@ -1321,6 +1055,33 @@ Run `rsx:check` before commits. Enforces naming, prohibits animations on non-act
|
||||
|
||||
---
|
||||
|
||||
## SKILLS (Auto-Activated)
|
||||
|
||||
Detailed guidance for specific tasks is available via Claude Code skills. These activate automatically when relevant - no action needed.
|
||||
|
||||
| Skill | Activates When |
|
||||
|-------|---------------|
|
||||
| `forms` | Building forms, validation, vals() pattern |
|
||||
| `modals` | Creating dialogs, Modal.form(), Modal_Abstract |
|
||||
| `model-enums` | Implementing status_id, type_id, enum properties |
|
||||
| `file-attachments` | Upload flow, attach_to(), thumbnails |
|
||||
| `date-time` | Rsx_Time, Rsx_Date, timezone handling |
|
||||
| `blade-views` | Server-rendered pages, @rsx_id, @rsx_page_data |
|
||||
| `scss` | Component styling, BEM, responsive breakpoints |
|
||||
| `ajax-error-handling` | response_error(), Form_Utils, error codes |
|
||||
| `model-fetch` | Model.fetch(), lazy relationships, #[Ajax_Endpoint_Model_Fetch] |
|
||||
| `jquery-extensions` | .click() override, .exists(), .shallowFind() |
|
||||
| `background-tasks` | #[Task], #[Schedule], Task::dispatch() |
|
||||
| `crud-patterns` | List/view/edit structure, DataGrid, dual-route actions |
|
||||
| `js-decorators` | @route, @spa, @layout, @mutex, custom decorators |
|
||||
| `event-hooks` | #[OnEvent], filters, gates, actions |
|
||||
| `migrations` | Raw SQL, make:migration:safe, forward-only |
|
||||
| `polymorphic` | Type refs, morphTo, Polymorphic_Field_Helper |
|
||||
|
||||
Skills location: `~/.claude/skills/` (symlinked from `system/docs/skills/`)
|
||||
|
||||
---
|
||||
|
||||
## GETTING HELP
|
||||
|
||||
```bash
|
||||
|
||||
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