Skip to content

Instantly share code, notes, and snippets.

@Kvit
Last active July 6, 2025 12:49
Show Gist options
  • Select an option

  • Save Kvit/13fd0eea0f1e3a97758fb9dc676dc264 to your computer and use it in GitHub Desktop.

Select an option

Save Kvit/13fd0eea0f1e3a97758fb9dc676dc264 to your computer and use it in GitHub Desktop.
Example of Interactive table with FastHTML Datastar and GreatTables
############################################################################################
# REACTIVE TABLE DEMO WITH FASTHTML AND DATASTAR
#
# This example demonstrates:
# 1. Building a reactive data table with real-time filtering and pagination
# 2. Using Datastar for reactive UI updates without page refreshes
# 3. Implementing DaisyUI and TailwindCSS for modern styling
# 4. Handling user interactions with side effects (selection, filtering, pagination)
############################################################################################
import polars as pl
from great_tables import GT, google_font
from json import dumps as json_dumps
from fasthtml.common import ( Div, Link, Script, Style, Body,
Button, FastHTML, NotStr, serve, Span, Input, Option, Select, Label,
A, Aside,)
# Datastar installation via pip
# pip install datastar
# !!! If Pypi package is not updated with latest code, install SDK from source on github
# https://github.com/starfederation/datastar/tree/develop/sdk/python
from datastar_py.fasthtml import DatastarStreamingResponse
# BeautifulSoup is used to modify the HTML table for reactivity
from bs4 import BeautifulSoup
# ---- Import Scripts ----
# Add DaisyUI and TailwindCSS via CDN
daisylink = Link(rel="stylesheet",
href="https://cdn.jsdelivr.net/npm/[email protected]/daisyui.css", type="text/css")
tw_styles = Script(src="https://unpkg.com/@tailwindcss/browser@4")
# Datastar client-side via CDN
hdr_datastar = Script(
type="module", src="https://cdn.jsdelivr.net/gh/starfederation/[email protected]/bundles/datastar.js")
# Font Awesome Icons via CDN
font_awesome = Link(
rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css")
# local styesheet
local_style = Style("""
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap');
html {
font-family: 'Roboto', sans-serif;
}
.top-row {
height: calc(100vh - 100px);
}
.bottom-row {
height: 100px;
}
.scrollable {
height: 100%;
width: 100%;
overflow-y: auto;
}
[data-theme="recovr"] {
font-family: 'Roboto', sans-serif;
color-scheme: "light";
--color-base-100: oklch(100% 0 0);
--color-base-200: oklch(93% 0 0);
--color-base-300: oklch(86% 0 0);
--color-base-content: oklch(22.389% 0.031 278.072);
--color-primary: #607d8b;
--color-primary-content: oklch(100% 0 0);
--color-secondary: #6c757d;
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(52% 0.105 223.128);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(55% 0 0);
--color-neutral-content: oklch(100% 0 0);
--color-info: oklch(68% 0.169 237.323);
--color-info-content: oklch(100% 0 0);
--color-success: #26A69A;
--color-success-content: oklch(100% 0 0);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(0% 0 0);
--color-error: #E57373;
--color-error-content: oklch(0% 0 0);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.25rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
""")
headers = (daisylink, tw_styles, hdr_datastar, font_awesome, local_style)
ROWS_PER_PAGE = 15
# Use demo data columns for the datatable - all columns for display
demo_data_columns = ['id', 'year', 'manufacturer', 'model', 'price', 'VIN', 'odometer', 'state']
# String-only columns for search and filter operations
string_columns = ['manufacturer', 'model', 'VIN', 'state']
# Demo Datatable - this will be our primary datatable now
DEMO_DATATABLE = (
pl.read_parquet("https://storage.googleapis.com/rr-recovr-public/vehicles.parquet")
.filter(pl.col("VIN").is_not_null())
)
print(">>> Demo data columns", DEMO_DATATABLE.columns)
# Updated column widths for demo data
column_widths = {
"year": "60px",
"manufacturer": "120px",
"model": "120px",
"price": "80px",
"VIN": "150px",
"odometer": "80px",
"state": "60px"
}
# ---- Helper Functions ----
# Convert snake_case to Title Case for column labels
def snake_to_title(s):
"""Convert snake_case to Title Case."""
return s.replace('_', ' ').title()
# Convert snake_case column names to Title Case for better display
column_labels = {col: snake_to_title(col) for col in demo_data_columns}
# ---- Functions to subset data frame and create html table ----
def get_page_data(page=1, rows_per_page=ROWS_PER_PAGE, filter: dict | None = None, search: dict | None = None) -> pl.DataFrame:
"""
Get a paginated subset of the vehicle data with optional filtering and searching.
Parameters:
- page: Current page number (1-indexed)
- rows_per_page: Number of rows to display per page
- filter: Dictionary of {column_name: value} for exact matching filters
- search: Dictionary containing 'column' and 'pattern' for substring search
Returns:
- Filtered and paginated Polars DataFrame
"""
# Start with the complete dataset
page_data = DEMO_DATATABLE
# Apply filters if provided (exact matching)
# Example: filter = {"manufacturer": "toyota", "state": "ca"}
if filter:
for key, value in filter.items():
if value != "all": # "all" is a special value that means "don't filter this column"
page_data = page_data.filter(pl.col(key) == value)
# Apply search if provided (case-insensitive substring matching)
# Example: search = {"column": "manufacturer", "pattern": "toy"}
if search and page_data.height > 0:
pattern = search.get("pattern", "")
if pattern.strip():
# (?i) makes the regex pattern case-insensitive
page_data = page_data.filter(
pl.col(search["column"]).str.contains(f"(?i){pattern}"))
# Pagination: Calculate starting row and slice the DataFrame
start = (page - 1) * rows_per_page
# Safety check to prevent out-of-bounds access
if start > page_data.height:
start = 0
page_data = page_data.slice(start, rows_per_page)
return page_data
def GreatTable(page_data: pl.DataFrame, div_id="gt-table", select_signal: str = "claim_id", row_id: str = "id",
table_title: str = "Used Cars For Sale", column_widths: dict | None = None, **kwargs) -> Div:
"""
Creates an interactive HTML table from a DataFrame using Great Tables.
Features:
- Styled with TailwindCSS/DaisyUI
- Clickable rows that highlight when selected
- Column width control
- Customizable title
Parameters:
- page_data: Polars DataFrame containing the data to display
- div_id: HTML ID for the table container
- select_signal: Datastar signal name to update when a row is clicked
- row_id: Column name to use as unique identifier for rows
- table_title: Title displayed above the table
- column_widths: Dictionary of {column_name: width} for custom column sizing
Returns:
- Div containing the interactive HTML table
"""
# Default styling parameters
table_width: str = "1200px" # Width of the entire table
column_align = "left" # Text alignment within cells
# Convert column names from snake_case to Title Case for display
def snake_to_title(s):
return s.replace('_', ' ').title()
# Create a mapping of original column names to display names
column_labels = {col: snake_to_title(col) for col in page_data.columns}
# make GreatTable
page_gt = (GT(page_data, id="id")
.tab_header(
title=table_title,)
.cols_label(column_labels)
.cols_width(cases=column_widths)
.cols_align(align=column_align)
.opt_table_font(font=google_font(name="Roboto"))
.opt_row_striping()
.tab_options(
table_font_size="80%",
table_width=table_width,
))
# export the table to HTML
page_html = page_gt.as_raw_html()
# add datastar reactivity attribute to table rows via beautifulsoup
soup = BeautifulSoup(page_html, 'html.parser')
# Find the table body
tbody = soup.find('tbody')
if tbody:
# Find all table rows within the body
rows = tbody.find_all('tr')
if row_id in page_data.columns:
claim_ids = page_data[row_id].to_list()
for i, tr in enumerate(rows):
if i < len(claim_ids):
claim_id = claim_ids[i]
tr['data-on-click'] = f"${select_signal} = '{claim_id}'"
tr['data-class'] = f"{{'text-accent': ${select_signal} == '{claim_id}'}}"
else:
print("Warning: 'rowid' column not found in page_data.")
final_html = str(soup)
# Return the modified HTML as a string
return Div(NotStr(final_html), id=div_id)
return Div(
NotStr(
page_html
), id=div_id)
# Make the table and return it
def make_table(page: int = 1, rows_per_page: int = ROWS_PER_PAGE, filter: dict | None = None, search: dict | None = None, column_widths: dict | None = None) -> Div:
# get the page data
page_data = get_page_data(
page, rows_per_page, filter=filter, search=search)
# make the Great Table
table = GreatTable(page_data, column_widths=column_widths)
return table
def table_controls():
return Div(
Div(
# Left: Search group and selected claim badge
Div(
# Search group
Div(
Select(
*[
Option(
column_labels[col],
value=col,
selected=(col == "manufacturer")
) for col in string_columns
],
cls="select select-sm join-item bg-base-100 border-0 w-[150px]",
data_bind="search.column",
data_on_change="$search.pattern = ''; @post('/table/') "
),
Span(
Span(cls="fa fa-search text-base-content/60 mr-2"),
Input(
{"data-on-input__debounce.300ms":
"@post('/table/')"},
type="text",
placeholder="Search...",
cls="input input-sm w-full bg-transparent border-0 focus:outline-none",
data_bind="search.pattern",
data_on_change="@post('/table/')",
data_indicator_filtering=True
),
cls="input input-sm join-item bg-base-100",
fr="filter", data_attr_aria_busy="$filtering"
),
cls="join gap-x-1 w-96"
),
# Selected claim badge
Div(
A(
data_text="$claim_id",
data_attr_href="'https://craigslist.org/' + encodeURIComponent($claim_id)",
target="_blank",
cls="badge badge-accent badge-lg shadow transition-all duration-200 hover:scale-105"
),
data_show="$claim_id != null",
cls="ml-4"
),
cls="flex items-center gap-x-2"
),
# Right: Rows per page and pagination controls
Div(
# Rows per page
Div(
Label('Rows:', cls='text-sm mr-2'),
Select(
Option(ROWS_PER_PAGE),
Option(18),
Option(20),
cls='select select-bordered select-sm w-20',
data_bind="rows_per_page",
data_on_change="@post('/table/')"
),
cls='flex items-center gap-x-2'
),
# Pagination controls
Div(
Button(Span(cls="fa fa-angle-double-left"),
data_on_click="@post('/table/first')", cls='btn btn-sm btn-ghost join-item'),
Button(Span(cls="fa fa-angle-left"), data_on_click="@post('/table/previous')",
cls='btn btn-sm btn-ghost join-item'),
Label(
cls='text-sm px-4 flex items-center justify-center join-item',
data_text="'Page ' + String($page) + ' of ' + String($total_pages)"
),
Button(Span(cls="fa fa-angle-right"), data_on_click="@post('/table/next')",
cls='btn btn-sm btn-ghost join-item'),
Button(Span(cls="fa fa-angle-double-right"),
data_on_click="@post('/table/last')", cls='btn btn-sm btn-ghost join-item'),
cls='join ml-4'
),
cls="flex items-center gap-x-3 ml-auto"
),
cls="flex flex-wrap items-center gap-y-2 p-2 bg-base-200 shadow"
),
cls="w-full"
)
# Left sidebar
def left_sidebar():
return Aside(
Span(
"Filter",
cls="p-4 border-b border-neutral text-neutral-content"
),
Div(
# Filter manufacturer
Div(
Label("Manufacturer", fr="manufacturer_filter",
cls="font-medium text-sm text-neutral-content"),
Select(
Option("All", value="all", selected=True),
Option("Toyota", value="toyota"),
Option("Chevrolet", value="chevrolet"),
Option("Nissan", value="nissan"),
Option("Volkswagen", value="volkswagen"),
Option("Hyundai", value="hyundai"),
Option("Kia", value="kia"),
Option("Ford", value="ford"),
Option("Honda", value="honda"),
cls="select select-sm select-bordered w-full mt-1 bg-base-200 text-base-content",
data_bind="filter.manufacturer",
data_on_change="@post('/table/')"
),
cls="mb-4"
),
# Filter state
Div(
Label("State", fr="state_filter",
cls="font-medium text-sm text-neutral-content"),
Select(
Option("All", value="all", selected=True),
Option("CA", value="ca"),
Option("TX", value="tx"),
Option("NY", value="ny"),
cls="select select-sm select-bordered w-full mt-1 bg-base-200 text-base-content",
data_bind="filter.state",
data_on_change="@post('/table/')"
),
cls="mb-4"
),
# Filter model
Div(
Label("Model", fr="model_filter",
cls="font-medium text-sm text-neutral-content"),
Select(
Option("All", value="all", selected=True),
Option("Camry", value="camry"),
Option("Civic", value="civic"),
Option("F-150", value="f-150"),
cls="select select-sm select-bordered w-full mt-1 bg-base-200 text-base-content",
data_bind="filter.model",
data_on_change="@post('/table/')"
),
cls="mb-4"
),
# Reset Filters Button
Div(
Button(
Span(cls="fa fa-rotate-left mr-2 text-info"),
"Reset Filters",
cls="btn btn-sm w-full mt-2",
data_on_click="""$filter.manufacturer = 'all' ;
$filter.state = 'all' ;
$filter.model = 'all' ;
@post('/table/')
"""
),
cls="mt-2"
),
cls="p-4 flex flex-col"
),
cls="w-[250px] bg-secondary text-neutral-content rounded-box flex-shrink-0 flex flex-col shadow-md"
)
# ---- Web Application ----
app = FastHTML(
title="Vehicle List",
pico=False,
surreal=False,
htmx=False,
hdrs=headers,
live=True,
htmlkw=dict(lang="en", data_theme="recovr"),
)
rt = app.route
# ---- Routes ----
@rt("/table/{page}")
async def table(page: str, request):
try:
json_data = await request.json()
current_page = int(json_data.get("page", 1))
rows_per_page = int(json_data.get("rows_per_page", 5))
total_pages = (len(DEMO_DATATABLE) // rows_per_page) + 1
# page navigation logic
if page == "next":
show_page = min(current_page + 1, total_pages)
elif page == "previous":
show_page = max(current_page - 1, 1)
elif page == "first":
show_page = 1
elif page == "last":
show_page = total_pages
else:
# Try to parse page as a number
try:
show_page = int(page)
# Ensure it's within valid range
show_page = max(1, min(show_page, total_pages))
except ValueError:
show_page = current_page
# DEBUG
print("Request data:", json_data)
async def _():
yield DatastarStreamingResponse.merge_fragments(make_table(show_page, rows_per_page,
filter=json_data.get(
"filter", None),
search=json_data.get(
"search", None),
column_widths=column_widths)
)
yield DatastarStreamingResponse.merge_signals({"page": show_page,
"total_pages": total_pages,
})
return DatastarStreamingResponse(_())
except Exception as e:
print(f"Error processing request: {e}")
async def async_generator_with_error_message():
yield DatastarStreamingResponse.merge_fragments(Div(f"Error: {e}", cls="alert alert-error")) # noqa: F821
return DatastarStreamingResponse(async_generator_with_error_message())
@rt("/")
def index():
# Calculate total pages
total_pg = (len(DEMO_DATATABLE) // ROWS_PER_PAGE) + 1
return Body(
# init datastar signals
Div(
data_signals=json_dumps({
"page": 1,
"rows_per_page": ROWS_PER_PAGE,
"total_pages": total_pg,
"claim_id": None,
"search": {
"column": "manufacturer",
"pattern": ""
},
})
),
# Content
Div(
# Left sidebar
left_sidebar(),
# Table and controls
Div(
# Table
Div(
id="gt-table",
data_on_load="@post('/table/1')",
cls="flex-grow overflow-auto"
),
# Table controls
Div(
table_controls(),
id="table-controls"
),
cls="flex flex-col h-full w-full gap-4 border border-gray-300 rounded-sm p-2 m-2",
id="main-content"
),
cls="flex gap-1"
),
cls="container w-full p-2"
)
# ---- Test Run ----
if __name__ == "__main__":
serve(port=5001)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment