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; + } + } +} +