Tabela de dados com ordenação e busca¶
Neste exemplo você vai construir uma tabela de colaboradores com busca ao vivo e ordenação por coluna — tudo controlado por estado Python, zero JavaScript escrito por você. 🚀
Ao final você terá:
- Um
SearchBarque filtra as linhas a cada tecla digitada. - Botões de cabeçalho que alternam a ordenação ASC/DESC por coluna.
- Um contador de resultados que sempre mostra quantas linhas estão visíveis.
O que vamos construir¶
┌─ Tabela de Colaboradores ──────────────────────────────────┐
│ [ 🔍 Filtrar por nome, depto, cidade… ✕ ] │
│ 15 resultados de 15 │
│ [ Nome ▲ ] [ Departamento ] [ Cidade ] [ Salário ] │
│ ───────────────────────────────────────────────────── │
│ Alice Martins Engineering São Paulo R$ 18.500 │
│ Bruno Costa Design Rio de… R$ 12.000 │
│ … │
└────────────────────────────────────────────────────────────┘
Pré-requisitos¶
Nota
Se você ainda não conhece o ciclo básico do tempestweb (estado → view → patches),
leia primeiro o tutorial de introdução — ele explica
App, set_state e os dois modos de execução.
1. Os dados do domínio¶
Toda aplicação começa com os dados. Aqui temos uma lista de tuplas — cada tupla é um colaborador com quatro campos: nome, departamento, cidade e salário.
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from tempestweb._core import App, Style, Widget
from tempestweb._core.components import DataTable, SearchBar
from tempestweb._core.style import AlignItems, Edge, FontWeight
from tempestweb._core.widgets import Button, Column, Row, Text
from tempestweb._core.widgets.events import TextChangeEvent
# ---------------------------------------------------------------------------
# Domain data
# ---------------------------------------------------------------------------
#: Sample employee dataset — all string columns (name, department, city, salary).
_EMPLOYEES: list[tuple[str, str, str, str]] = [
("Alice Martins", "Engineering", "São Paulo", "R$ 18.500"),
("Bruno Costa", "Design", "Rio de Janeiro", "R$ 12.000"),
("Carla Fonseca", "Product", "Belo Horizonte", "R$ 14.200"),
("Diego Ribeiro", "Engineering", "Curitiba", "R$ 16.800"),
("Elena Sousa", "Marketing", "Recife", "R$ 9.400"),
("Fernando Lima", "Engineering", "São Paulo", "R$ 19.100"),
("Gabriela Nunes", "HR", "Porto Alegre", "R$ 8.750"),
("Henrique Alves", "Finance", "São Paulo", "R$ 15.300"),
("Isabela Torres", "Design", "Florianópolis", "R$ 13.600"),
("João Mendes", "Product", "Manaus", "R$ 11.900"),
("Karina Prado", "Engineering", "Brasília", "R$ 17.400"),
("Lucas Ferreira", "Marketing", "Salvador", "R$ 10.200"),
("Mariana Castro", "Finance", "São Paulo", "R$ 14.800"),
("Nicolás Rocha", "HR", "Curitiba", "R$ 9.100"),
("Olivia Campos", "Engineering", "Rio de Janeiro", "R$ 20.000"),
]
#: Column headers in display order.
COLUMNS: list[str] = ["Nome", "Departamento", "Cidade", "Salário"]
#: Number of data columns.
_N_COLS: int = len(COLUMNS)
Os dados são imutáveis (_EMPLOYEES é uma constante de módulo). O estado da
aplicação vai guardar apenas o filtro e a coluna de ordenação — nunca uma
cópia inteira dos dados transformados.
Dica — dados separados do estado
Mantendo o dataset no módulo e colocando apenas o filtro/sort no estado,
você garante que make_state() é barato (um dataclass pequeno) e que os
dados nunca são duplicados por sessão.
2. A direção de ordenação¶
Antes do estado, precisamos de um tipo que represente "ascendente ou descendente":
class SortDir(StrEnum):
"""Sort direction for a column.
Attributes:
ASC: Sort ascending (A → Z).
DESC: Sort descending (Z → A).
"""
ASC = "asc"
DESC = "desc"
StrEnum (Python 3.11+) faz com que SortDir.ASC == "asc" seja True, o que
facilita serialização e comparações.
3. O estado¶
O estado guarda apenas o que muda ao longo do tempo:
@dataclass
class DataTableState:
"""State for the sortable data-table app.
Attributes:
query: The current text in the search bar (controlled).
sort_col: Index of the column currently sorted, or ``-1`` for none.
sort_dir: Current sort direction (ascending or descending).
rows: The full dataset as a list of string tuples.
"""
query: str = ""
sort_col: int = -1
sort_dir: SortDir = SortDir.ASC
rows: list[tuple[str, str, str, str]] = field(
default_factory=lambda: list(_EMPLOYEES)
)
def make_state() -> DataTableState:
"""Build the initial state pre-loaded with the employee dataset.
Returns:
A fresh :class:`DataTableState` with all rows visible and no sort active.
"""
return DataTableState()
Veja cada campo:
| Campo | Tipo | Significado |
|---|---|---|
query |
str |
Texto digitado na busca (campo controlado). |
sort_col |
int |
Índice da coluna ordenada; -1 = nenhuma. |
sort_dir |
SortDir |
Direção da ordenação atual. |
rows |
list[tuple[...]] |
Dataset completo (cópia por sessão). |
Nota — rows no estado
O campo rows está no estado (e não é a constante global) para que, no
Modo B, cada sessão tenha seu próprio objeto. Isso abre a porta para
atualizações do dataset em tempo real sem compartilhar estado entre clientes.
4. A função pura de filtragem e ordenação¶
Esta é a função mais importante do exemplo. Ela não tem efeitos colaterais — recebe os dados e retorna a matriz já filtrada e ordenada:
def _filtered_rows(
rows: list[tuple[str, str, str, str]],
query: str,
sort_col: int,
sort_dir: SortDir,
) -> list[list[str]]:
"""Return the filtered and sorted string matrix for ``DataTable.rows``.
Filtering is case-insensitive: a row passes when any cell contains the
``query`` substring. Sorting is lexicographic on the selected column;
an inactive sort (``sort_col == -1``) preserves insertion order.
Args:
rows: The full dataset.
query: The current search term (empty string keeps all rows).
sort_col: Column index to sort on, or ``-1`` to skip sorting.
sort_dir: Ascending or descending sort direction.
Returns:
A list of string lists ready to pass to :class:`DataTable`.
"""
needle = query.strip().lower()
visible = [
row for row in rows if not needle or any(needle in cell.lower() for cell in row)
]
if sort_col >= 0:
visible = sorted(
visible,
key=lambda r: r[sort_col].lower(),
reverse=(sort_dir is SortDir.DESC),
)
return [list(row) for row in visible]
O que ela faz, passo a passo:
- Normaliza o termo de busca para minúsculas (
.lower()). - Filtra — uma linha passa se
needleé vazio ou aparece em qualquer célula. - Ordena — só quando
sort_col >= 0;reverse=Truepara DESC. - Converte cada tupla para lista, pois
DataTableesperalist[list[str]].
Dica — funções puras são fáceis de testar
Porque _filtered_rows não toca em app.state, você pode testá-la
diretamente com pytest — sem montar nenhum runtime, sem mocks.
5. A view — os handlers¶
A função view começa definindo os três handlers que respondem a eventos:
def view(app: App[DataTableState]) -> Widget:
"""Render the data table UI from the current state."""
state = app.state
# -- Handlers ------------------------------------------------------------
def on_search(event: TextChangeEvent) -> None:
"""Update the filter query on every keystroke."""
app.set_state(lambda s: setattr(s, "query", event.value))
def on_clear() -> None:
"""Clear the search bar and show all rows."""
app.set_state(lambda s: setattr(s, "query", ""))
def sort_by(col: int) -> None:
"""Sort the table by ``col``, toggling direction on repeated clicks."""
def mutate(s: DataTableState) -> None:
if s.sort_col == col:
s.sort_dir = SortDir.DESC if s.sort_dir is SortDir.ASC else SortDir.ASC
else:
s.sort_col = col
s.sort_dir = SortDir.ASC
app.set_state(mutate)
Veja a lógica do sort_by:
- Clicou na mesma coluna → inverte a direção (ASC → DESC → ASC…).
- Clicou em coluna diferente → vai para essa coluna, sempre começando ASC.
Aviso — nunca mute app.state diretamente
Todo handler passa uma função para app.set_state. O runtime aplica essa
função, detecta as mudanças e agenda um rebuild. Mutar app.state diretamente
quebra o ciclo de detecção de mudanças.
6. A view — os dados derivados¶
Com o estado em mãos, calculamos os valores que vão alimentar a UI:
# -- Derived view data ---------------------------------------------------
visible_matrix = _filtered_rows(
state.rows, state.query, state.sort_col, state.sort_dir
)
total_count = len(state.rows)
visible_count = len(visible_matrix)
summary = (
f"{visible_count} resultado{'s' if visible_count != 1 else ''} de {total_count}"
)
summary usa um f-string com plural condicional — "1 resultado de 15" vs
"15 resultados de 15".
7. A view — o cabeçalho de ordenação¶
Os botões de cabeçalho são criados com uma list comprehension. Cada botão
recebe um closure que captura o índice correto com (lambda i=col_idx: lambda: sort_by(i))():
# -- Sort-header row (buttons per column) --------------------------------
def _sort_label(col_idx: int) -> str:
"""Build the label for one sort-header button."""
label = COLUMNS[col_idx]
if state.sort_col == col_idx:
return label + (" ▲" if state.sort_dir is SortDir.ASC else " ▼")
return label
sort_buttons: list[Widget] = [
Button(
label=_sort_label(col_idx),
on_click=(lambda i=col_idx: lambda: sort_by(i))(),
key=f"sort-btn-{col_idx}",
style=Style(
grow=1.0,
padding=Edge.symmetric(vertical=8.0, horizontal=10.0),
radius=6.0,
font_weight=(
FontWeight.BOLD if state.sort_col == col_idx else FontWeight.NORMAL
),
),
)
for col_idx in range(_N_COLS)
]
Info — o padrão (lambda i=col_idx: lambda: sort_by(i))()
Em Python, closures em loops capturam a variável, não o valor. Se
usássemos on_click=lambda: sort_by(col_idx), todos os botões chamaria
sort_by(3) (o último valor). O padrão (lambda i=col_idx: lambda: sort_by(i))()
congela i no valor correto para cada botão.
O botão da coluna ativa recebe FontWeight.BOLD — uma dica visual que
complementa o ▲/▼ no label.
8. A view — a árvore raiz¶
Por fim, montamos a árvore completa com Column:
# -- Root tree -----------------------------------------------------------
return Column(
style=Style(gap=12.0, padding=Edge.all(16.0)),
children=[
Text(
content="Tabela de Colaboradores",
key="heading",
style=Style(font_size=20.0, font_weight=FontWeight.BOLD),
),
SearchBar(
value=state.query,
placeholder="Filtrar por nome, depto, cidade…",
on_change=on_search,
on_clear=on_clear,
key="searchbar",
),
Text(
content=summary,
key="summary",
style=Style(font_size=13.0),
),
Row(
key="sort-header",
style=Style(gap=4.0, align=AlignItems.CENTER),
children=sort_buttons,
),
DataTable(
columns=COLUMNS,
rows=visible_matrix,
sortable=False,
key="table",
),
],
)
A estrutura da árvore:
Column
├── Text (título)
├── SearchBar (campo controlado — value=state.query)
├── Text (contador de resultados)
├── Row (botões de sort, um por coluna)
│ ├── Button (col 0 — "Nome")
│ ├── Button (col 1 — "Departamento")
│ ├── Button (col 2 — "Cidade")
│ └── Button (col 3 — "Salário")
└── DataTable (matrix filtrada + ordenada)
Nota — sortable=False
O DataTable tem uma prop sortable nativa, mas aqui a deixamos desativada
(sortable=False) de propósito — todo o mecanismo de ordenação é implementado
explicitamente em Python. Isso demonstra que você pode substituir comportamentos
padrão de componentes com lógica customizada no estado.
9. O arquivo completo¶
Juntando tudo, o app.py final fica assim:
"""Sortable data table — exercises DataTable, SearchBar and column-sort state.
This exact ``view`` runs unchanged in both modes::
tempestweb dev --mode wasm # Python in the browser (Pyodide)
tempestweb dev --mode server # Python on the server (FastAPI + WebSocket)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from tempestweb._core import App, Style, Widget
from tempestweb._core.components import DataTable, SearchBar
from tempestweb._core.style import AlignItems, Edge, FontWeight
from tempestweb._core.widgets import Button, Column, Row, Text
from tempestweb._core.widgets.events import TextChangeEvent
_EMPLOYEES: list[tuple[str, str, str, str]] = [
("Alice Martins", "Engineering", "São Paulo", "R$ 18.500"),
("Bruno Costa", "Design", "Rio de Janeiro", "R$ 12.000"),
("Carla Fonseca", "Product", "Belo Horizonte", "R$ 14.200"),
("Diego Ribeiro", "Engineering", "Curitiba", "R$ 16.800"),
("Elena Sousa", "Marketing", "Recife", "R$ 9.400"),
("Fernando Lima", "Engineering", "São Paulo", "R$ 19.100"),
("Gabriela Nunes", "HR", "Porto Alegre", "R$ 8.750"),
("Henrique Alves", "Finance", "São Paulo", "R$ 15.300"),
("Isabela Torres", "Design", "Florianópolis", "R$ 13.600"),
("João Mendes", "Product", "Manaus", "R$ 11.900"),
("Karina Prado", "Engineering", "Brasília", "R$ 17.400"),
("Lucas Ferreira", "Marketing", "Salvador", "R$ 10.200"),
("Mariana Castro", "Finance", "São Paulo", "R$ 14.800"),
("Nicolás Rocha", "HR", "Curitiba", "R$ 9.100"),
("Olivia Campos", "Engineering", "Rio de Janeiro", "R$ 20.000"),
]
COLUMNS: list[str] = ["Nome", "Departamento", "Cidade", "Salário"]
_N_COLS: int = len(COLUMNS)
class SortDir(StrEnum):
"""Sort direction for a column."""
ASC = "asc"
DESC = "desc"
@dataclass
class DataTableState:
"""State for the sortable data-table app."""
query: str = ""
sort_col: int = -1
sort_dir: SortDir = SortDir.ASC
rows: list[tuple[str, str, str, str]] = field(
default_factory=lambda: list(_EMPLOYEES)
)
def make_state() -> DataTableState:
"""Build the initial state pre-loaded with the employee dataset."""
return DataTableState()
def _filtered_rows(
rows: list[tuple[str, str, str, str]],
query: str,
sort_col: int,
sort_dir: SortDir,
) -> list[list[str]]:
"""Return the filtered and sorted string matrix for ``DataTable.rows``."""
needle = query.strip().lower()
visible = [
row for row in rows if not needle or any(needle in cell.lower() for cell in row)
]
if sort_col >= 0:
visible = sorted(
visible,
key=lambda r: r[sort_col].lower(),
reverse=(sort_dir is SortDir.DESC),
)
return [list(row) for row in visible]
def view(app: App[DataTableState]) -> Widget:
"""Render the data table UI from the current state."""
state = app.state
def on_search(event: TextChangeEvent) -> None:
app.set_state(lambda s: setattr(s, "query", event.value))
def on_clear() -> None:
app.set_state(lambda s: setattr(s, "query", ""))
def sort_by(col: int) -> None:
def mutate(s: DataTableState) -> None:
if s.sort_col == col:
s.sort_dir = SortDir.DESC if s.sort_dir is SortDir.ASC else SortDir.ASC
else:
s.sort_col = col
s.sort_dir = SortDir.ASC
app.set_state(mutate)
visible_matrix = _filtered_rows(
state.rows, state.query, state.sort_col, state.sort_dir
)
total_count = len(state.rows)
visible_count = len(visible_matrix)
summary = (
f"{visible_count} resultado{'s' if visible_count != 1 else ''} de {total_count}"
)
def _sort_label(col_idx: int) -> str:
label = COLUMNS[col_idx]
if state.sort_col == col_idx:
return label + (" ▲" if state.sort_dir is SortDir.ASC else " ▼")
return label
sort_buttons: list[Widget] = [
Button(
label=_sort_label(col_idx),
on_click=(lambda i=col_idx: lambda: sort_by(i))(),
key=f"sort-btn-{col_idx}",
style=Style(
grow=1.0,
padding=Edge.symmetric(vertical=8.0, horizontal=10.0),
radius=6.0,
font_weight=(
FontWeight.BOLD if state.sort_col == col_idx else FontWeight.NORMAL
),
),
)
for col_idx in range(_N_COLS)
]
return Column(
style=Style(gap=12.0, padding=Edge.all(16.0)),
children=[
Text(
content="Tabela de Colaboradores",
key="heading",
style=Style(font_size=20.0, font_weight=FontWeight.BOLD),
),
SearchBar(
value=state.query,
placeholder="Filtrar por nome, depto, cidade…",
on_change=on_search,
on_clear=on_clear,
key="searchbar",
),
Text(
content=summary,
key="summary",
style=Style(font_size=13.0),
),
Row(
key="sort-header",
style=Style(gap=4.0, align=AlignItems.CENTER),
children=sort_buttons,
),
DataTable(
columns=COLUMNS,
rows=visible_matrix,
sortable=False,
key="table",
),
],
)
10. Executando o exemplo¶
Salve o arquivo em examples/data-table/app.py e rode com o comando abaixo
de acordo com o modo que você quer usar:
O Pyodide carrega o Python no browser. A busca e a ordenação executam localmente — sem round-trip de rede, latência zero.
Verificação
Abra o browser, digite "eng" na busca — você deve ver apenas os colaboradores de Engineering. Clique em "Nome ▲" — a lista deve ordenar por nome crescente. Clique de novo — "Nome ▼", ordem decrescente. ✅
Recapitulando¶
Você aprendeu a:
- Usar
SearchBarcomo campo controlado —value=state.query+ handleron_change. - Implementar sort bidirecional com um único campo
sort_col+sort_dirno estado. - Criar botões de cabeçalho em loop com closures corretas (padrão
lambda i=col_idx). - Manter a lógica de exibição em uma função pura (
_filtered_rows) separada da view. - Rodar o mesmo
app.pynos dois modos sem alterar uma linha.
Dica — próximos passos
- Adicione paginação: guarde um campo
page: intno estado e fatievisible_matrix[page * PAGE_SIZE : (page + 1) * PAGE_SIZE]. - Combine com o exemplo de formulário para adicionar novos colaboradores em tempo real.
- Veja o tutorial de estado para entender o ciclo evento → estado → rebuild em mais detalhes.