Refactor date/time classes to reduce code redundancy 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
931 lines
34 KiB
Plaintext
Executable File
931 lines
34 KiB
Plaintext
Executable File
SPA(3) RSX Framework Manual SPA(3)
|
|
|
|
NAME
|
|
spa - Single Page Application routing for authenticated areas
|
|
|
|
SYNOPSIS
|
|
PHP Bootstrap Controller (one per SPA module):
|
|
|
|
class Frontend_Spa_Controller extends Rsx_Controller_Abstract
|
|
{
|
|
#[SPA]
|
|
#[Auth('Permission::authenticated()')]
|
|
public static function index(Request $request, array $params = [])
|
|
{
|
|
return rsx_view(SPA);
|
|
}
|
|
}
|
|
|
|
JavaScript Action (many per module):
|
|
|
|
@route('/contacts')
|
|
@layout('Frontend_Layout')
|
|
@spa('Frontend_Spa_Controller::index')
|
|
class Contacts_Index_Action extends Spa_Action {
|
|
async on_load() {
|
|
this.data.contacts = await Frontend_Contacts_Controller.datagrid_fetch();
|
|
}
|
|
}
|
|
|
|
DESCRIPTION
|
|
The RSX SPA system provides client-side routing for authenticated application
|
|
areas, enabling navigation without page reloads. Unlike traditional Laravel
|
|
views where each navigation triggers a full page load, SPA modules bootstrap
|
|
once and handle all subsequent navigation client-side through JavaScript
|
|
actions.
|
|
|
|
This approach is fundamentally different from traditional server-side routing:
|
|
- Traditional: Every navigation loads new HTML from server
|
|
- SPA: First request bootstraps application, subsequent navigation is client-side
|
|
|
|
Key differences from Laravel:
|
|
- Laravel: Full page reload per navigation, routes in routes/ files
|
|
- RSX: Bootstrap once, routes defined in JavaScript with @route() decorator
|
|
|
|
- Laravel: Separate frontend frameworks (Vue, React) require build tooling
|
|
- RSX: Integrated JQHTML component system, no external build required
|
|
|
|
- Laravel: API routes separate from view routes
|
|
- RSX: Controllers provide Ajax endpoints, actions consume them
|
|
|
|
Benefits:
|
|
- No page reloads after initial bootstrap
|
|
- Persistent layout across navigation
|
|
- Automatic browser history integration
|
|
- Same Rsx::Route() syntax for both traditional and SPA routes
|
|
- No frontend build tooling required
|
|
|
|
When to use:
|
|
- Authenticated areas (dashboards, admin panels)
|
|
- Applications with frequent navigation
|
|
- Features requiring persistent state
|
|
|
|
When to avoid:
|
|
- Public pages requiring SEO
|
|
- Simple static content
|
|
- Forms without complex interactions
|
|
|
|
SPA ARCHITECTURE
|
|
SPA Module Structure:
|
|
An SPA module consists of six component types:
|
|
|
|
1. PHP Bootstrap Controller (ONE per feature/bundle)
|
|
- Single entry point marked with #[SPA]
|
|
- Performs authentication check with failure/redirect
|
|
- Renders SPA bootstrap layout
|
|
- Referenced by all actions via @spa() decorator
|
|
- One #[SPA] per feature/bundle (e.g., /app/frontend, /app/root, /app/login)
|
|
- Bundles segregate code for security and performance:
|
|
* Save bandwidth by loading only needed features
|
|
* Reduce processing time by smaller bundle sizes
|
|
* Protect confidential code (e.g., root admin from unauthorized users)
|
|
- Typically one #[SPA] per feature at rsx/app/(feature)/(feature)_spa_controller::index
|
|
|
|
2. Feature Controllers (Ajax Endpoints Only)
|
|
- Provide data via #[Ajax_Endpoint] methods
|
|
- No #[SPA] or #[Route] methods
|
|
- Called by actions to fetch/save data
|
|
|
|
3. JavaScript Actions (MANY per module)
|
|
- Represent individual pages/routes
|
|
- Define route patterns with @route()
|
|
- Load data in on_load() lifecycle method
|
|
- Access URL parameters via this.args
|
|
|
|
4. Action Templates (.jqhtml)
|
|
- Render action content
|
|
- Standard JQHTML component templates
|
|
- Replace traditional Blade views
|
|
|
|
5. Layout Template (.jqhtml)
|
|
- Persistent wrapper around actions
|
|
- Must have element with $sid="content"
|
|
- Persists across action navigation
|
|
|
|
6. Layout Class (.js)
|
|
- Extends Spa_Layout
|
|
- Optional on_action() hook for navigation tracking
|
|
|
|
Execution Flow:
|
|
First Request:
|
|
1. User navigates to SPA route (e.g., /contacts)
|
|
2. Dispatcher calls bootstrap controller
|
|
3. Bootstrap returns rsx_view(SPA) with window.rsxapp.is_spa = true
|
|
4. Client JavaScript discovers all actions via manifest
|
|
5. Router matches URL to action class
|
|
6. Creates layout on <body>
|
|
7. Creates action inside layout $sid="content" area
|
|
|
|
Subsequent Navigation:
|
|
1. User clicks link or calls Spa.dispatch()
|
|
2. Router matches URL to action class
|
|
3. Destroys old action, creates new action
|
|
4. Layout persists - no re-render
|
|
5. No server request, no page reload
|
|
|
|
BOOTSTRAP CONTROLLER
|
|
Creating SPA Entry Point:
|
|
// /rsx/app/frontend/Frontend_Spa_Controller.php
|
|
class Frontend_Spa_Controller extends Rsx_Controller_Abstract
|
|
{
|
|
#[SPA]
|
|
#[Auth('Permission::authenticated()')]
|
|
public static function index(Request $request, array $params = [])
|
|
{
|
|
return rsx_view(SPA);
|
|
}
|
|
}
|
|
|
|
Key Points:
|
|
- One bootstrap controller per feature/bundle
|
|
- #[SPA] attribute marks as SPA entry point
|
|
- #[Auth] performs server-side authentication with failure/redirect
|
|
- Must return rsx_view(SPA) - special constant
|
|
- All actions in the same bundle reference this via @spa() decorator
|
|
|
|
Bundle Segregation:
|
|
- Separate bundles for different features: /app/frontend, /app/root, /app/login
|
|
- Each bundle has its own #[SPA] bootstrap
|
|
- Benefits:
|
|
* Bandwidth: Users only download code for authorized features
|
|
* Performance: Smaller bundles = faster load times
|
|
* Security: Root admin code never sent to unauthorized users
|
|
- Example: Frontend users never receive root admin JavaScript/CSS
|
|
|
|
Location:
|
|
- Place at feature root: /rsx/app/{feature}/
|
|
- Not in subdirectories
|
|
- One per feature/bundle
|
|
- Naming: {Feature}_Spa_Controller::index
|
|
|
|
Passing Page Data:
|
|
Use PageData::add() to pass server-side data to JavaScript actions:
|
|
|
|
use App\RSpade\Core\View\PageData;
|
|
|
|
#[SPA]
|
|
public static function index(Request $request, array $params = [])
|
|
{
|
|
// Load data needed by SPA actions/components
|
|
$internal_contact = Contact_Model::where('type_id', Contact_Model::TYPE_INTERNAL)
|
|
->first();
|
|
|
|
PageData::add([
|
|
'contact_internal_id' => $internal_contact?->id,
|
|
'feature_flags' => config('rsx.features'),
|
|
]);
|
|
|
|
return rsx_view(SPA, ['bundle' => 'Frontend_Bundle']);
|
|
}
|
|
|
|
Access in JavaScript via window.rsxapp.page_data:
|
|
|
|
const internal_id = window.rsxapp.page_data.contact_internal_id;
|
|
|
|
See pagedata(3) for detailed usage.
|
|
|
|
Multiple SPA Bootstraps:
|
|
Different features can have separate SPA bootstraps:
|
|
- /app/frontend/Frontend_Spa_Controller::index (regular users)
|
|
- /app/root/Root_Spa_Controller::index (root admins)
|
|
- /app/login/Login_Spa_Controller::index (authentication flow)
|
|
|
|
Each bootstrap controls access to its feature's actions via #[Auth].
|
|
|
|
FEATURE CONTROLLERS
|
|
Ajax Endpoint Pattern:
|
|
Feature controllers provide data endpoints only, no routes or SPA entry:
|
|
|
|
// /rsx/app/frontend/contacts/frontend_contacts_controller.php
|
|
class Frontend_Contacts_Controller extends Rsx_Controller_Abstract
|
|
{
|
|
#[Ajax_Endpoint]
|
|
public static function datagrid_fetch(Request $request, array $params = []): array
|
|
{
|
|
return Contacts_DataGrid::fetch($params);
|
|
}
|
|
|
|
#[Ajax_Endpoint]
|
|
public static function save(Request $request, array $params = [])
|
|
{
|
|
// Validation and save logic
|
|
$contact->save();
|
|
|
|
Flash_Alert::success('Contact saved successfully');
|
|
|
|
return [
|
|
'contact_id' => $contact->id,
|
|
'redirect' => Rsx::Route('Contacts_View_Action', $contact->id),
|
|
];
|
|
}
|
|
}
|
|
|
|
Guidelines:
|
|
- All methods are #[Ajax_Endpoint]
|
|
- No #[Route] methods in SPA feature controllers
|
|
- No #[SPA] methods (only in bootstrap controller)
|
|
- Return arrays/data, not views
|
|
- Use Flash_Alert with redirects for success messages
|
|
|
|
See Also:
|
|
ajax_error_handling(3) for error patterns
|
|
controller(3) for #[Ajax_Endpoint] details
|
|
|
|
JAVASCRIPT ACTIONS
|
|
Creating Actions:
|
|
// /rsx/app/frontend/contacts/Contacts_Index_Action.js
|
|
@route('/contacts')
|
|
@layout('Frontend_Layout')
|
|
@spa('Frontend_Spa_Controller::index')
|
|
class Contacts_Index_Action extends Spa_Action {
|
|
async on_load() {
|
|
this.data.contacts = await Frontend_Contacts_Controller.datagrid_fetch();
|
|
}
|
|
|
|
on_create() {
|
|
// Component initialization
|
|
}
|
|
|
|
on_ready() {
|
|
// DOM is ready, setup event handlers
|
|
this.$sid('search').on('input', () => this.reload());
|
|
}
|
|
}
|
|
|
|
Decorator Syntax:
|
|
@route(pattern)
|
|
URL pattern with optional :param segments
|
|
Example: '/contacts/:id', '/users/:id/posts/:post_id'
|
|
|
|
Multiple routes per action:
|
|
@route('/contacts/add')
|
|
@route('/contacts/:id/edit')
|
|
The best matching route is selected based on provided parameters.
|
|
Enables single action to handle add/edit in one class.
|
|
|
|
@layout(class_name)
|
|
Layout class to render within
|
|
Example: 'Frontend_Layout', 'Dashboard_Layout'
|
|
|
|
@spa(controller::method)
|
|
References bootstrap controller
|
|
Example: 'Frontend_Spa_Controller::index'
|
|
|
|
@title(page_title)
|
|
Browser page title for this action (optional, clears if not present)
|
|
Alternatively, set dynamically in on_ready() via document.title = "value"
|
|
Example: @title('Contacts - RSX')
|
|
|
|
URL Parameters:
|
|
Parameters from route pattern and query string available in this.args:
|
|
|
|
// URL: /contacts/123?tab=history
|
|
@route('/contacts/:id')
|
|
class Contacts_View_Action extends Spa_Action {
|
|
on_create() {
|
|
console.log(this.args.id); // "123"
|
|
console.log(this.args.tab); // "history"
|
|
}
|
|
}
|
|
|
|
Lifecycle Methods:
|
|
on_create() Component construction, setup this.state
|
|
on_load() Fetch data, populate this.data (read-only this.args)
|
|
on_render() Template rendering
|
|
on_ready() DOM ready, setup event handlers
|
|
|
|
See Also:
|
|
jqhtml(3) for complete lifecycle documentation
|
|
|
|
ACTION TEMPLATES
|
|
Creating Templates:
|
|
Action templates are standard JQHTML components:
|
|
|
|
<!-- /rsx/app/frontend/contacts/Contacts_Index_Action.jqhtml -->
|
|
<Define:Contacts_Index_Action tag="main">
|
|
<h1>Contacts</h1>
|
|
|
|
<div class="contacts-list">
|
|
<% for (let contact of this.data.contacts) { %>
|
|
<div class="contact-item">
|
|
<a href="<%= Rsx.Route('Contacts_View_Action', {id: contact.id}) %>">
|
|
<%= contact.name %>
|
|
</a>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
</Define:Contacts_Index_Action>
|
|
|
|
Key Points:
|
|
- Use Rsx.Route() for all URLs, never hardcode
|
|
- Access data via this.data (loaded in on_load)
|
|
- Use standard JQHTML template syntax
|
|
- Can reference other components
|
|
|
|
LAYOUTS
|
|
Layout Template:
|
|
Layout provides persistent wrapper around actions:
|
|
|
|
<!-- /rsx/app/frontend/Frontend_Layout.jqhtml -->
|
|
<Define:Frontend_Layout>
|
|
<div class="frontend-layout">
|
|
<header class="mb-3">
|
|
<h1>My App</h1>
|
|
<nav>
|
|
<a href="<%= Rsx.Route('Contacts_Index_Action') %>">Contacts</a>
|
|
<a href="<%= Rsx.Route('Projects_Index_Action') %>">Projects</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<main $sid="content">
|
|
<!-- Actions render here -->
|
|
</main>
|
|
|
|
<footer class="mt-3">
|
|
<p>© 2024 My Company</p>
|
|
</footer>
|
|
</div>
|
|
</Define:Frontend_Layout>
|
|
|
|
Requirements:
|
|
- Must have element with $sid="content"
|
|
- Content area is where actions render
|
|
- Layout persists across navigation
|
|
|
|
Layout Class:
|
|
Optional JavaScript class for navigation tracking:
|
|
|
|
// /rsx/app/frontend/Frontend_Layout.js
|
|
class Frontend_Layout extends Spa_Layout {
|
|
on_action(url, action_name, args) {
|
|
// Called AFTER action component created and booted
|
|
// Called BEFORE action reaches on_ready()
|
|
// Can immediately access this.action properties
|
|
|
|
// Update active nav highlighting
|
|
this.$('.nav-link').removeClass('active');
|
|
this.$(`[data-action="${action_name}"]`).addClass('active');
|
|
|
|
// If you need to wait for action's full lifecycle:
|
|
// await this.action.ready();
|
|
}
|
|
}
|
|
|
|
on_action() Lifecycle Timing:
|
|
- Called after action component is created and booted
|
|
- Called before action reaches on_ready()
|
|
- Can immediately access this.action properties
|
|
- Use await this.action.ready() to wait for action's full loading
|
|
|
|
SUBLAYOUTS
|
|
Sublayouts allow nesting multiple persistent layouts. Each layout in the
|
|
chain persists independently based on navigation context.
|
|
|
|
Use Case:
|
|
A settings section with its own sidebar that persists across all
|
|
settings pages, while the main application header/footer (outer layout)
|
|
also persists. Navigating between settings pages preserves both layouts;
|
|
navigating to dashboard destroys the settings sublayout but keeps the
|
|
outer layout.
|
|
|
|
Defining Sublayouts:
|
|
Add multiple @layout decorators - first is outermost, subsequent are nested:
|
|
|
|
@route('/frontend/settings/profile')
|
|
@layout('Frontend_Spa_Layout') // Outermost (header/footer)
|
|
@layout('Settings_Layout') // Nested inside Frontend_Spa_Layout
|
|
@spa('Frontend_Spa_Controller::index')
|
|
class Settings_Profile_Action extends Spa_Action { }
|
|
|
|
Creating a Sublayout:
|
|
Sublayouts are Spa_Layout classes with $sid="content":
|
|
|
|
// /rsx/app/frontend/settings/Settings_Layout.js
|
|
class Settings_Layout extends Spa_Layout {
|
|
async on_action(url, action_name, args) {
|
|
// Update sidebar highlighting based on current action
|
|
this._update_active_nav(action_name);
|
|
}
|
|
}
|
|
|
|
<!-- /rsx/app/frontend/settings/Settings_Layout.jqhtml -->
|
|
<Define:Settings_Layout>
|
|
<div class="settings-container">
|
|
<aside class="settings-sidebar">
|
|
<a href="..." data-page="profile">Profile</a>
|
|
<a href="..." data-page="security">Security</a>
|
|
</aside>
|
|
<main $sid="content"></main>
|
|
</div>
|
|
</Define:Settings_Layout>
|
|
|
|
Chain Resolution:
|
|
When navigating, Spa compares current layout chain to target:
|
|
- Matching layouts from the top are reused
|
|
- First mismatched layout and everything below is destroyed
|
|
- New layouts/action created from divergence point down
|
|
|
|
Example:
|
|
Current: Frontend_Spa_Layout > Settings_Layout > Profile_Action
|
|
Target: Frontend_Spa_Layout > Settings_Layout > Security_Action
|
|
Result: Reuse both layouts, only replace action
|
|
|
|
Current: Frontend_Spa_Layout > Settings_Layout > Profile_Action
|
|
Target: Frontend_Spa_Layout > Dashboard_Action
|
|
Result: Reuse Frontend_Spa_Layout, destroy Settings_Layout, create Dashboard_Action
|
|
|
|
on_action Propagation:
|
|
All layouts in the chain receive on_action() with the final action's info:
|
|
- url: The navigated URL
|
|
- action_name: Name of the action class (bottom of chain)
|
|
- args: URL parameters
|
|
|
|
This allows each layout to update its UI (sidebar highlighting, breadcrumbs)
|
|
based on which action is currently active.
|
|
|
|
References:
|
|
Spa.layout Always the top-level layout
|
|
Spa.action() Always the bottom-level action (not a layout)
|
|
|
|
To access intermediate layouts, use DOM traversal or layout hooks.
|
|
|
|
STYLING
|
|
SPA actions and layouts are jqhtml components, so they automatically
|
|
receive their class name on the root DOM element. This enables scoped
|
|
SCSS styling.
|
|
|
|
Automatic Class Assignment:
|
|
<Define:Contacts_Index_Action> renders as:
|
|
<div class="Contacts_Index_Action Component Spa_Action ...">
|
|
|
|
<Define:Frontend_Layout> renders as:
|
|
<div class="Frontend_Layout Component Spa_Layout ...">
|
|
|
|
SCSS File Pairing:
|
|
Each action/layout can have a companion SCSS file with all styles
|
|
scoped to the component class:
|
|
|
|
// rsx/app/frontend/contacts/contacts_index_action.scss
|
|
.Contacts_Index_Action {
|
|
.filters { margin-bottom: 1rem; }
|
|
.contact-list { ... }
|
|
}
|
|
|
|
// rsx/app/frontend/frontend_layout.scss
|
|
.Frontend_Layout {
|
|
.app-sidebar { width: 250px; }
|
|
.app-content { margin-left: 250px; }
|
|
}
|
|
|
|
Enforcement:
|
|
SCSS files in rsx/app/ must wrap all rules in a single class
|
|
matching the action/layout name. This is enforced by the manifest
|
|
scanner and prevents CSS conflicts between pages.
|
|
|
|
See scss man page for complete scoping rules and philosophy.
|
|
|
|
URL GENERATION
|
|
CRITICAL: All URLs must use Rsx::Route() or Rsx.Route(). Raw URLs like
|
|
"/contacts" will produce errors.
|
|
|
|
Signature:
|
|
Rsx::Route($action, $params = null)
|
|
Rsx.Route(action, params = null)
|
|
|
|
Where:
|
|
- $action: Controller class, SPA action, or "Class::method"
|
|
Defaults to 'index' method if no :: present
|
|
- $params: Integer sets 'id', array/object provides named params
|
|
|
|
PHP Syntax:
|
|
// Works for both controller routes and SPA actions
|
|
Rsx::Route('Contacts_Index_Action') // /contacts
|
|
Rsx::Route('Contacts_View_Action', 123) // /contacts/123
|
|
|
|
JavaScript Syntax:
|
|
// Works for both controller routes and SPA actions
|
|
Rsx.Route('Contacts_Index_Action') // /contacts
|
|
Rsx.Route('Contacts_View_Action', 123) // /contacts/123
|
|
|
|
Automatic Detection:
|
|
Rsx::Route() automatically detects whether the class is:
|
|
- PHP controller with #[Route] attribute
|
|
- JavaScript SPA action with @route() decorator
|
|
|
|
Query Parameters:
|
|
Extra parameters become query string:
|
|
|
|
Rsx::Route('Contacts_Index_Action', ['filter' => 'active', 'sort' => 'name'])
|
|
// Result: /contacts?filter=active&sort=name
|
|
|
|
NAVIGATION
|
|
Automatic Browser Integration:
|
|
- Clicking links to SPA routes triggers client-side navigation
|
|
- Browser back/forward buttons handled without page reload
|
|
- URL updates in address bar via pushState()
|
|
- Links to external or non-SPA routes perform full page load
|
|
|
|
Programmatic Navigation:
|
|
Spa.dispatch(url)
|
|
Navigate to URL programmatically
|
|
If URL is part of current SPA, uses client-side routing
|
|
If URL is external or different SPA, performs full page load
|
|
|
|
Example:
|
|
// Navigate within SPA
|
|
Spa.dispatch('/contacts/123');
|
|
|
|
// Navigate to external URL (full page load)
|
|
Spa.dispatch('https://example.com');
|
|
|
|
Use Spa.dispatch() instead of window.location for all navigation.
|
|
|
|
Accessing Active Components:
|
|
Spa.layout Reference to current layout component instance
|
|
Spa.action() Reference to current action component instance
|
|
|
|
Example:
|
|
// From anywhere in SPA context
|
|
Spa.action().reload(); // Reload current action
|
|
Spa.layout.update_nav(); // Call layout method
|
|
|
|
Loader Title Hint:
|
|
When navigating from a list to a detail page, you can provide a hint
|
|
for the page title to display while the action loads its data. This
|
|
provides immediate visual feedback instead of showing a blank or
|
|
generic title during the loading state.
|
|
|
|
Add data-loader-title-hint attribute to links:
|
|
|
|
<a href="<%= Rsx.Route('Contacts_View_Action', {id: contact.id}) %>"
|
|
data-loader-title-hint="<%= contact.name %>">
|
|
<%= contact.name %>
|
|
</a>
|
|
|
|
When the link is clicked:
|
|
1. document.title is immediately set to the hint value
|
|
2. The hint is passed to the action as this.args._loader_title_hint
|
|
3. Action can use the hint while loading, then replace with real title
|
|
|
|
Using the Hint in Actions:
|
|
async page_title() {
|
|
// Show hint while loading, real title when data is ready
|
|
if (this.is_loading() && this.args._loader_title_hint) {
|
|
return this.args._loader_title_hint;
|
|
}
|
|
return `Contact: ${this.data.contact.name}`;
|
|
}
|
|
|
|
The _loader_title_hint parameter is automatically filtered from URLs
|
|
generated by Rsx.Route(), so it never appears in the browser address
|
|
bar or generated links.
|
|
|
|
SESSION VALIDATION
|
|
After each SPA navigation (except initial load and back/forward), the client
|
|
validates its state against the server by calling Rsx.validate_session().
|
|
|
|
This detects three types of staleness:
|
|
1. Codebase updates - build_key changed due to new deployment
|
|
2. User changes - account details modified (name, role, permissions)
|
|
3. Session changes - logged out, ACLs updated, session invalidated
|
|
|
|
On mismatch, triggers location.replace() for a transparent page refresh
|
|
that doesn't pollute browser history. The user sees the page reload with
|
|
fresh state, or is redirected to login if session was invalidated.
|
|
|
|
Validation is fire-and-forget (non-blocking) and fails silently on network
|
|
errors to avoid disrupting navigation. Stale state will be detected on
|
|
subsequent navigations or when the 30-minute SPA timeout triggers.
|
|
|
|
Manual validation:
|
|
// Returns true if valid, false if refresh triggered
|
|
const valid = await Rsx.validate_session();
|
|
|
|
FILE ORGANIZATION
|
|
Standard SPA Module Structure:
|
|
/rsx/app/frontend/
|
|
|-- Frontend_Spa_Controller.php # Bootstrap controller
|
|
|-- Frontend_Layout.js # Layout class
|
|
|-- Frontend_Layout.jqhtml # Layout template
|
|
|-- frontend_bundle.php # Bundle definition
|
|
|
|
|
|-- contacts/
|
|
| |-- frontend_contacts_controller.php # Ajax endpoints
|
|
| |-- Contacts_Index_Action.js # /contacts
|
|
| |-- Contacts_Index_Action.jqhtml
|
|
| |-- Contacts_View_Action.js # /contacts/:id
|
|
| `-- Contacts_View_Action.jqhtml
|
|
|
|
|
`-- projects/
|
|
|-- frontend_projects_controller.php
|
|
|-- Projects_Index_Action.js
|
|
`-- Projects_Index_Action.jqhtml
|
|
|
|
Naming Conventions:
|
|
- Bootstrap controller: {Module}_Spa_Controller.php
|
|
- Feature controllers: {feature}_{module}_controller.php
|
|
- Actions: {Feature}_{Action}_Action.js
|
|
- Templates: {Feature}_{Action}_Action.jqhtml
|
|
- Layout: {Module}_Layout.js and .jqhtml
|
|
|
|
EXAMPLES
|
|
Complete Contacts Module:
|
|
|
|
1. Bootstrap Controller:
|
|
// /rsx/app/frontend/Frontend_Spa_Controller.php
|
|
class Frontend_Spa_Controller extends Rsx_Controller_Abstract
|
|
{
|
|
#[SPA]
|
|
#[Auth('Permission::authenticated()')]
|
|
public static function index(Request $request, array $params = [])
|
|
{
|
|
return rsx_view(SPA);
|
|
}
|
|
}
|
|
|
|
2. Feature Controller:
|
|
// /rsx/app/frontend/contacts/frontend_contacts_controller.php
|
|
class Frontend_Contacts_Controller extends Rsx_Controller_Abstract
|
|
{
|
|
#[Ajax_Endpoint]
|
|
public static function fetch_all(Request $request, array $params = []): array
|
|
{
|
|
$contacts = Contact_Model::query()
|
|
->where('is_active', 1)
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
return $contacts->toArray();
|
|
}
|
|
|
|
#[Ajax_Endpoint]
|
|
public static function save(Request $request, array $params = [])
|
|
{
|
|
$contact = Contact_Model::findOrNew($params['id'] ?? null);
|
|
$contact->fill($params);
|
|
$contact->save();
|
|
|
|
Flash_Alert::success('Contact saved');
|
|
|
|
return [
|
|
'contact_id' => $contact->id,
|
|
'redirect' => Rsx::Route('Contacts_View_Action', $contact->id),
|
|
];
|
|
}
|
|
}
|
|
|
|
3. List Action:
|
|
// /rsx/app/frontend/contacts/Contacts_Index_Action.js
|
|
@route('/contacts')
|
|
@layout('Frontend_Layout')
|
|
@spa('Frontend_Spa_Controller::index')
|
|
class Contacts_Index_Action extends Spa_Action {
|
|
async on_load() {
|
|
this.data.contacts = await Frontend_Contacts_Controller.fetch_all();
|
|
}
|
|
}
|
|
|
|
4. List Template:
|
|
<!-- /rsx/app/frontend/contacts/Contacts_Index_Action.jqhtml -->
|
|
<Define:Contacts_Index_Action tag="main">
|
|
<h1>Contacts</h1>
|
|
<% for (let contact of this.data.contacts) { %>
|
|
<div>
|
|
<a href="<%= Rsx.Route('Contacts_View_Action', {id: contact.id}) %>">
|
|
<%= contact.name %>
|
|
</a>
|
|
</div>
|
|
<% } %>
|
|
</Define:Contacts_Index_Action>
|
|
|
|
5. View Action with Parameters:
|
|
// /rsx/app/frontend/contacts/Contacts_View_Action.js
|
|
@route('/contacts/:id')
|
|
@layout('Frontend_Layout')
|
|
@spa('Frontend_Spa_Controller::index')
|
|
class Contacts_View_Action extends Spa_Action {
|
|
async on_load() {
|
|
const contact_id = this.args.id;
|
|
this.data.contact = await Contact_Model.fetch(contact_id);
|
|
}
|
|
}
|
|
|
|
6. Layout:
|
|
<!-- /rsx/app/frontend/Frontend_Layout.jqhtml -->
|
|
<Define:Frontend_Layout>
|
|
<div class="app">
|
|
<header>
|
|
<nav>
|
|
<a href="<%= Rsx.Route('Contacts_Index_Action') %>">Contacts</a>
|
|
</nav>
|
|
</header>
|
|
<main $sid="content"></main>
|
|
</div>
|
|
</Define:Frontend_Layout>
|
|
|
|
SPA VS TRADITIONAL ROUTES
|
|
Architecture Comparison:
|
|
|
|
Traditional Route:
|
|
Every navigation:
|
|
1. Browser sends HTTP request to server
|
|
2. Controller executes, queries database
|
|
3. Blade template renders full HTML page
|
|
4. Server sends complete HTML to browser
|
|
5. Browser parses and renders new page
|
|
6. JavaScript re-initializes
|
|
7. User sees page flash/reload
|
|
|
|
SPA Route:
|
|
First navigation:
|
|
1. Browser sends HTTP request to server
|
|
2. Bootstrap controller renders SPA shell
|
|
3. JavaScript initializes, discovers actions
|
|
4. Router matches URL to action
|
|
5. Action loads data via Ajax
|
|
6. Action renders in persistent layout
|
|
|
|
Subsequent navigation:
|
|
1. Router matches URL to action (client-side)
|
|
2. New action loads data via Ajax
|
|
3. New action renders in existing layout
|
|
4. No server request, no page reload
|
|
5. Layout persists (header/nav/footer stay)
|
|
|
|
Code Comparison:
|
|
|
|
Traditional:
|
|
// Controller
|
|
#[Route('/contacts')]
|
|
public static function index(Request $request, array $params = []) {
|
|
$contacts = Contact_Model::all();
|
|
return rsx_view('Contacts_List', ['contacts' => $contacts]);
|
|
}
|
|
|
|
// Blade view
|
|
@extends('layout')
|
|
@section('content')
|
|
<h1>Contacts</h1>
|
|
@foreach($contacts as $contact)
|
|
<div>{{ $contact->name }}</div>
|
|
@endforeach
|
|
@endsection
|
|
|
|
SPA:
|
|
// Bootstrap (once)
|
|
#[SPA]
|
|
public static function index(Request $request, array $params = []) {
|
|
return rsx_view(SPA);
|
|
}
|
|
|
|
// Ajax endpoint
|
|
#[Ajax_Endpoint]
|
|
public static function fetch_all(Request $request, array $params = []): array {
|
|
return Contact_Model::all()->toArray();
|
|
}
|
|
|
|
// Action
|
|
@route('/contacts')
|
|
@spa('Frontend_Spa_Controller::index')
|
|
class Contacts_Index_Action extends Spa_Action {
|
|
async on_load() {
|
|
this.data.contacts = await Frontend_Contacts_Controller.fetch_all();
|
|
}
|
|
}
|
|
|
|
// Template
|
|
<Define:Contacts_Index_Action>
|
|
<h1>Contacts</h1>
|
|
<% for (let contact of this.data.contacts) { %>
|
|
<div><%= contact.name %></div>
|
|
<% } %>
|
|
</Define:Contacts_Index_Action>
|
|
|
|
TROUBLESHOOTING
|
|
SPA Not Initializing:
|
|
- Verify bootstrap controller has #[SPA] attribute
|
|
- Check controller returns rsx_view(SPA)
|
|
- Ensure bundle includes SPA directory
|
|
- Verify window.rsxapp.is_spa is true in HTML
|
|
|
|
Action Not Found:
|
|
- Check @route() decorator matches URL pattern
|
|
- Verify action extends Spa_Action
|
|
- Ensure action file included in bundle
|
|
- Run: php artisan rsx:manifest:build
|
|
|
|
Navigation Not Working:
|
|
- Verify using Rsx.Route() not hardcoded URLs
|
|
- Check @spa() decorator references correct bootstrap controller
|
|
- Ensure layout has $sid="content" element
|
|
- Test Spa.dispatch() directly
|
|
|
|
Layout Not Persisting:
|
|
- Verify all actions in module use same @layout()
|
|
- Check layout template has $sid="content"
|
|
- Ensure not mixing SPA and traditional routes
|
|
|
|
this.args Empty:
|
|
- Check route pattern includes :param for dynamic segments
|
|
- Verify URL matches route pattern exactly
|
|
- Remember query params (?key=value) also in this.args
|
|
|
|
Data Not Loading:
|
|
- Verify Ajax endpoint has #[Ajax_Endpoint]
|
|
- Check endpoint returns array, not view
|
|
- Ensure await used in on_load() for async calls
|
|
- Verify controller included in bundle
|
|
|
|
COMMON PATTERNS
|
|
Conditional Navigation:
|
|
// Navigate based on condition
|
|
if (user.is_admin) {
|
|
Spa.dispatch(Rsx.Route('Admin_Dashboard_Action'));
|
|
} else {
|
|
Spa.dispatch(Rsx.Route('User_Dashboard_Action'));
|
|
}
|
|
|
|
Reloading Current Action:
|
|
// Refresh current action data
|
|
Spa.action().reload();
|
|
|
|
Form Submission with Redirect:
|
|
async on_submit() {
|
|
const data = this.vals();
|
|
const result = await Controller.save(data);
|
|
|
|
if (result.errors) {
|
|
Form_Utils.apply_form_errors(this.$, result.errors);
|
|
return;
|
|
}
|
|
|
|
// Flash alert set server-side, redirect to view
|
|
Spa.dispatch(result.redirect);
|
|
}
|
|
|
|
Multiple Layouts:
|
|
// Different layouts for different areas
|
|
@layout('Admin_Layout')
|
|
class Admin_Users_Action extends Spa_Action { }
|
|
|
|
@layout('Frontend_Layout')
|
|
class Frontend_Contacts_Action extends Spa_Action { }
|
|
|
|
DETACHED ACTION LOADING
|
|
Spa.load_detached_action() loads an action without affecting the live SPA state.
|
|
The action is instantiated on a detached DOM element, runs its full lifecycle
|
|
(including on_load), and returns the component instance for inspection.
|
|
|
|
Use cases:
|
|
- Extracting action metadata (titles, breadcrumbs) for navigation UI
|
|
- Pre-fetching action data before navigation
|
|
- Inspecting action state without rendering it visibly
|
|
|
|
Basic Usage:
|
|
const action = await Spa.load_detached_action('/contacts/123');
|
|
if (action) {
|
|
const title = action.get_title?.() ?? action.constructor.name;
|
|
const breadcrumbs = action.get_breadcrumbs?.();
|
|
console.log('Page title:', title);
|
|
|
|
// IMPORTANT: Clean up when done to prevent memory leaks
|
|
action.stop();
|
|
}
|
|
|
|
With Cached Data:
|
|
// Skip network request if cached data available
|
|
const action = await Spa.load_detached_action('/contacts/123', {
|
|
use_cached_data: true
|
|
});
|
|
|
|
Extra Arguments:
|
|
// Pass additional args merged with URL-extracted params
|
|
const action = await Spa.load_detached_action('/contacts/123', {
|
|
some_option: true,
|
|
use_cached_data: true
|
|
});
|
|
|
|
What It Does NOT Affect:
|
|
- Spa.action() (current live action remains unchanged)
|
|
- Spa.layout (current live layout remains unchanged)
|
|
- Spa.route / Spa.params (current route state unchanged)
|
|
- Browser history
|
|
- The visible DOM
|
|
|
|
Returns:
|
|
- Fully-initialized Spa_Action instance if route matches
|
|
- null if no route matches the URL
|
|
|
|
IMPORTANT: The caller MUST call action.stop() when done with the detached
|
|
action to clean up event listeners and prevent memory leaks.
|
|
|
|
SEE ALSO
|
|
controller(3) - Controller patterns and Ajax endpoints
|
|
jqhtml(3) - Component lifecycle and templates
|
|
routing(3) - URL generation and route patterns
|
|
modals(3) - Modal dialogs in SPA context
|
|
ajax_error_handling(3) - Error handling patterns
|
|
pagedata(3) - Passing server-side data to JavaScript
|
|
rsxapp(3) - Global JavaScript runtime object
|
|
scss(3) - SCSS scoping conventions and component-first philosophy
|