Coverage for src / lexigram / admin / forms / fields / _base.py: 38%
254 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 abc import ABC, abstractmethod
4import copy
5from dataclasses import dataclass, field
6from enum import StrEnum
7from typing import TYPE_CHECKING, Any, Self
9from lexigram.logging import get_logger
11if TYPE_CHECKING:
12 from collections.abc import Callable
14 from lexigram.ui.core.base import Component
16logger = get_logger(__name__)
19class FieldType(StrEnum):
20 """Supported form field types."""
22 TEXT = "text"
23 NUMBER = "number"
24 EMAIL = "email"
25 PASSWORD = "password"
26 CHECKBOX = "checkbox"
27 SELECT = "select"
28 MULTI_SELECT = "multi_select"
29 DATE = "date"
30 DATETIME = "datetime"
31 TEXTAREA = "textarea"
32 FILE = "file"
33 IMAGE = "image"
34 NESTED = "nested"
35 LIST = "list"
36 RICH_TEXT = "rich_text"
37 MARKDOWN = "markdown"
38 COLOR = "color"
39 TAGS = "tags"
40 KEY_VALUE = "key_value"
41 JSON = "json"
42 BELONGS_TO = "belongs_to"
43 HAS_MANY = "has_many"
44 MORPH = "morph"
47@dataclass
48class FieldSchema:
49 """Definition of a single form field schema."""
51 name: str
52 label: str
53 type: FieldType = FieldType.TEXT
54 required: bool = False
55 default: Any = None
56 placeholder: str | None = None
57 help_text: str | None = None
58 options: list[dict[str, Any]] = field(default_factory=list)
59 metadata: dict[str, Any] = field(default_factory=dict)
60 nested_schema: Any | None = None # Avoid circular with FormSchema
61 validation: dict[str, Any] = field(default_factory=dict)
62 visible: bool = True
63 editable: bool = True
64 masked: bool = False
65 related_resource: str | None = None
66 """Name of the related admin resource (e.g. 'users', 'pets')."""
67 related_field: str | None = None
68 """The FK field name on the related side (for HAS_MANY)."""
71class Block:
72 """Definition of a content block for the Builder component."""
74 def __init__(
75 self,
76 name: str,
77 fields: list[Any],
78 label: str | None = None,
79 icon: str | None = None,
80 ):
81 self.name = name
82 self.fields = fields
83 self.label = label or name.replace("_", " ").title()
84 self.icon = icon
87class AdminField:
88 """Metadata for a Pydantic field to control how it's rendered in the admin UI."""
90 def __init__(
91 self,
92 label: str | None = None,
93 widget: type | None = None,
94 help_text: str | None = None,
95 belongs_to: str | None = None,
96 belongs_to_many: str | None = None,
97 searchable: bool = False,
98 email: bool = False,
99 url: bool = False,
100 min_value: int | None = None,
101 max_value: int | None = None,
102 regex: str | None = None,
103 unique: bool = False,
104 confirmed: bool = False,
105 **props: Any,
106 ):
107 self._label = label
108 self._widget = widget
109 self._help_text = help_text
110 self._belongs_to = belongs_to
111 self._belongs_to_many = belongs_to_many
112 self._searchable = searchable
113 self.email = email
114 self.url = url
115 self.min = min_value
116 self.max = max_value
117 self.regex = regex
118 self._unique = unique
119 self.confirmed = confirmed
120 self._placeholder: str | None = None
121 self._default_value: Any = None
122 self._disabled = False
123 self._readonly = False
124 self._hidden = False
125 self._visible_condition: Callable[[dict], bool] | None = None
126 self._hint: str | None = None
127 self._props = props
129 def label(self, value: str) -> Self:
130 self._label = value
131 return self
133 def widget(self, value: type) -> Self:
134 self._widget = value
135 return self
137 def props(self, **kwargs: Any) -> Self:
138 self._props.update(kwargs)
139 return self
141 def help_text(self, value: str) -> Self:
142 self._help_text = value
143 return self
145 def placeholder(self, value: str) -> Self:
146 self._placeholder = value
147 return self
149 def default(self, value: Any) -> Self:
150 self._default_value = value
151 return self
153 def disabled(self, value: bool = True) -> Self:
154 self._disabled = value
155 return self
157 def readonly(self, value: bool = True) -> Self:
158 self._readonly = value
159 return self
161 def hidden(self, value: bool = True) -> Self:
162 self._hidden = value
163 return self
165 def visible(self, condition: Callable[[dict], bool]) -> Self:
166 self._visible_condition = condition
167 return self
169 def visible_when(self, expression: str) -> Self:
170 self._props["visible_when"] = expression
171 return self
173 def hint(self, text: str) -> Self:
174 self._hint = text
175 return self
177 def searchable(self, value: bool = True) -> Self:
178 self._searchable = value
179 return self
181 def builder(self, blocks: list[Block]) -> Self:
182 self._props["builder_blocks"] = blocks
183 self._widget = "Builder" # type: ignore[assignment]
184 return self
186 def prefix(self, text: str) -> Self:
187 self._props["prefix"] = text
188 return self
190 def suffix(self, text: str) -> Self:
191 self._props["suffix"] = text
192 return self
194 def mask(self, pattern: str) -> Self:
195 self._props["mask"] = pattern
196 return self
198 def currency(self, code: str = "USD") -> Self:
199 self._props["currency"] = code
200 return self
202 def format_state_using(self, callback: Callable[[Any], Any]) -> Self:
203 self._props["format_state_using"] = callback
204 return self
206 def dehydrate_state_using(self, callback: Callable[[Any], Any]) -> Self:
207 self._props["dehydrate_state_using"] = callback
208 return self
210 def prefill_from(
211 self,
212 field_name: str,
213 transformer_js: str | None = None,
214 ) -> Self:
215 self._props["prefill_from"] = field_name
216 self._props["prefill_transformer"] = transformer_js
217 return self
219 def __call__(self, label: str) -> Self:
220 self._label = label
221 return self
224class AbstractField(ABC):
225 """Abstract base class for all form fields."""
227 def __init__(
228 self,
229 label: str | None = None,
230 required: bool = True,
231 disabled: bool = False,
232 help_text: str | None = None,
233 default: Any = None,
234 placeholder: str | None = None,
235 validators: list[Callable] | None = None,
236 name: str | None = None,
237 realtime_validate: bool = False,
238 async_validate: Callable[[Any], Any] | None = None,
239 format_state_using: Callable[[Any], Any] | None = None,
240 dehydrate_state_using: Callable[[Any], Any] | None = None,
241 autocomplete_source: str | Callable[[], list[str]] | None = None,
242 mask_pattern: str | None = None,
243 prefix: str | None = None,
244 suffix: str | None = None,
245 currency: str | None = None,
246 decimals: int | None = None,
247 prefill_from: str | None = None,
248 ):
249 self.label = label
250 self.required = required
251 self.disabled = disabled
252 self.help_text = help_text
253 self.default = default
254 self.placeholder = placeholder
255 self.validators = validators or []
256 self._name = name
257 self.realtime_validate = realtime_validate
258 self.async_validator = async_validate
259 self.format_state_using = format_state_using
260 self.dehydrate_state_using = dehydrate_state_using
261 self.autocomplete_source = autocomplete_source
262 self.mask_pattern = mask_pattern
263 self.prefix = prefix
264 self.suffix = suffix
265 self._currency = currency
266 self.decimals = decimals
267 self.prefill_from = prefill_from
268 self.aria_label = label
269 self.aria_describedby = f"{self.name}-help" if help_text else None
270 self.aria_errormessage = f"{self.name}-error"
271 self.value: Any = default
272 self.errors: list[str] = []
273 self.is_bound = False
274 self.is_validating = False
276 def get_default(self) -> Any:
277 """Return the default value for this field."""
278 return self.default
280 @property
281 def name(self) -> str:
282 return self._name or "unnamed_field"
284 @name.setter
285 def name(self, value: str) -> Any:
286 self._name = value
288 def bind(self, value: Any) -> AbstractField:
289 new_field = copy.copy(self)
290 new_field.value = value if value is not None else self.default
291 new_field.is_bound = True
292 return new_field
294 def validate(self, value: Any) -> Any:
295 if self.required and value in (None, ""):
296 raise ValueError(f"{self.label or self.name} is required")
297 return value
299 async def run_async_validation(self, value: Any) -> Any:
300 if self.async_validator:
301 try:
302 self.is_validating = True
303 result = await self.async_validator(value)
304 self.is_validating = False
305 return result
306 except Exception as e: # noqa: BLE001 — async validators are user-supplied callables that may raise anything
307 self.is_validating = False
308 logger.exception("Async validator failed for field %s", self._name)
309 raise ValueError(str(e)) from None
310 return value
312 def format_value(self, value: Any) -> Any:
313 if self.format_state_using:
314 try:
315 return self.format_state_using(value)
316 except BaseException:
317 logger.exception("Custom formatter failed for field %s", self._name)
318 return value
320 def dehydrate_value(self, value: Any) -> Any:
321 if self.dehydrate_state_using:
322 try:
323 return self.dehydrate_state_using(value)
324 except BaseException:
325 logger.exception("Custom dehydrator failed for field %s", self._name)
326 return value
328 def apply_mask(self, value: str) -> str:
329 if not self.mask_pattern or not value:
330 return value
331 masked = ""
332 value_idx = 0
333 for char in self.mask_pattern:
334 if char == "#" and value_idx < len(value):
335 if value[value_idx].isdigit():
336 masked += value[value_idx]
337 value_idx += 1
338 else:
339 break
340 elif char != "#" and value_idx < len(value):
341 masked += char
342 if value[value_idx] == char:
343 value_idx += 1
344 elif char != "#":
345 masked += char
346 return masked
348 @abstractmethod
349 def render(self, **kwargs) -> Component:
350 pass
352 def render_with_conditional(self, **kwargs) -> Any:
353 """Render the field, wrapped in an Alpine x-show div when ``visible_when`` is set.
355 This is the preferred call site when rendering fields inside a
356 ``FormBase`` or ``LayoutNode``, as it transparently adds the
357 ``x-show`` / ``x-cloak`` attributes required for declarative
358 show/hide based on other field values.
359 """
360 from lexigram.ui.core.base import el as _el
362 rendered = self.render(**kwargs)
363 expr = getattr(self, "_visible_expression", None)
364 if expr:
365 return _el("div", rendered, **{"x-show": expr, "x-cloak": True})
366 return rendered
368 def visible_when(self, expression: str) -> AbstractField:
369 """Declare a client-side Alpine.js condition controlling visibility.
371 Args:
372 expression: An Alpine.js boolean expression evaluated in the form
373 context, e.g. ``"formData.type === 'premium'"``.
375 Returns:
376 Self for method chaining.
377 """
378 self._visible_expression = expression
379 return self
381 def is_visible(self, form_data: dict[str, Any]) -> bool:
382 """Server-side visibility check (Python-evaluated).
384 For client-side (Alpine.js) show/hide use :meth:`visible_when`.
385 """
386 return True
389# Backward-compatible public symbol expected by field modules/importers.
390Field = AbstractField