Merge pull request 'feat: add task assignment' (#7) from feature/004-task-assignment into main

Reviewed-on: #7
This commit is contained in:
2026-07-26 22:50:09 +02:00
26 changed files with 964 additions and 39 deletions

View File

@ -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. 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 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 ## Starta backend

View File

@ -2,10 +2,15 @@ package se.rubble.hemhub.api;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; 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.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import se.rubble.hemhub.task.InvalidTaskException; 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.InvalidUserNameException;
import se.rubble.hemhub.user.UserNameAlreadyExistsException; import se.rubble.hemhub.user.UserNameAlreadyExistsException;
@ -33,5 +38,39 @@ public class ApiExceptionHandler {
return ResponseEntity.badRequest() return ResponseEntity.badRequest()
.body(new ApiError("INVALID_TASK", exception.getMessage())); .body(new ApiError("INVALID_TASK", exception.getMessage()));
} }
@ExceptionHandler(InvalidTaskAssignmentException.class)
public ResponseEntity<ApiError> handleInvalidTaskAssignment(
InvalidTaskAssignmentException exception) {
return ResponseEntity.badRequest()
.body(new ApiError("INVALID_TASK_ASSIGNMENT", exception.getMessage()));
} }
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiError> handleInvalidPathParameter() {
return ResponseEntity.badRequest()
.body(new ApiError(
"INVALID_TASK_ASSIGNMENT",
"Uppgifts-id måste vara ett giltigt UUID."));
}
@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<ApiError> handleTaskNotFound() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("TASK_NOT_FOUND", "Uppgiften finns inte."));
}
@ExceptionHandler(AssigneeNotFoundException.class)
public ResponseEntity<ApiError> handleAssigneeNotFound() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("USER_NOT_FOUND", "Användaren finns inte."));
}
@ExceptionHandler(TaskAssignmentConflictException.class)
public ResponseEntity<ApiError> handleTaskAssignmentConflict() {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError(
"TASK_ASSIGNMENT_NOT_ALLOWED",
"Ansvarig kan endast ändras för väntande uppgifter."));
}
}

View File

@ -0,0 +1,4 @@
package se.rubble.hemhub.task;
public class AssigneeNotFoundException extends RuntimeException {
}

View File

