Coverage for src/lexigram/admin/schema/select.py: 18%
156 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
1from __future__ import annotations
3from dataclasses import dataclass, field
4from enum import Enum
5from typing import Any
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
13@dataclass(frozen=True, kw_only=True)
14class SelectField(SchemaField[str]):
15 """A select / dropdown field."""
17 options: list[tuple[str, str]] = field(default_factory=list)
19 def __post_init__(self) -> None:
20 normalized = self._normalize_options(self.options)
21 object.__setattr__(self, "options", normalized)
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 []
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
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()
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)
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()
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)
87 def to_form(self, value: str | None) -> str:
88 if value is None:
89 return ""
90 return str(value)
93@dataclass(frozen=True, kw_only=True)
94class EnumField(SelectField):
95 """A select field backed by a Python Enum class.
97 Auto-derives options from the enum members and coerces form values
98 back to enum members on read.
99 """
101 enum_cls: type[Enum] | None = None
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__()
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
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"))
131 def to_form(self, value: Enum | None) -> str: # type: ignore[override]
132 if value is None:
133 return ""
134 return str(value.value)
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)
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)
151@dataclass(frozen=True, kw_only=True)
152class MultiSelectField(SelectField):
153 """A multi-select field allowing multiple value selection."""
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()
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))
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)
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)
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()
211@dataclass(frozen=True, kw_only=True)
212class RadioField(SelectField):
213 """A radio button group field for single selection."""
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()