Let's Go Processing forms › Displaying errors and repopulating fields
Previous · Contents · Next
Chapter 7.4.

Displaying errors and repopulating fields

Now that the snippetCreatePost handler is validating the data, the next stage is to manage these validation errors gracefully.

If there are any validation errors, we want to re-display the HTML form, highlighting the fields which failed validation and automatically re-populating any previously submitted data (so that the user doesn’t need to enter it again).

To do this, let’s begin by adding a new Form field to our templateData struct:

File: cmd/web/templates.go
package main

import (
    "html/template"
    "path/filepath"
    "time"

    "snippetbox.alexedwards.net/internal/models"
)

// Add a Form field with the type "any".
type templateData struct {
    CurrentYear int
    Snippet     models.Snippet
    Snippets    []models.Snippet
    Form        any
}

...

We’ll use this Form field to pass the validation errors and previously submitted data back to the template when we re-display the form.

Next let’s head back to our cmd/web/handlers.go file and define a new snippetCreateForm struct to hold the form data and any validation errors, and update our snippetCreatePost handler to use this.

Like so:

File: cmd/web/handlers.go
package main

...

// Define a snippetCreateForm struct to represent the form data and validation
// errors for the form fields. Note that all the struct fields are deliberately
// exported (i.e. start with a capital letter). This is because struct fields
// must be exported in order to be read by the html/template package when
// rendering the template.
type snippetCreateForm struct {
    Title       string
    Content     string
    Expires     int
    FieldErrors map[string]string
}

func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) {
    err := r.ParseForm()
    if err != nil {
        app.clientError(w, http.StatusBadRequest)
        return
    }

    // Get the expires value from the form as normal.
    expires, err := strconv.Atoi(r.PostForm.Get("expires"))
    if err != nil {
        app.clientError(w, http.StatusBadRequest)
        return
    }

    // Create an instance of the snippetCreateForm struct containing the values
    // from the form and an empty map for any validation errors.
    form := snippetCreateForm{
        Title:       r.PostForm.Get("title"),
        Content:     r.PostForm.Get("content"),
        Expires:     expires,
        FieldErrors: map[string]string{},
    }

    // Update the validation checks so that they operate on the snippetCreateForm
    // instance.
    if strings.TrimSpace(form.Title) == "" {
        form.FieldErrors["title"] = "This field cannot be blank"
    } else if utf8.RuneCountInString(form.Title) > 100 {
        form.FieldErrors["title"] = "This field cannot be more than 100 characters long"
    }

    if strings.TrimSpace(form.Content) == "" {
        form.FieldErrors["content"] = "This field cannot be blank"
    }

    if form.Expires != 1 && form.Expires != 7 && form.Expires != 365 {
        form.FieldErrors["expires"] = "This field must equal 1, 7 or 365"
    }

    // If there are any validation errors, then re-display the create.tmpl template,
    // passing in the snippetCreateForm instance as dynamic data in the Form 
    // field. Note that we use the HTTP status code 422 Unprocessable Entity 
    // when sending the response to indicate that there was a validation error.
    if len(form.FieldErrors) > 0 {
        data := app.newTemplateData(r)
        data.Form = form
        app.render(w, r, http.StatusUnprocessableEntity, "create.tmpl", data)
        return
    }

    // We also need to update this line to pass the data from the
    // snippetCreateForm instance to our Insert() method.
    id, err := app.snippets.Insert(form.Title, form.Content, form.Expires)
    if err != nil {
        app.serverError(w, r, err)
        return
    }

    http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther)
}

OK, so now when there are any validation errors we are re-displaying the create.tmpl template, passing in the previous data and validation errors in a snippetCreateForm struct via the template data’s Form field.

If you like, you should be able to run the application at this point and the code should compile without any errors.

Updating the HTML template

The next thing that we need to do is update our create.tmpl template to display the validation errors and re-populate any previous data.

Re-populating the form data is straightforward enough — we should be able to render this in the templates using tags like {{.Form.Title}} and {{.Form.Content}}, in the same way that we displayed the snippet data earlier in the book.

For the validation errors, the underlying type of our FieldErrors field is a map[string]string, which uses the form field names as keys. For maps, it’s possible to access the value for a given key by simply chaining the key name. So, for example, to render a validation error for the title field we can use the tag {{.Form.FieldErrors.title}} in our template.

With that in mind, let’s update the create.tmpl file to re-populate the data and display the error messages for each field, if they exist.

File: ui/html/pages/create.tmpl
{{define "title"}}Create a New Snippet{{end}}

