fix(ui): add project and task error summaries

This commit is contained in:
sechmachine
2026-08-15 01:46:23 +07:00
parent cbdbd8ee13
commit c5c143c3cd
9 changed files with 71 additions and 14 deletions
+2 -2
View File
@@ -4,7 +4,7 @@
- **Requirement IDs:** `UI-001`, `UI-002`, `UI-004`, `UI-009`, `I1-PLAT-06`, `I1-UI-04` - **Requirement IDs:** `UI-001`, `UI-002`, `UI-004`, `UI-009`, `I1-PLAT-06`, `I1-UI-04`
- **Scenario IDs:** `AC-UI-001`, `AC-UI-002`, `AC-UI-005` - **Scenario IDs:** `AC-UI-001`, `AC-UI-002`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AccountTemplateIntegrationTest` - **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AccountTemplateIntegrationTest`
- **Implementation commit:** `pending` - **Implementation commits:** `7dd61b9`, `f48fc63`, `f9ddef6`
## Protected behavior ## Protected behavior
@@ -90,6 +90,7 @@ PostgreSQL 18.4
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS BUILD SUCCESS
Total time: 18.361 s Total time: 18.361 s
```
Bootstrap-specific affected suite: Bootstrap-specific affected suite:
@@ -100,7 +101,6 @@ Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS BUILD SUCCESS
Total time: 17.350 s Total time: 17.350 s
``` ```
```
## External-test boundaries ## External-test boundaries
@@ -4,7 +4,7 @@
- **Requirement IDs:** `UI-001`, `UI-002`, `UI-003`, `UI-008`, `I1-ATT-03`, `I1-UI-04` - **Requirement IDs:** `UI-001`, `UI-002`, `UI-003`, `UI-008`, `I1-ATT-03`, `I1-UI-04`
- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-UI-001`, `AC-UI-005` - **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-UI-001`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest` - **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest`
- **Implementation commit:** `pending` - **Implementation commit:** `3064485`
## Protected behavior ## Protected behavior
@@ -4,11 +4,12 @@
- **Requirement IDs:** `UI-003`, `UI-004`, `UI-007`, `UI-009`, `UI-013`, `I1-UI-04` - **Requirement IDs:** `UI-003`, `UI-004`, `UI-007`, `UI-009`, `UI-013`, `I1-UI-04`
- **Scenario IDs:** `AC-UI-002`, `AC-UI-003`, `AC-UI-005` - **Scenario IDs:** `AC-UI-002`, `AC-UI-003`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.ProjectTaskShellContractTest#projectAndTaskPageUsesSharedDesktopShell`, `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest`, `com.lab.labtimesheet.feature.task.controller.TaskControllerTest` - **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.ProjectTaskShellContractTest#projectAndTaskPageUsesSharedDesktopShell`, `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest`, `com.lab.labtimesheet.feature.task.controller.TaskControllerTest`
- **Implementation commit:** `pending` - **Implementation and final-Project integration commits:** `401f676`, `4849e0b`
## Protected behavior ## Protected behavior
Every Iteration 1 Project and Task page uses the same authenticated desktop shell, local assets, role-aware Project navigation, table containment, form controls, empty states, status badges, and `dd/MM/yyyy` date presentation. Existing capability-gated actions, server routes, validation, authentication, and CSRF contracts remain unchanged. Every Iteration 1 Project and Task page uses the same authenticated desktop shell, local assets, role-aware Project navigation, table containment, form controls, empty states, status badges, and `dd/MM/yyyy` date presentation. Existing capability-gated actions, server routes, validation, authentication, and CSRF contracts remain unchanged.
Project and Task forms provide both an error summary and inline field errors for failed server validation.
## Test method ## Test method
@@ -70,7 +71,36 @@ BUILD SUCCESS
Total time: 4.020 s Total time: 4.020 s
``` ```
The first affected attempt additionally caught Thymeleaf trying to resolve a `null` action fragment on five pages. Replacing `null` with Thymeleaf's empty fragment token made the identical 25-test command green. The first affected attempt additionally caught Thymeleaf trying to resolve a `null` action fragment on five pages. Replacing `null` with Thymeleaf's empty fragment token made the identical 25-test command green. After merging the final Project activation pin, the one overlapping detail template retained both the shell and the capability-gated activation form; the focused Project/shell set passed 25 tests.
Final-review form-summary regression:
```text
./mvnw -Dtest=ProjectTaskShellContractTest test
RED: Tests run: 10, Failures: 2, Errors: 0, Skipped: 0
Both forms were missing #fields.hasAnyErrors() and #fields.allErrors().
./mvnw -Dtest=ProjectTaskShellContractTest,ProjectControllerTest,TaskControllerTest test
GREEN: Tests run: 29, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 4.214 s
```
## Final delivery gate
```text
npm ci
added 34 packages; audited 35 packages; 0 vulnerabilities
npm run build
Tailwind CSS v4.3.3: Done in 68ms
./mvnw test
PostgreSQL 18.4 via Testcontainers
Tests run: 152, Failures: 0, Errors: 0, Skipped: 0
36 Surefire reports
BUILD SUCCESS
```
## External-test boundaries ## External-test boundaries
+12 -4
View File
@@ -3,8 +3,8 @@
- **Test type:** Web and unit - **Test type:** Web and unit
- **Requirement IDs:** `AUTH-003`, `UI-003`, `UI-013`, `I1-UI-03` - **Requirement IDs:** `AUTH-003`, `UI-003`, `UI-013`, `I1-UI-03`
- **Scenario IDs:** `AC-AUTH-002`, `AC-UI-005` - **Scenario IDs:** `AC-AUTH-002`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.service.DashboardServiceTest`, `com.lab.labtimesheet.feature.reporting.controller.DashboardControllerWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AdminDashboardWebTest`, `com.lab.labtimesheet.feature.reporting.ReportingArchitectureTest` - **Test class/method:** `com.lab.labtimesheet.feature.reporting.service.DashboardServiceTest`, `com.lab.labtimesheet.feature.reporting.controller.DashboardControllerWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AdminDashboardWebTest`, `com.lab.labtimesheet.feature.reporting.controller.RoleDashboardWebIntegrationTest`, `com.lab.labtimesheet.feature.reporting.ReportingArchitectureTest`
- **Implementation commit:** `pending` - **Implementation and integration-test commits:** `b1c6b17`, `cbdbd8e`
## Protected behavior ## Protected behavior
@@ -12,7 +12,7 @@
## Test method ## Test method
The unit test supplies mocked concrete public feature services to the reporting coordinator and independently checks the exact Admin, Mentor, and Intern view DTOs, including Task-status and attendance-state translation. Negative cases prove that a forged authority, locked account, missing account, or inactive internship cannot produce a dashboard. The MVC slice proves role-to-template routing and authentication. The PostgreSQL web test bootstraps a real Admin through `BootstrapService` and exercises the complete authenticated route without SQL fixtures. The unit test supplies mocked concrete public feature services to the reporting coordinator and independently checks the exact Admin, Mentor, and Intern view DTOs, including Task-status and attendance-state translation. Negative cases prove that a forged authority, locked account, missing account, or inactive internship cannot produce a dashboard. The MVC slice proves role-to-template routing and authentication. PostgreSQL web tests bootstrap a real Admin and create/activate Mentor and Intern identities, SMTP configuration, a Project, and a Task only through public application services; they then exercise all three authenticated dashboard roles without repository, entity, JDBC, or SQL fixtures.
## Hand-derived expected result ## Hand-derived expected result
@@ -74,8 +74,16 @@ PostgreSQL 18.4 via Testcontainers
Tests run: 18, Failures: 0, Errors: 0, Skipped: 0 Tests run: 18, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS BUILD SUCCESS
Total time: 11.409 s Total time: 11.409 s
Production-shaped Mentor/Intern query journey:
./mvnw -Dtest=RoleDashboardWebIntegrationTest test
PostgreSQL 18.4 via Testcontainers
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 11.119 s
``` ```
## External-test boundaries ## External-test boundaries
The focused tests prove reporting composition, route selection, denial behavior, and one production-shaped Admin journey. Feature-owned suites separately prove the Project, Task, Attendance, and Account query semantics. Browser viewport behavior remains an integrated UI gate. The focused tests prove reporting composition, route selection, denial behavior, and production-shaped Admin, Mentor, and Intern journeys. Feature-owned suites separately prove additional Project, Task, Attendance, and Account query semantics. Browser viewport behavior remains an external UI boundary.
+1 -1
View File
@@ -4,7 +4,7 @@
- **Requirement IDs:** `UI-005`, `UI-006`, `UI-010`, `UI-018`, `I1-UI-02` - **Requirement IDs:** `UI-005`, `UI-006`, `UI-010`, `UI-018`, `I1-UI-02`
- **Scenario IDs:** `AC-UI-003`, `AC-UI-005` - **Scenario IDs:** `AC-UI-003`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest#themeTokensMeetTextFocusAndMeaningfulBoundaryContrast` - **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest#themeTokensMeetTextFocusAndMeaningfulBoundaryContrast`
- **Implementation commit:** `pending` - **Implementation commit:** `3343745`
## Protected behavior ## Protected behavior
File diff suppressed because one or more lines are too long
@@ -10,6 +10,10 @@
<main> <main>
<p class="page-description">Define the initial Project window and Leader. Membership changes remain server-authorized.</p> <p class="page-description">Define the initial Project window and Leader. Membership changes remain server-authorized.</p>
<form class="panel form-panel form-grid" method="post" th:action="@{/projects}" th:object="${projectForm}"> <form class="panel form-panel form-grid" method="post" th:action="@{/projects}" th:object="${projectForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">
<strong>Please correct the highlighted fields.</strong>
<ul><li th:each="error : ${#fields.allErrors()}" th:text="${error}">Validation error</li></ul>
</div>
<div class="field"> <div class="field">
<label class="field-label" for="name">Name</label> <label class="field-label" for="name">Name</label>
<input class="control" id="name" th:field="*{name}" required maxlength="160" th:attr="aria-invalid=${#fields.hasErrors('name')}"> <input class="control" id="name" th:field="*{name}" required maxlength="160" th:attr="aria-invalid=${#fields.hasErrors('name')}">
@@ -20,8 +24,8 @@
<textarea class="control" id="description" rows="5" th:field="*{description}"></textarea> <textarea class="control" id="description" rows="5" th:field="*{description}"></textarea>
</div> </div>
<div class="form-grid form-grid-three"> <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></div> <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')}"><p class="field-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></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')}"><p class="field-error" role="alert" th:if="${#fields.hasErrors('endDate')}" th:errors="*{endDate}">End date error</p><p class="field-error" role="alert" th:if="${#fields.hasErrors('dateRangeValid')}" th:errors="*{dateRangeValid}">Date range error</p></div>
<div class="field"> <div class="field">
<label class="field-label" for="leader">Initial Leader user ID</label> <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')}"> <input class="control" id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')}">
+2 -1
View File
@@ -10,11 +10,12 @@
<main> <main>
<p class="page-description">Assign work to a current eligible Project member.</p> <p class="page-description">Assign work to a current eligible Project member.</p>
<form class="panel form-panel form-grid" method="post" th:action="@{/projects/{projectId}/tasks(projectId=${projectId})}" th:object="${taskForm}"> <form class="panel form-panel form-grid" method="post" th:action="@{/projects/{projectId}/tasks(projectId=${projectId})}" th:object="${taskForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}"><strong>Please correct the highlighted fields.</strong><ul><li th:each="error : ${#fields.allErrors()}" th:text="${error}">Validation error</li></ul></div>
<div class="field"><label class="field-label" for="title">Title</label><input class="control" id="title" type="text" maxlength="200" required th:field="*{title}" th:attr="aria-invalid=${#fields.hasErrors('title')}"><p class="field-error" role="alert" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</p></div> <div class="field"><label class="field-label" for="title">Title</label><input class="control" id="title" type="text" maxlength="200" required th:field="*{title}" th:attr="aria-invalid=${#fields.hasErrors('title')}"><p class="field-error" role="alert" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</p></div>
<div class="field"><label class="field-label" for="description">Description</label><textarea class="control" id="description" rows="5" th:field="*{description}"></textarea></div> <div class="field"><label class="field-label" for="description">Description</label><textarea class="control" id="description" rows="5" th:field="*{description}"></textarea></div>
<div class="form-grid form-grid-three"> <div class="form-grid form-grid-three">
<div class="field"><label class="field-label" for="assigneeMembershipId">Assignee</label><select class="control" id="assigneeMembershipId" required th:field="*{assigneeMembershipId}" th:attr="aria-invalid=${#fields.hasErrors('assigneeMembershipId')}"><option value="">Select an assignee</option><option th:each="assignee : ${assignees}" th:value="${assignee.membershipId}" th:text="${assignee.displayName}">Member</option></select><p class="field-error" role="alert" th:if="${#fields.hasErrors('assigneeMembershipId')}" th:errors="*{assigneeMembershipId}">Assignee error</p></div> <div class="field"><label class="field-label" for="assigneeMembershipId">Assignee</label><select class="control" id="assigneeMembershipId" required th:field="*{assigneeMembershipId}" th:attr="aria-invalid=${#fields.hasErrors('assigneeMembershipId')}"><option value="">Select an assignee</option><option th:each="assignee : ${assignees}" th:value="${assignee.membershipId}" th:text="${assignee.displayName}">Member</option></select><p class="field-error" role="alert" th:if="${#fields.hasErrors('assigneeMembershipId')}" th:errors="*{assigneeMembershipId}">Assignee error</p></div>
<div class="field"><label class="field-label" for="dueDate">Due date</label><input class="control" id="dueDate" type="date" th:field="*{dueDate}"></div> <div class="field"><label class="field-label" for="dueDate">Due date</label><input class="control" id="dueDate" type="date" th:field="*{dueDate}" th:attr="aria-invalid=${#fields.hasErrors('dueDate')}"><p class="field-error" role="alert" th:if="${#fields.hasErrors('dueDate')}" th:errors="*{dueDate}">Due date error</p></div>
</div> </div>
<div class="form-actions"><a class="button" th:href="@{/projects/{projectId}/tasks(projectId=${projectId})}">Cancel</a><button class="button button-primary" type="submit">Create Task</button></div> <div class="form-actions"><a class="button" th:href="@{/projects/{projectId}/tasks(projectId=${projectId})}">Cancel</a><button class="button button-primary" type="submit">Create Task</button></div>
</form> </form>
@@ -24,6 +24,16 @@ class ProjectTaskShellContractTest {
.doesNotContain("<head>"); .doesNotContain("<head>");
} }
@ParameterizedTest
@MethodSource("projectAndTaskForms")
void projectAndTaskFormProvidesAnErrorSummary(String relativeTemplate) throws IOException {
String template = Files.readString(TEMPLATES.resolve(relativeTemplate));
assertThat(template)
.contains("#fields.hasAnyErrors()")
.contains("#fields.allErrors()");
}
private static Stream<String> projectAndTaskTemplates() { private static Stream<String> projectAndTaskTemplates() {
return Stream.of( return Stream.of(
"projects/list.html", "projects/list.html",
@@ -35,4 +45,8 @@ class ProjectTaskShellContractTest {
"tasks/form.html", "tasks/form.html",
"tasks/detail.html"); "tasks/detail.html");
} }
private static Stream<String> projectAndTaskForms() {
return Stream.of("projects/form.html", "tasks/form.html");
}
} }