diff --git a/setup/web_concurrent_edit_global_warning/odoo/addons/web_concurrent_edit_global_warning b/setup/web_concurrent_edit_global_warning/odoo/addons/web_concurrent_edit_global_warning new file mode 120000 index 000000000000..bf5f4c0afd86 --- /dev/null +++ b/setup/web_concurrent_edit_global_warning/odoo/addons/web_concurrent_edit_global_warning @@ -0,0 +1 @@ +../../../../web_concurrent_edit_global_warning \ No newline at end of file diff --git a/setup/web_concurrent_edit_global_warning/setup.py b/setup/web_concurrent_edit_global_warning/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/web_concurrent_edit_global_warning/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/web_concurrent_edit_global_warning/README.rst b/web_concurrent_edit_global_warning/README.rst new file mode 100644 index 000000000000..50d1ffc421a2 --- /dev/null +++ b/web_concurrent_edit_global_warning/README.rst @@ -0,0 +1,94 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================================== +Web Concurrent Edit Global Warning +================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:e5818c694893161b41a33abc3d006a1ac67a815baea90fe0d5e7998966a482eb + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/16.0/web_concurrent_edit_global_warning + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-16-0/web-16-0-web_concurrent_edit_global_warning + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides a mechanism to warn users about concurrent edits +from multiple users on the same record. + +When a user starts editing a record, the module tracks changes made to +that record. If the same record is being edited by another user, the +module detects this and shows a warning icon in the form view's status +indicator, and a popover with details about the concurrent edits. + +**Table of contents** + +.. contents:: + :local: + +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 +------- + +* Akretion + +Contributors +------------ + +- Florian Mounier florian.mounier@akretion.com + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-paradoxxxzero| image:: https://github.com/paradoxxxzero.png?size=40px + :target: https://github.com/paradoxxxzero + :alt: paradoxxxzero + +Current `maintainer `__: + +|maintainer-paradoxxxzero| + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_concurrent_edit_global_warning/__init__.py b/web_concurrent_edit_global_warning/__init__.py new file mode 100644 index 000000000000..e046e49fbe22 --- /dev/null +++ b/web_concurrent_edit_global_warning/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/web_concurrent_edit_global_warning/__manifest__.py b/web_concurrent_edit_global_warning/__manifest__.py new file mode 100644 index 000000000000..86c5255aa4da --- /dev/null +++ b/web_concurrent_edit_global_warning/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Web Concurrent Edit Global Warning", + "summary": """ + Adds a warning when a record is edited by multiple users at the same time. + """, + "version": "16.0.1.0.0", + "author": "Akretion, Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "https://github.com/OCA/web", + "depends": ["web"], + "data": [], + "assets": { + "web.assets_backend": [ + "web_concurrent_edit_global_warning/static/src/**/*", + ], + }, + "maintainers": ["paradoxxxzero"], + "installable": True, +} diff --git a/web_concurrent_edit_global_warning/controllers/__init__.py b/web_concurrent_edit_global_warning/controllers/__init__.py new file mode 100644 index 000000000000..dbe81ca0eff0 --- /dev/null +++ b/web_concurrent_edit_global_warning/controllers/__init__.py @@ -0,0 +1 @@ +from . import broadcast_channel diff --git a/web_concurrent_edit_global_warning/controllers/broadcast_channel.py b/web_concurrent_edit_global_warning/controllers/broadcast_channel.py new file mode 100644 index 000000000000..8e8e47beb1f4 --- /dev/null +++ b/web_concurrent_edit_global_warning/controllers/broadcast_channel.py @@ -0,0 +1,51 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import threading +from weakref import WeakSet + +from odoo import http + +from odoo.addons.bus.websocket import WebsocketConnectionHandler + + +class BroadcastChannelWebsocketConnectionHandler(WebsocketConnectionHandler): + _broadcast_websocket = WeakSet() + + @classmethod + def _serve_forever(cls, websocket, db, httprequest): + current_thread = threading.current_thread() + current_thread.type = "websocket" + cls._broadcast_websocket.add(websocket) + + for message in websocket.get_messages(): + for ws in set(cls._broadcast_websocket): + if ws != websocket: + try: + ws._send(message) + except Exception as e: + logging.warning(f"Error sending message: {e}") + try: + ws.close() + except Exception: + logging.warning(f"Error closing websocket: {e}") + cls._broadcast_websocket.remove(ws) + + cls._broadcast_websocket.remove(websocket) + + +class BroadcastChannel(http.Controller): + """Broadcast Channel for concurrent edit warning""" + + @http.route( + "/websocket/broadcast_channel", + type="http", + auth="public", + csrf=False, + websocket=True, + ) + def broadcast_channel(self, **kwargs): + """Websocket route to handle broadcast channel connections.""" + return BroadcastChannelWebsocketConnectionHandler.open_connection(http.request) diff --git a/web_concurrent_edit_global_warning/i18n/fr.po b/web_concurrent_edit_global_warning/i18n/fr.po new file mode 100644 index 000000000000..fabd26d7d859 --- /dev/null +++ b/web_concurrent_edit_global_warning/i18n/fr.po @@ -0,0 +1,129 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_concurrent_edit_global_warning +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Florian Mounier \n" +"Language-Team: \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Close" +msgstr "Fermer" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Concurrent Edit Detected" +msgstr "Modification concurrente détectée" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Concurrent Edit Detected!" +msgstr "Modification concurrente détectée !" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Concurrent Save Detected" +msgstr "Enregistrement concurrent détecté" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Concurrent Save Detected!" +msgstr "Enregistrement concurrent détecté !" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Discard Local Changes" +msgstr "Abandonner les modifications locales" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "" +"It is recommended to reload the record to see the latest changes before " +"making changes." +msgstr "" +"Il est recommandé de recharger l'enregistrement pour voir les dernières " +"modifications avant d'apporter de nouvelles modifications." + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "" +"It is recommended to wait for the other user to finish editing before making" +" changes." +msgstr "" +"Il est recommandé d'attendre que l'autre utilisateur ait terminé de modifier " +"l'enregistrement avant d'apporter de nouvelles modifications." + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Refresh" +msgstr "Actualiser" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Reload record" +msgstr "Recharger l'enregistrement" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "This record has been modified by another user" +msgstr "Cet enregistrement a été modifié par un autre utilisateur" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "This record is already being edited by" +msgstr "Cet enregistrement est déjà en cours de modification par" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "This record is being edited by another user" +msgstr "Cet enregistrement est en cours de modification par un autre utilisateur" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "This record seems to have changed." +msgstr "Cet enregistrement semble avoir été modifié." + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "other user" +msgstr "autre utilisateur" diff --git a/web_concurrent_edit_global_warning/i18n/web_concurrent_edit_global_warning.pot b/web_concurrent_edit_global_warning/i18n/web_concurrent_edit_global_warning.pot new file mode 100644 index 000000000000..b4da410ae48f --- /dev/null +++ b/web_concurrent_edit_global_warning/i18n/web_concurrent_edit_global_warning.pot @@ -0,0 +1,124 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_concurrent_edit_global_warning +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.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: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Close" +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Concurrent Edit Detected" +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Concurrent Edit Detected!" +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Concurrent Save Detected" +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Concurrent Save Detected!" +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Discard Local Changes" +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "" +"It is recommended to reload the record to see the latest changes before " +"making changes." +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "" +"It is recommended to wait for the other user to finish editing before making" +" changes." +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Refresh" +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "Reload record" +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "This record has been modified by another user" +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "This record is already being edited by" +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "This record is being edited by another user" +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "This record seems to have changed." +msgstr "" + +#. module: web_concurrent_edit_global_warning +#. odoo-javascript +#: code:addons/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml:0 +#, python-format +msgid "other user" +msgstr "" diff --git a/web_concurrent_edit_global_warning/readme/CONTRIBUTORS.md b/web_concurrent_edit_global_warning/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..328a37da87c9 --- /dev/null +++ b/web_concurrent_edit_global_warning/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Florian Mounier diff --git a/web_concurrent_edit_global_warning/readme/DESCRIPTION.md b/web_concurrent_edit_global_warning/readme/DESCRIPTION.md new file mode 100644 index 000000000000..611fabfff636 --- /dev/null +++ b/web_concurrent_edit_global_warning/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +This module provides a mechanism to warn users about concurrent edits from +multiple users on the same record. + +When a user starts editing a record, the module tracks changes made to +that record. If the same record is being edited by another user, +the module detects this and shows a warning icon in the form view's status indicator, +and a popover with details about the concurrent edits. diff --git a/web_concurrent_edit_global_warning/static/description/index.html b/web_concurrent_edit_global_warning/static/description/index.html new file mode 100644 index 000000000000..57a3926aaf96 --- /dev/null +++ b/web_concurrent_edit_global_warning/static/description/index.html @@ -0,0 +1,436 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Web Concurrent Edit Global Warning

