Coverage for src / lexigram / admin / schema / composite.py: 37%
93 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1from __future__ import annotations
3from dataclasses import dataclass
4from typing import Any
6from lexigram.admin.schema.base import SchemaField
7from lexigram.admin.schema.exceptions import FieldError
8from lexigram.result import Err, Ok, Result
9from lexigram.serialization import dumps_str, loads_str
10from lexigram.ui import Element, FileUpload, TextArea
13@dataclass(frozen=True, kw_only=True)
14class JsonField(SchemaField[dict | list]):
15 """A JSON editor field for dict/list values."""
17 def render_form(
18 self, value: dict | list | None, *, errors: list[str] | None = None
19 ) -> Element:
20 kwargs: dict[str, Any] = {"name": self.name, "rows": 8}
21 if value is not None:
22 kwargs["value"] = dumps_str(value, indent=2)
23 if self.label is not None:
24 kwargs["label"] = self.label
25 if errors:
26 kwargs["error"] = errors[0]
27 return TextArea(**kwargs).render()
29 def render_column(self, record: Any, value: dict | list | None) -> Element:
30 if value is None:
31 return Element("span", "\u2014", class_="text-muted")
32 pretty = dumps_str(value, indent=2)
33 return Element(
34 "pre",
35 pretty,
36 class_="text-xs text-muted-foreground max-w-md overflow-x-auto",
37 )
39 def from_form(self, raw: str | None) -> Result[dict | list | None, FieldError]:
40 if raw is None:
41 return Ok(None)
42 stripped = raw.strip()
43 if not stripped:
44 if self.nullable:
45 return Ok(None)
46 return Err(FieldError("Invalid JSON"))
47 try:
48 parsed = loads_str(stripped)
49 except ValueError:
50 return Err(FieldError("Invalid JSON"))
51 if not isinstance(parsed, (dict, list)):
52 return Err(FieldError("Must be a JSON object or array"))
53 return Ok(parsed)
55 def to_form(self, value: dict | list | None) -> str:
56 if value is None:
57 return ""
58 return dumps_str(value)
61@dataclass(frozen=True, kw_only=True)
62class HiddenField(SchemaField[Any]):
63 """A hidden input field for storing values not displayed to the user."""
65 def render_form(
66 self, value: Any | None, *, errors: list[str] | None = None
67 ) -> Element:
68 return Element(
69 "input",
70 type="hidden",
71 name=self.name,
72 value=value if value is not None else "",
73 )
75 def render_column(self, record: Any, value: Any | None) -> Element:
76 return Element("span", "\u2014", class_="text-muted")
78 def from_form(self, raw: str | None) -> Result[Any, FieldError]:
79 return Ok(raw)
81 def to_form(self, value: Any | None) -> str:
82 return "" if value is None else str(value)
85@dataclass(frozen=True, kw_only=True)
86class FileField(SchemaField[str]):
87 """A file upload field."""
89 def render_form(
90 self, value: str | None, *, errors: list[str] | None = None
91 ) -> Element:
92 kwargs: dict[str, Any] = {"name": self.name}
93 if self.label is not None:
94 kwargs["label"] = self.label
95 if self.required:
96 kwargs["required"] = True
97 if errors:
98 kwargs["error"] = errors[0]
99 return FileUpload(**kwargs).render()
101 def render_column(self, record: Any, value: str | None) -> Element:
102 if value is None:
103 return Element("span", "\u2014", class_="text-muted")
104 return Element(
105 "span", Element("a", value, href=value), class_="text-primary-600"
106 )
108 def from_form(self, raw: str | None) -> Result[str | None, FieldError]:
109 if raw is None:
110 return Ok(None)
111 stripped = raw.strip()
112 if not stripped:
113 if self.nullable:
114 return Ok(None)
115 return Ok("")
116 return Ok(stripped)
118 def to_form(self, value: str | None) -> str:
119 return "" if value is None else value
122@dataclass(frozen=True, kw_only=True)
123class ImageField(FileField):
124 """An image upload field with thumbnail preview."""
126 thumbnail_size: int = 64
128 def render_column(self, record: Any, value: str | None) -> Element:
129 if value is None:
130 return Element("span", "\u2014", class_="text-muted")
131 return Element(
132 "img",
133 src=value,
134 style=(
135 f"width: {self.thumbnail_size}px;"
136 f" height: {self.thumbnail_size}px;"
137 " object-fit: cover;"
138 " border-radius: 4px;"
139 ),
140 alt="",
141 )
144@dataclass(frozen=True, kw_only=True)
145class AvatarField(ImageField):
146 """An avatar upload field with circular thumbnail preview."""
148 size: int = 40
150 def render_column(self, record: Any, value: str | None) -> Element:
151 if value is None:
152 return Element("span", "\u2014", class_="text-muted")
153 return Element(
154 "img",
155 src=value,
156 style=(
157 f"width: {self.size}px;"
158 f" height: {self.size}px;"
159 " border-radius: 50%;"
160 " object-fit: cover;"
161 ),
162 alt="",
163 )