Easier form and form submission handling.

This commit is contained in:
mikestefanello 2024-06-09 21:39:04 -04:00
parent 400b9b36ba
commit 28abc92e74
7 changed files with 175 additions and 116 deletions

View File

@ -646,7 +646,7 @@ An example of this pattern is:
type ContactForm struct { type ContactForm struct {
Email string `form:"email" validate:"required,email"` Email string `form:"email" validate:"required,email"`
Message string `form:"message" validate:"required"` Message string `form:"message" validate:"required"`
Submission controller.FormSubmission Submission form.Submission
} }
``` ```
@ -654,27 +654,23 @@ Then in your page:
```go ```go
page := controller.NewPage(ctx) page := controller.NewPage(ctx)
page.Form = ContactForm{} page.Form = form.Get[ContactForm](ctx)
``` ```
How the _form_ gets populated with values so that your template can render them is covered in the next section. This will either initialize a new form to be rendered, or load one previously stored in the context (ie, if it was already submitted). How the _form_ gets populated with values so that your template can render them is covered in the next section.
#### Submission processing #### Submission processing
Form submission processing is made extremely simple by leveraging functionality provided by [Echo binding](https://echo.labstack.com/guide/binding/), [validator](https://github.com/go-playground/validator) and the `FormSubmission` struct located in `pkg/controller/form.go`. Form submission processing is made extremely simple by leveraging functionality provided by [Echo binding](https://echo.labstack.com/guide/binding/), [validator](https://github.com/go-playground/validator) and the `Submission` struct located in `pkg/form/form.go`.
Using the example form above, these are the steps you would take within the _POST_ callback for your route: Using the example form above, these are the steps you would take within the _POST_ callback for your route:
Start by storing a pointer to the form in the context so that your _GET_ callback can access the form values, which will be showed at the end: Start by setting the form in the request contxt. This stores a pointer to the form so that your _GET_ callback can access the form values (shown previously). It also will parse the input in the POST data to map to the struct so it becomes populated. This uses the `form` struct tags to map form values to the struct fields.
```go ```go
var form ContactForm var input ContactForm
ctx.Set(context.FormKey, &form)
```
Parse the input in the POST data to map to the struct so it becomes populated. This uses the `form` struct tags to map form values to the struct fields. if err := form.Set(ctx, &input); err != nil {
```go return err
if err := ctx.Bind(&form); err != nil {
// Something went wrong...
} }
``` ```
@ -695,17 +691,7 @@ if !form.Submission.HasErrors() {
In the event of a validation error, you most likely want to re-render the form with the values provided and any error messages. Since you stored a pointer to the _form_ in the context in the first step, you can first have the _POST_ handler call the _GET_: In the event of a validation error, you most likely want to re-render the form with the values provided and any error messages. Since you stored a pointer to the _form_ in the context in the first step, you can first have the _POST_ handler call the _GET_:
```go ```go
if form.Submission.HasErrors() { if form.Submission.HasErrors() {
return c.Get(ctx) return c.GetCallback(ctx)
}
```
Then, in your _GET_ handler, extract the form from the context so it can be passed to the templates:
```go
page := controller.NewPage(ctx)
page.Form = ContactForm{}
if form := ctx.Get(context.FormKey); form != nil {
page.Form = form.(*ContactForm)
} }
``` ```
@ -716,9 +702,9 @@ And finally, your template:
#### Inline validation #### Inline validation
The `FormSubmission` makes inline validation easier because it will store all validation errors in a map, keyed by the form struct field name. It also contains helper methods that your templates can use to provide classes and extract the error messages. The `Submission` makes inline validation easier because it will store all validation errors in a map, keyed by the form struct field name. It also contains helper methods that your templates can use to provide classes and extract the error messages.
While [validator](https://github.com/go-playground/validator) is a great package that is used to validate based on struct tags, the downside is that the messaging, by default, is not very human-readable or easy to override. Within `FormSubmission.setErrorMessages()` the validation errors are converted to more readable messages based on the tag that failed validation. Only a few tags are provided as an example, so be sure to expand on that as needed. While [validator](https://github.com/go-playground/validator) is a great package that is used to validate based on struct tags, the downside is that the messaging, by default, is not very human-readable or easy to override. Within `Submission.setErrorMessages()` the validation errors are converted to more readable messages based on the tag that failed validation. Only a few tags are provided as an example, so be sure to expand on that as needed.
To provide the inline validation in your template, there are two things that need to be done. To provide the inline validation in your template, there are two things that need to be done.

34
pkg/form/form.go Normal file
View File

@ -0,0 +1,34 @@
package form
import (
"fmt"
"net/http"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/context"
)
// Get gets a form from the context or initializes a new copy if one is not set
func Get[T any](ctx echo.Context) *T {
if v := ctx.Get(context.FormKey); v != nil {
return v.(*T)
}
var v T
return &v
}
// Set sets a form in the context and binds the request values to it
func Set(ctx echo.Context, form any) error {
ctx.Set(context.FormKey, form)
if err := ctx.Bind(form); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("unable to bind form: %v", err))
}
return nil
}
// Clear removes the form set in the context
func Clear(ctx echo.Context) {
ctx.Set(context.FormKey, nil)
}

