Coverage for src / lexigram / admin / ui / fields / pivot_data.py: 96%
68 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
1"""Pivot data field for editable pivot columns in relation managers."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import Any
8from lexigram.admin.schema.base import SchemaField
9from lexigram.admin.schema.exceptions import FieldError
10from lexigram.result import Ok, Result
11from lexigram.ui import Element, raw
14@dataclass(frozen=True, kw_only=True)
15class PivotColumn:
16 """Configuration for a single pivot column."""
18 name: str
19 label: str
20 field_type: str = "text"
21 required: bool = False
22 default: str = ""
25class PivotDataField(SchemaField):
26 """Editable pivot columns displayed inline in a relation manager.
28 Renders form fields for each pivot column.
29 """
31 def __init__(
32 self,
33 name: str,
34 *,
35 pivot_columns: list[PivotColumn] | None = None,
36 related_id: str = "",
37 **kwargs: Any,
38 ) -> None:
39 super().__init__(name=name, **kwargs)
40 self._pivot_columns = pivot_columns or []
41 self._related_id = related_id
43 @property
44 def pivot_columns(self) -> list[PivotColumn]:
45 return self._pivot_columns
47 def render_form(
48 self, value: dict[str, Any] | None, *, errors: list[str] | None = None
49 ) -> Element:
50 data = value or {}
52 children: list[Element] = []
53 for col in self.pivot_columns:
54 current = data.get(col.name, col.default)
55 input_html = self._build_pivot_input(col, current)
57 children.append(
58 Element(
59 "div",
60 Element(
61 "label",
62 col.label,
63 class_="text-xs font-medium text-muted-foreground w-24",
64 ),
65 raw(input_html),
66 class_="flex items-center gap-2 mb-2",
67 )
68 )
70 return Element(
71 "div",
72 *children,
73 class_="pivot-data-fields",
74 )
76 def _build_pivot_input(self, col: PivotColumn, value: str) -> str:
77 attrs = f'name="pivot_{col.name}" '
78 if col.field_type == "checkbox":
79 checked = "checked" if value else ""
80 return f'<input type="checkbox" {attrs} {checked} class="rounded border-border text-primary-600" />'
81 if col.field_type == "select":
82 return f'<select {attrs} class="px-2 py-1 text-sm border rounded">{value}</select>'
83 return f'<input type="{col.field_type}" {attrs} value="{value}" class="px-2 py-1 text-sm border rounded w-full" />'
85 def render_column(self, record: Any, value: dict[str, Any] | None) -> Element:
86 if value is None:
87 return Element("span", "\u2014", class_="text-muted")
88 parts = ", ".join(f"{k}={v}" for k, v in value.items())
89 return Element("span", parts, class_="text-sm text-muted-foreground")
91 def from_form(self, raw: str | None) -> Result[dict[str, Any] | None, FieldError]:
92 if raw is None:
93 return Ok(None)
94 if isinstance(raw, dict):
95 return Ok(dict(raw))
96 return Ok({})
98 def to_form(self, value: dict[str, Any] | None) -> str:
99 return ""
102class PivotTable:
103 """Table organism showing related records with inline pivot editing.
105 Renders a table where each row has the related record label,
106 editable pivot columns, and detach action.
107 """
109 def __init__(
110 self,
111 pivot_columns: list[PivotColumn],
112 rows: list[dict[str, Any]] | None = None,
113 ):
114 self.pivot_columns = pivot_columns
115 self.rows = rows or []
117 def render(self, resource_name: str = "", parent_id: str = "") -> str:
118 rel_name = "pivot"
120 header_cols = "".join(
121 f'<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">{c.label}</th>'
122 for c in self.pivot_columns
123 )
125 rows_html = ""
126 for row in self.rows:
127 related_id = row.get("id", "")
128 label = row.get("label", related_id)
130 pivot_cells = ""
131 for col in self.pivot_columns:
132 value = row.get("pivot", {}).get(col.name, col.default)
133 pivot_cells += f"""<td class="px-4 py-2">
134 <input type="text" class="px-2 py-1 text-sm border rounded w-full"
135 value="{value}" name="pivot_{col.name}"
136 hx-post="/admin/{resource_name}/{parent_id}/relations/{rel_name}/pivot/{related_id}"
137 hx-trigger="change" hx-swap="none" />
138 </td>"""
140 rows_html += f"""<tr>
141 <td class="px-4 py-2 text-sm font-medium text-foreground">{label}</td>
142 {pivot_cells}
143 <td class="px-4 py-2 text-sm">
144 <button class="text-destructive hover:text-destructive/90 text-sm"
145 hx-delete="/admin/{resource_name}/{parent_id}/relations/{rel_name}/{related_id}"
146 hx-confirm="Detach this record?"
147 hx-target="closest tr" hx-swap="outerHTML">Detach</button>
148 </td>
149 </tr>"""
151 return f"""<table class="min-w-full divide-y divide-border">
152 <thead class="bg-muted dark:bg-card">
153 <tr>
154 <th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">Record</th>
155 {header_cols}
156 <th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">Actions</th>
157 </tr>
158 </thead>
159 <tbody class="divide-y divide-border">{rows_html}</tbody>
160 </table>"""