@ -2,7 +2,11 @@ package se.rubble.hemhub.task;
import tools.jackson.databind.JsonNode; 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() { Integer integerPoints() {
if (points == null || !points.isIntegralNumber() || !points.canConvertToInt()) { if (points == null || !points.isIntegralNumber() || !points.canConvertToInt()) {
@ -11,4 +15,8 @@ public record CreateTaskRequest(String title, String description, JsonNode point
return points.intValue(); return points.intValue();
} }
UUIDValue parsedAssigneeId() {
return UUIDValue.optional(assigneeId);
}
} }

View File

@ -0,0 +1,8 @@
package se.rubble.hemhub.task;
public class InvalidTaskAssignmentException extends RuntimeException {
public InvalidTaskAssignmentException(String message) {
super(message);
}
}

View File

@ -8,7 +8,11 @@ import jakarta.persistence.Entity;
import jakarta.persistence.EnumType; import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated; import jakarta.persistence.Enumerated;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.FetchType;
import se.rubble.hemhub.user.User;
@Entity @Entity
@Table(name = "task") @Table(name = "task")
@ -30,6 +34,10 @@ class Task {
@Column(nullable = false) @Column(nullable = false)
private int points; private int points;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "assignee_id")
private User assignee;
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private Instant createdAt; private Instant createdAt;
@ -42,6 +50,7 @@ class Task {
String description, String description,
TaskStatus status, TaskStatus status,
int points, int points,
User assignee,
Instant createdAt) { Instant createdAt) {
if (points < 1 || points > 99) { if (points < 1 || points > 99) {
throw new InvalidTaskException( throw new InvalidTaskException(
@ -53,6 +62,7 @@ class Task {
this.description = description; this.description = description;
this.status = status; this.status = status;
this.points = points; this.points = points;
this.assignee = assignee;
this.createdAt = createdAt; this.createdAt = createdAt;
} }
@ -76,6 +86,21 @@ class Task {
return points; 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() { Instant getCreatedAt() {
return createdAt; return createdAt;
} }

View File

@ -0,0 +1,4 @@
package se.rubble.hemhub.task;
public class TaskAssignmentConflictException extends RuntimeException {
}

View File

@ -1,10 +1,13 @@
package se.rubble.hemhub.task; package se.rubble.hemhub.task;
import java.util.List; import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
@ -28,9 +31,24 @@ public class TaskController {
@PostMapping @PostMapping
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) { public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) {
UUIDValue assigneeId = request == null
? UUIDValue.optional(null)
: request.parsedAssigneeId();
return taskService.create( return taskService.create(
request == null ? null : request.title(), request == null ? null : request.title(),
request == null ? null : request.description(), 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());
} }
} }

View File

@ -0,0 +1,4 @@
package se.rubble.hemhub.task;
public class TaskNotFoundException extends RuntimeException {
}

View File

@ -4,9 +4,13 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.EntityGraph;
interface TaskRepository extends JpaRepository<Task, UUID> { interface TaskRepository extends JpaRepository<Task, UUID> {
@EntityGraph(attributePaths = "assignee")
List<Task> findAllByOrderByCreatedAtAscIdAsc(); List<Task> findAllByOrderByCreatedAtAscIdAsc();
}
@EntityGraph(attributePaths = "assignee")
java.util.Optional<Task> findOneById(UUID id);
}

View File

@ -9,6 +9,7 @@ public record TaskResponse(
String description, String description,
TaskStatus status, TaskStatus status,
int points, int points,
AssigneeResponse assignee,
Instant createdAt) { Instant createdAt) {
static TaskResponse from(Task task) { static TaskResponse from(Task task) {
@ -18,6 +19,14 @@ public record TaskResponse(
task.getDescription(), task.getDescription(),
task.getStatus(), task.getStatus(),
task.getPoints(), task.getPoints(),
AssigneeResponse.from(task.getAssignee()),
task.getCreatedAt()); 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());
}
}
} }

View File

@ -9,19 +9,24 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import se.rubble.hemhub.user.User;
import se.rubble.hemhub.user.UserRepository;
@Service @Service
class TaskService { class TaskService {
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final UserRepository userRepository;
private final Clock clock; private final Clock clock;
@Autowired @Autowired
TaskService(TaskRepository taskRepository) { TaskService(TaskRepository taskRepository, UserRepository userRepository) {
this(taskRepository, Clock.systemUTC()); this(taskRepository, userRepository, Clock.systemUTC());
} }
TaskService(TaskRepository taskRepository, Clock clock) { TaskService(TaskRepository taskRepository, UserRepository userRepository, Clock clock) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.userRepository = userRepository;
this.clock = clock; this.clock = clock;
} }
@ -36,7 +41,8 @@ class TaskService {
TaskResponse create( TaskResponse create(
String requestedTitle, String requestedTitle,
String requestedDescription, String requestedDescription,
Integer requestedPoints) { Integer requestedPoints,
UUID requestedAssigneeId) {
String title = requestedTitle == null ? "" : requestedTitle.trim(); String title = requestedTitle == null ? "" : requestedTitle.trim();
String description = normalizeDescription(requestedDescription); String description = normalizeDescription(requestedDescription);
@ -55,17 +61,39 @@ class TaskService {
"Poäng måste vara ett heltal mellan 1 och 99."); "Poäng måste vara ett heltal mellan 1 och 99.");
} }
User assignee = findAssignee(requestedAssigneeId);
Task task = new Task( Task task = new Task(
UUID.randomUUID(), UUID.randomUUID(),
title, title,
description, description,
TaskStatus.WAITING, TaskStatus.WAITING,
requestedPoints, requestedPoints,
assignee,
Instant.now(clock)); Instant.now(clock));
return TaskResponse.from(taskRepository.save(task)); 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) { private static String normalizeDescription(String requestedDescription) {
if (requestedDescription == null) { if (requestedDescription == null) {
return null; return null;

View File

@ -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.");
}
}
}

View File

@ -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;
}
}

View File

@ -10,7 +10,7 @@ import jakarta.persistence.Table;
@Entity @Entity
@Table(name = "app_user") @Table(name = "app_user")
class User { public class User {
@Id @Id
private UUID id; private UUID id;
@ -34,11 +34,11 @@ class User {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
UUID getId() { public UUID getId() {
return id; return id;
} }
String getName() { public String getName() {
return name; return name;
} }

View File

@ -4,8 +4,7 @@ import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
interface UserRepository extends JpaRepository<User, UUID> { public interface UserRepository extends JpaRepository<User, UUID> {
boolean existsByNormalizedName(String normalizedName); boolean existsByNormalizedName(String normalizedName);
} }

View File

@ -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);

View File

@ -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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -52,6 +53,7 @@ class TaskApiTest {
.andExpect(jsonPath("$.description").value("Bottenvåningen")) .andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.status").value("WAITING")) .andExpect(jsonPath("$.status").value("WAITING"))
.andExpect(jsonPath("$.points").value(7)) .andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.assignee").value((Object) null))
.andExpect(jsonPath("$.createdAt").isString()); .andExpect(jsonPath("$.createdAt").isString());
mockMvc.perform(get("/api/tasks")) mockMvc.perform(get("/api/tasks"))
@ -59,6 +61,67 @@ class TaskApiTest {
.andExpect(jsonPath("$[0].points").value(7)); .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 @Test
void storesBlankDescriptionAsNull() throws Exception { void storesBlankDescriptionAsNull() throws Exception {
mockMvc.perform(post("/api/tasks") mockMvc.perform(post("/api/tasks")
@ -133,9 +196,12 @@ class TaskApiTest {
UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002"); UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002");
UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003"); UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003");
taskRepository.save(new Task(newestId, "Nyast", null, TaskStatus.WAITING, 3, newer)); taskRepository.save(new Task(
taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, 2, older)); newestId, "Nyast", null, TaskStatus.WAITING, 3, null, newer));
taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, 1, older)); 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")) mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@ -145,6 +211,93 @@ class TaskApiTest {
.andExpect(jsonPath("$[2].title").value("Nyast")); .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 { private ResultActions createTaskWithPoints(int points) throws Exception {
return mockMvc.perform(post("/api/tasks") return mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@ -159,4 +312,50 @@ class TaskApiTest {
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK")); .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));
}
} }

View File

@ -22,6 +22,7 @@ class TaskTest {
null, null,
TaskStatus.WAITING, TaskStatus.WAITING,
points, points,
null,
Instant.parse("2026-07-26T12:00:00Z")); Instant.parse("2026-07-26T12:00:00Z"));
} }
} }

