Skip to content

Commit 600ec28

Browse files
jpp-odooaab-odoo
authored andcommitted
[IMP] web: add offline UI support
This commit introduces the first offline features in the webclient. For that matter, an "offline" service has been created. The service exposes a reactive containing the offline state such that components that want to render differently in offline can easily do so and be automatically re-rendered when switching from/to offline mode. A systray item has been added to indicate when we are offline. By default, all buttons and checkboxes are disabled in offline. The attribute `data-available-offline` can be set on those elements that want to be available offline (e.g. breadcrumbs, menus...). When offline, views that have an offline support (for now form, list and kanban), take the priority over other views, as we know the other views won't be able to fetch their data and render. task~5106517 Part-of: odoo#229492 Related: odoo/enterprise#96626 Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
1 parent 98cdf79 commit 600ec28

40 files changed

+583
-167
lines changed

addons/web/models/ir_ui_view.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def get_view_info(self):
1313
'display_name': display_name,
1414
'icon': _view_info[type_]['icon'],
1515
'multi_record': _view_info[type_].get('multi_record', True),
16+
'available_offline': _view_info[type_].get('available_offline', False),
1617
}
1718
for (type_, display_name)
1819
in self.fields_get(['type'], ['selection'])['type']['selection']
@@ -21,11 +22,11 @@ def get_view_info(self):
2122

2223
def _get_view_info(self):
2324
return {
24-
'list': {'icon': 'oi oi-view-list'},
25-
'form': {'icon': 'fa fa-address-card', 'multi_record': False},
25+
'list': {'icon': 'oi oi-view-list', 'available_offline': True},
26+
'form': {'icon': 'fa fa-address-card', 'multi_record': False, 'available_offline': True},
2627
'graph': {'icon': 'fa fa-area-chart'},
2728
'pivot': {'icon': 'oi oi-view-pivot'},
28-
'kanban': {'icon': 'oi oi-view-kanban'},
29+
'kanban': {'icon': 'oi oi-view-kanban', 'available_offline': True},
2930
'calendar': {'icon': 'fa fa-calendar'},
3031
'search': {'icon': 'oi oi-search'},
3132
}

