Invert logic to specify events for input validation.

Related review comment: https://github.com/Textualize/textual/pull/3193#discussion_r1321250339.
This commit is contained in:
Rodrigo Girão Serrão
2023-09-11 11:29:29 +01:00
parent b427a8a41a
commit a263072781
3 changed files with 127 additions and 44 deletions

View File

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

View File

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

View File

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