From 61a4e3895ab9e6d6824e4ca6df5e2a87b00c37e5 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 15 Oct 2026 23:03:24 +0200 Subject: [PATCH] feat(admin): required marks and report prefill on decisions (#3499) --- .../components/enforcement_fields.rs | 85 +++++++++++++++++-- fluxer_admin/src/templates/layout_scripts.rs | 2 +- .../pages/user_detail_tabs/moderation.rs | 4 +- fluxer_admin/tests/enforcement_panel.rs | 11 +++ 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/fluxer_admin/src/templates/components/enforcement_fields.rs b/fluxer_admin/src/templates/components/enforcement_fields.rs index abaa9c61f..bf7ea5626 100644 --- a/fluxer_admin/src/templates/components/enforcement_fields.rs +++ b/fluxer_admin/src/templates/components/enforcement_fields.rs @@ -98,11 +98,23 @@ pub fn field_label(for_id: &str, text: &str) -> Markup { } } +pub fn required_mark() -> Markup { + html! { + span class="text-red-500 ml-0.5" { "*" } + } +} + +fn required_label(for_id: &str, text: &str) -> Markup { + html! { + label for=(for_id) class=(FIELD_LABEL_CLASS) { (text) (required_mark()) } + } +} + pub fn internal_note_field(id_prefix: &str, help: &str) -> Markup { let id = format!("{id_prefix}-internal-note"); html! { div class="space-y-1" { - (field_label(&id, "Internal note")) + (required_label(&id, "Internal note")) input type="text" id=(id) name="internal_note" required maxlength=(INTERNAL_NOTE_MAX_CHARS) class=(FIELD_INPUT_CLASS); p class=(FIELD_HELP_CLASS) { (help) } @@ -147,14 +159,25 @@ pub fn statement_field(id_prefix: &str, help: &str) -> Markup { } pub fn radio_group(legend: &str, name: &str, id_prefix: &str, options: &[(&str, &str)]) -> Markup { + checked_radio_group(legend, name, id_prefix, options, None) +} + +fn checked_radio_group( + legend: &str, + name: &str, + id_prefix: &str, + options: &[(&str, &str)], + checked: Option<&str>, +) -> Markup { html! { fieldset class="space-y-1" { - legend class=(FIELD_LABEL_CLASS) { (legend) } + legend class=(FIELD_LABEL_CLASS) { (legend) (required_mark()) } div class="flex flex-wrap gap-x-4 gap-y-1" { @for (value, label) in options { @let id = format!("{id_prefix}-{name}-{value}"); div class="flex items-center gap-2" { input type="radio" id=(id) name=(name) value=(value) required + checked[checked == Some(*value)] class="h-4 w-4 accent-neutral-900"; label for=(id) class="text-sm text-neutral-900" { (label) } } @@ -181,7 +204,7 @@ pub fn reason_select( let id = format!("{id_prefix}-reason"); html! { div class="space-y-1" { - (field_label(&id, "Reason")) + (required_label(&id, "Reason")) select id=(id) name="reason_code" required class=(FIELD_INPUT_CLASS) { option value="" disabled selected { "Choose a reason" } @for (handling, group) in HANDLING_GROUPS @@ -226,13 +249,14 @@ pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup { let reports_id = format!("{prefix}-report-ids"); let messages_id = format!("{prefix}-evidence"); let rights_holder_id = format!("{prefix}-rights-holder"); + let from_report = !fields.report_ids.is_empty(); html! { div class="space-y-4" data-enforcement-fields="" { (reason_select(prefix, fields.kind, fields.allow_restricted)) - (radio_group("How did we find this?", "source", prefix, &[ + (checked_radio_group("How did we find this?", "source", prefix, &[ ("report", "A report"), ("own_review", "Our own review"), - ])) + ], from_report.then_some("report"))) div class="space-y-1" { (radio_group("Automated tools", "automation", prefix, &[ ("none", "Not involved"), @@ -243,7 +267,10 @@ pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup { } } div class="space-y-1" { - (field_label(&reports_id, "Reports")) + label for=(reports_id) class=(FIELD_LABEL_CLASS) { + "Reports" + span data-show-for-source="report" hidden[!from_report] { (required_mark()) } + } textarea id=(reports_id) name="report_ids" rows="2" placeholder="One report ID per line" class=(FIELD_INPUT_CLASS) { (fields.report_ids.join("\n")) @@ -257,13 +284,19 @@ pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup { class=(FIELD_INPUT_CLASS) { (messages.join("\n")) } + p class=(FIELD_HELP_CLASS) data-hide-for-codes=(restricted_codes) { + "Messages this user sent in a linked report are included automatically. Without any messages, add a note to the user." + } + p class=(FIELD_HELP_CLASS) data-show-for-codes=(restricted_codes) hidden { + "Messages this user sent in a linked report are included automatically. At least one message is required." + } } } div data-hide-for-codes=(restricted_codes) { (statement_field(prefix, "Shown to the user. Never name or describe other people. Not sent for child safety or violent extremism decisions.")) } div class="space-y-1" data-show-for-codes=(COPYRIGHT_REASON_CODE) hidden { - (field_label(&rights_holder_id, "Rights holder or right concerned")) + (required_label(&rights_holder_id, "Rights holder or right concerned")) input type="text" id=(rights_holder_id) name="rights_holder" required disabled maxlength=(RIGHTS_HOLDER_MAX_CHARS) class=(FIELD_INPUT_CLASS); p class=(FIELD_HELP_CLASS) { @@ -626,7 +659,43 @@ mod tests { assert!(html.contains(marker), "missing {marker}\n{html}"); } assert!(!html.contains(r#"name="automation" value="automated""#)); - assert!(!html.contains(r#"checked"#)); + assert_eq!(html.matches("checked").count(), 1); + assert!(html.contains(r#"name="source" value="report" required checked"#)); + assert!(html.contains(r#""#; + for label in [ + "Reason", + "How did we find this?", + "Automated tools", + "Internal note", + "Rights holder or right concerned", + ] { + assert!(html.contains(&format!("{label}{mark}")), "unmarked {label}"); + } + for label in ["Messages<", "Note to the user<"] { + assert!(html.contains(label), "marked {label}"); + } + assert_eq!(html.matches(mark).count(), 6); } #[test] diff --git a/fluxer_admin/src/templates/layout_scripts.rs b/fluxer_admin/src/templates/layout_scripts.rs index 1e7f883b2..ca1a4d67f 100644 --- a/fluxer_admin/src/templates/layout_scripts.rs +++ b/fluxer_admin/src/templates/layout_scripts.rs @@ -663,7 +663,7 @@ pub const ENFORCEMENT_FIELDS_SCRIPT: &str = r#" syncReason(form, control.value); } else if (control instanceof HTMLSelectElement && (control.name === 'resolution' || control.name === 'duration_hours')) { syncChoice(form, control.name, control.value); - } else if (control instanceof HTMLInputElement && control.type === 'radio' && (control.name === 'basis' || control.name === 'limit')) { + } else if (control instanceof HTMLInputElement && control.type === 'radio' && (control.name === 'basis' || control.name === 'limit' || control.name === 'source')) { syncChoice(form, control.name, control.value); } }); diff --git a/fluxer_admin/src/templates/pages/user_detail_tabs/moderation.rs b/fluxer_admin/src/templates/pages/user_detail_tabs/moderation.rs index e0cd151ee..c388c00fa 100644 --- a/fluxer_admin/src/templates/pages/user_detail_tabs/moderation.rs +++ b/fluxer_admin/src/templates/pages/user_detail_tabs/moderation.rs @@ -18,7 +18,7 @@ use crate::{ enforcement_fields::{ BasisFields, DecisionFields, FIELD_HELP_CLASS, FIELD_INPUT_CLASS, FIELD_LABEL_CLASS, basis_fields, deletion_days_field, enforcement_fields, - field_label, internal_note_field, network_block_fields, + field_label, internal_note_field, network_block_fields, required_mark, }, form::{checkbox, csrf_input, danger_button, form_actions, submit_button}, page_container::card_with_header, @@ -550,7 +550,7 @@ fn limit_card(base: &str, user: &AdminUser, csrf_token: &str, prefill: &Decision allow_restricted: true, })) fieldset class="space-y-1" { - legend class=(FIELD_LABEL_CLASS) { "Limit" } + legend class=(FIELD_LABEL_CLASS) { "Limit" (required_mark()) } div class="flex flex-wrap gap-x-4 gap-y-1" { @for limit in ENFORCEMENT_LIMITS { @let id = format!("limit-{}", limit.value); diff --git a/fluxer_admin/tests/enforcement_panel.rs b/fluxer_admin/tests/enforcement_panel.rs index 2e91b9393..2181a31ff 100644 --- a/fluxer_admin/tests/enforcement_panel.rs +++ b/fluxer_admin/tests/enforcement_panel.rs @@ -466,6 +466,16 @@ async fn moderation_tab_without_a_restriction_offers_suspend_and_warn_with_prefi assert!(suspend.contains(&format!(">{REPORT_ID}"))); assert!(suspend.contains(&format!(">{CHANNEL_ID}/{MESSAGE_ID}"))); assert!(section(&html, "?action=warn").contains("Send warning")); + let limit = between(&html, "?action=limit_features", "?action=warn"); + for expected in [ + format!(">{REPORT_ID}"), + format!(">{CHANNEL_ID}/{MESSAGE_ID}"), + r#"name="source" value="report" required checked"#.to_owned(), + r#"Limit*"#.to_owned(), + r#"Internal note*"#.to_owned(), + ] { + assert!(limit.contains(&expected), "missing {expected}\n{limit}"); + } } fn between<'a>(html: &'a str, start: &str, end: &str) -> &'a str { @@ -509,6 +519,7 @@ async fn moderation_tab_offers_close_and_limit_features() { assert!(limit.contains(expected), "missing {expected}\n{limit}"); } assert!(!limit.contains(r#"