📊 Data Tables

Data table with pagination, search, sorting, and inline actions

🎯 Overview

Category Components Purpose
📋 Table DataTable Paginated data table with search, sort, and actions
🔧 Resource DataTableResource Zero-boilerplate class to wire everything together

💡 Note: Form components (FormField, FormModal, FormGrid) are in the Components module. Import via from fh_matui.components import *


🏗️ Architecture

┌─────────────────────────────────────────────────────────┐
│                   DataTableResource                      │
├─────────────────────────────────────────────────────────┤
│  Auto-registers routes:                                  │
│  ├─ GET /resource       → DataTable (list view)         │
│  ├─ GET /resource/action→ FormModal (create/edit)       │
│  └─ POST /resource/save → Save handler (insert/update)  │
├─────────────────────────────────────────────────────────┤
│  DataTable                                              │
│  ├─ Pagination controls (page size, prev/next)          │
│  ├─ Search input (debounced, HTMX)                      │
│  ├─ Sortable column headers                             │
│  └─ Row action menus (edit, delete, custom)             │
├─────────────────────────────────────────────────────────┤
│  FormModal (from components)                            │
│  ├─ Auto-generates fields from column config            │
│  └─ HTMX submit to save endpoint                        │
└─────────────────────────────────────────────────────────┘

📚 Quick Reference

# Complete CRUD in 5 lines
resource = DataTableResource(
    app=app,
    name='products',
    data_source=lambda: db.products(),
    columns=[{'key': 'name', 'label': 'Name'}, {'key': 'price', 'label': 'Price'}]
)

🤔 DataTable vs DataTableResource: When to Use Which?

TL;DR: DataTable = UI only, DataTableResource = UI + routes + CRUD + forms

Quick Comparison

Aspect DataTable DataTableResource
What it is Pure UI function Full-stack class
Returns HTML table component Auto-registers 3 routes
Data handling You provide pre-paginated data Handles pagination internally
Routes ❌ You write them manually ✅ Auto-generated
Forms ❌ You build them manually ✅ Auto-generated from columns
CRUD operations ❌ You implement handlers ✅ Built-in with hooks
Use case Custom/complex tables Standard CRUD tables
Lines of code ~50-100 lines ~15 lines

📊 DataTable: The Building Block

DataTable is a pure UI component that renders a paginated table. It doesn’t know anything about your database or routes—you handle all that.

# You must write the route handler yourself
@rt("/my-products")
def products_table(req):
    # 1. Extract pagination params
    search, page, page_size = extract_params(req)
    
    # 2. Query your database
    data = db.query(f"SELECT * FROM products LIMIT {page_size} OFFSET {(page-1)*page_size}")
    total = db.query("SELECT COUNT(*) FROM products")[0]
    
    # 3. Return the UI component
    return DataTable(
        data=data,
        total=total,
        page=page,
        page_size=page_size,
        columns=[{"key": "name", "label": "Name"}],
        base_route="/my-products"
    )

# You also need separate routes for create/edit/delete forms...

Use DataTable when: - You need custom pagination logic - You want to embed a table inside a larger page - You’re building something non-standard - You already have route handlers


🔧 DataTableResource: The Complete Solution

DataTableResource is a high-level class that uses DataTable internally but also auto-registers routes, generates forms, and handles CRUD operations.

# One object replaces ~100 lines of code
products = DataTableResource(
    app=app,
    base_route="/products",
    columns=[{"key": "name", "label": "Name", "form": {"type": "text"}}],
    get_all=lambda: db.products(),
    get_by_id=lambda id: db.products[id],
    create=lambda data: db.products.insert(data),
    update=lambda id, data: db.products.update(id, data),
    delete=lambda id: db.products.delete(id),
    title="Products"
)

# That's it! Routes are auto-registered:
# GET  /products        → Table view
# GET  /products/action → Create/Edit/View modals
# POST /products/save   → Save handler

Use DataTableResource when: - You want standard CRUD functionality - You want auto-generated forms from column config - You need hooks for business logic (e.g., API calls) - You want minimal boilerplate


🎯 Decision Flowchart

Do you need a CRUD table with forms?
├── YES → Use DataTableResource
│         (auto routes, forms, hooks)
│
└── NO → Do you need just a data display?
         ├── YES → Use DataTable
         │         (pure UI, you handle routes)
         │
         └── NO → Consider a custom solution

📋 DataTable Wrapper

Component Purpose
DataTable Paginated table with search, sort, and row actions
table_state_from_request Extract pagination/search state from request
_action_menu Dropdown menu for row actions

Features: HTMX-powered pagination, debounced search, sortable columns, action menus


How It Works

The wrapper expects pre-paginated data from your backend. Your SQL backend provides: - data: Current page rows (via LIMIT/OFFSET) - total: Total record count (via COUNT(*))

