Coverage for src / lexigram / admin / resources / field_renderer.py: 0%
151 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:23 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:23 +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.di.decorators import inject
11from lexigram.logging import get_logger
12from lexigram.ui import el, render_to_string
14logger = get_logger(__name__)
17class FieldRendererProtocol(Protocol):
18 """Protocol for field renderers."""
20 def can_render(self, field_type: Any, field_schema: Any) -> bool: ...
22 def render_field(
23 self,
24 field_schema: Any,
25 value: Any,
26 common_args: dict[str, Any],
27 ) -> Any: ...
30class TextAreaFieldRenderer:
31 """Renderer for TEXTAREA fields."""
33 def can_render(self, field_type: Any, field_schema: Any) -> bool:
34 from lexigram.admin.forms.fields import FieldType
36 return field_type == FieldType.TEXTAREA
38 def render_field(
39 self,
40 field_schema: Any,
41 value: Any,
42 common_args: dict[str, Any],
43 ) -> Any:
44 from lexigram.admin.forms.fields import TextAreaField
46 return TextAreaField(**common_args)
49class NumberFieldRenderer:
50 """Renderer for NUMBER fields."""
52 def can_render(self, field_type: Any, field_schema: Any) -> bool:
53 from lexigram.admin.forms.fields import FieldType
55 return field_type == FieldType.NUMBER
57 def render_field(
58 self,
59 field_schema: Any,
60 value: Any,
61 common_args: dict[str, Any],
62 ) -> Any:
63 from lexigram.admin.forms.fields import IntegerField
65 return IntegerField(**common_args)
68class CheckboxFieldRenderer:
69 """Renderer for CHECKBOX fields."""
71 def can_render(self, field_type: Any, field_schema: Any) -> bool:
72 from lexigram.admin.forms.fields import FieldType
74 return field_type == FieldType.CHECKBOX
76 def render_field(
77 self,
78 field_schema: Any,
79 value: Any,
80 common_args: dict[str, Any],
81 ) -> Any:
82 from lexigram.admin.forms.fields import BooleanField
84 return BooleanField(**common_args)
87class SelectFieldRenderer:
88 """Renderer for SELECT fields."""
90 def can_render(self, field_type: Any, field_schema: Any) -> bool:
91 from lexigram.admin.forms.fields import FieldType
93 return field_type == FieldType.SELECT and field_schema.options
95 def render_field(
96 self,
97 field_schema: Any,
98 value: Any,
99 common_args: dict[str, Any],
100 ) -> Any:
101 from lexigram.admin.forms.fields import SelectField
103 options = [
104 (
105 opt.get("value", opt.get("id", "")),
106 opt.get("label", opt.get("name", "")),
107 )
108 for opt in field_schema.options
109 ]
110 return SelectField(options=options, **common_args)
113class DateFieldRenderer:
114 """Renderer for DATE/DATETIME fields."""
116 def can_render(self, field_type: Any, field_schema: Any) -> bool:
117 from lexigram.admin.forms.fields import FieldType
119 return field_type in (FieldType.DATE, FieldType.DATETIME)
121 def render_field(
122 self,
123 field_schema: Any,
124 value: Any,
125 common_args: dict[str, Any],
126 ) -> Any:
127 from lexigram.admin.forms.fields import DateField
129 return DateField(**common_args)
132class EmailFieldRenderer:
133 """Renderer for EMAIL fields."""
135 def can_render(self, field_type: Any, field_schema: Any) -> bool:
136 from lexigram.admin.forms.fields import FieldType
138 return field_type == FieldType.EMAIL
140 def render_field(
141 self,
142 field_schema: Any,
143 value: Any,
144 common_args: dict[str, Any],
145 ) -> Any:
146 from lexigram.admin.forms.fields import TextField
148 return TextField(type="email", **common_args)
151class PasswordFieldRenderer:
152 """Renderer for PASSWORD fields."""
154 def can_render(self, field_type: Any, field_schema: Any) -> bool:
155 from lexigram.admin.forms.fields import FieldType
157 return field_type == FieldType.PASSWORD
159 def render_field(
160 self,
161 field_schema: Any,
162 value: Any,
163 common_args: dict[str, Any],
164 ) -> Any:
165 from lexigram.admin.forms.fields import TextField
167 return TextField(type="password", **common_args)
170class ListFieldRenderer:
171 """Renderer for LIST fields (e.g., list[str])."""
173 def can_render(self, field_type: Any, field_schema: Any) -> bool:
174 from lexigram.admin.forms.fields import FieldType
176 return field_type == FieldType.LIST
178 def render_field(
179 self,
180 field_schema: Any,
181 value: Any,
182 common_args: dict[str, Any],
183 ) -> Any:
184 from lexigram.admin.forms.fields import TextAreaField
186 display_value = (
187 ", ".join(str(v) for v in value)
188 if isinstance(value, list)
189 else str(value or "")
190 )
191 args = {k: v for k, v in common_args.items() if k != "placeholder"}
192 return TextAreaField(
193 **args,
194 placeholder=common_args.get("placeholder")
195 or "Enter comma-separated values",
196 )
199class BelongsToFieldRenderer:
200 """Renders a BELONGS_TO field as a SelectField with related record options."""
202 def can_render(self, field_type: Any, field_schema: Any) -> bool:
203 from lexigram.admin.forms.fields import FieldType
205 return field_type == FieldType.BELONGS_TO
207 def render_field(
208 self,
209 field_schema: Any,
210 value: Any,
211 common_args: dict[str, Any],
212 ) -> Any:
213 from lexigram.admin.forms.fields import SelectField
215 options = field_schema.options or []
216 return SelectField(
217 options=[(o.get("value"), o.get("label")) for o in options],
218 **common_args,
219 )
222class HasManyFieldRenderer:
223 """Renders a HAS_MANY field as a MultiSelectField."""
225 def can_render(self, field_type: Any, field_schema: Any) -> bool:
226 from lexigram.admin.forms.fields import FieldType
228 return field_type == FieldType.HAS_MANY
230 def render_field(
231 self,
232 field_schema: Any,
233 value: Any,
234 common_args: dict[str, Any],
235 ) -> Any:
236 from lexigram.admin.forms.fields import SelectField
238 options = field_schema.options or []
239 return SelectField(
240 options=[(o.get("value"), o.get("label")) for o in options],
241 multiple=True,
242 **common_args,
243 )
246class DefaultFieldRenderer:
247 """Default renderer for unknown field types."""
249 def can_render(self, field_type: Any, field_schema: Any) -> bool:
250 return True
252 def render_field(
253 self,
254 field_schema: Any,
255 value: Any,
256 common_args: dict[str, Any],
257 ) -> Any:
258 from lexigram.admin.forms.fields import TextField
260 return TextField(**common_args)
263class FieldRendererRegistry:
264 """Registry for field renderers."""
266 def __init__(self) -> None:
267 self._renderers: list[FieldRendererProtocol] = []
268 self._register_renderers()
270 def _register_renderers(self) -> None:
271 self._renderers = [
272 TextAreaFieldRenderer(),
273 NumberFieldRenderer(),
274 CheckboxFieldRenderer(),
275 SelectFieldRenderer(),
276 DateFieldRenderer(),
277 EmailFieldRenderer(),
278 PasswordFieldRenderer(),
279 ListFieldRenderer(),
280 BelongsToFieldRenderer(),
281 HasManyFieldRenderer(),
282 DefaultFieldRenderer(),
283 ]
285 def get_renderer(
286 self,
287 field_type: Any,
288 field_schema: Any,
289 ) -> FieldRendererProtocol:
290 for renderer in self._renderers:
291 if renderer.can_render(field_type, field_schema):
292 return renderer
293 return DefaultFieldRenderer()
296# Global registry instance
297_field_renderer_registry = FieldRendererRegistry()
300@inject
301class FieldRenderer:
302 """Handles rendering of individual field components for admin resources."""
304 def __init__(self, config: AdminConfig, resource_name: str):
305 self._config = config
306 self.resource_name = resource_name
308 async def render_field(
309 self,
310 request,
311 resource,
312 field_name: str,
313 item_id: str | None = None,
314 user=None,
315 ) -> HTMLResponse:
316 """Render a single field for inline editing."""
317 self.resource_name.replace("_", " ").title()
319 # Fetch current item data
320 current_data: dict[str, Any] = {}
321 if item_id and resource and hasattr(resource, "service") and resource.service:
322 try:
323 if hasattr(resource.service, "get"):
324 item = await resource.service.get(item_id)
325 if item:
326 current_data = (
327 item.model_dump()
328 if hasattr(item, "model_dump")
329 else dict(item)
330 )
331 except (AttributeError, TypeError, ValueError, RuntimeError) as e:
332 logger.debug(
333 "Failed to get item %s/%s for field edit: %s",
334 self.resource_name,
335 item_id,
336 e,
337 )
339 # Get field value
340 field_value = current_data.get(field_name, "")
342 # Build field component
343 field_component = self._build_field_component(
344 resource,
345 field_name,
346 field_value,
347 item_id,
348 )
350 if not field_component:
351 return HTMLResponse(
352 el("span", "Field not found", class_="text-destructive"),
353 status_code=404,
354 )
356 # Return just the field component for HTMX inline editing
357 return HTMLResponse(render_to_string(field_component))
359 def _build_field_component(
360 self,
361 resource,
362 field_name: str,
363 value,
364 item_id: str | None = None,
365 ) -> Any:
366 """Build a field component for inline editing.
368 Args:
369 resource: The resource class
370 field_name: Name of the field to render
371 value: Current value of the field
372 item_id: ID of the item being edited (for inline editing)
374 Returns:
375 Field component ready for rendering
376 """
377 # Try to get field schema from resource model
378 if not resource or not resource.model:
379 return None
381 try:
382 from lexigram.admin.forms.components import FormSchemaGenerator
384 generator = FormSchemaGenerator()
385 schema = generator.from_pydantic(resource.model)
387 # Find the field schema
388 field_schema = None
389 for fs in schema.fields:
390 if fs.name == field_name:
391 field_schema = fs
392 break
394 if not field_schema:
395 return None
397 # Skip auto-generated fields
398 if field_name in ("id", "created_at", "updated_at"):
399 return None
401 # Create the appropriate field component based on type
402 return self._create_field_component(field_schema, value, item_id)
404 except (ImportError, AttributeError, TypeError, ValueError) as e:
405 logger.debug(
406 "Failed to build field component for %s.%s: %s",
407 self.resource_name,
408 field_name,
409 e,
410 )
411 return None
413 def _create_field_component(
414 self, field_schema, value, item_id: str | None = None
415 ) -> Any:
416 """Create a field component from a FieldSchema for inline editing.
418 Args:
419 field_schema: The field schema definition
420 value: Current value for the field
421 item_id: ID of the item being edited
423 Returns:
424 Field component instance configured for inline editing
425 """
426 # Determine action URL for inline editing
427 action_url = f"{self._config.prefix}/{self.resource_name}/{item_id}/field/{field_schema.name}"
429 common_args = {
430 "label": None, # No label for inline editing
431 "required": field_schema.required,
432 "disabled": not field_schema.editable,
433 "help_text": None, # No help text for inline editing
434 "default": field_schema.default,
435 "placeholder": field_schema.placeholder,
436 "name": field_schema.name,
437 "hx_post": action_url,
438 "hx_target": "closest td", # Replace the table cell
439 "hx_swap": "outerHTML",
440 "hx_trigger": "change", # Submit on change
441 }
443 # Use registry to get the appropriate renderer
444 renderer = _field_renderer_registry.get_renderer(
445 field_schema.type,
446 field_schema,
447 )
448 field = renderer.render_field(field_schema, value, common_args)
450 # Bind the value to the field
451 return field.bind(value)
454__all__ = [
455 "BelongsToFieldRenderer",
456 "CheckboxFieldRenderer",
457 "DateFieldRenderer",
458 "DefaultFieldRenderer",
459 "EmailFieldRenderer",
460 "FieldRenderer",
461 "FieldRendererProtocol",
462 "FieldRendererRegistry",
463 "HasManyFieldRenderer",
464 "NumberFieldRenderer",
465 "PasswordFieldRenderer",
466 "SelectFieldRenderer",
467 # Renderer classes
468 "TextAreaFieldRenderer",
469]