Validate profile form and use templates for fields

Let’s start first with a non-fancy validation method with just if
conditionals instead of bringing yet another complicated library.  I
hope i do not regret it.

I wanted to move all the input field to a template because all that
gobbledygook with the .input div and repeating the label in the
placeholder was starting to annoy me.  Now with error messages was even
more concerning.

I did not know whether the label should be a part of the input fields
or something that the template should do.  At the end i decided that
it makes more sense to be part of the input field because in the error
messages i use that same label, thus the template does not have a say
in that, and, besides, it was just easier to write the template.

The same with the error messages: i’ve seen frameworks that have a map
with the field’s id/name to the error slice, but then it would be
a bit harder to write the template.

I added AddError functions instead of just using append inside the
validator function, and have a local variable for whether it all went
OK, because i was worried that i would leave out the `ok = false`
in some conditions.

I had started writing “constructors” functions for InputField and
SelectField, but then had to add other methods to change the required
field and who knows what else, and in the end it was easier to just
construct the field inline.
This commit is contained in:
jordi fita mas 2023-01-31 15:40:12 +01:00
parent 89256d5b4c
commit 9f17f55547
9 changed files with 338 additions and 138 deletions

73
pkg/form.go Normal file
View File

@ -0,0 +1,73 @@
package pkg
import (
"context"
"net/mail"
)
type InputField struct {
Name string
Label string
Type string
Value string
Required bool
Errors []error
}
func (field *InputField) Equals(other *InputField) bool {
return field.Value == other.Value
}
func (field *InputField) IsEmpty() bool {
return field.Value == ""
}
func (field *InputField) HasValidEmail() bool {
_, err := mail.ParseAddress(field.Value)
return err == nil
}
type SelectOption struct {
Value string
Label string
}
type SelectField struct {
Name string
Label string
Selected string
Options []*SelectOption
Errors []error
}
func (field *SelectField) HasValidOption() bool {
for _, option := range field.Options {
if option.Value == field.Selected {
return true
}
}
return false
}
func MustGetOptions(ctx context.Context, conn *Conn, sql string, args ...interface{}) []*SelectOption {
rows, err := conn.Query(ctx, sql, args...)
if err != nil {
panic(err)
}
defer rows.Close()
var options []*SelectOption
for rows.Next() {
option := &SelectOption{}
err = rows.Scan(&option.Value, &option.Label)
if err != nil {
panic(err)
}
options = append(options, option)
}
if rows.Err() != nil {
panic(rows.Err())
}
return options
}

View File

