diff --git a/addons/cx_web_refresh_from_backend/README.rst b/addons/cx_web_refresh_from_backend/README.rst new file mode 100644 index 0000000..653317a --- /dev/null +++ b/addons/cx_web_refresh_from_backend/README.rst @@ -0,0 +1,123 @@ +======================== +Web Refresh From Backend +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:199e0da56a7d94568d062706d1f34ac6b38310034c25f5840e2631722e9d9f65 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github + :target: https://github.com/cetmix/cetmix-tower/tree/18.0/cx_web_refresh_from_backend + :alt: cetmix/cetmix-tower + +|badge1| |badge2| |badge3| + +Refresh UI views from backend +============================= + +This is a **technical module** that allows triggering a **UI reload** +from the backend. It enables triggering the reload action for selected +users and record IDs. + +-------------- + +🔧 Helper Function: ``reload_views`` +------------------------------------ + +A special helper function ``reload_views`` is added to the ``res.users`` +model. + +**Arguments** +~~~~~~~~~~~~~ + ++----------------+--------------------------+--------------------------+ +| Argument | Type | Description | ++================+==========================+==========================+ +| **model** | ``Char`` | Model name, e.g. | +| | | ``'res.partner'`` | ++----------------+--------------------------+--------------------------+ +| **view_types** | ``List of Char`` | View types to reload, | +| | *(optional)* | e.g. | +| | | ``["form", "kanban"]``. | +| | | Leave blank to reload | +| | | all views. | ++----------------+--------------------------+--------------------------+ +| **rec_ids** | ``List of Integer`` | The view will be | +| | *(optional)* | reloaded only if a | +| | | record with an ID from | +| | | this list is present in | +| | | the view. | ++----------------+--------------------------+--------------------------+ + +-------------- + +⚠️ Important Notes +------------------ + +Use this function **wisely**. + +When reloading **form views**, be aware that if a user is currently +editing a record, **their unsaved updates may be lost** when the form +reloads from the server (no confirmation dialog is shown). + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +🧩 Example Usage +---------------- + +Below is a code snippet showing how to use the ``reload_views`` helper +function. + +.. code:: python + + # Reload the kanban and form views for all salespeople when an opportunity is won + # Will reload views only if the current opportunity is being displayed + + group_id = self.env.ref("sales_team.group_sale_salesman").id + users_to_reload = self.env["res.users"].search([("groups_id", "in", [group_id])]) + users_to_reload.reload_views( + model="crm.lead", + view_types=["kanban", "form"], + rec_ids=[self.ids], + ) + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Cetmix + +Maintainers +----------- + +This module is part of the `cetmix/cetmix-tower `_ project on GitHub. + +You are welcome to contribute. diff --git a/addons/cx_web_refresh_from_backend/__init__.py b/addons/cx_web_refresh_from_backend/__init__.py new file mode 100644 index 0000000..961cb7d --- /dev/null +++ b/addons/cx_web_refresh_from_backend/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Cetmix OÜ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import models diff --git a/addons/cx_web_refresh_from_backend/__manifest__.py b/addons/cx_web_refresh_from_backend/__manifest__.py new file mode 100644 index 0000000..619bff1 --- /dev/null +++ b/addons/cx_web_refresh_from_backend/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2025 Cetmix OÜ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +# Mail is required: its ir.websocket override subscribes the partner channel to the +# bus, so users receive web.refresh_view notifications. + +{ + "name": "Web Refresh From Backend", + "summary": "Refresh frontend views from backend", + "version": "18.0.1.0.0", + "category": "Web", + "license": "LGPL-3", + "author": "Cetmix", + "website": "https://tower.cetmix.com", + "images": ["static/description/banner.png"], + "depends": ["mail"], + "assets": { + "web.assets_backend": [ + "cx_web_refresh_from_backend/static/src/views/utils/get_loaded_record_ids.esm.js", + "cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js", + "cx_web_refresh_from_backend/static/src/views/kanban/kanban_controller_patch.esm.js", + "cx_web_refresh_from_backend/static/src/views/form/form_controller_patch.esm.js", + ], + "web.qunit_suite_tests": [ + "cx_web_refresh_from_backend/static/tests/refresh_from_backend_tests.esm.js", + ], + }, + "installable": True, + "auto_install": False, +} diff --git a/addons/cx_web_refresh_from_backend/i18n/cx_web_refresh_from_backend.pot b/addons/cx_web_refresh_from_backend/i18n/cx_web_refresh_from_backend.pot new file mode 100644 index 0000000..526aac0 --- /dev/null +++ b/addons/cx_web_refresh_from_backend/i18n/cx_web_refresh_from_backend.pot @@ -0,0 +1,67 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cx_web_refresh_from_backend +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: cx_web_refresh_from_backend +#. odoo-javascript +#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0 +msgid "Cancel" +msgstr "" + +#. module: cx_web_refresh_from_backend +#. odoo-javascript +#: code:addons/cx_web_refresh_from_backend/static/src/views/form/form_controller_patch.esm.js:0 +msgid "Could not reload form. %(message)s" +msgstr "" + +#. module: cx_web_refresh_from_backend +#. odoo-javascript +#: code:addons/cx_web_refresh_from_backend/static/src/views/kanban/kanban_controller_patch.esm.js:0 +msgid "Could not reload kanban. %(message)s" +msgstr "" + +#. module: cx_web_refresh_from_backend +#. odoo-javascript +#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0 +msgid "Could not reload list. %(message)s" +msgstr "" + +#. module: cx_web_refresh_from_backend +#. odoo-javascript +#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0 +msgid "Could not save record. %(message)s" +msgstr "" + +#. module: cx_web_refresh_from_backend +#. odoo-javascript +#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0 +msgid "List is being refreshed from backend" +msgstr "" + +#. module: cx_web_refresh_from_backend +#. odoo-javascript +#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0 +msgid "Save & Refresh" +msgstr "" + +#. module: cx_web_refresh_from_backend +#: model:ir.model,name:cx_web_refresh_from_backend.model_res_users +msgid "User" +msgstr "" + +#. module: cx_web_refresh_from_backend +#. odoo-javascript +#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0 +msgid "You have unsaved edits. Save them before refreshing?" +msgstr "" diff --git a/addons/cx_web_refresh_from_backend/models/__init__.py b/addons/cx_web_refresh_from_backend/models/__init__.py new file mode 100644 index 0000000..4bf0702 --- /dev/null +++ b/addons/cx_web_refresh_from_backend/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Cetmix OÜ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import res_users diff --git a/addons/cx_web_refresh_from_backend/models/res_users.py b/addons/cx_web_refresh_from_backend/models/res_users.py new file mode 100644 index 0000000..df1acc9 --- /dev/null +++ b/addons/cx_web_refresh_from_backend/models/res_users.py @@ -0,0 +1,50 @@ +# Copyright 2025 Cetmix OÜ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class ResUsers(models.Model): + _inherit = "res.users" + + def reload_views(self, model, view_types=None, rec_ids=None): + """ + Trigger UI reload for selected users and record IDs. + + This method allows to reload specific views from the backend. + Be aware that when reloading form views, if a user is currently + doing some updates, those updates may be lost when the form reloads + (no confirmation dialog on the client). + + :param model: str, Model name (e.g., 'res.partner') + :param view_types: list of str, optional, View types to reload + (e.g., ['form', 'kanban']). Leave blank to reload all views. + :param rec_ids: list of int, optional, View will be reloaded only if a record + with id from the list is present in the view. + + Example usage: + # Reload the kanban and form views for all salespeople + # when an opportunity is won. + # Will reload views only if the current opportunity is being displayed + group_id = self.env.ref("sales_team.group_sale_salesman").id + users_to_reload = self.env["res.users"].search( + [("groups_id", "in", [group_id])] + ) + users_to_reload.reload_views( + model="crm.lead", + view_types=["kanban", "form"], + rec_ids=[self.ids] + ) + """ + + # Prepare the message payload + bus_message = { + "model": model, + "view_types": view_types or [], + "rec_ids": rec_ids or [], + } + + # Send one notification per user's partner in deterministic order. + bus_bus = self.env["bus.bus"] + for user in self.sorted("id"): + bus_bus._sendone(user.partner_id, "web.refresh_view", bus_message) diff --git a/addons/cx_web_refresh_from_backend/pyproject.toml b/addons/cx_web_refresh_from_backend/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/addons/cx_web_refresh_from_backend/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/addons/cx_web_refresh_from_backend/readme/DESCRIPTION.md b/addons/cx_web_refresh_from_backend/readme/DESCRIPTION.md new file mode 100644 index 0000000..48a550a --- /dev/null +++ b/addons/cx_web_refresh_from_backend/readme/DESCRIPTION.md @@ -0,0 +1,28 @@ +# Refresh UI views from backend + +This is a **technical module** that allows triggering a **UI reload** from the backend. +It enables triggering the reload action for selected users and record IDs. + +--- + +## 🔧 Helper Function: `reload_views` + +A special helper function `reload_views` is added to the `res.users` model. + +### **Arguments** + +| Argument | Type | Description | +|-----------|------|-------------| +| **model** | `Char` | Model name, e.g. `'res.partner'` | +| **view_types** | `List of Char` *(optional)* | View types to reload, e.g. `["form", "kanban"]`. Leave blank to reload all views. | +| **rec_ids** | `List of Integer` *(optional)* | The view will be reloaded only if a record with an ID from this list is present in the view. | + +--- + +## ⚠️ Important Notes + +Use this function **wisely**. + +When reloading **form views**, be aware that if a user is currently editing a record, +**their unsaved updates may be lost** when the form reloads from the server (no confirmation +dialog is shown). diff --git a/addons/cx_web_refresh_from_backend/readme/USAGE.md b/addons/cx_web_refresh_from_backend/readme/USAGE.md new file mode 100644 index 0000000..d7cb7de --- /dev/null +++ b/addons/cx_web_refresh_from_backend/readme/USAGE.md @@ -0,0 +1,16 @@ +## 🧩 Example Usage + +Below is a code snippet showing how to use the `reload_views` helper function. + +```python +# Reload the kanban and form views for all salespeople when an opportunity is won +# Will reload views only if the current opportunity is being displayed + +group_id = self.env.ref("sales_team.group_sale_salesman").id +users_to_reload = self.env["res.users"].search([("groups_id", "in", [group_id])]) +users_to_reload.reload_views( + model="crm.lead", + view_types=["kanban", "form"], + rec_ids=[self.ids], +) +``` diff --git a/addons/cx_web_refresh_from_backend/static/description/banner.png b/addons/cx_web_refresh_from_backend/static/description/banner.png new file mode 100644 index 0000000..b655285 Binary files /dev/null and b/addons/cx_web_refresh_from_backend/static/description/banner.png differ diff --git a/addons/cx_web_refresh_from_backend/static/description/icon.png b/addons/cx_web_refresh_from_backend/static/description/icon.png new file mode 100644 index 0000000..3963e67 Binary files /dev/null and b/addons/cx_web_refresh_from_backend/static/description/icon.png differ diff --git a/addons/cx_web_refresh_from_backend/static/description/index.html b/addons/cx_web_refresh_from_backend/static/description/index.html new file mode 100644 index 0000000..e544977 --- /dev/null +++ b/addons/cx_web_refresh_from_backend/static/description/index.html @@ -0,0 +1,479 @@ + + + + + +Web Refresh From Backend + + + +
+

