├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── builder.go ├── builder_test.go ├── example.png ├── examples ├── bootstrap │ ├── bootstrap.go │ └── bootstrap.png ├── errors │ ├── errors.go │ └── errors.png ├── nested │ ├── nested.go │ └── nested.png ├── readme │ └── readme.go └── tailwind │ ├── tailwind.go │ └── tailwind.png ├── go.mod ├── go.sum ├── reflect.go └── reflect_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.gitignore.io/api/macos,visualstudiocode,go 4 | 5 | ### Go ### 6 | # Binaries for programs and plugins 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | # Test binary, build with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | *.out 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | # Thumbnails 29 | ._* 30 | 31 | # Files that might appear in the root of a volume 32 | .DocumentRevisions-V100 33 | .fseventsd 34 | .Spotlight-V100 35 | .TemporaryItems 36 | .Trashes 37 | .VolumeIcon.icns 38 | .com.apple.timemachine.donotpresent 39 | 40 | # Directories potentially created on remote AFP share 41 | .AppleDB 42 | .AppleDesktop 43 | Network Trash Folder 44 | Temporary Items 45 | .apdisk 46 | 47 | ### VisualStudioCode ### 48 | .vscode/* 49 | !.vscode/settings.json 50 | !.vscode/tasks.json 51 | !.vscode/launch.json 52 | !.vscode/extensions.json 53 | 54 | 55 | # End of https://www.gitignore.io/api/macos,visualstudiocode,go 56 | 57 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 58 | 59 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - master 5 | - "1.13" 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2018 Jonathan Calhoun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Form 2 | 3 | Easily create HTML forms with Go structs. 4 | 5 | [![go report card](https://goreportcard.com/badge/github.com/joncalhoun/form "go report card")](https://goreportcard.com/report/github.com/joncalhoun/form) 6 | [![Build Status](https://travis-ci.org/joncalhoun/form.svg?branch=master)](https://travis-ci.org/joncalhoun/form) 7 | [![MIT license](http://img.shields.io/badge/license-MIT-brightgreen.svg)](http://opensource.org/licenses/MIT) 8 | [![GoDoc](https://godoc.org/github.com/joncalhoun/form?status.svg)](https://godoc.org/github.com/joncalhoun/form) 9 | 10 | ## Overview 11 | 12 | The `form` package makes it easy to take a Go struct and turn it into an HTML form using whatever HTML format you want. Below is an example, along with the output, but first let's just look at an example of what I mean. 13 | 14 | Let's say you have a Go struct that looks like this: 15 | 16 | ```go 17 | type customer struct { 18 | Name string 19 | Email string 20 | Address *address 21 | } 22 | 23 | type address struct { 24 | Street1 string 25 | Street2 string 26 | City string 27 | State string 28 | Zip string `form:"label=Postal Code"` 29 | } 30 | ``` 31 | 32 | Now you want to generate an HTML form for it, but that is somewhat annoying if you want to persist user-entered values if there is an error, or if you want to support loading URL query params and auto-filling the form for the user. With this package you can very easily do both of those things simply by defining what the HTML for an input field should be: 33 | 34 | ```html 35 |
36 | 39 | 40 | {{range errors}} 41 |

{{.}}

42 | {{end}} 43 |
44 | ``` 45 | 46 | This particular example is using [Tailwind CSS](https://tailwindcss.com/docs/what-is-tailwind/) to style the values, along with the `errors` template function which is provided via this `form` package when it creates the inputs for each field. 47 | 48 | Now we can render this entire struct as a form by simply using the `inputs_for` template function which is provided by the `form.Builder`'s `FuncMap` method: 49 | 50 | ```html 51 |
52 | {{inputs_for .Customer}} 53 | 54 |
55 | ``` 56 | 57 | And with it we will generate an HTML form like the one below: 58 | 59 | ![Example output from the forms package](example.png) 60 | 61 | Data set in the `.Customer` variable in our template will also be used when rendering the form, which is why you see `Michael Scott` and `michael@dunder.com` in the screenshot - these were set in the `.Customer` and were thus used to set the input's value. 62 | 63 | Error rendering is also possible, but requires the usage of the `inputs_and_errors_for` template function, and you need to pass in errors that implement the `fieldError` interface (shown below, but NOT exported): 64 | 65 | ```go 66 | type fieldError interface { 67 | FieldError() (field, err string) 68 | } 69 | ``` 70 | 71 | For instance, in [examples/errors/errors.go](examples/errors/errors.go) we pass data similar the following into our template when executing it: 72 | 73 | ```go 74 | data := struct { 75 | Form customer 76 | Errors []error 77 | }{ 78 | Form: customer{ 79 | Name: "Michael Scott", 80 | Email: "michael@dunder.com", 81 | Address: nil, 82 | }, 83 | Errors: []error{ 84 | fieldError{ 85 | Field: "Email", 86 | Issue: "is already taken", 87 | }, 88 | fieldError{ 89 | Field: "Address.Street1", 90 | Issue: "is required", 91 | }, 92 | ... 93 | }, 94 | } 95 | tpl.Execute(w, data) 96 | ``` 97 | 98 | And then in the template we call the `inputs_and_errors_for` function: 99 | 100 | ```html 101 |
102 | {{inputs_and_errors_for .Form .Errors}} 103 | 104 |
105 | ``` 106 | 107 | And we get an output like this: 108 | 109 | ![Example output from the forms package with errors](examples/errors/errors.png) 110 | 111 | 112 | ## Installation 113 | 114 | To install this package, simply `go get` it: 115 | 116 | ``` 117 | go get github.com/joncalhoun/form 118 | ``` 119 | 120 | ## Complete Examples 121 | 122 | This entire example can be found in the [examples/readme](examples/readme) directory. Additional examples can also be found in the [examples/](examples/) directory and are a great way to see how this package could be used. 123 | 124 | **Source Code** 125 | 126 | ```go 127 | package main 128 | 129 | import ( 130 | "html/template" 131 | "net/http" 132 | 133 | "github.com/joncalhoun/form" 134 | ) 135 | 136 | var inputTpl = ` 137 | 140 | 141 | {{with .Footer}} 142 |

{{.}}

143 | {{end}} 144 | ` 145 | 146 | type Address struct { 147 | Street1 string `form:"label=Street;placeholder=123 Sample St"` 148 | Street2 string `form:"label=Street (cont);placeholder=Apt 123"` 149 | City string 150 | State string `form:"footer=Or your Province"` 151 | Zip string `form:"label=Postal Code"` 152 | Country string 153 | } 154 | 155 | func main() { 156 | tpl := template.Must(template.New("").Parse(inputTpl)) 157 | fb := form.Builder{ 158 | InputTemplate: tpl, 159 | } 160 | 161 | pageTpl := template.Must(template.New("").Funcs(fb.FuncMap()).Parse(` 162 | 163 | 164 |
165 | {{inputs_for .}} 166 |
167 | 168 | `)) 169 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 170 | w.Header().Set("Content-Type", "text/html") 171 | pageTpl.Execute(w, Address{ 172 | Street1: "123 Known St", 173 | Country: "United States", 174 | }) 175 | }) 176 | http.ListenAndServe(":3000", nil) 177 | } 178 | ``` 179 | 180 | **Relevant HTML** trimmed for brevity 181 | 182 | 183 | ```html 184 |
185 | 188 | 189 | 190 | 193 | 194 | 195 | 198 | 199 | 200 | 203 | 204 |

