Compare commits
34 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f451d69d70 |
|||
|
fe5d84d200 |
|||
|
f70f0a1f9d |
|||
|
7373b19212 |
|||
|
e2c62de59e |
|||
|
bec0acd2f2 |
|||
|
1d40aa6b09 |
|||
|
b9c5f6a2fd |
|||
|
97f5cf3215 |
|||
|
a06afe583d |
|||
|
f25f823265 |
|||
|
e5c4f3783c |
|||
|
fcff10cec0 |
|||
|
56c7ac9d04 |
|||
|
fdf94ba319 |
|||
|
f9dd26dd4c |
|||
|
28dc55d920 |
|||
|
e76ada7930 |
|||
|
21e0cdc733 |
|||
|
6668356b4c |
|||
|
eb8ecafea1 |
|||
|
627182d141 |
|||
|
722d55106b |
|||
|
27b7cb63ed |
|||
|
b4ec7c6178 |
|||
|
f1b45c7ad4 |
|||
|
1a06382387 |
|||
|
37eaf6b348 |
|||
|
c6fc6e45e4 |
|||
|
f0a94dec93 |
|||
|
5a3ec773d5 |
|||
|
17da3f7aee |
|||
|
d154000625 |
|||
|
781fd04ffb |
19 changed files with 1013 additions and 307 deletions
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
73
README.md
73
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/
|
||||
|
|
|
|||
81
example.go
Normal file
81
example.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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
119
example/view/bootstrap.html
Normal 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
138
example/view/html5.html
Normal 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>
|
||||
|
||||
153
form/field.go
153
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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
80
form/field_collection.go
Normal 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)
|
||||
}
|
||||
103
form/form.go
103
form/form.go
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
8
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
|
||||
)
|
||||
|
|
|
|||
6
go.sum
6
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=
|
||||
|
|
|
|||
175
main.go
175
main.go
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
111
util/collection.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
57
util/transformer.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue