From 6201d3eb10f2af6cc923636f4637451d5c91b8b6 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Mon, 9 Mar 2026 21:14:01 +0100 Subject: [PATCH 01/14] Fix size of Square around error --- radicale/web/internal_data/css/main.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index 7ba2e92b..94ae598e 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -356,13 +356,13 @@ img.loading { .error::before { content: "!"; - height: 1em; + height: 1.4em; color: white; background: rgb(217, 48, 37); font-weight: bold; border-radius: 100%; display: inline-block; - width: 1.1em; + width: 1.4em; margin-right: 5px; font-size: 1em; text-align: center; From b9179871c4da42daa14c3ec09469c16d744f321f Mon Sep 17 00:00:00 2001 From: Max Berger Date: Mon, 9 Mar 2026 23:17:47 +0100 Subject: [PATCH 02/14] Create common error handler and input field validator --- .../web/internal_data/js/scenes/LoginScene.js | 77 +++++++++--------- radicale/web/internal_data/js/utils/error.js | 74 +++++++++++++++++ .../internal_data/js/utils/form_validator.js | 80 +++++++++++++++++++ 3 files changed, 190 insertions(+), 41 deletions(-) create mode 100644 radicale/web/internal_data/js/utils/error.js create mode 100644 radicale/web/internal_data/js/utils/form_validator.js diff --git a/radicale/web/internal_data/js/scenes/LoginScene.js b/radicale/web/internal_data/js/scenes/LoginScene.js index 40855397..f6cc6b91 100644 --- a/radicale/web/internal_data/js/scenes/LoginScene.js +++ b/radicale/web/internal_data/js/scenes/LoginScene.js @@ -23,7 +23,8 @@ import { get_principal } from "../api/api.js"; import { CollectionsScene } from "./CollectionsScene.js"; import { LoadingScene } from "./LoadingScene.js"; import { Scene, pop_scene, push_scene, scene_stack } from "./scene_manager.js"; - +import { ErrorHandler } from "../utils/error.js"; +import { FormValidator, validate_non_empty } from "../utils/form_validator.js"; /** * @constructor * @implements {Scene} @@ -42,8 +43,10 @@ export class LoginScene { /** @type {?number} */ let scene_index = null; let user = ""; - let error = ""; /** @type {?XMLHttpRequest} */ let principal_req = null; + let errorHandler = new ErrorHandler(error_form); + let validator = new FormValidator(errorHandler); + validator.addValidator(user_form, validate_non_empty(user_form, "Username")); function read_form() { user = user_form.value; @@ -52,52 +55,43 @@ export class LoginScene { function fill_form() { user_form.value = user; password_form.value = ""; - if (error) { - error_form.textContent = "Error: " + error; - error_form.classList.remove("hidden"); - } else { - error_form.classList.add("hidden"); - } } function onlogin() { try { read_form(); let password = password_form.value; - if (user) { - error = ""; - // setup logout - logout_view.classList.remove("hidden"); - logout_btn.onclick = onlogout; - refresh_btn.onclick = refresh; - logout_user_form.textContent = user + "'s Collections"; - // Fetch principal - let loading_scene = new LoadingScene(); - push_scene(loading_scene, false); - principal_req = get_principal(user, password, function (collection, error1) { - if (scene_index === null) { - return; - } - principal_req = null; - if (error1) { - error = error1; - pop_scene(scene_index); - } else { - // show collections - let saved_user = user; - user = ""; - let collections_scene = new CollectionsScene( - saved_user, password, collection, function (error1) { - error = error1; - user = saved_user; - }); - push_scene(collections_scene, true); - } - }); - } else { - error = "Username is empty"; - fill_form(); + if (!validator.validate()) { + return false; } + // setup logout + logout_view.classList.remove("hidden"); + logout_btn.onclick = onlogout; + refresh_btn.onclick = refresh; + logout_user_form.textContent = user + "'s Collections"; + // Fetch principal + let loading_scene = new LoadingScene(); + push_scene(loading_scene, false); + principal_req = get_principal(user, password, function (collection, error1) { + if (scene_index === null) { + return; + } + principal_req = null; + if (error1) { + errorHandler.setError(error1); + pop_scene(scene_index); + } else { + // show collections + let saved_user = user; + user = ""; + let collections_scene = new CollectionsScene( + saved_user, password, collection, function (error1) { + errorHandler.setError(error1); + user = saved_user; + }); + push_scene(collections_scene, true); + } + }); } catch (err) { console.error(err); } @@ -117,6 +111,7 @@ export class LoginScene { return false; } + function remove_logout() { logout_view.classList.add("hidden"); logout_btn.onclick = null; diff --git a/radicale/web/internal_data/js/utils/error.js b/radicale/web/internal_data/js/utils/error.js new file mode 100644 index 00000000..842d5791 --- /dev/null +++ b/radicale/web/internal_data/js/utils/error.js @@ -0,0 +1,74 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2026-2026 Max Berger + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * ErrorHandler manages error messages for a HTMLElement. + */ +export class ErrorHandler { + /** + * @param {HTMLElement} element + */ + constructor(element) { + /** @type {HTMLElement} */ this._element = element; + /** @type {string} */ this._lastHTML = "anything_but_blank"; + this.clearError(); + } + + /** + * Sets an error message for a given key. + * @param {string} errorMessage + */ + setError(errorMessage) { + this._update([errorMessage]); + } + + /** + * Sets multiple error messages. + * @param {string[]} errorMessages + */ + setErrors(errorMessages) { + this._update(errorMessages); + } + + clearError() { + this._update([]); + } + + + /** + * Updates the element visibility and text content. + * @param {string[]} errorMessages + * @private + */ + _update(errorMessages) { + let html = ""; + if (errorMessages.length > 0) { + html = errorMessages.join("
"); + } + + if (html !== this._lastHTML) { + this._element.innerHTML = html; + if (html) { + this._element.classList.remove("hidden"); + } else { + this._element.classList.add("hidden"); + } + this._lastHTML = html; + } + } +} diff --git a/radicale/web/internal_data/js/utils/form_validator.js b/radicale/web/internal_data/js/utils/form_validator.js new file mode 100644 index 00000000..96645296 --- /dev/null +++ b/radicale/web/internal_data/js/utils/form_validator.js @@ -0,0 +1,80 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2026-2026 Max Berger + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { ErrorHandler } from "./error.js"; + +/** + * Manages form validation by running validation functions on input fields. + */ +export class FormValidator { + + /** + * @param {ErrorHandler} error_handler + */ + constructor(error_handler) { + this.error_handler = error_handler; + this.validation_methods = []; + } + + /** + * @param {HTMLInputElement} field + * @param {function(): ?string} validation_method + */ + addValidator(field, validation_method) { + this.validation_methods.push({ field, validation_method }); + field.addEventListener("input", () => { + this.validate(); + }); + this.validate(); + } + + /** + * Validates all added validators. + * @returns true if all validators are valid + */ + validate() { + let errorMessages = []; + for (let { field, validation_method } of this.validation_methods) { + let errorMessage = validation_method(field); + if (errorMessage) { + errorMessages.push(errorMessage); + } + } + this.error_handler.setErrors(errorMessages); + return errorMessages.length === 0; + } +} + +/** + * Validates that the input is not empty. + * @param {HTMLInputElement} input + * @param {string} field_name + * @returns{function(): ?string} + */ +export function validate_non_empty(input, field_name) { + return () => { + if (input.value) { + return null; + } + return field_name + " is empty"; + }; +} + + + + From 6d7396395f65ba34cd482c25202f5eddde20063e Mon Sep 17 00:00:00 2001 From: Max Berger Date: Mon, 9 Mar 2026 23:35:56 +0100 Subject: [PATCH 03/14] Use new common error handling in CreateEditCollectionScene --- .../js/scenes/CreateEditCollectionScene.js | 42 +++++++----------- .../internal_data/js/utils/form_validator.js | 43 ++++++++++++++++++- 2 files changed, 57 insertions(+), 28 deletions(-) diff --git a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js index 3b6c280b..b780cca7 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js @@ -22,7 +22,9 @@ import { create_collection, edit_collection } from "../api/api.js"; import { COLOR_RE } from "../constants.js"; import { Collection, CollectionType } from "../models/collection.js"; -import { cleanHREFinput, isValidHREF, onCleanHREFinput, random_hex, random_uuid } from "../utils/misc.js"; +import { ErrorHandler } from "../utils/error.js"; +import { FormValidator, validate_color, validate_href } from "../utils/form_validator.js"; +import { cleanHREFinput, onCleanHREFinput, random_hex, random_uuid } from "../utils/misc.js"; import { LoadingScene } from "./LoadingScene.js"; import { Scene, pop_scene, push_scene, scene_stack } from "./scene_manager.js"; @@ -55,9 +57,16 @@ export class CreateEditCollectionScene { /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let create_edit_req = null; - let error = ""; /** @type {?HTMLSelectElement} */ let saved_type_form = null; + let errorHandler = new ErrorHandler(error_form); + let validator = new FormValidator(errorHandler); + + if (!edit) { + validator.addValidator(href_form, validate_href(href_form, "HREF")); + } + validator.addValidator(color_form, validate_color(color_form, "Color")); + let href = edit ? collection.href : collection.href + random_uuid() + "/"; let displayname = edit ? collection.displayname : ""; let description = edit ? collection.description : ""; @@ -87,10 +96,6 @@ export class CreateEditCollectionScene { if (!edit) { cleanHREFinput(href_form); let newhreftxtvalue = href_form.value.trim().toLowerCase(); - if (!isValidHREF(newhreftxtvalue)) { - alert("You must enter a valid HREF"); - return false; - } href = collection.href + newhreftxtvalue + "/"; } displayname = displayname_form.value; @@ -110,29 +115,19 @@ export class CreateEditCollectionScene { source_form.value = source; type_form.value = type; color_form.value = color; - if (error) { - error_form.textContent = "Error: " + error; - error_form.classList.remove("hidden"); - } - error_form.classList.add("hidden"); onTypeChange(null); type_form.addEventListener("change", onTypeChange); } function onsubmit() { try { - if (!read_form()) { + if (!validator.validate()) { return false; } + read_form(); let sane_color = color.trim(); if (sane_color) { - let color_match = COLOR_RE.exec(sane_color); - if (!color_match) { - error = "Invalid color"; - fill_form(); - return false; - } - sane_color = color_match[1]; + sane_color = COLOR_RE.exec(sane_color)[1]; } let loading_scene = new LoadingScene(); push_scene(loading_scene, false); @@ -143,7 +138,7 @@ export class CreateEditCollectionScene { } create_edit_req = null; if (error1) { - error = error1; + errorHandler.setError(error1); pop_scene(scene_index); } else { pop_scene(scene_index - 1); @@ -197,12 +192,7 @@ export class CreateEditCollectionScene { fill_form(); submit_btn.onclick = onsubmit; cancel_btn.onclick = oncancel; - if (error) { - error_form.textContent = "Error: " + error; - error_form.classList.remove("hidden"); - } else { - error_form.classList.add("hidden"); - } + validator.validate(); }; this.hide = function () { read_form(); diff --git a/radicale/web/internal_data/js/utils/form_validator.js b/radicale/web/internal_data/js/utils/form_validator.js index 96645296..252dbba6 100644 --- a/radicale/web/internal_data/js/utils/form_validator.js +++ b/radicale/web/internal_data/js/utils/form_validator.js @@ -16,7 +16,9 @@ * along with this program. If not, see . */ +import { COLOR_RE } from "../constants.js"; import { ErrorHandler } from "./error.js"; +import { isValidHREF } from "./misc.js"; /** * Manages form validation by running validation functions on input fields. @@ -74,7 +76,44 @@ export function validate_non_empty(input, field_name) { return field_name + " is empty"; }; } +/** + * Validates that the input is a valid HREF. + * @param {HTMLInputElement} input + * @param {string} field_name + * @returns {function(): ?string} + */ +export function validate_href(input, field_name) { + return () => { + let value = input.value.trim(); + if (!value) { + return field_name + " is empty"; + } + if (value.startsWith("/")) { + return field_name + " cannot start with /"; + } + if (!isValidHREF(value)) { + return field_name + " is invalid"; + } + return null; + }; +} - - +/** + * Validates that the input is a valid color. + * @param {HTMLInputElement} input + * @param {string} field_name + * @returns {function(): ?string} + */ +export function validate_color(input, field_name) { + return () => { + let value = input.value.trim(); + if (!value) { + return null; + } + if (!COLOR_RE.exec(value)) { + return field_name + " is invalid"; + } + return null; + }; +} From 55529f3031bd4a85cb3d05a64d1352d686893106 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Mon, 9 Mar 2026 23:40:41 +0100 Subject: [PATCH 04/14] Use new error handling in DeleteCollectionScene --- .../js/scenes/DeleteCollectionScene.js | 33 ++++++++++--------- .../internal_data/js/utils/form_validator.js | 16 ++++++++- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/radicale/web/internal_data/js/scenes/DeleteCollectionScene.js b/radicale/web/internal_data/js/scenes/DeleteCollectionScene.js index 540ad45f..878b8a19 100644 --- a/radicale/web/internal_data/js/scenes/DeleteCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/DeleteCollectionScene.js @@ -22,16 +22,20 @@ import { delete_collection } from "../api/api.js"; import { DELETE_CONFIRMATION_TEXT } from "../constants.js"; import { Collection } from "../models/collection.js"; +import { ErrorHandler } from "../utils/error.js"; +import { FormValidator, validate_equals } from "../utils/form_validator.js"; import { LoadingScene } from "./LoadingScene.js"; import { Scene, pop_scene, push_scene, scene_stack } from "./scene_manager.js"; /** * @implements {Scene} - * @param {string} user - * @param {string} password - * @param {Collection} collection */ export class DeleteCollectionScene { + /** + * @param {string} user + * @param {string} password + * @param {Collection} collection + */ constructor(user, password, collection) { /** @type {HTMLElement} */ let html_scene = document.getElementById("deletecollectionscene"); /** @type {HTMLElement} */ let title_form = html_scene.querySelector("[data-name=title]"); @@ -47,13 +51,15 @@ export class DeleteCollectionScene { /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let delete_req = null; - let error = ""; + + let errorHandler = new ErrorHandler(error_form); + let validator = new FormValidator(errorHandler); + + validator.addValidator(confirmation_txt, validate_equals(confirmation_txt, DELETE_CONFIRMATION_TEXT, "confirmation")); function ondelete() { - let confirmation_text_value = confirmation_txt.value; - if (confirmation_text_value != DELETE_CONFIRMATION_TEXT) { - alert("Please type the confirmation text to delete this collection."); - return; + if (!validator.validate()) { + return false; } try { let loading_scene = new LoadingScene(); @@ -63,8 +69,9 @@ export class DeleteCollectionScene { return; } delete_req = null; + delete_req = null; if (error1) { - error = error1; + errorHandler.setError(error1); pop_scene(scene_index); } else { pop_scene(scene_index - 1); @@ -99,13 +106,7 @@ export class DeleteCollectionScene { title_form.textContent = collection.displayname || collection.href; delete_btn.onclick = ondelete; cancel_btn.onclick = oncancel; - if (error) { - error_form.textContent = "Error: " + error; - error_form.classList.remove("hidden"); - } else { - error_form.classList.add("hidden"); - } - + validator.validate(); }; this.hide = function () { html_scene.classList.add("hidden"); diff --git a/radicale/web/internal_data/js/utils/form_validator.js b/radicale/web/internal_data/js/utils/form_validator.js index 252dbba6..0552b2df 100644 --- a/radicale/web/internal_data/js/utils/form_validator.js +++ b/radicale/web/internal_data/js/utils/form_validator.js @@ -115,5 +115,19 @@ export function validate_color(input, field_name) { } return null; }; +}/** + * Validates that the input matches a specific string. + * @param {HTMLInputElement} input + * @param {string} target + * @param {string} field_name + * @returns {function(): ?string} + */ +export function validate_equals(input, target, field_name) { + return () => { + let value = input.value; + if (value === target) { + return null; + } + return "Please type " + target + " in the " + field_name + " field"; + }; } - From d26324e417a1f8e98a3da9a7596707e60bdccd7f Mon Sep 17 00:00:00 2001 From: Max Berger Date: Mon, 9 Mar 2026 23:52:34 +0100 Subject: [PATCH 05/14] Use new error handler in UploadCollectionScene --- .../js/scenes/UploadCollectionScene.js | 48 ++++++++++--------- .../internal_data/js/utils/form_validator.js | 15 ++++++ 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js index bb52fc07..6aaff39f 100644 --- a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js @@ -21,7 +21,9 @@ import { upload_collection } from "../api/api.js"; import { Collection } from "../models/collection.js"; -import { cleanHREFinput, isValidHREF, onCleanHREFinput, random_uuid } from "../utils/misc.js"; +import { ErrorHandler } from "../utils/error.js"; +import { FormValidator, validate_files, validate_href } from "../utils/form_validator.js"; +import { cleanHREFinput, onCleanHREFinput, random_uuid } from "../utils/misc.js"; import { Scene, pop_scene, scene_stack } from "./scene_manager.js"; /** @@ -44,6 +46,7 @@ export class UploadCollectionScene { /** @type {HTMLElement} */ let href_label = html_scene.querySelector("label[for=href]"); /** @type {HTMLElement} */ let hreflimitmsg_html = html_scene.querySelector("[data-name=hreflimitmsg]"); /** @type {HTMLElement} */ let pending_html = html_scene.querySelector("[data-name=pending]"); + /** @type {HTMLElement} */ let error_form = html_scene.querySelector("[data-name=error]"); let files = uploadfile_form.files; href_form.addEventListener("input", onCleanHREFinput); @@ -53,6 +56,12 @@ export class UploadCollectionScene { href_form.value = ""; let href = ""; + let errorHandler = new ErrorHandler(error_form); + let validator = new FormValidator(errorHandler); + + validator.addValidator(href_form, validate_href(href_form, "HREF")); + validator.addValidator(uploadfile_form, validate_files(uploadfile_form, "file")); + /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let upload_req = null; /** @type {Array} */ let results = []; @@ -60,9 +69,10 @@ export class UploadCollectionScene { function upload_start() { try { - if (!read_form()) { + if (!validator.validate()) { return false; } + read_form(); uploadfile_form.classList.add("hidden"); uploadfile_lbl.classList.add("hidden"); href_form.classList.add("hidden"); @@ -125,40 +135,33 @@ export class UploadCollectionScene { return false; } + /** + * @param {number} i + */ function updateFileStatus(i) { if (nodes === null) { return; } - let success_form = nodes[i].querySelector("[data-name=success]"); - let error_form = nodes[i].querySelector("[data-name=error]"); + /** @type {HTMLElement} */ let file_success_form = nodes[i].querySelector("[data-name=success]"); + /** @type {HTMLElement} */ let file_error_form = nodes[i].querySelector("[data-name=error]"); if (results.length > i) { if (results[i]) { - success_form.classList.add("hidden"); - error_form.textContent = "Error: " + results[i]; + file_success_form.classList.add("hidden"); + file_error_form.textContent = "Error: " + results[i]; error_form.classList.remove("hidden"); } else { - success_form.classList.remove("hidden"); - error_form.classList.add("hidden"); + file_success_form.classList.remove("hidden"); + file_error_form.classList.add("hidden"); } } else { - success_form.classList.add("hidden"); - error_form.classList.add("hidden"); + file_success_form.classList.add("hidden"); + file_error_form.classList.add("hidden"); } } function read_form() { cleanHREFinput(href_form); - let newhreftxtvalue = href_form.value.trim().toLowerCase(); - if (!isValidHREF(newhreftxtvalue)) { - alert("You must enter a valid HREF"); - return false; - } - href = newhreftxtvalue; - - if (uploadfile_form.files.length == 0) { - alert("You must select at least one file to upload"); - return false; - } + href = href_form.value.trim().toLowerCase(); files = uploadfile_form.files; return true; } @@ -169,7 +172,7 @@ export class UploadCollectionScene { hreflimitmsg_html.classList.remove("hidden"); href_form.classList.add("hidden"); href_label.classList.add("hidden"); - href_form.value = random_uuid(); // dummy, will be replaced on upload + href_form.value = random_uuid(); // fake HREF, will be replaced on upload } else { hreflimitmsg_html.classList.add("hidden"); href_form.classList.remove("hidden"); @@ -195,6 +198,7 @@ export class UploadCollectionScene { href_label.classList.remove("hidden"); hreflimitmsg_html.classList.add("hidden"); pending_html.classList.add("hidden"); + errorHandler.clearError(); close_btn.onclick = null; upload_btn.onclick = null; href_form.value = ""; diff --git a/radicale/web/internal_data/js/utils/form_validator.js b/radicale/web/internal_data/js/utils/form_validator.js index 0552b2df..21cd0826 100644 --- a/radicale/web/internal_data/js/utils/form_validator.js +++ b/radicale/web/internal_data/js/utils/form_validator.js @@ -131,3 +131,18 @@ export function validate_equals(input, target, field_name) { return "Please type " + target + " in the " + field_name + " field"; }; } + +/** + * Validates that at least one file is selected in a file input. + * @param {HTMLInputElement} input + * @param {string} field_name + * @returns {function(): ?string} + */ +export function validate_files(input, field_name) { + return () => { + if (input.files && input.files.length > 0) { + return null; + } + return "Please select at least one " + field_name; + }; +} From e4775e45a5780716d5463a41f35fa20d41fab126 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Tue, 10 Mar 2026 21:29:49 +0100 Subject: [PATCH 06/14] Add ErrorHandler to share behavior --- radicale/web/internal_data/index.html | 4 +- radicale/web/internal_data/js/api/api.js | 48 +++++++++++++------ .../internal_data/js/scenes/NewShareScene.js | 44 ++++++++++++----- .../js/scenes/ShareCollectionScene.js | 43 +++++++++++------ 4 files changed, 99 insertions(+), 40 deletions(-) diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index d77a9711..3afc37de 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -200,6 +200,7 @@
+