Improve form and template usage (#66)

* Improve form and template usage.
This commit is contained in:
Mike Stefanello 2024-06-14 12:35:35 -04:00 committed by GitHub
parent 7d85ff0b08
commit 5ebd42d8f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 341 additions and 274 deletions

View File

@ -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.
@ -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
@ -638,7 +637,7 @@ 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:
@ -646,10 +645,12 @@ An example of this pattern is:
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
@ -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:
Using the example form above, this is all you would have to do within the _POST_ callback for your route:
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.
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
if err := form.Set(ctx, &input); err != nil {
err := form.Submit(ctx, &input)
```
Check the error returned, and act accordingly. For example:
```go
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
}
```
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...
}
```
Check if the form submission has any validation errors:
```go
if !input.Submission.HasErrors() {
// All good, now execute something!
}
```
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 input.Submission.HasErrors() {
return c.GetCallback(ctx)
}
```
And finally, your template:
```html
<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
@ -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
@ -768,7 +767,7 @@ 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`
@ -776,7 +775,7 @@ 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:
@ -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

View File

@ -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"
@ -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
@ -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.
@ -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
@ -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,

View File

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

View File

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

View File

@ -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 {
@ -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)

View File

@ -1,65 +1,80 @@
package form
import (
"fmt"
"net/http"
"github.com/go-playground/validator/v10"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/labstack/echo/v4"
)
// Submission 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.
// This satisfies the Form interface.
type Submission struct {
// IsSubmitted indicates if the form has been submitted
IsSubmitted bool
// isSubmitted indicates if the form has been submitted
isSubmitted bool
// Errors stores a slice of error message strings keyed by form struct field name
Errors map[string][]string
// errors stores a slice of error message strings keyed by form struct field name
errors map[string][]string
}
// Process processes a submission for a form
func (f *Submission) Process(ctx echo.Context, form any) error {
f.Errors = make(map[string][]string)
f.IsSubmitted = true
func (f *Submission) Submit(ctx echo.Context, form any) error {
f.isSubmitted = true
// Set in context so the form can later be retrieved
ctx.Set(context.FormKey, form)
// Bind the values from the incoming request to the form struct
if err := ctx.Bind(form); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("unable to bind form: %v", err))
}
// Validate the form
if err := ctx.Validate(form); err != nil {
f.setErrorMessages(err)
return err
}
return nil
}
// HasErrors indicates if the submission has any validation errors
func (f Submission) HasErrors() bool {
if f.Errors == nil {
return false
}
return len(f.Errors) > 0
func (f *Submission) IsSubmitted() bool {
return f.isSubmitted
}
// FieldHasErrors indicates if a given field on the form has any validation errors
func (f Submission) FieldHasErrors(fieldName string) bool {
func (f *Submission) IsValid() bool {
if f.errors == nil {
return true
}
return len(f.errors) == 0
}
func (f *Submission) IsDone() bool {
return f.IsSubmitted() && f.IsValid()
}
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 *Submission) SetFieldError(fieldName string, message string) {
if f.Errors == nil {
f.Errors = make(map[string][]string)
if f.errors == nil {
f.errors = make(map[string][]string)
}
f.Errors[fieldName] = append(f.Errors[fieldName], message)
f.errors[fieldName] = append(f.errors[fieldName], message)
}
// GetFieldErrors gets the errors for a given field name
func (f Submission) GetFieldErrors(fieldName string) []string {
if f.Errors == nil {
func (f *Submission) GetFieldErrors(fieldName string) []string {
if f.errors == nil {
return []string{}
}
return f.Errors[fieldName]
return f.errors[fieldName]
}
// GetFieldStatusClass returns an HTML class based on the status of the field
func (f Submission) GetFieldStatusClass(fieldName string) string {
if f.IsSubmitted {
func (f *Submission) GetFieldStatusClass(fieldName string) string {
if f.isSubmitted {
if f.FieldHasErrors(fieldName) {
return "is-danger"
}
@ -68,12 +83,6 @@ func (f Submission) GetFieldStatusClass(fieldName string) string {
return ""
}
// IsDone indicates if the submission is considered done which is when it has been submitted
// and there are no errors.
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 *Submission) setErrorMessages(err error) {
// Only this is supported right now

View File

@ -1,40 +1,59 @@
package form
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/pkg/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFormSubmission(t *testing.T) {
type formTest struct {
Name string `validate:"required"`
Email string `validate:"required,email"`
Submission Submission
Name string `form:"name" validate:"required"`
Email string `form:"email" validate:"required,email"`
Submission
}
e := echo.New()
e.Validator = services.NewValidator()
ctx, _ := tests.NewContext(e, "/")
form := formTest{
Name: "",
Email: "a@a.com",
}
err := form.Submission.Process(ctx, form)
require.NoError(t, err)
assert.True(t, form.Submission.HasErrors())
assert.True(t, form.Submission.FieldHasErrors("Name"))
assert.False(t, form.Submission.FieldHasErrors("Email"))
require.Len(t, form.Submission.GetFieldErrors("Name"), 1)
assert.Len(t, form.Submission.GetFieldErrors("Email"), 0)
assert.Equal(t, "This field is required.", form.Submission.GetFieldErrors("Name")[0])
assert.Equal(t, "is-danger", form.Submission.GetFieldStatusClass("Name"))
assert.Equal(t, "is-success", form.Submission.GetFieldStatusClass("Email"))
assert.False(t, form.Submission.IsDone())
t.Run("valid request", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("email=a@a.com"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
ctx := e.NewContext(req, httptest.NewRecorder())
var form formTest
err := form.Submit(ctx, &form)
assert.IsType(t, validator.ValidationErrors{}, err)
assert.Empty(t, form.Name)
assert.Equal(t, "a@a.com", form.Email)
assert.False(t, form.IsValid())
assert.True(t, form.FieldHasErrors("Name"))
assert.False(t, form.FieldHasErrors("Email"))
require.Len(t, form.GetFieldErrors("Name"), 1)
assert.Len(t, form.GetFieldErrors("Email"), 0)
assert.Equal(t, "This field is required.", form.GetFieldErrors("Name")[0])
assert.Equal(t, "is-danger", form.GetFieldStatusClass("Name"))
assert.Equal(t, "is-success", form.GetFieldStatusClass("Email"))
assert.False(t, form.IsDone())
formInCtx := Get[formTest](ctx)
require.NotNil(t, formInCtx)
assert.Equal(t, form.Email, formInCtx.Email)
})
t.Run("invalid request", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("abc=abc"))
ctx := e.NewContext(req, httptest.NewRecorder())
var form formTest
err := form.Submit(ctx, &form)
assert.IsType(t, new(echo.HTTPError), err)
})
}

View File

@ -6,10 +6,10 @@ import (
"reflect"
"strings"
"github.com/mikestefanello/pagoda/config"
"github.com/Masterminds/sprig"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/random"
"github.com/mikestefanello/pagoda/config"
)
var (
@ -17,29 +17,28 @@ var (
CacheBuster = random.String(10)
)
// GetFuncMap provides a template function map
func GetFuncMap() template.FuncMap {
// See http://masterminds.github.io/sprig/ for available funcs
funcMap := sprig.FuncMap()
// Provide a list of custom functions
// Expand this as you add more functions to this package
// Avoid using a name already in use by sprig
f := template.FuncMap{
"hasField": HasField,
"file": File,
"link": Link,
type funcMap struct {
web *echo.Echo
}
for k, v := range f {
funcMap[k] = v
// NewFuncMap provides a template function map
func NewFuncMap(web *echo.Echo) template.FuncMap {
fm := &funcMap{web: web}
// See http://masterminds.github.io/sprig/ for all provided funcs
funcs := sprig.FuncMap()
// Include all the custom functions
funcs["hasField"] = fm.hasField
funcs["file"] = fm.file
funcs["link"] = fm.link
funcs["url"] = fm.url
return funcs
}
return funcMap
}
// HasField checks if an interface contains a given field
func HasField(v any, name string) bool {
// hasField checks if an interface contains a given field
func (fm *funcMap) hasField(v any, name string) bool {
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Ptr {
rv = rv.Elem()
@ -50,13 +49,13 @@ func HasField(v any, name string) bool {
return rv.FieldByName(name).IsValid()
}
// File appends a cache buster to a given filepath so it can remain cached until the app is restarted
func File(filepath string) string {
// file appends a cache buster to a given filepath so it can remain cached until the app is restarted
func (fm *funcMap) file(filepath string) string {
return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, CacheBuster)
}
// Link outputs HTML for a link element, providing the ability to dynamically set the active class
func Link(url, text, currentPath string, classes ...string) template.HTML {
// link outputs HTML for a link element, providing the ability to dynamically set the active class
func (fm *funcMap) link(url, text, currentPath string, classes ...string) template.HTML {
if currentPath == url {
classes = append(classes, "is-active")
}
@ -64,3 +63,8 @@ func Link(url, text, currentPath string, classes ...string) template.HTML {
html := fmt.Sprintf(`<a class="%s" href="%s">%s</a>`, strings.Join(classes, " "), url, text)
return template.HTML(html)
}
// url generates a URL from a given route name and optional parameters
func (fm *funcMap) url(routeName string, params ...any) string {
return fm.web.Reverse(routeName, params...)
}

View File

@ -4,36 +4,60 @@ import (
"fmt"
"testing"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/config"
"github.com/stretchr/testify/assert"
)
func TestNewFuncMap(t *testing.T) {
f := NewFuncMap(echo.New())
assert.NotNil(t, f["hasField"])
assert.NotNil(t, f["link"])
assert.NotNil(t, f["file"])
assert.NotNil(t, f["url"])
}
func TestHasField(t *testing.T) {
type example struct {
name string
}
var e example
assert.True(t, HasField(e, "name"))
assert.False(t, HasField(e, "abcd"))
f := new(funcMap)
assert.True(t, f.hasField(e, "name"))
assert.False(t, f.hasField(e, "abcd"))
}
func TestLink(t *testing.T) {
link := string(Link("/abc", "Text", "/abc"))
f := new(funcMap)
link := string(f.link("/abc", "Text", "/abc"))
expected := `<a class="is-active" href="/abc">Text</a>`
assert.Equal(t, expected, link)
link = string(Link("/abc", "Text", "/abc", "first", "second"))
link = string(f.link("/abc", "Text", "/abc", "first", "second"))
expected = `<a class="first second is-active" href="/abc">Text</a>`
assert.Equal(t, expected, link)
link = string(Link("/abc", "Text", "/def"))
link = string(f.link("/abc", "Text", "/def"))
expected = `<a class="" href="/abc">Text</a>`
assert.Equal(t, expected, link)
}
func TestFile(t *testing.T) {
file := File("test.png")
f := new(funcMap)
file := f.file("test.png")
expected := fmt.Sprintf("/%s/test.png?v=%s", config.StaticPrefix, CacheBuster)
assert.Equal(t, expected, file)
}
func TestUrl(t *testing.T) {
f := new(funcMap)
f.web = echo.New()
f.web.GET("/mypath/:id", func(c echo.Context) error {
return nil
}).Name = "test"
out := f.url("test", 5)
assert.Equal(t, "/mypath/5", out)
}

View File

@ -4,6 +4,7 @@ import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/ent/user"
@ -39,13 +40,13 @@ type (
forgotPasswordForm struct {
Email string `form:"email" validate:"required,email"`
Submission form.Submission
form.Submission
}
loginForm struct {
Email string `form:"email" validate:"required,email"`
Password string `form:"password" validate:"required"`
Submission form.Submission
form.Submission
}
registerForm struct {
@ -53,13 +54,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 form.Submission
form.Submission
}
resetPasswordForm struct {
Password string `form:"password" validate:"required"`
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
Submission form.Submission
form.Submission
}
)
@ -114,17 +115,14 @@ func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
return c.ForgotPasswordPage(ctx)
}
// Set the form in context and parse the form values
if err := form.Set(ctx, &input); err != nil {
return err
}
err := form.Submit(ctx, &input)
if err := input.Submission.Process(ctx, input); err != nil {
return c.Fail(err, "unable to process form submission")
}
if input.Submission.HasErrors() {
switch err.(type) {
case nil:
case validator.ValidationErrors:
return c.ForgotPasswordPage(ctx)
default:
return err
}
// Attempt to load the user
@ -179,23 +177,20 @@ func (c *Auth) LoginSubmit(ctx echo.Context) error {
var input loginForm
authFailed := func() error {
input.Submission.SetFieldError("Email", "")
input.Submission.SetFieldError("Password", "")
input.SetFieldError("Email", "")
input.SetFieldError("Password", "")
msg.Danger(ctx, "Invalid credentials. Please try again.")
return c.LoginPage(ctx)
}
// Set in context and parse the form values
if err := form.Set(ctx, &input); err != nil {
return err
}
err := form.Submit(ctx, &input)
if err := input.Submission.Process(ctx, input); err != nil {
return c.Fail(err, "unable to process form submission")
}
if input.Submission.HasErrors() {
switch err.(type) {
case nil:
case validator.ValidationErrors:
return c.LoginPage(ctx)
default:
return err
}
// Attempt to load the user
@ -250,17 +245,14 @@ func (c *Auth) RegisterPage(ctx echo.Context) error {
func (c *Auth) RegisterSubmit(ctx echo.Context) error {
var input registerForm
// 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")
}
err := form.Submit(ctx, &input)
if err := input.Submission.Process(ctx, input); err != nil {
return c.Fail(err, "unable to process form submission")
}
if input.Submission.HasErrors() {
switch err.(type) {
case nil:
case validator.ValidationErrors:
return c.RegisterPage(ctx)
default:
return err
}
// Hash the password
@ -341,17 +333,14 @@ func (c *Auth) ResetPasswordPage(ctx echo.Context) error {
func (c *Auth) ResetPasswordSubmit(ctx echo.Context) error {
var input resetPasswordForm
// 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")
}
err := form.Submit(ctx, &input)
if err := input.Submission.Process(ctx, input); err != nil {
return c.Fail(err, "unable to process form submission")
}
if input.Submission.HasErrors() {
switch err.(type) {
case nil:
case validator.ValidationErrors:
return c.ResetPasswordPage(ctx)
default:
return err
}
// Hash the new password

View File

@ -3,6 +3,7 @@ package handlers
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/controller"
"github.com/mikestefanello/pagoda/pkg/form"
@ -25,7 +26,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 form.Submission
form.Submission
}
)
@ -57,17 +58,17 @@ func (c *Contact) Page(ctx echo.Context) error {
func (c *Contact) Submit(ctx echo.Context) error {
var input contactForm
// Store in context and parse the form values
if err := form.Set(ctx, &input); err != nil {
err := form.Submit(ctx, &input)
switch err.(type) {
case nil:
case validator.ValidationErrors:
return c.Page(ctx)
default:
return err
}
if err := input.Submission.Process(ctx, input); err != nil {
return c.Fail(err, "unable to process form submission")
}
if !input.Submission.HasErrors() {
err := c.mail.
err = c.mail.
Compose().
To(input.Email).
Subject("Contact form submitted").
@ -77,7 +78,6 @@ func (c *Contact) Submit(ctx echo.Context) error {
if err != nil {
return c.Fail(err, "unable to send email")
}
}
return c.Page(ctx)
}

View File

@ -48,8 +48,8 @@ func Danger(ctx echo.Context, message string) {
Set(ctx, TypeDanger, message)
}
// Set adds a new flash message of a given type into the session storage
// Errors will logged and not returned
// Set adds a new flash message of a given type into the session storage.
// Errors will be logged and not returned.
func Set(ctx echo.Context, typ Type, message string) {
if sess, err := getSession(ctx); err == nil {
sess.AddFlash(message, string(typ))
@ -57,8 +57,8 @@ func Set(ctx echo.Context, typ Type, message string) {
}
}
// Get gets flash messages of a given type from the session storage
// Errors will logged and not returned
// Get gets flash messages of a given type from the session storage.
// Errors will be logged and not returned.
func Get(ctx echo.Context, typ Type) []string {
var msgs []string

View File

@ -183,7 +183,7 @@ func (c *Container) initAuth() {
// initTemplateRenderer initializes the template renderer
func (c *Container) initTemplateRenderer() {
c.TemplateRenderer = NewTemplateRenderer(c.Config)
c.TemplateRenderer = NewTemplateRenderer(c.Config, c.Web)
}
// initMail initialize the mail client

View File

@ -8,6 +8,7 @@ import (
"io/fs"
"sync"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/pkg/funcmap"
"github.com/mikestefanello/pagoda/templates"
@ -53,10 +54,10 @@ type (
)
// NewTemplateRenderer creates a new TemplateRenderer
func NewTemplateRenderer(cfg *config.Config) *TemplateRenderer {
func NewTemplateRenderer(cfg *config.Config, web *echo.Echo) *TemplateRenderer {
return &TemplateRenderer{
templateCache: sync.Map{},
funcMap: funcmap.GetFuncMap(),
funcMap: funcmap.NewFuncMap(web),
config: cfg,
}
}

View File

@ -19,9 +19,9 @@
{{template "content" .}}
<div class="content is-small has-text-centered" hx-boost="true">
<a href="{{call .ToURL "login"}}">Login</a> &#9676;
<a href="{{call .ToURL "register"}}">Create an account</a> &#9676;
<a href="{{call .ToURL "forgot_password"}}">Forgot password?</a>
<a href="{{url "login"}}">Login</a> &#9676;
<a href="{{url "register"}}">Create an account</a> &#9676;
<a href="{{url "forgot_password"}}">Forgot password?</a>
</div>
</div>
</div>

View File

@ -9,7 +9,7 @@
<nav class="navbar is-dark">
<div class="container">
<div class="navbar-brand" hx-boost="true">
<a href="{{call .ToURL "home"}}" class="navbar-item">{{.AppName}}</a>
<a href="{{url "home"}}" class="navbar-item">{{.AppName}}</a>
</div>
<div id="navbarMenu" class="navbar-menu">
<div class="navbar-end">
@ -25,19 +25,19 @@
<aside class="menu" hx-boost="true">
<p class="menu-label">General</p>
<ul class="menu-list">
<li>{{link (call .ToURL "home") "Dashboard" .Path}}</li>
<li>{{link (call .ToURL "about") "About" .Path}}</li>
<li>{{link (call .ToURL "contact") "Contact" .Path}}</li>
<li>{{link (url "home") "Dashboard" .Path}}</li>
<li>{{link (url "about") "About" .Path}}</li>
<li>{{link (url "contact") "Contact" .Path}}</li>
</ul>
<p class="menu-label">Account</p>
<ul class="menu-list">
{{- if .IsAuth}}
<li>{{link (call .ToURL "logout") "Logout" .Path}}</li>
<li>{{link (url "logout") "Logout" .Path}}</li>
{{- else}}
<li>{{link (call .ToURL "login") "Login" .Path}}</li>
<li>{{link (call .ToURL "register") "Register" .Path}}</li>
<li>{{link (call .ToURL "forgot_password") "Forgot password" .Path}}</li>
<li>{{link (url "login") "Login" .Path}}</li>
<li>{{link (url "register") "Register" .Path}}</li>
<li>{{link (url "forgot_password") "Forgot password" .Path}}</li>
{{- end}}
</ul>
</aside>
@ -70,7 +70,7 @@
<h2 class="subtitle">Search</h2>
<p class="control">
<input
hx-get="{{call .ToURL "search"}}"
hx-get="{{url "search"}}"
hx-trigger="keyup changed delay:500ms"
hx-target="#results"
name="query"

View File

@ -12,7 +12,7 @@
{{end}}
{{define "form"}}
{{- if .Form.Submission.IsDone}}
{{- if .Form.IsDone}}
<article class="message is-large is-success">
<div class="message-header">
<p>Thank you!</p>
@ -22,13 +22,13 @@
</div>
</article>
{{- else}}
<form id="contact" method="post" hx-post="{{call .ToURL "contact.post"}}">
<form id="contact" method="post" hx-post="{{url "contact.post"}}">
<div class="field">
<label for="email" class="label">Email address</label>
<div class="control">
<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}}">
</div>
{{template "field-errors" (.Form.Submission.GetFieldErrors "Email")}}
{{template "field-errors" (.Form.GetFieldErrors "Email")}}
</div>
<div class="control">
@ -45,15 +45,15 @@
<input type="radio" name="department" value="hr" {{if eq .Form.Department "hr"}}checked{{end}}/>
HR
</label>
{{template "field-errors" (.Form.Submission.GetFieldErrors "Department")}}
{{template "field-errors" (.Form.GetFieldErrors "Department")}}
</div>
<div class="field">
<label for="message" class="label">Message</label>
<div class="control">
<textarea id="message" name="message" class="textarea {{.Form.Submission.GetFieldStatusClass "Message"}}">{{.Form.Message}}</textarea>
<textarea id="message" name="message" class="textarea {{.Form.GetFieldStatusClass "Message"}}">{{.Form.Message}}</textarea>
</div>
{{template "field-errors" (.Form.Submission.GetFieldErrors "Message")}}
{{template "field-errors" (.Form.GetFieldErrors "Message")}}
</div>
<div class="field is-grouped">

View File

@ -4,7 +4,7 @@
{{else if or (eq .StatusCode 403) (eq .StatusCode 401)}}
<p>You are not authorized to view the requested page.</p>
{{else if eq .StatusCode 404}}
<p>Click {{link (call .ToURL "home") "here" .Path}} to return home</p>
<p>Click {{link (url "home") "here" .Path}} to return home</p>
{{else}}
<p>Something went wrong</p>
{{end}}

View File

@ -1,5 +1,5 @@
{{define "content"}}
<form method="post" hx-boost="true" action="{{call .ToURL "forgot_password.post"}}">
<form method="post" hx-boost="true" action="{{url "forgot_password.post"}}">
<div class="content">
<p>Enter your email address and we'll email you a link that allows you to reset your password.</p>
</div>
@ -15,7 +15,7 @@
<button class="button is-primary">Reset password</button>
</p>
<p class="control">
<a href="{{call .ToURL "home"}}" class="button is-light">Cancel</a>
<a href="{{url "home"}}" class="button is-light">Cancel</a>
</p>
</div>
{{template "csrf" .}}

View File

@ -1,5 +1,5 @@
{{define "content"}}
<form method="post" hx-boost="true" action="{{call .ToURL "login.post"}}">
<form method="post" hx-boost="true" action="{{url "login.post"}}">
{{template "messages" .}}
<div class="field">
<label for="email" class="label">Email address</label>
@ -20,7 +20,7 @@
<button class="button is-primary">Log in</button>
</p>
<p class="control">
<a href="{{call .ToURL "home"}}" class="button is-light">Cancel</a>
<a href="{{url "home"}}" class="button is-light">Cancel</a>
</p>
</div>
{{template "csrf" .}}

View File

@ -1,31 +1,31 @@
{{define "content"}}
<form method="post" hx-boost="true" action="{{call .ToURL "register.post"}}">
<form method="post" hx-boost="true" action="{{url "register.post"}}">
<div class="field">
<label for="name" class="label">Name</label>
<div class="control">
<input type="text" id="name" name="name" class="input {{.Form.Submission.GetFieldStatusClass "Name"}}" value="{{.Form.Name}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "Name")}}
<input type="text" id="name" name="name" class="input {{.Form.GetFieldStatusClass "Name"}}" value="{{.Form.Name}}">
{{template "field-errors" (.Form.GetFieldErrors "Name")}}
</div>
</div>
<div class="field">
<label for="email" class="label">Email address</label>
<div class="control">
<input type="email" id="email" name="email" class="input {{.Form.Submission.GetFieldStatusClass "Email"}}" value="{{.Form.Email}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "Email")}}
<input type="email" id="email" name="email" class="input {{.Form.GetFieldStatusClass "Email"}}" value="{{.Form.Email}}">
{{template "field-errors" (.Form.GetFieldErrors "Email")}}
</div>
</div>
<div class="field">
<label for="password" class="label">Password</label>
<div class="control">
<input type="password" id="password" name="password" placeholder="*******" class="input {{.Form.Submission.GetFieldStatusClass "Password"}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "Password")}}
<input type="password" id="password" name="password" placeholder="*******" class="input {{.Form.GetFieldStatusClass "Password"}}">
{{template "field-errors" (.Form.GetFieldErrors "Password")}}
</div>
</div>
<div class="field">
<label for="password-confirm" class="label">Confirm password</label>
<div class="control">
<input type="password" id="password-confirm" name="password-confirm" placeholder="*******" class="input {{.Form.Submission.GetFieldStatusClass "ConfirmPassword"}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "ConfirmPassword")}}
<input type="password" id="password-confirm" name="password-confirm" placeholder="*******" class="input {{.Form.GetFieldStatusClass "ConfirmPassword"}}">
{{template "field-errors" (.Form.GetFieldErrors "ConfirmPassword")}}
</div>
</div>
<div class="field is-grouped">
@ -33,7 +33,7 @@
<button class="button is-primary">Register</button>
</p>
<p class="control">
<a href="{{call .ToURL "home"}}" class="button is-light">Cancel</a>
<a href="{{url "home"}}" class="button is-light">Cancel</a>
</p>
</div>
{{template "csrf" .}}

View File

@ -3,15 +3,15 @@
<div class="field">
<label for="password" class="label">Password</label>
<div class="control">
<input type="password" id="password" name="password" placeholder="*******" class="input {{.Form.Submission.GetFieldStatusClass "Password"}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "Password")}}
<input type="password" id="password" name="password" placeholder="*******" class="input {{.Form.GetFieldStatusClass "Password"}}">
{{template "field-errors" (.Form.GetFieldErrors "Password")}}
</div>
</div>
<div class="field">
<label for="password-confirm" class="label">Confirm password</label>
<div class="control">
<input type="password" id="password-confirm" name="password-confirm" placeholder="*******" class="input {{.Form.Submission.GetFieldStatusClass "ConfirmPassword"}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "ConfirmPassword")}}
<input type="password" id="password-confirm" name="password-confirm" placeholder="*******" class="input {{.Form.GetFieldStatusClass "ConfirmPassword"}}">
{{template "field-errors" (.Form.GetFieldErrors "ConfirmPassword")}}
</div>
</div>
<div class="field is-grouped">