Преглед изворни кода

Wrap Input and TextArea with DebounceInput for full control (#1484)

Masen Furer пре 1 година
родитељ
комит
4a658ef9be

+ 4 - 6
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

+ 34 - 7
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}

+ 4 - 7
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)
 

+ 4 - 7
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)

+ 119 - 0
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 == ""