Let users throw a concept away #228

Merged
robbertbos merged 13 commits from delete-a-concept into main 2026-08-05 21:20:57 +00:00
Owner

Closes #221.

A concept could be created, edited and sent, but never thrown away. It only disappeared by being sent or scheduled, so changing your mind left you stuck with it.

What this adds

  • Concepten list - a trash button per row, always visible, with a context-specific accessible name ("Concept weggooien: Naar #algemeen").
  • New-message modal - a labelled "Weggooien" beside Verstuur. The "x" is unchanged: it still closes and keeps the concept, because autosave already saved it. Closing and discarding are two different intentions and now have two different controls.

The thread reply bar deliberately has no button. It got one during development and it was removed after looking at it: a red trash can between the formatting controls reads as a formatting action. Instead, emptying a reply cleans the concept up - the same ten-second delayed delete, so both routes carry one guarantee. That one is silent: the user is mid-edit and did not ask for a message, and typing again cancels the timer, which is the undo. It also closes the source of the "Leeg concept" rows that used to accumulate.

Why a delayed delete instead of a confirmation dialog

Deleting a draft server-side also reaps its attachment files from disk (_delete_orphan_refs), so an undo that restored the row afterwards could not bring the files back - _file_entries_from_refs fails on a missing ref. Rather than confirm up front or promise an undo that silently loses attachments, the DELETE is held for ten seconds behind an undo toast. Nothing has happened yet, so there is nothing to restore.

This reuses the shape of delayedSend.ts, which already holds sends for ten seconds. That file's timer/toast/undo core is extracted into lib/delayedAction.ts and shared. One difference is deliberate: delayedSend warns on tab close, because a lost send is a lost message; a lost discard just leaves the concept in place, so discard does not warn.

ReplyComposer runs both directions through one watcher: content means the concept lives (which also takes back a discard started from the Concepten list, inside its window), empty means clean it up. A second guard stops the server seed from re-loading a concept that is already on its way out. Without either, the timer would delete what the user just wrote and reap its files.

Toast.vue's fixed width: 360px became a floor: the action row is flex-shrink: 0, so the message got 155px while "Concept weggegooid" needs 158, and wrapped to two lines over three pixels.

Components

No custom CSS. nldd-button / nldd-icon-button with variant="critical-transparent" and icon="trash"; NLDD's text supplies both the aria-label and the tooltip. critical-transparent rather than destructive so the least-wanted action on screen does not outshout Verstuur. The one new rule is a flex spacer in the list row.

Review found real bugs

An adversarial review pass caught seven defects that the passing tests did not, all fixed in 201c3f8. The sharpest:

  • Discarding while the autosave-create was still in flight was dead code. close() bumps the composer's session, so no adoption guard could ever match: no delete, no toast, concept silently left behind. The create now hands back a promise instead of being polled for. The test that "covered" this passed on an unreachable path because it never mirrored App.vue unsetting open; it now does, and fails without the fix.
  • Cancelling a discard left its toast on screen, still saying "Concept weggegooid" with an undo button that did nothing.
  • Only typing cancelled a pending discard. Adding an attachment, or loading a cancelled scheduled post back into the composer, did not - and the timer then deleted that content and reaped the files.
  • Reopening a card inside the undo window re-seeded the concept that was on its way out, which the timer then deleted from under the user.

Verified

679 frontend tests, 1657 backend tests, typecheck, build, eslint and pre-commit all green. Backend untouched: both DELETE endpoints already existed for the internal send cleanup.

Every new test was mutation-checked - the protection removed, the test confirmed to fail, the code restored. Driven in the running app too: the row vanishes at once, the toast undoes it, ten seconds later the concept is really gone (confirmed across a reload), and the trash icon renders a real glyph rather than the empty box an unknown icon name would give.

