Skip to content

Commit

Permalink
Improve form and template usage (#66)
Browse files Browse the repository at this point in the history
* Improve form and template usage.
  • Loading branch information
mikestefanello committed Jun 14, 2024
1 parent 7d85ff0 commit 5ebd42d
Show file tree
Hide file tree
Showing 22 changed files with 336 additions and 269 deletions.
63 changes: 31 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ If you wish to require either authentication or non-authentication for a given r

### Email verification

Most web applications require the user to verify their email address (or other form of contact information). The `User` entity has a field `Verified` to indicate if they have verified themself. When a user successfully registers, an email is sent to them containing a link with a token that will verify their account when visited. This route is currently accessible at `/email/verify/:token` and handled by `routes/VerifyEmail`.
Most web applications require the user to verify their email address (or other form of contact information). The `User` entity has a field `Verified` to indicate if they have verified themself. When a user successfully registers, an email is sent to them containing a link with a token that will verify their account when visited. This route is currently accessible at `/email/verify/:token` and handled by `pkg/handlers/auth.go`.

There is currently no enforcement that a `User` must be verified in order to access the application. If that is something you desire, it will have to be added in yourself. It was not included because you may want partial access of certain features until the user verifies; or no access at all.

Expand Down Expand Up @@ -529,7 +529,6 @@ func (c *home) Get(ctx echo.Context) error {
Using the `echo.Context`, the `Page` will be initialized with the following fields populated:

- `Context`: The passed in _context_
- `ToURL`: A function the templates can use to generate a URL with a given route name and parameters
- `Path`: The requested URL path
- `URL`: The requested URL
- `StatusCode`: Defaults to 200
Expand Down Expand Up @@ -638,18 +637,20 @@ The `Data` field on the `Page` is of type `any` and is what allows your route to

### Forms

The `Form` field on the `Page` is similar to the `Data` field in that it's an `any` type but it's meant to store a struct that represents a form being rendered on the page.
The `Form` field on the `Page` is similar to the `Data` field, but it's meant to store a struct that represents a form being rendered on the page.

An example of this pattern is:

```go
type ContactForm struct {
Email string `form:"email" validate:"required,email"`
Message string `form:"message" validate:"required"`
Submission form.Submission
form.Submission
}
```

Embedding `form.Submission` satisfies the `form.Form` interface and makes dealing with submissions and validation extremely easy.

Then in your page:

```go
Expand All @@ -663,41 +664,39 @@ This will either initialize a new form to be rendered, or load one previously st

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/submission.go`.

Using the example form above, these are the steps you would take within the _POST_ callback for your route:

Start by setting the form in the request context. 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 input ContactForm
Using the example form above, this is all you would have to do within the _POST_ callback for your route:

if err := form.Set(ctx, &input); err != nil {
return err
}
```
Start by submitting the form along with the request context. This will:
1. Store a pointer to the form so that your _GET_ callback can access the form values (shown previously). That allows the form to easily be re-rendered with any validation errors it may have as well as the values that were provided.
2. Parse the input in the _POST_ data to map to the struct so the fields becomes populated. This uses the `form` struct tags to map form input values to the struct fields.
3. Validate the values in the struct fields according to the rules provided in the optional `validate` struct tags.

Process the submission which uses [validator](https://github.com/go-playground/validator) to check for validation errors:
```go
if err := input.Submission.Process(ctx, input); err != nil {
// Something went wrong...
}
```
var input ContactForm

Check if the form submission has any validation errors:
```go
if !input.Submission.HasErrors() {
// All good, now execute something!
}
err := form.Submit(ctx, &input)
```

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_:
Check the error returned, and act accordingly. For example:
```go
if input.Submission.HasErrors() {
return c.GetCallback(ctx)
switch err.(type) {
case nil:
// All good!
case validator.ValidationErrors:
// The form input was not valid, so re-render the form
return c.Page(ctx)
default:
// Request failed, show the error page
return err
}
```

And finally, your template:
```html
<input id="email" name="email" type="email" class="input" value="{{.Form.Email}}">
<form id="contact" method="post" hx-post="{{url "contact.post"}}">
<input id="email" name="email" type="email" class="input" value="{{.Form.Email}}">
<input id="message" name="message" type="text" class="input" value="{{.Form.Message}}">
</form
```

#### Inline validation
Expand All @@ -710,12 +709,12 @@ To provide the inline validation in your template, there are two things that nee

First, include a status class on the element so it will highlight green or red based on the validation:
```html
<input id="email" name="email" type="email" class="input {{.Form.Submission.GetFieldStatusClass "Email"}}" value="{{.Form.Email}}">
<input id="email" name="email" type="email" class="input {{.Form.GetFieldStatusClass "Email"}}" value="{{.Form.Email}}">
```

Second, render the error messages, if there are any for a given field:
```go
{{template "field-errors" (.Form.Submission.GetFieldErrors "Email")}}
{{template "field-errors" (.Form.GetFieldErrors "Email")}}
```

### Headers
Expand Down Expand Up @@ -768,15 +767,15 @@ e.GET("/user/profile/:user", profile.Get).Name = "user_profile"

And you want to generate a URL in the template, you can:
```go
{{call .ToURL "user_profile" 1}
{{url "user_profile" 1}
```
Which will generate: `/user/profile/1`
There is also a helper function provided in the [funcmap](#funcmap) to generate links which has the benefit of adding an _active_ class if the link URL matches the current path. This is especially useful for navigation menus.
```go
{{link (call .ToURL "user_profile" .AuthUser.ID) "Profile" .Path "extra-class"}}
{{link (url "user_profile" .AuthUser.ID) "Profile" .Path "extra-class"}}
```
Will generate:
Expand Down Expand Up @@ -923,7 +922,7 @@ To make things easier and less repetitive, parameters given to the _template ren
The `funcmap` package provides a _function map_ (`template.FuncMap`) which will be included for all templates rendered with the [template renderer](#template-renderer). Aside from a few custom functions, [sprig](https://github.com/Masterminds/sprig) is included which provides over 100 commonly used template functions. The full list is available [here](http://masterminds.github.io/sprig/).
To include additional custom functions, add to the slice in `GetFuncMap()` and define the function in the package. It will then become automatically available in all templates.
To include additional custom functions, add to the map in `NewFuncMap()` and define the function in the package. It will then become automatically available in all templates.
## Cache
Expand Down
11 changes: 4 additions & 7 deletions pkg/controller/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/htmx"
"github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/templates"
Expand Down Expand Up @@ -34,9 +35,6 @@ type Page struct {
// Context stores the request context
Context echo.Context

// ToURL is a function to convert a route name and optional route parameters to a URL
ToURL func(name string, params ...any) string

// Path stores the path of the current request
Path string

Expand All @@ -50,8 +48,8 @@ type Page struct {
// Form stores a struct that represents a form on the page.
// This should be a struct with fields for each form field, using both "form" and "validate" tags
// It should also contain a Submission field of type FormSubmission if you wish to have validation
// messagesa and markup presented to the user
Form any
// messages and markup presented to the user
Form form.Form

// Layout stores the name of the layout base template file which will be used when the page is rendered.
// This should match a template file located within the layouts directory inside the templates directory.
Expand All @@ -67,7 +65,7 @@ type Page struct {
// IsHome stores whether the requested page is the home page or not
IsHome bool

// IsAuth stores whether or not the user is authenticated
// IsAuth stores whether the user is authenticated
IsAuth bool

// AuthUser stores the authenticated user
Expand Down Expand Up @@ -125,7 +123,6 @@ type Page struct {
func NewPage(ctx echo.Context) Page {
p := Page{
Context: ctx,
ToURL: ctx.Echo().Reverse,
Path: ctx.Request().URL.Path,
URL: ctx.Request().URL.String(),
StatusCode: http.StatusOK,
Expand Down
1 change: 0 additions & 1 deletion pkg/controller/page_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ func TestNewPage(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
p := NewPage(ctx)
assert.Same(t, ctx, p.Context)
assert.NotNil(t, p.ToURL)
assert.Equal(t, "/", p.Path)
assert.Equal(t, "/", p.URL)
assert.Equal(t, http.StatusOK, p.StatusCode)
Expand Down
50 changes: 36 additions & 14 deletions pkg/form/form.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
package form

import (
"fmt"
"net/http"

"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/context"
)

// Form represents a form that can be submitted and validated
type Form interface {
// Submit marks the form as submitted, stores a pointer to it in the context, binds the request
// values to the struct fields, and validates the input based on the struct tags.
// Returns a validator.ValidationErrors if the form values were not valid.
// Returns an echo.HTTPError if the request failed to process.
Submit(c echo.Context, form any) error

// IsSubmitted returns true if the form was submitted
IsSubmitted() bool

// IsValid returns true if the form has no validation errors
IsValid() bool

// IsDone returns true if the form was submitted and has no validation errors
IsDone() bool

// FieldHasErrors returns true if a given struct field has validation errors
FieldHasErrors(fieldName string) bool

// SetFieldError sets a validation error message for a given struct field
SetFieldError(fieldName string, message string)

// GetFieldErrors returns the validation errors for a given struct field
GetFieldErrors(fieldName string) []string

// GetFieldStatusClass returns a CSS class to be used for a given struct field
GetFieldStatusClass(fieldName string) string
}

// 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 {
Expand All @@ -17,18 +44,13 @@ func Get[T any](ctx echo.Context) *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)
}

// Submit submits a form
// See Form.Submit()
func Submit(ctx echo.Context, form Form) error {
return form.Submit(ctx, form)
}
50 changes: 27 additions & 23 deletions pkg/form/form_test.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
package form

import (
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestContextFuncs(t *testing.T) {
type mockForm struct {
called bool
Submission
}

func (m *mockForm) Submit(_ echo.Context, _ any) error {
m.called = true
return nil
}

func TestSubmit(t *testing.T) {
m := mockForm{}
ctx, _ := tests.NewContext(echo.New(), "/")
err := Submit(ctx, &m)
require.NoError(t, err)
assert.True(t, m.called)
}

func TestGetClear(t *testing.T) {
e := echo.New()

type example struct {
Expand All @@ -26,29 +42,17 @@ func TestContextFuncs(t *testing.T) {
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)
t.Run("get non-empty context", func(t *testing.T) {
form := example{
Name: "test",
}
ctx, _ := tests.NewContext(e, "/")
ctx.Set(context.FormKey, &form)

// Get again and expect the values were stored
got := Get[example](ctx)
require.NotNil(t, got)
assert.Equal(t, "abc", form.Name)
assert.Equal(t, "test", form.Name)

// Clear
Clear(ctx)
Expand Down
Loading

0 comments on commit 5ebd42d

Please sign in to comment.