@rt("/my-table")
def my_table(req):
    search, page, page_size = extract_params(req)
    data = db.query(f"SELECT * FROM items LIMIT {page_size} OFFSET {(page-1)*page_size}")
    total = db.query("SELECT COUNT(*) FROM items")
    
    return DataTable(
        data=data,
        total=total,
        page=page,
        page_size=page_size,
        search=search,
        columns=[{"key": "name", "label": "Name"}],
        crud_ops={"create": True, "update": True, "delete": True},
        base_route="/my-table"
    )

DataTable Parameters

Parameter Type Description
data list[dict] Current page rows (pre-paginated by backend)
total int Total record count (for pagination math)
page int Current page number (1-indexed)
page_size int Records per page
search str Current search term
columns list[dict] Column configs: [{"key": "name", "label": "Name", "searchable": True}]
crud_ops dict Enabled operations: {"create": True, "update": True, "delete": True}
base_route str Base URL for HTMX requests (e.g., /crud-products)
row_id_field str Field name for row ID (default: "id")
title str Table card header title
container_id str HTML id for HTMX targeting (auto-generated if None)
page_sizes list Page size options (default: [5, 10, 20, 50])
search_placeholder str Placeholder for search input
create_label str Label for create button
empty_message str Message when no records found

source

DataTable


def DataTable(
    data:list, total:int, page:int=1, page_size:int=10, search:str='', columns:list=None, crud_ops:dict=None,
    crud_enabled:dict=None, base_route:str='', row_id_field:str='id', title:str='Records', container_id:str=None,
    page_sizes:list=None, search_placeholder:str='Search...', create_label:str='New Record',
    empty_message:str='No records match the current filters.',
    table_style:str='border', # border, stripes, min, fixed (can combine: 'border stripes')
    space:str='small-space', # no-space, small-space, medium-space, large-space
    align:str=None, # left-align, right-align, center-align (None = default left)
    group_by:str=None, # Column key to group rows by
    group_header_format:Callable=None, # Format group header text
    group_header_cls:str='surface-container', # CSS classes for group header row
    group_header_icon:Union=None, # Icon name or callable
    group_header_space:str='small-space', # no-space, small-space, medium-space, large-space
):

Generic data table with server-side pagination, search, and row actions.

Table Styling (BeerCSS): table_style: Table visual style - ‘border’, ‘stripes’, ‘min’, ‘fixed’ or combine like ‘border stripes’ space: Row spacing - ‘no-space’, ‘small-space’, ‘medium-space’, ‘large-space’
align: Text alignment - ‘left-align’, ‘right-align’, ‘center-align’ (None for default)

Grouping: group_by: Column key to group rows by (e.g., ‘transaction_date’) group_header_format: Callable to format group value for display (e.g., lambda d: d.strftime(‘%B %d, %Y’)) group_header_cls: CSS classes for group header rows (default: ‘surface-container’) group_header_space: Spacing for group header cells - ‘no-space’, ‘small-space’, ‘medium-space’, ‘large-space’


source

table_state_from_request


def table_state_from_request(
    req, page_sizes:NoneType=None
):

Extract pagination state from request query params.

Returns dict with: search, page, page_size


🎯 CrudContext - Enhanced Hook Context

Rich context object for custom CRUD hooks with external API integration support

The CrudContext dataclass provides complete access to request state, user info, database, and record data for implementing complex business logic in hooks.

📦 Why CrudContext?

Before (simple hooks):

def on_before_create(data):
    data['created_at'] = datetime.now()
    return data  # Limited to data manipulation

After (with CrudContext):

async def on_create(ctx: CrudContext) -> dict:
    # Access user info
    user_id = ctx.user['user_id']
    
    # Call external API
    api = QuilttClient()
    response = await api.create_connection(
        institution=ctx.record['institution_name'],
        user_id=user_id
    )
    
    # Enrich record with API response
    ctx.record['connection_id'] = response['id']
    ctx.record['status'] = 'pending'
    
    return ctx.record  # DataTableResource will insert this

🏗️ Architecture

┌─────────────────────────────────────────────────────────┐
│                   DataTableResource                      │
├─────────────────────────────────────────────────────────┤
│  Enhanced CRUD Hooks (with CrudContext)                 │
│  ├─ on_create(ctx) → Return modified record dict        │
│  ├─ on_update(ctx) → Return modified record dict        │
│  └─ on_delete(ctx) → Perform custom deletion logic      │
├─────────────────────────────────────────────────────────┤
│  CrudContext provides:                                  │
│  ├─ request          → Full Starlette request           │
│  ├─ user             → request.state.user               │
│  ├─ db               → request.state.tenant_db          │
│  ├─ tbl              → request.state.tables[table_name] │
│  ├─ record           → Form data dict                   │
│  └─ record_id        → ID for update/delete             │
├─────────────────────────────────────────────────────────┤
│  Features:                                              │
│  ✅ Async/sync hook support                             │
│  ✅ Auto-refresh via HX-Trigger                         │
│  ✅ Toast notifications                                 │
│  ✅ External API integration                            │
│  ✅ Backward compatible with old hooks                  │
└─────────────────────────────────────────────────────────┘

