feat(portal): sidebar shell template + portal.portal_layout inherit

fp_portal_shell wraps every /my/* page (FP custom + Odoo default)
in a sticky-sidebar shell with no per-template edits. Sidebar markup
is a separate fp_portal_sidebar template that reads fp_sidebar_items
+ fp_partner_display_name from the page context.

Approach D ($0 re-emit) used instead of plan's unbalanced-xpath approach:
position="replace" on //div[@id='wrap'] with $0 inside <main> causes
Odoo's Python inheritance engine to re-emit the original #wrap node
(verified in tools/template_inheritance.py lines 162-169). Every
xpath block is well-formed XML.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-17 13:38:25 -04:00
parent df74d702af
commit d17cadabf0
2 changed files with 107 additions and 0 deletions

View File

@@ -55,6 +55,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'security/fp_portal_security.xml', 'security/fp_portal_security.xml',
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/fp_sequence_data.xml', 'data/fp_sequence_data.xml',
'views/fp_portal_shell.xml',
'views/fp_portal_macros.xml', 'views/fp_portal_macros.xml',
'views/fp_quote_request_views.xml', 'views/fp_quote_request_views.xml',
'views/fp_portal_dashboard.xml', 'views/fp_portal_dashboard.xml',

View File

@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Wraps every /my/* page (FP custom + Odoo default) in the new
sidebar shell. Inherits portal.portal_layout so we don't have
to edit every individual page template.
Implementation note (Approach D, $0 re-emit):
The plan originally proposed injecting an unbalanced main opening
tag in one xpath block and its closing tag in another. QWeb parses
each xpath payload as an independent XML fragment, so unbalanced
tags are rejected at load time.
Instead we use position="replace" on //div[@id='wrap'] with $0
inside the replacement payload. $0 is supported by Odoo 19's
Python view inheritance engine (tools/template_inheritance.py,
lines 162-169): any element whose text content is the literal
string "$0" has its text cleared and the deep-copied original node
appended as a child. This produces a fully balanced replacement tree
that nests the original #wrap (and all its Odoo-managed content)
inside our .o_fp_portal_main element.
Verified from portal_templates.xml line 155:
div id="wrap" class="o_portal_wrap"
div class="container pt-3 pb-5"
t t-out="0" (Odoo content slot)
/div
/div
-->
<odoo>
<!-- ================================================================== -->
<!-- Inherit portal.portal_layout to wrap content in sidebar shell -->
<!-- ================================================================== -->
<template id="fp_portal_shell"
name="FP Portal Shell — Sidebar Wrap"
inherit_id="portal.portal_layout"
priority="50">
<!--
Replace #wrap entirely. The $0 text node inside
<main class="o_fp_portal_main"> causes Odoo's inheritance
engine to re-emit the original #wrap div (with all its
children) at that position. Every existing portal page
continues to render correctly because Odoo's <t t-out="0"/>
content slot inside #wrap is preserved verbatim.
-->
<xpath expr="//div[@id='wrap']" position="replace">
<div class="o_fp_portal_shell">
<!-- Mobile hamburger (shown only below 768px via SCSS) -->
<button type="button"
class="o_fp_portal_hamburger d-md-none"
aria-label="Open navigation">
<i class="fa fa-bars"/>
</button>
<!-- Backdrop for mobile drawer (hidden by default) -->
<div class="o_fp_portal_backdrop"/>
<!-- Sidebar navigation component -->
<t t-call="fusion_plating_portal.fp_portal_sidebar"/>
<!-- Main content area — original #wrap re-emitted here via $0 -->
<main class="o_fp_portal_main">$0</main>
</div>
</xpath>
</template>
<!-- ================================================================== -->
<!-- Sidebar template — rendered by fp_portal_shell -->
<!-- ================================================================== -->
<template id="fp_portal_sidebar" name="FP Portal Sidebar">
<aside class="o_fp_portal_sidebar">
<!-- Partner display name header -->
<div class="o_fp_sidebar_header">
<t t-out="fp_partner_display_name or 'My Account'"/>
</div>
<!-- Navigation items, walked from the Python-side data structure.
fp_sidebar_items is injected by the controller mixin in Task 4.
Guards here handle the case where Task 4 hasn't deployed yet. -->
<t t-foreach="fp_sidebar_items or []" t-as="entry">
<!-- Section labels render as non-link headers -->
<t t-if="entry.get('type') == 'section_label'">
<div class="o_fp_sidebar_section_label" t-out="entry['label']"/>
</t>
<!-- Items render as anchor links -->
<t t-elif="entry.get('type') == 'item'">
<a t-att-href="entry['url']"
t-attf-class="o_fp_sidebar_item #{'o_fp_sidebar_active' if entry.get('active') else ''}">
<span class="o_fp_sidebar_icon" t-out="entry.get('icon') or '&#x2022;'"/>
<span t-out="entry['label']"/>
</a>
</t>
</t>
<!-- Footer: sign out link always present -->
<div class="o_fp_sidebar_footer">
<a href="/web/session/logout?redirect=/" class="o_fp_sidebar_item">
<span class="o_fp_sidebar_icon">&#x21AA;</span>
<span>Sign Out</span>
</a>
</div>
</aside>
</template>
</odoo>