feat(web): fuse copy + close into one control on the case page
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
This commit is contained in:
@@ -436,40 +436,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
|
||||||
<form
|
|
||||||
class="delete-form"
|
|
||||||
method="post"
|
|
||||||
action="/cases/{{ case_id }}/close"
|
|
||||||
>
|
|
||||||
{% call csrf::field(csrf_token) %}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="delete-btn"
|
|
||||||
title="Fall schließen"
|
|
||||||
aria-label="Fall schließen"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M3 6h18"></path>
|
|
||||||
<path
|
|
||||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"
|
|
||||||
></path>
|
|
||||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
||||||
<line x1="10" x2="10" y1="11" y2="17"></line>
|
|
||||||
<line x1="14" x2="14" y1="11" y2="17"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
{% match recorded_at_iso %} {% when Some with (iso) %}
|
{% match recorded_at_iso %} {% when Some with (iso) %}
|
||||||
@@ -623,13 +589,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="doc-body">{{ html|safe }}</div>
|
<div id="doc-body">{{ html|safe }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if !is_closed %}
|
||||||
|
<div class="doc-footer-actions">
|
||||||
|
<form id="copy-close-form" method="post" action="/cases/{{ case_id }}/close">
|
||||||
|
{% call csrf::field(csrf_token) %}
|
||||||
|
<button type="submit" class="copy-close-btn">Kopieren + Schließen</button>
|
||||||
|
</form>
|
||||||
|
<p id="copy-close-error" class="copy-error" role="alert" hidden>
|
||||||
|
Kopieren fehlgeschlagen — Fall nicht geschlossen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const btn = document.getElementById("copy-btn");
|
// Shared two-path clipboard write: async Clipboard API in
|
||||||
if (!btn) return;
|
// secure contexts, textarea + execCommand fallback otherwise.
|
||||||
btn.hidden = false;
|
// Returns whether the copy actually succeeded so callers can
|
||||||
btn.addEventListener("click", async () => {
|
// gate the follow-up close on it.
|
||||||
const text = document.getElementById("doc-body").innerText;
|
async function copyDocText() {
|
||||||
|
const body = document.getElementById("doc-body");
|
||||||
|
if (!body) return false; // no document → nothing to copy
|
||||||
|
const text = body.innerText;
|
||||||
let ok = false;
|
let ok = false;
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
@@ -637,7 +617,7 @@
|
|||||||
ok = true;
|
ok = true;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
/* fall through */
|
/* fall through to execCommand */
|
||||||
}
|
}
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
const ta = document.createElement("textarea");
|
const ta = document.createElement("textarea");
|
||||||
@@ -652,11 +632,35 @@
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
ta.remove();
|
ta.remove();
|
||||||
}
|
}
|
||||||
if (ok) {
|
return ok;
|
||||||
btn.classList.add("copied");
|
}
|
||||||
setTimeout(() => btn.classList.remove("copied"), 1500);
|
|
||||||
}
|
const copyBtn = document.getElementById("copy-btn");
|
||||||
});
|
if (copyBtn) {
|
||||||
|
copyBtn.hidden = false;
|
||||||
|
copyBtn.addEventListener("click", async () => {
|
||||||
|
if (await copyDocText()) {
|
||||||
|
copyBtn.classList.add("copied");
|
||||||
|
setTimeout(() => copyBtn.classList.remove("copied"), 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined "copy then close": the clipboard write must finish
|
||||||
|
// inside the user-gesture context before the form navigates,
|
||||||
|
// and a failed copy aborts the close so the user never loses a
|
||||||
|
// document they believed was on the clipboard.
|
||||||
|
const closeForm = document.getElementById("copy-close-form");
|
||||||
|
if (closeForm) {
|
||||||
|
closeForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (await copyDocText()) {
|
||||||
|
closeForm.submit(); // close → 303 → /cases
|
||||||
|
} else {
|
||||||
|
document.getElementById("copy-close-error").hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% when None %} {% match analysis_failed %} {% when Some with (failure) %}
|
{% when None %} {% match analysis_failed %} {% when Some with (failure) %}
|
||||||
@@ -743,7 +747,16 @@
|
|||||||
{{ recordings_count }} Aufnahme{% if recordings_count != 1 %}n{%
|
{{ recordings_count }} Aufnahme{% if recordings_count != 1 %}n{%
|
||||||
endif %}, davon {{ transcribed_count }} transkribiert.
|
endif %}, davon {{ transcribed_count }} transkribiert.
|
||||||
</div>
|
</div>
|
||||||
{% endif %} {% endmatch %} {% endmatch %}
|
{% endif %} {% endmatch %}
|
||||||
|
{% if !is_closed %}
|
||||||
|
<div class="doc-footer-actions">
|
||||||
|
<form method="post" action="/cases/{{ case_id }}/close">
|
||||||
|
{% call csrf::field(csrf_token) %}
|
||||||
|
<button type="submit" class="copy-close-btn">Schließen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endmatch %}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -295,6 +295,10 @@ async fn case_page_empty_case_shows_placeholder() {
|
|||||||
body.contains(&format!(r#"action="{}""#, paths::case_close(case_id))),
|
body.contains(&format!(r#"action="{}""#, paths::case_close(case_id))),
|
||||||
"close form missing on empty case"
|
"close form missing on empty case"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains(r#">Schließen</button>"#),
|
||||||
|
"documentless open case must offer a bare Schließen button"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -843,3 +847,118 @@ async fn case_recordings_has_no_action_buttons() {
|
|||||||
"recordings page must not expose the close action"
|
"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("</form>").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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user