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
16pub struct ListOptions<'a> {
18 pub files: Map<String, Value>,
20 pub loader: Option<&'a dyn FileLoader>,
22 pub basepath: String,
24 pub language: String,
26 pub data: Value,
28 pub page: Value,
30 pub total: Value,
33 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
52pub 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: ®ex::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: ®ex::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 #[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 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 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
231const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
233
234fn 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
254pub(crate) struct ListContext {
256 pub(crate) data: Value,
257 pub(crate) page: Option<u64>,
258 pub(crate) total: Option<u64>,
259}
260
261fn 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
295pub fn build_list(spec: &Value, rows: &[Value], options: &ListOptions<'_>) -> FormResult<Value> {
297 let spec = &member_ordered(spec);
299 let checked = list_context(spec, rows, options)?;
300 build_display(spec, rows, options, &checked, "list", "columns")
301}
302
303pub(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 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 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 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 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 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 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
500pub(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
539pub 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 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
750pub(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
780fn 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 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 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 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 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
860fn 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}