Coverage for src / lexigram / admin / forms / builder.py: 25%
229 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
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.forms.fields import (
24 AbstractField,
25 BooleanField,
26 DateField,
27 IntegerField,
28 SelectField,
29 TextAreaField,
30 TextField,
31)
32from lexigram.domain import DomainModel
33from lexigram.logging import get_logger
34from lexigram.primitives.builder import AbstractBuilder
35from lexigram.ui import el, render_to_string
37if TYPE_CHECKING:
38 from collections.abc import Callable
40logger = get_logger(__name__)
42T = TypeVar("T", bound=DomainModel)
45@dataclass
46class FieldConfig:
47 """Configuration for a form field."""
49 name: str
50 label: str | None = None
51 help_text: str | None = None
52 placeholder: str | None = None
53 required: bool = True
54 disabled: bool = False
55 hidden: bool = False
56 widget: str | None = None # Override default widget
57 order: int = 100
58 group: str | None = None
59 col_span: int = 1 # For grid layouts
60 validators: list[Callable] = dataclass_field(default_factory=list)
61 props: dict[str, Any] = dataclass_field(default_factory=dict)
64class FormBuilder(AbstractBuilder["Form[T]"], Generic[T]):
65 """Build forms from Pydantic models.
67 Usage:
68 @dataclass(init=False)
69 class UserForm(DomainModel):
70 name: str
71 email: str
72 age: int
74 form = (FormBuilder(UserForm)
75 .field("name", label="Full Name", placeholder="Enter your name")
76 .field("email", widget="email")
77 .field("age", help_text="Must be 18 or older")
78 .exclude("password")
79 .build())
80 """
82 def __init__(self, model: type[T]):
83 super().__init__()
84 self.model = model
85 self._field_configs: dict[str, FieldConfig] = {}
86 self._excluded: set[str] = set()
87 self._groups: dict[str, list[str]] = {}
88 self._layout: str = "vertical" # "vertical", "horizontal", "grid"
89 self._columns: int = 1
90 self._submit_label: str = "Submit"
91 self._cancel_url: str | None = None
93 self._form_class: type | None = None
95 # Extract fields from model
96 self._init_from_model()
98 def _init_from_model(self) -> None:
99 """Initialize field configs from Pydantic model."""
100 hints = get_type_hints(self.model)
101 model_fields = self.model.model_fields # type: ignore[attr-defined]
103 for name, field_info in model_fields.items():
104 hints.get(name, str)
106 # Determine if required
107 required = field_info.is_required()
109 # Generate label from name
110 label = name.replace("_", " ").title()
112 # Get help text from description
113 help_text = field_info.description
115 # Determine default
117 self._field_configs[name] = FieldConfig(
118 name=name,
119 label=label,
120 help_text=help_text,
121 required=required,
122 )
124 def field(
125 self,
126 name: str,
127 *,
128 label: str | None = None,
129 help_text: str | None = None,
130 placeholder: str | None = None,
131 required: bool | None = None,
132 disabled: bool = False,
133 hidden: bool = False,
134 widget: str | None = None,
135 order: int | None = None,
136 group: str | None = None,
137 col_span: int = 1,
138 validators: list[Callable] | None = None,
139 **props: Any,
140 ) -> FormBuilder[T]:
141 """Configure a field."""
142 if name not in self._field_configs:
143 raise ValueError(f"Unknown field: {name}")
145 config = self._field_configs[name]
147 if label is not None:
148 config.label = label
149 if help_text is not None:
150 config.help_text = help_text
151 if placeholder is not None:
152 config.placeholder = placeholder
153 if required is not None:
154 config.required = required
155 if disabled:
156 config.disabled = disabled
157 if hidden:
158 config.hidden = hidden
159 if widget is not None:
160 config.widget = widget
161 if order is not None:
162 config.order = order
163 if group is not None:
164 config.group = group
165 if group not in self._groups:
166 self._groups[group] = []
167 if name not in self._groups[group]:
168 self._groups[group].append(name)
169 if validators:
170 config.validators.extend(validators)
172 config.col_span = col_span
173 config.props.update(props)
175 return self
177 def exclude(self, *fields: str) -> FormBuilder[T]:
178 """Exclude fields from the form."""
179 self._excluded.update(fields)
180 return self
182 def include_only(self, *fields: str) -> FormBuilder[T]:
183 """Include only specified fields."""
184 all_fields = set(self._field_configs.keys())
185 self._excluded = all_fields - set(fields)
186 return self
188 def group(
189 self,
190 name: str,
191 *fields: str,
192 label: str | None = None,
193 ) -> FormBuilder[T]:
194 """Group fields together."""
195 self._groups[name] = list(fields)
196 for field_name in fields:
197 if field_name in self._field_configs:
198 self._field_configs[field_name].group = name
199 return self
201 def layout(self, layout: str, columns: int = 1) -> FormBuilder[T]:
202 """Set form layout."""
203 self._layout = layout
204 self._columns = columns
205 return self
207 def submit_label(self, label: str) -> FormBuilder[T]:
208 """Set submit button label."""
209 self._submit_label = label
210 return self
212 def cancel_url(self, url: str) -> FormBuilder[T]:
213 """Set cancel button URL."""
214 self._cancel_url = url
215 return self
217 @classmethod
218 def create(cls, form_class: type | None = None) -> FormBuilder:
219 """Create a fluent FormBuilder without a Pydantic model.
221 Supports the create()/text()/build() API for lightweight dynamic forms.
222 """
223 builder: FormBuilder = cls.__new__(cls)
224 super(FormBuilder, builder).__init__()
225 builder.model = None # type: ignore[assignment]
226 builder._field_configs = {}
227 builder._excluded = set()
228 builder._groups = {}
229 builder._layout = "vertical"
230 builder._columns = 1
231 builder._submit_label = "Submit"
232 builder._cancel_url = None
233 builder._form_class = form_class
234 return builder
236 def text(self, name: str, **kwargs: Any) -> FormBuilder[T]:
237 """Add a text field to the form (fluent API)."""
238 label: str = kwargs.get("label") or name.replace("_", " ").title()
239 self._field_configs[name] = FieldConfig(
240 name=name,
241 label=label,
242 help_text=kwargs.get("help_text"),
243 placeholder=kwargs.get("placeholder"),
244 required=kwargs.get("required", True),
245 )
246 return self
248 def build(self) -> Form[T]:
249 """Build the form."""
250 fields: list[AbstractField] = []
251 hints = get_type_hints(self.model) if self.model is not None else {}
253 # Sort by order
254 sorted_configs = sorted(self._field_configs.items(), key=lambda x: x[1].order)
256 for name, config in sorted_configs:
257 if name in self._excluded or config.hidden:
258 continue
260 field_type = hints.get(name, str)
261 field = self._create_field(name, field_type, config)
262 fields.append(field)
264 return Form(
265 model=self.model,
266 fields=fields,
267 groups=self._groups,
268 layout=self._layout,
269 columns=self._columns,
270 submit_label=self._submit_label,
271 cancel_url=self._cancel_url,
272 )
274 def _create_field(
275 self,
276 name: str,
277 field_type: type,
278 config: FieldConfig,
279 ) -> AbstractField:
280 """Create a Field instance from type and config."""
281 # Handle widget override
282 if config.widget:
283 return self._create_field_by_widget(name, config)
285 # Handle Optional types
286 origin = get_origin(field_type)
287 if origin is type(None) or str(origin) == "typing.Union":
288 args = get_args(field_type)
289 # Get the non-None type
290 for arg in args:
291 if arg is not type(None):
292 field_type = arg
293 config.required = False
294 break
296 # Map Python types to Field classes
297 if field_type is str:
298 return TextField(
299 name=name,
300 label=config.label,
301 help_text=config.help_text,
302 placeholder=config.placeholder,
303 required=config.required,
304 disabled=config.disabled,
305 validators=config.validators,
306 **config.props,
307 )
308 if field_type is int:
309 return IntegerField(
310 name=name,
311 label=config.label,
312 help_text=config.help_text,
313 placeholder=config.placeholder,
314 required=config.required,
315 disabled=config.disabled,
316 validators=config.validators,
317 **config.props,
318 )
319 if field_type is bool:
320 return BooleanField(
321 name=name,
322 label=config.label,
323 help_text=config.help_text,
324 required=config.required,
325 disabled=config.disabled,
326 validators=config.validators,
327 **config.props,
328 )
329 if field_type in (date, datetime):
330 return DateField(
331 name=name,
332 label=config.label,
333 help_text=config.help_text,
334 required=config.required,
335 disabled=config.disabled,
336 validators=config.validators,
337 **config.props,
338 )
339 if issubclass(field_type, Enum) if isinstance(field_type, type) else False:
340 options = [(e.value, e.name.replace("_", " ").title()) for e in field_type] # type: ignore[attr-defined]
341 return SelectField(
342 name=name,
343 label=config.label,
344 help_text=config.help_text,
345 options=options,
346 required=config.required,
347 disabled=config.disabled,
348 validators=config.validators,
349 **config.props,
350 )
351 # Default to text
352 return TextField(
353 name=name,
354 label=config.label,
355 help_text=config.help_text,
356 placeholder=config.placeholder,
357 required=config.required,
358 disabled=config.disabled,
359 validators=config.validators,
360 **config.props,
361 )
363 def _create_field_by_widget(self, name: str, config: FieldConfig) -> AbstractField:
364 """Create field by widget name."""
365 widget = config.widget
367 if widget == "textarea":
368 return TextAreaField(
369 name=name,
370 label=config.label,
371 help_text=config.help_text,
372 placeholder=config.placeholder,
373 required=config.required,
374 disabled=config.disabled,
375 **config.props,
376 )
377 if widget == "email":
378 return TextField(
379 name=name,
380 label=config.label,
381 help_text=config.help_text,
382 placeholder=config.placeholder,
383 required=config.required,
384 disabled=config.disabled,
385 type="email",
386 **config.props,
387 )
388 if widget == "password":
389 return TextField(
390 name=name,
391 label=config.label,
392 help_text=config.help_text,
393 placeholder=config.placeholder,
394 required=config.required,
395 disabled=config.disabled,
396 type="password",
397 **config.props,
398 )
399 if widget == "switch":
400 return BooleanField(
401 name=name,
402 label=config.label,
403 help_text=config.help_text,
404 required=config.required,
405 disabled=config.disabled,
406 widget="switch",
407 **config.props,
408 )
409 return TextField(
410 name=name,
411 label=config.label,
412 help_text=config.help_text,
413 placeholder=config.placeholder,
414 required=config.required,
415 disabled=config.disabled,
416 **config.props,
417 )
420@dataclass
421class FormResult(Generic[T]):
422 """Result of form validation."""
424 success: bool
425 data: T | None = None
426 errors: dict[str, list[str]] = dataclass_field(default_factory=dict)
428 @property
429 def is_valid(self) -> bool:
430 return self.success and not self.errors
433class Form(Generic[T]):
434 """A built form ready for rendering and validation."""
436 def __init__(
437 self,
438 model: type[T],
439 fields: list[AbstractField],
440 groups: dict[str, list[str]],
441 layout: str,
442 columns: int,
443 submit_label: str,
444 cancel_url: str | None,
445 ):
446 self.model = model
447 self.fields = {f.name: f for f in fields}
448 self.field_list = fields
449 self.groups = groups
450 self.layout = layout
451 self.columns = columns
452 self.submit_label = submit_label
453 self.cancel_url = cancel_url
455 def bind(self, data: dict[str, Any]) -> Form[T]:
456 """Bind data to form fields."""
457 for name, field in self.fields.items():
458 if name in data:
459 field.value = data[name]
460 field.is_bound = True
461 return self
463 async def validate(self, data: dict[str, Any]) -> FormResult[T]:
464 """Validate form data against model."""
465 errors: dict[str, list[str]] = {}
467 # Run field-level validation first
468 for name, field in self.fields.items():
469 value = data.get(name)
470 try:
471 field.validate(value)
472 # Run async validation if present
473 if field.async_validator:
474 await field.run_async_validation(value)
475 except ValueError as e:
476 errors.setdefault(name, []).append(str(e))
477 field.errors = [str(e)]
479 # If field validation passed, try Pydantic model validation
480 if not errors:
481 try:
482 instance = self.model.model_validate(data)
483 return FormResult(success=True, data=instance)
484 except (ValueError, TypeError, AttributeError) as e:
485 if hasattr(e, "errors"):
486 for error in e.errors():
487 field_name = (
488 str(error["loc"][0]) if error["loc"] else "__root__"
489 )
490 message = error["msg"]
491 errors.setdefault(field_name, []).append(message)
492 if field_name in self.fields:
493 self.fields[field_name].errors = [message]
494 else:
495 errors.setdefault("__root__", []).append(str(e))
497 return FormResult(success=False, errors=errors)
499 def render_html(self, action: str, method: str = "POST") -> str:
500 """Render form as HTML."""
501 field_els = [
502 el("div", field.render(), class_="form-field") for field in self.field_list
503 ]
504 btns: list[Any] = [
505 el("button", self.submit_label, type="submit", class_="btn btn-primary")
506 ]
507 if self.cancel_url:
508 btns.append(
509 el("a", "Cancel", href=self.cancel_url, class_="btn btn-secondary")
510 )
511 return render_to_string(
512 el(
513 "form",
514 el(
515 "div",
516 *field_els,
517 class_="form-fields",
518 style=f"display:grid;grid-template-columns:repeat({self.columns},1fr);gap:1rem",
519 ),
520 el("div", *btns, class_="form-actions", style="margin-top:1.5rem"),
521 action=action,
522 method=method,
523 class_=f"admin-form layout-{self.layout}",
524 )
525 )
527 def render_htmx(
528 self,
529 action: str,
530 target: str = "#form-result",
531 swap: str = "innerHTML",
532 ) -> str:
533 """Render form with HTMX attributes."""
534 field_els = [
535 el("div", field.render(), class_="form-field") for field in self.field_list
536 ]
537 btns: list[Any] = [
538 el("button", self.submit_label, type="submit", class_="btn btn-primary")
539 ]
540 if self.cancel_url:
541 btns.append(
542 el("a", "Cancel", href=self.cancel_url, class_="btn btn-secondary")
543 )
544 spinner_id = target.lstrip("#") + "-spinner"
545 return render_to_string(
546 el(
547 "form",
548 el("div", "Saving...", id=spinner_id, class_="htmx-indicator"),
549 el(
550 "div",
551 *field_els,
552 class_="form-fields",
553 style=f"display:grid;grid-template-columns:repeat({self.columns},1fr);gap:1rem",
554 ),
555 el("div", *btns, class_="form-actions", style="margin-top:1.5rem"),
556 **{
557 "hx-post": action,
558 "hx-target": target,
559 "hx-swap": swap,
560 "hx-indicator": "#form-spinner",
561 "class": f"admin-form layout-{self.layout}",
562 },
563 )
564 )