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

150 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, 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 return Select(**kwargs).render() 

58 

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

60 if value is None: 

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

62 label = self._get_label(value) 

63 display = label if label is not None else value 

64 return Element("span", display) 

65 

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

67 return Select( 

68 name=self.name, 

69 choices=self.options, 

70 value=current_value, 

71 ).render() 

72 

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

74 if raw is None: 

75 return Ok(None) 

76 stripped = raw.strip() 

77 if not stripped: 

78 if self.nullable: 

79 return Ok(None) 

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

81 if self._get_label(stripped) is None: 

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

83 return Ok(stripped) 

84 

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

86 if value is None: 

87 return "" 

88 return str(value) 

89 

90 

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

92class EnumField(SelectField): 

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

94 

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

96 back to enum members on read. 

97 """ 

98 

99 enum_cls: type[Enum] | None = None 

100 

101 def __post_init__(self) -> None: 

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

103 derived = [ 

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

105 ] 

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

107 super().__post_init__() 

108 

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

110 if self.enum_cls is None: 

111 return None 

112 for m in self.enum_cls: 

113 if m.value == value: 

114 return m 

115 return None 

116 

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

118 result = super().from_form(raw) 

119 if result.is_err(): 

120 return Err(result.unwrap_err()) 

121 value = result.unwrap() 

122 if value is None: 

123 return Ok(None) 

124 member = self._get_enum_member(value) 

125 if member is not None: 

126 return Ok(member) 

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

128 

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

130 if value is None: 

131 return "" 

132 return str(value.value) 

133 

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

135 if value is None: 

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

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

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

139 display = label if label is not None else raw 

140 return Element("span", display) 

141 

142 def render_form( 

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

144 ) -> Element: 

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

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

147 

148 

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

150class MultiSelectField(SelectField): 

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

152 

153 def render_form( # type: ignore[override] 

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

155 ) -> Element: 

156 kwargs: dict[str, Any] = { 

157 "name": self.name, 

158 "choices": self.options, 

159 } 

160 if value is not None: 

161 kwargs["value"] = value 

162 if self.label is not None: 

163 kwargs["label"] = self.label 

164 if errors: 

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

166 if self.placeholder is not None: 

167 kwargs["placeholder"] = self.placeholder 

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

169 

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

171 if value is None: 

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

173 labels: list[str] = [] 

174 for v in value: 

175 label = self._get_label(v) 

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

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

178 

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

180 if raw is None: 

181 return Ok(None) 

182 stripped = raw.strip() 

183 if not stripped: 

184 if self.nullable: 

185 return Ok(None) 

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

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

188 for v in values: 

189 if self._get_label(v) is None: 

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

191 return Ok(values) 

192 

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

194 if value is None: 

195 return "" 

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

197 

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

199 return Select( 

200 name=self.name, 

201 choices=self.options, 

202 multiple=True, 

203 value=current_value, 

204 ).render() 

205 

206 

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

208class RadioField(SelectField): 

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

210 

211 def render_form( 

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

213 ) -> Element: 

214 kwargs: dict[str, Any] = { 

215 "name": self.name, 

216 "choices": self.options, 

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

218 } 

219 if self.label is not None: 

220 kwargs["label"] = self.label 

221 if errors: 

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

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