"""Ensure stopPropagation and preventDefault work as expected."""
from __future__ import annotations
import asyncio
import time
from typing import Callable, Coroutine, Generator
import pytest
from selenium.webdriver.common.by import By
from reflex.testing import AppHarness, WebDriver
def TestEventAction():
"""App for testing event_actions."""
from typing import List, Optional
import reflex as rx
class EventActionState(rx.State):
order: List[str]
def on_click(self, ev):
self.order.append(f"on_click:{ev}")
@rx.event
def on_click2(self):
self.order.append("on_click2")
def on_click_throttle(self):
self.order.append("on_click_throttle")
def on_click_debounce(self):
self.order.append("on_click_debounce")
class EventFiringComponent(rx.Component):
"""A component that fires onClick event without passing DOM event."""
tag = "EventFiringComponent"
def _get_custom_code(self) -> Optional[str]:
return """
function EventFiringComponent(props) {
return (
props.onClick("foo")}>
Event Firing Component
)
}"""
def get_event_triggers(self):
return {"on_click": lambda: []}
def index():
return rx.vstack(
rx.input(
value=EventActionState.router.session.client_token,
is_read_only=True,
id="token",
),
rx.button("No events", id="btn-no-events"),
rx.button(
"Stop Prop Only",
id="btn-stop-prop-only",
on_click=rx.stop_propagation, # type: ignore
),
rx.button(
"Click event",
on_click=EventActionState.on_click("no_event_actions"), # type: ignore
id="btn-click-event",
),
rx.button(
"Click stop propagation",
on_click=EventActionState.on_click("stop_propagation").stop_propagation, # type: ignore
id="btn-click-stop-propagation",
),
rx.button(
"Click stop propagation2",
on_click=EventActionState.on_click2.stop_propagation,
id="btn-click-stop-propagation2",
),
rx.button(
"Click event 2",
on_click=EventActionState.on_click2,
id="btn-click-event2",
),
rx.link(
"Link",
href="#",
on_click=EventActionState.on_click("link_no_event_actions"), # type: ignore
id="link",
),
rx.link(
"Link Stop Propagation",
href="#",
on_click=EventActionState.on_click( # type: ignore
"link_stop_propagation"
).stop_propagation,
id="link-stop-propagation",
),
rx.link(
"Link Prevent Default Only",
href="/invalid",
on_click=rx.prevent_default, # type: ignore
id="link-prevent-default-only",
),
rx.link(
"Link Prevent Default",
href="/invalid",
on_click=EventActionState.on_click( # type: ignore
"link_prevent_default"
).prevent_default,
id="link-prevent-default",
),
rx.link(
"Link Both",
href="/invalid",
on_click=EventActionState.on_click( # type: ignore
"link_both"
).stop_propagation.prevent_default,
id="link-stop-propagation-prevent-default",
),
EventFiringComponent.create(
id="custom-stop-propagation",
on_click=EventActionState.on_click( # type: ignore
"custom-stop-propagation"
).stop_propagation,
),
EventFiringComponent.create(
id="custom-prevent-default",
on_click=EventActionState.on_click( # type: ignore
"custom-prevent-default"
).prevent_default,
),
rx.button(
"Throttle",
id="btn-throttle",
on_click=lambda: EventActionState.on_click_throttle.throttle(
200
).stop_propagation,
),
rx.button(
"Debounce",
id="btn-debounce",
on_click=EventActionState.on_click_debounce.debounce(
200
).stop_propagation,
),
rx.list( # type: ignore
rx.foreach(
EventActionState.order, # type: ignore
rx.list_item,
),
),
on_click=EventActionState.on_click("outer"), # type: ignore
)
app = rx.App(state=rx.State)
app.add_page(index)
@pytest.fixture(scope="module")
def event_action(tmp_path_factory) -> Generator[AppHarness, None, None]:
"""Start TestEventAction app at tmp_path via AppHarness.
Args:
tmp_path_factory: pytest tmp_path_factory fixture
Yields:
running AppHarness instance
"""
with AppHarness.create(
root=tmp_path_factory.mktemp("event_action"),
app_source=TestEventAction,
) as harness:
yield harness
@pytest.fixture
def driver(event_action: AppHarness) -> Generator[WebDriver, None, None]:
"""Get an instance of the browser open to the event_action app.
Args:
event_action: harness for TestEventAction app
Yields:
WebDriver instance.
"""
assert event_action.app_instance is not None, "app is not running"
driver = event_action.frontend()
try:
yield driver
finally:
driver.quit()
@pytest.fixture()
def token(event_action: AppHarness, driver: WebDriver) -> str:
"""Get the token associated with backend state.
Args:
event_action: harness for TestEventAction app.
driver: WebDriver instance.
Returns:
The token visible in the driver browser.
"""
assert event_action.app_instance is not None
token_input = driver.find_element(By.ID, "token")
assert token_input
# wait for the backend connection to send the token
token = event_action.poll_for_value(token_input)
assert token is not None
return token
@pytest.fixture()
def poll_for_order(
event_action: AppHarness, token: str
) -> Callable[[list[str]], Coroutine[None, None, None]]:
"""Poll for the order list to match the expected order.
Args:
event_action: harness for TestEventAction app.
token: The token visible in the driver browser.
Returns:
An async function that polls for the order list to match the expected order.
"""
state_name = event_action.get_state_name("_event_action_state")
state_full_name = event_action.get_full_state_name(["_event_action_state"])
async def _poll_for_order(exp_order: list[str]):
async def _backend_state():
return await event_action.get_state(f"{token}_{state_full_name}")
async def _check():
return (await _backend_state()).substates[state_name].order == exp_order
await AppHarness._poll_for_async(_check)
assert (await _backend_state()).substates[state_name].order == exp_order
return _poll_for_order
@pytest.mark.parametrize(
("element_id", "exp_order"),
[
("btn-no-events", ["on_click:outer"]),
("btn-stop-prop-only", []),
("btn-click-event", ["on_click:no_event_actions", "on_click:outer"]),
("btn-click-stop-propagation", ["on_click:stop_propagation"]),
("btn-click-stop-propagation2", ["on_click2"]),
("btn-click-event2", ["on_click2", "on_click:outer"]),
("link", ["on_click:link_no_event_actions", "on_click:outer"]),
("link-stop-propagation", ["on_click:link_stop_propagation"]),
("link-prevent-default", ["on_click:link_prevent_default", "on_click:outer"]),
("link-prevent-default-only", ["on_click:outer"]),
("link-stop-propagation-prevent-default", ["on_click:link_both"]),
(
"custom-stop-propagation",
["on_click:custom-stop-propagation", "on_click:outer"],
),
(
"custom-prevent-default",
["on_click:custom-prevent-default", "on_click:outer"],
),
],
)
@pytest.mark.usefixtures("token")
@pytest.mark.asyncio
async def test_event_actions(
driver: WebDriver,
poll_for_order: Callable[[list[str]], Coroutine[None, None, None]],
element_id: str,
exp_order: list[str],
):
"""Click links and buttons and assert on fired events.
Args:
driver: WebDriver instance.
poll_for_order: function that polls for the order list to match the expected order.
element_id: The id of the element to click.
exp_order: The expected order of events.
"""
el = driver.find_element(By.ID, element_id)
assert el
prev_url = driver.current_url
el.click()
if "on_click:outer" not in exp_order:
# really make sure the outer event is not fired
await asyncio.sleep(0.5)
await poll_for_order(exp_order)
if element_id.startswith("link") and "prevent-default" not in element_id:
assert driver.current_url != prev_url
else:
assert driver.current_url == prev_url
@pytest.mark.usefixtures("token")
@pytest.mark.asyncio
async def test_event_actions_throttle_debounce(
driver: WebDriver,
poll_for_order: Callable[[list[str]], Coroutine[None, None, None]],
):
"""Click buttons with debounce and throttle and assert on fired events.
Args:
driver: WebDriver instance.
poll_for_order: function that polls for the order list to match the expected order.
"""
btn_throttle = driver.find_element(By.ID, "btn-throttle")
assert btn_throttle
btn_debounce = driver.find_element(By.ID, "btn-debounce")
assert btn_debounce
exp_events = 10
throttle_duration = exp_events * 0.2 # 200ms throttle
throttle_start = time.time()
while time.time() - throttle_start < throttle_duration:
btn_throttle.click()
btn_debounce.click()
try:
await poll_for_order(["on_click_throttle"] * exp_events + ["on_click_debounce"])
except AssertionError:
# Sometimes the last event gets throttled due to race, this is okay.
await poll_for_order(
["on_click_throttle"] * (exp_events - 1) + ["on_click_debounce"]
)