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.

#Ask your agent

Text
Read https://cicadel.fauzaanu.com/en/0.2.6/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 reads ?page= (default 1). Zero, negative, malformed, repeated or larger-than-MaxPageNumber values return ErrInvalidPage. Set SizeParam (for example "per_page") to let clients choose a size up to MaxPerPage (default 100); otherwise the size is always PerPage (default 20).
  • Query requires an Order clause and returns ErrUnordered without one; unordered pages can repeat or skip rows. Add a unique column such as id as the last sort key. It counts and loads in one read-only REPEATABLE READ transaction, so the total and items agree. Inside your own transaction it uses that transaction instead.
  • A page past the last returns ErrPageNotFound. Page 1 of an empty result is a normal empty page.
  • Pagination applies no authorization: scope the query to rows the user may see.
  • Slice paginates an in-memory slice the same way.

Page has Items, Number, Size, Total, Pages, HasPrevious(), HasNext(), Start() and End() (1-based item positions, for “Showing 21–40 of 95”).

paginate.Navigation(page) builds props for paginate.Nav, a semantic, unstyled component:

  • a <nav aria-label="Pagination"> landmark with Previous/Next links using rel="prev" and rel="next";
  • page numbers with aria-current="page" on the current page;
  • long ranges elided with …, matching Django’s Paginator.get_elided_page_range. Call page.Links(onEachSide, onEnds) for other widths.

Links are query-only relative URLs such as ?page=3&q=go. They keep every other query parameter, never contain a host or path, and page 1 omits page=. Style the list with your own CSS, for example nav[aria-label=Pagination] a[aria-current].

#Flash messages

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.

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)
    }
}

On the page that shows them, Pop returns and removes pending messages, so each 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)
  • Levels are flash.Info, Success, Warning and Error. flash.List renders errors and warnings with role="alert" and the others with role="status", adds data-level for styling, and renders nothing when there are no messages.
  • Text is plain text and always escaped. Each message is 1–1000 bytes of UTF-8, and at most 20 messages can be pending per session.
  • Peek reads without removing. 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.