Let's Go پردازش فرم‌ها › نمایش خطاها و پر کردن مجدد فیلدها
قبلی · فهرست · بعدی
فصل ۷.۴.

نمایش خطاها و پر کردن مجدد فیلدها

حالا که handler snippetCreatePost داده‌ها را اعتبارسنجی می‌کند، مرحله بعدی مدیریت این خطاهای اعتبارسنجی به صورت مناسب است.

اگر هرگونه خطای اعتبارسنجی وجود داشته باشد، می‌خواهیم فرم HTML را دوباره نمایش دهیم، فیلدهایی که اعتبارسنجی را رد کرده‌اند را برجسته کنیم و به طور خودکار هر داده قبلاً ارسال شده را دوباره پر کنیم (تا کاربر مجبور نباشد دوباره آن را وارد کند).

برای انجام این کار، بیایید با افزودن یک فیلد جدید Form به ساختار templateData خود شروع کنیم:

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
}

...

از این فیلد Form برای ارسال خطاهای اعتبارسنجی و داده‌های قبلاً ارسال شده به قالب هنگام نمایش مجدد فرم استفاده خواهیم کرد.

بعد بیایید به فایل cmd/web/handlers.go خود برگردیم و یک ساختار جدید snippetCreateForm برای نگهداری داده‌های فرم و هرگونه خطای اعتبارسنجی تعریف کنیم و handler snippetCreatePost خود را برای استفاده از این به‌روزرسانی کنیم.

به این صورت:

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)
}

خوب، حالا وقتی هرگونه خطای اعتبارسنجی وجود دارد، قالب create.tmpl را دوباره نمایش می‌دهیم و داده‌های قبلی و خطاهای اعتبارسنجی را در یک ساختار snippetCreateForm از طریق فیلد Form داده قالب ارسال می‌کنیم.

اگر می‌خواهید، باید بتوانید برنامه را در این مرحله اجرا کنید و کد باید بدون هیچ خطایی کامپایل شود.

به‌روزرسانی قالب HTML

چیز بعدی که باید انجام دهیم، به‌روزرسانی قالب create.tmpl خود برای نمایش خطاهای اعتبارسنجی و پر کردن مجدد هر داده قبلی است.

پر کردن مجدد داده‌های فرم به اندازه کافی ساده است — باید بتوانیم این را در قالب‌ها با استفاده از تگ‌هایی مانند {{.Form.Title}} و {{.Form.Content}} رندر کنیم، به همان روشی که داده‌های اسنیپت را قبلاً در کتاب نمایش دادیم.

برای خطاهای اعتبارسنجی، نوع زیرین فیلد FieldErrors ما یک map[string]string است که از نام‌های فیلد فرم به عنوان کلید استفاده می‌کند. برای نقشه‌ها، می‌توان با زنجیره کردن نام کلید به مقدار یک کلید مشخص دسترسی پیدا کرد. بنابراین، به عنوان مثال، برای رندر کردن یک خطای اعتبارسنجی برای فیلد title می‌توانیم از تگ {{.Form.FieldErrors.title}} در قالب خود استفاده کنیم.

با در نظر گرفتن این موضوع، بیایید فایل create.tmpl را برای پر کردن مجدد داده‌ها و نمایش پیام‌های خطا برای هر فیلد، در صورت وجود، به‌روزرسانی کنیم.

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}}

امیدوارم این نشانه‌گذاری و استفاده ما از اکشن‌های قالب‌سازی Go به طور کلی واضح باشد — فقط از تکنیک‌هایی استفاده می‌کند که قبلاً دیده و بحث کرده‌ایم در کتاب.

یک چیز نهایی وجود دارد که باید انجام دهیم. اگر الان برنامه را اجرا کنیم، هنگام اولین بازدید از فرم در http://localhost:4000/snippet/create یک خطای 500 Internal Server Error دریافت خواهیم کرد. این به این دلیل است که handler snippetCreate ما در حال حاضر مقداری برای فیلد templateData.Form تنظیم نمی‌کند، به این معنی که وقتی Go سعی می‌کند یک تگ قالب مانند {{with .Form.FieldErrors.title}} را ارزیابی کند، به دلیل اینکه Form nil است، خطا می‌دهد.