source

CrudContext


def CrudContext(
    request:Any, user:Optional=None, db:Optional=None, tbl:Optional=None, record:dict=None, record_id:Optional=None,
    feedback_id:Optional=None
)->None:

🎯 Context object passed to enhanced CRUD operation hooks.

Provides rich access to request state, user info, database, and record data. Perfect for implementing complex business logic and external API integration.

📦 Fields

  • request: Full Starlette request object (headers, query params, session, state)
  • user: Current user dict from request.state.user (if available)
  • db: Database instance from request.state.tenant_db (if available)
  • tbl: Table instance from request.state.tables[table_name] (if available)
  • record: Form data dict with field values
  • record_id: Record ID for update/delete operations (None for create)

💡 Usage in Hooks

Example: Create with External API

async def quiltt_create_connection(ctx: CrudContext) -> dict:
    # Access user info
    user_id = ctx.user['user_id']

    # Call external API
    api = QuilttClient()
    response = await api.create_connection(
        institution=ctx.record['institution_name'],
        user_id=user_id
    )

    # Enrich record with API response
    ctx.record['connection_id'] = response['id']
    ctx.record['account_id'] = response['account_id']
    ctx.record['status'] = 'pending'

    return ctx.record  # DataTableResource will insert this

DataTableResource(
    ...,
    on_create=quiltt_create_connection  # 🆕 Enhanced hook
)

Example: Soft Delete

def soft_delete_budget(ctx: CrudContext) -> None:
    # Access table directly
    ctx.tbl.update({
        'id': ctx.record_id,
        'is_deleted': True,
        'deleted_at': datetime.now().isoformat(),
        'deleted_by': ctx.user['user_id']
    })
    # No return needed for delete hooks

DataTableResource(
    ...,
    on_delete=soft_delete_budget,  # 🆕 Custom delete logic
    get_table=lambda req: req.state.tables['budgets']
)

Example: Update with Sync

async def sync_transaction_update(ctx: CrudContext) -> dict:
    # Update external API first
    api = TransactionAPI()
    await api.update_transaction(
        transaction_id=ctx.record_id,
        data=ctx.record
    )

    # Add sync timestamp
    ctx.record['last_synced'] = datetime.now().isoformat()
    return ctx.record

🔧 DataTableResource

Component Purpose
DataTableResource High-level class that auto-registers table + forms + routes

Features: Auto-registers routes, handles pagination/search/save, all callbacks receive request, async hooks with CrudContext, auto-refresh via HX-Trigger, layout wrapper for full-page responses


DataTableResource Parameters

Parameter Type Description
app FastHTML App instance to register routes
base_route str Base URL path (e.g., /products)
columns list[dict] Column config (same as DataTable)
get_all Callable[[Request], list] (req) -> list of all records
get_by_id Callable[[Request, Any], Any] (req, id) -> record or None
create Callable[[Request, dict], Any] (req, data) -> record
update Callable[[Request, Any, dict], Any] (req, id, data) -> record
delete Callable[[Request, Any], bool] (req, id) -> bool
title str Display title for table
layout_wrapper Callable[[FT, Request], FT] Wrap full-page responses in app layout

💡 All data callbacks receive request as first parameter for multi-tenant support


🎯 CRUD Hooks (with CrudContext)

Hook Signature Purpose
on_create (ctx: CrudContext) -> dict Custom create logic with full context
on_update (ctx: CrudContext) -> dict Custom update logic with full context
on_delete (ctx: CrudContext) -> None Custom delete logic with full context

Features: - ✅ Async/sync support (hooks can be async def or regular def) - ✅ Access to user, db via CrudContext - ✅ Perfect for external API integration (e.g., Quiltt) - ✅ Auto-refresh table after mutations via HX-Trigger


🏢 Multi-Tenant Example

In a multi-tenant app where each tenant has their own database (request.state.tenant_db):

# Define your data callbacks - all receive request
def get_all_connections(req):
    """Get all connections from tenant's database."""
    tbl = req.state.tenant_db.t.connections
    return tbl(order_by="created_at DESC")

def get_connection_by_id(req, id):
    """Get single connection from tenant's database."""
    tbl = req.state.tenant_db.t.connections
    return tbl[id]

def create_connection(req, data):
    """Create connection in tenant's database."""
    tbl = req.state.tenant_db.t.connections
    return tbl.insert(data)

