Files
doctate/docs/specs/0002-copy-close-on-case-page.md
T
Brummel 43ff2f20e1 spec: copy-close on case page
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
2026-06-01 14:20:14 +02:00

248 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
```html
<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:
```js
(() => {
// 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 438458):
```html
{% 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-actions` block** (new, after `#doc-body`, `!is_closed`
only) — one close form whose button label is `Kopieren + Schließen`
when `has_document`, else `Schließ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
1. User clicks **Kopieren + Schließen**.
2. `submit` event fires → handler `preventDefault()`s.
3. `await copyDocText()` runs the clipboard write inside the gesture.
4. On `true`: `form.submit()``POST /cases/{id}/close` → handler
writes the `.closed` marker and returns `303` to
`resolve_list_return_path(headers)`. The `Referer` is the case page
`/cases/{uuid}`, which that function strips to `/cases`. Browser
lands on the list.
5. On `false`: `#copy-close-error` is 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-error` is 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 text
`Kopieren + 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 with `action="…/close"` is still present and
its label is `Schließen`.
- **Closed case** renders no `copy-close-form`; the reopen control is
still present.
- **`case_page_document_has_copy_button`** stays 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 `execCommand` fallback path still applies.
- [ ] Closed cases render neither button; reopen unchanged.
- [ ] Case list (`my_cases.html`) is untouched this cycle (`refs #14`).