Coverage for src/lexigram/admin/schema/composite.py: 14%
100 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +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 if self.readonly:
28 kwargs["disabled"] = True
29 return TextArea(**kwargs).render()
31 def render_column(self, record: Any, value: dict | list | None) -> Element:
32 if value is None:
33 return Element("span", "\u2014", class_="text-muted")
34 pretty = dumps_str(value, indent=2)
35 return Element(
36 "pre",
37 pretty,
38 class_="text-xs text-muted-foreground max-w-md overflow-x-auto",
39 )
41 def from_form(self, raw: str | None) -> Result[dict | list | None, FieldError]:
42 if raw is None:
43 return Ok(None)
44 stripped = raw.strip()
45 if not stripped:
46 if self.nullable:
47 return Ok(None)
48 return Err(FieldError("Invalid JSON"))
49 try:
50 parsed = loads_str(stripped)
51 except ValueError:
52 return Err(FieldError("Invalid JSON"))
53 if not isinstance(parsed, (dict, list)):
54 return Err(FieldError("Must be a JSON object or array"))
55 return Ok(parsed)
57 def to_form(self, value: dict | list | None) -> str:
58 if value is None:
59 return ""
60 return dumps_str(value)
63@dataclass(frozen=True, kw_only=True)
64class HiddenField(SchemaField[Any]):
65 """A hidden input field for storing values not displayed to the user."""
67 def render_form(
68 self, value: Any | None, *, errors: list[str] | None = None
69 ) -> Element:
70 return Element(
71 "input",
72 type="hidden",
73 name=self.name,
74 value=value if value is not None else "",
75 )
77 def render_column(self, record: Any, value: Any | None) -> Element:
78 return Element("span", "\u2014", class_="text-muted")
80 def from_form(self, raw: str | None) -> Result[Any, FieldError]:
81 return Ok(raw)
83 def to_form(self, value: Any | None) -> str:
84 return "" if value is None else str(value)
87@dataclass(frozen=True, kw_only=True)
88class FileField(SchemaField[str]):
89 """A file upload field."""
91 def render_form(
92 self, value: str | None, *, errors: list[str] | None = None
93 ) -> Element:
94 kwargs: dict[str, Any] = {"name": self.name}
95 if self.label is not None:
96 kwargs["label"] = self.label
97 if self.required:
98 kwargs["required"] = True
99 if errors:
100 kwargs["error"] = errors[0]
101 if self.readonly:
102 kwargs["disabled"] = True
103 return FileUpload(**kwargs).render()
105 def render_column(self, record: Any, value: str | None) -> Element:
106 if value is None:
107 return Element("span", "\u2014", class_="text-muted")
108 return Element(
109 "span", Element("a", value, href=value), class_="text-primary-600"
110 )
112 def from_form(self, raw: str | None) -> Result[str | None, FieldError]:
113 if raw is None:
114 return Ok(None)
115 stripped = raw.strip()
116 if not stripped:
117 if self.nullable:
118 return Ok(None)
119 return Ok("")
120 return Ok(stripped)
122 def to_form(self, value: str | None) -> str:
123 return "" if value is None else value
126@dataclass(frozen=True, kw_only=True)
127class ImageField(FileField):
128 """An image upload field with thumbnail preview."""
130 thumbnail_size: int = 64
132 def render_infolist_entry(self, value: Any) -> Any:
133 from lexigram.ui import InfolistEntry, InfolistEntryType
135 return InfolistEntry(
136 name=self.name,
137 label=self.label or self.name.replace("_", " ").title(),
138 value=value,
139 type=InfolistEntryType.IMAGE,
140 )
142 def render_column(self, record: Any, value: str | None) -> Element:
143 if value is None:
144 return Element("span", "\u2014", class_="text-muted")
145 return Element(
146 "img",
147 src=value,
148 style=(
149 f"width: {self.thumbnail_size}px;"
150 f" height: {self.thumbnail_size}px;"
151 " object-fit: cover;"
152 " border-radius: 4px;"
153 ),
154 alt="",
155 )
158@dataclass(frozen=True, kw_only=True)
159class AvatarField(ImageField):
160 """An avatar upload field with circular thumbnail preview."""
162 size: int = 40
164 def render_column(self, record: Any, value: str | None) -> Element:
165 if value is None:
166 return Element("span", "\u2014", class_="text-muted")
167 return Element(
168 "img",
169 src=value,
170 style=(
171 f"width: {self.size}px;"
172 f" height: {self.size}px;"
173 " border-radius: 50%;"
174 " object-fit: cover;"
175 ),
176 alt="",
177 )