fh-matui 0.9.7__py3-none-any.whl → 0.9.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 -1
- fh_matui/datatable.py +186 -69
- {fh_matui-0.9.7.dist-info → fh_matui-0.9.9.dist-info}/METADATA +1 -1
- {fh_matui-0.9.7.dist-info → fh_matui-0.9.9.dist-info}/RECORD +8 -8
- {fh_matui-0.9.7.dist-info → fh_matui-0.9.9.dist-info}/WHEEL +0 -0
- {fh_matui-0.9.7.dist-info → fh_matui-0.9.9.dist-info}/entry_points.txt +0 -0
- {fh_matui-0.9.7.dist-info → fh_matui-0.9.9.dist-info}/licenses/LICENSE +0 -0
- {fh_matui-0.9.7.dist-info → fh_matui-0.9.9.dist-info}/top_level.txt +0 -0
fh_matui/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.9.
|
|
1
|
+
__version__ = "0.9.8"
|
fh_matui/datatable.py
CHANGED
|
@@ -22,10 +22,50 @@ from .components import *
|
|
|
22
22
|
from math import ceil
|
|
23
23
|
from urllib.parse import urlencode
|
|
24
24
|
from typing import Callable, Optional, Any
|
|
25
|
+
from dataclasses import asdict, is_dataclass
|
|
25
26
|
|
|
26
27
|
# Default page size options
|
|
27
28
|
PAGE_SIZES = [5, 10, 20, 50]
|
|
28
29
|
|
|
30
|
+
|
|
31
|
+
def _to_dict(obj: Any) -> dict:
|
|
32
|
+
"""
|
|
33
|
+
Convert any record type to dict.
|
|
34
|
+
|
|
35
|
+
Handles:
|
|
36
|
+
- dict -> pass through
|
|
37
|
+
- dataclass -> asdict()
|
|
38
|
+
- namedtuple -> _asdict()
|
|
39
|
+
- Pydantic model -> model_dump() or dict()
|
|
40
|
+
- ORM/object with __dict__ -> vars() filtered
|
|
41
|
+
- dict-like with keys -> dict()
|
|
42
|
+
- None -> {}
|
|
43
|
+
"""
|
|
44
|
+
if obj is None:
|
|
45
|
+
return {}
|
|
46
|
+
if isinstance(obj, dict):
|
|
47
|
+
return obj
|
|
48
|
+
# Dataclass
|
|
49
|
+
if is_dataclass(obj) and not isinstance(obj, type):
|
|
50
|
+
return asdict(obj)
|
|
51
|
+
# Namedtuple
|
|
52
|
+
if hasattr(obj, '_asdict'):
|
|
53
|
+
return obj._asdict()
|
|
54
|
+
# Pydantic v2
|
|
55
|
+
if hasattr(obj, 'model_dump'):
|
|
56
|
+
return obj.model_dump()
|
|
57
|
+
# Pydantic v1
|
|
58
|
+
if hasattr(obj, 'dict') and callable(getattr(obj, 'dict')):
|
|
59
|
+
return obj.dict()
|
|
60
|
+
# ORM-style objects with __dict__
|
|
61
|
+
if hasattr(obj, "__dict__"):
|
|
62
|
+
return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
|
|
63
|
+
# Dict-like
|
|
64
|
+
if hasattr(obj, 'keys'):
|
|
65
|
+
return dict(obj)
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
|
|
29
69
|
def _safe_int(value, default):
|
|
30
70
|
"""Safely convert to positive int or return default."""
|
|
31
71
|
try:
|
|
@@ -113,8 +153,9 @@ def _action_menu(
|
|
|
113
153
|
)
|
|
114
154
|
|
|
115
155
|
items = []
|
|
116
|
-
# View is
|
|
117
|
-
|
|
156
|
+
# View is conditional (read operation)
|
|
157
|
+
if crud_ops.get("view", True):
|
|
158
|
+
items.append(action_item("View", "visibility", "view"))
|
|
118
159
|
|
|
119
160
|
if crud_ops.get("update", False):
|
|
120
161
|
items.append(action_item("Edit", "edit", "edit"))
|
|
@@ -122,6 +163,20 @@ def _action_menu(
|
|
|
122
163
|
if crud_ops.get("delete", False):
|
|
123
164
|
items.append(action_item("Delete", "delete", "delete", confirm="Delete this record?"))
|
|
124
165
|
|
|
166
|
+
# Custom actions
|
|
167
|
+
for custom_action in crud_ops.get("custom_actions", []):
|
|
168
|
+
# Check per-row condition if provided
|
|
169
|
+
condition = custom_action.get("condition")
|
|
170
|
+
if condition and not condition(row):
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
items.append(action_item(
|
|
174
|
+
label=custom_action["label"],
|
|
175
|
+
icon=custom_action["icon"],
|
|
176
|
+
action=custom_action["name"],
|
|
177
|
+
confirm=custom_action.get("confirm")
|
|
178
|
+
))
|
|
179
|
+
|
|
125
180
|
return Div(
|
|
126
181
|
Button(
|
|
127
182
|
Icon("more_vert"),
|
|
@@ -158,6 +213,9 @@ def DataTable(
|
|
|
158
213
|
container_id = container_id or f"crud-table-{base_route.replace('/', '-').strip('-')}"
|
|
159
214
|
feedback_id = f"{container_id}-feedback"
|
|
160
215
|
|
|
216
|
+
# Auto-convert data records to dicts (supports dataclass, namedtuple, Pydantic, ORM)
|
|
217
|
+
data = [_to_dict(r) for r in data]
|
|
218
|
+
|
|
161
219
|
# Auto-generate columns from first data row if not provided
|
|
162
220
|
if columns is None and data:
|
|
163
221
|
columns = [{"key": k, "label": k.replace("_", " ").title()} for k in data[0].keys()]
|
|
@@ -206,87 +264,100 @@ def DataTable(
|
|
|
206
264
|
)
|
|
207
265
|
table_rows.append(row_dict)
|
|
208
266
|
|
|
209
|
-
#
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
267
|
+
# Create button
|
|
268
|
+
create_button = None
|
|
269
|
+
if crud_ops.get("create", False):
|
|
270
|
+
query = urlencode({"action": "create", "search": search, "page": page, "page_size": page_size})
|
|
271
|
+
create_button = Button(
|
|
272
|
+
Icon("add"),
|
|
273
|
+
create_label,
|
|
274
|
+
hx_get=f"{base_route}/action?{query}",
|
|
275
|
+
hx_target=f"#{feedback_id}",
|
|
276
|
+
hx_swap="outerHTML",
|
|
277
|
+
cls=ButtonT.primary
|
|
278
|
+
)
|
|
221
279
|
|
|
222
|
-
#
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
280
|
+
# Pagination controls
|
|
281
|
+
pagination = None
|
|
282
|
+
if total_pages > 1:
|
|
283
|
+
prev_params = urlencode({"search": search, "page": max(1, page - 1), "page_size": page_size})
|
|
284
|
+
next_params = urlencode({"search": search, "page": min(total_pages, page + 1), "page_size": page_size})
|
|
285
|
+
|
|
286
|
+
pagination = Div(
|
|
287
|
+
Button(
|
|
288
|
+
Icon("navigate_before"),
|
|
289
|
+
hx_get=f"{base_route}?{prev_params}",
|
|
231
290
|
hx_target=f"#{container_id}",
|
|
232
291
|
hx_push_url="true",
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
onfocus="this.parentElement.classList.add('max')",
|
|
236
|
-
onblur="if(!this.value) this.parentElement.classList.remove('max')"
|
|
292
|
+
disabled=page == 1,
|
|
293
|
+
cls="circle"
|
|
237
294
|
),
|
|
238
|
-
cls="
|
|
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(
|
|
295
|
+
Span(f"Page {page} of {total_pages}", cls="middle"),
|
|
246
296
|
Button(
|
|
247
|
-
Icon("
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
)
|
|
297
|
+
Icon("navigate_next"),
|
|
298
|
+
hx_get=f"{base_route}?{next_params}",
|
|
299
|
+
hx_target=f"#{container_id}",
|
|
300
|
+
hx_push_url="true",
|
|
301
|
+
disabled=page == total_pages,
|
|
302
|
+
cls="circle"
|
|
303
|
+
),
|
|
304
|
+
cls="row center-align"
|
|
254
305
|
)
|
|
255
306
|
|
|
256
|
-
#
|
|
257
|
-
|
|
258
|
-
Div(Span(summary, cls="small-text grey-text")),
|
|
307
|
+
# Search form
|
|
308
|
+
search_form = Form(
|
|
259
309
|
Div(
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
310
|
+
Icon("search", cls="small"),
|
|
311
|
+
Input(
|
|
312
|
+
type="search",
|
|
313
|
+
name="search",
|
|
314
|
+
value=search,
|
|
315
|
+
placeholder=search_placeholder,
|
|
316
|
+
hx_get=base_route,
|
|
317
|
+
hx_trigger="input changed delay:300ms, search",
|
|
318
|
+
hx_target=f"#{container_id}",
|
|
319
|
+
hx_push_url="true",
|
|
320
|
+
hx_include="[name='page_size']"
|
|
266
321
|
),
|
|
267
|
-
cls="
|
|
322
|
+
cls="field prefix"
|
|
268
323
|
),
|
|
269
|
-
|
|
324
|
+
Input(type="hidden", name="page_size", value=page_size),
|
|
325
|
+
cls="max"
|
|
270
326
|
)
|
|
271
327
|
|
|
272
|
-
#
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
328
|
+
# Build table with proper Thead/Tbody structure
|
|
329
|
+
data_table = Table(
|
|
330
|
+
Thead(Tr(*[Th(label) for label in header_labels])),
|
|
331
|
+
Tbody(*[
|
|
332
|
+
Tr(*[Td(row_dict.get(key, '')) for key in header_keys])
|
|
333
|
+
for row_dict in table_rows
|
|
334
|
+
]),
|
|
335
|
+
cls="border"
|
|
336
|
+
) if data else Div(empty_message, cls="center-align padding")
|
|
337
|
+
|
|
338
|
+
return Article(
|
|
339
|
+
Div(id=feedback_id), # Feedback container for modals/toasts
|
|
340
|
+
Div(
|
|
341
|
+
search_form,
|
|
342
|
+
create_button,
|
|
343
|
+
cls="row"
|
|
283
344
|
),
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
345
|
+
data_table,
|
|
346
|
+
Div(
|
|
347
|
+
Div(
|
|
348
|
+
_page_size_select(page_size, search, base_route, container_id, page_sizes),
|
|
349
|
+
Span(summary, cls="small-text grey-text"),
|
|
350
|
+
cls="row"
|
|
351
|
+
),
|
|
352
|
+
pagination,
|
|
353
|
+
cls="grid"
|
|
354
|
+
) if total > 0 else None,
|
|
355
|
+
id=container_id,
|
|
356
|
+
hx_trigger=f"{container_id}-refresh from:body",
|
|
357
|
+
hx_get=f"{base_route}?{base_query}",
|
|
358
|
+
hx_target=f"#{container_id}",
|
|
359
|
+
hx_swap="outerHTML"
|
|
287
360
|
)
|
|
288
|
-
|
|
289
|
-
return Div(card, Div(id=feedback_id), id=container_id)
|
|
290
361
|
|
|
291
362
|
# %% ../nbs/05_datatable.ipynb 9
|
|
292
363
|
import asyncio
|
|
@@ -479,6 +550,29 @@ class DataTableResource:
|
|
|
479
550
|
}
|
|
480
551
|
else:
|
|
481
552
|
self.crud_ops = crud_ops
|
|
553
|
+
|
|
554
|
+
# Validate custom_actions if provided
|
|
555
|
+
custom_actions = crud_ops.get("custom_actions", [])
|
|
556
|
+
if custom_actions:
|
|
557
|
+
reserved_names = {"view", "edit", "delete", "create"}
|
|
558
|
+
for action in custom_actions:
|
|
559
|
+
# Check required fields
|
|
560
|
+
if "name" not in action:
|
|
561
|
+
raise ValueError("Custom action missing required 'name' field")
|
|
562
|
+
if "label" not in action:
|
|
563
|
+
raise ValueError(f"Custom action '{action['name']}' missing required 'label' field")
|
|
564
|
+
if "icon" not in action:
|
|
565
|
+
raise ValueError(f"Custom action '{action['name']}' missing required 'icon' field")
|
|
566
|
+
if "handler" not in action:
|
|
567
|
+
raise ValueError(f"Custom action '{action['name']}' missing required 'handler' field")
|
|
568
|
+
|
|
569
|
+
# Check for reserved name collisions
|
|
570
|
+
if action["name"] in reserved_names:
|
|
571
|
+
raise ValueError(f"Custom action name '{action['name']}' conflicts with reserved action name")
|
|
572
|
+
|
|
573
|
+
# Validate handler is callable
|
|
574
|
+
if not callable(action["handler"]):
|
|
575
|
+
raise ValueError(f"Custom action '{action['name']}' handler must be callable")
|
|
482
576
|
|
|
483
577
|
# CRUD hooks
|
|
484
578
|
self.on_create_hook = on_create
|
|
@@ -743,6 +837,29 @@ class DataTableResource:
|
|
|
743
837
|
logger.error(f"Delete failed: {e}", exc_info=True)
|
|
744
838
|
return self._error_toast(f"Delete failed: {str(e)}")
|
|
745
839
|
|
|
840
|
+
# Handle CUSTOM ACTIONS
|
|
841
|
+
custom_actions = self.crud_ops.get("custom_actions", [])
|
|
842
|
+
for custom_action in custom_actions:
|
|
843
|
+
if action == custom_action["name"]:
|
|
844
|
+
try:
|
|
845
|
+
ctx = self._build_context(req, record=record, record_id=record_id)
|
|
846
|
+
handler = custom_action["handler"]
|
|
847
|
+
|
|
848
|
+
# Execute handler (sync or async)
|
|
849
|
+
if asyncio.iscoroutinefunction(handler):
|
|
850
|
+
loop = asyncio.get_event_loop()
|
|
851
|
+
result = loop.run_until_complete(handler(ctx))
|
|
852
|
+
else:
|
|
853
|
+
result = handler(ctx)
|
|
854
|
+
|
|
855
|
+
# Return success toast with refresh trigger
|
|
856
|
+
message = result if isinstance(result, str) else f"{custom_action['label']} completed successfully."
|
|
857
|
+
return self._success_toast(message)
|
|
858
|
+
|
|
859
|
+
except Exception as e:
|
|
860
|
+
logger.error(f"Custom action '{action}' failed: {e}", exc_info=True)
|
|
861
|
+
return self._error_toast(f"{custom_action['label']} failed: {str(e)}")
|
|
862
|
+
|
|
746
863
|
return Div(id=self.feedback_id)
|
|
747
864
|
|
|
748
865
|
async def _handle_save(self, req):
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
fh_matui/__init__.py,sha256=
|
|
1
|
+
fh_matui/__init__.py,sha256=4eQCGJV2-GRHX8FlvA_4etK0E4LFj2or592J4aePZoA,23
|
|
2
2
|
fh_matui/_modidx.py,sha256=naHwPQ4kCo-5saE_uozmdPA881kp1gnqvKhmaG-Ya-4,23914
|
|
3
3
|
fh_matui/app_pages.py,sha256=Sn9-tgBpaPNbR-0nZtPLoSCmAWLOGB4UQ88IkFvzBRY,10361
|
|
4
4
|
fh_matui/components.py,sha256=KjdTHzWRXpVWBEIGskW1HfhjPpzRYzi6UA_yRjZyMWM,48254
|
|
5
5
|
fh_matui/core.py,sha256=xtVBN8CtC50ZJ4Iu7o-mUhaA87tWdnz8gBfKRk63Zhs,10680
|
|
6
|
-
fh_matui/datatable.py,sha256=
|
|
6
|
+
fh_matui/datatable.py,sha256=NNb6WvGZ0QY13md4itwYH4a9yKZrOdwcaDW7PGo-vQM,36558
|
|
7
7
|
fh_matui/foundations.py,sha256=b7PnObJpKN8ZAU9NzCm9xpfnHzFjjAROU7E2YvA_tj4,1820
|
|
8
8
|
fh_matui/web_pages.py,sha256=4mF-jpfVcZTVepfQ-aMGgIUp-nBp0YCkvcdsWhUYeaA,34879
|
|
9
|
-
fh_matui-0.9.
|
|
10
|
-
fh_matui-0.9.
|
|
11
|
-
fh_matui-0.9.
|
|
12
|
-
fh_matui-0.9.
|
|
13
|
-
fh_matui-0.9.
|
|
14
|
-
fh_matui-0.9.
|
|
9
|
+
fh_matui-0.9.9.dist-info/licenses/LICENSE,sha256=xV8xoN4VOL0uw9X8RSs2IMuD_Ss_a9yAbtGNeBWZwnw,11337
|
|
10
|
+
fh_matui-0.9.9.dist-info/METADATA,sha256=OGoxCFS3_qRnEJe2EkD-dMDl21lhYt5ecds1B6f5xpU,10490
|
|
11
|
+
fh_matui-0.9.9.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
12
|
+
fh_matui-0.9.9.dist-info/entry_points.txt,sha256=zn4CR4gNTiAAxbFsCxHAf2tQhtW29_YOffjbUTgeoWI,38
|
|
13
|
+
fh_matui-0.9.9.dist-info/top_level.txt,sha256=l80d5eoA2ZjqtPYwAorLMS5PiHxUxz3zKzxMJ41Xoso,9
|
|
14
|
+
fh_matui-0.9.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|