Or your Province

205 | 206 | 209 | 210 | 211 | 214 | 215 |
216 | ``` 217 | 218 | ## How it works 219 | 220 | The `form.Builder` type provides a single method - `Inputs` - which will parse the provided struct to determine which fields it contains, any values set for each field, and any struct tags provided for the form package. Once that information is parsed it will execute the provided `InputTemplate` field in the builder for each field in the struct, **including nested fields**. 221 | 222 | Most of the time you will probably want to just make this helper available to your html templates via the `template.Funcs()` functions and the `template.FuncMap` type, as I did in the example above. 223 | 224 | ## I don't recommend tagging domain types 225 | 226 | It is also worth mentioning that I don't really recommend adding `form` struct tags to your domain types, and I typically create types specifically used to generate forms. Eg: 227 | 228 | ```go 229 | // This is my domain type 230 | type User struct { 231 | ID int 232 | Name string 233 | Email string 234 | PasswordHash string 235 | } 236 | 237 | // Somewhere else I'll create my html-specific type: 238 | type signupForm struct { 239 | Name string `form:"..."` 240 | Email string `form:"type=email"` 241 | Password string `form:"type=password"` 242 | Confirmation string `form:"type=password;label=Password Confirmation"` 243 | } 244 | ``` 245 | 246 | ## Parsing submitted forms 247 | 248 | If you also need to parse forms created by this package, I recommend using the [gorilla/schema](https://github.com/gorilla/schema) package. This package *should* generate input names compliant with the `gorilla/schema` package by default, so as long as you don't change the names it should be pretty trivial to decode. 249 | 250 | There is an example of this in the [examples/tailwind](examples/tailwind) directory. 251 | 252 | ## Rendering errors 253 | 254 | If you want to render errors, see the [examples/errors/errors.go](examples/errors/errors.go) example and most notably check out the `inputs_and_errors_for` function provided to templates via the `Builder.FuncMap()` function. 255 | 256 | *TODO: Add some better examples here, but the provided code sample **is** a complete example.* 257 | 258 | ## This may have bugs 259 | 260 | This is a very early iteration of the package, and while it appears to be working for my needs chances are it doesn't cover every use case. If you do find one that isn't covered, try to provide a PR with a breaking test. 261 | 262 | 263 | ## Notes 264 | 265 | This section is mostly for myself to jot down notes, but feel free to read away. 266 | 267 | ### Potential features 268 | 269 | #### Parsing forms 270 | 271 | Long term this could also support parsing forms, but gorilla/schema does a great job of that already so I don't see any reason to at this time. It would likely be easier to just make the default input names line up with what gorilla/schema expects and provide examples for how to use the two together. 272 | 273 | #### Checkboxes and other data types 274 | 275 | Maybe allow for various templates for different types, but for now this is possible to do in the HTML templates so it isn't completely missing. 276 | 277 | #### Headers on nested structs 278 | 279 | Let's say we have this type: 280 | 281 | ```go 282 | type Nested struct { 283 | Name string 284 | Email string 285 | Address Address 286 | } 287 | 288 | type Address struct { 289 | Street1 string 290 | Street2 string 291 | // ... 292 | } 293 | ``` 294 | 295 | It might make sense to make an optional way to add headers in the form when the nested Address portion is rendered, so the form looks like: 296 | 297 | ``` 298 | Name: [ ] 299 | Email: [ ] 300 | 301 |
302 | 303 | Street1: [ ] 304 | Street2: [ ] 305 | ... 306 | ``` 307 | 308 | This *should* be pretty easy to do with struct tags on the `Address Address` line. 309 | -------------------------------------------------------------------------------- /builder.go: -------------------------------------------------------------------------------- 1 | // Package form is used to generate HTML forms. Most notably, the 2 | // form.Builder makes it very easy to take a struct with set values and 3 | // generate the input tags, labels, etc for each field in the struct. 4 | // 5 | // See the examples directory for a more comprehensive idea of what can be\ 6 | // accomplished with this package. 7 | package form 8 | 9 | import ( 10 | "errors" 11 | "fmt" 12 | "html/template" 13 | "strings" 14 | ) 15 | 16 | // Builder is used to build HTML forms/inputs for Go structs. Basic 17 | // usage looks something like this: 18 | // 19 | // tpl := template.Must(template.New("").Parse(` 20 | // 21 | // `)) 22 | // fb := Builder{InputTemplate: tpl} 23 | // html := fb.Inputs(struct{ 24 | // Name string `form:"name=full-name"` 25 | // Email string `form:"type=email"` 26 | // }{"Michael Scott", "michael@dundermifflin.com"}) 27 | // // Outputs: 28 | // // 29 | // // 30 | // 31 | // This is a VERY limited example, but should demonstrate the basic 32 | // idea. The Builder uses a single template to and will call it with all the 33 | // information about each individual field and return the resulting HTML. 34 | // 35 | // The most common use for this is to provide a helper function for your HTML 36 | // templates. Eg something like: 37 | // 38 | // fb := Builder{...} 39 | // tpl, err := template.New("").Funcs(template.FuncMap{ 40 | // "inputs_for": fb.Inputs, 41 | // }) 42 | // 43 | // // Then later in a template: 44 | //
45 | // {{inputs_for .SomeStruct}} 46 | //
47 | // 48 | // For a much more thorough set of examples, see the examples directory. 49 | // There is even an example illustrating how the gorilla/schema package can 50 | // be used to parse forms that are created by the Builder. 51 | type Builder struct { 52 | InputTemplate *template.Template 53 | } 54 | 55 | // Inputs will parse the provided struct into fields and then execute the 56 | // Builder.InputTemplate with each field. The returned HTML is simply 57 | // all of these results appended one after another. 58 | // 59 | // Inputs only accepts structs for the first argument. This may change 60 | // later, but that is all I needed for my use case so it is what it does. 61 | // If you need support for something else like maps let me know. 62 | // 63 | // Inputs' second argument - errs - will be used to render errors for 64 | // individual fields. This is done by looking for errors that implement 65 | // the fieldError interface: 66 | // 67 | // type fieldError interface { 68 | // FieldError() (field, err string) 69 | // } 70 | // 71 | // Where the first return value is expected to be the field with an error 72 | // and the second is the actual error message to be displayed. This is then 73 | // used to provide an `errors` template function that will return a slice 74 | // of errors (if there are any) for the current field your InputTemplate 75 | // is rendering. See examples/errors/errors.go for an example of this in 76 | // action. 77 | // 78 | // This interface is not exported and you can pass other errors into Inputs 79 | // but they currently won't be used. 80 | func (b *Builder) Inputs(v interface{}, errs ...error) (template.HTML, error) { 81 | tpl, err := b.InputTemplate.Clone() 82 | if err != nil { 83 | return "", err 84 | } 85 | fields := fields(v) 86 | errors := fieldErrors(errs) 87 | var html template.HTML 88 | for _, field := range fields { 89 | var sb strings.Builder 90 | tpl.Funcs(template.FuncMap{ 91 | "errors": func() []string { 92 | if errs, ok := errors[field.Name]; ok { 93 | return errs 94 | } 95 | return nil 96 | }, 97 | }) 98 | err := tpl.Execute(&sb, field) 99 | if err != nil { 100 | return "", err 101 | } 102 | html = html + template.HTML(sb.String()) 103 | } 104 | return html, nil 105 | } 106 | 107 | // FuncMap returns a template.FuncMap that defines both the inputs_for and 108 | // inputs_and_errors_for functions for usage in the template package. The 109 | // latter is provided via a closure because variadic parameters and the 110 | // template package don't play very nicely and this just simplifies things 111 | // a lot for end users of the form package. 112 | func (b *Builder) FuncMap() template.FuncMap { 113 | return template.FuncMap{ 114 | "inputs_for": b.Inputs, 115 | "inputs_and_errors_for": func(v interface{}, errs []error) (template.HTML, error) { 116 | return b.Inputs(v, errs...) 117 | }, 118 | } 119 | } 120 | 121 | // FuncMap is present to make it a little easier to build the InputTemplate 122 | // field of the Builder type. In order to parse a template that uses the 123 | // `errors` function, you need to have that template defined when the 124 | // template is parsed. We clearly don't know whether a field has an error 125 | // or not until it is parsed via the Inputs method call, so this basically 126 | // just provides a stubbed out errors function that returns nil so the template 127 | // compiles correctly. 128 | // 129 | // See examples/errors/errors.go for a clear example of this being used. 130 | func FuncMap() template.FuncMap { 131 | return template.FuncMap{ 132 | "errors": ErrorsStub, 133 | } 134 | } 135 | 136 | // ErrorsStub is a stubbed out function that simply returns nil. It is present 137 | // to make it a little easier to build the InputTemplate field of the Builder 138 | // type, since your template will likely use the errors function in the 139 | // template before it can truly be defined. You probably just want to use 140 | // the provided FuncMap helper, but this can be useful when you need to 141 | // build your own template.FuncMap. 142 | // 143 | // See examples/errors/errors.go for a clear example of the FuncMap function 144 | // being used, and see FuncMap for an example of how ErrorsStub can be used. 145 | func ErrorsStub() []string { 146 | return nil 147 | } 148 | 149 | // fieldError is an interface defining an error that represents something 150 | // wrong with a particular struct field. The name should correspond to the 151 | // name value used when building the HTML form, which is currently a period 152 | // separated list of all fields that lead up to the particular field. 153 | // Eg, in the following struct the Mouse field would have a key of Cat.Mouse: 154 | // 155 | // type Dog struct { 156 | // Cat: struct{ 157 | // Mouse string 158 | // } 159 | // } 160 | // 161 | // The top level Dog struct name is not used because this is unnecessary, 162 | // but any other nested struct names are necessary to properly determine 163 | // the field. 164 | // 165 | // It should also be noted that if you provide a custom field name, that 166 | // name should also be used in fieldError implementations. 167 | type fieldError interface { 168 | FieldError() (field, err string) 169 | } 170 | 171 | // errors will build a map where each key is the field name, and each 172 | // value is a slice of strings representing errors with that field. 173 | // 174 | // It works by looking for errors that implement the following interface: 175 | // 176 | // interface { 177 | // FieldError() (string, string) 178 | // } 179 | // 180 | // Where the first string returned is expected to be the field name, and 181 | // the second return value is expected to be an error with that field. 182 | // Any errors that implement this interface are then used to build the 183 | // slice of errors for the field, meaning you can provide multiple 184 | // errors for the same field and all will be utilized. 185 | func fieldErrors(errs []error) map[string][]string { 186 | ret := make(map[string][]string) 187 | for _, err := range errs { 188 | var fe fieldError 189 | if !errors.As(err, &fe) { 190 | fmt.Println(err, "isnt field error") 191 | continue 192 | } 193 | field, fieldErr := fe.FieldError() 194 | ret[field] = append(ret[field], fieldErr) 195 | } 196 | return ret 197 | } 198 | -------------------------------------------------------------------------------- /builder_test.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "html/template" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestBuilder_Inputs(t *testing.T) { 13 | tpl := template.Must(template.New("").Parse(strings.TrimSpace(` 14 | 15 | `))) 16 | tests := []struct { 17 | name string 18 | tpl *template.Template 19 | arg interface{} 20 | want template.HTML 21 | }{ 22 | { 23 | name: "label and input", 24 | tpl: tpl, 25 | arg: struct { 26 | Name string 27 | Email string `form:"type=email;placeholder=bob@example.com"` 28 | }{ 29 | Name: "Michael Scott", 30 | }, 31 | want: template.HTML(strings.Join([]string{ 32 | strings.TrimSpace(` 33 | `), 34 | strings.TrimSpace(` 35 | `), 36 | }, "")), 37 | }, 38 | } 39 | for _, tc := range tests { 40 | t.Run(tc.name, func(t *testing.T) { 41 | b := &Builder{ 42 | InputTemplate: tc.tpl, 43 | } 44 | got, err := b.Inputs(tc.arg) 45 | if err != nil { 46 | t.Errorf("Builder.Inputs() err = %v, want %v", err, nil) 47 | } 48 | if !reflect.DeepEqual(got, tc.want) { 49 | t.Errorf("Builder.Inputs() = %v, want %v", got, tc.want) 50 | } 51 | }) 52 | } 53 | } 54 | 55 | type testFieldError struct { 56 | field, err string 57 | } 58 | 59 | func (e testFieldError) Error() string { 60 | return fmt.Sprintf("invalid field: %v", e.field) 61 | } 62 | 63 | func (e testFieldError) FieldError() (field, err string) { 64 | return e.field, e.err 65 | } 66 | 67 | func TestBuilder_Inputs_errors(t *testing.T) { 68 | // Sanity check on our test type first 69 | tfe := testFieldError{ 70 | field: "field", 71 | err: "err", 72 | } 73 | var fe fieldError 74 | if !errors.As(tfe, &fe) { 75 | t.Fatalf("As(testFieldError, fieldError) = false") 76 | } 77 | if !errors.As(fmt.Errorf("wrapped: %w", tfe), &fe) { 78 | t.Fatalf("As(wrapped, fieldError) = false") 79 | } 80 | 81 | tpl := template.Must(template.New("").Funcs(FuncMap()).Parse(strings.TrimSpace(` 82 | {{range errors}}

