Coverage for src / lexigram / admin / schema / repeater.py: 24%
85 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1from __future__ import annotations
3from dataclasses import dataclass, field
4from typing import Any
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
13@dataclass(frozen=True, kw_only=True)
14class RepeaterField(SchemaField[list[dict[str, Any]]]):
15 """A repeatable group of sub-fields.
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).
21 Each item is serialised as a JSON object. The whole collection
22 is stored as a JSON array.
23 """
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"
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 []
35 initial_json = dumps_str(items)
36 state_id = f"repeater_{self.name}"
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 )
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)
62 template_item = self._build_item_template()
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 )
79 hidden = Element(
80 "input",
81 type="hidden",
82 name=self.name,
83 **{":value": "serialized"},
84 )
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 )
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 )
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 )
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)
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 )
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 )
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)
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 )
197 tpl = Element(
198 "div",
199 *tpl_rows,
200 tpl_remove,
201 class_=("relative p-4 border border-border rounded-xl bg-background"),
202 )
204 return Element(
205 "template",
206 tpl,
207 **{"x-for": "(item, index) in items", ":key": "index"},
208 )
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 )
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)
256 def to_form(self, value: list[dict[str, Any]] | None) -> str:
257 return "" if value is None else dumps_str(value)