From a06afe583d379f7ed457ec04e940c98edeae29f6 Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Wed, 24 Sep 2025 09:03:50 +0200 Subject: [PATCH 1/9] [WIP] feat: add collection widget --- example/form.go | 31 ++++++++++--- form/field.go | 77 +++++++++++++++++++++++++------- form/field_collection.go | 73 ++++++++++++++++++++++++++++++ form/form.go | 40 +++++++++++++++-- main.go | 28 +++++++++--- theme/html5.go | 48 ++++++++++++++++++++ util/collection.go | 96 ++++++++++++++++++++++++++++++++++++++++ util/inspect.go | 4 ++ 8 files changed, 367 insertions(+), 30 deletions(-) create mode 100644 form/field_collection.go create mode 100644 util/collection.go diff --git a/example/form.go b/example/form.go index 458f33b..4fd035c 100644 --- a/example/form.go +++ b/example/form.go @@ -36,12 +36,18 @@ type ExampleDates struct { } type ExampleData struct { - Bytes []byte - Text string - Checkbox bool - Dates ExampleDates - Choices ExampleChoices - Inputs ExampleOtherInputs + Collection []CollectionItem + Bytes []byte + Text string + Checkbox bool + Dates ExampleDates + Choices ExampleChoices + Inputs ExampleOtherInputs +} + +type CollectionItem struct { + ValueA string + ValueB string } func CreateDataForm() *form.Form { @@ -60,6 +66,19 @@ func CreateDataForm() *form.Form { }) return form.NewForm( + form.NewFieldCollection("Collection"). + WithOptions( + form.NewOption("label", "Collection"), + form.NewOption("form", form.NewForm( + form.NewFieldText("ValueA"). + WithOptions(form.NewOption("label", "Value A")). + WithConstraints( + validation.NewNotBlank(), + ), + form.NewFieldText("ValueB"). + WithOptions(form.NewOption("label", "Value B")), + )), + ), form.NewFieldText("Bytes"). WithOptions( form.NewOption("label", "Bytes"), diff --git a/form/field.go b/form/field.go index 1a3f5e5..dee37bf 100644 --- a/form/field.go +++ b/form/field.go @@ -27,6 +27,14 @@ import ( // Generic function for field.Validation func FieldValidation(f *Field) bool { + // if f.IsCollection { + // if formOption := f.GetOption("form"); formOption != nil { + // if formValue, ok := formOption.Value.(*Form); ok { + // godump.Dump(formValue) + // } + // } + // } + if len(f.Children) > 0 { isValid := true @@ -56,20 +64,22 @@ func FieldValidation(f *Field) bool { // Field represents a field in a form type Field struct { - Name string `json:"name"` - Widget string `json:"widget"` - Data any `json:"-"` - Options []*Option `json:"options"` - Children []*Field `json:"children"` - Constraints []validation.Constraint `json:"-"` - Errors []validation.Error `json:"-"` - BeforeMount func(data any) (any, error) `json:"-"` - BeforeBind func(data any) (any, error) `json:"-"` - Validate func(f *Field) bool `json:"-"` - IsSlice bool `json:"is_slice"` - IsFixedName bool `json:"is_fixed_name"` - Form *Form `json:"-"` - Parent *Field `json:"-"` + Name string `json:"name"` + NamePrefix string `json:"name_prefix"` + Widget string `json:"widget"` + Data any `json:"-"` + Options []*Option `json:"options"` + Children []*Field `json:"children"` + Constraints []validation.Constraint `json:"-"` + Errors []validation.Error `json:"-"` + BeforeMount func(data any) (any, error) `json:"-"` + BeforeBind func(data any) (any, error) `json:"-"` + Validate func(f *Field) bool `json:"-"` + IsSlice bool `json:"is_slice"` + IsCollection bool `json:"is_collection"` + IsFixedName bool `json:"is_fixed_name"` + Form *Form `json:"-"` + Parent *Field `json:"-"` } // Generates a new field with default properties @@ -102,6 +112,20 @@ func NewField(name, widget string) *Field { return f } +func (f *Field) Copy() *Field { + return &Field{ + Name: f.Name, + Widget: f.Widget, + Options: f.Options, + Constraints: f.Constraints, + BeforeMount: f.BeforeMount, + BeforeBind: f.BeforeBind, + Validate: f.Validate, + IsSlice: f.IsSlice, + IsFixedName: f.IsFixedName, + } +} + // Checks if the field contains an option using its name func (f *Field) HasOption(name string) bool { for _, option := range f.Options { @@ -137,6 +161,20 @@ func (f *Field) WithOptions(options ...*Option) *Field { return f } +func (f *Field) RemoveOption(name string) *Field { + var options []*Option + + for _, option := range f.Options { + if option.Name != name { + options = append(options, option) + } + } + + f.Options = options + + return f +} + // Sets data the field func (f *Field) WithData(data any) *Field { f.Data = data @@ -158,6 +196,13 @@ func (f *Field) WithSlice() *Field { return f } +// Sets that the field represents a collection +func (f *Field) WithCollection() *Field { + f.IsCollection = true + + return f +} + // Sets that the name of the field is not computed func (f *Field) WithFixedName() *Field { f.IsFixedName = true @@ -233,9 +278,9 @@ func (f *Field) GetName() string { } if f.Form != nil && f.Form.Name != "" { - name = fmt.Sprintf("%s[%s]", f.Form.Name, f.Name) + name = fmt.Sprintf("%s%s[%s]", f.Form.Name, f.NamePrefix, f.Name) } else if f.Parent != nil { - name = fmt.Sprintf("%s[%s]", f.Parent.GetName(), f.Name) + name = fmt.Sprintf("%s%s[%s]", f.Parent.GetName(), f.NamePrefix, f.Name) } else { name = f.Name } diff --git a/form/field_collection.go b/form/field_collection.go new file mode 100644 index 0000000..436ea27 --- /dev/null +++ b/form/field_collection.go @@ -0,0 +1,73 @@ +package form + +import ( + "fmt" + "reflect" +) + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Generates a sub form +func NewFieldCollection(name string) *Field { + f := NewField(name, "collection"). + WithOptions( + NewOption("allow_add", true), + NewOption("allow_delete", true), + NewOption("form", nil), + ). + WithCollection() + + f.WithBeforeMount(func(data any) (any, error) { + if opt := f.GetOption("form"); opt != nil { + if src, ok := opt.Value.(*Form); ok { + src.Name = f.GetName() + t := reflect.TypeOf(data) + + switch t.Kind() { + case reflect.Slice: + slice := reflect.ValueOf(data) + + for i := 0; i < slice.Len(); i++ { + form := src.Copy() + form.Mount(slice.Index(i).Interface()) + + field := f.Copy() + field.Widget = "sub_form" + field.Name = fmt.Sprintf("%d", i) + field.Add(form.Fields...) + field. + RemoveOption("form"). + RemoveOption("label") + + for _, c := range field.Children { + c.NamePrefix = fmt.Sprintf("[%d]", i) + } + + f.Add(field) + } + } + } + } + + return data, nil + }) + + return f +} + +func NewCollection(name string) *Field { + return NewFieldCollection(name) +} diff --git a/form/form.go b/form/form.go index a2dda2a..b96df95 100644 --- a/form/form.go +++ b/form/form.go @@ -22,8 +22,10 @@ import ( "net/http" "net/url" "slices" + "strings" "github.com/mitchellh/mapstructure" + "github.com/yassinebenaid/godump" "gitnet.fr/deblan/go-form/util" "gitnet.fr/deblan/go-form/validation" ) @@ -108,8 +110,10 @@ func (f *Form) End() *Form { func (f *Form) AddGlobalField(field *Field) { f.GlobalFields = append(f.GlobalFields, field) - for _, c := range field.Children { - f.AddGlobalField(c) + if field.Widget != "collection" { + for _, c := range field.Children { + f.AddGlobalField(c) + } } } @@ -220,6 +224,8 @@ func (f *Form) Bind(data any) error { field.Bind(toBind, nil) } + godump.Dump(toBind) + return mapstructure.Decode(toBind, data) } @@ -255,8 +261,23 @@ func (f *Form) HandleRequest(req *http.Request) { isSubmitted := false + type collectionData map[string]any + for _, c := range f.GlobalFields { - if data.Has(c.GetName()) { + if c.IsCollection { + collection := util.NewCollection() + + for key, _ := range data { + if strings.HasPrefix(key, c.GetName()) { + root := strings.Replace(key, c.GetName(), "", 1) + indexes := util.ExtractDataIndexes(root) + + collection.Add(indexes, data.Get(key)) + } + } + + c.Mount(collection.Slice()) + } else if data.Has(c.GetName()) { isSubmitted = true if c.IsSlice { @@ -289,3 +310,16 @@ func (f *Form) ErrorsTree() map[string]any { "children": slices.Collect(maps.Values(errors)), } } + +func (f *Form) Copy() *Form { + var fields []*Field + + for _, i := range f.Fields { + f := *i + fields = append(fields, &f) + } + + return &Form{ + Fields: fields, + } +} diff --git a/main.go b/main.go index 55e9229..da58d53 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "html/template" "log" "net/http" @@ -13,6 +14,11 @@ import ( func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { data := example.ExampleData{} + data.Collection = []example.CollectionItem{ + {"Value a 1", "Value b 1"}, + {"Value a 2", "Value b 2"}, + {"Value a 3", "Value b 3"}, + } f := example.CreateDataForm() f.Mount(data) @@ -22,7 +28,7 @@ func main() { if f.IsSubmitted() && f.IsValid() { f.Bind(&data) - godump.Dump(data) + // godump.Dump(data) } } @@ -91,10 +97,19 @@ func main() { Valid {{ .Form.IsValid }} -
- Data -
{{ .Dump }}
-
+ + + + + +
+
{{ .Dump }}
+
+
+ JSON +
{{ .Json }}
+
+
{{ form .Form }} @@ -105,9 +120,12 @@ func main() { var dump godump.Dumper dump.Theme = godump.Theme{} + j, _ := json.MarshalIndent(f, " ", " ") + w.Header().Set("Content-Type", "text/html; charset=utf-8") tpl.Execute(w, map[string]any{ "Form": f, + "Json": string(j), "Dump": template.HTML(dump.Sprint(data)), }) }) diff --git a/theme/html5.go b/theme/html5.go index a441e48..a709e4e 100644 --- a/theme/html5.go +++ b/theme/html5.go @@ -1,6 +1,7 @@ package theme import ( + "bytes" "fmt" "github.com/spf13/cast" @@ -348,6 +349,53 @@ var Html5 = CreateTheme(func() map[string]RenderFunc { ) } + theme["collection"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + var prototype string + + if opt := field.GetOption("form"); opt != nil { + if val, ok := opt.Value.(*form.Form); ok { + var buffer bytes.Buffer + dest := form.NewFieldSubForm(field.Name) + + for _, c := range val.Fields { + child := c.Copy() + child.NamePrefix = "[__name__]" + dest.Add(child) + } + + fieldPrototype := parent["form_row"](parent, dest) + fieldPrototype.Render(&buffer) + + prototype = buffer.String() + } + } + + field.WithOptions(form.NewOption("prototype", prototype)) + field.Widget = "collection_build" + + return Div( + Attr("data-prototype", prototype), + parent["form_widget"](parent, field), + ) + } + + theme["collection_build"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + prototype := field.GetOption("prototype").AsString() + var items []Node + + for _, child := range field.Children { + items = append(items, parent["form_row"](parent, child)) + } + + return Div( + Attr("data-prototype", prototype), + Group(items), + ) + } + theme["form_widget"] = func(parent map[string]RenderFunc, args ...any) Node { field := args[0].(*form.Field) diff --git a/util/collection.go b/util/collection.go new file mode 100644 index 0000000..7ee4d66 --- /dev/null +++ b/util/collection.go @@ -0,0 +1,96 @@ +package util + +import ( + "regexp" + "strings" + + "github.com/spf13/cast" +) + +type CollectionValue struct { + Name string + Value string + Children map[string]*CollectionValue +} + +type Collection struct { + Children map[int]*CollectionValue +} + +func NewCollection() *Collection { + return &Collection{ + Children: make(map[int]*CollectionValue), + } +} + +func NewCollectionValue(name string) *CollectionValue { + return &CollectionValue{ + Name: name, + Children: make(map[string]*CollectionValue), + } +} + +func (c *Collection) Add(indexes []string, value string) { + firstIndex := cast.ToInt(indexes[0]) + size := len(indexes) + child := c.Children[firstIndex] + + if child == nil { + child = NewCollectionValue(indexes[0]) + c.Children[firstIndex] = child + } + + child.Add(indexes[1:size], value, nil) +} + +func (c *Collection) Slice() []any { + var result []any + + for _, child := range c.Children { + result = append(result, child.Map()) + } + + return result +} + +func (c *CollectionValue) Map() any { + if len(c.Children) == 0 { + return c.Value + } + + results := make(map[string]any) + + for _, child := range c.Children { + results[child.Name] = child.Map() + } + + return results +} + +func (c *CollectionValue) Add(indexes []string, value string, lastChild *CollectionValue) { + size := len(indexes) + + if size > 0 { + firstIndex := indexes[0] + child := c.Children[firstIndex] + + child = NewCollectionValue(indexes[0]) + c.Children[firstIndex] = child + + child.Add(indexes[1:size], value, child) + } else { + lastChild.Value = value + } +} + +func ExtractDataIndexes(value string) []string { + re := regexp.MustCompile(`\[[^\]]+\]`) + items := re.FindAll([]byte(value), -1) + var results []string + + for _, i := range items { + results = append(results, strings.Trim(string(i), "[]")) + } + + return results +} diff --git a/util/inspect.go b/util/inspect.go index 17c0839..22fa4f4 100644 --- a/util/inspect.go +++ b/util/inspect.go @@ -29,6 +29,10 @@ func InspectStruct(input interface{}) (map[string]interface{}, error) { val = val.Elem() } + if val.Kind() == reflect.Map { + return input.(map[string]interface{}), nil + } + if val.Kind() != reflect.Struct { return nil, errors.New("Invalid type") } From 97f5cf321560b7abd32bd58a77d590aa54f94cf9 Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Fri, 26 Sep 2025 17:49:39 +0200 Subject: [PATCH 2/9] [WIP] feat: handle collection in form rendering, mount and bind --- form/field.go | 41 ++++++++++++++++++++++++++++++---------- form/field_collection.go | 33 +++++++++++++++++++------------- form/form.go | 11 +++-------- main.go | 30 +++++++++++++++++++++++++++-- theme/html5.go | 5 +---- 5 files changed, 83 insertions(+), 37 deletions(-) diff --git a/form/field.go b/form/field.go index dee37bf..b4484df 100644 --- a/form/field.go +++ b/form/field.go @@ -21,20 +21,13 @@ import ( "slices" "strings" + "github.com/yassinebenaid/godump" "gitnet.fr/deblan/go-form/util" "gitnet.fr/deblan/go-form/validation" ) // Generic function for field.Validation func FieldValidation(f *Field) bool { - // if f.IsCollection { - // if formOption := f.GetOption("form"); formOption != nil { - // if formValue, ok := formOption.Value.(*Form); ok { - // godump.Dump(formValue) - // } - // } - // } - if len(f.Children) > 0 { isValid := true @@ -47,6 +40,10 @@ func FieldValidation(f *Field) bool { } isValid = isValid && isChildValid + + for _, sc := range c.Children { + isValid = isValid && FieldValidation(sc) + } } return isValid @@ -115,6 +112,7 @@ func NewField(name, widget string) *Field { func (f *Field) Copy() *Field { return &Field{ Name: f.Name, + Form: f.Form, Widget: f.Widget, Options: f.Options, Constraints: f.Constraints, @@ -332,7 +330,7 @@ func (f *Field) Mount(data any) error { } // Bind the data into the given map -func (f *Field) Bind(data map[string]any, key *string) error { +func (f *Field) Bind(data map[string]any, key *string, parentIsSlice bool) error { if len(f.Children) == 0 { v, err := f.BeforeBind(f.Data) @@ -352,7 +350,30 @@ func (f *Field) Bind(data map[string]any, key *string) error { data[f.Name] = make(map[string]any) for _, child := range f.Children { - child.Bind(data[f.Name].(map[string]any), key) + child.Bind(data[f.Name].(map[string]any), key, f.IsCollection) + } + + if f.IsCollection { + var nextData []any + values := data[f.Name].(map[string]any) + var keys []string + + for key, _ := range values { + keys = append(keys, key) + } + + slices.Sort(keys) + + for _, key := range keys { + for valueKey, value := range values { + if valueKey == key { + godump.Dump([]string{valueKey, key}) + nextData = append(nextData, value) + } + } + } + + data[f.Name] = nextData } return nil diff --git a/form/field_collection.go b/form/field_collection.go index 436ea27..52b8e02 100644 --- a/form/field_collection.go +++ b/form/field_collection.go @@ -41,22 +41,29 @@ func NewFieldCollection(name string) *Field { slice := reflect.ValueOf(data) for i := 0; i < slice.Len(); i++ { - form := src.Copy() - form.Mount(slice.Index(i).Interface()) + name := fmt.Sprintf("%d", i) + value := slice.Index(i).Interface() - field := f.Copy() - field.Widget = "sub_form" - field.Name = fmt.Sprintf("%d", i) - field.Add(form.Fields...) - field. - RemoveOption("form"). - RemoveOption("label") + if f.HasChild(name) { + f.GetChild(name).Mount(value) + } else { + form := src.Copy() + form.Mount(value) - for _, c := range field.Children { - c.NamePrefix = fmt.Sprintf("[%d]", i) + field := f.Copy() + field.Widget = "sub_form" + field.Name = name + field.Add(form.Fields...) + field. + RemoveOption("form"). + RemoveOption("label") + + for _, c := range field.Children { + c.NamePrefix = fmt.Sprintf("[%d]", i) + } + + f.Add(field) } - - f.Add(field) } } } diff --git a/form/form.go b/form/form.go index b96df95..c17da05 100644 --- a/form/form.go +++ b/form/form.go @@ -25,7 +25,6 @@ import ( "strings" "github.com/mitchellh/mapstructure" - "github.com/yassinebenaid/godump" "gitnet.fr/deblan/go-form/util" "gitnet.fr/deblan/go-form/validation" ) @@ -110,10 +109,8 @@ func (f *Form) End() *Form { func (f *Form) AddGlobalField(field *Field) { f.GlobalFields = append(f.GlobalFields, field) - if field.Widget != "collection" { - for _, c := range field.Children { - f.AddGlobalField(c) - } + for _, c := range field.Children { + f.AddGlobalField(c) } } @@ -221,11 +218,9 @@ func (f *Form) Bind(data any) error { toBind := make(map[string]any) for _, field := range f.Fields { - field.Bind(toBind, nil) + field.Bind(toBind, nil, false) } - godump.Dump(toBind) - return mapstructure.Decode(toBind, data) } diff --git a/main.go b/main.go index da58d53..7c4e97a 100644 --- a/main.go +++ b/main.go @@ -16,8 +16,6 @@ func main() { data := example.ExampleData{} data.Collection = []example.CollectionItem{ {"Value a 1", "Value b 1"}, - {"Value a 2", "Value b 2"}, - {"Value a 3", "Value b 3"}, } f := example.CreateDataForm() @@ -113,6 +111,34 @@ func main() { {{ form .Form }} + + `) diff --git a/theme/html5.go b/theme/html5.go index a709e4e..a4728e0 100644 --- a/theme/html5.go +++ b/theme/html5.go @@ -375,10 +375,7 @@ var Html5 = CreateTheme(func() map[string]RenderFunc { field.WithOptions(form.NewOption("prototype", prototype)) field.Widget = "collection_build" - return Div( - Attr("data-prototype", prototype), - parent["form_widget"](parent, field), - ) + return parent["form_widget"](parent, field) } theme["collection_build"] = func(parent map[string]RenderFunc, args ...any) Node { From b9c5f6a2fd77130d02b5cc0d2a908047564a1bca Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Wed, 1 Oct 2025 16:30:27 +0200 Subject: [PATCH 3/9] fix(collection): always validate fields when another field is invalid" --- form/field.go | 10 ++++------ form/field_choice.go | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/form/field.go b/form/field.go index b4484df..f1558c2 100644 --- a/form/field.go +++ b/form/field.go @@ -21,13 +21,12 @@ import ( "slices" "strings" - "github.com/yassinebenaid/godump" "gitnet.fr/deblan/go-form/util" "gitnet.fr/deblan/go-form/validation" ) // Generic function for field.Validation -func FieldValidation(f *Field) bool { +func DefaultFieldValidation(f *Field) bool { if len(f.Children) > 0 { isValid := true @@ -39,10 +38,10 @@ func FieldValidation(f *Field) bool { c.Errors = errs } - isValid = isValid && isChildValid + isValid = isChildValid && isValid for _, sc := range c.Children { - isValid = isValid && FieldValidation(sc) + isValid = DefaultFieldValidation(sc) && isValid } } @@ -104,7 +103,7 @@ func NewField(name, widget string) *Field { NewOption("help_attr", Attrs{}), ) - f.Validate = FieldValidation + f.Validate = DefaultFieldValidation return f } @@ -367,7 +366,6 @@ func (f *Field) Bind(data map[string]any, key *string, parentIsSlice bool) error for _, key := range keys { for valueKey, value := range values { if valueKey == key { - godump.Dump([]string{valueKey, key}) nextData = append(nextData, value) } } diff --git a/form/field_choice.go b/form/field_choice.go index 70d2545..16c06fc 100644 --- a/form/field_choice.go +++ b/form/field_choice.go @@ -145,7 +145,7 @@ func NewFieldChoice(name string) *Field { ) f.Validate = func(field *Field) bool { - isValid := FieldValidation(field) + isValid := field.Validate(field) if len(validation.NewNotBlank().Validate(field.Data)) == 0 { choices := field.GetOption("choices").Value.(*Choices) From 1d40aa6b09135368915c10216dcb11dce98bb05c Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Wed, 1 Oct 2025 18:13:43 +0200 Subject: [PATCH 4/9] refactor: refactor the example --- example.go | 76 +++++++++++++ example/form.go | 32 +++--- example/view/bootstrap.html | 113 +++++++++++++++++++ example/view/html5.html | 133 ++++++++++++++++++++++ main.go | 219 ------------------------------------ 5 files changed, 338 insertions(+), 235 deletions(-) create mode 100644 example.go create mode 100644 example/view/bootstrap.html create mode 100644 example/view/html5.html delete mode 100644 main.go diff --git a/example.go b/example.go new file mode 100644 index 0000000..2aa6727 --- /dev/null +++ b/example.go @@ -0,0 +1,76 @@ +package main + +import ( + "embed" + "encoding/json" + "html/template" + "log" + "net/http" + + "github.com/yassinebenaid/godump" + "gitnet.fr/deblan/go-form/example" + "gitnet.fr/deblan/go-form/theme" +) + +//go:embed example/view/*.html +var templates embed.FS + +func handler(view, action string, formRenderer *theme.Renderer, w http.ResponseWriter, r *http.Request) { + entity := example.ExampleData{} + form := example.CreateDataForm(action) + + form.Mount(entity) + + if r.Method == form.Method { + form.HandleRequest(r) + + if form.IsSubmitted() && form.IsValid() { + form.Bind(&entity) + } + } + + content, _ := templates.ReadFile(view) + + formAsJson, _ := json.MarshalIndent(form, " ", " ") + + tpl, _ := template.New("page"). + Funcs(formRenderer.FuncMap()). + Parse(string(content)) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + var dump godump.Dumper + dump.Theme = godump.Theme{} + + tpl.Execute(w, map[string]any{ + "isSubmitted": form.IsSubmitted(), + "isValid": form.IsValid(), + "form": form, + "json": string(formAsJson), + "dump": template.HTML(dump.Sprint(entity)), + }) +} + +func main() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + handler( + "example/view/html5.html", + "/", + theme.NewRenderer(theme.Html5), + w, + r, + ) + }) + + http.HandleFunc("/bootstrap", func(w http.ResponseWriter, r *http.Request) { + handler( + "example/view/bootstrap.html", + "/bootstrap", + theme.NewRenderer(theme.Bootstrap5), + w, + r, + ) + }) + + log.Fatal(http.ListenAndServe(":1122", nil)) +} diff --git a/example/form.go b/example/form.go index 4fd035c..8c516ba 100644 --- a/example/form.go +++ b/example/form.go @@ -50,7 +50,7 @@ type CollectionItem struct { ValueB string } -func CreateDataForm() *form.Form { +func CreateDataForm(action string) *form.Form { items := []Item{ Item{Id: 1, Name: "Item 1"}, Item{Id: 2, Name: "Item 2"}, @@ -66,19 +66,6 @@ func CreateDataForm() *form.Form { }) return form.NewForm( - form.NewFieldCollection("Collection"). - WithOptions( - form.NewOption("label", "Collection"), - form.NewOption("form", form.NewForm( - form.NewFieldText("ValueA"). - WithOptions(form.NewOption("label", "Value A")). - WithConstraints( - validation.NewNotBlank(), - ), - form.NewFieldText("ValueB"). - WithOptions(form.NewOption("label", "Value B")), - )), - ), form.NewFieldText("Bytes"). WithOptions( form.NewOption("label", "Bytes"), @@ -196,6 +183,19 @@ func CreateDataForm() *form.Form { form.NewOption("multiple", true), ), ), + form.NewFieldCollection("Collection"). + WithOptions( + form.NewOption("label", "Collection"), + form.NewOption("form", form.NewForm( + form.NewFieldText("ValueA"). + WithOptions(form.NewOption("label", "Value A")). + WithConstraints( + validation.NewNotBlank(), + ), + form.NewFieldText("ValueB"). + WithOptions(form.NewOption("label", "Value B")), + )), + ), form.NewFieldCsrf("_csrf_token").WithData("my-token"), form.NewSubmit("submit"). WithOptions( @@ -206,8 +206,8 @@ func CreateDataForm() *form.Form { ). End(). WithOptions( - form.NewOption("help", "Form help"), + form.NewOption("help", "Form global help"), ). WithMethod(http.MethodPost). - WithAction("/") + WithAction(action) } diff --git a/example/view/bootstrap.html b/example/view/bootstrap.html new file mode 100644 index 0000000..7b848f1 --- /dev/null +++ b/example/view/bootstrap.html @@ -0,0 +1,113 @@ + + + + + Form with Bootstrap + + + + +
+
+ Debug view +
+ Submitted: + {{ .isSubmitted }} +
+
+ Valid: + {{ .isValid }} +
+ +
+ Dump of data +
{{ .dump }}
+
+ +
+ Form as JSON +
{{ .json }}
+
+
+ + {{if .isValid}} +
The form is valid!
+ {{else}} +
The form is invalid!
+ {{end}} + + {{ form .form }} +
+ + + + diff --git a/example/view/html5.html b/example/view/html5.html new file mode 100644 index 0000000..90c33fb --- /dev/null +++ b/example/view/html5.html @@ -0,0 +1,133 @@ + + + + + Form HTML5 (with Pico) + + + + + + + + +
+ Debug view + +
+ Submitted: + {{ .isSubmitted }} +
+
+ Valid: + {{ .isValid }} +
+ +
+ Dump of data +
{{ .dump }}
+
+ +
+ Form as JSON +
{{ .json }}
+
+ +
+ + {{if .isValid}} +

The form is valid!

+ {{else}} +

The form is invalid!

+ {{end}} + + {{ form .form }} + + + + + diff --git a/main.go b/main.go deleted file mode 100644 index 7c4e97a..0000000 --- a/main.go +++ /dev/null @@ -1,219 +0,0 @@ -package main - -import ( - "encoding/json" - "html/template" - "log" - "net/http" - - "github.com/yassinebenaid/godump" - "gitnet.fr/deblan/go-form/example" - "gitnet.fr/deblan/go-form/theme" -) - -func main() { - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - data := example.ExampleData{} - data.Collection = []example.CollectionItem{ - {"Value a 1", "Value b 1"}, - } - - f := example.CreateDataForm() - f.Mount(data) - - if r.Method == f.Method { - f.HandleRequest(r) - - if f.IsSubmitted() && f.IsValid() { - f.Bind(&data) - // godump.Dump(data) - } - } - - render := theme.NewRenderer(theme.Html5) - - tpl, _ := template.New("page").Funcs(render.FuncMap()).Parse(` - - - - - Form - - - -
-
- Submitted - {{ .Form.IsSubmitted }} -
-
- Valid - {{ .Form.IsValid }} -
- - - - - -
-
{{ .Dump }}
-
-
- JSON -
{{ .Json }}
-
-
-
- - {{ form .Form }} - - - - - `) - - var dump godump.Dumper - dump.Theme = godump.Theme{} - - j, _ := json.MarshalIndent(f, " ", " ") - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - tpl.Execute(w, map[string]any{ - "Form": f, - "Json": string(j), - "Dump": template.HTML(dump.Sprint(data)), - }) - }) - - http.HandleFunc("/bootstrap", func(w http.ResponseWriter, r *http.Request) { - data := example.ExampleData{} - - f := example.CreateDataForm() - f.WithAction("/bootstrap") - f.Mount(data) - - if r.Method == f.Method { - f.HandleRequest(r) - - if f.IsSubmitted() && f.IsValid() { - f.Bind(&data) - godump.Dump(data) - } - } - - render := theme.NewRenderer(theme.Bootstrap5) - - tpl, _ := template.New("page").Funcs(render.FuncMap()).Parse(` - - - - - Form - - - -
-
-
- Submitted - {{ .Form.IsSubmitted }} -
-
- Valid - {{ .Form.IsValid }} -
-
- Data -
{{ .Dump }}
-
-
- - {{ form .Form }} -
- - - `) - - var dump godump.Dumper - dump.Theme = godump.Theme{} - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - tpl.Execute(w, map[string]any{ - "Form": f, - "Dump": template.HTML(dump.Sprint(data)), - }) - }) - - log.Fatal(http.ListenAndServe(":1122", nil)) -} From bec0acd2f2e333e7317eb36cfc1211f09c43fb5f Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Wed, 1 Oct 2025 18:14:08 +0200 Subject: [PATCH 5/9] feat(theme/html5): add classes for help and errors html nodes --- theme/html5.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/theme/html5.go b/theme/html5.go index a4728e0..2d7889e 100644 --- a/theme/html5.go +++ b/theme/html5.go @@ -59,6 +59,7 @@ var Html5 = CreateTheme(func() map[string]RenderFunc { } return Ul( + Class("gf-errors"), Group(result), ) } @@ -94,6 +95,7 @@ var Html5 = CreateTheme(func() map[string]RenderFunc { } return Div( + Class("gf-help"), Text(help), extra, ) @@ -452,6 +454,7 @@ var Html5 = CreateTheme(func() map[string]RenderFunc { form := args[0].(*form.Form) return Form( + Class("gf-form"), Action(form.Action), Method(form.Method), parent["form_attributes"](parent, form), From 7373b1921235164584cd9a9d292118a61012d3e6 Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Wed, 1 Oct 2025 18:15:23 +0200 Subject: [PATCH 6/9] doc: update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f052f98..12a7ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## [Unreleased] +### Added + +- feat: add collection widget + ## v1.4.0 ### Added From f70f0a1f9d191307163268fe864aa5476eedc6ed Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Wed, 1 Oct 2025 23:23:13 +0200 Subject: [PATCH 7/9] feat(example): add theme picker --- example.go | 25 ++++++++++++++---------- example/form.go | 38 +++++++++++++++++++++++++++++++++++++ example/view/bootstrap.html | 12 +++++++++--- example/view/html5.html | 9 +++++++-- 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/example.go b/example.go index 2aa6727..91918d4 100644 --- a/example.go +++ b/example.go @@ -17,21 +17,24 @@ var templates embed.FS func handler(view, action string, formRenderer *theme.Renderer, w http.ResponseWriter, r *http.Request) { entity := example.ExampleData{} - form := example.CreateDataForm(action) + entityForm := example.CreateDataForm(action) + entityForm.Mount(entity) - form.Mount(entity) + style := example.NewTheme(action) + styleForm := example.CreateThemeSelectorForm() + styleForm.Mount(style) - if r.Method == form.Method { - form.HandleRequest(r) + if r.Method == entityForm.Method { + entityForm.HandleRequest(r) - if form.IsSubmitted() && form.IsValid() { - form.Bind(&entity) + if entityForm.IsSubmitted() && entityForm.IsValid() { + entityForm.Bind(&entity) } } content, _ := templates.ReadFile(view) - formAsJson, _ := json.MarshalIndent(form, " ", " ") + formAsJson, _ := json.MarshalIndent(entityForm, " ", " ") tpl, _ := template.New("page"). Funcs(formRenderer.FuncMap()). @@ -43,9 +46,10 @@ func handler(view, action string, formRenderer *theme.Renderer, w http.ResponseW dump.Theme = godump.Theme{} tpl.Execute(w, map[string]any{ - "isSubmitted": form.IsSubmitted(), - "isValid": form.IsValid(), - "form": form, + "isSubmitted": entityForm.IsSubmitted(), + "isValid": entityForm.IsValid(), + "form": entityForm, + "styleForm": styleForm, "json": string(formAsJson), "dump": template.HTML(dump.Sprint(entity)), }) @@ -72,5 +76,6 @@ func main() { ) }) + log.Println("Browse: http://localhost:1122") log.Fatal(http.ListenAndServe(":1122", nil)) } diff --git a/example/form.go b/example/form.go index 8c516ba..fc6e8bf 100644 --- a/example/form.go +++ b/example/form.go @@ -50,6 +50,10 @@ type CollectionItem struct { ValueB string } +type Theme struct { + Value string `field:"lowerCamel"` +} + func CreateDataForm(action string) *form.Form { items := []Item{ Item{Id: 1, Name: "Item 1"}, @@ -211,3 +215,37 @@ func CreateDataForm(action string) *form.Form { WithMethod(http.MethodPost). WithAction(action) } + +func NewTheme(value string) *Theme { + return &Theme{Value: value} +} + +func CreateThemeSelectorForm() *form.Form { + choices := form.NewChoices([]map[string]string{ + map[string]string{"value": "/", "label": "Html5"}, + map[string]string{"value": "/bootstrap", "label": "Bootstrap5"}, + }) + + choices.LabelBuilder = func(key int, item any) string { + return item.(map[string]string)["label"] + } + + choices.ValueBuilder = func(key int, item any) string { + return item.(map[string]string)["value"] + } + + return form.NewForm( + form.NewFieldChoice("value"). + WithOptions( + form.NewOption("choices", choices), + form.NewOption("label", "Select a theme"), + form.NewOption("required", true), + form.NewOption("attr", form.Attrs{ + "onchange": "document.location.href = this.value", + }), + ), + ). + End(). + WithName(""). + WithMethod(http.MethodGet) +} diff --git a/example/view/bootstrap.html b/example/view/bootstrap.html index 7b848f1..23543fd 100644 --- a/example/view/bootstrap.html +++ b/example/view/bootstrap.html @@ -2,11 +2,12 @@ - Form with Bootstrap + Demo of go-form with Bootstrap -
+
+ {{ form_widget (.styleForm.GetField "value") }} +
+ +

Demo of go-form with Bootstrap

+
Debug view
diff --git a/example/view/html5.html b/example/view/html5.html index 90c33fb..88db2d8 100644 --- a/example/view/html5.html +++ b/example/view/html5.html @@ -2,7 +2,7 @@ - Form HTML5 (with Pico) + Demo of go-form (pure HTML5 and Pico) @@ -41,7 +41,12 @@ } - + + {{ form_widget (.styleForm.GetField "value") }} +
+ +

Demo of go-form (pure HTML5 and Pico)

+
Debug view From fe5d84d200a2e7dd53d2b46870fd7f31656d4813 Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Wed, 1 Oct 2025 23:24:42 +0200 Subject: [PATCH 8/9] update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12a7ade..ba56fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ ## [Unreleased] +## v1.5.0 + ### Added - feat: add collection widget +- feat: refactoring and improvement of example ## v1.4.0 From f451d69d702b350f7361de930153b88f4638015f Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Wed, 1 Oct 2025 23:30:16 +0200 Subject: [PATCH 9/9] doc: add documentation of functions and licence --- form/field.go | 2 ++ form/field_collection.go | 10 +++++----- form/form.go | 1 + util/collection.go | 15 +++++++++++++++ util/transformer.go | 15 +++++++++++++++ 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/form/field.go b/form/field.go index f1558c2..fe63326 100644 --- a/form/field.go +++ b/form/field.go @@ -158,6 +158,7 @@ func (f *Field) WithOptions(options ...*Option) *Field { return f } +// Remove an option if exists func (f *Field) RemoveOption(name string) *Field { var options []*Option @@ -377,6 +378,7 @@ func (f *Field) Bind(data map[string]any, key *string, parentIsSlice bool) error return nil } +// Generates a tree of errors func (f *Field) ErrorsTree(tree map[string]any, key *string) { var index string diff --git a/form/field_collection.go b/form/field_collection.go index 52b8e02..3f3ece1 100644 --- a/form/field_collection.go +++ b/form/field_collection.go @@ -1,10 +1,5 @@ package form -import ( - "fmt" - "reflect" -) - // @license GNU AGPL version 3 or any later version // // This program is free software: you can redistribute it and/or modify @@ -20,6 +15,11 @@ import ( // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import ( + "fmt" + "reflect" +) + // Generates a sub form func NewFieldCollection(name string) *Field { f := NewField(name, "collection"). diff --git a/form/form.go b/form/form.go index c17da05..04016d3 100644 --- a/form/form.go +++ b/form/form.go @@ -293,6 +293,7 @@ func (f *Form) IsSubmitted() bool { return f.RequestData != nil } +// Generates a tree of errors func (f *Form) ErrorsTree() map[string]any { errors := make(map[string]any) diff --git a/util/collection.go b/util/collection.go index 7ee4d66..f0aa649 100644 --- a/util/collection.go +++ b/util/collection.go @@ -1,5 +1,20 @@ package util +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + import ( "regexp" "strings" diff --git a/util/transformer.go b/util/transformer.go index 743b117..a942fa1 100644 --- a/util/transformer.go +++ b/util/transformer.go @@ -1,5 +1,20 @@ package util +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + import ( "fmt" "net/url"