Closes #221. A concept could be created, edited and sent, but never thrown away. It only disappeared by being sent or scheduled, so changing your mind left you stuck with it. ## What this adds - **Concepten list** - a trash button per row, always visible, with a context-specific accessible name ("Concept weggooien: Naar #algemeen"). - **New-message modal** - a labelled "Weggooien" beside Verstuur. The "x" is unchanged: it still closes and keeps the concept, because autosave already saved it. Closing and discarding are two different intentions and now have two different controls. The thread reply bar deliberately has **no** button. It got one during development and it was removed after looking at it: a red trash can between the formatting controls reads as a formatting action. Instead, **emptying a reply cleans the concept up** - the same ten-second delayed delete, so both routes carry one guarantee. That one is silent: the user is mid-edit and did not ask for a message, and typing again cancels the timer, which is the undo. It also closes the source of the "Leeg concept" rows that used to accumulate. ## Why a delayed delete instead of a confirmation dialog Deleting a draft server-side also reaps its attachment files from disk (`_delete_orphan_refs`), so an undo that restored the row afterwards could not bring the files back - `_file_entries_from_refs` fails on a missing ref. Rather than confirm up front or promise an undo that silently loses attachments, the DELETE is held for ten seconds behind an undo toast. Nothing has happened yet, so there is nothing to restore. This reuses the shape of `delayedSend.ts`, which already holds sends for ten seconds. That file's timer/toast/undo core is extracted into `lib/delayedAction.ts` and shared. One difference is deliberate: `delayedSend` warns on tab close, because a lost send is a lost message; a lost discard just leaves the concept in place, so discard does not warn. `ReplyComposer` runs both directions through one watcher: content means the concept lives (which also takes back a discard started from the Concepten list, inside its window), empty means clean it up. A second guard stops the server seed from re-loading a concept that is already on its way out. Without either, the timer would delete what the user just wrote and reap its files. `Toast.vue`'s fixed `width: 360px` became a floor: the action row is `flex-shrink: 0`, so the message got 155px while "Concept weggegooid" needs 158, and wrapped to two lines over three pixels. ## Components No custom CSS. `nldd-button` / `nldd-icon-button` with `variant="critical-transparent"` and `icon="trash"`; NLDD's `text` supplies both the aria-label and the tooltip. `critical-transparent` rather than `destructive` so the least-wanted action on screen does not outshout Verstuur. The one new rule is a flex spacer in the list row. ## Review found real bugs An adversarial review pass caught seven defects that the passing tests did not, all fixed in `201c3f8`. The sharpest: - Discarding while the autosave-create was still in flight was **dead code**. `close()` bumps the composer's session, so no adoption guard could ever match: no delete, no toast, concept silently left behind. The create now hands back a promise instead of being polled for. The test that "covered" this passed on an unreachable path because it never mirrored `App.vue` unsetting `open`; it now does, and fails without the fix. - Cancelling a discard left its toast on screen, still saying "Concept weggegooid" with an undo button that did nothing. - Only typing cancelled a pending discard. Adding an attachment, or loading a cancelled scheduled post back into the composer, did not - and the timer then deleted that content and reaped the files. - Reopening a card inside the undo window re-seeded the concept that was on its way out, which the timer then deleted from under the user. ## Verified 679 frontend tests, 1657 backend tests, typecheck, build, eslint and pre-commit all green. Backend untouched: both DELETE endpoints already existed for the internal send cleanup. Every new test was mutation-checked - the protection removed, the test confirmed to fail, the code restored. Driven in the running app too: the row vanishes at once, the toast undoes it, ten seconds later the concept is really gone (confirmed across a reload), and the trash icon renders a real glyph rather than the empty box an unknown icon name would give.
Fix the discard paths found in review
All checks were successful
CI / e2e (pull_request) Successful in 3m29s
CI / backend-test-postgres (pull_request) Successful in 4m21s
CI / release-scripts (pull_request) Successful in 6s
security-scan / SBOM (trivy) (pull_request) Successful in 11s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 14s
security-scan / JS SCA (npm audit) (pull_request) Successful in 18s
security-scan / Python SAST (bandit) (pull_request) Successful in 20s
CI / pre-commit (pull_request) Successful in 30s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 41s
CI / backend-test (pull_request) Successful in 53s
test-build / build (backend) (pull_request) Successful in 1m0s
test-build / build (frontend) (pull_request) Successful in 1m6s
test-build / build (pull_request) Successful in 0s
CI / frontend-test (pull_request) Successful in 1m18s
201c3f8edf
- Discarding while the autosave-create is in flight was dead code: close()
  bumps the session, so no adoption guard could ever match and the concept
  stayed behind without even a toast. The create now hands back a promise.
- Cancelling a discard left its toast on screen, still claiming the concept
  was thrown away and offering an undo button that did nothing.
- Any content written back into a reply draft now cancels a pending discard,
  not just typing: an attachment or a queued-post edit would otherwise be
  deleted, and its files reaped, when the timer fired.
- Reopening a card inside the undo window no longer seeds the concept that is
  on its way out.
- A failed discard in the reply composer reports on the toast bus and restores
  the snapshot; the composer is usually unmounted by then.
- Enter on a list row's discard button no longer discards and opens at once.
- The two delete mutations await their list invalidation, so the row cannot
  flash back before the refetch lands.
Drop the discard button from the reply bar
All checks were successful
CI / frontend-test (pull_request) Successful in 1m51s
test-build / build (backend) (pull_request) Successful in 1m24s
test-build / build (frontend) (pull_request) Successful in 1m31s
test-build / build (pull_request) Successful in 0s
CI / e2e (pull_request) Successful in 3m50s
CI / backend-test-postgres (pull_request) Successful in 4m52s
CI / pre-commit (pull_request) Successful in 44s
CI / release-scripts (pull_request) Successful in 12s
security-scan / SBOM (trivy) (pull_request) Successful in 16s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 18s
security-scan / Python SAST (bandit) (pull_request) Successful in 29s
security-scan / JS SCA (npm audit) (pull_request) Successful in 30s
CI / backend-test (pull_request) Successful in 1m19s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 50s
4f75c3f478
A red trash can between the formatting controls reads as a formatting action.
Reply concepts are discarded from the Concepten page instead.

