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/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)