Coverage for src / lexigram / admin / ui / widgets / infolist.py: 0%
120 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 17:07 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 17:07 +0800
1"""Read-only entry display for admin detail pages.
3Provides ``InfolistWidget`` — a component that renders labelled
4key-value pairs in a grid layout, suitable for ``show`` / detail views.
5"""
7from __future__ import annotations
9from dataclasses import dataclass
10from datetime import date, datetime
11from enum import Enum
12from typing import Any
14from lexigram.ui import Component, Element, raw
17class InfolistEntryType(str, Enum):
18 TEXT = "text"
19 BOOLEAN = "boolean"
20 BADGE = "badge"
21 DATE = "date"
22 IMAGE = "image"
23 MONEY = "money"
24 URL = "url"
25 EMAIL = "email"
28@dataclass(frozen=True, kw_only=True)
29class InfolistEntry:
30 """A single labelled entry within an ``InfolistWidget``."""
32 name: str
33 label: str
34 value: Any = None
35 type: InfolistEntryType = InfolistEntryType.TEXT
36 icon: str | None = None
37 badge_type: str = "default"
38 image_url: str | None = None
39 currency: str = "USD"
40 url: str | None = None
41 section: str | None = None
44class InfolistWidget(Component):
45 """Render a read-only list of labelled entries suitable for detail pages.
47 Entries are arranged in a configurable-column grid. Supports
48 grouping by section headings.
49 """
51 def __init__(
52 self,
53 entries: list[InfolistEntry],
54 columns: int = 2,
55 **props: Any,
56 ) -> None:
57 super().__init__(entries=entries, columns=columns, **props)
58 self.entries = entries
59 self.columns = max(1, min(columns, 4))
61 def render(self) -> Element:
62 if not self.entries:
63 return Element(
64 "div",
65 Element(
66 "p",
67 "No information available.",
68 class_="text-sm text-muted-foreground italic",
69 ),
70 class_="p-6 text-center",
71 )
73 sections = self._group_by_section()
75 parts: list[Element] = []
76 for section_title, section_entries in sections:
77 if section_title:
78 parts.append(
79 Element(
80 "h3",
81 section_title,
82 class_=(
83 "text-sm font-semibold text-foreground "
84 "border-b border-border pb-2 mb-4"
85 ),
86 )
87 )
89 grid = self._render_entries_grid(section_entries)
90 parts.append(grid)
92 grid_class = (
93 "grid gap-4 "
94 f"{'grid-cols-1' if self.columns == 1 else ''}"
95 f"{'grid-cols-1 sm:grid-cols-2' if self.columns == 2 else ''}"
96 f"{'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' if self.columns == 3 else ''}"
97 f"{'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4' if self.columns == 4 else ''}"
98 )
100 return Element(
101 "div",
102 *parts,
103 class_=grid_class,
104 )
106 def _group_by_section(
107 self,
108 ) -> list[tuple[str | None, list[InfolistEntry]]]:
109 groups: dict[str | None, list[InfolistEntry]] = {}
110 for entry in self.entries:
111 key = entry.section
112 if key not in groups:
113 groups[key] = []
114 groups[key].append(entry)
116 ordered: list[tuple[str | None, list[InfolistEntry]]] = []
117 for entry in self.entries:
118 key = entry.section
119 if key in groups:
120 ordered.append((key, groups.pop(key)))
122 ordered.extend(groups.items())
123 return ordered
125 def _render_entries_grid(self, entries: list[InfolistEntry]) -> Element:
126 cards: list[Element] = []
127 for entry in entries:
128 cards.append(self._render_entry_card(entry))
129 return Element("div", *cards)
131 def _render_entry_card(self, entry: InfolistEntry) -> Element:
132 rendered_value = self._render_value(entry)
134 icon_el = (
135 Element(
136 "span",
137 raw(entry.icon),
138 class_="w-4 h-4 mr-1.5 inline-block",
139 )
140 if entry.icon
141 else ""
142 )
144 return Element(
145 "div",
146 Element(
147 "dt",
148 icon_el,
149 entry.label,
150 class_=(
151 "text-xs font-medium text-muted-foreground dark:text-muted-foreground "
152 "uppercase tracking-wide mb-1"
153 ),
154 ),
155 Element(
156 "dd",
157 rendered_value,
158 class_="text-sm text-foreground",
159 ),
160 class_=("p-3 rounded-lg border border-border bg-background"),
161 )
163 def _render_value(self, entry: InfolistEntry) -> Element | str:
164 if entry.value is None:
165 return Element("span", "\u2014", class_="text-muted")
167 if entry.type == InfolistEntryType.BOOLEAN:
168 return self._render_boolean(entry.value)
169 if entry.type == InfolistEntryType.BADGE:
170 return self._render_badge(entry.value, entry.badge_type)
171 if entry.type == InfolistEntryType.DATE:
172 return self._render_date(entry.value)
173 if entry.type == InfolistEntryType.IMAGE:
174 return self._render_image(entry.value, entry.image_url)
175 if entry.type == InfolistEntryType.MONEY:
176 return self._render_money(entry.value, entry.currency)
177 if entry.type == InfolistEntryType.URL:
178 return self._render_url(str(entry.value), entry.url)
179 if entry.type == InfolistEntryType.EMAIL:
180 return self._render_email(str(entry.value))
181 return self._render_text(str(entry.value))
183 def _render_text(self, text: str) -> Element:
184 return Element("span", text)
186 def _render_boolean(self, value: Any) -> Element:
187 is_true = bool(value)
188 if is_true:
189 return Element(
190 "span",
191 "\u2713 Yes",
192 class_="text-success font-medium",
193 )
194 return Element(
195 "span",
196 "\u2717 No",
197 class_="text-destructive font-medium",
198 )
200 def _render_badge(self, value: Any, badge_type: str) -> Element:
201 color_map = {
202 "success": "bg-success/10 text-success",
203 "warning": "bg-warning/10 text-warning",
204 "danger": "bg-destructive/10 text-destructive",
205 "info": "bg-info/10 text-info",
206 "default": "bg-muted text-foreground dark:bg-muted dark:text-foreground",
207 }
208 bg = color_map.get(badge_type, color_map["default"])
209 return Element(
210 "span",
211 str(value),
212 class_=f"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {bg}",
213 )
215 def _render_date(self, value: Any) -> Element:
216 if isinstance(value, datetime):
217 formatted = value.strftime("%Y-%m-%d %H:%M")
218 elif isinstance(value, date):
219 formatted = value.isoformat()
220 else:
221 formatted = str(value)
222 return Element("span", formatted)
224 def _render_image(self, value: Any, image_url: str | None = None) -> Element:
225 src = image_url or str(value)
226 return Element(
227 "img",
228 src=src,
229 alt=str(value),
230 class_="max-h-20 rounded",
231 )
233 def _render_money(self, value: Any, currency: str = "USD") -> Element:
234 try:
235 amount = float(value)
236 symbols = {"USD": "$", "EUR": "\u20ac", "GBP": "\u00a3"}
237 symbol = symbols.get(currency, currency + " ")
238 formatted = f"{symbol}{amount:,.2f}"
239 except (ValueError, TypeError):
240 formatted = str(value)
241 return Element("span", formatted)
243 def _render_url(self, value: str, url: str | None = None) -> Element:
244 href = url or value
245 return Element(
246 "a",
247 value,
248 href=href,
249 target="_blank",
250 rel="noopener noreferrer",
251 class_="text-primary-600 hover:text-primary-800 underline",
252 )
254 def _render_email(self, value: str) -> Element:
255 return Element(
256 "a",
257 value,
258 href=f"mailto:{value}",
259 class_="text-primary-600 hover:text-primary-800 underline",
260 )