diff --git a/CHANGELOG.md b/CHANGELOG.md
index d2a2bf1..ba56fe5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,49 @@
## [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
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 }}
-
-
-
-
- {{ 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 }}
-
-
-
-
- {{ 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 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