Coverage for src/lexigram/admin/resources/field_renderer.py: 0%
184 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1from __future__ import annotations
3"""Field rendering for admin resources."""
5from typing import Any, Protocol
7from starlette.responses import HTMLResponse
9from lexigram.admin.config import AdminConfig
10from lexigram.admin.schema import (
11 BelongsToField,
12 BooleanField,
13 ColorField,
14 DateField,
15 DateTimeField,
16 EmailField,
17 HasManyField,
18 JsonField,
19 MorphField,
20 MultiSelectField,
21 NumberField,
22 PasswordField,
23 SchemaField,
24 SelectField,
25 TagsField,
26 TextAreaField,
27 TextField,
28)
29from lexigram.di.decorators import inject
30from lexigram.logging import get_logger
31from lexigram.serialization import dumps_str
32from lexigram.ui import (
33 BelongsTo,
34 DateInput,
35 MultiSelect,
36 NumberInput,
37 Select,
38 Switch,
39 TextArea,
40 TextInput,
41 el,
42 render_to_string,
43)
45logger = get_logger(__name__)
48class FieldRendererProtocol(Protocol):
49 """Protocol for field renderers."""
51 def can_render(self, field_schema: SchemaField) -> bool: ...
53 def render_field(
54 self,
55 field_schema: SchemaField,
56 value: Any,
57 common_args: dict[str, Any],
58 ) -> Any: ...
61def _atom_args(
62 common_args: dict[str, Any], value: Any, extra: dict[str, Any] | None = None
63) -> dict[str, Any]:
64 """Build atom kwargs from shared inline-editing args.
66 Args:
67 common_args: Shared args (name, label, required, disabled, hx_*, ...).
68 value: Current field value.
69 extra: Additional atom-specific kwargs.
71 Returns:
72 Kwargs acceptable by lexigram.ui input atoms.
73 """
74 args: dict[str, Any] = {
75 "name": common_args["name"],
76 "value": value if value is not None else "",
77 "label": common_args.get("label"),
78 "required": common_args.get("required", False),
79 "disabled": common_args.get("disabled", False),
80 }
81 for key in ("placeholder", "error"):
82 if common_args.get(key):
83 args[key] = common_args[key]
84 args.update({k: v for k, v in common_args.items() if k.startswith("hx_")})
85 if extra:
86 args.update(extra)
87 return args
90class TextAreaFieldRenderer:
91 """Renderer for multi-line text fields."""
93 def can_render(self, field_schema: SchemaField) -> bool:
94 return isinstance(field_schema, TextAreaField)
96 def render_field(
97 self,
98 field_schema: SchemaField,
99 value: Any,
100 common_args: dict[str, Any],
101 ) -> Any:
102 return TextArea(**_atom_args(common_args, value))
105class ListFieldRenderer:
106 """Renderer for tag-style list fields (comma-joined text area)."""
108 def can_render(self, field_schema: SchemaField) -> bool:
109 return isinstance(field_schema, TagsField)
111 def render_field(
112 self,
113 field_schema: SchemaField,
114 value: Any,
115 common_args: dict[str, Any],
116 ) -> Any:
117 display_value = (
118 ", ".join(str(v) for v in value)
119 if isinstance(value, list)
120 else str(value or "")
121 )
122 return TextArea(**_atom_args(common_args, display_value))
125class JsonFieldRenderer:
126 """Renderer for JSON fields."""
128 def can_render(self, field_schema: SchemaField) -> bool:
129 return isinstance(field_schema, JsonField)
131 def render_field(
132 self,
133 field_schema: SchemaField,
134 value: Any,
135 common_args: dict[str, Any],
136 ) -> Any:
137 display_value = dumps_str(value) if value is not None else ""
138 args = _atom_args(common_args, display_value, extra={"rows": 8})
139 return TextArea(**args)
142class NumberFieldRenderer:
143 """Renderer for numeric fields."""
145 def can_render(self, field_schema: SchemaField) -> bool:
146 return isinstance(field_schema, NumberField)
148 def render_field(
149 self,
150 field_schema: SchemaField,
151 value: Any,
152 common_args: dict[str, Any],
153 ) -> Any:
154 return NumberInput(**_atom_args(common_args, value))
157class BooleanFieldRenderer:
158 """Renderer for boolean fields."""
160 def can_render(self, field_schema: SchemaField) -> bool:
161 return isinstance(field_schema, BooleanField)
163 def render_field(
164 self,
165 field_schema: SchemaField,
166 value: Any,
167 common_args: dict[str, Any],
168 ) -> Any:
169 hx_props = {k: v for k, v in common_args.items() if k.startswith("hx_")}
170 return Switch(
171 label=common_args.get("label") or "",
172 name=common_args["name"],
173 value=bool(value),
174 **hx_props,
175 )
178class MultiSelectFieldRenderer:
179 """Renderer for multi-select fields."""
181 def can_render(self, field_schema: SchemaField) -> bool:
182 return isinstance(field_schema, MultiSelectField)
184 def render_field(
185 self,
186 field_schema: SchemaField,
187 value: Any,
188 common_args: dict[str, Any],
189 ) -> Any:
190 if not isinstance(field_schema, MultiSelectField):
191 return None
192 choices = field_schema.options or []
193 selected = value if isinstance(value, list) else []
194 return MultiSelect(
195 **_atom_args(common_args, selected, extra={"choices": choices})
196 )
199class HasManyFieldRenderer:
200 """Renderer for HAS_MANY relation fields."""
202 def can_render(self, field_schema: SchemaField) -> bool:
203 return isinstance(field_schema, HasManyField)
205 def render_field(
206 self,
207 field_schema: SchemaField,
208 value: Any,
209 common_args: dict[str, Any],
210 ) -> Any:
211 if not isinstance(field_schema, HasManyField):
212 return None
213 choices = field_schema.options or []
214 selected = value if isinstance(value, list) else []
215 return MultiSelect(
216 **_atom_args(common_args, selected, extra={"choices": choices})
217 )
220class BelongsToFieldRenderer:
221 """Renderer for BELONGS_TO relation fields."""
223 def can_render(self, field_schema: SchemaField) -> bool:
224 return isinstance(field_schema, BelongsToField)
226 def render_field(
227 self,
228 field_schema: SchemaField,
229 value: Any,
230 common_args: dict[str, Any],
231 ) -> Any:
232 if not isinstance(field_schema, BelongsToField):
233 return None
234 return BelongsTo(
235 **_atom_args(
236 common_args,
237 value,
238 extra={
239 "resource": field_schema.resource,
240 "choices": field_schema.options or [],
241 },
242 )
243 )
246class MorphFieldRenderer:
247 """Renderer for polymorphic relation fields."""
249 def can_render(self, field_schema: SchemaField) -> bool:
250 return isinstance(field_schema, MorphField)
252 def render_field(
253 self,
254 field_schema: SchemaField,
255 value: Any,
256 common_args: dict[str, Any],
257 ) -> Any:
258 if not isinstance(field_schema, MorphField):
259 return None
260 args = _atom_args(
261 common_args, value, extra={"choices": field_schema.options or []}
262 )
263 return Select(**args)
266class SelectFieldRenderer:
267 """Renderer for select fields."""
269 def can_render(self, field_schema: SchemaField) -> bool:
270 return isinstance(field_schema, SelectField)
272 def render_field(
273 self,
274 field_schema: SchemaField,
275 value: Any,
276 common_args: dict[str, Any],
277 ) -> Any:
278 if not isinstance(field_schema, SelectField):
279 return None
280 args = _atom_args(
281 common_args, value, extra={"choices": field_schema.options or []}
282 )
283 return Select(**args)
286class DateFieldRenderer:
287 """Renderer for date fields."""
289 def can_render(self, field_schema: SchemaField) -> bool:
290 return isinstance(field_schema, DateField)
292 def render_field(
293 self,
294 field_schema: SchemaField,
295 value: Any,
296 common_args: dict[str, Any],
297 ) -> Any:
298 iso_value = value.isoformat() if value is not None else ""
299 return DateInput(**_atom_args(common_args, iso_value))
302class DateTimeFieldRenderer:
303 """Renderer for datetime fields."""
305 def can_render(self, field_schema: SchemaField) -> bool:
306 return isinstance(field_schema, DateTimeField)
308 def render_field(
309 self,
310 field_schema: SchemaField,
311 value: Any,
312 common_args: dict[str, Any],
313 ) -> Any:
314 iso_value = value.isoformat() if value is not None else ""
315 return DateInput(**_atom_args(common_args, iso_value))
318class EmailFieldRenderer:
319 """Renderer for email fields."""
321 def can_render(self, field_schema: SchemaField) -> bool:
322 return isinstance(field_schema, EmailField)
324 def render_field(
325 self,
326 field_schema: SchemaField,
327 value: Any,
328 common_args: dict[str, Any],
329 ) -> Any:
330 return TextInput(
331 **_atom_args(common_args, value, extra={"input_type": "email"})
332 )
335class PasswordFieldRenderer:
336 """Renderer for password fields."""
338 def can_render(self, field_schema: SchemaField) -> bool:
339 return isinstance(field_schema, PasswordField)
341 def render_field(
342 self,
343 field_schema: SchemaField,
344 value: Any,
345 common_args: dict[str, Any],
346 ) -> Any:
347 return TextInput(
348 **_atom_args(common_args, value, extra={"input_type": "password"})
349 )
352class ColorFieldRenderer:
353 """Renderer for color fields."""
355 def can_render(self, field_schema: SchemaField) -> bool:
356 return isinstance(field_schema, ColorField)
358 def render_field(
359 self,
360 field_schema: SchemaField,
361 value: Any,
362 common_args: dict[str, Any],
363 ) -> Any:
364 return TextInput(
365 **_atom_args(common_args, value, extra={"input_type": "color"})
366 )
369class TextFieldRenderer:
370 """Renderer for plain text fields."""
372 def can_render(self, field_schema: SchemaField) -> bool:
373 return isinstance(field_schema, TextField)
375 def render_field(
376 self,
377 field_schema: SchemaField,
378 value: Any,
379 common_args: dict[str, Any],
380 ) -> Any:
381 return TextInput(**_atom_args(common_args, value))
384class DefaultFieldRenderer:
385 """Default renderer for unknown field types."""
387 def can_render(self, field_schema: SchemaField) -> bool:
388 return True
390 def render_field(
391 self,
392 field_schema: SchemaField,
393 value: Any,
394 common_args: dict[str, Any],
395 ) -> Any:
396 return TextInput(**_atom_args(common_args, value))
399class FieldRendererRegistry:
400 """Registry for field renderers."""
402 def __init__(self) -> None:
403 self._renderers: list[FieldRendererProtocol] = [
404 TextAreaFieldRenderer(),
405 ListFieldRenderer(),
406 JsonFieldRenderer(),
407 MultiSelectFieldRenderer(),
408 HasManyFieldRenderer(),
409 BelongsToFieldRenderer(),
410 MorphFieldRenderer(),
411 SelectFieldRenderer(),
412 DateFieldRenderer(),
413 DateTimeFieldRenderer(),
414 EmailFieldRenderer(),
415 PasswordFieldRenderer(),
416 ColorFieldRenderer(),
417 NumberFieldRenderer(),
418 BooleanFieldRenderer(),
419 TextFieldRenderer(),
420 DefaultFieldRenderer(),
421 ]
423 def get_renderer(self, field_schema: SchemaField) -> FieldRendererProtocol:
424 for renderer in self._renderers:
425 if renderer.can_render(field_schema):
426 return renderer
427 return DefaultFieldRenderer()
430# Global registry instance
431_field_renderer_registry = FieldRendererRegistry()
434@inject
435class FieldRenderer:
436 """Handles rendering of individual field components for admin resources."""
438 def __init__(self, config: AdminConfig, resource_name: str):
439 self._config = config
440 self.resource_name = resource_name
442 async def render_field(
443 self,
444 request,
445 resource,
446 field_name: str,
447 item_id: str | None = None,
448 user=None,
449 ) -> HTMLResponse:
450 """Render a single field for inline editing."""
451 self.resource_name.replace("_", " ").title()
453 # Fetch current item data
454 current_data: dict[str, Any] = {}
455 if item_id and resource and hasattr(resource, "service") and resource.service:
456 try:
457 if hasattr(resource.service, "get"):
458 item = await resource.service.get(item_id)
459 if item:
460 current_data = (
461 item.model_dump()
462 if hasattr(item, "model_dump")
463 else dict(item)
464 )
465 except (AttributeError, TypeError, ValueError, RuntimeError) as e:
466 logger.debug(
467 "Failed to get item %s/%s for field edit: %s",
468 self.resource_name,
469 item_id,
470 e,
471 )
473 # Get field value
474 field_value = current_data.get(field_name, "")
476 # Build field component
477 field_component = self._build_field_component(
478 resource,
479 field_name,
480 field_value,
481 item_id,
482 )
484 if not field_component:
485 return HTMLResponse(
486 el("span", "Field not found", class_="text-destructive"),
487 status_code=404,
488 )
490 # Return just the field component for HTMX inline editing
491 return HTMLResponse(render_to_string(field_component))
493 def _build_field_component(
494 self,
495 resource,
496 field_name: str,
497 value,
498 item_id: str | None = None,
499 ) -> Any:
500 """Build a field component for inline editing.
502 Args:
503 resource: The resource class
504 field_name: Name of the field to render
505 value: Current value of the field
506 item_id: ID of the item being edited (for inline editing)
508 Returns:
509 Field component ready for rendering
510 """
511 # Try to get field schema from resource model
512 if not resource or not resource.model:
513 return None
515 try:
516 from lexigram.admin.forms.components import FormSchemaGenerator
518 generator = FormSchemaGenerator()
519 schema = generator.from_pydantic(resource.model)
521 # Find the field schema
522 field_schema = None
523 for fs in schema.fields:
524 if fs.name == field_name:
525 field_schema = fs
526 break
528 if not field_schema:
529 return None
531 # Skip auto-generated fields
532 if field_name in ("id", "created_at", "updated_at"):
533 return None
535 # Create the appropriate field component based on type
536 return self._create_field_component(field_schema, value, item_id)
538 except (ImportError, AttributeError, TypeError, ValueError) as e:
539 logger.debug(
540 "Failed to build field component for %s.%s: %s",
541 self.resource_name,
542 field_name,
543 e,
544 )
545 return None
547 def _create_field_component(
548 self, field_schema, value, item_id: str | None = None
549 ) -> Any:
550 """Create a field component from a FieldSchema for inline editing.
552 Args:
553 field_schema: The field schema definition
554 value: Current value for the field
555 item_id: ID of the item being edited
557 Returns:
558 Field component instance configured for inline editing
559 """
560 # Determine action URL for inline editing
561 action_url = f"{self._config.prefix}/{self.resource_name}/{item_id}/field/{field_schema.name}"
563 common_args = {
564 "label": None, # No label for inline editing
565 "required": field_schema.required,
566 "readonly": field_schema.readonly,
567 "help_text": None, # No help text for inline editing
568 "default": field_schema.default,
569 "placeholder": field_schema.placeholder,
570 "name": field_schema.name,
571 "hx_post": action_url,
572 "hx_target": "closest td", # Replace the table cell
573 "hx_swap": "outerHTML",
574 "hx_trigger": "change", # Submit on change
575 }
577 # Use registry to get the appropriate renderer
578 renderer = _field_renderer_registry.get_renderer(field_schema)
579 return renderer.render_field(field_schema, value, common_args)
582__all__ = [
583 "BelongsToFieldRenderer",
584 "BooleanFieldRenderer",
585 "ColorFieldRenderer",
586 "DateFieldRenderer",
587 "DateTimeFieldRenderer",
588 "DefaultFieldRenderer",
589 "EmailFieldRenderer",
590 "FieldRenderer",
591 "FieldRendererProtocol",
592 "FieldRendererRegistry",
593 "HasManyFieldRenderer",
594 "JsonFieldRenderer",
595 "ListFieldRenderer",
596 "MorphFieldRenderer",
597 "MultiSelectFieldRenderer",
598 "NumberFieldRenderer",
599 "PasswordFieldRenderer",
600 "SelectFieldRenderer",
601 "TextAreaFieldRenderer",
602 "TextFieldRenderer",
603]