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 ### 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. 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: Using the `echo.Context`, the `Page` will be initialized with the following fields populated:
- `Context`: The passed in _context_ - `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 - `Path`: The requested URL path
- `URL`: The requested URL - `URL`: The requested URL
- `StatusCode`: Defaults to 200 - `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 ### 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: An example of this pattern is:
@ -646,10 +645,12 @@ 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 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: Then in your page:
```go ```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`. 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 ```go
var input ContactForm 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 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: And finally, your template:
```html ```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 #### 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: First, include a status class on the element so it will highlight green or red based on the validation:
```html ```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: Second, render the error messages, if there are any for a given field:
```go ```go
{{template "field-errors" (.Form.Submission.GetFieldErrors "Email")}} {{template "field-errors" (.Form.GetFieldErrors "Email")}}
``` ```
### Headers ### 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: And you want to generate a URL in the template, you can:
```go ```go
{{call .ToURL "user_profile" 1} {{url "user_profile" 1}
``` ```
Which will generate: `/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. 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 ```go
{{link (call .ToURL "user_profile" .AuthUser.ID) "Profile" .Path "extra-class"}} {{link (url "user_profile" .AuthUser.ID) "Profile" .Path "extra-class"}}
``` ```
Will generate: 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/). 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 ## Cache

View File

@ -7,6 +7,7 @@ import (
"github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/htmx" "github.com/mikestefanello/pagoda/pkg/htmx"
"github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/templates" "github.com/mikestefanello/pagoda/templates"
@ -34,9 +35,6 @@ type Page struct {
// Context stores the request context // Context stores the request context
Context echo.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 stores the path of the current request
Path string Path string
@ -50,8 +48,8 @@ type Page struct {
// Form stores a struct that represents a form on the page. // 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 // 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 // It should also contain a Submission field of type FormSubmission if you wish to have validation
// messagesa and markup presented to the user // messages and markup presented to the user
Form any Form form.Form
// Layout stores the name of the layout base template file which will be used when the page is rendered. // 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. // 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 stores whether the requested page is the home page or not
IsHome bool IsHome bool
// IsAuth stores whether or not the user is authenticated // IsAuth stores whether the user is authenticated
IsAuth bool IsAuth bool
// AuthUser stores the authenticated user // AuthUser stores the authenticated user
@ -125,7 +123,6 @@ type Page struct {
func NewPage(ctx echo.Context) Page { func NewPage(ctx echo.Context) Page {
p := Page{ p := Page{
Context: ctx, Context: ctx,
ToURL: ctx.Echo().Reverse,
Path: ctx.Request().URL.Path, Path: ctx.Request().URL.Path,
URL: ctx.Request().URL.String(), URL: ctx.Request().URL.String(),
StatusCode: http.StatusOK, StatusCode: http.StatusOK,

View File

@ -17,7 +17,6 @@ func TestNewPage(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/") ctx, _ := tests.NewContext(c.Web, "/")
p := NewPage(ctx) p := NewPage(ctx)
assert.Same(t, ctx, p.Context) assert.Same(t, ctx, p.Context)
assert.NotNil(t, p.ToURL)
assert.Equal(t, "/", p.Path) assert.Equal(t, "/", p.Path)
assert.Equal(t, "/", p.URL) assert.Equal(t, "/", p.URL)
assert.Equal(t, http.StatusOK, p.StatusCode) assert.Equal(t, http.StatusOK, p.StatusCode)

View File

@ -1,13 +1,40 @@
package form package form
import ( import (
"fmt"
"net/http"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/context" "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 // 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 { func Get[T any](ctx echo.Context) *T {
if v := ctx.Get(context.FormKey); v != nil { if v := ctx.Get(context.FormKey); v != nil {
@ -17,18 +44,13 @@ func Get[T any](ctx echo.Context) *T {
return &v 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 // Clear removes the form set in the context
func Clear(ctx echo.Context) { func Clear(ctx echo.Context) {
ctx.Set(context.FormKey, nil) 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 package form
import ( import (
"net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/tests" "github.com/mikestefanello/pagoda/pkg/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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() e := echo.New()
type example struct { type example struct {
@ -26,29 +42,17 @@ func TestContextFuncs(t *testing.T) {
assert.NotNil(t, form) assert.NotNil(t, form)
}) })
t.Run("set bad request", func(t *testing.T) { t.Run("get non-empty context", func(t *testing.T) {
// Set with a bad request form := example{
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("abc=abc")) Name: "test",
ctx := e.NewContext(req, httptest.NewRecorder()) }
var form example ctx, _ := tests.NewContext(e, "/")
err := Set(ctx, &form) ctx.Set(context.FormKey, &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 // Get again and expect the values were stored
got := Get[example](ctx) got := Get[example](ctx)
require.NotNil(t, got) require.NotNil(t, got)
assert.Equal(t, "abc", form.Name) assert.Equal(t, "test", form.Name)
// Clear // Clear
Clear(ctx) Clear(ctx)

View File

@ -1,65 +1,80 @@
package form package form
import ( import (
"fmt"
"net/http"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/labstack/echo/v4" "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 { type Submission struct {
// IsSubmitted indicates if the form has been submitted // isSubmitted indicates if the form has been submitted
IsSubmitted bool isSubmitted bool
// Errors stores a slice of error message strings keyed by form struct field name // errors stores a slice of error message strings keyed by form struct field name
Errors map[string][]string errors map[string][]string
} }
// Process processes a submission for a form func (f *Submission) Submit(ctx echo.Context, form any) error {
func (f *Submission) Process(ctx echo.Context, form any) error { f.isSubmitted = true
f.Errors = make(map[string][]string)
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 // Validate the form
if err := ctx.Validate(form); err != nil { if err := ctx.Validate(form); err != nil {
f.setErrorMessages(err) f.setErrorMessages(err)
return err
} }
return nil return nil
} }
// HasErrors indicates if the submission has any validation errors func (f *Submission) IsSubmitted() bool {
func (f Submission) HasErrors() bool { return f.isSubmitted
if f.Errors == nil {
return false
}
return len(f.Errors) > 0
} }
// FieldHasErrors indicates if a given field on the form has any validation errors func (f *Submission) IsValid() bool {
func (f Submission) FieldHasErrors(fieldName string) 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 return len(f.GetFieldErrors(fieldName)) > 0
} }
// SetFieldError sets an error message for a given field name
func (f *Submission) 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)
} }
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 {
func (f Submission) GetFieldErrors(fieldName string) []string { if f.errors == nil {
if f.Errors == nil {
return []string{} 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 {
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"
} }
@ -68,12 +83,6 @@ func (f Submission) GetFieldStatusClass(fieldName string) string {
return "" 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 // setErrorMessages sets errors messages on the submission for all fields that failed validation
func (f *Submission) setErrorMessages(err error) { func (f *Submission) setErrorMessages(err error) {
// Only this is supported right now // Only this is supported right now

View File

@ -1,40 +1,59 @@
package form package form
import ( import (
"net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/pkg/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestFormSubmission(t *testing.T) { func TestFormSubmission(t *testing.T) {
type formTest struct { type formTest struct {
Name string `validate:"required"` Name string `form:"name" validate:"required"`
Email string `validate:"required,email"` Email string `form:"email" validate:"required,email"`
Submission Submission Submission
} }
e := echo.New() e := echo.New()
e.Validator = services.NewValidator() 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()) t.Run("valid request", func(t *testing.T) {
assert.True(t, form.Submission.FieldHasErrors("Name")) req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("email=a@a.com"))
assert.False(t, form.Submission.FieldHasErrors("Email")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
require.Len(t, form.Submission.GetFieldErrors("Name"), 1) ctx := e.NewContext(req, httptest.NewRecorder())
assert.Len(t, form.Submission.GetFieldErrors("Email"), 0)
assert.Equal(t, "This field is required.", form.Submission.GetFieldErrors("Name")[0]) var form formTest
assert.Equal(t, "is-danger", form.Submission.GetFieldStatusClass("Name")) err := form.Submit(ctx, &form)
assert.Equal(t, "is-success", form.Submission.GetFieldStatusClass("Email")) assert.IsType(t, validator.ValidationErrors{}, err)
assert.False(t, form.Submission.IsDone())
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" "reflect"
"strings" "strings"
"github.com/mikestefanello/pagoda/config"
"github.com/Masterminds/sprig" "github.com/Masterminds/sprig"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/random" "github.com/labstack/gommon/random"
"github.com/mikestefanello/pagoda/config"
) )
var ( var (
@ -17,29 +17,28 @@ var (
CacheBuster = random.String(10) CacheBuster = random.String(10)
) )
// GetFuncMap provides a template function map type funcMap struct {
func GetFuncMap() template.FuncMap { web *echo.Echo
// 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,
}
for k, v := range f {
funcMap[k] = v
}
return funcMap
} }
// HasField checks if an interface contains a given field // NewFuncMap provides a template function map
func HasField(v any, name string) bool { 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
}
// hasField checks if an interface contains a given field
func (fm *funcMap) hasField(v any, name string) bool {
rv := reflect.ValueOf(v) rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Ptr { if rv.Kind() == reflect.Ptr {
rv = rv.Elem() rv = rv.Elem()
@ -50,13 +49,13 @@ func HasField(v any, name string) bool {
return rv.FieldByName(name).IsValid() return rv.FieldByName(name).IsValid()
} }
// File appends a cache buster to a given filepath so it can remain cached until the app is restarted // file appends a cache buster to a given filepath so it can remain cached until the app is restarted
func File(filepath string) string { func (fm *funcMap) file(filepath string) string {
return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, CacheBuster) 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 // 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 { func (fm *funcMap) link(url, text, currentPath string, classes ...string) template.HTML {
if currentPath == url { if currentPath == url {
classes = append(classes, "is-active") 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) html := fmt.Sprintf(`<a class="%s" href="%s">%s</a>`, strings.Join(classes, " "), url, text)
return template.HTML(html) 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" "fmt"
"testing" "testing"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/config" "github.com/mikestefanello/pagoda/config"
"github.com/stretchr/testify/assert" "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) { func TestHasField(t *testing.T) {
type example struct { type example struct {
name string name string
} }
var e example var e example
assert.True(t, HasField(e, "name")) f := new(funcMap)
assert.False(t, HasField(e, "abcd")) assert.True(t, f.hasField(e, "name"))
assert.False(t, f.hasField(e, "abcd"))
} }
func TestLink(t *testing.T) { 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>` expected := `<a class="is-active" href="/abc">Text</a>`
assert.Equal(t, expected, link) 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>` expected = `<a class="first second is-active" href="/abc">Text</a>`
assert.Equal(t, expected, link) 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>` expected = `<a class="" href="/abc">Text</a>`
assert.Equal(t, expected, link) assert.Equal(t, expected, link)
} }
func TestFile(t *testing.T) { 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) expected := fmt.Sprintf("/%s/test.png?v=%s", config.StaticPrefix, CacheBuster)
assert.Equal(t, expected, file) 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" "fmt"
"strings" "strings"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/ent/user" "github.com/mikestefanello/pagoda/ent/user"
@ -38,14 +39,14 @@ type (
} }
forgotPasswordForm struct { forgotPasswordForm struct {
Email string `form:"email" validate:"required,email"` Email string `form:"email" validate:"required,email"`
Submission form.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 form.Submission form.Submission
} }
registerForm struct { registerForm struct {
@ -53,13 +54,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 form.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 form.Submission form.Submission
} }
) )
@ -114,17 +115,14 @@ func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
return c.ForgotPasswordPage(ctx) return c.ForgotPasswordPage(ctx)
} }
// Set the form in context and parse the form values err := form.Submit(ctx, &input)
if err := form.Set(ctx, &input); err != nil {
return err
}
if err := input.Submission.Process(ctx, input); err != nil { switch err.(type) {
return c.Fail(err, "unable to process form submission") case nil:
} case validator.ValidationErrors:
if input.Submission.HasErrors() {
return c.ForgotPasswordPage(ctx) return c.ForgotPasswordPage(ctx)
default:
return err
} }
// Attempt to load the user // Attempt to load the user
@ -179,23 +177,20 @@ func (c *Auth) LoginSubmit(ctx echo.Context) error {
var input loginForm var input loginForm
authFailed := func() error { authFailed := func() error {
input.Submission.SetFieldError("Email", "") input.SetFieldError("Email", "")
input.Submission.SetFieldError("Password", "") input.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)
} }
// Set in context and parse the form values err := form.Submit(ctx, &input)
if err := form.Set(ctx, &input); err != nil {
return err
}
if err := input.Submission.Process(ctx, input); err != nil { switch err.(type) {
return c.Fail(err, "unable to process form submission") case nil:
} case validator.ValidationErrors:
if input.Submission.HasErrors() {
return c.LoginPage(ctx) return c.LoginPage(ctx)
default:
return err
} }
// Attempt to load the user // 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 { func (c *Auth) RegisterSubmit(ctx echo.Context) error {
var input registerForm var input registerForm
// Set in context and parse the form values err := form.Submit(ctx, &input)
if err := form.Set(ctx, &input); err != nil {
return c.Fail(err, "unable to parse register form")
}
if err := input.Submission.Process(ctx, input); err != nil { switch err.(type) {
return c.Fail(err, "unable to process form submission") case nil:
} case validator.ValidationErrors:
if input.Submission.HasErrors() {
return c.RegisterPage(ctx) return c.RegisterPage(ctx)
default:
return err
} }
// Hash the password // Hash the password
@ -341,17 +333,14 @@ func (c *Auth) ResetPasswordPage(ctx echo.Context) error {
func (c *Auth) ResetPasswordSubmit(ctx echo.Context) error { func (c *Auth) ResetPasswordSubmit(ctx echo.Context) error {
var input resetPasswordForm var input resetPasswordForm
// Set in context and parse the form values err := form.Submit(ctx, &input)
if err := form.Set(ctx, &input); err != nil {
return c.Fail(err, "unable to parse password reset form")
}
if err := input.Submission.Process(ctx, input); err != nil { switch err.(type) {
return c.Fail(err, "unable to process form submission") case nil:
} case validator.ValidationErrors:
if input.Submission.HasErrors() {
return c.ResetPasswordPage(ctx) return c.ResetPasswordPage(ctx)
default:
return err
} }
// Hash the new password // Hash the new password

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@
{{else if or (eq .StatusCode 403) (eq .StatusCode 401)}} {{else if or (eq .StatusCode 403) (eq .StatusCode 401)}}
<p>You are not authorized to view the requested page.</p> <p>You are not authorized to view the requested page.</p>
{{else if eq .StatusCode 404}} {{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}} {{else}}
<p>Something went wrong</p> <p>Something went wrong</p>
{{end}} {{end}}

View File

@ -1,5 +1,5 @@
{{define "content"}} {{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"> <div class="content">
<p>Enter your email address and we'll email you a link that allows you to reset your password.</p> <p>Enter your email address and we'll email you a link that allows you to reset your password.</p>
</div> </div>
@ -15,7 +15,7 @@
<button class="button is-primary">Reset password</button> <button class="button is-primary">Reset password</button>
</p> </p>
<p class="control"> <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> </p>
</div> </div>
{{template "csrf" .}} {{template "csrf" .}}

View File

@ -1,5 +1,5 @@
{{define "content"}} {{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" .}} {{template "messages" .}}
<div class="field"> <div class="field">
<label for="email" class="label">Email address</label> <label for="email" class="label">Email address</label>
@ -20,7 +20,7 @@
<button class="button is-primary">Log in</button> <button class="button is-primary">Log in</button>
</p> </p>
<p class="control"> <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> </p>
</div> </div>
{{template "csrf" .}} {{template "csrf" .}}

View File

@ -1,31 +1,31 @@
{{define "content"}} {{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"> <div class="field">
<label for="name" class="label">Name</label> <label for="name" class="label">Name</label>
<div class="control"> <div class="control">
<input type="text" id="name" name="name" class="input {{.Form.Submission.GetFieldStatusClass "Name"}}" value="{{.Form.Name}}"> <input type="text" id="name" name="name" class="input {{.Form.GetFieldStatusClass "Name"}}" value="{{.Form.Name}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "Name")}} {{template "field-errors" (.Form.GetFieldErrors "Name")}}
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label for="email" class="label">Email address</label> <label for="email" class="label">Email address</label>
<div class="control"> <div class="control">
<input type="email" id="email" name="email" class="input {{.Form.Submission.GetFieldStatusClass "Email"}}" value="{{.Form.Email}}"> <input type="email" id="email" name="email" class="input {{.Form.GetFieldStatusClass "Email"}}" value="{{.Form.Email}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "Email")}} {{template "field-errors" (.Form.GetFieldErrors "Email")}}
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label for="password" class="label">Password</label> <label for="password" class="label">Password</label>
<div class="control"> <div class="control">
<input type="password" id="password" name="password" placeholder="*******" class="input {{.Form.Submission.GetFieldStatusClass "Password"}}"> <input type="password" id="password" name="password" placeholder="*******" class="input {{.Form.GetFieldStatusClass "Password"}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "Password")}} {{template "field-errors" (.Form.GetFieldErrors "Password")}}
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label for="password-confirm" class="label">Confirm password</label> <label for="password-confirm" class="label">Confirm password</label>
<div class="control"> <div class="control">
<input type="password" id="password-confirm" name="password-confirm" placeholder="*******" class="input {{.Form.Submission.GetFieldStatusClass "ConfirmPassword"}}"> <input type="password" id="password-confirm" name="password-confirm" placeholder="*******" class="input {{.Form.GetFieldStatusClass "ConfirmPassword"}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "ConfirmPassword")}} {{template "field-errors" (.Form.GetFieldErrors "ConfirmPassword")}}
</div> </div>
</div> </div>
<div class="field is-grouped"> <div class="field is-grouped">
@ -33,7 +33,7 @@
<button class="button is-primary">Register</button> <button class="button is-primary">Register</button>
</p> </p>
<p class="control"> <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> </p>
</div> </div>
{{template "csrf" .}} {{template "csrf" .}}

View File

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