feat(projects): replace numeric Intern inputs with picker

This commit is contained in:
sechmachine
2026-08-15 15:02:03 +07:00
parent 64c9370aa0
commit 169da1a9f4
12 changed files with 509 additions and 36 deletions
+16
View File
@@ -120,6 +120,7 @@
.primary-action { margin-left: auto; }
.button { display: inline-flex; min-height: 2.35rem; align-items: center; justify-content: center; gap: .45rem; border: 1px solid var(--border-strong); border-radius: .5rem; padding: .5rem .8rem; background: var(--panel); color: var(--ink); font-weight: 650; text-decoration: none; cursor: pointer; }
.button-primary { border-color: var(--ink); background: var(--ink); color: var(--panel); }
.button:disabled { cursor: not-allowed; opacity: .55; }
.button-danger { border-color: color-mix(in srgb, var(--danger), transparent 65%); background: color-mix(in srgb, var(--danger), transparent 90%); color: var(--danger); }
.panel { border: 1px solid var(--border); border-radius: .75rem; background: var(--panel); box-shadow: 0 10px 28px rgb(20 25 35 / .06); }
.panel-header { padding: .9rem 1rem; border-bottom: 1px solid var(--border); }
@@ -170,6 +171,21 @@
.notification-menu { min-width: 18rem; padding: .75rem; }
dialog { max-width: 30rem; border: 1px solid var(--border); border-radius: .9rem; background: var(--panel); color: var(--ink); padding: 1.25rem; }
dialog::backdrop { background: rgb(0 0 0 / .45); }
.picker-trigger { justify-content: flex-start; }
.picker-summary { margin: 0; color: var(--muted); font-size: .78rem; }
.picker-drawer { width: min(32rem, 100%); max-width: 32rem; height: 100dvh; max-height: 100dvh; margin: 0 0 0 auto; border-radius: .9rem 0 0 .9rem; padding: 0; }
.picker-header, .picker-footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; }
.picker-header { border-bottom: 1px solid var(--border); }
.picker-header .field-help { margin: .2rem 0 0; }
.picker-body { display: grid; gap: .5rem; padding: 1rem; }
.picker-options { display: grid; gap: .5rem; margin-top: .5rem; }
.picker-option { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: .75rem; border: 1px solid var(--border); border-radius: .65rem; padding: .75rem; cursor: pointer; }
.picker-option:hover { border-color: var(--border-strong); background: var(--panel-muted); }
.picker-option input { margin-top: .2rem; }
.picker-option span { display: grid; gap: .18rem; min-width: 0; }
.picker-option small, .picker-empty { color: var(--muted); }
.picker-empty { margin: 1rem 0; text-align: center; }
.picker-footer { border-top: 1px solid var(--border); justify-content: flex-end; }
@keyframes pulse { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; } }
}
@@ -1,13 +1,21 @@
package com.lab.labtimesheet.feature.project.controller;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm;
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm;
import com.lab.labtimesheet.feature.project.model.dto.ProjectMembersForm;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import jakarta.validation.Valid;
import java.security.Principal;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -33,6 +41,8 @@ public class ProjectController {
private final ProjectQueryService pages;
private final ProjectService projects;
private final AccountService accounts;
private final Clock clock;
/**
* Lists only Projects visible to the authenticated actor and exposes Project creation only
@@ -64,6 +74,7 @@ public class ProjectController {
throw new ProjectAccessDeniedException();
}
model.addAttribute("projectForm", new ProjectCreateForm());
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
return "projects/form";
}
@@ -73,22 +84,30 @@ public class ProjectController {
* @param principal authenticated user
* @param projectForm validated browser input
* @param bindingResult binding and domain validation results
* @param model response model used when validation fails
* @return a redirect to the created Project, or the creation form on validation failure
*/
@PostMapping
public String create(
Principal principal,
@Valid @ModelAttribute("projectForm") ProjectCreateForm projectForm,
BindingResult bindingResult) {
BindingResult bindingResult,
Model model) {
var actor = pages.authenticatedActor(principal.getName());
if (!"MENTOR".equals(actor.role())) {
throw new ProjectAccessDeniedException();
}
if (bindingResult.hasErrors()) {
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
return "projects/form";
}
try {
long projectId = projects.create(actorId(principal), projectForm.toCommand());
long projectId = projects.create(actor.userId(), projectForm.toCommand());
return "redirect:/projects/" + projectId;
} catch (ProjectRuleViolationException exception) {
bindingResult.rejectValue(
"initialLeaderUserId", "project.initialLeader.ineligible", exception.getMessage());
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
return "projects/form";
}
}
@@ -139,41 +158,40 @@ public class ProjectController {
@GetMapping("/{projectId}/members")
public String members(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal);
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("members", pages.members(actorId, projectId));
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
populateMembersModel(actorId, projectId, model);
model.addAttribute("projectMembersForm", new ProjectMembersForm());
return "projects/members";
}
/**
* Adds an eligible Intern or re-renders membership history with the submitted identifier
* and a safe validation message.
* Adds all selected eligible Interns atomically or re-renders membership history with the
* complete retained selection and a safe validation message.
*
* @param principal authenticated user
* @param projectId owning Project identifier
* @param memberForm validated Intern selection
* @param membersForm validated Intern selection
* @param bindingResult binding and domain validation results
* @param model response model used on failure
* @return a membership redirect after success, or the membership view on validation failure
*/
@PostMapping("/{projectId}/members")
public String addMember(
public String addMembers(
Principal principal,
@PathVariable long projectId,
@Valid @ModelAttribute("projectMemberForm") ProjectMemberForm memberForm,
@Valid @ModelAttribute("projectMembersForm") ProjectMembersForm membersForm,
BindingResult bindingResult,
Model model) {
long actorId = actorId(principal);
if (!bindingResult.hasErrors()) {
try {
projects.addMember(actorId, projectId, memberForm.internUserId());
projects.addMembers(actorId, projectId, membersForm.internUserIds());
return "redirect:/projects/" + projectId + "/members";
} catch (ProjectRuleViolationException exception) {
bindingResult.rejectValue("internUserId", "project.member.ineligible", exception.getMessage());
bindingResult.rejectValue(
"internUserIds", "project.members.ineligible", exception.getMessage());
}
}
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("members", pages.members(actorId, projectId));
populateMembersModel(actorId, projectId, model);
return "projects/members";
}
@@ -189,8 +207,7 @@ public class ProjectController {
@GetMapping("/{projectId}/leadership")
public String leadership(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal);
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("leadership", pages.leadership(actorId, projectId));
populateLeadershipModel(actorId, projectId, model);
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
return "projects/leadership";
}
@@ -221,11 +238,45 @@ public class ProjectController {
bindingResult.rejectValue("internUserId", "project.leader.ineligible", exception.getMessage());
}
}
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("leadership", pages.leadership(actorId, projectId));
populateLeadershipModel(actorId, projectId, model);
return "projects/leadership";
}
private void populateMembersModel(long actorId, long projectId, Model model) {
var project = pages.detail(actorId, projectId);
var members = pages.members(actorId, projectId);
model.addAttribute("project", project);
model.addAttribute("members", members);
if (project.canManage()) {
Set<Long> currentMemberIds = members.stream()
.filter(member -> member.leftAt() == null)
.map(member -> member.internUserId())
.collect(Collectors.toUnmodifiableSet());
model.addAttribute("eligibleInternOptions", eligibleInternOptions().stream()
.filter(option -> !currentMemberIds.contains(option.userId()))
.toList());
}
}
private void populateLeadershipModel(long actorId, long projectId, Model model) {
var project = pages.detail(actorId, projectId);
model.addAttribute("project", project);
model.addAttribute("leadership", pages.leadership(actorId, projectId));
if (project.canManage()) {
Set<Long> replacementIds = pages.members(actorId, projectId).stream()
.filter(member -> member.leftAt() == null && !member.currentLeader())
.map(member -> member.internUserId())
.collect(Collectors.toUnmodifiableSet());
model.addAttribute("eligibleInternOptions", eligibleInternOptions().stream()
.filter(option -> replacementIds.contains(option.userId()))
.toList());
}
}
private List<EligibleInternOption> eligibleInternOptions() {
return accounts.eligibleInternOptions(LocalDate.now(clock));
}
private long actorId(Principal principal) {
return pages.authenticatedUserId(principal.getName());
}
@@ -0,0 +1,18 @@
package com.lab.labtimesheet.feature.project.model.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import java.util.List;
/**
* Browser form for one atomic owning-Mentor direct-add selection.
*
* @param internUserIds distinct positive Intern account identifiers selected in the picker
*/
public record ProjectMembersForm(@NotEmpty List<@Positive Long> internUserIds) {
/** Creates an empty form for the initial membership page. */
public ProjectMembersForm() {
this(List.of());
}
}
File diff suppressed because one or more lines are too long
+53
View File
@@ -37,4 +37,57 @@ document.addEventListener('DOMContentLoaded', () => {
try { localStorage.setItem('labtimesheet-sidebar', collapsed ? 'collapsed' : 'expanded'); }
catch (_) { /* Collapse still works for this page. */ }
});
document.querySelectorAll('[data-intern-picker]').forEach((picker) => {
const open = picker.querySelector('[data-picker-open]');
const dialog = picker.querySelector('[data-picker-dialog]');
const search = picker.querySelector('[data-picker-search]');
const summary = picker.querySelector('[data-picker-summary]');
const empty = picker.querySelector('[data-picker-empty]');
const cancel = picker.querySelector('[data-picker-cancel]');
const apply = picker.querySelector('[data-picker-apply]');
const options = [...picker.querySelectorAll('[data-picker-option]')];
let initialSelection = [];
const inputs = () => options.map((option) => option.querySelector('input'));
const updateSummary = () => {
const selected = options
.filter((option) => option.querySelector('input').checked)
.map((option) => option.querySelector('[data-picker-label]').textContent.trim());
summary.textContent = selected.length === 0
? `No Intern${inputs()[0]?.type === 'radio' ? '' : 's'} selected`
: `${selected.length} Intern${selected.length === 1 ? '' : 's'} selected: ${selected.join(', ')}`;
};
const filter = () => {
const query = search.value.trim().toLocaleLowerCase();
let visible = 0;
options.forEach((option) => {
option.hidden = !option.dataset.pickerSearch.toLocaleLowerCase().includes(query);
if (!option.hidden) visible += 1;
});
empty.hidden = visible !== 0;
};
const restore = () => {
inputs().forEach((input, index) => { input.checked = initialSelection[index]; });
updateSummary();
};
inputs().forEach((input) => input.addEventListener('change', updateSummary));
search.addEventListener('input', filter);
open.addEventListener('click', () => {
initialSelection = inputs().map((input) => input.checked);
search.value = '';
filter();
dialog.showModal();
search.focus();
});
cancel.addEventListener('click', () => {
restore();
dialog.close();
});
dialog.addEventListener('cancel', restore);
dialog.addEventListener('close', () => open.focus());
apply.addEventListener('click', () => dialog.close());
updateSummary();
});
});
@@ -26,10 +26,29 @@
<div class="form-grid form-grid-three">
<div class="field"><label class="field-label" for="startDate">Start date</label><input class="control" id="startDate" type="date" th:field="*{startDate}" required th:attr="aria-invalid=${#fields.hasErrors('startDate')},aria-describedby=${#fields.hasErrors('startDate') ? 'startDate-error' : null}"><p class="field-error" id="startDate-error" role="alert" th:if="${#fields.hasErrors('startDate')}" th:errors="*{startDate}">Start date error</p></div>
<div class="field"><label class="field-label" for="endDate">End date</label><input class="control" id="endDate" type="date" th:field="*{endDate}" required th:attr="aria-invalid=${#fields.hasErrors('endDate') or #fields.hasErrors('dateRangeValid')},aria-describedby=${#fields.hasErrors('endDate') ? 'endDate-error' : (#fields.hasErrors('dateRangeValid') ? 'dateRangeValid-error' : null)}"><p class="field-error" id="endDate-error" role="alert" th:if="${#fields.hasErrors('endDate')}" th:errors="*{endDate}">End date error</p><p class="field-error" id="dateRangeValid-error" role="alert" th:if="${#fields.hasErrors('dateRangeValid')}" th:errors="*{dateRangeValid}">Date range error</p></div>
<div class="field">
<label class="field-label" for="leader">Initial Leader user ID</label>
<input class="control" id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')},aria-describedby=${#fields.hasErrors('initialLeaderUserId') ? 'initialLeaderUserId-error' : null}">
<div class="field" data-intern-picker>
<span class="field-label">Initial Leader</span>
<button class="button picker-trigger" type="button" data-picker-open
th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')},aria-describedby=${#fields.hasErrors('initialLeaderUserId') ? 'initialLeaderUserId-error' : null}">Choose an eligible Intern</button>
<p class="picker-summary" data-picker-summary aria-live="polite">No Intern selected</p>
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible Interns are available.</p>
<p class="field-error" id="initialLeaderUserId-error" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="leader-picker-title">
<div class="picker-header"><div><h2 class="panel-title" id="leader-picker-title">Choose initial Leader</h2><p class="field-help">Only currently eligible Interns are available.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
<div class="picker-body">
<label class="field-label" for="leader-search">Search by name or Student Code</label>
<input class="control" id="leader-search" type="search" autocomplete="off" data-picker-search>
<div class="picker-options">
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
<input type="radio" th:field="*{initialLeaderUserId}" th:value="${option.userId}" required>
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
</label>
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible Interns.</p>
</div>
</div>
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
</dialog>
</div>
</div>
<div class="form-actions"><a class="button" th:href="@{/projects}">Cancel</a><button class="button button-primary" type="submit">Create Project</button></div>
@@ -20,10 +20,32 @@
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}">Leader</td><td th:text="${#temporals.format(term.startedAt, 'dd/MM/yyyy HH:mm')}">Started</td><td th:text="${term.endedAt == null ? 'Current' : #temporals.format(term.endedAt, 'dd/MM/yyyy HH:mm')}">Current</td></tr></tbody>
</table></div>
</section>
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}" th:object="${projectMemberForm}">
<form class="panel form-panel form-grid" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}" th:object="${projectMemberForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the Leader selection.</div>
<div class="field"><label class="field-label" for="leader">New Leader user ID</label><input class="control" id="leader" th:field="*{internUserId}" type="number" min="1" required th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'leadership-intern-user-error' : null}"><p class="field-error" id="leadership-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p></div>
<span></span><button class="button button-primary" type="submit">Change Leader</button>
<div class="field" data-intern-picker>
<span class="field-label">New Leader</span>
<button class="button picker-trigger" type="button" data-picker-open th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'leadership-intern-user-error' : null}">Choose a current member</button>
<p class="picker-summary" data-picker-summary aria-live="polite">No Intern selected</p>
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible current members are available.</p>
<p class="field-error" id="leadership-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p>
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="leadership-picker-title">
<div class="picker-header"><div><h2 class="panel-title" id="leadership-picker-title">Choose new Leader</h2><p class="field-help">Only eligible current members other than the current Leader are available.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
<div class="picker-body">
<label class="field-label" for="leadership-search">Search by name or Student Code</label>
<input class="control" id="leadership-search" type="search" autocomplete="off" data-picker-search>
<div class="picker-options">
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
<input type="radio" th:field="*{internUserId}" th:value="${option.userId}" required>
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
</label>
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible current members.</p>
</div>
</div>
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
</dialog>
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Change Leader</button></div>
</form>
</main>
</body>
@@ -20,10 +20,32 @@
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}">Intern</td><td th:text="${#temporals.format(member.joinedAt, 'dd/MM/yyyy HH:mm')}">Joined</td><td th:text="${member.leftAt == null ? 'Current' : #temporals.format(member.leftAt, 'dd/MM/yyyy HH:mm')}">Current</td><td><span class="badge" th:classappend="${member.currentLeader ? ' badge-success' : ''}" th:text="${member.currentLeader ? 'Leader' : 'Member'}">Member</span></td></tr></tbody>
</table></div>
</section>
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}" th:object="${projectMemberForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the member selection.</div>
<div class="field"><label class="field-label" for="intern">Intern user ID</label><input class="control" id="intern" th:field="*{internUserId}" type="number" min="1" required th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'member-intern-user-error' : null}"><p class="field-error" id="member-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p></div>
<span></span><button class="button button-primary" type="submit">Add member</button>
<form class="panel form-panel form-grid" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}" th:object="${projectMembersForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}"><strong>Member selection could not be saved.</strong><ul><li th:each="fieldError : ${#fields.allErrors()}" th:text="${fieldError}">Selection error</li></ul></div>
<div class="field" data-intern-picker>
<span class="field-label">Interns to add</span>
<button class="button picker-trigger" type="button" data-picker-open th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
th:attr="aria-invalid=${#fields.hasErrors('internUserIds')},aria-describedby=${#fields.hasErrors('internUserIds') ? 'member-intern-user-error' : null}">Choose eligible Interns</button>
<p class="picker-summary" data-picker-summary aria-live="polite">No Interns selected</p>
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible nonmembers are available.</p>
<p class="field-error" id="member-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserIds')}" th:errors="*{internUserIds}"></p>
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="member-picker-title">
<div class="picker-header"><div><h2 class="panel-title" id="member-picker-title">Add Project members</h2><p class="field-help">Select one or more eligible Interns who are not current members.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
<div class="picker-body">
<label class="field-label" for="member-search">Search by name or Student Code</label>
<input class="control" id="member-search" type="search" autocomplete="off" data-picker-search>
<div class="picker-options">
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
<input type="checkbox" th:field="*{internUserIds}" th:value="${option.userId}">
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
</label>
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible Interns.</p>
</div>
</div>
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
</dialog>
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Add selected members</button></div>
</form>
</main>
</body>
@@ -1,5 +1,7 @@
package com.lab.labtimesheet.feature.project.controller;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -17,6 +19,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
@@ -27,9 +31,12 @@ import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import java.time.Instant;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
@@ -50,9 +57,116 @@ class ProjectControllerTest {
@MockitoBean
private ProjectService projects;
@MockitoBean
private AccountService accounts;
@MockitoBean
private Clock clock;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@BeforeEach
void serverBusinessDate() {
when(clock.instant()).thenReturn(Instant.parse("2026-08-15T01:00:00Z"));
when(clock.getZone()).thenReturn(ZoneId.of("Asia/Ho_Chi_Minh"));
}
@Test
@WithMockUser(username = "mentor@example.test")
void projectCreationRendersSearchableEligibleLeaderOptionsWithoutVisibleNumericIds() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
option(20L, "Nguyen An", "STU-020"),
option(21L, "Tran Binh", "STU-021")));
mvc.perform(get("/projects/new"))
.andExpect(status().isOk())
.andExpect(model().attributeExists("eligibleInternOptions"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("data-intern-picker")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("type=\"radio\"")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("Nguyen An")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("STU-020")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("01/08/2026 31/12/2026")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("Initial Leader user ID"))));
}
@Test
@WithMockUser(username = "mentor@example.test")
void projectCreationExplainsWhenNoEligibleLeaderIsAvailable() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of());
mvc.perform(get("/projects/new"))
.andExpect(status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("No eligible Interns are available.")));
}
@Test
@WithMockUser(username = "mentor@example.test")
void memberAndLeadershipPickersExposeOnlyValidServerFilteredOptions() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
when(pages.members(10L, 30L)).thenReturn(List.of(
new ProjectMemberView(40L, 20L, "Current Leader", Instant.parse("2026-08-15T00:00:00Z"), null, true),
new ProjectMemberView(41L, 21L, "Current Member", Instant.parse("2026-08-15T00:00:00Z"), null, false)));
when(pages.leadership(10L, 30L)).thenReturn(List.of());
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
option(20L, "Current Leader", "STU-020"),
option(21L, "Current Member", "STU-021"),
option(22L, "Eligible Nonmember", "STU-022")));
String membersHtml = mvc.perform(get("/projects/30/members"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertTrue(membersHtml.contains("name=\"internUserIds\""));
assertTrue(membersHtml.contains("Eligible Nonmember"));
assertFalse(membersHtml.contains("data-picker-label>Current Member"));
String leadershipHtml = mvc.perform(get("/projects/30/leadership"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertTrue(leadershipHtml.contains("type=\"radio\""));
assertTrue(leadershipHtml.contains("Current Member"));
assertFalse(leadershipHtml.contains("Eligible Nonmember"));
assertFalse(leadershipHtml.contains("data-picker-label>Current Leader"));
}
@Test
@WithMockUser(username = "mentor@example.test")
void rejectedMemberBatchRetainsEverySelectionAndShowsRecoveryCopy() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
when(pages.members(10L, 30L)).thenReturn(List.of());
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
option(21L, "First Intern", "STU-021"),
option(22L, "Second Intern", "STU-022")));
doThrow(new ProjectRuleViolationException("One or more selected Interns are no longer eligible"))
.when(projects).addMembers(10L, 30L, List.of(21L, 22L));
mvc.perform(post("/projects/30/members")
.with(csrf())
.param("internUserIds", "21", "22"))
.andExpect(status().isOk())
.andExpect(view().name("projects/members"))
.andExpect(model().attributeHasFieldErrors("projectMembersForm", "internUserIds"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("One or more selected Interns are no longer eligible")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("value=\"21\" id=\"internUserIds1\" name=\"internUserIds\" checked=\"checked\"")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("value=\"22\" id=\"internUserIds2\" name=\"internUserIds\" checked=\"checked\"")));
}
@Test
@WithMockUser(username = "mentor@example.test")
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
@@ -144,7 +258,8 @@ class ProjectControllerTest {
@Test
@WithMockUser(username = "mentor@example.test")
void validCreateSubmissionUsesAuthenticatedMentorAndRedirectsToDetail() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(projects.create(
10L,
new ProjectCreateCommand(
@@ -217,6 +332,9 @@ class ProjectControllerTest {
@Test
@WithMockUser(username = "mentor@example.test")
void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
mvc.perform(post("/projects")
.with(csrf())
.param("name", " ")
@@ -239,6 +357,8 @@ class ProjectControllerTest {
@Test
@WithMockUser(username = "mentor@example.test")
void domainValidationErrorsStayOnTheirSafeFormsWithRetainedInput() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
when(pages.members(10L, 30L)).thenReturn(List.of(new ProjectMemberView(
@@ -255,7 +375,7 @@ class ProjectControllerTest {
99L)))
.thenThrow(new ProjectRuleViolationException("Intern must have an active account and internship"));
doThrow(new ProjectRuleViolationException("Intern is already a current Project member"))
.when(projects).addMember(10L, 30L, 20L);
.when(projects).addMembers(10L, 30L, List.of(20L));
doThrow(new ProjectRuleViolationException("Selected Intern is already the current Leader"))
.when(projects).changeLeader(10L, 30L, 20L);
@@ -274,12 +394,14 @@ class ProjectControllerTest {
mvc.perform(post("/projects/30/members")
.with(csrf())
.param("internUserId", "20"))
.param("internUserIds", "20"))
.andExpect(status().isOk())
.andExpect(view().name("projects/members"))
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
.andExpect(model().attributeHasFieldErrors("projectMembersForm", "internUserIds"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("value=\"20\"")));
.string(containsString("Intern is already a current Project member")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("value=\"20\""))));
mvc.perform(post("/projects/30/leadership")
.with(csrf())
@@ -288,7 +410,9 @@ class ProjectControllerTest {
.andExpect(view().name("projects/leadership"))
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("value=\"20\"")));
.string(containsString("Selected Intern is already the current Leader")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("value=\"20\""))));
}
@Test
@@ -388,4 +512,13 @@ class ProjectControllerTest {
"Current Leader",
true);
}
private static EligibleInternOption option(long userId, String name, String studentCode) {
return new EligibleInternOption(
userId,
name,
studentCode,
LocalDate.of(2026, 8, 1),
LocalDate.of(2026, 12, 31));
}
}
+98
View File
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
class Target {
listeners = new Map();
addEventListener(type, listener) {
this.listeners.set(type, listener);
}
dispatch(type) {
this.listeners.get(type)?.({preventDefault() {}, target: this});
}
}
test('picker searches name and student code, summarizes selection, and cancels safely', () => {
const open = Object.assign(new Target(), {focus() { this.focused = true; }});
const cancel = new Target();
const apply = new Target();
const search = Object.assign(new Target(), {value: '', focus() { this.focused = true; }});
const summary = {textContent: ''};
const empty = {hidden: true};
const firstInput = Object.assign(new Target(), {checked: false, type: 'checkbox'});
const secondInput = Object.assign(new Target(), {checked: false, type: 'checkbox'});
const options = [
option('Nguyen An STU-020', 'Nguyen An (STU-020)', firstInput),
option('Tran Binh STU-021', 'Tran Binh (STU-021)', secondInput),
];
const dialog = Object.assign(new Target(), {
showModal() { this.open = true; },
close() { this.open = false; this.dispatch('close'); },
});
const picker = {
querySelector(selector) {
return new Map([
['[data-picker-open]', open], ['[data-picker-dialog]', dialog],
['[data-picker-search]', search], ['[data-picker-summary]', summary],
['[data-picker-empty]', empty], ['[data-picker-cancel]', cancel],
['[data-picker-apply]', apply],
]).get(selector) ?? null;
},
querySelectorAll(selector) {
return selector === '[data-picker-option]' ? options : [];
},
};
let ready;
const document = {
documentElement: {dataset: {}, style: {}},
addEventListener(type, listener) { if (type === 'DOMContentLoaded') ready = listener; },
querySelector() { return null; },
querySelectorAll(selector) { return selector === '[data-intern-picker]' ? [picker] : []; },
};
vm.runInNewContext(readFileSync('src/main/resources/static/assets/app.js', 'utf8'), {
document,
localStorage: {getItem() { return null; }, setItem() {}, removeItem() {}},
matchMedia() { return {matches: false}; },
});
ready();
open.dispatch('click');
assert.equal(dialog.open, true);
assert.equal(search.focused, true);
search.value = 'stu-021';
search.dispatch('input');
assert.equal(options[0].hidden, true);
assert.equal(options[1].hidden, false);
assert.equal(empty.hidden, true);
secondInput.checked = true;
secondInput.dispatch('change');
assert.equal(summary.textContent, '1 Intern selected: Tran Binh (STU-021)');
cancel.dispatch('click');
assert.equal(secondInput.checked, false);
assert.equal(summary.textContent, 'No Interns selected');
assert.equal(open.focused, true);
open.dispatch('click');
secondInput.checked = true;
secondInput.dispatch('change');
apply.dispatch('click');
assert.equal(secondInput.checked, true);
});
function option(searchValue, label, input) {
return {
hidden: false,
dataset: {pickerSearch: searchValue},
querySelector(selector) {
if (selector === 'input') return input;
if (selector === '[data-picker-label]') return {textContent: label};
return null;
},
};
}