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) Clients New 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: Client List ID Name Created Actions <%= row.id %> <%= row.name %> <%= new Date(row.created_at).toLocaleDateString() %> 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: <% if (this.data.loading) { %> <% } else if (this.data.error_data) { %> <% } else { %> <%= this.data.client.name %> Edit
<%= this.data.client.name %>
<% } %>
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) <% if (this.data.loading) { %> <% } else if (this.data.error_data) { %> <% } else { %> <%= this.data.is_edit ? 'Edit Client' : 'Add Client' %> <% if (this.data.is_edit) { %> <% } %> <% } %> 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 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) { %> <% } %> 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)