feat(ui): integrate project and task pages with shell
This commit is contained in:
@@ -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>
|
||||
<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('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('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="@{/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>
|
||||
<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>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title th:text="${project.name}">Project</title></head>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle=${project.name},
|
||||
section='Projects',
|
||||
activeNav='projects',
|
||||
primaryAction=~{::#primary-action},
|
||||
content=~{::main})}">
|
||||
<body>
|
||||
<a id="primary-action" class="button button-primary" th:href="@{/projects/{id}/tasks(id=${project.id})}">View Tasks</a>
|
||||
<main>
|
||||
<h1 th:text="${project.name}">Project</h1>
|
||||
<p th:text="${project.description}"></p>
|
||||
<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>
|
||||
<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>
|
||||
<p class="page-description" th:text="${project.description == null || #strings.isEmpty(project.description) ? 'No Project description.' : project.description}">Project description.</p>
|
||||
<section class="panel metric-strip" aria-label="Project details">
|
||||
<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>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title>Create Project</title></head>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle='Create Project',
|
||||
section='Projects',
|
||||
activeNav='projects',
|
||||
primaryAction=~{},
|
||||
content=~{::main})}">
|
||||
<body>
|
||||
<main>
|
||||
<h1>Create Project</h1>
|
||||
<form method="post" th:action="@{/projects}" th:object="${projectForm}">
|
||||
<label for="name">Name</label><input id="name" th:field="*{name}" required maxlength="160">
|
||||
<p th:errors="*{name}"></p>
|
||||
<label for="description">Description</label><textarea id="description" th:field="*{description}"></textarea>
|
||||
<label for="startDate">Start date</label><input id="startDate" type="date" th:field="*{startDate}" required>
|
||||
<label for="endDate">End date</label><input id="endDate" type="date" th:field="*{endDate}" required>
|
||||
<label for="leader">Initial Leader user ID</label><input id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required>
|
||||
<p th:errors="*{initialLeaderUserId}"></p>
|
||||
<button type="submit">Create Project</button>
|
||||
</form>
|
||||
<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}">
|
||||
<div class="field">
|
||||
<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')}">
|
||||
<p class="field-error" role="alert" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name 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="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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title>Project leadership</title></head>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle=${project.name + ' leadership'},
|
||||
section='Projects',
|
||||
activeNav='projects',
|
||||
primaryAction=~{},
|
||||
content=~{::main})}">
|
||||
<body>
|
||||
<main>
|
||||
<h1><span th:text="${project.name}">Project</span> leadership</h1>
|
||||
<table><caption>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}"></td><td th:text="${term.startedAt}"></td><td th:text="${term.endedAt}"></td></tr></tbody>
|
||||
</table>
|
||||
<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>
|
||||
<nav class="tabs" aria-label="Project sections">
|
||||
<a class="tab" th:href="@{/projects/{id}(id=${project.id})}">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})}" aria-current="page">Leadership</a>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title>Projects</title></head>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle='Projects',
|
||||
section='Projects',
|
||||
activeNav='projects',
|
||||
primaryAction=~{::#primary-action},
|
||||
content=~{::main})}">
|
||||
<body>
|
||||
<a id="primary-action" class="button button-primary" th:if="${canCreateProject}" th:href="@{/projects/new}">Create Project</a>
|
||||
<main>
|
||||
<h1>Projects</h1>
|
||||
<a th:if="${canCreateProject}" href="/projects/new">Create Project</a>
|
||||
<p th:if="${#lists.isEmpty(projects)}">No authorized Projects.</p>
|
||||
<table th:unless="${#lists.isEmpty(projects)}">
|
||||
<caption>Authorized Projects</caption>
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Dates</th></tr></thead>
|
||||
<tbody><tr th:each="project : ${projects}">
|
||||
<td><a th:href="@{/projects/{id}(id=${project.id})}" th:text="${project.name}">Project</a></td>
|
||||
<td th:text="${project.status}">PLANNED</td>
|
||||
<td><span th:text="${project.startDate}">Start</span> – <span th:text="${project.endDate}">End</span></td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
<p class="page-description">Projects visible to your current role and membership.</p>
|
||||
<section class="panel" th:if="${#lists.isEmpty(projects)}">
|
||||
<th:block th:replace="~{fragments/components :: empty('No authorized Projects', 'Projects you can access will appear here.')} "></th:block>
|
||||
</section>
|
||||
<section class="panel" th:unless="${#lists.isEmpty(projects)}">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<caption class="sr-only">Authorized Projects</caption>
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Start</th><th scope="col">End</th></tr></thead>
|
||||
<tbody><tr th:each="project : ${projects}">
|
||||
<td><a th:href="@{/projects/{id}(id=${project.id})}" th:text="${project.name}">Project</a></td>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title>Project members</title></head>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle=${project.name + ' members'},
|
||||
section='Projects',
|
||||
activeNav='projects',
|
||||
primaryAction=~{},
|
||||
content=~{::main})}">
|
||||
<body>
|
||||
<main>
|
||||
<h1><span th:text="${project.name}">Project</span> members</h1>
|
||||
<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>
|
||||
<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>
|
||||
</table>
|
||||
<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>
|
||||
<nav class="tabs" aria-label="Project sections">
|
||||
<a class="tab" th:href="@{/projects/{id}(id=${project.id})}">Overview</a>
|
||||
<a class="tab" th:href="@{/projects/{id}/members(id=${project.id})}" aria-current="page">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>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,36 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title th:text="${details.task.title}">Task</title>
|
||||
</head>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle=${details.task.title},
|
||||
section='Projects',
|
||||
activeNav='projects',
|
||||
primaryAction=~{},
|
||||
content=~{::main})}">
|
||||
<body>
|
||||
<main>
|
||||
<h1 th:text="${details.task.title}">Task</h1>
|
||||
<p th:text="${details.task.description ?: 'No description'}">No description</p>
|
||||
<p>Assignee: <strong th:text="${details.task.assigneeName}">Assignee</strong></p>
|
||||
<p>Status: <strong th:text="${details.task.status}">TODO</strong></p>
|
||||
<p>Due date: <span th:text="${details.task.dueDate ?: '—'}">—</span></p>
|
||||
|
||||
<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>
|
||||
<select id="status" name="status" required>
|
||||
<option th:each="status : ${statuses}" th:value="${status}" th:text="${status}">TODO</option>
|
||||
</select>
|
||||
<button type="submit">Change status</button>
|
||||
<p class="page-description" th:text="${details.task.description == null || #strings.isEmpty(details.task.description) ? 'No Task description.' : details.task.description}">Task description.</p>
|
||||
<section class="panel metric-strip metric-strip-three" aria-label="Task details">
|
||||
<div class="metric"><div class="metric-label">Assignee</div><div class="metric-value" th:text="${details.task.assigneeName}">Assignee</div></div>
|
||||
<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>
|
||||
<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 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})}">
|
||||
<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>
|
||||
<span></span><button class="button button-primary" type="submit">Change status</button>
|
||||
</form>
|
||||
|
||||
<section aria-labelledby="comments-heading">
|
||||
<h2 id="comments-heading">Comments</h2>
|
||||
<ol>
|
||||
<li th:each="comment : ${details.comments}" th:text="${comment.body}">Comment</li>
|
||||
</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 class="panel form-panel" aria-labelledby="comments-heading">
|
||||
<header class="panel-header"><h2 class="panel-title" id="comments-heading">Comments</h2></header>
|
||||
<div th:if="${#lists.isEmpty(details.comments)}"><th:block th:replace="~{fragments/components :: empty('No comments', 'Task discussion will appear here.')} "></th:block></div>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Create Task</title>
|
||||
</head>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle='Create Task',
|
||||
section='Projects',
|
||||
activeNav='projects',
|
||||
primaryAction=~{},
|
||||
content=~{::main})}">
|
||||
<body>
|
||||
<main>
|
||||
<h1>Create Task</h1>
|
||||
<form method="post" th:action="@{/projects/{projectId}/tasks(projectId=${projectId})}" th:object="${taskForm}">
|
||||
<div>
|
||||
<label for="title">Title</label>
|
||||
<input id="title" type="text" maxlength="200" required th:field="*{title}">
|
||||
<p role="alert" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</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}">
|
||||
<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="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="dueDate">Due date</label><input class="control" id="dueDate" type="date" th:field="*{dueDate}"></div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Project tasks</title>
|
||||
</head>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle='Project Tasks',
|
||||
section='Projects',
|
||||
activeNav='projects',
|
||||
primaryAction=~{::#primary-action},
|
||||
content=~{::main})}">
|
||||
<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>
|
||||
<h1>Project tasks</h1>
|
||||
<p>Progress: <strong th:text="${progressLabel}">N/A</strong></p>
|
||||
<dl>
|
||||
<dt>TODO</dt><dd th:text="${taskList.progress.todo}">0</dd>
|
||||
<dt>IN_PROGRESS</dt><dd th:text="${taskList.progress.inProgress}">0</dd>
|
||||
<dt>BLOCKED</dt><dd th:text="${taskList.progress.blocked}">0</dd>
|
||||
<dt>DONE</dt><dd th:text="${taskList.progress.done}">0</dd>
|
||||
</dl>
|
||||
<p th:if="${taskList.canCreate}"><a th:href="@{/projects/{projectId}/tasks/new(projectId=${projectId})}">Create Task</a></p>
|
||||
<table>
|
||||
<caption>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>
|
||||
<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 th:text="${task.status}">TODO</td>
|
||||
<td th:text="${task.dueDate ?: '—'}">—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="page-description">Current non-deleted Tasks and Project completion progress.</p>
|
||||
<section class="panel metric-strip" aria-label="Task progress">
|
||||
<div class="metric"><div class="metric-label">Progress</div><div class="metric-value" th:text="${progressLabel}">N/A</div></div>
|
||||
<div class="metric"><div class="metric-label">TODO</div><div class="metric-value" th:text="${taskList.progress.todo}">0</div></div>
|
||||
<div class="metric"><div class="metric-label">IN PROGRESS</div><div class="metric-value" th:text="${taskList.progress.inProgress}">0</div></div>
|
||||
<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>
|
||||
</section>
|
||||
<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>
|
||||
<section class="panel" th:unless="${#lists.isEmpty(taskList.tasks)}">
|
||||
<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>
|
||||
<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>
|
||||
</table></div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+38
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user