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)
}
func gettext(str string, locale *Locale) string {
return locale.Get(str)
}
func mustGetAvailableLanguages(db *Db) []language.Tag {
rows, err := db.Query(context.Background(), "select lang_tag from language where selectable")
if err != nil {

View File

@ -2,7 +2,9 @@ package pkg
import (
"context"
"errors"
"net/http"
"strings"
)
type LanguageOption struct {
@ -10,66 +12,117 @@ type LanguageOption struct {
Name string
}
type ProfilePage struct {
Name string
Email string
Password string
PasswordConfirm string
Language string
Languages []LanguageOption
type profileForm struct {
Name *InputField
Email *InputField
Password *InputField
PasswordConfirm *InputField
Language *SelectField
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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
conn := getConn(r)
page := ProfilePage{
Email: user.Email,
Language: user.Language.String(),
}
locale := getLocale(r)
form := newProfileForm(r.Context(), conn, locale)
if r.Method == "POST" {
r.ParseForm()
page.Email = r.FormValue("email")
page.Name = r.FormValue("name")
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)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
if ok := form.Validate(locale); ok {
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 {
page.Languages = mustGetLanguageOptions(r.Context(), conn)
if err := conn.QueryRow(r.Context(), "select name from user_profile").Scan(&page.Name); err != nil {
panic(nil)
}
form.Name.Value = conn.MustGetText(r.Context(), "", "select name from user_profile")
form.Email.Value = user.Email
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
},
})
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)
}
if err := t.ExecuteTemplate(wr, layout, data); err != nil {

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: numerus\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"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n"
@ -56,13 +56,13 @@ msgstr "Entrada"
msgid "Invalid user or password"
msgstr "Nom dusuari o contrasenya incorrectes"
#: web/template/login.gohtml:17 web/template/profile.gohtml:19
#: web/template/tax-details.gohtml:27 web/template/contacts-new.gohtml:27
#: web/template/login.gohtml:17 web/template/tax-details.gohtml:27
#: web/template/contacts-new.gohtml:27 pkg/profile.go:37
msgctxt "input"
msgid "Email"
msgstr "Correu-e"
#: web/template/login.gohtml:22 web/template/profile.gohtml:27
#: web/template/login.gohtml:22 pkg/profile.go:43
msgctxt "input"
msgid "Password"
msgstr "Contrasenya"
@ -82,32 +82,17 @@ msgctxt "title"
msgid "User Access Data"
msgstr "Dades accés usuari"
#: web/template/profile.gohtml:14
msgctxt "input"
msgid "User name"
msgstr "Nom dusuari"
#: web/template/profile.gohtml:23
#: web/template/profile.gohtml:16
msgctxt "title"
msgid "Password Change"
msgstr "Canvi contrasenya"
#: web/template/profile.gohtml:32
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmació contrasenya"
#: web/template/profile.gohtml:37
msgctxt "input"
#: web/template/profile.gohtml:23
msgctxt "title"
msgid "Language"
msgstr "Idioma"
#: web/template/profile.gohtml:40
msgctxt "language option"
msgid "Automatic"
msgstr "Automàtic"
#: web/template/profile.gohtml:46 web/template/tax-details.gohtml:133
#: web/template/profile.gohtml:27 web/template/tax-details.gohtml:133
msgctxt "action"
msgid "Save changes"
msgstr "Desa canvis"
@ -244,3 +229,43 @@ msgstr "Afegeix nou impost"
msgctxt "title"
msgid "New Contact"
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 ""
"Project-Id-Version: numerus\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"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n"
@ -56,13 +56,13 @@ msgstr "Entrada"
msgid "Invalid user or password"
msgstr "Nombre de usuario o contraseña inválido"
#: web/template/login.gohtml:17 web/template/profile.gohtml:19
#: web/template/tax-details.gohtml:27 web/template/contacts-new.gohtml:27
#: web/template/login.gohtml:17 web/template/tax-details.gohtml:27
#: web/template/contacts-new.gohtml:27 pkg/profile.go:37
msgctxt "input"
msgid "Email"
msgstr "Correo-e"
#: web/template/login.gohtml:22 web/template/profile.gohtml:27
#: web/template/login.gohtml:22 pkg/profile.go:43
msgctxt "input"
msgid "Password"
msgstr "Contraseña"
@ -82,32 +82,17 @@ msgctxt "title"
msgid "User Access Data"
msgstr "Datos acceso usuario"
#: web/template/profile.gohtml:14
msgctxt "input"
msgid "User name"
msgstr "Nombre de usuario"
#: web/template/profile.gohtml:23
#: web/template/profile.gohtml:16
msgctxt "title"
msgid "Password Change"
msgstr "Cambio de contraseña"
#: web/template/profile.gohtml:32
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmación contrasenya"
#: web/template/profile.gohtml:37
msgctxt "input"
#: web/template/profile.gohtml:23
msgctxt "title"
msgid "Language"
msgstr "Idioma"
#: web/template/profile.gohtml:40
msgctxt "language option"
msgid "Automatic"
msgstr "Automático"
#: web/template/profile.gohtml:46 web/template/tax-details.gohtml:133
#: web/template/profile.gohtml:27 web/template/tax-details.gohtml:133
msgctxt "action"
msgid "Save changes"
msgstr "Guardar cambios"
@ -244,3 +229,43 @@ msgstr "Añadir nuevo impuesto"
msgctxt "title"
msgid "New Contact"
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;
}
.input ul {
font-size: .8em;
padding-left: 1em;
color: var(--numerus--color--red);
}
[lang="en"] input:not([required]) + label::after {
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" -}}
{{( pgettext "User Settings" "title" )}}
{{( pgettext "User Settings" "title" )}}
{{- end }}
{{ define "content" }}
<section class="dialog-content">
<h2>{{(pgettext "User Settings" "title")}}</h2>
<form method="POST" action="/profile">
<fieldset class="full-width">
<legend>{{( pgettext "User Access Data" "title" )}}</legend>
<section class="dialog-content">
<h2>{{(pgettext "User Settings" "title")}}</h2>
<form method="POST">
<fieldset class="full-width">
<legend>{{( pgettext "User Access Data" "title" )}}</legend>
<div class="input">
<input type="text" name="name" id="name" required="required" value="{{ .Name }}" placeholder="{{( pgettext "User name" "input" )}}">
<label for="name">{{( pgettext "User name" "input" )}}</label>
</div>
{{ template "input-field" .Name }}
{{ template "input-field" .Email }}
</fieldset>
<fieldset class="full-width">
<legend>{{( pgettext "Password Change" "title" )}}</legend>
<div class="input">
<input type="email" name="email" id="email" required="required" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}">
<label for="email">{{( pgettext "Email" "input" )}}</label>
</div>
</fieldset>
<fieldset class="full-width">
<legend>{{( pgettext "Password Change" "title" )}}</legend>
{{ template "input-field" .Password }}
{{ template "input-field" .PasswordConfirm }}
</fieldset>
<div class="input">
<input type="password" name="password" id="password" value="{{ .Password }}" placeholder="{{( pgettext "Password" "input" )}}">
<label for="password">{{( pgettext "Password" "input" )}}</label>
</div>
<fieldset>
<legend id="language-legend">{{( pgettext "Language" "title" )}}</legend>
<div class="input">
<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>
{{ template "select-field" .Language }}
<fieldset>
<legend id="language-legend">{{( pgettext "Language" "input" )}}</legend>
<select id="language" name="language" aria-labelledby="language-legend">
<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>
<button type="submit">{{( pgettext "Save changes" "action" )}}</button>
</fieldset>
</form>
</section>
{{- end }}