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. A
5//! submission folds them into the repeater's key, as a JSON array of each item's keys, before
6//! anything reads it: the edit's stored value, a hidden repeater and the parse then see one key
7//! like 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::{HashMap, 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::{Schema, fields::Field, tree::Source};
21use crate::form::{FieldError, FieldErrors};
22
23/// One item of a repeater: the typed value each row parses into, stored as a `toasty::Embed`
24/// struct in a `#[document]` list.
25///
26/// Derive it with [`RepeaterItem`](derive@crate::RepeaterItem). Its fields post their own names,
27/// as an [`ActionInput`](trait@crate::ActionInput)'s do, under the row's prefix.
28pub trait RepeaterItem: Sized + Send + Sync + 'static {
29    /// One row's controls, each posting its field's name.
30    fn schema() -> Schema;
31
32    /// Parse one row's submission of [`schema`](Self::schema)'s keys.
33    ///
34    /// # Errors
35    ///
36    /// Every key that failed, each once.
37    fn parse(
38        cx: &Cx,
39        values: &HashMap<String, String>,
40    ) -> std::result::Result<Self, Vec<FieldError>>;
41
42    /// Write the item's keys as its row posts them.
43    fn write(&self, out: &mut HashMap<String, String>);
44}
45
46/// What the blank row's keys carry for the number the browser gives a row it adds.
47pub(crate) const ROW: &str = "__row__";
48
49/// The most rows one repeater takes: each renders in full when the form comes back refused, so
50/// an unbounded list would let a small submission render a page of any size.
51pub const MAX_ROWS: usize = 100;
52
53/// A repeater's control: its item's controls, one row each.
54pub(crate) struct RepeaterControl {
55    item: fn() -> Schema,
56    /// The add button's label, over "Add item".
57    pub(crate) add_label: Option<String>,
58}
59
60impl RepeaterControl {
61    pub(crate) fn new<T: RepeaterItem>() -> Self {
62        Self {
63            item: T::schema,
64            add_label: None,
65        }
66    }
67
68    /// Folds the rows posted under `key` into `key`, in the order it lists them. A row's key
69    /// that its item does not declare, or that a row it does not list posts, stays where it is
70    /// for the unknown-key check to refuse. A submission that does not post `key` folds nothing,
71    /// 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_ROWS`] rows. A browser posts every control of each row it
77    /// shows, so none of these comes from the form.
78    pub(crate) fn fold(
79        &self,
80        key: &str,
81        values: &mut HashMap<String, String>,
82    ) -> std::result::Result<(), String> {
83        let Some(order) = values.remove(key) else {
84            return Ok(());
85        };
86        let leaves: Vec<String> = (self.item)()
87            .fields()
88            .map(|field| field.name().to_string())
89            .collect();
90        let mut seen = HashSet::new();
91        let mut rows = Vec::new();
92        for token in order.split(',').map(str::trim).filter(|t| !t.is_empty()) {
93            if rows.len() == MAX_ROWS {
94                return Err(format!("`{key}` lists more than {MAX_ROWS} rows"));
95            }
96            let row: usize = token
97                .parse()
98                .map_err(|_| format!("`{key}` lists `{token}`, which is not a row"))?;
99            if !seen.insert(row) {
100                return Err(format!("`{key}` lists row {row} twice"));
101            }
102            let item: HashMap<String, String> = leaves
103                .iter()
104                .filter_map(|leaf| {
105                    let value = values.remove(&row_key(key, &row.to_string(), leaf))?;
106                    Some((leaf.clone(), value))
107                })
108                .collect();
109            if item.is_empty() {
110                return Err(format!("`{key}` lists row {row}, which posts nothing"));
111            }
112            rows.push(item);
113        }
114        values.insert(key.to_string(), encode(&rows));
115        Ok(())
116    }
117
118    /// Adds to `errors` what the item's controls' own rules refuse in each row of the folded
119    /// `value`, under the key the row's control posts: a choice that is not one of its options,
120    /// an email field's address. A value holding no rows is the parse's to refuse.
121    pub(crate) async fn check(&self, cx: &Cx, key: &str, value: &str, errors: &mut FieldErrors) {
122        for (row, item) in decode(value).unwrap_or_default().iter().enumerate() {
123            let (schema, values) = self.row(key, &row.to_string(), item);
124            Box::pin(schema.check_controls(cx, &values, &HashMap::new(), errors)).await;
125        }
126    }
127
128    /// The item's schema posting under the row `row` of the repeater `key`, and `item`'s keys as
129    /// that row posts them.
130    fn row(
131        &self,
132        key: &str,
133        row: &str,
134        item: &HashMap<String, String>,
135    ) -> (Schema, HashMap<String, String>) {
136        let prefix = row_key(key, row, "");
137        let mut schema = (self.item)();
138        for field in &mut schema.fields {
139            field.prefix(&prefix);
140        }
141        let values = item
142            .iter()
143            .map(|(leaf, value)| (format!("{prefix}{leaf}"), value.clone()))
144            .collect();
145        (schema, values)
146    }
147
148    /// Renders the repeater `field` from `source`: a row per stored or posted item, and the blank
149    /// row the add button copies.
150    pub(crate) async fn render<'a>(
151        &self,
152        cx: &'a Cx,
153        field: &Field,
154        source: &Source<'_>,
155    ) -> Result<BoxView<'a>> {
156        let key = field.name().to_string();
157        let items = source.value(&key).and_then(decode).unwrap_or_default();
158        let mut rows = Vec::with_capacity(items.len());
159        for (row, item) in items.iter().enumerate() {
160            rows.push(
161                self.render_row(cx, &key, &row.to_string(), item, source.errors(), source)
162                    .await?,
163            );
164        }
165        let blank = self
166            .render_row(cx, &key, ROW, &HashMap::new(), &FieldErrors::new(), source)
167            .await?;
168        let order = (0..items.len())
169            .map(|row| row.to_string())
170            .collect::<Vec<_>>()
171            .join(",");
172        let next = items.len().to_string();
173        let label = field.label_str().to_string();
174        let add = self
175            .add_label
176            .clone()
177            .unwrap_or_else(|| "Add item".to_string());
178        let error = source.error_for(field);
179        let legend_id = format!("{}-legend", source.id(&key));
180        Ok(view! {
181            cx =>
182            field_set(
183                attrs: attributes! {
184                    class="ac-field gap-3"
185                    data-repeater=(key.clone())
186                    data-repeater-next=(next)
187                },
188                field_legend(
189                    variant: FieldLegendVariant::Label,
190                    attrs: attributes! { id=(legend_id.clone()) class="mb-0" },
191                    (label)
192                )
193                <input type="hidden" name=(key) value=(order) data-repeater-order="">
194                if let Some(error) = error {
195                    ui_field_error(attrs: attributes! { class="ac-error" }, (error))
196                }
197                <ol
198                    class="flex flex-col gap-3"
199                    aria-labelledby=(legend_id)
200                    data-repeater-rows=""
201                >
202                    for row in rows {
203                        (row)
204                    }
205                </ol>
206                <template data-repeater-blank="">(blank)</template>
207                <div>
208                    button(
209                        variant: ButtonVariant::Outline,
210                        size: ButtonSize::Sm,
211                        attrs: attributes! { type="button" data-repeater-add="" },
212                        (add)
213                    )
214                </div>
215            )
216        }
217        .boxed())
218    }
219
220    /// Renders the row `row` of the repeater `key` holding `item`, its controls posting under the
221    /// row's prefix, with the buttons that move and remove it.
222    async fn render_row<'a>(
223        &self,
224        cx: &'a Cx,
225        key: &str,
226        row: &str,
227        item: &HashMap<String, String>,
228        errors: &FieldErrors,
229        source: &Source<'_>,
230    ) -> Result<BoxView<'a>> {
231        let (schema, values) = self.row(key, row, item);
232        let mut rendered = Source::form(&values, errors).scoped(source.scope());
233        if let Some(options) = source.options() {
234            rendered = rendered.options_at(options);
235        }
236        let controls = schema.render(cx, rendered).await?;
237        let row = row.to_string();
238        let action = |label: &'static str, data: Attributes| {
239            view! {
240                cx =>
241                button(
242                    variant: ButtonVariant::Ghost,
243                    size: ButtonSize::Sm,
244                    attrs: attributes! { type="button" (data) },
245                    (label)
246                )
247            }
248        };
249        Ok(view! {
250            cx =>
251            <li
252                class="flex flex-col gap-4 rounded-lg border border-border p-4"
253                data-repeater-row=(row)
254            >
255                (controls)
256                <div class="flex flex-wrap justify-end gap-2">
257                    (action("Move up", attributes! { cx => data-repeater-up="" }))
258                    (action("Move down", attributes! { cx => data-repeater-down="" }))
259                    (action("Remove", attributes! { cx => data-repeater-remove="" }))
260                </div>
261            </li>
262        }
263        .boxed())
264    }
265}
266
267/// The key the row `row` of the repeater `key` posts its item's `leaf` under.
268pub(crate) fn row_key(key: &str, row: &str, leaf: &str) -> String {
269    format!("{key}.{row}.{leaf}")
270}
271
272/// The rows' keys as the repeater's one value.
273fn encode(rows: &[HashMap<String, String>]) -> String {
274    serde_json::to_string(rows).expect("a list of string maps serializes")
275}
276
277/// The rows' keys a repeater's value holds: none for a blank one, `None` for one that is not a
278/// list of rows.
279pub(crate) fn decode(value: &str) -> Option<Vec<HashMap<String, String>>> {
280    if value.trim().is_empty() {
281        return Some(Vec::new());
282    }
283    serde_json::from_str(value).ok()
284}
285
286/// The repeater's value holding `items`: what a record form hydrates the repeater with, and what
287/// a hand-written one writes.
288pub fn write_items<T: RepeaterItem>(items: &[T]) -> String {
289    let rows: Vec<HashMap<String, String>> = items
290        .iter()
291        .map(|item| {
292            let mut out = HashMap::new();
293            item.write(&mut out);
294            out
295        })
296        .collect();
297    encode(&rows)
298}
299
300/// The items the repeater `key` holds in a submission
301/// [folded](crate::Schema::fold_repeaters), none when it posts nothing: what a record form's
302/// parse reads the repeater with, and what a hand-written one or an action input reads.
303///
304/// # Errors
305///
306/// Each row's refusals, under the key the row's control posts, or the repeater's own when its
307/// value holds no rows.
308pub fn parse_items<T: RepeaterItem>(
309    cx: &Cx,
310    key: &str,
311    values: &HashMap<String, String>,
312) -> std::result::Result<Vec<T>, Vec<FieldError>> {
313    let Some(rows) = decode(values.get(key).map_or("", String::as_str)) else {
314        return Err(vec![FieldError::invalid(key, "The rows could not be read")]);
315    };
316    let mut items = Vec::with_capacity(rows.len());
317    let mut errors = Vec::new();
318    for (row, values) in rows.iter().enumerate() {
319        match T::parse(cx, values) {
320            Ok(item) => items.push(item),
321            Err(failures) => errors.extend(failures.into_iter().map(|failure| FieldError {
322                key: row_key(key, &row.to_string(), &failure.key),
323                kind: failure.kind,
324            })),
325        }
326    }
327    if errors.is_empty() {
328        Ok(items)
329    } else {
330        Err(errors)
331    }
332}
333
334#[cfg(test)]
335mod tests;