Coverage for src/lexigram/admin/schema/misc.py: 15%
161 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
4import re
5from typing import Any
7from lexigram.admin.schema.base import SchemaField
8from lexigram.admin.schema.boolean import BooleanField
9from lexigram.admin.schema.exceptions import FieldError
10from lexigram.result import Err, Ok, Result
11from lexigram.serialization import dumps_str, loads_str
12from lexigram.ui import (
13 ColorPicker,
14 Element,
15 Rating,
16 Switch,
17 TagsInput,
18)
19from lexigram.ui import (
20 KeyValueField as KeyValueWidget,
21)
24@dataclass(frozen=True, kw_only=True)
25class ToggleField(BooleanField):
26 """A boolean toggle field that always renders as a Switch."""
28 def render_form(
29 self, value: bool | None, *, errors: list[str] | None = None
30 ) -> Element:
31 kwargs: dict[str, Any] = {
32 "name": self.name,
33 "value": value if value is not None else False,
34 }
35 if self.label is not None:
36 kwargs["label"] = self.label
37 else:
38 kwargs["label"] = ""
39 if errors:
40 kwargs["error"] = errors[0]
41 if self.readonly:
42 kwargs["disabled"] = True
43 return Switch(**kwargs).render()
46@dataclass(frozen=True, kw_only=True)
47class ColorField(SchemaField[str]):
48 """A color picker field for hex color values."""
50 def render_form(
51 self, value: str | None, *, errors: list[str] | None = None
52 ) -> Element:
53 kwargs: dict[str, Any] = {
54 "name": self.name,
55 "value": value if value is not None else "#000000",
56 }
57 if self.label is not None:
58 kwargs["label"] = self.label
59 if errors:
60 kwargs["error"] = errors[0]
61 if self.readonly:
62 kwargs["disabled"] = True
63 return ColorPicker(**kwargs).render()
65 def render_column(self, record: Any, value: str | None) -> Element:
66 if value is None:
67 return Element("span", "\u2014", class_="text-muted")
68 swatch = Element(
69 "span",
70 style=(
71 f"background-color: {value};"
72 " display: inline-block;"
73 " width: 16px; height: 16px;"
74 " border-radius: 3px;"
75 " margin-right: 6px;"
76 " vertical-align: middle;"
77 " border: 1px solid #ccc;"
78 ),
79 )
80 return Element("span", swatch, value)
82 def from_form(self, raw: str | None) -> Result[str | None, FieldError]:
83 if raw is None:
84 return Ok(None)
85 stripped = raw.strip()
86 if not stripped:
87 if self.nullable:
88 return Ok(None)
89 return Err(FieldError("Invalid color"))
90 if re.match(r"^#[0-9a-fA-F]{6}$", stripped):
91 return Ok(stripped)
92 return Err(FieldError("Invalid color format. Must be hex like #ff0000"))
94 def to_form(self, value: str | None) -> str:
95 return "" if value is None else value
98@dataclass(frozen=True, kw_only=True)
99class RatingField(SchemaField[int]):
100 """A star rating field (1-5)."""
102 def render_form(
103 self, value: int | None, *, errors: list[str] | None = None
104 ) -> Element:
105 kwargs: dict[str, Any] = {
106 "name": self.name,
107 "value": value if value is not None else 0,
108 }
109 if self.label is not None:
110 kwargs["label"] = self.label
111 if errors:
112 kwargs["error"] = errors[0]
113 if self.readonly:
114 kwargs["disabled"] = True
115 return Rating(**kwargs).render()
117 def render_column(self, record: Any, value: int | None) -> Element:
118 if value is None:
119 return Element("span", "\u2014", class_="text-muted")
120 stars = "\u2605" * value + "\u2606" * (5 - value)
121 return Element("span", stars, class_="text-warning")
123 def from_form(self, raw: str | None) -> Result[int | None, FieldError]:
124 if raw is None:
125 return Ok(None)
126 stripped = raw.strip()
127 if not stripped:
128 if self.nullable:
129 return Ok(None)
130 return Err(FieldError("Invalid rating"))
131 try:
132 val = int(stripped)
133 except ValueError:
134 return Err(FieldError("Must be a number between 1 and 5"))
135 if val < 1 or val > 5:
136 return Err(FieldError("Rating must be between 1 and 5"))
137 return Ok(val)
139 def to_form(self, value: int | None) -> str:
140 return "" if value is None else str(value)
143@dataclass(frozen=True, kw_only=True)
144class TagsField(SchemaField[list[str]]):
145 """A tags input field for comma-separated or JSON array values."""
147 def render_form(
148 self, value: list[str] | None, *, errors: list[str] | None = None
149 ) -> Element:
150 kwargs: dict[str, Any] = {"name": self.name}
151 if value is not None:
152 kwargs["value"] = value
153 if self.label is not None:
154 kwargs["label"] = self.label
155 if self.placeholder is not None:
156 kwargs["placeholder"] = self.placeholder
157 if errors:
158 kwargs["error"] = errors[0]
159 if self.readonly:
160 kwargs["disabled"] = True
161 return TagsInput(**kwargs).render()
163 def render_column(self, record: Any, value: list[str] | None) -> Element:
164 if value is None or len(value) == 0:
165 return Element("span", "\u2014", class_="text-muted")
166 tags = [
167 Element(
168 "span",
169 tag,
170 class_="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-primary-100 text-primary-800 mr-1",
171 )
172 for tag in value
173 ]
174 return Element("span", *tags)
176 def from_form(self, raw: str | None) -> Result[list[str] | None, FieldError]:
177 if raw is None:
178 return Ok(None)
179 stripped = raw.strip()
180 if not stripped:
181 if self.nullable:
182 return Ok(None)
183 return Ok([])
184 if stripped.startswith("["):
185 try:
186 parsed = loads_str(stripped)
187 if isinstance(parsed, list):
188 return Ok([str(t).strip() for t in parsed if str(t).strip()])
189 except ValueError:
190 pass
191 return Ok([t.strip() for t in stripped.split(",") if t.strip()])
193 def to_form(self, value: list[str] | None) -> str:
194 return "" if value is None else ",".join(value)
197@dataclass(frozen=True, kw_only=True)
198class KeyValueField(SchemaField[dict[str, str]]):
199 """A key-value field backed by JSON editing."""
201 def render_form(
202 self, value: dict[str, str] | None, *, errors: list[str] | None = None
203 ) -> Element:
204 kwargs: dict[str, Any] = {"name": self.name}
205 if value is not None:
206 kwargs["value"] = dict(value)
207 if self.label is not None:
208 kwargs["label"] = self.label
209 if errors:
210 kwargs["error"] = errors[0]
211 if self.readonly:
212 kwargs["disabled"] = True
213 return KeyValueWidget(**kwargs).render()
215 def render_column(self, record: Any, value: dict[str, str] | None) -> Element:
216 if value is None or len(value) == 0:
217 return Element("span", "\u2014", class_="text-muted")
218 rows = [
219 Element(
220 "tr",
221 Element(
222 "td",
223 k,
224 class_="font-medium text-foreground pr-4 py-1 text-sm",
225 ),
226 Element(
227 "td",
228 v,
229 class_="text-muted-foreground py-1 text-sm",
230 ),
231 )
232 for k, v in value.items()
233 ]
234 return Element(
235 "table",
236 Element("tbody", *rows),
237 class_="min-w-full",
238 )
240 def from_form(self, raw: str | None) -> Result[dict[str, str] | None, FieldError]:
241 if raw is None:
242 return Ok(None)
243 stripped = raw.strip()
244 if not stripped:
245 if self.nullable:
246 return Ok(None)
247 return Err(FieldError("Invalid JSON"))
248 try:
249 parsed = loads_str(stripped)
250 except ValueError:
251 return Err(FieldError("Invalid JSON"))
252 if not isinstance(parsed, dict):
253 return Err(FieldError("Must be a JSON object"))
254 result: dict[str, str] = {}
255 for k, v in parsed.items():
256 result[str(k)] = str(v)
257 return Ok(result)
259 def to_form(self, value: dict[str, str] | None) -> str:
260 return "" if value is None else dumps_str(value)