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/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)