View File

@ -29,6 +29,7 @@ Den ansvarar för:
- hämtning och presentation av användare och uppgifter; - hämtning och presentation av användare och uppgifter;
- lokalt val av aktiv användare; - lokalt val av aktiv användare;
- formulär för att skapa användare och uppgifter; - 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; - klientnära validering och begripliga felmeddelanden;
- uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart. - uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart.
@ -60,6 +61,7 @@ Aktuella endpoints:
- `POST /api/users` - `POST /api/users`
- `GET /api/tasks` - `GET /api/tasks`
- `POST /api/tasks` - `POST /api/tasks`
- `PUT /api/tasks/{taskId}/assignee`
### Databas och migreringar ### Databas och migreringar
@ -77,6 +79,7 @@ Flyway kör migreringarna:
- `V1__create_users.sql` - `V1__create_users.sql`
- `V2__create_tasks.sql` - `V2__create_tasks.sql`
- `V3__add_task_points.sql` - `V3__add_task_points.sql`
- `V4__add_task_assignee.sql`
Hibernate är konfigurerat med `ddl-auto=validate`; Flyway skapar schemat och Hibernate är konfigurerat med `ddl-auto=validate`; Flyway skapar schemat och
Hibernate validerar entiteterna mot det. Hibernate validerar entiteterna mot det.
@ -104,12 +107,19 @@ En uppgift lagras i tabellen `task` med:
- `description`: valfri beskrivning, högst 500 tecken; - `description`: valfri beskrivning, högst 500 tecken;
- `status`: `WAITING`, `IN_PROGRESS` eller `COMPLETED`; - `status`: `WAITING`, `IN_PROGRESS` eller `COMPLETED`;
- `points`: obligatoriskt heltal mellan 1 och 99; - `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`. - `created_at`: en `Instant`, lagrad som `TIMESTAMP WITH TIME ZONE`.
Status lagras som enumens textvärde genom `EnumType.STRING`. Nya uppgifter får Status lagras som enumens textvärde genom `EnumType.STRING`. Nya uppgifter får
alltid status `WAITING`. Poängintervallet skyddas i backend och med en alltid status `WAITING`. Poängintervallet skyddas i backend och med en
databasconstraint. Det finns ingen relation mellan uppgifter och användare; databasconstraint. En uppgift kan vara otilldelad eller referera till exakt en
alla aktiva användare ser samma uppgiftslista. 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 ### Aktiv användare
@ -124,8 +134,9 @@ lokalt per browser och utgör inte autentisering eller behörighetskontroll.
### Felhantering ### Felhantering
Backend använder ett litet gemensamt JSON-format med `code` och `message`. Backend använder ett litet gemensamt JSON-format med `code` och `message`.
`ApiExceptionHandler` översätter kända valideringsfel till `400 Bad Request` `ApiExceptionHandler` översätter kända valideringsfel till `400 Bad Request`,
och dubbletter av användarnamn till `409 Conflict`. 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 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 återförsökas. Formulärfel visas nära formuläret och inmatningen behålls vid

