diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index dda898282..a91d8304d 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -78,7 +78,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
diff --git a/README.md b/README.md
index 2c52f0917..ba4d2569a 100644
--- a/README.md
+++ b/README.md
@@ -111,6 +111,7 @@ If you would like to support this project and have your avatar or company logo a
+
Consider this low-barrier form of contribution yourself.
diff --git a/examples/single_page_app/router_frame.js b/examples/single_page_app/router_frame.js
index 1f8d5b880..fceaf0942 100644
--- a/examples/single_page_app/router_frame.js
+++ b/examples/single_page_app/router_frame.js
@@ -1,10 +1,9 @@
export default {
template: "
",
mounted() {
+ const initial_path = window.location.pathname;
window.addEventListener("popstate", (event) => {
- if (event.state?.page) {
- this.$emit("open", event.state.page);
- }
+ this.$emit("open", event.state?.page || initial_path);
});
const connectInterval = setInterval(async () => {
if (window.socket.id === undefined) return;
diff --git a/nicegui/air.py b/nicegui/air.py
index 3ea6c6207..348e553d2 100644
--- a/nicegui/air.py
+++ b/nicegui/air.py
@@ -130,6 +130,8 @@ def _handle_handshake(data: Dict[str, Any]) -> bool:
return False
client = Client.instances[client_id]
client.environ = data['environ']
+ if data.get('old_tab_id'):
+ core.app.storage.copy_tab(data['old_tab_id'], data['tab_id'])
client.tab_id = data['tab_id']
client.on_air = True
client.handle_handshake()
diff --git a/nicegui/elements/tree.py b/nicegui/elements/tree.py
index c94e172fc..865e6e1cb 100644
--- a/nicegui/elements/tree.py
+++ b/nicegui/elements/tree.py
@@ -40,11 +40,13 @@ def __init__(self,
self._props['node-key'] = node_key
self._props['label-key'] = label_key
self._props['children-key'] = children_key
- self._props['selected'] = None
- self._props['expanded'] = []
- self._props['ticked'] = []
- if tick_strategy is not None:
- self._props['tick-strategy'] = tick_strategy
+ if on_select:
+ self._props['selected'] = None
+ if on_expand:
+ self._props['expanded'] = []
+ if on_tick or tick_strategy:
+ self._props['ticked'] = []
+ self._props['tick-strategy'] = tick_strategy or 'leaf'
self._select_handlers = [on_select] if on_select else []
self._expand_handlers = [on_expand] if on_expand else []
self._tick_handlers = [on_tick] if on_tick else []
@@ -79,6 +81,7 @@ def handle_ticked(e: GenericEventArguments) -> None:
def on_select(self, callback: Handler[ValueChangeEventArguments]) -> Self:
"""Add a callback to be invoked when the selection changes."""
+ self._props.setdefault('selected', None)
self._select_handlers.append(callback)
return self
@@ -87,6 +90,7 @@ def select(self, node_key: Optional[str]) -> Self:
:param node_key: node key to select
"""
+ self._props.setdefault('selected', None)
if self._props['selected'] != node_key:
self._props['selected'] = node_key
self.update()
@@ -98,11 +102,14 @@ def deselect(self) -> Self:
def on_expand(self, callback: Handler[ValueChangeEventArguments]) -> Self:
"""Add a callback to be invoked when the expansion changes."""
+ self._props.setdefault('expanded', [])
self._expand_handlers.append(callback)
return self
def on_tick(self, callback: Handler[ValueChangeEventArguments]) -> Self:
"""Add a callback to be invoked when a node is ticked or unticked."""
+ self._props.setdefault('ticked', [])
+ self._props.setdefault('tick-strategy', 'leaf')
self._tick_handlers.append(callback)
return self
@@ -111,6 +118,7 @@ def tick(self, node_keys: Optional[List[str]] = None) -> Self:
:param node_keys: list of node keys to tick or ``None`` to tick all nodes (default: ``None``)
"""
+ self._props.setdefault('ticked', [])
self._props['ticked'][:] = self._find_node_keys(node_keys).union(self._props['ticked'])
self.update()
return self
@@ -120,6 +128,7 @@ def untick(self, node_keys: Optional[List[str]] = None) -> Self:
:param node_keys: list of node keys to untick or ``None`` to untick all nodes (default: ``None``)
"""
+ self._props.setdefault('ticked', [])
self._props['ticked'][:] = set(self._props['ticked']).difference(self._find_node_keys(node_keys))
self.update()
return self
@@ -129,6 +138,7 @@ def expand(self, node_keys: Optional[List[str]] = None) -> Self:
:param node_keys: list of node keys to expand (default: all nodes)
"""
+ self._props.setdefault('expanded', [])
self._props['expanded'][:] = self._find_node_keys(node_keys).union(self._props['expanded'])
self.update()
return self
@@ -138,6 +148,7 @@ def collapse(self, node_keys: Optional[List[str]] = None) -> Self:
:param node_keys: list of node keys to collapse (default: all nodes)
"""
+ self._props.setdefault('expanded', [])
self._props['expanded'][:] = set(self._props['expanded']).difference(self._find_node_keys(node_keys))
self.update()
return self
diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py
index 456027ebf..c08115465 100644
--- a/nicegui/nicegui.py
+++ b/nicegui/nicegui.py
@@ -167,6 +167,8 @@ async def _on_handshake(sid: str, data: Dict[str, str]) -> bool:
client = Client.instances.get(data['client_id'])
if not client:
return False
+ if data.get('old_tab_id'):
+ app.storage.copy_tab(data['old_tab_id'], data['tab_id'])
client.tab_id = data['tab_id']
if sid[:5].startswith('test-'):
client.environ = {'asgi.scope': {'description': 'test client', 'type': 'test'}}
diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js
index c525fce46..04f70b29c 100644
--- a/nicegui/static/nicegui.js
+++ b/nicegui/static/nicegui.js
@@ -294,6 +294,16 @@ function createRandomUUID() {
}
}
+const OLD_TAB_ID = sessionStorage.__nicegui_tab_closed === "false" ? sessionStorage.__nicegui_tab_id : null;
+const TAB_ID =
+ !sessionStorage.__nicegui_tab_id || sessionStorage.__nicegui_tab_closed === "false"
+ ? (sessionStorage.__nicegui_tab_id = createRandomUUID())
+ : sessionStorage.__nicegui_tab_id;
+sessionStorage.__nicegui_tab_closed = "false";
+window.onbeforeunload = function () {
+ sessionStorage.__nicegui_tab_closed = "true";
+};
+
function createApp(elements, options) {
replaceUndefinedAttributes(elements, 0);
return (app = Vue.createApp({
@@ -319,12 +329,12 @@ function createApp(elements, options) {
window.did_handshake = false;
const messageHandlers = {
connect: () => {
- let tabId = sessionStorage.getItem("__nicegui_tab_id");
- if (!tabId) {
- tabId = createRandomUUID();
- sessionStorage.setItem("__nicegui_tab_id", tabId);
- }
- window.socket.emit("handshake", { client_id: window.clientId, tab_id: tabId }, (ok) => {
+ const args = {
+ client_id: window.clientId,
+ tab_id: TAB_ID,
+ old_tab_id: OLD_TAB_ID,
+ };
+ window.socket.emit("handshake", args, (ok) => {
if (!ok) {
console.log("reloading because handshake failed for clientId " + window.clientId);
window.location.reload();
diff --git a/nicegui/storage.py b/nicegui/storage.py
index 87a114c65..a72b8c9d9 100644
--- a/nicegui/storage.py
+++ b/nicegui/storage.py
@@ -191,6 +191,11 @@ def tab(self) -> observables.ObservableDict:
self._tabs[client.tab_id] = observables.ObservableDict()
return self._tabs[client.tab_id]
+ def copy_tab(self, old_tab_id: str, tab_id: str) -> None:
+ """Copy the tab storage to a new tab. (For internal use only.)"""
+ if old_tab_id in self._tabs:
+ self._tabs[tab_id] = observables.ObservableDict(self._tabs[old_tab_id].copy())
+
async def prune_tab_storage(self) -> None:
"""Regularly prune tab storage that is older than the configured `max_tab_storage_age`."""
while True:
diff --git a/tests/test_radio.py b/tests/test_radio.py
index 9793c9fa7..054de2b04 100644
--- a/tests/test_radio.py
+++ b/tests/test_radio.py
@@ -2,14 +2,57 @@
from nicegui.testing import Screen
-def test_changing_options(screen: Screen):
- r = ui.radio([10, 20, 30], value=10)
- ui.label().bind_text_from(r, 'value', lambda v: f'value = {v}')
- ui.button('reverse', on_click=lambda: (r.options.reverse(), r.update()))
- ui.button('clear', on_click=lambda: (r.options.clear(), r.update()))
+def test_radio_click(screen: Screen):
+ radio = ui.radio(['A', 'B', 'C'])
+ ui.label().bind_text_from(radio, 'value', lambda x: f'Value: {x}')
screen.open('/')
+ screen.click('A')
+ screen.should_contain('Value: A')
+ screen.click('B')
+ screen.should_contain('Value: B')
+
+ screen.click('B') # already selected, should not change
+ screen.wait(0.5)
+ screen.should_contain('Value: B')
+
+
+def test_radio_set_value(screen: Screen):
+ radio = ui.radio(['A', 'B', 'C'])
+ ui.label().bind_text_from(radio, 'value', lambda x: f'Value: {x}')
+
+ screen.open('/')
+ radio.set_value('B')
+ screen.should_contain('Value: B')
+
+
+def test_radio_set_options(screen: Screen):
+ radio = ui.radio(['A', 'B', 'C'], value='C', on_change=lambda e: ui.notify(f'Event: {e.value}'))
+ ui.label().bind_text_from(radio, 'value', lambda x: f'Value: {x}')
+
+ ui.button('reverse', on_click=lambda: (radio.options.reverse(), radio.update())) # type: ignore
+ ui.button('clear', on_click=lambda: (radio.options.clear(), radio.update())) # type: ignore
+
+ screen.open('/')
+ radio.set_options(['C', 'D', 'E'])
+ screen.should_contain('D')
+ screen.should_contain('E')
+ screen.should_contain('Value: C')
+
+ radio.set_options(['X', 'Y', 'Z'])
+ screen.should_contain('X')
+ screen.should_contain('Y')
+ screen.should_contain('Z')
+ screen.should_contain('Value: None')
+ screen.should_contain('Event: None')
+
+ radio.set_options(['1', '2', '3'], value='3')
+ screen.should_contain('Value: 3')
+ screen.should_contain('Event: 3')
+
screen.click('reverse')
- screen.should_contain('value = 10')
+ screen.should_contain('Value: 3')
+ screen.should_contain('Event: 3')
+
screen.click('clear')
- screen.should_contain('value = None')
+ screen.should_contain('Value: None')
diff --git a/tests/test_radio_element.py b/tests/test_radio_element.py
deleted file mode 100644
index 73fb6065f..000000000
--- a/tests/test_radio_element.py
+++ /dev/null
@@ -1,76 +0,0 @@
-from nicegui import events, ui
-from nicegui.testing import Screen
-
-
-def test_radio_click(screen: Screen):
- r = ui.radio(['A', 'B', 'C'])
-
- screen.open('/')
- screen.click('A')
- assert r.value == 'A'
- screen.click('B')
- assert r.value == 'B'
-
-
-def test_radio_click_already_selected(screen: Screen):
- r = ui.radio(['A', 'B', 'C'], value='B')
-
- screen.open('/')
- screen.click('B')
- assert r.value == 'B'
-
-
-def test_radio_set_value(screen: Screen):
- r = ui.radio(['A', 'B', 'C'])
-
- screen.open('/')
- r.set_value('B')
- assert r.value == 'B'
-
-
-def test_radio_set_options(screen: Screen):
- r = ui.radio(['A', 'B', 'C'])
-
- screen.open('/')
- r.set_options(['D', 'E', 'F'])
- assert r.options == ['D', 'E', 'F']
-
-
-def test_radio_set_options_value_still_valid(screen: Screen):
- r = ui.radio(['A', 'B', 'C'], value='C')
-
- screen.open('/')
- r.set_options(['C', 'D', 'E'])
- assert r.value == 'C'
-
-
-def test_radio_set_options_value_none(screen: Screen):
- r = ui.radio(['A', 'B', 'C'], value='C')
-
- screen.open('/')
- r.set_options(['D', 'E', 'F'])
- assert r.value is None
-
-
-def test_radio_set_options_value(screen: Screen):
- r = ui.radio(['A', 'B', 'C'])
-
- screen.open('/')
- r.set_options(['D', 'E', 'F'], value='E')
- assert r.value == 'E'
-
-
-def test_radio_set_options_value_callback(screen: Screen):
- """Fix for https://github.com/zauberzeug/nicegui/issues/3733.
-
- When using `set_options` with the value argument set and the `on_change` callback active on the element,
- `on_change` should never pass `None` through, even if the old value is not within the new list of element options.
- """
-
- def check_change_is_not_none(e: events.ValueChangeEventArguments):
- assert e.value is not None
-
- r = ui.radio(['A', 'B', 'C'], on_change=check_change_is_not_none)
-
- screen.open('/')
- r.set_options(['D', 'E', 'F'], value='F')
diff --git a/website/documentation/content/user_documentation.py b/website/documentation/content/user_documentation.py
index 245f4a50c..00da5e456 100644
--- a/website/documentation/content/user_documentation.py
+++ b/website/documentation/content/user_documentation.py
@@ -10,9 +10,9 @@ def user_fixture():
ui.markdown('''
We recommend utilizing the `user` fixture instead of the [`screen` fixture](/documentation/screen) wherever possible
because execution is as fast as unit tests and it does not need Selenium as a dependency
- when loaded via `pytest_plugins = ['nicegui.testing.user_plugin']`
- (see [project structure](/documentation/project_structure)).
+ when loaded via `pytest_plugins = ['nicegui.testing.user_plugin']`.
The `user` fixture cuts away the browser and replaces it by a lightweight simulation entirely in Python.
+ See [project structure](/documentation/project_structure) for a description of the setup.
You can assert to "see" specific elements or content, click buttons, type into inputs and trigger events.
We aimed for a nice API to write acceptance tests which read like a story and are easy to understand.