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

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 return Switch(**kwargs).render() 

42 

43 

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

45class ColorField(SchemaField[str]): 

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

47 

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

60 

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) 

77 

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

89 

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

91 return "" if value is None else value 

92 

93 

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

95class RatingField(SchemaField[int]): 

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

97 

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

110 

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

116 

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) 

132 

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

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

135 

136 

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

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

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

140 

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

154 

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) 

167 

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

184 

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

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

187 

188 

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

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

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

192 

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

204 

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 ) 

229 

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) 

248 

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

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