From 5362614c3e3842de6d781b90e05b74af8b405a9f Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:42:44 -1000 Subject: [PATCH] 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. --- context.go | 28 +++++ field.go | 58 ++++++++++ field_test.go | 178 +++++++++++++++++++++++++++++++ internal/examples/signup/main.go | 87 +++++++++++++++ rule.go | 128 ++++++++++++++++++++++ rule_test.go | 116 ++++++++++++++++++++ 6 files changed, 595 insertions(+) create mode 100644 field.go create mode 100644 field_test.go create mode 100644 internal/examples/signup/main.go create mode 100644 rule.go create mode 100644 rule_test.go diff --git a/context.go b/context.go index e2f8df6..7f35916 100644 --- a/context.go +++ b/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") diff --git a/field.go b/field.go new file mode 100644 index 0000000..d3ccda7 --- /dev/null +++ b/field.go @@ -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 +} diff --git a/field_test.go b/field_test.go new file mode 100644 index 0000000..c1d41c8 --- /dev/null +++ b/field_test.go @@ -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()) +} diff --git a/internal/examples/signup/main.go b/internal/examples/signup/main.go new file mode 100644 index 0000000..70694c6 --- /dev/null +++ b/internal/examples/signup/main.go @@ -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() +} diff --git a/rule.go b/rule.go new file mode 100644 index 0000000..78126a0 --- /dev/null +++ b/rule.go @@ -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} +} diff --git a/rule_test.go b/rule_test.go new file mode 100644 index 0000000..46ebca3 --- /dev/null +++ b/rule_test.go @@ -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") +}