Wrap Input and TextArea with DebounceInput for full control (#1484)
This commit is contained in:
parent
e214aa26aa
commit
4a658ef9be
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
tests/components/forms/test_debounce.py
Normal file
119
tests/components/forms/test_debounce.py
Normal file
@ -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 == ""
|
Loading…
Reference in New Issue
Block a user