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.
|
**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
|
## MODALS
|
||||||
|
|||||||
Reference in New Issue
Block a user