Coverage for src/lexigram/admin/schema/repeater.py: 29%

85 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:31 +0800

1from __future__ import annotations 

2 

3from dataclasses import dataclass, field 

4from typing import Any 

5 

6from lexigram.admin.schema.base import SchemaField 

7from lexigram.admin.schema.exceptions import FieldError 

8from lexigram.result import Err, Ok, Result 

9from lexigram.serialization import dumps_str, loads_str 

10from lexigram.ui import Element, raw 

11 

12 

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

14class RepeaterField(SchemaField[list[dict[str, Any]]]): 

15 """A repeatable group of sub-fields. 

16 

17 Renders an Alpine.js-managed list of sub-forms, each containing 

18 the configured ``fields``. Useful for managing ordered or 

19 nested collections (e.g., line items, social links, addresses). 

20 

21 Each item is serialised as a JSON object. The whole collection 

22 is stored as a JSON array. 

23 """ 

24 

25 fields: list[SchemaField[Any]] = field(default_factory=list) 

26 min_items: int = 0 

27 max_items: int | None = None 

28 add_button_label: str = "Add Item" 

29 

30 def render_form( 

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

32 ) -> Element: 

33 items = value if value is not None else [] 

34 

35 initial_json = dumps_str(items) 

36 state_id = f"repeater_{self.name}" 

37 

38 x_data = ( 

39 f"{{" 

40 f" items: {initial_json}," 

41 f" addItem() {{" 

42 f" if(this.maxItems && this.items.length >= this.maxItems) return;" 

43 f" let blank = {{}};" 

44 f" this.items.push(blank);" 

45 f" }}," 

46 f" removeItem(index) {{ this.items.splice(index, 1); }}," 

47 f" moveItem(from, to) {{" 

48 f" let arr = this.items;" 

49 f" let [removed] = arr.splice(from, 1);" 

50 f" arr.splice(to, 0, removed);" 

51 f" }}," 

52 f" get serialized() {{ return JSON.stringify(this.items); }}," 

53 f" maxItems: {self.max_items or 'null'}," 

54 f"}}" 

55 ) 

56 

57 item_rows: list[Element] = [] 

58 for i, item in enumerate(items): 

59 sub_fields = self._render_item(item, i) 

60 item_rows.append(sub_fields) 

61 

62 template_item = self._build_item_template() 

63 

64 add_disabled = self.max_items is not None and len(items) >= self.max_items 

65 add_button = Element( 

66 "button", 

67 self.add_button_label, 

68 type="button", 

69 **{"@click": "addItem()"}, 

70 disabled="disabled" if add_disabled else None, 

71 class_=( 

72 "mt-3 inline-flex items-center px-3 py-1.5 text-xs font-medium " 

73 "text-primary-600 bg-primary-50 dark:bg-primary-900/30 " 

74 "dark:text-primary-400 rounded-lg " 

75 "hover:bg-primary-100 transition-colors duration-200" 

76 ), 

77 ) 

78 

79 hidden = Element( 

80 "input", 

81 type="hidden", 

82 name=self.name, 

83 **{":value": "serialized"}, 

84 ) 

85 

86 counter = ( 

87 Element( 

88 "p", 

89 **{"x-text": "`${items.length} item(s)`"}, 

90 class_="text-xs text-muted-foreground mt-1", 

91 ) 

92 if self.max_items is not None 

93 else "" 

94 ) 

95 

96 if self.label: 

97 return Element( 

98 "div", 

99 Element( 

100 "label", 

101 self.label, 

102 class_="block text-sm font-medium text-foreground mb-2", 

103 ), 

104 Element( 

105 "div", 

106 template_item, 

107 *item_rows, 

108 add_button, 

109 hidden, 

110 counter, 

111 **{"x-data": x_data}, 

112 class_="space-y-3", 

113 ), 

114 ( 

115 Element( 

116 "p", 

117 errors[0], 

118 id=f"{self.name}-error", 

119 class_="mt-2 text-sm text-destructive", 

120 ) 

121 if errors 

122 else "" 

123 ), 

124 class_="mb-6", 

125 ) 

126 

127 return Element( 

128 "div", 

129 template_item, 

130 *item_rows, 

131 add_button, 

132 hidden, 

133 counter, 

134 **{"x-data": x_data}, 

135 class_="space-y-3", 

136 ) 

137 

