Add form conventions man page and CLAUDE.md documentation
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
358
app/RSpade/man/form_conventions.txt
Executable file
358
app/RSpade/man/form_conventions.txt
Executable file
@@ -0,0 +1,358 @@
|
||||
FORM_CONVENTIONS(3) RSX Framework Manual FORM_CONVENTIONS(3)
|
||||
|
||||
NAME
|
||||
Form Conventions - Architectural patterns for SPA form loading and saving
|
||||
|
||||
SYNOPSIS
|
||||
// Action: Load form data
|
||||
async on_load() {
|
||||
const record = await My_Model.fetch(this.args.id);
|
||||
this.data.form_data = {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
status_id: record.status_id,
|
||||
};
|
||||
}
|
||||
|
||||
// Template: Bind form data
|
||||
<Rsx_Form $data=this.data.form_data $controller="My_Controller" $method="save">
|
||||
|
||||
// Controller: Single save endpoint
|
||||
#[Ajax_Endpoint]
|
||||
public static function save(Request $request, array $params = []) {
|
||||
// Validate, save, return redirect
|
||||
}
|
||||
|
||||
DESCRIPTION
|
||||
The RSX form system follows a traditional server-side form pattern adapted
|
||||
for SPA applications. The key principle is: one endpoint for loading form
|
||||
state, one endpoint for validating and saving. All data flows through a
|
||||
single serializable form_data object.
|
||||
|
||||
This approach mirrors how forms work in traditional Laravel Blade
|
||||
applications:
|
||||
- Controller::edit() loads data and passes to view
|
||||
- Controller::update() validates and saves
|
||||
- Both methods live in the same controller, making it easy to verify
|
||||
field alignment between load and save
|
||||
|
||||
In RSX SPA, this becomes:
|
||||
- Action::on_load() fetches data into this.data.form_data
|
||||
- Controller::save() receives form values and persists them
|
||||
- Both reference the same field names, ensuring alignment
|
||||
|
||||
DATA FLOW
|
||||
|
||||
LOADING:
|
||||
|
||||
1. Action.on_load() fetches data from Model or Controller
|
||||
2. Assigns structured object to this.data.form_data
|
||||
3. Template passes form_data to <Rsx_Form $data=...>
|
||||
4. Rsx_Form.on_ready() calls vals() to distribute values
|
||||
5. Each widget receives its value via val(value) setter
|
||||
|
||||
SUBMITTING:
|
||||
|
||||
1. User clicks submit button
|
||||
2. Rsx_Form.submit() calls vals() to gather all widget values
|
||||
3. Ajax POST sends {field: value, ...} to Controller::method
|
||||
4. Controller validates, saves, returns response
|
||||
5. On success with redirect, Rsx_Form navigates to result.redirect
|
||||
6. On validation error, Rsx_Form displays errors on fields
|
||||
|
||||
ACTION RESPONSIBILITIES
|
||||
|
||||
The SPA Action is responsible for loading all data needed by the form.
|
||||
|
||||
on_create() - Set Defaults:
|
||||
|
||||
on_create() {
|
||||
// 1. Default form_data for "add" mode
|
||||
this.data.form_data = {
|
||||
title: '',
|
||||
status_id: Project_Model.STATUS_ACTIVE,
|
||||
team_members: [], // Repeater fields start as empty arrays
|
||||
};
|
||||
|
||||
// 2. Dropdown options (from model enums or static lists)
|
||||
this.data.status_options = Project_Model.status_id_enum_select();
|
||||
|
||||
// 3. Mode detection
|
||||
this.data.is_edit = !!this.args.id;
|
||||
}
|
||||
|
||||
on_load() - Fetch Data:
|
||||
|
||||
async on_load() {
|
||||
if (!this.data.is_edit) return; // Add mode uses defaults
|
||||
|
||||
// Option A: Simple forms - use Model.fetch()
|
||||
const record = await Project_Model.fetch(this.args.id);
|
||||
this.data.form_data = {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
status_id: record.status_id,
|
||||
};
|
||||
|
||||
// Option B: Complex forms - use controller endpoint
|
||||
// this.data.form_data = await Project_Controller.get_form_data({
|
||||
// id: this.args.id
|
||||
// });
|
||||
}
|
||||
|
||||
IMPORTANT: on_ready() is for UI interactions only (show/hide, focus).
|
||||
Never load data in on_ready() - it runs after vals() has already
|
||||
distributed values to widgets.
|
||||
|
||||
TEMPLATE PATTERN
|
||||
|
||||
Standard form template structure:
|
||||
|
||||
<Rsx_Form $data=this.data.form_data
|
||||
$controller="Project_Controller"
|
||||
$method="save">
|
||||
|
||||
<% if (this.data.is_edit) { %>
|
||||
<Form_Hidden_Field $name="id" />
|
||||
<% } %>
|
||||
|
||||
<Form_Field $name="title" $label="Title" $required=true>
|
||||
<Text_Input />
|
||||
</Form_Field>
|
||||
|
||||
<Form_Field $name="status_id" $label="Status">
|
||||
<Select_Input $options=this.data.status_options />
|
||||
</Form_Field>
|
||||
|
||||
<Form_Field $name="team_members" $label="Team">
|
||||
<Form_Repeater
|
||||
$edit_input="Team_Member_Edit"
|
||||
$display_input="Team_Member_Display"
|
||||
/>
|
||||
</Form_Field>
|
||||
|
||||
<button type="button" class="btn btn-primary"
|
||||
@click=this.sid('form').submit()>
|
||||
Save
|
||||
</button>
|
||||
</Rsx_Form>
|
||||
|
||||
Key points:
|
||||
- $data receives the form_data object from Action
|
||||
- Field $name attributes match keys in form_data
|
||||
- Hidden field for ID only shown in edit mode
|
||||
- Repeater field names match array keys in form_data
|
||||
|
||||
CONTROLLER RESPONSIBILITIES
|
||||
|
||||
Load Endpoint (Optional):
|
||||
|
||||
For complex forms with relationships, provide a dedicated load endpoint:
|
||||
|
||||
#[Ajax_Endpoint]
|
||||
public static function get_form_data(Request $request, array $params = [])
|
||||
{
|
||||
$project = Project_Model::find($params['id']);
|
||||
|
||||
return [
|
||||
'id' => $project->id,
|
||||
'title' => $project->title,
|
||||
'status_id' => $project->status_id,
|
||||
// Repeater data as arrays
|
||||
'team_members' => $project->team->map(fn($t) => [
|
||||
'user_id' => $t->user_id,
|
||||
'role_id' => $t->role_id,
|
||||
])->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
This keeps the Action simple and ensures server controls the data shape.
|
||||
|
||||
Save Endpoint:
|
||||
|
||||
#[Ajax_Endpoint]
|
||||
public static function save(Request $request, array $params = [])
|
||||
{
|
||||
// 1. VALIDATE
|
||||
$errors = [];
|
||||
|
||||
if (empty($params['title'])) {
|
||||
$errors['title'] = 'Title is required';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return response_form_error('Please correct errors', $errors);
|
||||
}
|
||||
|
||||
// 2. SAVE MAIN RECORD
|
||||
$project = !empty($params['id'])
|
||||
? Project_Model::find($params['id'])
|
||||
: new Project_Model();
|
||||
|
||||
$project->title = $params['title'];
|
||||
$project->status_id = $params['status_id'];
|
||||
$project->save();
|
||||
|
||||
// 3. SYNC RELATIONSHIPS
|
||||
// Repeater sends array: [{user_id: 1, role_id: 2}, ...]
|
||||
$project->team()->detach();
|
||||
foreach ($params['team_members'] ?? [] as $member) {
|
||||
$project->team()->attach($member['user_id'], [
|
||||
'role_id' => $member['role_id'],
|
||||
]);
|
||||
}
|
||||
|
||||
// 4. RETURN REDIRECT
|
||||
Flash_Alert::success('Saved successfully');
|
||||
return ['redirect' => Rsx::Route('Project_View_Action', $project->id)];
|
||||
}
|
||||
|
||||
FIELD ALIGNMENT
|
||||
|
||||
The critical benefit of this pattern is ensuring field alignment between
|
||||
load and save. Keep both endpoints in the same controller file:
|
||||
|
||||
// Controller file shows the complete data contract
|
||||
class Project_Controller extends Rsx_Controller_Abstract
|
||||
{
|
||||
// LOAD: These fields go INTO the form
|
||||
public static function get_form_data(...) {
|
||||
return [
|
||||
'id' => ...,
|
||||
'title' => ...,
|
||||
'status_id' => ...,
|
||||
'team_members' => [...],
|
||||
];
|
||||
}
|
||||
|
||||
// SAVE: These same fields come BACK from the form
|
||||
public static function save(...) {
|
||||
$params['id'];
|
||||
$params['title'];
|
||||
$params['status_id'];
|
||||
$params['team_members'];
|
||||
}
|
||||
}
|
||||
|
||||
When adding a new field:
|
||||
1. Add to get_form_data() return array
|
||||
2. Add to save() parameter handling
|
||||
3. Add to template Form_Field
|
||||
4. Add to on_create() defaults (for add mode)
|
||||
|
||||
All four locations are easily verified by searching the field name.
|
||||
|
||||
REPEATER FIELDS
|
||||
|
||||
Repeater fields collect multiple values into an array.
|
||||
|
||||
Simple Repeaters (array of IDs):
|
||||
|
||||
// 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):
|
||||
|
||||
// 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'],
|
||||
]);
|
||||
}
|
||||
|
||||
KEY PRINCIPLES
|
||||
|
||||
1. SERIALIZABLE STATE
|
||||
form_data must be JSON-serializable. No model instances, no
|
||||
functions, no circular references. Plain objects and arrays only.
|
||||
|
||||
2. SINGLE SUBMIT
|
||||
One Ajax call sends all form data. No per-field saves, no partial
|
||||
updates. The form is atomic - it succeeds or fails as a unit.
|
||||
|
||||
3. SERVER SYNCS RELATIONSHIPS
|
||||
Controller receives arrays from repeaters and syncs them server-side.
|
||||
Delete removed items, add new items, update changed items.
|
||||
|
||||
4. ACTION LOADS, FORM DISTRIBUTES
|
||||
Action's on_load() prepares form_data as a plain object.
|
||||
Rsx_Form's on_ready() distributes values to widgets via vals().
|
||||
Widgets never know where data came from.
|
||||
|
||||
5. CONTROLLER ALIGNMENT
|
||||
Keep get_form_data() and save() in the same file. This makes it
|
||||
trivial to verify field names match between load and save.
|
||||
|
||||
VALIDATION RESPONSES
|
||||
|
||||
Success with redirect:
|
||||
|
||||
return ['redirect' => Rsx::Route('View_Action', $id)];
|
||||
|
||||
Validation errors:
|
||||
|
||||
return response_form_error('Please correct errors', [
|
||||
'title' => 'Title is required',
|
||||
'email' => 'Invalid email format',
|
||||
]);
|
||||
|
||||
Rsx_Form automatically displays field errors below each Form_Field.
|
||||
|
||||
COMMON MISTAKES
|
||||
|
||||
Loading data in on_ready():
|
||||
|
||||
// WRONG - on_ready() runs AFTER form already populated
|
||||
on_ready() {
|
||||
this.data.form_data = await Controller.get_data();
|
||||
}
|
||||
|
||||
// CORRECT - on_load() runs BEFORE render
|
||||
async on_load() {
|
||||
this.data.form_data = await Controller.get_data();
|
||||
}
|
||||
|
||||
Non-serializable form_data:
|
||||
|
||||
// WRONG - Model instance is not serializable
|
||||
this.data.form_data = await Project_Model.fetch(id);
|
||||
|
||||
// CORRECT - Extract plain object
|
||||
const record = await Project_Model.fetch(id);
|
||||
this.data.form_data = {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
};
|
||||
|
||||
Field name mismatch:
|
||||
|
||||
// form_data uses 'name'
|
||||
this.data.form_data = { name: 'Test' };
|
||||
|
||||
// Template uses 'title' - VALUE WILL BE EMPTY
|
||||
<Form_Field $name="title">
|
||||
|
||||
SEE ALSO
|
||||
forms_and_widgets(3), jqhtml(3), ajax(3)
|
||||
|
||||
RSX Framework December 2024 FORM_CONVENTIONS(3)
|
||||
@@ -891,6 +891,39 @@ class My_Form extends Component {
|
||||
|
||||
**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('Error', ['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`
|
||||
|
||||
---
|
||||
|
||||
## MODALS
|
||||
|
||||
Reference in New Issue
Block a user