View File

@ -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.

View File

@ -43,11 +43,12 @@ Feature 02 är klara. Den aktuella applikationen har:
- en bräda med Väntande, Pågående och Klart; - en bräda med Väntande, Pågående och Klart;
- nya uppgifter som alltid skapas med status `WAITING`. - nya uppgifter som alltid skapas med status `WAITING`.
Det finns ännu inga uppgiftstilldelningar, statusändringar, drag-and-drop, Tilldelning av högst en ansvarig användare per uppgift är under utveckling. Det
redigeringar, raderingar, deadlines eller återkommande uppgifter. finns ännu inga statusändringar, drag-and-drop, redigeringar, raderingar,
deadlines eller återkommande uppgifter.
Nuvarande användarval är inte autentisering. 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 ## 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 | | 1 Användarval | Klar | 0 | Centrala användare och lokalt aktivt användar-id |
| 2 Skapa uppgifter | Klar | 01 | Gemensamma uppgifter och trekolumnsbräda | | 2 Skapa uppgifter | Klar | 01 | Gemensamma uppgifter och trekolumnsbräda |
| 3 Uppgiftspoäng | Pågående | 2 | Poäng på uppgifter | | 3 Uppgiftspoäng | Pågående | 2 | Poäng på uppgifter |
| 4 Tilldelning | Planerad | 12 | Valfri ansvarig användare | | 4 Tilldelning | Pågående | 12 | Valfri ansvarig användare |
| 5 Statusändring | Planerad | 4 | Backendstyrda statusövergångar | | 5 Statusändring | Planerad | 4 | Backendstyrda statusövergångar |
| 6 Drag-and-drop | Planerad | 5 | Kortflytt via status-API | | 6 Drag-and-drop | Planerad | 5 | Kortflytt via status-API |
| 7 Radera uppgift | Planerad | 2 | Bekräftad radering | | 7 Radera uppgift | Planerad | 2 | Bekräftad radering |
@ -130,7 +131,7 @@ databasen har inget permanent defaultvärde.
### Feature 4 Tilldelning av uppgifter ### Feature 4 Tilldelning av uppgifter
**Status:** Planerad **Status:** Pågående
**Beroenden:** Feature 1 och Feature 2 **Beroenden:** Feature 1 och Feature 2
@ -141,13 +142,10 @@ databasen har inget permanent defaultvärde.
- visa ansvarig på uppgiftskort; - visa ansvarig på uppgiftskort;
- kunna ändra ansvarig på en befintlig uppgift. - kunna ändra ansvarig på en befintlig uppgift.
En väntande uppgift får vara tilldelad eller otilldelad. Tilldelning införs före En väntande uppgift får vara tilldelad eller otilldelad och har högst en
statusändring eftersom en pågående uppgift senare måste ha en ansvarig. 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
**Öppna frågor:** öppet tills användarradering införs.
- om en uppgift ska ha endast en ansvarig;
- hur borttagna användare ska hanteras när användarradering införs.
### Feature 5 Statusändring och statusregler ### Feature 5 Statusändring och statusregler

View File

