diff --git a/CHANGELOG.md b/CHANGELOG.md index ba56fe5..195158a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,72 +1,5 @@ ## [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 - -- fix(form): replace existing option in WithOptions - -## v1.1.2 - -### Added - -- chore(example): remove boostrap classes - -### Fixed - -- fix(theme): checkbox is check on nil value - ## v1.1.1 ### Fixed diff --git a/README.md b/README.md index 3754083..767ec58 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ 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. -[`go-form`][go-form] is heavily influenced by [Symfony Form](https://symfony.com/doc/current/forms.html). It includes: +## Introduction + +[`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 @@ -10,11 +12,70 @@ 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 -## Documentation +## Installation -* [Official documentation][doc] -* [Demo][demo] +```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)) +} +``` [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 deleted file mode 100644 index 91918d4..0000000 --- a/example.go +++ /dev/null @@ -1,81 +0,0 @@ -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 fc6e8bf..d945a99 100644 --- a/example/form.go +++ b/example/form.go @@ -36,25 +36,15 @@ type ExampleDates struct { } type ExampleData struct { - Collection []CollectionItem - Bytes []byte - Text string - Checkbox bool - Dates ExampleDates - Choices ExampleChoices - Inputs ExampleOtherInputs + Bytes []byte + Text string + Checkbox bool + Dates ExampleDates + Choices ExampleChoices + Inputs ExampleOtherInputs } -type CollectionItem struct { - ValueA string - ValueB string -} - -type Theme struct { - Value string `field:"lowerCamel"` -} - -func CreateDataForm(action string) *form.Form { +func CreateDataForm() *form.Form { items := []Item{ Item{Id: 1, Name: "Item 1"}, Item{Id: 2, Name: "Item 2"}, @@ -74,6 +64,9 @@ func CreateDataForm(action string) *form.Form { WithOptions( form.NewOption("label", "Bytes"), form.NewOption("required", true), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ). WithBeforeMount(func(data any) (any, error) { return cast.ToString(data), nil @@ -88,6 +81,9 @@ func CreateDataForm(action string) *form.Form { WithOptions( form.NewOption("label", "Text"), form.NewOption("help", "Must contain 'deblan'"), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ). WithConstraints( validation.NewRegex(`deblan`), @@ -95,6 +91,9 @@ func CreateDataForm(action string) *form.Form { form.NewFieldCheckbox("Checkbox"). WithOptions( form.NewOption("label", "Checkbox"), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ), form.NewSubForm("Inputs"). WithOptions( @@ -104,6 +103,9 @@ func CreateDataForm(action string) *form.Form { form.NewFieldNumber("Number"). WithOptions( form.NewOption("label", "Number"), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ). WithConstraints( validation.NewRange().WithRange(1, 20), @@ -112,10 +114,16 @@ func CreateDataForm(action string) *form.Form { form.NewFieldRange("Range"). WithOptions( form.NewOption("label", "Range"), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ), form.NewFieldMail("Mail"). WithOptions( form.NewOption("label", "Mail"), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ). WithConstraints( validation.Mail{}, @@ -123,6 +131,9 @@ func CreateDataForm(action string) *form.Form { form.NewFieldPassword("Password"). WithOptions( form.NewOption("label", "Password"), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ). WithConstraints( validation.NewLength().WithMin(10), @@ -136,18 +147,30 @@ func CreateDataForm(action string) *form.Form { form.NewFieldDate("Date"). WithOptions( form.NewOption("label", "Date"), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ), form.NewFieldDatetime("DateTime"). WithOptions( form.NewOption("label", "Datetime"), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ), form.NewFieldDatetimeLocal("DateTimeLocal"). WithOptions( form.NewOption("label", "DateTime local"), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ), form.NewFieldTime("Time"). WithOptions( form.NewOption("label", "Time"), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ), ), form.NewSubForm("Choices"). @@ -157,6 +180,9 @@ func CreateDataForm(action string) *form.Form { WithOptions( form.NewOption("choices", itemsChoices), form.NewOption("label", "Select"), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ). WithConstraints( validation.NewNotBlank(), @@ -166,6 +192,9 @@ func CreateDataForm(action string) *form.Form { form.NewOption("choices", itemsChoices), form.NewOption("label", "Select (expanded)"), form.NewOption("expanded", true), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ), form.NewFieldChoice("MultipleSelect"). WithSlice(). @@ -173,6 +202,9 @@ func CreateDataForm(action string) *form.Form { form.NewOption("choices", itemsChoices), form.NewOption("label", "Multiple select"), form.NewOption("multiple", true), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ). WithConstraints( validation.NewNotBlank(), @@ -185,21 +217,11 @@ func CreateDataForm(action string) *form.Form { form.NewOption("label", "Multiple select (expanded)"), form.NewOption("expanded", true), form.NewOption("multiple", true), + form.NewOption("row_attr", form.Attrs{ + "class": "col-12 mb-3", + }), ), ), - 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( @@ -210,42 +232,14 @@ func CreateDataForm(action string) *form.Form { ). End(). WithOptions( - form.NewOption("help", "Form global help"), + form.NewOption("help", "form help"), + form.NewOption("help_attr", form.Attrs{ + "class": "btn btn-primary", + }), + form.NewOption("attr", form.Attrs{ + "class": "row", + }), ). 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) + WithAction("/") } diff --git a/example/view/bootstrap.html b/example/view/bootstrap.html deleted file mode 100644 index 23543fd..0000000 --- a/example/view/bootstrap.html +++ /dev/null @@ -1,119 +0,0 @@ - - - - - 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 deleted file mode 100644 index 88db2d8..0000000 --- a/example/view/html5.html +++ /dev/null @@ -1,138 +0,0 @@ - - - - - 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 fe63326..7abae57 100644 --- a/form/field.go +++ b/form/field.go @@ -17,8 +17,6 @@ package form import ( "fmt" - "maps" - "slices" "strings" "gitnet.fr/deblan/go-form/util" @@ -26,7 +24,7 @@ import ( ) // Generic function for field.Validation -func DefaultFieldValidation(f *Field) bool { +func FieldValidation(f *Field) bool { if len(f.Children) > 0 { isValid := true @@ -38,11 +36,7 @@ func DefaultFieldValidation(f *Field) bool { c.Errors = errs } - isValid = isChildValid && isValid - - for _, sc := range c.Children { - isValid = DefaultFieldValidation(sc) && isValid - } + isValid = isValid && isChildValid } return isValid @@ -60,22 +54,20 @@ func DefaultFieldValidation(f *Field) bool { // Field represents a field in a form type Field struct { - 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:"-"` + 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 } // Generates a new field with default properties @@ -103,26 +95,11 @@ func NewField(name, widget string) *Field { NewOption("help_attr", Attrs{}), ) - f.Validate = DefaultFieldValidation + f.Validate = FieldValidation 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 { @@ -158,21 +135,6 @@ 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 @@ -194,13 +156,6 @@ 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 @@ -276,9 +231,9 @@ func (f *Field) GetName() string { } if f.Form != nil && f.Form.Name != "" { - name = fmt.Sprintf("%s%s[%s]", f.Form.Name, f.NamePrefix, f.Name) + name = fmt.Sprintf("%s[%s]", f.Form.Name, f.Name) } else if f.Parent != nil { - name = fmt.Sprintf("%s%s[%s]", f.Parent.GetName(), f.NamePrefix, f.Name) + name = fmt.Sprintf("%s[%s]", f.Parent.GetName(), f.Name) } else { name = f.Name } @@ -330,7 +285,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, parentIsSlice bool) error { +func (f *Field) Bind(data map[string]any, key *string) error { if len(f.Children) == 0 { v, err := f.BeforeBind(f.Data) @@ -350,74 +305,8 @@ func (f *Field) Bind(data map[string]any, key *string, parentIsSlice bool) error data[f.Name] = make(map[string]any) for _, child := range f.Children { - 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 + child.Bind(data[f.Name].(map[string]any), key) } 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 16c06fc..6a4014d 100644 --- a/form/field_choice.go +++ b/form/field_choice.go @@ -16,11 +16,9 @@ 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 { @@ -34,9 +32,9 @@ func (c Choice) Match(value string) bool { } type Choices struct { - Data any `json:"data"` - ValueBuilder func(key int, item any) string `json:"-"` - LabelBuilder func(key int, item any) string `json:"-"` + Data any + ValueBuilder func(key int, item any) string + LabelBuilder func(key int, item any) string } func (c *Choices) Match(f *Field, value string) bool { @@ -101,24 +99,6 @@ 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 { @@ -144,28 +124,6 @@ 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 deleted file mode 100644 index 3f3ece1..0000000 --- a/form/field_collection.go +++ /dev/null @@ -1,80 +0,0 @@ -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 04016d3..2b20bae 100644 --- a/form/form.go +++ b/form/form.go @@ -16,13 +16,8 @@ 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" @@ -31,15 +26,14 @@ import ( // Field represents a form type Form struct { - 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:"-"` + Fields []*Field + GlobalFields []*Field + Errors []validation.Error + Method string + Action string + Name string + Options []*Option + RequestData *url.Values } // Generates a new form with default properties @@ -96,8 +90,6 @@ 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) } @@ -163,11 +155,7 @@ func (f *Form) WithAction(v string) *Form { // Appends options to the form func (f *Form) WithOptions(options ...*Option) *Form { for _, option := range options { - if f.HasOption(option.Name) { - f.GetOption(option.Name).Value = option.Value - } else { - f.Options = append(f.Options, option) - } + f.Options = append(f.Options, option) } return f @@ -207,18 +195,12 @@ 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, false) + field.Bind(toBind, nil) } return mapstructure.Decode(toBind, data) @@ -228,51 +210,17 @@ func (f *Form) Bind(data any) error { func (f *Form) HandleRequest(req *http.Request) { var data url.Values - 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) + if f.Method != "GET" { + req.ParseForm() + data = req.Form } else { - switch f.Method { - case "GET": - data = req.URL.Query() - default: - req.ParseForm() - data = req.Form - } + data = req.URL.Query() } isSubmitted := false - type collectionData map[string]any - for _, c := range f.GlobalFields { - 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()) { + if data.Has(c.GetName()) { isSubmitted = true if c.IsSlice { @@ -292,30 +240,3 @@ 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 95eee67..9fccd27 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 `json:"name"` - Value any `json:"value"` + Name string + Value any } func NewOption(name string, value any) *Option { diff --git a/go.mod b/go.mod index 20dda1e..0b43ae4 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,13 @@ 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 - maragu.dev/gomponents v1.1.0 +) + +require ( + github.com/samber/lo v1.51.0 // indirect + golang.org/x/text v0.22.0 // indirect + maragu.dev/gomponents v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index c6c8a71..fd3f0f9 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ 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= @@ -12,9 +10,13 @@ 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 new file mode 100644 index 0000000..a08940d --- /dev/null +++ b/main.go @@ -0,0 +1,178 @@ +package main + +import ( + "html/template" + "log" + "net/http" + "os" + + "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)), + }) + + os.Stdout + }) + + log.Fatal(http.ListenAndServe(":1122", nil)) +} diff --git a/theme/bootstrap5.go b/theme/bootstrap5.go index 525bdb8..cc3548c 100644 --- a/theme/bootstrap5.go +++ b/theme/bootstrap5.go @@ -51,8 +51,6 @@ 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 2d7889e..a5803a9 100644 --- a/theme/html5.go +++ b/theme/html5.go @@ -1,7 +1,6 @@ package theme import ( - "bytes" "fmt" "github.com/spf13/cast" @@ -59,7 +58,6 @@ var Html5 = CreateTheme(func() map[string]RenderFunc { } return Ul( - Class("gf-errors"), Group(result), ) } @@ -95,7 +93,6 @@ var Html5 = CreateTheme(func() map[string]RenderFunc { } return Div( - Class("gf-help"), Text(help), extra, ) @@ -188,7 +185,7 @@ var Html5 = CreateTheme(func() map[string]RenderFunc { ID(field.GetId()), Type(fieldType), Value(value), - If(fieldType == "checkbox" && field.Data != nil && field.Data != false, Checked()), + If(fieldType == "checkbox" && field.Data != false, Checked()), If(field.HasOption("required") && field.GetOption("required").AsBool(), Required()), parent["input_attributes"](parent, field), ) @@ -351,50 +348,6 @@ 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) @@ -443,18 +396,17 @@ var Html5 = CreateTheme(func() map[string]RenderFunc { theme["form_content"] = func(parent map[string]RenderFunc, args ...any) Node { form := args[0].(*form.Form) - return Group([]Node{ + return Div( 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 deleted file mode 100644 index f0aa649..0000000 --- a/util/collection.go +++ /dev/null @@ -1,111 +0,0 @@ -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 22fa4f4..76c8e9f 100644 --- a/util/inspect.go +++ b/util/inspect.go @@ -18,8 +18,6 @@ package util import ( "errors" "reflect" - - "github.com/iancoleman/strcase" ) func InspectStruct(input interface{}) (map[string]interface{}, error) { @@ -29,10 +27,6 @@ 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") } @@ -43,16 +37,8 @@ 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 - fieldTag := tags.Get("field") - - if fieldTag == "lowerCamel" { - name = strcase.ToLowerCamel(name) - } - - result[name] = value.Interface() + result[field.Name] = value.Interface() } return result, nil diff --git a/util/transformer.go b/util/transformer.go deleted file mode 100644 index a942fa1..0000000 --- a/util/transformer.go +++ /dev/null @@ -1,57 +0,0 @@ -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 1962cb2..a417e22 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 data == nil || v.IsZero() { + if v.IsZero() { errors = append(errors, Error(c.Message)) return errors