138 def _render_item(self, item: dict[str, Any], index: int) -> Element: 

139 rows: list[Element] = [] 

140 for subfield in self.fields: 

141 sub_value = item.get(subfield.name) 

142 sub_rendered = subfield.render_form(sub_value) 

143 rows.append(sub_rendered) 

144 

145 svg = raw( 

146 '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" ' 

147 'stroke="currentColor">' 

148 '<path stroke-linecap="round" stroke-linejoin="round" ' 

149 'stroke-width="2" d="M6 18L18 6M6 6l12 12" />' 

150 "</svg>" 

151 ) 

152 remove_btn = Element( 

153 "button", 

154 svg, 

155 type="button", 

156 **{"@click": f"removeItem({index})"}, 

157 class_=( 

158 "absolute top-2 right-2 p-1 text-muted-foreground hover:text-destructive " 

159 "transition-colors duration-200 rounded" 

160 ), 

161 ) 

162 

163 return Element( 

164 "div", 

165 *rows, 

166 remove_btn, 

167 class_="relative p-4 border border-border rounded-xl bg-card dark:bg-background", 

168 ) 

169 

170 def _build_item_template(self) -> Element: 

171 tpl_rows: list[Element] = [] 

172 for subfield in self.fields: 

173 sub_rendered = subfield.render_form(None) 

174 tpl_rows.append(sub_rendered) 

175 

176 tpl_svg = raw( 

177 '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" ' 

178 'stroke="currentColor">' 

179 '<path stroke-linecap="round" stroke-linejoin="round" ' 

180 'stroke-width="2" d="M6 18L18 6M6 6l12 12" />' 

181 "</svg>" 

182 ) 

183 tpl_remove = Element( 

184 "button", 

185 tpl_svg, 

186 type="button", 

187 **{"@click": "removeItem(index)"}, 

188 class_=( 

189 '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" ' 

190 'stroke="currentColor">' 

191 '<path stroke-linecap="round" stroke-linejoin="round" ' 

192 'stroke-width="2" d="M6 18L18 6M6 6l12 12" />' 

193 "</svg>" 

194 ), 

195 ) 

196 

197 tpl = Element( 

198 "div", 

199 *tpl_rows, 

200 tpl_remove, 

201 class_=("relative p-4 border border-border rounded-xl bg-background"), 

202 ) 

203 

204 return Element( 

205 "template", 

206 tpl, 

207 **{"x-for": "(item, index) in items", ":key": "index"}, 

208 ) 

209 

210 def render_column(self, record: Any, value: list[dict[str, Any]] | None) -> Element: 

211 if value is None: 

212 return Element("span", "\u2014", class_="text-muted") 

213 count = len(value) 

214 label = "item" if count == 1 else "items" 

215 return Element( 

216 "span", 

217 f"{count} {label}", 

218 class_="text-sm text-muted-foreground", 

219 ) 

220 

221 def from_form( 

222 self, raw: str | None 

223 ) -> Result[list[dict[str, Any]] | None, FieldError]: 

224 if raw is None: 

225 return Ok(None) 

226 stripped = raw.strip() 

227 if not stripped: 

228 if self.nullable: 

229 return Ok(None) 

230 return Err(FieldError("Invalid JSON array")) 

231 try: 

232 parsed = loads_str(stripped) 

233 except ValueError: 

234 return Err(FieldError("Invalid JSON array")) 

235 if not isinstance(parsed, list): 

236 return Err(FieldError("Must be a JSON array")) 

237 validated: list[dict[str, Any]] = [] 

238 for i, item in enumerate(parsed): 

239 if not isinstance(item, dict): 

240 return Err(FieldError(f"Item {i} must be a JSON object")) 

241 validated_item: dict[str, Any] = {} 

242 for subfield in self.fields: 

243 sub_raw = item.get(subfield.name) 

244 sub_value_raw_str = None if sub_raw is None else str(sub_raw) 

245 sub_result = subfield.from_form(sub_value_raw_str) 

246 if sub_result.is_err(): 

247 return Err( 

248 FieldError( 

249 f"Item {i}.{subfield.name}: {sub_result.unwrap_err()}" 

250 ) 

251 ) 

252 validated_item[subfield.name] = sub_result.unwrap() 

253 validated.append(validated_item) 

254 return Ok(validated) 

255 

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

257 return "" if value is None else dumps_str(value)