{{.}}

{{end}} 83 | `))) 84 | tests := []struct { 85 | name string 86 | tpl *template.Template 87 | arg interface{} 88 | errors []error 89 | want template.HTML 90 | }{ 91 | { 92 | name: "label and input", 93 | tpl: tpl, 94 | arg: struct { 95 | Name string 96 | Email string `form:"type=email;placeholder=bob@example.com"` 97 | }{ 98 | Name: "Michael Scott", 99 | }, 100 | errors: []error{ 101 | fmt.Errorf("wrapped: %w", testFieldError{ 102 | field: "Name", 103 | err: "is required", 104 | }), 105 | fmt.Errorf("first: %w", fmt.Errorf("second: %w", testFieldError{ 106 | field: "Email", 107 | err: "is taken", 108 | })), 109 | }, 110 | want: template.HTML(strings.Join([]string{ 111 | strings.TrimSpace(` 112 |

is required

`), 113 | strings.TrimSpace(` 114 |

is taken

`), 115 | }, "")), 116 | }, 117 | } 118 | for _, tc := range tests { 119 | t.Run(tc.name, func(t *testing.T) { 120 | b := &Builder{ 121 | InputTemplate: tc.tpl, 122 | } 123 | got, err := b.Inputs(tc.arg, tc.errors...) 124 | if err != nil { 125 | t.Errorf("Builder.Inputs() err = %v, want %v", err, nil) 126 | } 127 | if !reflect.DeepEqual(got, tc.want) { 128 | t.Errorf("Builder.Inputs() = %v, want %v", got, tc.want) 129 | } 130 | }) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joncalhoun/form/ef16fa783afb380438ff8b93d994aa7b1304125f/example.png -------------------------------------------------------------------------------- /examples/bootstrap/bootstrap.go: -------------------------------------------------------------------------------- 1 | // Produces a form like: 2 | // https://www.dropbox.com/s/72z88osbcwik26n/Screenshot%202018-07-01%2014.13.31.png?dl=0&raw=1 3 | 4 | package main 5 | 6 | import ( 7 | "html/template" 8 | "net/http" 9 | 10 | "github.com/joncalhoun/form" 11 | ) 12 | 13 | var inputTpl = ` 14 |
15 | 18 | {{if eq .Type "textarea"}} 19 | 20 | {{else}} 21 | 22 | {{end}} 23 | {{with .Footer}} 24 | 25 | {{.}} 26 | 27 | {{end}} 28 |
` 29 | 30 | func main() { 31 | tpl := template.Must(template.New("").Parse(inputTpl)) 32 | fb := form.Builder{ 33 | InputTemplate: tpl, 34 | } 35 | 36 | pageTpl := template.Must(template.New("").Funcs(fb.FuncMap()).Parse(` 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
46 | 47 | 50 | 51 | 52 | 73 |
74 | 75 | 76 | `)) 77 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 78 | w.Header().Set("Content-Type", "text/html") 79 | pageTpl.Execute(w, userForm{ 80 | Name: "Jon Calhoun", 81 | Email: "jon@calhoun.io", 82 | Bio: "I like to write Go code!", 83 | Ignored: "info here should be ignored entirely because of the \"-\" form tag value", 84 | }) 85 | }) 86 | http.ListenAndServe(":3000", nil) 87 | } 88 | 89 | type userForm struct { 90 | Name string 91 | Email string `form:"type=email"` 92 | 93 | Password string `form:"type=password"` 94 | Confirmation string `form:"display=Password Confirmation;type=password"` 95 | 96 | Bio string `form:"type=textarea;placeholder=Tell us a bit about yourself"` 97 | 98 | Ignored string `form:"-"` 99 | } 100 | -------------------------------------------------------------------------------- /examples/bootstrap/bootstrap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joncalhoun/form/ef16fa783afb380438ff8b93d994aa7b1304125f/examples/bootstrap/bootstrap.png -------------------------------------------------------------------------------- /examples/errors/errors.go: -------------------------------------------------------------------------------- 1 | // Produces a form like: 2 | // 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | "html/template" 9 | "net/http" 10 | 11 | "github.com/gorilla/schema" 12 | "github.com/joncalhoun/form" 13 | ) 14 | 15 | var inputTpl = ` 16 |
17 | 20 | 21 | {{range errors}} 22 |

