├── card-view.jpg ├── struc2frm.jpg ├── .gitignore ├── dev-server ├── static │ └── favicon.ico └── main.go ├── coverage.bat ├── go.mod ├── validator-interface.go ├── go.sum ├── pre-commit ├── .github └── workflows │ └── codecov.yml ├── tpl-main.html ├── .travis.yml ├── LICENSE ├── handler-card.go ├── handler-file-upload.go ├── form-token.go ├── funcs_test.go ├── csv.go ├── handler-file-upload_test.go ├── card_test.go ├── card.go ├── default.css ├── static.go ├── handler-form_test.go ├── handler-form.go ├── README.md └── struc2frm.go /card-view.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbberlin/struc2frm/HEAD/card-view.jpg -------------------------------------------------------------------------------- /struc2frm.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbberlin/struc2frm/HEAD/struc2frm.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | 5 | # Test files 6 | tmp* -------------------------------------------------------------------------------- /dev-server/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbberlin/struc2frm/HEAD/dev-server/static/favicon.ico -------------------------------------------------------------------------------- /coverage.bat: -------------------------------------------------------------------------------- 1 | REM go get golang.org/x/tools/cmd/cover 2 | go test -coverprofile tmp-coverage.out github.com/pbberlin/struc2frm 3 | go tool cover -html=tmp-coverage.out -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pbberlin/struc2frm 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/go-playground/form v3.1.4+incompatible 7 | github.com/pkg/errors v0.9.1 8 | gopkg.in/go-playground/assert.v1 v1.2.1 // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /validator-interface.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | // Validator interface is non mandatory helper interface for form structs; 4 | // it returns error messages suitable for s2f.AddErrors; 5 | // a valid form struct enables further processing; 6 | type Validator interface { 7 | Validate() (map[string]string, bool) 8 | } 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-playground/form v3.1.4+incompatible h1:lvKiHVxE2WvzDIoyMnWcjyiBxKt2+uFJyZcPYWsLnjI= 2 | github.com/go-playground/form v3.1.4+incompatible/go.mod h1:lhcKXfTuhRtIZCIKUeJ0b5F207aeQCPbZU09ScKjwWg= 3 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 4 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 5 | gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= 6 | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= 7 | -------------------------------------------------------------------------------- /pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # since appengine does not find static files 4 | # in packages, we create this backup upon each git commit 5 | 6 | echo "package struc2frm" > static.go 7 | echo '' >> static.go 8 | 9 | 10 | echo -n "const staticTplMainHTML = \`" >> static.go 11 | cat tpl-main.html >> static.go 12 | echo "\`" >> static.go 13 | echo '' >> static.go 14 | 15 | 16 | echo -n "const staticDefaultCSS = \`" >> static.go 17 | cat default.css >> static.go 18 | echo "\`" >> static.go 19 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: Test and coverage 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 2 17 | 18 | - uses: actions/setup-go@v2 19 | with: 20 | go-version: 1.17 21 | 22 | - name: Run coverage 23 | run: go test -race -coverprofile=coverage.out -covermode=atomic ./... 24 | 25 | - name: Upload coverage to Codecov 26 | run: bash <(curl -s https://codecov.io/bash) 27 | -------------------------------------------------------------------------------- /tpl-main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Department shuffler 7 | 24 | 25 | 26 | 27 | 28 | %v 29 | 30 |
31 |
32 | 33 | %v 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | # - 1.10.2 5 | - 1.13 6 | # - tip 7 | 8 | os: 9 | - linux 10 | # - osx 11 | 12 | services: 13 | # - mysql 14 | 15 | sudo: false 16 | 17 | addons: 18 | apt: 19 | packages: 20 | - wget 21 | 22 | env: 23 | matrix: 24 | # CODECOV_TOKEN only needed for private repos 25 | - DATASOURCE1=travis SQL_PW="" GO111MODULE=on CODECOV_TOKEN="5223b84b-6a98-46c1-882e-0bf0307eef2b" 26 | 27 | before_install: 28 | # codecov.io requirement: 29 | - go get -t -v ./... 30 | 31 | 32 | install: 33 | # - go get -t ./... 34 | 35 | before_script: 36 | # - mysql -e 'create database exceldb_test;' 37 | # - go vet ./... 38 | 39 | script: 40 | # - go test -v ./... 41 | # codecov.io replacement: 42 | - go test -race -coverprofile=coverage.txt -covermode=atomic 43 | 44 | notifications: 45 | email: false 46 | 47 | after_success: 48 | # codecov.io requirement: 49 | - bash <(curl -s https://codecov.io/bash) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 all 13 | 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 THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /handler-card.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // CardH is an example http handler func 11 | func CardH(w http.ResponseWriter, req *http.Request) { 12 | 13 | w.Header().Add("Content-Type", "text/html") 14 | 15 | s2f := New() 16 | s2f.ShowHeadline = true 17 | s2f.FocusFirstError = true 18 | s2f.SetOptions("department", []string{"ub", "fm"}, []string{"UB", "FM"}) 19 | s2f.SetOptions("items2", []string{"anton", "berta", "caesar", "dora"}, []string{"Anton", "Berta", "Caesar", "Dora"}) 20 | // s2f.Method = "GET" 21 | 22 | // init values - non-multiple 23 | frm := entryForm{ 24 | // HashKey: time.Now().Format("2006-01-02"), 25 | Department: "ub", 26 | Groups: 2, 27 | DateLayout: "[2006-01-02]", 28 | Date: time.Now().Format("2006-01-02"), 29 | Time: time.Now().Format("15:04"), 30 | CheckThis: true, 31 | } 32 | 33 | dept := s2f.DefaultOptionKey("department") 34 | frm.Items = strings.Join(itemGroups[dept], "\n") 35 | 36 | errs, _ := frm.Validate() 37 | s2f.AddErrors(errs) // add errors only for a populated form 38 | 39 | fmt.Fprintf( 40 | w, 41 | defaultHTML, 42 | s2f.Card(frm), 43 | "", 44 | ) 45 | 46 | } 47 | -------------------------------------------------------------------------------- /handler-file-upload.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | type uploadFormT struct { 10 | TextField string `json:"text_field,omitempty" form:"maxlength='16',size='16'"` 11 | // Requires distinct way of form parsing 12 | Upload []byte `json:"upload,omitempty" form:"accesskey='u',accept='.txt',suffix='*.txt files'"` 13 | } 14 | 15 | func (frm uploadFormT) Validate() (map[string]string, bool) { 16 | return nil, true 17 | } 18 | 19 | // FileUploadH is an http handler func for file upload 20 | func FileUploadH(w http.ResponseWriter, req *http.Request) { 21 | w.Header().Add("Content-Type", "text/html") 22 | 23 | s2f := New() 24 | s2f.ShowHeadline = true 25 | s2f.Indent = 80 26 | 27 | // init values 28 | frm := uploadFormT{ 29 | TextField: "some-init-text", 30 | } 31 | 32 | populated, err := DecodeMultipartForm(req, &frm) 33 | if populated && err != nil { 34 | s2f.AddError("global", fmt.Sprintf("cannot decode multipart form: %v
\n
%v
", err, indentedDump(req.Form))) 35 | log.Printf("cannot decode multipart form: %v
\n
%v
", err, indentedDump(req.Form)) 36 | } 37 | 38 | bts, excelFileName, err := ExtractUploadedFile(req) 39 | if err != nil { 40 | s2f.AddError("global", fmt.Sprintf("Cannot extract file from POST form: %v
\n", err)) 41 | } 42 | 43 | fileMsg := "" 44 | // if len(bts) > 0 && excelFileName != "" { 45 | if populated { 46 | fileMsg = fmt.Sprintf("%v bytes read from excel file -%v-
\n", len(bts), excelFileName) 47 | fileMsg = fmt.Sprintf("%vFile content is --%v--
\n", fileMsg, string(bts)) 48 | } else { 49 | fileMsg = "No upload filename - or empty file
\n" 50 | 51 | } 52 | 53 | fmt.Fprintf( 54 | w, 55 | defaultHTML, 56 | s2f.Form(frm), 57 | fileMsg, 58 | ) 59 | 60 | } 61 | -------------------------------------------------------------------------------- /dev-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "math/rand" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/pbberlin/struc2frm" 12 | ) 13 | 14 | func main() { 15 | 16 | rand.Seed(time.Now().UTC().UnixNano()) 17 | log.SetFlags(log.Lshortfile | log.Ldate | log.Ltime) 18 | log.SetFlags(log.Lshortfile | log.Ltime) 19 | 20 | pfx := "struc2frm" 21 | 22 | mux1 := http.NewServeMux() // base router 23 | 24 | mux1.HandleFunc("/", struc2frm.FormH) 25 | if pfx != "" { 26 | mux1.HandleFunc("/"+pfx, struc2frm.FormH) 27 | mux1.HandleFunc("/"+pfx+"/", struc2frm.FormH) 28 | } 29 | 30 | mux1.HandleFunc("/file-upload", struc2frm.FileUploadH) 31 | if pfx != "" { 32 | mux1.HandleFunc("/"+pfx+"/file-upload", struc2frm.FileUploadH) 33 | mux1.HandleFunc("/"+pfx+"/file-upload/", struc2frm.FileUploadH) 34 | } 35 | 36 | mux1.HandleFunc("/card", struc2frm.CardH) 37 | if pfx != "" { 38 | mux1.HandleFunc("/"+pfx+"/card", struc2frm.CardH) 39 | mux1.HandleFunc("/"+pfx+"/card/", struc2frm.CardH) 40 | } 41 | 42 | mux4 := http.NewServeMux() // top router for non-middlewared handlers 43 | mux4.Handle("/", mux1) 44 | 45 | serveIcon := func(w http.ResponseWriter, r *http.Request) { 46 | w.Header().Set("Content-Type", "image/x-icon") 47 | // w.Header().Set("Cache-Control", fmt.Sprintf("public,max-age=%d", 60*60*24)) 48 | fv := "favicon.ico" 49 | bts, _ := ioutil.ReadFile("./static/" + fv) 50 | fmt.Fprint(w, bts) 51 | // log.Printf("%v bytes written", len(bts)) 52 | } 53 | mux4.HandleFunc("favicon.ico", serveIcon) 54 | mux4.HandleFunc("/favicon.ico", serveIcon) 55 | if pfx != "" { 56 | mux1.HandleFunc("/"+pfx+"/favicon.ico", serveIcon) 57 | mux1.HandleFunc("/"+pfx+"/favicon.ico/", serveIcon) 58 | } 59 | 60 | IPPort := fmt.Sprintf("%v:%v", "localhost", "8085") 61 | log.Printf("starting http server at %v ... ", IPPort) 62 | log.Printf("==========================") 63 | log.Printf(" ") 64 | 65 | log.Fatal(http.ListenAndServe(IPPort, mux4)) 66 | 67 | } 68 | -------------------------------------------------------------------------------- /form-token.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | "io" 8 | "log" 9 | "time" 10 | ) 11 | 12 | // var tokenSaltNotWorking = GeneratePassword(22) // not interoperational between multiple instances of go-questionnaire, transferrer, generator 13 | 14 | // set timezone to a constant - this is important for client-server talks, e.g. appengine frankfurt runs on different zone 15 | var fixedLocation = time.FixedZone("UTC_-2", -2*60*60) 16 | 17 | // tok rounds time to hours 18 | // and computes a hash from it 19 | func tok(hoursOffset int, salt string) string { 20 | hasher := sha256.New() 21 | _, err := io.WriteString(hasher, salt) 22 | if err != nil { 23 | log.Printf("Error writing salt to hasher: %v", err) 24 | } 25 | t := time.Now().In(fixedLocation) 26 | if hoursOffset != 0 { 27 | t = t.Add(time.Duration(hoursOffset) * time.Hour) 28 | } 29 | // log.Printf("token time: %v", t.Format("02.01.2006 15")) 30 | _, err = io.WriteString(hasher, t.Format("02.01.2006 15")) 31 | if err != nil { 32 | log.Printf("Error writing date-hour to hasher: %v", err) 33 | } 34 | hash := hasher.Sum(nil) 35 | return hex.EncodeToString(hash) 36 | } 37 | 38 | // FormToken returns a form token. 39 | // User independent. 40 | // Should we add the user name into the hashed base? 41 | func (s2f *s2FT) FormToken() string { 42 | return tok(0, s2f.Salt) 43 | } 44 | 45 | // ValidateFormToken checks tokens 46 | // against current hour - back to n previous hours. 47 | // Plus one more for bounding glitches / border crossing 48 | // when the rounding jumps from 12:59 to 13:00. 49 | // i.e. 50 | // FormTimeout := 2 51 | // lower bound := -4 52 | // => Checking token against current hour, previous hour, second previous hour, third previous hour 53 | func (s2f *s2FT) ValidateFormToken(arg string) error { 54 | lowerBound := s2f.FormTimeout*-1 - 1 55 | for i := 0; i >= lowerBound; i-- { 56 | if arg == tok(i, s2f.Salt) { 57 | return nil 58 | } 59 | } 60 | if arg == tok(1, s2f.Salt) { 61 | return nil 62 | } 63 | return fmt.Errorf("form token not issued within last two hours - reload") 64 | } 65 | -------------------------------------------------------------------------------- /funcs_test.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | ) 7 | 8 | func init() { 9 | log.SetFlags(log.Lshortfile | log.Ltime) 10 | } 11 | 12 | func TestLabelize(t *testing.T) { 13 | 14 | tests := []struct { 15 | in string 16 | want string 17 | }{ 18 | { 19 | in: "bond_fund", 20 | want: "Bond fund", 21 | }, 22 | { 23 | in: "bondFund", 24 | want: "Bond fund", 25 | }, 26 | { 27 | in: "bondFUND", 28 | want: "Bond fund", 29 | }, 30 | { 31 | in: "BONDFund", 32 | want: "Bondfund", 33 | }, 34 | } 35 | for idx, tt := range tests { 36 | got := labelize(tt.in) 37 | if got != tt.want { 38 | t.Errorf("idx%2v: %-16v is %-16v should be %v", idx, tt.in, got, tt.want) 39 | } else { 40 | t.Logf("idx%2v: %-16v is %-16v indeed", idx, tt.in, got) 41 | } 42 | } 43 | } 44 | 45 | func TestContainsComma(t *testing.T) { 46 | 47 | tests := []struct { 48 | in string 49 | want bool 50 | }{ 51 | { 52 | in: `"form:"subtype='select'"`, 53 | want: false, 54 | }, 55 | { 56 | in: `"form:"subtype='select',accesskey='p',onchange='true',title='loading items'"`, 57 | want: false, 58 | }, 59 | { 60 | in: `"maxlength='16',size='16',suffix='salt, changes randomness'"`, 61 | want: false, 62 | }, 63 | { 64 | in: `"accesskey='t',maxlength='16',size='16',pattern='[0-9\\.\\-/]{2,10}',placeholder='2006/01/02 15:04'"`, 65 | want: false, 66 | }, 67 | { 68 | in: `"maxlength='16',size='16',suffix='salt, changes randomness'"`, 69 | want: false, 70 | }, 71 | { 72 | in: `"accesskey='t',maxlength='16',size='16',pattern='[0-9\\.\\-/]{2,10}',placeholder='2006/01/02 15:04'"`, 73 | want: true, 74 | }, 75 | { 76 | in: `"maxlength='16',size='16',suffix='salt, changes randomness'"`, 77 | want: true, 78 | }, 79 | } 80 | for idx, tt := range tests { 81 | got := commaInsideQuotes(tt.in) 82 | if got != tt.want { 83 | t.Errorf("idx%2v: %-16v is %-16v should be %v", idx, tt.in, got, tt.want) 84 | } else { 85 | t.Logf("idx%2v: %-16v is %-16v indeed", idx, tt.in, got) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /csv.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | ) 8 | 9 | // CSVLine renders intf into a line of CSV formatted data; no double quotes 10 | func (s2f *s2FT) CSVLine(intf interface{}, sep string) string { 11 | 12 | v := reflect.ValueOf(intf) // ifVal 13 | typeOfS := v.Type() 14 | // v = v.Elem() // dereference 15 | 16 | if v.Kind().String() != "struct" { 17 | return fmt.Sprintf("struct2form.CSVLine() - arg1 must be struct - is %v", v.Kind()) 18 | } 19 | 20 | values := make([]string, 0, v.NumField()) 21 | 22 | for i := 0; i < v.NumField(); i++ { 23 | 24 | // struct field name; i.e. Name, Birthdate 25 | fn := typeOfS.Field(i).Name 26 | if fn[0:1] != strings.ToUpper(fn[0:1]) { // only used to find unexported fields; otherwise json tag name is used 27 | continue // skip unexported 28 | } 29 | if strings.HasPrefix(fn, "Separator") { 30 | continue 31 | } 32 | 33 | val := v.Field(i).Interface() 34 | if valBool, ok := val.(bool); ok { 35 | values = append(values, fmt.Sprintf("%v", valBool)) 36 | } else { 37 | values = append(values, fmt.Sprintf("%v", val)) // covers string, float, int ... 38 | } 39 | // not implemented: replacing keys with values 40 | } 41 | 42 | w := &strings.Builder{} 43 | for idx := range values { 44 | fmt.Fprintf(w, "%v%v", values[idx], sep) 45 | } 46 | 47 | valid := true // default 48 | errs := map[string]string{} 49 | if vldr, ok := intf.(Validator); ok { // if validator interface is implemented... 50 | errs, valid = vldr.Validate() // ...check for validity 51 | } 52 | if !valid { 53 | fmt.Fprintf(w, "struct content is invalid, ") 54 | for fld, msg := range errs { 55 | fmt.Fprintf(w, "field '%v' has error '%v', ", fld, msg) 56 | } 57 | } 58 | 59 | fmt.Fprintf(w, "\n") 60 | return w.String() 61 | } 62 | 63 | // HeaderRow renders intf field names into a line of CSV formatted data 64 | func (s2f *s2FT) HeaderRow(intf interface{}, sep string) string { 65 | 66 | v := reflect.ValueOf(intf) // ifVal 67 | typeOfS := v.Type() 68 | // v = v.Elem() // dereference 69 | 70 | if v.Kind().String() != "struct" { 71 | return fmt.Sprintf("struct2form.CSVLine() - arg1 must be struct - is %v", v.Kind()) 72 | } 73 | 74 | headers := make([]string, 0, v.NumField()) 75 | 76 | for i := 0; i < v.NumField(); i++ { 77 | 78 | // struct field name; i.e. Name, Birthdate 79 | fn := typeOfS.Field(i).Name 80 | if fn[0:1] != strings.ToUpper(fn[0:1]) { // only used to find unexported fields; otherwise json tag name is used 81 | continue // skip unexported 82 | } 83 | if strings.HasPrefix(fn, "Separator") { 84 | continue 85 | } 86 | 87 | headers = append(headers, fn) 88 | } 89 | 90 | w := &strings.Builder{} 91 | 92 | for idx := range headers { 93 | fmt.Fprintf(w, "%v%v", headers[idx], sep) 94 | } 95 | 96 | fmt.Fprintf(w, "\n") 97 | return w.String() 98 | } 99 | -------------------------------------------------------------------------------- /handler-file-upload_test.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "mime/multipart" 9 | "net/http" 10 | "net/http/httptest" 11 | "os" 12 | "path" 13 | "strings" 14 | "testing" 15 | ) 16 | 17 | func TestFileUpload(t *testing.T) { 18 | 19 | fileName := "upload-file.txt" 20 | uploadBody := &bytes.Buffer{} 21 | 22 | if false { // normally we would provide the contents of a real file... 23 | filePath := path.Join(".", fileName) 24 | file, _ := os.Open(filePath) 25 | defer file.Close() 26 | } 27 | file := strings.NewReader("file content 123") // instead more easy 28 | 29 | mpWriter := multipart.NewWriter(uploadBody) 30 | part, err := mpWriter.CreateFormFile("upload", fileName) 31 | if err != nil { 32 | t.Fatalf("could not create form multi part: %v", err) 33 | } 34 | io.Copy(part, file) 35 | 36 | // adding normal fields... 37 | mpWriter.WriteField("text_field", "posted-text") 38 | mpWriter.WriteField("token", New().FormToken()) 39 | mpWriter.Close() 40 | 41 | req, err := http.NewRequest("POST", "/file-upload", uploadBody) // <-- encoded payload 42 | if err != nil { 43 | t.Fatalf("could not create request: %v", err) 44 | } 45 | /* 46 | Content-Type is *not* 47 | application/x-www-form-urlencoded 48 | but 49 | multipart/form-data; boundary=... 50 | */ 51 | req.Header.Add("Content-Type", mpWriter.FormDataContentType()) 52 | 53 | w := httptest.NewRecorder() // satisfying http.ResponseWriter for recording 54 | handler := http.HandlerFunc(FileUploadH) 55 | 56 | handler.ServeHTTP(w, req) 57 | 58 | if status := w.Code; status != http.StatusOK { 59 | t.Errorf("returned status code: got %v want %v", status, http.StatusOK) 60 | } 61 | 62 | // Check the response body 63 | expected1 := `
64 | 65 | 66 | 67 |
 
68 | 69 | 70 |
 
71 | 72 |
 
73 |
` 74 | 75 | expected1 = fmt.Sprintf(expected1, New().FormToken()) 76 | 77 | expected2 := `16 bytes read from excel file -upload-file.txt-
78 | File content is --file content 123--
` 79 | 80 | body := w.Body.String() 81 | 82 | if !strings.Contains(body, expected1) { 83 | t.Errorf("handler returned unexpected body") 84 | ioutil.WriteFile("tmp-test-fileupload1_want.html", []byte(expected1), 0777) 85 | ioutil.WriteFile("tmp-test-fileupload1_got.html", []byte(body), 0777) 86 | } 87 | if !strings.Contains(body, expected2) { 88 | t.Errorf("handler did not get the uploaded file name or data right") 89 | ioutil.WriteFile("tmp-test-fileupload2_want.html", []byte(expected2), 0777) 90 | ioutil.WriteFile("tmp-test-fileupload2_got.html", []byte(body), 0777) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /card_test.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | type userDataFormT struct { 11 | Gender string `json:"gender" form:"subtype=select,size='1'"` 12 | Decade string `json:"decade" form:"subtype=select,size='1',label='Decade of birth'"` 13 | Culture string `json:"culture" form:"subtype=select,size='1',label='Cultural background',suffix='as influence on taste'"` 14 | Ownership string `json:"ownership" form:"subtype=select,size='1',label='Home ownership',suffix=''"` 15 | Separator01 string `json:"separator01" form:"subtype='separator'"` 16 | Buying bool `json:"buying" form:"label='Home buying experience'"` 17 | Recent string `json:"recent" form:"subtype=select,size='1',label='Last purchase',suffix=''"` 18 | 19 | Status string `json:"status" form:"-"` // for server communication 20 | Msg string `json:"msg" form:"-"` // for server communication 21 | 22 | DontRender string `json:"dont_render" form:"-"` // test - not rendered, despite value 23 | 24 | } 25 | 26 | // Validate checks whether form entries as a whole are "submittable"; 27 | // implementation is optional 28 | func (frm userDataFormT) Validate() (map[string]string, bool) { 29 | g1 := frm.Gender != "" && frm.Decade != "" && frm.Culture != "" && frm.Ownership != "" 30 | g2 := frm.Buying && frm.Recent != "" || !frm.Buying 31 | return nil, g1 && g2 32 | } 33 | 34 | func TestCardView(t *testing.T) { 35 | 36 | ud := userDataFormT{ 37 | Gender: "m", 38 | Decade: "1960s", 39 | Culture: "european", 40 | Ownership: "renter", 41 | 42 | DontRender: "should not appear", 43 | } 44 | 45 | s2f := New() 46 | s2f.SuffixPos = 1 47 | 48 | s2f.SkipEmpty = true 49 | 50 | s2f.SetOptions("gender", []string{"", "f", "m", "o"}, 51 | []string{"Please choose", "female", "male", "third"}) 52 | s2f.SetOptions("decade", []string{"", "before1960", "1960s", "1970s", "1980s", "1990s", "2000s", "2010s-"}, 53 | []string{"Please choose", "before 1960", "1960-69", "1970-79", "1980-89", "1990-99", "2000-09", "2010-"}) 54 | s2f.SetOptions("culture", []string{"", "european", "near-east", "asian", "indian", "african"}, 55 | []string{"Please choose", "European", "Near East", "Asian", "Indian", "African"}) 56 | s2f.SetOptions("ownership", []string{"", "owner", "renter"}, 57 | []string{"Please choose", "Homeowner", "Renter"}) 58 | s2f.SetOptions("recent", []string{"", "2020s", "2010s", "2000s", "1990s", "1980s", "before1980"}, 59 | []string{"Please choose", "2020s", "2010s", "2000s", "1990s", "1980s", "before"}) 60 | 61 | got := s2f.Card(ud) 62 | 63 | // Check the response body 64 | want := `
65 | 85 |
86 | ` 87 | 88 | want = fmt.Sprintf( 89 | want, 90 | s2f.InstanceID, 91 | "male", // ud.Gender is "m" 92 | "1960-69", // ud.Decade is 1960s 93 | "European", // ud.Culture is "european" 94 | "Renter", // ud.Ownership is "renter" 95 | ) 96 | if !strings.Contains(string(got), want) { 97 | t.Errorf("got != want") 98 | ioutil.WriteFile("tmp-cardview_want.html", []byte(want), 0777) 99 | ioutil.WriteFile("tmp-cardview_got.html", []byte(got), 0777) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /card.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "reflect" 8 | "strings" 9 | ) 10 | 11 | // Card creates an HTML list view - instead of an HTML form; 12 | // TODO: render fieldsets 13 | func (s2f *s2FT) Card(intf interface{}) template.HTML { 14 | 15 | v := reflect.ValueOf(intf) // ifVal 16 | typeOfS := v.Type() 17 | // v = v.Elem() // dereference 18 | 19 | if v.Kind().String() != "struct" { 20 | return template.HTML(fmt.Sprintf("struct2form.Card() - arg1 must be struct - is %v", v.Kind())) 21 | } 22 | 23 | labels := make([]string, 0, v.NumField()) 24 | values := make([]string, 0, v.NumField()) 25 | sfxs := make([]string, 0, v.NumField()) 26 | statusMsg := "" 27 | 28 | for i := 0; i < v.NumField(); i++ { 29 | 30 | // struct field name; i.e. Name, Birthdate 31 | fn := typeOfS.Field(i).Name 32 | if fn[0:1] != strings.ToUpper(fn[0:1]) { // only used to find unexported fields; otherwise json tag name is used 33 | continue // skip unexported 34 | } 35 | 36 | inpName := typeOfS.Field(i).Tag.Get("json") // i.e. date_layout 37 | inpName = strings.Replace(inpName, ",omitempty", "", -1) 38 | inpLabel := labelize(inpName) 39 | 40 | attrs := typeOfS.Field(i).Tag.Get("form") // i.e. form:"maxlength='42',size='28',suffix='optional'" 41 | 42 | if structTag(attrs, "label") != "" { 43 | inpLabel = structTag(attrs, "label") 44 | } 45 | 46 | if strings.Contains(attrs, ", ") || strings.Contains(attrs, ", ") { 47 | return template.HTML(fmt.Sprintf("struct2form.Card() - field %v: tag 'form' cannot contain ', ' or ' ,' ", inpName)) 48 | } 49 | 50 | if commaInsideQuotes(attrs) { 51 | return template.HTML(fmt.Sprintf("struct2form.Card() - field %v: tag 'form' - use , instead of ',' inside of single quotes values", inpName)) 52 | } 53 | 54 | if attrs == "-" { 55 | continue 56 | } 57 | 58 | if fn == "Status" || fn == "Msg" { 59 | val := v.Field(i).Interface() 60 | if valStr, ok := val.(string); ok { 61 | if statusMsg != "" { 62 | statusMsg += " - " 63 | } 64 | statusMsg += valStr 65 | } 66 | continue 67 | } 68 | 69 | val := v.Field(i).Interface() 70 | 71 | if fmt.Sprint(val) == "" && s2f.SkipEmpty { 72 | if !strings.HasPrefix(fn, "Separator") { // separators should be rendered, though they have no value 73 | continue 74 | } 75 | } 76 | 77 | labels = append(labels, inpLabel) 78 | if valBool, ok := val.(bool); ok { 79 | values = append(values, fmt.Sprintf("%v", valBool)) 80 | } else { 81 | values = append(values, fmt.Sprintf("%v", val)) // covers string, float, int ... 82 | } 83 | 84 | // Replace keys with values 85 | idx := len(values) - 1 86 | if values[idx] != "" { 87 | if opts, ok := s2f.selectOptions[inpName]; ok { 88 | for _, opt := range opts { 89 | // log.Printf("For %12v: Comparing %5v to %5v %5v", inpName, values[idx], opt.Key, opt.Val) 90 | if values[idx] == opt.Key && opt.Val != "" { 91 | values[idx] = opt.Val 92 | } 93 | } 94 | } 95 | } 96 | 97 | sfx := structTag(attrs, "suffix") 98 | sfxs = append(sfxs, sfx) 99 | 100 | } 101 | 102 | w := &bytes.Buffer{} 103 | 104 | s2f.RenderCSS(w) 105 | 106 | // one class selector for general - one for specific instance 107 | fmt.Fprintf(w, "
\n", s2f.InstanceID) 108 | 109 | if s2f.ShowHeadline { 110 | fmt.Fprintf(w, "

%v

\n", labelize(typeOfS.Name())) 111 | } 112 | 113 | fmt.Fprintf(w, "
    \n") 114 | 115 | // fieldsetOpen := false 116 | 117 | valid := true // default 118 | errs := map[string]string{} 119 | if vldr, ok := intf.(Validator); ok { // if validator interface is implemented... 120 | errs, valid = vldr.Validate() // ...check for validity 121 | } 122 | if valid { 123 | for idx, label := range labels { 124 | if strings.HasPrefix(label, "Separator") { 125 | fmt.Fprint(w, "\t
    \n") 126 | continue 127 | } 128 | fmt.Fprintf(w, "\t
  • \n") 129 | 130 | if s2f.SuffixPos == 1 && sfxs[idx] != "" { 131 | fmt.Fprintf(w, `
    %v: 132 |
    133 |
    `, label, sfxs[idx]) 134 | } else { 135 | fmt.Fprintf(w, `
    %v:
    `, label) 136 | } 137 | 138 | fmt.Fprintf(w, " %v \n", values[idx]) 139 | 140 | if s2f.SuffixPos == 2 { 141 | if sfxs[idx] != "" { 142 | fmt.Fprintf(w, "", sfxs[idx]) 143 | } 144 | } 145 | 146 | fmt.Fprintf(w, "\t
  • \n") 147 | } 148 | } else { 149 | fmt.Fprintf(w, "\t
  • \n") 150 | fmt.Fprintf(w, "\t Struct content is invalid: %v\n", statusMsg) 151 | for fld, msg := range errs { 152 | fmt.Fprintf(w, "\t Field: %v - %v\n", fld, msg) 153 | } 154 | fmt.Fprintf(w, "\t
  • \n") 155 | } 156 | 157 | fmt.Fprintf(w, "
\n") 158 | 159 | fmt.Fprint(w, "
\n") 160 | 161 | // global replacements 162 | ret := strings.ReplaceAll(w.String(), ",", ",") 163 | 164 | return template.HTML(ret) 165 | } 166 | -------------------------------------------------------------------------------- /default.css: -------------------------------------------------------------------------------- 1 | 2 | div.struc2frm { 3 | padding: 4px; 4 | margin: 4px; 5 | border: 1px solid #aaa; 6 | border-radius: 6px; 7 | } 8 | 9 | div.struc2frm h3 { 10 | padding: 4px; 11 | margin: 4px; 12 | } 13 | 14 | div.struc2frm input, 15 | div.struc2frm textarea, 16 | div.struc2frm select, 17 | div.struc2frm button, 18 | div.struc2frm label { 19 | padding: 4px; 20 | margin: 4px; 21 | } 22 | /* screen width dependent min-width below */ 23 | div.struc2frm label { 24 | display: inline-block; 25 | vertical-align: middle; 26 | margin-top: 1px; 27 | text-align: right; 28 | } 29 | div.struc2frm .radio-group label { 30 | min-width: unset; 31 | } 32 | 33 | div.struc2frm span.postlabel { 34 | display: inline-block; 35 | vertical-align: middle; 36 | font-size: 90%; 37 | position: relative; 38 | top: -0.15em; 39 | margin-left: 4px; 40 | line-height: 90%; 41 | /* max-width: 40px; */ 42 | } 43 | 44 | div.struc2frm div.separator { 45 | height: 1px; 46 | border-top: 1px solid #aaa; 47 | 48 | padding: 0; 49 | margin: 0; 50 | margin-top: 4px; 51 | margin-bottom: 4px; 52 | } 53 | 54 | div.struc2frm fieldset { 55 | border: 1px solid #aaa; 56 | padding: 4px; 57 | margin: 14px 4px; 58 | border-radius: 8px; 59 | 60 | } 61 | div.struc2frm legend { 62 | font-size: 90%; 63 | margin-left: 8px; 64 | 65 | color: #444; 66 | border: 1px solid #aaa; 67 | padding: 0px 8px; 68 | border-radius: 5px; 69 | } 70 | 71 | div.struc2frm button[type=submit], 72 | div.struc2frm input[type=submit] 73 | { 74 | margin-left: 186px; /*some default*/ 75 | width: 280px; 76 | height: 40px; 77 | padding: 4px 16px; 78 | margin-top: 12px; 79 | margin-bottom: 8px; 80 | border-radius: 6px; 81 | } 82 | 83 | div.struc2frm input[type="radio"] { 84 | margin-right: 2rem; 85 | } 86 | 87 | 88 | /* Hiding spinners for integers/floats */ 89 | /* stackoverflow.com/questions/3790935/ */ 90 | input[type="number"]::-webkit-outer-spin-button, 91 | input[type="number"]::-webkit-inner-spin-button { 92 | -webkit-appearance: none; 93 | margin: 0; 94 | } 95 | input[type="number"] { 96 | -moz-appearance: textfield; 97 | } 98 | 99 | /* 100 | artificial arrow icon for select/dropdown 101 | by wrapping a div around each select ; 102 | looks more stylish; 103 | the arrow is creatd by the ::after pseudo tag; 104 | requires the dropdown button itself to be suppressed; 105 | 106 | even when the pseudo class is disabled, this style must be set; 107 | 108 | also created by struc2frm 109 | */ 110 | .select-arrow { 111 | position: relative; 112 | display: inline-block; 113 | margin: 0; 114 | padding: 0; 115 | } 116 | 117 | .DISABLED-select-arrow::after { 118 | 119 | /* display: inline-block; */ 120 | position: absolute; 121 | right: 1.5rem; 122 | top: 1.3rem; 123 | /* z-index: 3; */ 124 | 125 | pointer-events: none; 126 | 127 | content: " "; 128 | width: 0.6em; 129 | height: 0.6em; 130 | 131 | transform-origin: center; 132 | transform: rotate(-45deg); 133 | /* background-color: blueviolet; */ 134 | background: transparent; 135 | color: var(--clr-pri); 136 | border-left: 2px solid var(--clr-pri); 137 | border-bottom: 2px solid var(--clr-pri); 138 | } 139 | 140 | 141 | div.struc2frm .card-label { 142 | display: inline-block; 143 | vertical-align: top; 144 | 145 | width: 40%; 146 | } 147 | 148 | 149 | /* if s2f.Indent == 0 - set values by CSS */ 150 | /* ========================================== */ 151 | 152 | /* Smartphones (portrait and landscape) */ 153 | @media screen and (max-width: 1023px){ 154 | 155 | div.struc2frm label { 156 | min-width: 90px; 157 | } 158 | div.struc2frm h3 { 159 | margin-left: 106px; 160 | } 161 | div.struc2frm button[type=submit], 162 | div.struc2frm input[type=submit] 163 | { 164 | margin-left: 106px; 165 | } 166 | 167 | } 168 | 169 | 170 | 171 | /* Desktops and laptops */ 172 | @media screen and (min-width: 1024px){ 173 | 174 | div.struc2frm label { 175 | min-width: 120px; 176 | } 177 | div.struc2frm h3 { 178 | margin-left: 136px; 179 | } 180 | div.struc2frm button[type=submit], 181 | div.struc2frm input[type=submit] 182 | { 183 | margin-left: 136px; 184 | } 185 | 186 | } 187 | 188 | /* Large screens */ 189 | @media screen and (min-width: 1824px){ 190 | 191 | div.struc2frm label { 192 | min-width: 150px; 193 | } 194 | div.struc2frm h3 { 195 | margin-left: 166px; 196 | } 197 | div.struc2frm button[type=submit], 198 | div.struc2frm input[type=submit] 199 | { 200 | margin-left: 166px; 201 | } 202 | 203 | } 204 | 205 | 206 | /* change specific inputs */ 207 | div.struc2frm label[for="time"] { 208 | min-width: 20px; 209 | } 210 | div.struc2frm select[name="department"] { 211 | background-color: darkkhaki; 212 | } 213 | 214 | .error-block { 215 | margin: 0.2rem; 216 | margin-top: 0.4rem; 217 | margin-left: 1.6rem; 218 | font-size: 120%; 219 | color: var(--clr-err, #d22); 220 | } 221 | 222 | div.wildcardselect { 223 | display: inline-block; 224 | margin-top: 0.2rem; 225 | vertical-align: top; 226 | } 227 | div.wildcardselect input { 228 | font-size: 80%; 229 | padding: 1px; 230 | margin: 2px; 231 | width: 2.2rem; 232 | } -------------------------------------------------------------------------------- /static.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | const staticTplMainHTML = ` 4 | 5 | 6 | 7 | 8 | Department shuffler 9 | 26 | 27 | 28 | 29 | 30 | %v 31 | 32 |
33 |
34 | 35 | %v 36 | 37 | 38 | 39 | ` 40 | 41 | const staticDefaultCSS = ` 42 | div.struc2frm { 43 | padding: 4px; 44 | margin: 4px; 45 | border: 1px solid #aaa; 46 | border-radius: 6px; 47 | } 48 | 49 | div.struc2frm h3 { 50 | padding: 4px; 51 | margin: 4px; 52 | } 53 | 54 | div.struc2frm input, 55 | div.struc2frm textarea, 56 | div.struc2frm select, 57 | div.struc2frm button, 58 | div.struc2frm label { 59 | padding: 4px; 60 | margin: 4px; 61 | } 62 | /* screen width dependent min-width below */ 63 | div.struc2frm label { 64 | display: inline-block; 65 | vertical-align: middle; 66 | margin-top: 1px; 67 | text-align: right; 68 | } 69 | div.struc2frm .radio-group label { 70 | min-width: unset; 71 | } 72 | 73 | div.struc2frm span.postlabel { 74 | display: inline-block; 75 | vertical-align: middle; 76 | font-size: 90%; 77 | position: relative; 78 | top: -0.15em; 79 | margin-left: 4px; 80 | line-height: 90%; 81 | /* max-width: 40px; */ 82 | } 83 | 84 | div.struc2frm div.separator { 85 | height: 1px; 86 | border-top: 1px solid #aaa; 87 | 88 | padding: 0; 89 | margin: 0; 90 | margin-top: 4px; 91 | margin-bottom: 4px; 92 | } 93 | 94 | div.struc2frm fieldset { 95 | border: 1px solid #aaa; 96 | padding: 4px; 97 | margin: 14px 4px; 98 | border-radius: 8px; 99 | 100 | } 101 | div.struc2frm legend { 102 | font-size: 90%; 103 | margin-left: 8px; 104 | 105 | color: #444; 106 | border: 1px solid #aaa; 107 | padding: 0px 8px; 108 | border-radius: 5px; 109 | } 110 | 111 | div.struc2frm button[type=submit], 112 | div.struc2frm input[type=submit] 113 | { 114 | margin-left: 186px; /*some default*/ 115 | width: 280px; 116 | height: 40px; 117 | padding: 4px 16px; 118 | margin-top: 12px; 119 | margin-bottom: 8px; 120 | border-radius: 6px; 121 | } 122 | 123 | div.struc2frm input[type="radio"] { 124 | margin-right: 2rem; 125 | } 126 | 127 | 128 | /* Hiding spinners for integers/floats */ 129 | /* stackoverflow.com/questions/3790935/ */ 130 | input[type="number"]::-webkit-outer-spin-button, 131 | input[type="number"]::-webkit-inner-spin-button { 132 | -webkit-appearance: none; 133 | margin: 0; 134 | } 135 | input[type="number"] { 136 | -moz-appearance: textfield; 137 | } 138 | 139 | /* 140 | artificial arrow icon for select/dropdown 141 | by wrapping a div around each select ; 142 | looks more stylish; 143 | the arrow is creatd by the ::after pseudo tag; 144 | requires the dropdown button itself to be suppressed; 145 | 146 | even when the pseudo class is disabled, this style must be set; 147 | 148 | also created by struc2frm 149 | */ 150 | .select-arrow { 151 | position: relative; 152 | display: inline-block; 153 | margin: 0; 154 | padding: 0; 155 | } 156 | 157 | .DISABLED-select-arrow::after { 158 | 159 | /* display: inline-block; */ 160 | position: absolute; 161 | right: 1.5rem; 162 | top: 1.3rem; 163 | /* z-index: 3; */ 164 | 165 | pointer-events: none; 166 | 167 | content: " "; 168 | width: 0.6em; 169 | height: 0.6em; 170 | 171 | transform-origin: center; 172 | transform: rotate(-45deg); 173 | /* background-color: blueviolet; */ 174 | background: transparent; 175 | color: var(--clr-pri); 176 | border-left: 2px solid var(--clr-pri); 177 | border-bottom: 2px solid var(--clr-pri); 178 | } 179 | 180 | 181 | div.struc2frm .card-label { 182 | display: inline-block; 183 | vertical-align: top; 184 | 185 | width: 40%; 186 | } 187 | 188 | 189 | /* if s2f.Indent == 0 - set values by CSS */ 190 | /* ========================================== */ 191 | 192 | /* Smartphones (portrait and landscape) */ 193 | @media screen and (max-width: 1023px){ 194 | 195 | div.struc2frm label { 196 | min-width: 90px; 197 | } 198 | div.struc2frm h3 { 199 | margin-left: 106px; 200 | } 201 | div.struc2frm button[type=submit], 202 | div.struc2frm input[type=submit] 203 | { 204 | margin-left: 106px; 205 | } 206 | 207 | } 208 | 209 | 210 | 211 | /* Desktops and laptops */ 212 | @media screen and (min-width: 1024px){ 213 | 214 | div.struc2frm label { 215 | min-width: 120px; 216 | } 217 | div.struc2frm h3 { 218 | margin-left: 136px; 219 | } 220 | div.struc2frm button[type=submit], 221 | div.struc2frm input[type=submit] 222 | { 223 | margin-left: 136px; 224 | } 225 | 226 | } 227 | 228 | /* Large screens */ 229 | @media screen and (min-width: 1824px){ 230 | 231 | div.struc2frm label { 232 | min-width: 150px; 233 | } 234 | div.struc2frm h3 { 235 | margin-left: 166px; 236 | } 237 | div.struc2frm button[type=submit], 238 | div.struc2frm input[type=submit] 239 | { 240 | margin-left: 166px; 241 | } 242 | 243 | } 244 | 245 | 246 | /* change specific inputs */ 247 | div.struc2frm label[for="time"] { 248 | min-width: 20px; 249 | } 250 | div.struc2frm select[name="department"] { 251 | background-color: darkkhaki; 252 | } 253 | 254 | .error-block { 255 | margin: 0.2rem; 256 | margin-top: 0.4rem; 257 | margin-left: 1.6rem; 258 | font-size: 120%; 259 | color: var(--clr-err, #d22); 260 | } 261 | 262 | div.wildcardselect { 263 | display: inline-block; 264 | margin-top: 0.2rem; 265 | vertical-align: top; 266 | } 267 | div.wildcardselect input { 268 | font-size: 80%; 269 | padding: 1px; 270 | margin: 2px; 271 | width: 2.2rem; 272 | }` 273 | -------------------------------------------------------------------------------- /handler-form_test.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "math/rand" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "strings" 12 | "testing" 13 | "time" 14 | ) 15 | 16 | func TestMainGetH(t *testing.T) { 17 | test(t, "GET") 18 | } 19 | 20 | func TestMainPostH(t *testing.T) { 21 | test(t, "POST") 22 | } 23 | 24 | func test(t *testing.T, method string) { 25 | 26 | token := New().FormToken() 27 | numGroups := rand.Intn(1000) + 1 28 | pth := "/" 29 | var postBody io.Reader 30 | 31 | if method == "GET" { 32 | pth = fmt.Sprintf("/?groups=%v&token=%v&items2=anton&items2=caesar&fruit=peach", 33 | numGroups, token, 34 | ) 35 | t.Logf("testing GET request with \nnumGroups = %v ", numGroups) 36 | } 37 | if method == "POST" { 38 | data := url.Values{} 39 | data.Set("groups", fmt.Sprintf("%v", numGroups)) 40 | data.Set("items2", "anton") 41 | data.Add("items2", "caesar") 42 | data.Set("token", token) 43 | data.Set("fruit", "peach") 44 | postBody = strings.NewReader(data.Encode()) 45 | t.Logf( 46 | "testing POST request with \nnumGroups = %v ; %v ", 47 | numGroups, data.Encode(), 48 | ) 49 | } 50 | 51 | req, err := http.NewRequest(method, pth, postBody) // <-- encoded payload 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | 56 | if method == "POST" { 57 | // browser default encoding for post is "application/x-www-form-urlencoded" 58 | // programmatically we must set it programmatically 59 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 60 | } 61 | 62 | w := httptest.NewRecorder() // satisfying http.ResponseWriter for recording 63 | handler := http.HandlerFunc(FormH) 64 | 65 | handler.ServeHTTP(w, req) 66 | 67 | if status := w.Code; status != http.StatusOK { 68 | t.Errorf("returned status code: got %v want %v", status, http.StatusOK) 69 | } else { 70 | t.Logf("Response code OK") 71 | } 72 | 73 | // Check the response body 74 | expected := `

Entry form

75 |
76 | 77 |

Missing department

78 | 79 |
80 | 84 |
85 |
 
86 |
87 | 88 | 89 |
 
90 | 91 | 92 |
 
93 | 94 | 100 |
 
101 | 102 |
103 | 109 |
110 |
 
111 |
 Group 01  112 | 113 | 114 | 115 | 116 |
 
117 |
118 |
 Group 02  119 | 120 | 121 |
 
122 |

You need to comply

123 | 124 | 125 | 126 |
 
127 | 128 |
129 |
130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 |
138 |
 
139 |
140 | 141 |
 
142 |
143 | ` 144 | 145 | expected = fmt.Sprintf( 146 | expected, 147 | New().FormToken(), 148 | time.Now().Format("2006-01-02"), 149 | numGroups, 150 | time.Now().Format("2006-01-02"), 151 | time.Now().Format("15:04"), 152 | ) 153 | 154 | body := w.Body.String() 155 | 156 | if !strings.Contains(body, expected) { 157 | // t.Errorf("handler returned unexpected body: got %v want %v", w.Body.String(), expected) 158 | t.Errorf("handler returned unexpected body") 159 | ioutil.WriteFile(fmt.Sprintf("tmp-test_%v_want.html", method), []byte(expected), 0777) 160 | ioutil.WriteFile(fmt.Sprintf("tmp-test_%v_got.html", method), []byte(body), 0777) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /handler-form.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | import ( 4 | "crypto" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "sort" 9 | "strconv" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | var itemGroups = map[string][]string{ 15 | "ub": { 16 | "Brutsyum, Zusoh", 17 | "Dovosuke, Udsyuke", 18 | "Fyrkros, Loekyo", 19 | "Gyaffsydu, Loekusde", 20 | "Heyos, Ysyr", 21 | "Rtoynbsonnos, Tars", 22 | }, 23 | "fm": { 24 | "Bsackbuaos, Punk", 25 | "Bachos-Keonon, Tasd", 26 | "Hiroso, Meivynu", 27 | "Rachydt, Racho", 28 | "Ruchsiedos, Misea", 29 | }, 30 | } 31 | 32 | type entryForm struct { 33 | Department string `json:"department,omitempty" form:"subtype='select',accesskey='p',onchange='true',label='Department/Abteilung',title='loading items'"` 34 | Separator01 string `json:"separator01,omitempty" form:"subtype='separator'"` 35 | HashKey string `json:"hashkey,omitempty" form:"maxlength='16',size='16',autocapitalize='off',suffix='salt, changes randomness'"` // the , instead of , prevents wrong parsing 36 | Groups int `json:"groups,omitempty" form:"min=1,max='100',maxlength='3',size='3'"` 37 | Items string `json:"items,omitempty" form:"subtype='textarea',cols='22',rows='4',maxlength='4000',label='Textarea of
line items',title='add times - delimited by newline (enter)'"` 38 | Items2 []string `json:"items2,omitempty" form:"subtype='select',size='3',multiple='true',label='Multi
select
dropdown',autofocus='true'"` 39 | Group01 string `json:"group01,omitempty" form:"subtype='fieldset'"` 40 | Date string `json:"date,omitempty" form:"subtype='date',nobreak=true,min='1989-10-29',max='2030-10-29'"` 41 | Time string `json:"time,omitempty" form:"subtype='time',maxlength='12',inputmode='numeric',size='12'"` 42 | Group02 string `json:"group02,omitempty" form:"subtype='fieldset'"` 43 | // stackoverflow.com/questions/399078 - inside character classes escape ^-]\ 44 | DateLayout string `json:"date_layout,omitempty" form:"accesskey='t',maxlength='16',size='16',pattern='[0-9\\.\\-/]{2,10}',placeholder='2006/01/02 15:04',label='Layout of the date'"` // 2006-01-02 15:04 45 | CheckThis bool `json:"check_this,omitempty" form:"suffix='without consequence'"` 46 | Fruit string `json:"fruit,omitempty" form:"subtype='radiogroup',suffix='like dropdown'"` 47 | 48 | // Requires distinct way of form parsing 49 | // Upload []byte `json:"upload,omitempty" form:"accesskey='u',accept='.xlsx'"` 50 | 51 | // Email would be 52 | // Email string `json:"email" form:"maxlength='42',size='28',pattern='[a-zA-Z0-9\\.\\-_%+]+@[a-zA-Z0-9\\.\\-]+\\.[a-zA-Z]{2,18}'"` 53 | } 54 | 55 | // Validate checks whether form entries as a whole are "submittable"; 56 | // more than just 'populated'; 57 | // Validate generates error messages 58 | func (frm entryForm) Validate() (map[string]string, bool) { 59 | errs := map[string]string{} 60 | g1 := frm.Department != "" 61 | if !g1 { 62 | errs["department"] = "Missing department" 63 | } 64 | g2 := frm.CheckThis 65 | if !g2 { 66 | errs["check_this"] = "You need to comply" 67 | } 68 | g3 := frm.Items != "" 69 | if !g3 { 70 | errs["items"] = "No items" 71 | } 72 | return errs, g1 && g2 && g3 73 | } 74 | 75 | // FormH is an example http handler func 76 | func FormH(w http.ResponseWriter, req *http.Request) { 77 | 78 | w.Header().Add("Content-Type", "text/html") 79 | 80 | s2f := New() 81 | s2f.ShowHeadline = true 82 | s2f.FocusFirstError = true 83 | s2f.SetOptions("department", []string{"ub", "fm"}, []string{"UB", "FM"}) 84 | s2f.SetOptions("items2", []string{"anton", "berta", "caesar", "dora"}, []string{"Anton", "Berta", "Caesar", "Dora"}) 85 | s2f.SetOptions("fruit", []string{"pear", "plum", "peach", "noanswer"}, []string{"Pear", "Plum", "Peach", ""}) 86 | // s2f.Method = "GET" 87 | 88 | // init values - non-multiple 89 | frm := entryForm{ 90 | HashKey: time.Now().Format("2006-01-02"), 91 | Groups: 2, 92 | Date: time.Now().Format("2006-01-02"), 93 | Time: time.Now().Format("15:04"), 94 | } 95 | 96 | // pulling in values from http request 97 | populated, err := Decode(req, &frm) 98 | if populated && err != nil { 99 | s2f.AddError("global", fmt.Sprintf("cannot decode form: %v
\n
%v
", err, indentedDump(req.Form))) 100 | log.Printf("cannot decode form: %v
\n
%v
", err, indentedDump(req.Form)) 101 | } 102 | 103 | // init values - multiple 104 | if !populated { 105 | if len(frm.Items2) == 0 { 106 | frm.Items2 = []string{"berta", "dora"} 107 | } 108 | } 109 | 110 | if req.Form.Get("debug") != "" { 111 | fmt.Fprintf(w, "
%v
", indentedDump(req.Form)) 112 | fmt.Fprintf(w, "
%v
", indentedDump(frm)) 113 | } 114 | 115 | dept := req.FormValue("department") 116 | if dept == "" { 117 | dept = s2f.DefaultOptionKey("department") 118 | } 119 | frm.Items = strings.Join(itemGroups[dept], "\n") 120 | 121 | errs, valid := frm.Validate() 122 | 123 | // 124 | // business logic: reshuffling... 125 | bins := [][]string{} 126 | binsF := "" // formatted as html 127 | 128 | if populated { 129 | 130 | if !valid { 131 | s2f.AddErrors(errs) // add errors only for a populated form 132 | } else { 133 | // further processing 134 | // see below 135 | } 136 | 137 | salt1 := req.FormValue("hashkey") 138 | salt2 := "dudoedeldu" 139 | 140 | num, _ := strconv.Atoi(req.FormValue("groups")) 141 | items := strings.Split(req.FormValue("items"), "\n") 142 | for i := 0; i < len(items); i++ { 143 | items[i] = strings.TrimSpace(items[i]) 144 | } 145 | 146 | itemMp := map[string]string{} 147 | keys := []string{} 148 | 149 | hasher := crypto.MD5.New() 150 | for _, item := range items { 151 | hasher.Write([]byte(item + salt1 + salt2)) 152 | key := string(hasher.Sum(nil)) 153 | itemMp[key] = item 154 | keys = append(keys, key) 155 | } 156 | sort.Strings(keys) 157 | items = make([]string, 0, len(items)) 158 | for _, key := range keys { 159 | items = append(items, itemMp[key]) 160 | } 161 | 162 | bins = make([][]string, num) 163 | 164 | for itemCounter, item := range items { 165 | binID := itemCounter % num 166 | bins[binID] = append(bins[binID], item) 167 | } 168 | 169 | for i := 0; i < len(bins); i++ { 170 | binsF += "
\n" 171 | binsF += fmt.Sprintf("\tGroup %v
\n", i+1) 172 | for j := 0; j < len(bins[i]); j++ { 173 | binsF += "\t" + bins[i][j] + "
\n" 174 | } 175 | binsF += "
\n\n" 176 | } 177 | 178 | } 179 | 180 | // render to HTML for user input / error correction 181 | if !valid { 182 | // fmt.Fprint(w, s2f.Form(frm)) 183 | } 184 | 185 | fmt.Fprintf( 186 | w, 187 | defaultHTML, 188 | s2f.Form(frm), 189 | binsF, 190 | ) 191 | 192 | } 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # struc2frm 2 | 3 | ![./struc2frm.jpg](./struc2frm.jpg) 4 | 5 | ## Golang Struct to HTML Form 6 | 7 | [![GoDoc](http://godoc.org/github.com/pbberlin/struc2frm?status.svg)](https://godoc.org/github.com/pbberlin/struc2frm) 8 | [![Travis Build](https://travis-ci.com/pbberlin/struc2frm.svg?branch=master)](https://travis-ci.com/pbberlin/struc2frm) 9 | [![codecov](https://codecov.io/gh/pbberlin/struc2frm/branch/master/graph/badge.svg)](https://codecov.io/gh/pbberlin/struc2frm) 10 | [![codecv2](https://github.com/pbberlin/struc2frm/actions/workflows/codecov.yml/badge.svg)](https://github.com/pbberlin/struc2frm/actions/workflows/codecov.yml) 11 | 12 | * Package struc2frm converts a golang `struct type` into an `HTML input form`. 13 | 14 | * All your backend forms generated directly from golang structs. 15 | 16 | * HTML input field info is taken from the `form` struct tag. 17 | 18 | * Decode() and DecodeMultipartForm() transform the HTTP request data 19 | back into an instance of the `struct type` used for the HTML code. 20 | 21 | * Decode() and DecodeMultipartForm() also check the 22 | auto-generated form token against [CSRF attacks](https://en.wikipedia.org/wiki/Cross-site_request_forgery). 23 | 24 | * Use `Form()` to render an HTML form 25 | 26 | * Use `Card()` to render a read-only HTML card. 27 | 28 | 29 | 30 | * Fully functional example-webserver in directory `systemtest`; 31 | compile and run, then 32 | [Main example](http://localhost:8085/) 33 | [File upload example](http://localhost:8085/file-upload) 34 | 35 | ## Example use 36 | 37 | ```golang 38 | type entryForm struct { 39 | Department string `json:"department,omitempty" form:"subtype='select',accesskey='p',onchange='true',label='Department/Abteilung',title='loading items'"` 40 | Separator01 string `json:"separator01,omitempty" form:"subtype='separator'"` 41 | HashKey string `json:"hashkey,omitempty" form:"maxlength='16',size='16',autocapitalize='off',suffix='salt, changes randomness'"` // the , instead of , prevents wrong parsing 42 | Groups int `json:"groups,omitempty" form:"min=1,max='100',maxlength='3',size='3'"` 43 | Items string `json:"items,omitempty" form:"subtype='textarea',cols='22',rows='4',maxlength='4000',label='Textarea of
line items',title='add times - delimited by newline (enter)'"` 44 | Items2 []string `json:"items2,omitempty" form:"subtype='select',size='3',multiple='true',label='Multi
select
dropdown',autofocus='true'"` 45 | Group01 string `json:"group01,omitempty" form:"subtype='fieldset'"` 46 | Date string `json:"date,omitempty" form:"subtype='date',nobreak=true,min='1989-10-29',max='2030-10-29'"` 47 | Time string `json:"time,omitempty" form:"subtype='time',maxlength='12',inputmode='numeric',size='12'"` 48 | Group02 string `json:"group02,omitempty" form:"subtype='fieldset'"` 49 | DateLayout string `json:"date_layout,omitempty" form:"accesskey='t',maxlength='16',size='16',pattern='[0-9\\.\\-/]{2,10}',placeholder='2006/01/02 15:04',label='Layout of the date'"` // 2006-01-02 15:04 50 | CheckThis bool `json:"check_this,omitempty" form:"suffix='without consequence'"` 51 | 52 | // Requires distinct way of form parsing 53 | // Upload []byte `json:"upload,omitempty" form:"accesskey='u',accept='.xlsx'"` 54 | } 55 | 56 | // Validate checks whether form entries as a whole are "submittable"; 57 | // more than just 'populated' 58 | // Validate generates error messages 59 | func (frm entryForm) Validate() (map[string]string, bool) { 60 | errs := map[string]string{} 61 | g1 := frm.Department != "" 62 | if !g1 { 63 | errs["department"] = "Missing department" 64 | } 65 | g2 := frm.CheckThis 66 | if !g2 { 67 | errs["check_this"] = "You need to comply" 68 | } 69 | g3 := frm.Items != "" 70 | if !g3 { 71 | errs["items"] = "No items" 72 | } 73 | return errs, g1 && g2 && g3 74 | } 75 | 76 | 77 | // getting a converter 78 | s2f := struc2frm.New() // or clone existing one 79 | s2f.ShowHeadline = true // set options 80 | s2f.SetOptions("department", []string{"ub", "fm"}, []string{"UB", "FM"}) 81 | 82 | // init values - non-multiple 83 | frm := entryForm{ 84 | HashKey: time.Now().Format("2006-01-02"), 85 | Groups: 2, 86 | Date: time.Now().Format("2006-01-02"), 87 | Time: time.Now().Format("15:04"), 88 | } 89 | 90 | // pulling in values from http request 91 | populated, err := Decode(req, &frm) 92 | if populated && err != nil { 93 | s2f.AddError("global", fmt.Sprintf("cannot decode form: %v
\n
%v
", err, indentedDump(r.Form))) 94 | log.Printf("cannot decode form: %v
\n
%v
", err, indentedDump(r.Form)) 95 | } 96 | 97 | // init values - multiple 98 | if !populated { 99 | if len(frm.Items2) == 0 { 100 | frm.Items2 = []string{"berta", "dora"} 101 | } 102 | } 103 | 104 | errs, valid := frm.Validate() 105 | 106 | if populated { 107 | if !valid { 108 | s2f.AddErrors(errs) // add errors only for a populated form 109 | } else { 110 | // further processing with valid form data 111 | } 112 | } 113 | 114 | if !valid { 115 | // render to HTML for user input / error correction 116 | fmt.Fprint(w, s2f.Form(frm)) 117 | } 118 | 119 | 120 | ``` 121 | 122 | ## Global options 123 | 124 | * `ShowHeadline` - show a headline derived from struct name; default `false`. 125 | 126 | * `FormTag` - suppress the surrounding `
...
` if you want to compose a form from multiple structs. 127 | 128 | * `Name` - form name attribute; default `frmMain` 129 | 130 | * `Action` - HTML form action URL; default is empty string 131 | 132 | * `Method` - GET or POST; default `POST` 133 | 134 | * `Salt` and `FormTimeout` - parameters to generate CSRF token 135 | 136 | * `FocusFirstError` - focus on inputs with errors; default `true` 137 | 138 | * `ForceSubmit` - show submit button despite `onchange=form.submit()`; default `false` 139 | 140 | * `Indent`, `IndentAddenum`, `VerticalSpacer` - change indentation in `px`; vertical spacing in `rem` 141 | 142 | * `CSS` - default CSS classes for reasonable appearance. 143 | Incorporate similar rules into your application style sheet, 144 | and set to empty string. 145 | 146 | ## Attributes for field types 147 | 148 | * Use `float64` or `int` to create number inputs - with attributes `min=1,max=100,step=2`. 149 | Notice that `step=2` defines maximum precision; uneven numbers become invalid. 150 | This is an [HTML5 restriction](https://stackoverflow.com/questions/14365348/). 151 | 152 | * `string` supports attribute `placeholder='2006/01/02 15:04'` to show a pattern to the user (placeholder). 153 | 154 | * `string` supports attribute `pattern='[0-9\\.\\-/]{10}'` to restrict the entry to a regular expression. 155 | 156 | * Use attributes `maxlength='16'` and `size='16'` 157 | determine width and maximum content length respectively for `input` and `textarea`. 158 | Attribute `size` determines height for select/dropdown elements. 159 | 160 | * Use `string` field with subtype `textarea` and attributes `cols='32',rows='22'` 161 | 162 | * Use `string` field with subtype `date` and attributes `min='1989-10-29'` or `max=...` 163 | 164 | * Use `string` field with subtype `time` 165 | 166 | * Use `bool` to create a checkbox 167 | 168 | ### Separator and fieldset 169 | 170 | These are `dummmy` fields for formatting only 171 | 172 | * Every `string` field with subtype `separator` is rendered into a horizontal line 173 | * If the struct tag `form` has as `label`, then its contents are rendered. 174 | Serving as static text paragraph. 175 | 176 | * Every `string` field with subtype `fieldset` is rendered into grouping box with label 177 | 178 | ### Select / dropdown inputs 179 | 180 | * Use `string | int | float64 | bool` field with subtype `select` 181 | 182 | * Use `size=1` or `size=5` to determine the height 183 | 184 | * Use `SetOptions()` to fill input[select] elements 185 | 186 | * Use `DefaultOptionKey()` to read the pre-selected option on clean forms 187 | 188 | * Use `onchange='true'` for onchange submit 189 | 190 | ### Radiogroup 191 | 192 | Like [select / dropdown](#select--dropdown-inputs), 193 | but rendered as radio inputs. 194 | 195 | ### Select multiple 196 | 197 | * Use subtype `select` with `multiple='true'` to enable the selection of __multiple items__ 198 | in conjunction with struct field type `[]string | []int | []float64 | []bool` 199 | 200 | * Use `wildcardselect='true'` to show an additional input after the select, 201 | accepting wildcard expressions with `*` for selecting options from the select. 202 | * Wildcard expressions are case sensitive. 203 | * Multiple wildcard expressions can be chained using `;`. 204 | * Multiple expressions are applied successively additively. 205 | * Any wildcard expression can be negated by `!` prefixing, resulting in _unselect_. 206 | * Example `Car*;Bike*;!Carsharing`. 207 | * To debug, open the Javascript console of your browser and type `wildcardselectDebug = true;` 208 | 209 | * Parsing of HTTP request into form struct for `multiple` fields 210 | is __additive__. 211 | => Init values should not be set before parsing but afterwards. 212 | 213 | ```golang 214 | // ... 215 | populated, err := Decode(req, &frm) 216 | // ... 217 | 218 | if len(frm.Items2) == 0 { 219 | frm.Items2 = []string{"berta", "dora"} // setting defaults if request parsing did not yield any user input 220 | } 221 | ``` 222 | 223 | ## Submit button 224 | 225 | If your form only has `select` inputs with `onchange='this.form.submit()'` 226 | then no submit button is shown. 227 | 228 | This can be overridden by setting `struc2frm.New().ShowSubmit` to true. 229 | 230 | ## General field attributes 231 | 232 | * Use `form:"-"` to exclude fields from being rendered 233 | neither in form view nor in card view 234 | 235 | * Every field can have an attribute `label=...`, 236 | appearing before the input element, 237 | if not specified, json:"[name]..." is labelized and used 238 | * `label-style` can specify an individual CSS styles for the label tag 239 | 240 | * Every field can have an attribute `suffix=...`, 241 | appearing after the input element 242 | 243 | * Every field can have an attribute `title=...` 244 | for mouse-over tooltips 245 | 246 | * Values inside of `label='...'`, `suffix='...'`, `title='...'`, `placeholder='...'`, `pattern='...'` 247 | need `,` instead of `,` 248 | 249 | * Every field can have an attribute `accesskey='[a-z]'` 250 | Accesskeys are not put into the label, but into the input tag 251 | 252 | * Every field can have an attribute `nobreak='true'` 253 | so that the next input remains on the same line 254 | 255 | * Every field can have an attribute `autofocus='true'` 256 | setting the keyboard focus to this input. 257 | Use this only once per form. 258 | `autofocus='true'` is overwritten by `FocusFirstError==true`; see below. 259 | 260 | ### Field attributes for mobile phones 261 | 262 | * `inputmode="numeric"` opens the numbers keyboard on mobile phones 263 | 264 | * `autocapitalize=off` switches off first letter upper casing 265 | 266 | ## Validation and errors 267 | 268 | The `Validator` interface is non mandatory helper interface for form structs. 269 | 270 | ```golang 271 | type Validator interface { 272 | Validate() (map[string]string, bool) 273 | } 274 | ``` 275 | 276 | It returns error messages suitable for `s2f.AddErrors()`. 277 | 278 | ```golang 279 | if populated { 280 | errs, valid := frm.Validate() 281 | if !valid { 282 | s2f.AddErrors(errs) // add errors only for a populated form 283 | // render to HTML for user input / error correction 284 | fmt.Fprint(w, s2f.Form(frm)) 285 | ``` 286 | 287 | A _valid_ form struct enables further processing. 288 | 289 | ```golang 290 | } else { 291 | // further processing 292 | } 293 | ``` 294 | 295 | * Keep `FocusFirstError=true` to focus the first input having an error message. 296 | 297 | * This overrides `autofocus='true'`. 298 | 299 | ## File upload 300 | 301 | * input[file] must have golang type `[]byte` 302 | 303 | * input[file] should be named `upload` 304 | and _requires_ `ParseMultipartForm()` instead of `ParseForm()` 305 | 306 | * `DecodeMultipartForm()` and `ExtractUploadedFile()` are helper funcs 307 | to extract file upload data 308 | 309 | Example 310 | 311 | ```golang 312 | 313 | type entryForm struct { 314 | TextField string `json:"text_field,omitempty" form:"maxlength='16',size='16'"` 315 | // Requires distinct way of form parsing 316 | Upload []byte `json:"upload,omitempty" form:"accesskey='u',accept='.txt',suffix='*.txt files'"` 317 | } 318 | 319 | s2f := struc2frm.New() // or clone existing one 320 | s2f.ShowHeadline = true // set options 321 | s2f.Indent = 80 322 | 323 | 324 | // init values 325 | frm := entryForm{ 326 | TextField: "some-init-text", 327 | } 328 | 329 | populated, err := DecodeMultipartForm(req, &frm) 330 | if populated && err != nil { 331 | s2f.AddError("global", fmt.Sprintf("cannot decode multipart form: %v
\n
%v
", err, indentedDump(req.Form))) 332 | log.Printf("cannot decode multipart form: %v
\n
%v
", err, indentedDump(req.Form)) 333 | } 334 | 335 | bts, excelFileName, err := ExtractUploadedFile(req) 336 | if err != nil { 337 | fmt.Fprintf(w, "Cannot extract file from POST form: %v
\n", err) 338 | } 339 | 340 | fileMsg := "" 341 | if populated { 342 | fileMsg = fmt.Sprintf("%v bytes read from excel file -%v-
\n", len(bts), excelFileName) 343 | fileMsg = fmt.Sprintf("%vFile content is --%v--
\n", fileMsg, string(bts)) 344 | } else { 345 | fileMsg = "No upload filename - or empty file
\n" 346 | 347 | } 348 | 349 | fmt.Fprintf( 350 | w, 351 | defaultHTML, 352 | s2f.HTML(frm), 353 | fileMsg, 354 | ) 355 | ``` 356 | 357 | See `handler-file-upload_test.go` on how to programmatically POST a file and key-values. 358 | 359 | ## CSS Styling 360 | 361 | * Styling is done via CSS selectors 362 | and can be customized 363 | by changing or appending `struc2frm.New().CSS` 364 | 365 | * If you already have good styles in your website, 366 | set `CSS = ""` 367 | 368 | ```CSS 369 | div.struc2frm { 370 | padding: 4px; 371 | } 372 | div.struc2frm input { 373 | margin: 4px; 374 | } 375 | ``` 376 | 377 | The media query CSS block in default `struc2frm.New().CSS` 378 | can be used to change the label width depending on screen width. 379 | 380 | Label width can also be changed by setting 381 | via `struc2frm.New().Indent` and `struc2frm.New().IndentAddenum` 382 | to none-zero values. 383 | 384 | ```CSS 385 | div.struc2frm-34323168 H3 { 386 | margin-left: 116px; /* programmatically set via s3f.Indent for each form */ 387 | } 388 | ``` 389 | 390 | ```CSS 391 | /* change specific inputs */ 392 | div.struc2frm label[for="time"] { 393 | min-width: 20px; 394 | } 395 | div.struc2frm select[name="department"] { 396 | background-color: darkkhaki; 397 | } 398 | ``` 399 | 400 | ## Technical stuff 401 | 402 | Language | files | blank | comment | code 403 | --- | --- | --- | --- | --- 404 | Go | 5 | 123 | 68 | 672 405 | Markdown | 1 | 61 | 0 | 150 406 | CSS | 1 | 26 | 8 | 120 407 | HTML | 1 | 6 | 1 | 30 408 | 409 | * Default CSS is init-loaded from an in-package file `default.css`, 410 | mostly to have syntax highlighting while editing it. 411 | 412 | ## Todo 413 | 414 | * Can we use `0x2C` instead of `,` ? 415 | 416 | * `radiogroup` needs more finesse 417 | 418 | * Low Prio: Move JavaScript code for multiselect into a JS file 419 | -------------------------------------------------------------------------------- /struc2frm.go: -------------------------------------------------------------------------------- 1 | // Package struc2frm creates an HTML input form 2 | // for a given struct type; 3 | // see README.md for details. 4 | package struc2frm 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "fmt" 10 | "html/template" 11 | "io" 12 | "io/ioutil" 13 | "log" 14 | "net" 15 | "net/http" 16 | "path" 17 | "reflect" 18 | "regexp" 19 | "runtime" 20 | "strings" 21 | "time" 22 | "unicode" 23 | 24 | "github.com/go-playground/form" 25 | "github.com/pkg/errors" 26 | ) 27 | 28 | var defaultHTML = "" 29 | 30 | func init() { 31 | _, filename, _, _ := runtime.Caller(0) 32 | sourceDirPath := path.Join(path.Dir(filename), "tpl-main.html") 33 | bts, err := ioutil.ReadFile(sourceDirPath) 34 | if err != nil { 35 | log.Printf("Could not load main template: %v", err) 36 | defaultHTML = staticTplMainHTML 37 | log.Printf("Loaded %v chars from static.go instead", len(staticTplMainHTML)) 38 | return 39 | } 40 | defaultHTML = string(bts) 41 | } 42 | 43 | var defaultCSS = "" 44 | 45 | func init() { 46 | _, filename, _, _ := runtime.Caller(0) 47 | sourceDirPath := path.Join(path.Dir(filename), "default.css") 48 | bts, err := ioutil.ReadFile(sourceDirPath) 49 | if err != nil { 50 | log.Printf("Could not load default CSS: %v", err) 51 | defaultCSS = staticDefaultCSS // \n") 193 | 194 | if s2f.Indent == 0 { // using additional generic specs - for instance with media query 195 | return 196 | } 197 | 198 | // instance specific 199 | specific := ` 200 | 214 | ` 215 | specific = fmt.Sprintf( 216 | specific, 217 | s2f.Indent, 218 | s2f.Indent+s2f.IndentAddenum, 219 | s2f.Indent+s2f.IndentAddenum, 220 | ) 221 | specific = strings.ReplaceAll(specific, "div.struc2frm-", fmt.Sprintf("div.struc2frm-%v", s2f.InstanceID)) 222 | 223 | fmt.Fprint(w, specific) 224 | } 225 | 226 | func (s2f *s2FT) verticalSpacer() string { 227 | return fmt.Sprintf("\t
 
", s2f.VerticalSpacer) 228 | } 229 | 230 | // SetOptions to prepare dropdown/select options - with keys and labels 231 | // for rendering in Form() 232 | func (s2f *s2FT) SetOptions(nameJSON string, keys, labels []string) { 233 | if s2f.selectOptions == nil { 234 | s2f.selectOptions = map[string]options{} 235 | } 236 | s2f.selectOptions[nameJSON] = options{} // always reset options to prevent accumulation of options on clones 237 | 238 | if len(keys) != len(labels) { 239 | s2f.selectOptions[nameJSON] = append(s2f.selectOptions[nameJSON], option{"key", "keys and labels length does not match"}) 240 | } else { 241 | for i, key := range keys { 242 | s2f.selectOptions[nameJSON] = append(s2f.selectOptions[nameJSON], option{key, labels[i]}) 243 | } 244 | } 245 | } 246 | 247 | // AddError adds a validation message; 248 | // key 'global' writes msg on top of form. 249 | func (s2f *s2FT) AddError(nameJSON string, msg string) { 250 | if s2f.errors == nil { 251 | s2f.errors = map[string]string{} 252 | } 253 | if _, ok := s2f.errors[nameJSON]; ok { 254 | s2f.errors[nameJSON] += "
\n" 255 | } 256 | s2f.errors[nameJSON] += msg 257 | } 258 | 259 | // AddErrors adds validation messages; 260 | // key 'global' writes msg on top of form. 261 | func (s2f *s2FT) AddErrors(errs map[string]string) { 262 | if s2f.errors == nil { 263 | s2f.errors = map[string]string{} 264 | } 265 | for nameJSON, msg := range errs { 266 | if _, ok := s2f.errors[nameJSON]; ok { 267 | s2f.errors[nameJSON] += "
\n" 268 | } 269 | s2f.errors[nameJSON] += msg 270 | } 271 | } 272 | 273 | // DefaultOptionKey gives the value to be selected on form init 274 | func (s2f *s2FT) DefaultOptionKey(name string) string { 275 | if s2f.selectOptions == nil { 276 | return "" 277 | } 278 | if len(s2f.selectOptions[name]) == 0 { 279 | return "" 280 | } 281 | return s2f.selectOptions[name][0].Key 282 | } 283 | 284 | // rendering tags 285 | func (opts options) HTML(selecteds []string) string { 286 | w := &bytes.Buffer{} 287 | // log.Printf("select options - selecteds %v", selecteds) 288 | for _, o := range opts { 289 | found := false 290 | for _, selected := range selecteds { 291 | if o.Key == selected { 292 | found = true 293 | // log.Printf("found %v", o.Key) 294 | } 295 | } 296 | if found { 297 | fmt.Fprintf(w, "\t\t\n", o.Key, o.Val) 298 | } else { 299 | fmt.Fprintf(w, "\t\t\n", o.Key, o.Val) 300 | } 301 | } 302 | return w.String() 303 | } 304 | 305 | // rendering tags 306 | func (opts options) Radio(name string, selecteds []string) string { 307 | w := &bytes.Buffer{} 308 | for _, o := range opts { 309 | checked := "" 310 | for _, selected := range selecteds { 311 | if o.Key == selected { 312 | checked = "checked=\"checked\"" 313 | // log.Printf("selected %v", o.Key) 314 | } 315 | } 316 | 317 | // A click on a label for a *radio* does not focus the input; possibly because input name is not unique 318 | // Possible remedy stackoverflow.com/questions/13273806/ 319 | // rejected for its HTML ugliness 320 | if o.Val != "" { 321 | fmt.Fprintf(w, "\t\t\n", name, o.Val) 322 | } 323 | 324 | fmt.Fprintf(w, 325 | "\t\t\n", 326 | name, o.Key, checked, 327 | ) 328 | 329 | } 330 | return w.String() 331 | } 332 | 333 | /*ValToString converts reflect.Value to string. 334 | 335 | go-playground/form.Decode nicely converts all kins of request.Form strings 336 | into the desired struct types. 337 | 338 | But to render the form to HTML, we have to convert those types back to string. 339 | 340 | val.String() of a bool yields "" 341 | val.String() of an int yields "" 342 | val.String() of a float yields "" 343 | */ 344 | func ValToString(val reflect.Value) string { 345 | 346 | tp := val.Kind() 347 | 348 | valStr := val.String() // trivial case 349 | if tp == reflect.Bool { 350 | valStr = fmt.Sprint(val.Bool()) 351 | } else if tp == reflect.Int { 352 | valStr = fmt.Sprint(val.Int()) 353 | } else if tp == reflect.Float64 { 354 | valStr = fmt.Sprint(val.Float()) 355 | } 356 | 357 | return valStr 358 | 359 | } 360 | 361 | // golang type and 'form' struct tag 'subtype' => html input type 362 | func toInputType(t, attrs string) string { 363 | 364 | switch t { 365 | case "string", "[]string": 366 | switch structTag(attrs, "subtype") { // various possibilities - distinguish by subtype 367 | case "separator": 368 | return "separator" 369 | case "fieldset": 370 | return "fieldset" 371 | case "date": 372 | return "date" 373 | case "time": 374 | return "time" 375 | case "textarea": 376 | return "textarea" 377 | case "select": 378 | return "select" 379 | case "radiogroup": 380 | return "radiogroup" 381 | } 382 | return "text" 383 | case "int", "float64", "[]int", "[]float64": 384 | switch structTag(attrs, "subtype") { // might want dropdown, for instance for list of years 385 | case "select": 386 | return "select" 387 | } 388 | return "number" 389 | case "bool", "[]bool": 390 | switch structTag(attrs, "subtype") { // not always checkbox, but sometimes dropdown 391 | case "select": 392 | return "select" 393 | } 394 | return "checkbox" 395 | case "[]uint8": 396 | return "file" 397 | } 398 | return "text" 399 | } 400 | 401 | // parsing the struct tag 'form'; 402 | // returning a *single* value for argument key; 403 | // i.e. "maxlength='42',size='28',suffix='optional'" 404 | // key=size 405 | // returns 28 406 | func structTag(tags, key string) string { 407 | tagss := strings.Split(tags, ",") 408 | for _, a := range tagss { 409 | aLow := strings.ToLower(a) 410 | if strings.HasPrefix(aLow, key) { 411 | kv := strings.Split(a, "=") 412 | if len(kv) == 2 { 413 | return strings.Trim(kv[1], "'") 414 | } 415 | } 416 | } 417 | return "" 418 | } 419 | 420 | // convert the struct tag 'form' to html input attributes; 421 | // mostly replacing comma with single space; 422 | // i.e. "maxlength='42',size='28',suffix='optional'" 423 | func structTagsToAttrs(tags string) string { 424 | tagss := strings.Split(tags, ",") 425 | ret := "" 426 | for _, t := range tagss { 427 | t = strings.TrimSpace(t) 428 | tl := strings.ToLower(t) // tag lower 429 | switch { 430 | case strings.HasPrefix(tl, "subtype="): // string - [date,textarea,select] - not an HTML attribute; kept for debugging 431 | ret += " " + t 432 | case strings.HasPrefix(tl, "size="): // visible width of input field 433 | ret += " " + t 434 | case strings.HasPrefix(tl, "maxlength="): // digits of input data 435 | ret += " " + t 436 | case strings.HasPrefix(tl, "max="): // for input number 437 | ret += " " + t 438 | case strings.HasPrefix(tl, "min="): // for input number 439 | ret += " " + t 440 | case strings.HasPrefix(tl, "step="): // for input number - special value 'any' 441 | ret += " " + t 442 | case strings.HasPrefix(tl, "pattern="): // client side validation; i.e. date layout [0-9\\.\\-/]{10} 443 | ret += " " + t 444 | case strings.HasPrefix(tl, "placeholder="): // a watermark showing expected input; i.e. 2006/01/02 15:04 445 | ret += " " + t 446 | case strings.HasPrefix(tl, "rows="): // for texarea 447 | ret += " " + t 448 | case strings.HasPrefix(tl, "cols="): // for texarea 449 | ret += " " + t 450 | case strings.HasPrefix(tl, "accept="): // file upload extension 451 | ret += " " + t 452 | case strings.HasPrefix(tl, "onchange"): // file upload extension 453 | ret += " " + "onchange='javascript:this.form.submit();'" 454 | case strings.HasPrefix(tl, "wildcardselect"): // show extra input next to select - to select options 455 | ret += " " + t 456 | case strings.HasPrefix(tl, "accesskey="): // goes into input, not into label 457 | ret += " " + t 458 | case strings.HasPrefix(tl, "title="): // mouse over tooltip - alt 459 | ret += " " + t 460 | case strings.HasPrefix(tl, "autocapitalize="): // 'off' prevents upper case for first word on mobile phones 461 | ret += " " + t 462 | case strings.HasPrefix(tl, "inputmode="): // 'numeric' shows only numbers keysboard on mobile phones 463 | ret += " " + t 464 | case strings.HasPrefix(tl, "multiple"): // dropdown/select - select multiple items; no value 465 | ret += " " + "multiple" // only the attribute; no value 466 | case strings.HasPrefix(tl, "autofocus"): 467 | ret += " " + "autofocus" // only the attribute; no value 468 | default: 469 | // "label=" is not converted into an attribute 470 | // "label-style=" ~ 471 | // "suffix=" ~ 472 | // "nobreak=" ~ 473 | } 474 | 475 | } 476 | return ret 477 | } 478 | 479 | // for example 'Date layout' with accesskey 't' becomes 'Date layout' 480 | func accessKeyify(s, attrs string) string { 481 | ak := structTag(attrs, "accesskey") 482 | if ak == "" { 483 | return s 484 | } 485 | akr := rune(ak[0]) 486 | akrUp := unicode.ToUpper(akr) 487 | 488 | s2 := []rune{} 489 | found := false 490 | // log.Printf("-%s- -%s-", s, ak) 491 | for _, ru := range s { 492 | // log.Printf("\tcomparing %#U to %#U - %#U", ru, akr, akrUp) 493 | if (ru == akr || ru == akrUp) && !found { 494 | s2 = append(s2, '<', 'u', '>') 495 | s2 = append(s2, ru) 496 | s2 = append(s2, '<', '/', 'u', '>') 497 | found = true 498 | continue 499 | } 500 | s2 = append(s2, ru) 501 | } 502 | return string(s2) 503 | } 504 | 505 | // labelize converts struct field names and json field names 506 | // to human readable format: 507 | // bond_fund => Bond fund 508 | // bondFund => Bond fund 509 | // bondFUND => Bond fund 510 | // 511 | // edge case: BONDFund would be converted to 'Bondfund' 512 | func labelize(s string) string { 513 | rs := make([]rune, 0, len(s)) 514 | previousUpper := false 515 | for i, char := range s { 516 | if i == 0 { 517 | rs = append(rs, unicode.ToUpper(char)) 518 | previousUpper = true 519 | } else { 520 | if char == '_' { 521 | char = ' ' 522 | } 523 | if unicode.ToUpper(char) == char { 524 | if !previousUpper && char != ' ' { 525 | rs = append(rs, ' ') 526 | } 527 | rs = append(rs, unicode.ToLower(char)) 528 | previousUpper = true 529 | } else { 530 | rs = append(rs, char) 531 | previousUpper = false 532 | } 533 | } 534 | } 535 | return string(rs) 536 | } 537 | 538 | // ParseMultipartForm parses an HTTP request form 539 | // with file attachments 540 | func ParseMultipartForm(r *http.Request) error { 541 | 542 | if r.Method == "GET" { 543 | return nil 544 | } 545 | 546 | const _24K = (1 << 20) * 24 547 | err := r.ParseMultipartForm(_24K) 548 | if err != nil { 549 | log.Printf("Parse multipart form error: %v\n", err) 550 | return err 551 | } 552 | return nil 553 | } 554 | 555 | // ExtractUploadedFile extracts a file from an HTTP POST request. 556 | // It needs the request form to be prepared with ParseMultipartForm. 557 | func ExtractUploadedFile(r *http.Request, names ...string) (bts []byte, fname string, err error) { 558 | 559 | if r.Method == "GET" { 560 | return 561 | } 562 | 563 | name := "upload" 564 | if len(names) > 0 { 565 | name = names[0] 566 | } 567 | 568 | _, fheader, err := r.FormFile(name) 569 | if err != nil { 570 | log.Printf("Error unpacking upload bytes from post request: %v\n", err) 571 | return 572 | } 573 | 574 | fname = fheader.Filename 575 | log.Printf("Uploaded filename = %+v", fname) 576 | 577 | rdr, err := fheader.Open() 578 | if err != nil { 579 | log.Printf("Error opening uploaded file: %v\n", err) 580 | return 581 | } 582 | defer rdr.Close() 583 | 584 | bts, err = ioutil.ReadAll(rdr) 585 | if err != nil { 586 | log.Printf("Error reading uploaded file: %v\n", err) 587 | return 588 | } 589 | 590 | log.Printf("Extracted %v bytes from uploaded file", len(bts)) 591 | return 592 | 593 | } 594 | 595 | // Form takes a struct instance 596 | // and turns it into an HTML form. 597 | func (s2f *s2FT) Form(intf interface{}) template.HTML { 598 | 599 | v := reflect.ValueOf(intf) // interface val 600 | typeOfS := v.Type() 601 | // v = v.Elem() // de reference 602 | 603 | if v.Kind().String() != "struct" { 604 | return template.HTML(fmt.Sprintf("struct2form.Form() - arg1 must be struct - is %v", v.Kind())) 605 | } 606 | 607 | w := &bytes.Buffer{} 608 | 609 | needSubmit := false // only select with onchange:submit() ? 610 | 611 | // collect fields with initial focus and fields with errors 612 | inputWithFocus := "" // first input having an autofocus attribute 613 | firstInputWithError := "" // first input having an error message 614 | if s2f.FocusFirstError { 615 | for i := 0; i < v.NumField(); i++ { 616 | inpName := typeOfS.Field(i).Tag.Get("json") // i.e. date_layout 617 | inpName = strings.Replace(inpName, ",omitempty", "", -1) 618 | _, hasError := s2f.errors[inpName] 619 | if hasError { 620 | firstInputWithError = inpName 621 | break 622 | } 623 | } 624 | } 625 | // error focus takes precedence over init focus 626 | if firstInputWithError != "" { 627 | inputWithFocus = firstInputWithError 628 | } else { 629 | for i := 0; i < v.NumField(); i++ { 630 | inpName := typeOfS.Field(i).Tag.Get("json") // i.e. date_layout 631 | inpName = strings.Replace(inpName, ",omitempty", "", -1) 632 | attrs := typeOfS.Field(i).Tag.Get("form") // i.e. form:"maxlength='42',size='28'" 633 | if structTag(attrs, "autofocus") != "" { 634 | inputWithFocus = inpName 635 | } 636 | } 637 | } 638 | 639 | s2f.RenderCSS(w) 640 | 641 | // one class selector for general - one for specific instance 642 | fmt.Fprintf(w, "
\n", s2f.InstanceID) 643 | 644 | if s2f.ShowHeadline { 645 | fmt.Fprintf(w, "

%v

\n", labelize(typeOfS.Name())) 646 | } 647 | 648 | // file upload requires distinct form attribute 649 | uploadPostForm := false 650 | for i := 0; i < v.NumField(); i++ { 651 | tp := v.Field(i).Type().Name() // primitive type name: string, int 652 | if typeOfS.Field(i).Type.Kind() == reflect.Slice { 653 | tp = "[]" + typeOfS.Field(i).Type.Elem().Name() 654 | } 655 | if toInputType(tp, "") == "file" { 656 | uploadPostForm = true 657 | break 658 | } 659 | } 660 | 661 | if s2f.FormTag { 662 | if uploadPostForm { 663 | fmt.Fprintf(w, "
\n", s2f.Name, s2f.Action) 664 | } else { 665 | // browser default encoding for post is "application/x-www-form-urlencoded" 666 | fmt.Fprintf(w, "\n", s2f.Name, s2f.Action, s2f.Method) 667 | } 668 | } 669 | 670 | if errMsg, ok := s2f.errors["global"]; ok { 671 | fmt.Fprintf(w, "\t

%v

\n", errMsg) 672 | } 673 | 674 | fmt.Fprintf(w, "\t\n", s2f.FormToken()) 675 | 676 | fieldsetOpen := false 677 | 678 | // Render fields 679 | for i := 0; i < v.NumField(); i++ { 680 | 681 | // struct field name; i.e. Name, Birthdate 682 | fn := typeOfS.Field(i).Name 683 | if fn[0:1] != strings.ToUpper(fn[0:1]) { // only used to find unexported fields; otherwise json tag name is used 684 | continue // skip unexported 685 | } 686 | 687 | inpName := typeOfS.Field(i).Tag.Get("json") // i.e. date_layout 688 | inpName = strings.Replace(inpName, ",omitempty", "", -1) 689 | inpLabel := labelize(inpName) 690 | 691 | attrs := typeOfS.Field(i).Tag.Get("form") // i.e. form:"maxlength='42',size='28'" 692 | 693 | if structTag(attrs, "label") != "" { 694 | inpLabel = structTag(attrs, "label") 695 | } 696 | 697 | if strings.Contains(attrs, ", ") || strings.Contains(attrs, ", ") { 698 | return template.HTML(fmt.Sprintf("struct2form.Form() - field %v: tag 'form' cannot contain ', ' or ' ,' ", inpName)) 699 | } 700 | 701 | if commaInsideQuotes(attrs) { 702 | return template.HTML(fmt.Sprintf("struct2form.Form() - field %v: tag 'form' - use , instead of ',' inside of single quotes values", inpName)) 703 | } 704 | 705 | if attrs == "-" { 706 | continue 707 | } 708 | 709 | // getting the value and the type of the iterated struct field 710 | val := v.Field(i) 711 | if false { 712 | // if our entry form struct would contain pointer fields... 713 | val = reflect.Indirect(val) // pointer converted to value 714 | val = reflect.Indirect(val) // idempotent 715 | val = val.Elem() // what is the difference? 716 | } 717 | 718 | tp := v.Field(i).Type().Name() // primitive type name: string, int 719 | if typeOfS.Field(i).Type.Kind() == reflect.Slice { 720 | tp = "[]" + v.Type().Field(i).Type.Elem().Name() // []byte => []uint8 721 | } 722 | 723 | valStr := ValToString(val) 724 | valStrs := []string{valStr} // for select multiple='false' 725 | 726 | // for select multiple='true' 727 | // if tp == []string or []int or []float64 ... 728 | // unpack slice from checkbox arrays or select/dropdown multiple 729 | if typeOfS.Field(i).Type.Kind() == reflect.Slice { 730 | 731 | // valSlice := reflect.MakeSlice(val.Type(), val.Cap(), val.Len()) 732 | // valSlice := val.Slice(0, val.Len()) 733 | valSlice := val // same as above 734 | 735 | // log.Printf( 736 | // "kind of type %v - elem type %v - capacity %v - length %v - %v", 737 | // valSlice.Kind(), val.Type(), valSlice.Cap(), valSlice.Len(), valSlice, 738 | // ) 739 | 740 | valStrs = []string{} // reset 741 | for i := 0; i < valSlice.Len(); i++ { 742 | // see package fmt/print.go - printValue()::865 743 | // vx := valSlice.Slice(i, i+1) // this woudl be a subslice 744 | valElem := valSlice.Index(i) // this is an element of a slice 745 | // log.Printf("Elem is %v", ValToString(valElem)) 746 | valStrs = append(valStrs, ValToString(valElem)) 747 | } 748 | 749 | // select multiple: 750 | // having extracted valStrs, we want prevent additive request into form parsing; 751 | // but CanSet() yields false; 752 | // better setting init values *conditionally* *after* parsing 753 | if valSlice.CanSet() && false { 754 | log.Printf("%v - %v - %v - trying slice reset", inpName, tp, val.Type()) 755 | valueSlice := reflect.MakeSlice(val.Type(), 0, 5) 756 | valueSlice.Set(valueSlice) 757 | } 758 | } 759 | 760 | errMsg, hasError := s2f.errors[inpName] 761 | if hasError { 762 | fmt.Fprintf(w, "\t

%v

\n", errMsg) 763 | } 764 | 765 | labelStyle := structTag(attrs, "label-style") // for instance irregular width - overriding CSS style 766 | 767 | // label positioning for tall inputs 768 | specialVAlign := "" 769 | if toInputType(tp, attrs) == "textarea" { 770 | specialVAlign = "vertical-align: top;" 771 | } 772 | if toInputType(tp, attrs) == "select" { 773 | if structTag(attrs, "multiple") != "" { 774 | specialVAlign = "vertical-align: top;" 775 | } 776 | } 777 | if toInputType(tp, attrs) != "separator" && 778 | toInputType(tp, attrs) != "fieldset" { 779 | fmt.Fprintf(w, 780 | "\t\n", // no whitespace - input immediately afterwards 781 | inpName, labelStyle, specialVAlign, accessKeyify(inpLabel, attrs), 782 | ) 783 | } 784 | 785 | // various inputs 786 | switch toInputType(tp, attrs) { 787 | case "checkbox": 788 | needSubmit = true 789 | checked := "" 790 | if val.Bool() { 791 | checked = "checked" 792 | } 793 | fmt.Fprintf(w, "\t\n", toInputType(tp, attrs), inpName, inpName, "true", checked, structTagsToAttrs(attrs)) 794 | fmt.Fprintf(w, "\t", inpName) 795 | case "file": 796 | needSubmit = true 797 | // 798 | fmt.Fprintf(w, "\t", 799 | toInputType(tp, attrs), inpName, inpName, "ignored.json", structTagsToAttrs(attrs), 800 | ) 801 | case "date", "time": 802 | needSubmit = true 803 | // 804 | fmt.Fprintf(w, "\t", 805 | toInputType(tp, attrs), inpName, inpName, val, structTagsToAttrs(attrs), 806 | ) 807 | case "textarea": 808 | needSubmit = true 809 | fmt.Fprintf(w, "\t") 814 | case "radiogroup": 815 | if structTag(attrs, "onchange") == "" { 816 | needSubmit = true // select without auto submit => needs submit button 817 | } 818 | fmt.Fprint(w, "\t
\n") 819 | fmt.Fprint(w, "\t
\n") 820 | fmt.Fprint(w, s2f.selectOptions[inpName].Radio(inpName, valStrs)) 821 | fmt.Fprint(w, "\t
") 822 | fmt.Fprint(w, "\t
") 823 | 824 | case "select": 825 | if structTag(attrs, "onchange") == "" { 826 | needSubmit = true // select without auto submit => needs submit button 827 | } 828 | fmt.Fprint(w, "\t
\n") 829 | fmt.Fprintf(w, "\t\n") 832 | fmt.Fprint(w, "\t
") 833 | if structTag(attrs, "wildcardselect") != "" { 834 | fmt.Fprint(w, "\t\t
\n") 835 | // onchange only triggers on blur 836 | // onkeydown makes too much noise 837 | // oninput is just perfect 838 | fmt.Fprintf(w, ` `, 845 | inpName+"_so", 846 | inpName+"_so", 847 | "", 848 | ) 849 | fmt.Fprint(w, "\n\t\t
") 850 | /* 851 | JS function is printed repeatedly for multiple selects 852 | and multiple forms per request. 853 | The complexity of keeping track would be even more ugly. 854 | */ 855 | fmt.Fprintf(w, ` 856 | 938 | 939 | `) 940 | } 941 | 942 | case "separator": 943 | // when separator has an explicit label value 944 | if structTag(attrs, "label") != "" { 945 | fmt.Fprintf(w, "\t
%v
", inpLabel) 946 | } else { 947 | fmt.Fprint(w, "\t
") 948 | } 949 | 950 | case "fieldset": 951 | if fieldsetOpen { 952 | fmt.Fprint(w, "\n") 953 | } 954 | fmt.Fprint(w, "
") 955 | fmt.Fprintf(w, "\t %v ", inpLabel) 956 | fieldsetOpen = true 957 | default: 958 | // plain vanilla input 959 | needSubmit = true 960 | fmt.Fprintf(w, "\t", toInputType(tp, attrs), inpName, inpName, val, structTagsToAttrs(attrs)) 961 | 962 | } 963 | 964 | sfx := structTag(attrs, "suffix") 965 | if sfx != "" { 966 | fmt.Fprintf(w, "", sfx) 967 | } 968 | 969 | if toInputType(tp, attrs) != "separator" && 970 | toInputType(tp, attrs) != "fieldset" && 971 | structTag(attrs, "nobreak") == "" { 972 | fmt.Fprintf(w, "\n") 973 | fmt.Fprintf(w, s2f.verticalSpacer()) 974 | } 975 | 976 | // close input with newline 977 | fmt.Fprintf(w, "\n") 978 | 979 | } 980 | 981 | if fieldsetOpen { 982 | fmt.Fprint(w, "
\n") 983 | } 984 | 985 | if needSubmit || s2f.ForceSubmit { 986 | // name should *not* be 'submit' 987 | // avoiding error on this.form.submit() 988 | // 'submit is not a function' stackoverflow.com/questions/833032/ 989 | fmt.Fprintf(w, "\t\n%v\n", s2f.verticalSpacer()) 990 | } else { 991 | fmt.Fprintf(w, "\t\n") 996 | } 997 | fmt.Fprint(w, "
\n") 998 | 999 | if inputWithFocus != "" { 1000 | // finding form by name - setting focus by name; 1001 | // this repeats or overrides the autofocus mechanism; 1002 | // is it always last in timeline? 1003 | fmt.Fprintf(w, ` 1004 | 1042 | `, s2f.Name, inputWithFocus) 1043 | } 1044 | 1045 | // global replacements 1046 | ret := strings.ReplaceAll(w.String(), ",", ",") 1047 | 1048 | return template.HTML(ret) 1049 | } 1050 | 1051 | // HTML takes a struct instance 1052 | // and uses the default formatter 1053 | // to turns it into an HTML form. 1054 | func HTML(intf interface{}) template.HTML { 1055 | return defaultS2F.Form(intf) 1056 | } 1057 | 1058 | func indentedDump(v interface{}) string { 1059 | 1060 | firstColLeftMostPrefix := " " 1061 | byts, err := json.MarshalIndent(v, firstColLeftMostPrefix, "\t") 1062 | if err != nil { 1063 | s := fmt.Sprintf("error indent: %v\n", err) 1064 | return s 1065 | } 1066 | // byts = bytes.Replace(byts, []byte(`\u003c`), []byte("<"), -1) 1067 | // byts = bytes.Replace(byts, []byte(`\u003e`), []byte(">"), -1) 1068 | // byts = bytes.Replace(byts, []byte(`\n`), []byte("\n"), -1) 1069 | return string(byts) 1070 | } 1071 | 1072 | // Decode the http request form into ptr2Struct; 1073 | // validating the CSRF token (https://en.wikipedia.org/wiki/Cross-site_request_forgery); 1074 | // deriving the 'populated' return value from the existence of the CSRF token. 1075 | // We *could* call Validate() on ptr2Struct if implemented; 1076 | // but valid is *more* than just populated. 1077 | func Decode(r *http.Request, ptr2Struct interface{}) (populated bool, err error) { 1078 | err = r.ParseForm() 1079 | if err != nil { 1080 | return false, errors.Wrapf(err, "cannot parse form: %v
\n
%v
", err, indentedDump(r.Form)) 1081 | } 1082 | return decode(r, ptr2Struct) 1083 | } 1084 | 1085 | // DecodeMultipartForm decodes the form into an instance of struct 1086 | // and checks the token against CSRF attacks (https://en.wikipedia.org/wiki/Cross-site_request_forgery) 1087 | func DecodeMultipartForm(r *http.Request, ptr2Struct interface{}) (populated bool, err error) { 1088 | err = ParseMultipartForm(r) 1089 | if err != nil { 1090 | return false, errors.Wrapf(err, "cannot parse multi part form: %v
\n
%v
", err, indentedDump(r.Form)) 1091 | } 1092 | return decode(r, ptr2Struct) 1093 | } 1094 | 1095 | func decode(r *http.Request, ptr2Struct interface{}) (populated bool, err error) { 1096 | 1097 | // 1098 | // check for empty requests 1099 | _, hasToken := r.Form["token"] // missing validation token 1100 | ln := len(r.Form) // request form is empty 1101 | // sm := r.FormValue("btnSubmit") != "" // submit btn would not be present in single dropdown forms with onclick 1102 | if ln > 0 && !hasToken { 1103 | log.Printf("warning: request params ignored, due to missing validation token") 1104 | } 1105 | if ln < 1 || !hasToken { 1106 | return false, nil 1107 | } 1108 | 1109 | err = New().ValidateFormToken(r.Form.Get("token")) 1110 | if err != nil { 1111 | return true, errors.Wrap(err, "form token exists; but invalid") 1112 | } 1113 | 1114 | dec := form.NewDecoder() 1115 | dec.SetTagName("json") 1116 | err = dec.Decode(ptr2Struct, r.Form) 1117 | if err != nil { 1118 | return true, errors.Wrapf(err, "cannot decode form: %v
\n
%v
", err, indentedDump(r.Form)) 1119 | } 1120 | 1121 | // this belongs outside of the library into application side 1122 | if false { 1123 | if vldr, ok := ptr2Struct.(Validator); ok { 1124 | _, valid := vldr.Validate() 1125 | if !valid { 1126 | return false, nil 1127 | } 1128 | } 1129 | } 1130 | 1131 | return true, nil 1132 | 1133 | } 1134 | --------------------------------------------------------------------------------