diff --git a/WebApp/Components/Features/Events/Create.razor b/WebApp/Components/Features/Events/Create.razor
index c86663a..76c0b0d 100644
--- a/WebApp/Components/Features/Events/Create.razor
+++ b/WebApp/Components/Features/Events/Create.razor
@@ -3,8 +3,13 @@
@using WebApp.Components.Shared.Components
@using Core.Utility
@using Microsoft.EntityFrameworkCore
+@using MudBlazor
+@using WebApp.Services
@inject AppDbContext context
@inject NavigationManager NavigationManager
+@inject ISnackbar Snackbar
+@inject FormValidationService ValidationService
+@inject EventDefinitionService EventDefinitionService
-
+
+
+
@@ -111,67 +118,52 @@
private EventDefinition EventDefinition { get; set; } = new();
private FormChangeTracker? _formChangeTracker;
+ private EditContext? _editContext;
+ private List _validationErrors = new();
+
+ protected override void OnInitialized()
+ {
+ _editContext = new EditContext(EventDefinition);
+ }
+
+ private void OnInvalidSubmit(EditContext editContext)
+ {
+ _validationErrors = ValidationService.HandleInvalidSubmit(editContext, EventDefinition, errors => _validationErrors = errors);
+ StateHasChanged();
+ }
private async Task OnValidSubmit()
{
- _formChangeTracker?.AllowNavigation();
-
- // Normalize and process related careers
- await ProcessRelatedCareersAsync(EventDefinition);
-
- context.Events.Add(EventDefinition);
- await context.SaveChangesAsync();
- NavigationManager.NavigateTo(ReturnUrl ?? "/events");
- }
-
- private async Task ProcessRelatedCareersAsync(EventDefinition eventDefinition)
- {
- if (string.IsNullOrWhiteSpace(eventDefinition.RelatedCareersText))
- {
- eventDefinition.RelatedCareers.Clear();
- return;
- }
-
- var normalizedNames = CareerNormalizer.NormalizeCareerNames(eventDefinition.RelatedCareersText).ToList();
+ _validationErrors.Clear();
- if (!normalizedNames.Any())
+ try
{
- eventDefinition.RelatedCareers.Clear();
- return;
- }
+ // Normalize and process related careers
+ await EventDefinitionService.ProcessRelatedCareersAsync(EventDefinition);
- // Get all existing careers from database (case-insensitive lookup)
- var existingCareers = await context.Careers.ToListAsync();
- var careerLookup = existingCareers.ToDictionary(
- c => CareerNormalizer.GetNormalizedKey(c.Name),
- c => c,
- StringComparer.OrdinalIgnoreCase);
-
- var careersToAdd = new List();
-
- foreach (var normalizedName in normalizedNames)
- {
- var normalizedKey = CareerNormalizer.GetNormalizedKey(normalizedName);
+ context.Events.Add(EventDefinition);
+ await context.SaveChangesAsync();
- if (careerLookup.TryGetValue(normalizedKey, out var existingCareer))
- {
- // Use existing career (preserve original capitalization)
- careersToAdd.Add(existingCareer);
- }
- else
- {
- // Create new career with the normalized name (preserving capitalization from input)
- var newCareer = new Career { Name = normalizedName };
- context.Careers.Add(newCareer);
- careersToAdd.Add(newCareer);
- careerLookup[normalizedKey] = newCareer;
- }
+ Snackbar.Add($"Event '{EventDefinition.Name}' created successfully.", Severity.Success);
+ _formChangeTracker?.AllowNavigation();
+ NavigationManager.NavigateTo(ReturnUrl ?? "/events");
+ }
+ catch (DbUpdateException ex)
+ {
+ ValidationService.HandleDbUpdateException(
+ ex,
+ "An error occurred while creating the event.",
+ "event",
+ "creating",
+ "name");
+ }
+ catch (Exception ex)
+ {
+ ValidationService.HandleException(ex);
}
-
- // Replace the collection
- eventDefinition.RelatedCareers = careersToAdd;
}
+
private void HandleCancel()
{
_formChangeTracker?.AllowNavigation();
diff --git a/WebApp/Components/Features/Events/Edit.razor b/WebApp/Components/Features/Events/Edit.razor
index 34275b6..9bf2363 100644
--- a/WebApp/Components/Features/Events/Edit.razor
+++ b/WebApp/Components/Features/Events/Edit.razor
@@ -3,10 +3,16 @@
@using Microsoft.EntityFrameworkCore
@using WebApp.Components.Shared.Components
@using Core.Utility
+@using MudBlazor
+@using System.ComponentModel.DataAnnotations
+@using WebApp.Services
@inject AppDbContext context
@inject NavigationManager NavigationManager
+@inject ISnackbar Snackbar
+@inject FormValidationService ValidationService
+@inject EventDefinitionService EventDefinitionService
-@if (EventDefinition is null)
+@if (EventDefinition is null || _editContext is null)
{
Loading...
return;
@@ -18,10 +24,12 @@
ShowBackButton="true"
BackButtonUrl="@(ReturnUrl ?? "/events")" />
-
+
+
+
@@ -120,6 +128,8 @@
private EventDefinition? EventDefinition { get; set; }
private FormChangeTracker? _formChangeTracker;
+ private EditContext? _editContext;
+ private List _validationErrors = new();
protected override async Task OnInitializedAsync()
{
@@ -135,96 +145,74 @@
{
// Populate RelatedCareersText from RelatedCareers collection
EventDefinition.RelatedCareersText = string.Join("\n", EventDefinition.RelatedCareers.Select(c => c.Name));
+
+ // Create EditContext for validation
+ _editContext = new EditContext(EventDefinition);
}
}
+
+ private void OnInvalidSubmit(EditContext editContext)
+ {
+ _validationErrors = ValidationService.HandleInvalidSubmit(editContext, EventDefinition!, errors => _validationErrors = errors);
+ StateHasChanged();
+ }
// To protect from overposting attacks, enable the specific properties you want to bind to.
// For more information, see https://learn.microsoft.com/aspnet/core/blazor/forms/#mitigate-overposting-attacks.
private async Task OnValidSubmit()
{
- // Get the tracked entity from the database
- var trackedEntity = await context.Events
- .Include(e => e.RelatedCareers)
- .FirstOrDefaultAsync(e => e.Id == EventDefinition!.Id);
-
- if (trackedEntity == null)
- {
- NavigationManager.NavigateTo("notfound");
- return;
- }
-
- // Update scalar properties from the form-bound entity
- context.Entry(trackedEntity).CurrentValues.SetValues(EventDefinition!);
-
- // Normalize and process related careers
- await ProcessRelatedCareersAsync(trackedEntity);
-
try
{
+ // Get the tracked entity from the database
+ var trackedEntity = await context.Events
+ .Include(e => e.RelatedCareers)
+ .FirstOrDefaultAsync(e => e.Id == EventDefinition!.Id);
+
+ if (trackedEntity == null)
+ {
+ Snackbar.Add("Event not found. It may have been deleted.", Severity.Error);
+ NavigationManager.NavigateTo("notfound");
+ return;
+ }
+
+ // Update scalar properties from the form-bound entity
+ context.Entry(trackedEntity).CurrentValues.SetValues(EventDefinition!);
+
+ // Normalize and process related careers
+ await EventDefinitionService.ProcessRelatedCareersAsync(trackedEntity);
+
await context.SaveChangesAsync();
+ _validationErrors.Clear();
+ Snackbar.Add($"Event '{EventDefinition!.Name}' saved successfully.", Severity.Success);
_formChangeTracker?.AllowNavigation();
NavigationManager.NavigateTo(ReturnUrl ?? "/events");
}
- catch (DbUpdateConcurrencyException)
+ catch (DbUpdateConcurrencyException ex)
{
- if (!EventDefinitionExists(EventDefinition!.Id))
- {
- NavigationManager.NavigateTo("notfound");
- }
- else
+ if (!ValidationService.HandleDbUpdateConcurrencyException(
+ ex,
+ () => EventDefinitionExists(EventDefinition!.Id),
+ "event",
+ () => NavigationManager.NavigateTo("notfound")))
{
throw;
}
}
+ catch (DbUpdateException ex)
+ {
+ ValidationService.HandleDbUpdateException(
+ ex,
+ "An error occurred while saving the event.",
+ "event",
+ "saving",
+ "name");
+ }
+ catch (Exception ex)
+ {
+ ValidationService.HandleException(ex);
+ }
}
- private async Task ProcessRelatedCareersAsync(EventDefinition eventDefinition)
- {
- if (string.IsNullOrWhiteSpace(eventDefinition.RelatedCareersText))
- {
- eventDefinition.RelatedCareers.Clear();
- return;
- }
-
- var normalizedNames = CareerNormalizer.NormalizeCareerNames(eventDefinition.RelatedCareersText).ToList();
-
- if (!normalizedNames.Any())
- {
- eventDefinition.RelatedCareers.Clear();
- return;
- }
-
- // Get all existing careers from database (case-insensitive lookup)
- var existingCareers = await context.Careers.ToListAsync();
- var careerLookup = existingCareers.ToDictionary(
- c => CareerNormalizer.GetNormalizedKey(c.Name),
- c => c,
- StringComparer.OrdinalIgnoreCase);
-
- var careersToAdd = new List();
-
- foreach (var normalizedName in normalizedNames)
- {
- var normalizedKey = CareerNormalizer.GetNormalizedKey(normalizedName);
-
- if (careerLookup.TryGetValue(normalizedKey, out var existingCareer))
- {
- // Use existing career (preserve original capitalization)
- careersToAdd.Add(existingCareer);
- }
- else
- {
- // Create new career with the normalized name (preserving capitalization from input)
- var newCareer = new Career { Name = normalizedName };
- context.Careers.Add(newCareer);
- careersToAdd.Add(newCareer);
- careerLookup[normalizedKey] = newCareer;
- }
- }
-
- // Replace the collection
- eventDefinition.RelatedCareers = careersToAdd;
- }
private async Task HandleCancel()
{
diff --git a/WebApp/Components/Features/Students/Create.razor b/WebApp/Components/Features/Students/Create.razor
index 5e5989a..88968b2 100644
--- a/WebApp/Components/Features/Students/Create.razor
+++ b/WebApp/Components/Features/Students/Create.razor
@@ -1,8 +1,13 @@
@page "/students/create"
@attribute [Authorize]
@using WebApp.Components.Shared.Components
+@using MudBlazor
+@using WebApp.Services
+@using Microsoft.EntityFrameworkCore
@inject AppDbContext Context
@inject NavigationManager NavigationManager
+@inject ISnackbar Snackbar
+@inject FormValidationService ValidationService
-
+
+
+
Student Information
@@ -53,14 +60,45 @@
private Student Student { get; set; } = new() { TsaYear = 1 };
private FormChangeTracker? _formChangeTracker;
+ private EditContext? _editContext;
+ private List _validationErrors = new();
- private void OnValidSubmit(EditContext context)
+ protected override void OnInitialized()
{
- _formChangeTracker?.AllowNavigation();
+ _editContext = new EditContext(Student);
+ }
- Context.Students.Add(Student);
- Context.SaveChanges();
- NavigationManager.NavigateTo(ReturnUrl ?? "/students");
+ private void OnInvalidSubmit(EditContext editContext)
+ {
+ _validationErrors = ValidationService.HandleInvalidSubmit(editContext, Student, errors => _validationErrors = errors);
+ StateHasChanged();
+ }
+
+ private async Task OnValidSubmit()
+ {
+ _validationErrors.Clear();
+
+ try
+ {
+ Context.Students.Add(Student);
+ await Context.SaveChangesAsync();
+
+ Snackbar.Add($"Student '{Student.FirstNameLastName}' created successfully.", Severity.Success);
+ _formChangeTracker?.AllowNavigation();
+ NavigationManager.NavigateTo(ReturnUrl ?? "/students");
+ }
+ catch (DbUpdateException ex)
+ {
+ ValidationService.HandleDbUpdateException(
+ ex,
+ "An error occurred while creating the student.",
+ "student",
+ "creating");
+ }
+ catch (Exception ex)
+ {
+ ValidationService.HandleException(ex);
+ }
}
private void HandleCancel()
diff --git a/WebApp/Components/Features/Students/Edit.razor b/WebApp/Components/Features/Students/Edit.razor
index c22f8af..7dff7dd 100644
--- a/WebApp/Components/Features/Students/Edit.razor
+++ b/WebApp/Components/Features/Students/Edit.razor
@@ -2,10 +2,14 @@
@attribute [Authorize]
@using Microsoft.EntityFrameworkCore
@using WebApp.Components.Shared.Components
+@using MudBlazor
+@using WebApp.Services
@inject AppDbContext Context
@inject NavigationManager NavigationManager
+@inject ISnackbar Snackbar
+@inject FormValidationService ValidationService
-@if (Student is null)
+@if (Student is null || _editContext is null)
{
Loading...
return;
@@ -19,10 +23,13 @@
@* https://www.mudblazor.com/components/form *@
@* https://medium.com/@husainalbar/applying-mudblazor-for-crud-operations-in-our-blazor-project-a343037a52ef *@
-
+
+
+
+
@@ -64,6 +71,8 @@
private Student? Student { get; set; }
private FormChangeTracker? _formChangeTracker;
+ private EditContext? _editContext;
+ private List _validationErrors = new();
protected override async Task OnInitializedAsync()
{
@@ -73,12 +82,36 @@
{
NavigationManager.NavigateTo("notfound");
}
+ else
+ {
+ _editContext = new EditContext(Student);
+ }
+ }
+
+ private void OnInvalidSubmit(EditContext editContext)
+ {
+ _validationErrors = ValidationService.HandleInvalidSubmit(editContext, Student!, errors => _validationErrors = errors);
+ StateHasChanged();
}
// To protect from overposting attacks, enable the specific properties you want to bind to.
// For more information, see https://learn.microsoft.com/aspnet/core/blazor/forms/#mitigate-overposting-attacks.
private async Task UpdateStudent()
{
+ // Validate before processing
+ if (_editContext != null)
+ {
+ var errors = ValidationService.CollectValidationErrors(_editContext, Student!);
+ if (errors.Any())
+ {
+ _validationErrors = ValidationService.HandleInvalidSubmit(_editContext, Student!, err => _validationErrors = err);
+ StateHasChanged();
+ return;
+ }
+ }
+
+ _validationErrors.Clear();
+
if (Student?.OfficerRole == 0)
Student.OfficerRole = null;
@@ -87,20 +120,33 @@
try
{
await Context.SaveChangesAsync();
+ Snackbar.Add($"Student '{Student!.FirstNameLastName}' saved successfully.", Severity.Success);
_formChangeTracker?.AllowNavigation();
NavigationManager.NavigateTo(ReturnUrl ?? "/students");
}
- catch (DbUpdateConcurrencyException)
+ catch (DbUpdateConcurrencyException ex)
{
- if (!StudentExists(Student!.Id))
- {
- NavigationManager.NavigateTo("notfound");
- }
- else
+ if (!ValidationService.HandleDbUpdateConcurrencyException(
+ ex,
+ () => StudentExists(Student!.Id),
+ "student",
+ () => NavigationManager.NavigateTo("notfound")))
{
throw;
}
}
+ catch (DbUpdateException ex)
+ {
+ ValidationService.HandleDbUpdateException(
+ ex,
+ "An error occurred while saving the student.",
+ "student",
+ "saving");
+ }
+ catch (Exception ex)
+ {
+ ValidationService.HandleException(ex);
+ }
}
private async Task HandleCancel()
diff --git a/WebApp/Components/Features/Teams/Create.razor b/WebApp/Components/Features/Teams/Create.razor
index e0726a8..2def5a7 100644
--- a/WebApp/Components/Features/Teams/Create.razor
+++ b/WebApp/Components/Features/Teams/Create.razor
@@ -2,10 +2,14 @@
@attribute [Authorize]
@using Microsoft.EntityFrameworkCore
@using WebApp.Components.Shared.Components
+@using MudBlazor
+@using WebApp.Services
@inject AppDbContext Context
@inject NavigationManager NavigationManager
+@inject ISnackbar Snackbar
+@inject FormValidationService ValidationService
-@if (_events is null)
+@if (_events is null || _editContext is null)
{
Loading...
return;
@@ -17,10 +21,13 @@
ShowBackButton="true"
BackButtonUrl="@(ReturnUrl ?? "/teams")" />
-
+
+
+
+
@@ -79,6 +86,8 @@
private Team Team { get; set; } = new();
private FormChangeTracker? _formChangeTracker;
+ private EditContext? _editContext;
+ private List _validationErrors = new();
private List? _events;
private List _students = [];
private IEnumerable _selectedStudents = [];
@@ -93,6 +102,14 @@
.ToListAsync();
_students = await Context.Students.ToListAsync();
+
+ _editContext = new EditContext(Team);
+ }
+
+ private void OnInvalidSubmit(EditContext editContext)
+ {
+ _validationErrors = ValidationService.HandleInvalidSubmit(editContext, Team, errors => _validationErrors = errors);
+ StateHasChanged();
}
private async Task OnEventChanged(EventDefinition selectedEvent)
@@ -111,61 +128,92 @@
private async Task AddTeam()
{
+ // Validate before processing
+ if (_editContext != null)
+ {
+ var errors = ValidationService.CollectValidationErrors(_editContext, Team);
+ if (errors.Any())
+ {
+ _validationErrors = ValidationService.HandleInvalidSubmit(_editContext, Team, err => _validationErrors = err);
+ StateHasChanged();
+ return;
+ }
+ }
+
+ _validationErrors.Clear();
+
// Clear previous error message
_errorMessage = null;
- // Get current count of teams for this event
- var existingTeamCount = await Context.Teams
- .CountAsync(t => t.Event.Id == Team.Event.Id);
-
- // Prohibit creation of third team
- if (existingTeamCount >= 2)
+ try
{
- _errorMessage = $"Cannot create a third team for {Team.Event.Name}. Maximum of 2 teams allowed.";
- return;
- }
+ // Get current count of teams for this event
+ var existingTeamCount = await Context.Teams
+ .CountAsync(t => t.Event.Id == Team.Event.Id);
- // Handle automatic numbering based on event format
- if (Team.Event.EventFormat == EventFormat.Individual && _selectedStudents.Count() == 1)
- {
- // For individual events, use student's first name as identifier
- var student = _selectedStudents.First();
- Team.Identifier = student.FirstName;
- Team.Captain = student;
- }
- else if (existingTeamCount == 1)
- {
- // This is the second team - assign numbers
- var existingTeam = await Context.Teams
- .FirstOrDefaultAsync(t => t.Event.Id == Team.Event.Id);
-
- if (existingTeam != null)
+ // Prohibit creation of third team
+ if (existingTeamCount >= 2)
{
- // Update existing team to number 1
- existingTeam.Identifier = "1";
- Context.Teams.Update(existingTeam);
+ _errorMessage = $"Cannot create a third team for {Team.Event.Name}. Maximum of 2 teams allowed.";
+ Snackbar.Add(_errorMessage, Severity.Error);
+ return;
}
- // Set new team to number 2
- Team.Identifier = "2";
+ // Handle automatic numbering based on event format
+ if (Team.Event.EventFormat == EventFormat.Individual && _selectedStudents.Count() == 1)
+ {
+ // For individual events, use student's first name as identifier
+ var student = _selectedStudents.First();
+ Team.Identifier = student.FirstName;
+ Team.Captain = student;
+ }
+ else if (existingTeamCount == 1)
+ {
+ // This is the second team - assign numbers
+ var existingTeam = await Context.Teams
+ .FirstOrDefaultAsync(t => t.Event.Id == Team.Event.Id);
+
+ if (existingTeam != null)
+ {
+ // Update existing team to number 1
+ existingTeam.Identifier = "1";
+ Context.Teams.Update(existingTeam);
+ }
+
+ // Set new team to number 2
+ Team.Identifier = "2";
+ }
+ else
+ {
+ // This is the first team - no number
+ Team.Identifier = null;
+ }
+
+ // Add selected students to the team
+ foreach (var student in _selectedStudents)
+ {
+ Team.Students.Add(student);
+ }
+
+ Context.Teams.Add(Team);
+
+ await Context.SaveChangesAsync();
+ Snackbar.Add($"Team '{Team}' created successfully.", Severity.Success);
+ _formChangeTracker?.AllowNavigation();
+ NavigationManager.NavigateTo(ReturnUrl ?? "/teams");
}
- else
+ catch (DbUpdateException ex)
{
- // This is the first team - no number
- Team.Identifier = null;
+ ValidationService.HandleDbUpdateException(
+ ex,
+ "An error occurred while creating the team.",
+ "team",
+ "creating");
}
-
- // Add selected students to the team
- foreach (var student in _selectedStudents)
+ catch (Exception ex)
{
- Team.Students.Add(student);
+ ValidationService.HandleException(ex);
}
-
- Context.Teams.Add(Team);
-
- await Context.SaveChangesAsync();
- _formChangeTracker?.AllowNavigation();
- NavigationManager.NavigateTo(ReturnUrl ?? "/teams");
}
private void HandleCancel()
diff --git a/WebApp/Components/Features/Teams/Edit.razor b/WebApp/Components/Features/Teams/Edit.razor
index 525b691..cb48a9e 100644
--- a/WebApp/Components/Features/Teams/Edit.razor
+++ b/WebApp/Components/Features/Teams/Edit.razor
@@ -2,10 +2,14 @@
@attribute [Authorize]
@using Microsoft.EntityFrameworkCore
@using WebApp.Components.Shared.Components
+@using MudBlazor
+@using WebApp.Services
@inject AppDbContext Context
@inject NavigationManager NavigationManager
+@inject ISnackbar Snackbar
+@inject FormValidationService ValidationService
-@if (Team is null)
+@if (Team is null || _editContext is null)
{
Loading...
return;
@@ -16,10 +20,13 @@
ShowBackButton="true"
BackButtonUrl="@(ReturnUrl ?? "/teams")" />
-
+
+
+
+
@@ -53,6 +60,8 @@
private Team? Team { get; set; }
private FormChangeTracker? _formChangeTracker;
+ private EditContext? _editContext;
+ private List _validationErrors = new();
private IEnumerable? _selectedStudents = [];
private List _students = [];
@@ -69,24 +78,48 @@
{
NavigationManager.NavigateTo("notfound");
}
-
- switch (Team!.Event.EventFormat)
+ else
{
- case EventFormat.Individual when Team.Students.Count == 1:
- Team.Captain ??= Team.Students[0];
- Team.Identifier ??= Team.Captain.FirstName;
- break;
- case EventFormat.Team:
- break;
- default:
- throw new ArgumentOutOfRangeException();
+ _editContext = new EditContext(Team);
+
+ switch (Team.Event.EventFormat)
+ {
+ case EventFormat.Individual when Team.Students.Count == 1:
+ Team.Captain ??= Team.Students[0];
+ Team.Identifier ??= Team.Captain.FirstName;
+ break;
+ case EventFormat.Team:
+ break;
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
}
}
+ private void OnInvalidSubmit(EditContext editContext)
+ {
+ _validationErrors = ValidationService.HandleInvalidSubmit(editContext, Team!, errors => _validationErrors = errors);
+ StateHasChanged();
+ }
+
// To protect from overposting attacks, enable the specific properties you want to bind to.
// For more information, see https://learn.microsoft.com/aspnet/core/blazor/forms/#mitigate-overposting-attacks.
private async Task UpdateTeam()
{
+ // Validate before processing
+ if (_editContext != null)
+ {
+ var errors = ValidationService.CollectValidationErrors(_editContext, Team!);
+ if (errors.Any())
+ {
+ _validationErrors = ValidationService.HandleInvalidSubmit(_editContext, Team!, err => _validationErrors = err);
+ StateHasChanged();
+ return;
+ }
+ }
+
+ _validationErrors.Clear();
+
//Context.Attach(Team!).Entity = EntityState.Modified;
Team?.Students.Clear();
if (_selectedStudents != null)
@@ -105,20 +138,33 @@
try
{
await Context.SaveChangesAsync();
+ Snackbar.Add($"Team '{Team}' saved successfully.", Severity.Success);
_formChangeTracker?.AllowNavigation();
NavigationManager.NavigateTo(ReturnUrl ?? "/teams");
}
- catch (DbUpdateConcurrencyException)
+ catch (DbUpdateConcurrencyException ex)
{
- if (!TeamExists(Team!.Id))
- {
- NavigationManager.NavigateTo("notfound");
- }
- else
+ if (!ValidationService.HandleDbUpdateConcurrencyException(
+ ex,
+ () => TeamExists(Team!.Id),
+ "team",
+ () => NavigationManager.NavigateTo("notfound")))
{
throw;
}
}
+ catch (DbUpdateException ex)
+ {
+ ValidationService.HandleDbUpdateException(
+ ex,
+ "An error occurred while saving the team.",
+ "team",
+ "saving");
+ }
+ catch (Exception ex)
+ {
+ ValidationService.HandleException(ex);
+ }
}
private async Task HandleCancel()
diff --git a/WebApp/Components/Shared/Components/ValidationErrorDisplay.razor b/WebApp/Components/Shared/Components/ValidationErrorDisplay.razor
new file mode 100644
index 0000000..b12190e
--- /dev/null
+++ b/WebApp/Components/Shared/Components/ValidationErrorDisplay.razor
@@ -0,0 +1,24 @@
+@namespace WebApp.Components.Shared.Components
+@using MudBlazor
+
+@if (Errors != null && Errors.Any())
+{
+
+ Please fix the following validation errors:
+
+ @foreach (var error in Errors)
+ {
+ - @error
+ }
+
+
+}
+
+@code {
+ ///
+ /// List of validation error messages to display
+ ///
+ [Parameter]
+ public List? Errors { get; set; }
+}
+
diff --git a/WebApp/Components/Shared/Layout/MainLayout.razor b/WebApp/Components/Shared/Layout/MainLayout.razor
index 690d22f..4e444f1 100644
--- a/WebApp/Components/Shared/Layout/MainLayout.razor
+++ b/WebApp/Components/Shared/Layout/MainLayout.razor
@@ -4,6 +4,7 @@
+
diff --git a/WebApp/Program.cs b/WebApp/Program.cs
index 2a6b76e..7fbd1bf 100644
--- a/WebApp/Program.cs
+++ b/WebApp/Program.cs
@@ -174,6 +174,8 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
// State container for maintaining state per user connection (Blazor Server)
builder.Services.AddScoped();
diff --git a/WebApp/Services/EventDefinitionService.cs b/WebApp/Services/EventDefinitionService.cs
new file mode 100644
index 0000000..cad1649
--- /dev/null
+++ b/WebApp/Services/EventDefinitionService.cs
@@ -0,0 +1,82 @@
+using Core.Entities;
+using Core.Utility;
+using Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace WebApp.Services;
+
+///
+/// Service for handling EventDefinition-related operations
+///
+public class EventDefinitionService
+{
+ private readonly AppDbContext _context;
+
+ public EventDefinitionService(AppDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Processes and normalizes related careers for an EventDefinition.
+ /// Matches existing careers by normalized name (case-insensitive) or creates new ones.
+ ///
+ /// The EventDefinition to process careers for
+ public async Task ProcessRelatedCareersAsync(EventDefinition eventDefinition)
+ {
+ if (string.IsNullOrWhiteSpace(eventDefinition.RelatedCareersText))
+ {
+ eventDefinition.RelatedCareers.Clear();
+ return;
+ }
+
+ var normalizedNames = CareerNormalizer.NormalizeCareerNames(eventDefinition.RelatedCareersText).ToList();
+
+ if (!normalizedNames.Any())
+ {
+ eventDefinition.RelatedCareers.Clear();
+ return;
+ }
+
+ // Get all existing careers from database (case-insensitive lookup)
+ var existingCareers = await _context.Careers
+ .Where(c => !string.IsNullOrWhiteSpace(c.Name))
+ .ToListAsync();
+
+ // Build lookup dictionary, handling potential duplicates by taking the first occurrence
+ var careerLookup = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var career in existingCareers)
+ {
+ var normalizedKey = CareerNormalizer.GetNormalizedKey(career.Name);
+ if (!careerLookup.ContainsKey(normalizedKey))
+ {
+ careerLookup[normalizedKey] = career;
+ }
+ }
+
+ var careersToAdd = new List();
+
+ foreach (var normalizedName in normalizedNames)
+ {
+ var normalizedKey = CareerNormalizer.GetNormalizedKey(normalizedName);
+
+ if (careerLookup.TryGetValue(normalizedKey, out var existingCareer))
+ {
+ // Use existing career (preserve original capitalization)
+ careersToAdd.Add(existingCareer);
+ }
+ else
+ {
+ // Create new career with the normalized name (preserving capitalization from input)
+ var newCareer = new Career { Name = normalizedName };
+ _context.Careers.Add(newCareer);
+ careersToAdd.Add(newCareer);
+ careerLookup[normalizedKey] = newCareer;
+ }
+ }
+
+ // Replace the collection
+ eventDefinition.RelatedCareers = careersToAdd;
+ }
+}
+
diff --git a/WebApp/Services/FormValidationService.cs b/WebApp/Services/FormValidationService.cs
new file mode 100644
index 0000000..fc20063
--- /dev/null
+++ b/WebApp/Services/FormValidationService.cs
@@ -0,0 +1,175 @@
+using System.ComponentModel.DataAnnotations;
+using Microsoft.AspNetCore.Components.Forms;
+using Microsoft.EntityFrameworkCore;
+using MudBlazor;
+
+namespace WebApp.Services;
+
+///
+/// Service for handling form validation errors and displaying them to users
+///
+public class FormValidationService
+{
+ private readonly ISnackbar _snackbar;
+
+ public FormValidationService(ISnackbar snackbar)
+ {
+ _snackbar = snackbar;
+ }
+
+ ///
+ /// Collects all validation errors from an EditContext and model validation
+ ///
+ /// The EditContext from the form
+ /// The model being validated
+ /// List of validation error messages
+ public List CollectValidationErrors(EditContext editContext, object model)
+ {
+ var errors = new List();
+
+ // Validate the entire model using data annotations
+ var validationResults = new List();
+ var validationContext = new ValidationContext(model);
+ bool isValid = Validator.TryValidateObject(model, validationContext, validationResults, true);
+
+ if (!isValid)
+ {
+ foreach (var result in validationResults)
+ {
+ foreach (var memberName in result.MemberNames)
+ {
+ var errorMessage = $"{memberName}: {result.ErrorMessage}";
+ if (!errors.Contains(errorMessage))
+ {
+ errors.Add(errorMessage);
+ }
+ }
+ }
+ }
+
+ // Also get validation messages from EditContext (this gets all field-level messages)
+ var validationMessages = editContext.GetValidationMessages();
+ foreach (var message in validationMessages)
+ {
+ if (!errors.Contains(message))
+ {
+ errors.Add(message);
+ }
+ }
+
+ return errors;
+ }
+
+ ///
+ /// Handles invalid form submission by collecting errors and displaying them
+ ///
+ /// The EditContext from the form
+ /// The model being validated
+ /// Optional callback to handle the collected errors (e.g., store in component state)
+ /// List of validation error messages
+ public List HandleInvalidSubmit(EditContext editContext, object model, Action>? onErrorsCollected = null)
+ {
+ var errors = CollectValidationErrors(editContext, model);
+
+ if (onErrorsCollected != null)
+ {
+ onErrorsCollected(errors);
+ }
+
+ if (errors.Any())
+ {
+ var errorText = string.Join("; ", errors);
+ _snackbar.Add($"Validation failed: {errorText}", Severity.Error);
+ }
+ else
+ {
+ _snackbar.Add("Please correct the validation errors and try again.", Severity.Warning);
+ }
+
+ return errors;
+ }
+
+ ///
+ /// Handles DbUpdateException and displays user-friendly error messages
+ ///
+ /// The DbUpdateException to handle
+ /// Default error message if specific handling isn't available
+ /// Name of the entity being saved (e.g., "event", "student", "team")
+ /// Operation being performed (e.g., "saving", "creating")
+ /// Field name for UNIQUE constraint errors (e.g., "name", "email")
+ public void HandleDbUpdateException(
+ DbUpdateException ex,
+ string defaultErrorMessage,
+ string entityName = "record",
+ string operation = "saving",
+ string? uniqueConstraintField = null)
+ {
+ var errorMessage = defaultErrorMessage;
+
+ if (ex.InnerException != null)
+ {
+ var innerMessage = ex.InnerException.Message;
+
+ if (innerMessage.Contains("UNIQUE constraint", StringComparison.OrdinalIgnoreCase) ||
+ innerMessage.Contains("duplicate key", StringComparison.OrdinalIgnoreCase))
+ {
+ if (!string.IsNullOrEmpty(uniqueConstraintField))
+ {
+ errorMessage = $"A {entityName} with this {uniqueConstraintField} already exists. Please choose a different {uniqueConstraintField}.";
+ }
+ else
+ {
+ errorMessage = $"A {entityName} with this information already exists. Please check for duplicates.";
+ }
+ }
+ else if (innerMessage.Contains("FOREIGN KEY constraint", StringComparison.OrdinalIgnoreCase))
+ {
+ errorMessage = $"Cannot {operation}: This {entityName} is referenced by other records.";
+ }
+ else
+ {
+ errorMessage = $"Database error: {innerMessage}";
+ }
+ }
+
+ _snackbar.Add(errorMessage, Severity.Error);
+ }
+
+ ///
+ /// Handles generic exceptions and displays user-friendly error messages
+ ///
+ /// The Exception to handle
+ public void HandleException(Exception ex)
+ {
+ var errorMessage = $"An unexpected error occurred: {ex.Message}";
+ _snackbar.Add(errorMessage, Severity.Error);
+ }
+
+ ///
+ /// Handles DbUpdateConcurrencyException and displays user-friendly error messages
+ ///
+ /// The DbUpdateConcurrencyException to handle
+ /// Function to check if the entity still exists
+ /// Name of the entity (e.g., "event", "student", "team")
+ /// Optional action to perform when entity is not found (e.g., navigate to notfound page)
+ /// True if the exception was handled (entity not found), false if it should be rethrown
+ public bool HandleDbUpdateConcurrencyException(
+ DbUpdateConcurrencyException ex,
+ Func entityExists,
+ string entityName = "record",
+ Action? onNotFound = null)
+ {
+ if (!entityExists())
+ {
+ _snackbar.Add($"{entityName.Substring(0, 1).ToUpper()}{entityName.Substring(1)} not found. It may have been deleted.", Severity.Error);
+ onNotFound?.Invoke();
+ return true;
+ }
+ else
+ {
+ _snackbar.Add($"The {entityName} was modified by another user. Please refresh and try again.", Severity.Warning);
+ return false;
+ }
+ }
+}
+