{{.}}

23 | {{end}} 24 | {{with .Footer}} 25 |

{{.}}

26 | {{end}} 27 |
` 28 | 29 | func main() { 30 | tpl := template.Must(template.New("").Funcs(form.FuncMap()).Parse(inputTpl)) 31 | fb := form.Builder{ 32 | InputTemplate: tpl, 33 | } 34 | 35 | pageTpl := template.Must(template.New("").Funcs(fb.FuncMap()).Parse(` 36 | 37 | 38 | 39 | 40 | 41 |
42 |
43 | {{inputs_and_errors_for .Form .Errors}} 44 |
45 | 48 |
49 |
50 |

51 | © 2018 Acme Corp. All rights reserved. 52 |

53 |
54 | 55 | 56 | `)) 57 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 58 | switch r.Method { 59 | case http.MethodGet: 60 | w.Header().Set("Content-Type", "text/html") 61 | data := struct { 62 | Form nestedForm 63 | Errors []error 64 | }{ 65 | Form: nestedForm{ 66 | Name: "Michael Scott", 67 | Email: "michael@dunder.com", 68 | Address: nil, 69 | }, 70 | Errors: []error{ 71 | fieldError{ 72 | Field: "Email", 73 | Issue: "is already taken", 74 | }, 75 | fieldError{ 76 | Field: "Address.Street1", 77 | Issue: "is required", 78 | }, 79 | fieldError{ 80 | Field: "Address.City", 81 | Issue: "is required", 82 | }, 83 | fieldError{ 84 | Field: "Address.State", 85 | Issue: "must be a US state", 86 | }, 87 | fieldError{ 88 | Field: "Address.Zip", 89 | Issue: "must be 5 digits", 90 | }, 91 | fieldError{ 92 | Field: "Address.Zip", 93 | Issue: "is required", 94 | }, 95 | }, 96 | } 97 | err := pageTpl.Execute(w, data) 98 | if err != nil { 99 | panic(err) 100 | } 101 | return 102 | case http.MethodPost: 103 | default: 104 | http.NotFound(w, r) 105 | return 106 | } 107 | 108 | // You can also process these forms using the gorilla/schema package. 109 | r.ParseForm() 110 | dec := schema.NewDecoder() 111 | dec.IgnoreUnknownKeys(true) 112 | var form nestedForm 113 | err := dec.Decode(&form, r.PostForm) 114 | if err != nil { 115 | http.Error(w, err.Error(), http.StatusBadRequest) 116 | return 117 | } 118 | w.Header().Set("Content-Type", "application/json") 119 | b, _ := json.Marshal(form) 120 | w.Write(b) 121 | }) 122 | http.ListenAndServe(":3000", nil) 123 | } 124 | 125 | type nestedForm struct { 126 | Name string 127 | Email string 128 | Address *address 129 | } 130 | 131 | type address struct { 132 | Street1 string 133 | Street2 string 134 | City string 135 | State string 136 | Zip string `form:"label=Postal Code"` 137 | } 138 | 139 | type fieldError struct { 140 | Field string 141 | Issue string 142 | } 143 | 144 | func (fe fieldError) Error() string { 145 | return fmt.Sprintf("%v: %v", fe.Field, fe.Issue) 146 | } 147 | 148 | // You can implement this however you want - this is just how I'm doing it. 149 | func (fe fieldError) FieldError() (field, err string) { 150 | return fe.Field, fe.Issue 151 | } 152 | -------------------------------------------------------------------------------- /examples/errors/errors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joncalhoun/form/ef16fa783afb380438ff8b93d994aa7b1304125f/examples/errors/errors.png -------------------------------------------------------------------------------- /examples/nested/nested.go: -------------------------------------------------------------------------------- 1 | // Produces a form like: 2 | // 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "html/template" 8 | "net/http" 9 | 10 | "github.com/gorilla/schema" 11 | "github.com/joncalhoun/form" 12 | ) 13 | 14 | var inputTpl = ` 15 |
16 | 19 | 20 | {{with .Footer}} 21 |

