Skip to content
How-toCoreDisplay lists and tables from state.

Show a collection of items

Goal

Your state holds a list of things, such as a to-do list, a team roster, or an inventory, and you want the page to show every item, however many there are.

Before you start

You can keep values in state. One pattern runs through this page: the collection is a list in state, and the page rebuilds its display from that list on every visit, so adding or removing items requires no extra display code.

The smallest version

BulletedList turns a Python list into a bulleted list on the page. Grow the list in a route and the display grows with it:

from drafter import *
from dataclasses import dataclass


@dataclass
class State:
    chores: list[str]


@route
def index(state: State) -> Page:
    return Page(state, [
        "Today's chores:",
        BulletedList(state.chores),
        Button("Add a chore", "add_chore"),
        Button("Finish one", "finish_chore")
    ])


@route
def add_chore(state: State) -> Page:
    state.chores.append("Walk the dogs")
    return index(state)


@route
def finish_chore(state: State) -> Page:
    if state.chores:
        state.chores.pop()
    return index(state)


start_server(State(["Feed the cats", "Water the plants"]))

NumberedList works the same way when order matters. Note the guard in finish_chore: removing from an empty list is an error, so check first.

Recipe: a table from a list of dataclasses

When each item has several parts, make the item a dataclass and the collection a list of them. Table turns that list into rows, with the field names as column headers:

from drafter import *
from dataclasses import dataclass


@dataclass
class Pet:
    name: str
    kind: str
    sound: str


@dataclass
class State:
    pets: list[Pet]


@route
def index(state: State) -> Page:
    return Page(state, [
        "The pet registry:",
        Table(state.pets),
        Button("Register Domino", "add_domino")
    ])


@route
def add_domino(state: State) -> Page:
    state.pets.append(Pet("Domino", "cat", "meow"))
    return index(state)


assert_has(index(State([Pet("Ada", "corgi", "woof")])),
           Table([Pet("Ada", "corgi", "woof")]))

start_server(State([
    Pet("Ada", "corgi", "woof"),
    Pet("Babbage", "mutt", "woof"),
    Pet("Captain", "cat", "meow")
]))

The test's needle is a whole component: it checks the page contains that exact one-pet table. (Text needles like "Ada" search the page's text, which does not reach inside a table's rows.)

Recipe: build the display with a loop

When neither a plain list nor a table fits, build the content yourself. Loop over the collection, make a piece of content per item, and put the pieces in the page's list. A helper function keeps the route readable:

from drafter import *
from dataclasses import dataclass


@dataclass
class Player:
    name: str
    score: int


@dataclass
class State:
    players: list[Player]


def scoreboard(players: list[Player]) -> list:
    lines = []
    for player in players:
        lines.append(player.name + ": " + str(player.score) + " points\n")
    return lines


@route
def index(state: State) -> Page:
    return Page(state, [
        "Scoreboard:\n"
    ] + scoreboard(state.players) + [
        Button("Everyone scores", "all_score")
    ])


@route
def all_score(state: State) -> Page:
    for player in state.players:
        player.score = player.score + 1
    return index(state)


start_server(State([Player("Red team", 3), Player("Blue team", 5)]))

Recipe: handle the empty case

A collection page should still make sense with nothing in it. Check for the empty list and say something helpful instead of showing a blank space:

from drafter import *
from dataclasses import dataclass


@dataclass
class State:
    wishes: list[str]


@route
def index(state: State) -> Page:
    if state.wishes:
        display = BulletedList(state.wishes)
    else:
        display = "Your wishlist is empty. Add something!"
    return Page(state, [
        "Wishlist:\n",
        display,
        "\n",
        TextBox("wish"),
        "\n",
        Button("Add to wishlist", "add_wish")
    ])


@route
def add_wish(state: State, wish: str) -> Page:
    state.wishes.append(wish)
    return index(state)


assert_has(index(State([])), "empty")

start_server(State([]))

This example also shows how to add items from real input: the TextBox value arrives as a parameter, and the route appends it to the list.

Common problems

  • The list displays oddly, with items run together: you put the raw list into the content directly. Wrap it in a component: BulletedList(state.chores), not state.chores.
  • An error when removing an item: the list was empty. Guard with if state.chores: before pop, as the first recipe does.
  • The table's columns look wrong: Table reads column names from the dataclass fields. If a row is not that dataclass, or the list mixes types, rows will not line up.
  • New items do not appear: the route changed a copy, not the state's list. Use state.pets.append(...) on the state's own list.

Understand it

State covers nested dataclasses and lists as your app's memory.

See another example

The pet registry adds and views records with a table, and the to-do list shows adding and removing items.

Look it up

BulletedList, NumberedList, and Table.

Fix a problem

State doesn't match the State class, and Page content must be a list for content-shape mistakes.