Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.
Part 2
<input th:case="number"
th:class="${#fields.hasErrors(name)} ? 'form-control is-invalid' : 'form-control'"
type="number" step="any" th:field="*{__${name}__}" />
<input th:case="datetime-local"
th:class="${#fields.hasErrors(name)} ? 'form-control is-invalid' : 'form-control'"
type="datetime-local" th:field="*{__${name}__}" />Update your "resources/templates/fragments/inputField.html" fragment to support number and datetime-local types.
YOUR TASK: Create LeagueController.java in your league package.
Inject a LeagueRepository and SchoolRepository into the constructor.
package org.springframework.samples.petclinic.league;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.samples.petclinic.school.SchoolRepository;
@Controller
@RequestMapping("/schools/{schoolId}/leagues")
public class LeagueController {
// -- Your code goes here --
}
package org.springframework.samples.petclinic.league;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.samples.petclinic.school.SchoolRepository;
@Controller
@RequestMapping("/schools/{schoolId}/leagues")
public class LeagueController {
private final LeagueRepository leagueRepository;
private final SchoolRepository schoolRepository;
public LeagueController(LeagueRepository leagueRepository, SchoolRepository schoolRepository) {
this.leagueRepository = leagueRepository;
this.schoolRepository = schoolRepository;
}
}
SOLUTION
import java.util.*;
import org.springframework.samples.petclinic.school.Location;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.samples.petclinic.school.School;
import org.springframework.http.HttpStatus;
// ... inside LeagueController.java ...
???("school")
public School findSchool(@PathVariable("schoolId") int schoolId) {
return schoolRepository.findById(schoolId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
}
???("statuses")
public Map<String, String> getStatuses() {
Map<String, String> map = new ???<>();
map.put("DRAFT", "Draft");
map.put("ACTIVE", "Active");
map.put("INACTIVE", "Inactive");
map.put("POSTPONED", "Postponed");
map.put("CANCELLED", "Cancelled");
map.put("PAST", "Past");
return map;
}
???("types")
public Map<String, String> getTypes() {
Map<String, String> map = new ???<>();
map.put("MALE", "Male");
map.put("FEMALE", "Female");
map.put("COED", "Coed");
return map;
}
???("capacityTypes")
public Map<String, String> getCapacityTypes() {
Map<String, String> map = new ???<>();
map.put("TEAM", "Team");
map.put("INDIVIDUAL", "Individual");
return map;
}
???("locationOptions")
public Map<Integer, String> getLocationOptions(@ModelAttribute("school") School school) {
Map<Integer, String> map = new ???<>();
map.put(null, "-- Select a Location --"); // Optional empty default
if (school.getLocations() != null) {
for (Location loc : school.getLocations()) {
map.put(loc.getId(), loc.getName());
}
}
return map;
}Add these methods to the LeagueController.java to generate the labels and values for select and radio inputs.
YOUR TASK: Replace five ???'s with annotations so these methods are executed before any @RequestMapping, @GetMapping, or @PostMapping methods in the same controller.
Replace four ???'s with a Map object type that guarantees the options appear in the exact order you specify, rather than displaying randomly. (Hint: See ProfileController)
// ... inside LeagueController.java ...
@ModelAttribute("school")
public School findSchool(@PathVariable("schoolId") int schoolId) {
return schoolRepository.findById(schoolId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
}
@ModelAttribute("statuses")
public Map<String, String> getStatuses() {
Map<String, String> map = new LinkedHashMap<>();
map.put("DRAFT", "Draft");
map.put("ACTIVE", "Active");
map.put("INACTIVE", "Inactive");
map.put("POSTPONED", "Postponed");
map.put("CANCELLED", "Cancelled");
map.put("PAST", "Past");
return map;
}
@ModelAttribute("types")
public Map<String, String> getTypes() {
Map<String, String> map = new LinkedHashMap<>();
map.put("MALE", "Male");
map.put("FEMALE", "Female");
map.put("COED", "Coed");
return map;
}
@ModelAttribute("capacityTypes")
public Map<String, String> getCapacityTypes() {
Map<String, String> map = new LinkedHashMap<>();
map.put("TEAM", "Team");
map.put("INDIVIDUAL", "Individual");
return map;
}
@ModelAttribute("locationOptions")
public Map<Integer, String> getLocationOptions(@ModelAttribute("school") School school) {
Map<Integer, String> map = new LinkedHashMap<>();
map.put(null, "-- Select a Location --"); // Optional empty default
if (school.getLocations() != null) {
for (Location loc : school.getLocations()) {
map.put(loc.getId(), loc.getName());
}
}
return map;
}SOLUTION
import org.springframework.ui.Model;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
// ... inside LeagueController.java ...
// View Details
@GetMapping("/{leagueId}")
public String showLeagueDetails(??? int leagueId, Model model, @ModelAttribute("school") School school) {
League league = ???;
model.addAttribute("league", league);
model.addAttribute("canEdit", checkEditPermissions(school));
return "leagues/leagueDetails";
}
private boolean checkEditPermissions(School school) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || auth.getName().equals("anonymousUser")) {
return false;
}
String userEmail = auth.getName();
boolean isSuperAdmin = auth.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("MANAGE_ALL_SCHOOLS"));
boolean isSchoolAdmin = auth.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("MANAGE_FACILITIES") || a.getAuthority().equals("SCHOOL_ADMIN"));
boolean belongsToSchool = userEmail.endsWith("@" + school.getDomain()) || userEmail.endsWith("." + school.getDomain());
return isSuperAdmin || (isSchoolAdmin && belongsToSchool);
}Add the following @GetMapping to display league details when visiting a URL like: http://localhost:8080/schools/1/leagues/1
YOUR TASK: Replace the first ???'s to access the league id path variable. Then replace the second ???'s to look up a league by its id. If the league isn't found, display an error saying something like "League not found".
import org.springframework.ui.Model;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
// ... inside LeagueController.java ...
// View Details
@GetMapping("/{leagueId}")
public String showLeagueDetails(@PathVariable("leagueId") int leagueId, Model model, @ModelAttribute("school") School school) {
League league = leagueRepository.findById(leagueId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "League not found"));
model.addAttribute("league", league);
model.addAttribute("canEdit", checkEditPermissions(school));
return "leagues/leagueDetails";
}
private boolean checkEditPermissions(School school) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || auth.getName().equals("anonymousUser")) {
return false;
}
String userEmail = auth.getName();
boolean isSuperAdmin = auth.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("MANAGE_ALL_SCHOOLS"));
boolean isSchoolAdmin = auth.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("MANAGE_FACILITIES") || a.getAuthority().equals("SCHOOL_ADMIN"));
boolean belongsToSchool = userEmail.endsWith("@" + school.getDomain()) || userEmail.endsWith("." + school.getDomain());
return isSuperAdmin || (isSchoolAdmin && belongsToSchool);
}SOLUTION
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'leagues')}">
<body>
<div class="container mt-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a th:href="@{|/schools/${school.slug}|}" th:text="${school.name}">School</a>
</li>
<li class="breadcrumb-item active" aria-current="page" th:text="${league.name}">League</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-start mt-3">
<div>
<h2 th:text="${league.name}">League Name</h2>
<span class="badge bg-secondary" th:text="${league.type}">TYPE</span>
<span class="badge" th:classappend="${league.status.name() == 'DRAFT' ? 'bg-warning text-dark' : 'bg-success'}" th:text="${league.status}">STATUS</span>
</div>
<div ??? class="d-flex gap-2">
<a th:href="@{|/schools/${school.id}/leagues/${league.id}/edit|}" class="btn btn-outline-secondary">Edit</a>
<form th:action="@{|/schools/${school.id}/leagues/${league.id}/delete|}" method="post" onsubmit="return confirm('Are you sure you want to delete this league?');">
<button type="submit" class="btn btn-outline-danger">Delete</button>
</form>
</div>
</div>
<p class="lead mt-3" th:text="${league.description}">Description goes here.</p>
<div class="row mt-5">
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">League Details</div>
<ul class="list-group list-group-flush">
<li class="list-group-item"><strong>Format:</strong> <span th:text="${league.capacityType}">Team</span></li>
<li class="list-group-item"><strong>Capacity:</strong> <span th:text="${league.capacity != null ? league.capacity : 'Unlimited'}">12</span></li>
<li class="list-group-item"><strong>Fee:</strong> $<span th:text="${league.fee != null ? league.fee : '0.00'}">50.00</span></li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Schedule</div>
<ul class="list-group list-group-flush">
<li class="list-group-item"><strong>Registration Closes:</strong> <span th:text="${league.registrationEnd != null ? #temporals.format(league.registrationEnd, 'MMM dd, yyyy') : 'TBD'}">Date</span></li>
<li class="list-group-item"><strong>Season Starts:</strong> <span th:text="${league.leagueStart != null ? #temporals.format(league.leagueStart, 'MMM dd, yyyy') : 'TBD'}">Date</span></li>
<li class="list-group-item"><strong>Season Ends:</strong> <span th:text="${league.leagueEnd != null ? #temporals.format(league.leagueEnd, 'MMM dd, yyyy') : 'TBD'}">Date</span></li>
</ul>
</div>
</div>
</div>
</div>
</body>
</html>Create leagueDetails.html with the following code in your src/main/resources/templates/leagues/ directory.
YOUR TASK: Replace the ???'s with code that only shows the Edit and Delete buttons if the current user has editing privileges.
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'leagues')}">
<body>
<div class="container mt-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a th:href="@{|/schools/${school.slug}|}" th:text="${school.name}">School</a>
</li>
<li class="breadcrumb-item active" aria-current="page" th:text="${league.name}">League</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-start mt-3">
<div>
<h2 th:text="${league.name}">League Name</h2>
<span class="badge bg-secondary" th:text="${league.type}">TYPE</span>
<span class="badge" th:classappend="${league.status.name() == 'DRAFT' ? 'bg-warning text-dark' : 'bg-success'}" th:text="${league.status}">STATUS</span>
</div>
<div th:if="${canEdit}" class="d-flex gap-2">
<a th:href="@{|/schools/${school.id}/leagues/${league.id}/edit|}" class="btn btn-outline-secondary">Edit</a>
<form th:action="@{|/schools/${school.id}/leagues/${league.id}/delete|}" method="post" onsubmit="return confirm('Are you sure you want to delete this league?');">
<button type="submit" class="btn btn-outline-danger">Delete</button>
</form>
</div>
</div>
<p class="lead mt-3" th:text="${league.description}">Description goes here.</p>
<div class="row mt-5">
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">League Details</div>
<ul class="list-group list-group-flush">
<li class="list-group-item"><strong>Format:</strong> <span th:text="${league.capacityType}">Team</span></li>
<li class="list-group-item"><strong>Capacity:</strong> <span th:text="${league.capacity != null ? league.capacity : 'Unlimited'}">12</span></li>
<li class="list-group-item"><strong>Fee:</strong> $<span th:text="${league.fee != null ? league.fee : '0.00'}">50.00</span></li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Schedule</div>
<ul class="list-group list-group-flush">
<li class="list-group-item"><strong>Registration Closes:</strong> <span th:text="${league.registrationEnd != null ? #temporals.format(league.registrationEnd, 'MMM dd, yyyy') : 'TBD'}">Date</span></li>
<li class="list-group-item"><strong>Season Starts:</strong> <span th:text="${league.leagueStart != null ? #temporals.format(league.leagueStart, 'MMM dd, yyyy') : 'TBD'}">Date</span></li>
<li class="list-group-item"><strong>Season Ends:</strong> <span th:text="${league.leagueEnd != null ? #temporals.format(league.leagueEnd, 'MMM dd, yyyy') : 'TBD'}">Date</span></li>
</ul>
</div>
</div>
</div>
</div>
</body>
</html>SOLUTION
// All users can access the list of schools, individual schools, and league details
.requestMatchers(HttpMethod.GET,
"/schools",
"/schools/{schoolId:\\d+}",
"/schools/{slug:[a-zA-Z0-9-]*[a-zA-Z-][a-zA-Z0-9-]*}",
"/schools/{schoolId:\\d+}/leagues/{leagueId:\\d+}" // Add this line
).permitAll()Open the SecurityConfig class and update the securityFilterChain method to explicitly permit unauthenticated access to the league details route.
Because this specifies HttpMethod.GET, this change only opens the "View Details" page. Routes for creating (/new), editing (/edit), and deleting (/delete) leagues will still safely fall to the .anyRequest().authenticated() rule, and your LeagueController will continue to enforce the SCHOOL_ADMIN permissions before allowing those actions.
Raise your hand and show Marc the following.
Click the "View Info" button next to the league. This should display the league details.
Log in as a SCHOOL_ADMIN user.
Go back to the same page and the Edit and Delete buttons should appear.
Change the league id in the address bar to something that doesn't exist. A message saying "League not found" should appear.
Extra Credit: Changing the school id will display league as if it belongs to another school. Fix that bug.
Push to GitHub and deploy to Azure if you cannot continue to the next part.
When logged in as a SCHOOL_ADMIN, and trying to edit a league, you will get several exceptions because of how Thymeleaf's th:field works.
When rendering the "createOrEditLeague" form, Thymeleaf will try to auto-select the correct dropdown option. To do this, it takes the league.defaultLocation and attempts to convert it into a simple String (the ID) to match it against your <option> values.
Because the Location is fetched "lazily" (meaning Spring closes the database connection before fetching the location's details) and Spring doesn't have a built-in "Location-to-String" converter, so it crashes.
Continued on next slide...
The cleanest way to fix this is to use a Transient ID Bridge to bind the form to a temporary ID field, and manually link it to the real Location entity behind the scenes.
Open League.java. We need to create a new @Transient integer field to hold the ID from the form. @Transient tells Hibernate not to create a column for this in your database.
// -- Inside League --
@Transient
private Integer locationId;
public Integer getLocationId() {
// If there's a validation error, keep the user's submitted ID
if (this.locationId != null) {
return this.locationId;
}
// If we are loading an existing league, grab the real location's ID
if (this.defaultLocation != null) {
return this.defaultLocation.getId();
}
return null;
}
public void setLocationId(Integer locationId) {
this.locationId = locationId;
}import jakarta.validation.Valid;
import org.springframework.validation.BindingResult;
// -- Inside LeagueController --
// Create - Show Form
@GetMapping("/new")
public String initCreationForm(Model model, @ModelAttribute("school") School school) {
if (!checkEditPermissions(school)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied");
}
League league = new League();
league.setSchool(school);
model.addAttribute("league", league);
return "leagues/createOrUpdateLeagueForm";
}
// Create - Process Form
@PostMapping("/new")
public String processCreationForm(@Valid League league, BindingResult result, @ModelAttribute("school") School school) {
if (!checkEditPermissions(school)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied");
}
// Explicitly tie the error to the UI's locationId field
if (league.getLocationId() == null) {
result.rejectValue("locationId", "NotNull.league.locationId", "Please select a default location for this league.");
}
if (result.hasErrors()) {
return "leagues/createOrUpdateLeagueForm";
}
// Map the transient ID to a real Location proxy before saving
Location locationProxy = new Location();
locationProxy.setId(league.getLocationId());
league.setDefaultLocation(locationProxy);
league.setSchool(school);
leagueRepository.save(league);
return "redirect:/schools/" + school.getId() + "/leagues/" + league.getId();
}In your LeagueController, add a @GetMapping to display the "Create New League" form and a @PostMapping to process the form.
When trying to update a league, we want the four date fields to pre-populate.
Java's LocalDateTime formats dates with seconds (e.g., 2026-09-07T18:00:00). However, the HTML5 <input type="datetime-local"> expects the value to be formatted down to the minute without seconds (e.g., 2026-09-07T18:00).
Because of this, when you submit the form, Spring does not know how to parse the browser's incoming date string back into a Java LocalDateTime object. This causes a "Type Mismatch" validation error behind the scenes.
Continued on next slide...
import org.springframework.format.annotation.DateTimeFormat;
// ... inside League.java ...
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
@Column(name = "registration_start")
private LocalDateTime registrationStart;
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
@Column(name = "registration_end")
private LocalDateTime registrationEnd;
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
@Column(name = "league_start")
private LocalDateTime leagueStart;
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
@Column(name = "league_end")
private LocalDateTime leagueEnd;You must explicitly tell Spring how to format these fields for the web using the @DateTimeFormat annotation.
Open League.java and add the annotation to all four of your date variables:
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'leagues')}">
<body>
<div class="container mt-4" style="max-width: 800px;">
<h2 th:text="${league.id == null ? 'Create League' : 'Edit League'}">League</h2>
<form th:object="${league}" method="post" class="mt-4">
<div th:if="${#fields.hasAnyErrors()}" class="alert alert-danger">
<p class="mb-2"><strong>Please correct the following errors:</strong></p>
<ul class="mb-0">
<li th:each="err : ${#fields.allErrors()}" th:text="${err}"></li>
</ul>
</div>
<div class="row">
<div class="col-md-8">
<div th:replace="~{fragments/inputField :: input('League Name', 'name', 'text')}"></div>
</div>
<div class="col-md-4">
<div th:replace="~{fragments/selectField :: select('Status', 'status', ${statuses})}"></div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div th:replace="~{fragments/selectField :: select('Default Location', 'locationId', ${locationOptions})}"></div>
</div>
<div class="col-md-4 d-flex align-items-center mt-3">
<div th:replace="~{fragments/checkboxField :: checkbox('Is Public (Visible to Guests)', 'isPublic')}"></div>
</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea id="description" th:field="*{description}" class="form-control" rows="3"></textarea>
<div class="invalid-feedback" th:if="${#fields.hasErrors('description')}" th:errors="*{description}">Error</div>
</div>
<div class="row mt-4">
<div class="col-md-4">
<div th:replace="~{fragments/radioField :: radio('Gender Type', 'type', ${types})}"></div>
</div>
<div class="col-md-4">
<div th:replace="~{fragments/radioField :: radio('Registration Type', 'capacityType', ${capacityTypes})}"></div>
</div>
<div class="col-md-4">
<div th:replace="~{fragments/inputField :: input('Capacity Limit', 'capacity', 'number')}"></div>
<div th:replace="~{fragments/inputField :: input('Registration Fee ($)', 'fee', 'number')}"></div>
</div>
</div>
<hr class="my-4" />
<h4>Schedule</h4>
<div class="row mt-3">
<div class="col-md-6">
<div th:replace="~{fragments/inputField :: input('Registration Starts', 'registrationStart', 'datetime-local')}"></div>
</div>
<div class="col-md-6">
<div th:replace="~{fragments/inputField :: input('Registration Ends', 'registrationEnd', 'datetime-local')}"></div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div th:replace="~{fragments/inputField :: input('League Starts', 'leagueStart', 'datetime-local')}"></div>
</div>
<div class="col-md-6">
<div th:replace="~{fragments/inputField :: input('League Ends', 'leagueEnd', 'datetime-local')}"></div>
</div>
</div>
<div class="d-grid gap-2 mt-4">
<button type="submit" class="btn btn-primary" th:text="${league.id == null ? 'Create League' : 'Update League'}">Submit</button>
<a th:href="@{|/schools/${school.slug}|}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</body>
</html>
Create createOrUpdateLeagueForm.html in your src/main/resources/templates/leagues/ directory.
It utilizes form fragments and maps the new date inputs.
Because you do not have a textarea fragment, the Description field uses standard HTML, which is completely fine.
If you submit the "Create League" form with no data, it sends a payload containing the status field (which has the value "DRAFT" for the league). Spring's WebDataBinder automatically tries to map incoming form fields to all objects in the model.
Because your controller fetches the parent School using @ModelAttribute("school") before processing the form, Spring sees the School object in the model and eagerly attempts to bind the form's status value to school.status as well.
Since your SchoolStatus enum does not contain a "DRAFT" option, it throws a type mismatch error and crashes before it can even evaluate the validation on your League object.
Continued on the next slide...
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
// ... inside LeagueController.java ...
@InitBinder("school")
public void initSchoolBinder(WebDataBinder dataBinder) {
// Prevent form data from being bound to the School object
dataBinder.setDisallowedFields("*");
}Open your LeagueController.java file and add an @InitBinder method (placed near the top, right after your constructor or @ModelAttribute methods)
This instructs the data binder to completely disallow binding web request parameters to the school model attribute.
Raise your hand and show Marc the following.
Visiting this page as a guest user should redirect you to the login page.
Signing in as a SCHOOL_ADMIN should display the form.
Submitting a blank form will trigger the @Valid checks on the league and return the view with the correct validation error messages displayed next to the empty inputs.
Submitting the form with valid data should add a new league record to the database and redirect you to the new league's details page.
Push to GitHub and deploy to Azure if you cannot continue to the next part.
// -- Inside LeagueController
// Edit - Show Form
@GetMapping("/{leagueId}/edit")
public String initUpdateForm(@PathVariable("leagueId") int leagueId, Model model, @ModelAttribute("school") School school) {
if (!checkEditPermissions(school)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied");
}
League league = leagueRepository.findById(leagueId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "League not found"));
model.addAttribute("league", league);
return "leagues/createOrUpdateLeagueForm";
}
// Edit - Process Form
@PostMapping("/{leagueId}/edit")
public String processUpdateForm(@Valid League league, BindingResult result, @PathVariable("leagueId") int leagueId, @ModelAttribute("school") School school) {
if (!checkEditPermissions(school)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied");
}
// Explicitly tie the error to the UI's locationId field
if (league.getLocationId() == null) {
result.rejectValue("locationId", "NotNull.league.locationId", "Please select a default location for this league.");
}
if (result.hasErrors()) {
league.setId(leagueId);
return "leagues/createOrUpdateLeagueForm";
}
// Fetch the existing record to preserve teams, events, and timestamps
League existingLeague = leagueRepository.findById(leagueId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "League not found"));
// Safely update ONLY the fields that can be changed via this form
existingLeague.setName(league.getName());
existingLeague.setStatus(league.getStatus());
existingLeague.setDescription(league.getDescription());
existingLeague.setType(league.getType());
existingLeague.setCapacityType(league.getCapacityType());
existingLeague.setCapacity(league.getCapacity());
existingLeague.setFee(league.getFee());
existingLeague.setIsPublic(league.getIsPublic());
existingLeague.setRegistrationStart(league.getRegistrationStart());
existingLeague.setRegistrationEnd(league.getRegistrationEnd());
existingLeague.setLeagueStart(league.getLeagueStart());
existingLeague.setLeagueEnd(league.getLeagueEnd());
// Map the location proxy to the existing record
Location locationProxy = new Location();
locationProxy.setId(league.getLocationId());
existingLeague.setDefaultLocation(locationProxy);
leagueRepository.save(existingLeague);
return "redirect:/schools/" + school.getId() + "/leagues/" + leagueId;
}In your LeagueController, add a @GetMapping to display the "Edit League" form and a @PostMapping to process the form.
In the processUpdateForm method, we safely copy the form data into the existing league database record, rather than overwriting the entire record.
We then take the locationId the user submitted with a league and attach it to a Location object before saving it to the database. We can do this using a "Proxy Object" that just holds the ID. This is called proxy mapping.
import org.springframework.transaction.annotation.Transactional;
// -- Inside LeagueController
// Delete
@PostMapping("/{leagueId}/delete")
@Transactional
public String deleteLeague(@PathVariable("leagueId") int leagueId, @ModelAttribute("school") School school) {
if (!checkEditPermissions(school)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied");
}
League league = leagueRepository.findById(leagueId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "League not found"));
leagueRepository.delete(league);
return "redirect:/schools/" + school.getSlug();
}In your LeagueController, add a @PostMapping to process the delete a league request.
Raise your hand and show Marc the following.
Visiting this page as a guest user should redirect you to the login page.
Signing in as a SCHOOL_ADMIN should display the form with data pre-populated.
Submitting the form with valid data should update a the existing league record to the database and redirect you to the league's details page.
Pressing the Delete button should display a JavaScript confirm prompt. Pressing OK should redirect the user to the school detail page with the league removed.
The record will still exist in the database, but the "deleted_at" field will be set to the current timestamp.
Push to GitHub and deploy to Azure when finished.
Validated that the registration end date must come after the registration start date, the league start date must come after the registration end date, and the league end date must come after the league start date?
To validate chronological dates across multiple fields while still highlighting the specific inputs in red, the most effective approach in Spring Boot is to create a custom class-level validation constraint.
This allows you to evaluate the entire League object at once, but programmatically assign the error messages to specific properties (like registrationEnd or leagueStart) so your Thymeleaf fragments catch them.
package org.springframework.samples.petclinic.league;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Constraint(validatedBy = LeagueScheduleValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidLeagueSchedule {
String message() default "Invalid schedule dates";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}Create a new file named ValidLeagueSchedule.java in your league package. This interface defines the custom annotation.
package org.springframework.samples.petclinic.league;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class LeagueScheduleValidator implements ConstraintValidator<ValidLeagueSchedule, League> {
@Override
public boolean isValid(League league, ConstraintValidatorContext context) {
boolean isValid = true;
// Clear the generic class-level error so we can build specific field errors
context.disableDefaultConstraintViolation();
// 1. Registration End > Registration Start
if (league.getRegistrationStart() != null && league.getRegistrationEnd() != null) {
if (!league.getRegistrationEnd().isAfter(league.getRegistrationStart())) {
context.buildConstraintViolationWithTemplate("{ValidLeagueSchedule.registrationEnd}")
.addPropertyNode("registrationEnd")
.addConstraintViolation();
isValid = false;
}
}
// 2. League Start > Registration End
if (league.getRegistrationEnd() != null && league.getLeagueStart() != null) {
if (!league.getLeagueStart().isAfter(league.getRegistrationEnd())) {
context.buildConstraintViolationWithTemplate("{ValidLeagueSchedule.leagueStart}")
.addPropertyNode("leagueStart")
.addConstraintViolation();
isValid = false;
}
}
// 3. League End > League Start
if (league.getLeagueStart() != null && league.getLeagueEnd() != null) {
if (!league.getLeagueEnd().isAfter(league.getLeagueStart())) {
context.buildConstraintViolationWithTemplate("{ValidLeagueSchedule.leagueEnd}")
.addPropertyNode("leagueEnd")
.addConstraintViolation();
isValid = false;
}
}
return isValid;
}
}Create another new file named LeagueScheduleValidator.java in the same package.
This class implements the interface. Notice how we disable the default class-level error and instead use .addPropertyNode("fieldName") to tie the validation failure directly to your HTML inputs. Because drafts can have missing dates, it only checks the chronological order if both dates in the sequence are provided.
// ... other imports ...
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
@Entity
@Table(name = "leagues")
@SQLDelete(sql = "UPDATE leagues SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
@ValidLeagueSchedule // <-- Add this line
public class League extends NamedEntity {
// ... class contents ...
}Open your League.java file and add the new annotation directly above the class declaration.
ValidLeagueSchedule.registrationEnd=Registration end date must come after the start date.
ValidLeagueSchedule.leagueStart=League start date must come after registration closes.
ValidLeagueSchedule.leagueEnd=League end date must come after the season starts.Finally, open your src/main/resources/messages.properties file and add the three message templates we referenced in the validator:
When you attempt to submit a schedule that breaks these rules, the form will reload and your inputField fragment will render the specific date inputs in red with the appropriate error text below them.
// Change this from Desc to Asc
List<League> findBySchoolIdOrderByLeagueStartAsc(Integer schoolId);To sort the list of leagues by their start date so the most immediate upcoming seasons appear first, you need to change the sorting direction from descending (Desc) to ascending (Asc) for the School Admin view.
Your guest view (using the @Query for findActiveLeagues) is already sorting ascending, so this will unify the order for all users.
Open LeagueRepository.java and update the method signature for the admin query to use Asc instead of Desc.
Note: In MySQL, NULL values are treated as the lowest possible value when sorting. Because draft leagues may have leagueStart set to null, they will conveniently appear at the very top of the list for School Admins, right above the closest upcoming active league.
// Fetch the appropriate leagues
List<League> leagues;
if (checkEditPermissions(school)) {
// Use the new Asc method here
leagues = leagueRepository.findBySchoolIdOrderByLeagueStartAsc(school.getId());
} else {
leagues = leagueRepository.findActiveLeagues(school.getId(), League.LeagueStatus.DRAFT, LocalDateTime.now());
}
mav.addObject("leagues", leagues);Open your SchoolController.java file, locate the showSchoolBySlug method, and update the method call inside the if (checkEditPermissions(school)) block.
By Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.