{{.}}

22 | {{end}} 23 |
` 24 | 25 | func main() { 26 | tpl := template.Must(template.New("").Parse(inputTpl)) 27 | fb := form.Builder{ 28 | InputTemplate: tpl, 29 | } 30 | 31 | pageTpl := template.Must(template.New("").Funcs(fb.FuncMap()).Parse(` 32 | 33 | 34 | 35 | 36 | 37 |
38 |
39 | {{inputs_for .}} 40 |
41 | 44 | 45 | Forgot Password? 46 | 47 |
48 |
49 |

50 | © 2018 Acme Corp. All rights reserved. 51 |

52 |
53 | 54 | 55 | `)) 56 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 57 | switch r.Method { 58 | case http.MethodGet: 59 | w.Header().Set("Content-Type", "text/html") 60 | pageTpl.Execute(w, nestedForm{ 61 | Name: "Michael Scott", 62 | Email: "michael@dunder.com", 63 | Address: nil, 64 | }) 65 | return 66 | case http.MethodPost: 67 | default: 68 | http.NotFound(w, r) 69 | return 70 | } 71 | 72 | // You can also process these forms using the gorilla/schema package. 73 | r.ParseForm() 74 | dec := schema.NewDecoder() 75 | dec.IgnoreUnknownKeys(true) 76 | var form nestedForm 77 | err := dec.Decode(&form, r.PostForm) 78 | if err != nil { 79 | http.Error(w, err.Error(), http.StatusBadRequest) 80 | return 81 | } 82 | w.Header().Set("Content-Type", "application/json") 83 | b, _ := json.Marshal(form) 84 | w.Write(b) 85 | }) 86 | http.ListenAndServe(":3000", nil) 87 | } 88 | 89 | type nestedForm struct { 90 | Name string 91 | Email string 92 | Address *address 93 | } 94 | 95 | type address struct { 96 | Street1 string 97 | Street2 string 98 | City string 99 | State string 100 | Zip string `form:"label=Postal Code"` 101 | } 102 | -------------------------------------------------------------------------------- /examples/nested/nested.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joncalhoun/form/ef16fa783afb380438ff8b93d994aa7b1304125f/examples/nested/nested.png -------------------------------------------------------------------------------- /examples/readme/readme.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "html/template" 5 | "net/http" 6 | 7 | "github.com/joncalhoun/form" 8 | ) 9 | 10 | var inputTpl = ` 11 | 14 | 15 | {{with .Footer}} 16 |