def update_connection(req, id, data):
    """Update connection in tenant's database."""
    tbl = req.state.tenant_db.t.connections
    data['id'] = id
    return tbl.update(data)

def delete_connection(req, id):
    """Delete connection from tenant's database."""
    tbl = req.state.tenant_db.t.connections
    tbl.delete(id)
    return True

# Create resource - handles all routes automatically
connections = DataTableResource(
    app=app,
    base_route="/connections",
    columns=connection_columns,
    get_all=get_all_connections,
    get_by_id=get_connection_by_id,
    create=create_connection,
    update=update_connection,
    delete=delete_connection,
    title="Bank Connections",
    search_placeholder="Search connections...",
    create_label="Add Connection",
    layout_wrapper=lambda content, req: AppLayout(content, user=req.state.user)
)

📐 Layout Wrapper

For full-page (non-HTMX) requests, wrap the table in your app layout:

def my_layout_wrapper(content, req):
    """Wrap content in app layout with sidebar and navbar."""
    return AppLayout(
        content,
        sidebar_links=get_sidebar_links(),
        nav_bar=NavBar(user=req.state.user),
        title="Dashboard"
    )

DataTableResource(
    ...,
    layout_wrapper=my_layout_wrapper
)

How it works: - HTMX requests (HX-Request: true header) → Returns partial HTML for table swap - Full-page requests (no header) → Wraps response with layout_wrapper(content, req)


🚀 Basic Usage Example

# Simple in-memory example (no multi-tenant)
PRODUCTS = [{"id": 1, "name": "Widget", "price": 9.99}]

products = DataTableResource(
    app=app,
    base_route="/products",
    columns=[
        {"key": "name", "label": "Name", "searchable": True},
        {"key": "price", "label": "Price"}
    ],
    get_all=lambda req: PRODUCTS,
    get_by_id=lambda req, id: next((p for p in PRODUCTS if p["id"] == id), None),
    create=lambda req, data: PRODUCTS.append({"id": len(PRODUCTS)+1, **data}),
    update=lambda req, id, data: ...,
    delete=lambda req, id: ...,
    title="Products"
)

🔗 With External API Integration (Async Hook)

async def quiltt_create_connection(ctx: CrudContext) -> dict:
    """Call Quiltt API before storing connection in database."""
    from quiltt_client import QuilttAPI
    
    api = QuilttAPI()
    response = await api.create_connection(
        institution=ctx.record['institution_name'],
        user_id=ctx.user['user_id']
    )
    
    # Enrich record with API response
    ctx.record['connection_id'] = response['id']
    ctx.record['account_id'] = response['account_id']
    ctx.record['status'] = 'pending'
    
    return ctx.record

DataTableResource(
    app=app,
    base_route="/connections",
    columns=connection_columns,
    get_all=lambda req: req.state.tenant_db.t.connections(),
    get_by_id=lambda req, id: req.state.tenant_db.t.connections[id],
    create=lambda req, data: req.state.tenant_db.t.connections.insert(data),
    title="Bank Connections",
    on_create=quiltt_create_connection
)

🎨 Custom Row Actions

Add domain-specific actions to the row menu beyond standard View/Edit/Delete

The custom_actions feature allows you to extend the row action menu with custom operations like “Refresh”, “Archive”, “Clone”, “Approve”, etc., each with their own handlers, icons, and optional confirmation dialogs.

📦 Why Custom Actions?

Before: Only View/Edit/Delete hardcoded actions

crud_ops = {"create": True, "update": True, "delete": True}
# ❌ Cannot add "Refresh", "Archive", or domain-specific actions

After: Extensible action menu with custom handlers

crud_ops = {
    "view": False,  # Can disable View now!
    "update": False,
    "delete": True,
    "custom_actions": [
        {
            "name": "refresh",
            "label": "Refresh Data",
            "icon": "sync",
            "handler": lambda ctx: sync_external_data(ctx.record_id),
            "confirm": None  # No confirmation needed
        },
        {
            "name": "archive",
            "label": "Archive",
            "icon": "archive",
            "handler": handle_archive,
            "confirm": "Archive this record?",
            "condition": lambda row: not row.get('is_archived')  # Only show if not archived
        }
    ]
}

🏗️ Custom Action Structure

Each custom action requires:

Field Type Required Description
name str ✅ Yes Internal action identifier (used in routing)
label str ✅ Yes Display text in menu
icon str ✅ Yes Material icon name (e.g., “sync”, “archive”)
handler Callable ✅ Yes Function that receives CrudContext
confirm str ❌ Optional Confirmation dialog message
condition Callable[[dict], bool] ❌ Optional Per-row visibility test

Reserved action names: view, edit, delete, create (will raise ValueError if used)


🎯 Handler Signature

Handlers receive a CrudContext object with full access to request state:

def handle_refresh(ctx: CrudContext) -> Optional[str]:
    """
    Custom action handler.
    
    Args:
        ctx.request: Full Starlette request
        ctx.user: request.state.user (if available)
        ctx.db: request.state.tenant_db (if available)
        ctx.record: Current row data as dict
        ctx.record_id: ID of the row
    
    Returns:
        Optional success message (defaults to "{label} completed successfully.")
    """
    # Access external API
    api = ExternalAPI(ctx.user['api_token'])
    api.sync_data(ctx.record_id)
    
    # Update record
    ctx.db.t.connections.update({
        'id': ctx.record_id,
        'last_synced': datetime.now().isoformat()
    })
    
    return "Data refreshed successfully!"  # Custom success message

Async handlers are supported:

async def handle_async_action(ctx: CrudContext) -> str:
    result = await some_async_operation(ctx.record_id)
    return f"Completed: {result}"

📊 Complete Example

# Handler definitions
def refresh_connection(ctx: CrudContext) -> str:
    """Sync data from external API."""
    api = BankAPI(ctx.user['bank_token'])
    response = api.refresh_accounts(ctx.record['connection_id'])
    
    ctx.db.t.connections.update({
        'id': ctx.record_id,
        'last_synced': datetime.now().isoformat(),
        'account_count': response['account_count']
    })
    
    return f"Refreshed {response['account_count']} accounts"

def archive_connection(ctx: CrudContext):
    """Soft delete by archiving."""
    ctx.db.t.connections.update({
        'id': ctx.record_id,
        'is_archived': True,
        'archived_at': datetime.now().isoformat(),
        'archived_by': ctx.user['user_id']
    })
    # Return None = uses default success message

async def clone_connection(ctx: CrudContext) -> str:
    """Create a duplicate connection."""
    new_record = {**ctx.record}
    del new_record['id']
    new_record['name'] = f"{new_record['name']} (Copy)"
    new_record['created_at'] = datetime.now().isoformat()
    
    # Async database operation
    result = await ctx.db.t.connections.insert_async(new_record)
    return f"Cloned as connection #{result['id']}"

# DataTableResource configuration
DataTableResource(
    app=app,
    base_route="/connections",
    columns=connection_columns,
    get_all=lambda req: req.state.tenant_db.t.connections(),
    get_by_id=lambda req, id: req.state.tenant_db.t.connections[id],
    crud_ops={
        "view": False,  # 🆕 Disable View action
        "create": True,
        "update": False,  # No edit needed
        "delete": True,
        "custom_actions": [  # 🆕 Custom actions
            {
                "name": "refresh",
                "label": "Refresh Data",
                "icon": "sync",
                "handler": refresh_connection
            },
            {
                "name": "archive",
                "label": "Archive",
                "icon": "archive",
                "handler": archive_connection,
                "confirm": "Archive this connection?",
                "condition": lambda row: not row.get('is_archived')  # Only show if active
            },
            {
                "name": "clone",
                "label": "Duplicate",
                "icon": "content_copy",
                "handler": clone_connection,
                "confirm": "Create a copy of this connection?"
            }
        ]
    },
    title="Bank Connections"
)

🔧 Per-Row Conditional Actions

Use the condition callable to show/hide actions based on row data:

{
    "name": "approve",
    "label": "Approve",
    "icon": "check_circle",
    "handler": handle_approve,
    "condition": lambda row: row.get('status') == 'pending'  # Only show for pending items
}

Common conditions: - Status-based: lambda row: row['status'] == 'active' - Role-based: lambda row: row['owner_id'] == current_user_id (access via handler) - Feature flag: lambda row: row.get('supports_refresh', False) - Type-based: lambda row: row['type'] in ['checking', 'savings']


🎭 Future: Full Renderer Override (Escape Hatch)

For complete UI control beyond menu items, a future row_actions_renderer parameter will allow replacing the entire action menu:

# 🔮 Future feature (not yet implemented)
def custom_row_actions(row, row_id, ctx):
    """Full control over row action UI."""
    if row['type'] == 'external':
        return Button(
            "Launch Widget",
            onclick="launchExternalWidget()",
            cls="secondary"
        )
    # Fall back to standard menu
    return None

DataTable(
    ...,
    row_actions_renderer=custom_row_actions  # 🔮 Planned
)

This would be useful for: - Non-menu layouts (button groups, custom dropdowns) - External widget integrations - Row-specific completely different UIs

Status: Documented as planned feature, implement only if real use cases emerge.



📊 DataTableResource

A high-level resource that auto-registers all routes for a complete CRUD data table.

💡 DB-Level Pagination (Recommended for Large Datasets)

By default, DataTableResource fetches all rows via get_all and paginates in Python. For large datasets, this is inefficient. Use the get_count parameter for efficient DB-level pagination:

def get_products(req):
    page = int(req.query_params.get('page', 1))
    page_size = int(req.query_params.get('page_size', 10))
    offset = (page - 1) * page_size
    # SQL-level pagination - only fetch current page
    return list(tbl(limit=page_size, offset=offset))

def get_product_count(req):
    # Efficient COUNT(*) query
    return db.execute("SELECT COUNT(*) FROM products").scalar()

DataTableResource(
    get_all=get_products,        # Returns only page_size rows
    get_count=get_product_count,  # Returns total count efficiently
    ...
)

Behavior: - With get_count: Uses your count function; assumes get_all returns pre-paginated data - Without get_count: Falls back to Python-side filtering/pagination (requires get_all to return ALL rows)


source

DataTableResource


def DataTableResource(
    app, base_route:str, columns:list, get_all:Callable, # (req) -> list (can be paginated)
    get_by_id:Callable, # (req, id) -> record
    get_count:Callable=None, # (req) -> total count for pagination
    create:Callable=None, # (req, data) -> record
    update:Callable=None, # (req, id, data) -> record
    delete:Callable=None, # (req, id) -> bool
    title:str='Records', # Display options
    row_id_field:str='id', crud_ops:dict=None, page_sizes:list=None, search_placeholder:str='Search...',
    create_label:str='New Record', empty_message:str='No records found.',
    on_create:Callable=None, # CRUD Hooks (with CrudContext)
    on_update:Callable=None, on_delete:Callable=None, layout_wrapper:Callable=None, # (content, req) -> wrapped
    id_generator:Callable=None, # Custom generators
    timestamp_fields:dict=None, group_by:str=None, # Column key to group rows by
    group_header_format:Callable=None, # Format group header text
    group_header_cls:str='surface-container', # CSS classes for group header row
    group_header_icon:Union=None, # Icon name or callable
    group_header_space:str='small-space', # no-space, small-space, medium-space, large-space
    space:str='small-space', # Row density: "no-space", "small-space", or None
):

🔧 High-level resource that auto-registers all routes for a data table.

Features: - All callbacks receive request for multi-tenant support - Custom CRUD hooks with CrudContext for rich business logic - Async/sync hook support for external API integration - Auto-refresh table via HX-Trigger after mutations - Layout wrapper for full-page (non-HTMX) responses - Optional get_count for efficient DB-level pagination - Row grouping with group_by for visual organization

Auto-registers 3 routes: - GET {base_route} → DataTable list view - GET {base_route}/action → FormModal for create/edit/view/delete - POST {base_route}/save → Save handler with hooks

Row Grouping:

Group rows by a column value with formatted section headers:

DataTableResource(
    ...,
    group_by="transaction_date",
    group_header_format=lambda d: d.strftime("%B %d, %Y"),
    group_header_icon="calendar_today"
)

DB-Level Pagination (Recommended for large datasets):

For efficient pagination, provide both get_all (returning paginated rows) and get_count (returning total count):

def get_products(req):
    page = int(req.query_params.get('page', 1))
    page_size = int(req.query_params.get('page_size', 10))
    offset = (page - 1) * page_size
    search = req.query_params.get('search', '')
    # SQL-level pagination
    return list(tbl(limit=page_size, offset=offset))

def get_product_count(req):
    search = req.query_params.get('search', '')
    return db.execute("SELECT COUNT(*) FROM products").scalar()

DataTableResource(
    get_all=get_products,      # Returns page_size rows
    get_count=get_product_count,  # Returns total count
    ...
)

If get_count is not provided, the library filters and paginates in Python (requires get_all to return ALL rows - inefficient for large datasets).


📊 Row Grouping

Visually organize rows by a column value with section headers

The group_by feature allows you to group table rows by a common column value (e.g., date, category, status), displaying a formatted header row that spans all columns before each group.

📦 Why Row Grouping?

Before (flat table):

| Date       | Description | Amount  |
|------------|-------------|---------|
| 2026-02-01 | Grocery     | $50.00  |
| 2026-02-01 | Gas         | $35.00  |
| 2026-02-01 | Coffee      | $5.00   |
| 2026-01-31 | Restaurant  | $45.00  |

After (grouped with headers):

┌──────────────────────────────────────┐
│ 📅 February 1, 2026                  │  ← Group header (colspan)
├──────────────────────────────────────┤
│ Grocery                    | $50.00  │
│ Gas                        | $35.00  │
│ Coffee                     | $5.00   │
├──────────────────────────────────────┤
│ 📅 January 31, 2026                  │  ← Group header
├──────────────────────────────────────┤
│ Restaurant                 | $45.00  │
└──────────────────────────────────────┘

🎯 Grouping Parameters

Parameter Type Default Description
group_by str None Column key to group rows by
group_header_format Callable[[Any], str] str() Function to format group header text
group_header_cls str "surface-container" CSS classes for group header row
group_header_icon str \| Callable None Icon name or callable returning icon per group

