Validation#
Input fields can check what the user types against a set of rules. A rule has a
type ('required', 'email', …), an optional custom message, and a
trigger that decides when it runs. Rules run in the order you add them and stop
at the first failure. The same rule engine also works standalone, with no widget
attached.
Rules validate the field’s typed value — a custom rule on a
NumberField receives a number, on a
DateField a date. So the rule kind matches the value
kind: the text rules ('stringLength', 'pattern', 'email') apply to
text fields, 'range' applies to numeric and date/time fields, and the rest
('required', 'compare', 'custom') apply anywhere. Attaching a rule to
a field whose value it can’t validate raises an error, so the mismatch surfaces
when you write the code, not when the user types.
A field’s validity is reactive state you can read, bind, or subscribe to
(field.valid / field.error), and it still emits valid / invalid
events.
Adding rules to a field#
Call add_validation_rule() on a field with a rule type and its options. Add
as many as you like — they are checked in order, and the first failure wins:
email = bs.TextField(label="Email")
email.add_validation_rule("required")
email.add_validation_rule("email", message="Enter a valid email.")
name = bs.TextField(label="Name")
name.add_validation_rule("stringLength", min=2, max=50)
Because 'required' is the most common rule, fields accept it as a
required=True constructor shortcut — it adds the rule and appends an asterisk
to the label:
email = bs.TextField(label="Email", required=True) # same as add_validation_rule("required")
In a Form, set required=True on the field’s
FieldItem for the same effect — see Building Forms.
Built-in rule types#
Type |
Options |
Checks that the value… |
|---|---|---|
|
— |
is not empty or whitespace. |
|
— |
looks like an email address. (text fields) |
|
|
has a length within the given bounds. (text fields) |
|
|
matches the regular expression. (text fields) |
|
|
falls within the given bounds — a number, or a |
|
|
equals another field’s value (confirm-password, etc.). |
|
|
satisfies your own predicate. |
Every rule also accepts message (override the default text) and trigger
(see below).
Numeric and date bounds#
A 'range' rule bounds an ordered value — a number, a date, or a
time — with min and/or max. Unlike a field’s min_value /
max_value (which clamp the input silently), a rule reports an
out-of-range value with a message:
age = bs.NumberField(label="Age")
age.add_validation_rule("range", min=18, message="You must be 18 or older.")
import datetime
start = bs.DateField(label="Start date")
start.add_validation_rule("range", min=datetime.date.today())
An empty field is not out of range, and an empty box is not a malformed email
address — 'range' and the text rules pass when there is nothing to check.
Add 'required' when the field must also be filled in.
Note
'range' is for numeric and date/time fields; 'stringLength' bounds the
length of a text field. Each rule applies only where it makes sense, so
age.add_validation_rule("stringLength", …) raises rather than silently
measuring the number’s digits.
Custom rules#
A 'custom' rule runs any predicate that takes the field’s typed value and
returns a bool — a string for a text field, a number for a
NumberField, a date for a
DateField. Pair it with a message so the user knows what
went wrong:
code = bs.TextField(label="Invite code")
code.add_validation_rule(
"custom",
func=lambda v: v.isdigit() and len(v) == 6,
message="The code is 6 digits.",
)
amount = bs.NumberField(label="Amount")
amount.add_validation_rule( # v is a number here, not text
"custom",
func=lambda v: v % 5 == 0,
message="Enter a multiple of 5.",
)
Unlike the built-in rules, 'custom' and 'compare' also run on an empty
field — only your predicate can say what an empty value means. On an optional
field, accept it explicitly: func=lambda v: not v or v.isdigit().
If your predicate raises, the value is reported invalid — a predicate that
could not judge a value has not cleared it. The field shows “Could not check
this value (expected: must be over 5)”, carrying your message as an
expectation rather than as a verdict: the check never ran, so asserting your
message of this value could be plainly false, but discarding it would leave the
user nothing to act on. Your message is shown on its own, as the verdict,
when the predicate actually returns False. The first time a rule’s predicate raises,
one line naming the exception is written to stderr so the failure is not silent;
set BOOTSTACK_DEBUG=1 for the full traceback. This matters most on an
optional field: a predicate that assumes a value is present raises on the empty
case, and the field then reads as invalid rather than passing. Handle the empty
value explicitly, as above, rather than relying on the rule to absorb it.
Confirming a second field#
A 'compare' rule passes only when the value matches other_field. The
other field can be another field widget, a Signal, or any zero-argument
callable — its value is read fresh each time the rule runs, so it always
compares against the current text:
password = bs.PasswordField(label="Password", required=True)
password.add_validation_rule("stringLength", min=8)
confirm = bs.PasswordField(label="Confirm password")
confirm.add_validation_rule(
"compare",
other_field=password, # a field widget…
message="Passwords don't match.",
)
# …or compare against a Signal / callable instead of a widget
pin = bs.Signal("")
confirm.add_validation_rule("compare", other_field=pin)
confirm.add_validation_rule("compare", other_field=lambda: expected_value())
When does a rule run?#
The trigger controls when a rule fires during normal typing. Each rule type
has a sensible default, which you can override per rule:
Trigger |
Runs… |
|---|---|
|
as the user types and when the field loses focus. Default for
|
|
only as the user types. |
|
only when the field loses focus. Default for |
|
never automatically — only when you call |
Auto-validation is debounced, so a fast typist doesn’t trigger a check on every keystroke. Override the trigger when the default doesn’t fit — for example, to check a length rule live:
name.add_validation_rule("stringLength", min=2, max=50, trigger="always")
Reacting to validation#
A field’s validity is reactive state. field.error is a Signal[str] — the
current message, or "" when valid — and field.valid is a Signal[bool].
Bind the error straight to a label and it keeps itself in sync:
email = bs.TextField(label="Email")
email.add_validation_rule("email", message="Enter a valid email.")
bs.Label(textsignal=email.error, accent="danger") # shows and clears itself
Read either signal on demand by calling it (email.valid()), or subscribe
to run code on each change. The field already shows its own message below the
input, so binding error is for surfacing the state somewhere else (a summary,
a submit button’s enabled state).
For a one-off reaction, the on_valid / on_invalid events still fire — each
receives a ValidationEvent carrying
value, is_valid, and message:
email.on_invalid(lambda e: toast(e.message))
Run every rule on demand with validate() (regardless of trigger); it returns
True when they all pass:
if email.validate():
submit()
Validating a whole form before submit#
A common pattern is to gate a submit button on every field passing. validate()
runs all rules and surfaces the messages through the fields’ own events, so the
guard itself stays short:
fields = [email, password, confirm]
def on_submit():
if all(f.validate() for f in fields): # each shows its own error
save(email.value, password.value)
bs.Button("Create account", on_click=on_submit)
Because all() short-circuits, call validate() on each field in a list
comprehension first if you want every field to display its error at once:
results = [f.validate() for f in fields] # validate them all
if all(results):
save(...)
A Form aggregates this for you. form.valid is a
reactive Signal[bool] AND-ed over its fields’ own valid signals, so a
submit button can react to it directly — no manual list to keep in sync:
form = bs.Form(items=[
{"key": "email", "required": True},
{"key": "password", "dtype": "password", "required": True},
])
submit = bs.Button("Create account", on_click=on_submit)
def gate(ok):
submit.disabled = not ok
form.valid.subscribe(gate)
A field is valid until proven otherwise, so form.valid() reads True before
any rule has run — call form.validate() to force a full pass (for example, to
gate the button’s initial state, or in on_submit). form.errors is a live
dict of the failing fields keyed by field key, handy for a summary:
def on_submit():
if not form.validate():
toast(f"{len(form.errors)} field(s) need attention")
return
save(form.value)
Standalone rules#
The rule engine doesn’t need a widget. Construct a ValidationRule and call validate() directly — useful
for checking values from config, a CLI, or a background job:
from bootstack.validation import ValidationRule
rule = ValidationRule("stringLength", min=3, max=8)
result = rule.validate("hi")
result.is_valid # False
result.message # "Enter between 3 and 8 characters."
ValidationRule("email").validate("a@b.co").is_valid # True
ValidationRule("compare", other_field="yes").validate("no").is_valid # False
See also#
Events — the
ValidationEventpayload and theon_*model.TextField — the field widget these rules attach to.
Form Dialog — collects and validates a set of fields at once.
API reference#
The complete reference for ValidationRule and ValidationResult lives in
Validation. At a glance:
A single validation rule that can be applied to a string value. |
|
The outcome of a single validation check. |