fix(ui): resolve round one shell findings
This commit is contained in:
+20
@@ -7,15 +7,35 @@ import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
/**
|
||||
* Selects the dashboard view for the authenticated global authority.
|
||||
*
|
||||
* <p>The authority selects only which role-specific flow to invoke. The reporting service then
|
||||
* reloads and revalidates the persisted account role and lifecycle before returning any data.
|
||||
*/
|
||||
@Controller
|
||||
public class DashboardController {
|
||||
|
||||
private final DashboardService dashboardService;
|
||||
|
||||
/**
|
||||
* Creates the dashboard endpoint backed by the reporting composition service.
|
||||
*
|
||||
* @param dashboardService service that authorizes and assembles role-scoped dashboard data
|
||||
*/
|
||||
public DashboardController(DashboardService dashboardService) {
|
||||
this.dashboardService = dashboardService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dashboard permitted by the caller's authenticated global role.
|
||||
*
|
||||
* @param authentication authenticated caller whose name is the persisted account email
|
||||
* @param model Thymeleaf model populated with the role-specific {@code dashboard} projection
|
||||
* @return the Admin, Mentor, or Intern dashboard template name
|
||||
* @throws DashboardAccessDeniedException when the authority is unsupported or does not match
|
||||
* an active persisted account identity
|
||||
*/
|
||||
@GetMapping("/dashboard")
|
||||
public String dashboard(Authentication authentication, Model model) {
|
||||
String email = authentication.getName();
|
||||
|
||||
+10
@@ -4,9 +4,19 @@ import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
/**
|
||||
* Non-disclosing denial raised when an authenticated identity cannot access a role dashboard.
|
||||
*
|
||||
* <p>Callers must not include protected record identifiers or lifecycle details in the message.
|
||||
*/
|
||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||
public class DashboardAccessDeniedException extends AccessDeniedException {
|
||||
|
||||
/**
|
||||
* Creates a safe dashboard denial.
|
||||
*
|
||||
* @param message generic reason suitable for server-side diagnosis without protected details
|
||||
*/
|
||||
public DashboardAccessDeniedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
@@ -3,23 +3,62 @@ package com.lab.labtimesheet.feature.reporting.model.dto;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Closed set of immutable, role-specific dashboard projections rendered by Reporting.
|
||||
*
|
||||
* <p>Each projection contains only data authorized and calculated by its owning feature service.
|
||||
*/
|
||||
public sealed interface DashboardView {
|
||||
|
||||
/**
|
||||
* System-wide counts visible to an active Admin.
|
||||
*
|
||||
* @param activeAccounts active account count
|
||||
* @param pendingActivations accounts awaiting activation
|
||||
* @param activeInternships active internship count
|
||||
* @param activeProjects active Projects visible to an Admin
|
||||
*/
|
||||
record Admin(long activeAccounts, long pendingActivations,
|
||||
long activeInternships, long activeProjects) implements DashboardView {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owning-Mentor operational summary; empty authorized scopes are represented by zero counts.
|
||||
*
|
||||
* @param displayName persisted Mentor display name
|
||||
* @param activeProjects active owned Project count
|
||||
* @param activeMembers distinct eligible active members across owned Projects
|
||||
* @param blockedTasks blocked Tasks visible within active owned Projects
|
||||
*/
|
||||
record Mentor(String displayName, long activeProjects, long activeMembers,
|
||||
long blockedTasks) implements DashboardView {
|
||||
}
|
||||
|
||||
/**
|
||||
* Eligible Intern summary for the Attendance policy's current business date.
|
||||
*
|
||||
* @param displayName persisted Intern display name
|
||||
* @param attendanceState current policy-local attendance state
|
||||
* @param activeProjects active Projects containing a current eligible membership
|
||||
* @param assignedTasks current assigned Task count
|
||||
* @param priorityTasks ordered Task-owned priority items; empty when none are assigned
|
||||
*/
|
||||
record Intern(String displayName, AttendanceState attendanceState, long activeProjects,
|
||||
long assignedTasks, List<AssignedTask> priorityTasks) implements DashboardView {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact Task row shown on an Intern dashboard.
|
||||
*
|
||||
* @param title Task title
|
||||
* @param projectName owning Project name
|
||||
* @param status Task status label supplied by the Task feature
|
||||
* @param dueDate Task due date, or {@code null} when no due date is assigned
|
||||
*/
|
||||
record AssignedTask(String title, String projectName, String status, LocalDate dueDate) {
|
||||
}
|
||||
|
||||
/** Current attendance state exposed to the Intern dashboard. */
|
||||
enum AttendanceState {
|
||||
NOT_CHECKED_IN("Not checked in"),
|
||||
CHECKED_IN("Checked in"),
|
||||
@@ -31,6 +70,11 @@ public sealed interface DashboardView {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the English presentation label for this state.
|
||||
*
|
||||
* @return non-empty user-facing state label
|
||||
*/
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ import com.lab.labtimesheet.feature.task.service.TaskDashboardService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Composes authorized dashboard projections exclusively from public feature services and DTOs.
|
||||
*
|
||||
* <p>This service owns no persistence mapping or business-date calculation. Account lifecycle and
|
||||
* role are revalidated from persisted identity data, while Project, Task, and Attendance retain
|
||||
* ownership of their query scope, ordering, and attendance-policy business date.
|
||||
*/
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
public class DashboardService {
|
||||
@@ -28,6 +35,14 @@ public class DashboardService {
|
||||
private final TaskDashboardService tasks;
|
||||
private final AttendanceApplicationService attendance;
|
||||
|
||||
/**
|
||||
* Creates a reporting coordinator over the concrete feature query boundaries.
|
||||
*
|
||||
* @param accounts account identity and Admin summary boundary
|
||||
* @param projects role-scoped Project summary boundary
|
||||
* @param tasks role-scoped Task dashboard boundary
|
||||
* @param attendance attendance state boundary using the active policy business date
|
||||
*/
|
||||
public DashboardService(
|
||||
AccountService accounts,
|
||||
ProjectQueryService projects,
|
||||
@@ -39,6 +54,16 @@ public class DashboardService {
|
||||
this.attendance = attendance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds system-wide Admin counts after confirming an active persisted Admin identity.
|
||||
*
|
||||
* <p>Counts are zero when the corresponding feature has no matching records.
|
||||
*
|
||||
* @param email authenticated account email
|
||||
* @return account lifecycle counts and the Admin-visible active Project count
|
||||
* @throws DashboardAccessDeniedException when the persisted account is missing, inactive, or
|
||||
* not an Admin
|
||||
*/
|
||||
public DashboardView.Admin admin(String email) {
|
||||
AccountIdentity admin = activeAccount(email, GlobalRole.ADMIN);
|
||||
AccountSummary accountSummary = accounts.summary();
|
||||
@@ -50,6 +75,17 @@ public class DashboardService {
|
||||
projectSummary.activeProjectCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the owning-Mentor dashboard after persisted-role revalidation.
|
||||
*
|
||||
* <p>Project and member counts are scoped by the Project service; blocked Task count is scoped
|
||||
* by the Task service. Each empty scope is represented by a zero count.
|
||||
*
|
||||
* @param email authenticated account email
|
||||
* @return Mentor display name and role-scoped Project, member, and blocked-Task counts
|
||||
* @throws DashboardAccessDeniedException when the persisted account is missing, inactive, or
|
||||
* not a Mentor
|
||||
*/
|
||||
public DashboardView.Mentor mentor(String email) {
|
||||
AccountIdentity mentor = activeAccount(email, GlobalRole.MENTOR);
|
||||
ProjectDashboardSummary projectSummary = projects.dashboardSummary(mentor.id());
|
||||
@@ -61,6 +97,19 @@ public class DashboardService {
|
||||
taskSummary.blockedTaskCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the eligible Intern dashboard after persisted-role revalidation.
|
||||
*
|
||||
* <p>The Attendance feature determines today's state from its policy-owned business date. The
|
||||
* Task feature owns assigned count and priority ordering; no matching Tasks produce an empty
|
||||
* priority list. Attendance ineligibility is converted to the same non-disclosing dashboard
|
||||
* denial as other invalid Intern lifecycle states.
|
||||
*
|
||||
* @param email authenticated account email
|
||||
* @return Intern attendance state, scoped counts, and at most the Task service's priority items
|
||||
* @throws DashboardAccessDeniedException when the persisted account or internship is not
|
||||
* eligible for the Intern dashboard
|
||||
*/
|
||||
public DashboardView.Intern intern(String email) {
|
||||
AccountIdentity intern = activeAccount(email, GlobalRole.INTERN);
|
||||
AttendanceCurrentState attendanceState;
|
||||
|
||||
@@ -39,12 +39,17 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="item : ${items}">
|
||||
<td th:text="${item.workDate}"></td>
|
||||
<td th:text="${item.checkInAt}"></td>
|
||||
<td th:text="${item.checkOutAt == null ? 'Missing' : item.checkOutAt}"></td>
|
||||
<td th:text="${#temporals.format(item.workDate, 'dd/MM/yyyy')}"></td>
|
||||
<td th:text="${#temporals.format(item.checkInAt.atZone(item.policy.zoneId), 'HH:mm')}"></td>
|
||||
<td th:text="${item.checkOutAt == null ? 'Missing' : #temporals.format(item.checkOutAt.atZone(item.policy.zoneId), 'HH:mm')}"></td>
|
||||
<td th:text="|${item.policy.scheduledStart}–${item.policy.scheduledEnd} (${item.policy.zoneId})|"></td>
|
||||
<td th:text="|${item.policy.checkInGraceMinutes} min / ${item.policy.checkoutGraceMinutes} min|"></td>
|
||||
<td th:text="${item.violations.missingCheckout ? 'Missing checkout' : (item.violations.earlyDeparture ? 'Early departure' : (item.violations.late ? 'Late' : 'On time'))}"></td>
|
||||
<td>
|
||||
<span th:if="${!item.violations.late and !item.violations.earlyDeparture and !item.violations.missingCheckout}">On time</span>
|
||||
<span th:if="${item.violations.late}">Late</span>
|
||||
<span th:if="${item.violations.earlyDeparture}" th:text="${item.violations.late ? ', Early departure' : 'Early departure'}">Early departure</span>
|
||||
<span th:if="${item.violations.missingCheckout}" th:text="${item.violations.late or item.violations.earlyDeparture ? ', Missing checkout' : 'Missing checkout'}">Missing checkout</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle=${errorTitle},
|
||||
section='Error',
|
||||
activeNav='',
|
||||
primaryAction=~{},
|
||||
content=~{::main})}">
|
||||
<body>
|
||||
<main>
|
||||
<section class="panel empty-state">
|
||||
<p class="auth-eyebrow" th:text="|Error ${errorStatus}|">Error</p>
|
||||
<h2 th:text="${errorTitle}">Request could not be completed</h2>
|
||||
<p th:text="${errorMessage}">The requested operation could not be completed.</p>
|
||||
<p><a class="button button-primary" th:href="@{/dashboard}">Return to dashboard</a></p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -24,8 +24,7 @@
|
||||
<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}" 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="@{/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">My attendance</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>
|
||||
@@ -38,7 +37,7 @@
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</div>
|
||||
<a class="account" th:href="@{/profile}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#circle-user-round}"></use></svg><span class="sidebar-label" sec:authentication="name">Profile</span></a>
|
||||
<div class="account"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#circle-user-round}"></use></svg><span class="sidebar-label" sec:authentication="name">Account</span></div>
|
||||
<form class="logout-form" th:action="@{/logout}" method="post"><button class="nav-link" type="submit"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#log-out}"></use></svg><span class="sidebar-label">Logout</span></button></form>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -46,7 +45,6 @@
|
||||
<header class="app-header">
|
||||
<button class="icon-button" type="button" data-sidebar-toggle aria-label="Toggle sidebar" title="Toggle sidebar"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#panel-left}"></use></svg></button>
|
||||
<div class="header-title"><span class="breadcrumb" th:text="${section}">Section</span> / <span th:text="${pageTitle}">Page</span></div>
|
||||
<div class="header-actions"><a class="icon-button" th:href="@{/notifications}" aria-label="Notifications" title="Notifications"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#bell}"></use></svg></a></div>
|
||||
</header>
|
||||
<div class="page">
|
||||
<div class="page-heading">
|
||||
|
||||
@@ -16,20 +16,20 @@
|
||||
</div>
|
||||
<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>
|
||||
<input class="control" id="name" th:field="*{name}" required maxlength="160" th:attr="aria-invalid=${#fields.hasErrors('name')},aria-describedby=${#fields.hasErrors('name') ? 'name-error' : null}">
|
||||
<p class="field-error" id="name-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 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 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"><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')}">
|
||||
<p class="field-error" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
|
||||
<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}">
|
||||
<p class="field-error" id="initialLeaderUserId-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>
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<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="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')},aria-describedby=${#fields.hasErrors('title') ? 'title-error' : null}"><p class="field-error" id="title-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}" 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 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')},aria-describedby=${#fields.hasErrors('assigneeMembershipId') ? 'assigneeMembershipId-error' : null}"><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" id="assigneeMembershipId-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}" th:attr="aria-invalid=${#fields.hasErrors('dueDate')},aria-describedby=${#fields.hasErrors('dueDate') ? 'dueDate-error' : null}"><p class="field-error" id="dueDate-error" role="alert" th:if="${#fields.hasErrors('dueDate')}" th:errors="*{dueDate}">Due date error</p></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>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user