From f296d1544687fd1ba9989423d26a7ee7db4f2d0b Mon Sep 17 00:00:00 2001 From: Urban Modig Date: Mon, 27 Jul 2026 21:26:19 +0200 Subject: [PATCH] feat: add task deletion --- README.md | 7 +- .../se/rubble/hemhub/task/TaskController.java | 7 + .../se/rubble/hemhub/task/TaskService.java | 7 + .../hemhub/task/TaskDeletionApiTest.java | 128 +++++ docs/architecture.md | 12 + docs/features/007-task-deletion.md | 442 ++++++++++++++++++ docs/roadmap.md | 26 +- frontend/src/App.test.tsx | 169 ++++++- frontend/src/TaskBoard.tsx | 179 ++++++- frontend/src/styles.css | 51 ++ 10 files changed, 1009 insertions(+), 19 deletions(-) create mode 100644 backend/src/test/java/se/rubble/hemhub/task/TaskDeletionApiTest.java create mode 100644 docs/features/007-task-deletion.md diff --git a/README.md b/README.md index 2aa16bc..b7389fd 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ Backend använder en lokal H2-databas i minnet. Databasschemat hanteras med Flyway, och lokal utvecklingsdata återställs när backend startas om. API:t innehåller endpoints under `/api/users` för användare och `/api/tasks` för -att skapa, lista, tilldela och ändra status på gemensamma hushållsuppgifter. -Uppgiftskort kan flyttas mellan brädans statuskolumner med drag-and-drop eller -med de befintliga statusknapparna. +att skapa, lista, tilldela, ändra status på och permanent radera gemensamma +hushållsuppgifter. Uppgiftskort kan flyttas mellan brädans statuskolumner med +drag-and-drop eller med de befintliga statusknapparna. Radering kräver +bekräftelse och genomförs först när backend har bekräftat operationen. ## Starta backend diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskController.java b/backend/src/main/java/se/rubble/hemhub/task/TaskController.java index 0ee8fb7..9cd904b 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/TaskController.java +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskController.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.UUID; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -65,4 +66,10 @@ public class TaskController { request.parsedStatus(), request.parsedActiveUserId().value()); } + + @DeleteMapping("/{taskId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable UUID taskId) { + taskService.delete(taskId); + } } diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskService.java b/backend/src/main/java/se/rubble/hemhub/task/TaskService.java index cd713a3..addcaf1 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/TaskService.java +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskService.java @@ -104,6 +104,13 @@ class TaskService { return TaskResponse.from(task); } + @Transactional + void delete(UUID taskId) { + Task task = taskRepository.findById(taskId) + .orElseThrow(TaskNotFoundException::new); + taskRepository.delete(task); + } + private User findAssignee(UUID requestedAssigneeId) { if (requestedAssigneeId == null) { return null; diff --git a/backend/src/test/java/se/rubble/hemhub/task/TaskDeletionApiTest.java b/backend/src/test/java/se/rubble/hemhub/task/TaskDeletionApiTest.java new file mode 100644 index 0000000..4de6f0d --- /dev/null +++ b/backend/src/test/java/se/rubble/hemhub/task/TaskDeletionApiTest.java @@ -0,0 +1,128 @@ +package se.rubble.hemhub.task; + +import java.time.Instant; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import se.rubble.hemhub.user.User; +import se.rubble.hemhub.user.UserRepository; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +class TaskDeletionApiTest { + + @Autowired + private WebApplicationContext context; + + @Autowired + private TaskRepository taskRepository; + + @Autowired + private UserRepository userRepository; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + taskRepository.deleteAll(); + userRepository.deleteAll(); + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @ParameterizedTest + @EnumSource(TaskStatus.class) + void deletesTaskInEveryStatus(TaskStatus statusValue) throws Exception { + User assignee = createUser(); + Task task = saveTask(statusValue, assignee); + + mockMvc.perform(delete("/api/tasks/{taskId}", task.getId())) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + + mockMvc.perform(get("/api/tasks")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + org.junit.jupiter.api.Assertions.assertTrue(userRepository.existsById(assignee.getId())); + } + + @Test + void deletesOnlyRequestedTask() throws Exception { + User assignee = createUser(); + Task deleted = saveTask(TaskStatus.WAITING, assignee); + Task remaining = saveTask(TaskStatus.COMPLETED, null); + + mockMvc.perform(delete("/api/tasks/{taskId}", deleted.getId())) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/tasks")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value(remaining.getId().toString())) + .andExpect(jsonPath("$[0].status").value("COMPLETED")); + org.junit.jupiter.api.Assertions.assertTrue(userRepository.existsById(assignee.getId())); + } + + @Test + void returnsNotFoundForUnknownAndAlreadyDeletedTask() throws Exception { + Task task = saveTask(TaskStatus.WAITING, null); + + mockMvc.perform(delete("/api/tasks/{taskId}", task.getId())) + .andExpect(status().isNoContent()); + mockMvc.perform(delete("/api/tasks/{taskId}", task.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TASK_NOT_FOUND")); + mockMvc.perform(delete( + "/api/tasks/{taskId}", + "00000000-0000-0000-0000-000000000099")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TASK_NOT_FOUND")); + } + + @Test + void keepsExistingBadRequestForInvalidUuid() throws Exception { + mockMvc.perform(delete("/api/tasks/{taskId}", "inte-ett-uuid")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT")); + } + + private User createUser() throws Exception { + String response = mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name": "Urban"} + """)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + String id = com.jayway.jsonpath.JsonPath.read(response, "$.id"); + return userRepository.findById(UUID.fromString(id)).orElseThrow(); + } + + private Task saveTask(TaskStatus statusValue, User assignee) { + return taskRepository.save(new Task( + UUID.randomUUID(), + "Dammsuga", + "Bottenvåningen", + statusValue, + 7, + assignee, + Instant.parse("2026-07-28T09:00:00Z"))); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index cca018f..df468fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,6 +32,7 @@ dnd-kit-ekosystemets aktuella React-adapter. Den ansvarar för: - val och visning av ansvarig användare på uppgifter; - serverbekräftade statusändringar genom knappar på uppgiftskorten; - optimistiska statusflyttar genom drag-and-drop mellan brädans kolumner; +- bekräftad och serverbekräftad permanent radering av uppgifter; - klientnära validering och begripliga felmeddelanden; - uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart. @@ -65,6 +66,7 @@ Aktuella endpoints: - `POST /api/tasks` - `PUT /api/tasks/{taskId}/assignee` - `PUT /api/tasks/{taskId}/status` +- `DELETE /api/tasks/{taskId}` ### Databas och migreringar @@ -129,6 +131,10 @@ Alla direkta statusövergångar är tillåtna och samma målstatus är idempoten frontend aktiv användares id, och backend tilldelar användaren och ändrar status i samma transaktion. En befintlig ansvarig byts aldrig av statusoperationen. +Uppgifter raderas fysiskt genom task-repositoryt. Det finns ingen +mjukraderingsflagga, papperskorg eller återställningsmodell. Radering av en +uppgift påverkar inte dess ansvariga användare. + ### Aktiv användare Användarlistan hämtas från backend. Frontend lagrar endast den valda @@ -156,6 +162,12 @@ backendens fullständiga respons. Status- och tilldelningsanrop delar låsning p task-id, så det berörda kortet blockeras utan att resten av brädan låses. Drag-and-drop återanvänder backendens befintliga status-API oförändrat. +Radering är serverbekräftad och använder samma låsning per task-id. Kortet och +bekräftelsedialogen ligger kvar tills backend svarar. Vid `204 No Content` +tas kortet bort lokalt. Ett `404`-svar tas endast som bekräftelse på att kortet +redan saknas när felkoden är `TASK_NOT_FOUND`; övriga fel behåller kortet och +dialogen för ett nytt försök. + ### Teststrategi Backend har JUnit 5-tester: diff --git a/docs/features/007-task-deletion.md b/docs/features/007-task-deletion.md new file mode 100644 index 0000000..b7c3fb1 --- /dev/null +++ b/docs/features/007-task-deletion.md @@ -0,0 +1,442 @@ +# Feature 7 – Radera uppgift + +## Status + +Implementerad och verifierad på feature-branchen. Merge återstår. + +## Bakgrund + +HemHub stödjer skapande, visning, tilldelning, statusändring och drag-and-drop +av uppgifter. Det saknas möjlighet att ta bort uppgifter som inte längre är +relevanta eller som skapats av misstag. + +Feature 7 inför permanent radering av en enskild uppgift. Radering hålls +separat från generell redigering så att det destruktiva flödet, dess +bekräftelse och felhantering kan implementeras och verifieras isolerat. + +## Mål + +Feature 7 ska: + +- införa ett backend-API för permanent radering av en uppgift; +- låta användaren initiera radering från uppgiftskortet; +- kräva en tydlig bekräftelse före radering; +- ta bort kortet från brädan först efter serverbekräftelse; +- återanvända befintlig låsning och felhantering per task-id; +- fungera tillsammans med statusändring, tilldelning och drag-and-drop utan + parallella state- eller requestflöden. + +## Omfattning + +Feature 7 omfattar endast permanent radering av en befintlig uppgift. + +En uppgift får raderas oavsett om dess status är `WAITING`, `IN_PROGRESS` eller +`COMPLETED`. Uppgiftens ansvariga användare och aktiv browseranvändare påverkar +inte möjligheten att radera. Det lokala användarvalet är inte autentisering +eller behörighetskontroll. + +## Avgränsningar + +Feature 7 ska inte införa: + +- mjuk radering, papperskorg, återställning eller undo; +- arkivering, versions-, status- eller poänghistorik; +- generell redigering; +- batchradering eller markering av flera kort; +- radering av användare; +- behörigheter eller ägarskap; +- realtidsuppdatering mellan browsers; +- automatisk gallring; +- persistent kortordning; +- nya relationer till uppgifter; +- generell cascade-logik för framtida modeller. + +## Permanent radering + +Radering är permanent. När användaren har bekräftat raderingen tas uppgiften +bort ur databasen. Ingen `deleted`-flagga, `deletedAt`, dold arkiveringsmodell +eller annan form av mjuk radering införs. + +HemHub är en liten familjeapplikation utan revisionslogg, papperskorg eller +återställningsflöde. En mjukraderingsmodell skulle därför öka komplexiteten +utan ett tydligt nuvarande produktvärde. + +Om framtida features för återkommande uppgifter eller poänghistorik behöver +bevara information efter radering ska deras datamodeller och raderingsregler +beslutas i respektive feature. + +## Tillåtna statusar + +Samtliga uppgifter får raderas oavsett status. Det krävs inte att en +`IN_PROGRESS`-uppgift först flyttas till `WAITING`, och en `COMPLETED`-uppgift +behandlas inte annorlunda än övriga uppgifter. + +Bekräftelseflödet är samma för alla statusar. Ingen extra varning eller +ytterligare bekräftelse införs för pågående uppgifter. + +## Raderingskontroll + +Raderingskontrollen ska visas som en diskret sopkorgsikon direkt på det +befintliga uppgiftskortet, uppe till höger i ett eget åtgärdsområde. Feature 7 +inför inget kompakt eller expanderat kortläge. + +Kontrollen ska: + +- visas på befintliga uppgiftskort; +- ha en tillgänglig etikett som identifierar uppgiften, exempelvis + `Radera Töm diskmaskinen`; +- öppna bekräftelsedialogen; +- inte starta drag-and-drop; +- ha en rimlig klickyta för touch; +- ha neutral stil i normalläge och tydlig hover- och fokusmarkering; +- vara inaktiverad när samma uppgift har en pågående operation. + +Sopkorgen implementeras som inline-SVG enligt projektets befintliga +ikonmönster. Feature 7 lägger inte till något ikonbibliotek. + +Den destruktiva visuella betoningen ska primärt ligga i +bekräftelsedialogen. Kontrollen ska kunna flyttas till en framtida meny eller +detaljdialog utan att backend-API eller delete-flödet behöver göras om. + +## Bekräftelsedialog + +Radering bekräftas i en separat delete-modal. Den ska följa beteendemönstret i +`CreateTaskModal`, men Feature 7 inför ingen gemensam modalkomponent och gör +ingen bred modalrefaktorering. Dialogen ska visa: + +- rubriken `Radera uppgift?`; +- uppgiftens titel; +- tydlig information om att raderingen är permanent; +- knappen `Avbryt`; +- den destruktivt utformade knappen `Radera`. + +Exempel: + +> Är du säker på att du vill radera **Töm diskmaskinen**? Uppgiften raderas +> permanent och kan inte återställas. + +Delete-modalen har inget stängningskryss. Innan delete-anropet har startat ska +den kunna stängas med `Avbryt`, Escape eller klick på bakgrunden. Under +pågående delete-anrop blockeras samtliga stängningsvägar. + +Dialogen ska följa projektets befintliga modalstruktur och fokusprinciper. +`Avbryt` får initialt fokus när dialogen öppnas; den destruktiva knappen +`Radera` får inte initialt fokus. Båda knapparna ska vara +tangentbordsåtkomliga. + +## Backend-API + +Radering sker genom: + +```http +DELETE /api/tasks/{taskId} +``` + +### Lyckad radering + +När uppgiften finns och raderas svarar backend med `204 No Content` utan body. + +### Okänd uppgift + +Om uppgiften inte finns svarar backend med `404 Not Found` och projektets +befintliga felformat: + +```text +TASK_NOT_FOUND +``` + +Det gäller även om samma task-id tidigare har raderats. Ett andra delete-anrop +mot samma id ger därför `404 TASK_NOT_FOUND`. + +### Ogiltigt task-id + +Ett task-id som inte kan tolkas som UUID ger `400 Bad Request` med repositoryts +nuvarande requestfel och felformat. Den befintliga felkoden +`INVALID_TASK_ASSIGNMENT` ändras inte inom Feature 7. Feature 7 inför ingen +separat felmodell för UUID-fel. + +### Konflikter och transaktion + +Radering är tillåten för samtliga statusar och oavsett ansvarig. Feature 7 har +därför inget domänfall som ger `409 Conflict`. + +Raderingen ska ske inom backendens normala transaktionsgräns och endast ta bort +den identifierade uppgiften. Den får inte ändra eller radera ansvarig +användare, andra användare eller andra uppgifter. + +## Databas + +Feature 7 raderar raden permanent ur tabellen `task`. + +Nuvarande datamodell har inga dokumenterade beroendeentiteter som kräver en ny +migrering eller särskild cascade-policy. Den befintliga relationen från +`task.assignee_id` till `app_user.id` ska verifieras så att den inte hindrar +radering av uppgiften. Den ansvariga användaren ska finnas kvar. + +Ingen databasmigrering ska skapas om det faktiska schemat redan stödjer +radering. Framtida relationer till uppgifter får definiera sin delete-policy +när de införs. + +## Frontendens uppdateringsstrategi + +Frontend använder serverbekräftad radering. När användaren bekräftar ska +frontend: + +1. markera uppgiften som upptagen; +2. behålla kortet i dess nuvarande kolumn; +3. behålla bekräftelsedialogen öppen; +4. skicka delete-anropet; +5. vänta på serverns svar; +6. vid `204 No Content` ta bort uppgiften ur den lokala task-listan; +7. stänga dialogen; +8. frigöra låsningen för task-id. + +Kortet ska inte tas bort optimistiskt. Ingen rollback-modell behövs eftersom +kortet ligger kvar under anropet. + +## Vänteläge + +När delete-anropet pågår ska: + +- bekräftelsedialogen ligga kvar öppen; +- `Radera` och `Avbryt` vara inaktiverade; +- Escape och bakgrundsklick inte kunna stänga dialogen; +- kortet ligga kvar i sin kolumn och tonas ned lätt; +- alla interaktiva kontroller på samma kort vara inaktiverade. + +Ingen spinner eller text som `Raderar…` krävs. + +## Låsning och samspel med andra operationer + +Delete ska återanvända den befintliga låsningen per task-id. När uppgiften har +en pågående status-, tilldelnings- eller dragoperation ska radering inte kunna +initieras. + +När delete-anropet pågår ska samma uppgift inte kunna dras, ändra status, ändra +ansvarig, öppna en ny raderingsdialog eller skicka ytterligare delete-anrop. +Andra kort ska förbli interaktiva och kunna ha egna samtidiga operationer. + +Feature 7 inför inget globalt vänteläge, separat delete-lås eller parallell +requestmodell. + +## Felhantering + +### Vanliga delete-fel + +Vid nätverksfel, serverfel eller annat vanligt delete-fel ska: + +- kortet ligga kvar oförändrat; +- dialogen ligga kvar öppen; +- vänteläget avslutas; +- kontrollerna aktiveras igen; +- felmeddelandet + `Det gick inte att radera uppgiften. Försök igen.` visas i dialogen; +- användaren kunna försöka igen eller avbryta. + +Delete-felet ska inte blandas med status- eller tilldelningsfel på kortet. + +### `404 TASK_NOT_FOUND` + +Om backend svarar med `404 TASK_NOT_FOUND` betraktas kortet som inaktuellt. +Frontend ska då ta bort uppgiften ur den lokala task-listan, stänga dialogen +och frigöra låsningen utan att visa det generella delete-felet. + +Frontendens generella `ApiError`-typ utökas med ett valfritt `code`. Delete- +flödet ska använda `code === "TASK_NOT_FOUND"` och status `404` för detta fall +och får inte tolka meddelandetexten. + +Andra typer av `404` ska inte behandlas som en redan borttagen uppgift. + +## Frontendtester + +Frontendtesterna ska minst verifiera: + +- att sopkorgsknappen visas direkt på det befintliga uppgiftskortet; +- att ikonen är inline-SVG och inte kräver ett ikonbibliotek; +- tillgänglig etikett och rätt uppgift i bekräftelsedialogen; +- information om permanent radering; +- initialt fokus på `Avbryt`, aldrig på `Radera`; +- att modalen saknar stängningskryss; +- stängning med `Avbryt`, Escape och bakgrundsklick före anrop; +- `DELETE /api/tasks/{taskId}` först efter bekräftelse; +- att kort och dialog ligger kvar under anropet; +- att dialogen inte kan stängas medan anropet pågår; +- gemensam låsning för status, tilldelning, drag och radering; +- att andra kort förblir interaktiva; +- blockering av dubbla delete-anrop; +- att `204 No Content` tar bort rätt kort och stänger dialogen; +- att vanliga fel behåller kort och dialog samt kan återförsökas; +- att `404 TASK_NOT_FOUND` tar bort det inaktuella kortet; +- att ett annat `404`-fel inte feltolkas som `TASK_NOT_FOUND`; +- att raderingskontrollen inte bryter drag-and-drop; +- grundläggande tangentbordsfokus och knappaktivering. + +Testerna ska verifiera beteende och state, inte exakt ikonplacering, färg eller +pixelmått. + +## Backendtester + +Backendtesterna ska minst verifiera: + +- radering i `WAITING`, `IN_PROGRESS` och `COMPLETED`; +- `204 No Content` utan body; +- att den raderade uppgiften inte längre finns i `GET /api/tasks`; +- att andra uppgifter och den ansvariga användaren finns kvar oförändrade; +- `404 TASK_NOT_FOUND` för okänt id och ett andra delete-anrop; +- projektets befintliga `400`-fel för ogiltigt UUID-format. + +Testerna ska följa repositoryts befintliga integrationsteststil. + +## Manuell verifiering + +Följande ska verifieras manuellt: + +1. Sopkorgsknappens placering uppe till höger i ett eget åtgärdsområde, + neutrala normalläge, touchyta, hover, fokus och tillgängliga etikett. +2. Radering av uppgifter i samtliga tre statusar. +3. Initialt fokus på `Avbryt`, inget stängningskryss samt avbrytande med knapp, + Escape och bakgrundsklick före anrop. +4. Rätt titel och information om permanent radering. +5. Titel nära maximal längd. +6. Blockering av dubbla delete-anrop. +7. Vänteläge för kort och dialog under fördröjt svar. +8. Låsning av drag, status, tilldelning och ny radering för samma kort. +9. Fortsatt interaktion med andra kort. +10. Vanligt serverfel, visat felmeddelande och nytt försök. +11. `404 TASK_NOT_FOUND` och lokal borttagning av inaktuellt kort. +12. Desktop- och mobilbredd samt tangentbordsaktivering. +13. Omladdning efter lyckad radering så att uppgiften inte återkommer. + +## Dokumentation + +Feature 7 dokumenteras i: + +```text +docs/features/007-task-deletion.md +``` + +Vid implementation ska `README.md`, `docs/architecture.md` och +`docs/roadmap.md` uppdateras när det är relevant. + +Roadmapen ska markera Feature 7 som `Klar` först efter implementation, +automatiska tester, manuell verifiering och merge. + +Ett nytt ADR behövs inte för permanent radering. Beslutet gäller den nuvarande +task-livscykeln och etablerar inte en generell raderingspolicy för framtida +entiteter. + +## Acceptanskriterier + +Feature 7 är klar när: + +- en uppgift kan raderas permanent med `DELETE /api/tasks/{taskId}`; +- lyckad radering ger `204 No Content`; +- okänd eller redan raderad uppgift ger `404 TASK_NOT_FOUND`; +- ogiltigt UUID-format följer befintlig felhantering; +- alla tre statusar kan raderas oavsett ansvarig eller aktiv användare; +- radering kräver en egen bekräftelsedialog; +- dialogen visar rätt titel och anger att raderingen inte kan återställas; +- `Avbryt` får initialt fokus och `Radera` får inte initialt fokus; +- modalen saknar stängningskryss och blockerar alla stängningsvägar under + anropet; +- en diskret inline-SVG-sopkorg visas direkt på befintliga uppgiftskort; +- raderingskontrollen startar inte drag; +- kortet tas bort först efter serverbekräftelse; +- samma task-id låses för status, tilldelning, drag och ny radering; +- andra kort förblir interaktiva; +- vanliga fel behåller kort och dialog och kan återförsökas; +- `404 TASK_NOT_FOUND` tar bort det inaktuella lokala kortet; +- ingen mjukradering, återställningsmodell eller onödig migrering införs; +- backend- och frontendtester täcker centrala flöden; +- manuell verifiering genomförs; +- relevant dokumentation uppdateras. + +## Implementerad lösning + +Backendens task-controller och task-service har utökats med fysisk radering via +`DELETE /api/tasks/{taskId}`. Servicen hämtar först uppgiften för att +återanvända `TaskNotFoundException` och raderar därefter entiteten inom en +transaktion. Ingen entitet, exception handler eller Flyway-migrering behövde +ändras. + +Frontendens `TaskBoard` använder samma per-task-lås som status- och +tilldelningsoperationerna. Radering är serverbekräftad: kortet och dialogen +ligger kvar medan anropet pågår, och kortet tas bort först efter `204 No +Content`. Endast ett svar med både status `404` och felkoden +`TASK_NOT_FOUND` tar bort ett känt inaktuellt kort. Övriga fel behåller kortet +och dialogen så att användaren kan försöka igen. + +`TaskCard` har inget kompakt eller expanderat läge. En neutral +inline-SVG-knapp ligger direkt i kortets övre högra åtgärdsområde och stoppar +pointer-händelsen innan den når dragytan. Den separata delete-modalen följer +`CreateTaskModal`-mönstret utan en gemensam modalabstraktion. `Avbryt` får +initialt fokus, modalen saknar stängningskryss och samtliga stängningsvägar +blockeras under delete-anropet. + +Frontendens generella `ApiError` innehåller nu ett valfritt `code`. Den +befintliga backendhanteringen av felaktigt UUID är oförändrad och returnerar +fortsatt `400 INVALID_TASK_ASSIGNMENT`. + +## Tester och verifiering + +Automatiskt verifierat på feature-branchen: + +- backendens fullständiga testsvit: 47 tester passerade; +- frontendens fullständiga testsvit: 49 tester passerade; +- frontendens produktionsbygge och TypeScript-kompilering passerade; +- `git diff --check` passerade. + +Backendtesterna ligger i den separata integrationstestklassen +`TaskDeletionApiTest`. Frontendens delete-flöden testas tillsammans med övriga +brädbeteenden i `App.test.tsx`. + +Manuell browserverifiering genomfördes mot lokalt körande frontend och backend +i Chrome. Följande verifierades: + +- permanent radering och kvarstående borttagning efter omladdning för + `WAITING`, `IN_PROGRESS` och `COMPLETED`; +- lång titel, radbrytning och korrekt uppgiftstitel i dialogen; +- initialt fokus på `Avbryt`, tabb-ordning till `Radera`, Escape och + bakgrundsklick före anrop samt avsaknad av stängningskryss; +- fördröjd delete-respons med kvarvarande och nedtonat kort, öppen låst modal + och inaktiverade stängningsvägar; +- gemensam låsning av drag, status, ansvarig och ny radering för samma kort, + samtidigt som andra kort förblev interaktiva; +- snabbt dubbelklick på `Radera` utan dubbla delete-anrop; +- vanligt serverfel där kort och modal låg kvar, felet visades och ett nytt + försök lyckades; +- `404 TASK_NOT_FOUND`, där det inaktuella kortet togs bort lokalt; +- neutral sopkorgsknapp med 40 × 40 pixlars klickyta, inline-SVG och + pointer-hantering som inte startade drag; +- desktopbredd 1440 × 1000 och mobilbredd 390 × 844 utan horisontell + scrollning. + +Inga problem upptäcktes i Feature 7-flödena. Featuren ska ändå inte markeras +som mergad eller `Klar` i roadmapen förrän merge är genomförd. + +## Relaterade commits + +Fylls i efter commit och merge. + +## Implementationsprinciper + +Före implementation ska Codex läsa: + +```text +AGENTS.md +README.md +docs/architecture.md +docs/development.md +docs/roadmap.md +docs/decisions/ +docs/features/005-task-status.md +docs/features/006-task-drag-and-drop.md +``` + +Codex ska även läsa relevant backendkod, frontendkod och befintliga tester. +Repositoryts faktiska kod, tester och dokumentation har företräde framför +antaganden i detta dokument. + +Implementation, tester och relevant dokumentation ska uppdateras tillsammans. +Codex ska inte committa, pusha, skapa pull request eller merga utan uttrycklig +instruktion. diff --git a/docs/roadmap.md b/docs/roadmap.md index 73ab764..a74dfa5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -34,7 +34,9 @@ Följande statusvärden används: ## Nuvarande läge -Feature 0–6 är klara. Den aktuella applikationen på `main` har: +Feature 0–6 är klara. Feature 7 är implementerad och verifierad på +feature-branchen; merge återstår. Den aktuella applikationen på +feature-branchen har: - ett monorepo med separat React/Vite-frontend och Spring Boot-backend; - centralt lagrade användare och ett lokalt browserval av aktiv användare; @@ -46,16 +48,16 @@ Feature 0–6 är klara. Den aktuella applikationen på `main` har: - backendstyrda statusändringar mellan `WAITING`, `IN_PROGRESS` och `COMPLETED`; - automatisk tilldelning till aktiv användare när en otilldelad uppgift påbörjas; - drag-and-drop mellan statuskolumner med optimistisk flytt och rollback; +- serverbekräftad permanent radering med bekräftelsedialog; - en bräda med Väntande, Pågående och Klart; - nya uppgifter som alltid skapas med status `WAITING`. Tilldelning och status är separata egenskaper; tilldelningsflödet ändrar inte uppgiftens status. Alla direkta statusövergångar är tillåtna och -`IN_PROGRESS` kräver ansvarig. Det finns ännu ingen redigering, radering, -deadline eller återkommande uppgift. Nuvarande användarval är inte -autentisering. +`IN_PROGRESS` kräver ansvarig. Det finns ännu ingen redigering, deadline eller +återkommande uppgift. Nuvarande användarval är inte autentisering. -**Feature 7 – Radera uppgift är nästa planerade produktfeature.** +**Feature 7 – Radera uppgift är pågående tills merge är genomförd.** ## Featureöversikt @@ -68,7 +70,7 @@ autentisering. | 4 – Tilldelning | Klar | 1–2 | Valfri ansvarig användare | | 5 – Statusändring | Klar | 4 | Backendstyrda statusövergångar | | 6 – Drag-and-drop | Klar | 5 | Kortflytt via status-API | -| 7 – Radera uppgift | Planerad | 2 | Bekräftad radering | +| 7 – Radera uppgift | Pågående | 2 | Bekräftad permanent radering | | 8 – Redigera uppgift | Planerad | 3 | Titel, beskrivning och poäng | | 9 – Deadline | Planerad | 2 | Valfri deadline och förseningsmarkering | | 10 – Sökning och filtrering | Planerad | 2; 4 för ansvarig; 9 för deadline | Sökning och filter på brädan | @@ -208,7 +210,7 @@ modellen och statusreglerna finns. ### Feature 7 – Radera uppgift -**Status:** Planerad +**Status:** Pågående **Beroenden:** Feature 2 @@ -221,9 +223,10 @@ modellen och statusreglerna finns. Radering hålls separat från redigering så att databorttagning och dess konsekvenser kan verifieras isolerat. -**Öppen fråga:** - -- permanent radering eller mjuk radering. +Feature 7 använder permanent fysisk radering. Frontend behåller kortet tills +backend har bekräftat raderingen och använder samma låsning per task-id som +status, tilldelning och drag-and-drop. Implementation, automatiska tester och +manuell browserverifiering är färdiga på feature-branchen; merge återstår. ### Feature 8 – Redigera uppgift @@ -429,7 +432,6 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering. ## Öppna tvärgående frågor -- Ska uppgifter raderas permanent eller mjukt? - Hur ska datum, tider och tidszoner representeras? - Ska H2 behållas för lokal utveckling efter PostgreSQL-införandet? - Hur ska användare senare kunna redigeras eller raderas, särskilt när de är @@ -440,6 +442,8 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering. ## Ändringshistorik +- 2026-07-27: Feature 7 implementerades och verifierades på feature-branchen + med permanent, serverbekräftad radering. Merge återstår. - 2026-07-27: Feature 6 verifierades och mergades. Optimistisk drag-and-drop med full rollback infördes, och Feature 7 blev nästa planerade produktfeature. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 201f685..13223d4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -5,6 +5,7 @@ import App from './App' const dragAndDrop = vi.hoisted(() => ({ onTaskDrop: null as ((taskId: string, status: string) => void) | null, + disabledTaskIds: new Set(), })) vi.mock('./TaskDragAndDrop', () => ({ @@ -18,10 +19,18 @@ vi.mock('./TaskDragAndDrop', () => ({ dragAndDrop.onTaskDrop = onTaskDrop return children }, - useTaskDraggable: () => ({ - ref: () => {}, - isDragging: false, - }), + useTaskDraggable: (taskId: string, disabled: boolean) => { + if (disabled) { + dragAndDrop.disabledTaskIds.add(taskId) + } else { + dragAndDrop.disabledTaskIds.delete(taskId) + } + + return { + ref: () => {}, + isDragging: false, + } + }, useTaskColumnDropTarget: () => ({ ref: () => {}, isDropTarget: false, @@ -74,6 +83,7 @@ const tasks = [ beforeEach(() => { window.localStorage.clear() dragAndDrop.onTaskDrop = null + dragAndDrop.disabledTaskIds.clear() }) afterEach(() => { @@ -540,6 +550,153 @@ test('statusfel behåller tidigare status och ansvarig och visas på kortet', as expect(within(card).getByText('Ta uppgift')).toBeInTheDocument() }) +test('sopkorgsknappen öppnar delete-modal med Avbryt i fokus och utan delete-anrop', async () => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = mockUsersAndTasks(users, [tasks[0]]) + render() + + const deleteButton = await screen.findByRole('button', { name: 'Radera Dammsuga' }) + expect(deleteButton.querySelector('svg')).toBeInTheDocument() + fireEvent.pointerDown(deleteButton) + fireEvent.click(deleteButton) + + const dialog = screen.getByRole('dialog', { name: 'Radera uppgift?' }) + expect(within(dialog).getByText('Dammsuga')).toBeInTheDocument() + expect(within(dialog).getByText(/raderas permanent och kan inte återställas/i)) + .toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: 'Avbryt' })).toHaveFocus() + expect(within(dialog).getByRole('button', { name: 'Radera' })).not.toHaveFocus() + expect(within(dialog).queryByRole('button', { name: 'Stäng' })).not.toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledTimes(2) +}) + +test('delete-modal kan stängas med Avbryt, Escape och bakgrundsklick före anrop', async () => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = mockUsersAndTasks(users, [tasks[0]]) + const { container } = render() + + const deleteButton = await screen.findByRole('button', { name: 'Radera Dammsuga' }) + fireEvent.click(deleteButton) + fireEvent.click(screen.getByRole('button', { name: 'Avbryt' })) + expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument() + + fireEvent.click(deleteButton) + fireEvent.keyDown(window, { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument() + + fireEvent.click(deleteButton) + fireEvent.mouseDown(container.querySelector('.modal-backdrop')!) + expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledTimes(2) +}) + +test('delete är serverbekräftad och låser bara det berörda kortet och modalen', async () => { + const otherTask = { + ...tasks[0], + id: '00000000-0000-0000-0000-000000000010', + title: 'Putsa fönster', + } + let resolveDelete!: (response: Response) => void + const deleteResponse = new Promise((resolve) => { + resolveDelete = resolve + }) + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask])) + fetchMock.mockReturnValueOnce(deleteResponse) + const { container } = render() + + const deleteButton = await screen.findByRole('button', { name: 'Radera Dammsuga' }) + fireEvent.click(deleteButton) + const dialog = screen.getByRole('dialog', { name: 'Radera uppgift?' }) + const confirm = within(dialog).getByRole('button', { name: 'Radera' }) + fireEvent.click(confirm) + fireEvent.click(confirm) + + const card = screen.getByRole('button', { name: 'Radera Dammsuga' }).closest('article')! + const otherCard = screen.getByText('Putsa fönster').closest('article')! + expect(card).toBeInTheDocument() + expect(card).toHaveAttribute('aria-busy', 'true') + expect(within(card).getByRole('button', { name: 'Radera Dammsuga' })).toBeDisabled() + expect(dragAndDrop.disabledTaskIds.has(tasks[0].id)).toBe(true) + expect(dragAndDrop.disabledTaskIds.has(otherTask.id)).toBe(false) + expect(within(card).getByRole('button', { name: 'Påbörja' })).toBeDisabled() + expect( + within(card).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }), + ).toBeDisabled() + expect(within(otherCard).getByRole('button', { name: 'Påbörja' })).toBeEnabled() + expect(within(otherCard).getByRole('button', { name: 'Radera Putsa fönster' })).toBeEnabled() + expect(within(dialog).getByRole('button', { name: 'Avbryt' })).toBeDisabled() + expect(confirm).toBeDisabled() + expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}`, { + method: 'DELETE', + }) + expect(fetchMock).toHaveBeenCalledTimes(3) + + fireEvent.keyDown(window, { key: 'Escape' }) + fireEvent.mouseDown(container.querySelector('.modal-backdrop')!) + expect(screen.getByRole('dialog', { name: 'Radera uppgift?' })).toBeInTheDocument() + + await act(async () => resolveDelete(emptyResponse(204))) + expect(screen.queryByRole('button', { name: 'Radera Dammsuga' })).not.toBeInTheDocument() + expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument() + expect(screen.getByText('Putsa fönster')).toBeInTheDocument() +}) + +test('vanligt delete-fel behåller kort och dialog och kan återförsökas', async () => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]])) + fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'Serverfel' }, 500)) + fetchMock.mockResolvedValueOnce(emptyResponse(204)) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Radera Dammsuga' })) + fireEvent.click(screen.getByRole('button', { name: 'Radera' })) + + const dialog = await screen.findByRole('dialog', { name: 'Radera uppgift?' }) + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + 'Det gick inte att radera uppgiften. Försök igen.', + ) + expect(screen.getByRole('button', { name: 'Radera Dammsuga' })).toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: 'Avbryt' })).toBeEnabled() + + fireEvent.click(within(dialog).getByRole('button', { name: 'Radera' })) + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Radera Dammsuga' })).not.toBeInTheDocument(), + ) + expect(fetchMock).toHaveBeenCalledTimes(4) +}) + +test('404 TASK_NOT_FOUND tar bort inaktuellt kort men andra 404-fel gör det inte', async () => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]])) + fetchMock.mockResolvedValueOnce( + jsonResponse({ code: 'OTHER_NOT_FOUND', message: 'Annat fel' }, 404), + ) + fetchMock.mockResolvedValueOnce( + jsonResponse({ code: 'TASK_NOT_FOUND', message: 'Uppgiften finns inte.' }, 404), + ) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Radera Dammsuga' })) + fireEvent.click(screen.getByRole('button', { name: 'Radera' })) + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Det gick inte att radera uppgiften. Försök igen.', + ) + expect(screen.getByRole('button', { name: 'Radera Dammsuga' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Radera' })) + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Radera Dammsuga' })).not.toBeInTheDocument(), + ) + expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument() +}) + test('drag flyttar optimistiskt, låser kortet och använder hela serverresponsen', async () => { const otherTask = { ...tasks[0], @@ -903,3 +1060,7 @@ function jsonResponse(body: unknown, status = 200) { headers: { 'Content-Type': 'application/json' }, }) } + +function emptyResponse(status: number) { + return new Response(null, { status }) +} diff --git a/frontend/src/TaskBoard.tsx b/frontend/src/TaskBoard.tsx index c018762..f06ec12 100644 --- a/frontend/src/TaskBoard.tsx +++ b/frontend/src/TaskBoard.tsx @@ -24,6 +24,7 @@ type Task = { } type ApiError = { + code?: string message?: string } @@ -44,6 +45,8 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP const [tasks, setTasks] = useState([]) const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading') const [showCreateTask, setShowCreateTask] = useState(false) + const [deletingTask, setDeletingTask] = useState(null) + const [deleteError, setDeleteError] = useState('') const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState(null) const [pendingTaskIds, setPendingTaskIds] = useState>(new Set()) const [taskErrors, setTaskErrors] = useState>({}) @@ -194,6 +197,55 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP void updateStatus(task, status, 'optimistic') } + const openDeleteTask = (task: Task) => { + if (pendingTaskIdsRef.current.has(task.id)) { + return + } + + setDeleteError('') + setDeletingTask(task) + } + + const closeDeleteTask = () => { + if (deletingTask && pendingTaskIdsRef.current.has(deletingTask.id)) { + return + } + + setDeleteError('') + setDeletingTask(null) + } + + const deleteTask = async (task: Task) => { + if (!beginTaskRequest(task.id)) { + return + } + + setDeleteError('') + + try { + const response = await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' }) + + if (response.status === 204) { + setTasks((current) => current.filter((candidate) => candidate.id !== task.id)) + setDeletingTask(null) + return + } + + const apiError = (await response.json().catch(() => ({}))) as ApiError + if (response.status === 404 && apiError.code === 'TASK_NOT_FOUND') { + setTasks((current) => current.filter((candidate) => candidate.id !== task.id)) + setDeletingTask(null) + return + } + + setDeleteError('Det gick inte att radera uppgiften. Försök igen.') + } catch { + setDeleteError('Det gick inte att radera uppgiften. Försök igen.') + } finally { + finishTaskRequest(task.id) + } + } + return (
@@ -241,6 +293,7 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP onChangeStatus={(task, status) => void updateStatus(task, status, 'server-confirmed') } + onDelete={openDeleteTask} /> ))} @@ -256,6 +309,16 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP }} /> )} + + {deletingTask && ( + void deleteTask(deletingTask)} + /> + )}
) } @@ -270,6 +333,7 @@ type TaskColumnProps = { onEditAssignee: (taskId: string) => void onChangeAssignee: (task: Task, assigneeId: string) => void onChangeStatus: (task: Task, status: TaskStatus) => void + onDelete: (task: Task) => void } function TaskColumn({ @@ -282,6 +346,7 @@ function TaskColumn({ onEditAssignee, onChangeAssignee, onChangeStatus, + onDelete, }: TaskColumnProps) { const { ref, isDropTarget } = useTaskColumnDropTarget(column.status) @@ -304,6 +369,7 @@ function TaskColumn({ onEditAssignee={() => onEditAssignee(task.id)} onChangeAssignee={(assigneeId) => onChangeAssignee(task, assigneeId)} onChangeStatus={(status) => onChangeStatus(task, status)} + onDelete={() => onDelete(task)} /> ))} @@ -320,6 +386,7 @@ type TaskCardProps = { onEditAssignee: () => void onChangeAssignee: (assigneeId: string) => void onChangeStatus: (status: TaskStatus) => void + onDelete: () => void } function TaskCard({ @@ -331,6 +398,7 @@ function TaskCard({ onEditAssignee, onChangeAssignee, onChangeStatus, + onDelete, }: TaskCardProps) { const { ref, isDragging } = useTaskDraggable(task.id, pending) @@ -345,7 +413,19 @@ function TaskCard({ >

{task.title}

- {task.points} p +
+ {task.points} p + +
{task.description &&

{task.description}

}