Compare commits

...

34 commits

Author SHA1 Message Date
f451d69d70
doc: add documentation of functions and licence 2025-10-01 23:30:16 +02:00
fe5d84d200
update changelog 2025-10-01 23:24:42 +02:00
f70f0a1f9d
feat(example): add theme picker 2025-10-01 23:23:13 +02:00
7373b19212
doc: update changelog 2025-10-01 18:15:23 +02:00
e2c62de59e
Merge branch 'feature/collection' into develop 2025-10-01 18:14:26 +02:00
bec0acd2f2
feat(theme/html5): add classes for help and errors html nodes 2025-10-01 18:14:08 +02:00
1d40aa6b09
refactor: refactor the example 2025-10-01 18:13:43 +02:00
b9c5f6a2fd
fix(collection): always validate fields when another field is invalid" 2025-10-01 16:30:31 +02:00
97f5cf3215
[WIP] feat: handle collection in form rendering, mount and bind 2025-09-26 17:49:39 +02:00
a06afe583d
[WIP] feat: add collection widget 2025-09-24 09:03:50 +02:00
f25f823265
release v1.4.0 2025-08-18 09:46:50 +02:00
e5c4f3783c
Merge branch 'feature/json' into develop 2025-08-18 09:46:20 +02:00
fcff10cec0
update changelog 2025-08-18 09:46:17 +02:00
56c7ac9d04
feat: add json configuration 2025-08-18 09:45:38 +02:00
fdf94ba319
fix: reset GlobalFields in End() 2025-08-18 09:14:48 +02:00
f9dd26dd4c
Merge branch 'feature/json-method' into develop 2025-08-08 08:56:44 +02:00
28dc55d920
update changelog 2025-08-08 08:56:21 +02:00
e76ada7930
feat(util/transformer): transform booleans to 0 and 1 2025-08-08 08:56:15 +02:00
21e0cdc733
feat: allow to handle request using json body 2025-08-07 18:11:59 +02:00
6668356b4c
chore: update dependencies 2025-08-07 18:11:30 +02:00
eb8ecafea1
feat: add map to url.Values transformer 2025-08-07 18:10:17 +02:00
627182d141
update changelog 2025-08-02 14:55:37 +02:00
722d55106b
feat: set children as [int]any 2025-08-01 22:07:39 +02:00
27b7cb63ed
feat: add meta and children in form.ErrorsTree() 2025-08-01 21:11:47 +02:00
b4ec7c6178
feat: add ErrorsTree() on form and field 2025-08-01 19:41:40 +02:00
f1b45c7ad4
Merge branch 'feature/form-name' into develop 2025-08-01 18:05:55 +02:00
1a06382387
feat: remove 'snake' as field case choice 2025-08-01 18:02:25 +02:00
37eaf6b348
update changelog 2025-08-01 17:42:44 +02:00
c6fc6e45e4
fix(validation/notblank): nil value validation 2025-08-01 17:42:27 +02:00
f0a94dec93
feat: add tag field to specify the naming strategy (case) 2025-08-01 17:41:41 +02:00
5a3ec773d5
feat: add tag field to specify the naming strategy (case) 2025-08-01 17:41:28 +02:00
17da3f7aee
doc: add link to the documentation and the demo 2025-07-30 14:06:47 +02:00
d154000625
doc: add link to the documentation and the demo 2025-07-30 14:06:09 +02:00
781fd04ffb
fix(input/choice): add specific validation func 2025-07-29 13:49:46 +02:00
19 changed files with 1013 additions and 307 deletions

View file

@ -1,5 +1,43 @@
## [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

View file

@ -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/

81
example.go Normal file
View file

@ -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))
}

View file

@ -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)
}

