feat(tasks): add task pages and request boundaries
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
public record TaskAssigneeChoice(long membershipId, String displayName) {}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.Locale;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@Controller
|
||||
public class TaskController {
|
||||
|
||||
private final TaskService taskService;
|
||||
|
||||
public TaskController(TaskService taskService) {
|
||||
this.taskService = taskService;
|
||||
}
|
||||
|
||||
@GetMapping("/projects/{projectId}/tasks")
|
||||
String list(Authentication authentication, @PathVariable long projectId, Model model) {
|
||||
TaskListView taskList = taskService.list(authentication.getName(), projectId);
|
||||
model.addAttribute("projectId", projectId);
|
||||
model.addAttribute("taskList", taskList);
|
||||
model.addAttribute("progressLabel", progressLabel(taskList.progress()));
|
||||
return "tasks/list";
|
||||
}
|
||||
|
||||
@GetMapping("/projects/{projectId}/tasks/new")
|
||||
String createForm(Authentication authentication, @PathVariable long projectId, Model model) {
|
||||
model.addAttribute("taskForm", new TaskCreateForm("", "", null, null));
|
||||
populateForm(authentication.getName(), projectId, model);
|
||||
return "tasks/form";
|
||||
}
|
||||
|
||||
@PostMapping("/projects/{projectId}/tasks")
|
||||
String create(
|
||||
Authentication authentication,
|
||||
@PathVariable long projectId,
|
||||
@Valid @ModelAttribute("taskForm") TaskCreateForm form,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
if (bindingResult.hasErrors()) {
|
||||
populateForm(authentication.getName(), projectId, model);
|
||||
return "tasks/form";
|
||||
}
|
||||
TaskView task = taskService.create(
|
||||
authentication.getName(),
|
||||
new CreateTaskCommand(
|
||||
projectId,
|
||||
form.assigneeMembershipId(),
|
||||
form.title(),
|
||||
form.description(),
|
||||
form.dueDate()));
|
||||
return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id());
|
||||
}
|
||||
|
||||
@GetMapping("/projects/{projectId}/tasks/{taskId}")
|
||||
String details(
|
||||
Authentication authentication,
|
||||
@PathVariable long projectId,
|
||||
@PathVariable long taskId,
|
||||
Model model) {
|
||||
model.addAttribute("projectId", projectId);
|
||||
model.addAttribute("details", taskService.details(authentication.getName(), projectId, taskId));
|
||||
model.addAttribute("statuses", TaskStatus.values());
|
||||
return "tasks/detail";
|
||||
}
|
||||
|
||||
@PostMapping("/projects/{projectId}/tasks/{taskId}/status")
|
||||
String changeStatus(
|
||||
Authentication authentication,
|
||||
@PathVariable long projectId,
|
||||
@PathVariable long taskId,
|
||||
@RequestParam TaskStatus status) {
|
||||
taskService.changeStatus(authentication.getName(), projectId, taskId, status);
|
||||
return detailsRedirect(projectId, taskId);
|
||||
}
|
||||
|
||||
@PostMapping("/projects/{projectId}/tasks/{taskId}/comments")
|
||||
String addComment(
|
||||
Authentication authentication,
|
||||
@PathVariable long projectId,
|
||||
@PathVariable long taskId,
|
||||
@RequestParam String body) {
|
||||
taskService.addComment(authentication.getName(), projectId, taskId, body);
|
||||
return detailsRedirect(projectId, taskId);
|
||||
}
|
||||
|
||||
private void populateForm(String actorEmail, long projectId, Model model) {
|
||||
model.addAttribute("projectId", projectId);
|
||||
model.addAttribute("assignees", taskService.assignmentChoices(actorEmail, projectId));
|
||||
}
|
||||
|
||||
private static String detailsRedirect(long projectId, long taskId) {
|
||||
return "redirect:/projects/%d/tasks/%d".formatted(projectId, taskId);
|
||||
}
|
||||
|
||||
private static String progressLabel(TaskProgress progress) {
|
||||
return progress.completionPercentage().isEmpty()
|
||||
? "N/A"
|
||||
: String.format(Locale.ROOT, "%.1f%%", progress.completionPercentage().getAsDouble());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDate;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
public record TaskCreateForm(
|
||||
@NotBlank @Size(max = 200) String title,
|
||||
String description,
|
||||
@NotNull Long assigneeMembershipId,
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueDate) {}
|
||||
@@ -153,6 +153,32 @@ public class TaskService {
|
||||
return new TaskDetails(task, comments);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<TaskAssigneeChoice> assignmentChoices(String actorEmail, long projectId) {
|
||||
Actor actor = requireActiveActor(actorEmail);
|
||||
requireOpenProject(projectId);
|
||||
long actorMembershipId = requireActorMembership(projectId, actor.id());
|
||||
boolean leader = isCurrentLeader(projectId, actorMembershipId);
|
||||
return jdbc.sql("""
|
||||
select m.id, u.display_name
|
||||
from project_memberships m
|
||||
join app_users u on u.id = m.intern_user_id
|
||||
join intern_profiles i on i.user_id = m.intern_user_id
|
||||
where m.project_id = :projectId
|
||||
and m.left_at is null
|
||||
and u.account_status = 'ACTIVE'
|
||||
and i.internship_status = 'ACTIVE'
|
||||
and (:leader or m.id = :actorMembershipId)
|
||||
order by m.id
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.param("leader", leader)
|
||||
.param("actorMembershipId", actorMembershipId)
|
||||
.query((rs, rowNum) -> new TaskAssigneeChoice(
|
||||
rs.getLong("id"), rs.getString("display_name")))
|
||||
.list();
|
||||
}
|
||||
|
||||
private Actor requireActiveActor(String email) {
|
||||
Actor actor = requireReadableActor(email);
|
||||
if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title th:text="${details.task.title}">Task</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1 th:text="${details.task.title}">Task</h1>
|
||||
<p th:text="${details.task.description ?: 'No description'}">No description</p>
|
||||
<p>Status: <strong th:text="${details.task.status}">TODO</strong></p>
|
||||
<p>Due date: <span th:text="${details.task.dueDate ?: '—'}">—</span></p>
|
||||
|
||||
<form method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/status(projectId=${projectId},taskId=${details.task.id})}">
|
||||
<label for="status">New status</label>
|
||||
<select id="status" name="status" required>
|
||||
<option th:each="status : ${statuses}" th:value="${status}" th:text="${status}">TODO</option>
|
||||
</select>
|
||||
<button type="submit">Change status</button>
|
||||
</form>
|
||||
|
||||
<section aria-labelledby="comments-heading">
|
||||
<h2 id="comments-heading">Comments</h2>
|
||||
<ol>
|
||||
<li th:each="comment : ${details.comments}" th:text="${comment.body}">Comment</li>
|
||||
</ol>
|
||||
<form method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/comments(projectId=${projectId},taskId=${details.task.id})}">
|
||||
<label for="body">Comment</label>
|
||||
<textarea id="body" name="body" required></textarea>
|
||||
<button type="submit">Add comment</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Create Task</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Create Task</h1>
|
||||
<form method="post" th:action="@{/projects/{projectId}/tasks(projectId=${projectId})}" th:object="${taskForm}">
|
||||
<div>
|
||||
<label for="title">Title</label>
|
||||
<input id="title" type="text" maxlength="200" required th:field="*{title}">
|
||||
<p role="alert" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" th:field="*{description}"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="assigneeMembershipId">Assignee</label>
|
||||
<select id="assigneeMembershipId" required th:field="*{assigneeMembershipId}">
|
||||
<option value="">Select an assignee</option>
|
||||
<option th:each="assignee : ${assignees}" th:value="${assignee.membershipId}" th:text="${assignee.displayName}">Member</option>
|
||||
</select>
|
||||
<p role="alert" th:if="${#fields.hasErrors('assigneeMembershipId')}" th:errors="*{assigneeMembershipId}">Assignee error</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="dueDate">Due date</label>
|
||||
<input id="dueDate" type="date" th:field="*{dueDate}">
|
||||
</div>
|
||||
<button type="submit">Create Task</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Project tasks</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Project tasks</h1>
|
||||
<p>Progress: <strong th:text="${progressLabel}">N/A</strong></p>
|
||||
<dl>
|
||||
<dt>TODO</dt><dd th:text="${taskList.progress.todo}">0</dd>
|
||||
<dt>IN_PROGRESS</dt><dd th:text="${taskList.progress.inProgress}">0</dd>
|
||||
<dt>BLOCKED</dt><dd th:text="${taskList.progress.blocked}">0</dd>
|
||||
<dt>DONE</dt><dd th:text="${taskList.progress.done}">0</dd>
|
||||
</dl>
|
||||
<p><a th:href="@{/projects/{projectId}/tasks/new(projectId=${projectId})}">Create Task</a></p>
|
||||
<table>
|
||||
<caption>Current non-deleted Tasks</caption>
|
||||
<thead><tr><th scope="col">Title</th><th scope="col">Status</th><th scope="col">Due date</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="task : ${taskList.tasks}">
|
||||
<td><a th:href="@{/projects/{projectId}/tasks/{taskId}(projectId=${projectId},taskId=${task.id})}" th:text="${task.title}">Task</a></td>
|
||||
<td th:text="${task.status}">TODO</td>
|
||||
<td th:text="${task.dueDate ?: '—'}">—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,6 +7,7 @@ import com.lab.labtimesheet.tasks.CreateTaskCommand;
|
||||
import com.lab.labtimesheet.tasks.TaskCommentView;
|
||||
import com.lab.labtimesheet.tasks.TaskDetails;
|
||||
import com.lab.labtimesheet.tasks.TaskListView;
|
||||
import com.lab.labtimesheet.tasks.TaskAssigneeChoice;
|
||||
import com.lab.labtimesheet.tasks.TaskNotFoundException;
|
||||
import com.lab.labtimesheet.tasks.TaskService;
|
||||
import com.lab.labtimesheet.tasks.TaskStatus;
|
||||
@@ -233,6 +234,16 @@ class TaskCreationIntegrationTest {
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFormChoicesAreSelfOnlyForMembersAndAllActiveMembersForLeader() {
|
||||
assertThat(taskService.assignmentChoices("member@example.test", projectId))
|
||||
.extracting(TaskAssigneeChoice::membershipId)
|
||||
.containsExactly(memberMembershipId);
|
||||
assertThat(taskService.assignmentChoices("leader@example.test", projectId))
|
||||
.extracting(TaskAssigneeChoice::membershipId)
|
||||
.containsExactly(leaderMembershipId, memberMembershipId);
|
||||
}
|
||||
|
||||
private long insertUser(String email, String role) {
|
||||
return jdbc.sql("""
|
||||
insert into app_users
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@WebMvcTest(TaskController.class)
|
||||
class TaskControllerTest {
|
||||
|
||||
private static final String ACTOR_EMAIL = "member@example.test";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
private TaskService taskService;
|
||||
|
||||
@Test
|
||||
void taskListRequiresAuthentication() throws Exception {
|
||||
mockMvc.perform(get("/projects/10/tasks"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
|
||||
verifyNoInteractions(taskService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyTaskListRendersNotApplicableProgress() throws Exception {
|
||||
given(taskService.list(ACTOR_EMAIL, 10L))
|
||||
.willReturn(new TaskListView(List.of(), TaskProgress.from(List.of())));
|
||||
|
||||
mockMvc.perform(get("/projects/10/tasks").with(user(ACTOR_EMAIL)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("tasks/list"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("N/A")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void guessedTaskIdentifierReturnsNotFoundWithoutRenderingDetails() throws Exception {
|
||||
given(taskService.details(ACTOR_EMAIL, 10L, 999L)).willThrow(new TaskNotFoundException());
|
||||
|
||||
mockMvc.perform(get("/projects/10/tasks/999").with(user(ACTOR_EMAIL)))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validCreateFormUsesAuthenticatedIdentityAndRedirectsToCreatedTask() throws Exception {
|
||||
given(taskService.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class)))
|
||||
.willReturn(task(25L));
|
||||
|
||||
mockMvc.perform(post("/projects/10/tasks")
|
||||
.with(user(ACTOR_EMAIL))
|
||||
.with(csrf())
|
||||
.param("title", "Draft")
|
||||
.param("description", "Notes")
|
||||
.param("assigneeMembershipId", "7")
|
||||
.param("dueDate", "2026-08-20"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("/projects/10/tasks/25"));
|
||||
|
||||
ArgumentCaptor<CreateTaskCommand> command = ArgumentCaptor.forClass(CreateTaskCommand.class);
|
||||
verify(taskService).create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), command.capture());
|
||||
assertThat(command.getValue()).isEqualTo(new CreateTaskCommand(
|
||||
10L, 7L, "Draft", "Notes", LocalDate.of(2026, 8, 20)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankCreateFormRendersValidationErrorWithoutWriting() throws Exception {
|
||||
given(taskService.assignmentChoices(ACTOR_EMAIL, 10L))
|
||||
.willReturn(List.of(new TaskAssigneeChoice(7L, "Member")));
|
||||
|
||||
mockMvc.perform(post("/projects/10/tasks")
|
||||
.with(user(ACTOR_EMAIL))
|
||||
.with(csrf())
|
||||
.param("title", " ")
|
||||
.param("assigneeMembershipId", "7"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("tasks/form"))
|
||||
.andExpect(model().attributeHasFieldErrors("taskForm", "title"));
|
||||
|
||||
verify(taskService, org.mockito.Mockito.never())
|
||||
.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusAndCommentPostsUseAuthenticatedIdentityAndCsrf() throws Exception {
|
||||
given(taskService.changeStatus(ACTOR_EMAIL, 10L, 25L, TaskStatus.IN_PROGRESS))
|
||||
.willReturn(task(25L));
|
||||
given(taskService.addComment(ACTOR_EMAIL, 10L, 25L, "Update"))
|
||||
.willReturn(new TaskCommentView(3L, 25L, 5L, "Update", Instant.parse("2026-08-14T10:00:00Z")));
|
||||
|
||||
mockMvc.perform(post("/projects/10/tasks/25/status")
|
||||
.with(user(ACTOR_EMAIL))
|
||||
.with(csrf())
|
||||
.param("status", "IN_PROGRESS"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("/projects/10/tasks/25"));
|
||||
mockMvc.perform(post("/projects/10/tasks/25/comments")
|
||||
.with(user(ACTOR_EMAIL))
|
||||
.with(csrf())
|
||||
.param("body", "Update"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("/projects/10/tasks/25"));
|
||||
}
|
||||
|
||||
private static TaskView task(long id) {
|
||||
Instant instant = Instant.parse("2026-08-14T10:00:00Z");
|
||||
return new TaskView(
|
||||
id, 10L, 7L, "Draft", "Notes", TaskStatus.TODO,
|
||||
LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user