59
pkg/form/form_test.go Normal file
View File

@ -0,0 +1,59 @@
package form
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestContextFuncs(t *testing.T) {
e := echo.New()
type example struct {
Name string `form:"name"`
}
t.Run("get empty context", func(t *testing.T) {
// Empty context, still return a form
ctx, _ := tests.NewContext(e, "/")
form := Get[example](ctx)
assert.NotNil(t, form)
})
t.Run("set bad request", func(t *testing.T) {
// Set with a bad request
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("abc=abc"))
ctx := e.NewContext(req, httptest.NewRecorder())
var form example
err := Set(ctx, &form)
assert.Error(t, err)
})
t.Run("set", func(t *testing.T) {
// Set and parse the values
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("name=abc"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
ctx := e.NewContext(req, httptest.NewRecorder())
var form example
err := Set(ctx, &form)
require.NoError(t, err)
assert.Equal(t, "abc", form.Name)
// Get again and expect the values were stored
got := Get[example](ctx)
require.NotNil(t, got)
assert.Equal(t, "abc", form.Name)
// Clear
Clear(ctx)
got = Get[example](ctx)
require.NotNil(t, got)
assert.Empty(t, got.Name)
})
}

View File

@ -1,4 +1,4 @@
package controller package form
import ( import (
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
@ -6,8 +6,8 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
// FormSubmission represents the state of the submission of a form, not including the form itself // Submission represents the state of the submission of a form, not including the form itself
type FormSubmission struct { type Submission struct {
// IsSubmitted indicates if the form has been submitted // IsSubmitted indicates if the form has been submitted
IsSubmitted bool IsSubmitted bool
@ -16,7 +16,7 @@ type FormSubmission struct {
} }
// Process processes a submission for a form // Process processes a submission for a form
func (f *FormSubmission) Process(ctx echo.Context, form any) error { func (f *Submission) Process(ctx echo.Context, form any) error {
f.Errors = make(map[string][]string) f.Errors = make(map[string][]string)
f.IsSubmitted = true f.IsSubmitted = true
@ -29,7 +29,7 @@ func (f *FormSubmission) Process(ctx echo.Context, form any) error {
} }
// HasErrors indicates if the submission has any validation errors // HasErrors indicates if the submission has any validation errors
func (f FormSubmission) HasErrors() bool { func (f Submission) HasErrors() bool {
if f.Errors == nil { if f.Errors == nil {
return false return false
} }
@ -37,12 +37,12 @@ func (f FormSubmission) HasErrors() bool {
} }
// FieldHasErrors indicates if a given field on the form has any validation errors // FieldHasErrors indicates if a given field on the form has any validation errors
func (f FormSubmission) FieldHasErrors(fieldName string) bool { func (f Submission) FieldHasErrors(fieldName string) bool {
return len(f.GetFieldErrors(fieldName)) > 0 return len(f.GetFieldErrors(fieldName)) > 0
} }
// SetFieldError sets an error message for a given field name // SetFieldError sets an error message for a given field name
func (f *FormSubmission) SetFieldError(fieldName string, message string) { func (f *Submission) SetFieldError(fieldName string, message string) {
if f.Errors == nil { if f.Errors == nil {
f.Errors = make(map[string][]string) f.Errors = make(map[string][]string)
} }
@ -50,7 +50,7 @@ func (f *FormSubmission) SetFieldError(fieldName string, message string) {
} }
// GetFieldErrors gets the errors for a given field name // GetFieldErrors gets the errors for a given field name
func (f FormSubmission) GetFieldErrors(fieldName string) []string { func (f Submission) GetFieldErrors(fieldName string) []string {
if f.Errors == nil { if f.Errors == nil {
return []string{} return []string{}
} }
@ -58,7 +58,7 @@ func (f FormSubmission) GetFieldErrors(fieldName string) []string {
} }
// GetFieldStatusClass returns an HTML class based on the status of the field // GetFieldStatusClass returns an HTML class based on the status of the field
func (f FormSubmission) GetFieldStatusClass(fieldName string) string { func (f Submission) GetFieldStatusClass(fieldName string) string {
if f.IsSubmitted { if f.IsSubmitted {
if f.FieldHasErrors(fieldName) { if f.FieldHasErrors(fieldName) {
return "is-danger" return "is-danger"
@ -70,12 +70,12 @@ func (f FormSubmission) GetFieldStatusClass(fieldName string) string {
// IsDone indicates if the submission is considered done which is when it has been submitted // IsDone indicates if the submission is considered done which is when it has been submitted
// and there are no errors. // and there are no errors.
func (f FormSubmission) IsDone() bool { func (f Submission) IsDone() bool {
return f.IsSubmitted && !f.HasErrors() return f.IsSubmitted && !f.HasErrors()
} }
// setErrorMessages sets errors messages on the submission for all fields that failed validation // setErrorMessages sets errors messages on the submission for all fields that failed validation
func (f *FormSubmission) setErrorMessages(err error) { func (f *Submission) setErrorMessages(err error) {
// Only this is supported right now // Only this is supported right now
ves, ok := err.(validator.ValidationErrors) ves, ok := err.(validator.ValidationErrors)
if !ok { if !ok {

View File

@ -1,8 +1,10 @@
package controller package form
import ( import (
"testing" "testing"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/pkg/tests" "github.com/mikestefanello/pagoda/pkg/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -13,16 +15,18 @@ func TestFormSubmission(t *testing.T) {
type formTest struct { type formTest struct {
Name string `validate:"required"` Name string `validate:"required"`
Email string `validate:"required,email"` Email string `validate:"required,email"`
Submission FormSubmission Submission Submission
} }
ctx, _ := tests.NewContext(c.Web, "/") e := echo.New()
e.Validator = services.NewValidator()
ctx, _ := tests.NewContext(e, "/")
form := formTest{ form := formTest{
Name: "", Name: "",
Email: "a@a.com", Email: "a@a.com",
} }
err := form.Submission.Process(ctx, form) err := form.Submission.Process(ctx, form)
assert.NoError(t, err) require.NoError(t, err)
assert.True(t, form.Submission.HasErrors()) assert.True(t, form.Submission.HasErrors())
assert.True(t, form.Submission.FieldHasErrors("Name")) assert.True(t, form.Submission.FieldHasErrors("Name"))

View File

@ -9,6 +9,7 @@ import (
"github.com/mikestefanello/pagoda/ent/user" "github.com/mikestefanello/pagoda/ent/user"
"github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/controller" "github.com/mikestefanello/pagoda/pkg/controller"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/middleware" "github.com/mikestefanello/pagoda/pkg/middleware"
"github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/services"
@ -38,13 +39,13 @@ type (
forgotPasswordForm struct { forgotPasswordForm struct {
Email string `form:"email" validate:"required,email"` Email string `form:"email" validate:"required,email"`
Submission controller.FormSubmission Submission form.Submission
} }
loginForm struct { loginForm struct {
Email string `form:"email" validate:"required,email"` Email string `form:"email" validate:"required,email"`
Password string `form:"password" validate:"required"` Password string `form:"password" validate:"required"`
Submission controller.FormSubmission Submission form.Submission
} }
registerForm struct { registerForm struct {
@ -52,13 +53,13 @@ type (
Email string `form:"email" validate:"required,email"` Email string `form:"email" validate:"required,email"`
Password string `form:"password" validate:"required"` Password string `form:"password" validate:"required"`
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
Submission controller.FormSubmission Submission form.Submission
} }
resetPasswordForm struct { resetPasswordForm struct {
Password string `form:"password" validate:"required"` Password string `form:"password" validate:"required"`
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
Submission controller.FormSubmission Submission form.Submission
} }
) )
@ -99,42 +100,37 @@ func (c *Auth) ForgotPasswordPage(ctx echo.Context) error {
page.Layout = templates.LayoutAuth page.Layout = templates.LayoutAuth
page.Name = templates.PageForgotPassword page.Name = templates.PageForgotPassword
page.Title = "Forgot password" page.Title = "Forgot password"
page.Form = forgotPasswordForm{} page.Form = form.Get[forgotPasswordForm](ctx)
if form := ctx.Get(context.FormKey); form != nil {
page.Form = form.(*forgotPasswordForm)
}
return c.RenderPage(ctx, page) return c.RenderPage(ctx, page)
} }
func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error { func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
var form forgotPasswordForm var input forgotPasswordForm
ctx.Set(context.FormKey, &form)
succeed := func() error { succeed := func() error {
ctx.Set(context.FormKey, nil) form.Clear(ctx)
msg.Success(ctx, "An email containing a link to reset your password will be sent to this address if it exists in our system.") msg.Success(ctx, "An email containing a link to reset your password will be sent to this address if it exists in our system.")
return c.ForgotPasswordPage(ctx) return c.ForgotPasswordPage(ctx)
} }
// Parse the form values // Set the form in context and parse the form values
if err := ctx.Bind(&form); err != nil { if err := form.Set(ctx, &input); err != nil {
return c.Fail(err, "unable to parse forgot password form") return err
} }
if err := form.Submission.Process(ctx, form); err != nil { if err := input.Submission.Process(ctx, input); err != nil {
return c.Fail(err, "unable to process form submission") return c.Fail(err, "unable to process form submission")
} }
if form.Submission.HasErrors() { if input.Submission.HasErrors() {
return c.ForgotPasswordPage(ctx) return c.ForgotPasswordPage(ctx)
} }
// Attempt to load the user // Attempt to load the user
u, err := c.orm.User. u, err := c.orm.User.
Query(). Query().
Where(user.Email(strings.ToLower(form.Email))). Where(user.Email(strings.ToLower(input.Email))).
Only(ctx.Request().Context()) Only(ctx.Request().Context())
switch err.(type) { switch err.(type) {
@ -174,43 +170,38 @@ func (c *Auth) LoginPage(ctx echo.Context) error {
page.Layout = templates.LayoutAuth page.Layout = templates.LayoutAuth
page.Name = templates.PageLogin page.Name = templates.PageLogin
page.Title = "Log in" page.Title = "Log in"
page.Form = loginForm{} page.Form = form.Get[loginForm](ctx)
if form := ctx.Get(context.FormKey); form != nil {
page.Form = form.(*loginForm)
}
return c.RenderPage(ctx, page) return c.RenderPage(ctx, page)
} }
func (c *Auth) LoginSubmit(ctx echo.Context) error { func (c *Auth) LoginSubmit(ctx echo.Context) error {
var form loginForm var input loginForm
ctx.Set(context.FormKey, &form)
authFailed := func() error { authFailed := func() error {
form.Submission.SetFieldError("Email", "") input.Submission.SetFieldError("Email", "")
form.Submission.SetFieldError("Password", "") input.Submission.SetFieldError("Password", "")
msg.Danger(ctx, "Invalid credentials. Please try again.") msg.Danger(ctx, "Invalid credentials. Please try again.")
return c.LoginPage(ctx) return c.LoginPage(ctx)
} }
// Parse the form values // Set in context and parse the form values
if err := ctx.Bind(&form); err != nil { if err := form.Set(ctx, &input); err != nil {
return c.Fail(err, "unable to parse login form") return err
} }
if err := form.Submission.Process(ctx, form); err != nil { if err := input.Submission.Process(ctx, input); err != nil {
return c.Fail(err, "unable to process form submission") return c.Fail(err, "unable to process form submission")
} }
if form.Submission.HasErrors() { if input.Submission.HasErrors() {
return c.LoginPage(ctx) return c.LoginPage(ctx)
} }
// Attempt to load the user // Attempt to load the user
u, err := c.orm.User. u, err := c.orm.User.
Query(). Query().
Where(user.Email(strings.ToLower(form.Email))). Where(user.Email(strings.ToLower(input.Email))).
Only(ctx.Request().Context()) Only(ctx.Request().Context())
switch err.(type) { switch err.(type) {
@ -222,7 +213,7 @@ func (c *Auth) LoginSubmit(ctx echo.Context) error {
} }
// Check if the password is correct // Check if the password is correct
err = c.auth.CheckPassword(form.Password, u.Password) err = c.auth.CheckPassword(input.Password, u.Password)
if err != nil { if err != nil {
return authFailed() return authFailed()
} }
@ -251,34 +242,29 @@ func (c *Auth) RegisterPage(ctx echo.Context) error {
page.Layout = templates.LayoutAuth page.Layout = templates.LayoutAuth
page.Name = templates.PageRegister page.Name = templates.PageRegister
page.Title = "Register" page.Title = "Register"
page.Form = registerForm{} page.Form = form.Get[registerForm](ctx)
if form := ctx.Get(context.FormKey); form != nil {
page.Form = form.(*registerForm)
}
return c.RenderPage(ctx, page) return c.RenderPage(ctx, page)
} }
func (c *Auth) RegisterSubmit(ctx echo.Context) error { func (c *Auth) RegisterSubmit(ctx echo.Context) error {
var form registerForm var input registerForm
ctx.Set(context.FormKey, &form)
// Parse the form values // Set in context and parse the form values
if err := ctx.Bind(&form); err != nil { if err := form.Set(ctx, &input); err != nil {
return c.Fail(err, "unable to parse register form") return c.Fail(err, "unable to parse register form")
} }
if err := form.Submission.Process(ctx, form); err != nil { if err := input.Submission.Process(ctx, input); err != nil {
return c.Fail(err, "unable to process form submission") return c.Fail(err, "unable to process form submission")
} }
if form.Submission.HasErrors() { if input.Submission.HasErrors() {
return c.RegisterPage(ctx) return c.RegisterPage(ctx)
} }
// Hash the password // Hash the password
pwHash, err := c.auth.HashPassword(form.Password) pwHash, err := c.auth.HashPassword(input.Password)
if err != nil { if err != nil {
return c.Fail(err, "unable to hash password") return c.Fail(err, "unable to hash password")
} }
@ -286,8 +272,8 @@ func (c *Auth) RegisterSubmit(ctx echo.Context) error {
// Attempt creating the user // Attempt creating the user
u, err := c.orm.User. u, err := c.orm.User.
Create(). Create().
SetName(form.Name). SetName(input.Name).
SetEmail(form.Email). SetEmail(input.Email).
SetPassword(pwHash). SetPassword(pwHash).
Save(ctx.Request().Context()) Save(ctx.Request().Context())
@ -347,34 +333,29 @@ func (c *Auth) ResetPasswordPage(ctx echo.Context) error {
page.Layout = templates.LayoutAuth page.Layout = templates.LayoutAuth
page.Name = templates.PageResetPassword page.Name = templates.PageResetPassword
page.Title = "Reset password" page.Title = "Reset password"
page.Form = resetPasswordForm{} page.Form = form.Get[resetPasswordForm](ctx)
if form := ctx.Get(context.FormKey); form != nil {
page.Form = form.(*resetPasswordForm)
}
return c.RenderPage(ctx, page) return c.RenderPage(ctx, page)
} }
func (c *Auth) ResetPasswordSubmit(ctx echo.Context) error { func (c *Auth) ResetPasswordSubmit(ctx echo.Context) error {
var form resetPasswordForm var input resetPasswordForm
ctx.Set(context.FormKey, &form)
// Parse the form values // Set in context and parse the form values
if err := ctx.Bind(&form); err != nil { if err := form.Set(ctx, &input); err != nil {
return c.Fail(err, "unable to parse password reset form") return c.Fail(err, "unable to parse password reset form")
} }
if err := form.Submission.Process(ctx, form); err != nil { if err := input.Submission.Process(ctx, input); err != nil {
return c.Fail(err, "unable to process form submission") return c.Fail(err, "unable to process form submission")
} }
if form.Submission.HasErrors() { if input.Submission.HasErrors() {
return c.ResetPasswordPage(ctx) return c.ResetPasswordPage(ctx)
} }
// Hash the new password // Hash the new password
hash, err := c.auth.HashPassword(form.Password) hash, err := c.auth.HashPassword(input.Password)
if err != nil { if err != nil {
return c.Fail(err, "unable to hash password") return c.Fail(err, "unable to hash password")
} }

View File

@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/controller" "github.com/mikestefanello/pagoda/pkg/controller"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates" "github.com/mikestefanello/pagoda/templates"
) )
@ -25,7 +25,7 @@ type (
Email string `form:"email" validate:"required,email"` Email string `form:"email" validate:"required,email"`
Department string `form:"department" validate:"required,oneof=sales marketing hr"` Department string `form:"department" validate:"required,oneof=sales marketing hr"`
Message string `form:"message" validate:"required"` Message string `form:"message" validate:"required"`
Submission controller.FormSubmission Submission form.Submission
} }
) )
@ -49,34 +49,29 @@ func (c *Contact) Page(ctx echo.Context) error {
page.Layout = templates.LayoutMain page.Layout = templates.LayoutMain
page.Name = templates.PageContact page.Name = templates.PageContact
page.Title = "Contact us" page.Title = "Contact us"
page.Form = contactForm{} page.Form = form.Get[contactForm](ctx)
if form := ctx.Get(context.FormKey); form != nil {
page.Form = form.(*contactForm)
}
return c.RenderPage(ctx, page) return c.RenderPage(ctx, page)
} }
func (c *Contact) Submit(ctx echo.Context) error { func (c *Contact) Submit(ctx echo.Context) error {
var form contactForm var input contactForm
ctx.Set(context.FormKey, &form)
// Parse the form values // Store in context and parse the form values
if err := ctx.Bind(&form); err != nil { if err := form.Set(ctx, &input); err != nil {
return c.Fail(err, "unable to bind form") return err
} }
if err := form.Submission.Process(ctx, form); err != nil { if err := input.Submission.Process(ctx, input); err != nil {
return c.Fail(err, "unable to process form submission") return c.Fail(err, "unable to process form submission")
} }
if !form.Submission.HasErrors() { if !input.Submission.HasErrors() {
err := c.mail. err := c.mail.
Compose(). Compose().
To(form.Email). To(input.Email).
Subject("Contact form submitted"). Subject("Contact form submitted").
Body(fmt.Sprintf("The message is: %s", form.Message)). Body(fmt.Sprintf("The message is: %s", input.Message)).
Send(ctx) Send(ctx)
if err != nil { if err != nil {