From 65a6488c0b268f49b1361591f025bdea8d67f754 Mon Sep 17 00:00:00 2001 From: Urban Modig Date: Mon, 27 Jul 2026 00:46:37 +0200 Subject: [PATCH] feat: add task status transitions --- README.md | 2 +- .../hemhub/api/ApiExceptionHandler.java | 19 +- .../task/InvalidTaskStatusException.java | 4 + .../main/java/se/rubble/hemhub/task/Task.java | 19 +- .../task/TaskAssignmentConflictException.java | 4 - .../se/rubble/hemhub/task/TaskController.java | 14 + .../task/TaskRequiresAssigneeException.java | 4 + .../se/rubble/hemhub/task/TaskService.java | 21 +- .../hemhub/task/UpdateTaskStatusRequest.java | 23 ++ .../se/rubble/hemhub/task/TaskApiTest.java | 21 +- .../rubble/hemhub/task/TaskStatusApiTest.java | 255 ++++++++++++++++++ .../java/se/rubble/hemhub/task/TaskTest.java | 14 + docs/architecture.md | 16 +- docs/features/005-task-status.md | 123 +++++++++ docs/roadmap.md | 20 +- frontend/src/App.test.tsx | 220 ++++++++++++++- frontend/src/App.tsx | 9 +- frontend/src/TaskBoard.tsx | 202 ++++++++++---- frontend/src/styles.css | 28 +- 19 files changed, 893 insertions(+), 125 deletions(-) create mode 100644 backend/src/main/java/se/rubble/hemhub/task/InvalidTaskStatusException.java delete mode 100644 backend/src/main/java/se/rubble/hemhub/task/TaskAssignmentConflictException.java create mode 100644 backend/src/main/java/se/rubble/hemhub/task/TaskRequiresAssigneeException.java create mode 100644 backend/src/main/java/se/rubble/hemhub/task/UpdateTaskStatusRequest.java create mode 100644 backend/src/test/java/se/rubble/hemhub/task/TaskStatusApiTest.java create mode 100644 docs/features/005-task-status.md diff --git a/README.md b/README.md index 0fecab7..095952d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 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 och tilldela gemensamma hushållsuppgifter. +att skapa, lista, tilldela och ändra status på gemensamma hushållsuppgifter. ## Starta backend diff --git a/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java b/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java index 0515bd8..d9378ec 100644 --- a/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java +++ b/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java @@ -8,9 +8,10 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import se.rubble.hemhub.task.InvalidTaskException; import se.rubble.hemhub.task.InvalidTaskAssignmentException; +import se.rubble.hemhub.task.InvalidTaskStatusException; import se.rubble.hemhub.task.AssigneeNotFoundException; -import se.rubble.hemhub.task.TaskAssignmentConflictException; import se.rubble.hemhub.task.TaskNotFoundException; +import se.rubble.hemhub.task.TaskRequiresAssigneeException; import se.rubble.hemhub.user.InvalidUserNameException; import se.rubble.hemhub.user.UserNameAlreadyExistsException; @@ -66,11 +67,19 @@ public class ApiExceptionHandler { .body(new ApiError("USER_NOT_FOUND", "Användaren finns inte.")); } - @ExceptionHandler(TaskAssignmentConflictException.class) - public ResponseEntity handleTaskAssignmentConflict() { + @ExceptionHandler(InvalidTaskStatusException.class) + public ResponseEntity handleInvalidTaskStatus() { + return ResponseEntity.badRequest() + .body(new ApiError( + "INVALID_TASK_STATUS", + "Status måste vara WAITING, IN_PROGRESS eller COMPLETED.")); + } + + @ExceptionHandler(TaskRequiresAssigneeException.class) + public ResponseEntity handleTaskRequiresAssignee() { return ResponseEntity.status(HttpStatus.CONFLICT) .body(new ApiError( - "TASK_ASSIGNMENT_NOT_ALLOWED", - "Ansvarig kan endast ändras för väntande uppgifter.")); + "TASK_REQUIRES_ASSIGNEE", + "En pågående uppgift måste ha en ansvarig.")); } } diff --git a/backend/src/main/java/se/rubble/hemhub/task/InvalidTaskStatusException.java b/backend/src/main/java/se/rubble/hemhub/task/InvalidTaskStatusException.java new file mode 100644 index 0000000..42cadc2 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/InvalidTaskStatusException.java @@ -0,0 +1,4 @@ +package se.rubble.hemhub.task; + +public class InvalidTaskStatusException extends RuntimeException { +} 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 52771f3..cd4ec86 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/Task.java +++ b/backend/src/main/java/se/rubble/hemhub/task/Task.java @@ -56,6 +56,9 @@ class Task { throw new InvalidTaskException( "Poäng måste vara ett heltal mellan 1 och 99."); } + if (status == TaskStatus.IN_PROGRESS && assignee == null) { + throw new TaskRequiresAssigneeException(); + } this.id = id; this.title = title; @@ -91,14 +94,22 @@ class Task { } void changeAssignee(User assignee) { - ensureAssignmentChangeAllowed(); + if (status == TaskStatus.IN_PROGRESS && assignee == null) { + throw new TaskRequiresAssigneeException(); + } + this.assignee = assignee; } - void ensureAssignmentChangeAllowed() { - if (status != TaskStatus.WAITING) { - throw new TaskAssignmentConflictException(); + void changeStatus(TaskStatus targetStatus, User automaticAssignee) { + if (targetStatus == TaskStatus.IN_PROGRESS && assignee == null) { + if (automaticAssignee == null) { + throw new TaskRequiresAssigneeException(); + } + assignee = automaticAssignee; } + + status = targetStatus; } Instant getCreatedAt() { diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskAssignmentConflictException.java b/backend/src/main/java/se/rubble/hemhub/task/TaskAssignmentConflictException.java deleted file mode 100644 index 4065080..0000000 --- a/backend/src/main/java/se/rubble/hemhub/task/TaskAssignmentConflictException.java +++ /dev/null @@ -1,4 +0,0 @@ -package se.rubble.hemhub.task; - -public class TaskAssignmentConflictException extends RuntimeException { -} 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 ca1f46d..0ee8fb7 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/TaskController.java +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskController.java @@ -51,4 +51,18 @@ public class TaskController { return taskService.updateAssignee(taskId, request.parsedAssigneeId().value()); } + + @PutMapping("/{taskId}/status") + public TaskResponse updateStatus( + @PathVariable UUID taskId, + @RequestBody(required = false) UpdateTaskStatusRequest request) { + if (request == null) { + throw new InvalidTaskStatusException(); + } + + return taskService.updateStatus( + taskId, + request.parsedStatus(), + request.parsedActiveUserId().value()); + } } diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskRequiresAssigneeException.java b/backend/src/main/java/se/rubble/hemhub/task/TaskRequiresAssigneeException.java new file mode 100644 index 0000000..082b178 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskRequiresAssigneeException.java @@ -0,0 +1,4 @@ +package se.rubble.hemhub.task; + +public class TaskRequiresAssigneeException extends RuntimeException { +} 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 284bb9a..cd713a3 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/TaskService.java +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskService.java @@ -78,13 +78,32 @@ class TaskService { TaskResponse updateAssignee(UUID taskId, UUID requestedAssigneeId) { Task task = taskRepository.findOneById(taskId) .orElseThrow(TaskNotFoundException::new); - task.ensureAssignmentChangeAllowed(); User assignee = findAssignee(requestedAssigneeId); task.changeAssignee(assignee); return TaskResponse.from(task); } + @Transactional + TaskResponse updateStatus( + UUID taskId, + TaskStatus targetStatus, + UUID activeUserId) { + Task task = taskRepository.findOneById(taskId) + .orElseThrow(TaskNotFoundException::new); + + User automaticAssignee = null; + if (targetStatus == TaskStatus.IN_PROGRESS && task.getAssignee() == null) { + if (activeUserId == null) { + throw new TaskRequiresAssigneeException(); + } + automaticAssignee = findAssignee(activeUserId); + } + + task.changeStatus(targetStatus, automaticAssignee); + return TaskResponse.from(task); + } + private User findAssignee(UUID requestedAssigneeId) { if (requestedAssigneeId == null) { return null; diff --git a/backend/src/main/java/se/rubble/hemhub/task/UpdateTaskStatusRequest.java b/backend/src/main/java/se/rubble/hemhub/task/UpdateTaskStatusRequest.java new file mode 100644 index 0000000..0fc37d2 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/UpdateTaskStatusRequest.java @@ -0,0 +1,23 @@ +package se.rubble.hemhub.task; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.JsonNodeType; + +public record UpdateTaskStatusRequest(JsonNode status, JsonNode activeUserId) { + + TaskStatus parsedStatus() { + if (status == null || status.getNodeType() != JsonNodeType.STRING) { + throw new InvalidTaskStatusException(); + } + + try { + return TaskStatus.valueOf(status.stringValue()); + } catch (IllegalArgumentException exception) { + throw new InvalidTaskStatusException(); + } + } + + UUIDValue parsedActiveUserId() { + return UUIDValue.optional(activeUserId); + } +} diff --git a/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java b/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java index 10d961a..9e05710 100644 --- a/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java +++ b/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java @@ -199,7 +199,7 @@ class TaskApiTest { taskRepository.save(new Task( newestId, "Nyast", null, TaskStatus.WAITING, 3, null, newer)); taskRepository.save(new Task( - secondId, "Andra", null, TaskStatus.IN_PROGRESS, 2, null, older)); + secondId, "Andra", null, TaskStatus.WAITING, 2, null, older)); taskRepository.save(new Task( firstId, "Första", null, TaskStatus.COMPLETED, 1, null, older)); @@ -279,25 +279,6 @@ class TaskApiTest { .andExpect(jsonPath("$.code").value("USER_NOT_FOUND")); } - @Test - void rejectsAssigneeChangeForTaskThatIsNotWaiting() throws Exception { - UUID taskId = UUID.randomUUID(); - taskRepository.save(new Task( - taskId, - "Pågående", - null, - TaskStatus.IN_PROGRESS, - 3, - null, - Instant.parse("2026-07-26T12:00:00Z"))); - - updateAssignee(taskId.toString(), """ - {"assigneeId": null} - """) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("TASK_ASSIGNMENT_NOT_ALLOWED")); - } - private ResultActions createTaskWithPoints(int points) throws Exception { return mockMvc.perform(post("/api/tasks") .contentType(MediaType.APPLICATION_JSON) diff --git a/backend/src/test/java/se/rubble/hemhub/task/TaskStatusApiTest.java b/backend/src/test/java/se/rubble/hemhub/task/TaskStatusApiTest.java new file mode 100644 index 0000000..1a266e7 --- /dev/null +++ b/backend/src/test/java/se/rubble/hemhub/task/TaskStatusApiTest.java @@ -0,0 +1,255 @@ +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.CsvSource; +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.ResultActions; +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.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 TaskStatusApiTest { + + private static final Instant CREATED_AT = Instant.parse("2026-07-27T10:15:30Z"); + + @Autowired + private WebApplicationContext context; + + @Autowired + private TaskRepository taskRepository; + + @Autowired + private UserRepository userRepository; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + taskRepository.deleteAll(); + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @ParameterizedTest + @CsvSource({ + "WAITING, IN_PROGRESS", + "WAITING, COMPLETED", + "IN_PROGRESS, WAITING", + "IN_PROGRESS, COMPLETED", + "COMPLETED, WAITING", + "COMPLETED, IN_PROGRESS" + }) + void allowsEveryDirectStatusTransition(TaskStatus initial, TaskStatus target) + throws Exception { + User assignee = createUser(); + Task task = saveTask(initial, assignee); + + updateStatus(task.getId(), target, assignee.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(target.name())) + .andExpect(jsonPath("$.title").value("Dammsuga")) + .andExpect(jsonPath("$.description").value("Bottenvåningen")) + .andExpect(jsonPath("$.points").value(7)) + .andExpect(jsonPath("$.createdAt").value(CREATED_AT.toString())) + .andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString())); + } + + @ParameterizedTest + @EnumSource(TaskStatus.class) + void acceptsCurrentStatusAsIdempotentTarget(TaskStatus statusValue) throws Exception { + User assignee = createUser(); + Task task = saveTask(statusValue, assignee); + + updateStatus(task.getId(), statusValue, null) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(statusValue.name())) + .andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString())); + } + + @Test + void automaticallyAssignsActiveUserWhenUnassignedTaskStarts() throws Exception { + User activeUser = createUser(); + Task task = saveTask(TaskStatus.WAITING, null); + + updateStatus(task.getId(), TaskStatus.IN_PROGRESS, activeUser.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("IN_PROGRESS")) + .andExpect(jsonPath("$.assignee.id").value(activeUser.getId().toString())) + .andExpect(jsonPath("$.assignee.name").value(activeUser.getName())); + } + + @Test + void keepsExistingAssigneeAndDoesNotResolveActiveUser() throws Exception { + User assignee = createUser(); + Task task = saveTask(TaskStatus.WAITING, assignee); + + updateStatus( + task.getId(), + TaskStatus.IN_PROGRESS, + UUID.fromString("00000000-0000-0000-0000-000000000099")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString())); + } + + @Test + void requiresValidActiveUserWhenUnassignedTaskStarts() throws Exception { + Task task = saveTask(TaskStatus.COMPLETED, null); + + updateStatus(task.getId(), TaskStatus.IN_PROGRESS, null) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("TASK_REQUIRES_ASSIGNEE")); + + updateStatus( + task.getId(), + TaskStatus.IN_PROGRESS, + UUID.fromString("00000000-0000-0000-0000-000000000099")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER_NOT_FOUND")); + } + + @ParameterizedTest + @EnumSource(TaskStatus.class) + void allowsAssigningAndChangingAssigneeInEveryStatus(TaskStatus statusValue) + throws Exception { + User first = createUser(); + User second = createUser(); + Task task = saveTask(statusValue, first); + + updateAssignee(task.getId(), second.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(statusValue.name())) + .andExpect(jsonPath("$.assignee.id").value(second.getId().toString())); + } + + @ParameterizedTest + @EnumSource(value = TaskStatus.class, names = {"WAITING", "COMPLETED"}) + void allowsRemovingAssigneeOutsideInProgress(TaskStatus statusValue) throws Exception { + Task task = saveTask(statusValue, createUser()); + + removeAssignee(task.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(statusValue.name())) + .andExpect(jsonPath("$.assignee").value((Object) null)); + } + + @Test + void rejectsRemovingAssigneeFromInProgressTask() throws Exception { + User assignee = createUser(); + Task task = saveTask(TaskStatus.IN_PROGRESS, assignee); + + removeAssignee(task.getId()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("TASK_REQUIRES_ASSIGNEE")) + .andExpect(jsonPath("$.message") + .value("En pågående uppgift måste ha en ansvarig.")); + } + + @Test + void validatesStatusRequestAndTaskId() throws Exception { + Task task = saveTask(TaskStatus.WAITING, null); + + rawStatusUpdate(task.getId().toString(), "{}") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TASK_STATUS")); + rawStatusUpdate(task.getId().toString(), """ + {"status": null} + """) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TASK_STATUS")); + rawStatusUpdate(task.getId().toString(), """ + {"status": "UNKNOWN"} + """) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TASK_STATUS")); + rawStatusUpdate(task.getId().toString(), """ + {"status": "IN_PROGRESS", "activeUserId": "inte-ett-uuid"} + """) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT")); + rawStatusUpdate(task.getId().toString(), "{") + .andExpect(status().isBadRequest()); + rawStatusUpdate("00000000-0000-0000-0000-000000000099", """ + {"status": "WAITING"} + """) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TASK_NOT_FOUND")); + } + + private User createUser() throws Exception { + String name = "U" + UUID.randomUUID(); + String response = mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name": "%s"} + """.formatted(name))) + .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, + CREATED_AT)); + } + + private ResultActions updateStatus( + UUID taskId, + TaskStatus target, + UUID activeUserId) throws Exception { + String activeUserJson = activeUserId == null + ? "" + : ", \"activeUserId\": \"%s\"".formatted(activeUserId); + return rawStatusUpdate( + taskId.toString(), + """ + {"status": "%s"%s} + """.formatted(target.name(), activeUserJson)); + } + + private ResultActions rawStatusUpdate(String taskId, String body) throws Exception { + return mockMvc.perform(put("/api/tasks/{taskId}/status", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(body)); + } + + private ResultActions updateAssignee(UUID taskId, UUID assigneeId) throws Exception { + return mockMvc.perform(put("/api/tasks/{taskId}/assignee", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"assigneeId": "%s"} + """.formatted(assigneeId))); + } + + private ResultActions removeAssignee(UUID taskId) throws Exception { + return mockMvc.perform(put("/api/tasks/{taskId}/assignee", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"assigneeId": null} + """)); + } +} 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 785545d..7b6d3e5 100644 --- a/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java +++ b/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java @@ -15,6 +15,20 @@ class TaskTest { assertThrows(InvalidTaskException.class, () -> taskWithPoints(100)); } + @Test + void rejectsInProgressTaskWithoutAssignee() { + assertThrows( + TaskRequiresAssigneeException.class, + () -> new Task( + UUID.randomUUID(), + "Dammsuga", + null, + TaskStatus.IN_PROGRESS, + 3, + null, + Instant.parse("2026-07-26T12:00:00Z"))); + } + private Task taskWithPoints(int points) { return new Task( UUID.randomUUID(), diff --git a/docs/architecture.md b/docs/architecture.md index 8aae574..9f83243 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,6 +30,7 @@ Den ansvarar för: - lokalt val av aktiv användare; - formulär för att skapa användare och uppgifter; - val och visning av ansvarig användare på uppgifter; +- serverbekräftade statusändringar genom knappar på uppgiftskorten; - klientnära validering och begripliga felmeddelanden; - uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart. @@ -62,6 +63,7 @@ Aktuella endpoints: - `GET /api/tasks` - `POST /api/tasks` - `PUT /api/tasks/{taskId}/assignee` +- `PUT /api/tasks/{taskId}/status` ### Databas och migreringar @@ -117,9 +119,14 @@ ansvarig användare. Relationen hämtas tillsammans med uppgifterna när de list så API-responsen kan innehålla ansvarigs `id` och `name` utan separata frontend-anrop. Alla aktiva användare ser samma uppgiftslista. -Ansvarig är valfri vid skapande. Endast väntande uppgifter kan få ändrad -ansvarig genom det särskilda tilldelnings-API:t. Tilldelning ändrar aldrig -uppgiftens status. +Ansvarig är valfri vid skapande. Tilldelnings-API:t kan tilldela eller byta +ansvarig i samtliga statusar. Ansvarig kan tas bort i `WAITING` och `COMPLETED`, +men inte i `IN_PROGRESS`. Tilldelning ändrar aldrig uppgiftens status. + +Alla direkta statusövergångar är tillåtna och samma målstatus är idempotent. +`IN_PROGRESS` kräver en ansvarig. När en otilldelad uppgift påbörjas skickar +frontend aktiv användares id, och backend tilldelar användaren och ändrar status +i samma transaktion. En befintlig ansvarig byts aldrig av statusoperationen. ### Aktiv användare @@ -140,7 +147,8 @@ otillåtna tilldelningsändringar till `409 Conflict`. Frontend skiljer mellan fel vid hämtning och skapande. Hämtfel kan återförsökas. Formulärfel visas nära formuläret och inmatningen behålls vid -misslyckade API-anrop. +misslyckade API-anrop. Status- och tilldelningsfel visas lokalt på berört kort; +kortet uppdateras först med backendens bekräftade respons. ### Teststrategi diff --git a/docs/features/005-task-status.md b/docs/features/005-task-status.md new file mode 100644 index 0000000..bd2b2b7 --- /dev/null +++ b/docs/features/005-task-status.md @@ -0,0 +1,123 @@ +# Feature 5 – Statusändring och statusregler + +## Status + +Färdig och verifierad på feature-branchen; ännu inte mergad till `main`. + +## Bakgrund + +Efter Feature 4 kan uppgifter vara otilldelade eller ha en ansvarig, men inget +API eller gränssnitt kan ändra status. Feature 5 inför ett enkelt knappflöde +före drag-and-drop och säkerställer statusreglerna i backend. + +## Mål + +- ändra status genom ett särskilt backend-API; +- tillåta direkta övergångar mellan `WAITING`, `IN_PROGRESS` och `COMPLETED`; +- kräva ansvarig för `IN_PROGRESS`; +- automatiskt tilldela aktiv browseranvändare när en otilldelad uppgift + påbörjas; +- tillåta statusberoende ändringar av ansvarig; +- använda serverbekräftade uppdateringar och vänteläge per kort. + +## Status- och tilldelningsregler + +Alla statusar får ändras direkt till varandra. Ett anrop med aktuell status som +mål är giltigt och idempotent. En uppgift behöver inte passera +`IN_PROGRESS` för att bli `COMPLETED`. + +`WAITING` och `COMPLETED` får vara tilldelade eller otilldelade. +`IN_PROGRESS` måste alltid ha en ansvarig. Det befintliga tilldelnings-API:t +kan tilldela eller byta ansvarig i samtliga statusar och ta bort ansvarig i +`WAITING` och `COMPLETED`. Ett försök att ta bort ansvarig i `IN_PROGRESS` +avvisas med `409 TASK_REQUIRES_ASSIGNEE`. Tilldelning ändrar aldrig status. + +När en otilldelad uppgift ändras till `IN_PROGRESS` skickar frontend aktiv +användares id. Backend verifierar användaren, tilldelar den och ändrar status i +samma transaktion. Om uppgiften redan har en ansvarig behålls den, och skickat +`activeUserId` används inte för att byta ansvarig. + +## API-förändringar + +Status ändras med: + +```http +PUT /api/tasks/{taskId}/status +``` + +```json +{ + "status": "IN_PROGRESS", + "activeUserId": "d56b54dd-31b0-4d71-8a10-82464be59a61" +} +``` + +`status` är obligatoriskt. `activeUserId` krävs endast när en otilldelad +uppgift ska bli `IN_PROGRESS`. Responsen använder samma fullständiga +task-format som övriga task-operationer. + +Kända fel använder befintligt format med `code` och `message`: + +- okänd uppgift: `404 TASK_NOT_FOUND`; +- saknad, null eller okänd status: `400 INVALID_TASK_STATUS`; +- okänd användare: `404 USER_NOT_FOUND`; +- otilldelad `IN_PROGRESS`: `409 TASK_REQUIRES_ASSIGNEE`; +- ogiltigt UUID-format: `400` med befintlig requestfelkod. + +`USER_NOT_FOUND` och `INVALID_TASK_ASSIGNMENT` återanvänds från Feature 4 i +stället för att införa parallella felkoder för samma användar-id. + +## Databasförändringar + +Inga. Befintliga kolumner för status och ansvarig är tillräckliga. + +## Frontendförändringar + +Varje kort visar två statusknappar: + +- `WAITING`: `Påbörja`, `Markera klar`; +- `IN_PROGRESS`: `Till Väntande`, `Markera klar`; +- `COMPLETED`: `Till Väntande`, `Påbörja igen`. + +Tilldelningskontrollen är redigerbar i alla statusar. Alternativet `Ingen` +visas inte för `IN_PROGRESS`. + +Status och tilldelning delar ett vänteläge per uppgift. Under ett anrop ligger +kortet kvar i sin kolumn och båda kontrollerna på kortet är inaktiverade. +Övriga kort är fortsatt interaktiva. Vid framgång ersätts uppgiften med +serverresponsen; vid fel behålls tidigare data och felet visas lokalt. + +## Tester och verifiering + +Backendtesterna omfattar samtliga direkta övergångar, idempotens, automatisk +tilldelning, bevarad ansvarig, statusvalidering, okända id:n, statusberoende +tilldelning och oförändrade uppgiftsfält. + +Frontendtesterna omfattar knapparnas statusmappning, requestformat, +serverbekräftad flytt, automatisk tilldelning i responsen, lokalt vänteläge, +dubbelsubmitsskydd, fel utan optimistisk ändring och statusberoende +tilldelningsalternativ. + +Manuell verifiering genomfördes genom ett sammanhängande flöde genom alla tre +statusar, automatisk tilldelning, byte och borttagning av ansvarig, omladdning +och centrala API-fel. Browserflödet verifierades i desktop- och mobilbredd utan +upptäckta problem. + +## Avgränsningar + +Ingen drag-and-drop, radering, generell redigering, sortering, deadline, +återkommande uppgift, status- eller poänghistorik, slutföranderegistrering, +statistik, användaradministration, autentisering eller behörighetskontroll +införs. + +## Kända begränsningar + +Aktiv användare är ett lokalt browserval och inte autentisering. Backend kan +verifiera att id:t finns, men inte vem som faktiskt använder browsern. +Statusknapparna är ett första gränssnitt före Feature 6. Det finns ingen +versionskontroll för konkurrerande uppdateringar utöver transaktioner och +aktuell serverlogik. + +## Relaterade commits + +Fylls i efter implementation och merge. diff --git a/docs/roadmap.md b/docs/roadmap.md index a867d7d..06860a1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -46,12 +46,12 @@ Feature 0–4 är klara. Den aktuella applikationen har: - nya uppgifter som alltid skapas med status `WAITING`. Tilldelning och status är separata egenskaper; tilldelningsflödet ändrar inte -uppgiftens status. Det finns ännu inga statusändringar, drag-and-drop, -redigeringar, raderingar, deadlines eller återkommande uppgifter. +uppgiftens status. Statusändring och regeln att `IN_PROGRESS` kräver ansvarig +är under utveckling. Det finns ännu ingen drag-and-drop, redigering, radering, +deadline eller återkommande uppgift. Nuvarande användarval är inte autentisering. -**Feature 5 – Statusändring och statusregler är nästa planerade -produktfeature.** +**Feature 5 – Statusändring och statusregler är pågående.** ## Featureöversikt @@ -62,7 +62,7 @@ produktfeature.** | 2 – Skapa uppgifter | Klar | 0–1 | Gemensamma uppgifter och trekolumnsbräda | | 3 – Uppgiftspoäng | Klar | 2 | Poäng på uppgifter | | 4 – Tilldelning | Klar | 1–2 | Valfri ansvarig användare | -| 5 – Statusändring | Planerad | 4 | Backendstyrda statusövergångar | +| 5 – Statusändring | Pågående | 4 | Backendstyrda statusövergångar | | 6 – Drag-and-drop | Planerad | 5 | Kortflytt via status-API | | 7 – Radera uppgift | Planerad | 2 | Bekräftad radering | | 8 – Redigera uppgift | Planerad | 3 | Titel, beskrivning och poäng | @@ -152,7 +152,7 @@ senare måste ha en ansvarig. Hur borttagna användare ska hanteras är fortsatt ### Feature 5 – Statusändring och statusregler -**Status:** Planerad +**Status:** Pågående **Beroenden:** Feature 4 @@ -169,11 +169,9 @@ bygga en komplex interaktion. Feature 5 återanvänder Feature 4:s tilldelningsmodell och särskilda API för ansvarig; statusändring sker i ett separat statusflöde. -**Öppna frågor:** - -- vad som sker när en otilldelad uppgift sätts till `IN_PROGRESS`; -- om aktiv användare ska föreslås automatiskt; -- vad som sker om ansvarig tas bort från en pågående uppgift. +En otilldelad uppgift som sätts till `IN_PROGRESS` tilldelas automatiskt den +aktiva browseranvändaren. En befintlig ansvarig behålls. Ansvarig kan bytas men +inte tas bort medan uppgiften är pågående. ### Feature 6 – Drag-and-drop diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e8f4546..dfbfe15 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -174,7 +174,7 @@ test('brädan visar tre kolumner och grupperar hämtade uppgifter', async () => render() await screen.findByText('Dammsuga') - const waiting = screen.getByRole('region', { name: 'Väntande' }) + const waiting = await screen.findByRole('region', { name: 'Väntande' }) const inProgress = screen.getByRole('region', { name: 'Pågående' }) const completed = screen.getByRole('region', { name: 'Klart' }) @@ -280,11 +280,12 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Skapa ny uppgift' })).not.toBeInTheDocument(), ) - const waiting = screen.getByRole('region', { name: 'Väntande' }) - expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([ - 'Dammsuga7 pBottenvåningenTa uppgift', - 'Putsa fönster7 pKöketAnna', - ]) + const waiting = await screen.findByRole('region', { name: 'Väntande' }) + expect( + within(waiting) + .getAllByRole('article') + .map((card) => within(card).getByRole('heading').textContent), + ).toEqual(['Dammsuga', 'Putsa fönster']) expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -332,7 +333,28 @@ test('Ingen skickas som null när en uppgift skapas', async () => { }) }) -test('kort visar ansvarig och endast väntande uppgifter har redigerbar kontroll', async () => { +test('statusanrop byter inte en befintlig ansvarig', async () => { + const assignedWaiting = { + ...tasks[0], + assignee: { id: users[1].id, name: users[1].name }, + } + const updatedTask = { ...assignedWaiting, status: 'IN_PROGRESS' } + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([assignedWaiting])) + fetchMock.mockResolvedValueOnce(jsonResponse(updatedTask)) + render() + + const card = (await screen.findByText('Dammsuga')).closest('article')! + fireEvent.click(within(card).getByRole('button', { name: 'Påbörja' })) + + const inProgress = screen.getByRole('region', { name: 'Pågående' }) + expect(await within(inProgress).findByText('Anna')).toBeInTheDocument() + expect(within(inProgress).queryByText('Urban')).not.toBeInTheDocument() +}) + +test('alla statusar har redigerbar tilldelning med statusberoende alternativ', async () => { window.localStorage.setItem('hemhub.activeUserId', users[0].id) mockUsersAndTasks(users, tasks) render() @@ -347,8 +369,188 @@ test('kort visar ansvarig och endast väntande uppgifter har redigerbar kontroll expect(within(waitingCard!).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' })) .toHaveTextContent('Ta uppgift') expect(within(inProgressCard!).getByText('Anna')).toBeInTheDocument() - expect(within(inProgressCard!).queryByRole('button', { name: /ansvarig/i })).not.toBeInTheDocument() expect(within(completedCard!).getByText('Otilldelad')).toBeInTheDocument() + + fireEvent.click( + within(inProgressCard!).getByRole('button', { name: 'Ändra ansvarig för Diska' }), + ) + expect( + within(inProgressCard!).queryByRole('option', { name: 'Ingen' }), + ).not.toBeInTheDocument() + + fireEvent.click( + within(completedCard!).getByRole('button', { + name: 'Ändra ansvarig för Vattna blommor', + }), + ) + expect(within(completedCard!).getByRole('option', { name: 'Ingen' })).toBeInTheDocument() +}) + +test('visar rätt statusknappar för varje kolumn', async () => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + mockUsersAndTasks(users, tasks) + render() + + const waitingCard = (await screen.findByText('Dammsuga')).closest('article')! + const inProgressCard = screen.getByText('Diska').closest('article')! + const completedCard = screen.getByText('Vattna blommor').closest('article')! + + expect(within(waitingCard).getByRole('button', { name: 'Påbörja' })).toBeInTheDocument() + expect(within(waitingCard).getByRole('button', { name: 'Markera klar' })).toBeInTheDocument() + expect(within(inProgressCard).getByRole('button', { name: 'Till Väntande' })) + .toBeInTheDocument() + expect(within(inProgressCard).getByRole('button', { name: 'Markera klar' })) + .toBeInTheDocument() + expect(within(completedCard).getByRole('button', { name: 'Till Väntande' })) + .toBeInTheDocument() + expect(within(completedCard).getByRole('button', { name: 'Påbörja igen' })) + .toBeInTheDocument() +}) + +test.each([ + { task: tasks[0], button: 'Påbörja', target: 'IN_PROGRESS' }, + { task: tasks[0], button: 'Markera klar', target: 'COMPLETED' }, + { task: tasks[1], button: 'Till Väntande', target: 'WAITING' }, + { task: tasks[1], button: 'Markera klar', target: 'COMPLETED' }, + { task: tasks[2], button: 'Till Väntande', target: 'WAITING' }, + { task: tasks[2], button: 'Påbörja igen', target: 'IN_PROGRESS' }, +])('$button skickar status $target', async ({ task, button, target }) => { + const updatedTask = { + ...task, + status: target, + assignee: + target === 'IN_PROGRESS' && !task.assignee + ? { id: users[0].id, name: users[0].name } + : task.assignee, + } + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([task])) + fetchMock.mockResolvedValueOnce(jsonResponse(updatedTask)) + render() + + const card = (await screen.findByText(task.title)).closest('article')! + fireEvent.click(within(card).getByRole('button', { name: button })) + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)) + expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${task.id}/status`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + status: target, + ...(target === 'IN_PROGRESS' ? { activeUserId: users[0].id } : {}), + }), + }) +}) + +test('status uppdateras först efter serversvar och låser endast berört kort', async () => { + const otherTask = { + ...tasks[0], + id: '00000000-0000-0000-0000-000000000010', + title: 'Putsa fönster', + } + const updatedTask = { + ...tasks[0], + status: 'IN_PROGRESS', + assignee: { id: users[0].id, name: users[0].name }, + } + let resolveStatus!: (response: Response) => void + const statusResponse = new Promise((resolve) => { + resolveStatus = 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(statusResponse) + render() + + const waiting = await screen.findByRole('region', { name: 'Väntande' }) + const card = (await within(waiting).findByText('Dammsuga')).closest('article')! + const otherCard = within(waiting).getByText('Putsa fönster').closest('article')! + const startButton = within(card).getByRole('button', { name: 'Påbörja' }) + fireEvent.click(startButton) + fireEvent.click(startButton) + + expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument() + expect(startButton).toBeDisabled() + expect(within(card).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' })) + .toBeDisabled() + expect(within(otherCard).getByRole('button', { name: 'Påbörja' })).toBeEnabled() + expect(fetchMock).toHaveBeenCalledTimes(3) + + resolveStatus(jsonResponse(updatedTask)) + const inProgress = screen.getByRole('region', { name: 'Pågående' }) + expect(await within(inProgress).findByText('Dammsuga')).toBeInTheDocument() + expect(within(inProgress).getByText('Urban')).toBeInTheDocument() +}) + +test('statusfel behåller tidigare status och ansvarig och visas på kortet', 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: 'TASK_REQUIRES_ASSIGNEE', + message: 'En pågående uppgift måste ha en ansvarig.', + }, + 409, + ), + ) + render() + + const waiting = await screen.findByRole('region', { name: 'Väntande' }) + const card = (await within(waiting).findByText('Dammsuga')).closest('article')! + fireEvent.click(within(card).getByRole('button', { name: 'Påbörja' })) + + expect(await within(card).findByRole('alert')).toHaveTextContent( + 'En pågående uppgift måste ha en ansvarig.', + ) + expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument() + expect(within(card).getByText('Ta uppgift')).toBeInTheDocument() +}) + +test('ansvarig kan bytas i Pågående och tas bort i Klart', async () => { + const changedInProgress = { ...tasks[1], assignee: { id: users[0].id, name: users[0].name } } + const unassignedCompleted = { ...tasks[2], assignee: null } + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([tasks[1], { ...tasks[2], assignee: tasks[1].assignee }])) + fetchMock.mockResolvedValueOnce(jsonResponse(changedInProgress)) + fetchMock.mockResolvedValueOnce(jsonResponse(unassignedCompleted)) + render() + + const inProgressCard = (await screen.findByText('Diska')).closest('article')! + fireEvent.click( + within(inProgressCard).getByRole('button', { name: 'Ändra ansvarig för Diska' }), + ) + fireEvent.change(within(inProgressCard).getByRole('combobox'), { + target: { value: users[0].id }, + }) + await waitFor(() => + expect(within(inProgressCard).getByRole('button', { name: 'Ändra ansvarig för Diska' })) + .toHaveTextContent('Urban'), + ) + + const completedCard = screen.getByText('Vattna blommor').closest('article')! + fireEvent.click( + within(completedCard).getByRole('button', { + name: 'Ändra ansvarig för Vattna blommor', + }), + ) + fireEvent.change(within(completedCard).getByRole('combobox'), { target: { value: '' } }) + + await waitFor(() => + expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[2].id}/assignee`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assigneeId: null }), + }), + ) }) test('val av ansvarig anropar endpointen och uppdaterar kortet efter svar', async () => { @@ -369,6 +571,8 @@ test('val av ansvarig anropar endpointen och uppdaterar kortet efter svar', asyn fireEvent.change(select, { target: { value: users[1].id } }) expect(select).toBeDisabled() + expect(within(select.closest('article')!).getByRole('button', { name: 'Påbörja' })) + .toBeDisabled() expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 38d853b..ba14dec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -79,7 +79,14 @@ function App() { } if (activeUser) { - return + return ( + + ) } if (showCreateUser) { diff --git a/frontend/src/TaskBoard.tsx b/frontend/src/TaskBoard.tsx index 54bf38d..841a3e0 100644 --- a/frontend/src/TaskBoard.tsx +++ b/frontend/src/TaskBoard.tsx @@ -24,6 +24,7 @@ type ApiError = { } type TaskBoardProps = { + activeUserId: string activeUserName: string users: UserSummary[] onLogOut: () => void @@ -35,13 +36,14 @@ const columns: { status: TaskStatus; title: string }[] = [ { status: 'COMPLETED', title: 'Klart' }, ] -function TaskBoard({ activeUserName, users, onLogOut }: TaskBoardProps) { +function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardProps) { const [tasks, setTasks] = useState([]) const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading') const [showCreateTask, setShowCreateTask] = useState(false) const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState(null) - const [savingAssigneeTaskIds, setSavingAssigneeTaskIds] = useState>(new Set()) - const [assignmentErrors, setAssignmentErrors] = useState>({}) + const [pendingTaskIds, setPendingTaskIds] = useState>(new Set()) + const [taskErrors, setTaskErrors] = useState>({}) + const pendingTaskIdsRef = useRef(new Set()) const loadTasks = async () => { setLoadState('loading') @@ -64,13 +66,32 @@ function TaskBoard({ activeUserName, users, onLogOut }: TaskBoardProps) { void loadTasks() }, []) - const updateAssignee = async (task: Task, assigneeId: string) => { - if (savingAssigneeTaskIds.has(task.id)) { - return + const beginTaskRequest = (taskId: string) => { + if (pendingTaskIdsRef.current.has(taskId)) { + return false } - setSavingAssigneeTaskIds((current) => new Set(current).add(task.id)) - setAssignmentErrors((current) => ({ ...current, [task.id]: '' })) + pendingTaskIdsRef.current.add(taskId) + setPendingTaskIds(new Set(pendingTaskIdsRef.current)) + setTaskErrors((current) => ({ ...current, [taskId]: '' })) + return true + } + + const finishTaskRequest = (taskId: string) => { + pendingTaskIdsRef.current.delete(taskId) + setPendingTaskIds(new Set(pendingTaskIdsRef.current)) + } + + const replaceTask = (updatedTask: Task) => { + setTasks((current) => + current.map((task) => (task.id === updatedTask.id ? updatedTask : task)), + ) + } + + const updateAssignee = async (task: Task, assigneeId: string) => { + if (!beginTaskRequest(task.id)) { + return + } try { const response = await fetch(`/api/tasks/${task.id}/assignee`, { @@ -81,7 +102,7 @@ function TaskBoard({ activeUserName, users, onLogOut }: TaskBoardProps) { if (!response.ok) { const apiError = (await response.json().catch(() => ({}))) as ApiError - setAssignmentErrors((current) => ({ + setTaskErrors((current) => ({ ...current, [task.id]: apiError.message ?? 'Det gick inte att ändra ansvarig. Försök igen.', })) @@ -89,21 +110,51 @@ function TaskBoard({ activeUserName, users, onLogOut }: TaskBoardProps) { } const updatedTask = (await response.json()) as Task - setTasks((current) => - current.map((currentTask) => (currentTask.id === updatedTask.id ? updatedTask : currentTask)), - ) + replaceTask(updatedTask) setEditingAssigneeTaskId(null) } catch { - setAssignmentErrors((current) => ({ + setTaskErrors((current) => ({ ...current, [task.id]: 'Det gick inte att ändra ansvarig. Försök igen.', })) } finally { - setSavingAssigneeTaskIds((current) => { - const next = new Set(current) - next.delete(task.id) - return next + finishTaskRequest(task.id) + } + } + + const updateStatus = async (task: Task, status: TaskStatus) => { + if (!beginTaskRequest(task.id)) { + return + } + + try { + const response = await fetch(`/api/tasks/${task.id}/status`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + status, + ...(status === 'IN_PROGRESS' ? { activeUserId } : {}), + }), }) + + if (!response.ok) { + const apiError = (await response.json().catch(() => ({}))) as ApiError + setTaskErrors((current) => ({ + ...current, + [task.id]: apiError.message ?? 'Det gick inte att ändra status. Försök igen.', + })) + return + } + + replaceTask((await response.json()) as Task) + setEditingAssigneeTaskId(null) + } catch { + setTaskErrors((current) => ({ + ...current, + [task.id]: 'Det gick inte att ändra status. Försök igen.', + })) + } finally { + finishTaskRequest(task.id) } } @@ -146,24 +197,37 @@ function TaskBoard({ activeUserName, users, onLogOut }: TaskBoardProps) {
{tasks .filter((task) => task.status === column.status) - .map((task) => ( -
-
-

{task.title}

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

{task.description}

} - setEditingAssigneeTaskId(task.id)} - onChange={(assigneeId) => void updateAssignee(task, assigneeId)} - /> -
- ))} + .map((task) => { + const pending = pendingTaskIds.has(task.id) + + return ( +
+
+

{task.title}

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

{task.description}

} + setEditingAssigneeTaskId(task.id)} + onChange={(assigneeId) => void updateAssignee(task, assigneeId)} + /> + void updateStatus(task, status)} + /> + {taskErrors[task.id] && ( +

+ {taskErrors[task.id]} +

+ )} +
+ ) + })}
))} @@ -187,8 +251,7 @@ type AssigneeControlProps = { task: Task users: UserSummary[] editing: boolean - saving: boolean - error?: string + pending: boolean onEdit: () => void onChange: (assigneeId: string) => void } @@ -218,21 +281,12 @@ function AssigneeControl({ task, users, editing, - saving, - error, + pending, onEdit, onChange, }: AssigneeControlProps) { - const displayName = task.assignee?.name ?? (task.status === 'WAITING' ? 'Ta uppgift' : 'Otilldelad') - - if (task.status !== 'WAITING') { - return ( -
- - {displayName} -
- ) - } + const displayName = + task.assignee?.name ?? (task.status === 'WAITING' ? 'Ta uppgift' : 'Otilldelad') return (
@@ -243,11 +297,11 @@ function AssigneeControl({