Added a basic homepage

This commit is contained in:
CamZawacki 2026-05-20 16:09:54 +01:00
parent d40640a648
commit 12fd3c04ca
113 changed files with 414 additions and 506 deletions

55
internal/form/form.go Normal file
View file

@ -0,0 +1,55 @@
package form
import (
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/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, or 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
}
// 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 {
if form, ok := v.(*T); ok {
return form
}
}
var v T
return &v
}
// 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

@ -0,0 +1,67 @@
package form
import (
"testing"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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 {
Name string `form:"name"`
}
t.Run("get empty context", func(t *testing.T) {
// Empty context, still return a form.
ctx, _ := tests.NewContext(e, "/")
form := Get[example](ctx)
assert.NotNil(t, form)
})
t.Run("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, "test", got.Name)
// Attempt getting a different type to ensure there's no panic.
ret := Get[int](ctx)
require.NotNil(t, ret)
// Clear.
Clear(ctx)
got = Get[example](ctx)
require.NotNil(t, got)
assert.Empty(t, got.Name)
})
}

105
internal/form/submission.go Normal file
View file

@ -0,0 +1,105 @@
package form
import (
"fmt"
"net/http"
"github.com/go-playground/validator/v10"
"github.com/camzawacki/personal-site/internal/context"
"github.com/labstack/echo/v4"
)
// 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
// errors stores a slice of error message strings keyed by form struct field name.
errors map[string][]string
}
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
}
func (f *Submission) IsSubmitted() bool {
return f.isSubmitted
}
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
}
func (f *Submission) SetFieldError(fieldName string, message string) {
if f.errors == nil {
f.errors = make(map[string][]string)
}
f.errors[fieldName] = append(f.errors[fieldName], message)
}
func (f *Submission) GetFieldErrors(fieldName string) []string {
if f.errors == nil {
return []string{}
}
return f.errors[fieldName]
}
// 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
ves, ok := err.(validator.ValidationErrors)
if !ok {
return
}
for _, ve := range ves {
var message string
// Provide better error messages depending on the failed validation tag.
// This should be expanded as you use additional tags in your validation.
switch ve.Tag() {
case "required":
message = "This field is required."
case "email":
message = "Enter a valid email address."
case "eqfield":
message = "Does not match."
case "gte":
message = fmt.Sprintf("Must be greater than or equal to %v.", ve.Param())
default:
message = "Invalid value."
}
// Add the error.
f.SetFieldError(ve.Field(), message)
}
}

View file

@ -0,0 +1,57 @@
package form
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/services"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFormSubmission(t *testing.T) {
type formTest struct {
Name string `form:"name" validate:"required"`
Email string `form:"email" validate:"required,email"`
Submission
}
e := echo.New()
e.Validator = services.NewValidator()
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.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)
})
}