From aaebe888f3bae43b3413e9fa3893fec90afac8b4 Mon Sep 17 00:00:00 2001 From: Urban Modig Date: Sun, 26 Jul 2026 22:49:06 +0200 Subject: [PATCH] feat: add task assignment --- README.md | 2 +- .../hemhub/api/ApiExceptionHandler.java | 41 +++- .../task/AssigneeNotFoundException.java | 4 + .../rubble/hemhub/task/CreateTaskRequest.java | 10 +- .../task/InvalidTaskAssignmentException.java | 8 + .../main/java/se/rubble/hemhub/task/Task.java | 25 +++ .../task/TaskAssignmentConflictException.java | 4 + .../se/rubble/hemhub/task/TaskController.java | 20 +- .../hemhub/task/TaskNotFoundException.java | 4 + .../se/rubble/hemhub/task/TaskRepository.java | 6 +- .../se/rubble/hemhub/task/TaskResponse.java | 9 + .../se/rubble/hemhub/task/TaskService.java | 36 ++- .../java/se/rubble/hemhub/task/UUIDValue.java | 25 +++ .../task/UpdateTaskAssigneeRequest.java | 16 ++ .../main/java/se/rubble/hemhub/user/User.java | 6 +- .../se/rubble/hemhub/user/UserRepository.java | 3 +- .../db/migration/V4__add_task_assignee.sql | 6 + .../se/rubble/hemhub/task/TaskApiTest.java | 205 +++++++++++++++++- .../java/se/rubble/hemhub/task/TaskTest.java | 1 + docs/architecture.md | 19 +- docs/features/004-task-assignment.md | 122 +++++++++++ docs/roadmap.md | 22 +- frontend/src/App.test.tsx | 152 ++++++++++++- frontend/src/App.tsx | 2 +- frontend/src/TaskBoard.tsx | 182 +++++++++++++++- frontend/src/styles.css | 73 +++++++ 26 files changed, 964 insertions(+), 39 deletions(-) create mode 100644 backend/src/main/java/se/rubble/hemhub/task/AssigneeNotFoundException.java create mode 100644 backend/src/main/java/se/rubble/hemhub/task/InvalidTaskAssignmentException.java create mode 100644 backend/src/main/java/se/rubble/hemhub/task/TaskAssignmentConflictException.java create mode 100644 backend/src/main/java/se/rubble/hemhub/task/TaskNotFoundException.java create mode 100644 backend/src/main/java/se/rubble/hemhub/task/UUIDValue.java create mode 100644 backend/src/main/java/se/rubble/hemhub/task/UpdateTaskAssigneeRequest.java create mode 100644 backend/src/main/resources/db/migration/V4__add_task_assignee.sql create mode 100644 docs/features/004-task-assignment.md diff --git a/README.md b/README.md index 523a332..0fecab7 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 och lista gemensamma hushållsuppgifter. +att skapa, lista och tilldela 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 c001781..0515bd8 100644 --- a/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java +++ b/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java @@ -2,10 +2,15 @@ package se.rubble.hemhub.api; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import se.rubble.hemhub.task.InvalidTaskException; +import se.rubble.hemhub.task.InvalidTaskAssignmentException; +import se.rubble.hemhub.task.AssigneeNotFoundException; +import se.rubble.hemhub.task.TaskAssignmentConflictException; +import se.rubble.hemhub.task.TaskNotFoundException; import se.rubble.hemhub.user.InvalidUserNameException; import se.rubble.hemhub.user.UserNameAlreadyExistsException; @@ -33,5 +38,39 @@ public class ApiExceptionHandler { return ResponseEntity.badRequest() .body(new ApiError("INVALID_TASK", exception.getMessage())); } -} + @ExceptionHandler(InvalidTaskAssignmentException.class) + public ResponseEntity handleInvalidTaskAssignment( + InvalidTaskAssignmentException exception) { + return ResponseEntity.badRequest() + .body(new ApiError("INVALID_TASK_ASSIGNMENT", exception.getMessage())); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleInvalidPathParameter() { + return ResponseEntity.badRequest() + .body(new ApiError( + "INVALID_TASK_ASSIGNMENT", + "Uppgifts-id måste vara ett giltigt UUID.")); + } + + @ExceptionHandler(TaskNotFoundException.class) + public ResponseEntity handleTaskNotFound() { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ApiError("TASK_NOT_FOUND", "Uppgiften finns inte.")); + } + + @ExceptionHandler(AssigneeNotFoundException.class) + public ResponseEntity handleAssigneeNotFound() { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ApiError("USER_NOT_FOUND", "Användaren finns inte.")); + } + + @ExceptionHandler(TaskAssignmentConflictException.class) + public ResponseEntity handleTaskAssignmentConflict() { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ApiError( + "TASK_ASSIGNMENT_NOT_ALLOWED", + "Ansvarig kan endast ändras för väntande uppgifter.")); + } +} diff --git a/backend/src/main/java/se/rubble/hemhub/task/AssigneeNotFoundException.java b/backend/src/main/java/se/rubble/hemhub/task/AssigneeNotFoundException.java new file mode 100644 index 0000000..261afb7 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/AssigneeNotFoundException.java @@ -0,0 +1,4 @@ +package se.rubble.hemhub.task; + +public class AssigneeNotFoundException extends RuntimeException { +} diff --git a/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java b/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java index 215e7f7..01824d2 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java +++ b/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java @@ -2,7 +2,11 @@ package se.rubble.hemhub.task; import tools.jackson.databind.JsonNode; -public record CreateTaskRequest(String title, String description, JsonNode points) { +public record CreateTaskRequest( + String title, + String description, + JsonNode points, + JsonNode assigneeId) { Integer integerPoints() { if (points == null || !points.isIntegralNumber() || !points.canConvertToInt()) { @@ -11,4 +15,8 @@ public record CreateTaskRequest(String title, String description, JsonNode point return points.intValue(); } + + UUIDValue parsedAssigneeId() { + return UUIDValue.optional(assigneeId); + } } diff --git a/backend/src/main/java/se/rubble/hemhub/task/InvalidTaskAssignmentException.java b/backend/src/main/java/se/rubble/hemhub/task/InvalidTaskAssignmentException.java new file mode 100644 index 0000000..4ba3d25 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/InvalidTaskAssignmentException.java @@ -0,0 +1,8 @@ +package se.rubble.hemhub.task; + +public class InvalidTaskAssignmentException extends RuntimeException { + + public InvalidTaskAssignmentException(String message) { + super(message); + } +} 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 b76d883..52771f3 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/Task.java +++ b/backend/src/main/java/se/rubble/hemhub/task/Task.java @@ -8,7 +8,11 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.FetchType; +import se.rubble.hemhub.user.User; @Entity @Table(name = "task") @@ -30,6 +34,10 @@ class Task { @Column(nullable = false) private int points; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assignee_id") + private User assignee; + @Column(name = "created_at", nullable = false) private Instant createdAt; @@ -42,6 +50,7 @@ class Task { String description, TaskStatus status, int points, + User assignee, Instant createdAt) { if (points < 1 || points > 99) { throw new InvalidTaskException( @@ -53,6 +62,7 @@ class Task { this.description = description; this.status = status; this.points = points; + this.assignee = assignee; this.createdAt = createdAt; } @@ -76,6 +86,21 @@ class Task { return points; } + User getAssignee() { + return assignee; + } + + void changeAssignee(User assignee) { + ensureAssignmentChangeAllowed(); + this.assignee = assignee; + } + + void ensureAssignmentChangeAllowed() { + if (status != TaskStatus.WAITING) { + throw new TaskAssignmentConflictException(); + } + } + Instant getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskAssignmentConflictException.java b/backend/src/main/java/se/rubble/hemhub/task/TaskAssignmentConflictException.java new file mode 100644 index 0000000..4065080 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskAssignmentConflictException.java @@ -0,0 +1,4 @@ +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 07831c0..ca1f46d 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/TaskController.java +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskController.java @@ -1,10 +1,13 @@ package se.rubble.hemhub.task; import java.util.List; +import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; @@ -28,9 +31,24 @@ public class TaskController { @PostMapping @ResponseStatus(HttpStatus.CREATED) public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) { + UUIDValue assigneeId = request == null + ? UUIDValue.optional(null) + : request.parsedAssigneeId(); return taskService.create( request == null ? null : request.title(), request == null ? null : request.description(), - request == null ? null : request.integerPoints()); + request == null ? null : request.integerPoints(), + assigneeId.value()); + } + + @PutMapping("/{taskId}/assignee") + public TaskResponse updateAssignee( + @PathVariable UUID taskId, + @RequestBody(required = false) UpdateTaskAssigneeRequest request) { + if (request == null) { + throw new InvalidTaskAssignmentException("Fältet assigneeId måste anges."); + } + + return taskService.updateAssignee(taskId, request.parsedAssigneeId().value()); } } diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskNotFoundException.java b/backend/src/main/java/se/rubble/hemhub/task/TaskNotFoundException.java new file mode 100644 index 0000000..14770cc --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskNotFoundException.java @@ -0,0 +1,4 @@ +package se.rubble.hemhub.task; + +public class TaskNotFoundException extends RuntimeException { +} diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskRepository.java b/backend/src/main/java/se/rubble/hemhub/task/TaskRepository.java index 167e194..0ec71a4 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/TaskRepository.java +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskRepository.java @@ -4,9 +4,13 @@ import java.util.List; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.EntityGraph; interface TaskRepository extends JpaRepository { + @EntityGraph(attributePaths = "assignee") List findAllByOrderByCreatedAtAscIdAsc(); -} + @EntityGraph(attributePaths = "assignee") + java.util.Optional findOneById(UUID id); +} diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java b/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java index 51f76ee..bed7793 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java @@ -9,6 +9,7 @@ public record TaskResponse( String description, TaskStatus status, int points, + AssigneeResponse assignee, Instant createdAt) { static TaskResponse from(Task task) { @@ -18,6 +19,14 @@ public record TaskResponse( task.getDescription(), task.getStatus(), task.getPoints(), + AssigneeResponse.from(task.getAssignee()), task.getCreatedAt()); } + + public record AssigneeResponse(UUID id, String name) { + + static AssigneeResponse from(se.rubble.hemhub.user.User user) { + return user == null ? null : new AssigneeResponse(user.getId(), user.getName()); + } + } } 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 b5ae1fb..284bb9a 100644 --- a/backend/src/main/java/se/rubble/hemhub/task/TaskService.java +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskService.java @@ -9,19 +9,24 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import se.rubble.hemhub.user.User; +import se.rubble.hemhub.user.UserRepository; + @Service class TaskService { private final TaskRepository taskRepository; + private final UserRepository userRepository; private final Clock clock; @Autowired - TaskService(TaskRepository taskRepository) { - this(taskRepository, Clock.systemUTC()); + TaskService(TaskRepository taskRepository, UserRepository userRepository) { + this(taskRepository, userRepository, Clock.systemUTC()); } - TaskService(TaskRepository taskRepository, Clock clock) { + TaskService(TaskRepository taskRepository, UserRepository userRepository, Clock clock) { this.taskRepository = taskRepository; + this.userRepository = userRepository; this.clock = clock; } @@ -36,7 +41,8 @@ class TaskService { TaskResponse create( String requestedTitle, String requestedDescription, - Integer requestedPoints) { + Integer requestedPoints, + UUID requestedAssigneeId) { String title = requestedTitle == null ? "" : requestedTitle.trim(); String description = normalizeDescription(requestedDescription); @@ -55,17 +61,39 @@ class TaskService { "Poäng måste vara ett heltal mellan 1 och 99."); } + User assignee = findAssignee(requestedAssigneeId); Task task = new Task( UUID.randomUUID(), title, description, TaskStatus.WAITING, requestedPoints, + assignee, Instant.now(clock)); return TaskResponse.from(taskRepository.save(task)); } + @Transactional + 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); + } + + private User findAssignee(UUID requestedAssigneeId) { + if (requestedAssigneeId == null) { + return null; + } + + return userRepository.findById(requestedAssigneeId) + .orElseThrow(AssigneeNotFoundException::new); + } + private static String normalizeDescription(String requestedDescription) { if (requestedDescription == null) { return null; diff --git a/backend/src/main/java/se/rubble/hemhub/task/UUIDValue.java b/backend/src/main/java/se/rubble/hemhub/task/UUIDValue.java new file mode 100644 index 0000000..e73b25e --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/UUIDValue.java @@ -0,0 +1,25 @@ +package se.rubble.hemhub.task; + +import java.util.UUID; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.JsonNodeType; + +record UUIDValue(boolean present, UUID value) { + + static UUIDValue optional(JsonNode node) { + if (node == null || node.isNull()) { + return new UUIDValue(node != null, null); + } + + if (node.getNodeType() != JsonNodeType.STRING) { + throw new InvalidTaskAssignmentException("Användar-id måste vara ett giltigt UUID."); + } + + try { + return new UUIDValue(true, UUID.fromString(node.stringValue())); + } catch (IllegalArgumentException exception) { + throw new InvalidTaskAssignmentException("Användar-id måste vara ett giltigt UUID."); + } + } +} diff --git a/backend/src/main/java/se/rubble/hemhub/task/UpdateTaskAssigneeRequest.java b/backend/src/main/java/se/rubble/hemhub/task/UpdateTaskAssigneeRequest.java new file mode 100644 index 0000000..61fa6db --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/UpdateTaskAssigneeRequest.java @@ -0,0 +1,16 @@ +package se.rubble.hemhub.task; + +import tools.jackson.databind.JsonNode; + +public record UpdateTaskAssigneeRequest(JsonNode assigneeId) { + + UUIDValue parsedAssigneeId() { + UUIDValue parsed = UUIDValue.optional(assigneeId); + + if (!parsed.present()) { + throw new InvalidTaskAssignmentException("Fältet assigneeId måste anges."); + } + + return parsed; + } +} diff --git a/backend/src/main/java/se/rubble/hemhub/user/User.java b/backend/src/main/java/se/rubble/hemhub/user/User.java index ea3298d..46913b8 100644 --- a/backend/src/main/java/se/rubble/hemhub/user/User.java +++ b/backend/src/main/java/se/rubble/hemhub/user/User.java @@ -10,7 +10,7 @@ import jakarta.persistence.Table; @Entity @Table(name = "app_user") -class User { +public class User { @Id private UUID id; @@ -34,11 +34,11 @@ class User { this.createdAt = createdAt; } - UUID getId() { + public UUID getId() { return id; } - String getName() { + public String getName() { return name; } diff --git a/backend/src/main/java/se/rubble/hemhub/user/UserRepository.java b/backend/src/main/java/se/rubble/hemhub/user/UserRepository.java index 013b4a9..3f5fb54 100644 --- a/backend/src/main/java/se/rubble/hemhub/user/UserRepository.java +++ b/backend/src/main/java/se/rubble/hemhub/user/UserRepository.java @@ -4,8 +4,7 @@ import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; -interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository { boolean existsByNormalizedName(String normalizedName); } - diff --git a/backend/src/main/resources/db/migration/V4__add_task_assignee.sql b/backend/src/main/resources/db/migration/V4__add_task_assignee.sql new file mode 100644 index 0000000..d04d187 --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__add_task_assignee.sql @@ -0,0 +1,6 @@ +ALTER TABLE task + ADD COLUMN assignee_id UUID; + +ALTER TABLE task + ADD CONSTRAINT fk_task_assignee + FOREIGN KEY (assignee_id) REFERENCES app_user (id); 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 3247c7e..10d961a 100644 --- a/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java +++ b/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java @@ -15,6 +15,7 @@ import org.springframework.web.context.WebApplicationContext; 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; @@ -52,6 +53,7 @@ class TaskApiTest { .andExpect(jsonPath("$.description").value("Bottenvåningen")) .andExpect(jsonPath("$.status").value("WAITING")) .andExpect(jsonPath("$.points").value(7)) + .andExpect(jsonPath("$.assignee").value((Object) null)) .andExpect(jsonPath("$.createdAt").isString()); mockMvc.perform(get("/api/tasks")) @@ -59,6 +61,67 @@ class TaskApiTest { .andExpect(jsonPath("$[0].points").value(7)); } + @Test + void createsUnassignedTaskWhenAssigneeIsExplicitlyNull() throws Exception { + mockMvc.perform(post("/api/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title": "Dammsuga", "points": 1, "assigneeId": null} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.assignee").value((Object) null)); + } + + @Test + void createsAndListsTaskWithAssignee() throws Exception { + UUID userId = createUser("Anna"); + + mockMvc.perform(post("/api/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "Dammsuga", + "points": 7, + "assigneeId": "%s" + } + """.formatted(userId))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.assignee.id").value(userId.toString())) + .andExpect(jsonPath("$.assignee.name").value("Anna")); + + mockMvc.perform(get("/api/tasks")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].assignee.id").value(userId.toString())) + .andExpect(jsonPath("$[0].assignee.name").value("Anna")); + } + + @Test + void rejectsUnknownAssigneeWhenCreatingTask() throws Exception { + mockMvc.perform(post("/api/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "Dammsuga", + "points": 7, + "assigneeId": "00000000-0000-0000-0000-000000000099" + } + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER_NOT_FOUND")); + + mockMvc.perform(post("/api/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "Dammsuga", + "points": 7, + "assigneeId": "inte-ett-uuid" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT")); + } + @Test void storesBlankDescriptionAsNull() throws Exception { mockMvc.perform(post("/api/tasks") @@ -133,9 +196,12 @@ class TaskApiTest { UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002"); UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003"); - taskRepository.save(new Task(newestId, "Nyast", null, TaskStatus.WAITING, 3, newer)); - taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, 2, older)); - taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, 1, older)); + 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)); + taskRepository.save(new Task( + firstId, "Första", null, TaskStatus.COMPLETED, 1, null, older)); mockMvc.perform(get("/api/tasks")) .andExpect(status().isOk()) @@ -145,6 +211,93 @@ class TaskApiTest { .andExpect(jsonPath("$[2].title").value("Nyast")); } + @Test + void assignsChangesAndRemovesAssigneeWithoutChangingOtherTaskFields() throws Exception { + UUID firstUserId = createUser("Bo"); + UUID secondUserId = createUser("Cecilia"); + String taskId = createTask("Dammsuga", "Bottenvåningen", 7, null); + + updateAssignee(taskId, """ + {"assigneeId": "%s"} + """.formatted(firstUserId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.assignee.id").value(firstUserId.toString())) + .andExpect(jsonPath("$.status").value("WAITING")) + .andExpect(jsonPath("$.title").value("Dammsuga")) + .andExpect(jsonPath("$.description").value("Bottenvåningen")) + .andExpect(jsonPath("$.points").value(7)); + + updateAssignee(taskId, """ + {"assigneeId": "%s"} + """.formatted(secondUserId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.assignee.id").value(secondUserId.toString())); + + updateAssignee(taskId, """ + {"assigneeId": null} + """) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.assignee").value((Object) null)) + .andExpect(jsonPath("$.status").value("WAITING")) + .andExpect(jsonPath("$.title").value("Dammsuga")) + .andExpect(jsonPath("$.description").value("Bottenvåningen")) + .andExpect(jsonPath("$.points").value(7)); + } + + @Test + void rejectsMissingAssigneeFieldAndInvalidUuid() throws Exception { + String taskId = createTask("Dammsuga", null, 1, null); + + updateAssignee(taskId, "{}") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT")); + updateAssignee(taskId, """ + {"assigneeId": "inte-ett-uuid"} + """) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT")); + updateAssignee("inte-ett-uuid", """ + {"assigneeId": null} + """) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT")); + } + + @Test + void returnsNotFoundForUnknownTaskAndUnknownAssignee() throws Exception { + String taskId = createTask("Dammsuga", null, 1, null); + + updateAssignee("00000000-0000-0000-0000-000000000099", """ + {"assigneeId": null} + """) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TASK_NOT_FOUND")); + updateAssignee(taskId, """ + {"assigneeId": "00000000-0000-0000-0000-000000000099"} + """) + .andExpect(status().isNotFound()) + .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) @@ -159,4 +312,50 @@ class TaskApiTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("INVALID_TASK")); } + + private UUID createUser(String name) throws Exception { + 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 UUID.fromString(id); + } + + private String createTask( + String title, + String description, + int points, + UUID assigneeId) throws Exception { + String descriptionJson = description == null ? "null" : "\"%s\"".formatted(description); + String assigneeJson = assigneeId == null ? "null" : "\"%s\"".formatted(assigneeId); + String response = mockMvc.perform(post("/api/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "%s", + "description": %s, + "points": %d, + "assigneeId": %s + } + """.formatted(title, descriptionJson, points, assigneeJson))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + return com.jayway.jsonpath.JsonPath.read(response, "$.id"); + } + + private ResultActions updateAssignee(String taskId, String body) throws Exception { + return mockMvc.perform(put("/api/tasks/{taskId}/assignee", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(body)); + } } 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 cbeeee7..785545d 100644 --- a/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java +++ b/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java @@ -22,6 +22,7 @@ class TaskTest { null, TaskStatus.WAITING, points, + null, Instant.parse("2026-07-26T12:00:00Z")); } } diff --git a/docs/architecture.md b/docs/architecture.md index 7ce4974..8aae574 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,6 +29,7 @@ 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; +- val och visning av ansvarig användare på uppgifter; - klientnära validering och begripliga felmeddelanden; - uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart. @@ -60,6 +61,7 @@ Aktuella endpoints: - `POST /api/users` - `GET /api/tasks` - `POST /api/tasks` +- `PUT /api/tasks/{taskId}/assignee` ### Databas och migreringar @@ -77,6 +79,7 @@ Flyway kör migreringarna: - `V1__create_users.sql` - `V2__create_tasks.sql` - `V3__add_task_points.sql` +- `V4__add_task_assignee.sql` Hibernate är konfigurerat med `ddl-auto=validate`; Flyway skapar schemat och Hibernate validerar entiteterna mot det. @@ -104,12 +107,19 @@ En uppgift lagras i tabellen `task` med: - `description`: valfri beskrivning, högst 500 tecken; - `status`: `WAITING`, `IN_PROGRESS` eller `COMPLETED`; - `points`: obligatoriskt heltal mellan 1 och 99; +- `assignee_id`: nullable främmande nyckel till `app_user`; - `created_at`: en `Instant`, lagrad som `TIMESTAMP WITH TIME ZONE`. Status lagras som enumens textvärde genom `EnumType.STRING`. Nya uppgifter får alltid status `WAITING`. Poängintervallet skyddas i backend och med en -databasconstraint. Det finns ingen relation mellan uppgifter och användare; -alla aktiva användare ser samma uppgiftslista. +databasconstraint. En uppgift kan vara otilldelad eller referera till exakt en +ansvarig användare. Relationen hämtas tillsammans med uppgifterna när de listas, +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. ### Aktiv användare @@ -124,8 +134,9 @@ lokalt per browser och utgör inte autentisering eller behörighetskontroll. ### Felhantering Backend använder ett litet gemensamt JSON-format med `code` och `message`. -`ApiExceptionHandler` översätter kända valideringsfel till `400 Bad Request` -och dubbletter av användarnamn till `409 Conflict`. +`ApiExceptionHandler` översätter kända valideringsfel till `400 Bad Request`, +saknade uppgifter eller användare till `404 Not Found` och dubbletter eller +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 diff --git a/docs/features/004-task-assignment.md b/docs/features/004-task-assignment.md new file mode 100644 index 0000000..9f41741 --- /dev/null +++ b/docs/features/004-task-assignment.md @@ -0,0 +1,122 @@ +# Feature 4 – Tilldelning av uppgifter + +## Status + +Pågående. + +## Bakgrund + +HemHub har centralt lagrade användare och gemensamma uppgifter. För att senare +kunna införa regler för pågående arbete behöver en uppgift kunna ha en ansvarig +användare, utan att tilldelning samtidigt ändrar uppgiftens status. + +## Mål + +- välja en valfri ansvarig när en uppgift skapas; +- visa ansvarig på uppgiftskortet; +- tilldela, byta eller ta bort ansvarig på en väntande uppgift; +- lagra tilldelningen centralt så att alla användare ser samma värde. + +## Omfattning + +En uppgift kan vara otilldelad eller tilldelad exakt en befintlig användare. +`Ingen` är standard vid skapande och den aktiva browseranvändaren förväljs +inte. Frontend återanvänder användarlistan som redan hämtas vid appstart. + +På ett otilldelat väntande kort öppnar `Ta uppgift` ett användarval. Ett +tilldelat väntande kort visar namnet och öppnar samma val. Ändringen skickas +direkt till backend och kortet uppdateras först med den bekräftade responsen. +Vid fel behålls den tidigare tilldelningen och ett lokalt felmeddelande visas. + +För `IN_PROGRESS` och `COMPLETED` visas ansvarig eller `Otilldelad` utan +redigerbar kontroll. + +## Produktregler + +- En uppgift har högst en ansvarig. +- Ansvarig är valfri och måste motsvara en befintlig användare. +- Endast uppgifter med status `WAITING` får få ändrad ansvarig. +- Tilldelning ändrar aldrig status, titel, beskrivning eller poäng. +- Vem som helst kan välja valfri ansvarig; aktiv användare är inte + autentisering eller behörighetskontroll. + +## API-förändringar + +`POST /api/tasks` accepterar det valfria fältet `assigneeId`. Saknat fält eller +`null` skapar en otilldelad uppgift. Ett UUID som inte motsvarar en användare +ger `404 Not Found`. + +`PUT /api/tasks/{taskId}/assignee` ändrar endast ansvarig: + +```json +{"assigneeId": "d56b54dd-31b0-4d71-8a10-82464be59a61"} +``` + +`{"assigneeId": null}` tar bort tilldelningen. Fältet måste finnas i requesten. +Responsen är den uppdaterade uppgiften. Task-responser innehåller: + +```json +{"assignee": {"id": "d56b54dd-31b0-4d71-8a10-82464be59a61", "name": "Anna"}} +``` + +Otilldelade uppgifter har `"assignee": null`. Ogiltigt UUID eller saknat fält +ger `400`, okänd uppgift eller användare ger `404` och ändring av en uppgift +som inte väntar ger `409`. Felen använder det befintliga formatet med `code` +och `message`. + +## Databasförändringar + +`V4__add_task_assignee.sql` lägger till `task.assignee_id UUID NULL` med en +främmande nyckel till `app_user.id`. Befintliga uppgifter blir otilldelade. +Migreringen använder varken `ON DELETE CASCADE` eller `ON DELETE SET NULL`. + +JPA-modellen använder en lazy `ManyToOne`. Repositoryts listning och +id-hämtning använder en entity graph för att hämta ansvarig tillsammans med +uppgiften och undvika N+1-frågor när responsen byggs. + +## Frontendförändringar + +Skapandedialogen innehåller ett tilldelningsval med `Ingen` och samtliga +användare. Valet bevaras tillsammans med övriga formulärvärden vid fel. + +Väntande kort har en separat tilldelningskontroll. Kontrollen är inaktiverad +medan just det kortets request pågår; övriga delar av brädan förblir +interaktiva. Serverns task-respons ersätter motsvarande uppgift i den befintliga +listan utan att ändra ordningen. + +## Tester och verifiering + +Backendens integrationstester täcker skapande med och utan ansvarig, +responsformat, okända id:n, tilldelning, byte, av-tilldelning, statuskonflikt +och att övriga uppgiftsfält inte ändras. + +Frontendtesterna täcker standardval och användarlista i skapandedialogen, +create-requestens `assigneeId`, kortens redigerbara och statiska lägen, +tilldelningsrequest, vänteläge, serverbekräftad uppdatering, av-tilldelning och +fel utan optimistisk ändring. + +Manuell verifiering ska omfatta skapande med och utan ansvarig, tilldelning, +byte, av-tilldelning, bevarad status, omladdning, statiska kontroller för andra +statusar, felrespons och projektets normala desktop- och mobilbredder. + +## Avgränsning mot Feature 5 + +Feature 4 inför inget API eller UI för statusändring och inte regeln att +`IN_PROGRESS` måste ha en ansvarig. Tilldelning leder inte automatiskt till +`IN_PROGRESS`, och av-tilldelning leder inte automatiskt till `WAITING`. + +## Ingår inte + +Flera ansvariga, statusändring, drag-and-drop, generell redigering, radering, +deadlines, återkommande uppgifter, poänghistorik, användaradministration, +autentisering, behörighetskontroll och automatisk tilldelning ingår inte. + +## Kända begränsningar + +Användare kan ännu inte raderas, så relationens framtida beteende vid +användarradering är inte beslutat. Frontend har ingen optimistisk uppdatering; +det tidigare värdet ligger kvar tills backend svarar. + +## Relaterade commits + +Fylls i när featuren har committats. diff --git a/docs/roadmap.md b/docs/roadmap.md index 16a4024..9213b35 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -43,11 +43,12 @@ Feature 0–2 är klara. Den aktuella applikationen har: - en bräda med Väntande, Pågående och Klart; - nya uppgifter som alltid skapas med status `WAITING`. -Det finns ännu inga uppgiftstilldelningar, statusändringar, drag-and-drop, -redigeringar, raderingar, deadlines eller återkommande uppgifter. +Tilldelning av högst en ansvarig användare per uppgift är under utveckling. Det +finns ännu inga statusändringar, drag-and-drop, redigeringar, raderingar, +deadlines eller återkommande uppgifter. Nuvarande användarval är inte autentisering. -**Feature 3 – Uppgiftspoäng är pågående.** +**Feature 4 – Tilldelning av uppgifter är pågående.** ## Featureöversikt @@ -57,7 +58,7 @@ Nuvarande användarval är inte autentisering. | 1 – Användarval | Klar | 0 | Centrala användare och lokalt aktivt användar-id | | 2 – Skapa uppgifter | Klar | 0–1 | Gemensamma uppgifter och trekolumnsbräda | | 3 – Uppgiftspoäng | Pågående | 2 | Poäng på uppgifter | -| 4 – Tilldelning | Planerad | 1–2 | Valfri ansvarig användare | +| 4 – Tilldelning | Pågående | 1–2 | Valfri ansvarig användare | | 5 – Statusändring | Planerad | 4 | Backendstyrda statusövergångar | | 6 – Drag-and-drop | Planerad | 5 | Kortflytt via status-API | | 7 – Radera uppgift | Planerad | 2 | Bekräftad radering | @@ -130,7 +131,7 @@ databasen har inget permanent defaultvärde. ### Feature 4 – Tilldelning av uppgifter -**Status:** Planerad +**Status:** Pågående **Beroenden:** Feature 1 och Feature 2 @@ -141,13 +142,10 @@ databasen har inget permanent defaultvärde. - visa ansvarig på uppgiftskort; - kunna ändra ansvarig på en befintlig uppgift. -En väntande uppgift får vara tilldelad eller otilldelad. Tilldelning införs före -statusändring eftersom en pågående uppgift senare måste ha en ansvarig. - -**Öppna frågor:** - -- om en uppgift ska ha endast en ansvarig; -- hur borttagna användare ska hanteras när användarradering införs. +En väntande uppgift får vara tilldelad eller otilldelad och har högst en +ansvarig. Tilldelning införs före statusändring eftersom en pågående uppgift +senare måste ha en ansvarig. Hur borttagna användare ska hanteras är fortsatt +öppet tills användarradering införs. ### Feature 5 – Statusändring och statusregler diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 22a77a6..e8f4546 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -22,6 +22,7 @@ const tasks = [ description: 'Bottenvåningen', status: 'WAITING', points: 7, + assignee: null, createdAt: '2026-07-24T10:00:00Z', }, { @@ -30,6 +31,7 @@ const tasks = [ description: null, status: 'IN_PROGRESS', points: 3, + assignee: { id: users[1].id, name: users[1].name }, createdAt: '2026-07-24T10:01:00Z', }, { @@ -38,6 +40,7 @@ const tasks = [ description: null, status: 'COMPLETED', points: 5, + assignee: null, createdAt: '2026-07-24T10:02:00Z', }, ] @@ -193,6 +196,13 @@ test('Ny uppgift öppnar modalen med fokus i titelfältet', async () => { expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument() expect(screen.getByLabelText('Titel')).toHaveFocus() expect(screen.getByLabelText('Poäng')).toHaveValue(1) + expect(screen.getByLabelText('Tilldela')).toHaveValue('') + expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Ingen' })) + .toBeInTheDocument() + expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Urban' })) + .toBeInTheDocument() + expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Anna' })) + .toBeInTheDocument() }) test.each([ @@ -246,6 +256,7 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn description: 'Köket', status: 'WAITING', points: 7, + assignee: { id: users[1].id, name: users[1].name }, createdAt: '2026-07-24T10:03:00Z', } window.localStorage.setItem('hemhub.activeUserId', users[0].id) @@ -263,6 +274,7 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn target: { value: ' Köket ' }, }) fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } }) + fireEvent.change(screen.getByLabelText('Tilldela'), { target: { value: users[1].id } }) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) await waitFor(() => @@ -270,17 +282,148 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn ) const waiting = screen.getByRole('region', { name: 'Väntande' }) expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([ - 'Dammsuga7 pBottenvåningen', - 'Putsa fönster7 pKöket', + 'Dammsuga7 pBottenvåningenTa uppgift', + 'Putsa fönster7 pKöketAnna', ]) expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: 'Putsa fönster', description: 'Köket', points: 7 }), + body: JSON.stringify({ + title: 'Putsa fönster', + description: 'Köket', + points: 7, + assigneeId: users[1].id, + }), }) fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) expect(screen.getByLabelText('Poäng')).toHaveValue(1) + expect(screen.getByLabelText('Tilldela')).toHaveValue('') +}) + +test('Ingen skickas som null när en uppgift skapas', async () => { + const createdTask = { + ...tasks[0], + id: '00000000-0000-0000-0000-000000000010', + title: 'Torka bordet', + } + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([])) + fetchMock.mockResolvedValueOnce(jsonResponse(createdTask, 201)) + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) + fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Torka bordet' } }) + fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)) + expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Torka bordet', + description: null, + points: 1, + assigneeId: null, + }), + }) +}) + +test('kort visar ansvarig och endast väntande uppgifter har redigerbar kontroll', 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(waitingCard).not.toBeNull() + expect(inProgressCard).not.toBeNull() + expect(completedCard).not.toBeNull() + 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() +}) + +test('val av ansvarig anropar endpointen och uppdaterar kortet efter svar', async () => { + const updatedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } } + let resolveAssignment!: (response: Response) => void + const assignmentResponse = new Promise((resolve) => { + resolveAssignment = resolve + }) + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]])) + fetchMock.mockReturnValueOnce(assignmentResponse) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' })) + const select = screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }) + fireEvent.change(select, { target: { value: users[1].id } }) + + expect(select).toBeDisabled() + expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assigneeId: users[1].id }), + }) + + resolveAssignment(jsonResponse(updatedTask)) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Ändra ansvarig för Dammsuga' })) + .toHaveTextContent('Anna'), + ) + expect(screen.queryByRole('combobox', { name: 'Ansvarig för Dammsuga' })).not.toBeInTheDocument() +}) + +test('val av Ingen av-tilldelar en väntande uppgift', async () => { + const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } } + const unassignedTask = { ...assignedTask, assignee: null } + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask])) + fetchMock.mockResolvedValueOnce(jsonResponse(unassignedTask)) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' })) + fireEvent.change(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }), { + target: { value: '' }, + }) + + await screen.findByText('Ta uppgift') + expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assigneeId: null }), + }) +}) + +test('misslyckad tilldelning behåller ansvarig och visar fel', async () => { + const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } } + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask])) + fetchMock.mockResolvedValueOnce( + jsonResponse({ code: 'USER_NOT_FOUND', message: 'Användaren finns inte.' }, 404), + ) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' })) + fireEvent.change(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }), { + target: { value: users[0].id }, + }) + + expect(await screen.findByRole('alert')).toHaveTextContent('Användaren finns inte.') + expect(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' })).toHaveValue(users[1].id) }) test.each([ @@ -321,9 +464,11 @@ test('formulärdata bevaras när skapande av uppgift misslyckas', async () => { const title = screen.getByLabelText('Titel') const description = screen.getByLabelText('Beskrivning (valfri)') const points = screen.getByLabelText('Poäng') + const assignee = screen.getByLabelText('Tilldela') fireEvent.change(title, { target: { value: 'Dammsuga' } }) fireEvent.change(description, { target: { value: 'Bottenvåningen' } }) fireEvent.change(points, { target: { value: '7' } }) + fireEvent.change(assignee, { target: { value: users[1].id } }) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.') @@ -331,6 +476,7 @@ test('formulärdata bevaras när skapande av uppgift misslyckas', async () => { expect(title).toHaveValue('Dammsuga') expect(description).toHaveValue('Bottenvåningen') expect(points).toHaveValue(7) + expect(assignee).toHaveValue(users[1].id) }) function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d81b2cb..38d853b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -79,7 +79,7 @@ function App() { } if (activeUser) { - return + return } if (showCreateUser) { diff --git a/frontend/src/TaskBoard.tsx b/frontend/src/TaskBoard.tsx index c3d6947..54bf38d 100644 --- a/frontend/src/TaskBoard.tsx +++ b/frontend/src/TaskBoard.tsx @@ -2,12 +2,20 @@ import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react' type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED' +type UserSummary = { + id: string + name: string +} + +type Assignee = UserSummary + type Task = { id: string title: string description: string | null status: TaskStatus points: number + assignee: Assignee | null createdAt: string } @@ -17,6 +25,7 @@ type ApiError = { type TaskBoardProps = { activeUserName: string + users: UserSummary[] onLogOut: () => void } @@ -26,10 +35,13 @@ const columns: { status: TaskStatus; title: string }[] = [ { status: 'COMPLETED', title: 'Klart' }, ] -function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) { +function TaskBoard({ 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 loadTasks = async () => { setLoadState('loading') @@ -52,6 +64,49 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) { void loadTasks() }, []) + const updateAssignee = async (task: Task, assigneeId: string) => { + if (savingAssigneeTaskIds.has(task.id)) { + return + } + + setSavingAssigneeTaskIds((current) => new Set(current).add(task.id)) + setAssignmentErrors((current) => ({ ...current, [task.id]: '' })) + + try { + const response = await fetch(`/api/tasks/${task.id}/assignee`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assigneeId: assigneeId || null }), + }) + + if (!response.ok) { + const apiError = (await response.json().catch(() => ({}))) as ApiError + setAssignmentErrors((current) => ({ + ...current, + [task.id]: apiError.message ?? 'Det gick inte att ändra ansvarig. Försök igen.', + })) + return + } + + const updatedTask = (await response.json()) as Task + setTasks((current) => + current.map((currentTask) => (currentTask.id === updatedTask.id ? updatedTask : currentTask)), + ) + setEditingAssigneeTaskId(null) + } catch { + setAssignmentErrors((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 + }) + } + } + return (
@@ -98,6 +153,15 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) { {task.points} p {task.description &&

{task.description}

} + setEditingAssigneeTaskId(task.id)} + onChange={(assigneeId) => void updateAssignee(task, assigneeId)} + /> ))} @@ -107,6 +171,7 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) { {showCreateTask && ( setShowCreateTask(false)} onCreated={(task) => { setTasks((currentTasks) => [...currentTasks, task]) @@ -118,15 +183,110 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) { ) } +type AssigneeControlProps = { + task: Task + users: UserSummary[] + editing: boolean + saving: boolean + error?: string + onEdit: () => void + onChange: (assigneeId: string) => void +} + +function UserIcon() { + return ( + + ) +} + +function AssigneeControl({ + task, + users, + editing, + saving, + error, + onEdit, + onChange, +}: AssigneeControlProps) { + const displayName = task.assignee?.name ?? (task.status === 'WAITING' ? 'Ta uppgift' : 'Otilldelad') + + if (task.status !== 'WAITING') { + return ( +
+ + {displayName} +
+ ) + } + + return ( +
+ {editing ? ( + + ) : ( + + )} + {error && ( +

+ {error} +

+ )} +
+ ) +} + type CreateTaskModalProps = { + users: UserSummary[] onClose: () => void onCreated: (task: Task) => void } -function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { +function CreateTaskModal({ users, onClose, onCreated }: CreateTaskModalProps) { const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [points, setPoints] = useState('1') + const [assigneeId, setAssigneeId] = useState('') const [error, setError] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const isSubmittingRef = useRef(false) @@ -191,6 +351,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { title: trimmedTitle, description: trimmedDescription || null, points: numericPoints, + assigneeId: assigneeId || null, }), }) @@ -264,6 +425,23 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { 1–99 poäng beroende på hur tidskrävande, besvärlig eller viktig uppgiften är.

+ + + {error && (

{error} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a9cff6c..476d10f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -23,6 +23,7 @@ h1 { button, input, +select, textarea { font: inherit; } @@ -38,6 +39,7 @@ button { button:disabled, input:disabled, +select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.65; @@ -70,6 +72,15 @@ input { border-radius: 0.4rem; } +select { + box-sizing: border-box; + width: 100%; + padding: 0.6rem; + border: 1px solid #9ca3af; + border-radius: 0.4rem; + background: white; +} + textarea { box-sizing: border-box; width: 100%; @@ -174,6 +185,68 @@ textarea { margin: 0; } +.task-assignment { + margin-top: 0.9rem; +} + +.task-assignee { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: #475569; + font-size: 0.9rem; +} + +.task-assignee-button { + padding: 0.25rem 0; + color: #2563eb; + background: transparent; +} + +.task-assignee-static { + margin-top: 0.9rem; +} + +.user-icon { + flex: 0 0 auto; +} + +.assignee-select-label { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.assignee-select-label select { + width: auto; + min-width: 9rem; +} + +.assignment-error { + margin-top: 0.4rem; + font-size: 0.85rem; +} + +.field-label-uppercase { + color: #64748b; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .task-card-header { display: flex; align-items: flex-start;