From 4d71b6ccd054a67122227b98141586ac6fef2d17 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 12 Aug 2026 23:05:23 +0200 Subject: [PATCH] Validate action targets; format timestamps client-side; clippy cleanup - A dangling alternative.action (navigating to "Nothing here") is now a lint/load-time rejection like any other dead reference. - Rows re-rendered after a client-side refetch showed raw milliseconds - format_ms now formats via js_sys::Date under hydrate, matching the server's rendering. Co-Authored-By: Claude Sonnet 5 --- src/app.rs | 18 ++++++++++++++++-- src/content.rs | 36 +++++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8c2423d..36ff592 100644 --- a/src/app.rs +++ b/src/app.rs @@ -492,7 +492,7 @@ fn AlternativeCard( for feature in &alternative.features { for req in &feature.requirements { if req.kind == "file" { - file_refs.entry(req.name.clone()).or_insert_with(NodeRef::new); + file_refs.entry(req.name.clone()).or_default(); } else if req.kind == "select" { select_field_map .entry(req.name.clone()) @@ -1335,7 +1335,21 @@ fn format_ms(ms: i64) -> String { .map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()) .unwrap_or_else(|| ms.to_string()) } - #[cfg(not(feature = "ssr"))] + // Rows re-rendered client-side (a post-confirm refetch) format + // here, not on the server - raw milliseconds would show otherwise. + #[cfg(all(not(feature = "ssr"), feature = "hydrate"))] + { + let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms as f64)); + format!( + "{:04}-{:02}-{:02} {:02}:{:02} UTC", + date.get_utc_full_year(), + date.get_utc_month() + 1, + date.get_utc_date(), + date.get_utc_hours(), + date.get_utc_minutes(), + ) + } + #[cfg(all(not(feature = "ssr"), not(feature = "hydrate")))] { ms.to_string() } diff --git a/src/content.rs b/src/content.rs index aac9876..03f789f 100644 --- a/src/content.rs +++ b/src/content.rs @@ -233,11 +233,8 @@ impl Requirement { } } -/// Extracts `scheme://host` from a repo's normal browser URL (e.g. -/// `https://project.uhhm.no/uhhm/questions` -> `https://project.uhhm.no`) -/// - the Gitea API base every helper in this module builds requests -/// against, including ones (like `resolve_gitea_repo`) that have -/// nothing to do with content loading, just the same Gitea instance. +/// Extracts `scheme://host` from a repo's normal browser URL - the +/// Gitea API base every helper in this module builds requests against. #[cfg(feature = "ssr")] pub fn gitea_api_base(repo_url: &str) -> anyhow::Result { let parsed = url::Url::parse(repo_url) @@ -407,6 +404,16 @@ pub fn validate_questions( ) -> anyhow::Result<()> { for question in questions.values() { for alternative in &question.alternatives { + // A dangling action is a literal dead end: the submit + // button navigates to "Nothing here". + if let Some(action) = &alternative.action { + if !questions.contains_key(action) { + anyhow::bail!( + "question {:?} alternative {:?}: action {:?} does not match any declared question id", + question.id, alternative.name, action + ); + } + } if let Some(st) = &alternative.self_transition { if let Some(schema) = aggregates.get(&st.bucket) { if !schema.has_state(&st.to) { @@ -727,4 +734,23 @@ alternatives: let err = validate_questions(&questions, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("not a declared edge")); } + + #[test] + fn dangling_action_is_rejected() { + let question: Question = + serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /nowhere\n") + .unwrap(); + let questions = std::collections::HashMap::from([(question.id.clone(), question)]); + let err = validate_questions(&questions, &Default::default()).unwrap_err(); + assert!(err.to_string().contains("does not match any declared question id")); + } + + #[test] + fn self_referencing_action_passes() { + let question: Question = + serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /a\n") + .unwrap(); + let questions = std::collections::HashMap::from([(question.id.clone(), question)]); + assert!(validate_questions(&questions, &Default::default()).is_ok()); + } }