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

1from __future__ import annotations 

2 

3from dataclasses import dataclass 

4import re 

5from typing import Any 

6 

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) 

22 

23 

24@dataclass(frozen=True, kw_only=True) 

25class ToggleField(BooleanField): 

26 """A boolean toggle field that always renders as a Switch.""" 

27 

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

44 

45 

46@dataclass(frozen=True, kw_only=True) 

47class ColorField(SchemaField[str]): 

48 """A color picker field for hex color values.""" 

49 

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

64 

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) 

81 

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

93 

94 def to_form(self, value: str | None) -> str: 

95 return "" if value is None else value 

96 

97 

98@dataclass(frozen=True, kw_only=True) 

99class RatingField(SchemaField[int]): 

100 """A star rating field (1-5).""" 

101 

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

116 

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

122 

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) 

138 

139 def to_form(self, value: int | None) -> str: 

140 return "" if value is None else str(value) 

141 

142 

143@dataclass(frozen=True, kw_only=True) 

144class TagsField(SchemaField[list[str]]): 

145 """A tags input field for comma-separated or JSON array values.""" 

146 

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

162 

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) 

175 

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()]) 

192 

193 def to_form(self, value: list[str] | None) -> str: 

194 return "" if value is None else ",".join(value) 

195 

196 

197@dataclass(frozen=True, kw_only=True) 

198class KeyValueField(SchemaField[dict[str, str]]): 

199 """A key-value field backed by JSON editing.""" 

200 

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

214 

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 ) 

239 

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) 

258 

259 def to_form(self, value: dict[str, str] | None) -> str: 

260 return "" if value is None else dumps_str(value)