fh-matui 0.9__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 +183 -0
- fh_matui/app_pages.py +291 -0
- fh_matui/components.py +1200 -0
- fh_matui/core.py +230 -0
- fh_matui/datatable.py +718 -0
- fh_matui/foundations.py +59 -0
- fh_matui/web_pages.py +852 -0
- fh_matui-0.9.dist-info/METADATA +101 -0
- fh_matui-0.9.dist-info/RECORD +14 -0
- fh_matui-0.9.dist-info/WHEEL +5 -0
- fh_matui-0.9.dist-info/entry_points.txt +2 -0
- fh_matui-0.9.dist-info/licenses/LICENSE +201 -0
- fh_matui-0.9.dist-info/top_level.txt +1 -0
fh_matui/datatable.py
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
"""Data table with pagination, search, sorting, and inline actions"""
|
|
2
|
+
|
|
3
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/05_table.ipynb.
|
|
4
|
+
|
|
5
|
+
# %% auto 0
|
|
6
|
+
__all__ = ['PAGE_SIZES', 'table_state_from_request', 'DataTable', 'DataTableResource']
|
|
7
|
+
|
|
8
|
+
# %% ../nbs/05_table.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_table.ipynb 6
|
|
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_table.ipynb 8
|
|
292
|
+
from typing import Callable, Optional, Any, Union
|
|
293
|
+
from dataclasses import asdict, is_dataclass
|
|
294
|
+
from datetime import datetime
|
|
295
|
+
import uuid
|
|
296
|
+
|
|
297
|
+
def _to_dict(obj: Any) -> dict:
|
|
298
|
+
"""Convert dataclass, ORM object, or dict to plain dict."""
|
|
299
|
+
if obj is None:
|
|
300
|
+
return {}
|
|
301
|
+
if isinstance(obj, dict):
|
|
302
|
+
return obj
|
|
303
|
+
if is_dataclass(obj):
|
|
304
|
+
return asdict(obj)
|
|
305
|
+
# ORM-style objects with __dict__
|
|
306
|
+
if hasattr(obj, "__dict__"):
|
|
307
|
+
return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
|
|
308
|
+
return dict(obj)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class DataTableResource:
|
|
312
|
+
"High-level resource that auto-registers all routes for a data table."
|
|
313
|
+
|
|
314
|
+
def __init__(
|
|
315
|
+
self,
|
|
316
|
+
app,
|
|
317
|
+
base_route: str,
|
|
318
|
+
columns: list[dict],
|
|
319
|
+
get_all: Callable[[], list],
|
|
320
|
+
get_by_id: Callable[[Any], Any],
|
|
321
|
+
create: Callable[[dict], Any] = None,
|
|
322
|
+
update: Callable[[Any, dict], Any] = None,
|
|
323
|
+
delete: Callable[[Any], bool] = None,
|
|
324
|
+
title: str = "Records",
|
|
325
|
+
row_id_field: str = "id",
|
|
326
|
+
crud_ops: dict = None,
|
|
327
|
+
page_sizes: list = None,
|
|
328
|
+
search_placeholder: str = "Search...",
|
|
329
|
+
create_label: str = "New Record",
|
|
330
|
+
empty_message: str = "No records found.",
|
|
331
|
+
# Lifecycle hooks
|
|
332
|
+
on_before_create: Callable[[dict], dict] = None,
|
|
333
|
+
on_after_create: Callable[[Any], None] = None,
|
|
334
|
+
on_before_update: Callable[[Any, dict], dict] = None,
|
|
335
|
+
on_after_update: Callable[[Any], None] = None,
|
|
336
|
+
on_before_delete: Callable[[Any], bool] = None,
|
|
337
|
+
on_after_delete: Callable[[Any], None] = None,
|
|
338
|
+
# Multi-tenant
|
|
339
|
+
user_filter: Callable = None,
|
|
340
|
+
# Custom generators
|
|
341
|
+
id_generator: Callable[[], Any] = None,
|
|
342
|
+
timestamp_fields: dict = None
|
|
343
|
+
):
|
|
344
|
+
self.app = app
|
|
345
|
+
self.base_route = base_route.rstrip("/")
|
|
346
|
+
self.columns = columns
|
|
347
|
+
self.get_all = get_all
|
|
348
|
+
self.get_by_id = get_by_id
|
|
349
|
+
self.create_fn = create
|
|
350
|
+
self.update_fn = update
|
|
351
|
+
self.delete_fn = delete
|
|
352
|
+
self.title = title
|
|
353
|
+
self.row_id_field = row_id_field
|
|
354
|
+
self.page_sizes = page_sizes or PAGE_SIZES
|
|
355
|
+
self.search_placeholder = search_placeholder
|
|
356
|
+
self.create_label = create_label
|
|
357
|
+
self.empty_message = empty_message
|
|
358
|
+
|
|
359
|
+
# Determine CRUD ops from provided functions
|
|
360
|
+
if crud_ops is None:
|
|
361
|
+
self.crud_ops = {
|
|
362
|
+
"create": create is not None,
|
|
363
|
+
"update": update is not None,
|
|
364
|
+
"delete": delete is not None
|
|
365
|
+
}
|
|
366
|
+
else:
|
|
367
|
+
self.crud_ops = crud_ops
|
|
368
|
+
|
|
369
|
+
# Lifecycle hooks
|
|
370
|
+
self.on_before_create = on_before_create
|
|
371
|
+
self.on_after_create = on_after_create
|
|
372
|
+
self.on_before_update = on_before_update
|
|
373
|
+
self.on_after_update = on_after_update
|
|
374
|
+
self.on_before_delete = on_before_delete
|
|
375
|
+
self.on_after_delete = on_after_delete
|
|
376
|
+
|
|
377
|
+
# Multi-tenant
|
|
378
|
+
self.user_filter = user_filter
|
|
379
|
+
|
|
380
|
+
# Generators
|
|
381
|
+
self.id_generator = id_generator
|
|
382
|
+
self.timestamp_fields = timestamp_fields or {}
|
|
383
|
+
|
|
384
|
+
# Derived IDs
|
|
385
|
+
self.container_id = f"crud-table-{base_route.replace('/', '-').strip('-')}"
|
|
386
|
+
self.feedback_id = f"{self.container_id}-feedback"
|
|
387
|
+
self.modal_id = f"{self.container_id}-modal"
|
|
388
|
+
|
|
389
|
+
# Register routes
|
|
390
|
+
self._register_routes()
|
|
391
|
+
|
|
392
|
+
def _register_routes(self):
|
|
393
|
+
"""Register all data table routes with the app."""
|
|
394
|
+
rt = self.app.route
|
|
395
|
+
|
|
396
|
+
# Main table route
|
|
397
|
+
@rt(self.base_route)
|
|
398
|
+
def _table_handler(req):
|
|
399
|
+
return self._handle_table(req)
|
|
400
|
+
|
|
401
|
+
# Action route (view/edit/create/delete)
|
|
402
|
+
@rt(f"{self.base_route}/action")
|
|
403
|
+
def _action_handler(req):
|
|
404
|
+
return self._handle_action(req)
|
|
405
|
+
|
|
406
|
+
# Save route (form submission)
|
|
407
|
+
@rt(f"{self.base_route}/save")
|
|
408
|
+
async def _save_handler(req):
|
|
409
|
+
return await self._handle_save(req)
|
|
410
|
+
|
|
411
|
+
def _get_filtered_data(self, req) -> list:
|
|
412
|
+
"""Get all data, optionally filtered by user_filter."""
|
|
413
|
+
all_data = self.get_all()
|
|
414
|
+
|
|
415
|
+
# Convert to list of dicts
|
|
416
|
+
data = [_to_dict(item) for item in all_data]
|
|
417
|
+
|
|
418
|
+
# Apply user filter if provided
|
|
419
|
+
if self.user_filter and callable(self.user_filter):
|
|
420
|
+
filter_criteria = self.user_filter(req)
|
|
421
|
+
if filter_criteria:
|
|
422
|
+
data = [
|
|
423
|
+
row for row in data
|
|
424
|
+
if all(row.get(k) == v for k, v in filter_criteria.items())
|
|
425
|
+
]
|
|
426
|
+
|
|
427
|
+
return data
|
|
428
|
+
|
|
429
|
+
def _filter_by_search(self, data: list, search: str) -> list:
|
|
430
|
+
"""Filter data by search term across searchable columns."""
|
|
431
|
+
if not search:
|
|
432
|
+
return data
|
|
433
|
+
|
|
434
|
+
needle = search.lower()
|
|
435
|
+
searchable_keys = [
|
|
436
|
+
col["key"] for col in self.columns
|
|
437
|
+
if col.get("searchable", False)
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
# If no columns marked searchable, search all
|
|
441
|
+
if not searchable_keys:
|
|
442
|
+
searchable_keys = [col["key"] for col in self.columns]
|
|
443
|
+
|
|
444
|
+
return [
|
|
445
|
+
row for row in data
|
|
446
|
+
if any(needle in str(row.get(key, "")).lower() for key in searchable_keys)
|
|
447
|
+
]
|
|
448
|
+
|
|
449
|
+
def _paginate(self, data: list, page: int, page_size: int) -> tuple:
|
|
450
|
+
"""Paginate data list. Returns (page_rows, total, adjusted_page)."""
|
|
451
|
+
total = len(data)
|
|
452
|
+
total_pages = max(1, ceil(total / page_size))
|
|
453
|
+
page = min(max(1, page), total_pages)
|
|
454
|
+
|
|
455
|
+
start = (page - 1) * page_size
|
|
456
|
+
end = start + page_size
|
|
457
|
+
|
|
458
|
+
return data[start:end], total, page
|
|
459
|
+
|
|
460
|
+
def _handle_table(self, req):
|
|
461
|
+
"""Handle main table route."""
|
|
462
|
+
# Extract pagination state
|
|
463
|
+
state = table_state_from_request(req, page_sizes=self.page_sizes)
|
|
464
|
+
search, page, page_size = state["search"], state["page"], state["page_size"]
|
|
465
|
+
|
|
466
|
+
# Get and filter data
|
|
467
|
+
data = self._get_filtered_data(req)
|
|
468
|
+
filtered = self._filter_by_search(data, search)
|
|
469
|
+
|
|
470
|
+
# Paginate
|
|
471
|
+
page_data, total, page = self._paginate(filtered, page, page_size)
|
|
472
|
+
|
|
473
|
+
# Build table
|
|
474
|
+
table = DataTable(
|
|
475
|
+
data=page_data,
|
|
476
|
+
total=total,
|
|
477
|
+
page=page,
|
|
478
|
+
page_size=page_size,
|
|
479
|
+
search=search,
|
|
480
|
+
columns=self.columns,
|
|
481
|
+
crud_ops=self.crud_ops,
|
|
482
|
+
base_route=self.base_route,
|
|
483
|
+
row_id_field=self.row_id_field,
|
|
484
|
+
title=self.title,
|
|
485
|
+
container_id=self.container_id,
|
|
486
|
+
page_sizes=self.page_sizes,
|
|
487
|
+
search_placeholder=self.search_placeholder,
|
|
488
|
+
create_label=self.create_label,
|
|
489
|
+
empty_message=self.empty_message
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
return table
|
|
493
|
+
|
|
494
|
+
def _handle_action(self, req):
|
|
495
|
+
"""Handle action route (view/edit/create/delete)."""
|
|
496
|
+
params = getattr(req, "query_params", {})
|
|
497
|
+
getter = params.get if hasattr(params, "get") else (lambda k, d=None: params[k] if k in params else d)
|
|
498
|
+
|
|
499
|
+
# Handle dismiss
|
|
500
|
+
if getter("dismiss") is not None:
|
|
501
|
+
return Div(id=self.feedback_id)
|
|
502
|
+
|
|
503
|
+
record_id = getter("id")
|
|
504
|
+
# Try to convert to int if numeric
|
|
505
|
+
if record_id:
|
|
506
|
+
try:
|
|
507
|
+
record_id = int(record_id)
|
|
508
|
+
except (TypeError, ValueError):
|
|
509
|
+
pass # Keep as string (e.g., UUID)
|
|
510
|
+
|
|
511
|
+
action = (getter("action", "view") or "view").lower()
|
|
512
|
+
search = getter("search", "") or ""
|
|
513
|
+
page = _safe_int(getter("page", 1), 1)
|
|
514
|
+
page_size = _safe_int(getter("page_size", 10), 10)
|
|
515
|
+
|
|
516
|
+
# URLs
|
|
517
|
+
return_params = urlencode({"search": search, "page": page, "page_size": page_size})
|
|
518
|
+
cancel_url = f"{self.base_route}/action?dismiss=1"
|
|
519
|
+
save_url = f"{self.base_route}/save?{return_params}"
|
|
520
|
+
|
|
521
|
+
# Handle CREATE
|
|
522
|
+
if action == "create":
|
|
523
|
+
if not self.crud_ops.get("create"):
|
|
524
|
+
return self._error_toast("Create operation not enabled.")
|
|
525
|
+
|
|
526
|
+
modal = FormModal(
|
|
527
|
+
columns=self.columns,
|
|
528
|
+
mode="create",
|
|
529
|
+
record=None,
|
|
530
|
+
modal_id=self.modal_id,
|
|
531
|
+
title=f"New {self.title.rstrip('s')}",
|
|
532
|
+
save_url=save_url,
|
|
533
|
+
save_target=f"#{self.feedback_id}",
|
|
534
|
+
cancel_url=cancel_url,
|
|
535
|
+
cancel_target=f"#{self.feedback_id}"
|
|
536
|
+
)
|
|
537
|
+
return self._wrap_modal(modal)
|
|
538
|
+
|
|
539
|
+
# Get record for view/edit/delete
|
|
540
|
+
record = None
|
|
541
|
+
if record_id:
|
|
542
|
+
raw_record = self.get_by_id(record_id)
|
|
543
|
+
record = _to_dict(raw_record) if raw_record else None
|
|
544
|
+
|
|
545
|
+
if not record:
|
|
546
|
+
return self._error_toast("Record not found.")
|
|
547
|
+
|
|
548
|
+
# Handle VIEW
|
|
549
|
+
if action == "view":
|
|
550
|
+
modal = FormModal(
|
|
551
|
+
columns=self.columns,
|
|
552
|
+
mode="view",
|
|
553
|
+
record=record,
|
|
554
|
+
modal_id=self.modal_id,
|
|
555
|
+
title=f"View {self.title.rstrip('s')}",
|
|
556
|
+
cancel_url=cancel_url,
|
|
557
|
+
cancel_target=f"#{self.feedback_id}"
|
|
558
|
+
)
|
|
559
|
+
return self._wrap_modal(modal)
|
|
560
|
+
|
|
561
|
+
# Handle EDIT
|
|
562
|
+
if action == "edit":
|
|
563
|
+
if not self.crud_ops.get("update"):
|
|
564
|
+
return self._error_toast("Update operation not enabled.")
|
|
565
|
+
|
|
566
|
+
modal = FormModal(
|
|
567
|
+
columns=self.columns,
|
|
568
|
+
mode="edit",
|
|
569
|
+
record=record,
|
|
570
|
+
modal_id=self.modal_id,
|
|
571
|
+
title=f"Edit {self.title.rstrip('s')}",
|
|
572
|
+
save_url=save_url,
|
|
573
|
+
save_target=f"#{self.feedback_id}",
|
|
574
|
+
cancel_url=cancel_url,
|
|
575
|
+
cancel_target=f"#{self.feedback_id}"
|
|
576
|
+
)
|
|
577
|
+
return self._wrap_modal(modal)
|
|
578
|
+
|
|
579
|
+
# Handle DELETE
|
|
580
|
+
if action == "delete":
|
|
581
|
+
if not self.crud_ops.get("delete"):
|
|
582
|
+
return self._error_toast("Delete operation not enabled.")
|
|
583
|
+
|
|
584
|
+
# Check before_delete hook
|
|
585
|
+
if self.on_before_delete:
|
|
586
|
+
if not self.on_before_delete(record_id):
|
|
587
|
+
return self._error_toast("Delete cancelled by validation.")
|
|
588
|
+
|
|
589
|
+
# Perform delete
|
|
590
|
+
try:
|
|
591
|
+
self.delete_fn(record_id)
|
|
592
|
+
|
|
593
|
+
# After delete hook
|
|
594
|
+
if self.on_after_delete:
|
|
595
|
+
self.on_after_delete(record_id)
|
|
596
|
+
|
|
597
|
+
return self._success_toast(f"Record deleted successfully.")
|
|
598
|
+
except Exception as e:
|
|
599
|
+
return self._error_toast(f"Delete failed: {str(e)}")
|
|
600
|
+
|
|
601
|
+
return Div(id=self.feedback_id)
|
|
602
|
+
|
|
603
|
+
async def _handle_save(self, req):
|
|
604
|
+
"""Handle save route (create/update form submission)."""
|
|
605
|
+
try:
|
|
606
|
+
form_data = await req.form()
|
|
607
|
+
record_id = form_data.get(self.row_id_field)
|
|
608
|
+
|
|
609
|
+
# Try to convert ID
|
|
610
|
+
if record_id:
|
|
611
|
+
try:
|
|
612
|
+
record_id = int(record_id)
|
|
613
|
+
except (TypeError, ValueError):
|
|
614
|
+
pass
|
|
615
|
+
|
|
616
|
+
# Build data dict from form, excluding the ID field
|
|
617
|
+
data = {}
|
|
618
|
+
for col in self.columns:
|
|
619
|
+
key = col["key"]
|
|
620
|
+
if key == self.row_id_field:
|
|
621
|
+
continue
|
|
622
|
+
|
|
623
|
+
form_cfg = col.get("form", {})
|
|
624
|
+
if form_cfg.get("hidden"):
|
|
625
|
+
continue
|
|
626
|
+
|
|
627
|
+
value = form_data.get(key)
|
|
628
|
+
|
|
629
|
+
# Type conversion based on form config
|
|
630
|
+
field_type = form_cfg.get("type", "text")
|
|
631
|
+
if field_type == "number" and value:
|
|
632
|
+
try:
|
|
633
|
+
value = float(value) if "." in str(value) else int(value)
|
|
634
|
+
except (TypeError, ValueError):
|
|
635
|
+
pass
|
|
636
|
+
elif field_type == "checkbox":
|
|
637
|
+
value = value == "on" or value == "true" or value == True
|
|
638
|
+
|
|
639
|
+
data[key] = value
|
|
640
|
+
|
|
641
|
+
# Add timestamps
|
|
642
|
+
now = datetime.now()
|
|
643
|
+
for field, ts_type in self.timestamp_fields.items():
|
|
644
|
+
if ts_type == "updated":
|
|
645
|
+
data[field] = now
|
|
646
|
+
elif ts_type == "created" and not record_id:
|
|
647
|
+
data[field] = now
|
|
648
|
+
|
|
649
|
+
# CREATE or UPDATE
|
|
650
|
+
if record_id:
|
|
651
|
+
# UPDATE
|
|
652
|
+
if self.on_before_update:
|
|
653
|
+
data = self.on_before_update(record_id, data)
|
|
654
|
+
|
|
655
|
+
result = self.update_fn(record_id, data)
|
|
656
|
+
|
|
657
|
+
if self.on_after_update:
|
|
658
|
+
self.on_after_update(result)
|
|
659
|
+
|
|
660
|
+
return self._success_toast("Record updated successfully.")
|
|
661
|
+
else:
|
|
662
|
+
# CREATE
|
|
663
|
+
if self.on_before_create:
|
|
664
|
+
data = self.on_before_create(data)
|
|
665
|
+
|
|
666
|
+
# Generate ID if needed
|
|
667
|
+
if self.id_generator:
|
|
668
|
+
data[self.row_id_field] = self.id_generator()
|
|
669
|
+
|
|
670
|
+
result = self.create_fn(data)
|
|
671
|
+
|
|
672
|
+
if self.on_after_create:
|
|
673
|
+
self.on_after_create(result)
|
|
674
|
+
|
|
675
|
+
return self._success_toast("Record created successfully.")
|
|
676
|
+
|
|
677
|
+
except Exception as e:
|
|
678
|
+
return self._error_toast(f"Save failed: {str(e)}")
|
|
679
|
+
|
|
680
|
+
def _wrap_modal(self, modal):
|
|
681
|
+
"""Wrap modal content in feedback div."""
|
|
682
|
+
if isinstance(modal, list):
|
|
683
|
+
return Div(*modal, id=self.feedback_id)
|
|
684
|
+
return Div(modal, id=self.feedback_id)
|
|
685
|
+
|
|
686
|
+
def _success_toast(self, message: str):
|
|
687
|
+
"""Return a success toast in the feedback div."""
|
|
688
|
+
toast = Toast(
|
|
689
|
+
message,
|
|
690
|
+
variant="success",
|
|
691
|
+
position="top",
|
|
692
|
+
action=A(
|
|
693
|
+
"Dismiss",
|
|
694
|
+
cls="inverse-link",
|
|
695
|
+
hx_get=f"{self.base_route}/action?dismiss=1",
|
|
696
|
+
hx_target=f"#{self.feedback_id}",
|
|
697
|
+
hx_swap="outerHTML"
|
|
698
|
+
),
|
|
699
|
+
active=True
|
|
700
|
+
)
|
|
701
|
+
return Div(toast, id=self.feedback_id)
|
|
702
|
+
|
|
703
|
+
def _error_toast(self, message: str):
|
|
704
|
+
"""Return an error toast in the feedback div."""
|
|
705
|
+
toast = Toast(
|
|
706
|
+
message,
|
|
707
|
+
variant="error",
|
|
708
|
+
position="top",
|
|
709
|
+
action=A(
|
|
710
|
+
"Dismiss",
|
|
711
|
+
cls="inverse-link",
|
|
712
|
+
hx_get=f"{self.base_route}/action?dismiss=1",
|
|
713
|
+
hx_target=f"#{self.feedback_id}",
|
|
714
|
+
hx_swap="outerHTML"
|
|
715
|
+
),
|
|
716
|
+
active=True
|
|
717
|
+
)
|
|
718
|
+
return Div(toast, id=self.feedback_id)
|