In Odoo 17, creating a dynamic and reusable record selector for your website can significantly enhance user experience, especially for tasks like selecting partners, products, or other records interactively. This blog walks you through implementing a Dynamic Record Selector widget for the Odoo website, focusing on making the model dynamic while maintaining a user-friendly and efficient interface. It supports any model dynamically.
Why a Dynamic Record Selector?
A record selector allows users to search and select records (e.g., partners, products, or employees) directly from a website interface. Making it dynamic means the widget can work with any Odoo model, reducing code duplication and improving reusability. Key features include:
* Infinite scrolling for seamless record loading.
* Real-time search to filter records as users type.
* Dynamic model support to work with any Odoo model (e.g., res.partner, product.template, etc.).
* User-friendly UI with loading indicators and notifications.
This implementation is particularly useful for e-commerce websites, customer portals, or any Odoo website requiring interactive record selection.
Implementation
1. Allow the widget to accept any model name dynamically.
2. Make the search and update logic model-agnostic.
3. Update the backend routes to handle dynamic models.
4. Ensure the UI remains intuitive and responsive.
Step-by-Step Implementation
Let’s create the DynamicRecordSelector to support dynamic models in Odoo 17. Below is the code with explanations.
Module Structure
+-- __init__.py
+-- __manifest__.py
+-- controllers/
¦ +-- __init__.py
¦ +-- dynamic_record_controller.py
+-- static/
¦ +-- src/
¦ ¦ +-- js/
¦ ¦ +-- dynamic_record_selector.js
¦ ¦ +-- css/
¦ ¦ +-- dynamic_record_selector.css
+-- views/
¦ +-- dynamic_record_selector.xml
__manifest__.py
{
'name': 'Website Dynamic Record Selector',
'version': '1.0',
'category': 'Website',
'summary': 'A dynamic record selector widget for Odoo 17 websites',
'description': """
This module provides a reusable dynamic record selector widget for Odoo 17 websites.
It allows users to search and select records from any model (e.g., res.partner, product.template)
with infinite scrolling, real-time search, and a user-friendly interface.
""",
'author': 'Your Name',
'depends': [
'website'
],
'data': [
'views/dynamic_record_selector.xml',
],
'assets': {
'web.assets_frontend': [
'website_dynamic_record_selector/static/src/js/dynamic_record_selector.js',
'website_dynamic_record_selector/static/src/css/dynamic_record_selector.css',
],
},
'installable': True,
'auto_install': False,
'application': False,
}
1. Update the Frontend Widget
The widget needs to accept a model name and field names dynamically. We’ll modify the JavaScript to make it generic.
dynamic_record_selector.js
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
import {_t} from "@web/core/l10n/translation";
import {debounce} from "@web/core/utils/timing";
publicWidget.registry.DynamicRecordSelector = publicWidget.Widget.extend({
selector: '.js_dynamic_record_selector',
events: {
'focus .search-input': '_onFocus',
'input .search-input': '_onSearchInput',
'click .record-list li': '_onRecordSelect',
'blur .search-input': '_onBlur',
'mousedown .record-dropdown': '_onDropdownMouseDown',
},
init: function () {
this._super.apply(this, arguments);
this.rpc = this.bindService("rpc");
},
start: function () {
this.limit = 10;
this.offset = 0;
this.selectedRecord = null;
this.isLoading = false;
this.hasMore = true;
this.$searchInput = this.$('.search-input');
this.$recordList = this.$('.record-list');
this.$recordDropdown = this.$('.record-dropdown');
this.isClickingInside = false;
// Get model and field from data attributes
this.model = this.$el.data('model') || 'res.partner';
this.displayField = this.$el.data('display-field') || 'name';
this.searchField = this.$el.data('search-field') || 'name';
// Create and append the loading indicator
this.$loadingIndicator = $('<div class="loading-indicator">Loading...</div>').hide();
this.$recordDropdown.append(this.$loadingIndicator);
this._setupInfiniteScroll();
return this._super.apply(this, arguments);
},
_setupInfiniteScroll: function () {
this.$recordDropdown.on('scroll', debounce(this._onScroll.bind(this), 200));
},
_onScroll: function () {
if (this.isLoading || !this.hasMore) return;
var scrollHeight = this.$recordList[0].scrollHeight;
var scrollTop = this.$recordList.scrollTop();
var clientHeight = this.$recordList.innerHeight();
if (scrollTop + clientHeight >= scrollHeight - 50) {
this._fetchRecords(true);
}
},
_onFocus: function () {
if (this.$recordList.children().length === 0) {
this._fetchRecords();
}
this.$recordDropdown.addClass('show');
},
_onBlur: function (ev) {
if (!this.isClickingInside) {
this.$recordDropdown.removeClass('show');
}
},
_onDropdownMouseDown: function (ev) {
this.isClickingInside = true;
setTimeout(() => {
this.isClickingInside = false;
}, 0);
},
_onSearchInput: function () {
this.offset = 0;
this.hasMore = true;
this._fetchRecords();
},
_onRecordSelect: function (ev) {
var $target = $(ev.currentTarget);
var recordId = $target.data('record-id');
if (!recordId) {
this.$recordDropdown.removeClass('show');
return;
}
var recordName = $target.text();
this.selectedRecord = { id: recordId, name: recordName };
this.$searchInput.val(recordName);
this.$('input[type="hidden"]').val(recordId);
this.$recordDropdown.removeClass('show');
//TODO: You can add your logic here
},
_fetchRecords: function (append) {
if (this.isLoading) return;
this.isLoading = true;
this._showLoading();
var self = this;
var searchTerm = this.$searchInput.val();
this.rpc('/website/dynamic_record/search', {
model: this.model,
term: searchTerm,
limit: this.limit,
offset: this.offset,
display_field: this.displayField,
search_field: this.searchField,
}).then(function (result) {
if (!append) {
self.$recordList.empty();
}
if (result.records.length > 0) {
var fragment = document.createDocumentFragment();
result.records.forEach(function (record) {
var li = document.createElement('li');
li.dataset.recordId = record.id;
li.textContent = record[self.displayField];
fragment.appendChild(li);
});
self.$recordList[0].appendChild(fragment);
self.offset += result.records.length;
self.hasMore = result.has_more;
} else if (!append) {
var li = document.createElement('li');
li.textContent = _t("No records found");
self.$recordList[0].appendChild(li);
self.hasMore = false;
}
self.$recordDropdown.addClass('show');
self.isLoading = false;
self._hideLoading();
}).catch(function (error) {
console.error("Error fetching records:", error);
self.isLoading = false;
self._hideLoading();
});
},
_showLoading: function () {
this.$loadingIndicator.show();
},
_hideLoading: function () {
this.$loadingIndicator.hide();
},
});
2. Update the Backend Routes
The backend needs to handle dynamic models for searching and updating records. Below are the updated controller routes.
dynamic_record_controller.py
from odoo import http
from odoo.http import request
class DynamicRecordController(http.Controller):
@http.route('/website/dynamic_record/search', type='json', auth="public", website=True)
def search_records(self, model, term='', limit=10, offset=0, display_field='name', search_field='name', **kwargs):
try:
# Build domain for search
domain = [(search_field, 'ilike', term)]
records = request.env[model].sudo().search_read(
domain=domain,
fields=['id', display_field],
limit=limit,
offset=offset,
order=f'{display_field} asc'
)
total_count = request.env[model].sudo().search_count(domain)
return {
'records': records,
'has_more': (offset + limit) < total_count
}
except Exception as e:
return {'error': str(e)}
3. Update the HTML Template
The widget needs an HTML structure with data attributes to specify the model and fields. Here’s an example template.
dynamic_record_selector.xml
<template id="dynamic_record_selector_template" name="Dynamic Record Selector">
<div class="js_dynamic_record_selector" t-att-data-model="model" t-att-data-display-field="display_field" t-att-data-search-field="search_field">
<input type="text" class="search-input form-control" placeholder="Search records..."/>
<input type="hidden" name="record_id"/>
<div class="record-dropdown dropdown-menu">
<ul class="record-list list-group"></ul>
</div>
</div>
</template>
4. Styling the Widget
To ensure a polished look, add some CSS for the dropdown and loading indicator.
dynamic_record_selector.css
.js_dynamic_record_selector {
position: relative;
}
.record-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
max-height: 300px;
overflow-y: auto;
}
.record-dropdown.show {
display: block;
}
.record-list {
list-style: none;
padding: 0;
margin: 0;
}
.record-list li {
padding: 8px 12px;
cursor: pointer;
}
.record-list li:hover {
background-color: #f8f9fa;
}
.loading-indicator {
text-align: center;
padding: 10px;
color: #666;
How to Use the Dynamic Record Selector:
In your QWeb template, include the selector with the desired model and fields by inheriting the template and placing the code exactly where you need it.
<t t-call="your_module.dynamic_record_selector_template">
<t t-set="model" t-value="'product.template'"/>
<t t-set="display_field" t-value="'name'"/>
<t t-set="search_field" t-value="'name'"/>
</t>
Benefits of the Dynamic Approach
* Reusability: Use the same widget for any model (e.g., res.partner, product.template, hr.employee).
* Scalability: Infinite scrolling ensures performance with large datasets.
* Flexibility: Configurable display and search fields adapt to different use cases.
* User-Friendly: Real-time search, loading indicators, and notifications enhance UX.
Conclusion
The Dynamic Record Selector is a powerful addition to Odoo 17 websites, enabling seamless record selection for any model. By making the widget model-agnostic, we’ve created a reusable and scalable solution that enhances user interaction. Try implementing this in your Odoo module and customize it to fit your specific needs!
To read more about How to add Dynamic Record Stages in Odoo 17, refer to our blog How to add Dynamic Record Stages in Odoo 17.