From a06afe583d379f7ed457ec04e940c98edeae29f6 Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Wed, 24 Sep 2025 09:03:50 +0200 Subject: [PATCH] [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") }