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():
|
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
|
import reflex as rx
|
||||||
|
|
||||||
class State(rx.State):
|
class State(rx.State):
|
||||||
@ -21,12 +21,10 @@ def FullyControlledInput():
|
|||||||
@app.add_page
|
@app.add_page
|
||||||
def index():
|
def index():
|
||||||
return rx.fragment(
|
return rx.fragment(
|
||||||
rx.debounce_input(
|
rx.input(
|
||||||
rx.input(
|
id="debounce_input_input",
|
||||||
on_change=State.set_text, id="debounce_input_input" # type: ignore
|
on_change=State.set_text, # type: ignore
|
||||||
),
|
|
||||||
value=State.text,
|
value=State.text,
|
||||||
debounce_timeout=0,
|
|
||||||
),
|
),
|
||||||
rx.input(value=State.text, id="value_input"),
|
rx.input(value=State.text, id="value_input"),
|
||||||
rx.input(on_change=State.set_text, id="on_change_input"), # type: ignore
|
rx.input(on_change=State.set_text, id="on_change_input"), # type: ignore
|
||||||
|
@ -20,16 +20,16 @@ class DebounceInput(Component):
|
|||||||
tag = "DebounceInput"
|
tag = "DebounceInput"
|
||||||
|
|
||||||
# Minimum input characters before triggering the on_change event
|
# 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
|
# 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
|
# 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
|
# 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
|
# If provided, create a fully-controlled input
|
||||||
value: Var[str]
|
value: Var[str]
|
||||||
@ -47,16 +47,17 @@ class DebounceInput(Component):
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: unless exactly one child element is provided.
|
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(
|
raise RuntimeError(
|
||||||
"Provide a single child for DebounceInput, such as rx.input() or "
|
"Provide a single child for DebounceInput, such as rx.input() or "
|
||||||
"rx.text_area()",
|
"rx.text_area()",
|
||||||
)
|
)
|
||||||
child = self.children[0]
|
self.children = []
|
||||||
tag = super()._render()
|
tag = super()._render()
|
||||||
tag.add_props(
|
tag.add_props(
|
||||||
|
**props,
|
||||||
**child.event_triggers,
|
**child.event_triggers,
|
||||||
**props_not_none(child),
|
|
||||||
sx=child.style,
|
sx=child.style,
|
||||||
id=child.id,
|
id=child.id,
|
||||||
class_name=child.class_name,
|
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}
|
cdict = {a: getattr(c, a) for a in c.get_props() if getattr(c, a, None) is not None}
|
||||||
return cdict
|
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 typing import Dict
|
||||||
|
|
||||||
from reflex.components.component import EVENT_ARG, Component
|
from reflex.components.component import EVENT_ARG, Component
|
||||||
|
from reflex.components.forms.debounce import DebounceInput
|
||||||
from reflex.components.libs.chakra import ChakraComponent
|
from reflex.components.libs.chakra import ChakraComponent
|
||||||
from reflex.utils import imports
|
from reflex.utils import imports
|
||||||
from reflex.vars import ImportVar, Var
|
from reflex.vars import ImportVar, Var
|
||||||
@ -79,15 +80,11 @@ class Input(ChakraComponent):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The component.
|
The component.
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the value is a state Var.
|
|
||||||
"""
|
"""
|
||||||
if isinstance(props.get("value"), Var) and props.get("on_change"):
|
if isinstance(props.get("value"), Var) and props.get("on_change"):
|
||||||
raise ValueError(
|
# create a debounced input if the user requests full control to avoid typing jank
|
||||||
"Input value cannot be bound to a state Var with on_change handler.\n"
|
return DebounceInput.create(
|
||||||
"Provide value prop to rx.debounce_input with rx.input as a child "
|
super().create(*children, **props), debounce_timeout=0
|
||||||
"component to create a fully controlled input."
|
|
||||||
)
|
)
|
||||||
return super().create(*children, **props)
|
return super().create(*children, **props)
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from reflex.components.component import EVENT_ARG, Component
|
from reflex.components.component import EVENT_ARG, Component
|
||||||
|
from reflex.components.forms.debounce import DebounceInput
|
||||||
from reflex.components.libs.chakra import ChakraComponent
|
from reflex.components.libs.chakra import ChakraComponent
|
||||||
from reflex.vars import Var
|
from reflex.vars import Var
|
||||||
|
|
||||||
@ -66,14 +67,10 @@ class TextArea(ChakraComponent):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The component.
|
The component.
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the value is a state Var.
|
|
||||||
"""
|
"""
|
||||||
if isinstance(props.get("value"), Var) and props.get("on_change"):
|
if isinstance(props.get("value"), Var) and props.get("on_change"):
|
||||||
raise ValueError(
|
# create a debounced input if the user requests full control to avoid typing jank
|
||||||
"TextArea value cannot be bound to a state Var with on_change handler.\n"
|
return DebounceInput.create(
|
||||||
"Provide value prop to rx.debounce_input with rx.text_area as a child "
|
super().create(*children, **props), debounce_timeout=0
|
||||||
"component to create a fully controlled input."
|
|
||||||
)
|
)
|
||||||
return super().create(*children, **props)
|
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