Coverage for src/lexigram/admin/forms/builder.py: 0%
247 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
1"""Form builder with Pydantic integration.
3Provides a declarative API for building forms from Pydantic models
4with automatic validation, HTMX integration, and customization.
5"""
7from __future__ import annotations
9from dataclasses import dataclass
10from dataclasses import field as dataclass_field
11from datetime import date, datetime
12from enum import Enum
13from typing import (
14 TYPE_CHECKING,
15 Any,
16 Generic,
17 TypeVar,
18 get_args,
19 get_origin,
20 get_type_hints,
21)
23from lexigram.admin.schema import (
24 BooleanField,
25 DateField,
26 DateTimeField,
27 EmailField,
28 EnumField,
29 IntegerField,
30 PasswordField,
31 SchemaField,
32 SelectField,
33 TextAreaField,
34 TextField,
35)
36from lexigram.domain import DomainModel
37from lexigram.logging import get_logger
38from lexigram.primitives.builder import AbstractBuilder
39from lexigram.ui import el, render_to_string
41if TYPE_CHECKING:
42 from collections.abc import Callable
44logger = get_logger(__name__)
46T = TypeVar("T", bound=DomainModel)
49@dataclass
50class FieldConfig:
51 """Configuration for a form field."""
53 name: str
54 label: str | None = None
55 help_text: str | None = None
56 placeholder: str | None = None
57 required: bool = True
58 disabled: bool = False
59 hidden: bool = False
60 widget: str | None = None # Override default widget
61 order: int = 100
62 group: str | None = None
63 col_span: int = 1 # For grid layouts
64 validators: list[Callable] = dataclass_field(default_factory=list)
65 props: dict[str, Any] = dataclass_field(default_factory=dict)
68class FormBuilder(AbstractBuilder["Form[T]"], Generic[T]):
69 """Build forms from Pydantic models.
71 Usage:
72 @dataclass(init=False)
73 class UserForm(DomainModel):
74 name: str
75 email: str
76 age: int
78 form = (FormBuilder(UserForm)
79 .field("name", label="Full Name", placeholder="Enter your name")
80 .field("email", widget="email")
81 .field("age", help_text="Must be 18 or older")
82 .exclude("password")
83 .build())
84 """
86 def __init__(self, model: type[T]):
87 super().__init__()
88 self.model = model
89 self._field_configs: dict[str, FieldConfig] = {}
90 self._excluded: set[str] = set()
91 self._groups: dict[str, list[str]] = {}
92 self._layout: str = "vertical" # "vertical", "horizontal", "grid"
93 self._columns: int = 1
94 self._submit_label: str = "Submit"
95 self._cancel_url: str | None = None
97 self._form_class: type | None = None
99 # Extract fields from model
100 self._init_from_model()
102 def _init_from_model(self) -> None:
103 """Initialize field configs from Pydantic model."""
104 hints = get_type_hints(self.model)
105 model_fields = self.model.model_fields # type: ignore[attr-defined]
107 for name, field_info in model_fields.items():
108 hints.get(name, str)
110 # Determine if required
111 required = field_info.is_required()
113 # Generate label from name
114 label = name.replace("_", " ").title()
116 # Get help text from description
117 help_text = field_info.description
119 # Determine default
121 self._field_configs[name] = FieldConfig(
122 name=name,
123 label=label,
124 help_text=help_text,
125 required=required,
126 )
128 def field(
129 self,
130 name: str,
131 *,
132 label: str | None = None,
133 help_text: str | None = None,
134 placeholder: str | None = None,
135 required: bool | None = None,
136 disabled: bool = False,
137 hidden: bool = False,
138 widget: str | None = None,
139 order: int | None = None,
140 group: str | None = None,
141 col_span: int = 1,
142 validators: list[Callable] | None = None,
143 **props: Any,
144 ) -> FormBuilder[T]:
145 """Configure a field."""
146 if name not in self._field_configs:
147 raise ValueError(f"Unknown field: {name}")
149 config = self._field_configs[name]
151 if label is not None:
152 config.label = label
153 if help_text is not None:
154 config.help_text = help_text
155 if placeholder is not None:
156 config.placeholder = placeholder
157 if required is not None:
158 config.required = required
159 if disabled:
160 config.disabled = disabled
161 if hidden:
162 config.hidden = hidden
163 if widget is not None:
164 config.widget = widget
165 if order is not None:
166 config.order = order
167 if group is not None:
168 config.group = group
169 if group not in self._groups:
170 self._groups[group] = []
171 if name not in self._groups[group]:
172 self._groups[group].append(name)
173 if validators:
174 config.validators.extend(validators)
176 config.col_span = col_span
177 config.props.update(props)
179 return self
181 def exclude(self, *fields: str) -> FormBuilder[T]:
182 """Exclude fields from the form."""
183 self._excluded.update(fields)
184 return self
186 def include_only(self, *fields: str) -> FormBuilder[T]:
187 """Include only specified fields."""
188 all_fields = set(self._field_configs.keys())
189 self._excluded = all_fields - set(fields)
190 return self
192 def group(
193 self,
194 name: str,
195 *fields: str,
196 label: str | None = None,
197 ) -> FormBuilder[T]:
198 """Group fields together."""
199 self._groups[name] = list(fields)
200 for field_name in fields:
201 if field_name in self._field_configs:
202 self._field_configs[field_name].group = name
203 return self
205 def layout(self, layout: str, columns: int = 1) -> FormBuilder[T]:
206 """Set form layout."""
207 self._layout = layout
208 self._columns = columns
209 return self
211 def submit_label(self, label: str) -> FormBuilder[T]:
212 """Set submit button label."""
213 self._submit_label = label
214 return self
216 def cancel_url(self, url: str) -> FormBuilder[T]:
217 """Set cancel button URL."""
218 self._cancel_url = url
219 return self
221 @classmethod
222 def create(cls, form_class: type | None = None) -> FormBuilder:
223 """Create a fluent FormBuilder without a Pydantic model.
225 Supports the create()/text()/build() API for lightweight dynamic forms.
226 """
227 builder: FormBuilder = cls.__new__(cls)
228 super(FormBuilder, builder).__init__()
229 builder.model = None # type: ignore[assignment]
230 builder._field_configs = {}
231 builder._excluded = set()
232 builder._groups = {}
233 builder._layout = "vertical"
234 builder._columns = 1
235 builder._submit_label = "Submit"
236 builder._cancel_url = None
237 builder._form_class = form_class
238 return builder
240 def text(self, name: str, **kwargs: Any) -> FormBuilder[T]:
241 """Add a text field to the form (fluent API)."""
242 label: str = kwargs.get("label") or name.replace("_", " ").title()
243 self._field_configs[name] = FieldConfig(
244 name=name,
245 label=label,
246 help_text=kwargs.get("help_text"),
247 placeholder=kwargs.get("placeholder"),
248 required=kwargs.get("required", True),
249 )
250 return self
252 def build(self) -> Form[T]:
253 """Build the form."""
254 fields: list[SchemaField] = []
255 hints = get_type_hints(self.model) if self.model is not None else {}
257 # Sort by order
258 sorted_configs = sorted(self._field_configs.items(), key=lambda x: x[1].order)
260 for name, config in sorted_configs:
261 if name in self._excluded or config.hidden:
262 continue
264 field_type = hints.get(name, str)
265 field = self._create_field(name, field_type, config)
266 fields.append(field)
268 return Form(
269 model=self.model,
270 fields=fields,
271 groups=self._groups,
272 layout=self._layout,
273 columns=self._columns,
274 submit_label=self._submit_label,
275 cancel_url=self._cancel_url,
276 )
278 def _create_field(
279 self,
280 name: str,
281 field_type: type,
282 config: FieldConfig,
283 ) -> SchemaField:
284 """Create a SchemaField instance from type and config."""
285 # Handle widget override
286 if config.widget:
287 return self._create_field_by_widget(name, config)
289 # Handle Optional types
290 origin = get_origin(field_type)
291 if origin is type(None) or str(origin) == "typing.Union":
292 args = get_args(field_type)
293 # Get the non-None type
294 for arg in args:
295 if arg is not type(None):
296 field_type = arg
297 config.required = False
298 break
300 common: dict[str, Any] = {
301 "name": name,
302 "label": config.label,
303 "help_text": config.help_text,
304 "required": config.required,
305 "readonly": config.disabled,
306 }
308 # Map Python types to schema field classes
309 if field_type is str:
310 return TextField(**common)
311 if field_type is int:
312 return IntegerField(**common)
313 if field_type is bool:
314 return BooleanField(**common)
315 if field_type is date:
316 return DateField(**common)
317 if field_type is datetime:
318 return DateTimeField(**common)
319 if isinstance(field_type, type) and issubclass(field_type, Enum):
320 return EnumField(
321 **common,
322 enum_cls=field_type,
323 )
324 # Default to text
325 return TextField(**common)
327 def _create_field_by_widget(self, name: str, config: FieldConfig) -> SchemaField:
328 """Create field by widget name."""
329 widget = config.widget
330 common: dict[str, Any] = {
331 "name": name,
332 "label": config.label,
333 "help_text": config.help_text,
334 "required": config.required,
335 "readonly": config.disabled,
336 }
337 if widget == "textarea":
338 return TextAreaField(**common)
339 if widget == "email":
340 return EmailField(**common)
341 if widget == "password":
342 return PasswordField(**common)
343 if widget == "switch":
344 return BooleanField(**common)
345 if widget == "select":
346 options = config.props.get("options", [])
347 return SelectField(**common, options=options)
348 return TextField(**common)
351@dataclass
352class FormResult(Generic[T]):
353 """Result of form validation."""
355 success: bool
356 data: T | None = None
357 errors: dict[str, list[str]] = dataclass_field(default_factory=dict)
359 @property
360 def is_valid(self) -> bool:
361 return self.success and not self.errors
364class Form(Generic[T]):
365 """A built form ready for rendering and validation."""
367 def __init__(
368 self,
369 model: type[T],
370 fields: list[SchemaField],
371 groups: dict[str, list[str]],
372 layout: str,
373 columns: int,
374 submit_label: str,
375 cancel_url: str | None,
376 ):
377 self.model = model
378 self.fields = {f.name: f for f in fields}
379 self.field_list = fields
380 self.groups = groups
381 self.layout = layout
382 self.columns = columns
383 self.submit_label = submit_label
384 self.cancel_url = cancel_url
385 self.values: dict[str, Any] = {}
386 self.errors: dict[str, list[str]] = {}
388 def bind(self, data: dict[str, Any]) -> Form[T]:
389 """Bind data to form values."""
390 for name in self.fields:
391 if name in data:
392 self.values[name] = data[name]
393 return self
395 async def validate(self, data: dict[str, Any]) -> FormResult[T]:
396 """Validate form data against model."""
397 errors: dict[str, list[str]] = {}
398 cleaned: dict[str, Any] = {}
400 # Run field-level validation first
401 for name, field in self.fields.items():
402 if name not in data:
403 continue
404 raw = data.get(name)
405 raw = raw if raw is None or isinstance(raw, str) else str(raw)
406 result = field.from_form(raw)
407 if result.is_err():
408 message = str(result.unwrap_err())
409 errors.setdefault(name, []).append(message)
410 self.errors[name] = [message]
411 else:
412 value = result.unwrap()
413 if field.required and (
414 value is None or (isinstance(value, str) and not value)
415 ):
416 message = "This field is required."
417 errors.setdefault(name, []).append(message)
418 self.errors[name] = [message]
419 else:
420 cleaned[name] = value
422 # If field validation passed, try Pydantic model validation
423 if not errors and self.model is not None:
424 try:
425 instance = self.model.model_validate(cleaned)
426 return FormResult(success=True, data=instance)
427 except (ValueError, TypeError, AttributeError) as e:
428 if hasattr(e, "errors"):
429 for error in e.errors():
430 field_name = (
431 str(error["loc"][0]) if error["loc"] else "__root__"
432 )
433 message = error["msg"]
434 errors.setdefault(field_name, []).append(message)
435 self.errors[field_name] = [message]
436 else:
437 message = str(e)
438 errors.setdefault("__root__", []).append(message)
439 self.errors["__root__"] = [message]
441 return FormResult(success=False, errors=errors)
443 def _render_field_els(self) -> list[Any]:
444 """Render all field elements with current bound values."""
445 return [
446 el(
447 "div",
448 field.render_form(self.values.get(name)),
449 class_="form-field",
450 )
451 for name, field in self.fields.items()
452 ]
454 def render_html(self, action: str, method: str = "POST") -> str:
455 """Render form as HTML."""
456 field_els = self._render_field_els()
457 btns: list[Any] = [
458 el("button", self.submit_label, type="submit", class_="btn btn-primary")
459 ]
460 if self.cancel_url:
461 btns.append(
462 el("a", "Cancel", href=self.cancel_url, class_="btn btn-secondary")
463 )
464 return render_to_string(
465 el(
466 "form",
467 el(
468 "div",
469 *field_els,
470 class_="form-fields",
471 style=f"display:grid;grid-template-columns:repeat({self.columns},1fr);gap:1rem",
472 ),
473 el("div", *btns, class_="form-actions", style="margin-top:1.5rem"),
474 action=action,
475 method=method,
476 class_=f"admin-form layout-{self.layout}",
477 )
478 )
480 def render_htmx(
481 self,
482 action: str,
483 target: str = "#form-result",
484 swap: str = "innerHTML",
485 ) -> str:
486 """Render form with HTMX attributes."""
487 field_els = self._render_field_els()
488 btns: list[Any] = [
489 el("button", self.submit_label, type="submit", class_="btn btn-primary")
490 ]
491 if self.cancel_url:
492 btns.append(
493 el("a", "Cancel", href=self.cancel_url, class_="btn btn-secondary")
494 )
495 spinner_id = target.lstrip("#") + "-spinner"
496 return render_to_string(
497 el(
498 "form",
499 el("div", "Saving...", id=spinner_id, class_="htmx-indicator"),
500 el(
501 "div",
502 *field_els,
503 class_="form-fields",
504 style=f"display:grid;grid-template-columns:repeat({self.columns},1fr);gap:1rem",
505 ),
506 el("div", *btns, class_="form-actions", style="margin-top:1.5rem"),
507 **{
508 "hx-post": action,
509 "hx-target": target,
510 "hx-swap": swap,
511 "hx-indicator": "#form-spinner",
512 "class": f"admin-form layout-{self.layout}",
513 },
514 )
515 )