Migrate $name from Form_Field to input components Refactor form inputs: $name moves from Form_Field to input components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
571 lines
21 KiB
Plaintext
Executable File
571 lines
21 KiB
Plaintext
Executable File
CRUD(3) RSX Framework Manual CRUD(3)
|
|
|
|
NAME
|
|
crud - Standard CRUD implementation pattern for RSpade applications
|
|
|
|
SYNOPSIS
|
|
A complete CRUD (Create, Read, Update, Delete) implementation consists of:
|
|
|
|
Directory Structure:
|
|
rsx/app/frontend/{feature}/
|
|
{feature}_controller.php # Ajax endpoints
|
|
list/
|
|
{Feature}_Index_Action.js # List page action
|
|
{Feature}_Index_Action.jqhtml # List page template
|
|
{feature}_datagrid.php # DataGrid backend
|
|
{feature}_datagrid.jqhtml # DataGrid template
|
|
view/
|
|
{Feature}_View_Action.js # Detail page action
|
|
{Feature}_View_Action.jqhtml # Detail page template
|
|
edit/
|
|
{Feature}_Edit_Action.js # Add/Edit page action
|
|
{Feature}_Edit_Action.jqhtml # Add/Edit page template
|
|
|
|
Model:
|
|
rsx/models/{feature}_model.php # With fetch() method
|
|
|
|
DESCRIPTION
|
|
This document describes the standard pattern for implementing CRUD
|
|
functionality in RSpade SPA applications. The pattern provides:
|
|
|
|
- Consistent file organization across all features
|
|
- DataGrid for listing with sorting, filtering, pagination
|
|
- Model.fetch() for loading single records
|
|
- Rsx_Form for add/edit with automatic Ajax submission
|
|
- Server-side validation with field-level errors
|
|
- Three-state loading pattern (loading/error/content)
|
|
- Single action class handling both add and edit modes
|
|
|
|
DIRECTORY STRUCTURE
|
|
Each CRUD feature uses three subdirectories:
|
|
|
|
list/ - Index page with DataGrid listing all records
|
|
view/ - Detail page showing single record
|
|
edit/ - Combined add/edit form (dual-route action)
|
|
|
|
The controller sits at the feature root and provides Ajax endpoints
|
|
for all operations (datagrid_fetch, save, delete, restore).
|
|
|
|
MODEL SETUP
|
|
|
|
Fetchable Model
|
|
To load records from JavaScript, the model needs a fetch() method
|
|
with the #[Ajax_Endpoint_Model_Fetch] attribute:
|
|
|
|
#[Ajax_Endpoint_Model_Fetch]
|
|
public static function fetch($id)
|
|
{
|
|
$record = static::withTrashed()->find($id);
|
|
if (!$record) {
|
|
return false;
|
|
}
|
|
|
|
return [
|
|
'id' => $record->id,
|
|
'name' => $record->name,
|
|
// Include all fields needed by view/edit pages
|
|
// Add computed fields for display
|
|
'status_label' => ucfirst($record->status),
|
|
'created_at_formatted' => $record->created_at->format('M d, Y'),
|
|
'created_at_human' => $record->created_at->diffForHumans(),
|
|
];
|
|
}
|
|
|
|
Key points:
|
|
- Use withTrashed() if soft deletes should be viewable
|
|
- Return false (not null) when record not found
|
|
- Include computed fields needed for display (labels, badges, formatted dates)
|
|
- The attribute enables Model.fetch(id) in JavaScript
|
|
|
|
See model_fetch(3) for complete documentation.
|
|
|
|
FEATURE CONTROLLER
|
|
The controller provides Ajax endpoints for all CRUD operations:
|
|
|
|
class Frontend_Clients_Controller extends Rsx_Controller_Abstract
|
|
{
|
|
#[Auth('Permission::anybody()')]
|
|
public static function pre_dispatch(Request $request, array $params = [])
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// DataGrid data endpoint
|
|
#[Auth('Permission::anybody()')]
|
|
#[Ajax_Endpoint]
|
|
public static function datagrid_fetch(Request $request, array $params = [])
|
|
{
|
|
return Clients_DataGrid::fetch($params);
|
|
}
|
|
|
|
// Save (create or update)
|
|
#[Auth('Permission::anybody()')]
|
|
#[Ajax_Endpoint]
|
|
public static function save(Request $request, array $params = [])
|
|
{
|
|
// Validation
|
|
$errors = [];
|
|
if (empty($params['name'])) {
|
|
$errors['name'] = 'Name is required';
|
|
}
|
|
if (!empty($errors)) {
|
|
return response_error(Ajax::ERROR_VALIDATION, $errors);
|
|
}
|
|
|
|
// Create or update
|
|
$id = $params['id'] ?? null;
|
|
if ($id) {
|
|
$record = Client_Model::find($id);
|
|
if (!$record) {
|
|
return response_error(Ajax::ERROR_NOT_FOUND, 'Client not found');
|
|
}
|
|
} else {
|
|
$record = new Client_Model();
|
|
}
|
|
|
|
// Set fields explicitly (no mass assignment)
|
|
$record->name = $params['name'];
|
|
$record->email = $params['email'] ?? null;
|
|
// ... all fields ...
|
|
$record->save();
|
|
|
|
Flash_Alert::success($id ? 'Updated successfully' : 'Created successfully');
|
|
|
|
return [
|
|
'id' => $record->id,
|
|
'redirect' => Rsx::Route('Clients_View_Action', $record->id),
|
|
];
|
|
}
|
|
|
|
// Soft delete
|
|
#[Auth('Permission::anybody()')]
|
|
#[Ajax_Endpoint]
|
|
public static function delete(Request $request, array $params = [])
|
|
{
|
|
$record = Client_Model::find($params['id']);
|
|
if (!$record) {
|
|
return response_error(Ajax::ERROR_NOT_FOUND, 'Client not found');
|
|
}
|
|
$record->delete();
|
|
return ['message' => 'Deleted successfully'];
|
|
}
|
|
|
|
// Restore soft-deleted record
|
|
#[Auth('Permission::anybody()')]
|
|
#[Ajax_Endpoint]
|
|
public static function restore(Request $request, array $params = [])
|
|
{
|
|
$record = Client_Model::withTrashed()->find($params['id']);
|
|
if (!$record) {
|
|
return response_error(Ajax::ERROR_NOT_FOUND, 'Client not found');
|
|
}
|
|
if (!$record->trashed()) {
|
|
return response_error(Ajax::ERROR_VALIDATION, ['message' => 'Not deleted']);
|
|
}
|
|
$record->restore();
|
|
return ['message' => 'Restored successfully'];
|
|
}
|
|
}
|
|
|
|
Validation Pattern
|
|
Return field-level errors that Rsx_Form can display:
|
|
|
|
$errors = [];
|
|
if (empty($params['name'])) {
|
|
$errors['name'] = 'Name is required';
|
|
}
|
|
if (!empty($params['email']) && !filter_var($params['email'], FILTER_VALIDATE_EMAIL)) {
|
|
$errors['email'] = 'Invalid email address';
|
|
}
|
|
if (!empty($errors)) {
|
|
return response_error(Ajax::ERROR_VALIDATION, $errors);
|
|
}
|
|
|
|
The errors array keys must match form field names. Rsx_Form
|
|
automatically displays these errors next to the corresponding fields.
|
|
|
|
LIST PAGE (INDEX)
|
|
|
|
Action Class (list/{Feature}_Index_Action.js)
|
|
@route('/clients')
|
|
@layout('Frontend_Spa_Layout')
|
|
@spa('Frontend_Spa_Controller::index')
|
|
@title('Clients - RSX')
|
|
class Clients_Index_Action extends Spa_Action {
|
|
full_width = true; // DataGrid pages typically use full width
|
|
|
|
async on_load() {
|
|
// DataGrid loads its own data - nothing to do here
|
|
}
|
|
}
|
|
|
|
Template (list/{Feature}_Index_Action.jqhtml)
|
|
<Define:Clients_Index_Action>
|
|
<Page>
|
|
<Page_Header>
|
|
<Page_Header_Left>
|
|
<Page_Title>Clients</Page_Title>
|
|
</Page_Header_Left>
|
|
<Page_Header_Right>
|
|
<a href="<%= Rsx.Route('Clients_Edit_Action') %>" class="btn btn-primary btn-sm">
|
|
<i class="bi bi-plus-circle"></i> New
|
|
</a>
|
|
</Page_Header_Right>
|
|
</Page_Header>
|
|
|
|
<Clients_DataGrid />
|
|
</Page>
|
|
</Define:Clients_Index_Action>
|
|
|
|
DATAGRID
|
|
|
|
Backend Class (list/{feature}_datagrid.php)
|
|
Extend DataGrid_Abstract and implement build_query():
|
|
|
|
class Clients_DataGrid extends DataGrid_Abstract
|
|
{
|
|
protected static array $sortable_columns = [
|
|
'id', 'name', 'city', 'created_at',
|
|
];
|
|
|
|
protected static function build_query(array $params): Builder
|
|
{
|
|
$query = Client_Model::query();
|
|
|
|
// Apply search filter
|
|
if (!empty($params['filter'])) {
|
|
$filter = $params['filter'];
|
|
$query->where(function ($q) use ($filter) {
|
|
$q->where('name', 'LIKE', "%{$filter}%")
|
|
->orWhere('city', 'LIKE', "%{$filter}%");
|
|
});
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
// Optional: transform records after fetch
|
|
protected static function transform_records(array $records, array $params): array
|
|
{
|
|
foreach ($records as &$record) {
|
|
$record['full_address'] = $record['city'] . ', ' . $record['state'];
|
|
}
|
|
return $records;
|
|
}
|
|
}
|
|
|
|
Template (list/{feature}_datagrid.jqhtml)
|
|
Extend DataGrid_Abstract and define columns and row template:
|
|
|
|
<Define:Clients_DataGrid
|
|
extends="DataGrid_Abstract"
|
|
$data_source=Frontend_Clients_Controller.datagrid_fetch
|
|
$sort="id"
|
|
$order="desc"
|
|
$per_page=15
|
|
class="card DataGrid">
|
|
|
|
<Slot:DG_Card_Header>
|
|
<Card_Title>Client List</Card_Title>
|
|
<Card_Header_Right>
|
|
<Search_Input $sid="filter_input" $placeholder="Search..." />
|
|
</Card_Header_Right>
|
|
</Slot:DG_Card_Header>
|
|
|
|
<Slot:DG_Table_Header>
|
|
<tr>
|
|
<th data-sortby="id">ID</th>
|
|
<th data-sortby="name">Name</th>
|
|
<th data-sortby="created_at">Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</Slot:DG_Table_Header>
|
|
|
|
<Slot:row>
|
|
<tr data-href="<%= Rsx.Route('Clients_View_Action', row.id) %>">
|
|
<td><%= row.id %></td>
|
|
<td><%= row.name %></td>
|
|
<td><%= new Date(row.created_at).toLocaleDateString() %></td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm">
|
|
<a class="btn btn-outline-primary" href="<%= Rsx.Route('Clients_View_Action', row.id) %>">
|
|
<i class="bi bi-eye"></i>
|
|
</a>
|
|
<a class="btn btn-outline-secondary" href="<%= Rsx.Route('Clients_Edit_Action', row.id) %>">
|
|
<i class="bi bi-pencil"></i>
|
|
</a>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</Slot:row>
|
|
|
|
</Define:Clients_DataGrid>
|
|
|
|
Key attributes:
|
|
- $data_source: Controller method that returns data
|
|
- $sort/$order: Default sort column and direction
|
|
- $per_page: Records per page
|
|
- data-sortby: Makes column header clickable for sorting
|
|
- data-href: Makes entire row clickable
|
|
|
|
VIEW PAGE (DETAIL)
|
|
|
|
Action Class (view/{Feature}_View_Action.js)
|
|
Uses Model.fetch() and three-state pattern:
|
|
|
|
@route('/clients/view/:id')
|
|
@layout('Frontend_Spa_Layout')
|
|
@spa('Frontend_Spa_Controller::index')
|
|
@title('Client Details')
|
|
class Clients_View_Action extends Spa_Action {
|
|
on_create() {
|
|
this.data.client = { name: '', tags: [] }; // Stub
|
|
this.data.error_data = null;
|
|
this.data.loading = true;
|
|
}
|
|
|
|
async on_load() {
|
|
try {
|
|
this.data.client = await Client_Model.fetch(this.args.id);
|
|
} catch (e) {
|
|
this.data.error_data = e;
|
|
}
|
|
this.data.loading = false;
|
|
}
|
|
}
|
|
|
|
Template (view/{Feature}_View_Action.jqhtml)
|
|
Three-state template pattern:
|
|
|
|
<Define:Clients_View_Action>
|
|
<Page>
|
|
<% if (this.data.loading) { %>
|
|
<Loading_Spinner $message="Loading..." />
|
|
<% } else if (this.data.error_data) { %>
|
|
<Universal_Error_Page_Component
|
|
$error_data="<%= this.data.error_data %>"
|
|
$record_type="Client"
|
|
$back_label="Go back to Clients"
|
|
$back_url="<%= Rsx.Route('Clients_Index_Action') %>"
|
|
/>
|
|
<% } else { %>
|
|
<Page_Header>
|
|
<Page_Header_Left>
|
|
<Page_Title><%= this.data.client.name %></Page_Title>
|
|
</Page_Header_Left>
|
|
<Page_Header_Right>
|
|
<a href="<%= Rsx.Route('Clients_Edit_Action', this.data.client.id) %>" class="btn btn-primary btn-sm">
|
|
<i class="bi bi-pencil"></i> Edit
|
|
</a>
|
|
</Page_Header_Right>
|
|
</Page_Header>
|
|
|
|
<!-- Display fields using this.data.client.* -->
|
|
<div class="card card-body">
|
|
<label class="text-muted small">Name</label>
|
|
<div class="fw-bold"><%= this.data.client.name %></div>
|
|
</div>
|
|
<% } %>
|
|
</Page>
|
|
</Define:Clients_View_Action>
|
|
|
|
See view_action_patterns(3) for detailed documentation.
|
|
|
|
EDIT PAGE (ADD/EDIT COMBINED)
|
|
|
|
Dual Route Pattern
|
|
A single action handles both add and edit via two @route decorators:
|
|
|
|
@route('/clients/add')
|
|
@route('/clients/edit/:id')
|
|
|
|
The action detects mode by checking for this.args.id:
|
|
- Add mode: this.args.id is undefined
|
|
- Edit mode: this.args.id contains the record ID
|
|
|
|
Action Class (edit/{Feature}_Edit_Action.js)
|
|
@route('/clients/add')
|
|
@route('/clients/edit/:id')
|
|
@layout('Frontend_Spa_Layout')
|
|
@spa('Frontend_Spa_Controller::index')
|
|
@title('Client')
|
|
class Clients_Edit_Action extends Spa_Action {
|
|
on_create() {
|
|
this.data.is_edit = !!this.args.id;
|
|
|
|
// Form data stub with defaults
|
|
this.data.form_data = {
|
|
name: '',
|
|
email: '',
|
|
status: 'active',
|
|
// ... all fields with defaults
|
|
};
|
|
|
|
// Dropdown options
|
|
this.data.status_options = {
|
|
active: 'Active',
|
|
inactive: 'Inactive',
|
|
};
|
|
|
|
this.data.error_data = null;
|
|
this.data.loading = this.data.is_edit; // Only load in edit mode
|
|
}
|
|
|
|
async on_load() {
|
|
if (!this.data.is_edit) {
|
|
return; // Add mode - nothing to load
|
|
}
|
|
|
|
try {
|
|
const record = await Client_Model.fetch(this.args.id);
|
|
|
|
// Populate form_data from record
|
|
this.data.form_data = {
|
|
id: record.id,
|
|
name: record.name,
|
|
email: record.email,
|
|
status: record.status || 'active',
|
|
// ... map all fields
|
|
};
|
|
} catch (e) {
|
|
this.data.error_data = e;
|
|
}
|
|
this.data.loading = false;
|
|
}
|
|
}
|
|
|
|
Template (edit/{Feature}_Edit_Action.jqhtml)
|
|
<Define:Clients_Edit_Action>
|
|
<Page>
|
|
<% if (this.data.loading) { %>
|
|
<Loading_Spinner $message="Loading..." />
|
|
<% } else if (this.data.error_data) { %>
|
|
<Universal_Error_Page_Component ... />
|
|
<% } else { %>
|
|
<Page_Header>
|
|
<Page_Title><%= this.data.is_edit ? 'Edit Client' : 'Add Client' %></Page_Title>
|
|
</Page_Header>
|
|
|
|
<Rsx_Form
|
|
$data="<%= JSON.stringify(this.data.form_data) %>"
|
|
$controller="Frontend_Clients_Controller"
|
|
$method="save">
|
|
|
|
<% if (this.data.is_edit) { %>
|
|
<Form_Hidden_Field $name="id" />
|
|
<% } %>
|
|
|
|
<Form_Field $label="Name" $required=true>
|
|
<Text_Input $name="name" />
|
|
</Form_Field>
|
|
|
|
<Form_Field $label="Status">
|
|
<Select_Input $name="status" $options="<%= JSON.stringify(this.data.status_options) %>" />
|
|
</Form_Field>
|
|
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
</Rsx_Form>
|
|
<% } %>
|
|
</Page>
|
|
</Define:Clients_Edit_Action>
|
|
|
|
RSX_FORM
|
|
|
|
The Rsx_Form component provides Ajax form submission with automatic
|
|
error handling. Required attributes:
|
|
|
|
$data - JSON string of initial form values
|
|
$controller - Controller class name
|
|
$method - Ajax endpoint method name
|
|
|
|
Form Fields
|
|
<Form_Field $label="Label" $required=true>
|
|
<Text_Input $name="fieldname" />
|
|
</Form_Field>
|
|
|
|
The $name on the input component must match:
|
|
- The key in $data JSON
|
|
- The key in server-side $params
|
|
- The key in validation $errors array
|
|
|
|
Hidden Fields
|
|
For edit mode, include the record ID:
|
|
|
|
<% if (this.data.is_edit) { %>
|
|
<Form_Hidden_Field $name="id" />
|
|
<% } %>
|
|
|
|
Available Input Components
|
|
- Text_Input ($type: text, email, url, password, number, textarea)
|
|
- Select_Input ($options: array or object)
|
|
- Checkbox_Input ($label: checkbox text)
|
|
- Phone_Text_Input (formatted phone input)
|
|
- Country_Select_Input, State_Select_Input
|
|
- File_Input (for uploads)
|
|
|
|
Validation Display
|
|
When the server returns response_error(Ajax::ERROR_VALIDATION, $errors),
|
|
Rsx_Form automatically displays errors next to matching fields.
|
|
|
|
Success Handling
|
|
When the server returns a redirect URL, Rsx_Form navigates there:
|
|
|
|
return [
|
|
'redirect' => Rsx::Route('Clients_View_Action', $record->id),
|
|
];
|
|
|
|
See forms_and_widgets(3) for custom form components.
|
|
|
|
TESTING
|
|
Test each page with rsx:debug:
|
|
|
|
php artisan rsx:debug /clients --console
|
|
php artisan rsx:debug /clients/view/1 --console
|
|
php artisan rsx:debug /clients/add --console
|
|
php artisan rsx:debug /clients/edit/1 --console
|
|
|
|
Test Ajax endpoints:
|
|
|
|
php artisan rsx:ajax Frontend_Clients_Controller datagrid_fetch
|
|
php artisan rsx:ajax Frontend_Clients_Controller save --args='{"name":"Test"}'
|
|
|
|
QUICK REFERENCE
|
|
|
|
Files for a "clients" feature:
|
|
rsx/app/frontend/clients/
|
|
frontend_clients_controller.php
|
|
list/
|
|
Clients_Index_Action.js
|
|
Clients_Index_Action.jqhtml
|
|
clients_datagrid.php
|
|
clients_datagrid.jqhtml
|
|
view/
|
|
Clients_View_Action.js
|
|
Clients_View_Action.jqhtml
|
|
edit/
|
|
Clients_Edit_Action.js
|
|
Clients_Edit_Action.jqhtml
|
|
rsx/models/
|
|
client_model.php (with fetch())
|
|
|
|
Routes:
|
|
/clients - List (Clients_Index_Action)
|
|
/clients/view/:id - View (Clients_View_Action)
|
|
/clients/add - Add (Clients_Edit_Action)
|
|
/clients/edit/:id - Edit (Clients_Edit_Action)
|
|
|
|
Ajax Endpoints:
|
|
Frontend_Clients_Controller.datagrid_fetch - DataGrid data
|
|
Frontend_Clients_Controller.save - Create/update
|
|
Frontend_Clients_Controller.delete - Soft delete
|
|
Frontend_Clients_Controller.restore - Restore deleted
|
|
|
|
JavaScript Data Loading:
|
|
const record = await Client_Model.fetch(id);
|
|
|
|
SEE ALSO
|
|
model_fetch(3), view_action_patterns(3), forms_and_widgets(3),
|
|
spa(3), controller(3), module_organization(3)
|
|
|
|
RSX Framework 2025-11-23 CRUD(3)
|