From 443f686c205e5768965a37bde880763aa6df2b42 Mon Sep 17 00:00:00 2001 From: Urban Modig Date: Tue, 28 Jul 2026 11:18:13 +0200 Subject: [PATCH] feat: add task editing --- README.md | 8 +- .../main/java/se/rubble/hemhub/task/Task.java | 11 + .../se/rubble/hemhub/task/TaskController.java | 15 + .../se/rubble/hemhub/task/TaskService.java | 66 ++- .../hemhub/task/UpdateTaskDetailsRequest.java | 40 ++ .../hemhub/task/TaskEditingApiTest.java | 245 +++++++++ .../java/se/rubble/hemhub/task/TaskTest.java | 20 + docs/architecture.md | 2 + docs/features/008-task-editing.md | 493 ++++++++++++++++++ docs/roadmap.md | 24 +- frontend/src/App.test.tsx | 241 +++++++++ frontend/src/TaskBoard.tsx | 251 +++++++++ frontend/src/styles.css | 23 + 13 files changed, 1409 insertions(+), 30 deletions(-) create mode 100644 backend/src/main/java/se/rubble/hemhub/task/UpdateTaskDetailsRequest.java create mode 100644 backend/src/test/java/se/rubble/hemhub/task/TaskEditingApiTest.java create mode 100644 docs/features/008-task-editing.md diff --git a/README.md b/README.md index b7389fd..afde909 100644 --- a/README.md +++ b/README.md @@ -10,10 +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, ä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. +att skapa, lista, redigera, 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. +Redigering och radering genomförs först när backend har bekräftat operationen. ## Starta backend diff --git a/backend/src/main/java/se/rubble/hemhub/task/Task.java b/backend/src/main/java/se/rubble/hemhub/task/Task.java index cd4ec86..8a74eba 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/Task.java +++ b/backend/src/main/java/se/rubble/hemhub/task/Task.java @@ -112,6 +112,17 @@ class Task { status = targetStatus; } + void changeDetails(String title, String description, int points) { + if (points < 1 || points > 99) { + throw new InvalidTaskException( + "Poäng måste vara ett heltal mellan 1 och 99."); + } + + this.title = title; + this.description = description; + this.points = points; + } + Instant getCreatedAt() { return createdAt; } 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 9cd904b..0fae3ee 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/TaskController.java +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskController.java @@ -67,6 +67,21 @@ public class TaskController { request.parsedActiveUserId().value()); } + @PutMapping("/{taskId}/details") + public TaskResponse updateDetails( + @PathVariable UUID taskId, + @RequestBody(required = false) UpdateTaskDetailsRequest request) { + if (request == null) { + throw new InvalidTaskException("Requesten måste innehålla uppgiftsdetaljer."); + } + + return taskService.updateDetails( + taskId, + request.parsedTitle(), + request.parsedDescription(), + request.parsedPoints()); + } + @DeleteMapping("/{taskId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable UUID 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 addcaf1..b326e5b 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/TaskService.java +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskService.java @@ -43,23 +43,9 @@ class TaskService { String requestedDescription, Integer requestedPoints, UUID requestedAssigneeId) { - String title = requestedTitle == null ? "" : requestedTitle.trim(); - String description = normalizeDescription(requestedDescription); - - if (title.isEmpty() || codePointLength(title) > 100) { - throw new InvalidTaskException( - "Titeln måste innehålla mellan 1 och 100 tecken."); - } - - if (description != null && codePointLength(description) > 500) { - throw new InvalidTaskException( - "Beskrivningen får innehålla högst 500 tecken."); - } - - if (requestedPoints == null) { - throw new InvalidTaskException( - "Poäng måste vara ett heltal mellan 1 och 99."); - } + String title = validateTitle(requestedTitle); + String description = validateDescription(requestedDescription); + int points = validatePoints(requestedPoints); User assignee = findAssignee(requestedAssigneeId); Task task = new Task( @@ -67,7 +53,7 @@ class TaskService { title, description, TaskStatus.WAITING, - requestedPoints, + points, assignee, Instant.now(clock)); @@ -104,6 +90,22 @@ class TaskService { return TaskResponse.from(task); } + @Transactional + TaskResponse updateDetails( + UUID taskId, + String requestedTitle, + String requestedDescription, + Integer requestedPoints) { + Task task = taskRepository.findOneById(taskId) + .orElseThrow(TaskNotFoundException::new); + String title = validateTitle(requestedTitle); + String description = validateDescription(requestedDescription); + int points = validatePoints(requestedPoints); + + task.changeDetails(title, description, points); + return TaskResponse.from(task); + } + @Transactional void delete(UUID taskId) { Task task = taskRepository.findById(taskId) @@ -120,13 +122,37 @@ class TaskService { .orElseThrow(AssigneeNotFoundException::new); } - private static String normalizeDescription(String requestedDescription) { + private static String validateTitle(String requestedTitle) { + String title = requestedTitle == null ? "" : requestedTitle.trim(); + if (title.isEmpty() || codePointLength(title) > 100) { + throw new InvalidTaskException( + "Titeln måste innehålla mellan 1 och 100 tecken."); + } + return title; + } + + private static String validateDescription(String requestedDescription) { if (requestedDescription == null) { return null; } String description = requestedDescription.trim(); - return description.isEmpty() ? null : description; + if (description.isEmpty()) { + return null; + } + if (codePointLength(description) > 500) { + throw new InvalidTaskException( + "Beskrivningen får innehålla högst 500 tecken."); + } + return description; + } + + private static int validatePoints(Integer requestedPoints) { + if (requestedPoints == null || requestedPoints < 1 || requestedPoints > 99) { + throw new InvalidTaskException( + "Poäng måste vara ett heltal mellan 1 och 99."); + } + return requestedPoints; } private static int codePointLength(String value) { diff --git a/backend/src/main/java/se/rubble/hemhub/task/UpdateTaskDetailsRequest.java b/backend/src/main/java/se/rubble/hemhub/task/UpdateTaskDetailsRequest.java new file mode 100644 index 0000000..9067cd1 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/UpdateTaskDetailsRequest.java @@ -0,0 +1,40 @@ +package se.rubble.hemhub.task; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.JsonNodeType; + +public record UpdateTaskDetailsRequest( + JsonNode title, + JsonNode description, + JsonNode points) { + + String parsedTitle() { + if (title == null || title.getNodeType() != JsonNodeType.STRING) { + throw new InvalidTaskException( + "Titeln måste innehålla mellan 1 och 100 tecken."); + } + return title.stringValue(); + } + + String parsedDescription() { + if (description == null) { + throw new InvalidTaskException("Fältet description måste anges."); + } + if (description.isNull()) { + return null; + } + if (description.getNodeType() != JsonNodeType.STRING) { + throw new InvalidTaskException( + "Beskrivningen får innehålla högst 500 tecken."); + } + return description.stringValue(); + } + + Integer parsedPoints() { + if (points == null || !points.isIntegralNumber() || !points.canConvertToInt()) { + throw new InvalidTaskException( + "Poäng måste vara ett heltal mellan 1 och 99."); + } + return points.intValue(); + } +} diff --git a/backend/src/test/java/se/rubble/hemhub/task/TaskEditingApiTest.java b/backend/src/test/java/se/rubble/hemhub/task/TaskEditingApiTest.java new file mode 100644 index 0000000..dcaaaf3 --- /dev/null +++ b/backend/src/test/java/se/rubble/hemhub/task/TaskEditingApiTest.java @@ -0,0 +1,245 @@ +package se.rubble.hemhub.task; + +import java.time.Instant; +import java.util.UUID; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +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.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +class TaskEditingApiTest { + + private static final Instant CREATED_AT = Instant.parse("2026-07-27T09:00:00Z"); + + @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 updatesDetailsInEveryStatusAndPreservesOtherFields(TaskStatus statusValue) throws Exception { + User assignee = createUser(); + Task task = saveTask(statusValue, assignee, "Före", "Gammal", 3); + + mockMvc.perform(put("/api/tasks/{taskId}/details", task.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": " Efter ", + "description": " Ny beskrivning ", + "points": 7 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(task.getId().toString())) + .andExpect(jsonPath("$.title").value("Efter")) + .andExpect(jsonPath("$.description").value("Ny beskrivning")) + .andExpect(jsonPath("$.points").value(7)) + .andExpect(jsonPath("$.status").value(statusValue.name())) + .andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString())) + .andExpect(jsonPath("$.assignee.name").value(assignee.getName())) + .andExpect(jsonPath("$.createdAt").value(CREATED_AT.toString())); + + Task updated = taskRepository.findOneById(task.getId()).orElseThrow(); + assertEquals(statusValue, updated.getStatus()); + assertEquals(assignee.getId(), updated.getAssignee().getId()); + assertEquals(CREATED_AT, updated.getCreatedAt()); + assertTrue(userRepository.existsById(assignee.getId())); + } + + @Test + void acceptsBoundariesBlankDescriptionAndUnchangedValues() throws Exception { + String title = "å".repeat(100); + String description = "å".repeat(500); + Task task = saveTask(TaskStatus.WAITING, null, title, description, 1); + + updateDetails(task.getId(), title, description, 99) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value(title)) + .andExpect(jsonPath("$.description").value(description)) + .andExpect(jsonPath("$.points").value(99)); + + updateDetails(task.getId(), title, " ", 1) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.description").doesNotExist()) + .andExpect(jsonPath("$.points").value(1)); + + updateDetails(task.getId(), title, null, 1) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.description").doesNotExist()); + } + + @ParameterizedTest(name = "{1}") + @MethodSource("invalidRequests") + void rejectsInvalidOrIncompleteRequests(String request, String description) throws Exception { + Task task = saveTask(TaskStatus.WAITING, null, "Före", null, 3); + + mockMvc.perform(put("/api/tasks/{taskId}/details", task.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TASK")); + } + + @Test + void returnsNotFoundAndKeepsOtherTaskUnchanged() throws Exception { + Task otherTask = saveTask(TaskStatus.COMPLETED, null, "Annan", "Oförändrad", 9); + UUID unknownId = UUID.randomUUID(); + + updateDetails(unknownId, "Ny", null, 4) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TASK_NOT_FOUND")); + + mockMvc.perform(get("/api/tasks")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value(otherTask.getId().toString())) + .andExpect(jsonPath("$[0].title").value("Annan")) + .andExpect(jsonPath("$[0].description").value("Oförändrad")) + .andExpect(jsonPath("$[0].points").value(9)); + } + + @Test + void keepsExistingBadRequestForInvalidUuid() throws Exception { + mockMvc.perform(put("/api/tasks/{taskId}/details", "inte-ett-uuid") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title": "Ny", "description": null, "points": 4} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT")); + } + + private org.springframework.test.web.servlet.ResultActions updateDetails( + UUID taskId, + String title, + String description, + int points) throws Exception { + return mockMvc.perform(put("/api/tasks/{taskId}/details", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": %s, + "description": %s, + "points": %d + } + """.formatted(jsonString(title), jsonString(description), points))); + } + + 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, + String title, + String description, + int points) { + return taskRepository.save(new Task( + UUID.randomUUID(), + title, + description, + statusValue, + points, + assignee, + CREATED_AT)); + } + + private static Stream invalidRequests() { + return Stream.of( + Arguments.of("{}", "alla fält saknas"), + Arguments.of(""" + {"description": null, "points": 1} + """, "titel saknas"), + Arguments.of(""" + {"title": null, "description": null, "points": 1} + """, "titel är null"), + Arguments.of(""" + {"title": " ", "description": null, "points": 1} + """, "titel är blank"), + Arguments.of(""" + {"title": "%s", "description": null, "points": 1} + """.formatted("a".repeat(101)), "titel är för lång"), + Arguments.of(""" + {"title": "Titel", "description": "%s", "points": 1} + """.formatted("a".repeat(501)), "beskrivning är för lång"), + Arguments.of(""" + {"title": "Titel", "points": 1} + """, "beskrivning saknas"), + Arguments.of(""" + {"title": "Titel", "description": null} + """, "poäng saknas"), + Arguments.of(""" + {"title": "Titel", "description": null, "points": null} + """, "poäng är null"), + Arguments.of(""" + {"title": "Titel", "description": null, "points": "7"} + """, "poäng är text"), + Arguments.of(""" + {"title": "Titel", "description": null, "points": 1.5} + """, "poäng är decimal"), + Arguments.of(""" + {"title": "Titel", "description": null, "points": 0} + """, "poäng är noll"), + Arguments.of(""" + {"title": "Titel", "description": null, "points": -1} + """, "poäng är negativ"), + Arguments.of(""" + {"title": "Titel", "description": null, "points": 100} + """, "poäng är för hög")); + } + + private static String jsonString(String value) { + if (value == null) { + return "null"; + } + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} diff --git a/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java b/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java index 7b6d3e5..9b8ca6c 100644 --- a/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java +++ b/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java @@ -5,6 +5,7 @@ import java.util.UUID; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; class TaskTest { @@ -29,6 +30,25 @@ class TaskTest { Instant.parse("2026-07-26T12:00:00Z"))); } + @Test + void changesOnlyEditableDetailsAndProtectsPointsInvariant() { + Task task = taskWithPoints(3); + UUID id = task.getId(); + TaskStatus status = task.getStatus(); + Instant createdAt = task.getCreatedAt(); + + task.changeDetails("Ny titel", "Ny beskrivning", 7); + + assertEquals(id, task.getId()); + assertEquals("Ny titel", task.getTitle()); + assertEquals("Ny beskrivning", task.getDescription()); + assertEquals(7, task.getPoints()); + assertEquals(status, task.getStatus()); + assertEquals(createdAt, task.getCreatedAt()); + assertThrows(InvalidTaskException.class, () -> task.changeDetails("Titel", null, 0)); + assertThrows(InvalidTaskException.class, () -> task.changeDetails("Titel", null, 100)); + } + private Task taskWithPoints(int points) { return new Task( UUID.randomUUID(), diff --git a/docs/architecture.md b/docs/architecture.md index df468fb..6c94c98 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,6 +29,7 @@ dnd-kit-ekosystemets aktuella React-adapter. Den ansvarar för: - hämtning och presentation av användare och uppgifter; - lokalt val av aktiv användare; - formulär för att skapa användare och uppgifter; +- serverbekräftad redigering av uppgifters titel, beskrivning och poäng; - 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; @@ -66,6 +67,7 @@ Aktuella endpoints: - `POST /api/tasks` - `PUT /api/tasks/{taskId}/assignee` - `PUT /api/tasks/{taskId}/status` +- `PUT /api/tasks/{taskId}/details` - `DELETE /api/tasks/{taskId}` ### Databas och migreringar diff --git a/docs/features/008-task-editing.md b/docs/features/008-task-editing.md new file mode 100644 index 0000000..b6f302a --- /dev/null +++ b/docs/features/008-task-editing.md @@ -0,0 +1,493 @@ +# Feature 8 – Redigera uppgift + +## Status + +Implementerad och automatiskt verifierad på feature-branchen. Manuell +browserverifiering och merge till `main` återstår. + +## Bakgrund + +HemHub stödjer skapande, visning, tilldelning, statusändring, drag-and-drop och +permanent radering av uppgifter. + +Det saknas fortfarande möjlighet att korrigera eller uppdatera en befintlig +uppgifts grundläggande innehåll. Feature 8 inför därför redigering av: + +- titel; +- beskrivning; +- poäng. + +Redigering hålls separat från de specialiserade flödena för ansvarig, status +och radering. + +## Mål + +Feature 8 ska: + +- låta användaren öppna en redigeringsdialog från uppgiftskortet; +- låta användaren ändra titel, beskrivning och poäng; +- återanvända samma valideringsregler som vid skapande; +- införa ett avgränsat backend-API för uppgiftens redigerbara detaljfält; +- använda serverbekräftad uppdatering; +- återanvända befintlig låsning och felhantering per task-id; +- fungera tillsammans med status, tilldelning, drag-and-drop och radering; +- behålla uppgiftens kolumn och ordning efter redigering. + +## Omfattning + +Feature 8 omfattar redigering av `title`, `description` och `points`. + +Följande egenskaper ska inte kunna ändras genom redigeringsflödet: + +- `id`; +- `status`; +- `assignee`; +- `createdAt`. + +Ansvarig ska fortsatt ändras genom tilldelningsflödet. Status ska fortsatt +ändras genom status-API:t och drag-and-drop-flödet. + +## Avgränsningar + +Feature 8 ska inte införa: + +- statusändring eller ändring av ansvarig i redigeringsformuläret; +- inline-redigering eller en generell detaljvy; +- deadline, återkommande uppgifter, kategorier, etiketter, kommentarer eller + bilagor; +- status-, poäng- eller versionshistorik eller revisionslogg; +- behörigheter, batchredigering eller autosave; +- realtidsuppdatering mellan browsers; +- en generell formulär- eller modalplattform; +- en ny global state-lösning; +- `updatedAt`; +- en ny åtgärdsmeny på uppgiftskortet. + +## Redigeringsflöde + +Redigering sker i en separat modal med: + +- titel; +- beskrivning; +- poäng; +- knappen `Avbryt`; +- knappen `Spara`; +- ett stängningskryss. + +Inline-redigering direkt på kortet ingår inte. En separat modal väljs eftersom +fälten redigeras som en sammanhållen operation, kortets layout ska förbli +stabil, inline-formulär skulle störa dragytan och ett serverbekräftat +spara-/avbrytflöde kan hanteras isolerat. + +## Initiering från uppgiftskortet + +Varje uppgiftskort får en separat synlig redigeringsknapp bredvid den befintliga +sopkorgsknappen. Den ska: + +- ligga i kortets befintliga åtgärdsområde uppe till höger; +- använda en neutral inline-SVG-ikon; +- ha ungefär samma klickyta som raderingsknappen; +- ha en tillgänglig etikett som identifierar uppgiften, exempelvis + `Redigera Töm diskmaskinen`; +- kunna aktiveras med tangentbord; +- inte initiera drag-and-drop; +- stoppa relevanta pointer-händelser innan de når dragytan; +- vara inaktiverad när samma uppgift har en pågående operation. + +Feature 8 inför ingen åtgärdsmeny. Om kortåtgärder senare flyttas till en meny +ska redigerings-API:t och det underliggande redigeringsflödet kunna behållas. + +## Separat redigeringsmodal + +Redigeringen implementeras som en separat komponent, exempelvis +`EditTaskModal`. Komponenten ska följa samma visuella och beteendemässiga +mönster som den befintliga skapandemodalen. + +Feature 8 kräver inte att skapande- och redigeringsmodalerna slås ihop till en +generell komponent med flera lägen. Mindre gemensamma valideringsfunktioner +eller formulärhjälpare får brytas ut om repositoryts faktiska kod tjänar på +det. + +## Formulärets initiala värden + +När redigeringsmodalen öppnas fylls den med uppgiftens aktuella titel, +beskrivning och poäng. En beskrivning som är `null` visas som tom sträng. + +Titelfältet får initialt fokus. Texten markeras inte automatiskt. Formuläret +baseras på task-versionen i frontend-state när dialogen öppnas. + +Om dialogen stängs utan att spara kastas lokala ändringar. När den öppnas igen +hämtas initialvärdena på nytt från den aktuella uppgiften i frontend-state. +Ingen särskild synkronisering eller versionshantering införs om task-data skulle +ändras medan modalen är öppen. + +## Tillåtna statusar och användare + +Titel, beskrivning och poäng får redigeras i `WAITING`, `IN_PROGRESS` och +`COMPLETED`, oavsett ansvarig eller aktiv browseranvändare. Aktiv användare är +ett lokalt browserval och inte autentisering eller behörighetskontroll. + +Att poäng kan ändras på en slutförd uppgift är accepterat i nuvarande modell +eftersom HemHub ännu saknar poänghistorik. En framtida historikfeature ska +besluta om intjänade poäng använder ett snapshot eller uppgiftens aktuella +poängvärde. + +## Stängningsbeteende + +Innan save-anropet har startat ska redigeringsmodalen kunna stängas med: + +- `Avbryt`; +- Escape; +- klick på modalens bakgrund; +- stängningskrysset. + +Osparade ändringar kastas utan extra bekräftelse. + +Under pågående save-anrop ska samtliga stängningsvägar blockeras: + +- `Avbryt` och `Spara` är inaktiverade; +- stängningskrysset är inaktiverat eller otillgängligt; +- Escape ignoreras; +- klick på bakgrunden ignoreras. + +Modalen ligger kvar öppen tills backend-anropet har slutförts. + +## Validering + +Redigering använder samma valideringsregler och användarmeddelanden som +skapande. Backend är alltid slutlig garant. + +### Titel + +Titeln trimmas, är obligatorisk och får innehålla högst 100 +Unicode-kodpunkter. Tom eller enbart blank titel är ogiltig. + +### Beskrivning + +Beskrivningen trimmas, är valfri och får innehålla högst 500 +Unicode-kodpunkter. Den skickas och lagras som `null` när den är tom efter +trimning. + +### Poäng + +Poäng är obligatoriskt, måste vara ett heltal mellan 1 och 99 och får inte +ersättas med ett backend-defaultvärde. + +Frontend blockerar submit vid tom eller för lång titel, för lång beskrivning, +tomt poängfält, text eller decimaltal samt poäng utanför 1–99. + +## Oförändrad submit + +`Spara` är tillgänglig även när användaren inte har ändrat något. Frontend +skickar ett normalt uppdateringsanrop och backend behandlar samma värden som en +giltig idempotent uppdatering. Ingen dirty-state införs. + +## Backend-API + +Redigering sker genom: + +```http +PUT /api/tasks/{taskId}/details +``` + +Endpointen ändrar endast uppgiftens redigerbara detaljfält. + +### Request + +Requesten innehåller alltid hela den redigerbara uppsättningen: + +```json +{ + "title": "Töm diskmaskinen", + "description": "Ställ in allt i rätt skåp", + "points": 3 +} +``` + +Samtliga tre fält ska finnas. `description` får vara `null`. Backend ska inte +implementera patchsemantik för saknade fält. + +### Lyckad uppdatering + +En lyckad uppdatering ger `200 OK` och hela den uppdaterade uppgiften i samma +task-format som övriga task-operationer. Serverns fullständiga respons är +slutlig sanning. + +### Fel + +- okänd uppgift ger `404 Not Found` och `TASK_NOT_FOUND`; +- ogiltigt task-id använder repositoryts befintliga hantering för ogiltiga + path-parametrar; +- ogiltig titel, beskrivning eller poäng ger `400 Bad Request` med den + befintliga task-valideringen och normalt felkoden `INVALID_TASK`. + +Repositoryts faktiska implementation har företräde. + +## Backendens uppdateringsregler + +Backend hämtar först den befintliga uppgiften och uppdaterar uttryckligen endast +`title`, `description` och `points`. + +Operationen får inte ändra `id`, `status`, `assignee` eller `createdAt`. Den ska +vara transaktionell, tillåten i samtliga statusar, idempotent för samma värden, +inte påverka andra uppgifter och använda samma trimning och normalisering som +skapandeflödet. Ingen `updatedAt` införs. + +## Databas + +Den befintliga task-tabellen innehåller redan titel, beskrivning och poäng. +Feature 8 ska därför inte kräva någon Flyway-migrering. + +Implementation ska verifiera kolumnlängder, `points NOT NULL`, +poängconstrainten 1–99, nullhantering för beskrivning och att övriga kolumner +inte påverkas. Ingen ny kolumn eller relation införs. + +## Frontendens uppdateringsstrategi + +Redigering är serverbekräftad. När användaren trycker `Spara` ska frontend: + +1. validera formuläret; +2. markera uppgiften som upptagen genom låsningen per task-id; +3. behålla kortets tidigare värden och modalen öppen; +4. inaktivera formuläret och samtliga stängningsvägar; +5. skicka `PUT /api/tasks/{taskId}/details`; +6. vid framgång ersätta uppgiften med serverns fullständiga respons på samma + plats i task-listan; +7. stänga modalen och frigöra låsningen. + +Frontend visar inte de redigerade värdena optimistiskt. Ingen rollback behövs +eftersom kortet behåller sina tidigare värden tills servern svarar. + +## Vänteläge och gemensam låsning + +Feature 8 återanvänder den befintliga låsningen per task-id. Under save ska: + +- formulärfält, knappar och stängningsvägar vara inaktiverade; +- kortet ligga kvar i samma kolumn och tonas ned; +- samma uppgift inte kunna dras, ändra status eller ansvarig, raderas, öppnas + för ny redigering eller skicka dubbla save-anrop; +- andra uppgifter förbli interaktiva. + +Ingen separat redigeringslåsning, global vänteläge eller parallell +requesthantering införs. En öppen modal låser inte tasken innan `Spara`. + +## Samspel med befintliga flöden + +Redigeringsknappen använder samma pointer-hantering som raderingsknappen och +startar inte drag-and-drop. + +Drag-and-drop förblir optimistiskt med rollback, medan redigering är +serverbekräftad. Status ändras fortsatt genom statusknappar eller drag-and-drop. +Ansvarig ändras fortsatt genom tilldelningsflödet. Raderingsknappen ligger +bredvid redigeringsikonen. Samtliga flöden delar låsningen per task-id. + +## Kortets ordning och kolumn + +En lyckad redigering ersätter uppgiften på befintlig plats i frontendens +task-lista utan omsortering. Status ändras inte, så kortet ligger normalt kvar i +samma kolumn. Serverns fullständiga task-respons ersätter ändå det lokala +värdet i sin helhet. + +## Felhantering + +### Frontendvalideringsfel + +Vid frontendvalideringsfel skickas inget API-anrop. Modalen och inmatningen +behålls, ett begripligt fel visas och användaren kan korrigera och försöka igen. + +### Vanliga API-fel + +Vid backendvalideringsfel, nätverksfel, serverfel eller oväntad respons ligger +kortet kvar oförändrat. Modalen och inmatningen behålls, vänteläget avslutas, +kontrollerna aktiveras och användaren kan försöka igen eller avbryta. + +Generellt meddelande: + +> Det gick inte att spara ändringarna. Försök igen. + +Frontend använder strukturerad felkod och HTTP-status där relevant och tolkar +inte meddelandetext. + +### `404 TASK_NOT_FOUND` + +Endast kombinationen HTTP `404` och `code === "TASK_NOT_FOUND"` behandlas som +ett inaktuellt lokalt kort. Frontend tar då bort uppgiften, stänger modalen och +frigör låsningen utan generellt redigeringsfel. Andra 404-fel behandlas som +vanliga fel. + +## Frontendtester + +Frontendtesterna ska verifiera beteende och state, inte exakt CSS eller intern +komponentstruktur. De ska minst täcka: + +- redigeringsknapp, inline-SVG, tillgänglig etikett, tangentbordsaktivering och + skydd mot dragstart; +- att en låst uppgift inte kan öppnas; +- rätt uppgift och initialvärden, inklusive `null` som tom beskrivning; +- initialt fokus i titelfältet; +- stängning med `Avbryt`, Escape, bakgrund och kryss; +- att osparade ändringar kastas och aktuell task-data används vid nästa + öppning; +- frontendvalidering av titel, beskrivning och poäng; +- rätt endpoint och fullständigt requestformat med trimmade värden och tom + beskrivning som `null`; +- oförändrad submit; +- serverbekräftat vänteläge, blockerade stängningsvägar, gemensam task-låsning + och blockerade dubbla save-anrop; +- att andra kort förblir interaktiva; +- fullständig serverrespons, bibehållen plats, ordning och kolumn; +- vanliga fel med bevarad modal/inmatning och fungerande återförsök; +- `404 TASK_NOT_FOUND` samt att andra 404-fel behandlas som vanliga fel. + +## Backendtester + +Backendtesterna bör ligga i en separat integrationstestklass, exempelvis +`TaskEditingApiTest`, om det passar repositoryts teststruktur. + +Testerna ska minst täcka: + +- samtidig ändring och trimning av titel, beskrivning och poäng; +- tom beskrivning som `null`; +- gränsvärdena 1/99 poäng, 100 kodpunkter i titel och 500 i beskrivning; +- idempotent uppdatering; +- redigering i samtliga tre statusar och fullständig `200 OK`-respons; +- saknad, null, tom eller för lång titel; +- för lång beskrivning; +- saknat, null, text, decimal eller poäng utanför 1–99; +- saknade fält i det fullständiga requestobjektet; +- okänt task-id och ogiltigt UUID; +- att id, status, ansvarig och `createdAt` bevaras; +- att andra uppgifter och ansvarig användare är oförändrade. + +## Implementerad lösning + +Backend exponerar `PUT /api/tasks/{taskId}/details`. Requestmodellen kräver +`title`, `description` och `points`; explicit `null` är endast tillåtet för +beskrivningen. Service-lagret återanvänder skapandeflödets trimning och +validering och uppdaterar en hämtad entitet genom en avgränsad +`changeDetails`-operation. ID, status, ansvarig och skapandetid bevaras. + +Frontend visar en neutral redigeringsknapp med inline-SVG bredvid +raderingsknappen. Den separata `EditTaskModal` fylls från aktuell task, +fokuserar titeln, validerar fälten och blockerar samtliga stängningsvägar under +save. Uppdateringen är serverbekräftad och återanvänder samma låsning per +task-id som status, tilldelning, drag-and-drop och radering. En fullständig +serverrespons ersätter tasken på dess befintliga plats. Endast ett strukturerat +`404 TASK_NOT_FOUND` tar bort ett inaktuellt lokalt kort. + +Ingen Flyway-migrering behövdes eftersom befintliga kolumner och constraints +täcker de redigerbara fälten. + +## Automatisk verifiering + +- Backendens riktade redigeringstester: 20 passerade. +- Fullständig backendtestsvit: 68 passerade. +- Frontendtester: 61 passerade. +- Frontendens TypeScript-kompilering och produktionsbygge passerade. +- `git diff --check` passerade. + +En verifierad begränsning i den lokala H2-databasen är att `VARCHAR` räknar +UTF-16-kodenheter för vissa tecken utanför BMP. Applikationen validerar enligt +Unicode-kodpunkter, men en titel med 100 sådana astrala tecken kan därför +avvisas av H2-kolumnen. Feature 8 ändrar inte databasschemat; PostgreSQL-målet +ska verifiera denna skillnad när produktionsdatabasen införs. + +## Manuell verifiering + +Följande ska verifieras manuellt: + +1. Redigeringsikonen, klickytan, stilen, etiketten och skyddet mot dragstart. +2. Klick- och tangentbordsöppning av rätt uppgift. +3. Redigering i `WAITING`, `IN_PROGRESS` och `COMPLETED`. +4. Initialvärden, tom beskrivning och initialt fokus. +5. Gränser och fel för titel, beskrivning och poäng. +6. Stängning med `Avbryt`, Escape, bakgrund och kryss samt kastade osparade + ändringar. +7. Oförändrad submit. +8. Fördröjt svar med gamla kortvärden, låst modal och nedtonat kort. +9. Gemensam låsning och fortsatt interaktion med andra kort. +10. Vanligt serverfel, bevarad inmatning och lyckat återförsök. +11. `404 TASK_NOT_FOUND` och annat 404-fel. +12. Bibehållen kolumn, ordning, status och ansvarig. +13. Sparade värden efter omladdning. +14. Desktop, mobil, touch och tangentbordsordning. + +## Dokumentation + +Feature 8 dokumenteras i: + +```text +docs/features/008-task-editing.md +``` + +Vid implementation uppdateras `README.md`, `docs/architecture.md`, +`docs/roadmap.md` och `docs/development.md` när relevant. + +Roadmapen markerar Feature 8 som `Klar` först efter implementation, automatiska +tester, produktionsbygge, manuell verifiering, merge till `main` och slutlig +dokumentationsuppdatering. + +Ett nytt ADR behövs normalt inte. Separat modal, redigeringsikon, +`PUT /api/tasks/{taskId}/details` och serverbekräftad uppdatering är lokala +beslut för Feature 8. + +## Acceptanskriterier + +Feature 8 är klar när: + +- varje kort har en tangentbordsåtkomlig redigeringskontroll som inte startar + drag; +- titel, beskrivning och poäng kan redigeras i en separat modal; +- aktuella värden fylls i, titeln får fokus och `null` beskrivning visas tom; +- redigering fungerar i samtliga statusar utan behörighetsregler; +- skapande och redigering använder samma valideringsregler; +- backend använder `PUT /api/tasks/{taskId}/details` med hela fältuppsättningen; +- samma värden accepteras idempotent; +- endast titel, beskrivning och poäng ändras; +- `200 OK` returnerar hela task-responsen; +- frontend är serverbekräftad och behåller gamla kortvärden under anropet; +- modal och task är låsta under save genom befintlig per-task-låsning; +- andra uppgifter förblir interaktiva; +- serverresponsen ersätter tasken på befintlig plats och kolumn; +- vanliga fel behåller modal och inmatning och kan återförsökas; +- endast `404 TASK_NOT_FOUND` tar bort ett inaktuellt lokalt kort; +- stängningsvägar fungerar före och blockeras under anrop; +- ingen inline-redigering, generell modalplattform, Flyway-migrering eller + `updatedAt` införs; +- automatiska och manuella kontroller genomförs; +- relevant dokumentation uppdateras. + +## Implementationsprinciper + +Före implementation ska Codex läsa repositoryts faktiska: + +```text +AGENTS.md +README.md +docs/architecture.md +docs/development.md +docs/roadmap.md +docs/decisions/ +docs/features/002-task-creation.md +docs/features/003-task-points.md +docs/features/004-task-assignment.md +docs/features/005-task-status.md +docs/features/006-task-drag-and-drop.md +docs/features/007-task-deletion.md +``` + +Codex ska även läsa relevant backendkod, frontendkod och befintliga tester och +särskilt verifiera entitet, controller, service, repository, request/response, +validering, schema, felmodell, task-listans ordning, modal- och kortstruktur, +per-task-låsning samt befintliga status-, tilldelnings-, drag- och deleteflöden. + +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. + +## Relaterade commits + +Fylls i efter implementation och merge. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9924ffb..ca509b2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -34,7 +34,9 @@ Följande statusvärden används: ## Nuvarande läge -Feature 0–7 är klara och finns på `main`. Den aktuella applikationen har: +Feature 0–7 är klara och finns på `main`. Feature 8 är implementerad på sin +feature-branch och inväntar manuell verifiering och merge. 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; @@ -47,15 +49,17 @@ Feature 0–7 är klara och finns på `main`. Den aktuella applikationen har: - 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; +- serverbekräftad redigering av titel, beskrivning och poäng; - 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, deadline eller -återkommande uppgift. Nuvarande användarval är inte autentisering. +`IN_PROGRESS` kräver ansvarig. Det finns ännu ingen deadline eller återkommande +uppgift. Nuvarande användarval är inte autentisering. -**Feature 8 – Redigera uppgift är nästa planerade produktfeature.** +**Feature 8 – Redigera uppgift är pågående. Ingen senare produktfeature utses +som nästa innan Feature 8 har verifierats och mergats.** ## Featureöversikt @@ -69,7 +73,7 @@ uppgiftens status. Alla direkta statusövergångar är tillåtna och | 5 – Statusändring | Klar | 4 | Backendstyrda statusövergångar | | 6 – Drag-and-drop | Klar | 5 | Kortflytt via status-API | | 7 – Radera uppgift | Klar | 2 | Bekräftad permanent radering | -| 8 – Redigera uppgift | Planerad | 3 | Titel, beskrivning och poäng | +| 8 – Redigera uppgift | Pågående | 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 | | 11 – Design av återkommande uppgifter | Planerad | 3–5, 9 | Beslut och plan, ingen produktionskod | @@ -231,7 +235,7 @@ featuren är mergad till `main`. ### Feature 8 – Redigera uppgift -**Status:** Planerad +**Status:** Pågående **Beroenden:** Feature 3 @@ -245,6 +249,12 @@ Featuren ligger efter poäng för att redigeringsflödet ska omfatta den då aktuella uppgiftsmodellen. Ansvarig ska fortsatt ändras genom tilldelningsflödet från Feature 4 och status genom statusflödet från Feature 5. +Den implementerade lösningen använder `PUT /api/tasks/{taskId}/details` och +uppdaterar endast titel, beskrivning och poäng. Frontend använder en separat +redigeringsmodal och serverbekräftad uppdatering genom den gemensamma låsningen +per task-id. Implementation och automatiska kontroller är genomförda på +feature-branchen; manuell browserverifiering och merge återstår. + ### Feature 9 – Deadline **Status:** Planerad @@ -443,6 +453,8 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering. ## Ändringshistorik +- 2026-07-27: Feature 8 implementerades och verifierades automatiskt på + feature-branchen. Manuell verifiering och merge återstår. - 2026-07-27: Feature 7 verifierades och mergades. Permanent, serverbekräftad radering infördes, och Feature 8 blev nästa planerade produktfeature. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 13223d4..4eb1bcf 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -550,6 +550,247 @@ test('statusfel behåller tidigare status och ansvarig och visas på kortet', as expect(within(card).getByText('Ta uppgift')).toBeInTheDocument() }) +test('redigeringsknappen öppnar rätt uppgift med aktuella värden och titelfokus', async () => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = mockUsersAndTasks(users, [tasks[0], tasks[1]]) + render() + + const editButton = await screen.findByRole('button', { name: 'Redigera Dammsuga' }) + expect(editButton.querySelector('svg')).toBeInTheDocument() + fireEvent.pointerDown(editButton) + fireEvent.click(editButton) + + const dialog = screen.getByRole('dialog', { name: 'Redigera uppgift' }) + expect(within(dialog).getByLabelText('Titel')).toHaveValue('Dammsuga') + expect(within(dialog).getByLabelText('Titel')).toHaveFocus() + expect(within(dialog).getByLabelText('Beskrivning (valfri)')).toHaveValue('Bottenvåningen') + expect(within(dialog).getByLabelText('Poäng')).toHaveValue(7) + expect(fetchMock).toHaveBeenCalledTimes(2) + + fireEvent.click(within(dialog).getByRole('button', { name: 'Avbryt' })) + fireEvent.click(screen.getByRole('button', { name: 'Redigera Diska' })) + expect(screen.getByLabelText('Beskrivning (valfri)')).toHaveValue('') +}) + +test('redigeringsmodal stängs normalt och kastar osparade ändringar', async () => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = mockUsersAndTasks(users, [tasks[0]]) + const { container } = render() + const editButton = await screen.findByRole('button', { name: 'Redigera Dammsuga' }) + + fireEvent.click(editButton) + fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Osparad' } }) + fireEvent.click(screen.getByRole('button', { name: 'Stäng' })) + expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument() + + fireEvent.click(editButton) + expect(screen.getByLabelText('Titel')).toHaveValue('Dammsuga') + fireEvent.keyDown(window, { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument() + + fireEvent.click(editButton) + fireEvent.mouseDown(container.querySelector('.modal-backdrop')!) + expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledTimes(2) +}) + +test.each([ + { + field: 'Titel', + value: ' ', + message: 'Titeln måste innehålla mellan 1 och 100 tecken.', + }, + { + field: 'Titel', + value: 'a'.repeat(101), + message: 'Titeln måste innehålla mellan 1 och 100 tecken.', + }, + { + field: 'Beskrivning (valfri)', + value: 'a'.repeat(501), + message: 'Beskrivningen får innehålla högst 500 tecken.', + }, + { + field: 'Poäng', + value: '1.5', + message: 'Poäng måste vara ett heltal mellan 1 och 99.', + }, + { + field: 'Poäng', + value: '', + message: 'Poäng måste vara ett heltal mellan 1 och 99.', + }, + { + field: 'Poäng', + value: '100', + message: 'Poäng måste vara ett heltal mellan 1 och 99.', + }, +])('ogiltig redigeringsdata i $field blockerar API-anrop', async ({ field, value, message }) => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = mockUsersAndTasks(users, [tasks[0]]) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' })) + fireEvent.change(screen.getByLabelText(field), { target: { value } }) + fireEvent.click(screen.getByRole('button', { name: 'Spara' })) + + expect(await screen.findByRole('alert')).toHaveTextContent(message) + expect(fetchMock).toHaveBeenCalledTimes(2) +}) + +test('redigering är serverbekräftad, låser samma task och ersätter hela svaret på samma plats', async () => { + const otherTask = { + ...tasks[0], + id: '00000000-0000-0000-0000-000000000010', + title: 'Putsa fönster', + } + const serverTask = { + ...tasks[0], + title: 'Dammsuga övervåningen', + description: null, + points: 9, + assignee: { id: users[1].id, name: users[1].name }, + } + let resolveEdit!: (response: Response) => void + const editResponse = new Promise((resolve) => { + resolveEdit = 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(editResponse) + const { container } = render() + + fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' })) + fireEvent.change(screen.getByLabelText('Titel'), { + target: { value: ' Dammsuga övervåningen ' }, + }) + fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { + target: { value: ' ' }, + }) + fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '9' } }) + const save = screen.getByRole('button', { name: 'Spara' }) + fireEvent.click(save) + fireEvent.click(save) + + const oldCard = screen.getByText('Dammsuga').closest('article')! + const otherCard = screen.getByText('Putsa fönster').closest('article')! + const dialog = screen.getByRole('dialog', { name: 'Redigera uppgift' }) + expect(oldCard).toHaveAttribute('aria-busy', 'true') + expect(within(oldCard).getByRole('button', { name: 'Redigera Dammsuga' })).toBeDisabled() + expect(within(oldCard).getByRole('button', { name: 'Radera Dammsuga' })).toBeDisabled() + expect(within(oldCard).getByRole('button', { name: 'Påbörja' })).toBeDisabled() + expect( + within(oldCard).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }), + ).toBeDisabled() + expect(dragAndDrop.disabledTaskIds.has(tasks[0].id)).toBe(true) + expect(dragAndDrop.disabledTaskIds.has(otherTask.id)).toBe(false) + expect(within(otherCard).getByRole('button', { name: 'Redigera Putsa fönster' })).toBeEnabled() + expect(within(dialog).getByLabelText('Titel')).toBeDisabled() + expect(within(dialog).getByRole('button', { name: 'Stäng' })).toBeDisabled() + expect(within(dialog).getByRole('button', { name: 'Avbryt' })).toBeDisabled() + expect(within(dialog).getByRole('button', { name: 'Sparar…' })).toBeDisabled() + expect(screen.getByText('Dammsuga')).toBeInTheDocument() + expect(screen.queryByText('Dammsuga övervåningen')).not.toBeInTheDocument() + expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/details`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Dammsuga övervåningen', + description: null, + points: 9, + }), + }) + expect(fetchMock).toHaveBeenCalledTimes(3) + + fireEvent.keyDown(window, { key: 'Escape' }) + fireEvent.mouseDown(container.querySelector('.modal-backdrop')!) + expect(screen.getByRole('dialog', { name: 'Redigera uppgift' })).toBeInTheDocument() + + await act(async () => resolveEdit(jsonResponse(serverTask))) + expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument() + const waitingCards = within(screen.getByRole('region', { name: 'Väntande' })).getAllByRole( + 'article', + ) + expect(within(waitingCards[0]).getByText('Dammsuga övervåningen')).toBeInTheDocument() + expect(within(waitingCards[0]).getByText('Anna')).toBeInTheDocument() + expect(within(waitingCards[1]).getByText('Putsa fönster')).toBeInTheDocument() +}) + +test('oförändrad redigering skickar alla detaljfälten', 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(tasks[0])) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' })) + fireEvent.click(screen.getByRole('button', { name: 'Spara' })) + + await waitFor(() => + expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/details`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Dammsuga', + description: 'Bottenvåningen', + points: 7, + }), + }), + ) +}) + +test('vanligt redigeringsfel behåller inmatning och kan återförsökas', async () => { + const updatedTask = { ...tasks[0], title: 'Ny titel' } + 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(jsonResponse(updatedTask)) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' })) + fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Ny titel' } }) + fireEvent.click(screen.getByRole('button', { name: 'Spara' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('Serverfel') + expect(screen.getByLabelText('Titel')).toHaveValue('Ny titel') + expect(screen.getByRole('button', { name: 'Spara' })).toBeEnabled() + expect(screen.getByText('Dammsuga')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Spara' })) + expect(await screen.findByText('Ny titel')).toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledTimes(4) +}) + +test('endast 404 TASK_NOT_FOUND tar bort ett inaktuellt kort vid redigering', 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: 'Redigera Dammsuga' })) + fireEvent.click(screen.getByRole('button', { name: 'Spara' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Annat fel') + expect(screen.getByText('Dammsuga')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Spara' })) + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Redigera Dammsuga' })).not.toBeInTheDocument(), + ) + expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.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]]) diff --git a/frontend/src/TaskBoard.tsx b/frontend/src/TaskBoard.tsx index f06ec12..9856caa 100644 --- a/frontend/src/TaskBoard.tsx +++ b/frontend/src/TaskBoard.tsx @@ -28,6 +28,12 @@ type ApiError = { message?: string } +type TaskDetails = { + title: string + description: string | null + points: number +} + type TaskBoardProps = { activeUserId: string activeUserName: string @@ -45,6 +51,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 [editingTask, setEditingTask] = useState(null) + const [editError, setEditError] = useState('') const [deletingTask, setDeletingTask] = useState(null) const [deleteError, setDeleteError] = useState('') const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState(null) @@ -197,6 +205,59 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP void updateStatus(task, status, 'optimistic') } + const openEditTask = (task: Task) => { + if (pendingTaskIdsRef.current.has(task.id)) { + return + } + + setEditError('') + setEditingTask(task) + } + + const closeEditTask = () => { + if (editingTask && pendingTaskIdsRef.current.has(editingTask.id)) { + return + } + + setEditError('') + setEditingTask(null) + } + + const updateDetails = async (task: Task, details: TaskDetails) => { + if (!beginTaskRequest(task.id)) { + return + } + + setEditError('') + + try { + const response = await fetch(`/api/tasks/${task.id}/details`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(details), + }) + + if (!response.ok) { + 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)) + setEditingTask(null) + return + } + + setEditError(apiError.message ?? 'Det gick inte att spara ändringarna. Försök igen.') + return + } + + replaceTask((await response.json()) as Task) + setEditingTask(null) + } catch { + setEditError('Det gick inte att spara ändringarna. Försök igen.') + } finally { + finishTaskRequest(task.id) + } + } + const openDeleteTask = (task: Task) => { if (pendingTaskIdsRef.current.has(task.id)) { return @@ -293,6 +354,7 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP onChangeStatus={(task, status) => void updateStatus(task, status, 'server-confirmed') } + onEdit={openEditTask} onDelete={openDeleteTask} /> ))} @@ -310,6 +372,16 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP /> )} + {editingTask && ( + void updateDetails(editingTask, details)} + /> + )} + {deletingTask && ( void onChangeAssignee: (task: Task, assigneeId: string) => void onChangeStatus: (task: Task, status: TaskStatus) => void + onEdit: (task: Task) => void onDelete: (task: Task) => void } @@ -346,6 +419,7 @@ function TaskColumn({ onEditAssignee, onChangeAssignee, onChangeStatus, + onEdit, onDelete, }: TaskColumnProps) { const { ref, isDropTarget } = useTaskColumnDropTarget(column.status) @@ -369,6 +443,7 @@ function TaskColumn({ onEditAssignee={() => onEditAssignee(task.id)} onChangeAssignee={(assigneeId) => onChangeAssignee(task, assigneeId)} onChangeStatus={(status) => onChangeStatus(task, status)} + onEdit={() => onEdit(task)} onDelete={() => onDelete(task)} /> ))} @@ -386,6 +461,7 @@ type TaskCardProps = { onEditAssignee: () => void onChangeAssignee: (assigneeId: string) => void onChangeStatus: (status: TaskStatus) => void + onEdit: () => void onDelete: () => void } @@ -398,6 +474,7 @@ function TaskCard({ onEditAssignee, onChangeAssignee, onChangeStatus, + onEdit, onDelete, }: TaskCardProps) { const { ref, isDragging } = useTaskDraggable(task.id, pending) @@ -415,6 +492,16 @@ function TaskCard({

{task.title}

{task.points} p + +
+
+ + setTitle(event.target.value)} + /> + + +