Views and mixins
A page's Load, its early returns, the mixins that read the request for it, and how LoadAll chains them.
A view is a page: a Go type with a Load method that fills in its fields from the request, which its template then renders. This page walks through the views of a small app, docsite/examples/games in goapplib’s repo: a paginated, searchable list of games, a page per game, and a “new game” page behind a login. Its test drives it over HTTP on every pull request, so the code here is what CI built.
The view contract ¶
type View[AC any] interface {
Load(r *http.Request, w http.ResponseWriter, app *App[AC]) (err error, finished bool)
}
goapplib makes a fresh view for each request, calls Load, and then does one of three things:
err != nil: answers 500 with the error’s text, and nothing renders.finished == true: does nothing more, sinceLoadwrote the response itself (a redirect, a 404, some JSON).- otherwise: renders the view’s template with the view as its data.
finished wins over err, so a Load that wrote a response returns nil, true.
The example’s views ¶
Here are all three, with the app context and the auth stand-in:
package games
import (
"net/http"
"net/url"
goal "github.com/panyam/goapplib"
)
// Site is the app context every page gets as app.Context.
type Site struct {
Name string
Store *Store
Auth goal.AuthProvider
}
// Chrome is what goapplib's layout reads from every page besides BasePage: the header and the
// navigation. Each page embeds it once rather than declaring the fields again.
type Chrome struct {
Header struct {
AppName string
IsLoggedIn bool
Username string
}
NavigationItems []NavItem
}
// NavItem is one header link.
type NavItem struct {
Href, Label string
Active bool
}
func (c *Chrome) fill(app *goal.App[*Site], active string) {
c.Header.AppName = app.Context.Name
c.NavigationItems = []NavItem{{Href: "/games/", Label: "Games", Active: active == "games"}}
}
// GamesListPage lists games, a page at a time, filtered by ?q=. Its steps run in order through
// LoadAll: goapplib's mixins read the request, then loadGames fetches this page of games and tells
// WithPagination how many there are.
type GamesListPage struct {
goal.BasePage
goal.WithPagination
goal.WithFiltering
Chrome
Games []Game
}
func (p *GamesListPage) Load(r *http.Request, w http.ResponseWriter, app *goal.App[*Site]) (error, bool) {
return goal.LoadAll(r, w, app, &p.BasePage, &p.WithPagination, &p.WithFiltering,
goal.LoaderFunc[*Site](p.loadGames))
}
func (p *GamesListPage) loadGames(r *http.Request, w http.ResponseWriter, app *goal.App[*Site]) (error, bool) {
p.Title = "Games"
p.fill(app, "games")
games, total := app.Context.Store.List(p.Query, p.Offset(), p.PageSize)
p.Games = games
p.SetTotal(total, p.Offset()+len(games) < total)
return nil, false
}
// GamePage shows one game, by the {id} in its route. An unknown id answers 404 itself and returns
// finished = true, so nothing renders.
type GamePage struct {
goal.BasePage
Chrome
Game Game
}
func (p *GamePage) Load(r *http.Request, w http.ResponseWriter, app *goal.App[*Site]) (error, bool) {
game, ok := app.Context.Store.Get(r.PathValue("id"))
if !ok {
http.NotFound(w, r)
return nil, true
}
p.Game = game
p.Title = game.Name
p.fill(app, "games")
return nil, false
}
// NewGamePage needs a login. WithAuth, loaded through AuthLoader, says who's asking, and someone
// who isn't logged in is sent to the login page instead.
type NewGamePage struct {
goal.BasePage
goal.WithAuth
Chrome
}
func (p *NewGamePage) Load(r *http.Request, w http.ResponseWriter, app *goal.App[*Site]) (error, bool) {
if err, done := goal.LoadAll(r, w, app, &p.BasePage,
goal.AuthLoader[*Site](&p.WithAuth, app.Context.Auth)); err != nil || done {
return err, done
}
if !p.IsLoggedIn {
// r.URL.Path is "/new" here, since the group stripped "/games". RequestURI is the path
// the browser asked for.
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.RequestURI), http.StatusFound)
return nil, true
}
p.Title = "New game"
p.fill(app, "games")
p.Header.IsLoggedIn, p.Header.Username = true, p.Username
return nil, false
}
// deleteGame is a plain handler beside the pages: no view and no template.
func deleteGame(app *goal.App[*Site]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !app.Context.Store.Delete(r.PathValue("id")) {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/games/", http.StatusSeeOther)
}
}
// CookieAuth is a stand-in goapplib.AuthProvider: the "user" cookie names who's logged in. A real
// app's provider asks its session store.
type CookieAuth struct{}
func (CookieAuth) GetLoggedInUserId(r *http.Request) string {
if c, err := r.Cookie("user"); err == nil {
return c.Value
}
return ""
}
func (CookieAuth) GetUserById(id string) (goal.AuthUser, error) { return profile{"username": id}, nil }
type profile map[string]any
func (p profile) Profile() map[string]any { return p }
A few things in it are worth a closer look.
GamesListPageis built from steps.LoadAllruns goapplib’s mixins, which read the request (?page=,?pageSize=,?q=,?sort=), then the page’s ownloadGames, which needs the app, so it’s a typedLoaderFunc[*Site].LoadAllstops at the first step that fails or finishes. The Concepts page has the details.GamePageanswers 404 itself. An unknown id callshttp.NotFoundand returnsnil, true, so nothing renders. Returning an error instead would answer 500, which is the wrong status for a missing game.NewGamePageredirects. It loadsWithAuththroughAuthLoader, and if nobody’s logged in, it redirects to the login page and returnsnil, true.Chromeholds what every page shares. goapplib’s layout reads.Headerand.NavigationItemsfrom every page (Getting started explains why), so each page embeds oneChromerather than declaring them again. A struct you embed in every page is the simplest kind of mixin you can write.
goapplib’s mixins ¶
Each mixin is a struct you embed in a page. Its fields become the page’s fields (so the template reads .CurrentPage, not .WithPagination.CurrentPage), and its Load fills them from the request. Every one of them is a Loader, so LoadAll chains them.
BasePage carries what goapplib’s layout needs: Title, MetaTitle, MetaDescription, CanonicalUrl, BodyClass, ActiveTab, CustomHeader, DisableSplashScreen, SplashTitle, SplashMessage and BodyDataAttributes. Its Load only sets a default BodyClass, so a page sets the rest itself, usually Title.
WithPagination reads ?page= (counting from 0) and ?pageSize= (20 by default, at most 100) into CurrentPage and PageSize. SetTotal(total, hasMore), called once the page knows how many items there are, fills in TotalCount, HasPrevPage, HasNextPage and Pages, the page numbers to show (up to five, around the current one). Offset(), PrevPage() and NextPage() do the arithmetic, and Paginator() returns the struct, for templates that take it as a whole.
WithFiltering reads ?q=, ?sort= and ?view= into Query, Sort and ViewMode. An empty sort becomes "modified_desc" and an empty view becomes "table".
WithHtmx reads htmx’s request headers into IsHtmx, IsBoosted, Target, Trigger, TriggerName, CurrentURL and Prompt. ShouldRenderFragment() is true for an htmx request that isn’t a boosted link, which is when a page usually wants to render only a fragment. Templates and htmx have their own page (coming with #96).
WithAuth holds LoggedInUserId, Username, IsLoggedIn and IsOwner. Its own Load does nothing, since it needs your auth service to do anything useful, which is a bit of a trap if you chain it like the other mixins. Load it with AuthLoader[*YourApp](&p.WithAuth, provider), or call p.WithAuth.LoadWithAuth(r, provider) yourself. provider is a goapplib.AuthProvider, a two-method interface:
type AuthProvider interface {
GetLoggedInUserId(r *http.Request) string // "" when nobody is logged in
GetUserById(id string) (AuthUser, error) // AuthUser has Profile() map[string]any
}
LoadWithAuth sets Username from the profile’s "username", if it has one. IsOwner is left for the page to set, since only the page knows what’s being owned. The example’s CookieAuth is a stand-in that trusts a cookie, so don’t copy it into a real app.
Your own mixins ¶
We think of a mixin as nothing more than a struct with fields and, if it reads the request, a Load. To chain it through LoadAll, give it goapplib’s loader shape, Load(r *http.Request, w http.ResponseWriter, app any) (error, bool). If it needs the app, assert it there, or make the step a LoaderFunc[*YourApp] instead:
package games
import (
"net/http"
goal "github.com/panyam/goapplib"
)
// WithGame loads the game named by the route's {id}, for any page about one game. It has
// goapplib's loader shape, so a page chains it with LoadAll: goal.LoadAll(r, w, app, &p.WithGame).
type WithGame struct{ Game Game }
func (m *WithGame) Load(r *http.Request, w http.ResponseWriter, app any) (error, bool) {
game, ok := app.(*goal.App[*Site]).Context.Store.Get(r.PathValue("id"))
if !ok {
http.NotFound(w, r)
return nil, true
}
m.Game = game
return nil, false
}
Then a page embeds WithGame and puts &p.WithGame in its LoadAll.