Skip to main content

tablo_core/schema/
repeater.rs

1//! Repeaters: a list of values stored in one `#[document]` column, edited one row per item.
2//!
3//! Each row posts its item's keys under `{key}.{row}.`, and the repeater's own key posts the rows
4//! in the order they show, so a row the browser adds, removes or moves needs no other key.
5//! [`Schema::read`] reads them into the repeater's key as [`FormValue::Rows`], each row holding its
6//! item's values: the edit's stored value, a hidden repeater and the parse then see one key like
7//! any other field's.
8//!
9//! The browser adds a row by copying the blank row the repeater renders in a `<template>`, its
10//! keys carrying [`ROW`] for the row's number (`assets/repeaters.js`).
11
12use 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
28/// What the blank row's keys carry for the number the browser gives a row it adds.
29pub(crate) const ROW: &str = "__row__";
30
31/// The most rows one repeater takes: each renders in full when the form comes back refused, so
32/// an unbounded list would let a small submission render a page of any size.
33pub const MAX_REPEATER_ROWS: usize = 100;
34
35/// A repeater's control: its item's controls, one row each.
36pub(crate) struct RepeaterControl {
37    item: fn() -> Schema,
38    /// The add button's label, over "Add item".
39    pub(crate) add_label: Option<crate::Text>,
40    /// How many rows it takes, set by `min_items` and `max_items`.
41    pub(crate) bounds: Items,
42}
43
44impl RepeaterControl {
45    /// One row's controls.
46    pub(crate) fn item_schema(&self) -> Schema {
47        (self.item)()
48    }
49
50    /// A repeater whose rows hold `item`'s controls.
51    #[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    /// Reads the rows posted under `key`, in the order `key` lists them, each through its item's
69    /// schema. A row's key that its item does not declare, or that a row `key` does not list
70    /// posts, stays unclaimed for the unknown-key check to refuse. `None` when the submission does
71    /// not post `key`, so an edit keeps the stored rows.
72    ///
73    /// # Errors
74    ///
75    /// `key` lists something other than row numbers, a row twice, a row posting none of its
76    /// item's keys, or more than [`MAX_REPEATER_ROWS`] rows. A browser posts every control of each
77    /// row it shows, so none of these comes from the form.
78    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    /// Adds to `errors` what the item's controls' own rules refuse in each of `rows`, under the
114    /// key the row's control posts: a choice that is not one of its options, an email field's
115    /// address, a text field's declared length, bounds and pattern. Each row checks against the
116    /// row `stored` holds at its position, so a value the record stores there is not checked
117    /// again.
118    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    /// The item's schema posting under the row `row` of the repeater `key`, and `item`'s keys as
136    /// that row posts them.
137    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    /// Renders the repeater `field` from `source`: a row per stored or posted item, and the blank
151    /// row the add button copies.
152    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        // A row's Remove button is disabled while the list holds no more rows than it needs;
162        // `assets/repeaters.js` keeps it so as rows come and go.
163        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        // Named as every control's error slot is, so the fieldset describes itself by it.
206        let error_id = format!("{}-error", source.id(&key));
207        let described_by = error.is_some().then(|| error_id.clone());
208        Ok(view! {
209            cx =>
210            field_set(
211                attrs: attributes! {
212                    class="ac-field gap-3"
213                    data-repeater=(key.clone())
214                    data-repeater-next=(next)
215                    data-repeater-max=(most)
216                    data-repeater-min=(min)
217                    aria-describedby=(described_by)
218                },
219                field_legend(
220                    variant: FieldLegendVariant::Label,
221                    attrs: attributes! { id=(legend_id.clone()) class="mb-0" },
222                    (label)
223                )
224                <input type="hidden" name=(key) value=(order) data-repeater-order="">
225                if let Some((message, kind)) = error {
226                    ui_field_error(
227                        attrs: attributes! { id=(error_id) class="ac-error" data-error=(kind) },
228                        (message)
229                    )
230                }
231                <ol
232                    class="flex flex-col gap-3"
233                    aria-labelledby=(legend_id)
234                    data-repeater-rows=""
235                >
236                    for row in rows {
237                        (row)
238                    }
239                </ol>
240                <template data-repeater-blank="">(blank)</template>
241                <div>
242                    button(
243                        variant: ButtonVariant::Outline,
244                        size: ButtonSize::Sm,
245                        attrs: attributes! { type="button" data-repeater-add="" disabled=(full) },
246                        (add)
247                    )
248                </div>
249            )
250        }
251        .boxed())
252    }
253
254    /// Renders the row `row` of the repeater `key` holding `item`, its controls posting under the
255    /// row's prefix, with the buttons that move and remove it, Remove disabled unless
256    /// `removable`.
257    #[expect(
258        clippy::too_many_arguments,
259        reason = "one call site renders a stored row, the other the blank one"
260    )]
261    async fn render_row<'a>(
262        &self,
263        cx: &'a Cx,
264        key: &str,
265        row: &str,
266        item: &FormValues,
267        errors: &FieldErrors,
268        removable: bool,
269        source: &Source<'_>,
270    ) -> Result<BoxView<'a>> {
271        let (schema, values) = self.row(key, row, item);
272        let mut rendered = Source::form(&values, errors).scoped(source.scope());
273        if let Some(options) = source.options() {
274            rendered = rendered.options_at(options);
275        }
276        let controls = schema.render(cx, rendered).await?;
277        let row = row.to_string();
278        let action = |id: &'static str, data: Attributes, disabled: bool| {
279            let label = crate::t!(cx, id);
280            view! {
281                cx =>
282                button(
283                    variant: ButtonVariant::Ghost,
284                    size: ButtonSize::Sm,
285                    attrs: attributes! { type="button" disabled=(disabled) (data) },
286                    (label)
287                )
288            }
289        };
290        Ok(view! {
291            cx =>
292            <li
293                class="flex flex-col gap-4 rounded-lg border border-border p-4"
294                data-repeater-row=(row)
295            >
296                (controls)
297                <div class="flex flex-wrap justify-end gap-2">
298                    (action(
299                        "tablo-repeater-up",
300                        attributes! { cx => data-repeater-up="" },
301                        false,
302                    ))
303                    (action(
304                        "tablo-repeater-down",
305                        attributes! { cx => data-repeater-down="" },
306                        false,
307                    ))
308                    (action(
309                        "tablo-repeater-remove",
310                        attributes! { cx => data-repeater-remove="" },
311                        !removable,
312                    ))
313                </div>
314            </li>
315        }
316        .boxed())
317    }
318}
319
320/// The key the row `row` of the repeater `key` posts its item's `leaf` under.
321pub(crate) fn row_key(key: &str, row: &str, leaf: &str) -> String {
322    format!("{key}.{row}.{leaf}")
323}
324
325/// The repeater's value holding `items`: what a record form hydrates the repeater with, and what
326/// a hand-written one writes.
327pub fn write_items<T: FormInput>(items: &[T]) -> FormValue {
328    FormValue::Rows(
329        items
330            .iter()
331            .map(|item| {
332                let mut out = FormValues::new();
333                item.write(&mut out);
334                out
335            })
336            .collect(),
337    )
338}
339
340/// The items the repeater `key` holds in a submission [read](crate::Schema::read) through its
341/// schema, none when it posts nothing: what a record form's parse reads the repeater with, and
342/// what a hand-written one or an action input reads.
343///
344/// # Errors
345///
346/// Each row's refusals, under the key the row's control posts, or the repeater's own when it holds
347/// text or a list.
348pub fn parse_items<T: FormInput>(
349    cx: &Cx,
350    key: &str,
351    values: &FormValues,
352) -> std::result::Result<Vec<T>, Vec<FieldError>> {
353    let rows: &[FormValues] = match values.get(key) {
354        None => &[],
355        Some(FormValue::Rows(rows)) => rows,
356        Some(_) => return Err(vec![crate::values::unreadable(key)]),
357    };
358    let mut items = Vec::with_capacity(rows.len());
359    let mut errors = Vec::new();
360    for (row, values) in rows.iter().enumerate() {
361        match T::parse(cx, values) {
362            Ok(item) => items.push(item),
363            Err(failures) => errors.extend(failures.into_iter().map(|failure| FieldError {
364                key: row_key(key, &row.to_string(), &failure.key),
365                kind: failure.kind,
366            })),
367        }
368    }
369    if errors.is_empty() {
370        Ok(items)
371    } else {
372        Err(errors)
373    }
374}
375
376#[cfg(test)]
377mod tests;