Coverage for src/lexigram/admin/ui/fields/pivot_data.py: 96%

68 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:22 +0800

1"""Pivot data field for editable pivot columns in relation managers.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import Any 

7 

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 

12 

13 

14@dataclass(frozen=True, kw_only=True) 

15class PivotColumn: 

16 """Configuration for a single pivot column.""" 

17 

18 name: str 

19 label: str 

20 field_type: str = "text" 

21 required: bool = False 

22 default: str = "" 

23 

24 

25class PivotDataField(SchemaField): 

26 """Editable pivot columns displayed inline in a relation manager. 

27 

28 Renders form fields for each pivot column. 

29 """ 

30 

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 

42 

43 @property 

44 def pivot_columns(self) -> list[PivotColumn]: 

45 return self._pivot_columns 

46 

47 def render_form( 

48 self, value: dict[str, Any] | None, *, errors: list[str] | None = None 

49 ) -> Element: 

50 data = value or {} 

51 

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) 

56 

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 ) 

69 

70 return Element( 

71 "div", 

72 *children, 

73 class_="pivot-data-fields", 

74 ) 

75 

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" />' 

84 

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") 

90 

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({}) 

97 

98 def to_form(self, value: dict[str, Any] | None) -> str: 

99 return "" 

100 

101 

102class PivotTable: 

103 """Table organism showing related records with inline pivot editing. 

104 

105 Renders a table where each row has the related record label, 

106 editable pivot columns, and detach action. 

107 """ 

108 

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 [] 

116 

117 def render(self, resource_name: str = "", parent_id: str = "") -> str: 

118 rel_name = "pivot" 

119 

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 ) 

124 

125 rows_html = "" 

126 for row in self.rows: 

127 related_id = row.get("id", "") 

128 label = row.get("label", related_id) 

129 

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>""" 

139 

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>""" 

150 

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>"""