fh-matui 0.9.7__tar.gz → 0.9.9__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.7/fh_matui.egg-info → fh_matui-0.9.9}/PKG-INFO +1 -1
  2. fh_matui-0.9.9/fh_matui/__init__.py +1 -0
  3. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui/datatable.py +186 -69
  4. {fh_matui-0.9.7 → fh_matui-0.9.9/fh_matui.egg-info}/PKG-INFO +1 -1
  5. {fh_matui-0.9.7 → fh_matui-0.9.9}/settings.ini +1 -1
  6. fh_matui-0.9.7/fh_matui/__init__.py +0 -1
  7. {fh_matui-0.9.7 → fh_matui-0.9.9}/LICENSE +0 -0
  8. {fh_matui-0.9.7 → fh_matui-0.9.9}/MANIFEST.in +0 -0
  9. {fh_matui-0.9.7 → fh_matui-0.9.9}/README.md +0 -0
  10. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui/_modidx.py +0 -0
  11. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui/app_pages.py +0 -0
  12. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui/components.py +0 -0
  13. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui/core.py +0 -0
  14. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui/foundations.py +0 -0
  15. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui/web_pages.py +0 -0
  16. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui.egg-info/SOURCES.txt +0 -0
  17. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui.egg-info/dependency_links.txt +0 -0
  18. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui.egg-info/entry_points.txt +0 -0
  19. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui.egg-info/not-zip-safe +0 -0
  20. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui.egg-info/requires.txt +0 -0
  21. {fh_matui-0.9.7 → fh_matui-0.9.9}/fh_matui.egg-info/top_level.txt +0 -0
  22. {fh_matui-0.9.7 → fh_matui-0.9.9}/pyproject.toml +0 -0
  23. {fh_matui-0.9.7 → fh_matui-0.9.9}/setup.cfg +0 -0
  24. {fh_matui-0.9.7 → fh_matui-0.9.9}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fh-matui
3
- Version: 0.9.7
3
+ Version: 0.9.9
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.8"
@@ -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 always available (read operation)
117
- items.append(action_item("View", "visibility", "view"))
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
- # 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
- )
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
- # 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",
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
- hx_vals='{"page":1}',
234
- autocomplete="off",
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="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(
295
+ Span(f"Page {page} of {total_pages}", cls="middle"),
246
296
  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
- )
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
- # Build footer with pagination - count on left, paginator centered below table
257
- footer_content = Div(
258
- Div(Span(summary, cls="small-text grey-text")),
307
+ # Search form
308
+ search_form = Form(
259
309
  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}"
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="row center-align middle-align small-space"
322
+ cls="field prefix"
268
323
  ),
269
- cls="grid"
324
+ Input(type="hidden", name="page_size", value=page_size),
325
+ cls="max"
270
326
  )
271
327
 
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"
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
- footer=footer_content,
285
- header=header_content,
286
- cls="surface-container border round"
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fh-matui
3
- Version: 0.9.7
3
+ Version: 0.9.9
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.7
8
+ version = 0.9.9
9
9
  min_python = 3.9
10
10
  license = apache2
11
11
  black_formatting = False
@@ -1 +0,0 @@
1
- __version__ = "0.9.6"
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