Web Refresh From Backend

+ + +

Beta License: LGPL-3 cetmix/cetmix-tower

+
+

Refresh UI views from backend

+

This is a technical module that allows triggering a UI reload +from the backend. It enables triggering the reload action for selected +users and record IDs.

+
+
+

🔧 Helper Function: reload_views

+

A special helper function reload_views is added to the res.users +model.

+
+

Arguments

+ +++++ + + + + + + + + + + + + + + + + + + + + +
ArgumentTypeDescription
modelCharModel name, e.g. +'res.partner'
view_typesList of Char +(optional)View types to reload, +e.g. +["form", "kanban"]. +Leave blank to reload +all views.
rec_idsList of Integer +(optional)The view will be +reloaded only if a +record with an ID from +this list is present in +the view.
+
+
+
+
+

⚠️ Important Notes

+

Use this function wisely.

+

When reloading form views, be aware that if a user is currently +editing a record, their unsaved updates may be lost when the form +reloads from the server (no confirmation dialog is shown).

+

Table of contents

+
+
+
+

Usage

+
+

🧩 Example Usage

+

Below is a code snippet showing how to use the reload_views helper +function.

+
+# Reload the kanban and form views for all salespeople when an opportunity is won
+# Will reload views only if the current opportunity is being displayed
+
+group_id = self.env.ref("sales_team.group_sale_salesman").id
+users_to_reload = self.env["res.users"].search([("groups_id", "in", [group_id])])
+users_to_reload.reload_views(
+    model="crm.lead",
+    view_types=["kanban", "form"],
+    rec_ids=[self.ids],
+)
+
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Cetmix
  • +
