mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user