feat: auto-track fields on context for zero-arg ValidateAll/ResetFields

Fields created via Context.Field are now tracked on the page context,
so ValidateAll() and ResetFields() with no arguments operate on all
fields by default. Explicit field args still work for selective use.

Also switches MinLen/MaxLen to utf8.RuneCountInString for correct
unicode handling and replaces fmt.Errorf with errors.New where
format strings are unnecessary.
This commit is contained in:
Ryan Hamamura
2026-02-11 19:57:13 -10:00
parent 5362614c3e
commit 10b4838f8d
4 changed files with 81 additions and 34 deletions

View File

@@ -32,6 +32,7 @@ type Context struct {
mu sync.RWMutex mu sync.RWMutex
ctxDisposedChan chan struct{} ctxDisposedChan chan struct{}
reqCtx context.Context reqCtx context.Context
fields []*Field
subscriptions []Subscription subscriptions []Subscription
subsMu sync.Mutex subsMu sync.Mutex
disposeOnce sync.Once disposeOnce sync.Once
@@ -482,16 +483,28 @@ func (c *Context) unsubscribeAll() {
// Field creates a signal with validation rules attached. // Field creates a signal with validation rules attached.
// The initial value seeds both the signal and the reset target. // The initial value seeds both the signal and the reset target.
// The field is tracked on the context so ValidateAll/ResetFields
// can operate on all fields by default.
func (c *Context) Field(initial any, rules ...Rule) *Field { func (c *Context) Field(initial any, rules ...Rule) *Field {
return &Field{ f := &Field{
signal: c.Signal(initial), signal: c.Signal(initial),
rules: rules, rules: rules,
initialVal: initial, initialVal: initial,
} }
target := c
if c.isComponent() {
target = c.parentPageCtx
}
target.fields = append(target.fields, f)
return f
} }
// ValidateAll runs Validate on every field, returning true only if all pass. // ValidateAll runs Validate on each field, returning true only if all pass.
// With no arguments it validates every field tracked on this context.
func (c *Context) ValidateAll(fields ...*Field) bool { func (c *Context) ValidateAll(fields ...*Field) bool {
if len(fields) == 0 {
fields = c.fields
}
ok := true ok := true
for _, f := range fields { for _, f := range fields {
if !f.Validate() { if !f.Validate() {
@@ -501,8 +514,12 @@ func (c *Context) ValidateAll(fields ...*Field) bool {
return ok return ok
} }
// ResetFields resets every field to its initial value and clears errors. // ResetFields resets each field to its initial value and clears errors.
// With no arguments it resets every field tracked on this context.
func (c *Context) ResetFields(fields ...*Field) { func (c *Context) ResetFields(fields ...*Field) {
if len(fields) == 0 {
fields = c.fields
}
for _, f := range fields { for _, f := range fields {
f.Reset() f.Reset()
} }

View File

@@ -96,27 +96,22 @@ func TestFieldReset(t *testing.T) {
func TestValidateAll(t *testing.T) { func TestValidateAll(t *testing.T) {
v := New() v := New()
var username, email *Field
v.Page("/", func(c *Context) { v.Page("/", func(c *Context) {
username = c.Field("", Required(), MinLen(3)) c.Field("", Required(), MinLen(3))
email = c.Field("", Required(), Email()) c.Field("", Required(), Email())
c.View(func() h.H { return h.Div() }) c.View(func() h.H { return h.Div() })
// both empty → both fail
assert.False(t, c.ValidateAll())
}) })
// both empty → both fail
assert.False(t, false) // sanity
ok := username.Validate() && email.Validate()
assert.False(t, ok)
// simulate ValidateAll via context
v2 := New() v2 := New()
var u2, e2 *Field
v2.Page("/", func(c *Context) { v2.Page("/", func(c *Context) {
u2 = c.Field("joe", Required(), MinLen(3)) c.Field("joe", Required(), MinLen(3))
e2 = c.Field("joe@x.com", Required(), Email()) c.Field("joe@x.com", Required(), Email())
c.View(func() h.H { return h.Div() }) c.View(func() h.H { return h.Div() })
assert.True(t, c.ValidateAll(u2, e2)) assert.True(t, c.ValidateAll())
}) })
} }
@@ -127,14 +122,30 @@ func TestValidateAllPartialFailure(t *testing.T) {
bad := c.Field("", Required()) bad := c.Field("", Required())
c.View(func() h.H { return h.Div() }) c.View(func() h.H { return h.Div() })
// ValidateAll must run ALL fields even if first passes ok := c.ValidateAll()
ok := c.ValidateAll(good, bad)
assert.False(t, ok) assert.False(t, ok)
assert.False(t, good.HasError()) assert.False(t, good.HasError())
assert.True(t, bad.HasError()) assert.True(t, bad.HasError())
}) })
} }
func TestValidateAllSelectiveArgs(t *testing.T) {
v := New()
v.Page("/", func(c *Context) {
a := c.Field("", Required())
b := c.Field("ok", Required())
cField := c.Field("", Required())
c.View(func() h.H { return h.Div() })
// only validate a and b — cField should be untouched
ok := c.ValidateAll(a, b)
assert.False(t, ok)
assert.True(t, a.HasError())
assert.False(t, b.HasError())
assert.False(t, cField.HasError(), "unselected field should not be validated")
})
}
func TestResetFields(t *testing.T) { func TestResetFields(t *testing.T) {
v := New() v := New()
v.Page("/", func(c *Context) { v.Page("/", func(c *Context) {
@@ -146,13 +157,30 @@ func TestResetFields(t *testing.T) {
b.SetValue("changed-b") b.SetValue("changed-b")
a.AddError("err") a.AddError("err")
c.ResetFields(a, b) c.ResetFields()
assert.Equal(t, "a", a.String()) assert.Equal(t, "a", a.String())
assert.Equal(t, "b", b.String()) assert.Equal(t, "b", b.String())
assert.False(t, a.HasError()) assert.False(t, a.HasError())
}) })
} }
func TestResetFieldsSelectiveArgs(t *testing.T) {
v := New()
v.Page("/", func(c *Context) {
a := c.Field("a")
b := c.Field("b")
c.View(func() h.H { return h.Div() })
a.SetValue("changed-a")
b.SetValue("changed-b")
// only reset a
c.ResetFields(a)
assert.Equal(t, "a", a.String())
assert.Equal(t, "changed-b", b.String(), "unselected field should not be reset")
})
}
func TestFieldValidateClearsPreviousErrors(t *testing.T) { func TestFieldValidateClearsPreviousErrors(t *testing.T) {
f := newTestField("", Required()) f := newTestField("", Required())
f.Validate() f.Validate()

View File

@@ -26,13 +26,13 @@ func main() {
email := c.Field("", via.Required(), via.Email()) email := c.Field("", via.Required(), via.Email())
age := c.Field("", via.Required(), via.Min(13), via.Max(120)) age := c.Field("", via.Required(), via.Min(13), via.Max(120))
// Optional field — only validated when non-empty // 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")) website := c.Field("", via.Pattern(`^$|^https?://\S+$`, "Must be a valid URL"))
var success string var success string
signup := c.Action(func() { signup := c.Action(func() {
success = "" success = ""
if !c.ValidateAll(username, email, age, website) { if !c.ValidateAll() {
c.Sync() c.Sync()
return return
} }
@@ -43,13 +43,13 @@ func main() {
return return
} }
success = "Account created for " + username.String() + "!" success = "Account created for " + username.String() + "!"
c.ResetFields(username, email, age, website) c.ResetFields()
c.Sync() c.Sync()
}) })
reset := c.Action(func() { reset := c.Action(func() {
success = "" success = ""
c.ResetFields(username, email, age, website) c.ResetFields()
c.Sync() c.Sync()
}) })

24
rule.go
View File

@@ -1,10 +1,12 @@
package via package via
import ( import (
"errors"
"fmt" "fmt"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
) )
// Rule defines a single validation check for a Field. // Rule defines a single validation check for a Field.
@@ -20,7 +22,7 @@ func Required(msg ...string) Rule {
} }
return Rule{func(val string) error { return Rule{func(val string) error {
if strings.TrimSpace(val) == "" { if strings.TrimSpace(val) == "" {
return fmt.Errorf("%s", m) return errors.New(m)
} }
return nil return nil
}} }}
@@ -33,8 +35,8 @@ func MinLen(n int, msg ...string) Rule {
m = msg[0] m = msg[0]
} }
return Rule{func(val string) error { return Rule{func(val string) error {
if len(val) < n { if utf8.RuneCountInString(val) < n {
return fmt.Errorf("%s", m) return errors.New(m)
} }
return nil return nil
}} }}
@@ -47,8 +49,8 @@ func MaxLen(n int, msg ...string) Rule {
m = msg[0] m = msg[0]
} }
return Rule{func(val string) error { return Rule{func(val string) error {
if len(val) > n { if utf8.RuneCountInString(val) > n {
return fmt.Errorf("%s", m) return errors.New(m)
} }
return nil return nil
}} }}
@@ -63,10 +65,10 @@ func Min(n int, msg ...string) Rule {
return Rule{func(val string) error { return Rule{func(val string) error {
v, err := strconv.Atoi(val) v, err := strconv.Atoi(val)
if err != nil { if err != nil {
return fmt.Errorf("%s", m) return errors.New("Must be a valid number")
} }
if v < n { if v < n {
return fmt.Errorf("%s", m) return errors.New(m)
} }
return nil return nil
}} }}
@@ -81,10 +83,10 @@ func Max(n int, msg ...string) Rule {
return Rule{func(val string) error { return Rule{func(val string) error {
v, err := strconv.Atoi(val) v, err := strconv.Atoi(val)
if err != nil { if err != nil {
return fmt.Errorf("%s", m) return errors.New("Must be a valid number")
} }
if v > n { if v > n {
return fmt.Errorf("%s", m) return errors.New(m)
} }
return nil return nil
}} }}
@@ -99,7 +101,7 @@ func Pattern(re string, msg ...string) Rule {
compiled := regexp.MustCompile(re) compiled := regexp.MustCompile(re)
return Rule{func(val string) error { return Rule{func(val string) error {
if !compiled.MatchString(val) { if !compiled.MatchString(val) {
return fmt.Errorf("%s", m) return errors.New(m)
} }
return nil return nil
}} }}
@@ -115,7 +117,7 @@ func Email(msg ...string) Rule {
} }
return Rule{func(val string) error { return Rule{func(val string) error {
if !emailRegexp.MatchString(val) { if !emailRegexp.MatchString(val) {
return fmt.Errorf("%s", m) return errors.New(m)
} }
return nil return nil
}} }}