fix(projects): preserve server picker recovery

This commit is contained in:
sechmachine
2026-08-15 15:16:28 +07:00
parent 5df9eff21e
commit f3ffdab48e
6 changed files with 92 additions and 15 deletions
@@ -164,8 +164,8 @@ public class ProjectController {
}
/**
* Adds all selected eligible Interns atomically or re-renders membership history with the
* complete retained selection and a safe validation message.
* Adds all selected eligible Interns atomically or re-renders membership history with every
* still-eligible selection retained and a count of unavailable choices.
*
* @param principal authenticated user
* @param projectId owning Project identifier
@@ -182,16 +182,27 @@ public class ProjectController {
BindingResult bindingResult,
Model model) {
long actorId = actorId(principal);
boolean rejectedByService = false;
if (!bindingResult.hasErrors()) {
try {
projects.addMembers(actorId, projectId, membersForm.internUserIds());
return "redirect:/projects/" + projectId + "/members";
} catch (ProjectRuleViolationException exception) {
rejectedByService = true;
bindingResult.rejectValue(
"internUserIds", "project.members.ineligible", exception.getMessage());
}
}
populateMembersModel(actorId, projectId, model);
var refreshedOptions = populateMembersModel(actorId, projectId, model);
if (rejectedByService) {
Set<Long> refreshedIds = refreshedOptions.stream()
.map(EligibleInternOption::userId)
.collect(Collectors.toUnmodifiableSet());
long unavailableSelectionCount = membersForm.internUserIds().stream()
.filter(userId -> !refreshedIds.contains(userId))
.count();
model.addAttribute("unavailableSelectionCount", unavailableSelectionCount);
}
return "projects/members";
}
@@ -242,7 +253,7 @@ public class ProjectController {
return "projects/leadership";
}
private void populateMembersModel(long actorId, long projectId, Model model) {
private List<EligibleInternOption> populateMembersModel(long actorId, long projectId, Model model) {
var project = pages.detail(actorId, projectId);
var members = pages.members(actorId, projectId);
model.addAttribute("project", project);
@@ -252,10 +263,13 @@ public class ProjectController {
.filter(member -> member.leftAt() == null)
.map(member -> member.internUserId())
.collect(Collectors.toUnmodifiableSet());
model.addAttribute("eligibleInternOptions", eligibleInternOptions().stream()
var options = eligibleInternOptions().stream()
.filter(option -> !currentMemberIds.contains(option.userId()))
.toList());
.toList();
model.addAttribute("eligibleInternOptions", options);
return options;
}
return List.of();
}
private void populateLeadershipModel(long actorId, long projectId, Model model) {
@@ -41,7 +41,7 @@
<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>
<input type="radio" th:field="*{initialLeaderUserId}" 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>
@@ -36,7 +36,7 @@
<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>
<input type="radio" th:field="*{internUserId}" 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 current members.</p>
@@ -29,6 +29,8 @@
<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>
<p class="field-help" role="status" th:if="${unavailableSelectionCount != null and unavailableSelectionCount > 0}"
th:text="${unavailableSelectionCount == 1 ? '1 previously selected Intern is no longer eligible; choose a replacement.' : unavailableSelectionCount + ' previously selected Interns are no longer eligible; choose replacements.'}">Unavailable selection recovery</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">