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 {
Email string `form:"email" validate:"required,email"`
Message string `form:"message" validate:"required"`
Submission controller.FormSubmission
Submission form.Submission
}
```
@ -654,27 +654,23 @@ Then in your page:
```go
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
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:
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
var form ContactForm
ctx.Set(context.FormKey, &form)
```
var input ContactForm
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
if err := ctx.Bind(&form); err != nil {
// Something went wrong...
if err := form.Set(ctx, &input); err != nil {
return err
}
```
@ -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_:
```go
if form.Submission.HasErrors() {
return c.Get(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)
return c.GetCallback(ctx)
}
```
@ -716,9 +702,9 @@ And finally, your template:
#### 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.

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 (
"github.com/go-playground/validator/v10"
@ -6,8 +6,8 @@ import (
"github.com/labstack/echo/v4"
)
// FormSubmission represents the state of the submission of a form, not including the form itself
type FormSubmission struct {
// Submission represents the state of the submission of a form, not including the form itself
type Submission struct {
// IsSubmitted indicates if the form has been submitted
IsSubmitted bool
@ -16,7 +16,7 @@ type FormSubmission struct {
}
// 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.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
func (f FormSubmission) HasErrors() bool {
func (f Submission) HasErrors() bool {
if f.Errors == nil {
return false
}
@ -37,12 +37,12 @@ func (f FormSubmission) HasErrors() bool {
}
// 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
}
// 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 {
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
func (f FormSubmission) GetFieldErrors(fieldName string) []string {
func (f Submission) GetFieldErrors(fieldName string) []string {
if f.Errors == nil {
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
func (f FormSubmission) GetFieldStatusClass(fieldName string) string {
func (f Submission) GetFieldStatusClass(fieldName string) string {
if f.IsSubmitted {
if f.FieldHasErrors(fieldName) {
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
// and there are no errors.
func (f FormSubmission) IsDone() bool {
func (f Submission) IsDone() bool {
return f.IsSubmitted && !f.HasErrors()
}
// 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
ves, ok := err.(validator.ValidationErrors)
if !ok {

View File

@ -1,8 +1,10 @@
package controller
package form
import (
"testing"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/pkg/tests"
"github.com/stretchr/testify/assert"
@ -13,16 +15,18 @@ func TestFormSubmission(t *testing.T) {
type formTest struct {
Name string `validate:"required"`
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{
Name: "",
Email: "a@a.com",
}
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.FieldHasErrors("Name"))

View File

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

View File

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