{{.}}

17 | {{end}} 18 | ` 19 | 20 | // Address is an example type used to demonstrate the form package. 21 | type Address struct { 22 | Street1 string `form:"label=Street;placeholder=123 Sample St"` 23 | Street2 string `form:"label=Street (cont);placeholder=Apt 123"` 24 | City string 25 | State string `form:"footer=Or your Province"` 26 | Zip string `form:"label=Postal Code"` 27 | Country string 28 | } 29 | 30 | func main() { 31 | tpl := template.Must(template.New("").Parse(inputTpl)) 32 | fb := form.Builder{ 33 | InputTemplate: tpl, 34 | } 35 | 36 | pageTpl := template.Must(template.New("").Funcs(fb.FuncMap()).Parse(` 37 | 38 | 39 |
40 | {{inputs_for .}} 41 |
42 | 43 | `)) 44 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 45 | w.Header().Set("Content-Type", "text/html") 46 | pageTpl.Execute(w, Address{ 47 | Street1: "123 Known St", 48 | Country: "United States", 49 | }) 50 | }) 51 | http.ListenAndServe(":3000", nil) 52 | } 53 | -------------------------------------------------------------------------------- /examples/tailwind/tailwind.go: -------------------------------------------------------------------------------- 1 | // Produces a form like: 2 | // https://www.dropbox.com/s/72z88osbcwik26n/Screenshot%202018-07-01%2014.13.31.png?dl=0&raw=1 3 | 4 | package main 5 | 6 | import ( 7 | "encoding/json" 8 | "html/template" 9 | "net/http" 10 | 11 | "github.com/gorilla/schema" 12 | "github.com/joncalhoun/form" 13 | ) 14 | 15 | var inputTpl = ` 16 |
17 | 20 | 21 | {{with .Footer}} 22 |

{{.}}

23 | {{end}} 24 |
` 25 | 26 | func main() { 27 | tpl := template.Must(template.New("").Parse(inputTpl)) 28 | fb := form.Builder{ 29 | InputTemplate: tpl, 30 | } 31 | 32 | pageTpl := template.Must(template.New("").Funcs(fb.FuncMap()).Parse(` 33 | 34 | 35 | 36 | 37 | 38 |
39 |
40 | {{inputs_for .}} 41 |
42 | 45 |
46 |
47 |

48 | © 2018 Acme Corp. All rights reserved. 49 |

