Coverage for src / lexigram / ui / organisms / repeater.py: 28%

29 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-10 04:11 +0800

1from __future__ import annotations 

2 

3import re 

4from typing import TYPE_CHECKING, Any 

5 

6from lexigram.serialization import dumps_str 

7from lexigram.ui.core.base import Component, el, raw, render_to_string 

8 

9if TYPE_CHECKING: 

10 from collections.abc import Callable 

11 

12 

13class Repeater(Component): 

14 """ 

15 A component that allows users to add/remove sets of fields. 

16 Useful for JSON arrays or HasMany relations. 

17 """ 

18 

19 def __init__( 

20 self, 

21 name: str, 

22 schema: list[Component] | Callable[[], list[Component]], 

23 value: list[dict] | None = None, 

24 label: str | None = None, 

25 add_label: str = "Add Item", 

26 item_label: str = "Item", 

27 **props, 

28 ) -> None: 

29 super().__init__( 

30 name=name, 

31 schema=schema, 

32 value=value, 

33 label=label, 

34 add_label=add_label, 

35 item_label=item_label, 

36 **props, 

37 ) 

38 self.name = name 

39 self.schema = schema 

40 self.value = value or [] 

41 self.label = label 

42 self.add_label = add_label 

43 self.item_label = item_label 

44 

45 def render(self) -> Any: 

46 # Determine schema components 

47 components = self.schema() if callable(self.schema) else self.schema 

48 

49 # Create a template by rendering components and replacing name/id with Alpine-friendly versions 

50 raw_template = "" 

51 for comp in components: 

52 raw_template += render_to_string(comp) 

53 

54 # Replace name="field" with :name="`{self.name}[${index}][field]`" 

55 # We look for name="([^"]+)" and id="([^"]+)" 

56 template_html = re.sub( 

57 r'name="([^"]+)"', 

58 rf':name="`{self.name}[${{index}}][\1]`"', 

59 raw_template, 

60 ) 

61 template_html = re.sub( 

62 r'id="([^"]+)"', 

63 rf':id="`{self.name}_${{index}}_\1`"', 

64 template_html, 

65 ) 

66 # Also need to handle for="..." labels 

67 template_html = re.sub( 

68 r'for="([^"]+)"', 

69 rf':for="`{self.name}_${{index}}_\1`"', 

70 template_html, 

71 ) 

72 

73 initial_data = dumps_str(self.value) 

74 

75 x_data = ( 

76 f"{{ " 

77 f"items: {initial_data}, " 

78 f"addItem() {{ this.items.push({{}}); }}, " 

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

80 f"moveUp(index) {{ if(index > 0) [this.items[index-1], this.items[index]] = [this.items[index], this.items[index-1]]; }}, " 

81 f"moveDown(index) {{ if(index < this.items.length - 1) [this.items[index+1], this.items[index]] = [this.items[index], this.items[index+1]]; }}" 

82 f" }}" 

83 ) 

84 

85 # Header with Label and Add Button 

86 header = el( 

87 "div", 

88 el( 

89 "h4", 

90 self.label or "", 

91 class_="text-sm font-semibold text-foreground", 

92 ) 

93 if self.label 

94 else "", 

95 el( 

96 "button", 

97 el( 

98 "svg", 

99 el( 

100 "path", 

101 d="M12 4v16m8-8H4", 

102 stroke_linecap="round", 

103 stroke_linejoin="round", 

104 stroke_width="2", 

105 ), 

106 class_="w-4 h-4 mr-1", 

107 fill="none", 

108 viewBox="0 0 24 24", 

109 stroke="currentColor", 

110 ), 

111 self.add_label, 

112 type="button", 

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

114 class_="inline-flex items-center px-3 py-1.5 text-xs font-semibold text-primary bg-primary/5 rounded-lg hover:bg-primary/15 transition-all duration-200", 

115 ), 

116 class_="flex items-center justify-between mb-4", 

117 ) 

118 

119 # Repeater Item Card 

120 item_card = el( 

121 "div", 

122 # Item Header (Drag handle placeholder, Label, Actions) 

123 el( 

124 "div", 

125 el( 

126 "span", 

127 **{"x-text": f"`{self.item_label} #${{index + 1}}`"}, 

128 class_="text-xs font-bold text-muted-foreground uppercase tracking-wider", 

129 ), 

130 el( 

131 "div", 

132 # Move Up 

133 el( 

134 "button", 

135 el( 

136 "svg", 

137 el( 

138 "path", 

139 d="M5 15l7-7 7 7", 

140 stroke_linecap="round", 

141 stroke_linejoin="round", 

142 stroke_width="2", 

143 ), 

144 class_="w-3 h-3", 

145 fill="none", 

146 viewBox="0 0 24 24", 

147 stroke="currentColor", 

148 aria_hidden="true", 

149 ), 

150 type="button", 

151 title="Move Up", 

152 aria_label="Move Up", 

153 **{"@click": "moveUp(index)", "x-show": "index > 0"}, 

154 class_="p-1 text-muted-foreground hover:text-primary transition-colors", 

155 ), 

156 # Move Down 

157 el( 

158 "button", 

159 el( 

160 "svg", 

161 el( 

162 "path", 

163 d="M19 9l-7 7-7-7", 

164 stroke_linecap="round", 

165 stroke_linejoin="round", 

166 stroke_width="2", 

167 ), 

168 class_="w-3 h-3", 

169 fill="none", 

170 viewBox="0 0 24 24", 

171 stroke="currentColor", 

172 aria_hidden="true", 

173 ), 

174 type="button", 

175 title="Move Down", 

176 aria_label="Move Down", 

177 **{ 

178 "@click": "moveDown(index)", 

179 "x-show": "index < items.length - 1", 

180 }, 

181 class_="p-1 text-muted-foreground hover:text-primary transition-colors", 

182 ), 

183 # Remove 

184 el( 

185 "button", 

186 el( 

187 "svg", 

188 el( 

189 "path", 

190 d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16", 

191 stroke_linecap="round", 

192 stroke_linejoin="round", 

193 stroke_width="2", 

194 ), 

195 class_="w-4 h-4", 

196 fill="none", 

197 viewBox="0 0 24 24", 

198 stroke="currentColor", 

199 aria_hidden="true", 

200 ), 

201 type="button", 

202 title="Remove", 

203 aria_label="Remove", 

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

205 class_="ml-2 p-1 text-muted-foreground hover:text-destructive transition-colors", 

206 ), 

207 class_="flex items-center gap-1", 

208 ), 

209 class_="flex items-center justify-between px-4 py-2 bg-muted border-b border-border", 

210 ), 

211 # Item Content 

212 el( 

213 "div", 

214 raw(template_html), 

215 class_="p-4 grid grid-cols-1 gap-4 sm:grid-cols-2", 

216 ), 

217 class_="bg-card border border-border rounded-xl overflow-hidden mb-4 shadow-sm", 

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

219 ) 

220 

221 repeater_id = f"repeater_{self.name}" 

222 

223 container = el( 

224 "div", 

225 header, 

226 el("template", item_card), 

227 el( 

228 "div", 

229 "No items added.", 

230 class_="text-center py-8 border-2 border-dashed border-border rounded-xl text-sm text-muted-foreground", 

231 **{"x-show": "items.length === 0"}, 

232 ), 

233 class_="repeater-container", 

234 **{"x-data": x_data, "id": repeater_id}, 

235 ) 

236 

237 return el("div", container, class_="mb-8")