cicadel

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

Text
Read https://cicadel.fauzaanu.com/en/0.2.8/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

Go
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-MaxPageNumber values return ErrInvalidPage. 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 to MaxPerPage (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 READ transaction, 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.
  • Slice paginates 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 methodContents
ItemsThe rows on this page
NumberThe page number
SizeThe page size
TotalThe total item count
PagesThe page count
HasPrevious(), HasNext()Whether neighbor pages exist
Start(), End()1-based item positions, for “Showing 21–40 of 95”

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" and rel="next";
  • page numbers, with aria-current="page" on the current page;
  • long ranges shortened with …, like Django’s Paginator.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:

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:

Go
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:

Go
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-level attribute for styling;
  • nothing when there are no messages.

#Message rules

  • Levels are flash.Info, Success, Warning and Error.
  • 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.
  • Peek reads 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.

Type to search this version.