# 📊 Data Tables


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

## 🎯 Overview

<table>
<colgroup>
<col style="width: 32%" />
<col style="width: 38%" />
<col style="width: 29%" />
</colgroup>
<thead>
<tr>
<th>Category</th>
<th>Components</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td>📋 Table</td>
<td><a
href="https://abhisheksreesaila.github.io/fh-matui/datatable.html#datatable"><code>DataTable</code></a></td>
<td>Paginated data table with search, sort, and actions</td>
</tr>
<tr>
<td>🔧 Resource</td>
<td><a
href="https://abhisheksreesaila.github.io/fh-matui/datatable.html#datatableresource"><code>DataTableResource</code></a></td>
<td>Zero-boilerplate class to wire everything together</td>
</tr>
</tbody>
</table>

> 💡 **Note**: Form components
> ([`FormField`](https://abhisheksreesaila.github.io/fh-matui/components.html#formfield),
> [`FormModal`](https://abhisheksreesaila.github.io/fh-matui/components.html#formmodal),
> [`FormGrid`](https://abhisheksreesaila.github.io/fh-matui/components.html#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

``` python
# 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`](https://abhisheksreesaila.github.io/fh-matui/datatable.html#datatable)
> = UI only,
> [`DataTableResource`](https://abhisheksreesaila.github.io/fh-matui/datatable.html#datatableresource)
> = UI + routes + CRUD + forms

### Quick Comparison

<table>
<colgroup>
<col style="width: 19%" />
<col style="width: 30%" />
<col style="width: 50%" />
</colgroup>
<thead>
<tr>
<th>Aspect</th>
<th><a
href="https://abhisheksreesaila.github.io/fh-matui/datatable.html#datatable"><code>DataTable</code></a></th>
<th><a
href="https://abhisheksreesaila.github.io/fh-matui/datatable.html#datatableresource"><code>DataTableResource</code></a></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>What it is</strong></td>
<td>Pure UI function</td>
<td>Full-stack class</td>
</tr>
<tr>
<td><strong>Returns</strong></td>
<td>HTML table component</td>
<td>Auto-registers 3 routes</td>
</tr>
<tr>
<td><strong>Data handling</strong></td>
<td>You provide pre-paginated data</td>
<td>Handles pagination internally</td>
</tr>
<tr>
<td><strong>Routes</strong></td>
<td>❌ You write them manually</td>
<td>✅ Auto-generated</td>
</tr>
<tr>
<td><strong>Forms</strong></td>
<td>❌ You build them manually</td>
<td>✅ Auto-generated from columns</td>
</tr>
<tr>
<td><strong>CRUD operations</strong></td>
<td>❌ You implement handlers</td>
<td>✅ Built-in with hooks</td>
</tr>
<tr>
<td><strong>Use case</strong></td>
<td>Custom/complex tables</td>
<td>Standard CRUD tables</td>
</tr>
<tr>
<td><strong>Lines of code</strong></td>
<td>~50-100 lines</td>
<td>~15 lines</td>
</tr>
</tbody>
</table>

------------------------------------------------------------------------

### 📊 DataTable: The Building Block

[`DataTable`](https://abhisheksreesaila.github.io/fh-matui/datatable.html#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.

``` python
# 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`](https://abhisheksreesaila.github.io/fh-matui/datatable.html#datatableresource)
is a **high-level class** that uses
[`DataTable`](https://abhisheksreesaila.github.io/fh-matui/datatable.html#datatable)
internally but also auto-registers routes, generates forms, and handles
CRUD operations.

``` python
# 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

<table>
<colgroup>
<col style="width: 55%" />
<col style="width: 45%" />
</colgroup>
<thead>
<tr>
<th>Component</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><a
href="https://abhisheksreesaila.github.io/fh-matui/datatable.html#datatable"><code>DataTable</code></a></td>
<td>Paginated table with search, sort, and row actions</td>
</tr>
<tr>
<td><a
href="https://abhisheksreesaila.github.io/fh-matui/datatable.html#table_state_from_request"><code>table_state_from_request</code></a></td>
<td>Extract pagination/search state from request</td>
</tr>
<tr>
<td><a
href="https://abhisheksreesaila.github.io/fh-matui/datatable.html#_action_menu"><code>_action_menu</code></a></td>
<td>Dropdown menu for row actions</td>
</tr>
</tbody>
</table>

**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(*)`)

``` python
@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

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

------------------------------------------------------------------------

<a
href="https://github.com/abhisheksreesaila/fh-matui/blob/master/fh_matui/datatable.py#L220"
target="_blank" style="float:right; font-size:smaller">source</a>

### DataTable

``` python

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’

------------------------------------------------------------------------

<a
href="https://github.com/abhisheksreesaila/fh-matui/blob/master/fh_matui/datatable.py#L104"
target="_blank" style="float:right; font-size:smaller">source</a>

### table_state_from_request

``` python

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`](https://abhisheksreesaila.github.io/fh-matui/datatable.html#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):**

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

**After (with CrudContext):**

``` python
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                  │
    └─────────────────────────────────────────────────────────┘

------------------------------------------------------------------------

<a
href="https://github.com/abhisheksreesaila/fh-matui/blob/master/fh_matui/datatable.py#L471"
target="_blank" style="float:right; font-size:smaller">source</a>

### CrudContext

``` python

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

``` python
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

``` python
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

``` python
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

<table>
<colgroup>
<col style="width: 55%" />
<col style="width: 45%" />
</colgroup>
<thead>
<tr>
<th>Component</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><a
href="https://abhisheksreesaila.github.io/fh-matui/datatable.html#datatableresource"><code>DataTableResource</code></a></td>
<td>High-level class that auto-registers table + forms + routes</td>
</tr>
</tbody>
</table>

**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

<table>
<colgroup>
<col style="width: 36%" />
<col style="width: 20%" />
<col style="width: 43%" />
</colgroup>
<thead>
<tr>
<th>Parameter</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>app</code></td>
<td><code>FastHTML</code></td>
<td>App instance to register routes</td>
</tr>
<tr>
<td><code>base_route</code></td>
<td><code>str</code></td>
<td>Base URL path (e.g., <code>/products</code>)</td>
</tr>
<tr>
<td><code>columns</code></td>
<td><code>list[dict]</code></td>
<td>Column config (same as DataTable)</td>
</tr>
<tr>
<td><code>get_all</code></td>
<td><code>Callable[[Request], list]</code></td>
<td><code>(req) -&gt; list</code> of all records</td>
</tr>
<tr>
<td><code>get_by_id</code></td>
<td><code>Callable[[Request, Any], Any]</code></td>
<td><code>(req, id) -&gt; record</code> or None</td>
</tr>
<tr>
<td><code>create</code></td>
<td><code>Callable[[Request, dict], Any]</code></td>
<td><code>(req, data) -&gt; record</code></td>
</tr>
<tr>
<td><code>update</code></td>
<td><code>Callable[[Request, Any, dict], Any]</code></td>
<td><code>(req, id, data) -&gt; record</code></td>
</tr>
<tr>
<td><code>delete</code></td>
<td><code>Callable[[Request, Any], bool]</code></td>
<td><code>(req, id) -&gt; bool</code></td>
</tr>
<tr>
<td><code>title</code></td>
<td><code>str</code></td>
<td>Display title for table</td>
</tr>
<tr>
<td><code>layout_wrapper</code></td>
<td><code>Callable[[FT, Request], FT]</code></td>
<td>Wrap full-page responses in app layout</td>
</tr>
</tbody>
</table>

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

------------------------------------------------------------------------

### 🎯 CRUD Hooks (with CrudContext)

<table>
<colgroup>
<col style="width: 23%" />
<col style="width: 42%" />
<col style="width: 34%" />
</colgroup>
<thead>
<tr>
<th>Hook</th>
<th>Signature</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>on_create</code></td>
<td><code>(ctx: CrudContext) -&gt; dict</code></td>
<td>Custom create logic with full context</td>
</tr>
<tr>
<td><code>on_update</code></td>
<td><code>(ctx: CrudContext) -&gt; dict</code></td>
<td>Custom update logic with full context</td>
</tr>
<tr>
<td><code>on_delete</code></td>
<td><code>(ctx: CrudContext) -&gt; None</code></td>
<td>Custom delete logic with full context</td>
</tr>
</tbody>
</table>

**Features:** - ✅ Async/sync support (hooks can be `async def` or
regular `def`) - ✅ Access to user, db via
[`CrudContext`](https://abhisheksreesaila.github.io/fh-matui/datatable.html#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`):

``` python
# 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:

``` python
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

``` python
# 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)

``` python
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

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

**After:** Extensible action menu with custom handlers

``` python
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:

<table>
<colgroup>
<col style="width: 19%" />
<col style="width: 16%" />
<col style="width: 27%" />
<col style="width: 36%" />
</colgroup>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>name</code></td>
<td><code>str</code></td>
<td>✅ Yes</td>
<td>Internal action identifier (used in routing)</td>
</tr>
<tr>
<td><code>label</code></td>
<td><code>str</code></td>
<td>✅ Yes</td>
<td>Display text in menu</td>
</tr>
<tr>
<td><code>icon</code></td>
<td><code>str</code></td>
<td>✅ Yes</td>
<td>Material icon name (e.g., “sync”, “archive”)</td>
</tr>
<tr>
<td><code>handler</code></td>
<td><code>Callable</code></td>
<td>✅ Yes</td>
<td>Function that receives <a
href="https://abhisheksreesaila.github.io/fh-matui/datatable.html#crudcontext"><code>CrudContext</code></a></td>
</tr>
<tr>
<td><code>confirm</code></td>
<td><code>str</code></td>
<td>❌ Optional</td>
<td>Confirmation dialog message</td>
</tr>
<tr>
<td><code>condition</code></td>
<td><code>Callable[[dict], bool]</code></td>
<td>❌ Optional</td>
<td>Per-row visibility test</td>
</tr>
</tbody>
</table>

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

------------------------------------------------------------------------

### 🎯 Handler Signature

Handlers receive a
[`CrudContext`](https://abhisheksreesaila.github.io/fh-matui/datatable.html#crudcontext)
object with full access to request state:

``` python
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:**

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

------------------------------------------------------------------------

### 📊 Complete Example

``` python
# 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:

``` python
{
    "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:

``` python
# 🔮 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`](https://abhisheksreesaila.github.io/fh-matui/datatable.html#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:
>
> ``` python
> 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)

------------------------------------------------------------------------

<a
href="https://github.com/abhisheksreesaila/fh-matui/blob/master/fh_matui/datatable.py#L583"
target="_blank" style="float:right; font-size:smaller">source</a>

### DataTableResource

``` python

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`](https://abhisheksreesaila.github.io/fh-matui/datatable.html#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:

``` python
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):

``` python
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

<table>
<colgroup>
<col style="width: 28%" />
<col style="width: 15%" />
<col style="width: 23%" />
<col style="width: 33%" />
</colgroup>
<thead>
<tr>
<th>Parameter</th>
<th>Type</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>group_by</code></td>
<td><code>str</code></td>
<td><code>None</code></td>
<td>Column key to group rows by</td>
</tr>
<tr>
<td><code>group_header_format</code></td>
<td><code>Callable[[Any], str]</code></td>
<td><code>str()</code></td>
<td>Function to format group header text</td>
</tr>
<tr>
<td><code>group_header_cls</code></td>
<td><code>str</code></td>
<td><code>"surface-container"</code></td>
<td>CSS classes for group header row</td>
</tr>
<tr>
<td><code>group_header_icon</code></td>
<td><code>str \| Callable</code></td>
<td><code>None</code></td>
<td>Icon name or callable returning icon per group</td>
</tr>
</tbody>
</table>

------------------------------------------------------------------------

### 🚀 Basic Usage

``` python
# 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)

``` python
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:

``` python
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

<details class="code-fold">
<summary>Code</summary>

``` python
# --- 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())
```

</details>

<iframe src="http://localhost:5555/_hZG_5AbpRSKJgJgvLpXjAA" style="width: 100%; height: auto; border: none;" onload="{
        let frame = this;
        window.addEventListener('message', function(e) {
            if (e.source !== frame.contentWindow) return; // Only proceed if the message is from this iframe
            if (e.data.height) frame.style.height = (e.data.height+1) + 'px';
        }, false);
    }" allow="accelerometer; autoplay; camera; clipboard-read; clipboard-write; display-capture; encrypted-media; fullscreen; gamepad; geolocation; gyroscope; hid; identity-credentials-get; idle-detection; magnetometer; microphone; midi; payment; picture-in-picture; publickey-credentials-get; screen-wake-lock; serial; usb; web-share; xr-spatial-tracking"></iframe> 

### DataTableResource Demo

Demonstrates how
[`DataTableResource`](https://abhisheksreesaila.github.io/fh-matui/datatable.html#datatableresource)
reduces ~100 lines of route handlers to ~15 lines:

<details class="code-fold">
<summary>Code</summary>

``` python
# --- 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())
```

</details>

<iframe src="http://localhost:5555/_GF3CDFxLR0GveBCEeS_ciA" style="width: 100%; height: auto; border: none;" onload="{
        let frame = this;
        window.addEventListener('message', function(e) {
            if (e.source !== frame.contentWindow) return; // Only proceed if the message is from this iframe
            if (e.data.height) frame.style.height = (e.data.height+1) + 'px';
        }, false);
    }" allow="accelerometer; autoplay; camera; clipboard-read; clipboard-write; display-capture; encrypted-media; fullscreen; gamepad; geolocation; gyroscope; hid; identity-credentials-get; idle-detection; magnetometer; microphone; midi; payment; picture-in-picture; publickey-credentials-get; screen-wake-lock; serial; usb; web-share; xr-spatial-tracking"></iframe> 