addons/web/static/src/@types/services.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ declare module "services" {
1414
import { nameService } from "@web/core/name_service";
1515
import { httpService } from "@web/core/network/http_service";
1616
import { notificationService } from "@web/core/notifications/notification_service";
17+
import { offlineService } from "@web/core/offline/offline_service";
1718
import { ormService } from "@web/core/orm_service";
1819
import { overlayService } from "@web/core/overlay/overlay_service";
1920
import { popoverService } from "@web/core/popover/popover_service";
@@ -54,6 +55,7 @@ declare module "services" {
5455
menu: typeof menuService;
5556
name: typeof nameService;
5657
notification: typeof notificationService;
58+
offline: typeof offlineService;
5759
orm: typeof ormService;
5860
overlay: typeof overlayService;
5961
popover: typeof popoverService;

addons/web/static/src/core/dialog/dialog.xml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,16 @@
3333

3434
<t t-name="web.Dialog.header">
3535
<t t-if="fullscreen">
36-
<button class="btn oi oi-arrow-left oi-large" aria-label="Close" tabindex="-1" t-on-click="dismiss" />
36+
<button class="btn oi oi-arrow-left oi-large" aria-label="Close" tabindex="-1" t-on-click="dismiss" data-available-offline=""/>
3737
</t>
3838
<h4 class="modal-title text-break flex-grow-1" t-att-class="{ 'me-auto': fullscreen, 'fs-5': props.size == 'sm' }">
3939
<t t-esc="props.title"/>
4040
</h4>
4141
<t t-if="onExpand">
42-
<button type="button" class="fa fa-expand btn opacity-75 opacity-100-hover o_expand_button" aria-label="Expand" tabindex="-1" t-on-click="onExpand"/>
42+
<button type="button" class="fa fa-expand btn opacity-75 opacity-100-hover o_expand_button" aria-label="Expand" tabindex="-1" t-on-click="onExpand" data-available-offline=""/>
4343
</t>
4444
<t t-if="!fullscreen">
45-
<button type="button" class="btn-close" t-att-class="{'position-absolute top-0 end-0 mt-1 me-1': design == 'minimal' and !onExpand }" aria-label="Close" tabindex="-1" t-on-click="dismiss"/>
45+
<button type="button" class="btn-close" t-att-class="{'position-absolute top-0 end-0 mt-1 me-1': design == 'minimal' and !onExpand }" aria-label="Close" tabindex="-1" t-on-click="dismiss" data-available-offline=""/>
4646
</t>
4747
</t>
4848
</templates>

addons/web/static/src/core/errors/error_dialogs.xml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<p t-esc="message" class="text-prewrap"/>
88
</div>
99
<t t-set-slot="footer">
10-
<button class="btn btn-primary o-default-button" t-on-click="props.close">Close</button>
10+
<button class="btn btn-primary o-default-button" t-on-click="props.close" data-available-offline="">Close</button>
1111
</t>
1212
</Dialog>
1313
</t>
@@ -19,7 +19,7 @@
1919
</div>
2020
<t t-set-slot="footer">
2121
<button class="btn btn-primary" t-on-click="onClick" t-esc="buttonText"/>
22-
<button class="btn btn-secondary" t-on-click="props.close">Close</button>
22+
<button class="btn btn-secondary" t-on-click="props.close" data-available-offline="">Close</button>
2323
</t>
2424
</Dialog>
2525
</t>
@@ -32,7 +32,7 @@
3232
</p>
3333
</div>
3434
<t t-set-slot="footer">
35-
<button class="btn btn-primary o-default-button" t-on-click="props.close">Close</button>
35+
<button class="btn btn-primary o-default-button" t-on-click="props.close" data-available-offline="">Close</button>
3636
</t>
3737
</Dialog>
3838
</t>
@@ -45,7 +45,7 @@
4545
</p>
4646
</div>
4747
<t t-set-slot="footer">
48-
<button class="btn btn-primary o-default-button" t-on-click="onClick">Close</button>
48+
<button class="btn btn-primary o-default-button" t-on-click="onClick" data-available-offline="">Close</button>
4949
</t>
5050
</Dialog>
5151
</t>
@@ -59,7 +59,7 @@
5959
<details t-on-toggle="() => { state.showTraceback = !state.showTraceback }">
6060
<summary t-esc="state.showTraceback ? this.constructor.hideTracebackButtonText : this.constructor.showTracebackButtonText" class="mb-1 link-info"/>
6161
<div class="text-bg-100 clearfix mt-2 position-relative o_error_detail pb-2">
62-
<button class="btn position-absolute top-0 end-0 pt-2 btn-link link-body-emphasis" t-ref="copyButton" t-on-click="onClickClipboard">
62+
<button class="btn position-absolute top-0 end-0 pt-2 btn-link link-body-emphasis" t-ref="copyButton" t-on-click="onClickClipboard" data-available-offline="">
6363
<span class="fa fa-clipboard"/>
6464
</button>
6565
<div class="ps-1 pt-1 ps-md-3 pt-md-3">
@@ -73,7 +73,7 @@
7373
</details>
7474
</div>
7575
<t t-set-slot="footer">
76-
<button class="btn btn-primary o-default-button" t-on-click="props.close">Close</button>
76+
<button class="btn btn-primary o-default-button" t-on-click="props.close" data-available-offline="">Close</button>
7777
</t>
7878
</Dialog>
7979
</t>

addons/web/static/src/core/errors/error_handlers.js

Lines changed: 4 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import { _t } from "@web/core/l10n/translation";
2-
import { browser } from "../browser/browser";
3-
import { ConnectionLostError, RPCError, rpc } from "../network/rpc";
1+
import { RPCError } from "../network/rpc";
42
import { registry } from "../registry";
53
import { session } from "@web/session";
64
import { user } from "@web/core/user";
@@ -81,54 +79,6 @@ export function rpcErrorHandler(env, error, originalError) {
8179

8280
errorHandlerRegistry.add("rpcErrorHandler", rpcErrorHandler, { sequence: 97 });
8381

84-
// -----------------------------------------------------------------------------
85-
// Lost connection errors
86-
// -----------------------------------------------------------------------------
87-
88-
let connectionLostNotifRemove = null;
89-
/**
90-
* @param {OdooEnv} env
91-
* @param {UncaughError} error
92-
* @param {Error} originalError
93-
* @returns {boolean}
94-
*/
95-
export function lostConnectionHandler(env, error, originalError) {
96-
if (!(error instanceof UncaughtPromiseError)) {
97-
return false;
98-
}
99-
if (originalError instanceof ConnectionLostError) {
100-
if (connectionLostNotifRemove) {
101-
// notification already displayed (can occur if there were several
102-
// concurrent rpcs when the connection was lost)
103-
return true;
104-
}
105-
connectionLostNotifRemove = env.services.notification.add(
106-
_t("Connection lost. Trying to reconnect..."),
107-
{ sticky: true }
108-
);
109-
let delay = 2000;
110-
browser.setTimeout(function checkConnection() {
111-
rpc("/web/webclient/version_info", {})
112-
.then(function () {
113-
if (connectionLostNotifRemove) {
114-
connectionLostNotifRemove();
115-
connectionLostNotifRemove = null;
116-
}
117-
env.services.notification.add(_t("Connection restored. You are back online."), {
118-
type: "info",
119-
});
120-
})
121-
.catch(() => {
122-
// exponential backoff, with some jitter
123-
delay = delay * 1.5 + 500 * Math.random();
124-
browser.setTimeout(checkConnection, delay);
125-
});
126-
}, delay);
127-
return true;
128-
}
129-
}
130-
errorHandlerRegistry.add("lostConnectionHandler", lostConnectionHandler, { sequence: 98 });
131-
13282
// -----------------------------------------------------------------------------
13383
// Default handler
13484
// -----------------------------------------------------------------------------
@@ -182,5 +132,7 @@ if (user.isInternalUser === undefined) {
182132
);
183133
}
184134
} else {
185-
registry.category("error_handlers").add("swallowAllVisitorErrors", swallowAllVisitorErrors, { sequence: 0 });
135+
registry
136+
.category("error_handlers")
137+
.add("swallowAllVisitorErrors", swallowAllVisitorErrors, { sequence: 0 });
186138
}

addons/web/static/src/core/notifications/notification.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<div t-on-mouseenter="freeze" t-on-mouseleave="refresh" t-attf-class="o_notification {{props.className}} d-flex mb-2 position-relative rounded shadow-lg" role="alert" aria-live="assertive" aria-atomic="true">
66
<span t-attf-class="o_notification_bar bg-{{props.type}} rounded-start"/>
77
<div class="w-100 py-2 ps-3 pe-5 border border-start-0 rounded-end text-break">
8-
<button type="button" class="o_notification_close btn-close position-absolute top-0 end-0 mt-2 me-2" aria-label="Close" t-on-click="close"/>
8+
<button type="button" class="o_notification_close btn-close position-absolute top-0 end-0 mt-2 me-2" aria-label="Close" t-on-click="close" data-available-offline=""/>
99
<div class="o_notification_body d-flex align-items-center">
1010
<span class="me-auto o_notification_content w-100">
1111
<!-- TODO-IPB: at the end, title has to be removed, but the change is too important to do all in once now -->
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.o_disabled_offline {
2+
cursor: not-allowed !important;
3+
pointer-events: auto !important;
4+
opacity: 0.5 !important;
5+
}
6+
7+
// must override this rule https://github.com/odoo/odoo/blob/9e3b9bd085483ba0e0e956762fc0ad7d759eec73/addons/web/static/src/webclient/webclient.scss#L140
8+
.o_disabled_offline[type="action"],
9+
.o_disabled_offline[type="toggle"] {
10+
cursor: not-allowed !important;
11+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { UncaughtPromiseError } from "../errors/error_service";
2+
import { ConnectionLostError } from "../network/rpc";
3+
import { registry } from "../registry";
4+
5+
const errorHandlerRegistry = registry.category("error_handlers");
6+
7+
// -----------------------------------------------------------------------------
8+
// Lost connection errors
9+
// -----------------------------------------------------------------------------
10+
11+
/**
12+
* @param {OdooEnv} env
13+
* @param {UncaughError} error
14+
* @param {Error} originalError
15+
* @returns {boolean}
16+
*/
17+
export function lostConnectionHandler(env, error, originalError) {
18+
if (!(error instanceof UncaughtPromiseError)) {
19+
return false;
20+
}
21+
if (originalError instanceof ConnectionLostError) {
22+
env.services.offline.status.offline = true;
23+
return true;
24+
}
25+
}
26+
errorHandlerRegistry.add("lostConnectionHandler", lostConnectionHandler, { sequence: 98 });
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { reactive, useState } from "@odoo/owl";
2+
import { browser } from "@web/core/browser/browser";
3+
import { ConnectionLostError, rpc, rpcBus } from "@web/core/network/rpc";
4+
import { registry } from "@web/core/registry";
5+
import { useService } from "@web/core/utils/hooks";
6+
7+
const SELECTORS_TO_DISABLE = [
8+
"button:not([data-available-offline]):not([disabled])",
9+
"input[type='checkbox']:not([disabled])",
10+
];
11+
12+
function offlineUI() {
13+
document.querySelectorAll(SELECTORS_TO_DISABLE.join(", ")).forEach((el) => {
14+
el.setAttribute("disabled", "");
15+
el.classList.add("o_disabled_offline");
16+
});
17+
}
18+
19+
function onlineUI() {
20+
document.querySelectorAll(".o_disabled_offline").forEach((el) => {
21+
el.removeAttribute("disabled");
22+
el.classList.remove("o_disabled_offline");
23+
});
24+
}
25+
26+
export const offlineService = {
27+
async start() {
28+
let timeout;
29+
let observer;
30+
31+
async function checkConnection() {
32+
try {
33+
await rpc("/web/webclient/version_info", {});
34+
} catch {
35+
status.offline = true;
36+
return;
37+
}
38+
status.offline = false;
39+
}
40+
41+
const status = reactive(
42+
{
43+
offline: false,
44+
},
45+
() => {
46+
if (status.offline) {
47+
// Disable everything in the UI that isn't marked as available offline
48+
offlineUI();
49+
// Create an observer instance linked to the callback function to keep disabling
50+
// buttons that would appear in the DOM while being offline
51+
observer = new MutationObserver((mutationList) => {
52+
if (status.offline && mutationList.find((m) => m.addedNodes.length > 0)) {
53+
offlineUI();
54+
}
55+
});
56+
observer.observe(document.body, {
57+
childList: true, // listen for direct children being added/removed
58+
subtree: true, // also observe descendants (not just direct children)
59+
});
60+
61+
// Repeatedly check if connection is back
62+
let delay = 2000;
63+
const _checkConnection = async () => {
64+
if (status.offline) {
65+
await checkConnection();
66+
// exponential backoff, with some jitter
67+
delay = delay * 1.5 + 500 * Math.random();
68+
timeout = browser.setTimeout(_checkConnection, delay);
69+
}
70+
};
71+
timeout = browser.setTimeout(_checkConnection, delay);
72+
} else {
73+
onlineUI();
74+
observer?.disconnect();
75+
browser.clearTimeout(timeout);
76+
}
77+
}
78+
);
79+
status.offline; // activate the reactivity!
80+
81+
rpcBus.addEventListener("RPC:RESPONSE", (ev) => {
82+
status.offline = ev.detail.error instanceof ConnectionLostError;
83+
});
84+
85+
return {
86+
status,
87+
checkConnection,
88+
};
89+
},
90+
};
91+
92+
registry.category("services").add("offline", offlineService);
93+
94+
export function useOfflineStatus() {
95+
return useState(useService("offline").status);
96+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Component, useEffect } from "@odoo/owl";
2+
import { registry } from "@web/core/registry";
3+
import { useService } from "@web/core/utils/hooks";
4+
import { useOfflineStatus } from "./offline_service";
5+
6+
class OfflineSystray extends Component {
7+
static template = "web.OfflineSystray";
8+
static props = {};
9+
10+
setup() {
11+
this.offlineService = useService("offline");
12+
this.status = useOfflineStatus();
13+
useEffect(this.env.redrawNavbar, () => [this.status.offline]);
14+
}
15+
}
16+
17+
const offlineSystrayItem = {
18+
Component: OfflineSystray,
19+
};
20+
21+
registry.category("systray").add("offline", offlineSystrayItem, { sequence: 1000 });

0 commit comments

Comments
 (0)