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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user