From 065a83442ce1da9eae9fd7a08710bace868dc012 Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Sun, 28 Dec 2025 19:56:16 -0500 Subject: [PATCH] Add FormValidationService and EventDefinitionService to dependency injection Enhanced the application's service layer by adding FormValidationService and EventDefinitionService to the dependency injection container in Program.cs. Updated Create, Edit, and other relevant components to utilize these services for improved form validation and event processing functionality. --- .../Components/Features/Events/Create.razor | 96 +++++----- WebApp/Components/Features/Events/Edit.razor | 132 ++++++------- .../Components/Features/Students/Create.razor | 50 ++++- .../Components/Features/Students/Edit.razor | 62 ++++++- WebApp/Components/Features/Teams/Create.razor | 136 +++++++++----- WebApp/Components/Features/Teams/Edit.razor | 82 ++++++-- .../Components/ValidationErrorDisplay.razor | 24 +++ .../Components/Shared/Layout/MainLayout.razor | 1 + WebApp/Program.cs | 2 + WebApp/Services/EventDefinitionService.cs | 82 ++++++++ WebApp/Services/FormValidationService.cs | 175 ++++++++++++++++++ 11 files changed, 642 insertions(+), 200 deletions(-) create mode 100644 WebApp/Components/Shared/Components/ValidationErrorDisplay.razor create mode 100644 WebApp/Services/EventDefinitionService.cs create mode 100644 WebApp/Services/FormValidationService.cs 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; + } + } +} +