119
example/view/bootstrap.html Normal file
View file

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Demo of go-form with Bootstrap</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
<style>
fieldset {
border-radius: var(--bs-border-radius);
border: var(--bs-border-width) solid var(--bs-border-color);
padding: 10px;
}
form > div:not(:last-child), fieldset > div:not(:last-child) {
padding-bottom: 20px;
}
.gf-help {
font-style: italic;
color: #999;
}
</style>
</head>
<body>
<div class="container pt-2">
{{ form_widget (.styleForm.GetField "value") }}
<hr>
<h1>Demo of go-form with Bootstrap</h1>
<details>
<summary>Debug view</summary>
<div class="py-2">
<strong>Submitted:</strong>
{{ .isSubmitted }}
</div>
<div class="py-2">
<strong>Valid:</strong>
{{ .isValid }}
</div>
<details class="py-2">
<summary><strong>Dump of data</strong></summary>
<pre class="p-2">{{ .dump }}</pre>
</details>
<details class="py-2">
<summary><strong>Form as JSON</strong></summary>
<pre class="p-2">{{ .json }}</pre>
</details>
</details>
{{if .isValid}}
<div class="alert alert-success">The form is valid!</div>
{{else}}
<div class="alert alert-warning">The form is invalid!</div>
{{end}}
{{ form .form }}
</div>
<script>
const collections = document.querySelectorAll('*[data-prototype]')
const collectionItemAddToolBar = (item) => {
const toolbar = document.createElement('div')
const createBtn = () => {
const btn = document.createElement('button')
btn.textContent = '-'
btn.type = 'button'
btn.className = "btn btn-primary"
btn.addEventListener('click', () => {
item.remove()
})
return btn
}
toolbar.appendChild(createBtn())
item.querySelector('fieldset').appendChild(toolbar)
}
const collectionAddToolbar = (collection) => {
const container = collection.parentNode
const toolbar = document.createElement('div')
const createBtn = () => {
const btn = document.createElement('button')
btn.textContent = '+'
btn.type = 'button'
btn.className = "btn btn-primary"
btn.addEventListener('click', () => {
const form = collection.getAttribute('data-prototype')
.replace(/__name__/g, collection.children.length)
collection.insertAdjacentHTML("beforeend", form)
collectionItemAddToolBar(collection.lastChild)
})
return btn
}
toolbar.appendChild(createBtn())
container.appendChild(toolbar)
}
collections.forEach((collection) => {
collectionAddToolbar(collection)
for (let item of collection.children) {
collectionItemAddToolBar(item)
}
})
</script>
</body>
</html>

138
example/view/html5.html Normal file
View file

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Demo of go-form (pure HTML5 and Pico)</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.colors.min.css">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<style>
* {
font-size: 15px;
}
.p10 {
padding-top: 10px;
padding-bottom: 10px;
}
pre {
padding: 10px;
}
fieldset {
border: 1px solid var(--pico-form-element-border-color);
padding: 10px;
}
form > div:not(:last-child), fieldset > div:not(:last-child) {
padding-bottom: 20px;
}
.gf-errors {
color: var(--pico-form-element-invalid-border-color);
}
.gf-help {
font-style: italic;
color: var(--pico-form-element-placeholder-color);
}
</style>
</head>
<body class="container p10">
{{ form_widget (.styleForm.GetField "value") }}
<hr>
<h1>Demo of go-form (pure HTML5 and Pico)</h1>
<details class="p10">
<summary>Debug view</summary>
<div class="p10">
<strong>Submitted:</strong>
{{ .isSubmitted }}
</div>
<div class="p10">
<strong>Valid:</strong>
{{ .isValid }}
</div>
<details class="p10">
<summary><strong>Dump of data</strong></summary>
<pre>{{ .dump }}</pre>
</details>
<details class="p10">
<summary><strong>Form as JSON</strong></summary>
<pre>{{ .json }}</pre>
</details>
</div>
</details>
{{if .isValid}}
<p class="pico-color-green-500">The form is valid!</p>
{{else}}
<p class="pico-color-red-500">The form is invalid!</p>
{{end}}
{{ form .form }}
<script>
const collections = document.querySelectorAll('*[data-prototype]')
const collectionItemAddToolBar = (item) => {
const toolbar = document.createElement('div')
const createBtn = () => {
const btn = document.createElement('button')
btn.textContent = '-'
btn.type = 'button'
btn.addEventListener('click', () => {
item.remove()
})
return btn
}
toolbar.appendChild(createBtn())
item.querySelector('fieldset').appendChild(toolbar)
}
const collectionAddToolbar = (collection) => {
const container = collection.parentNode
const toolbar = document.createElement('div')
const createBtn = () => {
const btn = document.createElement('button')
btn.textContent = '+'
btn.type = 'button'
btn.addEventListener('click', () => {
const form = collection.getAttribute('data-prototype')
.replace(/__name__/g, collection.children.length)
collection.insertAdjacentHTML("beforeend", form)
collectionItemAddToolBar(collection.lastChild)
})
return btn
}
toolbar.appendChild(createBtn())
container.appendChild(toolbar)
}
collections.forEach((collection) => {
collectionAddToolbar(collection)
for (let item of collection.children) {
collectionItemAddToolBar(item)
}
})
</script>
</body>
</html>

