diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ce2bf..ba56fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ ## [Unreleased] +## v1.5.0 + +### Added + +- feat: add collection widget +- feat: refactoring and improvement of example + +## v1.4.0 + +### Added + +- feat: add json configuration + +### Fixed + +- fix: reset `GlobalFields` in `End()` + +## v1.3.0 + +### Added + +- feat: allow to handle request using json body" +- feat: add `WithJsonRequest` on form +- feat: add `MapToUrlValues` transformer + +## v1.2.0 + +### Added + +- feat: add tag `field` to specify the naming strategy (case) +- feat: add `ErrorsTree` methods on form and field + +## v1.1.6 + +### Fixed + +- fix(input/choice): add specific validation func + +## v1.1.5 + +### Fixed + +- fix(input/choice): add specific validation func + +## v1.1.4 + +### Fixed + +- fix(theme/bootstrap5): fix button class +- fix(theme/html5): remove div wrapper on form content + ## v1.1.3 ### Fixed diff --git a/README.md b/README.md index 767ec58..3754083 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,7 @@ Creating and processing HTML forms is hard and repetitive. You need to deal with rendering HTML form fields, validating submitted data, mapping the form data into objects and a lot more. [`go-form`][go-form] includes a powerful form feature that provides all these features. -## Introduction - -[`go-form`][go-form] is heavily influenced by [Symfony Form](https://symfony.com/doc/current/forms.html). It includes: +[`go-form`][go-form] is heavily influenced by [Symfony Form](https://symfony.com/doc/current/forms.html). It includes: * A form builder based on fields declarations and independent of structs * Validation based on constraints @@ -12,70 +10,11 @@ Creating and processing HTML forms is hard and repetitive. You need to deal with * Data binding to populate a struct instance from a submitted form * Form renderer with customizable themes -## Installation +## Documentation -```shell -go get gitnet.fr/deblan/go-form -``` - -## Quick Start - -```go -package main - -import ( - "html/template" - "log" - "net/http" - - "gitnet.fr/deblan/go-form/form" - "gitnet.fr/deblan/go-form/theme" - "gitnet.fr/deblan/go-form/validation" -) - -func main() { - type Person struct { - Name string - } - - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - data := new(Person) - - f := form.NewForm( - form.NewFieldText("Name"). - WithOptions( - form.NewOption("label", "Your name"), - ). - WithConstraints( - validation.NewNotBlank(), - ), - form.NewSubmit("submit"), - ). - End(). - WithMethod(http.MethodPost). - WithAction("/") - - f.Mount(data) - - if r.Method == f.Method { - f.HandleRequest(r) - - if f.IsSubmitted() && f.IsValid() { - f.Bind(data) - } - } - - render := theme.NewRenderer(theme.Html5) - tpl, _ := template.New("page").Funcs(render.FuncMap()).Parse(`{{ form .Form }}`) - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - tpl.Execute(w, map[string]any{ - "Form": f, - }) - }) - - log.Fatal(http.ListenAndServe(":1324", nil)) -} -``` +* [Official documentation][doc] +* [Demo][demo] [go-form]: https://gitnet.fr/deblan/go-form +[demo]: https://gitnet.fr/deblan/go-form-demo +[doc]: https://deblan.gitnet.page/go-form/ 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 7abae57..fe63326 100644 --- a/form/field.go +++ b/form/field.go @@ -17,6 +17,8 @@ package form import ( "fmt" + "maps" + "slices" "strings" "gitnet.fr/deblan/go-form/util" @@ -24,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 @@ -36,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 @@ -54,20 +60,22 @@ func FieldValidation(f *Field) bool { // Field represents a field in a form type Field struct { - Name string - Widget string - Data any - Options []*Option - Children []*Field - Constraints []validation.Constraint - Errors []validation.Error - BeforeMount func(data any) (any, error) - BeforeBind func(data any) (any, error) - Validate func(f *Field) bool - IsSlice bool - IsFixedName bool - Form *Form - Parent *Field + 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 @@ -95,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 { @@ -135,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 @@ -156,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 @@ -231,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 } @@ -285,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) @@ -305,8 +350,74 @@ 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 + + if key != nil { + index = *key + } else { + index = f.Name + } + + if len(f.Children) == 0 { + if len(f.Errors) > 0 { + tree[index] = map[string]any{ + "meta": map[string]any{ + "id": f.GetId(), + "name": f.Name, + "formName": f.GetName(), + }, + "errors": f.Errors, + } + } + } else { + errors := make(map[string]any) + + for _, child := range f.Children { + if len(child.Errors) > 0 { + child.ErrorsTree(errors, &child.Name) + } + } + + if len(errors) > 0 { + tree[index] = map[string]any{ + "meta": map[string]any{ + "id": f.GetId(), + "name": f.Name, + "formName": f.GetName(), + }, + "errors": []validation.Error{}, + "children": slices.Collect(maps.Values(errors)), + } + } + } +} diff --git a/form/field_choice.go b/form/field_choice.go index 6a4014d..16c06fc 100644 --- a/form/field_choice.go +++ b/form/field_choice.go @@ -16,9 +16,11 @@ package form // along with this program. If not, see . import ( + "encoding/json" "reflect" "github.com/spf13/cast" + "gitnet.fr/deblan/go-form/validation" ) type Choice struct { @@ -32,9 +34,9 @@ func (c Choice) Match(value string) bool { } type Choices struct { - Data any - ValueBuilder func(key int, item any) string - LabelBuilder func(key int, item any) string + Data any `json:"data"` + ValueBuilder func(key int, item any) string `json:"-"` + LabelBuilder func(key int, item any) string `json:"-"` } func (c *Choices) Match(f *Field, value string) bool { @@ -99,6 +101,24 @@ func (c *Choices) GetChoices() []Choice { return choices } +func (c Choices) MarshalJSON() ([]byte, error) { + var choices []map[string]string + + v := reflect.ValueOf(c.Data) + + switch v.Kind() { + case reflect.Slice, reflect.Array, reflect.String, reflect.Map: + for i := 0; i < v.Len(); i++ { + choices = append(choices, map[string]string{ + "value": c.ValueBuilder(i, v.Index(i).Interface()), + "label": c.LabelBuilder(i, v.Index(i).Interface()), + }) + } + } + + return json.Marshal(choices) +} + // Generates an instance of Choices func NewChoices(items any) *Choices { builder := func(key int, item any) string { @@ -124,6 +144,28 @@ func NewFieldChoice(name string) *Field { NewOption("empty_choice_label", "None"), ) + f.Validate = func(field *Field) bool { + isValid := field.Validate(field) + + if len(validation.NewNotBlank().Validate(field.Data)) == 0 { + choices := field.GetOption("choices").Value.(*Choices) + isValidChoice := false + + for _, choice := range choices.GetChoices() { + if choices.Match(field, choice.Value) { + isValidChoice = true + } + } + + if !isValidChoice { + field.Errors = append(field.Errors, validation.Error("This value is not valid.")) + isValid = false + } + } + + return isValid + } + f.WithBeforeBind(func(data any) (any, error) { choices := f.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 742d192..04016d3 100644 --- a/form/form.go +++ b/form/form.go @@ -16,8 +16,13 @@ package form // along with this program. If not, see . import ( + "encoding/json" + "io/ioutil" + "maps" "net/http" "net/url" + "slices" + "strings" "github.com/mitchellh/mapstructure" "gitnet.fr/deblan/go-form/util" @@ -26,14 +31,15 @@ import ( // Field represents a form type Form struct { - Fields []*Field - GlobalFields []*Field - Errors []validation.Error - Method string - Action string - Name string - Options []*Option - RequestData *url.Values + Fields []*Field `json:"children"` + GlobalFields []*Field `json:"-"` + Errors []validation.Error `json:"-"` + Method string `json:"method"` + JsonRequest bool `json:"json_request"` + Action string `json:"action"` + Name string `json:"name"` + Options []*Option `json:"options"` + RequestData *url.Values `json:"-"` } // Generates a new form with default properties @@ -90,6 +96,8 @@ func (f *Form) Add(fields ...*Field) { // Configures its children deeply // This function must be called after adding all fields func (f *Form) End() *Form { + f.GlobalFields = []*Field{} + for _, c := range f.Fields { f.AddGlobalField(c) } @@ -199,12 +207,18 @@ func (f *Form) Mount(data any) error { return nil } +func (f *Form) WithJsonRequest() *Form { + f.JsonRequest = true + + return f +} + // Copies datas from the form to a struct 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) @@ -214,17 +228,51 @@ func (f *Form) Bind(data any) error { func (f *Form) HandleRequest(req *http.Request) { var data url.Values - if f.Method != "GET" { - req.ParseForm() - data = req.Form + if f.JsonRequest { + body, err := ioutil.ReadAll(req.Body) + + if err != nil { + return + } + + mapping := make(map[string]any) + err = json.Unmarshal(body, &mapping) + + if err != nil { + return + } + + data = url.Values{} + util.MapToUrlValues(&data, f.Name, mapping) } else { - data = req.URL.Query() + switch f.Method { + case "GET": + data = req.URL.Query() + default: + req.ParseForm() + data = req.Form + } } 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 { @@ -244,3 +292,30 @@ func (f *Form) HandleRequest(req *http.Request) { 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) + + for _, field := range f.Fields { + field.ErrorsTree(errors, nil) + } + + return map[string]any{ + "errors": f.Errors, + "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/form/option.go b/form/option.go index 9fccd27..95eee67 100644 --- a/form/option.go +++ b/form/option.go @@ -18,8 +18,8 @@ import "strings" // along with this program. If not, see . type Option struct { - Name string - Value any + Name string `json:"name"` + Value any `json:"value"` } func NewOption(name string, value any) *Option { diff --git a/go.mod b/go.mod index 0b43ae4..20dda1e 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,9 @@ module gitnet.fr/deblan/go-form go 1.23.0 require ( + github.com/iancoleman/strcase v0.3.0 github.com/mitchellh/mapstructure v1.5.0 github.com/spf13/cast v1.9.2 github.com/yassinebenaid/godump v0.11.1 -) - -require ( - github.com/samber/lo v1.51.0 // indirect - golang.org/x/text v0.22.0 // indirect - maragu.dev/gomponents v1.1.0 // indirect + maragu.dev/gomponents v1.1.0 ) diff --git a/go.sum b/go.sum index fd3f0f9..c6c8a71 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -10,13 +12,9 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= -github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/yassinebenaid/godump v0.11.1 h1:SPujx/XaYqGDfmNh7JI3dOyCUVrG0bG2duhO3Eh2EhI= github.com/yassinebenaid/godump v0.11.1/go.mod h1:dc/0w8wmg6kVIvNGAzbKH1Oa54dXQx8SNKh4dPRyW44= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= maragu.dev/gomponents v1.1.0 h1:iCybZZChHr1eSlvkWp/JP3CrZGzctLudQ/JI3sBcO4U= maragu.dev/gomponents v1.1.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= 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/bootstrap5.go b/theme/bootstrap5.go index cc3548c..525bdb8 100644 --- a/theme/bootstrap5.go +++ b/theme/bootstrap5.go @@ -51,6 +51,8 @@ var Bootstrap5 = ExtendTheme(Html5, func() map[string]RenderFunc { class = "form-check-input" } else if fieldType == "range" { class = "form-range" + } else if fieldType == "button" || fieldType == "submit" || fieldType == "reset" { + class = "btn" } else { class = "form-control" } diff --git a/theme/html5.go b/theme/html5.go index 2a85aee..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) @@ -396,17 +443,18 @@ var Html5 = CreateTheme(func() map[string]RenderFunc { theme["form_content"] = func(parent map[string]RenderFunc, args ...any) Node { form := args[0].(*form.Form) - return Div( + return Group([]Node{ parent["form_errors"](parent, form), parent["form_help"](parent, form), parent["form_fields"](parent, form.Fields), - ) + }) } theme["form"] = func(parent map[string]RenderFunc, args ...any) Node { 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 76c8e9f..22fa4f4 100644 --- a/util/inspect.go +++ b/util/inspect.go @@ -18,6 +18,8 @@ package util import ( "errors" "reflect" + + "github.com/iancoleman/strcase" ) func InspectStruct(input interface{}) (map[string]interface{}, error) { @@ -27,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") } @@ -37,8 +43,16 @@ func InspectStruct(input interface{}) (map[string]interface{}, error) { for i := 0; i < val.NumField(); i++ { field := typ.Field(i) value := val.Field(i) + tags := typ.Field(i).Tag + name := field.Name - result[field.Name] = value.Interface() + fieldTag := tags.Get("field") + + if fieldTag == "lowerCamel" { + name = strcase.ToLowerCamel(name) + } + + result[name] = value.Interface() } return result, nil diff --git a/util/transformer.go b/util/transformer.go new file mode 100644 index 0000000..a942fa1 --- /dev/null +++ b/util/transformer.go @@ -0,0 +1,57 @@ +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" +) + +func MapToUrlValues(values *url.Values, prefix string, data map[string]any) { + keyFormater := "%s" + + if prefix != "" { + keyFormater = prefix + "[%s]" + } + + for key, value := range data { + keyValue := fmt.Sprintf(keyFormater, key) + + switch v := value.(type) { + case string: + values.Add(keyValue, v) + case []string: + case []int: + case []int32: + case []int64: + case []any: + for _, s := range v { + values.Add(keyValue, fmt.Sprintf("%v", s)) + } + case bool: + if v { + values.Add(keyValue, "1") + } else { + values.Add(keyValue, "0") + } + case int, int64, float64: + values.Add(keyValue, fmt.Sprintf("%v", v)) + case map[string]any: + MapToUrlValues(values, keyValue, v) + default: + } + } +} diff --git a/validation/notblank.go b/validation/notblank.go index a417e22..1962cb2 100644 --- a/validation/notblank.go +++ b/validation/notblank.go @@ -39,7 +39,7 @@ func (c NotBlank) Validate(data any) []Error { v := reflect.ValueOf(data) - if v.IsZero() { + if data == nil || v.IsZero() { errors = append(errors, Error(c.Message)) return errors