{{define "main"}}
<form action='/snippet/create' method='POST'>
    <div>
        <label>Title:</label>
        <!-- Use the `with` action to render the value of .Form.FieldErrors.title
        if it is not empty. -->
        {{with .Form.FieldErrors.title}}
            <label class='error'>{{.}}</label>
        {{end}}
        <!-- Re-populate the title data by setting the `value` attribute. -->
        <input type='text' name='title' value='{{.Form.Title}}'>
    </div>
    <div>
        <label>Content:</label>
        <!-- Likewise render the value of .Form.FieldErrors.content if it is not
        empty. -->
        {{with .Form.FieldErrors.content}}
            <label class='error'>{{.}}</label>
        {{end}}
        <!-- Re-populate the content data as the inner HTML of the textarea. -->
        <textarea name='content'>{{.Form.Content}}</textarea>
    </div>
    <div>
        <label>Delete in:</label>
        <!-- And render the value of .Form.FieldErrors.expires if it is not empty. -->
        {{with .Form.FieldErrors.expires}}
            <label class='error'>{{.}}</label>
        {{end}}
        <!-- Here we use the `if` action to check if the value of the re-populated
        expires field equals 365. If it does, then we render the `checked`
        attribute so that the radio input is re-selected. -->
        <input type='radio' name='expires' value='365' {{if (eq .Form.Expires 365)}}checked{{end}}> One Year
        <!-- And we do the same for the other possible values too... -->
        <input type='radio' name='expires' value='7' {{if (eq .Form.Expires 7)}}checked{{end}}> One Week
        <input type='radio' name='expires' value='1' {{if (eq .Form.Expires 1)}}checked{{end}}> One Day
    </div>
    <div>
        <input type='submit' value='Publish snippet'>
    </div>
</form>
{{end}}

Hopefully this markup and our use of Go’s templating actions is generally clear — it’s just using techniques that we’ve already seen and discussed earlier in the book.

There’s one final thing we need to do. If we tried to run the application now, we would get a 500 Internal Server Error when we first visit the form at http://localhost:4000/snippet/create. This is because our snippetCreate handler currently doesn’t set a value for the templateData.Form field, meaning that when Go tries to evaluate a template tag like {{with .Form.FieldErrors.title}} it would result in an error because Form is nil.

Let’s fix that by updating our snippetCreate handler so that it initializes a new createSnippetForm instance and passes it to the template, like so:

File: cmd/web/handlers.go
package main

...

func (app *application) snippetCreate(w http.ResponseWriter, r *http.Request) {
    data := app.newTemplateData(r)

    // Initialize a new createSnippetForm instance and pass it to the template.
    // Notice how this is also a great opportunity to set any default or
    // 'initial' values for the form --- here we set the initial value for the 
    // snippet expiry to 365 days.
    data.Form = snippetCreateForm{
        Expires: 365,
    }

    app.render(w, r, http.StatusOK, "create.tmpl", data)
}

...

Now that’s done, please restart the application and visit http://localhost:4000/snippet/create in your browser. You should find that the page renders correctly without any errors.

The try adding some content and changing the default expiry time, but leave the title field blank like so:

07.04-01.png

After submission you should now see the form re-displayed, with the correctly re-populated snippet content and expiry option, and a “This field cannot be blank” error message alongside the title field:

07.04-02.png

Before we continue, feel free to spend some time playing around with the form and validation rules until you’re confident that everything is working as you expect it to.


Additional information

Restful routing

If you’ve got a background in Ruby-on-Rails, Laravel or similar, you might be wondering why we haven’t structured our routes and handlers to be more ‘RESTful’ and look like this:

Route pattern Handler Action
GET /snippets snippetIndex Display the home page
GET /snippets/{id} snippetView Display a specific snippet
GET /snippets/create snippetCreate Display a form for creating a new snippet
POST /snippets snippetCreatePost Save a new snippet

There are a couple of reasons.

The first reason is because of overlapping routes — a HTTP request to /snippets/create potentially matches both the GET /snippets/{id} and GET /snippets/create routes. In our application, the snippet ID values are always numeric so there will never be a ‘real’ overlap between these two routes — but imagine if our snippet ID values were user-generated, or a random 6-character string, and hopefully you can see the potential for a problem. Generally speaking, overlapping routes can be a source of bugs and unexpected behavior in your application, and it’s good practice to avoid them if you can — or use them with care and caution if you can’t.

The second reason is that the HTML form presented on /snippets/create would need to post to /snippets when submitted. This means that when we re-render the HTML form to show any validation errors, the URL in the user’s browser will also change to /snippets. YMMV on whether you consider this a problem or not — most users don’t look at URLs, but I think it’s a bit clunky and confusing in terms of UX… especially if a GET request to /snippets normally renders something else (like a list of all snippets).