Layout-managed breadcrumb system for SPA actions
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -993,7 +993,13 @@ class Spa {
|
||||
const action_name = action_class.name;
|
||||
|
||||
// Merge URL args with extra_args (extra_args take precedence)
|
||||
const args = { ...route_match.args, ...extra_args };
|
||||
// Include skip_render_and_ready to prevent side effects from on_ready()
|
||||
// (e.g., Spa.dispatch() which would cause redirect loops)
|
||||
const args = {
|
||||
...route_match.args,
|
||||
...extra_args,
|
||||
skip_render_and_ready: true
|
||||
};
|
||||
|
||||
console_debug('Spa', `load_detached_action: Loading ${action_name} with args:`, args);
|
||||
|
||||
@@ -1001,11 +1007,7 @@ class Spa {
|
||||
const $detached = $('<div>');
|
||||
|
||||
// Instantiate the action on the detached element
|
||||
// Skip render and on_ready phases - detached actions are for data extraction only
|
||||
// (e.g., getting title/breadcrumbs). Running on_ready() could trigger side effects
|
||||
// like Spa.dispatch() which would cause redirect loops.
|
||||
const options = { skip_render_and_ready: true };
|
||||
$detached.component(action_name, args, options);
|
||||
$detached.component(action_name, args);
|
||||
const action = $detached.component();
|
||||
|
||||
// Wait for on_load to complete (data fetching)
|
||||
|
||||
335
app/RSpade/man/breadcrumbs.txt
Executable file
335
app/RSpade/man/breadcrumbs.txt
Executable file
@@ -0,0 +1,335 @@
|
||||
BREADCRUMBS(3) RSX Framework Manual BREADCRUMBS(3)
|
||||
|
||||
NAME
|
||||
breadcrumbs - Layout-managed breadcrumb navigation system for SPA actions
|
||||
|
||||
SYNOPSIS
|
||||
Action Methods:
|
||||
|
||||
class Contacts_View_Action extends Spa_Action {
|
||||
async page_title() { return this.data.contact.name; }
|
||||
async breadcrumb_label() { return this.data.contact.name; }
|
||||
async breadcrumb_label_active() { return 'View Contact Details'; }
|
||||
async breadcrumb_parent() {
|
||||
return Rsx.Route('Contacts_Index_Action');
|
||||
}
|
||||
}
|
||||
|
||||
Template Component:
|
||||
|
||||
<Breadcrumb_Nav $crumbs="<%= JSON.stringify(chain) %>" />
|
||||
|
||||
DESCRIPTION
|
||||
The RSX breadcrumb system provides hierarchical navigation through action
|
||||
methods that define breadcrumb metadata. Unlike traditional breadcrumb
|
||||
systems where each page hardcodes its full breadcrumb path, RSX uses
|
||||
parent URL traversal to automatically build the breadcrumb chain.
|
||||
|
||||
Key characteristics:
|
||||
- Actions define breadcrumb behavior through async methods
|
||||
- Layout walks up the parent chain using Spa.load_detached_action()
|
||||
- Each parent contributes its breadcrumb_label() to the chain
|
||||
- Active page uses breadcrumb_label_active() for descriptive text
|
||||
- Document title set from page_title() method
|
||||
|
||||
This approach provides:
|
||||
- Dynamic breadcrumb content based on loaded data
|
||||
- Automatic chain building without hardcoding paths
|
||||
- Consistent breadcrumb behavior across all SPA actions
|
||||
- Descriptive active breadcrumb text for root pages
|
||||
|
||||
ACTION METHODS
|
||||
page_title()
|
||||
Returns the page title for document.title. Called once when action
|
||||
renders. Used for browser tab title.
|
||||
|
||||
async page_title() {
|
||||
return 'Contacts'; // Root page
|
||||
}
|
||||
|
||||
async page_title() {
|
||||
return this.data.contact.name; // Detail page
|
||||
}
|
||||
|
||||
async page_title() {
|
||||
return this.data.is_edit
|
||||
? `Edit: ${this.data.contact.name}`
|
||||
: 'New Contact'; // Add/Edit page
|
||||
}
|
||||
|
||||
breadcrumb_label()
|
||||
Returns the label shown when this action appears as a parent in
|
||||
another action's breadcrumb chain. Typically returns the entity
|
||||
name or short identifier.
|
||||
|
||||
async breadcrumb_label() {
|
||||
return this.data.contact.name;
|
||||
}
|
||||
|
||||
async breadcrumb_label() {
|
||||
return 'Projects';
|
||||
}
|
||||
|
||||
Not needed for pages that are never parents (leaf nodes like edit
|
||||
forms). Default returns page_title() if not defined.
|
||||
|
||||
breadcrumb_label_active()
|
||||
Returns the label shown for the current active page. This can be
|
||||
more descriptive than breadcrumb_label() since it's not constrained
|
||||
by breadcrumb width.
|
||||
|
||||
// Root pages - descriptive text
|
||||
async breadcrumb_label_active() {
|
||||
return 'Manage your contact database';
|
||||
}
|
||||
|
||||
// Detail pages - entity identifier
|
||||
async breadcrumb_label_active() {
|
||||
return `View ${this.data.contact.name}`;
|
||||
}
|
||||
|
||||
// Edit pages - action context
|
||||
async breadcrumb_label_active() {
|
||||
return this.data.is_edit ? 'Edit Contact' : 'New Contact';
|
||||
}
|
||||
|
||||
breadcrumb_parent()
|
||||
Returns the URL of the parent action for building the breadcrumb
|
||||
chain. The layout walks up this chain calling breadcrumb_label()
|
||||
on each parent until reaching null.
|
||||
|
||||
// Root pages - no parent
|
||||
async breadcrumb_parent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// View page - parent is list
|
||||
async breadcrumb_parent() {
|
||||
return Rsx.Route('Contacts_Index_Action');
|
||||
}
|
||||
|
||||
// Edit page - parent depends on context
|
||||
async breadcrumb_parent() {
|
||||
if (this.data.is_edit) {
|
||||
return Rsx.Route('Contacts_View_Action', {
|
||||
id: this.args.id
|
||||
});
|
||||
}
|
||||
return Rsx.Route('Contacts_Index_Action');
|
||||
}
|
||||
|
||||
LAYOUT INTEGRATION
|
||||
The layout is responsible for building the breadcrumb chain and rendering
|
||||
it. This happens in the layout's on_action() method.
|
||||
|
||||
Building the Chain:
|
||||
async _update_header_from_action() {
|
||||
const chain = [];
|
||||
|
||||
// Start with current action's active label
|
||||
chain.push({
|
||||
label: await this.action.breadcrumb_label_active(),
|
||||
url: null // Active item has no link
|
||||
});
|
||||
|
||||
// Walk up parent chain
|
||||
let parent_url = await this.action.breadcrumb_parent();
|
||||
while (parent_url) {
|
||||
const parent = await Spa.load_detached_action(parent_url, {
|
||||
use_cached_data: true
|
||||
});
|
||||
|
||||
if (!parent) break;
|
||||
|
||||
chain.unshift({
|
||||
label: await parent.breadcrumb_label(),
|
||||
url: parent_url
|
||||
});
|
||||
|
||||
parent_url = await parent.breadcrumb_parent();
|
||||
parent.stop(); // Clean up detached action
|
||||
}
|
||||
|
||||
this._set_header({
|
||||
title: await this.action.page_title(),
|
||||
breadcrumbs: chain
|
||||
});
|
||||
}
|
||||
|
||||
Calling from on_action():
|
||||
async on_action(url, action_name, args) {
|
||||
await this._update_header_from_action();
|
||||
}
|
||||
|
||||
BREADCRUMB_NAV COMPONENT
|
||||
Template:
|
||||
<Define:Breadcrumb_Nav tag="nav" aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<% for (let i = 0; i < this.args.crumbs.length; i++) {
|
||||
const crumb = this.args.crumbs[i];
|
||||
const is_last = i === this.args.crumbs.length - 1;
|
||||
%>
|
||||
<% if (is_last || !crumb.url) { %>
|
||||
<li class="breadcrumb-item active" aria-current="page">
|
||||
<%= crumb.label %>
|
||||
</li>
|
||||
<% } else { %>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="<%= crumb.url %>"><%= crumb.label %></a>
|
||||
</li>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</ol>
|
||||
</Define:Breadcrumb_Nav>
|
||||
|
||||
Uses Bootstrap 5 breadcrumb classes for consistent styling.
|
||||
|
||||
PATTERNS BY PAGE TYPE
|
||||
Index/Root Pages:
|
||||
Actions at the top of a hierarchy with no parent.
|
||||
|
||||
class Contacts_Index_Action extends Spa_Action {
|
||||
async page_title() { return 'Contacts'; }
|
||||
async breadcrumb_label() { return 'Contacts'; }
|
||||
async breadcrumb_label_active() {
|
||||
return 'Manage your contact database';
|
||||
}
|
||||
async breadcrumb_parent() { return null; }
|
||||
}
|
||||
|
||||
Result: Contacts / Manage your contact database
|
||||
(single breadcrumb with descriptive text)
|
||||
|
||||
View/Detail Pages:
|
||||
Actions showing a single record with a parent list page.
|
||||
|
||||
class Contacts_View_Action extends Spa_Action {
|
||||
async page_title() { return this.data.contact.name; }
|
||||
async breadcrumb_label() { return this.data.contact.name; }
|
||||
async breadcrumb_label_active() { return 'View Contact'; }
|
||||
async breadcrumb_parent() {
|
||||
return Rsx.Route('Contacts_Index_Action');
|
||||
}
|
||||
}
|
||||
|
||||
Result: Contacts > John Smith / View Contact
|
||||
|
||||
Edit Pages:
|
||||
Actions for editing existing or creating new records.
|
||||
|
||||
class Contacts_Edit_Action extends Spa_Action {
|
||||
async page_title() {
|
||||
if (this.data.is_edit) {
|
||||
return `Edit: ${this.data.contact.name}`;
|
||||
}
|
||||
return 'New Contact';
|
||||
}
|
||||
|
||||
async breadcrumb_label_active() {
|
||||
return this.data.is_edit ? 'Edit Contact' : 'New Contact';
|
||||
}
|
||||
|
||||
async breadcrumb_parent() {
|
||||
if (this.data.is_edit) {
|
||||
return Rsx.Route('Contacts_View_Action', {
|
||||
id: this.args.id
|
||||
});
|
||||
}
|
||||
return Rsx.Route('Contacts_Index_Action');
|
||||
}
|
||||
}
|
||||
|
||||
Edit result: Contacts > John Smith > View Contact / Edit Contact
|
||||
Add result: Contacts / New Contact
|
||||
|
||||
Nested Pages (Sublayouts):
|
||||
Actions within sublayouts like settings sections.
|
||||
|
||||
class Settings_Profile_Display_Action extends Spa_Action {
|
||||
async page_title() { return 'Display Preferences'; }
|
||||
async breadcrumb_label() { return 'Display Preferences'; }
|
||||
async breadcrumb_label_active() {
|
||||
return 'Configure display settings';
|
||||
}
|
||||
async breadcrumb_parent() {
|
||||
return Rsx.Route('Dashboard_Index_Action');
|
||||
}
|
||||
}
|
||||
|
||||
Result: Dashboard > Display Preferences / Configure display settings
|
||||
|
||||
DOCUMENT TITLE
|
||||
The layout sets document.title using page_title():
|
||||
|
||||
document.title = 'RSX - ' + await this.action.page_title();
|
||||
|
||||
Format: "AppName - PageTitle"
|
||||
|
||||
Examples:
|
||||
- RSX - Contacts
|
||||
- RSX - John Smith
|
||||
- RSX - Edit: John Smith
|
||||
|
||||
PERFORMANCE CONSIDERATIONS
|
||||
Parent Chain Loading:
|
||||
Spa.load_detached_action() with use_cached_data: true uses cached
|
||||
data from previous visits when available, minimizing network requests.
|
||||
|
||||
When visiting Contacts > John Smith > Edit Contact for the first time:
|
||||
1. Edit page loads with fresh data (required)
|
||||
2. View page loads detached with use_cached_data (may hit cache)
|
||||
3. Index page loads detached with use_cached_data (may hit cache)
|
||||
|
||||
Subsequent breadcrumb renders reuse cached data.
|
||||
|
||||
Stopping Detached Actions:
|
||||
Always call parent.stop() after extracting breadcrumb data to clean
|
||||
up event listeners and prevent memory leaks.
|
||||
|
||||
DEFAULT METHODS
|
||||
If an action doesn't define breadcrumb methods, defaults apply:
|
||||
|
||||
page_title() Returns class name (e.g., "Contacts_View_Action")
|
||||
breadcrumb_label() Returns page_title()
|
||||
breadcrumb_label_active() Returns page_title()
|
||||
breadcrumb_parent() Returns null (no parent)
|
||||
|
||||
Override only the methods you need. Most actions need at minimum:
|
||||
- page_title()
|
||||
- breadcrumb_label_active() (for descriptive root page text)
|
||||
- breadcrumb_parent() (for non-root pages)
|
||||
|
||||
MIGRATION FROM HARDCODED BREADCRUMBS
|
||||
If converting from inline breadcrumb components:
|
||||
|
||||
Old Pattern (template-based):
|
||||
<Page_Header>
|
||||
<Page_Header_Left>
|
||||
<Page_Title>Edit Contact</Page_Title>
|
||||
<Breadcrumb>
|
||||
<Breadcrumb_Item $href="...">Dashboard</Breadcrumb_Item>
|
||||
<Breadcrumb_Item $href="...">Contacts</Breadcrumb_Item>
|
||||
<Breadcrumb_Item $active=true>Edit</Breadcrumb_Item>
|
||||
</Breadcrumb>
|
||||
</Page_Header_Left>
|
||||
</Page_Header>
|
||||
|
||||
New Pattern (action methods):
|
||||
// Remove from template, keep only:
|
||||
<Page_Header>
|
||||
<Page_Header_Right>
|
||||
<!-- action buttons -->
|
||||
</Page_Header_Right>
|
||||
</Page_Header>
|
||||
|
||||
// Add to action class:
|
||||
async page_title() { return 'Edit Contact'; }
|
||||
async breadcrumb_label_active() { return 'Edit Contact'; }
|
||||
async breadcrumb_parent() {
|
||||
return Rsx.Route('Contacts_View_Action', { id: this.args.id });
|
||||
}
|
||||
|
||||
SEE ALSO
|
||||
spa(3) - SPA routing and layouts
|
||||
spa(3) DETACHED ACTION LOADING - Spa.load_detached_action() details
|
||||
spa(3) SUBLAYOUTS - Nested layout handling
|
||||
Reference in New Issue
Block a user