From 0308245524c5078c7823a6123f9370228570265e Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 1 Jun 2026 14:35:17 +0200 Subject: [PATCH] feat(web): fuse copy + close into one control on the case page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A finished case is handled in one gesture: a text button below the document copies the document text to the clipboard and then closes the case, landing back on /cases. Replaces the icon-only trash-can close that sat in the H1 title row. Behaviour: - Open case with a document: "Kopieren + Schließen" below the document. The submit handler awaits the clipboard write inside the user gesture, then submits the close form; a FAILED copy aborts the close and reveals an inline error, so a finished case never disappears from the default list while the clipboard is empty. - Open case without a document: a bare "Schließen" button (nothing to copy) keeps the close affordance the removed trash-can used to provide. - Closed case: neither button renders; the reopen affordance is untouched. Frontend-only. No Rust route/handler change: POST /cases/{id}/close already strips the case-page Referer to /cases, so "back to list" needs no new server work. The two-path clipboard routine (async Clipboard API + textarea/execCommand fallback) is extracted into a shared copyDocText() used by both the standalone icon copy button (kept, copy-without-close) and the new combined control — one source, no drift. Tests (RED-first, server/tests/case_page_test.rs): open+document renders the copy-close form with the close action and CSRF token; closed case hides it; the H1 trash-can is gone on an open case; the documentless open case keeps a bare Schließen. Full server suite green, clippy clean, fmt no-op. The JS submit-handler ordering (copy-before-navigate, abort-on-fail) has no Rust render harness — same inherent layer gap as the pre-existing #copy-btn — and is verified manually. Covers the case-page half of #14; the per-row case-list copy+close is deferred. refs #14 --- server/templates/case_page.html | 105 ++++++++++++++++------------ server/tests/case_page_test.rs | 119 ++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 46 deletions(-) diff --git a/server/templates/case_page.html b/server/templates/case_page.html index d3025dd..b6dc63f 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -436,40 +436,6 @@ - {% else %} -
- {% call csrf::field(csrf_token) %} - -
{% endif %} {% match recorded_at_iso %} {% when Some with (iso) %} @@ -623,13 +589,27 @@
{{ html|safe }}
+ {% if !is_closed %} + + {% endif %} {% when None %} {% match analysis_failed %} {% when Some with (failure) %} @@ -743,7 +747,16 @@ {{ recordings_count }} Aufnahme{% if recordings_count != 1 %}n{% endif %}, davon {{ transcribed_count }} transkribiert. - {% endif %} {% endmatch %} {% endmatch %} + {% endif %} {% endmatch %} + {% if !is_closed %} + + {% endif %} + {% endmatch %}
Schließen"#), + "documentless open case must offer a bare Schließen button" + ); } #[tokio::test] @@ -843,3 +847,118 @@ async fn case_recordings_has_no_action_buttons() { "recordings page must not expose the close action" ); } + +/// Property: an open case WITH a document renders the combined +/// "Kopieren + Schließen" control below the document — a single form +/// that POSTs the close action and carries the CSRF token. +#[tokio::test] +async fn case_page_open_document_has_copy_close_button() { + let cfg = TestConfig::new() + .with_label("cp-copyclose") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + write_document_for_test(&case_dir, "fertiger Brief", "1970-01-01T00:00:00Z"); + + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; + + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = body_string(resp).await; + + assert!( + body.contains(r#"id="copy-close-form""#), + "combined copy-close form missing" + ); + assert!( + body.contains("Kopieren + Schließen"), + "copy-close button label missing" + ); + // The combined form must POST the close action... + let form_start = body + .find(r#"id="copy-close-form""#) + .expect("copy-close form present"); + let rest = &body[form_start..]; + let form_end = rest.find("").expect("copy-close form closes"); + let form_html = &rest[..form_end]; + assert!( + form_html.contains(&format!(r#"action="{}""#, paths::case_close(case_id))), + "copy-close form must POST the close action" + ); + // ...and carry the CSRF token inside that same form. + assert!( + form_html.contains(r#"name="csrf_token""#), + "copy-close form must carry the CSRF token" + ); +} + +/// Guard: a CLOSED case (even with a document, rendered read-only) shows +/// no copy-close control — closing an already-closed case is impossible. +/// The reopen affordance is the only state-changing control. +#[tokio::test] +async fn case_page_closed_document_hides_copy_close() { + let cfg = TestConfig::new() + .with_label("cp-closed-copyclose") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + write_document_for_test(&case_dir, "fertiger Brief", "1970-01-01T00:00:00Z"); + write_closed_marker(&case_dir, "2026-04-15T12:00:00Z"); + + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; + + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = body_string(resp).await; + + assert!( + !body.contains(r#"id="copy-close-form""#), + "closed case must not expose the copy-close control" + ); + assert!( + body.contains(&format!(r#"action="{}""#, paths::case_reopen(case_id))), + "closed case must still offer reopen" + ); +} + +/// Property: the icon-only trash-can close button in the H1 title row is +/// gone — closing now happens via the text control below the document. +/// (The closed-case reopen affordance in the same slot is untouched.) +#[tokio::test] +async fn case_page_open_case_has_no_trashcan_close() { + let cfg = TestConfig::new() + .with_label("cp-no-trash") + .with_user(test_user("dr_a")) + .with_llm("http://unused") + .build(); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); + write_document_for_test(&case_dir, "fertiger Brief", "1970-01-01T00:00:00Z"); + + let app = doctate_server::create_router(cfg); + let cookie = login_dr_a(&app).await; + + let resp = app + .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = body_string(resp).await; + + assert!( + !body.contains(r#"title="Fall schließen""#), + "the H1 trash-can close button must be gone" + ); +}