{"table_sort":"fname"}

Data tables

The table package wraps the bespa table primitive with the chrome that turns rows into a usable list view. A typical data table is the Table widget plus three support widgets — quick-search, paginator, and page-size selector — laid out in a recognizable toolbar-bracketed shape.

Build the shell, then ask it for state

The idiom is to construct the table widget first — its name, columns, defaults — and then ask it what the current quick-search text, sort order, and page range are. The widget owns its state-variable naming convention; your handler stays out of it.

// store is your data layer; it knows how to query, sort, and paginate.

// 1. Build the table shell first, including its column definitions.
//    Col(visibility, width, alignment) — visibility uses "n"/"w"/"x"
//    letters for narrow/wide/expanded breakpoints.
tbl := wf.Table().
    WithDefaultPageRows(r, 25).
    Add(
        wf.Col("nwx", 12, "left").Add(wf.Sorter("fname", "First name")),
        wf.Col("nwx", 12, "left").Add(wf.Sorter("lname", "Last name")),
        wf.Col("wx", 20, "left").Add("Email"), // not sortable
    )

// 2. Ask the table what its current state is. The table owns the
//    state-variable naming convention; your handler doesn't need to.
rowFrom, rowTo := tbl.DisplayRange(r)
rows, total, _ := store.Query(
    tbl.Query(r),     // current quick-search text
    tbl.SortOrder(r), // current sort column + direction
    rowFrom, rowTo,   // current page window
)

// 3. Tell the table the row count so the paginator can compute the last page.
tbl.WithTotalRows(r, total)

// 4. Add the rows.
for _, p := range rows {
    tbl.Add(wf.Row().Add(
        wf.QuickSearchUnderliner(p.FirstName),
        wf.QuickSearchUnderliner(p.LastName),
        wf.QuickSearchUnderliner(p.Email),
    ).WithHref("/people/" + p.ID))
}

The accessors that matter:

Support widgets

Four widgets pair with a table. They sit outside it and auto-bind to the table by name:

Idiomatic layout

Two toolbars bracket the table. The top toolbar carries action buttons (“+ New”, etc.) and the quick-search on the left, and the paginator on the right. The bottom toolbar carries the page-size selector. This is the shape used by both the data-table and CRUD showcases:

wf.Toolbar().
    AddLeft(
        wf.ButtonTonal("").Add(wf.Icon("add"), " New").WithHref("?modal=new"),
        wf.QuickSearch(),
    ).
    AddRight(
        wf.Paginator(),
    ),
tbl,
wf.Toolbar().AddLeft(
    wf.PageSizer(),
),

Notice no widget calls RedrawIfChanged — the table and its support widgets each declare their own redraw conditions internally. A change to sort, search, or page redraws just the table and the paginator, leaving the rest of the page alone.

For a working version — search, sort, paginate, page-size, plus per-row edit and delete actions — open the CRUD example in a modal:

Multiple tables on one page

By default a table picks up the name "table" and the support widgets pick up the same default. For two tables on the same page, name one (or both) and point each companion widget at it with ForTable:

wf.Table().WithName("orders"). // ...
wf.QuickSearch().ForTable("orders")
wf.PageSizer().ForTable("orders")
wf.Paginator().ForTable("orders")
wf.QuickSearchUnderliner(text).ForTable("orders")

State variables become orders_q, orders_sort, orders_page — but your handler still doesn’t see those names directly; the accessor methods tbl.Query(r), etc., still do the right thing.

Sorting

Marking a column Sortable(true) makes its header a clickable control that writes the sort order into the table’s state. The framework doesn’t sort the rows for you — your backing store does, given tbl.SortOrder(r) as a hint. You’re free to ignore unsupported sort columns or map the column name to a different database field.

Paging vs. infinite scroll

BESPA’s table is page-oriented — the server returns a fixed window of rows per request. For very large datasets this is usually what you want: bounded memory on the server, bounded payload over the wire, and the rendered HTML stays small.

Backing with `database/sql`

Translate the table’s three accessors — Query, SortOrder, DisplayRange — into one SQL query that does the filter, sort, and limit/offset in the database. The handler hands the values straight to the store; the store maps them to columns:

