# Data table

> A data table with filters in Python: search, status chips, a role select, sortable columns, row selection, pagination, CSV export and an invite dialog.

Source: https://albakore.github.io/pyzed/blocks/data-table/

A team members table: filter by name or email, toggle statuses with chips, pick a role, sort by any column, select rows with checkboxes, page through results, copy them as CSV and invite people from a dialog.

The screenshot follows the site's theme: switch between light and dark at the top of the page to see the block in One Light and One Dark.

## Run it

From the repository root:

```bash
python examples/blocks/data_table.py
```

## What it shows

- Filtering, sorting and paging done in plain Python, so it is easy to swap in a database query.
- Two-line cells (avatar, name and email) through `ui.Column(render=...)`.
- `pz.Command.copy` puts the CSV on the clipboard.
- `ui.prompt` for the invite, with <kbd>Enter</kbd> to send.

## Components used

| Component | Used for |
| --- | --- |
| [Table](https://albakore.github.io/pyzed/library/table/) | The table |
| [Chip](https://albakore.github.io/pyzed/library/chip/) | Status filters |
| [Search input](https://albakore.github.io/pyzed/library/search-input/) | Text filter |
| [Select](https://albakore.github.io/pyzed/library/select/) | Role filter |
| [Checkbox](https://albakore.github.io/pyzed/library/checkbox/) | Row selection |
| [Badge](https://albakore.github.io/pyzed/library/badge/) | Statuses |
| [Avatar](https://albakore.github.io/pyzed/library/avatar/) | Members |
| [Facepile](https://albakore.github.io/pyzed/library/facepile/) | Team |
| [Prompt](https://albakore.github.io/pyzed/library/prompt/) | Invite dialog |
| [Icon button](https://albakore.github.io/pyzed/library/icon-button/) | Pagination and row menu |

## Code

The whole block, ready to copy. It only needs `pip install pyzed-ui`.

```python title="examples/blocks/data_table.py"
"""Data table block: a team members table with search, status filters, sorting, selection and paging."""

import pyzed as pz
from pyzed import ui

PEOPLE = [
    ("Olivia Martin", "olivia@acme.dev", "Owner", "Active", "Just now"),
    ("Jackson Lee", "jackson@acme.dev", "Admin", "Active", "5 min ago"),
    ("Isabella Nguyen", "isabella@acme.dev", "Editor", "Active", "1 hour ago"),
    ("William Kim", "will@acme.dev", "Viewer", "Invited", "—"),
    ("Sofia Davis", "sofia@acme.dev", "Editor", "Active", "Yesterday"),
    ("Liam Johnson", "liam@acme.dev", "Editor", "Suspended", "3 weeks ago"),
    ("Emma Wilson", "emma@acme.dev", "Admin", "Active", "2 hours ago"),
    ("Noah Brown", "noah@acme.dev", "Viewer", "Active", "4 days ago"),
    ("Ava Garcia", "ava@acme.dev", "Editor", "Invited", "—"),
    ("Lucas Rossi", "lucas@acme.dev", "Viewer", "Active", "Today"),
    ("Mia Fischer", "mia@acme.dev", "Editor", "Active", "Last week"),
    ("Ethan Clark", "ethan@acme.dev", "Viewer", "Suspended", "2 months ago"),
    ("Chloe Dubois", "chloe@acme.dev", "Admin", "Active", "Today"),
    ("Mateo Silva", "mateo@acme.dev", "Editor", "Active", "Yesterday"),
]
ROWS = [{"id": i, "name": n, "email": e, "role": r, "status": s, "seen": seen}
        for i, (n, e, r, s, seen) in enumerate(PEOPLE)]

BY_NAME = {r["name"]: r for r in ROWS}

STATUS_TINT = {"Active": "success", "Invited": "info", "Suspended": "error"}
ROLES = ["All roles", "Owner", "Admin", "Editor", "Viewer"]
PAGE_SIZE = 9
SORT_KEYS = {"Member": "name", "Role": "role", "Status": "status", "Last active": "seen"}

class DataTable(pz.App):
    title = "Team members"
    size = (1200, 760)

    def __init__(self):
        self.query = ""
        self.statuses = {"Active", "Invited"}
        self.role = "All roles"
        self.sort_by, self.descending = "Member", False  # a column title
        self.selected = {1, 4}
        self.page = 0
        self.notice = ""
        self.inviting, self.invite_email = False, ""

    # -- data ------------------------------------------------------------------------------
    def visible(self):
        q = self.query.lower()
        rows = [r for r in ROWS
                if r["status"] in self.statuses
                and (self.role == "All roles" or r["role"] == self.role)
                and (q in r["name"].lower() or q in r["email"].lower())]
        return rows

    # -- actions ---------------------------------------------------------------------------
    def toggle_status(self, status):
        self.statuses ^= {status}
        self.page = 0

    def toggle_row(self, row_id):
        self.selected ^= {row_id}

    def sort(self, key):
        self.descending = not self.descending if key == self.sort_by else False
        self.sort_by = key

    def export(self):
        rows = self.visible()
        self.notice = f"Copied {len(rows)} rows as CSV"
        lines = ["name,email,role,status"] + [f"{r['name']},{r['email']},{r['role']},{r['status']}" for r in rows]
        return pz.Command.copy("\n".join(lines))

    def invite(self):
        if "@" in self.invite_email:
            self.notice = f"Invitation sent to {self.invite_email}"
        self.inviting, self.invite_email = False, ""

    def set_query(self, value):
        self.query, self.page = value, 0

    # -- view ------------------------------------------------------------------------------
    def person(self, row):
        return pz.row([
            ui.avatar("".join(p[0] for p in row["name"].split()), size=28, index=row["id"]),
            pz.column([ui.label(row["name"], weight="medium"), ui.muted(row["email"], size="sm")], spacing=1),
        ], spacing=10, align="center")

    def toolbar(self):
        chips = [ui.chip(f"{status}  {sum(r['status'] == status for r in ROWS)}",
                         on_press=lambda status=status: self.toggle_status(status),
                         selected=status in self.statuses, icon="circle-dot" if status in self.statuses else None)
                 for status in STATUS_TINT]
        return pz.row([
            pz.container(ui.search_input(self.query, self.set_query, placeholder="Filter by name or email…"),
                         width=300),
            *chips,
            ui.select(ROLES, self.role, lambda v: setattr(self, "role", v), width=140),
            pz.hspace(),
            ui.button("Export", self.export, icon="download", variant="ghost"),
            ui.button("Invite member", lambda: setattr(self, "inviting", True), icon="user-plus", variant="solid"),
        ], spacing=8, align="center")

    def table(self, rows):
        columns = [
            ui.Column("", "id", width=36, sortable=False,
                      render=lambda i: ui.checkbox(i in self.selected, lambda _=None, i=i: self.toggle_row(i))),
            ui.Column("Member", "name", width=("fill", 3), render=lambda name: self.person(BY_NAME[name])),
            ui.Column("Role", "role", width=("fill", 1), render=lambda role: ui.label(role, color="text_muted")),
            ui.Column("Status", "status", width=("fill", 1),
                      render=lambda s: ui.badge(s, tint=STATUS_TINT[s])),
            ui.Column("Last active", "seen", width=("fill", 1), render=lambda s: ui.muted(s)),
            ui.Column("", "id", width=44, sortable=False,
                      render=lambda i: ui.icon_button("ellipsis", None, size="sm", tooltip="More")),
        ]
        return ui.table(columns, rows, sort_by=self.sort_by, descending=self.descending, on_sort=self.sort,
                        row_height=52)

    def view(self):
        rows = self.visible()
        pages = max(1, -(-len(rows) // PAGE_SIZE))
        page_rows = sorted(rows, key=lambda r: r[SORT_KEYS[self.sort_by]], reverse=self.descending)
        page_rows = page_rows[self.page * PAGE_SIZE:(self.page + 1) * PAGE_SIZE]
        start = self.page * PAGE_SIZE + 1 if rows else 0
        footer = pz.row([
            ui.muted(self.notice or f"{len(self.selected)} selected · showing {start}–{start + len(page_rows) - 1 if rows else 0} "
                     f"of {len(rows)}", size="sm"),
            pz.hspace(),
            ui.icon_button("chevron-left", lambda: setattr(self, "page", max(0, self.page - 1)),
                           disabled=self.page == 0, variant="filled"),
            *[ui.button(str(n + 1), lambda n=n: setattr(self, "page", n), size="sm",
                        variant="solid" if n == self.page else "ghost") for n in range(pages)],
            ui.icon_button("chevron-right", lambda: setattr(self, "page", min(pages - 1, self.page + 1)),
                           disabled=self.page >= pages - 1, variant="filled"),
        ], spacing=6, align="center")
        page = pz.column([
            pz.row([
                pz.column([ui.heading("Team members", level=2),
                           ui.muted("Manage who can access the Acme workspace and what they can do.")],
                          spacing=4),
                pz.hspace(),
                ui.facepile(["OM", "JL", "IN", "WK", "SD", "LJ"], size=28),
            ], align="center"),
            self.toolbar(),
            ui.card(pz.column([self.table(page_rows), ui.divider(), pz.container(footer, padding=(4, 4, 0, 4))],
                              spacing=8), padding=8),
        ], spacing=20, padding=28, height="fill").style(background="background")
        dialog = None
        if self.inviting:
            dialog = ui.prompt("Invite a member", self.invite_email, lambda v: setattr(self, "invite_email", v),
                               self.invite, lambda: setattr(self, "inviting", False),
                               message="They get an email with a link to join the workspace.",
                               placeholder="name@company.com", submit_label="Send invite", id="invite")
        return ui.dialog_layer(page, dialog, on_dismiss=lambda: setattr(self, "inviting", False))

if __name__ == "__main__":
    pz.run(DataTable)
```

## Make it yours

Change `theme` (or `self.theme = pz.themes.get("One Dark").with_accent("#e11d48")`), replace the sample data with your own and split pieces into [components](https://albakore.github.io/pyzed/guides/components/) when they grow. An AI agent with the [MCP server](https://albakore.github.io/pyzed/ai/mcp/) can read this block with `read_example("blocks/data_table")` and adapt it for you.
