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 - 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 - `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 ### Changed

View File

@@ -9,6 +9,7 @@ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
from rich.highlighter import Highlighter from rich.highlighter import Highlighter
from rich.segment import Segment from rich.segment import Segment
from rich.text import Text from rich.text import Text
from typing_extensions import Literal
from .. import events from .. import events
from .._segment_tools import line_crop from .._segment_tools import line_crop
@@ -21,6 +22,9 @@ from ..suggester import Suggester, SuggestionReady
from ..validation import ValidationResult, Validator from ..validation import ValidationResult, Validator
from ..widget import Widget from ..widget import Widget
InputValidationOn = Literal["blur", "changed", "submitted"]
"""Possible messages that trigger input validation."""
class _InputRenderable: class _InputRenderable:
"""Render the input content.""" """Render the input content."""
@@ -221,7 +225,7 @@ class Input(Widget, can_focus=True):
*, *,
suggester: Suggester | None = None, suggester: Suggester | None = None,
validators: Validator | Iterable[Validator] | 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, name: str | None = None,
id: str | None = None, id: str | None = None,
classes: 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 suggester: [`Suggester`][textual.suggester.Suggester] associated with this
input instance. input instance.
validators: An iterable of validators that the Input value will be checked against. validators: An iterable of validators that the Input value will be checked against.
prevent_validation_on: Message types for which validation shouldn't occur. validate_on: When does input validation happen? The default is to validate
Validation occurs for input changes and submissions, as well as on blur events. on input changes and submissions, as well as on blur events.
name: Optional name for the input widget. name: Optional name for the input widget.
id: Optional ID for the widget. id: Optional ID for the widget.
classes: Optional initial classes for the widget. classes: Optional initial classes for the widget.
@@ -258,14 +262,16 @@ class Input(Widget, can_focus=True):
self.validators = [] self.validators = []
else: else:
self.validators = list(validators) 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. _possible_validate_on_values = {"blur", "changed", "submitted"}
Including any of these types of messages in this set will skip validation on self.validate_on = (
these message types. 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: Example:
This creates an `Input` widget that only gets validated when the value This creates an `Input` widget that only gets validated when the value
@@ -274,7 +280,7 @@ class Input(Widget, can_focus=True):
```py ```py
from textual.events import Blur 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) self.refresh(layout=True)
validation_result = ( validation_result = (
self.validate(value) self.validate(value) if "changed" in self.validate_on else None
if self.Changed not in self.prevent_validation_on
else None
) )
self.post_message(self.Changed(self, value, validation_result)) 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: def _on_blur(self, _: Blur) -> None:
self.blink_timer.pause() self.blink_timer.pause()
if Blur not in self.prevent_validation_on: if "blur" in self.validate_on:
self.validate(self.value) self.validate(self.value)
def _on_focus(self, _: Focus) -> None: 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. Normally triggered by the user pressing Enter. This will also run any validators.
""" """
validation_result = ( validation_result = (
self.validate(self.value) self.validate(self.value) if "submitted" in self.validate_on else None
if self.Submitted not in self.prevent_validation_on
else None
) )
self.post_message(self.Submitted(self, self.value, validation_result)) self.post_message(self.Submitted(self, self.value, validation_result))

View File

@@ -1,3 +1,5 @@
import pytest
from textual import on from textual import on
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.events import Blur from textual.events import Blur
@@ -6,16 +8,16 @@ from textual.widgets import Input
class InputApp(App): class InputApp(App):
def __init__(self, prevent_validation_on=None): def __init__(self, validate_on=None):
super().__init__() super().__init__()
self.messages = [] self.messages = []
self.validator = Number(minimum=1, maximum=5) 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: def compose(self) -> ComposeResult:
yield Input( yield Input(
validators=self.validator, validators=self.validator,
prevent_validation_on=self.prevent_validation_on, validate_on=self.validate_on,
) )
@on(Input.Changed) @on(Input.Changed)
@@ -94,33 +96,112 @@ async def test_on_blur_triggers_validation():
assert input.has_class("-valid") assert input.has_class("-valid")
async def test_prevent_validation_on_changes(): @pytest.mark.parametrize(
app = InputApp([Input.Changed]) "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: async with app.run_test() as pilot:
# sanity checks
assert len(app.messages) == 0 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 = app.query_one(Input)
input.focus() assert not input.has_class("-valid")
assert not input.has_class("-invalid")
input.value = "3" input.value = "3"
await pilot.pause() 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) app.set_focus(None)
await pilot.pause() await pilot.pause()
assert not input.has_class("-valid") 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")