UXdooer
Developer Reference

Portal UI Developer Guide

Build powerful Odoo 18/19 portal modules on top of uxdooer_portal_ui_base — architecture, components, services, and best practices in one place.

13 Sections
OWL + SPA Architecture
Odoo 18 & 19
1

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.

diagram
┌─ 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.

2

Module Folder Structure

Use this exact layout for every new portal feature module. Deviating from this structure will break asset loading and registration.

tree
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
3

Manifest Conventions

Asset Declaration Order (web.assets_frontend)
  1. 1 XML templates — OWL compiles them before JS classes reference them
  2. 2 CSS
  3. 3 JS — children first, parent app last — the parent imports children at module-evaluation time
  4. 4 ("include", "portal.assets_chatter") — only if a detail view embeds PortalChatter
python
'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'),
    ],
},
4

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
nameDisplay name shown in admin settings
feature_codeUnique key — e.g. project_portal
sequenceOrder of menu items + welcome cards
is_enabledMaster switch — when off, the feature is hidden everywhere
portal_welcome_messageHero text on /my/home (base record only)
add_website_navigationAdds a "Visit Website" link in the sidebar
show_knowledge_centerAdds a "Knowledge Center" link in the sidebar
enable_custom_themeActivates theme token overrides
enable_export_excel, enable_export_pdfPer-feature export toggles
allow_address_managementUser can manage their delivery addresses
allow_payment_methodsUser can manage saved payment methods

How to extend it

  1. Model — inherit portal.settings and add Boolean / Char fields
  2. Data — seed one record via XML with noupdate="1"
  3. View — extend the base settings form, show your fields only when feature_code matches
  4. Controller — add a _is_<feature>_enabled() helper and gate every route with it
  5. Frontend — publish toggles via the _get_portal_features() hook
5

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.

python
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.

python
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

HelperUse 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_pageDefault page size

Standard JSON Response Envelope

✓ Success

return {
    'success': True,
    'data': { ... }
}

✗ Error

return {
    'success': False,
    'error': 'Human-readable message'
}
6

Common Backend Methods & Helpers

Hooks to Override

MethodPurpose
_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

HelperWhat 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
7

Frontend Architecture

Bootstrapping

  1. Any /my/* URL is served by an HTTP route that renders the shell template
  2. Odoo's public_components service instantiates PortalApp
  3. PortalApp loads user info, theme, features, menu items in parallel
  4. It parses window.location.pathname to decide which sub-app to mount

Sub-App Responsibilities

  • state.currentView — which view to show
  • state.viewMode — persisted in localStorage
  • loadCurrentRoute() — translates pathname to state
  • Owns all history.pushState calls

Registering Your Sub-App

javascript
/** @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);
8

Services Catalog

apiService — JSON-RPC wrapper · the ONLY way to talk to the backend
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 */ }
dateFormatService — locale-aware date/time formatting
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);
currencyFormatService — monetary / quantity formatting
import { currencyFormatService } from "@uxdooer_portal_ui_base/js/services/currency_format_service";

currencyFormatService.formatMonetary(amount, currencyId);
currencyFormatService.format(amount, { id, name });
currencyFormatService.formatQuantity(value);
9

Component Catalog

Form Controls

ComponentKey Props
SearchBarvalue, placeholder, onChange(value), debounce=300, autofocus
SortDropdownvalue, options:[{value,label,icon?}], onChange(value)
FilterDropdownvalue, options:[{value,label,count?}], onChange(value), multiSelect=false
TabNavigationtabs:[{id,label,badge?,visible?}], activeTab, onTabChange(id), style:'notebook'|'pills'|'tabs'

List / Collection Helpers

ComponentKey Props
PaginationcurrentPage, totalPages, onPageChange(page)
ViewSwitchercurrentView:'list'|'kanban', onViewChange(view)
ExportDropdownenableExcel, enablePdf, onExport(type), disabled
RecordNavigatorcurrentRecordId, prevRecordId, nextRecordId, onNavigate(id), recordLabel
CardViewitems, renderCard(item), onCardClick(item), emptyMessage

Display Components

