diff --git a/integ_tests/test_sharing_config.py b/integ_tests/test_sharing_config.py new file mode 100644 index 000000000..d9b23a9b2 --- /dev/null +++ b/integ_tests/test_sharing_config.py @@ -0,0 +1,323 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 Max Berger +# +# This library 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 library 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 Radicale. If not, see . + +""" +Integration tests for sharing actions configurations +""" + +import pathlib +from typing import Any, Generator + +import pytest +from playwright.sync_api import BrowserContext, Page, expect + +from integ_tests.common import (SHARING_HTPASSWD, SHARING_XREMOTE, Config, + login, start_radicale_server) + + +@pytest.fixture(params=[SHARING_HTPASSWD, SHARING_XREMOTE]) +def config(request: pytest.FixtureRequest) -> Config: + return request.param + + +@pytest.fixture +def radicale_server( + tmp_path: pathlib.Path, config: Config +) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path, config) + + +def _create_addressbook_and_open_share( + context: BrowserContext, page: Page, radicale_server: str, config: Config, name: str +) -> None: + login(page, radicale_server, config, context=context) + + # create collection of type ADDRESSBOOK for bday (bday only works with ADDRESSBOOK) + page.click('a[data-name="new"]') + page.locator('#createcollectionscene select[data-name="type"]').select_option( + "ADDRESSBOOK" + ) + page.locator('#createcollectionscene input[data-name="displayname"]').fill(name) + page.click('#createcollectionscene button[data-name="submit"]') + + # Open share scene + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + # Click Share by Map + page.click('button[data-name="sharebymap"]') + + +def test_sharing_config_save_and_reload( + context: BrowserContext, page: Page, radicale_server: str, config: Config +) -> None: + _create_addressbook_and_open_share( + context, page, radicale_server, config, "Addressbook for Save and Reload" + ) + + # Config details should be hidden initially + expect(page.locator("details[data-name='config']")).to_be_hidden() + + # Select Birthday conversion + page.click('label[for="newshare_conv_bday"]') + + # Config details should now be visible and open + expect(page.locator("details[data-name='config']")).to_be_visible() + expect(page.locator("details[data-name='config']")).to_have_attribute("open", "") + + # Fill share map fields (user and href) + page.locator('input[data-name="shareuser"]').fill("max") + page.locator('input[data-name="sharehref"]').fill("mapped-bday-reload") + + # Verify all 5 config properties are present + expect( + page.locator("#newshare_config_conversion_bday_summary_template") + ).to_be_visible() + expect( + page.locator("#newshare_config_conversion_bday_description_template") + ).to_be_visible() + expect( + page.locator("#newshare_config_conversion_bday_alarm_trigger_template") + ).to_be_visible() + expect(page.locator("#newshare_config_conversion_bday_categories")).to_be_visible() + expect(page.locator("#newshare_config_conversion_bday_age_max")).to_be_visible() + + # Set some config values + page.locator("#newshare_config_conversion_bday_summary_template").fill( + "{fn} Birthday" + ) + page.locator("#newshare_config_conversion_bday_age_max").fill("120") + + # Save + page.click('#createeditsharescene button[data-name="submit"]') + + # Verify share created + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") + ).to_have_count(1) + + # Re-open share in edit mode + page.click( + "tr[data-name='sharemaprowtemplate']:not(.hidden) button[data-name='edit']", + strict=True, + ) + + # Verify values are populated correctly in the inputs + expect( + page.locator("#newshare_config_conversion_bday_summary_template") + ).to_have_value("{fn} Birthday") + expect(page.locator("#newshare_config_conversion_bday_age_max")).to_have_value( + "120" + ) + + page.click('#createeditsharescene button[data-name="cancel"]') + + +def test_sharing_config_delete_checkbox( + context: BrowserContext, page: Page, radicale_server: str, config: Config +) -> None: + _create_addressbook_and_open_share( + context, page, radicale_server, config, "Addressbook for Delete Checkbox" + ) + + # Select Birthday conversion + page.click('label[for="newshare_conv_bday"]') + + # Fill share map fields (user and href) + page.locator('input[data-name="shareuser"]').fill("max") + page.locator('input[data-name="sharehref"]').fill("mapped-bday-delete") + + # Set config values + page.locator("#newshare_config_conversion_bday_summary_template").fill( + "{fn} Birthday" + ) + page.locator("#newshare_config_conversion_bday_age_max").fill("120") + + # Check delete checkbox for summary template + page.click( + "label[for='newshare_config_del_conversion_bday_summary_template']", strict=True + ) + expect( + page.locator("#newshare_config_conversion_bday_summary_template") + ).to_be_disabled() + + # Uncheck delete checkbox for summary template + page.click( + "label[for='newshare_config_del_conversion_bday_summary_template']", strict=True + ) + expect( + page.locator("#newshare_config_conversion_bday_summary_template") + ).to_be_enabled() + + # Check it again to save deletion + page.click( + "label[for='newshare_config_del_conversion_bday_summary_template']", strict=True + ) + + # Save + page.click('#createeditsharescene button[data-name="submit"]') + + # Verify share created + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") + ).to_have_count(1) + + # Re-open share in edit mode + page.click( + "tr[data-name='sharemaprowtemplate']:not(.hidden) button[data-name='edit']", + strict=True, + ) + + # Verify that the summary template checkbox is checked, input is disabled and empty (since it was deleted) + expect( + page.locator("#newshare_config_del_conversion_bday_summary_template") + ).to_be_checked() + expect( + page.locator("#newshare_config_conversion_bday_summary_template") + ).to_be_disabled() + expect( + page.locator("#newshare_config_conversion_bday_summary_template") + ).to_have_value("") + expect(page.locator("#newshare_config_conversion_bday_age_max")).to_have_value( + "120" + ) + + page.click('#createeditsharescene button[data-name="cancel"]') + + +def test_sharing_config_conversion_none( + context: BrowserContext, page: Page, radicale_server: str, config: Config +) -> None: + _create_addressbook_and_open_share( + context, page, radicale_server, config, "Addressbook for Conversion None" + ) + + # Select Birthday conversion + page.click('label[for="newshare_conv_bday"]') + + # Fill share map fields (user and href) + page.locator('input[data-name="shareuser"]').fill("max") + page.locator('input[data-name="sharehref"]').fill("mapped-bday-none") + + # Set some config values + page.locator("#newshare_config_conversion_bday_summary_template").fill( + "{fn} Birthday" + ) + page.locator("#newshare_config_conversion_bday_age_max").fill("120") + + # Save + page.click('#createeditsharescene button[data-name="submit"]') + + # Verify share created + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") + ).to_have_count(1) + + # Re-open share in edit mode + page.click( + "tr[data-name='sharemaprowtemplate']:not(.hidden) button[data-name='edit']", + strict=True, + ) + + # Change conversion back to None + page.click('label[for="newshare_conv_none"]') + expect(page.locator("details[data-name='config']")).to_be_hidden() + + # Save + page.click('#createeditsharescene button[data-name="submit"]') + + # Re-open share in edit mode once more + page.click( + "tr[data-name='sharemaprowtemplate']:not(.hidden) button[data-name='edit']", + strict=True, + ) + + # Change conversion back to Birthday + page.click('label[for="newshare_conv_bday"]') + + # Config fields should be empty and enabled, delete checkboxes unchecked + expect( + page.locator("#newshare_config_conversion_bday_summary_template") + ).to_have_value("") + expect( + page.locator("#newshare_config_conversion_bday_summary_template") + ).to_be_enabled() + expect( + page.locator("#newshare_config_del_conversion_bday_summary_template") + ).not_to_be_checked() + + expect(page.locator("#newshare_config_conversion_bday_age_max")).to_have_value("") + expect(page.locator("#newshare_config_conversion_bday_age_max")).to_be_enabled() + expect( + page.locator("#newshare_config_del_conversion_bday_age_max") + ).not_to_be_checked() + + page.click('#createeditsharescene button[data-name="cancel"]') + + +def test_sharing_config_invalid_integer( + context: BrowserContext, page: Page, radicale_server: str, config: Config +) -> None: + _create_addressbook_and_open_share( + context, page, radicale_server, config, "Addressbook for Invalid Int" + ) + + # Select Birthday conversion + page.click('label[for="newshare_conv_bday"]') + + # Fill share map fields (user and href) + page.locator('input[data-name="shareuser"]').fill("max") + page.locator('input[data-name="sharehref"]').fill("mapped-bday-invalid") + + # Set summary template (valid) + page.locator("#newshare_config_conversion_bday_summary_template").fill( + "{fn} Birthday" + ) + + # Set invalid integer for age_max (which has type 'int') + page.locator("#newshare_config_conversion_bday_age_max").fill("foo") + + # Verify that there is some error message shown + expect(page.locator("#createeditsharescene [data-name='error']")).not_to_be_empty() + expect(page.locator("#createeditsharescene [data-name='error']")).to_contain_text( + "Max age must be an integer" + ) + + # Click submit button + page.click('#createeditsharescene button[data-name="submit"]') + + # Verify we are still on the edit share scene (submit is blocked) + expect(page.locator("#createeditsharescene")).to_be_visible() + expect(page.locator("#createeditsharescene [data-name='error']")).to_contain_text( + "Max age must be an integer" + ) + + # Fill valid integer now + page.locator("#newshare_config_conversion_bday_age_max").fill("120") + + # Verify error is cleared + expect(page.locator("#createeditsharescene [data-name='error']")).to_be_empty() + + # Save + page.click('#createeditsharescene button[data-name="submit"]') + + # Verify share created successfully + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") + ).to_have_count(1) + + # Cancel/Close share collection scene + page.click('#sharecollectionscene button[data-name="cancel"]') diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 36fe251ec..d6363bd44 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -1324,7 +1324,8 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st User=User, Timestamp=Timestamp, Properties=Properties, - Actions=Actions) + Actions=Actions, + Conversion=Conversion) else: result = self.database_update_sharing( ShareType=ShareType, @@ -1337,7 +1338,8 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st User=User, Timestamp=Timestamp, Properties=Properties, - Actions=Actions) + Actions=Actions, + Conversion=Conversion) elif user == share['User']: # User is only allowed to update Properties diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index ae6adc8c8..b0fa0d63d 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -306,6 +306,12 @@