🚀 Basic Usage

# Group products by category
DataTableResource(
    app=app,
    base_route="/products",
    columns=product_columns,
    get_all=get_products,
    get_by_id=get_product_by_id,
    group_by="category",  # Group by the 'category' column
    group_header_icon="category"  # Show category icon in headers
)

📅 Transaction Table Example (Grouped by Date)

from datetime import date

# Transaction data - sorted by date DESC
transactions = [
    {"id": 1, "date": date(2026, 2, 1), "description": "Grocery", "amount": 50.00},
    {"id": 2, "date": date(2026, 2, 1), "description": "Gas", "amount": 35.00},
    {"id": 3, "date": date(2026, 2, 1), "description": "Coffee", "amount": 5.00},
    {"id": 4, "date": date(2026, 1, 31), "description": "Restaurant", "amount": 45.00},
    {"id": 5, "date": date(2026, 1, 31), "description": "Pharmacy", "amount": 12.00},
]

# Column config (date column still visible in rows)
transaction_columns = [
    {"key": "description", "label": "Description", "searchable": True},
    {"key": "amount", "label": "Amount", "renderer": lambda v, r: f"${v:,.2f}"},
]

DataTableResource(
    app=app,
    base_route="/transactions",
    columns=transaction_columns,
    get_all=lambda req: transactions,
    get_by_id=lambda req, id: next((t for t in transactions if t["id"] == id), None),
    
    # Grouping configuration
    group_by="date",
    group_header_format=lambda d: d.strftime("%A, %B %d, %Y"),  # "Saturday, February 01, 2026"
    group_header_icon="calendar_today",
    group_header_cls="surface-container",
    
    title="Transaction History"
)

🎨 Dynamic Icons per Group

Use a callable for group_header_icon to show different icons based on group value:

def status_icon(status):
    """Return icon based on status value."""
    icons = {
        "In Stock": "check_circle",
        "Low Stock": "warning",
        "Out of Stock": "error"
    }
    return icons.get(status, "help")

DataTableResource(
    ...,
    group_by="status",
    group_header_icon=status_icon  # Callable for dynamic icons
)

⚠️ Important Notes

  1. Sorting: Data should be pre-sorted by the group_by column in your get_all callback for logical grouping
  2. Pagination: Groups may span pages; each page shows its own group headers
  3. Search: When searching, only matching rows are shown; groups re-form from filtered results
  4. Backward Compatible: When group_by=None (default), tables render flat as before
Code
# --- Grouped Products Demo (by Category) ---
# Demonstrates the group_by feature with products organized by category

GROUPED_PRODUCTS = [
    # Audio category
    {"id": 4, "name": "AirPods Pro", "category": "Audio", "price": 249.00, "stock": 200},
    {"id": 9, "name": "HomePod Mini", "category": "Audio", "price": 99.00, "stock": 150},
    # Laptops category
    {"id": 1, "name": "MacBook Pro 16\"", "category": "Laptops", "price": 2499.00, "stock": 45},
    # Phones category
    {"id": 2, "name": "iPhone 15 Pro", "category": "Phones", "price": 999.00, "stock": 120},
    # Tablets category
    {"id": 3, "name": "iPad Air", "category": "Tablets", "price": 599.00, "stock": 0},
]

grouped_columns = [
    {"key": "name", "label": "Product", "searchable": True},
    {"key": "price", "label": "Price", "renderer": lambda v, r: f"${v:,.2f}"},
    {"key": "stock", "label": "Stock"},
]

# Create grouped resource
grouped_resource = DataTableResource(
    app=app,
    base_route="/grouped-products",
    columns=grouped_columns,
    get_all=lambda req: GROUPED_PRODUCTS,
    get_by_id=lambda req, id: next((p for p in GROUPED_PRODUCTS if p["id"] == id), None),
    title="Products by Category",
    
    # Grouping configuration
    group_by="category",
    group_header_icon="category",
    group_header_cls="surface-container",
    group_header_space="no-space",  # Compact group header spacing
    space="no-space"              # Compact rows with minimal padding
)

# Preview helper
def ex_grouped_products():
    mock_req = type('MockRequest', (), {'query_params': {}, 'headers': {}})()
    return grouped_resource._handle_table(mock_req)

preview(ex_grouped_products())

DataTableResource Demo

Demonstrates how DataTableResource reduces ~100 lines of route handlers to ~15 lines:

