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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1from __future__ import annotations
3import re
4from typing import TYPE_CHECKING, Any
6from lexigram.serialization import dumps_str
7from lexigram.ui.core.base import Component, el, raw, render_to_string
9if TYPE_CHECKING:
10 from collections.abc import Callable
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 """
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
45 def render(self) -> Any:
46 # Determine schema components
47 components = self.schema() if callable(self.schema) else self.schema
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)
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 )
73 initial_data = dumps_str(self.value)
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 )
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 )
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 )
221 repeater_id = f"repeater_{self.name}"
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 )
237 return el("div", container, class_="mb-8")