The two guards around it stay: a concept discarded from the list can be open in
this composer, or be opened in it, inside the undo window. Writing content back
takes the discard back, and the server seed skips a concept already on its way
out - neither was ever about the button.
The action row is flex-shrink:0, so at a fixed 360px the message got 155px.
"Concept weggegooid" needs 158, and wrapped to two lines over three pixels.
360px is now the floor rather than the width, capped at 30rem.
Clean the concept up when a reply is emptied
All checks were successful
CI / release-scripts (pull_request) Successful in 6s
CI / e2e (pull_request) Successful in 3m42s
security-scan / SBOM (trivy) (pull_request) Successful in 11s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 16s
security-scan / JS SCA (npm audit) (pull_request) Successful in 20s
CI / backend-test-postgres (pull_request) Successful in 4m18s
security-scan / Python SAST (bandit) (pull_request) Successful in 23s
CI / pre-commit (pull_request) Successful in 33s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 43s
CI / backend-test (pull_request) Successful in 1m3s
test-build / build (backend) (pull_request) Successful in 1m7s
test-build / build (frontend) (pull_request) Successful in 1m8s
test-build / build (pull_request) Successful in 0s
CI / frontend-test (pull_request) Successful in 1m20s
d699376d07
With no discard button in the reply bar, emptying the editor is how a reply
concept goes away. It runs the same 10s delayed delete as the list button, so
both routes carry one guarantee, and it closes the source of "Leeg concept"
rows.

Silent, unlike the button: the user is mid-edit and did not ask for a message,
and typing again cancels the timer - that is the undo. A flag keeps the send and
schedule paths, which clear the editor themselves, from reading as a discard.
Use nldd-icon-button for the toast dismiss
All checks were successful
test-build / build (pull_request) Successful in 0s
CI / frontend-test (pull_request) Successful in 1m29s
CI / e2e (pull_request) Successful in 3m45s
CI / backend-test-postgres (pull_request) Successful in 4m33s
CI / release-scripts (pull_request) Successful in 6s
security-scan / SBOM (trivy) (pull_request) Successful in 11s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 13s
security-scan / Python SAST (bandit) (pull_request) Successful in 20s
security-scan / JS SCA (npm audit) (pull_request) Successful in 21s
CI / pre-commit (pull_request) Successful in 30s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 45s
CI / backend-test (pull_request) Successful in 1m7s
test-build / build (frontend) (pull_request) Successful in 1m17s
test-build / build (backend) (pull_request) Successful in 1m17s
8af90c20e3
NLDD ships no toast, so Toast.vue stays hand-built - but its dismiss button was
a hand-rolled <button> re-creating what nldd-icon-button gives: hover, focus
ring and forced-colors support, for 19 lines of CSS. The hit target grows from
24 to 32px as a side effect.
Merge remote-tracking branch 'origin/main' into delete-a-concept
All checks were successful
CI / e2e (push) Successful in 3m31s
CI / release-scripts (pull_request) Successful in 6s
security-scan / SBOM (trivy) (pull_request) Successful in 8s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 11s
security-scan / JS SCA (npm audit) (pull_request) Successful in 18s
security-scan / Python SAST (bandit) (pull_request) Successful in 19s
CI / backend-test-postgres (push) Successful in 4m5s
CI / pre-commit (pull_request) Successful in 29s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 44s
CI / backend-test (pull_request) Successful in 54s
test-build / build (backend) (pull_request) Successful in 57s
test-build / build (frontend) (pull_request) Successful in 1m0s
test-build / build (pull_request) Successful in 0s
CI / frontend-test (pull_request) Successful in 1m13s
CI / e2e (pull_request) Successful in 3m42s
CI / backend-test-postgres (pull_request) Successful in 4m35s
CI / release-scripts (push) Successful in 7s
security-scan / SBOM (trivy) (push) Successful in 10s
security-scan / Filesystem scan (trivy fs) (push) Successful in 12s
security-scan / JS SCA (npm audit) (push) Successful in 14s
security-scan / Python SAST (bandit) (push) Successful in 19s
CI / pre-commit (push) Successful in 27s
security-scan / Python SCA (pip-audit) (push) Successful in 40s
publish-main / build (frontend) (push) Successful in 59s
publish-main / build (backend) (push) Successful in 1m3s
CI / backend-test (push) Successful in 1m22s
publish-main / build (push) Successful in 0s
CI / frontend-test (push) Successful in 1m5s
8597d5cf62
# Conflicts:
#	frontend/src/routes/ConceptBerichten.vue
robbertbos deleted branch delete-a-concept 2026-08-05 21:20:57 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
robbertbos/waggle!228
No description provided.