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 }}