Coverage for src / lexigram / admin / resources / detail_renderer.py: 0%
70 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:07 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:07 +0800
1from __future__ import annotations
3"""Detail view rendering for admin resources."""
5from typing import Any
7from starlette.responses import HTMLResponse
9from lexigram.admin.config import AdminConfig
10from lexigram.admin.engine.renderer import AdminRenderer
11from lexigram.admin.exceptions import DataError
12from lexigram.admin.observability.admin_metrics import AdminMetrics, OperationTimer
13from lexigram.admin.state.context import wants_fragment
14from lexigram.di.decorators import inject
15from lexigram.logging import get_logger
16from lexigram.ui import el, render_to_string
18logger = get_logger(__name__)
21@inject
22class DetailRenderer:
23 """Handles rendering of detail views for admin resources."""
25 def __init__(
26 self,
27 config: AdminConfig,
28 resource_name: str,
29 renderer: AdminRenderer,
30 metrics: AdminMetrics | None = None,
31 ):
32 self._config = config
33 self.resource_name = resource_name
34 self._renderer = renderer
35 self._metrics = metrics or AdminMetrics(None)
37 async def render_detail(
38 self,
39 request,
40 resource,
41 item_id: str,
42 user=None,
43 ) -> HTMLResponse:
44 """Render detail view."""
45 timer = OperationTimer()
47 label = self.resource_name.replace("_", " ").title()
49 item_html = await self._get_item_html(resource, item_id, label)
51 content = f"""
52 <div class="resource-header" style="margin-bottom: 1.5rem;">
53 <a href="{self._config.prefix}/{self.resource_name}" style="color: #6366f1;">← Back to {label}</a>
54 <h1>{label} #{item_id}</h1>
55 </div>
56 <div class="resource-content">{item_html}</div>
57 <div class="resource-actions" style="margin-top: 1rem;">
58 <a href="{self._config.prefix}/{self.resource_name}/{item_id}/edit" class="btn btn-secondary">Edit</a>
59 </div>
60 """
62 is_htmx = wants_fragment(request)
63 if is_htmx:
64 self._metrics.record_operation(
65 "detail",
66 resource=self.resource_name,
67 status="success",
68 duration_seconds=timer.elapsed(),
69 )
70 return HTMLResponse(content)
72 self._metrics.record_operation(
73 "detail",
74 resource=self.resource_name,
75 status="success",
76 duration_seconds=timer.elapsed(),
77 )
79 return self._renderer.render_page(
80 content,
81 request=request,
82 title=f"{label} #{item_id}",
83 breadcrumbs=[
84 {"label": "Dashboard", "url": self._config.prefix},
85 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
86 {
87 "label": f"#{item_id}",
88 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}",
89 },
90 ],
91 )
93 async def render_inline_edit(
94 self,
95 request,
96 resource,
97 item_id: str,
98 user=None,
99 ) -> HTMLResponse:
100 """Render detail view with inline editable fields.
102 Each field is rendered as an Alpine.js-driven inline editor. In
103 *display* mode the current value is shown with a pencil icon that
104 appears on hover. Clicking the icon switches to *edit* mode which
105 shows an ``<input>`` alongside Save and Cancel buttons. Saving
106 issues an HTMX ``PATCH`` to
107 ``/{resource_name}/{item_id}/inline?field=<name>`` carrying the new
108 value.
110 Args:
111 request: Incoming HTTP request.
112 resource: Admin resource instance.
113 item_id: Primary-key value of the record to display.
114 user: Authenticated user (optional, for future RBAC use).
116 Returns:
117 ``HTMLResponse`` with the inline-edit fragment or full page.
118 """
119 label = self.resource_name.replace("_", " ").title()
120 item_dict: dict = {}
122 if resource and hasattr(resource, "service") and resource.service:
123 try:
124 if hasattr(resource.service, "get"):
125 item = await resource.service.get(item_id)
126 if item:
127 item_dict = (
128 item.model_dump()
129 if hasattr(item, "model_dump")
130 else dict(item)
131 )
132 except DataError as exc:
133 logger.debug(
134 "inline_edit fetch failed resource=%s id=%s: %s",
135 self.resource_name,
136 item_id,
137 exc,
138 )
139 raise DataError(
140 message=f"Failed to retrieve {self.resource_name} item {item_id}",
141 original_error=exc,
142 ) from None
144 patch_base = f"{self._config.prefix}/{self.resource_name}/{item_id}/inline"
146 field_rows: list[Any] = []
147 for field_name, field_value in item_dict.items():
148 alpine_key = f"editing_{field_name}"
149 row = el(
150 "tr",
151 el(
152 "td",
153 el("strong", field_name),
154 class_="py-2 pr-4 align-top text-sm font-medium text-muted-foreground w-1/4",
155 ),
156 el(
157 "td",
158 # Display mode
159 el(
160 "div",
161 el(
162 "span",
163 str(field_value),
164 class_="inline-edit-value",
165 ),
166 el(
167 "button",
168 "✎",
169 type="button",
170 title=f"Edit {field_name}",
171 class_=(
172 "inline-edit-pencil ml-2 text-primary-500 opacity-0 "
173 "group-hover:opacity-100 transition-opacity text-xs"
174 ),
175 **{"@click": f"{alpine_key} = true"},
176 ),
177 class_="group flex items-center",
178 **{"x-show": f"!{alpine_key}"},
179 ),
180 # Edit mode
181 el(
182 "div",
183 el(
184 "input",
185 type="text",
186 name=field_name,
187 value=str(field_value),
188 class_=(
189 "inline-edit-input border border-border "
190 "rounded px-2 py-1 text-sm w-full "
191 "bg-muted text-foreground "
192 "focus:outline-none focus:ring-2 focus:ring-primary-500"
193 ),
194 **{
195 ":name": f"'{field_name}'",
196 "x-ref": f"input_{field_name}",
197 },
198 ),
199 el(
200 "button",
201 "Save",
202 type="button",
203 class_=(
204 "ml-2 px-2 py-1 text-xs font-medium text-white "
205 "bg-primary-600 hover:bg-primary-700 rounded "
206 "focus:outline-none focus:ring-2 focus:ring-primary-500"
207 ),
208 **{
209 "hx-patch": patch_base,
210 "hx-include": f"[name='{field_name}']",
211 "hx-target": "closest tr",
212 "hx-swap": "outerHTML",
213 "@click": f"{alpine_key} = false",
214 },
215 ),
216 el(
217 "button",
218 "Cancel",
219 type="button",
220 class_=(
221 "ml-1 px-2 py-1 text-xs font-medium text-muted-foreground "
222 "dark:text-muted-foreground hover:text-foreground rounded border "
223 "border-border"
224 ),
225 **{"@click": f"{alpine_key} = false"},
226 ),
227 class_="flex items-center gap-1",
228 **{"x-show": alpine_key},
229 ),
230 class_="py-2 text-sm text-foreground",
231 ),
232 **{
233 "x-data": f"{{ {alpine_key}: false }}",
234 "class": "border-t border-border",
235 },
236 )
237 field_rows.append(row)
239 table_html = render_to_string(
240 el("table", *field_rows, class_="detail-inline-edit-table w-full")
241 )
243 content = f"""
244 <div class="resource-header" style="margin-bottom: 1.5rem;">
245 <a href="{self._config.prefix}/{self.resource_name}" style="color: #6366f1;">← Back to {label}</a>
246 <h1>{label} #{item_id} <span class="text-muted-foreground text-xs">(inline edit)</span></h1>
247 </div>
248 <div class="resource-content">{table_html}</div>
249 """
251 is_htmx = wants_fragment(request)
252 if is_htmx:
253 return HTMLResponse(content)
255 return self._renderer.render_page(
256 content,
257 request=request,
258 title=f"{label} #{item_id} — Inline Edit",
259 breadcrumbs=[
260 {"label": "Dashboard", "url": self._config.prefix},
261 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
262 {
263 "label": f"#{item_id}",
264 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}",
265 },
266 {
267 "label": "Inline Edit",
268 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}/inline-edit",
269 },
270 ],
271 )
273 async def _get_item_html(self, resource, item_id: str, label: str) -> str:
274 """Get HTML representation of the item."""
275 if resource and hasattr(resource, "service") and resource.service:
276 try:
277 if hasattr(resource.service, "get"):
278 item = await resource.service.get(item_id)
279 if item:
280 item_dict = (
281 item.model_dump()
282 if hasattr(item, "model_dump")
283 else dict(item)
284 )
285 rows = [
286 el("tr", el("td", el("strong", k)), el("td", str(v)))
287 for k, v in item_dict.items()
288 ]
289 return render_to_string(
290 el("table", *rows, class_="detail-table")
291 )
292 return render_to_string(el("p", "Item not found"))
293 except DataError as e:
294 logger.debug(
295 "Failed to get item %s/%s: %s",
296 self.resource_name,
297 item_id,
298 e,
299 )
300 raise DataError(
301 message=f"Failed to retrieve {self.resource_name} item {item_id}",
302 original_error=e,
303 ) from None
305 return render_to_string(el("p", f"Item #{item_id}"))
308__all__ = ["DetailRenderer"]