diff --git a/CHANGELOG.md b/CHANGELOG.md index f052f98..ba56fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## [Unreleased] +## v1.5.0 + +### Added + +- feat: add collection widget +- feat: refactoring and improvement of example + ## v1.4.0 ### Added diff --git a/example.go b/example.go new file mode 100644 index 0000000..91918d4 --- /dev/null +++ b/example.go @@ -0,0 +1,81 @@ +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{} + entityForm := example.CreateDataForm(action) + entityForm.Mount(entity) + + style := example.NewTheme(action) + styleForm := example.CreateThemeSelectorForm() + styleForm.Mount(style) + + if r.Method == entityForm.Method { + entityForm.HandleRequest(r) + + if entityForm.IsSubmitted() && entityForm.IsValid() { + entityForm.Bind(&entity) + } + } + + content, _ := templates.ReadFile(view) + + formAsJson, _ := json.MarshalIndent(entityForm, " ", " ") + + 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": entityForm.IsSubmitted(), + "isValid": entityForm.IsValid(), + "form": entityForm, + "styleForm": styleForm, + "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.Println("Browse: http://localhost:1122") + log.Fatal(http.ListenAndServe(":1122", nil)) +} diff --git a/example/form.go b/example/form.go index 458f33b..fc6e8bf 100644 --- a/example/form.go +++ b/example/form.go @@ -36,15 +36,25 @@ 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 } -func CreateDataForm() *form.Form { +type CollectionItem struct { + ValueA string + ValueB string +} + +type Theme struct { + Value string `field:"lowerCamel"` +} + +func CreateDataForm(action string) *form.Form { items := []Item{ Item{Id: 1, Name: "Item 1"}, Item{Id: 2, Name: "Item 2"}, @@ -177,6 +187,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( @@ -187,8 +210,42 @@ func CreateDataForm() *form.Form { ). End(). WithOptions( - form.NewOption("help", "Form help"), + form.NewOption("help", "Form global help"), ). WithMethod(http.MethodPost). - WithAction("/") + 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 new file mode 100644 index 0000000..23543fd --- /dev/null +++ b/example/view/bootstrap.html @@ -0,0 +1,119 @@ + + + + + Demo of go-form with Bootstrap + + + + +
+ {{ form_widget (.styleForm.GetField "value") }} +
+ +

Demo of go-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..88db2d8 --- /dev/null +++ b/example/view/html5.html @@ -0,0 +1,138 @@ + + + + + Demo of go-form (pure HTML5 and Pico) + + + + + + + + + {{ form_widget (.styleForm.GetField "value") }} +
+ +

Demo of go-form (pure HTML5 and 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/form/field.go b/form/field.go index 1a3f5e5..fe63326 100644 --- a/form/field.go +++ b/form/field.go @@ -26,7 +26,7 @@ import ( ) // Generic function for field.Validation -func FieldValidation(f *Field) bool { +func DefaultFieldValidation(f *Field) bool { if len(f.Children) > 0 { isValid := true @@ -38,7 +38,11 @@ func FieldValidation(f *Field) bool { c.Errors = errs } - isValid = isValid && isChildValid + isValid = isChildValid && isValid + + for _, sc := range c.Children { + isValid = DefaultFieldValidation(sc) && isValid + } } return isValid @@ -56,20 +60,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 @@ -97,11 +103,26 @@ func NewField(name, widget string) *Field { NewOption("help_attr", Attrs{}), ) - f.Validate = FieldValidation + f.Validate = DefaultFieldValidation return f } +func (f *Field) Copy() *Field { + return &Field{ + Name: f.Name, + Form: f.Form, + 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 +158,21 @@ func (f *Field) WithOptions(options ...*Option) *Field { return f } +// Remove an option if exists +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 +194,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 +276,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 } @@ -287,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) @@ -307,12 +350,35 @@ 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 { + nextData = append(nextData, value) + } + } + } + + data[f.Name] = nextData } 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_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) diff --git a/form/field_collection.go b/form/field_collection.go new file mode 100644 index 0000000..3f3ece1 --- /dev/null +++ b/form/field_collection.go @@ -0,0 +1,80 @@ +package form + +// @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" + "reflect" +) + +// 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++ { + name := fmt.Sprintf("%d", i) + value := slice.Index(i).Interface() + + if f.HasChild(name) { + f.GetChild(name).Mount(value) + } else { + form := src.Copy() + form.Mount(value) + + 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) + } + } + } + } + } + + 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..04016d3 100644 --- a/form/form.go +++ b/form/form.go @@ -22,6 +22,7 @@ import ( "net/http" "net/url" "slices" + "strings" "github.com/mitchellh/mapstructure" "gitnet.fr/deblan/go-form/util" @@ -217,7 +218,7 @@ 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) } return mapstructure.Decode(toBind, data) @@ -255,8 +256,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 { @@ -277,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) @@ -289,3 +306,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 deleted file mode 100644 index 55e9229..0000000 --- a/main.go +++ /dev/null @@ -1,175 +0,0 @@ -package main - -import ( - "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{} - - 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 }} -
-
- 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)), - }) - }) - - 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)) -} diff --git a/theme/html5.go b/theme/html5.go index a441e48..2d7889e 100644 --- a/theme/html5.go +++ b/theme/html5.go @@ -1,6 +1,7 @@ package theme import ( + "bytes" "fmt" "github.com/spf13/cast" @@ -58,6 +59,7 @@ var Html5 = CreateTheme(func() map[string]RenderFunc { } return Ul( + Class("gf-errors"), Group(result), ) } @@ -93,6 +95,7 @@ var Html5 = CreateTheme(func() map[string]RenderFunc { } return Div( + Class("gf-help"), Text(help), extra, ) @@ -348,6 +351,50 @@ 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 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) @@ -407,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), diff --git a/util/collection.go b/util/collection.go new file mode 100644 index 0000000..f0aa649 --- /dev/null +++ b/util/collection.go @@ -0,0 +1,111 @@ +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" + + "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") } 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"