Coverage for src/lexigram/admin/schema/select.py: 90%

156 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1from __future__ import annotations 

2 

3from dataclasses import dataclass, field 

4from enum import Enum 

5from typing import Any 

6 

7from lexigram.admin.schema.base import SchemaField 

8from lexigram.admin.schema.exceptions import FieldError 

9from lexigram.result import Err, Ok, Result 

10from lexigram.ui import Element, MultiSelect, Radio, Select 

11 

12 

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

14class SelectField(SchemaField[str]): 

15 """A select / dropdown field.""" 

16 

17 options: list[tuple[str, str]] = field(default_factory=list) 

18 

19 def __post_init__(self) -> None: 

20 normalized = self._normalize_options(self.options) 

21 object.__setattr__(self, "options", normalized) 

22 

23 @staticmethod 

24 def _normalize_options( 

25 options: list[tuple[str, str]] | dict[str, str] | list[str], 

26 ) -> list[tuple[str, str]]: 

27 if isinstance(options, dict): 

28 return list(options.items()) 

29 if isinstance(options, list): 

30 result: list[tuple[str, str]] = [] 

31 for item in options: 

32 if isinstance(item, tuple): 

33 result.append(item) 

34 else: 

35 result.append((item, item)) 

36 return result 

37 return [] 

38 

39 def _get_label(self, value: str) -> str | None: 

40 for val, label in self.options: 

41 if val == value: 

42 return label 

43 return None 

44 

45 def render_form( 

46 self, value: str | None, *, errors: list[str] | None = None 

47 ) -> Element: 

48 kwargs: dict[str, Any] = { 

49 "name": self.name, 

50 "choices": self.options, 

51 "value": value if value is not None else "", 

52 } 

53 if self.label is not None: 

54 kwargs["label"] = self.label 

55 if errors: 

56 kwargs["error"] = errors[0] 

57 if self.readonly: 

58 kwargs["disabled"] = True 

59 return Select(**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 label = self._get_label(value) 

65 display = label if label is not None else value 

66 return Element("span", display) 

67 

68 def render_filter(self, current_value: Any | None = None) -> Element | None: 

69 return Select( 

70 name=self.name, 

71 choices=self.options, 

72 value=current_value, 

73 ).render() 

74 

75 def from_form(self, raw: str | None) -> Result[str | None, FieldError]: 

76 if raw is None: 

77 return Ok(None) 

78 stripped = raw.strip() 

79 if not stripped: 

80 if self.nullable: 

81 return Ok(None) 

82 return Err(FieldError("Invalid option")) 

83 if self._get_label(stripped) is None: 

84 return Err(FieldError("Invalid option")) 

85 return Ok(stripped) 

86 

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

88 if value is None: 

89 return "" 

90 return str(value) 

91 

92 

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

94class EnumField(SelectField): 

95 """A select field backed by a Python Enum class. 

96 

97 Auto-derives options from the enum members and coerces form values 

98 back to enum members on read. 

99 """ 

100 

101 enum_cls: type[Enum] | None = None 

102 

103 def __post_init__(self) -> None: 

104 if self.enum_cls is not None and not self.options: 

105 derived = [ 

106 (m.value, m.name.replace("_", " ").title()) for m in self.enum_cls 

107 ] 

108 object.__setattr__(self, "options", derived) 

109 super().__post_init__() 

110 

111 def _get_enum_member(self, value: str) -> Enum | None: 

112 if self.enum_cls is None: 

113 return None 

114 for m in self.enum_cls: 

115 if m.value == value: 

116 return m 

117 return None 

118 

119 def from_form(self, raw: str | None) -> Result[Enum | None, FieldError]: # type: ignore[override] 

120 result = super().from_form(raw) 

121 if result.is_err(): 

122 return Err(result.unwrap_err()) 

123 value = result.unwrap() 

124 if value is None: 

125 return Ok(None) 

126 member = self._get_enum_member(value) 

127 if member is not None: 

128 return Ok(member) 

129 return Err(FieldError("Invalid option")) 

130 

131 def to_form(self, value: Enum | None) -> str: # type: ignore[override] 

132 if value is None: 

133 return "" 

134 return str(value.value) 

135 

136 def render_column(self, record: Any, value: Enum | str | None) -> Element: 

137 if value is None: 

138 return Element("span", "\u2014", class_="text-muted") 

139 raw = value.value if isinstance(value, Enum) else value 

140 label = self._get_label(str(raw)) 

141 display = label if label is not None else raw 

142 return Element("span", display) 

143 

144 def render_form( 

145 self, value: Enum | str | None, *, errors: list[str] | None = None 

146 ) -> Element: 

147 raw = value.value if isinstance(value, Enum) else value 

148 return super().render_form(raw, errors=errors) 

149 

150 

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

152class MultiSelectField(SelectField): 

153 """A multi-select field allowing multiple value selection.""" 

154 

155 def render_form( # type: ignore[override] 

156 self, value: list[str] | None, *, errors: list[str] | None = None 

157 ) -> Element: 

158 kwargs: dict[str, Any] = { 

159 "name": self.name, 

160 "choices": self.options, 

161 } 

162 if value is not None: 

163 kwargs["value"] = value 

164 if self.label is not None: 

165 kwargs["label"] = self.label 

166 if errors: 

167 kwargs["error"] = errors[0] 

168 if self.placeholder is not None: 

169 kwargs["placeholder"] = self.placeholder 

170 if self.readonly: 

171 kwargs["disabled"] = True 

172 return MultiSelect(**kwargs).render() 

173 

174 def render_column(self, record: Any, value: list[str] | None) -> Element: # type: ignore[override] 

175 if value is None: 

176 return Element("span", "\u2014", class_="text-muted") 

177 labels: list[str] = [] 

178 for v in value: 

179 label = self._get_label(v) 

180 labels.append(label if label is not None else v) 

181 return Element("span", ", ".join(labels)) 

182 

183 def from_form(self, raw: str | None) -> Result[list[str] | None, FieldError]: # type: ignore[override] 

184 if raw is None: 

185 return Ok(None) 

186 stripped = raw.strip() 

187 if not stripped: 

188 if self.nullable: 

189 return Ok(None) 

190 return Err(FieldError("Invalid option")) 

191 values = [v.strip() for v in stripped.split(",") if v.strip()] 

192 for v in values: 

193 if self._get_label(v) is None: 

194 return Err(FieldError(f"Invalid option: {v}")) 

195 return Ok(values) 

196 

197 def to_form(self, value: list[str] | None) -> str: # type: ignore[override] 

198 if value is None: 

199 return "" 

200 return ",".join(str(v) for v in value) 

201 

202 def render_filter(self, current_value: Any | None = None) -> Element | None: 

203 return Select( 

204 name=self.name, 

205 choices=self.options, 

206 multiple=True, 

207 value=current_value, 

208 ).render() 

209 

210 

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

212class RadioField(SelectField): 

213 """A radio button group field for single selection.""" 

214 

215 def render_form( 

216 self, value: str | None, *, errors: list[str] | None = None 

217 ) -> Element: 

218 kwargs: dict[str, Any] = { 

219 "name": self.name, 

220 "choices": self.options, 

221 "value": value if value is not None else "", 

222 } 

223 if self.label is not None: 

224 kwargs["label"] = self.label 

225 if errors: 

226 kwargs["error"] = errors[0] 

227 if self.readonly: 

228 kwargs["disabled"] = True 

229 return Radio(**kwargs).render()