# Pagination and flash 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.7/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 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"` 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.
