feat(ui): integrate project and task pages with shell

This commit is contained in:
sechmachine
2026-08-15 01:35:30 +07:00
parent b1c6b170d0
commit 401f67671a
12 changed files with 297 additions and 136 deletions
@@ -0,0 +1,77 @@
# Test Evidence: Project and Task shared-shell integration
- **Test type:** Web
- **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`
- **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`
## 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.
## Test method
The focused parameterized contract checks all five Project and three Task production templates for shared-shell composition and the active Project navigation marker. The affected Project and Task MVC slices then render the production templates through their real controllers while mocking only their feature service boundary, exercising route selection, authorization, form binding, validation, and action visibility.
## Hand-derived expected result
All eight templates reference `fragments/layout :: shell`, identify `projects` as the active navigation section, and contain no duplicate page `<head>`. Mentor-only Project and Task creation controls remain capability-gated; Project members and leadership management stay hidden from non-managers; Task status/comment controls stay hidden when their capability flag is false. Empty Task progress remains `N/A`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectTaskShellContractTest test
```
**Observed result**
```text
Tests run: 8, Failures: 8, Errors: 0, Skipped: 0
Each standalone Project and Task template was missing "fragments/layout :: shell(".
BUILD FAILURE
Total time: 3.455 s
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
npm run build
./mvnw -Dtest=ProjectTaskShellContractTest test
```
**Observed result**
```text
Tailwind CSS v4.3.3: Done in 63ms
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectTaskShellContractTest,ProjectControllerTest,TaskControllerTest test
Tests run: 25, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
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.
## External-test boundaries
The MVC slices verify server-rendered markup and security/control contracts but do not emulate a browser viewport or visually compare illustrative mockups. PostgreSQL query and mutation behavior remains covered by the feature-owned integration suites; final asset reproducibility and the full PostgreSQL suite are separate delivery gates.
File diff suppressed because one or more lines are too long
@@ -23,11 +23,10 @@
<svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#layout-dashboard}"></use></svg><span class="sidebar-label">Overview</span></a></li> <svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#layout-dashboard}"></use></svg><span class="sidebar-label">Overview</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/accounts/new}" th:attr="aria-current=${activeNav == 'accounts'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#users}"></use></svg><span class="sidebar-label">Accounts</span></a></li> <li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/accounts/new}" th:attr="aria-current=${activeNav == 'accounts'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#users}"></use></svg><span class="sidebar-label">Accounts</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/attendance/calendar}" th:attr="aria-current=${activeNav == 'calendar'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#calendar-days}"></use></svg><span class="sidebar-label">Global calendar</span></a></li> <li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/attendance/calendar}" th:attr="aria-current=${activeNav == 'calendar'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#calendar-days}"></use></svg><span class="sidebar-label">Global calendar</span></a></li>
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/projects}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">Owned Projects</span></a></li> <li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/projects}" th:attr="aria-current=${activeNav == 'projects'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">Owned Projects</span></a></li>
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/attendance}" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">Intern attendance</span></a></li> <li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/attendance}" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">Intern attendance</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance/me}" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">My attendance</span></a></li> <li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance/me}" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">My attendance</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/projects}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">My Projects</span></a></li> <li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/projects}" th:attr="aria-current=${activeNav == 'projects'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">My Projects</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/tasks}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#list-check}"></use></svg><span class="sidebar-label">Assigned Tasks</span></a></li>
</ul> </ul>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -1,12 +1,27 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org"
<head><meta charset="utf-8"><title th:text="${project.name}">Project</title></head> th:replace="~{fragments/layout :: shell(
pageTitle=${project.name},
section='Projects',
activeNav='projects',
primaryAction=~{::#primary-action},
content=~{::main})}">
<body> <body>
<a id="primary-action" class="button button-primary" th:href="@{/projects/{id}/tasks(id=${project.id})}">View Tasks</a>
<main> <main>
<h1 th:text="${project.name}">Project</h1> <p class="page-description" th:text="${project.description == null || #strings.isEmpty(project.description) ? 'No Project description.' : project.description}">Project description.</p>
<p th:text="${project.description}"></p> <section class="panel metric-strip" aria-label="Project details">
<dl><dt>Status</dt><dd th:text="${project.status}"></dd><dt>Mentor</dt><dd th:text="${project.mentorName}"></dd><dt>Leader</dt><dd th:text="${project.leaderName}"></dd></dl> <div class="metric"><div class="metric-label">Status</div><div class="metric-value"><span class="badge" th:classappend="${project.status == 'ACTIVE' ? ' badge-success' : ''}" th:text="${project.status}">PLANNED</span></div></div>
<nav aria-label="Project sections"><a th:href="@{/projects/{id}/members(id=${project.id})}">Members</a> <a th:href="@{/projects/{id}/leadership(id=${project.id})}">Leadership</a></nav> <div class="metric"><div class="metric-label">Mentor</div><div class="metric-value" th:text="${project.mentorName}">Mentor</div></div>
<div class="metric"><div class="metric-label">Leader</div><div class="metric-value" th:text="${project.leaderName}">Leader</div></div>
<div class="metric"><div class="metric-label">Project window</div><div class="metric-value" th:text="${#temporals.format(project.startDate, 'dd/MM/yyyy') + ' ' + #temporals.format(project.endDate, 'dd/MM/yyyy')}">15/08/2026 30/09/2026</div></div>
</section>
<nav class="tabs" aria-label="Project sections">
<a class="tab" th:href="@{/projects/{id}(id=${project.id})}" aria-current="page">Overview</a>
<a class="tab" th:href="@{/projects/{id}/members(id=${project.id})}">Members</a>
<a class="tab" th:href="@{/projects/{id}/leadership(id=${project.id})}">Leadership</a>
<a class="tab" th:href="@{/projects/{id}/tasks(id=${project.id})}">Tasks</a>
</nav>
</main> </main>
</body> </body>
</html> </html>
+29 -13
View File
@@ -1,19 +1,35 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org"
<head><meta charset="utf-8"><title>Create Project</title></head> th:replace="~{fragments/layout :: shell(
pageTitle='Create Project',
section='Projects',
activeNav='projects',
primaryAction=~{},
content=~{::main})}">
<body> <body>
<main> <main>
<h1>Create Project</h1> <p class="page-description">Define the initial Project window and Leader. Membership changes remain server-authorized.</p>
<form method="post" th:action="@{/projects}" th:object="${projectForm}"> <form class="panel form-panel form-grid" method="post" th:action="@{/projects}" th:object="${projectForm}">
<label for="name">Name</label><input id="name" th:field="*{name}" required maxlength="160"> <div class="field">
<p th:errors="*{name}"></p> <label class="field-label" for="name">Name</label>
<label for="description">Description</label><textarea id="description" th:field="*{description}"></textarea> <input class="control" id="name" th:field="*{name}" required maxlength="160" th:attr="aria-invalid=${#fields.hasErrors('name')}">
<label for="startDate">Start date</label><input id="startDate" type="date" th:field="*{startDate}" required> <p class="field-error" role="alert" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name error</p>
<label for="endDate">End date</label><input id="endDate" type="date" th:field="*{endDate}" required> </div>
<label for="leader">Initial Leader user ID</label><input id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required> <div class="field">
<p th:errors="*{initialLeaderUserId}"></p> <label class="field-label" for="description">Description</label>
<button type="submit">Create Project</button> <textarea class="control" id="description" rows="5" th:field="*{description}"></textarea>
</form> </div>
<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="endDate">End date</label><input class="control" id="endDate" type="date" th:field="*{endDate}" required></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')}">
<p class="field-error" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
</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>
</form>
</main> </main>
</body> </body>
</html> </html>
@@ -1,13 +1,29 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org"
<head><meta charset="utf-8"><title>Project leadership</title></head> th:replace="~{fragments/layout :: shell(
pageTitle=${project.name + ' leadership'},
section='Projects',
activeNav='projects',
primaryAction=~{},
content=~{::main})}">
<body> <body>
<main> <main>
<h1><span th:text="${project.name}">Project</span> leadership</h1> <nav class="tabs" aria-label="Project sections">
<table><caption>Leadership history</caption><thead><tr><th scope="col">Leader</th><th scope="col">Started</th><th scope="col">Ended</th></tr></thead> <a class="tab" th:href="@{/projects/{id}(id=${project.id})}">Overview</a>
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}"></td><td th:text="${term.startedAt}"></td><td th:text="${term.endedAt}"></td></tr></tbody> <a class="tab" th:href="@{/projects/{id}/members(id=${project.id})}">Members</a>
</table> <a class="tab" th:href="@{/projects/{id}/leadership(id=${project.id})}" aria-current="page">Leadership</a>
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}"><label for="leader">New Leader user ID</label><input id="leader" name="internUserId" type="number" min="1" required><button type="submit">Change Leader</button></form> <a class="tab" th:href="@{/projects/{id}/tasks(id=${project.id})}">Tasks</a>
</nav>
<section class="panel form-panel" th:if="${#lists.isEmpty(leadership)}"><th:block th:replace="~{fragments/components :: empty('No leadership history', 'Leadership terms will appear here.')} "></th:block></section>
<section class="panel form-panel" th:unless="${#lists.isEmpty(leadership)}">
<div class="table-scroll"><table class="data-table"><caption class="sr-only">Leadership history</caption><thead><tr><th scope="col">Leader</th><th scope="col">Started</th><th scope="col">Ended</th></tr></thead>
<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})}">
<div class="field"><label class="field-label" for="leader">New Leader user ID</label><input class="control" id="leader" name="internUserId" type="number" min="1" required></div>
<span></span><button class="button button-primary" type="submit">Change Leader</button>
</form>
</main> </main>
</body> </body>
</html> </html>
+26 -14
View File
@@ -1,20 +1,32 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org"
<head><meta charset="utf-8"><title>Projects</title></head> th:replace="~{fragments/layout :: shell(
pageTitle='Projects',
section='Projects',
activeNav='projects',
primaryAction=~{::#primary-action},
content=~{::main})}">
<body> <body>
<a id="primary-action" class="button button-primary" th:if="${canCreateProject}" th:href="@{/projects/new}">Create Project</a>
<main> <main>
<h1>Projects</h1> <p class="page-description">Projects visible to your current role and membership.</p>
<a th:if="${canCreateProject}" href="/projects/new">Create Project</a> <section class="panel" th:if="${#lists.isEmpty(projects)}">
<p th:if="${#lists.isEmpty(projects)}">No authorized Projects.</p> <th:block th:replace="~{fragments/components :: empty('No authorized Projects', 'Projects you can access will appear here.')} "></th:block>
<table th:unless="${#lists.isEmpty(projects)}"> </section>
<caption>Authorized Projects</caption> <section class="panel" th:unless="${#lists.isEmpty(projects)}">
<thead><tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Dates</th></tr></thead> <div class="table-scroll">
<tbody><tr th:each="project : ${projects}"> <table class="data-table">
<td><a th:href="@{/projects/{id}(id=${project.id})}" th:text="${project.name}">Project</a></td> <caption class="sr-only">Authorized Projects</caption>
<td th:text="${project.status}">PLANNED</td> <thead><tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Start</th><th scope="col">End</th></tr></thead>
<td><span th:text="${project.startDate}">Start</span> <span th:text="${project.endDate}">End</span></td> <tbody><tr th:each="project : ${projects}">
</tr></tbody> <td><a th:href="@{/projects/{id}(id=${project.id})}" th:text="${project.name}">Project</a></td>
</table> <td><span class="badge" th:classappend="${project.status == 'ACTIVE' ? ' badge-success' : ''}" th:text="${project.status}">PLANNED</span></td>
<td th:text="${#temporals.format(project.startDate, 'dd/MM/yyyy')}">15/08/2026</td>
<td th:text="${#temporals.format(project.endDate, 'dd/MM/yyyy')}">30/09/2026</td>
</tr></tbody>
</table>
</div>
</section>
</main> </main>
</body> </body>
</html> </html>
@@ -1,13 +1,29 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org"
<head><meta charset="utf-8"><title>Project members</title></head> th:replace="~{fragments/layout :: shell(
pageTitle=${project.name + ' members'},
section='Projects',
activeNav='projects',
primaryAction=~{},
content=~{::main})}">
<body> <body>
<main> <main>
<h1><span th:text="${project.name}">Project</span> members</h1> <nav class="tabs" aria-label="Project sections">
<table><caption>Membership history</caption><thead><tr><th scope="col">Intern</th><th scope="col">Joined</th><th scope="col">Left</th><th scope="col">Role</th></tr></thead> <a class="tab" th:href="@{/projects/{id}(id=${project.id})}">Overview</a>
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}"></td><td th:text="${member.joinedAt}"></td><td th:text="${member.leftAt}"></td><td th:text="${member.currentLeader} ? 'Leader' : 'Member'"></td></tr></tbody> <a class="tab" th:href="@{/projects/{id}/members(id=${project.id})}" aria-current="page">Members</a>
</table> <a class="tab" th:href="@{/projects/{id}/leadership(id=${project.id})}">Leadership</a>
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}"><label for="intern">Intern user ID</label><input id="intern" name="internUserId" type="number" min="1" required><button type="submit">Add member</button></form> <a class="tab" th:href="@{/projects/{id}/tasks(id=${project.id})}">Tasks</a>
</nav>
<section class="panel form-panel" th:if="${#lists.isEmpty(members)}"><th:block th:replace="~{fragments/components :: empty('No membership history', 'Project members will appear here.')} "></th:block></section>
<section class="panel form-panel" th:unless="${#lists.isEmpty(members)}">
<div class="table-scroll"><table class="data-table"><caption class="sr-only">Membership history</caption><thead><tr><th scope="col">Intern</th><th scope="col">Joined</th><th scope="col">Left</th><th scope="col">Role</th></tr></thead>
<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})}">
<div class="field"><label class="field-label" for="intern">Intern user ID</label><input class="control" id="intern" name="internUserId" type="number" min="1" required></div>
<span></span><button class="button button-primary" type="submit">Add member</button>
</form>
</main> </main>
</body> </body>
</html> </html>
+21 -29
View File
@@ -1,36 +1,28 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org"
<head> th:replace="~{fragments/layout :: shell(
<meta charset="utf-8"> pageTitle=${details.task.title},
<meta name="viewport" content="width=device-width, initial-scale=1"> section='Projects',
<title th:text="${details.task.title}">Task</title> activeNav='projects',
</head> primaryAction=~{},
content=~{::main})}">
<body> <body>
<main> <main>
<h1 th:text="${details.task.title}">Task</h1> <p class="page-description" th:text="${details.task.description == null || #strings.isEmpty(details.task.description) ? 'No Task description.' : details.task.description}">Task description.</p>
<p th:text="${details.task.description ?: 'No description'}">No description</p> <section class="panel metric-strip metric-strip-three" aria-label="Task details">
<p>Assignee: <strong th:text="${details.task.assigneeName}">Assignee</strong></p> <div class="metric"><div class="metric-label">Assignee</div><div class="metric-value" th:text="${details.task.assigneeName}">Assignee</div></div>
<p>Status: <strong th:text="${details.task.status}">TODO</strong></p> <div class="metric"><div class="metric-label">Status</div><div class="metric-value"><span class="badge" th:classappend="${details.task.status.name() == 'BLOCKED' ? ' badge-warning' : details.task.status.name() == 'DONE' ? ' badge-success' : ''}" th:text="${details.task.status}">TODO</span></div></div>
<p>Due date: <span th:text="${details.task.dueDate ?: '—'}"></span></p> <div class="metric"><div class="metric-label">Due date</div><div class="metric-value" th:text="${details.task.dueDate == null ? 'No due date' : #temporals.format(details.task.dueDate, 'dd/MM/yyyy')}">No due date</div></div>
</section>
<form th:if="${details.canChangeStatus}" method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/status(projectId=${projectId},taskId=${details.task.id})}"> <form class="panel form-panel filter-form" th:if="${details.canChangeStatus}" method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/status(projectId=${projectId},taskId=${details.task.id})}">
<label for="status">New status</label> <div class="field"><label class="field-label" for="status">New status</label><select class="control" id="status" name="status" required><option th:each="status : ${statuses}" th:value="${status}" th:text="${status}" th:selected="${status == details.task.status}">TODO</option></select></div>
<select id="status" name="status" required> <span></span><button class="button button-primary" type="submit">Change status</button>
<option th:each="status : ${statuses}" th:value="${status}" th:text="${status}">TODO</option>
</select>
<button type="submit">Change status</button>
</form> </form>
<section class="panel form-panel" aria-labelledby="comments-heading">
<section aria-labelledby="comments-heading"> <header class="panel-header"><h2 class="panel-title" id="comments-heading">Comments</h2></header>
<h2 id="comments-heading">Comments</h2> <div th:if="${#lists.isEmpty(details.comments)}"><th:block th:replace="~{fragments/components :: empty('No comments', 'Task discussion will appear here.')} "></th:block></div>
<ol> <div class="table-scroll" th:unless="${#lists.isEmpty(details.comments)}"><table class="data-table"><caption class="sr-only">Task comments</caption><thead><tr><th scope="col">Comment</th><th scope="col">Created</th></tr></thead><tbody><tr th:each="comment : ${details.comments}"><td th:text="${comment.body}">Comment</td><td th:text="${#temporals.format(comment.createdAt, 'dd/MM/yyyy HH:mm')}">Created</td></tr></tbody></table></div>
<li th:each="comment : ${details.comments}" th:text="${comment.body}">Comment</li> <form class="form-grid form-panel" th:if="${details.canComment}" method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/comments(projectId=${projectId},taskId=${details.task.id})}"><div class="field"><label class="field-label" for="body">Comment</label><textarea class="control" id="body" name="body" rows="4" required></textarea></div><div class="form-actions"><button class="button button-primary" type="submit">Add comment</button></div></form>
</ol>
<form th:if="${details.canComment}" method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/comments(projectId=${projectId},taskId=${details.task.id})}">
<label for="body">Comment</label>
<textarea id="body" name="body" required></textarea>
<button type="submit">Add comment</button>
</form>
</section> </section>
</main> </main>
</body> </body>
+15 -29
View File
@@ -1,36 +1,22 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org"
<head> th:replace="~{fragments/layout :: shell(
<meta charset="utf-8"> pageTitle='Create Task',
<meta name="viewport" content="width=device-width, initial-scale=1"> section='Projects',
<title>Create Task</title> activeNav='projects',
</head> primaryAction=~{},
content=~{::main})}">
<body> <body>
<main> <main>
<h1>Create Task</h1> <p class="page-description">Assign work to a current eligible Project member.</p>
<form 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> <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>
<label for="title">Title</label> <div class="field"><label class="field-label" for="description">Description</label><textarea class="control" id="description" rows="5" th:field="*{description}"></textarea></div>
<input id="title" type="text" maxlength="200" required th:field="*{title}"> <div class="form-grid form-grid-three">
<p role="alert" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</p> <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> </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>
<label for="description">Description</label>
<textarea id="description" th:field="*{description}"></textarea>
</div>
<div>
<label for="assigneeMembershipId">Assignee</label>
<select id="assigneeMembershipId" required th:field="*{assigneeMembershipId}">
<option value="">Select an assignee</option>
<option th:each="assignee : ${assignees}" th:value="${assignee.membershipId}" th:text="${assignee.displayName}">Member</option>
</select>
<p role="alert" th:if="${#fields.hasErrors('assigneeMembershipId')}" th:errors="*{assigneeMembershipId}">Assignee error</p>
</div>
<div>
<label for="dueDate">Due date</label>
<input id="dueDate" type="date" th:field="*{dueDate}">
</div>
<button type="submit">Create Task</button>
</form> </form>
</main> </main>
</body> </body>
+21 -27
View File
@@ -1,33 +1,27 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org"
<head> th:replace="~{fragments/layout :: shell(
<meta charset="utf-8"> pageTitle='Project Tasks',
<meta name="viewport" content="width=device-width, initial-scale=1"> section='Projects',
<title>Project tasks</title> activeNav='projects',
</head> primaryAction=~{::#primary-action},
content=~{::main})}">
<body> <body>
<a id="primary-action" class="button button-primary" th:if="${taskList.canCreate}" th:href="@{/projects/{projectId}/tasks/new(projectId=${projectId})}">Create Task</a>
<main> <main>
<h1>Project tasks</h1> <p class="page-description">Current non-deleted Tasks and Project completion progress.</p>
<p>Progress: <strong th:text="${progressLabel}">N/A</strong></p> <section class="panel metric-strip" aria-label="Task progress">
<dl> <div class="metric"><div class="metric-label">Progress</div><div class="metric-value" th:text="${progressLabel}">N/A</div></div>
<dt>TODO</dt><dd th:text="${taskList.progress.todo}">0</dd> <div class="metric"><div class="metric-label">TODO</div><div class="metric-value" th:text="${taskList.progress.todo}">0</div></div>
<dt>IN_PROGRESS</dt><dd th:text="${taskList.progress.inProgress}">0</dd> <div class="metric"><div class="metric-label">IN PROGRESS</div><div class="metric-value" th:text="${taskList.progress.inProgress}">0</div></div>
<dt>BLOCKED</dt><dd th:text="${taskList.progress.blocked}">0</dd> <div class="metric"><div class="metric-label">BLOCKED / DONE</div><div class="metric-value" th:text="${taskList.progress.blocked + ' / ' + taskList.progress.done}">0 / 0</div></div>
<dt>DONE</dt><dd th:text="${taskList.progress.done}">0</dd> </section>
</dl> <section class="panel" th:if="${#lists.isEmpty(taskList.tasks)}"><th:block th:replace="~{fragments/components :: empty('No current Tasks', 'Tasks for this Project will appear here.')} "></th:block></section>
<p th:if="${taskList.canCreate}"><a th:href="@{/projects/{projectId}/tasks/new(projectId=${projectId})}">Create Task</a></p> <section class="panel" th:unless="${#lists.isEmpty(taskList.tasks)}">
<table> <div class="table-scroll"><table class="data-table"><caption class="sr-only">Current non-deleted Tasks</caption><thead><tr><th scope="col">Title</th><th scope="col">Assignee</th><th scope="col">Status</th><th scope="col">Due date</th></tr></thead>
<caption>Current non-deleted Tasks</caption> <tbody><tr th:each="task : ${taskList.tasks}"><td><a th:href="@{/projects/{projectId}/tasks/{taskId}(projectId=${projectId},taskId=${task.id})}" th:text="${task.title}">Task</a></td><td th:text="${task.assigneeName}">Assignee</td><td><span class="badge" th:classappend="${task.status.name() == 'BLOCKED' ? ' badge-warning' : task.status.name() == 'DONE' ? ' badge-success' : ''}" th:text="${task.status}">TODO</span></td><td th:text="${task.dueDate == null ? 'No due date' : #temporals.format(task.dueDate, 'dd/MM/yyyy')}">No due date</td></tr></tbody>
<thead><tr><th scope="col">Title</th><th scope="col">Assignee</th><th scope="col">Status</th><th scope="col">Due date</th></tr></thead> </table></div>
<tbody> </section>
<tr th:each="task : ${taskList.tasks}">
<td><a th:href="@{/projects/{projectId}/tasks/{taskId}(projectId=${projectId},taskId=${task.id})}" th:text="${task.title}">Task</a></td>
<td th:text="${task.assigneeName}">Assignee</td>
<td th:text="${task.status}">TODO</td>
<td th:text="${task.dueDate ?: '—'}"></td>
</tr>
</tbody>
</table>
</main> </main>
</body> </body>
</html> </html>
@@ -0,0 +1,38 @@
package com.lab.labtimesheet.feature.reporting.controller;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
class ProjectTaskShellContractTest {
private static final Path TEMPLATES = Path.of("src/main/resources/templates");
@ParameterizedTest
@MethodSource("projectAndTaskTemplates")
void projectAndTaskPageUsesSharedDesktopShell(String relativeTemplate) throws IOException {
String template = Files.readString(TEMPLATES.resolve(relativeTemplate));
assertThat(template)
.contains("fragments/layout :: shell(")
.contains("activeNav='projects'")
.doesNotContain("<head>");
}
private static Stream<String> projectAndTaskTemplates() {
return Stream.of(
"projects/list.html",
"projects/form.html",
"projects/detail.html",
"projects/members.html",
"projects/leadership.html",
"tasks/list.html",
"tasks/form.html",
"tasks/detail.html");
}
}