Design spec for the case-page half of #14: a single "Kopieren + Schließen" text button below the document that copies the document text to the clipboard and then closes the case, landing on /cases. The H1 trash-can close button is removed; the standalone icon copy button stays. A failed clipboard write aborts the close so a finished case never disappears with an empty clipboard. Frontend-only (one Askama template + its inline script); no Rust route change — the close handler already strips the case-page Referer to /cases. The case-list half of #14 is deferred. refs #14
9.7 KiB
Copy + Close on the Case Page — Design Spec
Date: 2026-06-01 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Goal
Give a user finishing a case a single gesture on the case detail page
(server/templates/case_page.html): copy the document text to the
clipboard, close the case, and land back on the case list (/cases).
Today these are two separate controls — the icon copy button
(#copy-btn, above the document) and the trash-can close form (in the
<h1> title row). The close already redirects to /cases from a case
page (see Data flow), so "back to list" needs no new server work; what
is missing is fusing copy + close into one button.
Scope is the case page only. Issue #14 also asks for a per-row
copy+close on the case list (my_cases.html); that part is explicitly
deferred — this cycle leaves the list untouched and the issue stays
open (refs #14, not closes).
Architecture
Frontend-only. One template file (server/templates/case_page.html)
and its inline <script>. No Rust change — no route, handler, or
struct is touched. The POST /cases/{case_id}/close endpoint
(server/src/routes/case_actions.rs:437) and its Referer-derived
redirect stay exactly as they are; the combined button merely submits
the existing close form after the clipboard write completes.
This keeps every server-side invariant fixed: the .closed
discovery-gate semantics (commit 5e86cb5), the IDOR guard, the CSRF
contract, and the close redirect target are all unchanged because no
server code moves.
Concrete code shapes
User-facing artefact (the headline)
What renders directly under the document for an open case. For a case with a document the button copies then closes; for an open case without a document it is a bare close (nothing to copy):
<div id="doc-body">{{ html|safe }}</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) %}
{% if has_document %}
<button type="submit" class="copy-close-btn">Kopieren + Schließen</button>
{% else %}
<button type="submit" class="copy-close-btn">Schließen</button>
{% endif %}
</form>
<p id="copy-close-error" class="copy-error" role="alert" hidden>
Kopieren fehlgeschlagen — Fall nicht geschlossen.
</p>
</div>
{% endif %}
The inline script, with the two-path clipboard write extracted into a shared function used by both the icon copy button and the close form:
(() => {
// Shared two-path clipboard write: async Clipboard API in secure
// contexts, textarea + execCommand fallback otherwise. Returns
// whether the copy actually succeeded so callers can gate the
// follow-up close on it.
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;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch (_) {
/* fall through to execCommand */
}
if (!ok) {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "-1000px";
document.body.appendChild(ta);
ta.select();
try {
ok = document.execCommand("copy");
} catch (_) {}
ta.remove();
}
return ok;
}
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) => {
// Bare "Schließen" (no document) submits normally, no copy.
if (!document.getElementById("doc-body")) return;
e.preventDefault();
if (await copyDocText()) {
closeForm.submit(); // close → 303 → /cases
} else {
document.getElementById("copy-close-error").hidden = false;
}
});
}
})();
Implementation shape (supporting — before → after)
Remove the H1 trash-can close form (open-case branch). The
{% if is_closed %} reopen branch is untouched.
Before (server/templates/case_page.html, the {% else %} arm of the
title-row form, ~lines 438–458):
{% 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" ...>
<svg ...><!-- trash icon --></svg>
</button>
</form>
{% endif %}
After: the {% else %} arm is dropped entirely — for an open case the
title row carries no close affordance (it moves below the document).
The {% if is_closed %} reopen arm and the {% endif %} remain.
Refactor the copy IIFE: the existing anonymous click handler that
inlines the clipboard logic becomes copyDocText() (shown above); the
icon button keeps its exact current behaviour by calling it.
Components
- H1 title row — open-case trash-close form removed; closed-case reopen form unchanged.
doc-footer-actionsblock (new, after#doc-body,!is_closedonly) — one close form whose button label isKopieren + Schließenwhenhas_document, elseSchließen; plus a hidden error paragraph.- Inline script —
copyDocText()shared by the icon copy button and the close form's submit handler.
No new template variables: is_closed, has_document, case_id, and
csrf_token are already in the template's context.
Data flow
- User clicks Kopieren + Schließen.
submitevent fires → handlerpreventDefault()s.await copyDocText()runs the clipboard write inside the gesture.- On
true:form.submit()→POST /cases/{id}/close→ handler writes the.closedmarker and returns303toresolve_list_return_path(headers). TheRefereris the case page/cases/{uuid}, which that function strips to/cases. Browser lands on the list. - On
false:#copy-close-erroris unhidden; no submit; case stays open.
The bare Schließen button (open case, no document) has no
#doc-body, so the handler returns early and the form submits
normally → same close → /cases.
Error handling
- Copy failure (both clipboard paths fail — e.g. insecure context
and no
execCommand): close is aborted, the case stays open, and#copy-close-erroris shown. The user never ends up with a closed (list-hidden) case and an empty clipboard. - CSRF: the new close form carries
{% call csrf::field(csrf_token) %}, identical to the form it replaces. - Closed case: neither button renders (
{% if !is_closed %}); closing an already-closed case is impossible. The reopen affordance is the only state-changing control, unchanged.
Testing strategy
RED→GREEN in server/tests/case_page_test.rs, which renders the page
and asserts on the HTML. The JS gesture ordering (copy-before-navigate,
abort-on-failure) is not covered by Rust tests — the project has no
headless-browser harness — and is verified manually.
New / extended assertions:
- Open case with document renders
id="copy-close-form", the textKopieren + Schließen, and a CSRF token; and no longer renders the H1 trash-can close button (assert its absence so the removal is pinned). - Open case without document (
case_page_empty_case_shows_placeholder, extended): the close form withaction="…/close"is still present and its label isSchließen. - Closed case renders no
copy-close-form; the reopen control is still present. case_page_document_has_copy_buttonstays green — the icon copy button survives the refactor.
Whole suite green via cargo test --manifest-path server/Cargo.toml -j 4;
cargo clippy and cargo fmt clean.
Acceptance criteria
A medical user finishing a case naturally reaches for "put the letter
on the clipboard and be done with this case" in one click — the
Kopieren + Schließen button above is that gesture, and is the
empirical evidence for the criterion. It introduces no new failure
class: a failed copy aborts the close (no false sense of a saved
document), CSRF is preserved, and the closed-case discovery-gate and
IDOR guarantees are untouched because no server code changes.
Checklist (the case-page half of issue #14):
- Case page: one control copies the document to the clipboard,
closes the case, and lands on
/cases, with the clipboard write guaranteed to complete before navigation. - On copy failure the case is not closed and an error is shown.
- Open case without a document keeps a working close control.
- The standalone icon copy button (copy without close) still works.
- The clipboard
execCommandfallback path still applies. - Closed cases render neither button; reopen unchanged.
- Case list (
my_cases.html) is untouched this cycle (refs #14).