fix: preserve show_closed view + ungrey closed-case controls

Three UX polish items for the show-closed listing, grouped because
they are the same root problem in three surfaces:

* handle_reopen_case now redirects via resolve_return_path(&headers)
  instead of hard-coding /web/cases, so a reopen from the
  ?show_closed=1 listing stays in that view. Matches the pattern
  already used by analyze/reset/delete-recording.

* handle_close_case now redirects via the new helper
  resolve_list_return_path(&headers), which wraps resolve_return_path
  and additionally collapses a /web/cases/{uuid} detail path to
  /web/cases while keeping the query string. A close from the detail
  page previously would have tried to redirect back to the now-404
  detail, or when triggered from ?show_closed=1 would have dropped
  the query.

* Closed cases in my_cases.html now render the checkbox,
  Analysieren/Neu analysieren and Reset controls in disabled state
  instead of being hidden. Layout stays consistent with mixed-state
  listings, and the user can see what actions would be available
  after reopen. The handlers remain the authoritative guard via
  locate_case_or_404 -> 404 for closed cases, so the HTML disabled
  is a hint only.

Adds four unit tests for resolve_list_return_path and two integration
tests for the new redirect behaviour (close from listing with query,
close from detail with query).
This commit is contained in:
2026-04-21 11:20:17 +02:00
parent 23e828df45
commit 8b1f58ba23
3 changed files with 191 additions and 7 deletions
+70 -4
View File
@@ -122,6 +122,30 @@ fn resolve_return_path(headers: &HeaderMap) -> String {
}
}
/// Like `resolve_return_path`, but collapses a `/web/cases/{uuid}` detail
/// path to `/web/cases` while preserving the query string. Used by
/// handlers that remove the case from the detail view (close, purge) —
/// a redirect back to the now-404 detail page would be a dead end, but
/// losing `?show_closed=1` would eject the user from the show-closed
/// view they were in.
fn resolve_list_return_path(headers: &HeaderMap) -> String {
let raw = resolve_return_path(headers);
let Some(tail) = raw.strip_prefix("/web/cases/") else {
return raw;
};
let (case_part, query) = match tail.split_once('?') {
Some((c, q)) => (c, Some(q)),
None => (tail, None),
};
if uuid::Uuid::parse_str(case_part).is_err() {
return raw;
}
match query {
Some(q) => format!("/web/cases?{q}"),
None => "/web/cases".into(),
}
}
/// Build an `AnalysisInput` for the given case directory.
/// Collects all `.m4a` (excluding `.m4a.failed`), reads the matching
/// `.transcript.txt` for each, skips blank transcripts, and computes
@@ -282,11 +306,17 @@ pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()>
/// The case immediately disappears from listings and detail views (404).
/// Reversible via `handle_reopen_case` (explicit button) or by uploading
/// a new recording for the same case (automatic via `handle_upload`).
///
/// Redirects back to the `Referer`-derived list path (see
/// `resolve_list_return_path`) so a close from the `?show_closed=1`
/// view keeps the user there — but drops the case-id segment if the
/// referer was the case detail, since that page 404s after the close.
pub async fn handle_close_case(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
headers: HeaderMap,
) -> Result<Redirect, AppError> {
let user_root = config.data_path.join(&user.slug);
let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "close").await?;
@@ -310,7 +340,7 @@ pub async fn handle_close_case(
CaseEventKind::CaseClosed,
);
Ok(Redirect::to("/web/cases"))
Ok(Redirect::to(&resolve_list_return_path(&headers)))
}
/// POST /web/cases/{case_id}/reopen
@@ -319,14 +349,20 @@ pub async fn handle_close_case(
/// immediately back in the default listing. A reopened case is
/// indistinguishable from one that was never closed — no status
/// remainders on disk. Idempotent: an already-open case is a no-op.
///
/// Redirects back to the `Referer`-path (filtered via
/// `resolve_return_path`) so a reopen from the `?show_closed=1` view
/// stays in that view instead of jumping back to the default listing.
pub async fn handle_reopen_case(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
headers: HeaderMap,
) -> Result<Redirect, AppError> {
let user_root = config.data_path.join(&user.slug);
let case_dir = user_root.join(case_id.to_string());
let return_path = resolve_return_path(&headers);
if !crate::paths::is_closed(&case_dir).await {
info!(
@@ -334,7 +370,7 @@ pub async fn handle_reopen_case(
case_id = %case_id,
"reopen requested but case is not closed"
);
return Ok(Redirect::to("/web/cases"));
return Ok(Redirect::to(&return_path));
}
match tokio::fs::remove_file(case_dir.join(CLOSE_MARKER)).await {
@@ -356,7 +392,7 @@ pub async fn handle_reopen_case(
CaseEventKind::CaseReopened,
);
Ok(Redirect::to("/web/cases"))
Ok(Redirect::to(&return_path))
}
/// POST /web/cases/{case_id}/reset
@@ -547,7 +583,7 @@ async fn remove_if_exists(path: &Path) -> Result<(), AppError> {
#[cfg(test)]
mod tests {
use super::resolve_return_path;
use super::{resolve_list_return_path, resolve_return_path};
use axum::http::{HeaderMap, HeaderValue, header::REFERER};
fn headers_with_referer(value: &str) -> HeaderMap {
@@ -605,4 +641,34 @@ mod tests {
h.insert(REFERER, HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap());
assert_eq!(resolve_return_path(&h), "/web/cases");
}
// `resolve_list_return_path` is `resolve_return_path` plus a case-
// detail collapse. It leaves non-detail paths alone and strips the
// `/{uuid}` segment from a detail path while preserving the query.
#[test]
fn list_return_path_preserves_list_referer() {
let h = headers_with_referer("/web/cases?show_closed=1");
assert_eq!(resolve_list_return_path(&h), "/web/cases?show_closed=1");
}
#[test]
fn list_return_path_collapses_detail_with_query() {
let h =
headers_with_referer("/web/cases/11111111-1111-1111-1111-111111111111?show_closed=1");
assert_eq!(resolve_list_return_path(&h), "/web/cases?show_closed=1");
}
#[test]
fn list_return_path_collapses_bare_detail() {
let h = headers_with_referer("/web/cases/11111111-1111-1111-1111-111111111111");
assert_eq!(resolve_list_return_path(&h), "/web/cases");
}
#[test]
fn list_return_path_leaves_non_uuid_segment_alone() {
// Non-UUID `/web/cases/<x>` is not a detail page — don't touch it.
let h = headers_with_referer("/web/cases/undo-delete");
assert_eq!(resolve_list_return_path(&h), "/web/cases/undo-delete");
}
}