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