@ -66,6 +66,10 @@ func pgettext(context string, str string, locale *Locale) string {
return locale.GetC(str, context) return locale.GetC(str, context)
} }
func gettext(str string, locale *Locale) string {
return locale.Get(str)
}
func mustGetAvailableLanguages(db *Db) []language.Tag { func mustGetAvailableLanguages(db *Db) []language.Tag {
rows, err := db.Query(context.Background(), "select lang_tag from language where selectable") rows, err := db.Query(context.Background(), "select lang_tag from language where selectable")
if err != nil { if err != nil {

View File

@ -2,7 +2,9 @@ package pkg
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"strings"
) )
type LanguageOption struct { type LanguageOption struct {
@ -10,66 +12,117 @@ type LanguageOption struct {
Name string Name string
} }
type ProfilePage struct { type profileForm struct {
Name string Name *InputField
Email string Email *InputField
Password string Password *InputField
PasswordConfirm string PasswordConfirm *InputField
Language string Language *SelectField
Languages []LanguageOption Valid bool
}
func newProfileForm(ctx context.Context, conn *Conn, locale *Locale) *profileForm {
automaticOption := pgettext("language option", "Automatic", locale)
languages := MustGetOptions(ctx, conn, "select 'und', $1 union all select lang_tag, endonym from language where selectable", automaticOption)
return &profileForm{
Name: &InputField{
Name: "name",
Label: pgettext("input", "User name", locale),
Type: "text",
Required: true,
},
Email: &InputField{
Name: "email",
Label: pgettext("input", "Email", locale),
Type: "email",
Required: true,
},
Password: &InputField{
Name: "password",
Label: pgettext("input", "Password", locale),
Type: "password",
},
PasswordConfirm: &InputField{
Name: "password_confirm",
Label: pgettext("input", "Password Confirmation", locale),
Type: "password",
},
Language: &SelectField{
Name: "language",
Label: pgettext("input", "Language", locale),
Options: languages,
},
}
}
func (form *profileForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.Email.Value = strings.TrimSpace(r.FormValue("email"))
form.Name.Value = strings.TrimSpace(r.FormValue("name"))
form.Password.Value = strings.TrimSpace(r.FormValue("password"))
form.PasswordConfirm.Value = strings.TrimSpace(r.FormValue("password_confirm"))
form.Language.Selected = r.FormValue("language")
return nil
}
func (form *profileForm) Validate(locale *Locale) bool {
form.Valid = true
if form.Email.IsEmpty() {
form.AppendInputError(form.Email, errors.New(gettext("Email can not be empty.", locale)))
} else if !form.Email.HasValidEmail() {
form.AppendInputError(form.Email, errors.New(gettext("This value is not a valid email. It should be like name@domain.com.", locale)))
}
if form.Name.IsEmpty() {
form.AppendInputError(form.Name, errors.New(gettext("Name can not be empty.", locale)))
}
if !form.PasswordConfirm.Equals(form.Password) {
form.AppendInputError(form.PasswordConfirm, errors.New(gettext("Confirmation does not match password.", locale)))
}
if !form.Language.HasValidOption() {
form.AppendSelectError(form.Language, errors.New(gettext("Selected language is not valid.", locale)))
}
return form.Valid
}
func (form *profileForm) AppendInputError(field *InputField, err error) {
field.Errors = append(field.Errors, err)
form.Valid = false
}
func (form *profileForm) AppendSelectError(field *SelectField, err error) {
field.Errors = append(field.Errors, err)
form.Valid = false
} }
func ProfileHandler() http.Handler { func ProfileHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := getUser(r) user := getUser(r)
conn := getConn(r) conn := getConn(r)
page := ProfilePage{ locale := getLocale(r)
Email: user.Email, form := newProfileForm(r.Context(), conn, locale)
Language: user.Language.String(),
}
if r.Method == "POST" { if r.Method == "POST" {
r.ParseForm() if err := form.Parse(r); err != nil {
page.Email = r.FormValue("email") http.Error(w, err.Error(), http.StatusBadRequest)
page.Name = r.FormValue("name") return
page.Password = r.FormValue("password")
page.PasswordConfirm = r.FormValue("password_confirm")
page.Language = r.FormValue("language")
cookie := conn.MustGetText(r.Context(), "", "update user_profile set name = $1, email = $2, lang_tag = $3 returning build_cookie()", page.Name, page.Email, page.Language)
setSessionCookie(w, cookie)
if page.Password != "" && page.Password == page.PasswordConfirm {
conn.MustExec(r.Context(), "select change_password($1)", page.Password)
} }
http.Redirect(w, r, "/profile", http.StatusSeeOther) if ok := form.Validate(locale); ok {
return cookie := conn.MustGetText(r.Context(), "", "update user_profile set name = $1, email = $2, lang_tag = $3 returning build_cookie()", form.Name.Value, form.Email.Value, form.Language.Selected)
setSessionCookie(w, cookie)
if form.Password.Value != "" {
conn.MustExec(r.Context(), "select change_password($1)", form.Password.Value)
}
company := getCompany(r)
http.Redirect(w, r, "/company/"+company.Slug+"/profile", http.StatusSeeOther)
return
}
w.WriteHeader(http.StatusUnprocessableEntity)
} else { } else {
page.Languages = mustGetLanguageOptions(r.Context(), conn) form.Name.Value = conn.MustGetText(r.Context(), "", "select name from user_profile")
if err := conn.QueryRow(r.Context(), "select name from user_profile").Scan(&page.Name); err != nil { form.Email.Value = user.Email
panic(nil) form.Language.Selected = user.Language.String()
}
} }
mustRenderAppTemplate(w, r, "profile.gohtml", page) mustRenderAppTemplate(w, r, "profile.gohtml", form)
}) })
} }
func mustGetLanguageOptions(ctx context.Context, conn *Conn) []LanguageOption {
rows, err := conn.Query(ctx, "select lang_tag, endonym from language where selectable")
if err != nil {
panic(err)
}
defer rows.Close()
var langs []LanguageOption
for rows.Next() {
var lang LanguageOption
err = rows.Scan(&lang.Tag, &lang.Name)
if err != nil {
panic(err)
}
langs = append(langs, lang)
}
if rows.Err() != nil {
panic(rows.Err())
}
return langs
}

