Skip to main content

polyspec_crudui_generator/
list.rs

1use polyspec_crudui_validator::compose::{
2    compose_properties, member_ordered, ComposeOptions, FileLoader, MemoryLoader,
3};
4use polyspec_crudui_validator::scan_forbidden_keys;
5use serde_json::{json, Map, Value};
6
7use crate::choice_list::{choice_label, is_choice_list};
8use crate::design::{appearance, flag, resolve_design};
9use crate::display_declaration::check_display_declarations;
10use crate::messages::{format_page, list_messages};
11use crate::render::{appearance_attrs, element, escape, raw_element, raw_text};
12use crate::template::check_known_keys;
13use crate::util::{join_class, scalar, segments, translate, value_at};
14use crate::{FormError, FormResult};
15
16/// Composition, language, display context and pagination inputs for a list.
17pub struct ListOptions<'a> {
18    /// Parsed documents available to composition.
19    pub files: Map<String, Value>,
20    /// Optional explicit composition loader.
21    pub loader: Option<&'a dyn FileLoader>,
22    /// Base directory for relative document references.
23    pub basepath: String,
24    /// Content language.
25    pub language: String,
26    /// List-level data used by column visibility expressions: null or an object.
27    pub data: Value,
28    /// Current page: null for none, otherwise an integer from 1 to 9007199254740991.
29    pub page: Value,
30    /// Total item count: null for none, otherwise an integer from 0 to 9007199254740991;
31    /// no total is derived from display rows.
32    pub total: Value,
33    /// Output layout: null (`table`), `table` or `card`.
34    pub layout: Value,
35}
36
37impl Default for ListOptions<'_> {
38    fn default() -> Self {
39        Self {
40            files: Map::new(),
41            loader: None,
42            basepath: String::new(),
43            language: "ko".into(),
44            data: Value::Null,
45            page: Value::Null,
46            total: Value::Null,
47            layout: Value::Null,
48        }
49    }
50}
51
52/// Read decoded JSON list rows: absent means no rows, and any value other than an array fails.
53pub fn list_rows(rows: Option<&Value>) -> FormResult<&[Value]> {
54    match rows {
55        None => Ok(&[]),
56        Some(Value::Array(rows)) => Ok(rows),
57        Some(_) => Err(FormError::input("List rows must be an array")),
58    }
59}
60
61fn format(value: Option<&Value>) -> Value {
62    match value {
63        Some(Value::String(kind)) if !kind.is_empty() => json!({"type":kind,"options":{}}),
64        Some(Value::Object(options)) => {
65            json!({"type":options.get("type").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("text"),"options":options})
66        }
67        _ => json!({"type":"text","options":{}}),
68    }
69}
70
71fn interpolate(template: &str, row: &Value, value: Option<&Value>) -> String {
72    let pattern = regex::Regex::new(r"\{=[A-Za-z_][\w.]*\}").expect("field interpolation pattern");
73    pattern
74        .replace_all(template, |capture: &regex::Captures<'_>| {
75            let path = &capture[0][2..capture[0].len() - 1];
76            scalar(if path == "field" {
77                value
78            } else {
79                value_at(row, path).or(value)
80            })
81        })
82        .into_owned()
83}
84
85fn truthy(value: Option<&Value>) -> bool {
86    match value {
87        None | Some(Value::Null) => false,
88        Some(Value::Bool(value)) => *value,
89        Some(Value::Number(value)) => value.as_f64().is_some_and(|v| v != 0.0),
90        Some(Value::String(value)) => !["", "0", "false"].contains(&value.as_str()),
91        _ => true,
92    }
93}
94
95fn cell_display(
96    format: &Value,
97    value: Option<&Value>,
98    row: &Value,
99    path: &str,
100    language: &str,
101) -> FormResult<Value> {
102    let kind = format["type"].as_str().unwrap_or("text");
103    let options = &format["options"];
104    let text = scalar(value);
105    let display = match kind {
106        "date" => {
107            let pattern = options["pattern"]
108                .as_str()
109                .filter(|s| !s.is_empty())
110                .unwrap_or("YYYY-MM-DD");
111            crate::date::format(&text, pattern).into()
112        }
113        "number" => {
114            let number = crate::number::parse(&text);
115            let Some(number) = number.filter(|v| v.is_finite()) else {
116                return Ok(text.into());
117            };
118            let mut buffer = ryu_js::Buffer::new();
119            let mut body = match options["decimals"].as_f64() {
120                Some(decimals) if (0.0..=100.0).contains(&decimals.trunc()) => {
121                    buffer.format_to_fixed(number, decimals as u8).to_owned()
122                }
123                Some(_) => {
124                    return Err(FormError::input(
125                        "Number decimals must be between 0 and 100",
126                    ))
127                }
128                _ => buffer.format(number).to_owned(),
129            };
130            if truthy(options.get("thousands")) {
131                let (integer, fraction) = body
132                    .split_once('.')
133                    .map(|(i, f)| (i, Some(f)))
134                    .unwrap_or((&body, None));
135                let grouped = regex::Regex::new(r"[0-9]+")
136                    .expect("digit grouping pattern")
137                    .replace_all(integer, |capture: &regex::Captures<'_>| {
138                        let digits = &capture[0];
139                        let mut grouped = String::new();
140                        for (index, character) in digits.chars().enumerate() {
141                            if index > 0 && (digits.len() - index).is_multiple_of(3) {
142                                grouped.push(',');
143                            }
144                            grouped.push(character);
145                        }
146                        grouped
147                    })
148                    .into_owned();
149                body = grouped + &fraction.map(|f| format!(".{f}")).unwrap_or_default();
150            }
151            (translate(options.get("prefix"), language)
152                + &body
153                + &translate(options.get("suffix"), language))
154                .into()
155        }
156        "badge" => {
157            let raw = options["map"].get(&text);
158            if raw.is_some_and(|v| v.is_object() || v.is_array()) {
159                let resolved = translate(raw, language);
160                json!({"kind":"badge","variant":resolved,"label":resolved})
161            } else {
162                json!({"kind":"badge","variant":scalar(raw),"label":text})
163            }
164        }
165        "link" => {
166            let href = match options.get("href") {
167                Some(Value::String(template)) => template.clone(),
168                Some(value @ Value::Object(_)) => {
169                    appearance(Some(value), row, &segments(path), &[])
170                }
171                _ => String::new(),
172            };
173            let caption = options.get("text").filter(|v| !v.is_null() && **v != "");
174            let mut result = json!({"kind":"link","href":interpolate(&href,row,value),"text":caption.map(|v|translate(Some(v),language)).unwrap_or(text)});
175            if let Some(target) = options["target"].as_str().filter(|s| !s.is_empty()) {
176                result["target"] = target.into();
177            }
178            result
179        }
180        "choice-label" => {
181            let items = &options["items"];
182            let raw = if is_choice_list(items) {
183                choice_label(items, &text)
184            } else if items.get("model").is_some() {
185                None
186            } else if let Some(array) = items.as_array() {
187                // A text that is no position selects no choice, and the label is the text itself.
188                #[allow(clippy::disallowed_methods, reason = "no position: a label, not an error")]
189                let position = text.parse::<usize>().ok();
190                position.and_then(|i| array.get(i))
191            } else {
192                items.get(&text)
193            };
194            // A choice label is content: a string or a language map.
195            raw.map(|v| translate(Some(v), language))
196                .unwrap_or(text)
197                .into()
198        }
199        "bool" => {
200            let value = truthy(value);
201            let source = options
202                .get(if value { "true" } else { "false" })
203                .filter(|v| !v.is_null());
204            json!({"kind":"bool","value":value,"label":source.map(|v|translate(Some(v),language)).unwrap_or_else(||value.to_string()),"as":options["as"].as_str().filter(|s|!s.is_empty()).unwrap_or("text")})
205        }
206        "image" => {
207            let mut result = json!({"kind":"image","src":text,"alt":interpolate(&translate(options.get("alt"),language),row,value)});
208            for key in ["width", "height"] {
209                if let Some(value) = options.get(key) {
210                    result[key] = scalar(Some(value)).into();
211                }
212            }
213            result
214        }
215        "html" => json!({"kind":"html","html":text}),
216        _ => {
217            // Only a number truncates; its integer part counts Unicode code points.
218            let limit = options["truncate"]
219                .as_f64()
220                .map(f64::trunc)
221                .filter(|limit| *limit >= 1.0 && text.chars().count() as f64 > *limit);
222            match limit {
223                Some(limit) => (text.chars().take(limit as usize).collect::<String>() + "…").into(),
224                None => text.into(),
225            }
226        }
227    };
228    Ok(display)
229}
230
231/// Largest integer a JSON number carries exactly in every runtime.
232const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
233
234/// Read an optional integer option: null means none, and any other value must be a number whose
235/// value is an integer from `minimum` to the largest safe integer, so an integral float such as
236/// 2.0 is 2 and negative zero is 0.
237fn list_integer(value: &Value, minimum: u64, message: &str) -> FormResult<Option<u64>> {
238    let integer = match value {
239        Value::Null => return Ok(None),
240        Value::Number(number) => number.as_u64().or_else(|| {
241            number
242                .as_f64()
243                .filter(|v| v.fract() == 0.0 && *v >= 0.0 && *v <= MAX_SAFE_INTEGER as f64)
244                .map(|v| v as u64)
245        }),
246        _ => None,
247    };
248    match integer {
249        Some(integer) if (minimum..=MAX_SAFE_INTEGER).contains(&integer) => Ok(Some(integer)),
250        _ => Err(FormError::input(message)),
251    }
252}
253
254/// Checked display inputs: the context, where null means empty, and the supplied page and total.
255pub(crate) struct ListContext {
256    pub(crate) data: Value,
257    pub(crate) page: Option<u64>,
258    pub(crate) total: Option<u64>,
259}
260
261/// Check list inputs in contract order: specification, rows, the columns declaration, context,
262/// page, then total.
263fn list_context(
264    spec: &Value,
265    rows: &[Value],
266    options: &ListOptions<'_>,
267) -> FormResult<ListContext> {
268    if !spec.is_object() {
269        return Err(FormError::input("List specification must be an object"));
270    }
271    if rows.iter().any(|row| !row.is_object()) {
272        return Err(FormError::input("List rows must be objects"));
273    }
274    if spec.get("columns").is_none() {
275        return Err(FormError::input("List specification must declare columns"));
276    }
277    let context = match &options.data {
278        Value::Null => json!({}),
279        data @ Value::Object(_) => data.clone(),
280        _ => return Err(FormError::input("List context must be an object")),
281    };
282    let page = list_integer(&options.page, 1, "List page must be a positive integer")?;
283    let total = list_integer(
284        &options.total,
285        0,
286        "List total must be a nonnegative integer",
287    )?;
288    Ok(ListContext {
289        data: context,
290        page,
291        total,
292    })
293}
294
295/// Compose a list and bind display rows without modifying the inputs.
296pub fn build_list(spec: &Value, rows: &[Value], options: &ListOptions<'_>) -> FormResult<Value> {
297    // The specification is read in member order; rows and the context keep their own order.
298    let spec = &member_ordered(spec);
299    let checked = list_context(spec, rows, options)?;
300    build_display(spec, rows, options, &checked, "list", "columns")
301}
302
303/// Build a list or detail display model from checked input. `own` is `list` or `detail` and
304/// `members` names the member map, `columns` or `fields`. The member map and a list `search` with
305/// `$ref` or `$patch` are composed, the composed specification is scanned for forbidden keys, and
306/// its declarations are checked.
307pub(crate) fn build_display(
308    spec: &Value,
309    rows: &[Value],
310    options: &ListOptions<'_>,
311    checked: &ListContext,
312    own: &str,
313    members: &str,
314) -> FormResult<Value> {
315    let context = &checked.data;
316    let memory = MemoryLoader::new(options.files.clone());
317    let loader = options.loader.unwrap_or(&memory);
318    let compose_options = ComposeOptions::with_basepath(&options.basepath);
319    let Some(declared) = spec[members].as_object() else {
320        return Err(FormError::input(format!(
321            "Invalid {members} at {own}: expected an object"
322        )));
323    };
324    let columns = compose_properties(declared.clone(), loader, &compose_options)?;
325    let search = match spec.get("search").and_then(Value::as_object) {
326        Some(search)
327            if own == "list" && (search.contains_key("$ref") || search.contains_key("$patch")) =>
328        {
329            Some(compose_properties(
330                search.clone(),
331                loader,
332                &compose_options,
333            )?)
334        }
335        _ => None,
336    };
337    // The composed specification keeps the member order of the declared one.
338    let mut composed = Map::new();
339    for (key, value) in spec.as_object().into_iter().flatten() {
340        let value = match &search {
341            _ if key == members => Value::Object(columns.clone()),
342            Some(search) if key == "search" => Value::Object(search.clone()),
343            _ => value.clone(),
344        };
345        composed.insert(key.clone(), value);
346    }
347    // The forbidden-key scan and then the declarations, as the display format specification orders them.
348    let spec = &Value::Object(composed);
349    scan_forbidden_keys(spec, &[])?;
350    check_display_declarations(spec, own, members)?;
351    let mut visible = Vec::new();
352    for (key, raw) in &columns {
353        let design = resolve_design(raw.get("design"), context, "", &[]);
354        if design["show"] == false {
355            continue;
356        }
357        visible.push(json!({"key":key,"field":raw["field"].as_str().unwrap_or(""),"label":raw.get("label").map(|v|translate(Some(v),&options.language)).unwrap_or_else(||key.clone()),"format":format(raw.get("format")),"sortable":raw.get("sortable").filter(|v|!v.is_null()).is_some_and(|v|flag(Some(v),context,&[],&[])),"design":design}));
358    }
359    let bound_rows = rows.iter().map(|row| {
360        let cells = visible.iter().map(|column| {
361            let path = column["field"].as_str().unwrap_or("");
362            let value = if path.is_empty() {None} else {value_at(row,path)};
363            let raw = &columns[column["key"].as_str().unwrap()];
364            // A model is JSON: a path absent from the row is null, and the member is always present.
365            Ok(json!({"format":column["format"],"value":value.cloned().unwrap_or(Value::Null),"display":cell_display(&column["format"],value,row,path,&options.language)?,"design":resolve_design(raw.get("design"),row,path,&[])}))
366        }).collect::<FormResult<Vec<_>>>()?;
367        Ok(json!({"cells":cells}))
368    }).collect::<FormResult<Vec<_>>>()?;
369    let pagination = pagination_model(
370        &spec["pagination"],
371        checked.page,
372        checked.total,
373        &options.language,
374    )?;
375    let mut actions = Vec::new();
376    for (key, raw) in spec["actions"].as_object().into_iter().flatten() {
377        // A script string: a button that runs the script when it is clicked.
378        if let Some(script) = raw.as_str() {
379            actions.push(json!({"key":key,"label":key,"behavior":{"onclick":script}}));
380            continue;
381        }
382        let label = raw
383            .get("label")
384            .map(|v| translate(Some(v), &options.language))
385            .unwrap_or_else(|| key.clone());
386        // An action object with a script is the object form of a script action.
387        if let Some(script) = raw["script"].as_str() {
388            actions.push(json!({"key":key,"label":label,"behavior":{"onclick":script}}));
389            continue;
390        }
391        let mut action = json!({"key":key,"label":label});
392        if raw.get("format").is_some() {
393            action["format"] = format(raw.get("format"));
394        }
395        let mut behavior = Map::new();
396        for (event, script) in raw["behavior"].as_object().into_iter().flatten() {
397            if let Some(script) = script.as_str().or_else(|| script["script"].as_str()) {
398                behavior.insert(event.clone(), script.into());
399            }
400        }
401        if !behavior.is_empty() {
402            action["behavior"] = behavior.into();
403        }
404        actions.push(action);
405    }
406    let mut result = json!({"columns":visible,"rows":bound_rows,"pagination":pagination});
407    if let Some(field) = spec["sort"]["field"].as_str().filter(|s| !s.is_empty()) {
408        result["sort"] =
409            json!({"field":field,"dir":if spec["sort"]["dir"]=="desc" {"desc"} else {"asc"}});
410    }
411    result["actions"] = actions.into();
412    // A list with no rows shows the declared empty text; absent or null empty shows the
413    // table's emptyList for the display language (same en fallback). A declared text, even "",
414    // is used as declared.
415    let empty_text = if spec.get("empty").is_some_and(|empty| !empty.is_null()) {
416        translate(spec.get("empty"), &options.language)
417    } else if own == "list" {
418        list_messages(&options.language).empty_list.to_string()
419    } else {
420        String::new()
421    };
422    result["empty"] = empty_text.into();
423    result["description"] = translate(spec.get("description"), &options.language).into();
424    result["design"] = resolve_design(spec.get("design"), context, "", &[]);
425    Ok(result)
426}
427
428fn str_at<'a>(value: &'a Value, key: &str) -> &'a str {
429    value[key].as_str().unwrap_or("")
430}
431
432fn cell_body(display: &Value) -> String {
433    if let Some(text) = display.as_str() {
434        return escape(text);
435    }
436    match str_at(display, "kind") {
437        "badge" => element(
438            "span",
439            &if str_at(display, "variant").is_empty() {
440                json!({"class":"crudui-badge"})
441            } else {
442                json!({"class":"crudui-badge", "data-crudui-variant":str_at(display,"variant")})
443            },
444            &escape(str_at(display, "label")),
445        ),
446        "link" => {
447            let mut attrs = json!({"href":display["href"]});
448            if let Some(target) = display.get("target") {
449                attrs["target"] = target.clone();
450            }
451            element("a", &attrs, &escape(str_at(display, "text")))
452        }
453        "image" => {
454            let mut attrs = json!({"src":display["src"],"alt":display["alt"]});
455            for key in ["width", "height"] {
456                if let Some(value) = display.get(key) {
457                    attrs[key] = value.clone();
458                }
459            }
460            element("img", &attrs, "")
461        }
462        "bool" => match str_at(display, "as") {
463            "check" => element(
464                "span",
465                &json!({"class":"crudui-bool crudui-bool--check","data-crudui-state":display["value"].to_string(),"aria-label":display["label"]}),
466                if display["value"] == true {
467                    "✔"
468                } else {
469                    "✘"
470                },
471            ),
472            "icon" => element(
473                "span",
474                &json!({"class":"crudui-bool crudui-bool--icon","data-crudui-state":display["value"].to_string(),"aria-label":display["label"]}),
475                "",
476            ),
477            _ => element(
478                "span",
479                &json!({"class":"crudui-bool crudui-bool--text","data-crudui-state":display["value"].to_string()}),
480                &escape(str_at(display, "label")),
481            ),
482        },
483        "html" => str_at(display, "html").into(),
484        _ => String::new(),
485    }
486}
487
488pub(crate) fn cell_html(cell: &Value, tag: &str, base: &str) -> String {
489    let main = &cell["design"]["main"];
490    element(
491        tag,
492        &appearance_attrs(
493            join_class(&[base, str_at(main, "class")]),
494            str_at(main, "style"),
495        ),
496        &cell_body(&cell["display"]),
497    )
498}
499
500/// The actions of a list or detail: `{block}__actions` with one `{block}__action` span per
501/// action holding a link or button with its behavior attributes; nothing without actions.
502pub(crate) fn actions_html(actions: &Value, block: &str) -> String {
503    let mut toolbar = String::new();
504    for action in actions.as_array().into_iter().flatten() {
505        let link = action["format"]["type"] == "link";
506        let mut attrs = if link {
507            json!({"href":action["format"]["options"]["href"].as_str().unwrap_or("#")})
508        } else {
509            json!({"type":"button"})
510        };
511        if link {
512            if let Some(target) = action["format"]["options"]["target"].as_str() {
513                attrs["target"] = target.into();
514            }
515        }
516        for (attribute, script) in action["behavior"].as_object().into_iter().flatten() {
517            attrs[attribute.as_str()] = script.clone();
518        }
519        toolbar += &element(
520            "span",
521            &json!({"class":format!("{block}__action"),"data-action":action["key"]}),
522            &raw_element(
523                if link { "a" } else { "button" },
524                &attrs,
525                &raw_text(str_at(action, "label")),
526            ),
527        );
528    }
529    if toolbar.is_empty() {
530        return toolbar;
531    }
532    element(
533        "div",
534        &json!({"class":format!("{block}__actions")}),
535        &toolbar,
536    )
537}
538
539/// Render a list with supplied display rows in table or card layout.
540pub fn render_list(spec: &Value, rows: &[Value], options: &ListOptions<'_>) -> FormResult<String> {
541    list_context(spec, rows, options)?;
542    let layout = match &options.layout {
543        Value::Null => "table",
544        Value::String(layout) if layout == "table" || layout == "card" => layout.as_str(),
545        _ => return Err(FormError::input("List layout must be table or card")),
546    };
547    let model = build_list(spec, rows, options)?;
548    let mut content = String::new();
549    let description = str_at(&model, "description");
550    if !description.is_empty() {
551        content += &element(
552            "p",
553            &json!({"class":"crudui-list__description"}),
554            &escape(description),
555        );
556    }
557    content += &actions_html(&model["actions"], "crudui-list");
558    let columns = model["columns"].as_array().unwrap();
559    let rows = model["rows"].as_array().unwrap();
560    if rows.is_empty() {
561        content += &element(
562            "div",
563            &json!({"class":"crudui-list__empty"}),
564            &escape(str_at(&model, "empty")),
565        );
566    } else if layout == "card" {
567        let cards = rows
568            .iter()
569            .map(|row| {
570                let cells = row["cells"]
571                    .as_array()
572                    .unwrap()
573                    .iter()
574                    .enumerate()
575                    .map(|(index, cell)| {
576                        let class = join_class(&[
577                            &format!(
578                                "crudui-list__cell crudui-value crudui-value--{}",
579                                str_at(&cell["format"], "type")
580                            ),
581                            str_at(&cell["design"]["main"], "class"),
582                        ]);
583                        element(
584                            "div",
585                            &json!({"class":class}),
586                            &(element(
587                                "span",
588                                &json!({"class":"crudui-list__card-label"}),
589                                &escape(str_at(&columns[index], "label")),
590                            ) + &cell_html(cell, "span", "crudui-list__card-value")),
591                        )
592                    })
593                    .collect::<String>();
594                element("article", &json!({"class":"crudui-list__card"}), &cells)
595            })
596            .collect::<String>();
597        content += &element("div", &json!({"class":"crudui-list__cards"}), &cards);
598    } else {
599        let header = columns
600            .iter()
601            .map(|column| {
602                let main = &column["design"]["main"];
603                let mut attrs = appearance_attrs(
604                    join_class(&["crudui-list__heading", str_at(main, "class")]),
605                    str_at(main, "style"),
606                );
607                if !str_at(column, "field").is_empty() {
608                    attrs["data-field"] = column["field"].clone();
609                }
610                if column["sortable"] == true {
611                    attrs["data-sortable"] = "true".into();
612                }
613                if model.get("sort").is_some()
614                    && (model["sort"]["field"] == column["field"]
615                        || model["sort"]["field"] == column["key"])
616                {
617                    attrs["data-sort-dir"] = model["sort"]["dir"].clone();
618                }
619                let mut body = element(
620                    "span",
621                    &json!({"class":"crudui-list__heading-label"}),
622                    &escape(str_at(column, "label")),
623                );
624                if column["sortable"] == true {
625                    body += &element("span", &json!({"class":"crudui-list__sort"}), "↕");
626                }
627                element("th", &attrs, &body)
628            })
629            .collect::<String>();
630        let body = rows
631            .iter()
632            .map(|row| {
633                element(
634                    "tr",
635                    &json!({}),
636                    &row["cells"]
637                        .as_array()
638                        .unwrap()
639                        .iter()
640                        .map(|cell| {
641                            cell_html(
642                                cell,
643                                "td",
644                                &format!(
645                                    "crudui-list__cell crudui-value crudui-value--{}",
646                                    str_at(&cell["format"], "type")
647                                ),
648                            )
649                        })
650                        .collect::<String>(),
651                )
652            })
653            .collect::<String>();
654        content += &element(
655            "table",
656            &json!({"class":"crudui-list__table"}),
657            &(element("thead", &json!({}), &element("tr", &json!({}), &header))
658                + &element("tbody", &json!({}), &body)),
659        );
660    }
661    let pagination = &model["pagination"];
662    if pagination["enabled"] == true {
663        let mut attrs = json!({"class":"crudui-list__pagination"});
664        for (input, output) in [
665            ("mode", "data-mode"),
666            ("perPage", "data-per-page"),
667            ("page", "data-page"),
668            ("total", "data-total"),
669        ] {
670            if let Some(value) = pagination.get(input) {
671                attrs[output] = scalar(Some(value)).into();
672            }
673        }
674        // Render buttons from the pagination model
675        let mut controls = String::new();
676        if let Some(buttons) = pagination.get("buttons").and_then(Value::as_array) {
677            for button_data in buttons {
678                let role = button_data["role"].as_str().unwrap_or("");
679                let page_num = button_data["page"].as_u64().unwrap_or(1);
680                let label = button_data["label"].as_str().unwrap_or("");
681                let current = button_data["current"].as_bool().unwrap_or(false);
682                let disabled = button_data["disabled"].as_bool().unwrap_or(false);
683
684                let class = match role {
685                    "previous" => "crudui-list__pagination-prev",
686                    "next" => "crudui-list__pagination-next",
687                    _ => "crudui-list__pagination-page",
688                };
689
690                let text = match role {
691                    "previous" => "‹".to_string(),
692                    "next" => "›".to_string(),
693                    _ => page_num.to_string(),
694                };
695
696                let mut button_attrs = json!({"type":"button", "class":class, "data-page":page_num.to_string(), "aria-label":label});
697                if current {
698                    button_attrs["aria-current"] = "page".into();
699                }
700                if disabled {
701                    button_attrs["disabled"] = true.into();
702                }
703
704                controls += &element("button", &button_attrs, &text);
705            }
706        }
707        content += &element("nav", &attrs, &controls);
708    }
709    let wrapper = &model["design"]["wrapper"];
710    let mut sources = Vec::new();
711    for row in rows {
712        for cell in row["cells"].as_array().into_iter().flatten() {
713            let display = &cell["display"];
714            if display["kind"] != "image" {
715                continue;
716            }
717            let source = str_at(display, "src");
718            if source.is_empty()
719                || source
720                    .get(..5)
721                    .is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
722                || sources.contains(&source)
723            {
724                continue;
725            }
726            sources.push(source);
727        }
728    }
729    let preloads = sources
730        .iter()
731        .map(|source| {
732            element(
733                "link",
734                &json!({"rel":"preload","as":"image","href":source}),
735                "",
736            )
737        })
738        .collect::<String>();
739    Ok(preloads
740        + &element(
741            "div",
742            &appearance_attrs(
743                join_class(&["crudui-list", str_at(wrapper, "class")]),
744                str_at(wrapper, "style"),
745            ),
746            &content,
747        ))
748}
749
750/// Reject a wrong value type or an unknown key in the pagination declaration at `path`.
751pub(crate) fn check_pagination_declaration(pagination: &Value, path: &str) -> FormResult<()> {
752    let fail = |key: &str, expected: &str| {
753        Err(FormError::input(format!(
754            "Invalid {key} at {path}: expected {expected}"
755        )))
756    };
757    if !pagination.is_boolean() && !pagination.is_object() {
758        return fail("pagination", "a boolean or an object");
759    }
760    let Some(pagination) = pagination.as_object() else {
761        return Ok(());
762    };
763    check_known_keys("pagination", pagination, &["per_page", "mode"], path)?;
764    if let Some(per_page) = pagination.get("per_page") {
765        if per_page.is_null() || list_integer(per_page, 1, "").is_err() {
766            return fail("pagination.per_page", "a positive integer");
767        }
768    }
769    if let Some(mode) = pagination.get("mode") {
770        if !mode
771            .as_str()
772            .is_some_and(|mode| ["pages", "offset", "cursor", "none"].contains(&mode))
773        {
774            return fail("pagination.mode", "pages, offset, cursor or none");
775        }
776    }
777    Ok(())
778}
779
780/// The pagination model, in member order: enabled, then for enabled paging perPage, mode and
781/// page with their defaults, the supplied total and pageCount. Disabled paging keeps only the
782/// supplied page and total.
783fn pagination_model(
784    declared: &Value,
785    page: Option<u64>,
786    total: Option<u64>,
787    language: &str,
788) -> FormResult<Value> {
789    let enabled = *declared == true || declared.is_object();
790    let mut pagination = json!({"enabled":enabled});
791    let per_page = match declared.get("per_page") {
792        Some(value) => {
793            list_integer(value, 1, "pagination.per_page must be a positive integer")?.unwrap_or(20)
794        }
795        None => 20,
796    };
797    if enabled {
798        pagination["perPage"] = per_page.into();
799        pagination["mode"] = declared
800            .get("mode")
801            .and_then(Value::as_str)
802            .unwrap_or("pages")
803            .into();
804        pagination["page"] = page.unwrap_or(1).into();
805    } else if let Some(page) = page {
806        pagination["page"] = page.into();
807    }
808    if let Some(total) = total {
809        pagination["total"] = total.into();
810    }
811    if enabled {
812        let page_count = total
813            .map(|total| total.div_ceil(per_page).max(1))
814            .unwrap_or(0);
815        pagination["pageCount"] = page_count.into();
816
817        // Generate buttons for pagination
818        let current_page = if page_count > 0 {
819            page.unwrap_or(1).min(page_count)
820        } else {
821            1
822        };
823        let messages = list_messages(language);
824        let mut buttons = Vec::new();
825
826        // Previous button
827        buttons.push(json!({
828            "role": "previous",
829            "page": (current_page - 1).max(1),
830            "label": messages.previous_page,
831            "current": false,
832            "disabled": current_page <= 1 || page_count == 0
833        }));
834
835        // Page number buttons
836        for value in pagination_pages(current_page, page_count) {
837            buttons.push(json!({
838                "role": "page",
839                "page": value,
840                "label": format_page(messages.page, value),
841                "current": value == current_page,
842                "disabled": value == current_page
843            }));
844        }
845
846        // Next button
847        buttons.push(json!({
848            "role": "next",
849            "page": if page_count > 0 { (current_page + 1).min(page_count) } else { 1 },
850            "label": messages.next_page,
851            "current": false,
852            "disabled": page_count == 0 || current_page >= page_count
853        }));
854
855        pagination["buttons"] = buttons.into();
856    }
857    Ok(pagination)
858}
859
860/// The bounded page-number window: every page up to seven pages, otherwise the first,
861/// previous, current, next and last page.
862fn pagination_pages(page: u64, page_count: u64) -> Vec<u64> {
863    if page_count <= 7 {
864        return (1..=page_count).collect();
865    }
866    let mut pages: Vec<u64> = Vec::new();
867    for value in [
868        1,
869        page.saturating_sub(1).max(1),
870        page,
871        page.saturating_add(1).min(page_count),
872        page_count,
873    ] {
874        if pages.last().is_none_or(|last| *last < value) {
875            pages.push(value);
876        }
877    }
878    pages
879}