Coverage for src / lexigram / admin / schema / misc.py: 25%
151 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
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 return Switch(**kwargs).render()
44@dataclass(frozen=True, kw_only=True)
45class ColorField(SchemaField[str]):
46 """A color picker field for hex color values."""
48 def render_form(
49 self, value: str | None, *, errors: list[str] | None = None
50 ) -> Element:
51 kwargs: dict[str, Any] = {
52 "name": self.name,
53 "value": value if value is not None else "#000000",
54 }
55 if self.label is not None:
56 kwargs["label"] = self.label
57 if errors:
58 kwargs["error"] = errors[0]
59 return ColorPicker(**kwargs).render()
61 def render_column(self, record: Any, value: str | None) -> Element:
62 if value is None:
63 return Element("span", "\u2014", class_="text-muted")
64 swatch = Element(
65 "span",
66 style=(
67 f"background-color: {value};"
68 " display: inline-block;"
69 " width: 16px; height: 16px;"
70 " border-radius: 3px;"
71 " margin-right: 6px;"
72 " vertical-align: middle;"
73 " border: 1px solid #ccc;"
74 ),
75 )
76 return Element("span", swatch, value)
78 def from_form(self, raw: str | None) -> Result[str | None, FieldError]:
79 if raw is None:
80 return Ok(None)
81 stripped = raw.strip()
82 if not stripped:
83 if self.nullable:
84 return Ok(None)
85 return Err(FieldError("Invalid color"))
86 if re.match(r"^#[0-9a-fA-F]{6}$", stripped):
87 return Ok(stripped)
88 return Err(FieldError("Invalid color format. Must be hex like #ff0000"))
90 def to_form(self, value: str | None) -> str:
91 return "" if value is None else value
94@dataclass(frozen=True, kw_only=True)
95class RatingField(SchemaField[int]):
96 """A star rating field (1-5)."""
98 def render_form(
99 self, value: int | None, *, errors: list[str] | None = None
100 ) -> Element:
101 kwargs: dict[str, Any] = {
102 "name": self.name,
103 "value": value if value is not None else 0,
104 }
105 if self.label is not None:
106 kwargs["label"] = self.label
107 if errors:
108 kwargs["error"] = errors[0]
109 return Rating(**kwargs).render()
111 def render_column(self, record: Any, value: int | None) -> Element:
112 if value is None:
113 return Element("span", "\u2014", class_="text-muted")
114 stars = "\u2605" * value + "\u2606" * (5 - value)
115 return Element("span", stars, class_="text-warning")
117 def from_form(self, raw: str | None) -> Result[int | None, FieldError]:
118 if raw is None:
119 return Ok(None)
120 stripped = raw.strip()
121 if not stripped:
122 if self.nullable:
123 return Ok(None)
124 return Err(FieldError("Invalid rating"))
125 try:
126 val = int(stripped)
127 except ValueError:
128 return Err(FieldError("Must be a number between 1 and 5"))
129 if val < 1 or val > 5:
130 return Err(FieldError("Rating must be between 1 and 5"))
131 return Ok(val)
133 def to_form(self, value: int | None) -> str:
134 return "" if value is None else str(value)
137@dataclass(frozen=True, kw_only=True)
138class TagsField(SchemaField[list[str]]):
139 """A tags input field for comma-separated or JSON array values."""
141 def render_form(
142 self, value: list[str] | None, *, errors: list[str] | None = None
143 ) -> Element:
144 kwargs: dict[str, Any] = {"name": self.name}
145 if value is not None:
146 kwargs["value"] = value
147 if self.label is not None:
148 kwargs["label"] = self.label
149 if self.placeholder is not None:
150 kwargs["placeholder"] = self.placeholder
151 if errors:
152 kwargs["error"] = errors[0]
153 return TagsInput(**kwargs).render()
155 def render_column(self, record: Any, value: list[str] | None) -> Element:
156 if value is None or len(value) == 0:
157 return Element("span", "\u2014", class_="text-muted")
158 tags = [
159 Element(
160 "span",
161 tag,
162 class_="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-primary-100 text-primary-800 mr-1",
163 )
164 for tag in value
165 ]
166 return Element("span", *tags)
168 def from_form(self, raw: str | None) -> Result[list[str] | None, FieldError]:
169 if raw is None:
170 return Ok(None)
171 stripped = raw.strip()
172 if not stripped:
173 if self.nullable:
174 return Ok(None)
175 return Ok([])
176 if stripped.startswith("["):
177 try:
178 parsed = loads_str(stripped)
179 if isinstance(parsed, list):
180 return Ok([str(t).strip() for t in parsed if str(t).strip()])
181 except ValueError:
182 pass
183 return Ok([t.strip() for t in stripped.split(",") if t.strip()])
185 def to_form(self, value: list[str] | None) -> str:
186 return "" if value is None else ",".join(value)
189@dataclass(frozen=True, kw_only=True)
190class KeyValueField(SchemaField[dict[str, str]]):
191 """A key-value field backed by JSON editing."""
193 def render_form(
194 self, value: dict[str, str] | None, *, errors: list[str] | None = None
195 ) -> Element:
196 kwargs: dict[str, Any] = {"name": self.name}
197 if value is not None:
198 kwargs["value"] = dict(value)
199 if self.label is not None:
200 kwargs["label"] = self.label
201 if errors:
202 kwargs["error"] = errors[0]
203 return KeyValueWidget(**kwargs).render()
205 def render_column(self, record: Any, value: dict[str, str] | None) -> Element:
206 if value is None or len(value) == 0:
207 return Element("span", "\u2014", class_="text-muted")
208 rows = [
209 Element(
210 "tr",
211 Element(
212 "td",
213 k,
214 class_="font-medium text-foreground pr-4 py-1 text-sm",
215 ),
216 Element(
217 "td",
218 v,
219 class_="text-muted-foreground py-1 text-sm",
220 ),
221 )
222 for k, v in value.items()
223 ]
224 return Element(
225 "table",
226 Element("tbody", *rows),
227 class_="min-w-full",
228 )
230 def from_form(self, raw: str | None) -> Result[dict[str, str] | None, FieldError]:
231 if raw is None:
232 return Ok(None)
233 stripped = raw.strip()
234 if not stripped:
235 if self.nullable:
236 return Ok(None)
237 return Err(FieldError("Invalid JSON"))
238 try:
239 parsed = loads_str(stripped)
240 except ValueError:
241 return Err(FieldError("Invalid JSON"))
242 if not isinstance(parsed, dict):
243 return Err(FieldError("Must be a JSON object"))
244 result: dict[str, str] = {}
245 for k, v in parsed.items():
246 result[str(k)] = str(v)
247 return Ok(result)
249 def to_form(self, value: dict[str, str] | None) -> str:
250 return "" if value is None else dumps_str(value)