fh-matui 0.9.8__tar.gz → 0.9.10__tar.gz
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-0.9.8/fh_matui.egg-info → fh_matui-0.9.10}/PKG-INFO +1 -1
- fh_matui-0.9.10/fh_matui/__init__.py +1 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/components.py +60 -22
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/datatable.py +164 -54
- {fh_matui-0.9.8 → fh_matui-0.9.10/fh_matui.egg-info}/PKG-INFO +1 -1
- {fh_matui-0.9.8 → fh_matui-0.9.10}/settings.ini +1 -1
- fh_matui-0.9.8/fh_matui/__init__.py +0 -1
- {fh_matui-0.9.8 → fh_matui-0.9.10}/LICENSE +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/MANIFEST.in +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/README.md +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/_modidx.py +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/app_pages.py +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/core.py +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/foundations.py +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/web_pages.py +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui.egg-info/SOURCES.txt +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui.egg-info/dependency_links.txt +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui.egg-info/entry_points.txt +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui.egg-info/not-zip-safe +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui.egg-info/requires.txt +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui.egg-info/top_level.txt +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/pyproject.toml +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/setup.cfg +0 -0
- {fh_matui-0.9.8 → fh_matui-0.9.10}/setup.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.9.9"
|
|
@@ -331,9 +331,14 @@ def NavBar(*children, brand=None, sticky=False, cls='', **kwargs):
|
|
|
331
331
|
return Nav(*children, cls=f"padding {nav_cls}", **kwargs)
|
|
332
332
|
|
|
333
333
|
# %% ../nbs/02_components.ipynb 35
|
|
334
|
-
def Modal(*c, id=None, footer=None, active=False, overlay=
|
|
335
|
-
"""BeerCSS modal dialog with
|
|
334
|
+
def Modal(*c, id=None, footer=None, active=False, overlay='default', position=None, cls=(), **kwargs):
|
|
335
|
+
"""BeerCSS modal dialog with position and overlay options."""
|
|
336
336
|
modal_cls = normalize_tokens(cls)
|
|
337
|
+
|
|
338
|
+
# Add position class if specified
|
|
339
|
+
if position in ['left', 'right', 'top', 'bottom']:
|
|
340
|
+
modal_cls.append(position)
|
|
341
|
+
|
|
337
342
|
if active:
|
|
338
343
|
modal_cls.append('active')
|
|
339
344
|
|
|
@@ -349,9 +354,22 @@ def Modal(*c, id=None, footer=None, active=False, overlay=True, cls=(), **kwargs
|
|
|
349
354
|
# Create the dialog
|
|
350
355
|
dialog = Dialog(*children, id=id, cls=cls_str, **kwargs)
|
|
351
356
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
357
|
+
# Handle overlay
|
|
358
|
+
if overlay and overlay not in [False, None]:
|
|
359
|
+
overlay_classes = ['overlay']
|
|
360
|
+
|
|
361
|
+
# Map overlay parameter to BeerCSS classes
|
|
362
|
+
if overlay == 'blur':
|
|
363
|
+
overlay_classes.append('blur')
|
|
364
|
+
elif overlay == 'small-blur':
|
|
365
|
+
overlay_classes.append('small-blur')
|
|
366
|
+
elif overlay == 'medium-blur':
|
|
367
|
+
overlay_classes.append('medium-blur')
|
|
368
|
+
elif overlay == 'large-blur':
|
|
369
|
+
overlay_classes.append('large-blur')
|
|
370
|
+
# 'default' means plain overlay with no blur class
|
|
371
|
+
|
|
372
|
+
overlay_cls = ' '.join(overlay_classes)
|
|
355
373
|
if active:
|
|
356
374
|
overlay_cls += " active"
|
|
357
375
|
|
|
@@ -717,15 +735,14 @@ def Toolbar(*items, cls='', elevate='large', fill=True, **kwargs):
|
|
|
717
735
|
if cls: classes.append(cls)
|
|
718
736
|
return Nav(*items, cls=' '.join(classes), **kwargs)
|
|
719
737
|
|
|
720
|
-
# %% ../nbs/02_components.ipynb
|
|
738
|
+
# %% ../nbs/02_components.ipynb 93
|
|
721
739
|
#| code-fold: true
|
|
722
|
-
def Toast(*c, cls='', position='top', variant='', action=None,
|
|
723
|
-
"""BeerCSS snackbar/toast notification with position and variant options."""
|
|
740
|
+
def Toast(*c, cls='', position='top', variant='', action=None, active=False, dur=None, **kwargs):
|
|
741
|
+
"""BeerCSS snackbar/toast notification with position ('top' or 'bottom') and variant options."""
|
|
724
742
|
classes = ['snackbar']
|
|
725
743
|
if variant: classes.append(variant)
|
|
726
|
-
if position:
|
|
727
|
-
|
|
728
|
-
classes.append(position_map.get(position, position))
|
|
744
|
+
if position in ['top', 'bottom']:
|
|
745
|
+
classes.append(position)
|
|
729
746
|
if active: classes.append('active')
|
|
730
747
|
if cls: classes.append(cls)
|
|
731
748
|
|
|
@@ -735,13 +752,34 @@ def Toast(*c, cls='', position='top', variant='', action=None, dur=5.0, active=F
|
|
|
735
752
|
if isinstance(action, str): content.append(A(action, cls='inverse-link'))
|
|
736
753
|
else: content.append(action)
|
|
737
754
|
else: content.extend(c)
|
|
738
|
-
|
|
755
|
+
|
|
756
|
+
snackbar = Div(*content, cls=' '.join(classes), **kwargs)
|
|
757
|
+
|
|
758
|
+
# If duration is specified and there's an id, generate auto-hide script
|
|
759
|
+
if dur and 'id' in kwargs:
|
|
760
|
+
timeout_ms = int(dur * 1000)
|
|
761
|
+
toast_id = kwargs['id']
|
|
762
|
+
# Wait for Beer CSS module to load before calling ui()
|
|
763
|
+
script = Script(f"""
|
|
764
|
+
window.addEventListener('load', function() {{
|
|
765
|
+
function showToast() {{
|
|
766
|
+
if (typeof ui !== 'undefined') {{
|
|
767
|
+
try {{ ui('#{toast_id}', {timeout_ms}); }}
|
|
768
|
+
catch (e) {{ console.error('Toast error:', e.message); }}
|
|
769
|
+
}}
|
|
770
|
+
}}
|
|
771
|
+
setTimeout(showToast, 50);
|
|
772
|
+
}});
|
|
773
|
+
""")
|
|
774
|
+
return (snackbar, script)
|
|
775
|
+
|
|
776
|
+
return snackbar
|
|
739
777
|
|
|
740
778
|
def Snackbar(*c, **kwargs):
|
|
741
779
|
"""Alias for Toast component."""
|
|
742
780
|
return Toast(*c, **kwargs)
|
|
743
781
|
|
|
744
|
-
# %% ../nbs/02_components.ipynb
|
|
782
|
+
# %% ../nbs/02_components.ipynb 96
|
|
745
783
|
#| code-fold: true
|
|
746
784
|
class ContainerT(VEnum):
|
|
747
785
|
"""Container size options (BeerCSS). Most alias to 'responsive'; use 'expand' for full-width."""
|
|
@@ -752,7 +790,7 @@ class ContainerT(VEnum):
|
|
|
752
790
|
xl = 'responsive'
|
|
753
791
|
expand = 'responsive max'
|
|
754
792
|
|
|
755
|
-
# %% ../nbs/02_components.ipynb
|
|
793
|
+
# %% ../nbs/02_components.ipynb 98
|
|
756
794
|
#| code-fold: true
|
|
757
795
|
def _get_form_config(col: dict) -> dict:
|
|
758
796
|
"""Extract form config from column, with sensible defaults."""
|
|
@@ -828,7 +866,7 @@ def FormField(
|
|
|
828
866
|
**attrs
|
|
829
867
|
)
|
|
830
868
|
|
|
831
|
-
# %% ../nbs/02_components.ipynb
|
|
869
|
+
# %% ../nbs/02_components.ipynb 100
|
|
832
870
|
#| code-fold: true
|
|
833
871
|
from typing import Callable, Any
|
|
834
872
|
|
|
@@ -947,7 +985,7 @@ def FormModal(
|
|
|
947
985
|
cls="large-width"
|
|
948
986
|
)
|
|
949
987
|
|
|
950
|
-
# %% ../nbs/02_components.ipynb
|
|
988
|
+
# %% ../nbs/02_components.ipynb 102
|
|
951
989
|
#| code-fold: true
|
|
952
990
|
def NavContainer(*li, title=None, brand=None, position='left', close_button=True, cls='active', id=None, **kwargs):
|
|
953
991
|
"""Slide-out navigation drawer with header and close button."""
|
|
@@ -1004,7 +1042,7 @@ def BottomNav(*c, cls='bottom', size='s', **kwargs):
|
|
|
1004
1042
|
final_cls = f"{cls} {size_cls}".strip()
|
|
1005
1043
|
return Nav(*c, cls=final_cls, **kwargs)
|
|
1006
1044
|
|
|
1007
|
-
# %% ../nbs/02_components.ipynb
|
|
1045
|
+
# %% ../nbs/02_components.ipynb 105
|
|
1008
1046
|
#| code-fold: true
|
|
1009
1047
|
def NavSideBarHeader(*c, cls='', **kwargs):
|
|
1010
1048
|
"""Sidebar header section for menu buttons and branding."""
|
|
@@ -1024,7 +1062,7 @@ def NavSideBarContainer(*children, position='left', size='m', cls='', active=Fal
|
|
|
1024
1062
|
nav_cls = f"{base_cls} {cls}".strip()
|
|
1025
1063
|
return Nav(*children, cls=nav_cls, **kwargs)
|
|
1026
1064
|
|
|
1027
|
-
# %% ../nbs/02_components.ipynb
|
|
1065
|
+
# %% ../nbs/02_components.ipynb 107
|
|
1028
1066
|
#| code-fold: true
|
|
1029
1067
|
def Layout(*content, sidebar=None, sidebar_links=None, nav_bar=None, container_size=ContainerT.expand,
|
|
1030
1068
|
main_bg='surface', sidebar_id='app-sidebar', cls='', **kwargs):
|
|
@@ -1069,7 +1107,7 @@ def Layout(*content, sidebar=None, sidebar_links=None, nav_bar=None, container_s
|
|
|
1069
1107
|
final_cls = f"surface-container {cls}".strip() if cls else "surface-container"
|
|
1070
1108
|
return Div(*layout_children, cls=final_cls, **kwargs)
|
|
1071
1109
|
|
|
1072
|
-
# %% ../nbs/02_components.ipynb
|
|
1110
|
+
# %% ../nbs/02_components.ipynb 111
|
|
1073
1111
|
#| code-fold: true
|
|
1074
1112
|
class TextT(VEnum):
|
|
1075
1113
|
"""Text styles using BeerCSS typography classes."""
|
|
@@ -1101,7 +1139,7 @@ class TextPresets(VEnum):
|
|
|
1101
1139
|
primary_link = 'link primary-text'
|
|
1102
1140
|
muted_link = 'link secondary-text'
|
|
1103
1141
|
|
|
1104
|
-
# %% ../nbs/02_components.ipynb
|
|
1142
|
+
# %% ../nbs/02_components.ipynb 112
|
|
1105
1143
|
#| code-fold: true
|
|
1106
1144
|
def CodeSpan(*c, cls=(), **kwargs):
|
|
1107
1145
|
"""Inline code snippet."""
|
|
@@ -1157,7 +1195,7 @@ def Sup(*c, cls=(), **kwargs):
|
|
|
1157
1195
|
cls_str = stringify(cls) if cls else None
|
|
1158
1196
|
return fc.Sup(*c, cls=cls_str, **kwargs) if cls_str else fc.Sup(*c, **kwargs)
|
|
1159
1197
|
|
|
1160
|
-
# %% ../nbs/02_components.ipynb
|
|
1198
|
+
# %% ../nbs/02_components.ipynb 114
|
|
1161
1199
|
#| code-fold: true
|
|
1162
1200
|
def FAQItem(question: str, answer: str, question_cls: str = '', answer_cls: str = ''):
|
|
1163
1201
|
"""Collapsible FAQ item using details/summary.
|
|
@@ -1175,7 +1213,7 @@ def FAQItem(question: str, answer: str, question_cls: str = '', answer_cls: str
|
|
|
1175
1213
|
Summary(Article(Nav(Div(question, cls=f"max bold {question_cls}".strip()), I("expand_more")), cls="round surface-variant border no-elevate")),
|
|
1176
1214
|
Article(P(answer, cls=f"secondary-text {answer_cls}".strip()), cls="round border padding"))
|
|
1177
1215
|
|
|
1178
|
-
# %% ../nbs/02_components.ipynb
|
|
1216
|
+
# %% ../nbs/02_components.ipynb 118
|
|
1179
1217
|
#| code-fold: true
|
|
1180
1218
|
def CookiesBanner(message='We use cookies to enhance your experience. By continuing to visit this site you agree to our use of cookies.',
|
|
1181
1219
|
accept_text='Accept', decline_text='Decline', settings_text=None, policy_link='/cookies', policy_text='Learn more',
|
|
@@ -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:
|
|
@@ -83,6 +123,7 @@ def _action_menu(
|
|
|
83
123
|
row: dict,
|
|
84
124
|
row_id: Any,
|
|
85
125
|
crud_ops: dict,
|
|
126
|
+
crud_enabled: dict,
|
|
86
127
|
base_route: str,
|
|
87
128
|
search: str,
|
|
88
129
|
page: int,
|
|
@@ -113,14 +154,14 @@ def _action_menu(
|
|
|
113
154
|
)
|
|
114
155
|
|
|
115
156
|
items = []
|
|
116
|
-
# View is conditional (read operation)
|
|
117
|
-
if
|
|
157
|
+
# View is conditional (read operation) - use crud_enabled for callable support
|
|
158
|
+
if crud_enabled.get("view", True):
|
|
118
159
|
items.append(action_item("View", "visibility", "view"))
|
|
119
160
|
|
|
120
|
-
if
|
|
161
|
+
if crud_enabled.get("update", False):
|
|
121
162
|
items.append(action_item("Edit", "edit", "edit"))
|
|
122
163
|
|
|
123
|
-
if
|
|
164
|
+
if crud_enabled.get("delete", False):
|
|
124
165
|
items.append(action_item("Delete", "delete", "delete", confirm="Delete this record?"))
|
|
125
166
|
|
|
126
167
|
# Custom actions
|
|
@@ -157,6 +198,7 @@ def DataTable(
|
|
|
157
198
|
search: str = '',
|
|
158
199
|
columns: list[dict] = None,
|
|
159
200
|
crud_ops: dict = None,
|
|
201
|
+
crud_enabled: dict = None,
|
|
160
202
|
base_route: str = '',
|
|
161
203
|
row_id_field: str = 'id',
|
|
162
204
|
title: str = 'Records',
|
|
@@ -169,10 +211,14 @@ def DataTable(
|
|
|
169
211
|
"Generic data table with server-side pagination, search, and row actions."
|
|
170
212
|
# Defaults
|
|
171
213
|
crud_ops = crud_ops or {"create": False, "update": False, "delete": False}
|
|
214
|
+
crud_enabled = crud_enabled or {k: bool(v) for k, v in crud_ops.items() if k != 'custom_actions'}
|
|
172
215
|
page_sizes = page_sizes or PAGE_SIZES
|
|
173
216
|
container_id = container_id or f"crud-table-{base_route.replace('/', '-').strip('-')}"
|
|
174
217
|
feedback_id = f"{container_id}-feedback"
|
|
175
218
|
|
|
219
|
+
# Auto-convert data records to dicts (supports dataclass, namedtuple, Pydantic, ORM)
|
|
220
|
+
data = [_to_dict(r) for r in data]
|
|
221
|
+
|
|
176
222
|
# Auto-generate columns from first data row if not provided
|
|
177
223
|
if columns is None and data:
|
|
178
224
|
columns = [{"key": k, "label": k.replace("_", " ").title()} for k in data[0].keys()]
|
|
@@ -184,7 +230,6 @@ def DataTable(
|
|
|
184
230
|
start_index = (page - 1) * page_size + 1 if total else 0
|
|
185
231
|
end_index = min(start_index + page_size - 1, total) if total else 0
|
|
186
232
|
summary = f"{start_index}-{end_index} of {total} records" if total else "No matching records"
|
|
187
|
-
|
|
188
233
|
base_query = urlencode({"search": search, "page_size": page_size})
|
|
189
234
|
|
|
190
235
|
# Build table header keys and labels
|
|
@@ -212,6 +257,7 @@ def DataTable(
|
|
|
212
257
|
row=row,
|
|
213
258
|
row_id=row_id,
|
|
214
259
|
crud_ops=crud_ops,
|
|
260
|
+
crud_enabled=crud_enabled,
|
|
215
261
|
base_route=base_route,
|
|
216
262
|
search=search,
|
|
217
263
|
page=page,
|
|
@@ -221,9 +267,9 @@ def DataTable(
|
|
|
221
267
|
)
|
|
222
268
|
table_rows.append(row_dict)
|
|
223
269
|
|
|
224
|
-
# Create button
|
|
270
|
+
# Create button - use crud_enabled for callable support
|
|
225
271
|
create_button = None
|
|
226
|
-
if
|
|
272
|
+
if crud_enabled.get("create", False):
|
|
227
273
|
query = urlencode({"action": "create", "search": search, "page": page, "page_size": page_size})
|
|
228
274
|
create_button = Button(
|
|
229
275
|
Icon("add"),
|
|
@@ -279,31 +325,46 @@ def DataTable(
|
|
|
279
325
|
cls="field prefix"
|
|
280
326
|
),
|
|
281
327
|
Input(type="hidden", name="page_size", value=page_size),
|
|
282
|
-
cls="
|
|
328
|
+
cls="max"
|
|
283
329
|
)
|
|
284
330
|
|
|
331
|
+
# Build table with proper Thead/Tbody structure
|
|
332
|
+
data_table = Table(
|
|
333
|
+
Thead(Tr(*[Th(label) for label in header_labels])),
|
|
334
|
+
Tbody(*[
|
|
335
|
+
Tr(*[Td(row_dict.get(key, '')) for key in header_keys])
|
|
336
|
+
for row_dict in table_rows
|
|
337
|
+
]),
|
|
338
|
+
cls="border"
|
|
339
|
+
) if data else Div(empty_message, cls="center-align padding")
|
|
340
|
+
|
|
341
|
+
# Footer section with page size selector and pagination
|
|
342
|
+
footer = None
|
|
343
|
+
if total > 0:
|
|
344
|
+
page_info_row = Div(
|
|
345
|
+
_page_size_select(page_size, search, base_route, container_id, page_sizes),
|
|
346
|
+
Span(summary, cls="small-text grey-text"),
|
|
347
|
+
cls="row"
|
|
348
|
+
)
|
|
349
|
+
if pagination:
|
|
350
|
+
# Two-row layout: info on top, centered pagination below
|
|
351
|
+
footer = Div(
|
|
352
|
+
page_info_row,
|
|
353
|
+
Div(pagination, cls="row center-align"),
|
|
354
|
+
)
|
|
355
|
+
else:
|
|
356
|
+
# Single row when no pagination needed
|
|
357
|
+
footer = page_info_row
|
|
358
|
+
|
|
285
359
|
return Article(
|
|
286
360
|
Div(id=feedback_id), # Feedback container for modals/toasts
|
|
287
361
|
Div(
|
|
288
362
|
search_form,
|
|
289
363
|
create_button,
|
|
290
|
-
cls="
|
|
364
|
+
cls="row"
|
|
291
365
|
),
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
headers=header_labels,
|
|
295
|
-
keys=header_keys,
|
|
296
|
-
cls="border"
|
|
297
|
-
) if data else Div(empty_message, cls="center-align padding"),
|
|
298
|
-
Div(
|
|
299
|
-
Div(
|
|
300
|
-
_page_size_select(page_size, search, base_route, container_id, page_sizes),
|
|
301
|
-
Span(summary, cls="small-text grey-text"),
|
|
302
|
-
cls="row"
|
|
303
|
-
),
|
|
304
|
-
pagination,
|
|
305
|
-
cls="grid"
|
|
306
|
-
) if total > 0 else None,
|
|
366
|
+
data_table,
|
|
367
|
+
footer,
|
|
307
368
|
id=container_id,
|
|
308
369
|
hx_trigger=f"{container_id}-refresh from:body",
|
|
309
370
|
hx_get=f"{base_route}?{base_query}",
|
|
@@ -405,6 +466,7 @@ class CrudContext:
|
|
|
405
466
|
tbl: Optional[Any] = None # request.state.tables[table_name] (if available)
|
|
406
467
|
record: dict = None # Form data dict
|
|
407
468
|
record_id: Optional[Any] = None # ID for update/delete (None for create)
|
|
469
|
+
feedback_id: Optional[str] = None # Target div ID for HTMX swap (for override handlers)
|
|
408
470
|
|
|
409
471
|
# %% ../nbs/05_datatable.ipynb 13
|
|
410
472
|
from typing import Callable, Optional, Any, Union
|
|
@@ -526,6 +588,20 @@ class DataTableResource:
|
|
|
526
588
|
if not callable(action["handler"]):
|
|
527
589
|
raise ValueError(f"Custom action '{action['name']}' handler must be callable")
|
|
528
590
|
|
|
591
|
+
# Parse crud_ops for callable overrides vs boolean enable/disable
|
|
592
|
+
# Callable = override handler (action is enabled)
|
|
593
|
+
# True = use default handler (action is enabled)
|
|
594
|
+
# False = action is disabled
|
|
595
|
+
self.crud_overrides = {}
|
|
596
|
+
self.crud_enabled = {}
|
|
597
|
+
for action_name in ['create', 'update', 'delete', 'view']:
|
|
598
|
+
value = self.crud_ops.get(action_name, action_name == 'view') # view defaults to True
|
|
599
|
+
if callable(value):
|
|
600
|
+
self.crud_overrides[action_name] = value
|
|
601
|
+
self.crud_enabled[action_name] = True # Callable means action is enabled
|
|
602
|
+
else:
|
|
603
|
+
self.crud_enabled[action_name] = bool(value)
|
|
604
|
+
|
|
529
605
|
# CRUD hooks
|
|
530
606
|
self.on_create_hook = on_create
|
|
531
607
|
self.on_update_hook = on_update
|
|
@@ -555,7 +631,7 @@ class DataTableResource:
|
|
|
555
631
|
return await hook(ctx)
|
|
556
632
|
return hook(ctx)
|
|
557
633
|
|
|
558
|
-
def _build_context(self, req, record: dict = None, record_id: Any = None) -> CrudContext:
|
|
634
|
+
def _build_context(self, req, record: dict = None, record_id: Any = None, include_feedback_id: bool = False) -> CrudContext:
|
|
559
635
|
"""🏗️ Build CrudContext from request."""
|
|
560
636
|
user = None
|
|
561
637
|
db = None
|
|
@@ -581,7 +657,8 @@ class DataTableResource:
|
|
|
581
657
|
db=db,
|
|
582
658
|
tbl=tbl,
|
|
583
659
|
record=record or {},
|
|
584
|
-
record_id=record_id
|
|
660
|
+
record_id=record_id,
|
|
661
|
+
feedback_id=self.feedback_id if include_feedback_id else None
|
|
585
662
|
)
|
|
586
663
|
|
|
587
664
|
def _wrap_response(self, content, req):
|
|
@@ -599,8 +676,8 @@ class DataTableResource:
|
|
|
599
676
|
return self._handle_table(req)
|
|
600
677
|
|
|
601
678
|
@rt(f"{self.base_route}/action")
|
|
602
|
-
def _action_handler(req):
|
|
603
|
-
return self._handle_action(req)
|
|
679
|
+
async def _action_handler(req):
|
|
680
|
+
return await self._handle_action(req)
|
|
604
681
|
|
|
605
682
|
@rt(f"{self.base_route}/save")
|
|
606
683
|
async def _save_handler(req):
|
|
@@ -656,6 +733,7 @@ class DataTableResource:
|
|
|
656
733
|
search=search,
|
|
657
734
|
columns=self.columns,
|
|
658
735
|
crud_ops=self.crud_ops,
|
|
736
|
+
crud_enabled=self.crud_enabled,
|
|
659
737
|
base_route=self.base_route,
|
|
660
738
|
row_id_field=self.row_id_field,
|
|
661
739
|
title=self.title,
|
|
@@ -683,8 +761,8 @@ class DataTableResource:
|
|
|
683
761
|
# Wrap with layout if full-page request
|
|
684
762
|
return self._wrap_response(content, req)
|
|
685
763
|
|
|
686
|
-
def _handle_action(self, req):
|
|
687
|
-
"""Handle action route (view/edit/create/delete)."""
|
|
764
|
+
async def _handle_action(self, req):
|
|
765
|
+
"""Handle action route (view/edit/create/delete) with override support."""
|
|
688
766
|
params = getattr(req, "query_params", {})
|
|
689
767
|
getter = params.get if hasattr(params, "get") else (lambda k, d=None: params[k] if k in params else d)
|
|
690
768
|
|
|
@@ -707,9 +785,47 @@ class DataTableResource:
|
|
|
707
785
|
cancel_url = f"{self.base_route}/action?dismiss=1"
|
|
708
786
|
save_url = f"{self.base_route}/save?{return_params}"
|
|
709
787
|
|
|
710
|
-
#
|
|
788
|
+
# Get record for actions that need it (not create)
|
|
789
|
+
record = None
|
|
790
|
+
if record_id:
|
|
791
|
+
raw_record = self.get_by_id(req, record_id)
|
|
792
|
+
record = _to_dict(raw_record) if raw_record else None
|
|
793
|
+
|
|
794
|
+
# Map action names to internal action names for overrides
|
|
795
|
+
action_map = {'edit': 'update'} # 'edit' action uses 'update' override
|
|
796
|
+
override_key = action_map.get(action, action)
|
|
797
|
+
|
|
798
|
+
# Check for CRUD override FIRST (callable in crud_ops)
|
|
799
|
+
if override_key in self.crud_overrides:
|
|
800
|
+
try:
|
|
801
|
+
ctx = self._build_context(req, record=record, record_id=record_id, include_feedback_id=True)
|
|
802
|
+
handler = self.crud_overrides[override_key]
|
|
803
|
+
|
|
804
|
+
# Execute handler (sync or async)
|
|
805
|
+
if asyncio.iscoroutinefunction(handler):
|
|
806
|
+
result = await handler(ctx)
|
|
807
|
+
else:
|
|
808
|
+
result = handler(ctx)
|
|
809
|
+
|
|
810
|
+
# Handle return value:
|
|
811
|
+
# - FT component: Return directly (full control to user)
|
|
812
|
+
# - str: Wrap in success toast
|
|
813
|
+
# - None: Default success message
|
|
814
|
+
if result is None:
|
|
815
|
+
return self._success_toast(f"{action.title()} completed successfully.")
|
|
816
|
+
elif isinstance(result, str):
|
|
817
|
+
return self._success_toast(result)
|
|
818
|
+
else:
|
|
819
|
+
# FT component - wrap in feedback div
|
|
820
|
+
return Div(result, id=self.feedback_id)
|
|
821
|
+
|
|
822
|
+
except Exception as e:
|
|
823
|
+
logger.error(f"CRUD override '{action}' failed: {e}", exc_info=True)
|
|
824
|
+
return self._error_toast(f"{action.title()} failed: {str(e)}")
|
|
825
|
+
|
|
826
|
+
# Handle CREATE (default behavior)
|
|
711
827
|
if action == "create":
|
|
712
|
-
if not self.
|
|
828
|
+
if not self.crud_enabled.get("create"):
|
|
713
829
|
return self._error_toast("Create operation not enabled.")
|
|
714
830
|
|
|
715
831
|
modal = FormModal(
|
|
@@ -725,16 +841,11 @@ class DataTableResource:
|
|
|
725
841
|
)
|
|
726
842
|
return self._wrap_modal(modal)
|
|
727
843
|
|
|
728
|
-
#
|
|
729
|
-
record = None
|
|
730
|
-
if record_id:
|
|
731
|
-
raw_record = self.get_by_id(req, record_id)
|
|
732
|
-
record = _to_dict(raw_record) if raw_record else None
|
|
733
|
-
|
|
844
|
+
# Record required for remaining actions
|
|
734
845
|
if not record:
|
|
735
846
|
return self._error_toast("Record not found.")
|
|
736
847
|
|
|
737
|
-
# Handle VIEW
|
|
848
|
+
# Handle VIEW (default behavior)
|
|
738
849
|
if action == "view":
|
|
739
850
|
modal = FormModal(
|
|
740
851
|
columns=self.columns,
|
|
@@ -747,9 +858,9 @@ class DataTableResource:
|
|
|
747
858
|
)
|
|
748
859
|
return self._wrap_modal(modal)
|
|
749
860
|
|
|
750
|
-
# Handle EDIT
|
|
861
|
+
# Handle EDIT (default behavior)
|
|
751
862
|
if action == "edit":
|
|
752
|
-
if not self.
|
|
863
|
+
if not self.crud_enabled.get("update"):
|
|
753
864
|
return self._error_toast("Update operation not enabled.")
|
|
754
865
|
|
|
755
866
|
modal = FormModal(
|
|
@@ -765,9 +876,9 @@ class DataTableResource:
|
|
|
765
876
|
)
|
|
766
877
|
return self._wrap_modal(modal)
|
|
767
878
|
|
|
768
|
-
# Handle DELETE
|
|
879
|
+
# Handle DELETE (default behavior)
|
|
769
880
|
if action == "delete":
|
|
770
|
-
if not self.
|
|
881
|
+
if not self.crud_enabled.get("delete"):
|
|
771
882
|
return self._error_toast("Delete operation not enabled.")
|
|
772
883
|
|
|
773
884
|
try:
|
|
@@ -775,11 +886,7 @@ class DataTableResource:
|
|
|
775
886
|
|
|
776
887
|
# Use on_delete hook if provided
|
|
777
888
|
if self.on_delete_hook:
|
|
778
|
-
|
|
779
|
-
loop = asyncio.get_event_loop()
|
|
780
|
-
loop.run_until_complete(self.on_delete_hook(ctx))
|
|
781
|
-
else:
|
|
782
|
-
self.on_delete_hook(ctx)
|
|
889
|
+
await self._call_hook(self.on_delete_hook, ctx)
|
|
783
890
|
else:
|
|
784
891
|
# Default: use delete_fn
|
|
785
892
|
self.delete_fn(req, record_id)
|
|
@@ -794,19 +901,22 @@ class DataTableResource:
|
|
|
794
901
|
for custom_action in custom_actions:
|
|
795
902
|
if action == custom_action["name"]:
|
|
796
903
|
try:
|
|
797
|
-
ctx = self._build_context(req, record=record, record_id=record_id)
|
|
904
|
+
ctx = self._build_context(req, record=record, record_id=record_id, include_feedback_id=True)
|
|
798
905
|
handler = custom_action["handler"]
|
|
799
906
|
|
|
800
907
|
# Execute handler (sync or async)
|
|
801
908
|
if asyncio.iscoroutinefunction(handler):
|
|
802
|
-
|
|
803
|
-
result = loop.run_until_complete(handler(ctx))
|
|
909
|
+
result = await handler(ctx)
|
|
804
910
|
else:
|
|
805
911
|
result = handler(ctx)
|
|
806
912
|
|
|
807
|
-
#
|
|
808
|
-
|
|
809
|
-
|
|
913
|
+
# Handle return value same as overrides
|
|
914
|
+
if result is None:
|
|
915
|
+
return self._success_toast(f"{custom_action['label']} completed successfully.")
|
|
916
|
+
elif isinstance(result, str):
|
|
917
|
+
return self._success_toast(result)
|
|
918
|
+
else:
|
|
919
|
+
return Div(result, id=self.feedback_id)
|
|
810
920
|
|
|
811
921
|
except Exception as e:
|
|
812
922
|
logger.error(f"Custom action '{action}' failed: {e}", exc_info=True)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.9.7"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|