feat(documents): add delete button to document edit form
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled

- DELETE /api/documents/{id} endpoint (204 No Content, WRITE_ALL required)
- DocumentService.deleteDocument() — throws 404 if not found, cascades
  via DB foreign keys (versions, annotations, comments all ON DELETE CASCADE)
- Delete form action in edit page server: redirects to / on success
- Two-step confirmation in the save bar: first click reveals inline
  "Wirklich löschen?" + confirm/cancel, avoiding native browser dialogs
- i18n key doc_delete_confirm added to de/en/es

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-26 10:52:43 +01:00
parent 963807ff05
commit 91a29d501d
9 changed files with 138 additions and 12 deletions

View File

@@ -25,6 +25,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.core.io.InputStreamResource;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
@@ -105,6 +106,15 @@ public class DocumentController {
}
}
// --- DELETE ---
@DeleteMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
documentService.deleteDocument(id);
return ResponseEntity.noContent().build();
}
// --- QUICK UPLOAD ---
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(

View File

@@ -277,6 +277,14 @@ public class DocumentService {
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
}
@Transactional
public void deleteDocument(UUID id) {
if (!documentRepository.existsById(id)) {
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
}
documentRepository.deleteById(id);
}
@Transactional
public void deleteTagCascading(UUID tagId) {
documentRepository.findByTags_Id(tagId).forEach(doc -> {

View File

@@ -121,6 +121,32 @@ class DocumentControllerTest {
.andExpect(status().isOk());
}
// ─── DELETE /api/documents/{id} ──────────────────────────────────────────
@Test
void deleteDocument_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + id))
.andExpect(status().isNoContent());
}
// ─── POST /api/documents/quick-upload ────────────────────────────────────
@Test

View File

@@ -35,6 +35,29 @@ class DocumentServiceTest {
@Mock AnnotationService annotationService;
@InjectMocks DocumentService documentService;
// ─── deleteDocument ───────────────────────────────────────────────────────
@Test
void deleteDocument_deletesById_whenExists() {
UUID id = UUID.randomUUID();
when(documentRepository.existsById(id)).thenReturn(true);
documentService.deleteDocument(id);
verify(documentRepository).deleteById(id);
}
@Test
void deleteDocument_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(documentRepository.existsById(id)).thenReturn(false);
assertThatThrownBy(() -> documentService.deleteDocument(id))
.isInstanceOf(DomainException.class)
.hasMessageContaining(id.toString());
verify(documentRepository, never()).deleteById(any());
}
// ─── getDocumentById ──────────────────────────────────────────────────────
@Test

View File

@@ -24,6 +24,7 @@
"btn_edit": "Bearbeiten",
"btn_create": "Erstellen",
"btn_delete": "Löschen",
"doc_delete_confirm": "Dokument wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"btn_back_to_overview": "Zurück zur Übersicht",
"btn_back": "Zurück",
"btn_back_to_document": "Zurück zum Dokument",

View File

@@ -24,6 +24,7 @@
"btn_edit": "Edit",
"btn_create": "Create",
"btn_delete": "Delete",
"doc_delete_confirm": "Really delete this document? This action cannot be undone.",
"btn_back_to_overview": "Back to overview",
"btn_back": "Back",
"btn_back_to_document": "Back to document",

View File

@@ -24,6 +24,7 @@
"btn_edit": "Editar",
"btn_create": "Crear",
"btn_delete": "Eliminar",
"doc_delete_confirm": "¿Realmente eliminar este documento? Esta acción no se puede deshacer.",
"btn_back_to_overview": "Volver al resumen",
"btn_back": "Volver",
"btn_back_to_document": "Volver al documento",

View File

@@ -58,5 +58,20 @@ export const actions = {
}
throw redirect(303, `/documents/${params.id}`);
},
delete: async ({ params, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
method: 'DELETE'
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { error: getErrorMessage(backendError?.code) });
}
throw redirect(303, '/');
}
};

View File

@@ -17,6 +17,7 @@ let selectedReceivers = $state(doc.receivers ?? []);
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
let dateIso = $state(doc.documentDate ?? '');
let dateDirty = $state(false);
let confirmDelete = $state(false);
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
@@ -244,18 +245,58 @@ function handleDateInput(e: Event) {
<div
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
>
<a
href="/documents/{doc.id}"
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.btn_cancel()}
</a>
<button
type="submit"
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
>
{m.btn_save()}
</button>
<!-- Left: delete -->
<div class="flex items-center gap-3">
{#if confirmDelete}
<span class="font-sans text-sm text-red-700">{m.doc_delete_confirm()}</span>
<button
type="submit"
form="delete-form"
class="rounded bg-red-600 px-4 py-1.5 text-sm font-bold text-white transition-colors hover:bg-red-700"
>
{m.btn_delete()}
</button>
<button
type="button"
onclick={() => (confirmDelete = false)}
class="text-sm text-ink-2 transition-colors hover:text-ink"
>
{m.btn_cancel()}
</button>
{:else}
<button
type="button"
onclick={() => (confirmDelete = true)}
class="flex items-center gap-1.5 text-sm text-ink-3 transition-colors hover:text-red-600"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Delete/Delete-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.btn_delete()}
</button>
{/if}
</div>
<!-- Right: cancel + save -->
<div class="flex items-center gap-4">
<a
href="/documents/{doc.id}"
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.btn_cancel()}
</a>
<button
type="submit"
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
>
{m.btn_save()}
</button>
</div>
</div>
</form>
<form id="delete-form" method="POST" action="?/delete" use:enhance></form>
</div>