From e4d9bd7fb82a6c0d4c00b66ae8bd0d01a726ece9 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sun, 15 Mar 2026 00:24:19 +0100 Subject: [PATCH] Move scene management logic into scene_manager --- integ_tests/test_scenes.py | 122 ++++++++++++++++++ radicale/web/internal_data/js/main.js | 2 +- .../js/scenes/CollectionsScene.js | 25 ++-- .../js/scenes/CreateEditCollectionScene.js | 15 +-- .../js/scenes/CreateEditShareScene.js | 14 +- .../js/scenes/DeleteCollectionScene.js | 16 +-- .../js/scenes/IncomingSharingScene.js | 29 ++--- .../web/internal_data/js/scenes/LoginScene.js | 34 ++--- .../js/scenes/ShareCollectionScene.js | 13 +- .../js/scenes/UploadCollectionScene.js | 7 +- .../internal_data/js/scenes/scene_manager.js | 83 +++++++++--- 11 files changed, 249 insertions(+), 111 deletions(-) create mode 100644 integ_tests/test_scenes.py diff --git a/integ_tests/test_scenes.py b/integ_tests/test_scenes.py new file mode 100644 index 00000000..a2cd1ad6 --- /dev/null +++ b/integ_tests/test_scenes.py @@ -0,0 +1,122 @@ +# 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 scene navigation +""" + +import pathlib +from typing import Any, Generator + +import pytest +from playwright.sync_api import Page, expect + +from integ_tests.common import create_collection, login, start_radicale_server + + +@pytest.fixture +def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path) + + +def test_navigation_create_collection_cancel(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() + + page.click('a[data-name="new"]') + expect(page.locator("#createcollectionscene")).to_be_visible() + + page.click('#createcollectionscene button[data-name="cancel"]') + expect(page.locator("#createcollectionscene")).to_be_hidden() + expect(page.locator("#collectionsscene")).to_be_visible() + + +def test_navigation_create_collection_submit(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() + + page.click('a[data-name="new"]') + expect(page.locator("#createcollectionscene")).to_be_visible() + + page.locator('#createcollectionscene input[data-name="displayname"]').fill( + "Nav Test Col" + ) + page.click('#createcollectionscene button[data-name="submit"]') + + expect(page.locator("#createcollectionscene")).to_be_hidden() + expect(page.locator("#collectionsscene")).to_be_visible() + expect(page.locator("article:has-text('Nav Test Col')")).to_be_visible() + + +def test_navigation_delete_collection_cancel(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() + + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="delete"]', force=True) + expect(page.locator("#deletecollectionscene")).to_be_visible() + + page.click('#deletecollectionscene button[data-name="cancel"]') + expect(page.locator("#deletecollectionscene")).to_be_hidden() + expect(page.locator("#collectionsscene")).to_be_visible() + + +def test_navigation_delete_collection_confirm(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() + + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="delete"]', force=True) + expect(page.locator("#deletecollectionscene")).to_be_visible() + + # We need to fill the confirmation text + confirmation_text = page.locator( + "#deletecollectionscene [data-name='deleteconfirmationtext']" + ).inner_text() + page.locator("#deletecollectionscene input[data-name='confirmationtxt']").fill( + confirmation_text + ) + page.click('#deletecollectionscene button[data-name="delete"]') + + expect(page.locator("#deletecollectionscene")).to_be_hidden() + expect(page.locator("#collectionsscene")).to_be_visible() + expect(page.locator("article:not(.hidden)")).to_have_count(0) + + +def test_navigation_refresh_button(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() + + page.click('#logoutview a[data-name="refresh"]') + # It shows LoadingScene briefly then back to CollectionsScene + expect(page.locator("#collectionsscene")).to_be_visible() + + +def test_login_logout_login(page: Page, radicale_server: str) -> None: + # 1. First login + login(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() + + # 2. Logout + page.click('#logoutview a[data-name="logout"]') + expect(page.locator("#loginscene")).to_be_visible() + expect(page.locator("#collectionsscene")).to_be_hidden() + + # 3. Second login + login(page, radicale_server) + expect(page.locator("#collectionsscene")).to_be_visible() diff --git a/radicale/web/internal_data/js/main.js b/radicale/web/internal_data/js/main.js index 31ee7c06..c8495878 100644 --- a/radicale/web/internal_data/js/main.js +++ b/radicale/web/internal_data/js/main.js @@ -24,4 +24,4 @@ import { push_scene } from "./scenes/scene_manager.js"; // Hide startup loading message document.getElementById("loadingscene").classList.add("hidden"); -push_scene(new LoginScene(), false); \ No newline at end of file +push_scene(new LoginScene()); \ No newline at end of file diff --git a/radicale/web/internal_data/js/scenes/CollectionsScene.js b/radicale/web/internal_data/js/scenes/CollectionsScene.js index d6883f66..1c850acd 100644 --- a/radicale/web/internal_data/js/scenes/CollectionsScene.js +++ b/radicale/web/internal_data/js/scenes/CollectionsScene.js @@ -29,7 +29,7 @@ import { CreateEditCollectionScene } from "./CreateEditCollectionScene.js"; import { DeleteCollectionScene } from "./DeleteCollectionScene.js"; import { IncomingSharingScene } from "./IncomingSharingScene.js"; import { LoadingScene } from "./LoadingScene.js"; -import { Scene, pop_scene, push_scene, scene_stack } from "./scene_manager.js"; +import { Scene, is_current_scene, pop_scene, push_scene } from "./scene_manager.js"; import { ShareCollectionScene, maybe_enable_sharing_options } from "./ShareCollectionScene.js"; import { UploadCollectionScene } from "./UploadCollectionScene.js"; @@ -51,7 +51,6 @@ export class CollectionsScene { /** @type {HTMLElement} */ let upload_btn = html_scene.querySelector("[data-name=upload]"); /** @type {HTMLElement} */ let incomingshares_btn = html_scene.querySelector("[data-name=incomingshares]"); - /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let collections_req = null; /** @type {?Array} */ let child_collections = null; /** @type {Array} */ let nodes = []; @@ -59,7 +58,7 @@ export class CollectionsScene { function onnew() { try { let create_collection_scene = new CreateEditCollectionScene(user, password, principal_collection); - push_scene(create_collection_scene, false); + push_scene(create_collection_scene); } catch (err) { console.error(err); } @@ -69,7 +68,7 @@ export class CollectionsScene { function onupload() { try { let upload_scene = new UploadCollectionScene(user, password, principal_collection); - push_scene(upload_scene, false); + push_scene(upload_scene); } catch (err) { console.error(err); } @@ -79,7 +78,7 @@ export class CollectionsScene { function onincomingshares() { try { let incoming_sharing_scene = new IncomingSharingScene(user, password); - push_scene(incoming_sharing_scene, false); + push_scene(incoming_sharing_scene); } catch (err) { console.error(err); } @@ -92,7 +91,7 @@ export class CollectionsScene { function onedit(collection) { try { let edit_collection_scene = new CreateEditCollectionScene(user, password, collection); - push_scene(edit_collection_scene, false); + push_scene(edit_collection_scene); } catch (err) { console.error(err); } @@ -105,7 +104,7 @@ export class CollectionsScene { function onshare(collection) { try { let share_collection_scene = new ShareCollectionScene(user, password, collection); - push_scene(share_collection_scene, false); + push_scene(share_collection_scene); } catch (err) { console.error(err); } @@ -118,7 +117,7 @@ export class CollectionsScene { function ondelete(collection) { try { let delete_collection_scene = new DeleteCollectionScene(user, password, collection); - push_scene(delete_collection_scene, false); + push_scene(delete_collection_scene); } catch (err) { console.error(err); } @@ -195,18 +194,18 @@ export class CollectionsScene { function update() { let loading_scene = new LoadingScene(); - push_scene(loading_scene, false); + push_scene(loading_scene); collections_req = get_collections(user, password, principal_collection, function (child_collections_, error) { - if (scene_index === null) { + if (!is_current_scene(loading_scene)) { return; } collections_req = null; if (error) { onerror(error); - pop_scene(scene_index - 1); + pop_scene(); } else { child_collections = child_collections_; - pop_scene(scene_index); + pop_scene(); } }); } @@ -226,7 +225,6 @@ export class CollectionsScene { }; this.hide = function () { html_scene.classList.add("hidden"); - scene_index = scene_stack.length - 1; new_btn.onclick = null; upload_btn.onclick = null; incomingshares_btn.onclick = null; @@ -238,7 +236,6 @@ export class CollectionsScene { nodes = []; }; this.release = function () { - scene_index = null; if (collections_req !== null) { collections_req.abort(); collections_req = null; diff --git a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js index b780cca7..21a3cdf4 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js @@ -26,7 +26,7 @@ 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"; +import { Scene, is_current_scene, pop_scene, pop_to_parent, push_scene } from "./scene_manager.js"; /** * @implements {Scene} @@ -55,7 +55,6 @@ export class CreateEditCollectionScene { /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); - /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let create_edit_req = null; /** @type {?HTMLSelectElement} */ let saved_type_form = null; @@ -130,18 +129,18 @@ export class CreateEditCollectionScene { sane_color = COLOR_RE.exec(sane_color)[1]; } let loading_scene = new LoadingScene(); - push_scene(loading_scene, false); + push_scene(loading_scene); let collection = new Collection(href, type, displayname, description, sane_color, 0, 0, source); let callback = function (error1) { - if (scene_index === null) { + if (!is_current_scene(loading_scene)) { return; } create_edit_req = null; if (error1) { errorHandler.setError(error1); - pop_scene(scene_index); + pop_scene(); } else { - pop_scene(scene_index - 1); + pop_to_parent(); } }; if (edit) { @@ -157,7 +156,7 @@ export class CreateEditCollectionScene { function oncancel() { try { - pop_scene(scene_index - 1); + pop_scene(); } catch (err) { console.error(err); } @@ -179,7 +178,6 @@ export class CreateEditCollectionScene { this.show = function () { this.release(); - scene_index = scene_stack.length - 1; // Clone type_form because it's impossible to hide options without removing them saved_type_form = type_form; type_form = /** @type {HTMLSelectElement} */ (type_form.cloneNode(true)); @@ -205,7 +203,6 @@ export class CreateEditCollectionScene { cancel_btn.onclick = null; }; this.release = function () { - scene_index = null; if (create_edit_req !== null) { create_edit_req.abort(); create_edit_req = null; diff --git a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js index 2125024d..9ff49b0e 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js @@ -24,7 +24,7 @@ import { CollectionType } from "../models/collection.js"; import { ErrorHandler } from "../utils/error.js"; import { FormValidator, validate_href, validate_not_empty_or_equals } from "../utils/form_validator.js"; import { onCleanHREFinput, random_uuid } from "../utils/misc.js"; -import { Scene, pop_scene, scene_stack } from "./scene_manager.js"; +import { Scene, is_current_scene, pop_scene } from "./scene_manager.js"; /** * @implements {Scene} @@ -38,6 +38,7 @@ export class CreateEditShareScene { * @param {Share} [share] If provided, the scene will be in edit mode. */ constructor(user, password, collection, shareType, share) { + let self = this; let edit = !!share; let pathMapped = collection.href; /** @type {HTMLElement} */ let html_scene = document.getElementById("newshare"); @@ -75,13 +76,10 @@ export class CreateEditShareScene { color_override_input.disabled = !color_override_enabled.checked; }; - /** @type {?number} */ let scene_index = null; function oncancel() { try { - if (scene_index !== null) { - pop_scene(scene_index - 1); - } + pop_scene(); } catch (err) { console.error(err); } @@ -110,13 +108,13 @@ export class CreateEditShareScene { } let callback = function (/** @type {string} */ error) { - if (scene_index === null) { + if (!is_current_scene(self)) { return; } if (error) { errorHandler.setError(error); } else { - pop_scene(scene_index - 1); + pop_scene(); } }; @@ -154,7 +152,6 @@ export class CreateEditShareScene { this.show = function () { this.release(); - scene_index = scene_stack.length - 1; html_scene.classList.remove("hidden"); cancel_btn.onclick = oncancel; form.onsubmit = onsubmit; @@ -229,7 +226,6 @@ export class CreateEditShareScene { }; this.release = function () { - scene_index = null; }; } } diff --git a/radicale/web/internal_data/js/scenes/DeleteCollectionScene.js b/radicale/web/internal_data/js/scenes/DeleteCollectionScene.js index 878b8a19..16546bbc 100644 --- a/radicale/web/internal_data/js/scenes/DeleteCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/DeleteCollectionScene.js @@ -25,7 +25,7 @@ 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"; +import { Scene, is_current_scene, pop_scene, pop_to_parent, push_scene } from "./scene_manager.js"; /** * @implements {Scene} @@ -49,7 +49,6 @@ export class DeleteCollectionScene { confirmation_txt.value = ""; confirmation_txt.addEventListener("keydown", onkeydown); - /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let delete_req = null; let errorHandler = new ErrorHandler(error_form); @@ -63,18 +62,17 @@ export class DeleteCollectionScene { } try { let loading_scene = new LoadingScene(); - push_scene(loading_scene, false); + push_scene(loading_scene); delete_req = delete_collection(user, password, collection, function (error1) { - if (scene_index === null) { + if (!is_current_scene(loading_scene)) { return; } delete_req = null; - delete_req = null; if (error1) { errorHandler.setError(error1); - pop_scene(scene_index); + pop_scene(); } else { - pop_scene(scene_index - 1); + pop_to_parent(); } }); } catch (err) { @@ -85,7 +83,7 @@ export class DeleteCollectionScene { function oncancel() { try { - pop_scene(scene_index - 1); + pop_scene(); } catch (err) { console.error(err); } @@ -101,7 +99,6 @@ export class DeleteCollectionScene { this.show = function () { this.release(); - scene_index = scene_stack.length - 1; html_scene.classList.remove("hidden"); title_form.textContent = collection.displayname || collection.href; delete_btn.onclick = ondelete; @@ -114,7 +111,6 @@ export class DeleteCollectionScene { delete_btn.onclick = null; }; this.release = function () { - scene_index = null; if (delete_req !== null) { delete_req.abort(); delete_req = null; diff --git a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js index 345f7445..d9652a60 100644 --- a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js +++ b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js @@ -23,7 +23,7 @@ import { Share, reload_sharing_list, update_incoming_share } from "../api/sharin import { ErrorHandler } from "../utils/error.js"; import { displayPermissions } from "../utils/permissions.js"; import { LoadingScene } from "./LoadingScene.js"; -import { Scene, pop_scene, push_scene, scene_stack } from "./scene_manager.js"; +import { Scene, is_current_scene, pop_scene, push_scene } from "./scene_manager.js"; /** * @implements {Scene} @@ -42,12 +42,11 @@ export class IncomingSharingScene { let error_handler = new ErrorHandler(error_element); - /** @type {?number} */ let scene_index = null; /** @type {Array} */ let nodes = []; /** @type {?Array} */ let shares_cache = null; function on_cancel() { - pop_scene(scene_index - 1); + pop_scene(); } /** @@ -100,7 +99,7 @@ export class IncomingSharingScene { let pathortoken_td = node.querySelector("[data-name=pathortoken]"); let owner_td = node.querySelector("[data-name=owner]"); - let permissions_td = /** @type {HTMLElement} */ node.querySelector("[data-name=permissions]"); + let permissions_td = /** @type {HTMLElement} */ (node.querySelector("[data-name=permissions]")); let enabled_cb = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=enabled]")); let hidden_cb = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=hidden]")); @@ -126,26 +125,25 @@ export class IncomingSharingScene { function update() { let loading_scene = new LoadingScene(); - push_scene(loading_scene, false); + push_scene(loading_scene); error_handler.clearError(); reload_sharing_list(user, password, null, function (shares, error) { - if (scene_index === null) { + if (!is_current_scene(loading_scene)) { return; } if (error) { error_handler.setError(error); - pop_scene(scene_index - 1); + pop_scene(); } else { shares_cache = shares; - pop_scene(scene_index); + pop_scene(); } }); } this.show = function () { - scene_index = scene_stack.length - 1; html_scene.classList.remove("hidden"); cancel_btn.onclick = on_cancel; if (shares_cache === null) { @@ -159,18 +157,17 @@ export class IncomingSharingScene { html_scene.classList.add("hidden"); cancel_btn.onclick = null; error_handler.clearError(); + }; + this.release = function () { + error_handler.clearError(); nodes.forEach(function (node) { - node.parentNode.removeChild(node); + if (node.parentNode) { + node.parentNode.removeChild(node); + } }); nodes = []; shares_cache = null; }; - - this.release = function () { - scene_index = null; - error_handler.clearError(); - shares_cache = null; - }; } } diff --git a/radicale/web/internal_data/js/scenes/LoginScene.js b/radicale/web/internal_data/js/scenes/LoginScene.js index 9d69e645..45d45725 100644 --- a/radicale/web/internal_data/js/scenes/LoginScene.js +++ b/radicale/web/internal_data/js/scenes/LoginScene.js @@ -20,11 +20,11 @@ */ 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"; +import { CollectionsScene } from "./CollectionsScene.js"; +import { LoadingScene } from "./LoadingScene.js"; +import { Scene, is_current_scene, pop_scene, pop_to_root, push_scene, replace_scene } from "./scene_manager.js"; /** * @constructor * @implements {Scene} @@ -41,7 +41,6 @@ export class LoginScene { /** @type {HTMLElement} */ let logout_btn = logout_view.querySelector("[data-name=logout]"); /** @type {HTMLElement} */ let refresh_btn = logout_view.querySelector("[data-name=refresh]"); - /** @type {?number} */ let scene_index = null; let user = ""; /** @type {?XMLHttpRequest} */ let principal_req = null; let errorHandler = new ErrorHandler(error_form); @@ -71,15 +70,15 @@ export class LoginScene { logout_user_form.textContent = user + "'s Collections"; // Fetch principal let loading_scene = new LoadingScene(); - push_scene(loading_scene, false); + push_scene(loading_scene); principal_req = get_principal(user, password, function (principal_collection, error1) { - if (scene_index === null) { + if (!is_current_scene(loading_scene)) { return; } principal_req = null; if (error1) { errorHandler.setError(error1); - pop_scene(scene_index); + pop_scene(); } else { // show collections let saved_user = user; @@ -89,7 +88,7 @@ export class LoginScene { errorHandler.setError(error1); user = saved_user; }); - push_scene(collections_scene, true); + replace_scene(collections_scene); } }); } catch (err) { @@ -98,18 +97,15 @@ export class LoginScene { return false; } - function onlogout() { + let onlogout = function () { try { - if (scene_index === null) { - return false; - } user = ""; - pop_scene(scene_index); + pop_to_root(); } catch (err) { console.error(err); } return false; - } + }; function remove_logout() { @@ -120,10 +116,10 @@ export class LoginScene { } function refresh() { - //The easiest way to refresh is to push a LoadingScene onto the stack and then pop it - //forcing the scene below it, the Collections Scene to refresh itself. - push_scene(new LoadingScene(), false); - pop_scene(scene_stack.length - 2); + // The easiest way to refresh is to push a LoadingScene onto the stack and then pop it + // forcing the scene below it, the Collections Scene to refresh itself. + push_scene(new LoadingScene()); + pop_scene(); } this.show = function () { @@ -131,7 +127,6 @@ export class LoginScene { fill_form(); form.onsubmit = onlogin; html_scene.classList.remove("hidden"); - scene_index = scene_stack.length - 1; user_form.focus(); }; this.hide = function () { @@ -140,7 +135,6 @@ export class LoginScene { form.onsubmit = null; }; this.release = function () { - scene_index = null; // cancel pending requests if (principal_req !== null) { principal_req.abort(); diff --git a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js index 779e5aba..393a4634 100644 --- a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js @@ -29,7 +29,7 @@ import { Collection } from "../models/collection.js"; import { ErrorHandler } from "../utils/error.js"; import { displayPermissions } from "../utils/permissions.js"; import { CreateEditShareScene } from "./CreateEditShareScene.js"; -import { Scene, pop_scene, push_scene, scene_stack } from "./scene_manager.js"; +import { Scene, pop_scene, push_scene } from "./scene_manager.js"; /** * @implements {Scene} @@ -41,7 +41,6 @@ export class ShareCollectionScene { * @param {Collection} collection The collection on which to edit sharing setting. Must exist. */ constructor(user, password, collection) { - /** @type {?number} */ let scene_index = null; let html_scene = document.getElementById("sharecollectionscene"); @@ -66,7 +65,7 @@ export class ShareCollectionScene { function oncancel() { try { - pop_scene(scene_index - 1); + pop_scene(); } catch (err) { console.error(err); } @@ -75,17 +74,16 @@ export class ShareCollectionScene { function onsharebytoken() { let create_edit_share_scene = new CreateEditShareScene(user, password, collection, "token"); - push_scene(create_edit_share_scene, false); + push_scene(create_edit_share_scene); } function onsharebymap() { let create_edit_share_scene = new CreateEditShareScene(user, password, collection, "map"); - push_scene(create_edit_share_scene, false); + push_scene(create_edit_share_scene); } this.show = function () { this.release(); - scene_index = scene_stack.length - 1; html_scene.classList.remove("hidden"); cancel_btn.onclick = oncancel; if (server_features.sharing && server_features.sharing.PermittedCreateCollectionByToken) { @@ -126,7 +124,6 @@ export class ShareCollectionScene { cancel_btn.onclick = null; }; this.release = function () { - scene_index = null; }; } } @@ -183,7 +180,7 @@ function add_share_row_node(user, password, collection, share, template, delete_ /** @type {HTMLElement} */ let edit_btn = node.querySelector("[data-name=edit]"); edit_btn.onclick = function () { let create_edit_share_scene = new CreateEditShareScene(user, password, collection, share.ShareType, share); - push_scene(create_edit_share_scene, false); + push_scene(create_edit_share_scene); }; /** @type {HTMLElement} */ let delete_btn = node.querySelector("[data-name=delete]"); diff --git a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js index 9ff5a9fa..310a7ef9 100644 --- a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js @@ -24,7 +24,7 @@ import { Collection } from "../models/collection.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"; +import { Scene, pop_scene } from "./scene_manager.js"; /** * @implements {Scene} @@ -62,7 +62,6 @@ export class UploadCollectionScene { 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 = []; /** @type {?Array} */ let nodes = null; @@ -128,7 +127,7 @@ export class UploadCollectionScene { function onclose() { try { - pop_scene(scene_index - 1); + pop_scene(); } catch (err) { console.error(err); } @@ -187,7 +186,6 @@ export class UploadCollectionScene { } this.show = function () { - scene_index = scene_stack.length - 1; html_scene.classList.remove("hidden"); close_btn.onclick = onclose; }; @@ -216,7 +214,6 @@ export class UploadCollectionScene { nodes = null; }; this.release = function () { - scene_index = null; if (upload_req !== null) { upload_req.abort(); upload_req = null; diff --git a/radicale/web/internal_data/js/scenes/scene_manager.js b/radicale/web/internal_data/js/scenes/scene_manager.js index 6fad8129..b54a20ae 100644 --- a/radicale/web/internal_data/js/scenes/scene_manager.js +++ b/radicale/web/internal_data/js/scenes/scene_manager.js @@ -42,44 +42,89 @@ export class Scene { /** * @type {Array} */ -export let scene_stack = []; +let scene_stack = []; /** * Push scene onto stack. * @param {Scene} scene - * @param {boolean} replace Replace the scene on top of the stack. */ -export function push_scene(scene, replace) { +export function push_scene(scene) { if (scene_stack.length >= 1) { scene_stack[scene_stack.length - 1].hide(); - if (replace) { - scene_stack.pop().release(); - } } scene_stack.push(scene); scene.show(); } /** - * Remove scenes from stack. - * @param {number} index New top of stack + * Replace the current scene with a new one. + * @param {Scene} scene */ -export function pop_scene(index) { - if (scene_stack.length - 1 <= index) { +export function replace_scene(scene) { + if (scene_stack.length >= 1) { + scene_stack[scene_stack.length - 1].hide(); + scene_stack.pop().release(); + } + scene_stack.push(scene); + scene.show(); +} + +/** + * Remove the current scene from the stack. + */ +export function pop_scene() { + if (scene_stack.length === 0) { return; } scene_stack[scene_stack.length - 1].hide(); - while (scene_stack.length - 1 > index) { - let old_length = scene_stack.length; + scene_stack.pop().release(); + if (scene_stack.length >= 1) { + scene_stack[scene_stack.length - 1].show(); + } +} + +/** + * Pop the current scene and the one below it. + * Useful for returning to the parent of the caller. + */ +export function pop_to_parent() { + if (scene_stack.length === 0) { + return; + } + scene_stack[scene_stack.length - 1].hide(); + scene_stack.pop().release(); + if (scene_stack.length >= 1) { scene_stack.pop().release(); - if (old_length - 1 === index + 1) { - break; - } } if (scene_stack.length >= 1) { - let scene = scene_stack[scene_stack.length - 1]; - scene.show(); - } else { - throw "Scene stack is empty"; + scene_stack[scene_stack.length - 1].show(); } +} + +/** + * Pop all scenes until only the root scene remains. + * If the stack is empty, nothing happens. + */ +export function pop_to_root() { + if (scene_stack.length === 0) { + return; + } + // Pop all scenes until only the first one remains + while (scene_stack.length > 1) { + scene_stack[scene_stack.length - 1].hide(); + scene_stack.pop().release(); + } + // The root scene is now at the top (index 0) and should be shown + if (scene_stack.length === 1) { + scene_stack[0].show(); // Ensure the root scene is visible + } +} + +/** + * Check if the given scene is the current top scene. + * @param {Scene} scene + * @returns {boolean} + */ +export function is_current_scene(scene) { + return scene_stack.length > 0 && scene_stack[scene_stack.length - 1] === scene; } \ No newline at end of file