// store wraps a *sql.DB. It maps the table's accessors onto a single SQL query.
func (s *PeopleStore) Query(q, sort string, from, to int) ([]*Person, int, error) {
    // Whitelist the sort column before interpolating; never trust user input.
    sortSQL := "lname ASC"
    switch sort {
    case "fname":      sortSQL = "fname ASC"
    case "fname desc": sortSQL = "fname DESC"
    case "lname":      sortSQL = "lname ASC"
    case "lname desc": sortSQL = "lname DESC"
    }

    where := ""
    args := []any{}
    if q != "" {
        where = "WHERE fname ILIKE $1 OR lname ILIKE $1 OR email ILIKE $1"
        args = append(args, "%"+q+"%")
    }

    var total int
    if err := s.db.QueryRow("SELECT count(*) FROM people "+where, args...).Scan(&total); err != nil {
        return nil, 0, err
    }

    rows, err := s.db.Query(
        "SELECT id, fname, lname, email FROM people "+where+
            " ORDER BY "+sortSQL+" LIMIT $"+strconv.Itoa(len(args)+1)+" OFFSET $"+strconv.Itoa(len(args)+2),
        append(args, to-from, from)...,
    )
    if err != nil { return nil, 0, err }
    defer rows.Close()

    var out []*Person
    for rows.Next() {
        p := &Person{}
        if err := rows.Scan(&p.ID, &p.FirstName, &p.LastName, &p.Email); err != nil {
            return nil, 0, err
        }
        out = append(out, p)
    }
    return out, total, nil
}

Two points worth highlighting:

Row selection and bulk actions

The table itself doesn’t carry a notion of “selected rows” — that’s an app-level pattern using checkboxes and a state variable that holds the selected IDs. Add a checkbox column and a state variable that aggregates the per-row checkbox values:

// "sel" carries the comma-separated list of selected IDs.
selected := strings.Split(wf.StateOf(r).Get("sel"), ",")
isSelected := func(id string) bool {
    for _, s := range selected {
        if s == id { return true }
    }
    return false
}

// Add a checkbox column as the leftmost column.
tbl.Add(wf.Col("nwx", 1, "center").Add(""))

for _, p := range rows {
    tbl.Add(wf.Row().Add(
        wf.Checkbox("sel_"+p.ID, "").
            WithChecked(isSelected(p.ID)).
            WithAutoSubmit(true),
        wf.QuickSearchUnderliner(p.FirstName),
        // ... other cells ...
    ))
}

// In the handler, on every request: rebuild "sel" from the per-row checkboxes.
sel := []string{}
for _, p := range rows {
    if wf.StateOf(r).Get("sel_"+p.ID) == "true" {
        sel = append(sel, p.ID)
    }
}
wf.StateOf(r).Set("sel", strings.Join(sel, ","))

With selection captured, a bulk-action button reads the IDs out of state and runs the operation. The button discriminates by name (the _submit state var) and disables itself when nothing is selected:

// A bulk-action button reads the selected IDs out of state.
wf.Toolbar().AddLeft(
    wf.QuickSearch(),
    wf.ButtonOutlined("delete").
        Add(wf.Icon("delete"), " Delete selected").
        WithDisabled(wf.StateOf(r).Get("sel") == "").
        RedrawIfChanged(r, "sel"),
)

// In the handler:
if wf.StateOf(r).Get("_submit") == "delete" {
    for _, id := range strings.Split(wf.StateOf(r).Get("sel"), ",") {
        store.Delete(id)
    }
    wf.StateOf(r).Set("sel", "") // clear the selection
}

For long-running bulk operations, redirect to a background job page rather than blocking the request — and consider a confirm modal before destructive actions.

Per-column filtering

Quick-search is one filter against all columns. For more control, add an input inside each column header — each writes to its own state variable and the handler passes a map to the store:

// Each filterable column gets its own state variable.
filterFname := wf.StateOf(r).Get("filter_fname")
filterEmail := wf.StateOf(r).Get("filter_email")

tbl.Add(
    wf.Col("nwx", 12, "left").Add(
        wf.Sorter("fname", "First name"),
        wf.InputText("filter_fname", filterFname).
            WithPlaceholder("filter").
            WithAutoSubmit(true),
    ),
    wf.Col("nwx", 20, "left").Add(
        "Email",
        wf.InputText("filter_email", filterEmail).
            WithPlaceholder("filter").
            WithAutoSubmit(true),
    ),
)

// Pass the per-column filters down to the store.
rows, total, _ := store.QueryFiltered(
    map[string]string{"fname": filterFname, "email": filterEmail},
    tbl.SortOrder(r), rowFrom, rowTo,
)

Per-column filters compose naturally with quick-search — keep both if it makes sense for the dataset. The filter inputs use WithAutoSubmit(true) so the table re-queries on every keystroke, the same way quick-search does.

See also

Showcase → Data table — the worked example with the toolbar-bracketed layout, sortable columns, quick-search, and the underliner highlighting matches.

Showcase → CRUD — the same layout with a “+ New” action button in the top toolbar and per-row edit / delete actions.