fh-matui 0.9.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fh_matui/__init__.py +1 -0
- fh_matui/_modidx.py +192 -0
- fh_matui/app_pages.py +291 -0
- fh_matui/components.py +1200 -0
- fh_matui/core.py +230 -0
- fh_matui/datatable.py +870 -0
- fh_matui/foundations.py +59 -0
- fh_matui/web_pages.py +919 -0
- fh_matui-0.9.7.dist-info/METADATA +243 -0
- fh_matui-0.9.7.dist-info/RECORD +14 -0
- fh_matui-0.9.7.dist-info/WHEEL +5 -0
- fh_matui-0.9.7.dist-info/entry_points.txt +2 -0
- fh_matui-0.9.7.dist-info/licenses/LICENSE +201 -0
- fh_matui-0.9.7.dist-info/top_level.txt +1 -0
fh_matui/datatable.py
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
"""Data table with pagination, search, sorting, and inline actions"""
|
|
2
|
+
|
|
3
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/05_datatable.ipynb.
|
|
4
|
+
|
|
5
|
+
# %% auto 0
|
|
6
|
+
__all__ = ['PAGE_SIZES', 'logger', 'table_state_from_request', 'DataTable', 'CrudContext', 'DataTableResource']
|
|
7
|
+
|
|
8
|
+
# %% ../nbs/05_datatable.ipynb 2
|
|
9
|
+
from fastcore.utils import *
|
|
10
|
+
from fasthtml.common import *
|
|
11
|
+
from fasthtml.jupyter import *
|
|
12
|
+
from fastlite import *
|
|
13
|
+
import fasthtml.components as fc
|
|
14
|
+
from fasthtml.common import A, Button as FhButton, I, Span
|
|
15
|
+
from .foundations import normalize_tokens, stringify, VEnum, dedupe_preserve_order
|
|
16
|
+
from .core import *
|
|
17
|
+
from nbdev.showdoc import show_doc
|
|
18
|
+
from .components import *
|
|
19
|
+
|
|
20
|
+
# %% ../nbs/05_datatable.ipynb 7
|
|
21
|
+
#| code-fold: true
|
|
22
|
+
from math import ceil
|
|
23
|
+
from urllib.parse import urlencode
|
|
24
|
+
from typing import Callable, Optional, Any
|
|
25
|
+
|
|
26
|
+
# Default page size options
|
|
27
|
+
PAGE_SIZES = [5, 10, 20, 50]
|
|
28
|
+
|
|
29
|
+
def _safe_int(value, default):
|
|
30
|
+
"""Safely convert to positive int or return default."""
|
|
31
|
+
try:
|
|
32
|
+
number = int(value)
|
|
33
|
+
return number if number > 0 else default
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
return default
|
|
36
|
+
|
|
37
|
+
def table_state_from_request(req, page_sizes=None):
|
|
38
|
+
"""
|
|
39
|
+
Extract pagination state from request query params.
|
|
40
|
+
|
|
41
|
+
Returns dict with: search, page, page_size
|
|
42
|
+
"""
|
|
43
|
+
page_sizes = page_sizes or PAGE_SIZES
|
|
44
|
+
params = getattr(req, "query_params", {})
|
|
45
|
+
getter = params.get if hasattr(params, "get") else (lambda key, default=None: params[key] if key in params else default)
|
|
46
|
+
|
|
47
|
+
search = (getter("search", "") or "").strip()
|
|
48
|
+
page = _safe_int(getter("page", 1), 1)
|
|
49
|
+
page_size = _safe_int(getter("page_size", 10), 10)
|
|
50
|
+
|
|
51
|
+
if page_size not in page_sizes:
|
|
52
|
+
page_size = page_sizes[0] if page_sizes else 10
|
|
53
|
+
|
|
54
|
+
return {"search": search, "page": page, "page_size": page_size}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _page_size_select(current_size: int, search: str, base_route: str, container_id: str, page_sizes: list):
|
|
58
|
+
"""Build page size dropdown selector."""
|
|
59
|
+
menu_id = f"{container_id}-page-size-menu"
|
|
60
|
+
options = []
|
|
61
|
+
for size in page_sizes:
|
|
62
|
+
params = urlencode({"search": search, "page_size": size, "page": 1})
|
|
63
|
+
option_cls = "active" if size == current_size else None
|
|
64
|
+
options.append(
|
|
65
|
+
Li(
|
|
66
|
+
f"Show {size}",
|
|
67
|
+
hx_get=f"{base_route}?{params}",
|
|
68
|
+
hx_target=f"#{container_id}",
|
|
69
|
+
hx_push_url="true",
|
|
70
|
+
cls=option_cls
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
return Button(
|
|
74
|
+
Span(f"Show {current_size}", cls="small-text grey-text"),
|
|
75
|
+
Icon("arrow_drop_down", cls="small grey-text"),
|
|
76
|
+
Menu(*options, cls="border", id=menu_id),
|
|
77
|
+
cls="transparent small",
|
|
78
|
+
data_ui=f"#{menu_id}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _action_menu(
|
|
83
|
+
row: dict,
|
|
84
|
+
row_id: Any,
|
|
85
|
+
crud_ops: dict,
|
|
86
|
+
base_route: str,
|
|
87
|
+
search: str,
|
|
88
|
+
page: int,
|
|
89
|
+
page_size: int,
|
|
90
|
+
container_id: str,
|
|
91
|
+
feedback_id: str
|
|
92
|
+
):
|
|
93
|
+
"""Build per-row action menu based on enabled CRUD operations."""
|
|
94
|
+
menu_id = f"crud-actions-{row_id}"
|
|
95
|
+
base_query = {"id": row_id, "search": search, "page": page, "page_size": page_size}
|
|
96
|
+
|
|
97
|
+
def action_item(label: str, icon: str, action: str, confirm: str = None):
|
|
98
|
+
query = urlencode({**base_query, "action": action})
|
|
99
|
+
attrs = {
|
|
100
|
+
"hx_get": f"{base_route}/action?{query}",
|
|
101
|
+
"hx_target": f"#{feedback_id}",
|
|
102
|
+
"hx_swap": "outerHTML"
|
|
103
|
+
}
|
|
104
|
+
if confirm:
|
|
105
|
+
attrs["hx_confirm"] = confirm
|
|
106
|
+
return Li(
|
|
107
|
+
A(
|
|
108
|
+
Icon(icon, cls="tiny"),
|
|
109
|
+
Span(label, cls="max"),
|
|
110
|
+
cls="row middle-align",
|
|
111
|
+
**attrs
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
items = []
|
|
116
|
+
# View is always available (read operation)
|
|
117
|
+
items.append(action_item("View", "visibility", "view"))
|
|
118
|
+
|
|
119
|
+
if crud_ops.get("update", False):
|
|
120
|
+
items.append(action_item("Edit", "edit", "edit"))
|
|
121
|
+
|
|
122
|
+
if crud_ops.get("delete", False):
|
|
123
|
+
items.append(action_item("Delete", "delete", "delete", confirm="Delete this record?"))
|
|
124
|
+
|
|
125
|
+
return Div(
|
|
126
|
+
Button(
|
|
127
|
+
Icon("more_vert"),
|
|
128
|
+
cls=(ButtonT.text, "circle"),
|
|
129
|
+
data_ui=f"#{menu_id}",
|
|
130
|
+
title="Row actions"
|
|
131
|
+
),
|
|
132
|
+
Menu(*items, id=menu_id),
|
|
133
|
+
cls="relative"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def DataTable(
|
|
138
|
+
data: list[dict],
|
|
139
|
+
total: int,
|
|
140
|
+
page: int = 1,
|
|
141
|
+
page_size: int = 10,
|
|
142
|
+
search: str = '',
|
|
143
|
+
columns: list[dict] = None,
|
|
144
|
+
crud_ops: dict = None,
|
|
145
|
+
base_route: str = '',
|
|
146
|
+
row_id_field: str = 'id',
|
|
147
|
+
title: str = 'Records',
|
|
148
|
+
container_id: str = None,
|
|
149
|
+
page_sizes: list = None,
|
|
150
|
+
search_placeholder: str = 'Search...',
|
|
151
|
+
create_label: str = 'New Record',
|
|
152
|
+
empty_message: str = 'No records match the current filters.'
|
|
153
|
+
):
|
|
154
|
+
"Generic data table with server-side pagination, search, and row actions."
|
|
155
|
+
# Defaults
|
|
156
|
+
crud_ops = crud_ops or {"create": False, "update": False, "delete": False}
|
|
157
|
+
page_sizes = page_sizes or PAGE_SIZES
|
|
158
|
+
container_id = container_id or f"crud-table-{base_route.replace('/', '-').strip('-')}"
|
|
159
|
+
feedback_id = f"{container_id}-feedback"
|
|
160
|
+
|
|
161
|
+
# Auto-generate columns from first data row if not provided
|
|
162
|
+
if columns is None and data:
|
|
163
|
+
columns = [{"key": k, "label": k.replace("_", " ").title()} for k in data[0].keys()]
|
|
164
|
+
columns = columns or []
|
|
165
|
+
|
|
166
|
+
# Calculate pagination metadata
|
|
167
|
+
total_pages = max(1, ceil(total / page_size)) if total > 0 else 1
|
|
168
|
+
page = min(max(1, page), total_pages)
|
|
169
|
+
start_index = (page - 1) * page_size + 1 if total else 0
|
|
170
|
+
end_index = min(start_index + page_size - 1, total) if total else 0
|
|
171
|
+
summary = f"{start_index}-{end_index} of {total} records" if total else "No matching records"
|
|
172
|
+
|
|
173
|
+
base_query = urlencode({"search": search, "page_size": page_size})
|
|
174
|
+
|
|
175
|
+
# Build table header keys and labels
|
|
176
|
+
header_keys = [col["key"] for col in columns] + ["actions"]
|
|
177
|
+
header_labels = [col.get("label", col["key"]) for col in columns] + [""]
|
|
178
|
+
|
|
179
|
+
# Build table rows
|
|
180
|
+
table_rows = []
|
|
181
|
+
for row in data:
|
|
182
|
+
row_id = row.get(row_id_field)
|
|
183
|
+
row_dict = {}
|
|
184
|
+
|
|
185
|
+
for col in columns:
|
|
186
|
+
key = col["key"]
|
|
187
|
+
value = row.get(key, "")
|
|
188
|
+
renderer = col.get("renderer")
|
|
189
|
+
|
|
190
|
+
if renderer and callable(renderer):
|
|
191
|
+
row_dict[key] = renderer(value, row)
|
|
192
|
+
else:
|
|
193
|
+
row_dict[key] = value
|
|
194
|
+
|
|
195
|
+
# Add actions column
|
|
196
|
+
row_dict["actions"] = _action_menu(
|
|
197
|
+
row=row,
|
|
198
|
+
row_id=row_id,
|
|
199
|
+
crud_ops=crud_ops,
|
|
200
|
+
base_route=base_route,
|
|
201
|
+
search=search,
|
|
202
|
+
page=page,
|
|
203
|
+
page_size=page_size,
|
|
204
|
+
container_id=container_id,
|
|
205
|
+
feedback_id=feedback_id
|
|
206
|
+
)
|
|
207
|
+
table_rows.append(row_dict)
|
|
208
|
+
|
|
209
|
+
# Empty state
|
|
210
|
+
if not table_rows:
|
|
211
|
+
empty_row = {col["key"]: "" for col in columns}
|
|
212
|
+
empty_row[columns[0]["key"]] = Span(empty_message, cls="small-text grey-text")
|
|
213
|
+
empty_row["actions"] = ""
|
|
214
|
+
table_rows.append(empty_row)
|
|
215
|
+
|
|
216
|
+
# Build header content
|
|
217
|
+
header_content = DivFullySpaced(
|
|
218
|
+
H5(title),
|
|
219
|
+
Span(f"{total} total", cls="small-text grey-text")
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Build search and create button row
|
|
223
|
+
toolbar_items = [
|
|
224
|
+
Field(
|
|
225
|
+
Input(
|
|
226
|
+
placeholder=search_placeholder,
|
|
227
|
+
name="search",
|
|
228
|
+
value=search,
|
|
229
|
+
hx_get=f"{base_route}?page_size={page_size}",
|
|
230
|
+
hx_trigger="keyup changed delay:500ms",
|
|
231
|
+
hx_target=f"#{container_id}",
|
|
232
|
+
hx_push_url="true",
|
|
233
|
+
hx_vals='{"page":1}',
|
|
234
|
+
autocomplete="off",
|
|
235
|
+
onfocus="this.parentElement.classList.add('max')",
|
|
236
|
+
onblur="if(!this.value) this.parentElement.classList.remove('max')"
|
|
237
|
+
),
|
|
238
|
+
cls="border round",
|
|
239
|
+
style="transition: flex-grow 0.3s ease"
|
|
240
|
+
)
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
if crud_ops.get("create", False):
|
|
244
|
+
create_query = urlencode({"action": "create", "search": search, "page": page, "page_size": page_size})
|
|
245
|
+
toolbar_items.append(
|
|
246
|
+
Button(
|
|
247
|
+
Icon("add"),
|
|
248
|
+
Span(create_label),
|
|
249
|
+
cls=ButtonT.primary,
|
|
250
|
+
hx_get=f"{base_route}/action?{create_query}",
|
|
251
|
+
hx_target=f"#{feedback_id}",
|
|
252
|
+
hx_swap="outerHTML"
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Build footer with pagination - count on left, paginator centered below table
|
|
257
|
+
footer_content = Div(
|
|
258
|
+
Div(Span(summary, cls="small-text grey-text")),
|
|
259
|
+
Div(
|
|
260
|
+
_page_size_select(page_size, search, base_route, container_id, page_sizes),
|
|
261
|
+
Pagination(
|
|
262
|
+
page,
|
|
263
|
+
total_pages,
|
|
264
|
+
f"{base_route}?{base_query}",
|
|
265
|
+
hx_target=f"#{container_id}"
|
|
266
|
+
),
|
|
267
|
+
cls="row center-align middle-align small-space"
|
|
268
|
+
),
|
|
269
|
+
cls="grid"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Create header label mapping for custom rendering
|
|
273
|
+
label_map = dict(zip(header_keys, header_labels))
|
|
274
|
+
|
|
275
|
+
# Build the card
|
|
276
|
+
card = Card(
|
|
277
|
+
DivFullySpaced(*toolbar_items, cls="padding"),
|
|
278
|
+
TableFromDicts(
|
|
279
|
+
header_keys,
|
|
280
|
+
table_rows,
|
|
281
|
+
header_cell_render=lambda k: Th(label_map.get(k, k)),
|
|
282
|
+
cls="border"
|
|
283
|
+
),
|
|
284
|
+
footer=footer_content,
|
|
285
|
+
header=header_content,
|
|
286
|
+
cls="surface-container border round"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return Div(card, Div(id=feedback_id), id=container_id)
|
|
290
|
+
|
|
291
|
+
# %% ../nbs/05_datatable.ipynb 9
|
|
292
|
+
import asyncio
|
|
293
|
+
import logging
|
|
294
|
+
from dataclasses import dataclass
|
|
295
|
+
from typing import Callable, Any, Optional
|
|
296
|
+
from starlette.responses import HTMLResponse
|
|
297
|
+
|
|
298
|
+
logger = logging.getLogger(__name__)
|
|
299
|
+
|
|
300
|
+
@dataclass
|
|
301
|
+
class CrudContext:
|
|
302
|
+
"""
|
|
303
|
+
🎯 Context object passed to enhanced CRUD operation hooks.
|
|
304
|
+
|
|
305
|
+
Provides rich access to request state, user info, database, and record data.
|
|
306
|
+
Perfect for implementing complex business logic and external API integration.
|
|
307
|
+
|
|
308
|
+
## 📦 Fields
|
|
309
|
+
|
|
310
|
+
- `request`: Full Starlette request object (headers, query params, session, state)
|
|
311
|
+
- `user`: Current user dict from `request.state.user` (if available)
|
|
312
|
+
- `db`: Database instance from `request.state.tenant_db` (if available)
|
|
313
|
+
- `tbl`: Table instance from `request.state.tables[table_name]` (if available)
|
|
314
|
+
- `record`: Form data dict with field values
|
|
315
|
+
- `record_id`: Record ID for update/delete operations (None for create)
|
|
316
|
+
|
|
317
|
+
## 💡 Usage in Hooks
|
|
318
|
+
|
|
319
|
+
### Example: Create with External API
|
|
320
|
+
```python
|
|
321
|
+
async def quiltt_create_connection(ctx: CrudContext) -> dict:
|
|
322
|
+
# Access user info
|
|
323
|
+
user_id = ctx.user['user_id']
|
|
324
|
+
|
|
325
|
+
# Call external API
|
|
326
|
+
api = QuilttClient()
|
|
327
|
+
response = await api.create_connection(
|
|
328
|
+
institution=ctx.record['institution_name'],
|
|
329
|
+
user_id=user_id
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# Enrich record with API response
|
|
333
|
+
ctx.record['connection_id'] = response['id']
|
|
334
|
+
ctx.record['account_id'] = response['account_id']
|
|
335
|
+
ctx.record['status'] = 'pending'
|
|
336
|
+
|
|
337
|
+
return ctx.record # DataTableResource will insert this
|
|
338
|
+
|
|
339
|
+
DataTableResource(
|
|
340
|
+
...,
|
|
341
|
+
on_create=quiltt_create_connection # 🆕 Enhanced hook
|
|
342
|
+
)
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Example: Soft Delete
|
|
346
|
+
```python
|
|
347
|
+
def soft_delete_budget(ctx: CrudContext) -> None:
|
|
348
|
+
# Access table directly
|
|
349
|
+
ctx.tbl.update({
|
|
350
|
+
'id': ctx.record_id,
|
|
351
|
+
'is_deleted': True,
|
|
352
|
+
'deleted_at': datetime.now().isoformat(),
|
|
353
|
+
'deleted_by': ctx.user['user_id']
|
|
354
|
+
})
|
|
355
|
+
# No return needed for delete hooks
|
|
356
|
+
|
|
357
|
+
DataTableResource(
|
|
358
|
+
...,
|
|
359
|
+
on_delete=soft_delete_budget, # 🆕 Custom delete logic
|
|
360
|
+
get_table=lambda req: req.state.tables['budgets']
|
|
361
|
+
)
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Example: Update with Sync
|
|
365
|
+
```python
|
|
366
|
+
async def sync_transaction_update(ctx: CrudContext) -> dict:
|
|
367
|
+
# Update external API first
|
|
368
|
+
api = TransactionAPI()
|
|
369
|
+
await api.update_transaction(
|
|
370
|
+
transaction_id=ctx.record_id,
|
|
371
|
+
data=ctx.record
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Add sync timestamp
|
|
375
|
+
ctx.record['last_synced'] = datetime.now().isoformat()
|
|
376
|
+
return ctx.record
|
|
377
|
+
```
|
|
378
|
+
"""
|
|
379
|
+
request: Any # Full Starlette request
|
|
380
|
+
user: Optional[dict] = None # request.state.user (if available)
|
|
381
|
+
db: Optional[Any] = None # request.state.tenant_db (if available)
|
|
382
|
+
tbl: Optional[Any] = None # request.state.tables[table_name] (if available)
|
|
383
|
+
record: dict = None # Form data dict
|
|
384
|
+
record_id: Optional[Any] = None # ID for update/delete (None for create)
|
|
385
|
+
|
|
386
|
+
# %% ../nbs/05_datatable.ipynb 13
|
|
387
|
+
from typing import Callable, Optional, Any, Union
|
|
388
|
+
from dataclasses import asdict, is_dataclass
|
|
389
|
+
from datetime import datetime
|
|
390
|
+
import uuid
|
|
391
|
+
|
|
392
|
+
def _to_dict(obj: Any) -> dict:
|
|
393
|
+
"""Convert dataclass, ORM object, or dict to plain dict."""
|
|
394
|
+
if obj is None:
|
|
395
|
+
return {}
|
|
396
|
+
if isinstance(obj, dict):
|
|
397
|
+
return obj
|
|
398
|
+
if is_dataclass(obj):
|
|
399
|
+
return asdict(obj)
|
|
400
|
+
# ORM-style objects with __dict__
|
|
401
|
+
if hasattr(obj, "__dict__"):
|
|
402
|
+
return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
|
|
403
|
+
return dict(obj)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _is_htmx_request(req) -> bool:
|
|
407
|
+
"""Check if request is an HTMX partial request."""
|
|
408
|
+
headers = getattr(req, 'headers', {})
|
|
409
|
+
return headers.get('HX-Request') == 'true'
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class DataTableResource:
|
|
413
|
+
"""
|
|
414
|
+
🔧 High-level resource that auto-registers all routes for a data table.
|
|
415
|
+
|
|
416
|
+
**Features:**
|
|
417
|
+
- All callbacks receive `request` for multi-tenant support
|
|
418
|
+
- Custom CRUD hooks with `CrudContext` for rich business logic
|
|
419
|
+
- Async/sync hook support for external API integration
|
|
420
|
+
- Auto-refresh table via HX-Trigger after mutations
|
|
421
|
+
- Layout wrapper for full-page (non-HTMX) responses
|
|
422
|
+
|
|
423
|
+
**Auto-registers 3 routes:**
|
|
424
|
+
- `GET {base_route}` → DataTable list view
|
|
425
|
+
- `GET {base_route}/action` → FormModal for create/edit/view/delete
|
|
426
|
+
- `POST {base_route}/save` → Save handler with hooks
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
def __init__(
|
|
430
|
+
self,
|
|
431
|
+
app,
|
|
432
|
+
base_route: str,
|
|
433
|
+
columns: list[dict],
|
|
434
|
+
# Data callbacks - ALL receive request as first param
|
|
435
|
+
get_all: Callable[[Any], list], # (req) -> list
|
|
436
|
+
get_by_id: Callable[[Any, Any], Any], # (req, id) -> record
|
|
437
|
+
create: Callable[[Any, dict], Any] = None, # (req, data) -> record
|
|
438
|
+
update: Callable[[Any, Any, dict], Any] = None, # (req, id, data) -> record
|
|
439
|
+
delete: Callable[[Any, Any], bool] = None, # (req, id) -> bool
|
|
440
|
+
# Display options
|
|
441
|
+
title: str = "Records",
|
|
442
|
+
row_id_field: str = "id",
|
|
443
|
+
crud_ops: dict = None,
|
|
444
|
+
page_sizes: list = None,
|
|
445
|
+
search_placeholder: str = "Search...",
|
|
446
|
+
create_label: str = "New Record",
|
|
447
|
+
empty_message: str = "No records found.",
|
|
448
|
+
# CRUD Hooks (with CrudContext)
|
|
449
|
+
on_create: Callable[[CrudContext], dict] = None,
|
|
450
|
+
on_update: Callable[[CrudContext], dict] = None,
|
|
451
|
+
on_delete: Callable[[CrudContext], None] = None,
|
|
452
|
+
# Layout wrapper for full-page responses
|
|
453
|
+
layout_wrapper: Callable[[Any, Any], Any] = None, # (content, req) -> wrapped
|
|
454
|
+
# Custom generators
|
|
455
|
+
id_generator: Callable[[], Any] = None,
|
|
456
|
+
timestamp_fields: dict = None
|
|
457
|
+
):
|
|
458
|
+
self.app = app
|
|
459
|
+
self.base_route = base_route.rstrip("/")
|
|
460
|
+
self.columns = columns
|
|
461
|
+
self.get_all = get_all
|
|
462
|
+
self.get_by_id = get_by_id
|
|
463
|
+
self.create_fn = create
|
|
464
|
+
self.update_fn = update
|
|
465
|
+
self.delete_fn = delete
|
|
466
|
+
self.title = title
|
|
467
|
+
self.row_id_field = row_id_field
|
|
468
|
+
self.page_sizes = page_sizes or PAGE_SIZES
|
|
469
|
+
self.search_placeholder = search_placeholder
|
|
470
|
+
self.create_label = create_label
|
|
471
|
+
self.empty_message = empty_message
|
|
472
|
+
|
|
473
|
+
# Determine CRUD ops from provided functions
|
|
474
|
+
if crud_ops is None:
|
|
475
|
+
self.crud_ops = {
|
|
476
|
+
"create": create is not None or on_create is not None,
|
|
477
|
+
"update": update is not None or on_update is not None,
|
|
478
|
+
"delete": delete is not None or on_delete is not None
|
|
479
|
+
}
|
|
480
|
+
else:
|
|
481
|
+
self.crud_ops = crud_ops
|
|
482
|
+
|
|
483
|
+
# CRUD hooks
|
|
484
|
+
self.on_create_hook = on_create
|
|
485
|
+
self.on_update_hook = on_update
|
|
486
|
+
self.on_delete_hook = on_delete
|
|
487
|
+
|
|
488
|
+
# Layout wrapper
|
|
489
|
+
self.layout_wrapper = layout_wrapper
|
|
490
|
+
|
|
491
|
+
# Generators
|
|
492
|
+
self.id_generator = id_generator
|
|
493
|
+
self.timestamp_fields = timestamp_fields or {}
|
|
494
|
+
|
|
495
|
+
# Derived IDs
|
|
496
|
+
self.container_id = f"crud-table-{base_route.replace('/', '-').strip('-')}"
|
|
497
|
+
self.feedback_id = f"{self.container_id}-feedback"
|
|
498
|
+
self.modal_id = f"{self.container_id}-modal"
|
|
499
|
+
self.refresh_trigger = f"{self.container_id}-refresh"
|
|
500
|
+
|
|
501
|
+
# Register routes
|
|
502
|
+
self._register_routes()
|
|
503
|
+
|
|
504
|
+
async def _call_hook(self, hook, ctx: CrudContext):
|
|
505
|
+
"""🔄 Call hook function, handling both sync and async."""
|
|
506
|
+
if hook is None:
|
|
507
|
+
return None
|
|
508
|
+
if asyncio.iscoroutinefunction(hook):
|
|
509
|
+
return await hook(ctx)
|
|
510
|
+
return hook(ctx)
|
|
511
|
+
|
|
512
|
+
def _build_context(self, req, record: dict = None, record_id: Any = None) -> CrudContext:
|
|
513
|
+
"""🏗️ Build CrudContext from request."""
|
|
514
|
+
user = None
|
|
515
|
+
db = None
|
|
516
|
+
tbl = None
|
|
517
|
+
|
|
518
|
+
# Extract user from request.state if available
|
|
519
|
+
try:
|
|
520
|
+
if hasattr(req, 'state') and hasattr(req.state, 'user'):
|
|
521
|
+
user = req.state.user
|
|
522
|
+
except AttributeError:
|
|
523
|
+
pass
|
|
524
|
+
|
|
525
|
+
# Extract db from request.state if available
|
|
526
|
+
try:
|
|
527
|
+
if hasattr(req, 'state') and hasattr(req.state, 'tenant_db'):
|
|
528
|
+
db = req.state.tenant_db
|
|
529
|
+
except AttributeError:
|
|
530
|
+
pass
|
|
531
|
+
|
|
532
|
+
return CrudContext(
|
|
533
|
+
request=req,
|
|
534
|
+
user=user,
|
|
535
|
+
db=db,
|
|
536
|
+
tbl=tbl,
|
|
537
|
+
record=record or {},
|
|
538
|
+
record_id=record_id
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
def _wrap_response(self, content, req):
|
|
542
|
+
"""Wrap content with layout_wrapper if not an HTMX request."""
|
|
543
|
+
if self.layout_wrapper and not _is_htmx_request(req):
|
|
544
|
+
return self.layout_wrapper(content, req)
|
|
545
|
+
return content
|
|
546
|
+
|
|
547
|
+
def _register_routes(self):
|
|
548
|
+
"""Register all data table routes with the app."""
|
|
549
|
+
rt = self.app.route
|
|
550
|
+
|
|
551
|
+
@rt(self.base_route)
|
|
552
|
+
def _table_handler(req):
|
|
553
|
+
return self._handle_table(req)
|
|
554
|
+
|
|
555
|
+
@rt(f"{self.base_route}/action")
|
|
556
|
+
def _action_handler(req):
|
|
557
|
+
return self._handle_action(req)
|
|
558
|
+
|
|
559
|
+
@rt(f"{self.base_route}/save")
|
|
560
|
+
async def _save_handler(req):
|
|
561
|
+
return await self._handle_save(req)
|
|
562
|
+
|
|
563
|
+
def _get_filtered_data(self, req) -> list:
|
|
564
|
+
"""Get all data from user's callback."""
|
|
565
|
+
all_data = self.get_all(req)
|
|
566
|
+
return [_to_dict(item) for item in all_data]
|
|
567
|
+
|
|
568
|
+
def _filter_by_search(self, data: list, search: str) -> list:
|
|
569
|
+
"""Filter data by search term across searchable columns."""
|
|
570
|
+
if not search:
|
|
571
|
+
return data
|
|
572
|
+
|
|
573
|
+
needle = search.lower()
|
|
574
|
+
searchable_keys = [
|
|
575
|
+
col["key"] for col in self.columns
|
|
576
|
+
if col.get("searchable", False)
|
|
577
|
+
]
|
|
578
|
+
|
|
579
|
+
if not searchable_keys:
|
|
580
|
+
searchable_keys = [col["key"] for col in self.columns]
|
|
581
|
+
|
|
582
|
+
return [
|
|
583
|
+
row for row in data
|
|
584
|
+
if any(needle in str(row.get(key, "")).lower() for key in searchable_keys)
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
def _paginate(self, data: list, page: int, page_size: int) -> tuple:
|
|
588
|
+
"""Paginate data list. Returns (page_rows, total, adjusted_page)."""
|
|
589
|
+
total = len(data)
|
|
590
|
+
total_pages = max(1, ceil(total / page_size))
|
|
591
|
+
page = min(max(1, page), total_pages)
|
|
592
|
+
start = (page - 1) * page_size
|
|
593
|
+
end = start + page_size
|
|
594
|
+
return data[start:end], total, page
|
|
595
|
+
|
|
596
|
+
def _handle_table(self, req):
|
|
597
|
+
"""Handle main table route."""
|
|
598
|
+
state = table_state_from_request(req, page_sizes=self.page_sizes)
|
|
599
|
+
search, page, page_size = state["search"], state["page"], state["page_size"]
|
|
600
|
+
|
|
601
|
+
data = self._get_filtered_data(req)
|
|
602
|
+
filtered = self._filter_by_search(data, search)
|
|
603
|
+
page_data, total, page = self._paginate(filtered, page, page_size)
|
|
604
|
+
|
|
605
|
+
table = DataTable(
|
|
606
|
+
data=page_data,
|
|
607
|
+
total=total,
|
|
608
|
+
page=page,
|
|
609
|
+
page_size=page_size,
|
|
610
|
+
search=search,
|
|
611
|
+
columns=self.columns,
|
|
612
|
+
crud_ops=self.crud_ops,
|
|
613
|
+
base_route=self.base_route,
|
|
614
|
+
row_id_field=self.row_id_field,
|
|
615
|
+
title=self.title,
|
|
616
|
+
container_id=self.container_id,
|
|
617
|
+
page_sizes=self.page_sizes,
|
|
618
|
+
search_placeholder=self.search_placeholder,
|
|
619
|
+
create_label=self.create_label,
|
|
620
|
+
empty_message=self.empty_message
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
# Wrap table in auto-refresh container
|
|
624
|
+
params = urlencode({"search": search, "page": page, "page_size": page_size})
|
|
625
|
+
table_container = Div(
|
|
626
|
+
table,
|
|
627
|
+
id=self.container_id,
|
|
628
|
+
hx_trigger=f"{self.refresh_trigger} from:body",
|
|
629
|
+
hx_get=f"{self.base_route}?{params}",
|
|
630
|
+
hx_target=f"#{self.container_id}",
|
|
631
|
+
hx_swap="outerHTML"
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
feedback = Div(id=self.feedback_id)
|
|
635
|
+
content = Div(feedback, table_container)
|
|
636
|
+
|
|
637
|
+
# Wrap with layout if full-page request
|
|
638
|
+
return self._wrap_response(content, req)
|
|
639
|
+
|
|
640
|
+
def _handle_action(self, req):
|
|
641
|
+
"""Handle action route (view/edit/create/delete)."""
|
|
642
|
+
params = getattr(req, "query_params", {})
|
|
643
|
+
getter = params.get if hasattr(params, "get") else (lambda k, d=None: params[k] if k in params else d)
|
|
644
|
+
|
|
645
|
+
if getter("dismiss") is not None:
|
|
646
|
+
return Div(id=self.feedback_id)
|
|
647
|
+
|
|
648
|
+
record_id = getter("id")
|
|
649
|
+
if record_id:
|
|
650
|
+
try:
|
|
651
|
+
record_id = int(record_id)
|
|
652
|
+
except (TypeError, ValueError):
|
|
653
|
+
pass
|
|
654
|
+
|
|
655
|
+
action = (getter("action", "view") or "view").lower()
|
|
656
|
+
search = getter("search", "") or ""
|
|
657
|
+
page = _safe_int(getter("page", 1), 1)
|
|
658
|
+
page_size = _safe_int(getter("page_size", 10), 10)
|
|
659
|
+
|
|
660
|
+
return_params = urlencode({"search": search, "page": page, "page_size": page_size})
|
|
661
|
+
cancel_url = f"{self.base_route}/action?dismiss=1"
|
|
662
|
+
save_url = f"{self.base_route}/save?{return_params}"
|
|
663
|
+
|
|
664
|
+
# Handle CREATE
|
|
665
|
+
if action == "create":
|
|
666
|
+
if not self.crud_ops.get("create"):
|
|
667
|
+
return self._error_toast("Create operation not enabled.")
|
|
668
|
+
|
|
669
|
+
modal = FormModal(
|
|
670
|
+
columns=self.columns,
|
|
671
|
+
mode="create",
|
|
672
|
+
record=None,
|
|
673
|
+
modal_id=self.modal_id,
|
|
674
|
+
title=f"New {self.title.rstrip('s')}",
|
|
675
|
+
save_url=save_url,
|
|
676
|
+
save_target=f"#{self.feedback_id}",
|
|
677
|
+
cancel_url=cancel_url,
|
|
678
|
+
cancel_target=f"#{self.feedback_id}"
|
|
679
|
+
)
|
|
680
|
+
return self._wrap_modal(modal)
|
|
681
|
+
|
|
682
|
+
# Get record for view/edit/delete
|
|
683
|
+
record = None
|
|
684
|
+
if record_id:
|
|
685
|
+
raw_record = self.get_by_id(req, record_id)
|
|
686
|
+
record = _to_dict(raw_record) if raw_record else None
|
|
687
|
+
|
|
688
|
+
if not record:
|
|
689
|
+
return self._error_toast("Record not found.")
|
|
690
|
+
|
|
691
|
+
# Handle VIEW
|
|
692
|
+
if action == "view":
|
|
693
|
+
modal = FormModal(
|
|
694
|
+
columns=self.columns,
|
|
695
|
+
mode="view",
|
|
696
|
+
record=record,
|
|
697
|
+
modal_id=self.modal_id,
|
|
698
|
+
title=f"View {self.title.rstrip('s')}",
|
|
699
|
+
cancel_url=cancel_url,
|
|
700
|
+
cancel_target=f"#{self.feedback_id}"
|
|
701
|
+
)
|
|
702
|
+
return self._wrap_modal(modal)
|
|
703
|
+
|
|
704
|
+
# Handle EDIT
|
|
705
|
+
if action == "edit":
|
|
706
|
+
if not self.crud_ops.get("update"):
|
|
707
|
+
return self._error_toast("Update operation not enabled.")
|
|
708
|
+
|
|
709
|
+
modal = FormModal(
|
|
710
|
+
columns=self.columns,
|
|
711
|
+
mode="edit",
|
|
712
|
+
record=record,
|
|
713
|
+
modal_id=self.modal_id,
|
|
714
|
+
title=f"Edit {self.title.rstrip('s')}",
|
|
715
|
+
save_url=save_url,
|
|
716
|
+
save_target=f"#{self.feedback_id}",
|
|
717
|
+
cancel_url=cancel_url,
|
|
718
|
+
cancel_target=f"#{self.feedback_id}"
|
|
719
|
+
)
|
|
720
|
+
return self._wrap_modal(modal)
|
|
721
|
+
|
|
722
|
+
# Handle DELETE
|
|
723
|
+
if action == "delete":
|
|
724
|
+
if not self.crud_ops.get("delete"):
|
|
725
|
+
return self._error_toast("Delete operation not enabled.")
|
|
726
|
+
|
|
727
|
+
try:
|
|
728
|
+
ctx = self._build_context(req, record=record, record_id=record_id)
|
|
729
|
+
|
|
730
|
+
# Use on_delete hook if provided
|
|
731
|
+
if self.on_delete_hook:
|
|
732
|
+
if asyncio.iscoroutinefunction(self.on_delete_hook):
|
|
733
|
+
loop = asyncio.get_event_loop()
|
|
734
|
+
loop.run_until_complete(self.on_delete_hook(ctx))
|
|
735
|
+
else:
|
|
736
|
+
self.on_delete_hook(ctx)
|
|
737
|
+
else:
|
|
738
|
+
# Default: use delete_fn
|
|
739
|
+
self.delete_fn(req, record_id)
|
|
740
|
+
|
|
741
|
+
return self._success_toast("Record deleted successfully.")
|
|
742
|
+
except Exception as e:
|
|
743
|
+
logger.error(f"Delete failed: {e}", exc_info=True)
|
|
744
|
+
return self._error_toast(f"Delete failed: {str(e)}")
|
|
745
|
+
|
|
746
|
+
return Div(id=self.feedback_id)
|
|
747
|
+
|
|
748
|
+
async def _handle_save(self, req):
|
|
749
|
+
"""Handle save route (create/update form submission)."""
|
|
750
|
+
try:
|
|
751
|
+
form_data = await req.form()
|
|
752
|
+
record_id = form_data.get(self.row_id_field)
|
|
753
|
+
|
|
754
|
+
if record_id:
|
|
755
|
+
try:
|
|
756
|
+
record_id = int(record_id)
|
|
757
|
+
except (TypeError, ValueError):
|
|
758
|
+
pass
|
|
759
|
+
|
|
760
|
+
# Build data dict from form
|
|
761
|
+
data = {}
|
|
762
|
+
for col in self.columns:
|
|
763
|
+
key = col["key"]
|
|
764
|
+
if key == self.row_id_field:
|
|
765
|
+
continue
|
|
766
|
+
|
|
767
|
+
form_cfg = col.get("form", {})
|
|
768
|
+
if form_cfg.get("hidden"):
|
|
769
|
+
continue
|
|
770
|
+
|
|
771
|
+
value = form_data.get(key)
|
|
772
|
+
|
|
773
|
+
field_type = form_cfg.get("type", "text")
|
|
774
|
+
if field_type == "number" and value:
|
|
775
|
+
try:
|
|
776
|
+
value = float(value) if "." in str(value) else int(value)
|
|
777
|
+
except (TypeError, ValueError):
|
|
778
|
+
pass
|
|
779
|
+
elif field_type == "checkbox":
|
|
780
|
+
value = value == "on" or value == "true" or value == True
|
|
781
|
+
|
|
782
|
+
data[key] = value
|
|
783
|
+
|
|
784
|
+
# Add timestamps
|
|
785
|
+
now = datetime.now()
|
|
786
|
+
for field, ts_type in self.timestamp_fields.items():
|
|
787
|
+
if ts_type == "updated":
|
|
788
|
+
data[field] = now
|
|
789
|
+
elif ts_type == "created" and not record_id:
|
|
790
|
+
data[field] = now
|
|
791
|
+
|
|
792
|
+
# CREATE or UPDATE
|
|
793
|
+
if record_id:
|
|
794
|
+
# ===== UPDATE =====
|
|
795
|
+
ctx = self._build_context(req, record=data, record_id=record_id)
|
|
796
|
+
|
|
797
|
+
if self.on_update_hook:
|
|
798
|
+
data = await self._call_hook(self.on_update_hook, ctx)
|
|
799
|
+
if data is not None:
|
|
800
|
+
self.update_fn(req, record_id, data)
|
|
801
|
+
else:
|
|
802
|
+
self.update_fn(req, record_id, data)
|
|
803
|
+
|
|
804
|
+
return self._success_toast("Record updated successfully.")
|
|
805
|
+
else:
|
|
806
|
+
# ===== CREATE =====
|
|
807
|
+
# Generate ID if needed
|
|
808
|
+
if self.id_generator and self.row_id_field not in data:
|
|
809
|
+
data[self.row_id_field] = self.id_generator()
|
|
810
|
+
|
|
811
|
+
ctx = self._build_context(req, record=data, record_id=None)
|
|
812
|
+
|
|
813
|
+
if self.on_create_hook:
|
|
814
|
+
data = await self._call_hook(self.on_create_hook, ctx)
|
|
815
|
+
if data is not None:
|
|
816
|
+
self.create_fn(req, data)
|
|
817
|
+
else:
|
|
818
|
+
self.create_fn(req, data)
|
|
819
|
+
|
|
820
|
+
return self._success_toast("Record created successfully.")
|
|
821
|
+
|
|
822
|
+
except Exception as e:
|
|
823
|
+
logger.error(f"Save failed: {e}", exc_info=True)
|
|
824
|
+
return self._error_toast(f"Save failed: {str(e)}")
|
|
825
|
+
|
|
826
|
+
def _wrap_modal(self, modal):
|
|
827
|
+
"""Wrap modal content in feedback div."""
|
|
828
|
+
if isinstance(modal, list):
|
|
829
|
+
return Div(*modal, id=self.feedback_id)
|
|
830
|
+
return Div(modal, id=self.feedback_id)
|
|
831
|
+
|
|
832
|
+
def _success_toast(self, message: str):
|
|
833
|
+
"""✅ Return a success toast with auto-refresh trigger."""
|
|
834
|
+
toast = Toast(
|
|
835
|
+
message,
|
|
836
|
+
variant="success",
|
|
837
|
+
position="top",
|
|
838
|
+
action=A(
|
|
839
|
+
"Dismiss",
|
|
840
|
+
cls="inverse-link",
|
|
841
|
+
hx_get=f"{self.base_route}/action?dismiss=1",
|
|
842
|
+
hx_target=f"#{self.feedback_id}",
|
|
843
|
+
hx_swap="outerHTML"
|
|
844
|
+
),
|
|
845
|
+
active=True
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
# Return as HTMLResponse with HX-Trigger for auto-refresh
|
|
849
|
+
from fasthtml.common import to_xml
|
|
850
|
+
html_content = to_xml(Div(toast, id=self.feedback_id))
|
|
851
|
+
response = HTMLResponse(content=html_content)
|
|
852
|
+
response.headers['HX-Trigger'] = self.refresh_trigger
|
|
853
|
+
return response
|
|
854
|
+
|
|
855
|
+
def _error_toast(self, message: str):
|
|
856
|
+
"""❌ Return an error toast (no auto-refresh on errors)."""
|
|
857
|
+
toast = Toast(
|
|
858
|
+
message,
|
|
859
|
+
variant="error",
|
|
860
|
+
position="top",
|
|
861
|
+
action=A(
|
|
862
|
+
"Dismiss",
|
|
863
|
+
cls="inverse-link",
|
|
864
|
+
hx_get=f"{self.base_route}/action?dismiss=1",
|
|
865
|
+
hx_target=f"#{self.feedback_id}",
|
|
866
|
+
hx_swap="outerHTML"
|
|
867
|
+
),
|
|
868
|
+
active=True
|
|
869
|
+
)
|
|
870
|
+
return Div(toast, id=self.feedback_id)
|