feat(projects): replace numeric Intern inputs with picker
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# Web Test Evidence
|
||||
|
||||
## Requirement and scenario IDs
|
||||
|
||||
- AUTH-001, AUTH-002, AUTH-011; PRJ-001, PRJ-004, PRJ-005, PRJ-006, PRJ-017; UI-001, UI-005, UI-014, UI-018; TST-001 through TST-010.
|
||||
- AC-AUTH-001, AC-AUTH-010, AC-PRJ-001, AC-PRJ-003, AC-PRJ-009, AC-UI-005, AC-TST-001.
|
||||
|
||||
## Behavior under test
|
||||
|
||||
Project creation, direct member addition, and leadership reassignment render only server-provided eligible Intern choices. The native dialog picker exposes name, student code, and internship dates while numeric identifiers remain form values rather than visible labels. Local search, selection summaries, focus, apply, cancel, empty results, and retained server errors remain usable without adding a client API.
|
||||
|
||||
## Expected result derivation
|
||||
|
||||
The expected options are literal fixtures from the Account public DTO. Project membership history independently determines which eligible users are valid nonmembers or current-member leadership candidates. Native dialog controls keep server forms and CSRF as the mutation boundary.
|
||||
|
||||
## RED
|
||||
|
||||
`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run test:ui` executed the dependency-free interaction contract first: 1 test, 1 failure. Opening the picker left `dialog.open` undefined because no picker behavior existed.
|
||||
|
||||
The combined Java RED command was `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest,ProjectServiceIntegrationTest' test`. After correcting test-only assertion imports, test compilation failed only because the requested `ProjectService.addMembers(long,long,List<Long>)` API did not exist. Controller rendering RED will be rerun after that producer API compiles.
|
||||
|
||||
After the producer API compiled, `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest' test` ran 19 tests with 4 expected assertion failures for the missing eligible-option model, filtered multi-select markup, and retained selection rendering. A separate no-roster regression ran 1 test with 1 assertion failure because the disabled picker trigger had no reachable explanatory copy.
|
||||
|
||||
## GREEN
|
||||
|
||||
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest' test` passed the initial rendered picker suite at 19/19. After adding the no-roster regression, the affected Project command below passed the expanded controller suite at 20/20.
|
||||
|
||||
`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run test:ui` passed 1/1 executable tests with no failures, proving local name/student-code filtering, summary updates, initial search focus, apply retention, cancel rollback, and opener focus restoration.
|
||||
|
||||
`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run build` succeeded with Tailwind CSS 4.3.3 and the existing local icon builder. No dependency was added.
|
||||
|
||||
## Affected suite
|
||||
|
||||
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectControllerTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectServiceIntegrationTest,ProjectTaskMutationContextTest,LayerStructureTest' test` passed 38/38 tests with no failures, errors, or skips.
|
||||
|
||||
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw -DskipTests compile` succeeded. Project-scoped `javadoc:javadoc` with `-Ddoclint=all` succeeded; it retained four non-fatal default-constructor warnings, including pre-existing advice/query types. `git diff --check` passed.
|
||||
|
||||
## External boundaries
|
||||
|
||||
No browser loop or Impeccable detector is run on this branch; the root owner performs one integrated pass. MockMvc proves rendered semantics and a dependency-free Node test executes the dialog/search/selection behavior with controlled DOM boundaries. These pre-merge results will be rerun after merging the taskmaster-specified exact `main` SHA.
|
||||
+2
-1
@@ -8,7 +8,8 @@
|
||||
"scripts": {
|
||||
"build": "npm run build:css && npm run build:icons",
|
||||
"build:css": "tailwindcss -i src/main/frontend/app.css -o src/main/resources/static/assets/app.css --minify",
|
||||
"build:icons": "node src/main/frontend/build-icons.mjs"
|
||||
"build:icons": "node src/main/frontend/build-icons.mjs",
|
||||
"test:ui": "node --test src/test/js/*.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "4.3.3",
|
||||
|
||||
@@ -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