diff --git a/integration/test_input.py b/integration/test_input.py index 7ac9a1e9d..9cdc8ace0 100644 --- a/integration/test_input.py +++ b/integration/test_input.py @@ -10,7 +10,7 @@ from reflex.testing import AppHarness def FullyControlledInput(): - """App using a fully controlled input with debounce wrapper.""" + """App using a fully controlled input with implicit debounce wrapper.""" import reflex as rx class State(rx.State): @@ -21,12 +21,10 @@ def FullyControlledInput(): @app.add_page def index(): return rx.fragment( - rx.debounce_input( - rx.input( - on_change=State.set_text, id="debounce_input_input" # type: ignore - ), + rx.input( + id="debounce_input_input", + on_change=State.set_text, # type: ignore value=State.text, - debounce_timeout=0, ), rx.input(value=State.text, id="value_input"), rx.input(on_change=State.set_text, id="on_change_input"), # type: ignore diff --git a/reflex/components/forms/debounce.py b/reflex/components/forms/debounce.py index 2f7058f26..70d87da4f 100644 --- a/reflex/components/forms/debounce.py +++ b/reflex/components/forms/debounce.py @@ -20,16 +20,16 @@ class DebounceInput(Component): tag = "DebounceInput" # Minimum input characters before triggering the on_change event - min_length: Var[int] = 0 # type: ignore + min_length: Var[int] # Time to wait between end of input and triggering on_change - debounce_timeout: Var[int] = 100 # type: ignore + debounce_timeout: Var[int] # If true, notify when Enter key is pressed - force_notify_by_enter: Var[bool] = True # type: ignore + force_notify_by_enter: Var[bool] # If true, notify when form control loses focus - force_notify_on_blur: Var[bool] = True # type: ignore + force_notify_on_blur: Var[bool] # If provided, create a fully-controlled input value: Var[str] @@ -47,16 +47,17 @@ class DebounceInput(Component): Raises: RuntimeError: unless exactly one child element is provided. """ - if not self.children or len(self.children) > 1: + child, props = _collect_first_child_and_props(self) + if isinstance(child, type(self)) or len(self.children) > 1: raise RuntimeError( "Provide a single child for DebounceInput, such as rx.input() or " "rx.text_area()", ) - child = self.children[0] + self.children = [] tag = super()._render() tag.add_props( + **props, **child.event_triggers, - **props_not_none(child), sx=child.style, id=child.id, class_name=child.class_name, @@ -78,3 +79,29 @@ def props_not_none(c: Component) -> dict[str, Any]: """ cdict = {a: getattr(c, a) for a in c.get_props() if getattr(c, a, None) is not None} return cdict + + +def _collect_first_child_and_props(c: Component) -> tuple[Component, dict[str, Any]]: + """Recursively find the first child of a different type than `c` with props. + + This function is used to collapse nested DebounceInput components by + applying props from each level. Parent props take precedent over child + props. The first child component that differs in type will be returned + along with all combined parent props seen along the way. + + Args: + c: the component to get_props from + + Returns: + tuple containing the first nested child of a different type and the collected + props from each component traversed. + """ + props = props_not_none(c) + if not c.children: + return c, props + child = c.children[0] + if not isinstance(child, type(c)): + return child, {**props_not_none(child), **props} + # carry props from nested DebounceInput components + recursive_child, child_props = _collect_first_child_and_props(child) + return recursive_child, {**child_props, **props} diff --git a/reflex/components/forms/input.py b/reflex/components/forms/input.py index 0fe3d1b50..33b2cf8c5 100644 --- a/reflex/components/forms/input.py +++ b/reflex/components/forms/input.py @@ -3,6 +3,7 @@ from typing import Dict from reflex.components.component import EVENT_ARG, Component +from reflex.components.forms.debounce import DebounceInput from reflex.components.libs.chakra import ChakraComponent from reflex.utils import imports from reflex.vars import ImportVar, Var @@ -79,15 +80,11 @@ class Input(ChakraComponent): Returns: The component. - - Raises: - ValueError: If the value is a state Var. """ if isinstance(props.get("value"), Var) and props.get("on_change"): - raise ValueError( - "Input value cannot be bound to a state Var with on_change handler.\n" - "Provide value prop to rx.debounce_input with rx.input as a child " - "component to create a fully controlled input." + # create a debounced input if the user requests full control to avoid typing jank + return DebounceInput.create( + super().create(*children, **props), debounce_timeout=0 ) return super().create(*children, **props) diff --git a/reflex/components/forms/textarea.py b/reflex/components/forms/textarea.py index 1ab6f8c3b..16541311f 100644 --- a/reflex/components/forms/textarea.py +++ b/reflex/components/forms/textarea.py @@ -3,6 +3,7 @@ from typing import Dict from reflex.components.component import EVENT_ARG, Component +from reflex.components.forms.debounce import DebounceInput from reflex.components.libs.chakra import ChakraComponent from reflex.vars import Var @@ -66,14 +67,10 @@ class TextArea(ChakraComponent): Returns: The component. - - Raises: - ValueError: If the value is a state Var. """ if isinstance(props.get("value"), Var) and props.get("on_change"): - raise ValueError( - "TextArea value cannot be bound to a state Var with on_change handler.\n" - "Provide value prop to rx.debounce_input with rx.text_area as a child " - "component to create a fully controlled input." + # create a debounced input if the user requests full control to avoid typing jank + return DebounceInput.create( + super().create(*children, **props), debounce_timeout=0 ) return super().create(*children, **props) diff --git a/tests/components/forms/test_debounce.py b/tests/components/forms/test_debounce.py new file mode 100644 index 000000000..e2d5cab74 --- /dev/null +++ b/tests/components/forms/test_debounce.py @@ -0,0 +1,119 @@ +"""Test that DebounceInput collapses nested forms.""" + +import pytest + +import reflex as rx +from reflex.vars import BaseVar + + +def test_render_no_child(): + """DebounceInput raises RuntimeError if no child is provided.""" + with pytest.raises(RuntimeError): + _ = rx.debounce_input().render() + + +def test_render_no_child_recursive(): + """DebounceInput raises RuntimeError if no child is provided.""" + with pytest.raises(RuntimeError): + _ = rx.debounce_input(rx.debounce_input(rx.debounce_input())).render() + + +def test_render_many_child(): + """DebounceInput raises RuntimeError if more than 1 child is provided.""" + with pytest.raises(RuntimeError): + _ = rx.debounce_input("foo", "bar").render() + + +class S(rx.State): + """Example state for debounce tests.""" + + value: str = "" + + def on_change(self, v: str): + """Dummy on_change handler. + + + Args: + v: The changed value. + """ + pass + + +def test_render_child_props(): + """DebounceInput should render props from child component.""" + tag = rx.debounce_input( + rx.input( + foo="bar", + baz="quuc", + value="real", + on_change=S.on_change, + ) + )._render() + assert tag.props["sx"] == {"foo": "bar", "baz": "quuc"} + assert tag.props["value"] == BaseVar( + name="real", type_=str, is_local=True, is_string=False + ) + assert len(tag.props["onChange"].events) == 1 + assert tag.props["onChange"].events[0].handler == S.on_change + assert tag.contents == "" + + +def test_render_child_props_recursive(): + """DebounceInput should render props from child component. + + If the child component is a DebounceInput, then props will be copied from it + recursively. + """ + tag = rx.debounce_input( + rx.debounce_input( + rx.debounce_input( + rx.debounce_input( + rx.input( + foo="bar", + baz="quuc", + value="real", + on_change=S.on_change, + ), + value="inner", + force_notify_on_blur=False, + ), + debounce_timeout=42, + ), + value="outer", + ), + force_notify_by_enter=False, + )._render() + assert tag.props["sx"] == {"foo": "bar", "baz": "quuc"} + assert tag.props["value"] == BaseVar( + name="real", type_=str, is_local=True, is_string=False + ) + assert tag.props["forceNotifyOnBlur"].name == "false" + assert tag.props["forceNotifyByEnter"].name == "false" + assert tag.props["debounceTimeout"] == 42 + assert len(tag.props["onChange"].events) == 1 + assert tag.props["onChange"].events[0].handler == S.on_change + assert tag.contents == "" + + +def test_full_control_implicit_debounce(): + """DebounceInput is used when value and on_change are used together.""" + tag = rx.input( + value=S.value, + on_change=S.on_change, + )._render() + assert tag.props["debounceTimeout"] == 0 + assert len(tag.props["onChange"].events) == 1 + assert tag.props["onChange"].events[0].handler == S.on_change + assert tag.contents == "" + + +def test_full_control_implicit_debounce_text_area(): + """DebounceInput is used when value and on_change are used together.""" + tag = rx.text_area( + value=S.value, + on_change=S.on_change, + )._render() + assert tag.props["debounceTimeout"] == 0 + assert len(tag.props["onChange"].events) == 1 + assert tag.props["onChange"].events[0].handler == S.on_change + assert tag.contents == ""