Coverage for src / lexigram / admin / ui / columns / types.py: 26%
229 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"""
2Concrete column implementations for common data types.
3"""
5from __future__ import annotations
7from datetime import UTC, date, datetime
8from typing import Any
10from lexigram.admin.ui.columns import Column
11from lexigram.ui import el
14class TextColumn(Column):
15 """Simple text column with optional formatting."""
17 def __init__(self, name: str, label: str | None = None):
18 super().__init__(name, label)
19 self._color: str | None = None
20 self._size: str = "sm"
21 self._weight: str = "normal"
22 self._mono = False
24 def color(self, color: str) -> TextColumn:
25 """Set text color (gray, red, blue, green, yellow, etc.)."""
26 self._color = color
27 return self
29 def size(self, size: str) -> TextColumn:
30 """Set text size (xs, sm, base, lg, xl)."""
31 self._size = size
32 return self
34 def weight(self, weight: str) -> TextColumn:
35 """Set font weight (normal, medium, semibold, bold)."""
36 self._weight = weight
37 return self
39 def mono(self, mono: bool = True) -> TextColumn:
40 """Use monospace font."""
41 self._mono = mono
42 return self
44 def render(self, value: Any, record: dict) -> Any:
45 """Render as styled text."""
46 if value is None:
47 return el("span", "—", class_="text-muted-foreground italic")
49 classes = [f"text-{self._size}"]
51 if self._color:
52 classes.append(f"text-{self._color}-600 dark:text-{self._color}-400")
53 else:
54 classes.append("text-foreground")
56 if self._weight != "normal":
57 classes.append(f"font-{self._weight}")
59 if self._mono:
60 classes.append("font-mono")
62 return el("span", str(value), class_="".join(classes))
65class BadgeColumn(Column):
66 """Status badge column with color coding."""
68 def __init__(
69 self,
70 name: str,
71 label: str | None = None,
72 colors: dict[str, str] | None = None,
73 ):
74 """
75 Initialize badge column.
77 Args:
78 name: Column field name
79 label: Display label
80 colors: Mapping of values to colors (e.g., {"active": "green", "inactive": "gray"})
81 """
82 super().__init__(name, label)
83 self._colors = colors or {}
84 self._icons: dict[str, str] = {}
86 def colors(self, colors: dict[str, str]) -> BadgeColumn:
87 """Set color mapping for values."""
88 self._colors = colors
89 return self
91 def icons(self, icons: dict[str, str]) -> BadgeColumn:
92 """Set icon mapping for values (emoji or icon class)."""
93 self._icons = icons
94 return self
96 def render(self, value: Any, record: dict) -> Any:
97 """Render as colored badge using atomic Badge component."""
98 if value is None:
99 return el("span", "—", class_="text-muted-foreground italic")
101 # Import Badge component
102 from lexigram.ui import Badge
104 # Handle list/tuple/set values
105 if isinstance(value, (list, tuple, set)):
106 badges = []
107 for item in value:
108 item_str = str(item)
109 item_lower = item_str.lower()
110 color = self._colors.get(item_lower, "gray")
111 icon = self._icons.get(item_lower, "")
112 badge_text = f"{icon} {item_str}" if icon else item_str
113 badges.append(Badge(badge_text, variant=color).render()) # type: ignore[arg-type]
115 return el("div", *badges, class_="flex flex-wrap gap-1")
117 value_str = str(value).lower()
118 color = self._colors.get(value_str, "gray")
119 icon = self._icons.get(value_str, "")
121 # Create badge with icon if present
122 badge_text = f"{icon} {value}" if icon else str(value)
124 # Use Badge atomic component
125 badge = Badge(badge_text, variant=color) # type: ignore[arg-type]
126 return badge.render()
129class BooleanColumn(Column):
130 """Boolean column with icons."""
132 def __init__(self, name: str, label: str | None = None):
133 super().__init__(name, label)
134 self._true_icon = "✓"
135 self._false_icon = "✗"
136 self._true_color = "green"
137 self._false_color = "red"
139 def true_icon(self, icon: str) -> BooleanColumn:
140 """Set icon for true values."""
141 self._true_icon = icon
142 return self
144 def false_icon(self, icon: str) -> BooleanColumn:
145 """Set icon for false values."""
146 self._false_icon = icon
147 return self
149 def true_color(self, color: str) -> BooleanColumn:
150 """Set color for true values."""
151 self._true_color = color
152 return self
154 def false_color(self, color: str) -> BooleanColumn:
155 """Set color for false values."""
156 self._false_color = color
157 return self
159 def render(self, value: Any, record: dict) -> Any:
160 """Render as icon with color."""
161 if value is None:
162 return el("span", "—", class_="text-muted-foreground italic")
164 is_true = bool(value)
165 icon = self._true_icon if is_true else self._false_icon
166 color = self._true_color if is_true else self._false_color
168 return el(
169 "span",
170 icon,
171 class_=f"inline-flex items-center justify-center w-6 h-6 rounded-full bg-{color}-100 dark:bg-{color}-900 text-{color}-600 dark:text-{color}-400 font-semibold",
172 )
175class DateColumn(Column):
176 """Date/datetime column with formatting."""
178 def __init__(
179 self,
180 name: str,
181 label: str | None = None,
182 date_format: str = "%Y-%m-%d",
183 ):
184 """
185 Initialize date column.
187 Args:
188 name: Column field name
189 label: Display label
190 date_format: strftime format string
191 """
192 super().__init__(name, label)
193 self._format = date_format
194 self._relative = False
195 self._timezone = None
197 def format(self, date_format: str) -> DateColumn:
198 """Set date format string."""
199 self._format = date_format
200 return self
202 def date(self) -> DateColumn:
203 """Format as date only (YYYY-MM-DD)."""
204 self._format = "%Y-%m-%d"
205 return self
207 def datetime(self) -> DateColumn:
208 """Format as datetime (YYYY-MM-DD HH:MM:SS)."""
209 self._format = "%Y-%m-%d %H:%M:%S"
210 return self
212 def time(self) -> DateColumn:
213 """Format as time only (HH:MM:SS)."""
214 self._format = "%H:%M:%S"
215 return self
217 def relative(self, relative: bool = True) -> DateColumn:
218 """Show relative time (e.g., '2 hours ago')."""
219 self._relative = relative
220 return self
222 def render(self, value: Any, record: dict) -> Any:
223 """Render formatted date."""
224 if value is None:
225 return el("span", "—", class_="text-muted-foreground italic")
227 # Convert to datetime if needed
228 if isinstance(value, str):
229 try:
230 # Try parsing ISO format
231 value = datetime.fromisoformat(value.replace("Z", "+00:00"))
232 except (ValueError, AttributeError):
233 return el("span", str(value), class_="text-foreground")
235 if isinstance(value, date) and not isinstance(value, datetime):
236 value = datetime.combine(value, datetime.min.time())
238 if not isinstance(value, datetime):
239 return el("span", str(value), class_="text-foreground")
241 # Format the date
242 formatted = value.strftime(self._format)
244 # Add relative time if enabled
245 if self._relative:
246 from datetime import timedelta
248 now = datetime.now(UTC) if value.tzinfo else datetime.now()
249 diff = now - value
251 if diff < timedelta(minutes=1):
252 relative = "just now"
253 elif diff < timedelta(hours=1):
254 minutes = int(diff.total_seconds() / 60)
255 relative = f"{minutes}m ago"
256 elif diff < timedelta(days=1):
257 hours = int(diff.total_seconds() / 3600)
258 relative = f"{hours}h ago"
259 elif diff < timedelta(days=30):
260 days = diff.days
261 relative = f"{days}d ago"
262 else:
263 relative = formatted
265 return el(
266 "span",
267 relative,
268 class_="text-foreground",
269 title=formatted,
270 )
272 return el("span", formatted, class_="text-foreground")
275class ImageColumn(Column):
276 """Image column with thumbnail preview."""
278 def __init__(self, name: str, label: str | None = None):
279 super().__init__(name, label)
280 self._size = 10 # Default 40px (10 * 4px)
281 self._rounded = True
282 self._square = False
284 def size(self, size: int) -> ImageColumn:
285 """Set image size in Tailwind units (e.g., 10 = 40px)."""
286 self._size = size
287 return self
289 def circular(self) -> ImageColumn:
290 """Make image circular."""
291 self._rounded = True
292 self._square = False
293 return self
295 def square(self) -> ImageColumn:
296 """Make image square with rounded corners."""
297 self._rounded = False
298 self._square = True
299 return self
301 def render(self, value: Any, record: dict) -> Any:
302 """Render as image thumbnail."""
303 if not value:
304 # Placeholder
305 classes = [
306 "bg-muted",
307 "dark:bg-muted",
308 f"w-{self._size}",
309 f"h-{self._size}",
310 ]
311 if self._rounded:
312 classes.append("rounded-full")
313 elif self._square:
314 classes.append("rounded-md")
316 return el("div", class_="".join(classes))
318 classes = [f"w-{self._size}", f"h-{self._size}", "object-cover"]
319 if self._rounded:
320 classes.append("rounded-full")
321 elif self._square:
322 classes.append("rounded-md")
324 return el("img", src=value, alt="", class_="".join(classes))
327class CurrencyColumn(Column):
328 """Currency column with formatting."""
330 def __init__(self, name: str, label: str | None = None, currency: str = "USD"):
331 super().__init__(name, label)
332 self._currency = currency
333 self._symbol = "$"
334 self._decimals = 2
336 def currency(self, currency: str) -> CurrencyColumn:
337 """Set currency code (USD, EUR, GBP, etc.)."""
338 self._currency = currency
339 # Set symbol based on currency
340 symbols = {
341 "USD": "$",
342 "EUR": "€",
343 "GBP": "£",
344 "JPY": "¥",
345 "CNY": "¥",
346 }
347 self._symbol = symbols.get(currency, currency)
348 return self
350 def decimals(self, decimals: int) -> CurrencyColumn:
351 """Set number of decimal places."""
352 self._decimals = decimals
353 return self
355 def render(self, value: Any, record: dict) -> Any:
356 """Render as formatted currency."""
357 if value is None:
358 return el("span", "—", class_="text-muted-foreground italic")
360 try:
361 amount = float(value)
362 formatted = f"{amount:,.{self._decimals}f}"
364 # Color based on positive/negative
365 color_class = "text-foreground"
366 if amount < 0:
367 color_class = "text-destructive"
368 elif amount > 0:
369 color_class = "text-success"
371 return el(
372 "span",
373 f"{self._symbol}{formatted}",
374 class_=f"font-medium {color_class}",
375 )
376 except (ValueError, TypeError):
377 return el("span", str(value), class_="text-foreground")
380class ListColumn(Column):
381 """Column for rendering lists of strings (e.g., tags, categories)."""
383 def __init__(self, name: str, label: str | None = None):
384 super().__init__(name, label)
385 self._badge = True # Default to badges
387 def badge(self, badge: bool = True) -> ListColumn:
388 """Render items as badges."""
389 self._badge = badge
390 return self
392 def render(self, value: Any, record: dict) -> Any:
393 """Render list items."""
394 if not value:
395 return el("span", "—", class_="text-muted-foreground italic")
397 # Handle string (comma separated) or proper list
398 if isinstance(value, str):
399 items = list(
400 filter(
401 lambda item: item.strip(),
402 (item.strip() for item in value.split(",")),
403 ),
404 )
405 elif isinstance(value, (list, tuple, set)):
406 items = list(filter(lambda item: item, (str(item) for item in value)))
407 else:
408 return el("span", str(value), class_="text-foreground")
410 if not items:
411 return el("span", "—", class_="text-muted-foreground italic")
413 from lexigram.ui import Badge
415 elements = []
416 for item in items:
417 if self._badge:
418 elements.append(Badge(item, variant="gray").render())
419 else:
420 elements.append(
421 el("span", item, class_="text-foreground"),
422 )
424 container_class = (
425 "flex flex-wrap gap-1" if self._badge else "flex flex-col gap-0.5"
426 )
427 return el("div", *elements, class_=container_class)