Expose DOM event actions on EventHandler, EventSpec, and EventChain (stopPropagation) (#1891)
* Expose preventDefault and stopPropagation for DOM events All EventHandler, EventSpec, and EventChain can now carry these extra "event_actions" that will be applied inside the frontend code when an event is triggered from the DOM. Fix #1621 Fix REF-675 * Test cases (and fixes) for "event_actions" * form: from __future__ import annotations for py38, py39 compat * Revert overzealous merge conflict resolution
This commit is contained in:
parent
e82bc40479
commit
56476d0a86
234
integration/test_event_actions.py
Normal file
234
integration/test_event_actions.py
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
"""Ensure stopPropagation and preventDefault work as expected."""
|
||||||
|
|
||||||
|
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."""
|
||||||
|
import reflex as rx
|
||||||
|
|
||||||
|
class EventActionState(rx.State):
|
||||||
|
order: list[str]
|
||||||
|
|
||||||
|
def on_click(self, ev):
|
||||||
|
self.order.append(f"on_click:{ev}")
|
||||||
|
|
||||||
|
def on_click2(self):
|
||||||
|
self.order.append("on_click2")
|
||||||
|
|
||||||
|
@rx.var
|
||||||
|
def token(self) -> str:
|
||||||
|
return self.get_token()
|
||||||
|
|
||||||
|
def index():
|
||||||
|
return rx.vstack(
|
||||||
|
rx.input(value=EventActionState.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",
|
||||||
|
),
|
||||||
|
rx.list(
|
||||||
|
rx.foreach(
|
||||||
|
EventActionState.order, # type: ignore
|
||||||
|
rx.list_item,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
on_click=EventActionState.on_click("outer"), # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
app = rx.App(state=EventActionState)
|
||||||
|
app.add_page(index)
|
||||||
|
app.compile()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
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(f"event_action"),
|
||||||
|
app_source=TestEventAction, # type: ignore
|
||||||
|
) 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _poll_for_order(exp_order: list[str]):
|
||||||
|
async def _backend_state():
|
||||||
|
return await event_action.get_state(token)
|
||||||
|
|
||||||
|
async def _check():
|
||||||
|
return (await _backend_state()).order == exp_order
|
||||||
|
|
||||||
|
await AppHarness._poll_for_async(_check)
|
||||||
|
assert (await _backend_state()).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"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@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()
|
||||||
|
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
|
@ -53,6 +53,7 @@ def FormSubmit():
|
|||||||
rx.button("Submit", type_="submit"),
|
rx.button("Submit", type_="submit"),
|
||||||
),
|
),
|
||||||
on_submit=FormState.form_submit,
|
on_submit=FormState.form_submit,
|
||||||
|
custom_attrs={"action": "/invalid"},
|
||||||
),
|
),
|
||||||
rx.spacer(),
|
rx.spacer(),
|
||||||
height="100vh",
|
height="100vh",
|
||||||
@ -145,6 +146,8 @@ async def test_submit(driver, form_submit: AppHarness):
|
|||||||
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
|
prev_url = driver.current_url
|
||||||
|
|
||||||
submit_input = driver.find_element(By.CLASS_NAME, "chakra-button")
|
submit_input = driver.find_element(By.CLASS_NAME, "chakra-button")
|
||||||
submit_input.click()
|
submit_input.click()
|
||||||
|
|
||||||
@ -166,3 +169,6 @@ async def test_submit(driver, form_submit: AppHarness):
|
|||||||
assert form_data["select_input"] == "option1"
|
assert form_data["select_input"] == "option1"
|
||||||
assert form_data["text_area_input"] == "Some\nText"
|
assert form_data["text_area_input"] == "Some\nText"
|
||||||
assert form_data["debounce_input"] == "bar baz"
|
assert form_data["debounce_input"] == "bar baz"
|
||||||
|
|
||||||
|
# submitting the form should NOT change the url (preventDefault on_submit event)
|
||||||
|
assert driver.current_url == prev_url
|
||||||
|
@ -486,8 +486,13 @@ export const useEventLoop = (
|
|||||||
const [connectError, setConnectError] = useState(null)
|
const [connectError, setConnectError] = useState(null)
|
||||||
|
|
||||||
// Function to add new events to the event queue.
|
// Function to add new events to the event queue.
|
||||||
const addEvents = (events, _e) => {
|
const addEvents = (events, _e, event_actions) => {
|
||||||
preventDefault(_e);
|
if (event_actions?.preventDefault && _e) {
|
||||||
|
_e.preventDefault();
|
||||||
|
}
|
||||||
|
if (event_actions?.stopPropagation && _e) {
|
||||||
|
_e.stopPropagation();
|
||||||
|
}
|
||||||
queueEvents(events, socket)
|
queueEvents(events, socket)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -532,16 +537,6 @@ export const isTrue = (val) => {
|
|||||||
return Array.isArray(val) ? val.length > 0 : !!val;
|
return Array.isArray(val) ? val.length > 0 : !!val;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Prevent the default event for form submission.
|
|
||||||
* @param event
|
|
||||||
*/
|
|
||||||
export const preventDefault = (event) => {
|
|
||||||
if (event && event.type == "submit") {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the value from a ref.
|
* Get the value from a ref.
|
||||||
* @param ref The ref to get the value from.
|
* @param ref The ref to get the value from.
|
||||||
|
@ -24,12 +24,14 @@ from .event import call_script as call_script
|
|||||||
from .event import clear_local_storage as clear_local_storage
|
from .event import clear_local_storage as clear_local_storage
|
||||||
from .event import console_log as console_log
|
from .event import console_log as console_log
|
||||||
from .event import download as download
|
from .event import download as download
|
||||||
|
from .event import prevent_default as prevent_default
|
||||||
from .event import redirect as redirect
|
from .event import redirect as redirect
|
||||||
from .event import remove_cookie as remove_cookie
|
from .event import remove_cookie as remove_cookie
|
||||||
from .event import remove_local_storage as remove_local_storage
|
from .event import remove_local_storage as remove_local_storage
|
||||||
from .event import set_clipboard as set_clipboard
|
from .event import set_clipboard as set_clipboard
|
||||||
from .event import set_focus as set_focus
|
from .event import set_focus as set_focus
|
||||||
from .event import set_value as set_value
|
from .event import set_value as set_value
|
||||||
|
from .event import stop_propagation as stop_propagation
|
||||||
from .event import window_alert as window_alert
|
from .event import window_alert as window_alert
|
||||||
from .middleware import Middleware as Middleware
|
from .middleware import Middleware as Middleware
|
||||||
from .model import Model as Model
|
from .model import Model as Model
|
||||||
|
@ -242,6 +242,9 @@ class Component(Base, ABC):
|
|||||||
if value._var_type is not EventChain:
|
if value._var_type is not EventChain:
|
||||||
raise ValueError(f"Invalid event chain: {value}")
|
raise ValueError(f"Invalid event chain: {value}")
|
||||||
return value
|
return value
|
||||||
|
elif isinstance(value, EventChain):
|
||||||
|
# Trust that the caller knows what they're doing passing an EventChain directly
|
||||||
|
return value
|
||||||
|
|
||||||
arg_spec = triggers.get(event_trigger, lambda: [])
|
arg_spec = triggers.get(event_trigger, lambda: [])
|
||||||
|
|
||||||
@ -260,7 +263,7 @@ class Component(Base, ABC):
|
|||||||
deprecation_version="0.2.8",
|
deprecation_version="0.2.8",
|
||||||
removal_version="0.3.0",
|
removal_version="0.3.0",
|
||||||
)
|
)
|
||||||
events = []
|
events: list[EventSpec] = []
|
||||||
for v in value:
|
for v in value:
|
||||||
if isinstance(v, EventHandler):
|
if isinstance(v, EventHandler):
|
||||||
# Call the event handler to get the event.
|
# Call the event handler to get the event.
|
||||||
@ -291,20 +294,26 @@ class Component(Base, ABC):
|
|||||||
raise ValueError(f"Invalid event chain: {value}")
|
raise ValueError(f"Invalid event chain: {value}")
|
||||||
|
|
||||||
# Add args to the event specs if necessary.
|
# Add args to the event specs if necessary.
|
||||||
events = [
|
events = [e.with_args(get_handler_args(e)) for e in events]
|
||||||
EventSpec(
|
|
||||||
handler=e.handler,
|
# Collect event_actions from each spec
|
||||||
args=get_handler_args(e),
|
event_actions = {}
|
||||||
client_handler_name=e.client_handler_name,
|
for e in events:
|
||||||
)
|
event_actions.update(e.event_actions)
|
||||||
for e in events
|
|
||||||
]
|
|
||||||
|
|
||||||
# Return the event chain.
|
# Return the event chain.
|
||||||
if isinstance(arg_spec, Var):
|
if isinstance(arg_spec, Var):
|
||||||
return EventChain(events=events, args_spec=None)
|
return EventChain(
|
||||||
|
events=events,
|
||||||
|
args_spec=None,
|
||||||
|
event_actions=event_actions,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return EventChain(events=events, args_spec=arg_spec) # type: ignore
|
return EventChain(
|
||||||
|
events=events,
|
||||||
|
args_spec=arg_spec, # type: ignore
|
||||||
|
event_actions=event_actions,
|
||||||
|
)
|
||||||
|
|
||||||
def get_event_triggers(self) -> Dict[str, Any]:
|
def get_event_triggers(self) -> Dict[str, Any]:
|
||||||
"""Get the event triggers for the component.
|
"""Get the event triggers for the component.
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
"""Form components."""
|
"""Form components."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict
|
from typing import Any, Callable, Dict, List
|
||||||
|
|
||||||
from reflex.components.component import Component
|
from reflex.components.component import Component
|
||||||
from reflex.components.libs.chakra import ChakraComponent
|
from reflex.components.libs.chakra import ChakraComponent
|
||||||
from reflex.constants import EventTriggers
|
from reflex.constants import EventTriggers
|
||||||
|
from reflex.event import EventChain, EventHandler, EventSpec
|
||||||
from reflex.vars import Var
|
from reflex.vars import Var
|
||||||
|
|
||||||
|
|
||||||
@ -16,6 +18,29 @@ class Form(ChakraComponent):
|
|||||||
# What the form renders to.
|
# What the form renders to.
|
||||||
as_: Var[str] = "form" # type: ignore
|
as_: Var[str] = "form" # type: ignore
|
||||||
|
|
||||||
|
def _create_event_chain(
|
||||||
|
self,
|
||||||
|
event_trigger: str,
|
||||||
|
value: Var
|
||||||
|
| EventHandler
|
||||||
|
| EventSpec
|
||||||
|
| List[EventHandler | EventSpec]
|
||||||
|
| Callable[..., Any],
|
||||||
|
) -> EventChain | Var:
|
||||||
|
"""Override the event chain creation to preventDefault for on_submit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_trigger: The event trigger.
|
||||||
|
value: The value of the event trigger.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The event chain.
|
||||||
|
"""
|
||||||
|
chain = super()._create_event_chain(event_trigger, value)
|
||||||
|
if event_trigger == EventTriggers.ON_SUBMIT and isinstance(chain, EventChain):
|
||||||
|
return chain.prevent_default
|
||||||
|
return chain
|
||||||
|
|
||||||
def get_event_triggers(self) -> Dict[str, Any]:
|
def get_event_triggers(self) -> Dict[str, Any]:
|
||||||
"""Get the event triggers that pass the component's value to the handler.
|
"""Get the event triggers that pass the component's value to the handler.
|
||||||
|
|
||||||
|
@ -101,7 +101,36 @@ def _no_chain_background_task(
|
|||||||
raise TypeError(f"{fn} is marked as a background task, but is not async.")
|
raise TypeError(f"{fn} is marked as a background task, but is not async.")
|
||||||
|
|
||||||
|
|
||||||
class EventHandler(Base):
|
class EventActionsMixin(Base):
|
||||||
|
"""Mixin for DOM event actions."""
|
||||||
|
|
||||||
|
# Whether to `preventDefault` or `stopPropagation` on the event.
|
||||||
|
event_actions: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stop_propagation(self):
|
||||||
|
"""Stop the event from bubbling up the DOM tree.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New EventHandler-like with stopPropagation set to True.
|
||||||
|
"""
|
||||||
|
return self.copy(
|
||||||
|
update={"event_actions": {"stopPropagation": True, **self.event_actions}},
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prevent_default(self):
|
||||||
|
"""Prevent the default behavior of the event.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New EventHandler-like with preventDefault set to True.
|
||||||
|
"""
|
||||||
|
return self.copy(
|
||||||
|
update={"event_actions": {"preventDefault": True, **self.event_actions}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EventHandler(EventActionsMixin):
|
||||||
"""An event handler responds to an event to update the state."""
|
"""An event handler responds to an event to update the state."""
|
||||||
|
|
||||||
# The function to call in response to the event.
|
# The function to call in response to the event.
|
||||||
@ -150,6 +179,7 @@ class EventHandler(Base):
|
|||||||
client_handler_name="uploadFiles",
|
client_handler_name="uploadFiles",
|
||||||
# `files` is defined in the Upload component's _use_hooks
|
# `files` is defined in the Upload component's _use_hooks
|
||||||
args=((Var.create_safe("files"), Var.create_safe("files")),),
|
args=((Var.create_safe("files"), Var.create_safe("files")),),
|
||||||
|
event_actions=self.event_actions.copy(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Otherwise, convert to JSON.
|
# Otherwise, convert to JSON.
|
||||||
@ -162,10 +192,12 @@ class EventHandler(Base):
|
|||||||
payload = tuple(zip(fn_args, values))
|
payload = tuple(zip(fn_args, values))
|
||||||
|
|
||||||
# Return the event spec.
|
# Return the event spec.
|
||||||
return EventSpec(handler=self, args=payload)
|
return EventSpec(
|
||||||
|
handler=self, args=payload, event_actions=self.event_actions.copy()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EventSpec(Base):
|
class EventSpec(EventActionsMixin):
|
||||||
"""An event specification.
|
"""An event specification.
|
||||||
|
|
||||||
Whereas an Event object is passed during runtime, a spec is used
|
Whereas an Event object is passed during runtime, a spec is used
|
||||||
@ -187,8 +219,24 @@ class EventSpec(Base):
|
|||||||
# Required to allow tuple fields.
|
# Required to allow tuple fields.
|
||||||
frozen = True
|
frozen = True
|
||||||
|
|
||||||
|
def with_args(self, args: Tuple[Tuple[Var, Var], ...]) -> EventSpec:
|
||||||
|
"""Copy the event spec, with updated args.
|
||||||
|
|
||||||
class EventChain(Base):
|
Args:
|
||||||
|
args: The new args to pass to the function.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A copy of the event spec, with the new args.
|
||||||
|
"""
|
||||||
|
return type(self)(
|
||||||
|
handler=self.handler,
|
||||||
|
client_handler_name=self.client_handler_name,
|
||||||
|
args=args,
|
||||||
|
event_actions=self.event_actions.copy(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EventChain(EventActionsMixin):
|
||||||
"""Container for a chain of events that will be executed in order."""
|
"""Container for a chain of events that will be executed in order."""
|
||||||
|
|
||||||
events: List[EventSpec]
|
events: List[EventSpec]
|
||||||
@ -196,6 +244,11 @@ class EventChain(Base):
|
|||||||
args_spec: Optional[Callable]
|
args_spec: Optional[Callable]
|
||||||
|
|
||||||
|
|
||||||
|
# These chains can be used for their side effects when no other events are desired.
|
||||||
|
stop_propagation = EventChain(events=[], args_spec=lambda: []).stop_propagation
|
||||||
|
prevent_default = EventChain(events=[], args_spec=lambda: []).prevent_default
|
||||||
|
|
||||||
|
|
||||||
class Target(Base):
|
class Target(Base):
|
||||||
"""A Javascript event target."""
|
"""A Javascript event target."""
|
||||||
|
|
||||||
|
@ -314,7 +314,7 @@ def format_prop(
|
|||||||
arg_def = "(_e)"
|
arg_def = "(_e)"
|
||||||
|
|
||||||
chain = ",".join([format_event(event) for event in prop.events])
|
chain = ",".join([format_event(event) for event in prop.events])
|
||||||
event = f"addEvents([{chain}], {arg_def})"
|
event = f"addEvents([{chain}], {arg_def}, {json_dumps(prop.event_actions)})"
|
||||||
prop = f"{arg_def} => {event}"
|
prop = f"{arg_def} => {event}"
|
||||||
|
|
||||||
# Handle other types.
|
# Handle other types.
|
||||||
|
@ -57,14 +57,14 @@ def test_script_event_handler():
|
|||||||
)
|
)
|
||||||
render_dict = component.render()
|
render_dict = component.render()
|
||||||
assert (
|
assert (
|
||||||
'onReady={(_e) => addEvents([Event("ev_state.on_ready", {})], (_e))}'
|
'onReady={(_e) => addEvents([Event("ev_state.on_ready", {})], (_e), {})}'
|
||||||
in render_dict["props"]
|
in render_dict["props"]
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
'onLoad={(_e) => addEvents([Event("ev_state.on_load", {})], (_e))}'
|
'onLoad={(_e) => addEvents([Event("ev_state.on_load", {})], (_e), {})}'
|
||||||
in render_dict["props"]
|
in render_dict["props"]
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
'onError={(_e) => addEvents([Event("ev_state.on_error", {})], (_e))}'
|
'onError={(_e) => addEvents([Event("ev_state.on_error", {})], (_e), {})}'
|
||||||
in render_dict["props"]
|
in render_dict["props"]
|
||||||
)
|
)
|
||||||
|
@ -425,7 +425,7 @@ def test_component_event_trigger_arbitrary_args():
|
|||||||
assert comp.render()["props"][0] == (
|
assert comp.render()["props"][0] == (
|
||||||
"onFoo={(__e,_alpha,_bravo,_charlie) => addEvents("
|
"onFoo={(__e,_alpha,_bravo,_charlie) => addEvents("
|
||||||
'[Event("c1_state.mock_handler", {_e:__e.target.value,_bravo:_bravo["nested"],_charlie:(_charlie.custom + 42)})], '
|
'[Event("c1_state.mock_handler", {_e:__e.target.value,_bravo:_bravo["nested"],_charlie:(_charlie.custom + 42)})], '
|
||||||
"(__e,_alpha,_bravo,_charlie))}"
|
"(__e,_alpha,_bravo,_charlie), {})}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import pytest
|
|||||||
|
|
||||||
from reflex import event
|
from reflex import event
|
||||||
from reflex.event import Event, EventHandler, EventSpec, fix_events
|
from reflex.event import Event, EventHandler, EventSpec, fix_events
|
||||||
|
from reflex.state import State
|
||||||
from reflex.utils import format
|
from reflex.utils import format
|
||||||
from reflex.vars import Var
|
from reflex.vars import Var
|
||||||
|
|
||||||
@ -261,3 +262,54 @@ def test_remove_local_storage():
|
|||||||
assert (
|
assert (
|
||||||
format.format_event(spec) == 'Event("_remove_local_storage", {key:`testkey`})'
|
format.format_event(spec) == 'Event("_remove_local_storage", {key:`testkey`})'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_actions():
|
||||||
|
"""Test DOM event actions, like stopPropagation and preventDefault."""
|
||||||
|
# EventHandler
|
||||||
|
handler = EventHandler(fn=lambda: None)
|
||||||
|
assert not handler.event_actions
|
||||||
|
sp_handler = handler.stop_propagation
|
||||||
|
assert handler is not sp_handler
|
||||||
|
assert sp_handler.event_actions == {"stopPropagation": True}
|
||||||
|
pd_handler = handler.prevent_default
|
||||||
|
assert handler is not pd_handler
|
||||||
|
assert pd_handler.event_actions == {"preventDefault": True}
|
||||||
|
both_handler = sp_handler.prevent_default
|
||||||
|
assert both_handler is not sp_handler
|
||||||
|
assert both_handler.event_actions == {
|
||||||
|
"stopPropagation": True,
|
||||||
|
"preventDefault": True,
|
||||||
|
}
|
||||||
|
assert not handler.event_actions
|
||||||
|
|
||||||
|
# Convert to EventSpec should carry event actions
|
||||||
|
sp_handler2 = handler.stop_propagation
|
||||||
|
spec = sp_handler2()
|
||||||
|
assert spec.event_actions == {"stopPropagation": True}
|
||||||
|
assert spec.event_actions == sp_handler2.event_actions
|
||||||
|
assert spec.event_actions is not sp_handler2.event_actions
|
||||||
|
# But it should be a copy!
|
||||||
|
assert spec.event_actions is not sp_handler2.event_actions
|
||||||
|
spec2 = spec.prevent_default
|
||||||
|
assert spec is not spec2
|
||||||
|
assert spec2.event_actions == {"stopPropagation": True, "preventDefault": True}
|
||||||
|
assert spec2.event_actions != spec.event_actions
|
||||||
|
|
||||||
|
# The original handler should still not be touched.
|
||||||
|
assert not handler.event_actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_actions_on_state():
|
||||||
|
class EventActionState(State):
|
||||||
|
def handler(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
handler = EventActionState.handler
|
||||||
|
assert isinstance(handler, EventHandler)
|
||||||
|
assert not handler.event_actions
|
||||||
|
|
||||||
|
sp_handler = EventActionState.handler.stop_propagation
|
||||||
|
assert sp_handler.event_actions == {"stopPropagation": True}
|
||||||
|
# should NOT affect other references to the handler
|
||||||
|
assert not handler.event_actions
|
||||||
|
@ -4,7 +4,7 @@ from typing import Any
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from reflex.components.tags.tag import Tag
|
from reflex.components.tags.tag import Tag
|
||||||
from reflex.event import EventChain, EventHandler, EventSpec
|
from reflex.event import EventChain, EventHandler, EventSpec, FrontendEvent
|
||||||
from reflex.style import Style
|
from reflex.style import Style
|
||||||
from reflex.utils import format
|
from reflex.utils import format
|
||||||
from reflex.vars import BaseVar, Var
|
from reflex.vars import BaseVar, Var
|
||||||
@ -290,6 +290,49 @@ def test_format_cond(condition: str, true_value: str, false_value: str, expected
|
|||||||
},
|
},
|
||||||
r'{{"a": "foo \"{ \"bar\" }\" baz", "b": val}}',
|
r'{{"a": "foo \"{ \"bar\" }\" baz", "b": val}}',
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
EventChain(
|
||||||
|
events=[EventSpec(handler=EventHandler(fn=mock_event))],
|
||||||
|
args_spec=lambda: [],
|
||||||
|
),
|
||||||
|
'{(_e) => addEvents([Event("mock_event", {})], (_e), {})}',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EventChain(
|
||||||
|
events=[
|
||||||
|
EventSpec(
|
||||||
|
handler=EventHandler(fn=mock_event),
|
||||||
|
args=(
|
||||||
|
(
|
||||||
|
Var.create_safe("arg"),
|
||||||
|
BaseVar(
|
||||||
|
_var_name="_e",
|
||||||
|
_var_type=FrontendEvent,
|
||||||
|
).target.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
args_spec=lambda: [],
|
||||||
|
),
|
||||||
|
'{(_e) => addEvents([Event("mock_event", {arg:_e.target.value})], (_e), {})}',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EventChain(
|
||||||
|
events=[EventSpec(handler=EventHandler(fn=mock_event))],
|
||||||
|
args_spec=lambda: [],
|
||||||
|
event_actions={"stopPropagation": True},
|
||||||
|
),
|
||||||
|
'{(_e) => addEvents([Event("mock_event", {})], (_e), {"stopPropagation": true})}',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EventChain(
|
||||||
|
events=[EventSpec(handler=EventHandler(fn=mock_event))],
|
||||||
|
args_spec=lambda: [],
|
||||||
|
event_actions={"preventDefault": True},
|
||||||
|
),
|
||||||
|
'{(_e) => addEvents([Event("mock_event", {})], (_e), {"preventDefault": true})}',
|
||||||
|
),
|
||||||
({"a": "red", "b": "blue"}, '{{"a": "red", "b": "blue"}}'),
|
({"a": "red", "b": "blue"}, '{{"a": "red", "b": "blue"}}'),
|
||||||
(BaseVar(_var_name="var", _var_type="int"), "{var}"),
|
(BaseVar(_var_name="var", _var_type="int"), "{var}"),
|
||||||
(
|
(
|
||||||
|
Loading…
Reference in New Issue
Block a user