Code
# --- Mock Data for Demo ---
MOCK_PRODUCTS = [
    {"id": 1, "name": "MacBook Pro 16\"", "category": "Laptops", "price": 2499.00, "stock": 45, "status": "In Stock"},
    {"id": 2, "name": "iPhone 15 Pro", "category": "Phones", "price": 999.00, "stock": 120, "status": "In Stock"},
    {"id": 3, "name": "iPad Air", "category": "Tablets", "price": 599.00, "stock": 0, "status": "Out of Stock"},
    {"id": 4, "name": "AirPods Pro", "category": "Audio", "price": 249.00, "stock": 200, "status": "In Stock"},
    {"id": 5, "name": "Apple Watch Ultra", "category": "Wearables", "price": 799.00, "stock": 30, "status": "Low Stock"},
    {"id": 6, "name": "Magic Keyboard", "category": "Accessories", "price": 299.00, "stock": 85, "status": "In Stock"},
    {"id": 7, "name": "Studio Display", "category": "Monitors", "price": 1599.00, "stock": 12, "status": "Low Stock"},
    {"id": 8, "name": "Mac Mini M2", "category": "Desktops", "price": 599.00, "stock": 60, "status": "In Stock"},
    {"id": 9, "name": "HomePod Mini", "category": "Audio", "price": 99.00, "stock": 150, "status": "In Stock"},
    {"id": 10, "name": "AirTag 4-Pack", "category": "Accessories", "price": 99.00, "stock": 0, "status": "Out of Stock"},
]

# Status chip styles
PRODUCT_STATUS_CLASSES = {
    "In Stock": "chip small success",
    "Low Stock": "chip small warning", 
    "Out of Stock": "chip small error"
}

# Column configuration with renderers and form config
product_columns = [
    {
        "key": "name",
        "label": "Product",
        "searchable": True,
        "renderer": lambda v, row: Strong(v),
        "form": {"type": "text", "required": True}
    },
    {
        "key": "category",
        "label": "Category",
        "searchable": True,
        "form": {
            "type": "select",
            "options": ["Laptops", "Phones", "Tablets", "Audio", "Wearables", "Accessories", "Monitors", "Desktops"]
        }
    },
    {
        "key": "price",
        "label": "Price",
        "renderer": lambda v, row: Span(f"${v:,.2f}", cls="bold"),
        "form": {"type": "number", "min": 0, "step": 0.01}
    },
    {
        "key": "stock",
        "label": "Stock",
        "renderer": lambda v, row: Span(str(v), cls=f"badge {'error' if v == 0 else 'warning' if v < 20 else 'success'}") if isinstance(v, int) else v,
        "form": {"type": "number", "min": 0, "step": 1}
    },
    {
        "key": "status",
        "label": "Status",
        "searchable": True,
        "renderer": lambda v, row: Span(v, cls=PRODUCT_STATUS_CLASSES.get(v, "chip small")),
        "form": {"type": "select", "options": ["In Stock", "Low Stock", "Out of Stock"]}
    }
]

# --- In-memory store (simulates database) ---
DEMO_PRODUCTS = list(MOCK_PRODUCTS)

# All callbacks receive request as first parameter
def demo_get_all(req):
    """Get all products. In multi-tenant app: req.state.tenant_db.t.products()"""
    return DEMO_PRODUCTS

def demo_get_by_id(req, id):
    """Get product by ID. In multi-tenant app: req.state.tenant_db.t.products[id]"""
    return next((p for p in DEMO_PRODUCTS if p["id"] == id), None)

def demo_create(req, data):
    """Create product. In multi-tenant app: req.state.tenant_db.t.products.insert(data)"""
    new_id = max(p["id"] for p in DEMO_PRODUCTS) + 1
    record = {"id": new_id, **data}
    DEMO_PRODUCTS.append(record)
    return record

def demo_update(req, id, data):
    """Update product. In multi-tenant app: tbl.update({'id': id, **data})"""
    for i, p in enumerate(DEMO_PRODUCTS):
        if p["id"] == id:
            DEMO_PRODUCTS[i] = {"id": id, **data}
            return DEMO_PRODUCTS[i]
    return None

def demo_delete(req, id):
    """Delete product. In multi-tenant app: req.state.tenant_db.t.products.delete(id)"""
    global DEMO_PRODUCTS
    DEMO_PRODUCTS = [p for p in DEMO_PRODUCTS if p["id"] != id]
    return True

# --- DataTableResource: One object replaces ~100 lines of route handlers ---
products_resource = DataTableResource(
    app=app,
    base_route="/products",
    columns=product_columns,
    get_all=demo_get_all,
    get_by_id=demo_get_by_id,
    create=demo_create,
    update=demo_update,
    delete=demo_delete,
    title="Products",
    search_placeholder="Search products...",
    create_label="Add Product",
    space="no-space"
)

# --- Preview helper ---
def ex_products():
    """Preview the DataTableResource table."""
    mock_req = type('MockRequest', (), {'query_params': {}, 'headers': {}})()
    return products_resource._handle_table(mock_req)

preview(ex_products())