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
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
# 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 438–458):
|
||||
|
||||
```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`).
|
||||
Reference in New Issue
Block a user