Paginate lists and show messages
paginate splits ordered query results into numbered pages and renders page
navigation. flash stores one-time messages, such as “Note created”, in the
user’s session and shows them on the next page.
They fill the roles of Django’s Paginator and messages framework.
#Ask your agent
Read https://cicadel.fauzaanu.com/en/dev/howto/pagination-and-messages/index.md.
Paginate [list route] with paginate.Parse and paginate.Query using a
deterministic Order, answer 400 for ErrInvalidPage and 404 for ErrPageNotFound,
and render paginate.Nav. After [form] succeeds, add a flash.Success message and
redirect; render flash.List on the next page. Add tests for both.#Paginate a list
func List(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req, err := paginate.Parse(r, paginate.Options{PerPage: 20})
if err != nil {
response.Error(w, http.StatusBadRequest)
return
}
page, err := paginate.Query[Note](r.Context(), db.Where("owner_id = ?", ownerID(r)).Order("created_at DESC, id DESC"), req)
if errors.Is(err, paginate.ErrPageNotFound) {
response.Error(w, http.StatusNotFound)
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError)
return
}
content := ui.Group(notesList(page.Items), paginate.Nav.Call(paginate.Navigation(page), ui.NoSlots{}))
if err := (ui.Renderer{}).HTML(r.Context(), w, http.StatusOK, content); err != nil && !ui.Committed(err) {
response.Error(w, http.StatusInternalServerError)
}
}
}#Parse the page number
Parse reads ?page=. The default is 1.
- Zero, negative, malformed, repeated or larger-than-
MaxPageNumbervalues returnErrInvalidPage. Answer 400. - The page size is
PerPage(default 20). - To let clients choose the size, set
SizeParam, for example"per_page". Clients can then pick up toMaxPerPage(default 100).
#Query the page
Query requires an Order clause. Without one it returns ErrUnordered, because
unordered pages can repeat or skip rows.
Tip
Make a unique column such as id the last sort key.
- It counts and loads in one read-only
REPEATABLE READtransaction, so the total and the items agree. - Inside your own transaction, it uses that transaction instead.
- A page past the last returns
ErrPageNotFound. Answer 404. - Page 1 of an empty result is a normal empty page.
Slicepaginates an in-memory slice the same way.
Warning
Pagination applies no authorization. Scope the query to rows the user may see.
#The page value
| Field or method | Contents |
|---|---|
Items | The rows on this page |
Number | The page number |
Size | The page size |
Total | The total item count |
Pages | The page count |
HasPrevious(), HasNext() | Whether neighbor pages exist |
Start(), End() | 1-based item positions, for “Showing 21–40 of 95” |
#Page navigation
paginate.Navigation(page) builds props for paginate.Nav. It is a semantic,
unstyled component that renders:
- a
<nav aria-label="Pagination">landmark; - Previous and Next links with
rel="prev"andrel="next"; - page numbers, with
aria-current="page"on the current page; - long ranges shortened with
…, like Django’sPaginator.get_elided_page_range.
For other widths, call page.Links(onEachSide, onEnds).
Links are query-only relative URLs such as ?page=3&q=go. They keep every other
query parameter and never contain a host or path. Page 1 omits page=.
Style it with your own CSS, for example nav[aria-label=Pagination] a[aria-current].
#Flash messages
#Create the store
Create the store from the same security.Sessions your browser sessions use.
In the standard project, that is the session manager passed to service.Browser
in project/application.go:
sessions := security.NewSessions(store, s.SecureCookies)
browser, err := service.Browser(sessions)
// ...
messages := flash.New(sessions)Pass messages to your app constructors, the same way you pass the database.
Routes must run inside session middleware. Accounts pages and browser.Middleware
already do. For other routes, add sessions.LoadAndSave to App.Middleware.
#Add a message
Add a message, then redirect:
func Create(messages *flash.Messages) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// ... save the note ...
if err := messages.Success(r.Context(), "Note created"); err != nil {
response.Error(w, http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/notes/", http.StatusSeeOther)
}
}#Show messages
On the next page, Pop returns and removes pending messages. So each one appears once:
pending, err := messages.Pop(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError)
return
}
content := ui.Group(flash.List.Call(flash.ListProps{Messages: pending}, ui.NoSlots{}), page)flash.List renders:
- errors and warnings with
role="alert"; - info and success messages with
role="status"; - a
data-levelattribute for styling; - nothing when there are no messages.
#Message rules
- Levels are
flash.Info,Success,WarningandError. - Text is plain text and is always escaped.
- Each message is 1–1000 bytes of UTF-8.
- At most 20 messages can be pending per session.
Peekreads messages without removing them.- Without session middleware, every call returns
flash.ErrNoSession.
#Limits
- Pagination uses
OFFSET, which slows down on very deep pages of large tables. Keyset (“load more after id X”) pagination is not included. - The standard project does not yet create the flash store for you. Wire it as shown above.