ComponentKey PropsNotes
EmptyStateicon, title, message, actionLabel?, onAction?Render between loading and the actual list
StatusBadgelabel, variant?, icon?, size?, pill?Auto-detects variant from label keywords
LoadingSpinnershow, message, sizeFor full-screen: render conditionally in layout

Import Paths

javascript
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.

A

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.

xml · template
<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:

javascript · sketch
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 }));
B

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.

xml
<FilterDropdown
    t-if="!props.projectId"
    value="state.filterBy"
    options="state.filterOptions"
    icon="'fa-filter'"
    onChange.bind="handleFilterChange"/>
C

EmptyState + Pagination — Branching Render Logic

Show the spinner, error, empty state, or the result list/pagination — in that exact order.

xml
<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>
D

ExportDropdown — Return Export Payload

The dropdown calls onExport(type) expecting a payload object back. Fetch all rows (no pagination), then map to headers + data.

javascript · pattern
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',
    };
}
E

Detail View — StatusBadge + RecordNavigator

Inline status badge in the title plus prev/next navigator that respects backend domain order.

xml
<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:

javascript · sketch
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';
}
F

CardView — Generic Kanban Grid

Inline-render each card via a render-prop. Click events route through onCardClick so the parent owns navigation.

xml
<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.

10

Portal Chatter Integration

Use the standard Odoo PortalChatter directly. The framework does not wrap it.

  1. 1

    Declare the chatter asset bundle

    Add ("include", "portal.assets_chatter") to your manifest's web.assets_frontend.

  2. 2

    Render a placeholder div

    Conditionally render <div class="o_portal_chatter" data-res_model="..." data-res_id="..." data-token="..."> when the backend's show_chatter flag is true.

  3. 3

    Mount in shadow DOM

    In onMounted, mount a new OWL App(PortalChatter, …) inside a shadow DOM created with getBundle("portal.assets_chatter_style").

  4. 4

    Always destroy on unmount

    Call destroyChatter() in both onWillUnmount and onWillUpdateProps. 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.

11

Window Event Reference

All portal navigation uses window.dispatchEvent(new CustomEvent(...)). Never use env.bus — the portal root listens only to window.

EventDetail PayloadDispatched 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-themeTopBar theme button
portal:notify{ message, type, duration }NotificationService.*
portal:search{ query }TopBar global search input
portal:toggle-mobile-sidebar{ isOpen }TopBar hamburger
javascript
// ✅ 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' });
12

Theming & CSS Tokens

All colors are CSS custom properties applied to :root by PortalApp. Always reference them in your module CSS.

Color Tokens
--portal-primary-color
--portal-secondary-color
--portal-accent-color
--portal-bg-color
--portal-bg-card
--portal-text-color
--portal-text-light
--portal-border-color
Layout Tokens
--portal-sidebar-width
--portal-topbar-height
Dark Mode

Theme mode is a class on <body>. Scope dark overrides:

.portal-theme-dark .my-card {
  background: var(--portal-bg-card);
  color: var(--portal-text-color);
}
css
/* ✅ 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; }
13

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_enabled check
  • Register the module sub-app in public_components
  • Use window.dispatchEvent for portal events
  • Call dateFormatService.ensureInitialized() in onWillStart
  • Use apiService.call(...) — not raw rpc(...)
  • Use ref + innerHTML in onMounted for HTML fields from backend

Never

  • Use t-esc / t-out for HTML from the backend
  • Modify Odoo core — extend via custom module
  • Put <script> tags in templates
  • Mount PortalChatter without destroying it on unmount
  • Compute access logic on the frontend
  • Add module-specific routes to the base module's controllers
  • Use env.bus for portal navigation events
  • Hardcode user-facing strings — wrap with _()

Common Pitfalls

SymptomLikely Cause
OWL "Template not found" error at loadXML asset declared after the JS that uses it in __manifest__.py
Old chatter still visible after navigatingForgot destroyChatter() in onWillUpdateProps
Welcome cards in wrong order / always visibleMissing 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 nothingSub-app doesn't listen to popstate or doesn't re-run loadCurrentRoute()
403 reading related fields as portal userMissing .sudo() when reading data the user shouldn't have raw access to
Two HTTP routes conflictMixing 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