feat(projects): replace numeric Intern inputs with picker
This commit is contained in:
@@ -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; } }
|
||||
}
|
||||
|
||||
+69
-18
@@ -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
@@ -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>
|
||||
|
||||
+139
-6
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user