View File

@ -24,7 +24,7 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
return "/company/" + company.Slug + uri return "/company/" + company.Slug + uri
}, },
}) })
if _, err := t.ParseFiles(templateFile(filename), templateFile(layout)); err != nil { if _, err := t.ParseFiles(templateFile(filename), templateFile(layout), templateFile("form.gohtml")); err != nil {
panic(err) panic(err)
} }
if err := t.ExecuteTemplate(wr, layout, data); err != nil { if err := t.ExecuteTemplate(wr, layout, data); err != nil {

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-01-31 13:28+0100\n" "POT-Creation-Date: 2023-01-31 15:17+0100\n"
"PO-Revision-Date: 2023-01-18 17:08+0100\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -56,13 +56,13 @@ msgstr "Entrada"
msgid "Invalid user or password" msgid "Invalid user or password"
msgstr "Nom dusuari o contrasenya incorrectes" msgstr "Nom dusuari o contrasenya incorrectes"
#: web/template/login.gohtml:17 web/template/profile.gohtml:19 #: web/template/login.gohtml:17 web/template/tax-details.gohtml:27
#: web/template/tax-details.gohtml:27 web/template/contacts-new.gohtml:27 #: web/template/contacts-new.gohtml:27 pkg/profile.go:37
msgctxt "input" msgctxt "input"
msgid "Email" msgid "Email"
msgstr "Correu-e" msgstr "Correu-e"
#: web/template/login.gohtml:22 web/template/profile.gohtml:27 #: web/template/login.gohtml:22 pkg/profile.go:43
msgctxt "input" msgctxt "input"
msgid "Password" msgid "Password"
msgstr "Contrasenya" msgstr "Contrasenya"
@ -82,32 +82,17 @@ msgctxt "title"
msgid "User Access Data" msgid "User Access Data"
msgstr "Dades accés usuari" msgstr "Dades accés usuari"
#: web/template/profile.gohtml:14 #: web/template/profile.gohtml:16
msgctxt "input"
msgid "User name"
msgstr "Nom dusuari"
#: web/template/profile.gohtml:23
msgctxt "title" msgctxt "title"
msgid "Password Change" msgid "Password Change"
msgstr "Canvi contrasenya" msgstr "Canvi contrasenya"
#: web/template/profile.gohtml:32 #: web/template/profile.gohtml:23
msgctxt "input" msgctxt "title"
msgid "Password Confirmation"
msgstr "Confirmació contrasenya"
#: web/template/profile.gohtml:37
msgctxt "input"
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: web/template/profile.gohtml:40 #: web/template/profile.gohtml:27 web/template/tax-details.gohtml:133
msgctxt "language option"
msgid "Automatic"
msgstr "Automàtic"
#: web/template/profile.gohtml:46 web/template/tax-details.gohtml:133
msgctxt "action" msgctxt "action"
msgid "Save changes" msgid "Save changes"
msgstr "Desa canvis" msgstr "Desa canvis"
@ -244,3 +229,43 @@ msgstr "Afegeix nou impost"
msgctxt "title" msgctxt "title"
msgid "New Contact" msgid "New Contact"
msgstr "Nou contacte" msgstr "Nou contacte"
#: pkg/profile.go:26
msgctxt "language option"
msgid "Automatic"
msgstr "Automàtic"
#: pkg/profile.go:31
msgctxt "input"
msgid "User name"
msgstr "Nom dusuari"
#: pkg/profile.go:48
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmació contrasenya"
#: pkg/profile.go:53
msgctxt "input"
msgid "Language"
msgstr "Idioma"
#: pkg/profile.go:74
msgid "Email can not be empty."
msgstr "No podeu deixar el correu-e en blanc."
#: pkg/profile.go:76
msgid "This value is not a valid email. It should be like name@domain.com."
msgstr "Aquest valor no és un correu-e vàlid. Hauria de ser similar a nom@domini.cat."
#: pkg/profile.go:79
msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc."
#: pkg/profile.go:82
msgid "Confirmation does not match password."
msgstr "La confirmació no és igual a la contrasenya."
#: pkg/profile.go:85
msgid "Selected language is not valid."
msgstr "Heu seleccionat un idioma que no és vàlid."

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-01-31 13:28+0100\n" "POT-Creation-Date: 2023-01-31 15:17+0100\n"
"PO-Revision-Date: 2023-01-18 17:45+0100\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -56,13 +56,13 @@ msgstr "Entrada"
msgid "Invalid user or password" msgid "Invalid user or password"
msgstr "Nombre de usuario o contraseña inválido" msgstr "Nombre de usuario o contraseña inválido"
#: web/template/login.gohtml:17 web/template/profile.gohtml:19 #: web/template/login.gohtml:17 web/template/tax-details.gohtml:27
#: web/template/tax-details.gohtml:27 web/template/contacts-new.gohtml:27 #: web/template/contacts-new.gohtml:27 pkg/profile.go:37
msgctxt "input" msgctxt "input"
msgid "Email" msgid "Email"
msgstr "Correo-e" msgstr "Correo-e"
#: web/template/login.gohtml:22 web/template/profile.gohtml:27 #: web/template/login.gohtml:22 pkg/profile.go:43
msgctxt "input" msgctxt "input"
msgid "Password" msgid "Password"
msgstr "Contraseña" msgstr "Contraseña"
@ -82,32 +82,17 @@ msgctxt "title"
msgid "User Access Data" msgid "User Access Data"
msgstr "Datos acceso usuario" msgstr "Datos acceso usuario"
#: web/template/profile.gohtml:14 #: web/template/profile.gohtml:16
msgctxt "input"
msgid "User name"
msgstr "Nombre de usuario"
#: web/template/profile.gohtml:23
msgctxt "title" msgctxt "title"
msgid "Password Change" msgid "Password Change"
msgstr "Cambio de contraseña" msgstr "Cambio de contraseña"
#: web/template/profile.gohtml:32 #: web/template/profile.gohtml:23
msgctxt "input" msgctxt "title"
msgid "Password Confirmation"
msgstr "Confirmación contrasenya"
#: web/template/profile.gohtml:37
msgctxt "input"
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: web/template/profile.gohtml:40 #: web/template/profile.gohtml:27 web/template/tax-details.gohtml:133
msgctxt "language option"
msgid "Automatic"
msgstr "Automático"
#: web/template/profile.gohtml:46 web/template/tax-details.gohtml:133
msgctxt "action" msgctxt "action"
msgid "Save changes" msgid "Save changes"
msgstr "Guardar cambios" msgstr "Guardar cambios"
@ -244,3 +229,43 @@ msgstr "Añadir nuevo impuesto"
msgctxt "title" msgctxt "title"
msgid "New Contact" msgid "New Contact"
msgstr "Nuevo contacto" msgstr "Nuevo contacto"
#: pkg/profile.go:26
msgctxt "language option"
msgid "Automatic"
msgstr "Automático"
#: pkg/profile.go:31
msgctxt "input"
msgid "User name"
msgstr "Nombre de usuario"
#: pkg/profile.go:48
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmación contrasenya"
#: pkg/profile.go:53
msgctxt "input"
msgid "Language"
msgstr "Idioma"
#: pkg/profile.go:74
msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco."
#: pkg/profile.go:76
msgid "This value is not a valid email. It should be like name@domain.com."
msgstr "Este valor no es un correo-e válido. Tiene que ser parecido a nombre@dominio.es."
#: pkg/profile.go:79
msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco."
#: pkg/profile.go:82
msgid "Confirmation does not match password."
msgstr "La confirmación no corresponde con la contraseña."
#: pkg/profile.go:85
msgid "Selected language is not valid."
msgstr "Habéis escogido un idioma que no es válido."

View File

@ -320,6 +320,12 @@ input.width-2x {
top: 1rem; top: 1rem;
} }
.input ul {
font-size: .8em;
padding-left: 1em;
color: var(--numerus--color--red);
}
[lang="en"] input:not([required]) + label::after { [lang="en"] input:not([required]) + label::after {
content: " (optional)" content: " (optional)"
} }

33
web/template/form.gohtml Normal file
View File

@ -0,0 +1,33 @@
{{ define "input-field" -}}
<div class="input {{ if .Errors }}has-errors{{ end }}">
<input type="{{ .Type }}" name="{{ .Name }}" id="{{ .Name }}-field"
{{ if .Required }}required="required"{{ end }} value="{{ .Value }}" placeholder="{{ .Label }}">
<label for="{{ .Name }}-field">{{ .Label }}</label>
{{ if .Errors }}
<ul>
{{- range $error := .Errors }}
<li>{{ . }}</li>
{{- end }}
</ul>
{{ end }}
</div>
{{- end }}
{{ define "select-field" -}}
<div class="input {{ if .Errors }}has-errors{{ end }}">
<select id="{{ .Name }}-field" name="{{ .Name }}">
{{- range $option := .Options }}
<option value="{{ .Value }}"
{{ if eq .Value $.Selected }}selected="selected"{{ end }}>{{ .Label }}</option>
{{- end }}
</select>
<label for="{{ .Name }}-field">{{ .Label }}</label>
{{ if .Errors }}
<ul>
{{- range $error := .Errors }}
<li>{{ . }}</li>
{{- end }}
</ul>
{{ end }}
</div>
{{- end }}

View File

@ -1,50 +1,31 @@
{{ define "title" -}} {{ define "title" -}}
{{( pgettext "User Settings" "title" )}} {{( pgettext "User Settings" "title" )}}
{{- end }} {{- end }}
{{ define "content" }} {{ define "content" }}
<section class="dialog-content"> <section class="dialog-content">
<h2>{{(pgettext "User Settings" "title")}}</h2> <h2>{{(pgettext "User Settings" "title")}}</h2>
<form method="POST" action="/profile"> <form method="POST">
<fieldset class="full-width"> <fieldset class="full-width">
<legend>{{( pgettext "User Access Data" "title" )}}</legend> <legend>{{( pgettext "User Access Data" "title" )}}</legend>
<div class="input"> {{ template "input-field" .Name }}
<input type="text" name="name" id="name" required="required" value="{{ .Name }}" placeholder="{{( pgettext "User name" "input" )}}"> {{ template "input-field" .Email }}
<label for="name">{{( pgettext "User name" "input" )}}</label> </fieldset>
</div> <fieldset class="full-width">
<legend>{{( pgettext "Password Change" "title" )}}</legend>
<div class="input"> {{ template "input-field" .Password }}
<input type="email" name="email" id="email" required="required" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}"> {{ template "input-field" .PasswordConfirm }}
<label for="email">{{( pgettext "Email" "input" )}}</label> </fieldset>
</div>
</fieldset>
<fieldset class="full-width">
<legend>{{( pgettext "Password Change" "title" )}}</legend>
<div class="input"> <fieldset>
<input type="password" name="password" id="password" value="{{ .Password }}" placeholder="{{( pgettext "Password" "input" )}}"> <legend id="language-legend">{{( pgettext "Language" "title" )}}</legend>
<label for="password">{{( pgettext "Password" "input" )}}</label>
</div>
<div class="input"> {{ template "select-field" .Language }}
<input type="password" name="password_confirm" id="password_confirm" value="{{ .PasswordConfirm }}" placeholder="{{( pgettext "Password Confirmation" "input" )}}">
<label for="password_confirm">{{( pgettext "Password Confirmation" "input" )}}</label>
</div>
</fieldset>
<fieldset> <button type="submit">{{( pgettext "Save changes" "action" )}}</button>
<legend id="language-legend">{{( pgettext "Language" "input" )}}</legend> </fieldset>
</form>
<select id="language" name="language" aria-labelledby="language-legend"> </section>
<option value="und">{{( pgettext "Automatic" "language option" )}}</option>
{{- range $language := .Languages }}
<option value="{{ .Tag }}" {{ if eq .Tag $.Language }}selected="selected"{{ end }}>{{ .Name }}</option>
{{- end }}
</select>
<button type="submit">{{( pgettext "Save changes" "action" )}}</button>
</fieldset>
</form>
</section>
{{- end }} {{- end }}