Jump to
Architecture in One Page
Every URL under /my/* renders one shell template that mounts the
PortalApp OWL component. Module sub-apps live in its content slot and swap
in/out without any page reload.
┌─ Browser ───────────────────────────────────────────────────────┐
│ Any /my/* URL renders one shell template: │
│ <owl-component name="uxdooer_portal_ui_base.portal_app"/> │
│ │
│ PortalApp (root) │
│ ├─ Sidebar (menu items, global search filter) │
│ ├─ TopBar (user, theme, search, mobile toggle) │
│ ├─ NotificationContainer │
│ └─ Module slot ──► ProjectPortalApp / SalePortalApp / … │
│ └─ ListView / KanbanView / DetailView │
│ └─ Reusable controls + services │
│ │
│ Navigation is 100 % client-side via History API + CustomEvents. │
└──────────────────────────────────────────────────────────────────┘
│ JSON-RPC {success, data, error}
┌─────────────▼────────────────────────────────────────────────────┐
│ Backend │
│ UxdooerPortalController(CustomerPortal) │
│ ├─ HTTP routes ► render the SPA shell │
│ ├─ JSON routes ► return data │
│ └─ Hooks: _get_portal_features, _get_searchable_records, │
│ _get_portal_action_items, get_menu_items, … │
└──────────────────────────────────────────────────────────────────┘
Rule 1 — SPA First
No full page reload after the initial /my/* request. Use history.pushState + the portal:navigate event.
Rule 2 — Inherit & Reuse
Prefer inheriting from standard Odoo portal controllers. Custom re-implementation is the exception.
Rule 3 — Window Events
Communicate between components with window CustomEvents — never env.bus for portal events.
Module Folder Structure
Use this exact layout for every new portal feature module. Deviating from this structure will break asset loading and registration.
uxdooer_portal_ui_<feature>/
├── __init__.py
├── __manifest__.py
├── controllers/
│ ├── __init__.py
│ └── portal.py # HTTP routes + JSON API + framework hooks
├── models/
│ ├── __init__.py
│ ├── portal_settings.py # _inherit = 'portal.settings'
│ └── <your_model>.py # optional model overrides
├── data/
│ └── portal_settings_data.xml # seed the feature_code record
├── views/
│ └── portal_templates.xml # extend portal.settings form
├── security/
│ └── ir.model.access.csv # only if you add new models
└── static/src/
├── xml/
│ └── portal_welcome_cards.xml # extends base welcome cards
├── css/
│ └── <feature>_portal.css
└── components/
├── <feature>_portal_app/ # entry component (REQUIRED)
│ ├── <feature>_portal_app.js
│ └── <feature>_portal_app.xml
├── <feature>_list/
├── <feature>_kanban/
└── <feature>_detail/
Naming Conventions
- Python controller class:
Uxdooer<Feature>PortalController - OWL components:
<Feature>ListView,<Feature>KanbanView,<Feature>DetailView - Template IDs:
uxdooer_portal_ui_<feature>.<Component> - Public registry key:
uxdooer_portal_ui_<feature>.<feature>_portal_app feature_code:<feature>_portal— e.g.project_portal,sale_portal
Manifest Conventions
- 1 XML templates — OWL compiles them before JS classes reference them
- 2 CSS
- 3 JS — children first, parent app last — the parent imports children at module-evaluation time
-
4
("include", "portal.assets_chatter")— only if a detail view embeds PortalChatter
'assets': {
'web.assets_frontend': [
# 1 — XML first
'uxdooer_portal_ui_project/static/src/xml/portal_welcome_cards.xml',
'uxdooer_portal_ui_project/static/src/components/project_portal_app/project_portal_app.xml',
# 2 — CSS
'uxdooer_portal_ui_project/static/src/css/project_portal.css',
# 3 — JS (children before parent)
'uxdooer_portal_ui_project/static/src/components/project_list/project_list.js',
'uxdooer_portal_ui_project/static/src/components/project_portal_app/project_portal_app.js',
# 4 — Chatter (if needed)
('include', 'portal.assets_chatter'),
],
},
The portal.settings Model
A single shared model drives feature visibility, sequencing, theming, and per-feature toggles. Every module owns exactly one record identified by a unique feature_code.
| Field | Purpose |
|---|---|
name | Display name shown in admin settings |
feature_code | Unique key — e.g. project_portal |
sequence | Order of menu items + welcome cards |
is_enabled | Master switch — when off, the feature is hidden everywhere |
portal_welcome_message | Hero text on /my/home (base record only) |
add_website_navigation | Adds a "Visit Website" link in the sidebar |
show_knowledge_center | Adds a "Knowledge Center" link in the sidebar |
enable_custom_theme | Activates theme token overrides |
enable_export_excel, enable_export_pdf | Per-feature export toggles |
allow_address_management | User can manage their delivery addresses |
allow_payment_methods | User can manage saved payment methods |
How to extend it
- Model — inherit
portal.settingsand add Boolean / Char fields - Data — seed one record via XML with
noupdate="1" - View — extend the base settings form, show your fields only when
feature_codematches - Controller — add a
_is_<feature>_enabled()helper and gate every route with it - Frontend — publish toggles via the
_get_portal_features()hook
Backend Controllers — What to Inherit & Override
(a) The Feature Controller
Inherits the matching Odoo portal controller. Overrides HTTP routes to render the SPA shell and exposes JSON API endpoints.
class UxdooerProjectPortalController(ProjectCustomerPortal):
# HTTP route — renders SPA shell instead of legacy page
@http.route(['/my/projects', '/my/projects/page/<int:page>'],
type='http', auth='user', website=True)
def portal_my_projects(self, page=1, **kw):
if not self._is_portal_feature_enabled('project_portal'):
return request.redirect('/my/home')
values = self._prepare_portal_layout_values()
return request.render('uxdooer_portal_ui_base.uxdooer_portal_layout', values)
# JSON API — paginated list
@http.route('/my/projects/api/list', type='jsonrpc', auth='user')
def project_api_list(self, page=1, limit=10, **kw):
domain = self._prepare_project_domain()
projects = request.env['project.project'].search(domain, ...)
return {'success': True, 'data': {...}}
(b) The Menu / Framework Extension
Inherits UxdooerPortalController and overrides framework hooks to register your feature.
class UxdooerProjectMenuExtension(UxdooerPortalController):
def _get_portal_features(self):
features = super()._get_portal_features()
settings = request.env['portal.settings'].sudo().search(
[('feature_code', '=', 'project_portal')], limit=1)
if settings and settings.is_enabled:
features['project_portal'] = {
'enabled': True, 'sequence': settings.sequence,
'name': settings.name, 'show_timesheet': settings.show_timesheet,
}
return features
def get_menu_items(self):
items = super().get_menu_items()
if self._is_portal_feature_enabled('project_portal'):
items.append({'label': 'Projects', 'route': '/my/projects',
'icon': 'briefcase', 'sequence': 20})
return items
Standard Odoo Helpers — Always Reuse
| Helper | Use it for |
|---|---|
_prepare_portal_layout_values() | Common portal context (partner, page_name, …) |
_document_check_access(model, id, token) | Access check + token validation |
_get_mandatory_fields(), _get_optional_fields() | Account form fields |
details_form_validate(data) | Account form validation |
_prepare_<model>_domain() | Domain builder |
_items_per_page | Default page size |
Standard JSON Response Envelope
✓ Success
return {
'success': True,
'data': { ... }
}
✗ Error
return {
'success': False,
'error': 'Human-readable message'
}
Common Backend Methods & Helpers
Hooks to Override
| Method | Purpose |
|---|---|
_get_portal_features() | Add your feature_code to the dict; include enabled, sequence, name, and feature toggles |
_get_searchable_records() | Return records for the TopBar global search with display options |
_get_portal_action_items() | Append items to the Action Center (records needing user attention) |
get_menu_items() | Append sidebar menu entries; items sorted by sequence globally |
Helpers to Call
| Helper | What it does |
|---|---|
_is_portal_feature_enabled(feature_code) | Generic check for any feature_code. Gate every route with this. |
_get_record_navigation_info(model, id, domain, order) | Returns {prev_id, next_id, current_index, total_count}. Run without sudo so IR rules apply. |
_strftime_to_intl_options(fmt) | Converts Odoo strftime to JS Intl options — used by /my/api/localization |
Base JSON Endpoints (provided for free)
/my/user_info — User + company branding
/my/menu_items — Sidebar menu items
/my/api/portal_features — Feature toggles
/my/api/search_data — Global search records
/my/api/actions — Action center items
/my/api/localization — Date/time/number formats
/my/theme_settings — Read/write theme colors
/my/account/details — Account form
Frontend Architecture
Bootstrapping
- Any
/my/*URL is served by an HTTP route that renders the shell template - Odoo's
public_componentsservice instantiatesPortalApp PortalApploads user info, theme, features, menu items in parallel- It parses
window.location.pathnameto decide which sub-app to mount
Sub-App Responsibilities
state.currentView— which view to showstate.viewMode— persisted in localStorageloadCurrentRoute()— translates pathname to state- Owns all
history.pushStatecalls
Registering Your Sub-App
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
export class ProjectPortalApp extends Component {
static template = "uxdooer_portal_ui_project.ProjectPortalApp";
static props = {};
setup() {
this.state = useState({ currentView: 'list', selectedId: null });
onWillStart(async () => { await this.loadCurrentRoute(); });
}
loadCurrentRoute() { /* parse window.location.pathname */ }
}
// Register in public_components — PortalApp mounts it from here
registry.category("public_components")
.add("uxdooer_portal_ui_project.project_portal_app", ProjectPortalApp);
Services Catalog
import { apiService } from "@uxdooer_portal_ui_base/js/services/api_service";
const response = await apiService.call("/my/projects/api/list", { page: 1, limit: 10 });
if (response.success) { /* use response.data */ }
else { /* show response.error */ }
import { dateFormatService } from "@uxdooer_portal_ui_base/js/services/date_format_service";
// Always call ensureInitialized() in onWillStart!
await dateFormatService.ensureInitialized();
dateFormatService.formatDate(value);
dateFormatService.formatDateTime(value);
dateFormatService.formatRelative(value);
import { currencyFormatService } from "@uxdooer_portal_ui_base/js/services/currency_format_service";
currencyFormatService.formatMonetary(amount, currencyId);
currencyFormatService.format(amount, { id, name });
currencyFormatService.formatQuantity(value);
Component Catalog
Form Controls
| Component | Key Props |
|---|---|
SearchBar | value, placeholder, onChange(value), debounce=300, autofocus |
SortDropdown | value, options:[{value,label,icon?}], onChange(value) |
FilterDropdown | value, options:[{value,label,count?}], onChange(value), multiSelect=false |
TabNavigation | tabs:[{id,label,badge?,visible?}], activeTab, onTabChange(id), style:'notebook'|'pills'|'tabs' |
List / Collection Helpers
| Component | Key Props |
|---|---|
Pagination | currentPage, totalPages, onPageChange(page) |
ViewSwitcher | currentView:'list'|'kanban', onViewChange(view) |
ExportDropdown | enableExcel, enablePdf, onExport(type), disabled |
RecordNavigator | currentRecordId, prevRecordId, nextRecordId, onNavigate(id), recordLabel |
CardView | items, renderCard(item), onCardClick(item), emptyMessage |
Display Components
| Component | Key Props | Notes |
|---|---|---|
EmptyState | icon, title, message, actionLabel?, onAction? | Render between loading and the actual list |
StatusBadge | label, variant?, icon?, size?, pill? | Auto-detects variant from label keywords |
LoadingSpinner | show, message, size | For full-screen: render conditionally in layout |
Import Paths
import { SearchBar } from "@uxdooer_portal_ui_base/components/controls/search_bar/search_bar";
import { SortDropdown } from "@uxdooer_portal_ui_base/components/controls/sort_dropdown/sort_dropdown";
import { FilterDropdown } from "@uxdooer_portal_ui_base/components/controls/filter_dropdown/filter_dropdown";
import { TabNavigation } from "@uxdooer_portal_ui_base/components/controls/tab_navigation/tab_navigation";
import { Pagination } from "@uxdooer_portal_ui_base/components/pagination/pagination";
import { ViewSwitcher } from "@uxdooer_portal_ui_base/components/view_switcher/view_switcher";
import { ExportDropdown } from "@uxdooer_portal_ui_base/components/export_dropdown/export_dropdown";
import { RecordNavigator } from "@uxdooer_portal_ui_base/components/record_navigator/record_navigator";
import { EmptyState } from "@uxdooer_portal_ui_base/components/display/empty_state/empty_state";
import { StatusBadge } from "@uxdooer_portal_ui_base/components/display/status_badge/status_badge";
import { LoadingSpinner } from "@uxdooer_portal_ui_base/components/loading_spinner/loading_spinner";
Usage Examples
Patterns extracted from the reference uxdooer_portal_ui_project module.
These snippets show shape and wiring — not full files — so you understand how each piece fits.
Building a List View Header
Compose tabs, search, sort, export, and view-switcher into a single header. Children emit changes; the parent owns state and re-fetches.
<div class="project-list-header">
<TabNavigation
tabs="tabs"
activeTab="'all'"
tabCounts="{ 'all': state.total }"
onTabChange.bind="handleTabChange"
style="'notebook'"/>
<div class="project-list-actions">
<SearchBar
value="state.searchTerm"
placeholder="'Search projects...'"
onChange.bind="handleSearch"/>
<SortDropdown
value="state.sortBy"
options="state.sortOptions"
onChange.bind="handleSort"/>
<ExportDropdown
enableExcel="state.exportSettings.excel"
enablePdf="state.exportSettings.pdf"
onExport.bind="handleExport"
disabled="state.loading or state.projects.length === 0"/>
<ViewSwitcher
t-if="props.onViewModeChange"
currentView="props.currentViewMode"
onViewChange.bind="props.onViewModeChange"/>
</div>
</div>
Minimal JS sketch — state shape + handler signatures:
this.state = useState({
projects: [], loading: false, total: 0,
searchTerm: '', sortBy: 'name',
currentPage: 1, totalPages: 1, limit: 10,
sortOptions: [], // [{value, label}]
exportSettings: { excel: true, pdf: true },
});
this.tabs = [{ id: 'all', label: 'Projects', badge: 0 }];
handleSearch(term) { this.state.searchTerm = term; this.state.currentPage = 1; this.load(); }
handleSort(value) { this.state.sortBy = value; this.state.currentPage = 1; this.load(); }
handleTabChange(tabId) { /* update domain, reload */ }
// Convert backend dict → dropdown array
this.state.sortOptions = Object.entries(response.data.sortings || {})
.map(([value, label]) => ({ value, label }));
FilterDropdown — Hide Filter on Sub-Pages
Conditionally render the project filter only when the task list is opened standalone — hide it when nested inside a project's detail page.
<FilterDropdown
t-if="!props.projectId"
value="state.filterBy"
options="state.filterOptions"
icon="'fa-filter'"
onChange.bind="handleFilterChange"/>
EmptyState + Pagination — Branching Render Logic
Show the spinner, error, empty state, or the result list/pagination — in that exact order.
<div t-if="state.loading">
<LoadingSpinner show="true" message="'Loading projects...'"/>
</div>
<div t-elif="state.error" class="project-list-error">
<i class="fa fa-exclamation-triangle fa-2x"/>
<p t-esc="state.error"/>
<button class="btn-retry" t-on-click="loadProjects">Retry</button>
</div>
<EmptyState
t-elif="state.projects.length === 0"
icon="'fa-folder-open'"
title="'No projects found'"
subtitle="state.searchTerm ? 'Try adjusting your search' : 'No projects available'"/>
<div t-else="">
<!-- render your table / cards here -->
<Pagination
currentPage="state.currentPage"
totalPages="state.totalPages"
onPageChange.bind="onPageChange"/>
</div>
ExportDropdown — Return Export Payload
The dropdown calls onExport(type) expecting a payload object back. Fetch all rows (no pagination), then map to headers + data.
async handleExport(type) {
const response = await apiService.call('/my/projects/api/list', {
page: 1, limit: 10000,
sortby: this.state.sortBy,
search: this.state.searchTerm,
});
if (!response.success || !response.data.projects.length) return { data: [] };
const headers = [
{ key: 'name', label: 'Project Name', width: 200 },
{ key: 'partner_name', label: 'Customer', width: 150 },
{ key: 'user_name', label: 'Project Manager', width: 150 },
{ key: 'task_count', label: 'Tasks', width: 80, align: 'center' },
];
const data = response.data.projects.map(p => ({
name: p.name,
partner_name: p.partner_id?.name || '-',
user_name: p.user_id?.name || '-',
task_count: p.task_count || 0,
}));
return {
headers, data,
filename: 'projects',
sheetName: 'Projects',
title: 'Projects',
subtitle: this.state.searchTerm ? `Search: ${this.state.searchTerm}` : '',
orientation: 'portrait',
};
}
Detail View — StatusBadge + RecordNavigator
Inline status badge in the title plus prev/next navigator that respects backend domain order.
<div class="detail-header">
<button class="btn-back-header" t-on-click="goBack">
<i class="fa fa-arrow-left"/>
</button>
<div class="header-title">
<h2>
<t t-esc="state.task.name"/>
<StatusBadge
label="state.task.state_label"
variant="getStateVariant(state.task.state)"/>
</h2>
</div>
<RecordNavigator
currentRecordId="props.taskId"
prevRecordId="state.prevTaskId"
nextRecordId="state.nextTaskId"
onNavigate.bind="navigateToTask"
recordLabel="'Task'"/>
</div>
Variant helper — let the backend label map to a UI color:
getStateVariant(state) {
// Map backend state keys → StatusBadge variants
return ({
'01_in_progress': 'info',
'02_changes_requested': 'warning',
'03_approved': 'success',
'1_done': 'success',
'1_canceled': 'danger',
})[state] || 'default';
}
CardView — Generic Kanban Grid
Inline-render each card via a render-prop. Click events route through onCardClick so the parent owns navigation.
<CardView
items="state.projects"
onCardClick.bind="viewProject"
emptyMessage="'No projects available'">
<t t-set-slot="card" t-slot-scope="scope">
<div class="project-card">
<h3 t-esc="scope.item.name"/>
<StatusBadge label="scope.item.state_label"/>
<p class="muted" t-esc="scope.item.partner_id?.name or '-'"/>
</div>
</t>
</CardView>
Pro Tip — Backend-Driven Options
Sort, filter, and groupby options should be defined on the backend as {key: label} dicts and returned in the list payload. The frontend just converts them with
Object.entries(...).map(([value, label]) => ({ value, label })) and feeds them to the dropdowns. This keeps translations on the server and a single source of truth for available options.
Portal Chatter Integration
Use the standard Odoo PortalChatter directly. The framework does not wrap it.
-
1
Declare the chatter asset bundle
Add
("include", "portal.assets_chatter")to your manifest'sweb.assets_frontend. -
2
Render a placeholder div
Conditionally render
<div class="o_portal_chatter" data-res_model="..." data-res_id="..." data-token="...">when the backend'sshow_chatterflag is true. -
3
Mount in shadow DOM
In
onMounted, mount a new OWLApp(PortalChatter, …)inside a shadow DOM created withgetBundle("portal.assets_chatter_style"). -
4
Always destroy on unmount
Call
destroyChatter()in bothonWillUnmountandonWillUpdateProps. Failing to destroy leaks DOM and event listeners.
⚠ Common Pitfall
Old chatter still visible after navigating? You forgot destroyChatter() in onWillUpdateProps before loading the new record.
Window Event Reference
All portal navigation uses window.dispatchEvent(new CustomEvent(...)). Never use env.bus — the portal root listens only to window.
| Event | Detail Payload | Dispatched by |
|---|---|---|
portal:navigate | { route, menuId, name? } | Any component requesting navigation |
portal:route-change | { route, menuId } | PortalApp after navigation |
portal:user-updated | { user } | Account form after save |
portal:toggle-theme | — | TopBar theme button |
portal:notify | { message, type, duration } | NotificationService.* |
portal:search | { query } | TopBar global search input |
portal:toggle-mobile-sidebar | { isOpen } | TopBar hamburger |
// ✅ Correct — navigate with window event
window.dispatchEvent(new CustomEvent('portal:navigate', {
detail: { route: '/my/projects', menuId: 'projects', name: 'Projects' }
}));
// ✅ Correct — listen to route changes
window.addEventListener('portal:route-change', (e) => {
const { route, menuId } = e.detail;
});
// ❌ Wrong — portal root doesn't listen to env.bus
this.env.bus.trigger('portal:navigate', { route: '/my/projects' });
Theming & CSS Tokens
All colors are CSS custom properties applied to :root by PortalApp. Always reference them in your module CSS.
--portal-primary-color--portal-secondary-color--portal-accent-color--portal-bg-color--portal-bg-card--portal-text-color--portal-text-light--portal-border-color--portal-sidebar-width--portal-topbar-heightTheme mode is a class on <body>. Scope dark overrides:
.portal-theme-dark .my-card {
background: var(--portal-bg-card);
color: var(--portal-text-color);
}
/* ✅ Use tokens — works with every theme + dark mode */
.my-feature-card {
background: var(--portal-bg-card);
border: 1px solid var(--portal-border-color);
color: var(--portal-text-color);
}
.my-feature-card__title {
color: var(--portal-primary-color);
}
.my-feature-card__action {
color: var(--portal-accent-color);
}
/* ❌ Never hardcode colors */
.my-feature-card { background: #fff; color: #1a2b3c; }
Conventions, Pitfalls & New-Module Checklist
✓ Always
- Inherit from Odoo standard portal controllers
- Wrap every JSON return in
{success, data}or{success:false, error} - Gate every route with an
is_enabledcheck - Register the module sub-app in
public_components - Use
window.dispatchEventfor portal events - Call
dateFormatService.ensureInitialized()inonWillStart - Use
apiService.call(...)— not rawrpc(...) - Use
ref + innerHTMLinonMountedfor HTML fields from backend
✗ Never
- Use
t-esc/t-outfor HTML from the backend - Modify Odoo core — extend via custom module
- Put
<script>tags in templates - Mount
PortalChatterwithout destroying it on unmount - Compute access logic on the frontend
- Add module-specific routes to the base module's controllers
- Use
env.busfor portal navigation events - Hardcode user-facing strings — wrap with
_()
Common Pitfalls
| Symptom | Likely Cause |
|---|---|
| OWL "Template not found" error at load | XML asset declared after the JS that uses it in __manifest__.py |
| Old chatter still visible after navigating | Forgot destroyChatter() in onWillUpdateProps |
| Welcome cards in wrong order / always visible | Missing data-feature-code / data-setting-field, or _get_portal_features() not extended |
| List values render as "[object Object]" | Returned a {id, name} dict — drill into .name in the template |
| Date always renders "-" | dateFormatService.ensureInitialized() not awaited |
| Browser back button does nothing | Sub-app doesn't listen to popstate or doesn't re-run loadCurrentRoute() |
| 403 reading related fields as portal user | Missing .sudo() when reading data the user shouldn't have raw access to |
| Two HTTP routes conflict | Mixing HTTP route and JSON route on the same path — use different paths |
New Module Checklist
Ready to build your portal module?
Our team can help you design, build, and deploy custom Odoo portal experiences.
Get in Touch