Explorar el Código

Various changes:

  1. Remove Class struct field
  2. Remove Type struct field and replace it with Kind
  3. Replace templates/field.html with fields.html template
  4. Use separate definitions for each form field type in the template
  5. Add checkbox type and NewCheckboxField constructor
  6. Add SetAutoComplete method for text fields
  7. Add support for custom validators errors for i18n purposes
Bozhin Zafirov hace 8 meses
padre
commit
6048bf0b53
Se han modificado 7 ficheros con 205 adiciones y 126 borrados
  1. 25 17
      constructors.go
  2. 9 0
      fallbackerror.go
  3. 36 0
      fields.html
  4. 32 30
      forms.go
  5. 0 10
      templates/field.html
  6. 2 2
      validate.go
  7. 101 67
      validators.go

+ 25 - 17
constructors.go

@@ -11,22 +11,28 @@ func strFromPtr(str *string) (s string) {
 }
 
 /* Generate new CharField field with type text */
-func NewCharField(Name string, Value *string) *FormField {
+func NewCharField(name string, value *string) *FormField {
 	return &FormField{
-		Name:  Name,
-		Value: strFromPtr(Value),
-		Class: "form-control",
-		Type:  "text",
+		Name:  name,
+		Value: strFromPtr(value),
+		Kind:  "text",
 	}
 }
 
-/* Generate a new hidden text field */
-func NewHiddenField(Name string, Value *string) *FormField {
+/* Generate new password field */
+func NewPasswordField(name string) *FormField {
+	return &FormField{
+		Name: name,
+		Kind: "password",
+	}
+}
+
+/* Generate a new checkbox field */
+func NewCheckboxField(name string, checked bool) *FormField {
 	return &FormField{
-		Name:  Name,
-		Value: strFromPtr(Value),
-		Class: "form-control",
-		Type:  "hidden",
+		Name:    name,
+		Checked: checked,
+		Kind:    "checkbox",
 	}
 }
 
@@ -35,8 +41,7 @@ func NewCsrfField(w http.ResponseWriter, r *http.Request, secure bool) *FormFiel
 	return &FormField{
 		Name:   csrfFieldName,
 		Value:  csrfToken(w, r, secure),
-		Class:  "form-control",
-		Type:   "hidden",
+		Kind:   "hidden",
 		Sticky: true,
 		Validators: ValidatorsList{
 			ValidCSRF(r),
@@ -44,8 +49,11 @@ func NewCsrfField(w http.ResponseWriter, r *http.Request, secure bool) *FormFiel
 	}
 }
 
-/* Generate new CharField field with type password */
-func NewPasswordField(Name string) *FormField {
-	field := NewCharField(Name, nil).SetType("password")
-	return field
+/* Generate a new hidden text field */
+func NewHiddenField(name string, value *string) *FormField {
+	return &FormField{
+		Name:  name,
+		Value: strFromPtr(value),
+		Kind:  "hidden",
+	}
 }

+ 9 - 0
fallbackerror.go

@@ -0,0 +1,9 @@
+package forms
+
+/* fallbackError returns err if not nil or defaultErr otherwise */
+func fallbackError(err error, defaultErr error) error {
+	if err != nil {
+		return err
+	}
+	return defaultErr
+}

+ 36 - 0
fields.html

@@ -0,0 +1,36 @@
+{{define "checkbox"}}
+<div class="form-check">
+  <input type="checkbox" class="form-check-input" id="{{.Name}}" name="{{.Name}}"{{if .Required}} required{{end}}{{if .Checked}} checked{{end}}>
+{{if .Label}}  <label class="form-check-label" for="{{.Name}}">{{.Label}}</label>{{end}}
+</div>
+{{if .Help}}  <div id="{{.Name}}Help" class="form-text">{{.Help}}</div>{{end}}
+{{if .Error}}{{range $e := .Error}}<div class="text-danger">{{$e}}</div>{{end}}{{end}}
+{{end}}
+
+{{define "password"}}
+{{if .Label}}<label class="form-label" for="{{.Name}}">{{.Label}}</label>{{end}}
+<input type="password" id="{{.Name}}" name="{{.Name}}" class="form-control"
+	{{- if .Placeholder}} placeholder="{{.Placeholder}}"{{end}}
+	{{- if .Help}} aria-describedby="{{.Name}}Help"{{end}}
+    {{- if .Required}} required{{end}}
+    {{- if .AutoFocus}} autofocus{{end}}>
+    {{if .Help}}<div id="{{.Name}}Help" class="form-text">{{.Help}}</div>{{end}}
+{{if .Error}}{{range $e := .Error}}<div class="text-danger">{{$e}}</div>{{end}}{{end}}
+{{end}}
+
+{{define "text"}}
+{{if .Label}}<label class="form-label" for="{{.Name}}">{{.Label}}</label>{{end}}
+<input type="text" id="{{.Name}}" name="{{.Name}}" class="form-control"
+	{{- if .Value}} value="{{.Value}}"{{end}}
+	{{- if .Placeholder}} placeholder="{{.Placeholder}}"{{end}}
+	{{- if .Help}} aria-describedby="{{.Name}}Help"{{end}}
+	{{- if .AutoComplete}} autocomplete="{{.AutoComplete}}"{{end}}
+    {{- if .Required}} required{{end}}
+    {{- if .AutoFocus}} autofocus{{end}}>
+    {{if .Help}}<div id="{{.Name}}Help" class="form-text">{{.Help}}</div>{{end}}
+{{if .Error}}{{range $e := .Error}}<div class="text-danger">{{$e}}</div>{{end}}{{end}}
+{{end}}
+
+{{define "hidden"}}
+<input type="hidden" id="{{.Name}}" name="{{.Name}}" value="{{.Value}}">
+{{end}}

+ 32 - 30
forms.go

@@ -3,7 +3,7 @@ package forms
 import (
 	"bytes"
 	"context"
-	"embed"
+	_ "embed"
 	"html/template"
 	"strconv"
 )
@@ -16,18 +16,19 @@ type ValidatorsList []ValidatorFunc
 
 /* A general purpose form  field struct */
 type FormField struct {
-	Name        string
-	Error       []error
-	Value       string
-	Label       string
-	Class       string
-	Type        string
-	Placeholder string
-	Help        string
-	Required    bool
-	AutoFocus   bool
-	Sticky      bool
-	Validators  ValidatorsList
+	Name         string
+	Error        []error
+	Value        string
+	Label        string
+	Kind         string
+	Placeholder  string
+	Help         string
+	Required     bool
+	AutoFocus    bool
+	Sticky       bool
+	Checked      bool
+	AutoComplete string
+	Validators   ValidatorsList
 }
 
 /* AddError appends a new error message to the form field errors list */
@@ -54,12 +55,6 @@ func (f *FormField) SetLabel(label string) *FormField {
 	return f
 }
 
-/* SetClass configures class name in form field */
-func (f *FormField) SetClass(class string) *FormField {
-	f.Class = class
-	return f
-}
-
 /* SetRequired marks FormField as mandatory */
 func (f *FormField) SetRequired() *FormField {
 	f.Required = true
@@ -72,9 +67,9 @@ func (f *FormField) SetAutoFocus() *FormField {
 	return f
 }
 
-/* SetType specifies input field type */
-func (f *FormField) SetType(t string) *FormField {
-	f.Type = t
+/* SetKind specifies input field type */
+func (f *FormField) SetKind(t string) *FormField {
+	f.Kind = t
 	return f
 }
 
@@ -90,6 +85,15 @@ func (f *FormField) SetHelp(help string) *FormField {
 	return f
 }
 
+/* SetAutoComplete sets the autocomplete flag for a field */
+func (f *FormField) SetAutoComplete(on bool) *FormField {
+	f.AutoComplete = "off"
+	if on {
+		f.AutoComplete = "on"
+	}
+	return f
+}
+
 /* GetString returns FormField.Value as string */
 func (f *FormField) GetString() string {
 	return f.Value
@@ -137,8 +141,8 @@ func (f *FormField) GetChecked() bool {
 }
 
 /* formFieldTemplate is a template to render FormField element in HTML format */
-//go:embed templates/*
-var formTemplates embed.FS
+//go:embed fields.html
+var fieldsTemplateStr string
 
 /* must checks for compile/start up errors */
 func must(r any, e error) any {
@@ -148,17 +152,15 @@ func must(r any, e error) any {
 	return r
 }
 
-/* formTemplate is compiled template to render FormField element in HTML format */
-var formTemplate = template.Must(
-	template.New("FormField").Parse(
-		string(must(formTemplates.ReadFile("templates/field.html")).([]byte)),
-	),
+/* fieldsTemplate is compiled template to render FormField element in HTML format */
+var fieldsTemplate = template.Must(
+	template.New("FormField").Parse(fieldsTemplateStr),
 )
 
 /* HTML renders FormField element in html format */
 func (f *FormField) HTML() template.HTML {
 	var buffer bytes.Buffer
-	if err := formTemplate.Execute(&buffer, f); err == nil {
+	if err := fieldsTemplate.ExecuteTemplate(&buffer, f.Kind, f); err == nil {
 		return template.HTML(buffer.String())
 	}
 	return template.HTML("")

+ 0 - 10
templates/field.html

@@ -1,10 +0,0 @@
-{{ if .Label }}<label class="form-label" for="{{ .Name }}">{{ .Label }}</label>{{ end }}
-<input type="{{ .Type }}" id="{{ .Name }}" name="{{ .Name }}"
-	{{- if .Class }} class="{{ .Class }}"{{ end }}
-	{{- if and .Value (ne .Type "password") }} value="{{ .Value }}"{{ end }}
-	{{- if .Placeholder}} placeholder="{{ .Placeholder }}"{{ end }}
-	{{- if .Help }} aria-describedby="{{ .Name }}Help"{{ end }}
-	{{- if .Required }} required{{ end }}
-	{{- if .AutoFocus }} autofocus{{ end }}>
-	{{ if .Help }}<div id="{{ .Name }}Help" class="form-text">{{ .Help }}</div>{{ end }}
-{{ if .Error }}{{ range $e := .Error }}<div class="text-danger">{{ $e }}</div>{{ end }}{{ end }}

+ 2 - 2
validate.go

@@ -6,7 +6,7 @@ import (
 )
 
 /* ValidateForm parses a POST form into a pre-defined struct */
-func ValidateForm(r *http.Request, p any) (formErr error) {
+func ValidateForm(r *http.Request, p any, defaultErr error) (formErr error) {
 	/* asserts */
 	switch {
 	case r == nil:
@@ -56,7 +56,7 @@ func ValidateForm(r *http.Request, p any) (formErr error) {
 		}
 		/* assert on required fields */
 		if ff.Required && ff.Value == "" {
-			ff.Error = append(ff.Error, ERequiredField)
+			ff.Error = append(ff.Error, fallbackError(defaultErr, ERequiredField))
 			formErr = EFormHasErrors
 			continue
 		}

+ 101 - 67
validators.go

@@ -12,12 +12,9 @@ import (
 
 /* validation errors */
 var (
-	EInvalidInteger   = errors.New("not a valid integer value")
-	EInvalidFloat     = errors.New("not a valid float value")
-	ERequired         = errors.New("this field is required")
-	EInvalidEmail     = errors.New("invalid email address")
-	EInvalidDomain    = errors.New("invalid domain name")
-	EInvalidDomainTLD = errors.New("invalid domain TLD")
+	EInvalidInteger = errors.New("not a valid integer value")
+	EInvalidFloat   = errors.New("not a valid float value")
+	ERequired       = errors.New("this field is required")
 )
 
 /* A globaly defined set with valid domain TLDs */
@@ -65,13 +62,12 @@ func ValidLettersGeneric(Letters string, Error error) ValidatorFunc {
 	return Callback
 }
 
-/* ValidLength makes sure that a string length is between specified min and max values. */
-func ValidLength(min, max int) ValidatorFunc {
-	var ELength = errors.New(
-		fmt.Sprintf("must be a string between %d and %d characters in length", min, max))
+/* ValidLength makes sure that a string length is between specified vMin and vMax values. */
+func ValidLength(vMin, vMax int, err error) ValidatorFunc {
+	ELength := fmt.Errorf("must be a string between %d and %d characters in length", vMin, vMax)
 	return func(field *FormField, ctx context.Context) error {
-		if len(field.GetString()) != 0 && (len(field.GetString()) < min || len(field.GetString()) > max) {
-			return ELength
+		if len(field.GetString()) != 0 && (len(field.GetString()) < vMin || len(field.GetString()) > vMax) {
+			return fallbackError(err, ELength)
 		}
 		return nil
 	}
@@ -146,79 +142,117 @@ func ValidBetweenFloat(min, max float64) ValidatorFunc {
 }
 
 /* ValidFieldEqualTo is a validator that checks if two fields have the same value. */
-func ValidFieldEqualTo(Other *FormField, err error) ValidatorFunc {
+func ValidFieldEqualTo(other *FormField, err error) ValidatorFunc {
+	defaultErr := errors.New("The text field must match other field's text exactly")
 	return func(field *FormField, ctx context.Context) error {
-		if field.GetString() != Other.GetString() {
-			return err
+		if field.GetString() != other.GetString() {
+			return fallbackError(err, defaultErr)
 		}
 		return nil
 	}
 }
 
-/* validDomainNameStr performs checks if the domain name stored in *FormField is valid */
-func validDomainNameStr(domain string) error {
-	domain = strings.ToLower(strings.TrimSpace(domain))
-	domainLen := len(domain)
-	/* common domain name checks */
-	if domainLen == 0 || domainLen > 253 || strings.ContainsAny(domain, " \t\r\n") {
-		return EInvalidDomain
-	}
-	if domain[0] == '.' || domain[domainLen-1] == '.' {
-		return EInvalidDomain
-	}
-	/* make sure domain name only contains allowed letters */
-	for _, r := range domain {
-		if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '-' {
-			continue
+/* ValidExct checks if text field has exact value */
+func ValidExact(text string, err error) ValidatorFunc {
+	defaultErr := fmt.Errorf("The the text field must be exactly '%s'", text)
+	return func(field *FormField, ctx context.Context) error {
+		if field.GetString() != text {
+			return fallbackError(err, defaultErr)
 		}
-		return EInvalidDomain
-	}
-	/* split to levels (tld, domain[, subdomain...] */
-	levels := strings.Split(domain, ".")
-	levelsLen := len(levels)
-	if levelsLen < 2 {
-		return EInvalidDomain
+		return nil
 	}
-	/* perform common checks on levels */
-	for _, level := range levels {
-		levelLen := len(level)
-		if levelLen == 0 || levelLen > 63 {
-			return EInvalidDomain
+}
+
+/* validDomainNameStr performs checks if the domain name stored in *FormField is valid */
+func validDomainNameStr(err error) func(string) error {
+	EInvalidDomain := errors.New("invalid domain name")
+	return func(domain string) error {
+		domain = strings.ToLower(strings.TrimSpace(domain))
+		domainLen := len(domain)
+		/* common domain name checks */
+		if domainLen == 0 || domainLen > 253 || strings.ContainsAny(domain, " \t\r\n") {
+			return fallbackError(err, EInvalidDomain)
+		}
+		if domain[0] == '.' || domain[domainLen-1] == '.' {
+			return fallbackError(err, EInvalidDomain)
 		}
-		if level[0] == '-' || level[levelLen-1] == '-' {
-			return EInvalidDomain
+		/* make sure domain name only contains allowed letters */
+		for _, r := range domain {
+			if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '-' {
+				continue
+			}
+			return fallbackError(err, EInvalidDomain)
 		}
+		/* split to levels (tld, domain[, subdomain...] */
+		levels := strings.Split(domain, ".")
+		levelsLen := len(levels)
+		if levelsLen < 2 {
+			return fallbackError(err, EInvalidDomain)
+		}
+		/* perform common checks on levels */
+		for _, level := range levels {
+			levelLen := len(level)
+			if levelLen == 0 || levelLen > 63 {
+				return fallbackError(err, EInvalidDomain)
+			}
+			if level[0] == '-' || level[levelLen-1] == '-' {
+				return fallbackError(err, EInvalidDomain)
+			}
 
+		}
+		return nil
 	}
-	/* make sure tld has proper size and is whitelisted */
-	domainTLD := levels[len(levels)-1]
-	if len(domainTLD) < 2 {
-		return EInvalidDomainTLD
+}
+
+/* ValidDomainName performs basic domain name checks */
+func ValidDomainName(err error) ValidatorFunc {
+	callbackCheck := validDomainNameStr(err)
+	/* domain name checks */
+	return func(field *FormField, ctx context.Context) error {
+		return callbackCheck(field.GetString())
 	}
-	if _, ok := validTLDs[domainTLD]; !ok {
-		return EInvalidDomainTLD
+}
+
+/* ValidDomainNameTLD checks if a field in a domain name has a whitelisted TLD */
+func ValidDomainNameTLD(err error) ValidatorFunc {
+	EInvalidDomainTLD := errors.New("invalid domain TLD")
+	return func(field *FormField, ctx context.Context) error {
+		/* make sure tld has proper size and is whitelisted */
+		domain := strings.ToLower(strings.TrimSpace(field.GetString()))
+		levels := strings.Split(domain, ".")
+		domainTLD := levels[len(levels)-1]
+		if len(domainTLD) < 2 {
+			return fallbackError(err, EInvalidDomainTLD)
+		}
+		if _, ok := validTLDs[domainTLD]; !ok {
+			return fallbackError(err, EInvalidDomainTLD)
+		}
+		return nil
 	}
-	return nil
 }
 
 /* ValidEmail checks if field contains a valid email address */
-func ValidEmail(field *FormField, ctx context.Context) error {
-	/* sanitize input */
-	addrStr := strings.TrimSpace(field.GetString())
-	if addrStr == "" || !utf8.ValidString(addrStr) || strings.ContainsAny(addrStr, " \t\r\n") {
-		return EInvalidEmail
-	}
-	/* use mail.ParseAddress on the sanitized text */
-	addr, err := mail.ParseAddress(addrStr)
-	if err != nil || addr.Address != addrStr {
-		return EInvalidEmail
-	}
-	/* make sure email address has a valid domain name */
-	parts := strings.Split(addrStr, "@")
-	if err := validDomainNameStr(parts[len(parts)-1]); err != nil {
-		return EInvalidEmail
+func ValidEmail(err error) ValidatorFunc {
+	EInvalidEmail := errors.New("invalid email address")
+	domainValidator := validDomainNameStr(nil)
+	return func(field *FormField, ctx context.Context) error {
+		/* sanitize input */
+		addrStr := strings.TrimSpace(field.GetString())
+		if addrStr == "" || !utf8.ValidString(addrStr) || strings.ContainsAny(addrStr, " \t\r\n") {
+			return fallbackError(err, EInvalidEmail)
+		}
+		/* use mail.ParseAddress on the sanitized text */
+		addr, err := mail.ParseAddress(addrStr)
+		if err != nil || addr.Address != addrStr {
+			return fallbackError(err, EInvalidEmail)
+		}
+		/* make sure email address has a valid domain name */
+		domain := strings.SplitN(addrStr, "@", 2)[1]
+		if err := domainValidator(domain); err != nil {
+			return fallbackError(err, EInvalidEmail)
+		}
+		return nil
 	}
-	return nil
 }
 
 /* ValidCSRF checks if CSRF token is valid */