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

Lechler GmbH + daviborges666

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.