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            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    /// Renders the row `row` of the repeater `key` holding `item`, its controls posting under the
254    /// row's prefix, with the buttons that move and remove it, Remove disabled unless
255    /// `removable`.
256    #[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
317/// The key the row `row` of the repeater `key` posts its item's `leaf` under.
318pub(crate) fn row_key(key: &str, row: &str, leaf: &str) -> String {
319    format!("{key}.{row}.{leaf}")
320}
321
322/// The repeater's value holding `items`: what a record form hydrates the repeater with, and what
323/// a hand-written one writes.
324pub 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
337/// The items the repeater `key` holds in a submission [read](crate::Schema::read) through its
338/// schema, none when it posts nothing: what a record form's parse reads the repeater with, and
339/// what a hand-written one or an action input reads.
340///
341/// # Errors
342///
343/// Each row's refusals, under the key the row's control posts, or the repeater's own when it holds
344/// text or a list.
345pub 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;