بیایید این را با به‌روزرسانی handler snippetCreate خود طوری که یک نمونه جدید snippetCreateForm را مقداردهی اولیه می‌کند و به قالب می‌دهد، برطرف کنیم، به این صورت:

File: cmd/web/handlers.go
package main

...

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

    // Initialize a new snippetCreateForm 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)
}

...

حالا که این کار انجام شد، لطفاً برنامه را مجدداً راه‌اندازی کنید و http://localhost:4000/snippet/create را در مرورگر خود بازدید کنید. باید ببینید که صفحه بدون هیچ خطایی به درستی رندر می‌شود.

سپس سعی کنید مقداری محتوا اضافه کنید و زمان انقضای پیش‌فرض را تغییر دهید، اما فیلد عنوان را خالی بگذارید، به این صورت:

07.04-01.png

پس از ارسال باید فرم را دوباره نمایش داده شده ببینید، با محتوای اسنیپت و گزینه انقضا به درستی پر شده مجدد، و یک پیام خطای "This field cannot be blank" در کنار فیلد عنوان:

07.04-02.png

قبل از ادامه، آزادانه زمانی را صرف آزمایش فرم و قوانین اعتبارسنجی کنید تا مطمئن شوید که همه چیز همانطور که انتظار دارید کار می‌کند.


اطلاعات اضافی

مسیریابی RESTful

اگر پیشینه‌ای در Ruby-on-Rails، Laravel یا مشابه دارید، ممکن است تعجب کنید که چرا مسیرها و handlerهای خود را به صورت 'RESTful' ساختار نداده‌ایم و به این صورت نباشد:

Route pattern Handler Action
GET /snippets snippetIndex نمایش صفحه اصلی
GET /snippets/{id} snippetView نمایش یک اسنیپت خاص
GET /snippets/create snippetCreate نمایش یک فرم برای ایجاد اسنیپت جدید
POST /snippets snippetCreatePost ذخیره یک اسنیپت جدید

چند دلیل وجود دارد.

دلیل اول به خاطر مسیرهای همپوشان است — یک درخواست HTTP به /snippets/create به طور بالقوه با هر دو مسیر GET /snippets/{id} و GET /snippets/create مطابقت دارد. در برنامه ما، مقادیر شناسه اسنیپت همیشه عددی هستند بنابراین هرگز همپوشانی 'واقعی' بین این دو مسیر وجود نخواهد داشت — اما تصور کنید اگر مقادیر شناسه اسنیپت ما توسط کاربر تولید می‌شدند، یا یک رشته 6 کاراکتری تصادفی بودند، و امیدوارم بتوانید پتانسیل مشکل را ببینید. به طور کلی، مسیرهای همپوشان می‌توانند منبع باگ‌ها و رفتار غیرمنتظره در برنامه شما باشند و خوب است که در صورت امکان از آن‌ها اجتناب کنید — یا اگر نمی‌توانید، با دقت و احتیاط از آن‌ها استفاده کنید.

دلیل دوم این است که فرم HTML ارائه شده در /snippets/create باید هنگام ارسال به /snippets پست کند. این به این معنی است که وقتی فرم HTML را برای نشان دادن هرگونه خطای اعتبارسنجی دوباره رندر می‌کنیم، URL در مرورگر کاربر نیز به /snippets تغییر می‌کند. نظر شما در مورد اینکه این یک مشکل است یا نه متفاوت است — بیشتر کاربران به URL‌ها نگاه نمی‌کنند، اما فکر می‌کنم از نظر UX کمی ناهموار و گیج‌کننده است… به خصوص اگر یک درخواست GET به /snippets معمولاً چیز دیگری را رندر کند (مانند لیستی از همه اسنیپت‌ها).