feat: add field validation API with signup form example
Introduces Field, Rule, ValidateAll, ResetFields, and AddError for declarative input validation. Includes built-in rules (Required, MinLen, MaxLen, Min, Max, Email, Pattern, Custom) and a signup example exercising the full API surface.
This commit is contained in:
28
context.go
28
context.go
@@ -480,6 +480,34 @@ func (c *Context) unsubscribeAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// Field creates a signal with validation rules attached.
|
||||
// The initial value seeds both the signal and the reset target.
|
||||
func (c *Context) Field(initial any, rules ...Rule) *Field {
|
||||
return &Field{
|
||||
signal: c.Signal(initial),
|
||||
rules: rules,
|
||||
initialVal: initial,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateAll runs Validate on every field, returning true only if all pass.
|
||||
func (c *Context) ValidateAll(fields ...*Field) bool {
|
||||
ok := true
|
||||
for _, f := range fields {
|
||||
if !f.Validate() {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetFields resets every field to its initial value and clears errors.
|
||||
func (c *Context) ResetFields(fields ...*Field) {
|
||||
for _, f := range fields {
|
||||
f.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func newContext(id string, route string, v *V) *Context {
|
||||
if v == nil {
|
||||
panic("create context failed: app pointer is nil")
|
||||
|
||||
58
field.go
Normal file
58
field.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package via
|
||||
|
||||
// Field is a signal with built-in validation rules and error state.
|
||||
// It embeds *signal, so all signal methods (Bind, String, Int, Bool, SetValue, Text, ID)
|
||||
// work transparently.
|
||||
type Field struct {
|
||||
*signal
|
||||
rules []Rule
|
||||
errors []string
|
||||
initialVal any
|
||||
}
|
||||
|
||||
// Validate runs all rules against the current value.
|
||||
// Clears previous errors, populates new ones, returns true if all rules pass.
|
||||
func (f *Field) Validate() bool {
|
||||
f.errors = nil
|
||||
val := f.String()
|
||||
for _, r := range f.rules {
|
||||
if err := r.validate(val); err != nil {
|
||||
f.errors = append(f.errors, err.Error())
|
||||
}
|
||||
}
|
||||
return len(f.errors) == 0
|
||||
}
|
||||
|
||||
// HasError returns true if this field has any validation errors.
|
||||
func (f *Field) HasError() bool {
|
||||
return len(f.errors) > 0
|
||||
}
|
||||
|
||||
// FirstError returns the first validation error message, or "" if valid.
|
||||
func (f *Field) FirstError() string {
|
||||
if len(f.errors) > 0 {
|
||||
return f.errors[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Errors returns all current validation error messages.
|
||||
func (f *Field) Errors() []string {
|
||||
return f.errors
|
||||
}
|
||||
|
||||
// AddError manually adds an error message (useful for server-side or cross-field validation).
|
||||
func (f *Field) AddError(msg string) {
|
||||
f.errors = append(f.errors, msg)
|
||||
}
|
||||
|
||||
// ClearErrors removes all validation errors from this field.
|
||||
func (f *Field) ClearErrors() {
|
||||
f.errors = nil
|
||||
}
|
||||
|
||||
// Reset restores the field value to its initial value and clears all errors.
|
||||
func (f *Field) Reset() {
|
||||
f.SetValue(f.initialVal)
|
||||
f.errors = nil
|
||||
}
|
||||
178
field_test.go
Normal file
178
field_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func newTestField(initial any, rules ...Rule) *Field {
|
||||
v := New()
|
||||
var f *Field
|
||||
v.Page("/", func(c *Context) {
|
||||
f = c.Field(initial, rules...)
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func TestFieldCreation(t *testing.T) {
|
||||
f := newTestField("hello", Required())
|
||||
assert.Equal(t, "hello", f.String())
|
||||
assert.NotEmpty(t, f.ID())
|
||||
}
|
||||
|
||||
func TestFieldSignalDelegation(t *testing.T) {
|
||||
f := newTestField(42)
|
||||
assert.Equal(t, "42", f.String())
|
||||
assert.Equal(t, 42, f.Int())
|
||||
|
||||
f.SetValue("new")
|
||||
assert.Equal(t, "new", f.String())
|
||||
|
||||
// Bind returns an h.H element
|
||||
assert.NotNil(t, f.Bind())
|
||||
}
|
||||
|
||||
func TestFieldValidateSingleRule(t *testing.T) {
|
||||
f := newTestField("", Required())
|
||||
assert.False(t, f.Validate())
|
||||
assert.True(t, f.HasError())
|
||||
assert.Equal(t, "This field is required", f.FirstError())
|
||||
|
||||
f.SetValue("ok")
|
||||
assert.True(t, f.Validate())
|
||||
assert.False(t, f.HasError())
|
||||
assert.Equal(t, "", f.FirstError())
|
||||
}
|
||||
|
||||
func TestFieldValidateMultipleRules(t *testing.T) {
|
||||
f := newTestField("ab", Required(), MinLen(3))
|
||||
assert.False(t, f.Validate())
|
||||
errs := f.Errors()
|
||||
assert.Len(t, errs, 1)
|
||||
assert.Equal(t, "Must be at least 3 characters", errs[0])
|
||||
|
||||
f.SetValue("")
|
||||
assert.False(t, f.Validate())
|
||||
errs = f.Errors()
|
||||
assert.Len(t, errs, 2)
|
||||
}
|
||||
|
||||
func TestFieldErrors(t *testing.T) {
|
||||
f := newTestField("")
|
||||
assert.Nil(t, f.Errors())
|
||||
assert.False(t, f.HasError())
|
||||
assert.Equal(t, "", f.FirstError())
|
||||
}
|
||||
|
||||
func TestFieldAddError(t *testing.T) {
|
||||
f := newTestField("ok")
|
||||
f.AddError("username taken")
|
||||
assert.True(t, f.HasError())
|
||||
assert.Equal(t, "username taken", f.FirstError())
|
||||
assert.Len(t, f.Errors(), 1)
|
||||
}
|
||||
|
||||
func TestFieldClearErrors(t *testing.T) {
|
||||
f := newTestField("", Required())
|
||||
f.Validate()
|
||||
assert.True(t, f.HasError())
|
||||
f.ClearErrors()
|
||||
assert.False(t, f.HasError())
|
||||
}
|
||||
|
||||
func TestFieldReset(t *testing.T) {
|
||||
f := newTestField("initial", Required(), MinLen(3))
|
||||
f.SetValue("changed")
|
||||
f.AddError("some error")
|
||||
|
||||
f.Reset()
|
||||
assert.Equal(t, "initial", f.String())
|
||||
assert.False(t, f.HasError())
|
||||
}
|
||||
|
||||
func TestValidateAll(t *testing.T) {
|
||||
v := New()
|
||||
var username, email *Field
|
||||
v.Page("/", func(c *Context) {
|
||||
username = c.Field("", Required(), MinLen(3))
|
||||
email = c.Field("", Required(), Email())
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
|
||||
// both empty → both fail
|
||||
assert.False(t, false) // sanity
|
||||
ok := username.Validate() && email.Validate()
|
||||
assert.False(t, ok)
|
||||
|
||||
// simulate ValidateAll via context
|
||||
v2 := New()
|
||||
var u2, e2 *Field
|
||||
v2.Page("/", func(c *Context) {
|
||||
u2 = c.Field("joe", Required(), MinLen(3))
|
||||
e2 = c.Field("joe@x.com", Required(), Email())
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
assert.True(t, c.ValidateAll(u2, e2))
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateAllPartialFailure(t *testing.T) {
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
good := c.Field("valid", Required())
|
||||
bad := c.Field("", Required())
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
// ValidateAll must run ALL fields even if first passes
|
||||
ok := c.ValidateAll(good, bad)
|
||||
assert.False(t, ok)
|
||||
assert.False(t, good.HasError())
|
||||
assert.True(t, bad.HasError())
|
||||
})
|
||||
}
|
||||
|
||||
func TestResetFields(t *testing.T) {
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
a := c.Field("a", Required())
|
||||
b := c.Field("b", Required())
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
a.SetValue("changed-a")
|
||||
b.SetValue("changed-b")
|
||||
a.AddError("err")
|
||||
|
||||
c.ResetFields(a, b)
|
||||
assert.Equal(t, "a", a.String())
|
||||
assert.Equal(t, "b", b.String())
|
||||
assert.False(t, a.HasError())
|
||||
})
|
||||
}
|
||||
|
||||
func TestFieldValidateClearsPreviousErrors(t *testing.T) {
|
||||
f := newTestField("", Required())
|
||||
f.Validate()
|
||||
assert.True(t, f.HasError())
|
||||
|
||||
f.SetValue("ok")
|
||||
f.Validate()
|
||||
assert.False(t, f.HasError())
|
||||
}
|
||||
|
||||
func TestFieldCustomValidator(t *testing.T) {
|
||||
f := newTestField("bad", Custom(func(val string) error {
|
||||
if val == "bad" {
|
||||
return fmt.Errorf("no bad words")
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
assert.False(t, f.Validate())
|
||||
assert.Equal(t, "no bad words", f.FirstError())
|
||||
|
||||
f.SetValue("good")
|
||||
assert.True(t, f.Validate())
|
||||
}
|
||||
87
internal/examples/signup/main.go
Normal file
87
internal/examples/signup/main.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func main() {
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
DocumentTitle: "Signup",
|
||||
ServerAddress: ":8080",
|
||||
})
|
||||
|
||||
v.AppendToHead(h.StyleEl(h.Raw(`
|
||||
body { font-family: system-ui, sans-serif; max-width: 420px; margin: 2rem auto; padding: 0 1rem; }
|
||||
label { display: block; font-weight: 600; margin-top: 1rem; }
|
||||
input { display: block; width: 100%; padding: 0.4rem; margin-top: 0.25rem; box-sizing: border-box; }
|
||||
.error { color: #c00; font-size: 0.85rem; margin-top: 0.2rem; }
|
||||
.success { color: #080; margin-top: 1rem; }
|
||||
.actions { margin-top: 1.5rem; display: flex; gap: 0.5rem; }
|
||||
`)))
|
||||
|
||||
v.Page("/", func(c *via.Context) {
|
||||
username := c.Field("", via.Required(), via.MinLen(3), via.MaxLen(20))
|
||||
email := c.Field("", via.Required(), via.Email())
|
||||
age := c.Field("", via.Required(), via.Min(13), via.Max(120))
|
||||
// Optional field — only validated when non-empty
|
||||
website := c.Field("", via.Custom(func(val string) error { return nil }), via.Pattern(`^$|^https?://\S+$`, "Must be a valid URL"))
|
||||
|
||||
var success string
|
||||
|
||||
signup := c.Action(func() {
|
||||
success = ""
|
||||
if !c.ValidateAll(username, email, age, website) {
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
// Server-side check
|
||||
if username.String() == "admin" {
|
||||
username.AddError("Username is already taken")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
success = "Account created for " + username.String() + "!"
|
||||
c.ResetFields(username, email, age, website)
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
reset := c.Action(func() {
|
||||
success = ""
|
||||
c.ResetFields(username, email, age, website)
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
h.H1(h.Text("Sign Up")),
|
||||
|
||||
h.Label(h.Text("Username")),
|
||||
h.Input(h.Type("text"), h.Placeholder("pick a username"), username.Bind()),
|
||||
h.If(username.HasError(), h.Div(h.Class("error"), h.Text(username.FirstError()))),
|
||||
|
||||
h.Label(h.Text("Email")),
|
||||
h.Input(h.Type("email"), h.Placeholder("you@example.com"), email.Bind()),
|
||||
h.If(email.HasError(), h.Div(h.Class("error"), h.Text(email.FirstError()))),
|
||||
|
||||
h.Label(h.Text("Age")),
|
||||
h.Input(h.Type("number"), h.Placeholder("your age"), age.Bind()),
|
||||
h.If(age.HasError(), h.Div(h.Class("error"), h.Text(age.FirstError()))),
|
||||
|
||||
h.Label(h.Text("Website (optional)")),
|
||||
h.Input(h.Type("url"), h.Placeholder("https://example.com"), website.Bind()),
|
||||
h.If(website.HasError(), h.Div(h.Class("error"), h.Text(website.FirstError()))),
|
||||
|
||||
h.Div(h.Class("actions"),
|
||||
h.Button(h.Text("Sign Up"), signup.OnClick()),
|
||||
h.Button(h.Text("Reset"), reset.OnClick()),
|
||||
),
|
||||
|
||||
h.If(success != "", h.P(h.Class("success"), h.Text(success))),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
v.Start()
|
||||
}
|
||||
128
rule.go
Normal file
128
rule.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Rule defines a single validation check for a Field.
|
||||
type Rule struct {
|
||||
validate func(val string) error
|
||||
}
|
||||
|
||||
// Required rejects empty or whitespace-only values.
|
||||
func Required(msg ...string) Rule {
|
||||
m := "This field is required"
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
return Rule{func(val string) error {
|
||||
if strings.TrimSpace(val) == "" {
|
||||
return fmt.Errorf("%s", m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// MinLen rejects values shorter than n characters.
|
||||
func MinLen(n int, msg ...string) Rule {
|
||||
m := fmt.Sprintf("Must be at least %d characters", n)
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
return Rule{func(val string) error {
|
||||
if len(val) < n {
|
||||
return fmt.Errorf("%s", m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// MaxLen rejects values longer than n characters.
|
||||
func MaxLen(n int, msg ...string) Rule {
|
||||
m := fmt.Sprintf("Must be at most %d characters", n)
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
return Rule{func(val string) error {
|
||||
if len(val) > n {
|
||||
return fmt.Errorf("%s", m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// Min parses the value as an integer and rejects values less than n.
|
||||
func Min(n int, msg ...string) Rule {
|
||||
m := fmt.Sprintf("Must be at least %d", n)
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
return Rule{func(val string) error {
|
||||
v, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", m)
|
||||
}
|
||||
if v < n {
|
||||
return fmt.Errorf("%s", m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// Max parses the value as an integer and rejects values greater than n.
|
||||
func Max(n int, msg ...string) Rule {
|
||||
m := fmt.Sprintf("Must be at most %d", n)
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
return Rule{func(val string) error {
|
||||
v, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", m)
|
||||
}
|
||||
if v > n {
|
||||
return fmt.Errorf("%s", m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// Pattern rejects values that don't match the regular expression re.
|
||||
func Pattern(re string, msg ...string) Rule {
|
||||
m := "Invalid format"
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
compiled := regexp.MustCompile(re)
|
||||
return Rule{func(val string) error {
|
||||
if !compiled.MatchString(val) {
|
||||
return fmt.Errorf("%s", m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
var emailRegexp = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
// Email rejects values that don't look like an email address.
|
||||
func Email(msg ...string) Rule {
|
||||
m := "Invalid email address"
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
return Rule{func(val string) error {
|
||||
if !emailRegexp.MatchString(val) {
|
||||
return fmt.Errorf("%s", m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// Custom creates a rule from a user-provided validation function.
|
||||
// The function should return nil for valid input and an error for invalid input.
|
||||
func Custom(fn func(string) error) Rule {
|
||||
return Rule{validate: fn}
|
||||
}
|
||||
116
rule_test.go
Normal file
116
rule_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRequired(t *testing.T) {
|
||||
r := Required()
|
||||
assert.NoError(t, r.validate("hello"))
|
||||
assert.Error(t, r.validate(""))
|
||||
assert.Error(t, r.validate(" "))
|
||||
}
|
||||
|
||||
func TestRequiredCustomMessage(t *testing.T) {
|
||||
r := Required("name needed")
|
||||
err := r.validate("")
|
||||
assert.EqualError(t, err, "name needed")
|
||||
}
|
||||
|
||||
func TestMinLen(t *testing.T) {
|
||||
r := MinLen(3)
|
||||
assert.NoError(t, r.validate("abc"))
|
||||
assert.NoError(t, r.validate("abcd"))
|
||||
assert.Error(t, r.validate("ab"))
|
||||
assert.Error(t, r.validate(""))
|
||||
}
|
||||
|
||||
func TestMinLenCustomMessage(t *testing.T) {
|
||||
r := MinLen(5, "too short")
|
||||
err := r.validate("ab")
|
||||
assert.EqualError(t, err, "too short")
|
||||
}
|
||||
|
||||
func TestMaxLen(t *testing.T) {
|
||||
r := MaxLen(5)
|
||||
assert.NoError(t, r.validate("abc"))
|
||||
assert.NoError(t, r.validate("abcde"))
|
||||
assert.Error(t, r.validate("abcdef"))
|
||||
}
|
||||
|
||||
func TestMaxLenCustomMessage(t *testing.T) {
|
||||
r := MaxLen(2, "too long")
|
||||
err := r.validate("abc")
|
||||
assert.EqualError(t, err, "too long")
|
||||
}
|
||||
|
||||
func TestMin(t *testing.T) {
|
||||
r := Min(5)
|
||||
assert.NoError(t, r.validate("5"))
|
||||
assert.NoError(t, r.validate("10"))
|
||||
assert.Error(t, r.validate("4"))
|
||||
assert.Error(t, r.validate("abc"))
|
||||
}
|
||||
|
||||
func TestMinCustomMessage(t *testing.T) {
|
||||
r := Min(10, "need 10+")
|
||||
err := r.validate("3")
|
||||
assert.EqualError(t, err, "need 10+")
|
||||
}
|
||||
|
||||
func TestMax(t *testing.T) {
|
||||
r := Max(10)
|
||||
assert.NoError(t, r.validate("10"))
|
||||
assert.NoError(t, r.validate("5"))
|
||||
assert.Error(t, r.validate("11"))
|
||||
assert.Error(t, r.validate("abc"))
|
||||
}
|
||||
|
||||
func TestMaxCustomMessage(t *testing.T) {
|
||||
r := Max(5, "too big")
|
||||
err := r.validate("6")
|
||||
assert.EqualError(t, err, "too big")
|
||||
}
|
||||
|
||||
func TestPattern(t *testing.T) {
|
||||
r := Pattern(`^\d{3}$`)
|
||||
assert.NoError(t, r.validate("123"))
|
||||
assert.Error(t, r.validate("12"))
|
||||
assert.Error(t, r.validate("abcd"))
|
||||
}
|
||||
|
||||
func TestPatternCustomMessage(t *testing.T) {
|
||||
r := Pattern(`^\d+$`, "digits only")
|
||||
err := r.validate("abc")
|
||||
assert.EqualError(t, err, "digits only")
|
||||
}
|
||||
|
||||
func TestEmail(t *testing.T) {
|
||||
r := Email()
|
||||
assert.NoError(t, r.validate("user@example.com"))
|
||||
assert.NoError(t, r.validate("a.b+c@foo.co"))
|
||||
assert.Error(t, r.validate("notanemail"))
|
||||
assert.Error(t, r.validate("@example.com"))
|
||||
assert.Error(t, r.validate("user@"))
|
||||
assert.Error(t, r.validate(""))
|
||||
}
|
||||
|
||||
func TestEmailCustomMessage(t *testing.T) {
|
||||
r := Email("bad email")
|
||||
err := r.validate("nope")
|
||||
assert.EqualError(t, err, "bad email")
|
||||
}
|
||||
|
||||
func TestCustom(t *testing.T) {
|
||||
r := Custom(func(val string) error {
|
||||
if val != "magic" {
|
||||
return fmt.Errorf("must be magic")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
assert.NoError(t, r.validate("magic"))
|
||||
assert.EqualError(t, r.validate("other"), "must be magic")
|
||||
}
|
||||
Reference in New Issue
Block a user