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.
Files changed (24) hide show
  1. {fh_matui-0.9.8/fh_matui.egg-info → fh_matui-0.9.10}/PKG-INFO +1 -1
  2. fh_matui-0.9.10/fh_matui/__init__.py +1 -0
  3. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/components.py +60 -22
  4. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/datatable.py +164 -54
  5. {fh_matui-0.9.8 → fh_matui-0.9.10/fh_matui.egg-info}/PKG-INFO +1 -1
  6. {fh_matui-0.9.8 → fh_matui-0.9.10}/settings.ini +1 -1
  7. fh_matui-0.9.8/fh_matui/__init__.py +0 -1
  8. {fh_matui-0.9.8 → fh_matui-0.9.10}/LICENSE +0 -0
  9. {fh_matui-0.9.8 → fh_matui-0.9.10}/MANIFEST.in +0 -0
  10. {fh_matui-0.9.8 → fh_matui-0.9.10}/README.md +0 -0
  11. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/_modidx.py +0 -0
  12. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/app_pages.py +0 -0
  13. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/core.py +0 -0
  14. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/foundations.py +0 -0
  15. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui/web_pages.py +0 -0
  16. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui.egg-info/SOURCES.txt +0 -0
  17. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui.egg-info/dependency_links.txt +0 -0
  18. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui.egg-info/entry_points.txt +0 -0
  19. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui.egg-info/not-zip-safe +0 -0
  20. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui.egg-info/requires.txt +0 -0
  21. {fh_matui-0.9.8 → fh_matui-0.9.10}/fh_matui.egg-info/top_level.txt +0 -0
  22. {fh_matui-0.9.8 → fh_matui-0.9.10}/pyproject.toml +0 -0
  23. {fh_matui-0.9.8 → fh_matui-0.9.10}/setup.cfg +0 -0
  24. {fh_matui-0.9.8 → fh_matui-0.9.10}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fh-matui
3
- Version: 0.9.8
3
+ Version: 0.9.10
4
4
  Summary: material-ui for fasthtml
5
5
  Home-page: https://github.com/abhisheksreesaila/fh-matui
6
6
  Author: abhishek sreesaila
@@ -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=True, cls=(), **kwargs):
335
- """BeerCSS modal dialog with optional overlay and footer."""
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
- if overlay:
353
- # Return overlay + dialog as separate elements that BeerCSS can manage
354
- overlay_cls = "overlay blur"
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 92
738
+ # %% ../nbs/02_components.ipynb 93
721
739
  #| code-fold: true
722
- def Toast(*c, cls='', position='top', variant='', action=None, dur=5.0, active=False, **kwargs):
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
- position_map = {'top': 'bottom', 'bottom': 'top', 'left': 'right', 'right': 'left'}
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
- return Div(*content, cls=' '.join(classes), **kwargs)
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 94
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 96
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 98
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 100
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 103
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 105
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 109
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 110
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 112
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 116
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 crud_ops.get("view", True):
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 crud_ops.get("update", False):
161
+ if crud_enabled.get("update", False):
121
162
  items.append(action_item("Edit", "edit", "edit"))
122
163
 
123
- if crud_ops.get("delete", False):
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 crud_ops.get("create", False):
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="row"
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="grid"
364
+ cls="row"
291
365
  ),
292
- Table(
293
- *table_rows,
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
- # Handle CREATE
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.crud_ops.get("create"):
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
- # Get record for view/edit/delete
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.crud_ops.get("update"):
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.crud_ops.get("delete"):
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
- if asyncio.iscoroutinefunction(self.on_delete_hook):
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
- loop = asyncio.get_event_loop()
803
- result = loop.run_until_complete(handler(ctx))
909
+ result = await handler(ctx)
804
910
  else:
805
911
  result = handler(ctx)
806
912
 
807
- # Return success toast with refresh trigger
808
- message = result if isinstance(result, str) else f"{custom_action['label']} completed successfully."
809
- return self._success_toast(message)
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fh-matui
3
- Version: 0.9.8
3
+ Version: 0.9.10
4
4
  Summary: material-ui for fasthtml
5
5
  Home-page: https://github.com/abhisheksreesaila/fh-matui
6
6
  Author: abhishek sreesaila
@@ -5,7 +5,7 @@
5
5
  ### Python library ###
6
6
  repo = fh-matui
7
7
  lib_name = %(repo)s
8
- version = 0.9.8
8
+ version = 0.9.10
9
9
  min_python = 3.9
10
10
  license = apache2
11
11
  black_formatting = False
@@ -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