50 |
51 | 52 | 53 | `)) 54 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 55 | switch r.Method { 56 | case http.MethodGet: 57 | w.Header().Set("Content-Type", "text/html") 58 | pageTpl.Execute(w, loginForm{}) 59 | return 60 | case http.MethodPost: 61 | default: 62 | http.NotFound(w, r) 63 | return 64 | } 65 | 66 | // You can also process these forms using the gorilla/schema package. 67 | r.ParseForm() 68 | dec := schema.NewDecoder() 69 | dec.IgnoreUnknownKeys(true) 70 | var form loginForm 71 | err := dec.Decode(&form, r.PostForm) 72 | if err != nil { 73 | http.Error(w, err.Error(), http.StatusBadRequest) 74 | return 75 | } 76 | w.Header().Set("Content-Type", "application/json") 77 | b, _ := json.Marshal(form) 78 | w.Write(b) 79 | }) 80 | http.ListenAndServe(":3000", nil) 81 | } 82 | 83 | type loginForm struct { 84 | Email string `form:"type=email;;label=Email Address;id=email;placeholder=bob@example.com"` 85 | Password string `form:"type=password;id=password;footer=Keep it secret!"` 86 | } 87 | -------------------------------------------------------------------------------- /examples/tailwind/tailwind.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joncalhoun/form/ef16fa783afb380438ff8b93d994aa7b1304125f/examples/tailwind/tailwind.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/joncalhoun/form 2 | 3 | go 1.18 4 | 5 | require github.com/gorilla/schema v1.4.1 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= 2 | github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= 3 | -------------------------------------------------------------------------------- /reflect.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "html/template" 5 | "reflect" 6 | "strings" 7 | ) 8 | 9 | // valueOf is basically just reflect.ValueOf, but if the Kind() of the 10 | // value is a pointer or interface it will try to get the reflect.Value 11 | // of the underlying element, and if the pointer is nil it will 12 | // create a new instance of the type and return the reflect.Value of it. 13 | // 14 | // This is used to make the rest of the fields function simpler. 15 | func valueOf(v interface{}) reflect.Value { 16 | rv := reflect.ValueOf(v) 17 | // If a nil pointer is passed in but has a type we can recover, but I 18 | // really should just panic and tell people to fix their shitty code. 19 | if rv.Type().Kind() == reflect.Ptr && rv.IsNil() { 20 | rv = reflect.New(rv.Type().Elem()).Elem() 21 | } 22 | // If we have a pointer or interface let's try to get the underlying 23 | // element 24 | for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface { 25 | rv = rv.Elem() 26 | } 27 | return rv 28 | } 29 | 30 | func fields(v interface{}, names ...string) []field { 31 | rv := valueOf(v) 32 | if rv.Kind() != reflect.Struct { 33 | // We can't really do much with a non-struct type. I suppose this 34 | // could eventually support maps as well, but for now it does not. 35 | panic("invalid value; only structs are supported") 36 | } 37 | 38 | t := rv.Type() 39 | ret := make([]field, 0, t.NumField()) 40 | for i := 0; i < t.NumField(); i++ { 41 | rf := rv.Field(i) 42 | // If this is a nil pointer, create a new instance of the element. 43 | // This could probably be done in a simpler way given that we 44 | // typically recur with this value, but this works so I'm letting it 45 | // be. 46 | if t.Field(i).Type.Kind() == reflect.Ptr && rf.IsNil() { 47 | rf = reflect.New(t.Field(i).Type.Elem()).Elem() 48 | } 49 | 50 | // If this is a struct it has nested fields we need to add. The 51 | // simplest way to do this is to recursively call `fields` but 52 | // to provide the name of this struct field to be added as a prefix 53 | // to the fields. 54 | if rf.Kind() == reflect.Struct { 55 | ret = append(ret, fields(rf.Interface(), append(names, t.Field(i).Name)...)...) 56 | continue 57 | } 58 | 59 | // If we are still in this loop then we aren't dealing with a nested 60 | // struct and need to add the field. First we check to see if the 61 | // ignore tag is present, then we set default values, then finally 62 | // we overwrite defaults with any provided tags. 63 | tags := parseTags(t.Field(i).Tag.Get("form")) 64 | if _, ok := tags["-"]; ok { 65 | continue 66 | } 67 | name := append(names, t.Field(i).Name) 68 | f := field{ 69 | Name: strings.Join(name, "."), 70 | Label: t.Field(i).Name, 71 | Placeholder: t.Field(i).Name, 72 | Type: "text", 73 | Value: rv.Field(i).Interface(), 74 | } 75 | applyTags(&f, tags) 76 | ret = append(ret, f) 77 | } 78 | return ret 79 | } 80 | 81 | func applyTags(f *field, tags map[string]string) { 82 | if v, ok := tags["name"]; ok { 83 | f.Name = v 84 | } 85 | if v, ok := tags["label"]; ok { 86 | f.Label = v 87 | // DO NOT move this label check after the placeholder check or 88 | // this will cause issues. 89 | f.Placeholder = v 90 | } 91 | if v, ok := tags["placeholder"]; ok { 92 | f.Placeholder = v 93 | } 94 | if v, ok := tags["type"]; ok { 95 | f.Type = v 96 | } 97 | if v, ok := tags["id"]; ok { 98 | f.ID = v 99 | } 100 | if v, ok := tags["footer"]; ok { 101 | // Probably shouldn't be HTML but whatever. 102 | f.Footer = template.HTML(v) 103 | } 104 | } 105 | 106 | func parseTags(tags string) map[string]string { 107 | tags = strings.TrimSpace(tags) 108 | if len(tags) == 0 { 109 | return map[string]string{} 110 | } 111 | split := strings.Split(tags, ";") 112 | ret := make(map[string]string, len(split)) 113 | for _, tag := range split { 114 | kv := strings.Split(tag, "=") 115 | if len(kv) < 2 { 116 | if kv[0] == "-" { 117 | return map[string]string{ 118 | "-": "this field is ignored", 119 | } 120 | } 121 | continue 122 | } 123 | k, v := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) 124 | ret[k] = v 125 | } 126 | return ret 127 | } 128 | 129 | type field struct { 130 | Name string 131 | Label string 132 | Placeholder string 133 | Type string 134 | ID string 135 | Value interface{} 136 | Footer template.HTML 137 | } 138 | -------------------------------------------------------------------------------- /reflect_test.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "html/template" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | // fields is where like 99% of the real work gets done, so most of the 10 | // testing effort should be focused here. It is also very easy to 11 | // test - just plug in values and verify that you get the expected 12 | // field slice back. 13 | func Test_fields(t *testing.T) { 14 | type address struct { 15 | Street1 string 16 | } 17 | var nilAddress *address 18 | type addressWithTags struct { 19 | Street1 string `form:"name=street"` 20 | } 21 | 22 | tests := []struct { 23 | name string 24 | arg interface{} 25 | want []field 26 | }{ 27 | { 28 | name: "simple and empty", 29 | arg: struct { 30 | Name string 31 | }{}, 32 | want: []field{ 33 | { 34 | Name: "Name", 35 | Label: "Name", 36 | Placeholder: "Name", 37 | Type: "text", 38 | Value: "", 39 | }, 40 | }, 41 | }, { 42 | name: "simple with value", 43 | arg: struct { 44 | Name string 45 | }{"Michael Scott"}, 46 | want: []field{ 47 | { 48 | Name: "Name", 49 | Label: "Name", 50 | Placeholder: "Name", 51 | Type: "text", 52 | Value: "Michael Scott", 53 | }, 54 | }, 55 | }, { 56 | name: "simple with ignored", 57 | arg: struct { 58 | Name string 59 | Ignored string `form:"-"` 60 | }{"", "secret info"}, 61 | want: []field{ 62 | { 63 | Name: "Name", 64 | Label: "Name", 65 | Placeholder: "Name", 66 | Type: "text", 67 | Value: "", 68 | }, 69 | }, 70 | }, { 71 | name: "pointer to struct w/ val", 72 | arg: &address{}, 73 | want: []field{ 74 | { 75 | Name: "Street1", 76 | Label: "Street1", 77 | Placeholder: "Street1", 78 | Type: "text", 79 | Value: "", 80 | }, 81 | }, 82 | }, { 83 | name: "nil pointer with type", 84 | arg: nilAddress, 85 | want: []field{ 86 | { 87 | Name: "Street1", 88 | Label: "Street1", 89 | Placeholder: "Street1", 90 | Type: "text", 91 | Value: "", 92 | }, 93 | }, 94 | }, { 95 | name: "nested simple", 96 | arg: struct { 97 | Name string 98 | Address struct { 99 | Street1 string 100 | } 101 | }{}, 102 | want: []field{ 103 | { 104 | Name: "Name", 105 | Label: "Name", 106 | Placeholder: "Name", 107 | Type: "text", 108 | Value: "", 109 | }, { 110 | Name: "Address.Street1", 111 | Label: "Street1", 112 | Placeholder: "Street1", 113 | Type: "text", 114 | Value: "", 115 | }, 116 | }, 117 | }, { 118 | name: "nested with values", 119 | arg: struct { 120 | Name string 121 | Address address 122 | }{ 123 | Name: "Michael Scott", 124 | Address: address{"123 Test St"}, 125 | }, 126 | want: []field{ 127 | { 128 | Name: "Name", 129 | Label: "Name", 130 | Placeholder: "Name", 131 | Type: "text", 132 | Value: "Michael Scott", 133 | }, { 134 | Name: "Address.Street1", 135 | Label: "Street1", 136 | Placeholder: "Street1", 137 | Type: "text", 138 | Value: "123 Test St", 139 | }, 140 | }, 141 | }, { 142 | name: "nested with tags", 143 | arg: struct { 144 | Name string `form:"label=Full Name;id=name"` 145 | Password string `form:"type=password;footer=Something super secret!"` 146 | Address addressWithTags 147 | }{ 148 | Name: "Michael Scott", 149 | Address: addressWithTags{"123 Test St"}, 150 | }, 151 | want: []field{ 152 | { 153 | Name: "Name", 154 | Label: "Full Name", 155 | Placeholder: "Full Name", 156 | Type: "text", 157 | Value: "Michael Scott", 158 | ID: "name", 159 | }, { 160 | Name: "Password", 161 | Label: "Password", 162 | Placeholder: "Password", 163 | Type: "password", 164 | Value: "", 165 | Footer: template.HTML("Something super secret!"), 166 | }, { 167 | Name: "street", 168 | Label: "Street1", 169 | Placeholder: "Street1", 170 | Type: "text", 171 | Value: "123 Test St", 172 | }, 173 | }, 174 | }, { 175 | name: "nested with nil ptr", 176 | arg: struct { 177 | Name string 178 | Address *address 179 | }{ 180 | Name: "Michael Scott", 181 | Address: nil, 182 | }, 183 | want: []field{ 184 | { 185 | Name: "Name", 186 | Label: "Name", 187 | Placeholder: "Name", 188 | Type: "text", 189 | Value: "Michael Scott", 190 | }, { 191 | Name: "Address.Street1", 192 | Label: "Street1", 193 | Placeholder: "Street1", 194 | Type: "text", 195 | Value: "", 196 | }, 197 | }, 198 | }, 199 | } 200 | for _, tc := range tests { 201 | t.Run(tc.name, func(t *testing.T) { 202 | got := fields(tc.arg) 203 | if !reflect.DeepEqual(got, tc.want) { 204 | t.Errorf("fields(%+v) = %+v, want %+v", tc.arg, got, tc.want) 205 | } 206 | }) 207 | } 208 | } 209 | --------------------------------------------------------------------------------