+
+
+

Maintainers

+

This module is part of the cetmix/cetmix-tower project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/addons/cx_web_refresh_from_backend/static/src/views/form/form_controller_patch.esm.js b/addons/cx_web_refresh_from_backend/static/src/views/form/form_controller_patch.esm.js new file mode 100644 index 0000000..e7db1ad --- /dev/null +++ b/addons/cx_web_refresh_from_backend/static/src/views/form/form_controller_patch.esm.js @@ -0,0 +1,161 @@ +/** @odoo-module */ + +import {FormController} from "@web/views/form/form_controller"; +import {isResIdInRecIds} from "../utils/get_loaded_record_ids.esm"; +import {onWillUnmount} from "@odoo/owl"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; +import {_t} from "@web/core/l10n/translation"; + +patch(FormController.prototype, { + setup() { + super.setup(...arguments); + + // Bus_service is async; useService("bus_service") breaks (SERVICES_METADATA). + this.busService = this.env.services.bus_service; + this.notificationService = useService("notification"); + + this._lastLocalSave = null; + this._isRefreshInFlight = false; + this._hasRefreshQueued = false; + + this._boundBusHandler = this._onWebRefreshNotification.bind(this); + this.busService.subscribe("web.refresh_view", this._boundBusHandler); + + onWillUnmount(() => { + if (this.busService && this._boundBusHandler) { + this.busService.unsubscribe("web.refresh_view", this._boundBusHandler); + } + }); + }, + + /** + * Handle a web.refresh_view bus notification for this form. + * Called once per notification; coalesces concurrent refreshes via _queueRefresh. + * + * @param {Object} payload - Notification payload {model, view_types, rec_ids} + */ + async _onWebRefreshNotification(payload) { + if (!this.model || !this.model.root) { + return; + } + if (this._shouldRefreshView(payload)) { + await this._queueRefresh("refreshForm"); + } + }, + + async _queueRefresh(methodName) { + if (this._isRefreshInFlight) { + this._hasRefreshQueued = true; + return; + } + this._isRefreshInFlight = true; + try { + do { + this._hasRefreshQueued = false; + await this[methodName](); + } while (this._hasRefreshQueued); + } finally { + this._isRefreshInFlight = false; + } + }, + + /** + * Check whether a refresh notification is relevant to this form. + * + * Returns true when all of the following hold: + * - model matches current form model + * - requested view types include "form" (or none specified) + * - record id matches current record (or none specified) + * - form is not inside a dialog / wizard + * + * @param {Object} payload - Notification payload + * @returns {Boolean} + */ + _shouldRefreshView(payload) { + const {model, view_types = [], rec_ids = []} = payload; + + if (this.props.resModel !== model) { + return false; + } + if (view_types.length > 0 && !view_types.includes("form")) { + return false; + } + const currentResId = this.model && this.model.root && this.model.root.resId; + if (rec_ids.length > 0 && !isResIdInRecIds(currentResId, rec_ids)) { + return false; + } + // Skip refresh when form is in a dialog or when a wizard is on top + // of the stack. Refreshing in that context can leave wizard/confirmation + // dialogs stuck open (e.g. confirm="..." in wizard view). + if (this.env.inDialog) { + return false; + } + const currentController = this.actionService.currentController; + const currentAction = currentController && currentController.action; + if (currentAction && currentAction.target === "new") { + return false; + } + return true; + }, + + /** + * Refresh the form with actual data from server. + * + * Reloads without confirmation even when the record is dirty (client changes + * may be discarded). Dialog / wizard forms are filtered out in + * _shouldRefreshView(). + * + * @returns {Promise} + */ + async refreshForm() { + if (this._lastLocalSave && Date.now() - this._lastLocalSave < 2500) { + return; + } + + if (!this.model || !this.model.root) { + return; + } + + const record = this.model.root; + + try { + await record.load(); + } catch (error) { + this.notificationService.add(this._getRefreshErrorMessage(error), { + type: "danger", + }); + return; + } + + if (this.model && this.model.root) { + this.render(true); + } + }, + + _getRefreshErrorMessage(error) { + const message = + (error && error.data && error.data.message) || + (error && error.message) || + String(error); + return _t("Could not reload form. %(message)s", {message}); + }, + + /** + * Override of save button handler. + * + * After a successful save, stores a timestamp to avoid immediate auto-refresh + * triggered by our own write (bus notification). Failed saves leave the + * timestamp unchanged so refresh suppression does not apply incorrectly. + * + * @param {Object} params - Save options + * @returns {Promise} Result of the core save (truthy when save succeeded) + */ + async saveButtonClicked(params) { + const result = await super.saveButtonClicked(params); + if (result) { + this._lastLocalSave = Date.now(); + } + return result; + }, +}); diff --git a/addons/cx_web_refresh_from_backend/static/src/views/kanban/kanban_controller_patch.esm.js b/addons/cx_web_refresh_from_backend/static/src/views/kanban/kanban_controller_patch.esm.js new file mode 100644 index 0000000..c5a7412 --- /dev/null +++ b/addons/cx_web_refresh_from_backend/static/src/views/kanban/kanban_controller_patch.esm.js @@ -0,0 +1,125 @@ +/** @odoo-module */ + +import { + getLoadedRecordIds, + hasAnyLoadedIdInRecIds, +} from "../utils/get_loaded_record_ids.esm"; +import {KanbanController} from "@web/views/kanban/kanban_controller"; +import {onWillUnmount} from "@odoo/owl"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; +import {_t} from "@web/core/l10n/translation"; + +patch(KanbanController.prototype, { + setup() { + super.setup(...arguments); + // Bus_service is async; useService("bus_service") breaks (SERVICES_METADATA). + this.busService = this.env.services.bus_service; + this.notificationService = useService("notification"); + this._isRefreshInFlight = false; + this._hasRefreshQueued = false; + + this._boundBusHandler = this._onWebRefreshNotification.bind(this); + this.busService.subscribe("web.refresh_view", this._boundBusHandler); + + onWillUnmount(() => { + if (this.busService && this._boundBusHandler) { + this.busService.unsubscribe("web.refresh_view", this._boundBusHandler); + } + }); + }, + + /** + * Handle a web.refresh_view bus notification for this kanban. + * Called once per notification; coalesces concurrent refreshes via _queueRefresh. + * + * @param {Object} payload - Notification payload {model, view_types, rec_ids} + */ + async _onWebRefreshNotification(payload) { + if (!this.model || !this.model.root) { + return; + } + if (this._shouldRefreshView(payload)) { + await this._queueRefresh("refreshList"); + } + }, + + async _queueRefresh(methodName) { + if (this._isRefreshInFlight) { + this._hasRefreshQueued = true; + return; + } + this._isRefreshInFlight = true; + try { + do { + this._hasRefreshQueued = false; + await this[methodName](); + } while (this._hasRefreshQueued); + } finally { + this._isRefreshInFlight = false; + } + }, + + /** + * Check whether a refresh notification is relevant to this kanban. + * + * Returns true when all of the following hold: + * - model matches current kanban model + * - requested view types include "kanban" (or none specified) + * - at least one loaded record id is in rec_ids (or none specified) + * + * @param {Object} payload - Notification payload + * @returns {Boolean} + */ + _shouldRefreshView(payload) { + const {model, view_types = [], rec_ids = []} = payload; + + if (this.props.resModel !== model) { + return false; + } + if (view_types.length > 0 && !view_types.includes("kanban")) { + return false; + } + if (rec_ids.length > 0) { + const loadedIds = getLoadedRecordIds(this.model.root); + if (!hasAnyLoadedIdInRecIds(loadedIds, rec_ids)) { + return false; + } + } + return true; + }, + + /** + * Refresh the kanban with actual data from server. + * + * @returns {Promise} + */ + async refreshList() { + if (!this.model || !this.model.root) { + return; + } + + const list = this.model.root; + + try { + await list.load(); + } catch (error) { + this.notificationService.add(this._getRefreshErrorMessage(error), { + type: "danger", + }); + return; + } + + if (this.model && this.model.root) { + this.render(true); + } + }, + + _getRefreshErrorMessage(error) { + const message = + (error && error.data && error.data.message) || + (error && error.message) || + String(error); + return _t("Could not reload kanban. %(message)s", {message}); + }, +}); diff --git a/addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js b/addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js new file mode 100644 index 0000000..e9386f4 --- /dev/null +++ b/addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js @@ -0,0 +1,170 @@ +/** @odoo-module */ + +import { + getLoadedRecordIds, + hasAnyLoadedIdInRecIds, +} from "../utils/get_loaded_record_ids.esm"; +import {ConfirmationDialog} from "@web/core/confirmation_dialog/confirmation_dialog"; +import {ListController} from "@web/views/list/list_controller"; +import {onWillUnmount} from "@odoo/owl"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; +import {_t} from "@web/core/l10n/translation"; + +patch(ListController.prototype, { + setup() { + super.setup(...arguments); + // Bus_service is async; useService("bus_service") breaks (SERVICES_METADATA). + this.busService = this.env.services.bus_service; + this.notificationService = useService("notification"); + this._isRefreshInFlight = false; + this._hasRefreshQueued = false; + + this._boundBusHandler = this._onWebRefreshNotification.bind(this); + this.busService.subscribe("web.refresh_view", this._boundBusHandler); + + onWillUnmount(() => { + if (this.busService && this._boundBusHandler) { + this.busService.unsubscribe("web.refresh_view", this._boundBusHandler); + } + }); + }, + + /** + * Handle a web.refresh_view bus notification for this list. + * Called once per notification; coalesces concurrent refreshes via _queueRefresh. + * + * @param {Object} payload - Notification payload {model, view_types, rec_ids} + */ + async _onWebRefreshNotification(payload) { + if (!this.model || !this.model.root) { + return; + } + if (this._shouldRefreshView(payload)) { + await this._queueRefresh("refreshList"); + } + }, + + async _queueRefresh(methodName) { + if (this._isRefreshInFlight) { + this._hasRefreshQueued = true; + return; + } + this._isRefreshInFlight = true; + try { + do { + this._hasRefreshQueued = false; + await this[methodName](); + } while (this._hasRefreshQueued); + } finally { + this._isRefreshInFlight = false; + } + }, + + /** + * Check whether a refresh notification is relevant to this list. + * + * Returns true when all of the following hold: + * - model matches current list model + * - requested view types include "list" or "tree" (or none specified) + * - at least one loaded record id is in rec_ids (or none specified) + * + * @param {Object} payload - Notification payload + * @returns {Boolean} + */ + _shouldRefreshView(payload) { + const {model, view_types = [], rec_ids = []} = payload; + + if (this.props.resModel !== model) { + return false; + } + if ( + view_types.length > 0 && + !view_types.includes("list") && + !view_types.includes("tree") + ) { + return false; + } + if (rec_ids.length > 0) { + const loadedIds = getLoadedRecordIds(this.model.root); + if (!hasAnyLoadedIdInRecIds(loadedIds, rec_ids)) { + return false; + } + } + return true; + }, + + /** + * Refresh the list with actual data from server. + * If there is an edited record, asks the user to save or cancel. + * + * @returns {Promise} + */ + async refreshList() { + if (!this.model || !this.model.root) { + return; + } + + const list = this.model.root; + + if (list.editedRecord) { + const confirmed = await this._confirmListRefresh(); + + if (!confirmed) { + // User declined: drop coalesced refreshes queued during the dialog. + this._hasRefreshQueued = false; + return; + } + try { + await list.editedRecord.save(); + } catch (error) { + this.notificationService.add(this._getSaveErrorMessage(error), { + type: "danger", + }); + return; + } + } + + try { + await list.load(); + } catch (error) { + this.notificationService.add(this._getReloadErrorMessage(error), { + type: "danger", + }); + return; + } + + if (this.model && this.model.root) { + this.render(true); + } + }, + + async _confirmListRefresh() { + return await new Promise((resolve) => { + this.dialogService.add(ConfirmationDialog, { + title: _t("List is being refreshed from backend"), + body: _t("You have unsaved edits. Save them before refreshing?"), + confirm: () => resolve(true), + cancel: () => resolve(false), + confirmLabel: _t("Save & Refresh"), + cancelLabel: _t("Cancel"), + }); + }); + }, + + _getSaveErrorMessage(error) { + const message = + (error && error.data && error.data.message) || + (error && error.message) || + String(error); + return _t("Could not save record. %(message)s", {message}); + }, + + _getReloadErrorMessage(error) { + const message = + (error && error.data && error.data.message) || + (error && error.message) || + String(error); + return _t("Could not reload list. %(message)s", {message}); + }, +}); diff --git a/addons/cx_web_refresh_from_backend/static/src/views/utils/get_loaded_record_ids.esm.js b/addons/cx_web_refresh_from_backend/static/src/views/utils/get_loaded_record_ids.esm.js new file mode 100644 index 0000000..18ea6b5 --- /dev/null +++ b/addons/cx_web_refresh_from_backend/static/src/views/utils/get_loaded_record_ids.esm.js @@ -0,0 +1,55 @@ +/** @odoo-module */ + +/** + * Get IDs of records currently loaded in list-like root models. + * Supports both plain and grouped datasets. + * + * @param {Object} root - View root model (list/kanban) + * @returns {Array} + */ +export function getLoadedRecordIds(root) { + if (root.isGrouped) { + const recordIds = []; + const collectIds = (groups) => { + for (const group of groups) { + if (group.list && group.list.records) { + recordIds.push(...group.list.records.map((record) => record.resId)); + } + if (group.groups) { + collectIds(group.groups); + } + } + }; + collectIds(root.groups); + return recordIds; + } + return root.records.map((record) => record.resId); +} + +/** + * Whether any loaded record id is present in the notification id list. + * Uses a Set for O(n + m) membership checks instead of O(n * m) with includes. + * + * @param {Number[]} loadedIds - IDs currently visible in the view + * @param {Number[]} rec_ids - IDs from the bus payload + * @returns {Boolean} + */ +export function hasAnyLoadedIdInRecIds(loadedIds, rec_ids) { + const recIdSet = new Set(rec_ids); + return loadedIds.some((id) => recIdSet.has(id)); +} + +/** + * Whether a single record id is in the notification id list. + * Uses a Set for O(m) build + O(1) lookup vs repeated includes. + * + * @param {Number|undefined|false} resId - Current record id (e.g. form root) + * @param {Number[]} rec_ids - IDs from the bus payload + * @returns {Boolean} + */ +export function isResIdInRecIds(resId, rec_ids) { + if (!resId) { + return false; + } + return new Set(rec_ids).has(resId); +} diff --git a/addons/cx_web_refresh_from_backend/static/tests/refresh_from_backend_tests.esm.js b/addons/cx_web_refresh_from_backend/static/tests/refresh_from_backend_tests.esm.js new file mode 100644 index 0000000..dd462c5 --- /dev/null +++ b/addons/cx_web_refresh_from_backend/static/tests/refresh_from_backend_tests.esm.js @@ -0,0 +1,239 @@ +/** @odoo-module */ +/* global QUnit */ + +import "cx_web_refresh_from_backend/static/src/views/form/form_controller_patch.esm"; +import "cx_web_refresh_from_backend/static/src/views/kanban/kanban_controller_patch.esm"; +import "cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm"; + +import { + editInput, + getFixture, + makeDeferred, + nextTick, +} from "@web/../tests/helpers/utils"; +import { + makeView, + makeViewInDialog, + setupViewRegistries, +} from "@web/../tests/views/helpers"; + +let serverData = null; +let target = null; + +/** + * Simulate a web.refresh_view notification on the patched controller. + * + * The unit tests exercise the controller filtering and refresh logic, so they + * can call the public notification handler directly instead of reproducing the + * bus service internals. + * + * @param {Object} controller - Patched view controller instance + * @param {Object} payload - {model, view_types, rec_ids} + * @returns {Promise} + */ +function triggerRefresh(controller, payload) { + return controller._onWebRefreshNotification(payload); +} + +QUnit.module("cx_web_refresh_from_backend", (hooks) => { + hooks.beforeEach(() => { + serverData = { + models: { + "res.partner": { + fields: { + name: {string: "Name", type: "char"}, + }, + records: [ + {id: 1, name: "Partner 1"}, + {id: 2, name: "Partner 2"}, + ], + }, + }, + }; + setupViewRegistries(); + target = getFixture(); + }); + + QUnit.test( + "form: refresh runs only for matching notifications", + async function (assert) { + const form = await makeView({ + type: "form", + resModel: "res.partner", + serverData, + resId: 1, + arch: '
', + }); + + let refreshCalls = 0; + form.refreshForm = async () => { + refreshCalls++; + }; + + triggerRefresh(form, { + model: "res.users", + view_types: ["form"], + rec_ids: [1], + }); + triggerRefresh(form, { + model: "res.partner", + view_types: ["list"], + rec_ids: [1], + }); + triggerRefresh(form, { + model: "res.partner", + view_types: ["form"], + rec_ids: [2], + }); + triggerRefresh(form, { + model: "res.partner", + view_types: ["form"], + rec_ids: [1], + }); + await nextTick(); + + assert.strictEqual(refreshCalls, 1); + } + ); + + QUnit.test( + "form in dialog: matching notification is ignored", + async function (assert) { + const form = await makeViewInDialog({ + type: "form", + resModel: "res.partner", + serverData, + resId: 1, + arch: '
', + }); + + let refreshCalls = 0; + form.refreshForm = async () => { + refreshCalls++; + }; + + triggerRefresh(form, { + model: "res.partner", + view_types: ["form"], + rec_ids: [1], + }); + await nextTick(); + + assert.strictEqual(refreshCalls, 0); + } + ); + + QUnit.test( + "form: dirty form reloads from backend without confirmation dialog", + async function (assert) { + const form = await makeView({ + type: "form", + resModel: "res.partner", + serverData, + resId: 1, + arch: '
', + }); + + await form.model.root.switchMode("edit"); + await editInput( + target, + ".o_field_widget[name='name'] input", + "Changed Name" + ); + + triggerRefresh(form, { + model: "res.partner", + view_types: ["form"], + rec_ids: [1], + }); + await nextTick(); + await nextTick(); + + assert.containsNone( + target, + ".modal", + "backend refresh must not open a confirmation dialog" + ); + } + ); + + QUnit.test("list: burst notifications are coalesced", async function (assert) { + const list = await makeView({ + type: "list", + resModel: "res.partner", + serverData, + arch: '', + }); + + const deferred = makeDeferred(); + let refreshCalls = 0; + list.refreshList = async () => { + refreshCalls++; + if (refreshCalls === 1) { + await deferred; + } + }; + + const payload = {model: "res.partner", view_types: ["list"], rec_ids: [1]}; + triggerRefresh(list, payload); + triggerRefresh(list, payload); + triggerRefresh(list, payload); + await nextTick(); + + assert.strictEqual( + refreshCalls, + 1, + "only one refresh should run while in flight" + ); + + deferred.resolve(); + await nextTick(); + await nextTick(); + + assert.strictEqual( + refreshCalls, + 2, + "one additional refresh should run after in-flight refresh finishes" + ); + }); + + QUnit.test("kanban: burst notifications are coalesced", async function (assert) { + const kanban = await makeView({ + type: "kanban", + resModel: "res.partner", + serverData, + arch: '
', + }); + + const deferred = makeDeferred(); + let refreshCalls = 0; + kanban.refreshList = async () => { + refreshCalls++; + if (refreshCalls === 1) { + await deferred; + } + }; + + const payload = {model: "res.partner", view_types: ["kanban"], rec_ids: [1]}; + triggerRefresh(kanban, payload); + triggerRefresh(kanban, payload); + triggerRefresh(kanban, payload); + await nextTick(); + + assert.strictEqual( + refreshCalls, + 1, + "only one refresh should run while in flight" + ); + + deferred.resolve(); + await nextTick(); + await nextTick(); + + assert.strictEqual( + refreshCalls, + 2, + "one additional refresh should run after in-flight refresh finishes" + ); + }); +}); diff --git a/addons/cx_web_refresh_from_backend/tests/__init__.py b/addons/cx_web_refresh_from_backend/tests/__init__.py new file mode 100644 index 0000000..fbc9d5f --- /dev/null +++ b/addons/cx_web_refresh_from_backend/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Cetmix OÜ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import test_reload_views diff --git a/addons/cx_web_refresh_from_backend/tests/test_reload_views.py b/addons/cx_web_refresh_from_backend/tests/test_reload_views.py new file mode 100644 index 0000000..70d8f2c --- /dev/null +++ b/addons/cx_web_refresh_from_backend/tests/test_reload_views.py @@ -0,0 +1,78 @@ +# Copyright 2025 Cetmix OÜ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from unittest.mock import patch + +from odoo.tests import tagged + +from odoo.addons.base.tests.common import BaseCommon + + +@tagged("post_install", "-at_install") +class TestReloadViews(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user_admin = cls.env.ref("base.user_admin") + cls.user_demo = cls.env["res.users"].create( + { + "name": "Test User", + "login": "test_refresh_user", + "email": "test_refresh@example.com", + } + ) + + def test_reload_views_basic(self): + """Test basic reload_views call without parameters""" + with patch.object(type(self.env["bus.bus"]), "_sendone") as mock_sendone: + self.user_admin.reload_views(model="res.partner") + + mock_sendone.assert_called_once() + partner, channel, message = mock_sendone.call_args[0] + self.assertEqual(partner, self.user_admin.partner_id) + self.assertEqual(channel, "web.refresh_view") + self.assertEqual(message["model"], "res.partner") + self.assertEqual(message["view_types"], []) + self.assertEqual(message["rec_ids"], []) + + def test_reload_views_with_params(self): + """Test reload_views with view_types and rec_ids parameters""" + with patch.object(type(self.env["bus.bus"]), "_sendone") as mock_sendone: + self.user_admin.reload_views( + model="res.partner", + view_types=["form", "kanban"], + rec_ids=[self.partner.id], + ) + + mock_sendone.assert_called_once() + message = mock_sendone.call_args[0][2] + self.assertEqual(message["view_types"], ["form", "kanban"]) + self.assertEqual(message["rec_ids"], [self.partner.id]) + + def test_reload_views_recordset(self): + """Test reload_views on a multi-record user recordset. + + Ensures that calling reload_views on a recordset sends one notification + per user through _sendone. + """ + users = self.user_admin | self.user_demo + + with patch.object(type(self.env["bus.bus"]), "_sendone") as mock_sendone: + users.reload_views(model="res.partner") + + self.assertEqual(mock_sendone.call_count, 2) + + # Verify both users' partners are notified and payload is correct. + notified_partners = set() + for call in mock_sendone.call_args_list: + partner, channel, message = call[0] + notified_partners.add(partner) + self.assertEqual(channel, "web.refresh_view") + self.assertEqual(message["model"], "res.partner") + self.assertEqual(message["view_types"], []) + self.assertEqual(message["rec_ids"], []) + self.assertEqual(len(notified_partners), 2) + self.assertEqual( + notified_partners, + {self.user_admin.partner_id, self.user_demo.partner_id}, + )