Tower: upload cx_web_refresh_from_backend 18.0.1.0.0 (was 18.0.1.0.0, via marketplace)
This commit is contained in:
123
addons/cx_web_refresh_from_backend/README.rst
Normal file
123
addons/cx_web_refresh_from_backend/README.rst
Normal file
@@ -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 <https://github.com/cetmix/cetmix-tower/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 <https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cx_web_refresh_from_backend%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
|
||||||
|
|
||||||
|
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 <https://github.com/cetmix/cetmix-tower/tree/18.0/cx_web_refresh_from_backend>`_ project on GitHub.
|
||||||
|
|
||||||
|
You are welcome to contribute.
|
||||||
4
addons/cx_web_refresh_from_backend/__init__.py
Normal file
4
addons/cx_web_refresh_from_backend/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Copyright 2025 Cetmix OÜ
|
||||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||||
|
|
||||||
|
from . import models
|
||||||
30
addons/cx_web_refresh_from_backend/__manifest__.py
Normal file
30
addons/cx_web_refresh_from_backend/__manifest__.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
4
addons/cx_web_refresh_from_backend/models/__init__.py
Normal file
4
addons/cx_web_refresh_from_backend/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Copyright 2025 Cetmix OÜ
|
||||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||||
|
|
||||||
|
from . import res_users
|
||||||
50
addons/cx_web_refresh_from_backend/models/res_users.py
Normal file
50
addons/cx_web_refresh_from_backend/models/res_users.py
Normal file
@@ -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)
|
||||||
3
addons/cx_web_refresh_from_backend/pyproject.toml
Normal file
3
addons/cx_web_refresh_from_backend/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["whool"]
|
||||||
|
build-backend = "whool.buildapi"
|
||||||
28
addons/cx_web_refresh_from_backend/readme/DESCRIPTION.md
Normal file
28
addons/cx_web_refresh_from_backend/readme/DESCRIPTION.md
Normal file
@@ -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).
|
||||||
16
addons/cx_web_refresh_from_backend/readme/USAGE.md
Normal file
16
addons/cx_web_refresh_from_backend/readme/USAGE.md
Normal file
@@ -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],
|
||||||
|
)
|
||||||
|
```
|
||||||
BIN
addons/cx_web_refresh_from_backend/static/description/banner.png
Normal file
BIN
addons/cx_web_refresh_from_backend/static/description/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
BIN
addons/cx_web_refresh_from_backend/static/description/icon.png
Normal file
BIN
addons/cx_web_refresh_from_backend/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
479
addons/cx_web_refresh_from_backend/static/description/index.html
Normal file
479
addons/cx_web_refresh_from_backend/static/description/index.html
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
|
||||||
|
<title>Web Refresh From Backend</title>
|
||||||
|
<style type="text/css">
|
||||||
|
|
||||||
|
/*
|
||||||
|
:Author: David Goodger (goodger@python.org)
|
||||||
|
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
|
||||||
|
:Copyright: This stylesheet has been placed in the public domain.
|
||||||
|
|
||||||
|
Default cascading style sheet for the HTML output of Docutils.
|
||||||
|
Despite the name, some widely supported CSS2 features are used.
|
||||||
|
|
||||||
|
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
|
||||||
|
customize this style sheet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* used to remove borders from tables and images */
|
||||||
|
.borderless, table.borderless td, table.borderless th {
|
||||||
|
border: 0 }
|
||||||
|
|
||||||
|
table.borderless td, table.borderless th {
|
||||||
|
/* Override padding for "table.docutils td" with "! important".
|
||||||
|
The right padding separates the table cells. */
|
||||||
|
padding: 0 0.5em 0 0 ! important }
|
||||||
|
|
||||||
|
.first {
|
||||||
|
/* Override more specific margin styles with "! important". */
|
||||||
|
margin-top: 0 ! important }
|
||||||
|
|
||||||
|
.last, .with-subtitle {
|
||||||
|
margin-bottom: 0 ! important }
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none }
|
||||||
|
|
||||||
|
.subscript {
|
||||||
|
vertical-align: sub;
|
||||||
|
font-size: smaller }
|
||||||
|
|
||||||
|
.superscript {
|
||||||
|
vertical-align: super;
|
||||||
|
font-size: smaller }
|
||||||
|
|
||||||
|
a.toc-backref {
|
||||||
|
text-decoration: none ;
|
||||||
|
color: black }
|
||||||
|
|
||||||
|
blockquote.epigraph {
|
||||||
|
margin: 2em 5em ; }
|
||||||
|
|
||||||
|
dl.docutils dd {
|
||||||
|
margin-bottom: 0.5em }
|
||||||
|
|
||||||
|
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uncomment (and remove this text!) to get bold-faced definition list terms
|
||||||
|
dl.docutils dt {
|
||||||
|
font-weight: bold }
|
||||||
|
*/
|
||||||
|
|
||||||
|
div.abstract {
|
||||||
|
margin: 2em 5em }
|
||||||
|
|
||||||
|
div.abstract p.topic-title {
|
||||||
|
font-weight: bold ;
|
||||||
|
text-align: center }
|
||||||
|
|
||||||
|
div.admonition, div.attention, div.caution, div.danger, div.error,
|
||||||
|
div.hint, div.important, div.note, div.tip, div.warning {
|
||||||
|
margin: 2em ;
|
||||||
|
border: medium outset ;
|
||||||
|
padding: 1em }
|
||||||
|
|
||||||
|
div.admonition p.admonition-title, div.hint p.admonition-title,
|
||||||
|
div.important p.admonition-title, div.note p.admonition-title,
|
||||||
|
div.tip p.admonition-title {
|
||||||
|
font-weight: bold ;
|
||||||
|
font-family: sans-serif }
|
||||||
|
|
||||||
|
div.attention p.admonition-title, div.caution p.admonition-title,
|
||||||
|
div.danger p.admonition-title, div.error p.admonition-title,
|
||||||
|
div.warning p.admonition-title, .code .error {
|
||||||
|
color: red ;
|
||||||
|
font-weight: bold ;
|
||||||
|
font-family: sans-serif }
|
||||||
|
|
||||||
|
/* Uncomment (and remove this text!) to get reduced vertical space in
|
||||||
|
compound paragraphs.
|
||||||
|
div.compound .compound-first, div.compound .compound-middle {
|
||||||
|
margin-bottom: 0.5em }
|
||||||
|
|
||||||
|
div.compound .compound-last, div.compound .compound-middle {
|
||||||
|
margin-top: 0.5em }
|
||||||
|
*/
|
||||||
|
|
||||||
|
div.dedication {
|
||||||
|
margin: 2em 5em ;
|
||||||
|
text-align: center ;
|
||||||
|
font-style: italic }
|
||||||
|
|
||||||
|
div.dedication p.topic-title {
|
||||||
|
font-weight: bold ;
|
||||||
|
font-style: normal }
|
||||||
|
|
||||||
|
div.figure {
|
||||||
|
margin-left: 2em ;
|
||||||
|
margin-right: 2em }
|
||||||
|
|
||||||
|
div.footer, div.header {
|
||||||
|
clear: both;
|
||||||
|
font-size: smaller }
|
||||||
|
|
||||||
|
div.line-block {
|
||||||
|
display: block ;
|
||||||
|
margin-top: 1em ;
|
||||||
|
margin-bottom: 1em }
|
||||||
|
|
||||||
|
div.line-block div.line-block {
|
||||||
|
margin-top: 0 ;
|
||||||
|
margin-bottom: 0 ;
|
||||||
|
margin-left: 1.5em }
|
||||||
|
|
||||||
|
div.sidebar {
|
||||||
|
margin: 0 0 0.5em 1em ;
|
||||||
|
border: medium outset ;
|
||||||
|
padding: 1em ;
|
||||||
|
background-color: #ffffee ;
|
||||||
|
width: 40% ;
|
||||||
|
float: right ;
|
||||||
|
clear: right }
|
||||||
|
|
||||||
|
div.sidebar p.rubric {
|
||||||
|
font-family: sans-serif ;
|
||||||
|
font-size: medium }
|
||||||
|
|
||||||
|
div.system-messages {
|
||||||
|
margin: 5em }
|
||||||
|
|
||||||
|
div.system-messages h1 {
|
||||||
|
color: red }
|
||||||
|
|
||||||
|
div.system-message {
|
||||||
|
border: medium outset ;
|
||||||
|
padding: 1em }
|
||||||
|
|
||||||
|
div.system-message p.system-message-title {
|
||||||
|
color: red ;
|
||||||
|
font-weight: bold }
|
||||||
|
|
||||||
|
div.topic {
|
||||||
|
margin: 2em }
|
||||||
|
|
||||||
|
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
|
||||||
|
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
|
||||||
|
margin-top: 0.4em }
|
||||||
|
|
||||||
|
h1.title {
|
||||||
|
text-align: center }
|
||||||
|
|
||||||
|
h2.subtitle {
|
||||||
|
text-align: center }
|
||||||
|
|
||||||
|
hr.docutils {
|
||||||
|
width: 75% }
|
||||||
|
|
||||||
|
img.align-left, .figure.align-left, object.align-left, table.align-left {
|
||||||
|
clear: left ;
|
||||||
|
float: left ;
|
||||||
|
margin-right: 1em }
|
||||||
|
|
||||||
|
img.align-right, .figure.align-right, object.align-right, table.align-right {
|
||||||
|
clear: right ;
|
||||||
|
float: right ;
|
||||||
|
margin-left: 1em }
|
||||||
|
|
||||||
|
img.align-center, .figure.align-center, object.align-center {
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.align-center {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-left {
|
||||||
|
text-align: left }
|
||||||
|
|
||||||
|
.align-center {
|
||||||
|
clear: both ;
|
||||||
|
text-align: center }
|
||||||
|
|
||||||
|
.align-right {
|
||||||
|
text-align: right }
|
||||||
|
|
||||||
|
/* reset inner alignment in figures */
|
||||||
|
div.align-right {
|
||||||
|
text-align: inherit }
|
||||||
|
|
||||||
|
/* div.align-center * { */
|
||||||
|
/* text-align: left } */
|
||||||
|
|
||||||
|
.align-top {
|
||||||
|
vertical-align: top }
|
||||||
|
|
||||||
|
.align-middle {
|
||||||
|
vertical-align: middle }
|
||||||
|
|
||||||
|
.align-bottom {
|
||||||
|
vertical-align: bottom }
|
||||||
|
|
||||||
|
ol.simple, ul.simple {
|
||||||
|
margin-bottom: 1em }
|
||||||
|
|
||||||
|
ol.arabic {
|
||||||
|
list-style: decimal }
|
||||||
|
|
||||||
|
ol.loweralpha {
|
||||||
|
list-style: lower-alpha }
|
||||||
|
|
||||||
|
ol.upperalpha {
|
||||||
|
list-style: upper-alpha }
|
||||||
|
|
||||||
|
ol.lowerroman {
|
||||||
|
list-style: lower-roman }
|
||||||
|
|
||||||
|
ol.upperroman {
|
||||||
|
list-style: upper-roman }
|
||||||
|
|
||||||
|
p.attribution {
|
||||||
|
text-align: right ;
|
||||||
|
margin-left: 50% }
|
||||||
|
|
||||||
|
p.caption {
|
||||||
|
font-style: italic }
|
||||||
|
|
||||||
|
p.credits {
|
||||||
|
font-style: italic ;
|
||||||
|
font-size: smaller }
|
||||||
|
|
||||||
|
p.label {
|
||||||
|
white-space: nowrap }
|
||||||
|
|
||||||
|
p.rubric {
|
||||||
|
font-weight: bold ;
|
||||||
|
font-size: larger ;
|
||||||
|
color: maroon ;
|
||||||
|
text-align: center }
|
||||||
|
|
||||||
|
p.sidebar-title {
|
||||||
|
font-family: sans-serif ;
|
||||||
|
font-weight: bold ;
|
||||||
|
font-size: larger }
|
||||||
|
|
||||||
|
p.sidebar-subtitle {
|
||||||
|
font-family: sans-serif ;
|
||||||
|
font-weight: bold }
|
||||||
|
|
||||||
|
p.topic-title {
|
||||||
|
font-weight: bold }
|
||||||
|
|
||||||
|
pre.address {
|
||||||
|
margin-bottom: 0 ;
|
||||||
|
margin-top: 0 ;
|
||||||
|
font: inherit }
|
||||||
|
|
||||||
|
pre.literal-block, pre.doctest-block, pre.math, pre.code {
|
||||||
|
margin-left: 2em ;
|
||||||
|
margin-right: 2em }
|
||||||
|
|
||||||
|
pre.code .ln { color: gray; } /* line numbers */
|
||||||
|
pre.code, code { background-color: #eeeeee }
|
||||||
|
pre.code .comment, code .comment { color: #5C6576 }
|
||||||
|
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
|
||||||
|
pre.code .literal.string, code .literal.string { color: #0C5404 }
|
||||||
|
pre.code .name.builtin, code .name.builtin { color: #352B84 }
|
||||||
|
pre.code .deleted, code .deleted { background-color: #DEB0A1}
|
||||||
|
pre.code .inserted, code .inserted { background-color: #A3D289}
|
||||||
|
|
||||||
|
span.classifier {
|
||||||
|
font-family: sans-serif ;
|
||||||
|
font-style: oblique }
|
||||||
|
|
||||||
|
span.classifier-delimiter {
|
||||||
|
font-family: sans-serif ;
|
||||||
|
font-weight: bold }
|
||||||
|
|
||||||
|
span.interpreted {
|
||||||
|
font-family: sans-serif }
|
||||||
|
|
||||||
|
span.option {
|
||||||
|
white-space: nowrap }
|
||||||
|
|
||||||
|
span.pre {
|
||||||
|
white-space: pre }
|
||||||
|
|
||||||
|
span.problematic, pre.problematic {
|
||||||
|
color: red }
|
||||||
|
|
||||||
|
span.section-subtitle {
|
||||||
|
/* font-size relative to parent (h1..h6 element) */
|
||||||
|
font-size: 80% }
|
||||||
|
|
||||||
|
table.citation {
|
||||||
|
border-left: solid 1px gray;
|
||||||
|
margin-left: 1px }
|
||||||
|
|
||||||
|
table.docinfo {
|
||||||
|
margin: 2em 4em }
|
||||||
|
|
||||||
|
table.docutils {
|
||||||
|
margin-top: 0.5em ;
|
||||||
|
margin-bottom: 0.5em }
|
||||||
|
|
||||||
|
table.footnote {
|
||||||
|
border-left: solid 1px black;
|
||||||
|
margin-left: 1px }
|
||||||
|
|
||||||
|
table.docutils td, table.docutils th,
|
||||||
|
table.docinfo td, table.docinfo th {
|
||||||
|
padding-left: 0.5em ;
|
||||||
|
padding-right: 0.5em ;
|
||||||
|
vertical-align: top }
|
||||||
|
|
||||||
|
table.docutils th.field-name, table.docinfo th.docinfo-name {
|
||||||
|
font-weight: bold ;
|
||||||
|
text-align: left ;
|
||||||
|
white-space: nowrap ;
|
||||||
|
padding-left: 0 }
|
||||||
|
|
||||||
|
/* "booktabs" style (no vertical lines) */
|
||||||
|
table.docutils.booktabs {
|
||||||
|
border: 0px;
|
||||||
|
border-top: 2px solid;
|
||||||
|
border-bottom: 2px solid;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
table.docutils.booktabs * {
|
||||||
|
border: 0px;
|
||||||
|
}
|
||||||
|
table.docutils.booktabs th {
|
||||||
|
border-bottom: thin solid;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
|
||||||
|
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
|
||||||
|
font-size: 100% }
|
||||||
|
|
||||||
|
ul.auto-toc {
|
||||||
|
list-style-type: none }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document" id="web-refresh-from-backend">
|
||||||
|
<h1 class="title">Web Refresh From Backend</h1>
|
||||||
|
|
||||||
|
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
!! This file is generated by oca-gen-addon-readme !!
|
||||||
|
!! changes will be overwritten. !!
|
||||||
|
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
!! source digest: sha256:199e0da56a7d94568d062706d1f34ac6b38310034c25f5840e2631722e9d9f65
|
||||||
|
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||||
|
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/lgpl-3.0-standalone.html"><img alt="License: LGPL-3" src="https://img.shields.io/badge/license-LGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/cetmix/cetmix-tower/tree/18.0/cx_web_refresh_from_backend"><img alt="cetmix/cetmix-tower" src="https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github" /></a></p>
|
||||||
|
<div class="section" id="refresh-ui-views-from-backend">
|
||||||
|
<h1>Refresh UI views from backend</h1>
|
||||||
|
<p>This is a <strong>technical module</strong> that allows triggering a <strong>UI reload</strong>
|
||||||
|
from the backend. It enables triggering the reload action for selected
|
||||||
|
users and record IDs.</p>
|
||||||
|
<hr class="docutils" />
|
||||||
|
<div class="section" id="helper-function-reload-views">
|
||||||
|
<h2>🔧 Helper Function: <tt class="docutils literal">reload_views</tt></h2>
|
||||||
|
<p>A special helper function <tt class="docutils literal">reload_views</tt> is added to the <tt class="docutils literal">res.users</tt>
|
||||||
|
model.</p>
|
||||||
|
<div class="section" id="arguments">
|
||||||
|
<h3><strong>Arguments</strong></h3>
|
||||||
|
<table border="1" class="docutils">
|
||||||
|
<colgroup>
|
||||||
|
<col width="24%" />
|
||||||
|
<col width="38%" />
|
||||||
|
<col width="38%" />
|
||||||
|
</colgroup>
|
||||||
|
<thead valign="bottom">
|
||||||
|
<tr><th class="head">Argument</th>
|
||||||
|
<th class="head">Type</th>
|
||||||
|
<th class="head">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody valign="top">
|
||||||
|
<tr><td><strong>model</strong></td>
|
||||||
|
<td><tt class="docutils literal">Char</tt></td>
|
||||||
|
<td>Model name, e.g.
|
||||||
|
<tt class="docutils literal">'res.partner'</tt></td>
|
||||||
|
</tr>
|
||||||
|
<tr><td><strong>view_types</strong></td>
|
||||||
|
<td><tt class="docutils literal">List of Char</tt>
|
||||||
|
<em>(optional)</em></td>
|
||||||
|
<td>View types to reload,
|
||||||
|
e.g.
|
||||||
|
<tt class="docutils literal">["form", "kanban"]</tt>.
|
||||||
|
Leave blank to reload
|
||||||
|
all views.</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td><strong>rec_ids</strong></td>
|
||||||
|
<td><tt class="docutils literal">List of Integer</tt>
|
||||||
|
<em>(optional)</em></td>
|
||||||
|
<td>The view will be
|
||||||
|
reloaded only if a
|
||||||
|
record with an ID from
|
||||||
|
this list is present in
|
||||||
|
the view.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="docutils" />
|
||||||
|
<div class="section" id="important-notes">
|
||||||
|
<h2>⚠️ Important Notes</h2>
|
||||||
|
<p>Use this function <strong>wisely</strong>.</p>
|
||||||
|
<p>When reloading <strong>form views</strong>, be aware that if a user is currently
|
||||||
|
editing a record, <strong>their unsaved updates may be lost</strong> when the form
|
||||||
|
reloads from the server (no confirmation dialog is shown).</p>
|
||||||
|
<p><strong>Table of contents</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section" id="usage">
|
||||||
|
<h1>Usage</h1>
|
||||||
|
<div class="section" id="example-usage">
|
||||||
|
<h2>🧩 Example Usage</h2>
|
||||||
|
<p>Below is a code snippet showing how to use the <tt class="docutils literal">reload_views</tt> helper
|
||||||
|
function.</p>
|
||||||
|
<pre class="code python literal-block">
|
||||||
|
<span class="c1"># Reload the kanban and form views for all salespeople when an opportunity is won</span><span class="w">
|
||||||
|
</span><span class="c1"># Will reload views only if the current opportunity is being displayed</span><span class="w">
|
||||||
|
|
||||||
|
</span><span class="n">group_id</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">env</span><span class="o">.</span><span class="n">ref</span><span class="p">(</span><span class="s2">"sales_team.group_sale_salesman"</span><span class="p">)</span><span class="o">.</span><span class="n">id</span><span class="w">
|
||||||
|
</span><span class="n">users_to_reload</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">env</span><span class="p">[</span><span class="s2">"res.users"</span><span class="p">]</span><span class="o">.</span><span class="n">search</span><span class="p">([(</span><span class="s2">"groups_id"</span><span class="p">,</span> <span class="s2">"in"</span><span class="p">,</span> <span class="p">[</span><span class="n">group_id</span><span class="p">])])</span><span class="w">
|
||||||
|
</span><span class="n">users_to_reload</span><span class="o">.</span><span class="n">reload_views</span><span class="p">(</span><span class="w">
|
||||||
|
</span> <span class="n">model</span><span class="o">=</span><span class="s2">"crm.lead"</span><span class="p">,</span><span class="w">
|
||||||
|
</span> <span class="n">view_types</span><span class="o">=</span><span class="p">[</span><span class="s2">"kanban"</span><span class="p">,</span> <span class="s2">"form"</span><span class="p">],</span><span class="w">
|
||||||
|
</span> <span class="n">rec_ids</span><span class="o">=</span><span class="p">[</span><span class="bp">self</span><span class="o">.</span><span class="n">ids</span><span class="p">],</span><span class="w">
|
||||||
|
</span><span class="p">)</span>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section" id="bug-tracker">
|
||||||
|
<h1>Bug Tracker</h1>
|
||||||
|
<p>Bugs are tracked on <a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues">GitHub Issues</a>.
|
||||||
|
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
|
||||||
|
<a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cx_web_refresh_from_backend%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
|
||||||
|
<p>Do not contact contributors directly about support or help with technical issues.</p>
|
||||||
|
</div>
|
||||||
|
<div class="section" id="credits">
|
||||||
|
<h1>Credits</h1>
|
||||||
|
<div class="section" id="authors">
|
||||||
|
<h2>Authors</h2>
|
||||||
|
<ul class="simple">
|
||||||
|
<li>Cetmix</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="section" id="maintainers">
|
||||||
|
<h2>Maintainers</h2>
|
||||||
|
<p>This module is part of the <a class="reference external" href="https://github.com/cetmix/cetmix-tower/tree/18.0/cx_web_refresh_from_backend">cetmix/cetmix-tower</a> project on GitHub.</p>
|
||||||
|
<p>You are welcome to contribute.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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<void>}
|
||||||
|
*/
|
||||||
|
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<Boolean|undefined>} 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<void>}
|
||||||
|
*/
|
||||||
|
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});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<void>}
|
||||||
|
*/
|
||||||
|
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});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<Number>}
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<void>}
|
||||||
|
*/
|
||||||
|
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: '<form><field name="name"/></form>',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<form><field name="name"/></form>',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<form><field name="name"/></form>',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<list><field name="name"/></list>',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<kanban><templates><t t-name="card"><div><field name="name"/></div></t></templates></kanban>',
|
||||||
|
});
|
||||||
|
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
addons/cx_web_refresh_from_backend/tests/__init__.py
Normal file
4
addons/cx_web_refresh_from_backend/tests/__init__.py
Normal file
@@ -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
|
||||||
@@ -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},
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user