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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +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 return Select(**kwargs).render()
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)
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()
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)
85 def to_form(self, value: str | None) -> str:
86 if value is None:
87 return ""
88 return str(value)
91@dataclass(frozen=True, kw_only=True)
92class EnumField(SelectField):
93 """A select field backed by a Python Enum class.
95 Auto-derives options from the enum members and coerces form values
96 back to enum members on read.
97 """
99 enum_cls: type[Enum] | None = None
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__()
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
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"))
129 def to_form(self, value: Enum | None) -> str: # type: ignore[override]
130 if value is None:
131 return ""
132 return str(value.value)
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)
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)
149@dataclass(frozen=True, kw_only=True)
150class MultiSelectField(SelectField):
151 """A multi-select field allowing multiple value selection."""
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()
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))
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)
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)
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()
207@dataclass(frozen=True, kw_only=True)
208class RadioField(SelectField):
209 """A radio button group field for single selection."""
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()