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

This commit is contained in:
Masen Furer 2023-08-07 14:27:42 -07:00 committed by GitHub
parent e214aa26aa
commit 4a658ef9be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 165 additions and 27 deletions

View File

@ -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

View File

@ -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}

View File

@ -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)

View File

@ -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)

View 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 == ""