View file

@ -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)),
}
}
}
}

View file

@ -16,6 +16,7 @@ package form
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import (
"encoding/json"
"reflect"
"github.com/spf13/cast"
@ -33,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 {
@ -100,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 {
@ -126,15 +145,15 @@ func NewFieldChoice(name string) *Field {
)
f.Validate = func(field *Field) bool {
isValid := FieldValidation(field)
isValid := field.Validate(field)
if len(validation.NewNotBlank().Validate(field.Data)) == 0 {
choices := field.GetOption("choices").Value.(*Choices)
isValidChoice := true
isValidChoice := false
for _, choice := range choices.GetChoices() {
if !choices.Match(field, choice.Value) {
isValidChoice = false
if choices.Match(field, choice.Value) {
isValidChoice = true
}
}

80
form/field_collection.go Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
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)
}

View file

@ -16,8 +16,13 @@ package form
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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,
}
}

View file

@ -18,8 +18,8 @@ import "strings"
// along with this program. If not, see <http://www.gnu.org/licenses/>.
type Option struct {
Name string
Value any
Name string `json:"name"`
Value any `json:"value"`
}
func NewOption(name string, value any) *Option {

8
go.mod
View file

@ -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
)

6
go.sum
View file

@ -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=

175
main.go
View file

@ -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(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form</title>
<style>
input[type="text"],
input[type="date"],
input[type="datetime"],
input[type="time"],
input[type="range"],
input[type="email"],
input[type="number"],
input[type="password"],
select,
input[type="datetime-local"],
textarea {
box-sizing: border-box;
padding: 9px;
margin: 10px 0;
display: block;
width: 100%;
border: 1px solid black;
}
.form-errors {
margin: 0;
padding: 5px 0 0 0;
color: red;
list-style: none;
}
.form-errors li {
padding: 0;
margin: 0;
}
.form-help {
color: blue;
font-size: 9px;
}
.debug {
padding: 10px;
}
.debug .debug-value {
color: #555;
padding: 10px 0 0 10px;
}
</style>
</head>
<body>
<div class="debug">
<div>
<strong>Submitted</strong>
<span class="debug-value">{{ .Form.IsSubmitted }}</span>
</div>
<div>
<strong>Valid</strong>
<span class="debug-value">{{ .Form.IsValid }}</span>
</div>
<div>
<strong>Data</strong>
<pre class="debug-valid">{{ .Dump }}</pre>
</div>
</div>
{{ form .Form }}
</body>
</html>
`)
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(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
</head>
<body>
<div class="container">
<div class="list-group">
<div class="list-group-item">
<strong>Submitted</strong>
<span class="debug-value">{{ .Form.IsSubmitted }}</span>
</div>
<div class="list-group-item">
<strong>Valid</strong>
<span class="debug-value">{{ .Form.IsValid }}</span>
</div>
<div class="list-group-item">
<strong>Data</strong>
<pre class="debug-valid">{{ .Dump }}</pre>
</div>
</div>
{{ form .Form }}
</div>
</body>
</html>
`)
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))
}

View file

@ -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),

111
util/collection.go Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
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
}

View file

@ -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

57
util/transformer.go Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
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:
}
}
}

View file

@ -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