1use std::collections::HashSet;
13
14use tablo_ui::{
15 ButtonSize, ButtonVariant, FieldLegendVariant, button, field_error as ui_field_error,
16 field_legend, field_set,
17};
18use topcoat::{Result, context::Cx, view::*};
19
20use super::{
21 FormInput, Schema,
22 fields::{Field, Items},
23 read::Pairs,
24 tree::Source,
25};
26use crate::values::{FieldError, FieldErrors, FormValue, FormValues};
27
28pub(crate) const ROW: &str = "__row__";
30
31pub const MAX_REPEATER_ROWS: usize = 100;
34
35pub(crate) struct RepeaterControl {
37 item: fn() -> Schema,
38 pub(crate) add_label: Option<crate::Text>,
40 pub(crate) bounds: Items,
42}
43
44impl RepeaterControl {
45 pub(crate) fn item_schema(&self) -> Schema {
47 (self.item)()
48 }
49
50 #[cfg(test)]
52 pub(crate) fn of(item: fn() -> Schema) -> Self {
53 Self {
54 item,
55 add_label: None,
56 bounds: Items::default(),
57 }
58 }
59
60 pub(crate) fn new<T: FormInput>() -> Self {
61 Self {
62 item: T::schema,
63 add_label: None,
64 bounds: Items::default(),
65 }
66 }
67
68 pub(super) fn read(
79 &self,
80 key: &str,
81 pairs: &mut Pairs<'_>,
82 ) -> std::result::Result<Option<FormValue>, String> {
83 let Some(order) = pairs.claim(key).pop() else {
84 return Ok(None);
85 };
86 let item = (self.item)();
87 let mut seen = HashSet::new();
88 let mut rows = Vec::new();
89 for token in order.split(',').map(str::trim).filter(|t| !t.is_empty()) {
90 if rows.len() == MAX_REPEATER_ROWS {
91 return Err(format!("`{key}` lists more than {MAX_REPEATER_ROWS} rows"));
92 }
93 let row: usize = token
94 .parse()
95 .map_err(|_| format!("`{key}` lists `{token}`, which is not a row"))?;
96 if !seen.insert(row) {
97 return Err(format!("`{key}` lists row {row} twice"));
98 }
99 let mut posted = Vec::new();
100 for leaf in item.fields().map(Field::name) {
101 for value in pairs.claim(&row_key(key, &row.to_string(), leaf)) {
102 posted.push((leaf.to_string(), value.to_string()));
103 }
104 }
105 if posted.is_empty() {
106 return Err(format!("`{key}` lists row {row}, which posts nothing"));
107 }
108 rows.push(item.read_in(&posted, pairs.zone.clone())?);
109 }
110 Ok(Some(FormValue::Rows(rows)))
111 }
112
113 pub(crate) async fn check(
119 &self,
120 cx: &Cx,
121 key: &str,
122 rows: &[FormValues],
123 stored: &[FormValues],
124 errors: &mut FieldErrors,
125 ) {
126 for (row, item) in rows.iter().enumerate() {
127 let (schema, values) = self.row(key, &row.to_string(), item);
128 let held = stored.get(row).map_or_else(FormValues::new, |held| {
129 self.row(key, &row.to_string(), held).1
130 });
131 Box::pin(schema.check_controls(cx, &values, &held, errors)).await;
132 }
133 }
134
135 fn row(&self, key: &str, row: &str, item: &FormValues) -> (Schema, FormValues) {
138 let prefix = row_key(key, row, "");
139 let mut schema = (self.item)();
140 for field in &mut schema.fields {
141 field.prefix(&prefix);
142 }
143 let values = item
144 .iter()
145 .map(|(leaf, value)| (format!("{prefix}{leaf}"), value.clone()))
146 .collect();
147 (schema, values)
148 }
149
150 pub(crate) async fn render<'a>(
153 &self,
154 cx: &'a Cx,
155 field: &Field,
156 source: &Source<'_>,
157 ) -> Result<BoxView<'a>> {
158 let key = field.name().to_string();
159 let items = source.rows(&key);
160 let mut rows = Vec::with_capacity(items.len());
161 let removable = self.bounds.min.is_none_or(|min| items.len() > min);
164 for (row, item) in items.iter().enumerate() {
165 let errors = source.errors();
166 rows.push(
167 self.render_row(cx, &key, &row.to_string(), item, errors, removable, source)
168 .await?,
169 );
170 }
171 let blank = self
172 .render_row(
173 cx,
174 &key,
175 ROW,
176 &FormValues::new(),
177 &FieldErrors::new(),
178 true,
179 source,
180 )
181 .await?;
182 let order = (0..items.len())
183 .map(|row| row.to_string())
184 .collect::<Vec<_>>()
185 .join(",");
186 let next = items.len().to_string();
187 let most = self
188 .bounds
189 .max
190 .map_or(MAX_REPEATER_ROWS, |max| max.min(MAX_REPEATER_ROWS));
191 let full = items.len() >= most;
192 let min = self.bounds.min;
193 let label = field.label();
194 let add = self
195 .add_label
196 .clone()
197 .unwrap_or_else(|| crate::Text::message("tablo-repeater-add"));
198 let error = source.error_for(field).map(|error| {
199 (
200 error.message(&label).localize(cx),
201 super::fields::error_kind(error),
202 )
203 });
204 let legend_id = format!("{}-legend", source.id(&key));
205 let error_id = format!("{}-error", source.id(&key));
207 let described_by = error.is_some().then(|| error_id.clone());
208 Ok(view! {
209 field_set(
210 attrs: attributes! {
211 class="ac-field gap-3"
212 data-repeater=(key.clone())
213 data-repeater-next=(next)
214 data-repeater-max=(most)
215 data-repeater-min=(min)
216 aria-describedby=(described_by)
217 },
218 field_legend(
219 variant: FieldLegendVariant::Label,
220 attrs: attributes! { id=(legend_id.clone()) class="mb-0" },
221 (label)
222 )
223 <input type="hidden" name=(key) value=(order) data-repeater-order="">
224 if let Some((message, kind)) = error {
225 ui_field_error(
226 attrs: attributes! { id=(error_id) class="ac-error" data-error=(kind) },
227 (message)
228 )
229 }
230 <ol
231 class="flex flex-col gap-3"
232 aria-labelledby=(legend_id)
233 data-repeater-rows=""
234 >
235 for row in rows {
236 (row)
237 }
238 </ol>
239 <template data-repeater-blank="">(blank)</template>
240 <div>
241 button(
242 variant: ButtonVariant::Outline,
243 size: ButtonSize::Sm,
244 attrs: attributes! { type="button" data-repeater-add="" disabled=(full) },
245 (add)
246 )
247 </div>
248 )
249 }
250 .boxed())
251 }
252
253 #[expect(
257 clippy::too_many_arguments,
258 reason = "one call site renders a stored row, the other the blank one"
259 )]
260 async fn render_row<'a>(
261 &self,
262 cx: &'a Cx,
263 key: &str,
264 row: &str,
265 item: &FormValues,
266 errors: &FieldErrors,
267 removable: bool,
268 source: &Source<'_>,
269 ) -> Result<BoxView<'a>> {
270 let (schema, values) = self.row(key, row, item);
271 let mut rendered = Source::form(&values, errors).scoped(source.scope());
272 if let Some(options) = source.options() {
273 rendered = rendered.options_at(options);
274 }
275 let controls = schema.render(cx, rendered).await?;
276 let row = row.to_string();
277 let action = move |id: &'static str, data: Attributes, disabled: bool| {
278 let label = crate::t!(cx, id);
279 view! {
280 button(
281 variant: ButtonVariant::Ghost,
282 size: ButtonSize::Sm,
283 attrs: attributes! { type="button" disabled=(disabled) (data) },
284 (label)
285 )
286 }
287 };
288 Ok(view! {
289 <li
290 class="flex flex-col gap-4 rounded-lg border border-border p-4"
291 data-repeater-row=(row)
292 >
293 (controls)
294 <div class="flex flex-wrap justify-end gap-2">
295 (action(
296 "tablo-repeater-up",
297 attributes! { data-repeater-up="" },
298 false,
299 ))
300 (action(
301 "tablo-repeater-down",
302 attributes! { data-repeater-down="" },
303 false,
304 ))
305 (action(
306 "tablo-repeater-remove",
307 attributes! { data-repeater-remove="" },
308 !removable,
309 ))
310 </div>
311 </li>
312 }
313 .boxed())
314 }
315}
316
317pub(crate) fn row_key(key: &str, row: &str, leaf: &str) -> String {
319 format!("{key}.{row}.{leaf}")
320}
321
322pub fn write_items<T: FormInput>(items: &[T]) -> FormValue {
325 FormValue::Rows(
326 items
327 .iter()
328 .map(|item| {
329 let mut out = FormValues::new();
330 item.write(&mut out);
331 out
332 })
333 .collect(),
334 )
335}
336
337pub fn parse_items<T: FormInput>(
346 cx: &Cx,
347 key: &str,
348 values: &FormValues,
349) -> std::result::Result<Vec<T>, Vec<FieldError>> {
350 let rows: &[FormValues] = match values.get(key) {
351 None => &[],
352 Some(FormValue::Rows(rows)) => rows,
353 Some(_) => return Err(vec![crate::values::unreadable(key)]),
354 };
355 let mut items = Vec::with_capacity(rows.len());
356 let mut errors = Vec::new();
357 for (row, values) in rows.iter().enumerate() {
358 match T::parse(cx, values) {
359 Ok(item) => items.push(item),
360 Err(failures) => errors.extend(failures.into_iter().map(|failure| FieldError {
361 key: row_key(key, &row.to_string(), &failure.key),
362 kind: failure.kind,
363 })),
364 }
365 }
366 if errors.is_empty() {
367 Ok(items)
368 } else {
369 Err(errors)
370 }
371}
372
373#[cfg(test)]
374mod tests;