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

1""" 

2Concrete column implementations for common data types. 

3""" 

4 

5from __future__ import annotations 

6 

7from datetime import UTC, date, datetime 

8from typing import Any 

9 

10from lexigram.admin.ui.columns import Column 

11from lexigram.ui import el 

12 

13 

14class TextColumn(Column): 

15 """Simple text column with optional formatting.""" 

16 

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 

23 

24 def color(self, color: str) -> TextColumn: 

25 """Set text color (gray, red, blue, green, yellow, etc.).""" 

26 self._color = color 

27 return self 

28 

29 def size(self, size: str) -> TextColumn: 

30 """Set text size (xs, sm, base, lg, xl).""" 

31 self._size = size 

32 return self 

33 

34 def weight(self, weight: str) -> TextColumn: 

35 """Set font weight (normal, medium, semibold, bold).""" 

36 self._weight = weight 

37 return self 

38 

39 def mono(self, mono: bool = True) -> TextColumn: 

40 """Use monospace font.""" 

41 self._mono = mono 

42 return self 

43 

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

48 

49 classes = [f"text-{self._size}"] 

50 

51 if self._color: 

52 classes.append(f"text-{self._color}-600 dark:text-{self._color}-400") 

53 else: 

54 classes.append("text-foreground") 

55 

56 if self._weight != "normal": 

57 classes.append(f"font-{self._weight}") 

58 

59 if self._mono: 

60 classes.append("font-mono") 

61 

62 return el("span", str(value), class_="".join(classes)) 

63 

64 

65class BadgeColumn(Column): 

66 """Status badge column with color coding.""" 

67 

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. 

76 

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] = {} 

85 

86 def colors(self, colors: dict[str, str]) -> BadgeColumn: 

87 """Set color mapping for values.""" 

88 self._colors = colors 

89 return self 

90 

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 

95 

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

100 

101 # Import Badge component 

102 from lexigram.ui import Badge 

103 

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] 

114 

115 return el("div", *badges, class_="flex flex-wrap gap-1") 

116 

117 value_str = str(value).lower() 

118 color = self._colors.get(value_str, "gray") 

119 icon = self._icons.get(value_str, "") 

120 

121 # Create badge with icon if present 

122 badge_text = f"{icon} {value}" if icon else str(value) 

123 

124 # Use Badge atomic component 

125 badge = Badge(badge_text, variant=color) # type: ignore[arg-type] 

126 return badge.render() 

127 

128 

129class BooleanColumn(Column): 

130 """Boolean column with icons.""" 

131 

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" 

138 

139 def true_icon(self, icon: str) -> BooleanColumn: 

140 """Set icon for true values.""" 

141 self._true_icon = icon 

142 return self 

143 

144 def false_icon(self, icon: str) -> BooleanColumn: 

145 """Set icon for false values.""" 

146 self._false_icon = icon 

147 return self 

148 

149 def true_color(self, color: str) -> BooleanColumn: 

150 """Set color for true values.""" 

151 self._true_color = color 

152 return self 

153 

154 def false_color(self, color: str) -> BooleanColumn: 

155 """Set color for false values.""" 

156 self._false_color = color 

157 return self 

158 

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

163 

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 

167 

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 ) 

173 

174 

175class DateColumn(Column): 

176 """Date/datetime column with formatting.""" 

177 

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. 

186 

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 

196 

197 def format(self, date_format: str) -> DateColumn: 

198 """Set date format string.""" 

199 self._format = date_format 

200 return self 

201 

202 def date(self) -> DateColumn: 

203 """Format as date only (YYYY-MM-DD).""" 

204 self._format = "%Y-%m-%d" 

205 return self 

206 

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 

211 

212 def time(self) -> DateColumn: 

213 """Format as time only (HH:MM:SS).""" 

214 self._format = "%H:%M:%S" 

215 return self 

216 

217 def relative(self, relative: bool = True) -> DateColumn: 

218 """Show relative time (e.g., '2 hours ago').""" 

219 self._relative = relative 

220 return self 

221 

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

226 

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

234 

235 if isinstance(value, date) and not isinstance(value, datetime): 

236 value = datetime.combine(value, datetime.min.time()) 

237 

238 if not isinstance(value, datetime): 

239 return el("span", str(value), class_="text-foreground") 

240 

241 # Format the date 

242 formatted = value.strftime(self._format) 

243 

244 # Add relative time if enabled 

245 if self._relative: 

246 from datetime import timedelta 

247 

248 now = datetime.now(UTC) if value.tzinfo else datetime.now() 

249 diff = now - value 

250 

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 

264 

265 return el( 

266 "span", 

267 relative, 

268 class_="text-foreground", 

269 title=formatted, 

270 ) 

271 

272 return el("span", formatted, class_="text-foreground") 

273 

274 

275class ImageColumn(Column): 

276 """Image column with thumbnail preview.""" 

277 

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 

283 

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 

288 

289 def circular(self) -> ImageColumn: 

290 """Make image circular.""" 

291 self._rounded = True 

292 self._square = False 

293 return self 

294 

295 def square(self) -> ImageColumn: 

296 """Make image square with rounded corners.""" 

297 self._rounded = False 

298 self._square = True 

299 return self 

300 

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

315 

316 return el("div", class_="".join(classes)) 

317 

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

323 

324 return el("img", src=value, alt="", class_="".join(classes)) 

325 

326 

327class CurrencyColumn(Column): 

328 """Currency column with formatting.""" 

329 

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 

335 

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 

349 

350 def decimals(self, decimals: int) -> CurrencyColumn: 

351 """Set number of decimal places.""" 

352 self._decimals = decimals 

353 return self 

354 

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

359 

360 try: 

361 amount = float(value) 

362 formatted = f"{amount:,.{self._decimals}f}" 

363 

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" 

370 

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

378 

379 

380class ListColumn(Column): 

381 """Column for rendering lists of strings (e.g., tags, categories).""" 

382 

383 def __init__(self, name: str, label: str | None = None): 

384 super().__init__(name, label) 

385 self._badge = True # Default to badges 

386 

387 def badge(self, badge: bool = True) -> ListColumn: 

388 """Render items as badges.""" 

389 self._badge = badge 

390 return self 

391 

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

396 

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

409 

410 if not items: 

411 return el("span", "—", class_="text-muted-foreground italic") 

412 

413 from lexigram.ui import Badge 

414 

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 ) 

423 

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)