diff --git a/README.md b/README.md index b4aa9ce..6eb708b 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,9 @@ The library ships structure, the project owns the look (CSS-first doctrine): 3. **`form.SetGlobalClass("my-app-form")`** — adds classes to every `
` created afterwards, useful for scoping: `.my-app-form .tw-field { ... }`. +4. **`f.SetClass("my-form")`** — appends classes to a specific form instance + on top of any global classes. Chainable. + ## Built-in Input Types 18 types in `input/` ([full reference](docs/STANDARD_TYPES.md)): @@ -190,7 +193,8 @@ The library ships structure, the project owns the look (CSS-first doctrine): Need one that isn't here? **Custom inputs** live in your own package: embed `input.Base`, configure the `Permitted` rules, override `Validate` if needed. -Full pattern: [input/README.md](input/README.md). +If your input needs custom markup, implement the `form.Renderer` capability +interface — see [input/README.md](input/README.md). ## API Reference @@ -206,8 +210,10 @@ where the name comes from the optional `Namer` interface |--------|-------------| | `String() string` | Generates form HTML | | `Render() *dom.Element` | **WASM** — reactive DOM tree (`dom.ViewRenderer`) | +| `Submit() error` | Runs sync + validate + OnSubmit callback programmatically; returns first validation error | | `SetSSR(bool) *Form` | SSR mode: adds `method`/`action` attributes | | `OnSubmit(func(model.Fielder, func(error))) *Form` | WASM submit callback | +| `SetClass(...string) *Form` | Appends CSS classes to this form (on top of SetGlobalClass) | | `Validate() error` | Validates all inputs, returns first error | | `SyncValues(model.Fielder) error` | Copies input values back into the data struct | | `ValidateData(byte, model.Fielder) error` | Server-side validation (crudp.DataValidator) | diff --git a/docs/API.md b/docs/API.md index 18a0491..878ede9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -17,6 +17,19 @@ For each field in `data.Schema()`: 4. `field.NotNull` → `SetRequired(true)` on the input. 5. Current value bound via `fmt.ReadValues()` + `SetValues()`. +## `(*Form).Submit()` — Submit Pipeline + +Runs the full submission pipeline programmatically: +1. `SyncValues(data)` — syncs input values to the struct. +2. `Validate()` — performs final validation. +3. If valid and `OnSubmit` is set: + - Sets the `submitting` signal to `true`. + - Fires the `onSubmit(data, done)` callback. + - Resets the form on `done(nil)` unless `NoResetOnSuccess()` was called. + +The DOM `submit` handler delegates to this method after `PreventDefault()`. +Returns the first validation error, or `nil` if submission was dispatched. + ## `(*Form).Validate()` — Validation Detail - Skips fields with `SkipValidation` set to true in the input. @@ -56,6 +69,20 @@ Error messages from `Permitted.Validate(name, text)`: - `"space not allowed"` — space when Spaces=false - `"character {X} not allowed"` — disallowed character +## `form.Renderer` — Custom Input Markup + +Optional capability interface for custom inputs that own their markup: + +```go +type Renderer interface { + RenderInput(value *dom.SignalString, onInput func(string)) *dom.Element +} +``` + +- **Contract**: The form still owns the field wrapper (`div.tw-field`), the error span, and validation. +- **Wiring**: The widget must call `onInput` with the new value whenever the user interacts with the control; the form then updates the value signal and runs live validation. +- **Location**: Defined in package `form` (not `input`) because it references `*dom.Element`. + ## Namer Interface Fielder types can optionally implement `Namer` to provide a custom form name: diff --git a/form.go b/form.go index 2b575f6..9ff85e0 100644 --- a/form.go +++ b/form.go @@ -192,6 +192,44 @@ func (f *Form) SetOptions(fieldName string, opts ...fmt.KeyValue) *Form { // Reset clears all input values and error messages in the DOM and internal state. func (f *Form) Reset() { f.reset() } +// Submit runs the full submit pipeline programmatically: syncs input values +// into the bound struct, validates, and (if valid) fires the OnSubmit +// callback. Returns the first validation error, or nil if the submission +// was dispatched. The async result of the submission itself is delivered +// through the OnSubmit callback's done function. +func (f *Form) Submit() error { + // Sync all values from signals to struct + f.SyncValues(f.data) + + // Validate all (final check) + if err := f.Validate(); err != nil { + return err + } + + if f.onSubmit != nil { + f.submitting.Set(true) + f.onSubmit(f.data, func(err error) { + f.submitting.Set(false) + if err == nil && !f.noResetOnSuccess { + f.reset() + } + }) + } + return nil +} + +// SetClass appends CSS classes to this form (on top of any global classes +// set via SetGlobalClass). Chainable. +func (f *Form) SetClass(classes ...string) *Form { + for _, c := range classes { + if f.class != "" { + f.class += " " + } + f.class += c + } + return f +} + func (f *Form) resolveSubmitLabel() string { label := f.submitLabel if label == "" { diff --git a/input/README.md b/input/README.md index be5b19c..c7a758b 100644 --- a/input/README.md +++ b/input/README.md @@ -118,6 +118,29 @@ The custom input renders with the generic markup for its `htmlName` `checkbox` → bool, else text); override `Storage()` on your struct if your kind needs a different mapping. +### Custom markup (Renderer) + +If your input needs a special UI (e.g. a color picker, a file uploader, or a +composite widget), implement the `form.Renderer` interface in your struct. +The `form` package will call this instead of its default switch: + +```go +import ( + "github.com/tinywasm/dom" + "github.com/tinywasm/form" +) + +func (m *myInput) RenderInput(value *dom.SignalString, onInput func(string)) *dom.Element { + el := dom.NewElement("div").Class("my-custom-widget") + // ... build your UI, bind 'value' signal for updates + // ... call 'onInput(newValue)' when the user interacts + return el +} +``` + +The `form.Renderer` interface lives in the root `form` package because it +references `dom.Element`; the `input` package remains dom-free by contract. + ### Base Available Methods | Method | Purpose | diff --git a/render.go b/render.go index 373557d..afc0629 100644 --- a/render.go +++ b/render.go @@ -55,24 +55,7 @@ func (f *Form) Render() *dom.Element { // Bind submit event el.On("submit", func(e dom.Event) { e.PreventDefault() - - // Sync all values from signals to struct - f.SyncValues(f.data) - - // Validate all (final check) - if err := f.Validate(); err != nil { - return - } - - if f.onSubmit != nil { - f.submitting.Set(true) - f.onSubmit(f.data, func(err error) { - f.submitting.Set(false) - if err == nil && !f.noResetOnSuccess { - f.reset() - } - }) - } + f.Submit() }) return el diff --git a/render_input.go b/render_input.go index caf8b86..2077f90 100644 --- a/render_input.go +++ b/render_input.go @@ -12,6 +12,15 @@ type fieldComponent struct { err *dom.SignalString } +// Renderer is an optional capability for custom inputs that own their markup. +// The form still owns the field wrapper (div.tw-field), the error span, and +// validation: the widget must call onInput with the new value on user input — +// the form updates the value signal and runs live validation. The value +// signal carries the initial value and programmatic updates (SetValues). +type Renderer interface { + RenderInput(value *dom.SignalString, onInput func(string)) *dom.Element +} + func (fc *fieldComponent) String() string { return fc.Render().String() } @@ -39,16 +48,23 @@ func (fc *fieldComponent) validate(val string) { func (fc *fieldComponent) Render() *dom.Element { container := dom.NewElement("div").Class("tw-field") - htmlName := fc.Input.HTMLName() - switch htmlName { - case "radio": - fc.renderRadio(container) - case "select": - fc.renderSelect(container) - case "datalist": - fc.renderDatalist(container) - default: - fc.renderInput(container) + if r, ok := fc.Input.(Renderer); ok { + container.Child(r.RenderInput(fc.value, func(v string) { + fc.value.Set(v) + fc.validate(v) + })) + } else { + htmlName := fc.Input.HTMLName() + switch htmlName { + case "radio": + fc.renderRadio(container) + case "select": + fc.renderSelect(container) + case "datalist": + fc.renderDatalist(container) + default: + fc.renderInput(container) + } } errSpan := dom.NewElement("span"). diff --git a/submit.shared_test.go b/submit.shared_test.go deleted file mode 100644 index b957e3e..0000000 --- a/submit.shared_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package form - -import ( - "github.com/tinywasm/form/input" - "github.com/tinywasm/model" - "testing" -) - -type submitStruct struct { - model.Fielder - Nombre string -} - -func (s *submitStruct) Schema() []model.Field { - return []model.Field{ - {Name: "nombre", NotNull: true, Type: input.Text()}, - } -} - -func (s *submitStruct) Pointers() []any { return []any{&s.Nombre} } -func (s *submitStruct) Values() []any { return []any{s.Nombre} } - -func runSubmitTests(t *testing.T) { - t.Run("TestSubmit_CallbackReceivesDone", func(t *testing.T) { - s := &submitStruct{Nombre: "Jules"} - f, _ := New("app", s) - - called := false - f.OnSubmit(func(data model.Fielder, done func(error)) { - called = true - done(nil) - }) - - if f.onSubmit == nil { - t.Fatal("onSubmit callback not set") - } - - f.onSubmit(f.data, func(err error) { - // dummy done - }) - - if !called { - t.Error("OnSubmit callback was not called") - } - }) - - t.Run("TestSubmit_NoResetOnSuccess", func(t *testing.T) { - s := &submitStruct{Nombre: "Jules"} - f, _ := New("app", s) - f.NoResetOnSuccess() - f.SetValues("nombre", "Valor Original") - - doneCalled := false - f.onSubmit = func(data model.Fielder, done func(error)) { - done(nil) - } - f.onSubmit(f.data, func(err error) { - doneCalled = true - if err == nil && !f.noResetOnSuccess { - f.reset() - } - }) - - if !doneCalled { - t.Error("done callback was not called") - } - - val := f.Input("nombre").(interface{ GetValue() string }).GetValue() - if val == "" { - t.Error("Expected form to retain values when NoResetOnSuccess is set") - } - }) - - t.Run("TestSubmit_ResetClearsValues", func(t *testing.T) { - s := &submitStruct{Nombre: "Jules"} - f, _ := New("app", s) - f.SetValues("nombre", "New Value") - - f.Reset() - - val := f.Input("nombre").(interface{ GetValue() string }).GetValue() - if val != "" { - t.Errorf("Expected empty value after reset, got %q", val) - } - }) -} diff --git a/tests/features_test.go b/tests/features_test.go new file mode 100644 index 0000000..e641d24 --- /dev/null +++ b/tests/features_test.go @@ -0,0 +1,91 @@ +package form_test + +import ( + "testing" + + "github.com/tinywasm/dom" + "github.com/tinywasm/fmt" + "github.com/tinywasm/form" + "github.com/tinywasm/form/input" +) + +func TestForm_SetClass(t *testing.T) { + s := &submitStruct{Nombre: "Jules"} + + t.Run("Per-form class only", func(t *testing.T) { + f, _ := form.New("app", s) + f.SetClass("cms-form") + html := f.String() + if !fmt.Contains(html, "class='cms-form'") { + t.Errorf("Expected class='cms-form' in HTML, got %s", html) + } + }) + + t.Run("Global + per-form class", func(t *testing.T) { + form.SetGlobalClass("base-form") + f, _ := form.New("app", s) + f.SetClass("extra-form") + html := f.String() + if !fmt.Contains(html, "class='base-form extra-form'") { + t.Errorf("Expected combined classes, got %s", html) + } + }) +} + +type customInput struct { + input.Base +} + +func (c *customInput) Clone(parentID, name string) input.Input { + cl := *c + cl.InitBase(parentID, name, c.HTMLName()) + return &cl +} + +func (c *customInput) RenderInput(value *dom.SignalString, onInput func(string)) *dom.Element { + return dom.NewElement("div").Class("my-widget").Text("Custom Widget") +} + +func TestForm_Renderer(t *testing.T) { + ci := &customInput{} + ci.InitBase("custom", "Custom Field", "text") + + fx := &kindFixture{inp: ci} + f, _ := form.New("tid", fx) + + html := f.String() + + // Must contain custom markup + if !fmt.Contains(html, "class='my-widget'") { + t.Errorf("Expected 'my-widget' class, got %s", html) + } + + // Must contain the standard error span + if !fmt.Contains(html, "class='tw-field-error'") { + t.Errorf("Expected 'tw-field-error' class, got %s", html) + } +} + +func TestForm_RendererValidation(t *testing.T) { + // Custom input with a validation rule + ci := &customInput{} + ci.InitBase("custom", "Custom Field", "text") + ci.SetRequired(true) + + fx := &kindFixture{inp: ci} + f, _ := form.New("tid", fx) + + // Empty required field should fail validation + f.SetValues("tfield", "") + err := f.Validate() + if err == nil { + t.Error("Expected validation error for required custom field, got nil") + } + + // Non-empty should pass + f.SetValues("tfield", "valid") + err = f.Validate() + if err != nil { + t.Errorf("Unexpected validation error: %v", err) + } +} diff --git a/render_input_test.go b/tests/render_input_test.go similarity index 78% rename from render_input_test.go rename to tests/render_input_test.go index 62012d0..4e0eb9e 100644 --- a/render_input_test.go +++ b/tests/render_input_test.go @@ -1,13 +1,49 @@ -package form +package form_test import ( "testing" - "github.com/tinywasm/dom" "github.com/tinywasm/fmt" + "github.com/tinywasm/form" "github.com/tinywasm/form/input" + "github.com/tinywasm/model" ) +// kindFixture is a one-field Fielder used to render a single input kind +// through the real form pipeline. +type kindFixture struct { + inp input.Input + valText string + valInt int64 + valBool bool +} + +func (k *kindFixture) Schema() []model.Field { + return []model.Field{{Name: "tfield", Type: k.inp}} +} + +func (k *kindFixture) Pointers() []any { + switch k.inp.Storage() { + case model.FieldInt: + return []any{&k.valInt} + case model.FieldBool: + return []any{&k.valBool} + default: + return []any{&k.valText} + } +} + +func (k *kindFixture) Values() []any { + switch k.inp.Storage() { + case model.FieldInt: + return []any{k.valInt} + case model.FieldBool: + return []any{k.valBool} + default: + return []any{k.valText} + } +} + // Test_Render verifies String output for inputs with custom rendering. func Test_Render(t *testing.T) { cases := []rc{ @@ -81,8 +117,6 @@ func Test_Render(t *testing.T) { contain: `hello world`, }, - // ── Rut ────────────────────────────────────────────────────────────── - // ── Search ─────────────────────────────────────────────────────────── { t: "Search", name: "renders search input", @@ -103,21 +137,19 @@ func Test_Render(t *testing.T) { for _, c := range cases { c := c t.Run(c.t+"/"+c.name, func(t *testing.T) { - inp := buildInput(t, c.t, c.opts) - vSig := dom.NewString("") - if len(c.values) > 0 { - vSig.Set(c.values[0]) - if setter, ok := inp.(interface{ SetValues(...string) }); ok { - setter.SetValues(c.values...) - } + inp := buildInput(t, c.t) + fx := &kindFixture{inp: inp} + f, err := form.New("tid", fx) + if err != nil { + t.Fatalf("form.New failed: %v", err) } - fc := &fieldComponent{ - Input: inp, - value: vSig, - err: dom.NewString(""), + if len(c.opts) > 0 { + f.SetOptions("tfield", c.opts...) + } + if len(c.values) > 0 { + f.SetValues("tfield", c.values...) } - el := fc.Render() - html := el.String() + html := f.String() if !fmt.Contains(html, c.contain) { t.Errorf("RenderInput() missing %q\ngot: %s", c.contain, html) } @@ -139,9 +171,8 @@ var opts12 = []fmt.KeyValue{{Key: "1", Value: "Admin"}, {Key: "2", Value: "Edito var optsGender = []fmt.KeyValue{{Key: "m", Value: "Male"}, {Key: "f", Value: "Female"}} // buildInput creates a fresh input instance by kind. -func buildInput(t *testing.T, kind string, opts []fmt.KeyValue) input.Input { +func buildInput(t *testing.T, kind string) input.Input { t.Helper() - id, name := "tid", "tfield" var inp input.Input switch kind { case "Address": @@ -149,11 +180,7 @@ func buildInput(t *testing.T, kind string, opts []fmt.KeyValue) input.Input { case "Checkbox": inp = input.Checkbox() case "Datalist": - dl := input.Datalist() - if len(opts) > 0 { - dl.(interface{ SetOptions(...fmt.KeyValue) }).SetOptions(opts...) - } - inp = dl + inp = input.Datalist() case "Date": inp = input.Date() case "Email": @@ -161,11 +188,7 @@ func buildInput(t *testing.T, kind string, opts []fmt.KeyValue) input.Input { case "Filepath": inp = input.Filepath() case "Gender": - g := input.Gender() - if len(opts) > 0 { - g.(interface{ SetOptions(...fmt.KeyValue) }).SetOptions(opts...) - } - inp = g + inp = input.Gender() case "Hour": inp = input.Hour() case "IP": @@ -177,21 +200,13 @@ func buildInput(t *testing.T, kind string, opts []fmt.KeyValue) input.Input { case "Phone": inp = input.Phone() case "Radio": - r := input.Radio() - if len(opts) > 0 { - r.(interface{ SetOptions(...fmt.KeyValue) }).SetOptions(opts...) - } - inp = r + inp = input.Radio() case "Rut": inp = input.Rut() case "Search": inp = input.Search() case "Select": - s := input.Select() - if len(opts) > 0 { - s.(interface{ SetOptions(...fmt.KeyValue) }).SetOptions(opts...) - } - inp = s + inp = input.Select() case "Text": inp = input.Text() case "Textarea": @@ -200,5 +215,5 @@ func buildInput(t *testing.T, kind string, opts []fmt.KeyValue) input.Input { t.Fatalf("unknown input type: %q", kind) return nil } - return inp.Clone(id, name) + return inp } diff --git a/submit.back_test.go b/tests/submit.back_test.go similarity index 66% rename from submit.back_test.go rename to tests/submit.back_test.go index 646fc93..e1d2e14 100644 --- a/submit.back_test.go +++ b/tests/submit.back_test.go @@ -1,8 +1,10 @@ //go:build !wasm -package form +package form_test -import "testing" +import ( + "testing" +) func TestSubmit_Back(t *testing.T) { runSubmitTests(t) diff --git a/submit.front_test.go b/tests/submit.front_test.go similarity index 66% rename from submit.front_test.go rename to tests/submit.front_test.go index 1feaddb..af5e4e1 100644 --- a/submit.front_test.go +++ b/tests/submit.front_test.go @@ -1,8 +1,10 @@ //go:build wasm -package form +package form_test -import "testing" +import ( + "testing" +) func TestSubmit_Front(t *testing.T) { runSubmitTests(t) diff --git a/tests/submit.shared_test.go b/tests/submit.shared_test.go new file mode 100644 index 0000000..c8f0eed --- /dev/null +++ b/tests/submit.shared_test.go @@ -0,0 +1,130 @@ +package form_test + +import ( + "github.com/tinywasm/form" + "github.com/tinywasm/form/input" + "github.com/tinywasm/model" + "testing" +) + +type submitStruct struct { + model.Fielder + Nombre string +} + +func (s *submitStruct) Schema() []model.Field { + return []model.Field{ + {Name: "nombre", NotNull: true, Type: input.Text()}, + } +} + +func (s *submitStruct) Pointers() []any { return []any{&s.Nombre} } +func (s *submitStruct) Values() []any { return []any{s.Nombre} } + +func runSubmitTests(t *testing.T) { + t.Run("TestSubmit_CallbackReceivesDataAndDone", func(t *testing.T) { + s := &submitStruct{Nombre: "Jules"} + f, _ := form.New("app", s) + f.SetValues("nombre", "New Name") + + called := false + var receivedData model.Fielder + f.OnSubmit(func(data model.Fielder, done func(error)) { + called = true + receivedData = data + done(nil) + }) + + err := f.Submit() + if err != nil { + t.Fatalf("Submit failed: %v", err) + } + + if !called { + t.Error("OnSubmit callback was not called") + } + + if receivedData != s { + t.Errorf("Expected to receive bound struct, got %v", receivedData) + } + + if s.Nombre != "New Name" { + t.Errorf("Expected struct field to be synced, got %q", s.Nombre) + } + }) + + t.Run("TestSubmit_ValidationFailureReturnsError", func(t *testing.T) { + s := &submitStruct{Nombre: ""} // empty, but NotNull: true + f, _ := form.New("app", s) + f.SetValues("nombre", "") + + called := false + f.OnSubmit(func(data model.Fielder, done func(error)) { + called = true + done(nil) + }) + + err := f.Submit() + if err == nil { + t.Fatal("Expected validation error, got nil") + } + + if called { + t.Error("OnSubmit callback should NOT have been called on validation failure") + } + }) + + t.Run("TestSubmit_NoResetOnSuccess", func(t *testing.T) { + s := &submitStruct{Nombre: "Jules"} + f, _ := form.New("app", s) + f.NoResetOnSuccess() + f.SetValues("nombre", "Keep Me") + + f.OnSubmit(func(data model.Fielder, done func(error)) { + done(nil) + }) + + err := f.Submit() + if err != nil { + t.Fatalf("Submit failed: %v", err) + } + + val := f.Input("nombre").(interface{ GetValue() string }).GetValue() + if val != "Keep Me" { + t.Errorf("Expected form to retain value 'Keep Me', got %q", val) + } + }) + + t.Run("TestSubmit_DefaultResetOnSuccess", func(t *testing.T) { + s := &submitStruct{Nombre: "Jules"} + f, _ := form.New("app", s) + f.SetValues("nombre", "Clear Me") + + f.OnSubmit(func(data model.Fielder, done func(error)) { + done(nil) + }) + + err := f.Submit() + if err != nil { + t.Fatalf("Submit failed: %v", err) + } + + val := f.Input("nombre").(interface{ GetValue() string }).GetValue() + if val != "" { + t.Errorf("Expected empty value after successful submit, got %q", val) + } + }) + + t.Run("TestSubmit_ResetClearsValues", func(t *testing.T) { + s := &submitStruct{Nombre: "Jules"} + f, _ := form.New("app", s) + f.SetValues("nombre", "New Value") + + f.Reset() + + val := f.Input("nombre").(interface{ GetValue() string }).GetValue() + if val != "" { + t.Errorf("Expected empty value after reset, got %q", val) + } + }) +}