use std::collections::{HashMap, HashSet};
use tablo_ui::{
ButtonSize, ButtonVariant, FieldLegendVariant, button, field_error as ui_field_error,
field_legend, field_set,
};
use topcoat::{Result, context::Cx, view::*};
use super::{Schema, fields::Field, tree::Source};
use crate::form::{FieldError, FieldErrors};
pub trait RepeaterItem: Sized + Send + Sync + 'static {
fn schema() -> Schema;
fn parse(
cx: &Cx,
values: &HashMap<String, String>,
) -> std::result::Result<Self, Vec<FieldError>>;
fn write(&self, out: &mut HashMap<String, String>);
}
pub(crate) const ROW: &str = "__row__";
pub const MAX_ROWS: usize = 100;
pub(crate) struct RepeaterControl {
item: fn() -> Schema,
pub(crate) add_label: Option<String>,
}
impl RepeaterControl {
pub(crate) fn new<T: RepeaterItem>() -> Self {
Self {
item: T::schema,
add_label: None,
}
}
pub(crate) fn fold(
&self,
key: &str,
values: &mut HashMap<String, String>,
) -> std::result::Result<(), String> {
let Some(order) = values.remove(key) else {
return Ok(());
};
let leaves: Vec<String> = (self.item)()
.fields()
.map(|field| field.name().to_string())
.collect();
let mut seen = HashSet::new();
let mut rows = Vec::new();
for token in order.split(',').map(str::trim).filter(|t| !t.is_empty()) {
if rows.len() == MAX_ROWS {
return Err(format!("`{key}` lists more than {MAX_ROWS} rows"));
}
let row: usize = token
.parse()
.map_err(|_| format!("`{key}` lists `{token}`, which is not a row"))?;
if !seen.insert(row) {
return Err(format!("`{key}` lists row {row} twice"));
}
let item: HashMap<String, String> = leaves
.iter()
.filter_map(|leaf| {
let value = values.remove(&row_key(key, &row.to_string(), leaf))?;
Some((leaf.clone(), value))
})
.collect();
if item.is_empty() {
return Err(format!("`{key}` lists row {row}, which posts nothing"));
}
rows.push(item);
}
values.insert(key.to_string(), encode(&rows));
Ok(())
}
pub(crate) async fn check(&self, cx: &Cx, key: &str, value: &str, errors: &mut FieldErrors) {
for (row, item) in decode(value).unwrap_or_default().iter().enumerate() {
let (schema, values) = self.row(key, &row.to_string(), item);
Box::pin(schema.check_controls(cx, &values, &HashMap::new(), errors)).await;
}
}
fn row(
&self,
key: &str,
row: &str,
item: &HashMap<String, String>,
) -> (Schema, HashMap<String, String>) {
let prefix = row_key(key, row, "");
let mut schema = (self.item)();
for field in &mut schema.fields {
field.prefix(&prefix);
}
let values = item
.iter()
.map(|(leaf, value)| (format!("{prefix}{leaf}"), value.clone()))
.collect();
(schema, values)
}
pub(crate) async fn render<'a>(
&self,
cx: &'a Cx,
field: &Field,
source: &Source<'_>,
) -> Result<BoxView<'a>> {
let key = field.name().to_string();
let items = source.value(&key).and_then(decode).unwrap_or_default();
let mut rows = Vec::with_capacity(items.len());
for (row, item) in items.iter().enumerate() {
rows.push(
self.render_row(cx, &key, &row.to_string(), item, source.errors(), source)
.await?,
);
}
let blank = self
.render_row(cx, &key, ROW, &HashMap::new(), &FieldErrors::new(), source)
.await?;
let order = (0..items.len())
.map(|row| row.to_string())
.collect::<Vec<_>>()
.join(",");
let next = items.len().to_string();
let label = field.label_str().to_string();
let add = self
.add_label
.clone()
.unwrap_or_else(|| "Add item".to_string());
let error = source.error_for(field);
let legend_id = format!("{}-legend", source.id(&key));
Ok(view! {
cx =>
field_set(
attrs: attributes! {
class="ac-field gap-3"
data-repeater=(key.clone())
data-repeater-next=(next)
},
field_legend(
variant: FieldLegendVariant::Label,
attrs: attributes! { id=(legend_id.clone()) class="mb-0" },
(label)
)
<input type="hidden" name=(key) value=(order) data-repeater-order="">
if let Some(error) = error {
ui_field_error(attrs: attributes! { class="ac-error" }, (error))
}
<ol
class="flex flex-col gap-3"
aria-labelledby=(legend_id)
data-repeater-rows=""
>
for row in rows {
(row)
}
</ol>
<template data-repeater-blank="">(blank)</template>
<div>
button(
variant: ButtonVariant::Outline,
size: ButtonSize::Sm,
attrs: attributes! { type="button" data-repeater-add="" },
(add)
)
</div>
)
}
.boxed())
}
async fn render_row<'a>(
&self,
cx: &'a Cx,
key: &str,
row: &str,
item: &HashMap<String, String>,
errors: &FieldErrors,
source: &Source<'_>,
) -> Result<BoxView<'a>> {
let (schema, values) = self.row(key, row, item);
let mut rendered = Source::form(&values, errors).scoped(source.scope());
if let Some(options) = source.options() {
rendered = rendered.options_at(options);
}
let controls = schema.render(cx, rendered).await?;
let row = row.to_string();
let action = |label: &'static str, data: Attributes| {
view! {
cx =>
button(
variant: ButtonVariant::Ghost,
size: ButtonSize::Sm,
attrs: attributes! { type="button" (data) },
(label)
)
}
};
Ok(view! {
cx =>
<li
class="flex flex-col gap-4 rounded-lg border border-border p-4"
data-repeater-row=(row)
>
(controls)
<div class="flex flex-wrap justify-end gap-2">
(action("Move up", attributes! { cx => data-repeater-up="" }))
(action("Move down", attributes! { cx => data-repeater-down="" }))
(action("Remove", attributes! { cx => data-repeater-remove="" }))
</div>
</li>
}
.boxed())
}
}
/// The key the row `row` of the repeater `key` posts its item's `leaf` under.
pub(crate) fn row_key(key: &str, row: &str, leaf: &str) -> String {
format!("{key}.{row}.{leaf}")
}
/// The rows' keys as the repeater's one value.
fn encode(rows: &[HashMap<String, String>]) -> String {
serde_json::to_string(rows).expect("a list of string maps serializes")
}
/// The rows' keys a repeater's value holds: none for a blank one, `None` for one that is not a
/// list of rows.
pub(crate) fn decode(value: &str) -> Option<Vec<HashMap<String, String>>> {
if value.trim().is_empty() {
return Some(Vec::new());
}
serde_json::from_str(value).ok()
}
/// The repeater's value holding `items`: what a record form hydrates the repeater with, and what
/// a hand-written one writes.
pub fn write_items<T: RepeaterItem>(items: &[T]) -> String {
let rows: Vec<HashMap<String, String>> = items
.iter()
.map(|item| {
let mut out = HashMap::new();
item.write(&mut out);
out
})
.collect();
encode(&rows)
}
/// The items the repeater `key` holds in a submission
/// [folded](crate::Schema::fold_repeaters), none when it posts nothing: what a record form's
/// parse reads the repeater with, and what a hand-written one or an action input reads.
///
/// # Errors
///
/// Each row's refusals, under the key the row's control posts, or the repeater's own when its
/// value holds no rows.
pub fn parse_items<T: RepeaterItem>(
cx: &Cx,
key: &str,
values: &HashMap<String, String>,
) -> std::result::Result<Vec<T>, Vec<FieldError>> {
let Some(rows) = decode(values.get(key).map_or("", String::as_str)) else {
return Err(vec![FieldError::invalid(key, "The rows could not be read")]);
};
let mut items = Vec::with_capacity(rows.len());
let mut errors = Vec::new();
for (row, values) in rows.iter().enumerate() {
match T::parse(cx, values) {
Ok(item) => items.push(item),
Err(failures) => errors.extend(failures.into_iter().map(|failure| FieldError {
key: row_key(key, &row.to_string(), &failure.key),
kind: failure.kind,
})),
}
}
if errors.is_empty() {
Ok(items)
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests;