From a263072781cc0ddbf1e4587246f6a9707b26a7f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 11 Sep 2023 11:29:29 +0100 Subject: [PATCH] Invert logic to specify events for input validation. Related review comment: https://github.com/Textualize/textual/pull/3193#discussion_r1321250339. --- CHANGELOG.md | 2 +- src/textual/widgets/_input.py | 38 ++++---- tests/input/test_input_validation.py | 131 ++++++++++++++++++++++----- 3 files changed, 127 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1169c091..a2a66e5c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - TCSS styles `layer` and `layers` can be strings https://github.com/Textualize/textual/pull/3169 - `Input` is now validated when focus moves out of it https://github.com/Textualize/textual/pull/3193 -- Attribute `Input.prevent_validation_on` (and `__init__` parameter of the same name) to customise when validation occurs https://github.com/Textualize/textual/pull/3193 +- Attribute `Input.validate_on` (and `__init__` parameter of the same name) to customise when validation occurs https://github.com/Textualize/textual/pull/3193 ### Changed diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 5e39f0c0a..4821b34e4 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -9,6 +9,7 @@ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.highlighter import Highlighter from rich.segment import Segment from rich.text import Text +from typing_extensions import Literal from .. import events from .._segment_tools import line_crop @@ -21,6 +22,9 @@ from ..suggester import Suggester, SuggestionReady from ..validation import ValidationResult, Validator from ..widget import Widget +InputValidationOn = Literal["blur", "changed", "submitted"] +"""Possible messages that trigger input validation.""" + class _InputRenderable: """Render the input content.""" @@ -221,7 +225,7 @@ class Input(Widget, can_focus=True): *, suggester: Suggester | None = None, validators: Validator | Iterable[Validator] | None = None, - prevent_validation_on: Iterable[type[Message]] | None = None, + validate_on: Iterable[InputValidationOn] | None = None, name: str | None = None, id: str | None = None, classes: str | None = None, @@ -237,8 +241,8 @@ class Input(Widget, can_focus=True): suggester: [`Suggester`][textual.suggester.Suggester] associated with this input instance. validators: An iterable of validators that the Input value will be checked against. - prevent_validation_on: Message types for which validation shouldn't occur. - Validation occurs for input changes and submissions, as well as on blur events. + validate_on: When does input validation happen? The default is to validate + on input changes and submissions, as well as on blur events. name: Optional name for the input widget. id: Optional ID for the widget. classes: Optional initial classes for the widget. @@ -258,14 +262,16 @@ class Input(Widget, can_focus=True): self.validators = [] else: self.validators = list(validators) - self.prevent_validation_on: set[type[Message]] = set( - prevent_validation_on or [] - ) & {self.Changed, self.Submitted, Blur} - """Set with events to skip validation on. - Validation is only performed on blur, when input changes and when it's submitted. - Including any of these types of messages in this set will skip validation on - these message types. + _possible_validate_on_values = {"blur", "changed", "submitted"} + self.validate_on = ( + set(validate_on) & _possible_validate_on_values + if validate_on is not None + else _possible_validate_on_values + ) + """Set with event names to do input validation on. + + Validation can only be performed on blur, on input changes and on input submission. Example: This creates an `Input` widget that only gets validated when the value @@ -274,7 +280,7 @@ class Input(Widget, can_focus=True): ```py from textual.events import Blur - input = Input(prevent_validation_on=[Blur, Input.Changed]) + input = Input(validate_on=["submitted"]) ``` """ @@ -329,9 +335,7 @@ class Input(Widget, can_focus=True): self.refresh(layout=True) validation_result = ( - self.validate(value) - if self.Changed not in self.prevent_validation_on - else None + self.validate(value) if "changed" in self.validate_on else None ) self.post_message(self.Changed(self, value, validation_result)) @@ -414,7 +418,7 @@ class Input(Widget, can_focus=True): def _on_blur(self, _: Blur) -> None: self.blink_timer.pause() - if Blur not in self.prevent_validation_on: + if "blur" in self.validate_on: self.validate(self.value) def _on_focus(self, _: Focus) -> None: @@ -606,8 +610,6 @@ class Input(Widget, can_focus=True): Normally triggered by the user pressing Enter. This will also run any validators. """ validation_result = ( - self.validate(self.value) - if self.Submitted not in self.prevent_validation_on - else None + self.validate(self.value) if "submitted" in self.validate_on else None ) self.post_message(self.Submitted(self, self.value, validation_result)) diff --git a/tests/input/test_input_validation.py b/tests/input/test_input_validation.py index 0e6f78261..cfbdf3292 100644 --- a/tests/input/test_input_validation.py +++ b/tests/input/test_input_validation.py @@ -1,3 +1,5 @@ +import pytest + from textual import on from textual.app import App, ComposeResult from textual.events import Blur @@ -6,16 +8,16 @@ from textual.widgets import Input class InputApp(App): - def __init__(self, prevent_validation_on=None): + def __init__(self, validate_on=None): super().__init__() self.messages = [] self.validator = Number(minimum=1, maximum=5) - self.prevent_validation_on = prevent_validation_on or set() + self.validate_on = validate_on def compose(self) -> ComposeResult: yield Input( validators=self.validator, - prevent_validation_on=self.prevent_validation_on, + validate_on=self.validate_on, ) @on(Input.Changed) @@ -94,33 +96,112 @@ async def test_on_blur_triggers_validation(): assert input.has_class("-valid") -async def test_prevent_validation_on_changes(): - app = InputApp([Input.Changed]) +@pytest.mark.parametrize( + "validate_on", + [ + set(), + {"blur"}, + {"submitted"}, + {"blur", "submitted"}, + {"fried", "garbage"}, + ], +) +async def test_validation_on_changed_should_not_happen(validate_on): + app = InputApp(validate_on) async with app.run_test() as pilot: + # sanity checks assert len(app.messages) == 0 - app.query_one(Input).value = "3" - await pilot.pause() - assert len(app.messages) == 1 - assert app.messages[0].validation_result is None - - -async def test_prevent_validation_on_submission(): - app = InputApp([Input.Submitted]) - async with app.run_test() as pilot: - await app.query_one(Input).action_submit() - await pilot.pause() - assert len(app.messages) == 1 - assert app.messages[0].validation_result is None - - -async def test_prevent_validation_on_blur(): - app = InputApp([Blur]) - async with app.run_test() as pilot: input = app.query_one(Input) - input.focus() + assert not input.has_class("-valid") + assert not input.has_class("-invalid") + input.value = "3" await pilot.pause() - input.remove_class("-valid") + assert len(app.messages) == 1 + assert app.messages[-1].validation_result is None + assert not input.has_class("-valid") + assert not input.has_class("-invalid") + + +@pytest.mark.parametrize( + "validate_on", + [ + set(), + {"blur"}, + {"changed"}, + {"blur", "changed"}, + {"fried", "garbage"}, + ], +) +async def test_validation_on_submitted_should_not_happen(validate_on): + app = InputApp(validate_on) + async with app.run_test() as pilot: + # sanity checks + assert len(app.messages) == 0 + input = app.query_one(Input) + assert not input.has_class("-valid") + assert not input.has_class("-invalid") + + await input.action_submit() + await pilot.pause() + assert len(app.messages) == 1 + assert app.messages[-1].validation_result is None + assert not input.has_class("-valid") + assert not input.has_class("-invalid") + + +@pytest.mark.parametrize( + "validate_on", + [ + set(), + {"submitted"}, + {"changed"}, + {"submitted", "changed"}, + {"fried", "garbage"}, + ], +) +async def test_validation_on_blur_should_not_happen_unless_specified(validate_on): + app = InputApp(validate_on) + async with app.run_test() as pilot: + # sanity checks + input = app.query_one(Input) + assert not input.has_class("-valid") + assert not input.has_class("-invalid") + + input.focus() + await pilot.pause() app.set_focus(None) await pilot.pause() assert not input.has_class("-valid") + assert not input.has_class("-invalid") + + +async def test_none_validate_on_means_all_validations_happen(): + app = InputApp(None) + async with app.run_test() as pilot: + assert len(app.messages) == 0 # sanity checks + input = app.query_one(Input) + assert not input.has_class("-valid") + assert not input.has_class("-invalid") + + input.value = "3" + await pilot.pause() + assert len(app.messages) == 1 + assert app.messages[-1].validation_result is not None + assert input.has_class("-valid") + + input.remove_class("-valid") + + await input.action_submit() + await pilot.pause() + assert len(app.messages) == 2 + assert app.messages[-1].validation_result is not None + assert input.has_class("-valid") + + input.remove_class("-valid") + + input.focus() + await pilot.pause() + app.set_focus(None) + await pilot.pause() + assert input.has_class("-valid")