Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web_refresher/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Web Refresher",
"version": "18.0.1.0.0",
"version": "18.0.2.0.0",
"author": "Compassion Switzerland, Tecnativa, Odoo Community Association (OCA)",
"license": "AGPL-3",
"website": "https://github.com/OCA/web",
Expand Down
158 changes: 147 additions & 11 deletions web_refresher/static/src/js/refresher.esm.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,40 @@
* Copyright 2023 Taras Shabaranskyi
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */

import {Component} from "@odoo/owl";
import {Component, onMounted, onWillUnmount} from "@odoo/owl";
import {useDebounced} from "@web/core/utils/timing";
import {useService} from "@web/core/utils/hooks";

export function useRefreshAnimation(timeout) {
const refreshClass = "o_content__refresh";
let timeoutId = null;

/**
* @returns {DOMTokenList|null}
*/
function contentClassList() {
const content = document.querySelector(".o_content");
return content ? content.classList : null;
}
let cachedElement = null;

function clearAnimationTimeout() {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = null;
cachedElement = null;
}

function animate() {
clearAnimationTimeout();
contentClassList().add(refreshClass);

// Cache the element reference before modifying it
const content = document.querySelector(".o_content");
if (!content) {
return;
}

cachedElement = content;
cachedElement.classList.add(refreshClass);

timeoutId = setTimeout(() => {
contentClassList().remove(refreshClass);
// Only remove class from the same cached element
if (cachedElement && cachedElement.classList.contains(refreshClass)) {
cachedElement.classList.remove(refreshClass);
}
clearAnimationTimeout();
}, timeout);
}
Expand All @@ -39,13 +45,59 @@ export function useRefreshAnimation(timeout) {
}

export class Refresher extends Component {
autoRefreshIntervalKey = "oca.web_refresher.auto_refresh";
refreshDefaultSettingsKey = "default";
setup() {
super.setup();
this.action = useService("action");
this.refreshAnimation = useRefreshAnimation(1000);
this.onClickRefresh = useDebounced(this.onClickRefresh, 200);
this.onChangeAutoRefreshInterval = this.onChangeAutoRefreshInterval.bind(this);
this.runningRefresherId = null;
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The property runningRefresherId is initialized but not defined as a class field. Consider declaring it at the class level for clarity: runningRefresherId = null; before the setup() method, similar to autoRefreshIntervalKey and refreshDefaultSettingsKey.

Copilot uses AI. Check for mistakes.
onMounted(() => {
const intervalValue = this._getLocalStorageValue(window.location.pathname);
this.onChangeAutoRefreshInterval({
value: intervalValue.refreshInterval,
textContent: intervalValue.intervalText,
Comment on lines +60 to +61
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling onChangeAutoRefreshInterval in onMounted with an object that doesn't match the event structure could cause issues. The method expects either clickedOption.value/clickedOption.target.value and clickedOption.textContent/clickedOption.target.textContent. This works because of the nullish coalescing fallbacks, but creates inconsistent API usage. Consider creating a separate initialization method or documenting this dual usage pattern.

Suggested change
value: intervalValue.refreshInterval,
textContent: intervalValue.intervalText,
target: {
value: intervalValue.refreshInterval,
textContent: intervalValue.intervalText,
},

Copilot uses AI. Check for mistakes.
});
});
onWillUnmount(() => {
if (this.runningRefresherId) {
clearTimeout(this.runningRefresherId);
}
});
}

_getLocalStorageValue(lookupKey) {
const jsonValue = localStorage.getItem(this.autoRefreshIntervalKey);
const refreshSettings = jsonValue ? JSON.parse(jsonValue) : {};
if (!lookupKey) {
return refreshSettings;
}
let returnInterval = -1;
let returnText = "Off";
if (Object.hasOwn(refreshSettings, lookupKey)) {
const {refreshInterval = -1, intervalText = "Off"} =
refreshSettings[lookupKey];
returnInterval = parseInt(refreshInterval ?? -1);
returnText = intervalText;
} else if (Object.hasOwn(refreshSettings, this.refreshDefaultSettingsKey)) {
const {refreshInterval = -1, intervalText = "Off"} =
refreshSettings[this.refreshDefaultSettingsKey];
returnInterval = parseInt(refreshInterval ?? -1);
Comment on lines +82 to +87
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nullish coalescing operator is redundant here since refreshInterval already has a default value of -1 from destructuring on line 80. Additionally, parseInt on an already-numeric value is unnecessary if refreshInterval is stored as a number. If it's stored as a string in localStorage, ensure consistent parsing is applied.

Suggested change
returnInterval = parseInt(refreshInterval ?? -1);
returnText = intervalText;
} else if (Object.hasOwn(refreshSettings, this.refreshDefaultSettingsKey)) {
const {refreshInterval = -1, intervalText = "Off"} =
refreshSettings[this.refreshDefaultSettingsKey];
returnInterval = parseInt(refreshInterval ?? -1);
returnInterval =
typeof refreshInterval === "string"
? parseInt(refreshInterval, 10)
: refreshInterval;
returnText = intervalText;
} else if (Object.hasOwn(refreshSettings, this.refreshDefaultSettingsKey)) {
const {refreshInterval = -1, intervalText = "Off"} =
refreshSettings[this.refreshDefaultSettingsKey];
returnInterval =
typeof refreshInterval === "string"
? parseInt(refreshInterval, 10)
: refreshInterval;

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +87
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nullish coalescing operator is redundant here since refreshInterval already has a default value of -1 from destructuring on line 85. Additionally, parseInt on an already-numeric value is unnecessary if refreshInterval is stored as a number. If it's stored as a string in localStorage, ensure consistent parsing is applied.

Suggested change
returnInterval = parseInt(refreshInterval ?? -1);
returnText = intervalText;
} else if (Object.hasOwn(refreshSettings, this.refreshDefaultSettingsKey)) {
const {refreshInterval = -1, intervalText = "Off"} =
refreshSettings[this.refreshDefaultSettingsKey];
returnInterval = parseInt(refreshInterval ?? -1);
returnInterval =
typeof refreshInterval === "string"
? parseInt(refreshInterval, 10)
: refreshInterval;
returnText = intervalText;
} else if (Object.hasOwn(refreshSettings, this.refreshDefaultSettingsKey)) {
const {refreshInterval = -1, intervalText = "Off"} =
refreshSettings[this.refreshDefaultSettingsKey];
returnInterval =
typeof refreshInterval === "string"
? parseInt(refreshInterval, 10)
: refreshInterval;

Copilot uses AI. Check for mistakes.
returnText = intervalText;
}
return {refreshInterval: returnInterval, intervalText: returnText};
}

_setLocalStorageValue(settingsKey, {refreshInterval, intervalText}) {
// Get current settings
const jsonValue = localStorage.getItem(this.autoRefreshIntervalKey);
const refreshSettings = jsonValue ? JSON.parse(jsonValue) : {};
refreshSettings[settingsKey] = {refreshInterval, intervalText};
const value = JSON.stringify(refreshSettings);
localStorage.setItem(this.autoRefreshIntervalKey, value);
}
/**
* @returns {Boolean}
* @private
Expand Down Expand Up @@ -81,6 +133,16 @@ export class Refresher extends Component {
if (!updated) {
updated = this._searchModelRefresh();
}
// Check the refreshInterval is greater than 0 and start a timer for the next refresh
if (this.refreshInterval > 0) {
// Always attempt to clear a running timeout in case the refresh was done manually
if (typeof this.runningRefresherId === "number") {
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type check typeof this.runningRefresherId === 'number' is unnecessarily strict. Since clearTimeout safely handles null, undefined, and 0, a simple truthy check would suffice: if (this.runningRefresherId). This pattern is also used on line 212.

Suggested change
if (typeof this.runningRefresherId === "number") {
if (this.runningRefresherId) {

Copilot uses AI. Check for mistakes.
clearTimeout(this.runningRefresherId);
}
this.runningRefresherId = setTimeout(() => {
this.refresh();
}, this.refreshInterval);
}
Comment on lines +136 to +145
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consolidated logic for starting the refresh interval in the current refresh to allow recursive calls

return updated;
}

Expand All @@ -101,6 +163,23 @@ export class Refresher extends Component {
this.action.doAction(viewAction, options);
}

_isRefreshIntervalDefault() {
const localStoredIntervals = this._getLocalStorageValue();

if (
Object.hasOwn(localStoredIntervals, this.refreshDefaultSettingsKey) &&
Object.hasOwn(
localStoredIntervals[this.refreshDefaultSettingsKey],
"refreshInterval"
) &&
this.refreshInterval ===
localStoredIntervals[this.refreshDefaultSettingsKey].refreshInterval
) {
return true;
}
return false;
}

async onClickRefresh() {
const {searchModel, pagerProps} = this.props;
if (!searchModel && !pagerProps) {
Expand All @@ -111,6 +190,63 @@ export class Refresher extends Component {
this.refreshAnimation();
}
}

setRefreshAsDefault() {
this._setLocalStorageValue(this.refreshDefaultSettingsKey, {
refreshInterval: this.refreshInterval,
intervalText: document.getElementById("auto-refresh-interval-text")
.textContent,
});
this._setIntervalUi(
document.getElementById("auto-refresh-interval-text").textContent
);
Comment on lines +195 to +202
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This DOM query is performed twice in setRefreshAsDefault (lines 197-198 and 201). Consider storing the element or its textContent in a variable to avoid redundant DOM queries.

Suggested change
this._setLocalStorageValue(this.refreshDefaultSettingsKey, {
refreshInterval: this.refreshInterval,
intervalText: document.getElementById("auto-refresh-interval-text")
.textContent,
});
this._setIntervalUi(
document.getElementById("auto-refresh-interval-text").textContent
);
const intervalElement = document.getElementById("auto-refresh-interval-text");
const intervalText = intervalElement ? intervalElement.textContent : "";
this._setLocalStorageValue(this.refreshDefaultSettingsKey, {
refreshInterval: this.refreshInterval,
intervalText: intervalText,
});
this._setIntervalUi(intervalText);

Copilot uses AI. Check for mistakes.
}

onChangeAutoRefreshInterval(clickedOption) {
const newInterval =
parseInt(clickedOption.value ?? clickedOption.target.value) ?? -1;
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If both clickedOption.value and clickedOption.target.value are undefined, parseInt(undefined) returns NaN, and the second nullish coalescing won't trigger because NaN is not null/undefined. This should use logical OR (||) or check for NaN explicitly: parseInt(clickedOption.value ?? clickedOption.target.value, 10) || -1.

Suggested change
parseInt(clickedOption.value ?? clickedOption.target.value) ?? -1;
parseInt(clickedOption.value ?? clickedOption.target.value, 10) || -1;

Copilot uses AI. Check for mistakes.
const newIntervalText =
clickedOption.textContent ?? clickedOption.target.textContent ?? "Off";
this.refreshInterval = newInterval;
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The property refreshInterval is assigned but never declared as a class field. Consider declaring it at the class level for clarity, similar to other class properties.

Copilot uses AI. Check for mistakes.
this._setIntervalUi(newIntervalText);
if (this.runningRefresherId) {
clearTimeout(this.runningRefresherId);
}
Comment on lines +210 to +214
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling refresh() immediately when changing the auto-refresh interval triggers an immediate refresh, which is correct. However, if auto-refresh is turned off (value === -1), calling refresh() will not schedule another refresh (line 137 checks > 0), but the running timer isn't cleared until line 212-214, creating a potential race condition where the previous timer might still fire. The clearTimeout should happen before checking conditions in the refresh() method or the order should be reconsidered.

Suggested change
this.refreshInterval = newInterval;
this._setIntervalUi(newIntervalText);
if (this.runningRefresherId) {
clearTimeout(this.runningRefresherId);
}
if (this.runningRefresherId) {
clearTimeout(this.runningRefresherId);
this.runningRefresherId = null;
}
this.refreshInterval = newInterval;
this._setIntervalUi(newIntervalText);

Copilot uses AI. Check for mistakes.
this.refresh();

this._setLocalStorageValue(window.location.pathname, {
refreshInterval: newInterval,
intervalText: newIntervalText,
});
}

_setIntervalUi(intervalText) {
// Check if the refresh is active and spin the refresh button if active
const manualRefreshIcon = document.getElementById("manual-refresh-icon");
if (manualRefreshIcon) {
if (!this.refreshInterval || this.refreshInterval <= 0) {
manualRefreshIcon.classList.remove("fa-spin");
} else {
manualRefreshIcon.classList.add("fa-spin");
}
}
Comment on lines +225 to +232
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct DOM manipulation using getElementById is not idiomatic in OWL components. Consider using t-ref in the template and useRef hook to access DOM elements, or manage the spinning state through reactive state that updates the template.

Copilot uses AI. Check for mistakes.
// Set the interval dropdown text to the selected interval
const refreshText = document.getElementById("auto-refresh-interval-text");
if (refreshText) {
refreshText.textContent = intervalText;
}
// Check if the current interval is the default and set the star icon accordingly
const setAsDefaultIcon = document.getElementById("set-as-default-icon");
if (setAsDefaultIcon) {
if (this._isRefreshIntervalDefault()) {
setAsDefaultIcon.classList.remove("fa-star-o");
setAsDefaultIcon.classList.add("fa-star");
} else {
setAsDefaultIcon.classList.remove("fa-star");
setAsDefaultIcon.classList.add("fa-star-o");
}
}
}
}

Object.assign(Refresher, {
Expand Down
72 changes: 65 additions & 7 deletions web_refresher/static/src/xml/refresher.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,71 @@
<template>
<t t-name="web_refresher.Button">
<nav class="oe_refresher" aria-label="Refresher" aria-atomic="true">
<button
class="fa fa-refresh btn btn-icon oe_pager_refresh"
aria-label="Refresh"
t-on-click="onClickRefresh"
title="Refresh"
tabindex="-1"
/>
<div class="btn-group" role="group">
<div class="btn-group d-none d-md-block" role="group">
<button
id="auto-refresh-dd"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added id for easy selections

class="btn btn-secondary dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Auto Refresh: <span id="auto-refresh-interval-text">Off</span>
</button>
<div class="dropdown-menu" aria-labelledby="auto-refresh-dd">
<button
class="dropdown-item"
t-on-click="onChangeAutoRefreshInterval"
value="-1"
>Off</button>
Comment on lines +20 to +24
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used -1 as the "off" value to avoid null/undefined confusions. #javascriptthings

<button
class="dropdown-item"
t-on-click="onChangeAutoRefreshInterval"
value="1000"
>1s</button>
Comment on lines +25 to +29
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure 1s is really appropriate. I could easily be persuaded to remove 1s

<button
class="dropdown-item"
t-on-click="onChangeAutoRefreshInterval"
value="5000"
>5s</button>
<button
class="dropdown-item"
t-on-click="onChangeAutoRefreshInterval"
value="10000"
>10s</button>
<button
class="dropdown-item"
t-on-click="onChangeAutoRefreshInterval"
value="30000"
>30s</button>
<button
class="dropdown-item"
t-on-click="onChangeAutoRefreshInterval"
value="60000"
>1min</button>
</div>
</div>
<button
id="set-as-default-btn"
class="btn btn-secondary m-0 d-none d-md-block"
aria-label="Set as Default"
t-on-click="setRefreshAsDefault"
title="Set as Default"
tabindex="-1"
>
<i id="set-as-default-icon" class="fa" />
</button>
<button
id="manual-refresh-btn"
class="btn btn-secondary m-0"
aria-label="Refresh"
t-on-click="onClickRefresh"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintain existing flow with the a reset of the new timeout when manually clicked

title="Refresh"
tabindex="-1"
>
<i id="manual-refresh-icon" class="fa fa-refresh" />
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the icons to an <i> to allow the fa-spin to be added and removed without spinning the whole button 🤣

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added id for easy selections

</button>
</div>
</nav>
</t>
</template>
Loading