+ +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

This module provides a mechanism to warn users about concurrent edits +from multiple users on the same record.

+

When a user starts editing a record, the module tracks changes made to +that record. If the same record is being edited by another user, the +module detects this and shows a warning icon in the form view’s status +indicator, and a popover with details about the concurrent edits.

+

Table of contents

+ +
+

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

+
    +
  • Akretion
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

paradoxxxzero

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/web_concurrent_edit_global_warning/static/src/core/utils/hooks.esm.js b/web_concurrent_edit_global_warning/static/src/core/utils/hooks.esm.js new file mode 100644 index 000000000000..b3f09d764923 --- /dev/null +++ b/web_concurrent_edit_global_warning/static/src/core/utils/hooks.esm.js @@ -0,0 +1,153 @@ +/** @odoo-module **/ +// Copyright 2026 Akretion (http://www.akretion.com). +// @author Florian Mounier +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import {useComponent, useEffect} from "@odoo/owl"; +import legacySession from "web.session"; + +export function useWebsocketBroadcastChannel(onmessage, onconnect, options = {}) { + const component = useComponent(); + component._current_bc_ws = null; + const queue = []; + let timeoutId = null; + let retryCount = 0; + + useEffect( + () => { + const connect = () => { + component._current_bc_ws = new WebSocket( + `${legacySession.prefix.replace( + "http", + "ws" + )}/websocket/broadcast_channel` + ); + component._current_bc_ws.onopen = () => { + if (options.debug) { + console.log( + `Websocket broadcast channel ${component._current_bc_ws.url} opened` + ); + onconnect.bind(component)(); + } + retryCount = 0; + queue.forEach((message) => { + if (options.debug) { + console.log( + `Sending queued message on websocket broadcast channel:`, + message + ); + } + component._current_bc_ws.send(message); + }); + queue.length = 0; + }; + component._current_bc_ws.onclose = () => { + if (component._current_bc_ws) { + if (options.debug) { + console.log( + `Websocket broadcast lost connection, trying to reconnect in one second...` + ); + } + if (!timeoutId) { + timeoutId = setTimeout(() => { + retryCount++; + connect(); + timeoutId = null; + }, 1000); + } + } else if (options.debug) { + console.trace(); + console.log(`Websocket broadcast channel closed`); + } + }; + + component._current_bc_ws.onmessage = (evt) => { + if (options.debug) { + console.log( + `Received message on websocket broadcast channel:`, + evt.data + ); + } + onmessage.bind(component)(JSON.parse(evt.data)); + }; + component._current_bc_ws.onerror = (error) => { + if (options.debug) { + console.error(`Error on websocket broadcast channel:`, error); + } + try { + component._current_bc_ws.close(); + } catch (closeError) { + console.error( + `Error closing websocket broadcast channel:`, + closeError + ); + } + console.log( + `Reconnecting websocket broadcast channel in ${ + 5 + retryCount + }s...` + ); + if (!timeoutId) { + timeoutId = setTimeout(() => { + retryCount++; + connect(); + timeoutId = null; + }, 5000 + retryCount * 1000); + } + }; + }; + + if (!component._current_bc_ws) { + if (options.debug) { + console.log("Initializing websocket broadcast channel"); + } + connect(); + } + return () => { + if (options.debug) { + console.log(`Closing websocket broadcast channel`); + } + clearTimeout(timeoutId); + timeoutId = null; + component._current_bc_ws.close(); + component._current_bc_ws = null; + queue.length = 0; + }; + }, + () => [] + ); + const postMessage = (message) => { + if (component._current_bc_ws) { + if (options.debug) { + console.log(`Posting message on websocket broadcast channel:`, message); + } + if (component._current_bc_ws.readyState === WebSocket.OPEN) { + component._current_bc_ws.send(JSON.stringify(message)); + } else if (component._current_bc_ws.readyState === WebSocket.CONNECTING) { + if (options.debug) { + console.warn( + `Cannot post message, websocket broadcast channel is not open, queueing message` + ); + } + queue.push(JSON.stringify(message)); + } else { + if (options.debug) { + console.warn( + `Cannot post message, websocket broadcast channel is + ${ + component._current_bc_ws.readyState === WebSocket.CLOSED + ? "closed" + : "closing" + }` + ); + } + queue.push(JSON.stringify(message)); + } + } else if (options.debug) { + console.warn( + `Cannot post message, websocket broadcast channel is not initialized` + ); + } + }; + return {postMessage}; +} diff --git a/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.esm.js b/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.esm.js new file mode 100644 index 000000000000..695bdaa596ae --- /dev/null +++ b/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.esm.js @@ -0,0 +1,289 @@ +/** @odoo-module **/ +// Copyright 2026 Akretion (http://www.akretion.com). +// @author Florian Mounier +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import { + Component, + onWillUnmount, + useEffect, + useExternalListener, + useRef, + useState, +} from "@odoo/owl"; +import {FormStatusIndicator} from "@web/views/form/form_status_indicator/form_status_indicator"; +import {patch} from "@web/core/utils/patch"; +import {usePopover} from "@web/core/popover/popover_hook"; +import {useWebsocketBroadcastChannel} from "../../../core/utils/hooks.esm"; + +class ConcurrentEditWarningPopover extends Component {} +ConcurrentEditWarningPopover.template = + "web_concurrent_edit_global_warning.ConcurrentEditWarningPopover"; + +class ConcurrentSaveWarningPopover extends Component {} +ConcurrentSaveWarningPopover.template = + "web_concurrent_edit_global_warning.ConcurrentSaveWarningPopover"; + +patch( + FormStatusIndicator.prototype, + "web_concurrent_edit_global_warning.FormStatusIndicator", + { + setup() { + this.dirtyRemotes = useState([]); + this.remoteSaved = useState({value: false}); + this.recordSaved = false; + this.broadcastChannel = useWebsocketBroadcastChannel( + (message) => this._handleChannelMessage(message), + () => + this._refreshConcurrentEditRemotes( + this.props.model.root.resModel, + this.props.model.root.resId + ), + {debug: location.search.includes("debug=ws")} + ); + + this.popover = usePopover(); + this._currentConcurrentEditPopover = null; + this._currentConcurrentSavePopover = null; + this._concurrentEditButtonRef = useRef("concurrent_edit_warning_button"); + this._concurrentSaveButtonRef = useRef("concurrent_save_warning_button"); + + // Register save hook: + const save = + this.props.model.__bm__.save._orig_save || this.props.model.__bm__.save; + this.props.model.__bm__.save = async (...args) => { + const rv = await save.call(this.props.model.__bm__, ...args); + if (rv && rv.length) { + this.recordSaved = true; + } + return rv; + }; + this.props.model.__bm__.save._orig_save = save; + + // On record change, request the current dirty state + useEffect( + () => { + // Unique identifier specific to model/id + const start = !this._uid; + this._uid = new Date().getTime(); + const {resModel, resId} = this.props.model.root; + if (!start) { + this._refreshConcurrentEditRemotes(resModel, resId); + } + }, + () => [this.props.model.root.resId, this.props.model.root.resModel] + ); + + // Notify others when local dirty state changes + useEffect( + () => { + const {resModel, resId} = this.props.model.root; + this._notifyConcurrentEditChange(resModel, resId, this.dirty); + }, + () => [ + this.dirty, + this.props.model.root.resId, + this.props.model.root.resModel, + ] + ); + + // Auto open popover when concurrent edit detected + useEffect( + () => { + if (this.showConcurrentEditWarning) { + this.openConcurrentEditWarningPopover(); + } else { + this.closeConcurrentEditWarningPopover(); + } + }, + () => [this.showConcurrentEditWarning, this.dirtyRemotes.length] + ); + + // Auto open popover when remote save detected + useEffect( + () => { + if (this.saved && this.dirty) { + this.openConcurrentSaveWarningPopover(); + } else { + this.closeConcurrentSaveWarningPopover(); + } + }, + () => [this.saved, this.dirty] + ); + + const unregisterBeforeUnload = () => { + const {resModel, resId} = this.props.model.root; + this._notifyConcurrentEditChange(resModel, resId, false); + }; + + onWillUnmount(unregisterBeforeUnload.bind(this)); + useExternalListener( + window, + "beforeunload", + unregisterBeforeUnload.bind(this) + ); + }, + + _handleChannelMessage(message) { + const {resModel, resId} = this.props.model.root; + // Change remote dirty state according to remote message + if (message.type === "change") { + if (resModel === message.resModel && resId === message.resId) { + if (message.dirty && !this.dirtyRemotes.includes(message.uid)) { + this.dirtyRemotes.push(message.uid); + } else if ( + !message.dirty && + this.dirtyRemotes.includes(message.uid) + ) { + this.dirtyRemotes.splice( + this.dirtyRemotes.indexOf(message.uid), + 1 + ); + } + if (message.saved) { + this.remoteSaved.value = true; + } + } + // Sync request: reply with current dirty state + } else if (message.type === "sync") { + if (resModel === message.resModel && resId === message.resId) { + this._notifyConcurrentEditChange(resModel, resId, this.dirty); + } + } + }, + + _notifyConcurrentEditChange(resModel, resId, dirty) { + this.broadcastChannel.postMessage({ + type: "change", + resModel, + resId, + dirty, + saved: this.recordSaved, + uid: this._uid, + }); + this.recordSaved = false; + }, + _refreshConcurrentEditRemotes(resModel, resId) { + this.dirtyRemotes.splice(0, this.dirtyRemotes.length); + this.broadcastChannel.postMessage({ + type: "sync", + resModel, + resId, + uid: this._uid, + }); + }, + + openConcurrentEditWarningPopover() { + if (this._currentConcurrentEditPopover) { + this.closeConcurrentEditWarningPopover(); + } + if (!this._concurrentEditButtonRef.el) { + return; + } + this._currentConcurrentEditPopover = this.popover.add( + this._concurrentEditButtonRef.el, + ConcurrentEditWarningPopover, + { + onClose: this.closeConcurrentEditWarningPopover.bind(this), + userCount: this.dirtyRemotes.length, + refreshConcurrentEditRemotes: () => { + const {resModel, resId} = this.props.model.root; + this._refreshConcurrentEditRemotes(resModel, resId); + }, + discardChanges: () => { + this.discard(); + }, + }, + { + position: "bottom", + onClose: () => { + this._currentConcurrentEditPopover = null; + }, + closeOnClickAway: false, + } + ); + }, + + closeConcurrentEditWarningPopover() { + if (this._currentConcurrentEditPopover) { + this._currentConcurrentEditPopover(); + this._currentConcurrentEditPopover = null; + } + }, + + toggleConcurrentEditWarningPopover() { + if (this._currentConcurrentEditPopover) { + this.closeConcurrentEditWarningPopover(); + } else { + this.openConcurrentEditWarningPopover(); + } + }, + + openConcurrentSaveWarningPopover() { + if (this._currentConcurrentSavePopover) { + this.closeConcurrentSaveWarningPopover(); + } + if (!this._concurrentSaveButtonRef.el) { + return; + } + this._currentConcurrentSavePopover = this.popover.add( + this._concurrentSaveButtonRef.el, + ConcurrentSaveWarningPopover, + { + onClose: this.closeConcurrentSaveWarningPopover.bind(this), + refreshConcurrentSaveRemotes: () => { + const {resModel, resId} = this.props.model.root; + this._refreshConcurrentSaveRemotes(resModel, resId); + }, + refreshChanges: () => { + this.reloadRecord(); + }, + }, + { + position: "bottom", + onClose: () => { + this._currentConcurrentSavePopover = null; + }, + closeOnClickAway: false, + } + ); + }, + + closeConcurrentSaveWarningPopover() { + if (this._currentConcurrentSavePopover) { + this._currentConcurrentSavePopover(); + this._currentConcurrentSavePopover = null; + } + }, + + toggleConcurrentSaveWarningPopover() { + if (this._currentConcurrentSavePopover) { + this.closeConcurrentSaveWarningPopover(); + } else { + this.openConcurrentSaveWarningPopover(); + } + }, + + async reloadRecord() { + this.discard(); + await this.props.model.load({}, {keepChanges: true}); + this.remoteSaved.value = false; + }, + + get dirty() { + return this.props.model.root.isDirty || this.props.fieldIsDirty; + }, + + get saved() { + return this.remoteSaved.value; + }, + + get remoteDirty() { + return this.dirtyRemotes.length > 0; + }, + + get showConcurrentEditWarning() { + return this.remoteDirty && this.dirty; + }, + } +); diff --git a/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.scss b/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.scss new file mode 100644 index 000000000000..6500145eda0c --- /dev/null +++ b/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.scss @@ -0,0 +1,32 @@ +.o-concurrent_edit_tooltip { + header { + display: flex; + align-items: center; + justify-content: space-between; + .btn { + color: $o-white; + &:hover { + background-color: rgba($o-white, 0.2); + } + } + + .o_popover_header { + color: $o-white; + margin: 0; + background: none; + border: none; + } + } + &.warning { + box-shadow: 0 0 10px rgba($o-warning, 0.25); + header { + background-color: $o-warning; + } + } + &.danger { + box-shadow: 0 0 10px rgba($o-danger, 0.25); + header { + background-color: $o-danger; + } + } +} diff --git a/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml b/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml new file mode 100644 index 000000000000..cd9ecbb7e34e --- /dev/null +++ b/web_concurrent_edit_global_warning/static/src/views/form/form_status_indicator/form_status_indicator.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + +
+
+

+ Concurrent Edit Detected! +

+