New Share

+
Properties override
diff --git a/radicale/web/internal_data/js/api/sharing.js b/radicale/web/internal_data/js/api/sharing.js index b2a46be30..e83f440bc 100644 --- a/radicale/web/internal_data/js/api/sharing.js +++ b/radicale/web/internal_data/js/api/sharing.js @@ -138,8 +138,158 @@ export function discover_server_features(user, password, callback) { * @property {number} [TimestampUpdated] * @property {Object} [Properties] * @property {string} [Conversion] + * @property {Object} [Actions] */ +export class ConfigProperty { + /** + * @param {string} key + * @param {string} type + * @param {string} displayName + */ + constructor(key, type, displayName) { + /** @type {string} */ this.key = key; + /** @type {string} */ this.type = type; + /** @type {string} */ this.displayName = displayName; + } +} + +export const BDAY_CONFIG = Object.freeze([ + Object.freeze(new ConfigProperty("conversion_bday_summary_template", "str", "Summary template")), + Object.freeze(new ConfigProperty("conversion_bday_description_template", "str", "Description template")), + Object.freeze(new ConfigProperty("conversion_bday_alarm_trigger_template", "str", "Alarm trigger template")), + Object.freeze(new ConfigProperty("conversion_bday_categories", "str", "Categories")), + Object.freeze(new ConfigProperty("conversion_bday_age_max", "int", "Max age")) +]); + +export class ShareConfig { + /** + * @param {ShareConfig|Record} [data] + */ + constructor(data = {}) { + /** @type {Record} */ + this._values = {}; + let rawData = data; + if (data instanceof ShareConfig) { + rawData = data._values; + } + for (const [key, value] of Object.entries(rawData || {})) { + const bdayProp = BDAY_CONFIG.find(c => c.key === key); + if (bdayProp && bdayProp.type === "int" && value !== null && value !== undefined) { + let parsed = parseInt(String(value), 10); + this._values[key] = isNaN(parsed) ? value : parsed; + } else { + this._values[key] = value; + } + } + } + + /** + * @param {ConfigProperty} property + * @returns {any} + */ + get(property) { + let val = this._values[property.key] ?? null; + return val === "#DEL#" ? null : val; + } + + /** + * @param {ConfigProperty} property + * @param {any} val + */ + set(property, val) { + if (val === undefined || val === null || val === "") { + this._values[property.key] = null; + } else if (property.type === "int") { + let parsed = parseInt(String(val), 10); + this._values[property.key] = isNaN(parsed) ? val : parsed; + } else { + this._values[property.key] = val; + } + } + + /** + * @param {ConfigProperty|string} property + */ + delete(property) { + const key = typeof property === "string" ? property : property.key; + this._values[key] = "#DEL#"; + } + + /** + * @param {ConfigProperty} property + * @returns {boolean} + */ + isDeleted(property) { + return this._values[property.key] === "#DEL#"; + } + + /** + * @returns {Record} + */ + toJSON() { + /** @type {Record} */ + let obj = {}; + for (const [key, value] of Object.entries(this._values)) { + if (value !== null) { + obj[key] = value; + } + } + return obj; + } +} + +export class ShareActions { + /** + * @param {Record} [data] + */ + constructor(data = {}) { + /** @type {ShareConfig} */ + this._config = new ShareConfig(data.config || {}); + for (const [key, value] of Object.entries(data)) { + if (key !== "config") { + (/** @type {any} */ (this))[key] = value; + } + } + } + + /** + * @returns {ShareConfig} + */ + get config() { + return this._config; + } + + /** + * @param {ShareConfig|Record} value + */ + set config(value) { + if (value instanceof ShareConfig) { + this._config = value; + } else { + this._config = new ShareConfig(value || {}); + } + } + + /** + * @returns {Record|undefined} + */ + toJSON() { + /** @type {Record} */ + let obj = {}; + for (const [key, value] of Object.entries(this)) { + if (key === "_config" && value instanceof ShareConfig) { + let configJSON = value.toJSON(); + if (Object.keys(configJSON).length > 0) { + obj.config = configJSON; + } + } else if (value !== null && value !== undefined) { + obj[key] = value; + } + } + return Object.keys(obj).length > 0 ? obj : undefined; + } +} export class Share { /** @@ -160,6 +310,40 @@ export class Share { /** @type {number} */ this.TimestampUpdated = data.TimestampUpdated || 0; /** @type {Object} */ this.Properties = data.Properties || {}; /** @type {string} */ this.Conversion = data.Conversion || ""; + /** @type {ShareActions} */ this._Actions = new ShareActions(); + this.Actions = data.Actions || {}; + } + + /** + * @returns {ShareActions} + */ + get Actions() { + return this._Actions; + } + + /** + * @param {ShareActions|Record} value + */ + set Actions(value) { + if (value instanceof ShareActions) { + this._Actions = value; + } else { + this._Actions = new ShareActions(value || {}); + } + } + + /** + * @returns {ShareConfig} + */ + get config() { + return this.Actions.config; + } + + /** + * @param {ShareConfig|Record} value + */ + set config(value) { + this.Actions.config = value; } } @@ -247,6 +431,7 @@ export function add_share_by_token( Hidden: share.HiddenByOwner, Properties: share.Properties, Conversion: share.Conversion, + Actions: share.Actions, }, function (response) { let json_response = JSON.parse(response); @@ -288,6 +473,7 @@ export function add_share_by_map( User: share.User, PathOrToken: decodeURIComponent(share.PathOrToken), Conversion: share.Conversion, + Actions: share.Actions, }, function (response) { let json_response = JSON.parse(response); @@ -390,6 +576,7 @@ export function update_share_by_token( Hidden: share.HiddenByOwner, Properties: share.Properties, Conversion: share.Conversion, + Actions: share.Actions, }, function (response) { let json_response = JSON.parse(response); @@ -431,6 +618,7 @@ export function update_share_by_map( Hidden: share.HiddenByOwner, Properties: share.Properties, Conversion: share.Conversion, + Actions: share.Actions, }, function (response) { let json_response = JSON.parse(response); diff --git a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js index 27c09cc63..fba41fcb2 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js @@ -19,12 +19,12 @@ * along with this program. If not, see . */ -import { Share, add_share_by_map, add_share_by_token, get_property_key, update_share_by_map, update_share_by_token } from "../api/sharing.js"; +import { BDAY_CONFIG, Share, ShareConfig, add_share_by_map, add_share_by_token, get_property_key, update_share_by_map, update_share_by_token } from "../api/sharing.js"; import { CollectionType, Permission } from "../models/collection.js"; import { extract_title, update_title_and_description } from "../utils/collection_utils.js"; import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; -import { FormValidator, validate_href, validate_non_empty, validate_not_empty_or_equals } from "../utils/form_validator.js"; +import { FormValidator, validate_href, validate_integer, validate_non_empty, validate_not_empty_or_equals } from "../utils/form_validator.js"; import { get_element, get_element_by_id, onCleanHREFinput, random_uuid } from "../utils/misc.js"; import { Scene, is_current_scene, pop_scene } from "./scene_manager.js"; @@ -64,6 +64,8 @@ export class CreateEditShareScene { this._token_write_warning = /** @type {HTMLElement} */ (get_element(this._html_scene, "[data-name=token_write_warning]")); this._conversions_details = /** @type {HTMLDetailsElement} */ (get_element(this._html_scene, "[data-name=conversions]")); this._conversions_container = get_element(this._html_scene, "[data-name=conversions_container]"); + this._config_details = /** @type {HTMLDetailsElement} */ (get_element(this._html_scene, "[data-name=config]")); + this._config_container = get_element(this._html_scene, "[data-name=config_container]"); this._properties_fieldset = /** @type {HTMLDetailsElement} */ (get_element(this._html_scene, "[data-name=properties_override]")); this._displayname_override_enabled = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=displayname_override_enabled]")); @@ -78,9 +80,12 @@ export class CreateEditShareScene { this._cancel_btn = get_element(this._html_scene, "[data-name=cancel]"); this._errorHandler = new ErrorHandler(this._error_form); - this._map_validator = new FormValidator(this._errorHandler); + this._validator = new FormValidator(this._errorHandler); - this._map_validator.addValidator(this._shareuser_input, () => { + this._validator.addValidator(this._shareuser_input, () => { + if (this._shareType !== "map") { + return null; + } let conversion = this._get_selected_conversion(); if (conversion != "none") { return validate_non_empty(this._shareuser_input, "Share User")(); @@ -88,7 +93,34 @@ export class CreateEditShareScene { return validate_not_empty_or_equals(this._shareuser_input, user, "Share User")(); } }); - this._map_validator.addValidator(this._sharehref_input, validate_href(this._sharehref_input, "Share Href")); + this._validator.addValidator(this._sharehref_input, () => { + if (this._shareType !== "map") { + return null; + } + return validate_href(this._sharehref_input, "Share Href")(); + }); + + this._validator.addValidator(/** @type {any} */(this._form), () => { + let conversion = this._get_selected_conversion(); + if (conversion === "bday") { + for (let property of BDAY_CONFIG) { + if (property.type === "int") { + /** @type {HTMLInputElement | null} */ + let textInput = this._config_container.querySelector("#newshare_config_" + property.key); + /** @type {HTMLInputElement | null} */ + let deleteCheckbox = this._config_container.querySelector("#newshare_config_del_" + property.key); + let isDeleted = deleteCheckbox ? deleteCheckbox.checked : false; + if (textInput && !isDeleted) { + let error = validate_integer(textInput, property.displayName)(); + if (error) { + return error; + } + } + } + } + } + return null; + }); this._sharehref_input.addEventListener("input", onCleanHREFinput); @@ -150,8 +182,75 @@ export class CreateEditShareScene { } } - this._map_validator.validate(); + this._validator.validate(); } + if (conversion === "bday") { + this._config_details.classList.remove("hidden"); + this._config_details.open = true; + } else { + this._config_details.classList.add("hidden"); + } + } + + /** + * @param {readonly import("../api/sharing.js").ConfigProperty[]} config_properties + * @param {string} conversion + */ + _create_config_container(config_properties, conversion) { + this._config_container.innerHTML = ""; + config_properties.forEach(property => { + let row = document.createElement("div"); + row.className = "property-override"; + + let label = document.createElement("label"); + label.textContent = property.displayName + ":"; + label.htmlFor = "newshare_config_" + property.key; + + let textInput = document.createElement("input"); + textInput.type = "text"; + textInput.id = "newshare_config_" + property.key; + textInput.dataset.key = property.key; + + let deleteCheckbox = document.createElement("input"); + deleteCheckbox.type = "checkbox"; + deleteCheckbox.id = "newshare_config_del_" + property.key; + deleteCheckbox.dataset.key = property.key; + + let deleteLabel = document.createElement("label"); + deleteLabel.textContent = "delete"; + deleteLabel.htmlFor = deleteCheckbox.id; + + let val = null; + let isDel = false; + if (this._edit && this._share && this._share.Conversion === conversion && this._share.config) { + val = this._share.config.get(property); + isDel = this._share.config.isDeleted(property); + } + + if (isDel) { + deleteCheckbox.checked = true; + textInput.disabled = true; + textInput.value = ""; + } else { + deleteCheckbox.checked = false; + textInput.disabled = false; + textInput.value = val !== null ? String(val) : ""; + } + + deleteCheckbox.onchange = () => { + if (deleteCheckbox.checked) { + textInput.disabled = true; + } else { + textInput.disabled = false; + } + }; + + row.appendChild(label); + row.appendChild(deleteCheckbox); + row.appendChild(deleteLabel); + row.appendChild(textInput); + this._config_container.appendChild(row); + }); } _oncancel() { @@ -165,10 +264,8 @@ export class CreateEditShareScene { _onsubmit() { try { - if (this._shareType === "map") { - if (!this._map_validator.validate()) { - return false; - } + if (!this._validator.validate()) { + return false; } let conversion = this._get_selected_conversion(); let is_conversion = conversion != "none"; @@ -209,6 +306,33 @@ export class CreateEditShareScene { } }; + /** @type {Record} */ + let new_actions = {}; + if (this._edit && this._share && this._share.Actions) { + new_actions = JSON.parse(JSON.stringify(this._share.Actions)); + } + if (conversion_value === "bday") { + let new_config = new ShareConfig(); + BDAY_CONFIG.forEach(property => { + /** @type {HTMLInputElement | null} */ + let textInput = this._config_container.querySelector("#newshare_config_" + property.key); + /** @type {HTMLInputElement | null} */ + let deleteCheckbox = this._config_container.querySelector("#newshare_config_del_" + property.key); + if (deleteCheckbox && deleteCheckbox.checked) { + new_config.delete(property); + } else if (textInput) { + new_config.set(property, textInput.value); + } + }); + new_actions.config = new_config; + } else { + let new_config = new ShareConfig(); + BDAY_CONFIG.forEach(property => { + new_config.delete(property); + }); + new_actions.config = new_config; + } + let new_share = new Share({ ShareType: this._shareType, PathMapped: this._pathMapped, @@ -221,6 +345,7 @@ export class CreateEditShareScene { User: (this._edit && this._share) ? this._share.User : this._shareuser_input.value, PathOrToken: (this._edit && this._share) ? this._share.PathOrToken : (this._shareType === "map" ? "/" + this._shareuser_input.value + "/" + this._sharehref_input.value + "/" : ""), Conversion: conversion_value, + Actions: new_actions, }); if (this._edit) { @@ -255,6 +380,8 @@ export class CreateEditShareScene { this._cancel_btn.onclick = () => this._oncancel(); this._form.onsubmit = () => this._onsubmit(); + this._create_config_container(BDAY_CONFIG, "bday"); + let onChangeCallback = () => this._on_permissions_change(); this._permissions_ro_radio.addEventListener("change", onChangeCallback); this._permissions_rw_radio.addEventListener("change", onChangeCallback); @@ -409,12 +536,12 @@ export class CreateEditShareScene { } this._sharehref_input.disabled = this._edit; this._sharemapfields.classList.remove("hidden"); - this._map_validator.validate(); } else { this._sharehref_input.value = ""; this._sharemapfields.classList.add("hidden"); this._errorHandler.clearError(); } + this._validator.validate(); this._on_permissions_change(); update_title_and_description(this._collection, this._title, this._description); diff --git a/radicale/web/internal_data/js/utils/form_validator.js b/radicale/web/internal_data/js/utils/form_validator.js index 81b678eb9..6a5d3a94f 100644 --- a/radicale/web/internal_data/js/utils/form_validator.js +++ b/radicale/web/internal_data/js/utils/form_validator.js @@ -170,3 +170,23 @@ export function validate_files(input, field_name) { return "Please select at least one " + field_name; }; } + +/** + * Validates that the input is a valid integer (if not empty). + * @param {HTMLInputElement} input + * @param {string} field_name + * @returns {function(): ?string} + */ +export function validate_integer(input, field_name) { + return () => { + let value = input.value.trim(); + if (!value) { + return null; + } + let parsed = parseInt(value, 10); + if (isNaN(parsed) || String(parsed) !== value) { + return field_name + " must be an integer"; + } + return null; + }; +}