diff --git a/app/RSpade/Core/SPA/Spa.js b/app/RSpade/Core/SPA/Spa.js index e8ba36d8b..07898bb19 100755 --- a/app/RSpade/Core/SPA/Spa.js +++ b/app/RSpade/Core/SPA/Spa.js @@ -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 = $('
'); // 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) diff --git a/app/RSpade/man/breadcrumbs.txt b/app/RSpade/man/breadcrumbs.txt new file mode 100755 index 000000000..368ba7b54 --- /dev/null +++ b/app/RSpade/man/breadcrumbs.txt @@ -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: + + + +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: + + + + + 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): + + + Edit Contact + + Dashboard + Contacts + Edit + + + + + New Pattern (action methods): + // Remove from template, keep only: + + + + + + + // 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