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
30 changes: 24 additions & 6 deletions defaultmodules/calendar/calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {};

Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions defaultmodules/calendar/node_helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
Expand Down
76 changes: 76 additions & 0 deletions tests/unit/modules/default/calendar/calendar_errors_spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading