ui: Allow action configs for bday conversion

This commit is contained in:
Max Berger
2026-06-07 14:21:43 +02:00
parent 77bef83193
commit 1d8d734dca
6 changed files with 679 additions and 13 deletions

View File

@@ -0,0 +1,323 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2026-2026 Max Berger <max@berger.name>
#
# 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 <http://www.gnu.org/licenses/>.
"""
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"]')

View File

@@ -1324,7 +1324,8 @@ class BaseSharing:
User=User,
Timestamp=Timestamp,
Properties=Properties,
Actions=Actions)
Actions=Actions,
Conversion=Conversion)
else:
result = self.database_update_sharing(
ShareType=ShareType,
@@ -1337,7 +1338,8 @@ class BaseSharing:
User=User,
Timestamp=Timestamp,
Properties=Properties,
Actions=Actions)
Actions=Actions,
Conversion=Conversion)
elif user == share['User']:
# User is only allowed to update Properties

View File

@@ -306,6 +306,12 @@
<!-- Dynamic radio buttons will be inserted here -->
</div>
</details>
<details data-name="config" class="hidden">
<summary>Config</summary>
<div data-name="config_container">
<!-- Dynamic edit fields will be inserted here -->
</div>
</details>
<details data-name="properties_override">
<summary>Properties override</summary>
<div class="property-override">

View File

@@ -138,8 +138,158 @@ export function discover_server_features(user, password, callback) {
* @property {number} [TimestampUpdated]
* @property {Object<String, String>} [Properties]
* @property {string} [Conversion]
* @property {Object<String, *>} [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<string, any>} [data]
*/
constructor(data = {}) {
/** @type {Record<string, any>} */
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<string, any>}
*/
toJSON() {
/** @type {Record<string, any>} */
let obj = {};
for (const [key, value] of Object.entries(this._values)) {
if (value !== null) {
obj[key] = value;
}
}
return obj;
}
}
export class ShareActions {
/**
* @param {Record<string, any>} [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<string, any>} value
*/
set config(value) {
if (value instanceof ShareConfig) {
this._config = value;
} else {
this._config = new ShareConfig(value || {});
}
}
/**
* @returns {Record<string, any>|undefined}
*/
toJSON() {
/** @type {Record<string, any>} */
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<String, String>} */ 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<string, any>} 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<string, any>} 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);

View File

@@ -19,12 +19,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<string, any>} */
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);

View File

@@ -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;
};
}