#[must_use]
pub fn transition_controls(
action: &str,
field: &str,
current: &str,
transitions: &[(&str, &str, Option<&str>)],
can: impl Fn(&str) -> bool,
csrf: Option<&crate::security::CsrfToken>,
csrf_field: Option<&crate::security::CsrfFormField>,
) -> maud::Markup {
transition_controls_with_labels(
action,
field,
current,
transitions,
can,
csrf,
csrf_field,
&TransitionLabels::new(),
)
}
#[derive(Clone, Copy, Default)]
pub struct TransitionLabels<'a> {
group: Option<&'a str>,
buttons: &'a [(&'a str, &'a str)],
}
impl<'a> TransitionLabels<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
group: None,
buttons: &[],
}
}
#[must_use]
pub const fn group(mut self, label: &'a str) -> Self {
self.group = Some(label);
self
}
#[must_use]
pub const fn buttons(mut self, buttons: &'a [(&'a str, &'a str)]) -> Self {
self.buttons = buttons;
self
}
fn group_label(&self, field: &str) -> String {
self.group
.map_or_else(|| format!("{field} transitions"), ToString::to_string)
}
fn button_label(&self, to: &str) -> String {
self.buttons
.iter()
.find(|(state, _)| *state == to)
.map_or_else(
|| format!("Mark as {to}"),
|(_, label)| (*label).to_string(),
)
}
}
#[must_use]
#[allow(clippy::too_many_arguments)] pub fn transition_controls_with_labels(
action: &str,
field: &str,
current: &str,
transitions: &[(&str, &str, Option<&str>)],
can: impl Fn(&str) -> bool,
csrf: Option<&crate::security::CsrfToken>,
csrf_field: Option<&crate::security::CsrfFormField>,
labels: &TransitionLabels<'_>,
) -> maud::Markup {
let csrf_field_name = csrf_field.map_or("_csrf", |f| f.0.as_str());
maud::html! {
div class="autumn-transition-controls" role="group" aria-label=(labels.group_label(field)) {
@for (from, to, _guard) in transitions {
@if *from == current {
form method="post" action=(action) class="autumn-transition" {
@if let Some(tok) = csrf {
input type="hidden" name=(csrf_field_name) value=(tok.token());
}
input type="hidden" name=(field) value=(to);
button type="submit" disabled[!can(to)] { (labels.button_label(to)) }
}
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct BulkActionsConfig<'a> {
pub action: &'a str,
pub field_name: &'a str,
pub submit_label: &'a str,
pub select_label: &'a str,
}
impl<'a> BulkActionsConfig<'a> {
#[must_use]
pub const fn new(action: &'a str) -> Self {
Self {
action,
field_name: "ids",
submit_label: "Delete selected",
select_label: "Select row",
}
}
#[must_use]
pub const fn field_name(mut self, field_name: &'a str) -> Self {
self.field_name = field_name;
self
}
#[must_use]
pub const fn submit_label(mut self, submit_label: &'a str) -> Self {
self.submit_label = submit_label;
self
}
#[must_use]
pub const fn select_label(mut self, select_label: &'a str) -> Self {
self.select_label = select_label;
self
}
}
#[must_use]
pub fn bulk_select_checkbox(
value: impl std::fmt::Display,
config: &BulkActionsConfig<'_>,
) -> maud::Markup {
let value = value.to_string();
let aria_label = format!("{} {value}", config.select_label);
maud::html! {
input type="checkbox" name=(config.field_name) value=(value)
class="autumn-bulk-select" aria-label=(aria_label);
}
}
#[must_use]
pub fn bulk_actions_toolbar(config: &BulkActionsConfig<'_>) -> maud::Markup {
maud::html! {
div class="autumn-bulk-actions" role="group" {
button type="submit" { (config.submit_label) }
}
}
}
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn bulk_actions_form(
config: &BulkActionsConfig<'_>,
csrf_token: Option<&str>,
csrf_field: Option<&str>,
submit_token: Option<&str>,
submit_field: Option<&str>,
content: maud::Markup,
) -> maud::Markup {
let csrf_field_name = csrf_field.unwrap_or("_csrf");
let submit_field_name = submit_field.unwrap_or("_submit_token");
maud::html! {
form method="post" action=(config.action) class="autumn-bulk-form" {
@if let Some(token) = submit_token {
input type="hidden" name=(submit_field_name) value=(token);
}
@if let Some(token) = csrf_token {
input type="hidden" name=(csrf_field_name) value=(token);
}
(content)
(bulk_actions_toolbar(config))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const fn sample_transitions() -> &'static [(&'static str, &'static str, Option<&'static str>)] {
&[
("draft", "published", Some("can_publish")),
("published", "archived", None),
]
}
#[test]
fn transition_controls_only_renders_edges_from_current() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
None,
None,
)
.into_string();
assert!(html.contains(r#"value="published""#), "{html}");
assert!(!html.contains(r#"value="archived""#), "{html}");
}
#[test]
fn transition_controls_renders_terminal_current_as_empty_group() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"archived",
sample_transitions(),
|_to| true,
None,
None,
)
.into_string();
assert!(
html.contains(r#"class="autumn-transition-controls""#),
"{html}"
);
assert!(!html.contains("<form"), "{html}");
assert!(!html.contains("<button"), "{html}");
}
#[test]
fn transition_controls_guard_true_button_is_enabled() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|to| to == "published", None,
None,
)
.into_string();
assert!(
html.contains("<button type=\"submit\">Mark as published</button>"),
"{html}"
);
assert!(!html.contains("disabled"), "{html}");
}
#[test]
fn transition_controls_guard_false_button_is_disabled() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| false, None,
None,
)
.into_string();
assert!(html.contains(r#"value="published""#), "{html}");
assert!(html.contains("disabled"), "{html}");
}
#[test]
fn transition_controls_guardless_edge_from_current_is_enabled() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"published",
sample_transitions(),
|_to| true,
None,
None,
)
.into_string();
assert!(html.contains(r#"value="archived""#), "{html}");
assert!(
html.contains("<button type=\"submit\">Mark as archived</button>"),
"{html}"
);
assert!(!html.contains("disabled"), "{html}");
}
#[test]
fn transition_controls_form_is_post_no_js_and_carries_target_in_body() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
None,
None,
)
.into_string();
assert!(
html.contains(r#"<form method="post" action="/orders/42/transitions/status""#),
"{html}"
);
assert!(
html.contains(r#"<input type="hidden" name="status" value="published">"#),
"{html}"
);
assert!(!html.contains("onclick"), "{html}");
assert!(!html.contains("hx-"), "{html}");
assert!(!html.contains("<script"), "{html}");
}
#[test]
fn transition_controls_csrf_hidden_input_present_when_token_supplied() {
let token = crate::security::CsrfToken::new("secret-token".to_string());
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
Some(&token),
None,
)
.into_string();
assert!(
html.contains(r#"<input type="hidden" name="_csrf" value="secret-token">"#),
"{html}"
);
}
#[test]
fn transition_controls_csrf_absent_when_no_token() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
None,
None,
)
.into_string();
assert!(!html.contains("_csrf"), "{html}");
}
#[test]
fn transition_controls_honors_custom_csrf_field_name() {
let token = crate::security::CsrfToken::new("secret-token".to_string());
let field = crate::security::CsrfFormField("csrf_custom".to_string());
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
Some(&token),
Some(&field),
)
.into_string();
assert!(
html.contains(r#"<input type="hidden" name="csrf_custom" value="secret-token">"#),
"{html}"
);
assert!(!html.contains(r#"name="_csrf""#), "{html}");
}
#[test]
fn transition_controls_with_labels_overrides_the_group_label() {
let html = transition_controls_with_labels(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
None,
None,
&TransitionLabels::new().group("Passer à"),
)
.into_string();
assert!(html.contains("Passer à"), "{html}");
assert!(!html.contains("status transitions"), "{html}");
}
#[test]
fn transition_controls_with_labels_overrides_a_single_button_by_state() {
let transitions: &[(&str, &str, Option<&str>)] =
&[("pending", "approved", None), ("pending", "rejected", None)];
let html = transition_controls_with_labels(
"/orders/42/transitions/status",
"status",
"pending",
transitions,
|_to| true,
None,
None,
&TransitionLabels::new().buttons(&[("approved", "Approve it")]),
)
.into_string();
assert!(
html.contains("<button type=\"submit\">Approve it</button>"),
"{html}"
);
assert!(
html.contains("<button type=\"submit\">Mark as rejected</button>"),
"{html}"
);
}
#[test]
fn transition_controls_with_labels_default_is_byte_identical_to_transition_controls() {
let plain = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
None,
None,
)
.into_string();
let with_labels = transition_controls_with_labels(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
None,
None,
&TransitionLabels::new(),
)
.into_string();
assert_eq!(plain, with_labels);
}
#[test]
fn bulk_actions_config_defaults() {
let cfg = BulkActionsConfig::new("/posts/bulk_delete");
assert_eq!(cfg.action, "/posts/bulk_delete");
assert_eq!(cfg.field_name, "ids");
assert_eq!(cfg.submit_label, "Delete selected");
assert_eq!(cfg.select_label, "Select row");
}
#[test]
fn bulk_actions_config_builders() {
let cfg = BulkActionsConfig::new("/posts/bulk_delete")
.field_name("post_ids")
.submit_label("Archive selected")
.select_label("Select post");
assert_eq!(cfg.action, "/posts/bulk_delete");
assert_eq!(cfg.field_name, "post_ids");
assert_eq!(cfg.submit_label, "Archive selected");
assert_eq!(cfg.select_label, "Select post");
}
#[test]
fn bulk_select_checkbox_emits_name_value_and_aria_label() {
let cfg = BulkActionsConfig::new("/posts/bulk_delete");
let html = bulk_select_checkbox(42, &cfg).into_string();
assert!(html.contains(r#"type="checkbox""#), "{html}");
assert!(html.contains(r#"name="ids""#), "{html}");
assert!(html.contains(r#"value="42""#), "{html}");
assert!(html.contains("autumn-bulk-select"), "{html}");
assert!(html.contains(r#"aria-label="Select row 42""#), "{html}");
}
#[test]
fn bulk_select_checkbox_honours_custom_field_name() {
let cfg = BulkActionsConfig::new("/posts/bulk_delete")
.field_name("post_ids")
.select_label("Select post");
let html = bulk_select_checkbox(7, &cfg).into_string();
assert!(html.contains(r#"name="post_ids""#), "{html}");
assert!(!html.contains(r#"name="ids""#), "{html}");
assert!(html.contains(r#"aria-label="Select post 7""#), "{html}");
}
#[test]
fn bulk_actions_form_renders_csrf_hidden_input() {
let cfg = BulkActionsConfig::new("/posts/bulk_delete");
let html = bulk_actions_form(&cfg, Some("tok-123"), None, None, None, maud::html! {})
.into_string();
assert!(html.contains(r#"type="hidden""#), "{html}");
assert!(html.contains(r#"name="_csrf""#), "{html}");
assert!(html.contains(r#"value="tok-123""#), "{html}");
let named = bulk_actions_form(
&cfg,
Some("tok-123"),
Some("authenticity"),
None,
None,
maud::html! {},
)
.into_string();
assert!(named.contains(r#"name="authenticity""#), "{named}");
assert!(!named.contains(r#"name="_csrf""#), "{named}");
}
#[test]
fn bulk_actions_form_omits_csrf_input_when_none() {
let cfg = BulkActionsConfig::new("/posts/bulk_delete");
let html = bulk_actions_form(&cfg, None, None, None, None, maud::html! {}).into_string();
assert!(!html.contains(r#"type="hidden""#), "{html}");
assert!(!html.contains("_csrf"), "{html}");
assert!(html.contains("<form"), "{html}");
}
#[test]
fn bulk_actions_form_renders_submit_token_hidden_input() {
let cfg = BulkActionsConfig::new("/posts/bulk_delete");
let html = bulk_actions_form(&cfg, None, None, Some("submit-456"), None, maud::html! {})
.into_string();
assert!(html.contains(r#"name="_submit_token""#), "{html}");
assert!(html.contains(r#"value="submit-456""#), "{html}");
let named = bulk_actions_form(
&cfg,
None,
None,
Some("submit-456"),
Some("_once"),
maud::html! {},
)
.into_string();
assert!(named.contains(r#"name="_once""#), "{named}");
assert!(!named.contains(r#"name="_submit_token""#), "{named}");
let without = bulk_actions_form(&cfg, None, None, None, None, maud::html! {}).into_string();
assert!(!without.contains("_submit_token"), "{without}");
}
#[test]
fn bulk_actions_form_puts_submit_token_ahead_of_the_rows() {
let cfg = BulkActionsConfig::new("/posts/bulk_delete");
let rows = maud::html! {
@for id in 1..200 {
(bulk_select_checkbox(id, &cfg))
}
};
let html = bulk_actions_form(&cfg, Some("csrf-1"), None, Some("submit-456"), None, rows)
.into_string();
let token_at = html.find("_submit_token").expect("token rendered");
let first_row_at = html.find("autumn-bulk-select").expect("rows rendered");
assert!(
token_at < first_row_at,
"the submit token must lead the body: {html}"
);
}
#[test]
fn bulk_actions_form_posts_to_action_and_wraps_content() {
let cfg = BulkActionsConfig::new("/posts/bulk_delete");
let content = maud::html! { p { "row-marker" } };
let html = bulk_actions_form(&cfg, None, None, None, None, content).into_string();
assert!(html.contains(r#"method="post""#), "{html}");
assert!(html.contains(r#"action="/posts/bulk_delete""#), "{html}");
assert!(html.contains("autumn-bulk-form"), "{html}");
assert!(html.contains("row-marker"), "{html}");
let content_at = html.find("row-marker").expect("content rendered");
let toolbar_at = html
.find("autumn-bulk-actions")
.expect("toolbar rendered inside the form");
assert!(
content_at < toolbar_at,
"toolbar must follow content: {html}"
);
assert!(html.trim_end().ends_with("</form>"), "{html}");
}
#[test]
fn bulk_actions_toolbar_button_is_submit_and_labelled() {
let cfg = BulkActionsConfig::new("/posts/bulk_delete");
let html = bulk_actions_toolbar(&cfg).into_string();
assert!(html.contains("autumn-bulk-actions"), "{html}");
assert!(html.contains(r#"role="group""#), "{html}");
assert!(html.contains(r#"type="submit""#), "{html}");
assert!(html.contains("Delete selected"), "{html}");
}
#[test]
fn bulk_actions_toolbar_emits_nothing_scripted() {
let cfg = BulkActionsConfig::new("/posts/bulk_delete");
let html = bulk_actions_toolbar(&cfg).into_string();
assert!(!html.contains("onclick"), "{html}");
assert!(!html.contains("data-autumn-confirm"), "{html}");
assert!(!html.contains("<script"), "{html}");
}
}