Files
rspade_system/app/RSpade/man/crud.txt
2025-11-24 03:43:45 +00:00

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 $name="name" $label="Name" $required=true>
<Text_Input />
</Form_Field>
<Form_Field $name="status" $label="Status">
<Select_Input $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 $name="fieldname" $label="Label" $required=true>
<Text_Input />
</Form_Field>
The $name 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)