Coverage for src/lexigram/admin/forms/components.py: 0%
195 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"""Unified Form Components.
2Includes FormBase, FormBuilder, and FormSchemaGenerator.
3"""
5from __future__ import annotations
7import dataclasses
8from dataclasses import dataclass, field
9from datetime import date, datetime
10from enum import Enum
11import types as _builtin_types
12from typing import (
13 TYPE_CHECKING,
14 Any,
15 ClassVar,
16 Union,
17 get_args,
18 get_origin,
19)
21if TYPE_CHECKING:
22 from pydantic.fields import FieldInfo
24 from lexigram.admin.forms.builder import Form
26from lexigram.admin.exceptions import AdminValidationError
27from lexigram.admin.schema import (
28 BelongsToField,
29 BooleanField,
30 DateField,
31 DateTimeField,
32 EnumField,
33 FloatField,
34 HasManyField,
35 IntegerField,
36 JsonField,
37 MorphField,
38 MultiSelectField,
39 SchemaField,
40 TextField,
41)
42from lexigram.contracts.exceptions import FieldError
43from lexigram.result import Err, Ok, Result
44from lexigram.ui import Component, el
47@dataclass
48class FormSchema:
49 """Definition of a complete form structure."""
51 fields: list[SchemaField] = field(default_factory=list)
52 title: str | None = None
53 description: str | None = None
54 resource_name: str | None = None
55 metadata: dict[str, Any] = field(default_factory=dict)
56 layout: Any | None = None
58 def get_field(self, name: str) -> SchemaField | None:
59 for f in self.fields:
60 if f.name == name:
61 return f
62 return None
64 async def filter_for_user(
65 self,
66 user: Any,
67 resource_name: str,
68 permission_service: Any | None = None,
69 ) -> FormSchema:
70 """Return a copy of this schema with RBAC rules applied.
72 Args:
73 user: Current user for permission checks.
74 resource_name: Resource the form is rendered for.
75 permission_service: PermissionService used for field-level
76 checks. When None, the schema is returned unchanged.
78 Returns:
79 New FormSchema with non-viewable fields removed and
80 non-editable fields marked readonly.
81 """
82 if permission_service is None:
83 return self
84 fields: list[SchemaField] = []
85 for f in self.fields:
86 if not await permission_service.can_view_field(user, resource_name, f.name):
87 continue
88 schema_field = f
89 if not await permission_service.can_edit_field(user, resource_name, f.name):
90 schema_field = dataclasses.replace(f, readonly=True)
91 fields.append(schema_field)
92 return dataclasses.replace(self, fields=fields)
95class FormMeta(type):
96 """Metaclass to collect SchemaField instances from class attributes."""
98 def __new__(mcs, name, bases, namespace) -> Any:
99 fields: dict[str, SchemaField] = {}
100 for base in bases:
101 if hasattr(base, "_declared_fields"):
102 fields.update(base._declared_fields)
103 for key, value in list(namespace.items()):
104 if isinstance(value, SchemaField):
105 fields[key] = dataclasses.replace(value, name=key)
106 namespace["_declared_fields"] = fields
107 return super().__new__(mcs, name, bases, namespace)
110class FormBase(Component, metaclass=FormMeta):
111 """Base form class with lifecycle and rendering support."""
113 _declared_fields: ClassVar[dict[str, SchemaField]]
115 def __init__(
116 self,
117 data: dict | None = None,
118 initial: dict | None = None,
119 action: str | None = None,
120 method: str = "POST",
121 hx_post: str | None = None,
122 hx_target: str | None = None,
123 **props: Any,
124 ) -> None:
125 super().__init__(**props)
126 self.data = data or {}
127 self.initial = initial or {}
128 self.action = action
129 self.method = method
130 self.hx_post = hx_post
131 self.hx_target = hx_target
132 self.fields: dict[str, SchemaField] = dict(self._declared_fields)
133 self.values: dict[str, Any] = {}
134 self.errors: dict[str, list[str]] = {}
135 self._initialize_fields()
136 if self.data:
137 self.is_valid()
139 def _initialize_fields(self) -> None:
140 for name, field_schema in self._declared_fields.items():
141 if name in self.data:
142 self.values[name] = self.data[name]
143 elif name in self.initial:
144 self.values[name] = self.initial[name]
145 else:
146 self.values[name] = field_schema.default
148 def is_valid(self) -> bool:
149 self.errors = {}
150 is_valid = True
151 for name, field_schema in self._declared_fields.items():
152 value = self.values.get(name)
153 raw = value if value is None or isinstance(value, str) else str(value)
154 result = field_schema.from_form(raw)
155 if result.is_err():
156 error = result.unwrap_err()
157 self.errors[name] = [str(error)]
158 is_valid = False
159 continue
160 cleaned = result.unwrap()
161 if field_schema.required and (
162 cleaned is None or (isinstance(cleaned, str) and not cleaned)
163 ):
164 self.errors[name] = ["This field is required."]
165 is_valid = False
166 else:
167 self.values[name] = cleaned
168 return is_valid
170 async def validate(self) -> Result[dict[str, Any], AdminValidationError]:
171 """Validate form data, returning a Result.
173 Returns:
174 Ok containing cleaned data dict on success, or Err containing
175 AdminValidationError with per-field FieldError detail on failure.
176 """
177 if self.is_valid():
178 return Ok(self.cleaned_data)
179 field_errors = [
180 FieldError(field=name, message=msgs[0], code="invalid")
181 for name, msgs in self.errors.items()
182 if msgs
183 ]
184 return Err(
185 AdminValidationError(
186 message="Form validation failed",
187 errors=field_errors,
188 )
189 )
191 @property
192 def cleaned_data(self) -> dict:
193 return dict(self.values)
195 def render(self) -> Any:
196 from lexigram.ui import Button
198 layout = getattr(self, "layout", None)
199 if layout:
200 if hasattr(layout, "render"):
201 form_body = layout.render(self)
202 elif isinstance(layout, list):
203 form_body = el(
204 "div",
205 *[
206 (n.render(self) if hasattr(n, "render") else str(n))
207 for n in layout
208 ],
209 class_="space-y-6",
210 )
211 else:
212 form_body = str(layout)
213 else:
214 form_content = [
215 field_schema.render_form(self.values.get(name))
216 for name, field_schema in self.fields.items()
217 ]
218 form_body = el("div", *form_content, class_="space-y-4")
220 actions = el(
221 "div",
222 Button("Submit", type="submit", color="primary"),
223 class_="flex justify-end pt-4 border-t border-border mt-6",
224 )
226 attrs = {
227 "method": self.method,
228 "class": "bg-card p-6 rounded-lg shadow",
229 }
230 if self.action:
231 attrs["action"] = self.action
232 if self.hx_post:
233 attrs["hx-post"] = self.hx_post
234 if self.hx_target:
235 attrs["hx-target"] = self.hx_target
237 return el("form", form_body, actions, **attrs)
240class FormSchemaGenerator:
241 """Generates FormSchema from various data model types."""
243 def __init__(self, resource_registry: dict[str, type] | None = None) -> None:
244 self.resource_registry = resource_registry
246 def from_pydantic(self, model: type) -> FormSchema:
247 """Generate a FormSchema from a model class.
249 Supports both Pydantic v2 ``BaseModel`` subclasses (via
250 ``model_fields``) and ``DomainModel`` / stdlib dataclasses (via
251 ``__dataclass_fields__``).
252 """
253 fields = []
254 title = getattr(model, "__name__", "Form")
256 # Pydantic v2 BaseModel
257 if hasattr(model, "model_fields") and isinstance(model.model_fields, dict):
258 for name, field_info in model.model_fields.items():
259 fields.append(self._parse_pydantic_field(name, field_info))
260 elif hasattr(model, "__dataclass_fields__"):
261 # DomainModel / stdlib dataclass
262 import dataclasses
263 import typing
265 type_hints = typing.get_type_hints(model)
266 for dc_field in dataclasses.fields(model):
267 name = dc_field.name
268 annotation = type_hints.get(name)
269 meta: dict = dict(dc_field.metadata) if dc_field.metadata else {}
270 fields.append(
271 self._parse_dataclass_field(name, annotation, dc_field, meta)
272 )
273 else:
274 raise TypeError(
275 f"Unsupported model type: {model!r}. "
276 "Expected a Pydantic BaseModel or dataclass-backed DomainModel."
277 )
279 return FormSchema(fields=fields, title=title)
281 def _parse_pydantic_field(self, name: str, field_info: FieldInfo) -> SchemaField:
282 from pydantic_core import PydanticUndefined
284 label = (
285 str(field_info.title)
286 if field_info.title
287 else name.replace("_", " ").title()
288 )
289 is_required = (
290 field_info.is_required() if hasattr(field_info, "is_required") else True
291 )
292 if (
293 field_info.default is not PydanticUndefined
294 or field_info.default_factory is not None
295 ):
296 is_required = False
297 default = (
298 field_info.default if field_info.default is not PydanticUndefined else None
299 )
300 return self._build_field(
301 name,
302 field_info.annotation,
303 label=label,
304 required=is_required,
305 help_text=field_info.description,
306 default=default,
307 )
309 def _parse_dataclass_field(
310 self,
311 name: str,
312 annotation: Any,
313 dc_field: Any,
314 meta: dict,
315 ) -> SchemaField:
316 """Parse a stdlib dataclass field into a ``SchemaField``."""
317 import dataclasses
319 is_required = (
320 dc_field.default is dataclasses.MISSING
321 and dc_field.default_factory is dataclasses.MISSING
322 )
323 default = (
324 None
325 if is_required
326 else (
327 dc_field.default
328 if dc_field.default is not dataclasses.MISSING
329 else None
330 )
331 )
332 return self._build_field(
333 name,
334 annotation,
335 label=meta.get("title") or name.replace("_", " ").title(),
336 required=is_required,
337 help_text=meta.get("description"),
338 default=default,
339 )
341 def _build_field(
342 self,
343 name: str,
344 annotation: Any,
345 *,
346 label: str | None = None,
347 required: bool = False,
348 help_text: str | None = None,
349 default: Any = None,
350 ) -> SchemaField:
351 """Map a model annotation to a ``SchemaField`` instance."""
353 def is_model(t: Any) -> bool:
354 return isinstance(t, type) and (
355 hasattr(t, "model_fields") or hasattr(t, "__dataclass_fields__")
356 )
358 # Detect belongs-to FK: field ends with _id
359 if name.endswith("_id"):
360 return BelongsToField(
361 name=name,
362 label=label,
363 help_text=help_text,
364 required=required,
365 default=default,
366 resource=f"{name[:-3]}s",
367 )
369 origin = get_origin(annotation)
370 args = get_args(annotation)
372 # Detect polymorphic: Optional[Union[TypeA, TypeB]]
373 _union_types = (Union, _builtin_types.UnionType)
374 if origin in _union_types:
375 inner_types = [t for t in args if t is not type(None)]
376 non_primitive = [t for t in inner_types if is_model(t)]
377 if len(non_primitive) >= 2:
378 return MorphField(
379 name=name,
380 label=label,
381 help_text=help_text,
382 required=required,
383 default=default,
384 resource=name,
385 )
386 if len(inner_types) == 1:
387 return self._build_field(
388 name,
389 inner_types[0],
390 label=label,
391 required=required,
392 help_text=help_text,
393 default=default,
394 )
395 return TextField(
396 name=name,
397 label=label,
398 help_text=help_text,
399 required=required,
400 default=default,
401 )
403 # Detect has-many: list of domain models
404 if origin is list and args:
405 if is_model(args[0]):
406 return HasManyField(
407 name=name,
408 label=label,
409 help_text=help_text,
410 required=required,
411 default=default,
412 resource=f"{args[0].__name__.lower()}s",
413 )
414 if args[0] is str:
415 return MultiSelectField(
416 name=name,
417 label=label,
418 help_text=help_text,
419 required=required,
420 default=default,
421 )
422 return JsonField(
423 name=name,
424 label=label,
425 help_text=help_text,
426 required=required,
427 default=default,
428 )
430 if isinstance(annotation, type) and issubclass(annotation, Enum):
431 return EnumField(
432 name=name,
433 label=label,
434 help_text=help_text,
435 required=required,
436 default=default,
437 enum_cls=annotation,
438 )
439 if annotation is str:
440 return TextField(
441 name=name,
442 label=label,
443 help_text=help_text,
444 required=required,
445 default=default,
446 )
447 if annotation is int:
448 return IntegerField(
449 name=name,
450 label=label,
451 help_text=help_text,
452 required=required,
453 default=default,
454 )
455 if annotation is float:
456 return FloatField(
457 name=name,
458 label=label,
459 help_text=help_text,
460 required=required,
461 default=default,
462 )
463 if annotation is bool:
464 return BooleanField(
465 name=name,
466 label=label,
467 help_text=help_text,
468 required=required,
469 default=default,
470 )
471 if annotation is date:
472 return DateField(
473 name=name,
474 label=label,
475 help_text=help_text,
476 required=required,
477 default=default,
478 )
479 if annotation is datetime:
480 return DateTimeField(
481 name=name,
482 label=label,
483 help_text=help_text,
484 required=required,
485 default=default,
486 )
487 # Nested model → structured JSON input
488 if is_model(annotation):
489 return JsonField(
490 name=name,
491 label=label,
492 help_text=help_text,
493 required=required,
494 default=default,
495 )
496 return TextField(
497 name=name,
498 label=label,
499 help_text=help_text,
500 required=required,
501 default=default,
502 )
505def build_form(**fields) -> Form[Any]:
506 """Build a simple form dynamically from keyword field configs."""
507 from lexigram.admin.forms.builder import FormBuilder
509 builder = FormBuilder.create()
510 for name, config in fields.items():
511 builder.text(name, label=config.get("label"))
512 return builder.build()