[WIP] feat: add collection widget

This commit is contained in:
Simon Vieille 2025-09-24 09:03:50 +02:00
commit a06afe583d
Signed by: deblan
GPG key ID: 579388D585F70417
8 changed files with 367 additions and 30 deletions

View file

@ -36,12 +36,18 @@ 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
}
type CollectionItem struct {
ValueA string
ValueB string
}
func CreateDataForm() *form.Form {
@ -60,6 +66,19 @@ func CreateDataForm() *form.Form {
})
return form.NewForm(
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.NewFieldText("Bytes").
WithOptions(
form.NewOption("label", "Bytes"),

View file

@ -27,6 +27,14 @@ import (
// Generic function for field.Validation
func FieldValidation(f *Field) bool {
// if f.IsCollection {
// if formOption := f.GetOption("form"); formOption != nil {
// if formValue, ok := formOption.Value.(*Form); ok {
// godump.Dump(formValue)
// }
// }
// }
if len(f.Children) > 0 {
isValid := true
@ -56,20 +64,22 @@ func FieldValidation(f *Field) bool {
// Field represents a field in a form
type Field struct {
Name string `json:"name"`
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"`
IsFixedName bool `json:"is_fixed_name"`
Form *Form `json:"-"`
Parent *Field `json:"-"`
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
@ -102,6 +112,20 @@ func NewField(name, widget string) *Field {
return f
}
func (f *Field) Copy() *Field {
return &Field{
Name: f.Name,
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 {
@ -137,6 +161,20 @@ func (f *Field) WithOptions(options ...*Option) *Field {
return f
}
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
@ -158,6 +196,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
@ -233,9 +278,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
}

73
form/field_collection.go Normal file
View file

@ -0,0 +1,73 @@
package form
import (
"fmt"
"reflect"
)
// @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/>.
// 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++ {
form := src.Copy()
form.Mount(slice.Index(i).Interface())
field := f.Copy()
field.Widget = "sub_form"
field.Name = fmt.Sprintf("%d", i)
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

@ -22,8 +22,10 @@ import (
"net/http"
"net/url"
"slices"
"strings"
"github.com/mitchellh/mapstructure"
"github.com/yassinebenaid/godump"
"gitnet.fr/deblan/go-form/util"
"gitnet.fr/deblan/go-form/validation"
)
@ -108,8 +110,10 @@ func (f *Form) End() *Form {
func (f *Form) AddGlobalField(field *Field) {
f.GlobalFields = append(f.GlobalFields, field)
for _, c := range field.Children {
f.AddGlobalField(c)
if field.Widget != "collection" {
for _, c := range field.Children {
f.AddGlobalField(c)
}
}
}
@ -220,6 +224,8 @@ func (f *Form) Bind(data any) error {
field.Bind(toBind, nil)
}
godump.Dump(toBind)
return mapstructure.Decode(toBind, data)
}
@ -255,8 +261,23 @@ func (f *Form) HandleRequest(req *http.Request) {
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 {
@ -289,3 +310,16 @@ func (f *Form) ErrorsTree() map[string]any {
"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,
}
}

28
main.go
View file

@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"html/template"
"log"
"net/http"
@ -13,6 +14,11 @@ import (
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
data := example.ExampleData{}
data.Collection = []example.CollectionItem{
{"Value a 1", "Value b 1"},
{"Value a 2", "Value b 2"},
{"Value a 3", "Value b 3"},
}
f := example.CreateDataForm()
f.Mount(data)
@ -22,7 +28,7 @@ func main() {
if f.IsSubmitted() && f.IsValid() {
f.Bind(&data)
godump.Dump(data)
// godump.Dump(data)
}
}
@ -91,10 +97,19 @@ func main() {
<strong>Valid</strong>
<span class="debug-value">{{ .Form.IsValid }}</span>
</div>
<div>
<strong>Data</strong>
<pre class="debug-valid">{{ .Dump }}</pre>
</div>
<table width="100%">
<tr>
<td width="50%" valign="top">
<pre class="debug-valid">{{ .Dump }}</pre>
</td>
<td valign="top">
<details>
<summary>JSON</summary>
<pre class="debug-valid">{{ .Json }}</pre>
</details>
</td>
</tr>
</table>
</div>
{{ form .Form }}
@ -105,9 +120,12 @@ func main() {
var dump godump.Dumper
dump.Theme = godump.Theme{}
j, _ := json.MarshalIndent(f, " ", " ")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tpl.Execute(w, map[string]any{
"Form": f,
"Json": string(j),
"Dump": template.HTML(dump.Sprint(data)),
})
})

View file

@ -1,6 +1,7 @@
package theme
import (
"bytes"
"fmt"
"github.com/spf13/cast"
@ -348,6 +349,53 @@ 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 Div(
Attr("data-prototype", prototype),
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)

96
util/collection.go Normal file
View file

@ -0,0 +1,96 @@
package util
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

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