From 10b4838f8d912267a479ebe6f00f6623b09854aa Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:57:13 -1000 Subject: [PATCH] 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. --- context.go | 23 ++++++++++-- field_test.go | 60 +++++++++++++++++++++++--------- internal/examples/signup/main.go | 8 ++--- rule.go | 24 +++++++------ 4 files changed, 81 insertions(+), 34 deletions(-) diff --git a/context.go b/context.go index 7f35916..36d4634 100644 --- a/context.go +++ b/context.go @@ -32,6 +32,7 @@ type Context struct { mu sync.RWMutex ctxDisposedChan chan struct{} reqCtx context.Context + fields []*Field subscriptions []Subscription subsMu sync.Mutex disposeOnce sync.Once @@ -482,16 +483,28 @@ func (c *Context) unsubscribeAll() { // Field creates a signal with validation rules attached. // 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 { - return &Field{ + f := &Field{ signal: c.Signal(initial), rules: rules, 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 { + if len(fields) == 0 { + fields = c.fields + } ok := true for _, f := range fields { if !f.Validate() { @@ -501,8 +514,12 @@ func (c *Context) ValidateAll(fields ...*Field) bool { 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) { + if len(fields) == 0 { + fields = c.fields + } for _, f := range fields { f.Reset() } diff --git a/field_test.go b/field_test.go index c1d41c8..08f6d54 100644 --- a/field_test.go +++ b/field_test.go @@ -96,27 +96,22 @@ func TestFieldReset(t *testing.T) { 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.Field("", Required(), MinLen(3)) + c.Field("", Required(), Email()) 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() - 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.Field("joe", Required(), MinLen(3)) + c.Field("joe@x.com", Required(), Email()) 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()) c.View(func() h.H { return h.Div() }) - // ValidateAll must run ALL fields even if first passes - ok := c.ValidateAll(good, bad) + ok := c.ValidateAll() assert.False(t, ok) assert.False(t, good.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) { v := New() v.Page("/", func(c *Context) { @@ -146,13 +157,30 @@ func TestResetFields(t *testing.T) { b.SetValue("changed-b") a.AddError("err") - c.ResetFields(a, b) + c.ResetFields() assert.Equal(t, "a", a.String()) assert.Equal(t, "b", b.String()) 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) { f := newTestField("", Required()) f.Validate() diff --git a/internal/examples/signup/main.go b/internal/examples/signup/main.go index 70694c6..5a97c84 100644 --- a/internal/examples/signup/main.go +++ b/internal/examples/signup/main.go @@ -26,13 +26,13 @@ func main() { 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")) + website := c.Field("", via.Pattern(`^$|^https?://\S+$`, "Must be a valid URL")) var success string signup := c.Action(func() { success = "" - if !c.ValidateAll(username, email, age, website) { + if !c.ValidateAll() { c.Sync() return } @@ -43,13 +43,13 @@ func main() { return } success = "Account created for " + username.String() + "!" - c.ResetFields(username, email, age, website) + c.ResetFields() c.Sync() }) reset := c.Action(func() { success = "" - c.ResetFields(username, email, age, website) + c.ResetFields() c.Sync() }) diff --git a/rule.go b/rule.go index 78126a0..b7e3f3d 100644 --- a/rule.go +++ b/rule.go @@ -1,10 +1,12 @@ package via import ( + "errors" "fmt" "regexp" "strconv" "strings" + "unicode/utf8" ) // Rule defines a single validation check for a Field. @@ -20,7 +22,7 @@ func Required(msg ...string) Rule { } return Rule{func(val string) error { if strings.TrimSpace(val) == "" { - return fmt.Errorf("%s", m) + return errors.New(m) } return nil }} @@ -33,8 +35,8 @@ func MinLen(n int, msg ...string) Rule { m = msg[0] } return Rule{func(val string) error { - if len(val) < n { - return fmt.Errorf("%s", m) + if utf8.RuneCountInString(val) < n { + return errors.New(m) } return nil }} @@ -47,8 +49,8 @@ func MaxLen(n int, msg ...string) Rule { m = msg[0] } return Rule{func(val string) error { - if len(val) > n { - return fmt.Errorf("%s", m) + if utf8.RuneCountInString(val) > n { + return errors.New(m) } return nil }} @@ -63,10 +65,10 @@ func Min(n int, msg ...string) Rule { return Rule{func(val string) error { v, err := strconv.Atoi(val) if err != nil { - return fmt.Errorf("%s", m) + return errors.New("Must be a valid number") } if v < n { - return fmt.Errorf("%s", m) + return errors.New(m) } return nil }} @@ -81,10 +83,10 @@ func Max(n int, msg ...string) Rule { return Rule{func(val string) error { v, err := strconv.Atoi(val) if err != nil { - return fmt.Errorf("%s", m) + return errors.New("Must be a valid number") } if v > n { - return fmt.Errorf("%s", m) + return errors.New(m) } return nil }} @@ -99,7 +101,7 @@ func Pattern(re string, msg ...string) Rule { compiled := regexp.MustCompile(re) return Rule{func(val string) error { if !compiled.MatchString(val) { - return fmt.Errorf("%s", m) + return errors.New(m) } return nil }} @@ -115,7 +117,7 @@ func Email(msg ...string) Rule { } return Rule{func(val string) error { if !emailRegexp.MatchString(val) { - return fmt.Errorf("%s", m) + return errors.New(m) } return nil }}