├── example ├── example │ ├── __init__.py │ └── example.py ├── requirements.txt ├── .gitignore ├── assets │ └── favicon.ico └── rxconfig.py ├── setup.py ├── .gitignore ├── .github └── workflows │ ├── test.yml │ └── publish.yml ├── tests ├── test_render.py └── test_export.py ├── LICENSE ├── tox.ini ├── pyproject.toml ├── src └── reflex_debounce_input.py └── README.md /example/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | ../ -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | .web 2 | pynecone.db -------------------------------------------------------------------------------- /example/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trivial-intelligence/reflex-debounce-input/HEAD/example/assets/favicon.ico -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | use_scm_version=True, 5 | setup_requires=["setuptools_scm"], 6 | ) 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.egg-info 3 | 4 | .coverage 5 | .coverage.* 6 | .tox/ 7 | build/ 8 | example/backend.zip 9 | example/frontend.zip -------------------------------------------------------------------------------- /example/rxconfig.py: -------------------------------------------------------------------------------- 1 | import reflex as rx 2 | 3 | config = rx.Config( 4 | app_name="example", 5 | db_url="sqlite:///pynecone.db", 6 | env=rx.Env.DEV, 7 | frontend_packages=[], 8 | ) 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | unit: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | python -m pip install tox tox-gh-actions 24 | - name: Test with tox 25 | run: tox -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Set up Python 13 | uses: actions/setup-python@v3 14 | with: 15 | python-version: '3.x' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install tox 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | TWINE_REPOSITORY_URL: ${{ secrets.PYPI_INDEX }} 25 | run: | 26 | tox -e publish -- upload -------------------------------------------------------------------------------- /tests/test_render.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import reflex as rx 4 | from reflex.vars import BaseVar 5 | 6 | from reflex_debounce_input import debounce_input 7 | 8 | 9 | def test_render_no_child(): 10 | with pytest.raises(RuntimeError): 11 | _ = debounce_input().render() 12 | 13 | 14 | def test_render_child_props(): 15 | class S(rx.State): 16 | def on_change(self, v: str): 17 | pass 18 | 19 | tag = debounce_input( 20 | rx.input( 21 | foo="bar", 22 | baz="quuc", 23 | value="real", 24 | on_change=S.on_change, 25 | ) 26 | )._render() 27 | assert tag.props["sx"] == {"foo": "bar", "baz": "quuc"} 28 | assert tag.props["value"] == BaseVar(name="real", is_local=True, is_string=False) 29 | assert len(tag.props["onChange"].events) == 1 30 | assert tag.props["onChange"].events[0].handler == S.on_change 31 | assert tag.contents == "" 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Masen Furer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37, py38, py39, py310, py311, lint 3 | 4 | [gh-actions] 5 | python = 6 | 3.7: py37 7 | 3.8: py38 8 | 3.9: py39 9 | 3.10: py310, lint 10 | 3.11: py311 11 | 12 | [testenv] 13 | setenv = 14 | # for integration test passthru 15 | COVERAGE_RCFILE={toxinidir}/tox.ini 16 | deps = 17 | pytest 18 | pytest-cov 19 | pytest-randomly 20 | commands = 21 | pytest {posargs:--cov reflex_debounce_input} 22 | 23 | [testenv:publish] 24 | passenv = TWINE_* 25 | deps = 26 | build ~= 0.9.0 27 | twine ~= 4.0.1 28 | commands = 29 | {envpython} -m build --outdir {distdir} . 30 | twine {posargs:check} {distdir}/*.whl {distdir}/*.tar.gz 31 | 32 | [testenv:lint] 33 | deps = 34 | black ~= 22.10.0 35 | flake8 ~= 5.0.4 36 | mypy > 0.990, < 0.999 37 | commands = 38 | black --check setup.py src/ tests/ example/ 39 | flake8 setup.py src/ tests/ example/ 40 | # several `name-defined` and `type-arg` issues from reflex itself 41 | -mypy --strict src/ example/ 42 | 43 | [flake8] 44 | exclude = docs 45 | max-line-length = 100 46 | extend-ignore = 47 | W503,E402 48 | 49 | [pytest] 50 | testpaths = tests 51 | addopts = -rsxX -l --tb=short --strict-markers 52 | 53 | [coverage:run] 54 | branch = True 55 | parallel = True 56 | 57 | [coverage:report] 58 | show_missing = True 59 | 60 | [coverage:paths] 61 | # this maps paths in the `.tox` directory to the top level when combining 62 | source = 63 | src/ 64 | .tox/*/lib/python*/site-packages/ -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 40.0.4", 4 | "wheel >= 0.29.0", 5 | "setuptools_scm[toml]>=3.4", 6 | ] 7 | build-backend = 'setuptools.build_meta' 8 | 9 | [project] 10 | name = "reflex-debounce-input" 11 | description = "Reflex full-stack framework wrapper around react-debounce-input" 12 | authors = [ 13 | {name = "Masen Furer", email = "m_github@0x26.net"}, 14 | ] 15 | requires-python = ">=3.7" 16 | license = {file = "LICENSE"} 17 | classifiers = [ 18 | 'Development Status :: 4 - Beta', 19 | # 'Framework :: Reflex', 20 | # 'Framework :: Reflex :: 0.1', 21 | 'Intended Audience :: Developers', 22 | 'License :: OSI Approved :: MIT License', 23 | 'Operating System :: POSIX', 24 | 'Operating System :: Microsoft :: Windows', 25 | 'Operating System :: MacOS :: MacOS X', 26 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 27 | 'Topic :: Software Development :: Libraries', 28 | 'Topic :: Utilities', 29 | # 'Programming Language :: Javascript', 30 | 'Programming Language :: Python', 31 | ] 32 | dynamic = ["version", "readme"] 33 | dependencies = [ 34 | 'reflex ~= 0.2.0', 35 | ] 36 | 37 | [project.urls] 38 | Homepage = "https://github.com/trivial-intelligence/reflex-debounce-input" 39 | 40 | [tool.setuptools] 41 | platforms = ['unix', 'linux', 'osx', 'cygwin', 'win32'] 42 | 43 | [tool.setuptools.dynamic.readme] 44 | file = ["README.md"] 45 | content-type = "text/markdown" 46 | 47 | [tool.setuptools_scm] 48 | -------------------------------------------------------------------------------- /src/reflex_debounce_input.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | import reflex as rx 6 | 7 | from reflex.components.tags import Tag 8 | from reflex.vars import Var 9 | 10 | 11 | class DebounceInput(rx.Component): 12 | library = "react-debounce-input" 13 | tag = "DebounceInput" 14 | min_length: Var[int] = 0 15 | debounce_timeout: Var[int] = 100 16 | force_notify_by_enter: Var[bool] = True 17 | force_notify_on_blur: Var[bool] = True 18 | 19 | def _render(self) -> Tag: 20 | """Carry first child props directly on this tag. 21 | 22 | Since react-debounce-input wants to create and manage the underlying 23 | input component itself, we carry all props, events, and styles from 24 | the child, and then neuter the child's render method so it produces no output. 25 | """ 26 | if not self.children: 27 | raise RuntimeError( 28 | "Provide a child for DebounceInput, such as rx.input() or rx.text_area()", 29 | ) 30 | child = self.children[0] 31 | tag = super()._render() 32 | tag.add_props( 33 | **child.event_triggers, 34 | **props_not_none(child), 35 | sx=child.style, 36 | id=child.id, 37 | class_name=child.class_name, 38 | element=Var.create("{%s}" % child.tag, is_local=False, is_string=False), 39 | ) 40 | # do NOT render the child, DebounceInput will create it 41 | object.__setattr__(child, "render", lambda: "") 42 | return tag 43 | 44 | 45 | debounce_input = DebounceInput.create 46 | 47 | 48 | def props_not_none(c: rx.Component) -> dict[str, Any]: 49 | cdict = {a: getattr(c, a) for a in c.get_props() if getattr(c, a, None) is not None} 50 | return cdict 51 | 52 | 53 | def ensure_frontend_package(): 54 | from reflex.config import get_config 55 | 56 | config = get_config() 57 | for frontend_package in config.frontend_packages: 58 | if frontend_package.partition("@")[0].strip() == "react-debounce-input": 59 | return 60 | config.frontend_packages.append("react-debounce-input") 61 | 62 | 63 | # ensure that all users of this module have it available 64 | ensure_frontend_package() 65 | -------------------------------------------------------------------------------- /tests/test_export.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import shutil 3 | import subprocess 4 | import sys 5 | from unittest import mock 6 | 7 | import reflex.config 8 | import pytest 9 | 10 | import reflex_debounce_input 11 | 12 | 13 | @pytest.fixture 14 | def example_project(tmp_path): 15 | project_dir = Path(__file__).resolve().parents[1] / "example" 16 | tmp_project_dir = tmp_path / "example" 17 | shutil.copytree(project_dir, tmp_project_dir) 18 | return tmp_project_dir 19 | 20 | 21 | @pytest.fixture(params=[True, False], ids=["pin_deps", "no_deps"]) 22 | def pin_deps(request, example_project): 23 | if request.param: 24 | new_config = [] 25 | for config_line in ( 26 | (example_project / "rxconfig.py").read_text().splitlines(True) 27 | ): 28 | new_config.append( 29 | config_line.replace( 30 | "frontend_packages=[]", 31 | "frontend_packages=['react-debounce-input@3.3.0']", 32 | ) 33 | ) 34 | (example_project / "rxconfig.py").write_text("".join(new_config)) 35 | return request.param 36 | 37 | 38 | def test_export_example(example_project, pin_deps): 39 | _ = subprocess.run(["reflex", "init"], cwd=example_project, check=True) 40 | # hack to ensure frontend packages get generated pynecone-io/pynecone#814 41 | _ = subprocess.run( 42 | [ 43 | sys.executable, 44 | "-c", 45 | "import reflex_debounce_input; " 46 | "from reflex.utils.prerequisites import install_frontend_packages as f; f('.web')", 47 | ], 48 | cwd=example_project, 49 | check=True, 50 | ) 51 | _ = subprocess.run(["reflex", "export"], cwd=example_project, check=True) 52 | assert (example_project / "frontend.zip").exists() 53 | assert (example_project / "backend.zip").exists() 54 | 55 | 56 | @pytest.mark.parametrize( 57 | ("frontend_packages", "exp_frontend_packages"), 58 | [ 59 | ([], ["react-debounce-input"]), 60 | (["react-debounce-input"], ["react-debounce-input"]), 61 | (["react-debounce-input@3.3.0"], ["react-debounce-input@3.3.0"]), 62 | ( 63 | ["foo", "react-debounce-input@3.3.0", "bar"], 64 | ["foo", "react-debounce-input@3.3.0", "bar"], 65 | ), 66 | (["foo", "bar"], ["foo", "bar", "react-debounce-input"]), 67 | ], 68 | ) 69 | def test_ensure_frontend_package(monkeypatch, frontend_packages, exp_frontend_packages): 70 | config = reflex.config.get_config() 71 | config.frontend_packages = frontend_packages 72 | monkeypatch.setattr(reflex.config, "get_config", mock.Mock(return_value=config)) 73 | reflex_debounce_input.ensure_frontend_package() 74 | assert config.frontend_packages == exp_frontend_packages 75 | -------------------------------------------------------------------------------- /example/example/example.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import reflex as rx 4 | 5 | from reflex_debounce_input import debounce_input 6 | 7 | 8 | class State(rx.State): 9 | debounce_timeout: int = 500 10 | query: str = "" 11 | checked: bool = False 12 | 13 | 14 | app = rx.App(state=State) 15 | 16 | 17 | def debounce_controls() -> rx.Box: 18 | return rx.box( 19 | rx.text("Debounce Controls"), 20 | rx.text("debounce_timeout=", State.debounce_timeout), 21 | rx.slider( 22 | min_=0, 23 | max_=5000, 24 | value=State.debounce_timeout, 25 | on_change=State.set_debounce_timeout, 26 | ), 27 | ) 28 | 29 | 30 | def input_items() -> tuple[rx.GridItem, rx.GridItem]: 31 | return ( 32 | rx.grid_item( 33 | rx.heading("Input"), 34 | debounce_input( 35 | rx.input( 36 | placeholder="Query", 37 | value=State.query, 38 | on_change=State.set_query, 39 | ), 40 | debounce_timeout=State.debounce_timeout, 41 | ), 42 | ), 43 | rx.grid_item( 44 | rx.heading("Value"), 45 | rx.text(State.query), 46 | ), 47 | ) 48 | 49 | 50 | def textarea_items() -> tuple[rx.GridItem, rx.GridItem]: 51 | return ( 52 | rx.grid_item( 53 | rx.heading("Textarea"), 54 | debounce_input( 55 | rx.text_area( 56 | placeholder="Query (min_length=5)", 57 | value=State.query, 58 | on_change=State.set_query, 59 | ), 60 | debounce_timeout=State.debounce_timeout, 61 | min_length=5, 62 | ), 63 | ), 64 | rx.grid_item( 65 | rx.heading("Value"), 66 | rx.text(State.query), 67 | ), 68 | ) 69 | 70 | 71 | def checkbox_items() -> tuple[rx.GridItem, rx.GridItem]: 72 | return ( 73 | rx.grid_item( 74 | rx.heading("Checkbox"), 75 | debounce_input( 76 | rx.checkbox( 77 | value=State.checked, 78 | on_change=State.set_checked, 79 | ), 80 | debounce_timeout=State.debounce_timeout, 81 | ), 82 | ), 83 | rx.grid_item( 84 | rx.heading("Value"), 85 | rx.cond( 86 | State.checked, 87 | rx.text("Box is Checked"), 88 | ), 89 | ), 90 | ) 91 | 92 | 93 | @app.add_page 94 | def index() -> rx.Component: 95 | return rx.center( 96 | rx.vstack( 97 | debounce_controls(), 98 | rx.grid( 99 | *input_items(), 100 | *textarea_items(), 101 | *checkbox_items(), 102 | template_columns="repeat(2, 1fr)", 103 | gap=5, 104 | ), 105 | ), 106 | ) 107 | 108 | 109 | app.compile() 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reflex-debounce-input 2 | 3 | ## This functionality is now built in to Reflex itself 4 | 5 | As a result, this repo is now archived. 6 | 7 | ------------------- 8 | 9 | [![main branch test status](https://github.com/trivial-intelligence/reflex-debounce-input/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/trivial-intelligence/reflex-debounce-input/actions/workflows/test.yml?query=branch%3Amain) 10 | [![PyPI version](https://badge.fury.io/py/reflex-debounce-input.svg)](https://pypi.org/project/reflex-debounce-input) 11 | 12 | A wrapper around the generic [`react-debounce-input`](https://www.npmjs.com/package/react-debounce-input) component for the 13 | python-based full stack [reflex](https://reflex.dev) framework. 14 | 15 | Since all events in reflex are processed on the server-side, a client-side input debounce makes the app feel much less 16 | sluggish when working will fully controlled text inputs. 17 | 18 | ## Example 19 | 20 | ```python 21 | import reflex as rx 22 | 23 | from reflex_debounce_input import debounce_input 24 | 25 | class State(rx.State): 26 | query: str = "" 27 | 28 | 29 | app = rx.App(state=State) 30 | 31 | 32 | @app.add_page 33 | def index(): 34 | return rx.center( 35 | rx.hstack( 36 | debounce_input( 37 | rx.input( 38 | placeholder="Query", 39 | value=State.query, 40 | on_change=State.set_query, 41 | ), 42 | ), 43 | rx.text(State.query), 44 | ) 45 | ) 46 | 47 | app.compile() 48 | ``` 49 | 50 | ```console 51 | reflex init 52 | reflex run 53 | ``` 54 | 55 | Also work with textarea, simply pass `rx.text_area` as the child. See [larger example](./example) in the repo. 56 | 57 | ## Usage 58 | 59 | 1. Include `reflex-debounce-input` in your project `requirements.txt`. 60 | 2. Include a specific version of `react-debounce-input` in `rxconfig.py`. 61 | 62 | ```python 63 | config = rx.Config( 64 | ..., 65 | frontend_packages=[ 66 | "react-debounce-input@3.3.0", 67 | ], 68 | ) 69 | ``` 70 | 71 | 3. Wrap `reflex_debounce_input.debounce_input` around the component 72 | to debounce (typically a `rx.input` or `rx.text_area`). 73 | 74 | ### Props 75 | 76 | See documentation for [`react-debounce-input`](https://www.npmjs.com/package/react-debounce-input). 77 | 78 | #### `min_length: int = 0` 79 | 80 | Minimal length of text to start notify, if value becomes shorter then minLength (after removing some characters), there will be a notification with empty value ''. 81 | 82 | #### `debounce_timeout: int = 100` 83 | 84 | Notification debounce timeout in ms. If set to -1, disables automatic notification completely. Notification will only happen by pressing Enter then. 85 | 86 | #### `force_notify_by_enter: bool = True` 87 | 88 | Notification of current value will be sent immediately by hitting Enter key. Enabled by-default. Notification value follows the same rule as with debounced notification, so if Length is less, then minLength - empty value '' will be sent back. 89 | 90 | NOTE: if onKeyDown callback prop was present, it will be still invoked transparently. 91 | 92 | #### `force_notify_on_blur: bool = True` 93 | 94 | Same as `force_notify_by_enter`, but notification will be sent when focus leaves the input field. 95 | 96 | ## Changelog 97 | 98 | ### v0.3 - 2023-05-19 99 | 100 | * Support pynecone >= 0.1.30 (`pynecone.var` changed to `pynecone.vars`) 101 | 102 | ### v0.2 - 2023-04-24 103 | 104 | * `import reflex_debounce_input` automatically adds `react-debounce-input` to `Config.frontend_packages` 105 | * fix example in README, missing comma 106 | * improve test assertions when exporting example project 107 | 108 | ### v0.1 - 2023-04-21 109 | 110 | Initial Release 111 | --------------------------------------------------------------------------------