@ -22,6 +22,7 @@ const tasks = [
description: 'Bottenvåningen', description: 'Bottenvåningen',
status: 'WAITING', status: 'WAITING',
points: 7, points: 7,
assignee: null,
createdAt: '2026-07-24T10:00:00Z', createdAt: '2026-07-24T10:00:00Z',
}, },
{ {
@ -30,6 +31,7 @@ const tasks = [
description: null, description: null,
status: 'IN_PROGRESS', status: 'IN_PROGRESS',
points: 3, points: 3,
assignee: { id: users[1].id, name: users[1].name },
createdAt: '2026-07-24T10:01:00Z', createdAt: '2026-07-24T10:01:00Z',
}, },
{ {
@ -38,6 +40,7 @@ const tasks = [
description: null, description: null,
status: 'COMPLETED', status: 'COMPLETED',
points: 5, points: 5,
assignee: null,
createdAt: '2026-07-24T10:02:00Z', 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.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument()
expect(screen.getByLabelText('Titel')).toHaveFocus() expect(screen.getByLabelText('Titel')).toHaveFocus()
expect(screen.getByLabelText('Poäng')).toHaveValue(1) 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([ 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', description: 'Köket',
status: 'WAITING', status: 'WAITING',
points: 7, points: 7,
assignee: { id: users[1].id, name: users[1].name },
createdAt: '2026-07-24T10:03:00Z', createdAt: '2026-07-24T10:03:00Z',
} }
window.localStorage.setItem('hemhub.activeUserId', users[0].id) 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 ' }, target: { value: ' Köket ' },
}) })
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } }) 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' })) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
await waitFor(() => 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' }) const waiting = screen.getByRole('region', { name: 'Väntande' })
expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([ expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([
'Dammsuga7 pBottenvåningen', 'Dammsuga7 pBottenvåningenTa uppgift',
'Putsa fönster7 pKöket', 'Putsa fönster7 pKöketAnna',
]) ])
expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', { expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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' })) fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
expect(screen.getByLabelText('Poäng')).toHaveValue(1) 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(<App />)
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(<App />)
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<Response>((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(<App />)
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(<App />)
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(<App />)
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([ test.each([
@ -321,9 +464,11 @@ test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
const title = screen.getByLabelText('Titel') const title = screen.getByLabelText('Titel')
const description = screen.getByLabelText('Beskrivning (valfri)') const description = screen.getByLabelText('Beskrivning (valfri)')
const points = screen.getByLabelText('Poäng') const points = screen.getByLabelText('Poäng')
const assignee = screen.getByLabelText('Tilldela')
fireEvent.change(title, { target: { value: 'Dammsuga' } }) fireEvent.change(title, { target: { value: 'Dammsuga' } })
fireEvent.change(description, { target: { value: 'Bottenvåningen' } }) fireEvent.change(description, { target: { value: 'Bottenvåningen' } })
fireEvent.change(points, { target: { value: '7' } }) fireEvent.change(points, { target: { value: '7' } })
fireEvent.change(assignee, { target: { value: users[1].id } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.') 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(title).toHaveValue('Dammsuga')
expect(description).toHaveValue('Bottenvåningen') expect(description).toHaveValue('Bottenvåningen')
expect(points).toHaveValue(7) expect(points).toHaveValue(7)
expect(assignee).toHaveValue(users[1].id)
}) })
function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) { function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) {

View File

@ -79,7 +79,7 @@ function App() {
} }
if (activeUser) { if (activeUser) {
return <TaskBoard activeUserName={activeUser.name} onLogOut={logOut} /> return <TaskBoard activeUserName={activeUser.name} users={users} onLogOut={logOut} />
} }
if (showCreateUser) { if (showCreateUser) {

View File

@ -2,12 +2,20 @@ import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react'
type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED' type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED'
type UserSummary = {
id: string
name: string
}
type Assignee = UserSummary
type Task = { type Task = {
id: string id: string
title: string title: string
description: string | null description: string | null
status: TaskStatus status: TaskStatus
points: number points: number
assignee: Assignee | null
createdAt: string createdAt: string
} }
@ -17,6 +25,7 @@ type ApiError = {
type TaskBoardProps = { type TaskBoardProps = {
activeUserName: string activeUserName: string
users: UserSummary[]
onLogOut: () => void onLogOut: () => void
} }
@ -26,10 +35,13 @@ const columns: { status: TaskStatus; title: string }[] = [
{ status: 'COMPLETED', title: 'Klart' }, { status: 'COMPLETED', title: 'Klart' },
] ]
function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) { function TaskBoard({ activeUserName, users, onLogOut }: TaskBoardProps) {
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading') const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [showCreateTask, setShowCreateTask] = useState(false) const [showCreateTask, setShowCreateTask] = useState(false)
const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState<string | null>(null)
const [savingAssigneeTaskIds, setSavingAssigneeTaskIds] = useState<Set<string>>(new Set())
const [assignmentErrors, setAssignmentErrors] = useState<Record<string, string>>({})
const loadTasks = async () => { const loadTasks = async () => {
setLoadState('loading') setLoadState('loading')
@ -52,6 +64,49 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
void loadTasks() 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 ( return (
<main className="task-app"> <main className="task-app">
<header className="app-header"> <header className="app-header">
@ -98,6 +153,15 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
<span className="points-badge">{task.points} p</span> <span className="points-badge">{task.points} p</span>
</div> </div>
{task.description && <p>{task.description}</p>} {task.description && <p>{task.description}</p>}
<AssigneeControl
task={task}
users={users}
editing={editingAssigneeTaskId === task.id}
saving={savingAssigneeTaskIds.has(task.id)}
error={assignmentErrors[task.id]}
onEdit={() => setEditingAssigneeTaskId(task.id)}
onChange={(assigneeId) => void updateAssignee(task, assigneeId)}
/>
</article> </article>
))} ))}
</div> </div>
@ -107,6 +171,7 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
{showCreateTask && ( {showCreateTask && (
<CreateTaskModal <CreateTaskModal
users={users}
onClose={() => setShowCreateTask(false)} onClose={() => setShowCreateTask(false)}
onCreated={(task) => { onCreated={(task) => {
setTasks((currentTasks) => [...currentTasks, 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 (
<svg
className="user-icon"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
>
<circle cx="12" cy="8" r="3.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path
d="M5 20c.5-4 3-6 7-6s6.5 2 7 6"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
)
}
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 (
<div className="task-assignee task-assignee-static">
<UserIcon />
<span>{displayName}</span>
</div>
)
}
return (
<div className="task-assignment">
{editing ? (
<label className="assignee-select-label">
<span className="visually-hidden">Ansvarig för {task.title}</span>
<UserIcon />
<select
aria-label={`Ansvarig för ${task.title}`}
value={task.assignee?.id ?? ''}
disabled={saving}
autoFocus
onChange={(event) => onChange(event.target.value)}
>
<option value="">Ingen</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</label>
) : (
<button
type="button"
className="task-assignee task-assignee-button"
disabled={saving}
onClick={onEdit}
aria-label={`Ändra ansvarig för ${task.title}`}
>
<UserIcon />
<span>{displayName}</span>
</button>
)}
{error && (
<p className="assignment-error error" role="alert">
{error}
</p>
)}
</div>
)
}
type CreateTaskModalProps = { type CreateTaskModalProps = {
users: UserSummary[]
onClose: () => void onClose: () => void
onCreated: (task: Task) => void onCreated: (task: Task) => void
} }
function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { function CreateTaskModal({ users, onClose, onCreated }: CreateTaskModalProps) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [points, setPoints] = useState('1') const [points, setPoints] = useState('1')
const [assigneeId, setAssigneeId] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const isSubmittingRef = useRef(false) const isSubmittingRef = useRef(false)
@ -191,6 +351,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
title: trimmedTitle, title: trimmedTitle,
description: trimmedDescription || null, description: trimmedDescription || null,
points: numericPoints, points: numericPoints,
assigneeId: assigneeId || null,
}), }),
}) })
@ -264,6 +425,23 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
199 poäng beroende hur tidskrävande, besvärlig eller viktig uppgiften är. 199 poäng beroende hur tidskrävande, besvärlig eller viktig uppgiften är.
</p> </p>
<label className="field-label-uppercase" htmlFor="task-assignee">
Tilldela
</label>
<select
id="task-assignee"
value={assigneeId}
disabled={isSubmitting}
onChange={(event) => setAssigneeId(event.target.value)}
>
<option value="">Ingen</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
{error && ( {error && (
<p className="error" role="alert"> <p className="error" role="alert">
{error} {error}

View File

@ -23,6 +23,7 @@ h1 {
button, button,
input, input,
select,
textarea { textarea {
font: inherit; font: inherit;
} }
@ -38,6 +39,7 @@ button {
button:disabled, button:disabled,
input:disabled, input:disabled,
select:disabled,
textarea:disabled { textarea:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.65; opacity: 0.65;
@ -70,6 +72,15 @@ input {
border-radius: 0.4rem; 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 { textarea {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@ -174,6 +185,68 @@ textarea {
margin: 0; 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 { .task-card-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;