-
-
Save ganeshan/9c9705d44f7914066fbb828c8b7dda32 to your computer and use it in GitHub Desktop.
Example of Interactive table with FastHTML Datastar and GreatTables
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ############################################################################################ | |
| # 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