Coverage for src / lexigram / admin / forms / components.py: 21%
218 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"""Unified Form Components.
2Includes FormBase, FormBuilder, and FormSchemaGenerator.
3"""
5from __future__ import annotations
7from dataclasses import dataclass, field
8import types as _builtin_types
9from typing import (
10 TYPE_CHECKING,
11 Any,
12 ClassVar,
13 Union,
14 get_args,
15 get_origin,
16)
18if TYPE_CHECKING:
19 from pydantic.fields import FieldInfo
21 from lexigram.admin.forms.builder import Form
23from lexigram.admin.exceptions import AdminValidationError
24from lexigram.admin.forms.fields import AbstractField, FieldSchema, FieldType
25from lexigram.contracts.exceptions import FieldError
26from lexigram.result import Err, Ok, Result
27from lexigram.ui import Component, el
29try:
30 from lexigram.domain import DomainModel
32 HAS_PYDANTIC = True
33except ImportError:
34 HAS_PYDANTIC = False
37@dataclass
38class FormSchema:
39 """Definition of a complete form structure."""
41 fields: list[FieldSchema] = field(default_factory=list)
42 title: str | None = None
43 description: str | None = None
44 resource_name: str | None = None
45 metadata: dict[str, Any] = field(default_factory=dict)
46 layout: Any | None = None
48 def get_field(self, name: str) -> FieldSchema | None:
49 for f in self.fields:
50 if f.name == name:
51 return f
52 return None
55class FormMeta(type):
56 """Metaclass to collect Field instances from class attributes."""
58 def __new__(mcs, name, bases, namespace) -> Any:
59 fields = {}
60 for base in bases:
61 if hasattr(base, "_declared_fields"):
62 fields.update(base._declared_fields)
63 for key, value in list(namespace.items()):
64 if isinstance(value, AbstractField):
65 fields[key] = value
66 value.name = key
67 namespace["_declared_fields"] = fields
68 return super().__new__(mcs, name, bases, namespace)
71class FormBase(Component, metaclass=FormMeta):
72 """Base form class with lifecycle and rendering support."""
74 _declared_fields: ClassVar[dict[str, AbstractField]]
76 def __init__(
77 self,
78 data: dict | None = None,
79 initial: dict | None = None,
80 action: str | None = None,
81 method: str = "POST",
82 hx_post: str | None = None,
83 hx_target: str | None = None,
84 **props: Any,
85 ) -> None:
86 super().__init__(**props)
87 self.data = data or {}
88 self.initial = initial or {}
89 self.action = action
90 self.method = method
91 self.hx_post = hx_post
92 self.hx_target = hx_target
93 self.fields: dict[str, AbstractField] = {}
94 self.errors: dict[str, list[str]] = {}
95 self._initialize_fields()
96 if self.data:
97 self.is_valid()
99 def _initialize_fields(self) -> Any:
100 for name, field_proto in self._declared_fields.items():
101 value = self.data.get(name)
102 if value is None and not self.data:
103 value = self.initial.get(name, field_proto.default)
104 elif value is None and self.data:
105 value = field_proto.default
106 self.fields[name] = field_proto.bind(value)
108 def is_valid(self) -> bool:
109 self.errors = {}
110 is_valid = True
111 for name, form_field in self.fields.items():
112 try:
113 form_field.validate(form_field.value)
114 except ValueError as e:
115 form_field.errors.append(str(e))
116 self.errors[name] = [str(e)]
117 is_valid = False
118 return is_valid
120 async def validate(self) -> Result[dict[str, Any], AdminValidationError]:
121 """Validate form data, returning a Result.
123 Returns:
124 Ok containing cleaned data dict on success, or Err containing
125 AdminValidationError with per-field FieldError detail on failure.
126 """
127 if self.is_valid():
128 return Ok(self.cleaned_data)
129 field_errors = [
130 FieldError(field=name, message=msgs[0], code="invalid")
131 for name, msgs in self.errors.items()
132 if msgs
133 ]
134 return Err(
135 AdminValidationError(
136 message="Form validation failed",
137 errors=field_errors,
138 )
139 )
141 @property
142 def cleaned_data(self) -> dict:
143 return {name: form_field.value for name, form_field in self.fields.items()}
145 def render(self) -> Any:
146 from lexigram.ui import Button
148 layout = getattr(self, "layout", None)
149 if layout:
150 if hasattr(layout, "render"):
151 form_body = layout.render(self)
152 elif isinstance(layout, list):
153 form_body = el(
154 "div",
155 *[
156 (n.render(self) if hasattr(n, "render") else str(n))
157 for n in layout
158 ],
159 class_="space-y-6",
160 )
161 else:
162 form_body = str(layout)
163 else:
164 form_content = [field.render() for field in self.fields.values()]
165 form_body = el("div", *form_content, class_="space-y-4")
167 actions = el(
168 "div",
169 Button("Submit", type="submit", color="primary"),
170 class_="flex justify-end pt-4 border-t border-border mt-6",
171 )
173 attrs = {
174 "method": self.method,
175 "class": "bg-card p-6 rounded-lg shadow",
176 }
177 if self.action:
178 attrs["action"] = self.action
179 if self.hx_post:
180 attrs["hx-post"] = self.hx_post
181 if self.hx_target:
182 attrs["hx-target"] = self.hx_target
184 return el("form", form_body, actions, **attrs)
187class FormSchemaGenerator:
188 """Generates FormSchema from various data model types."""
190 def __init__(self, resource_registry: dict[str, type] | None = None) -> None:
191 self.resource_registry = resource_registry
193 def from_pydantic(self, model: type) -> FormSchema:
194 """Generate a FormSchema from a model class.
196 Supports both Pydantic v2 ``BaseModel`` subclasses (via
197 ``model_fields``) and ``DomainModel`` / stdlib dataclasses (via
198 ``__dataclass_fields__``).
199 """
200 fields = []
201 title = getattr(model, "__name__", "Form")
203 # Pydantic v2 BaseModel
204 if hasattr(model, "model_fields") and isinstance(model.model_fields, dict):
205 for name, field_info in model.model_fields.items():
206 fields.append(self._parse_pydantic_field(name, field_info))
207 elif hasattr(model, "__dataclass_fields__"):
208 # DomainModel / stdlib dataclass
209 import dataclasses
210 import typing
212 type_hints = typing.get_type_hints(model)
213 for dc_field in dataclasses.fields(model):
214 name = dc_field.name
215 annotation = type_hints.get(name)
216 meta: dict = dict(dc_field.metadata) if dc_field.metadata else {}
217 fields.append(
218 self._parse_dataclass_field(name, annotation, dc_field, meta)
219 )
220 else:
221 raise TypeError(
222 f"Unsupported model type: {model!r}. "
223 "Expected a Pydantic BaseModel or dataclass-backed DomainModel."
224 )
226 return FormSchema(fields=fields, title=title)
228 def _parse_pydantic_field(self, name: str, field_info: FieldInfo) -> FieldSchema:
229 annotation = field_info.annotation
230 field_type = self._map_type(name, annotation)
231 from pydantic_core import PydanticUndefined
233 label = (
234 str(field_info.title)
235 if field_info.title
236 else name.replace("_", " ").title()
237 )
238 is_required = (
239 field_info.is_required() if hasattr(field_info, "is_required") else True
240 )
241 if (
242 field_info.default is not PydanticUndefined
243 or field_info.default_factory is not None
244 ):
245 is_required = False
246 nested_schema = None
247 if field_type == FieldType.NESTED:
248 # Handle Optional[Model], Union[Model, None], or X | None (3.10+)
249 origin = get_origin(annotation)
250 args = get_args(annotation)
251 model_class = annotation
252 _union_types = (Union, _builtin_types.UnionType)
253 if origin in _union_types:
254 for arg in args:
255 if (
256 arg is not type(None)
257 and isinstance(arg, type)
258 and issubclass(arg, DomainModel)
259 ):
260 model_class = arg
261 break
263 if isinstance(model_class, type) and issubclass(model_class, DomainModel):
264 nested_schema = self.from_pydantic(model_class)
266 related_resource = None
267 related_field = None
268 if field_type == FieldType.BELONGS_TO and name.endswith("_id"):
269 related_resource = name[:-3] + "s"
270 elif field_type == FieldType.HAS_MANY:
271 related_field = None
273 return FieldSchema(
274 name=name,
275 label=label,
276 type=field_type,
277 required=is_required,
278 default=field_info.default
279 if field_info.default is not PydanticUndefined
280 else None,
281 help_text=field_info.description,
282 nested_schema=nested_schema,
283 related_resource=related_resource,
284 related_field=related_field,
285 )
287 def _parse_dataclass_field(
288 self,
289 name: str,
290 annotation: Any,
291 dc_field: Any,
292 meta: dict,
293 ) -> FieldSchema:
294 """Parse a stdlib dataclass field into a ``FieldSchema``."""
295 import dataclasses
297 field_type = self._map_type(name, annotation)
298 label = meta.get("title") or name.replace("_", " ").title()
299 description = meta.get("description")
300 is_required = (
301 dc_field.default is dataclasses.MISSING
302 and dc_field.default_factory is dataclasses.MISSING
303 )
304 default = (
305 None
306 if is_required
307 else (
308 dc_field.default
309 if dc_field.default is not dataclasses.MISSING
310 else None
311 )
312 )
314 nested_schema = None
315 if field_type == FieldType.NESTED:
316 origin = get_origin(annotation)
317 args = get_args(annotation)
318 model_class = annotation
319 _union_types = (Union, _builtin_types.UnionType)
320 if origin in _union_types:
321 for arg in args:
322 if (
323 arg is not type(None)
324 and isinstance(arg, type)
325 and hasattr(arg, "__dataclass_fields__")
326 ):
327 model_class = arg
328 break
329 if isinstance(model_class, type) and hasattr(
330 model_class, "__dataclass_fields__"
331 ):
332 nested_schema = self.from_pydantic(model_class)
334 related_resource = None
335 related_field = None
336 if field_type == FieldType.BELONGS_TO and name.endswith("_id"):
337 related_resource = name[:-3] + "s"
338 elif field_type == FieldType.HAS_MANY:
339 related_field = None
341 return FieldSchema(
342 name=name,
343 label=label,
344 type=field_type,
345 required=is_required,
346 default=default,
347 help_text=description,
348 nested_schema=nested_schema,
349 related_resource=related_resource,
350 related_field=related_field,
351 )
353 def _map_type(self, field_name: str, annotation: Any) -> FieldType:
354 # Detect belongs-to FK: field ends with _id
355 if field_name.endswith("_id"):
356 return FieldType.BELONGS_TO
358 origin = get_origin(annotation)
359 args = get_args(annotation)
361 # Detect has-many: list of domain models
362 if origin is list and args:
363 inner = args[0]
364 if isinstance(inner, type) and (
365 hasattr(inner, "model_fields") or hasattr(inner, "__dataclass_fields__")
366 ):
367 return FieldType.HAS_MANY
369 # Detect polymorphic: Optional[Union[TypeA, TypeB]]
370 _union_types = (Union, _builtin_types.UnionType)
371 if origin in _union_types:
372 inner_types = [t for t in args if t is not type(None)]
373 non_primitive = [
374 t
375 for t in inner_types
376 if isinstance(t, type) and hasattr(t, "__dataclass_fields__")
377 ]
378 if len(non_primitive) >= 2:
379 return FieldType.MORPH
381 # Original mapping logic follows (unchanged)
382 if origin in _union_types:
383 for arg in args:
384 if arg is not type(None):
385 return self._map_type(field_name, arg)
386 return FieldType.TEXT
387 if annotation is str:
388 return FieldType.TEXT
389 if annotation is int or annotation is float:
390 return FieldType.NUMBER
391 if annotation is bool:
392 return FieldType.CHECKBOX
393 from datetime import date, datetime
395 if annotation is date:
396 return FieldType.DATE
397 if annotation is datetime:
398 return FieldType.DATETIME
399 if isinstance(annotation, type) and (
400 issubclass(annotation, DomainModel)
401 or hasattr(annotation, "__dataclass_fields__")
402 ):
403 return FieldType.NESTED
404 if origin is list:
405 return FieldType.LIST
406 return FieldType.TEXT
409def build_form(**fields) -> Form[Any]:
410 """Build a simple form dynamically from keyword field configs."""
411 from lexigram.admin.forms.builder import FormBuilder
413 builder = FormBuilder.create()
414 for name, config in fields.items():
415 builder.text(name, label=config.get("label"))
416 return builder.build()