diff --git a/defaultmodules/calendar/calendar.js b/defaultmodules/calendar/calendar.js index c1de8b949b..ad19757dc5 100644 --- a/defaultmodules/calendar/calendar.js +++ b/defaultmodules/calendar/calendar.js @@ -57,6 +57,7 @@ Module.register("calendar", { excludedEvents: [], sliceMultiDayEvents: false, broadcastPastEvents: false, + keepEventsOnError: false, // keep showing the last known events if a later fetch fails (the error is hidden unless there are no events) nextDaysRelative: false, selfSignedCert: false, coloredText: false, @@ -113,6 +114,9 @@ Module.register("calendar", { // indicate no data available yet this.loaded = false; + // current fetch errors, keyed by calendar url + this.errors = {}; + // data holder of calendar url. Avoid fade out/in on updateDom (one for each calendar update) this.calendarDisplayer = {}; @@ -189,21 +193,24 @@ Module.register("calendar", { // save the event list this.calendarData[payload.url].events = payload.events; - this.error = null; + // a successful fetch clears the error of this calendar; the UI must then be redrawn + // even if the events did not change, otherwise the error stays visible + const recovered = payload.url in this.errors; + delete this.errors[payload.url]; this.loaded = true; if (this.config.broadcastEvents) { this.broadcastEvents(); } // if the checksum is the same - if (this.calendarData[payload.url].checksum === payload.checksum) { + if (!recovered && this.calendarData[payload.url].checksum === payload.checksum) { // then don't update the UI return; } // haven't seen or the checksum is different this.calendarData[payload.url].checksum = payload.checksum; - if (!this.config.updateOnFetch) { + if (!this.config.updateOnFetch && !recovered) { if (this.calendarDisplayer[payload.url] === undefined) { // calendar will never displayed, so display it this.updateDom(this.config.animationSpeed); @@ -216,21 +223,32 @@ Module.register("calendar", { } } else if (notification === "CALENDAR_ERROR") { const error_message = this.translate(payload.error_type); - this.error = this.translate("MODULE_CONFIG_ERROR", { MODULE_NAME: this.name, ERROR: error_message }); + // errors without url (e.g. malformed url) cannot be attributed to a calendar + this.errors[payload.url ?? ""] = this.translate("MODULE_CONFIG_ERROR", { MODULE_NAME: this.name, ERROR: error_message }); this.loaded = true; } this.updateDom(this.config.animationSpeed); }, + /** + * Whether the fetch error should replace the calendar content. + * @param {object[]} events the events that would be displayed + * @returns {boolean} true if the error should be shown + */ + shouldShowError (events) { + if (Object.keys(this.errors).length === 0) return false; + return !(this.config.keepEventsOnError && events.length > 0); + }, + // Override dom generator. getDom () { const events = this.createEventList(true); const wrapper = document.createElement("table"); wrapper.className = this.config.tableClass; - if (this.error) { - wrapper.innerHTML = this.error; + if (this.shouldShowError(events)) { + wrapper.innerHTML = Object.values(this.errors)[0]; wrapper.className = `${this.config.tableClass} dimmed`; return wrapper; } diff --git a/defaultmodules/calendar/node_helper.js b/defaultmodules/calendar/node_helper.js index c1da4c4e7e..c2b2a152d2 100644 --- a/defaultmodules/calendar/node_helper.js +++ b/defaultmodules/calendar/node_helper.js @@ -65,6 +65,7 @@ export default NodeHelper.create({ Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, errorInfo.message || errorInfo); this.sendSocketNotification("CALENDAR_ERROR", { id: identifier, + url: fetcher.url, error_type: errorInfo.translationKey }); }); diff --git a/tests/unit/modules/default/calendar/calendar_errors_spec.js b/tests/unit/modules/default/calendar/calendar_errors_spec.js new file mode 100644 index 0000000000..ad3816855a --- /dev/null +++ b/tests/unit/modules/default/calendar/calendar_errors_spec.js @@ -0,0 +1,76 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const URL_A = "https://a.example/cal.ics"; +const URL_B = "https://b.example/cal.ics"; + +/** + * Loads the calendar front end module with stubbed globals. + * @returns {object} module instance with updateDom spy + */ +const loadModule = function () { + let definition; + const code = fs.readFileSync(path.join(__dirname, "../../../../../defaultmodules/calendar/calendar.js"), "utf8"); + vm.runInNewContext(code, { + Module: { register: (name, def) => { definition = def; } }, + document: { createElement: () => ({ innerHTML: "", className: "" }) }, + Log: { debug () {}, warn () {}, error () {}, log () {} } + }); + const instance = Object.create(definition); + instance.config = { ...definition.defaults }; + instance.identifier = "calendar_1"; + instance.name = "calendar"; + instance.calendarData = {}; + instance.calendarDisplayer = {}; + instance.errors = {}; + instance.loaded = false; + instance.translate = (key) => key; + instance.updateDom = vi.fn(); + instance.broadcastEvents = vi.fn(); + instance.createEventList = vi.fn(() => instance.events || []); + return instance; +}; + +const ok = (url, checksum = "x") => ({ id: "calendar_1", url, events: [], checksum }); +const fail = (url) => ({ id: "calendar_1", url, error_type: "MODULE_ERROR_NO_CONNECTION" }); + +describe("Calendar error handling", () => { + it("redraws after recovery even if the events checksum is unchanged", () => { + const m = loadModule(); + m.socketNotificationReceived("CALENDAR_EVENTS", ok(URL_A)); + m.updateDom.mockClear(); + + m.socketNotificationReceived("CALENDAR_ERROR", fail(URL_A)); + expect(m.getDom().innerHTML).toContain("MODULE_CONFIG_ERROR"); + m.updateDom.mockClear(); + + m.socketNotificationReceived("CALENDAR_EVENTS", ok(URL_A)); + expect(m.updateDom).toHaveBeenCalled(); + expect(m.getDom().innerHTML).not.toContain("MODULE_CONFIG_ERROR"); + }); + + it("keeps showing the error until every failing calendar has recovered", () => { + const m = loadModule(); + m.socketNotificationReceived("CALENDAR_ERROR", fail(URL_A)); + m.socketNotificationReceived("CALENDAR_ERROR", fail(URL_B)); + m.socketNotificationReceived("CALENDAR_EVENTS", ok(URL_A)); + expect(m.getDom().innerHTML).toContain("MODULE_CONFIG_ERROR"); + m.socketNotificationReceived("CALENDAR_EVENTS", ok(URL_B)); + expect(m.getDom().innerHTML).not.toContain("MODULE_CONFIG_ERROR"); + }); + + it("shows the error instead of events by default", () => { + const m = loadModule(); + m.socketNotificationReceived("CALENDAR_ERROR", fail(URL_A)); + expect(m.shouldShowError([{ title: "e" }])).toBe(true); + }); + + it("keeps events visible on error with keepEventsOnError, but shows the error when there are none", () => { + const m = loadModule(); + m.config.keepEventsOnError = true; + m.socketNotificationReceived("CALENDAR_ERROR", fail(URL_A)); + expect(m.shouldShowError([{ title: "e" }])).toBe(false); + expect(m.shouldShowError([])).toBe(true); + }); +});