diff --git a/Events-MVC/Events-MVC.slnx b/Events-MVC/Events-MVC.slnx new file mode 100644 index 0000000..bda52f8 --- /dev/null +++ b/Events-MVC/Events-MVC.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Events-MVC/Events.EF/Data/MSSQL/EventsContext.cs b/Events-MVC/Events.EF/Data/MSSQL/EventsContext.cs new file mode 100644 index 0000000..df5e7a7 --- /dev/null +++ b/Events-MVC/Events.EF/Data/MSSQL/EventsContext.cs @@ -0,0 +1,143 @@ +// This file has been auto generated by EF Core Power Tools. +#nullable enable +using System; +using System.Collections.Generic; +using Events.EF.Models; +using Microsoft.EntityFrameworkCore; + +namespace Events.EF.Data.MSSQL; + +public partial class EventsContext : DbContext +{ + public EventsContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet Countries { get; set; } + + public virtual DbSet Events { get; set; } + + public virtual DbSet People { get; set; } + + public virtual DbSet Registrations { get; set; } + + public virtual DbSet Sports { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Code); + + entity.ToTable("Country"); + + entity.HasIndex(e => e.Name, "UQ_Country_Name").IsUnique(); + + entity.Property(e => e.Code) + .HasMaxLength(3) + .IsUnicode(false); + entity.Property(e => e.Alpha3) + .HasMaxLength(3) + .IsUnicode(false) + .IsFixedLength(); + entity.Property(e => e.Name) + .HasMaxLength(100) + .IsUnicode(false); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Event"); + + entity.Property(e => e.Name) + .HasMaxLength(150) + .IsUnicode(false); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Person"); + + entity.HasIndex(e => new { e.DocumentNumber, e.CountryCode }, "UQ_Person_DocumentNumber_CountryCode").IsUnique(); + + entity.Property(e => e.AddressCountry) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.AddressLine) + .HasMaxLength(200) + .IsUnicode(false); + entity.Property(e => e.City) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.ContactPhone) + .HasMaxLength(50) + .IsUnicode(false); + entity.Property(e => e.CountryCode) + .HasMaxLength(3) + .IsUnicode(false); + entity.Property(e => e.DocumentNumber) + .HasMaxLength(50) + .IsUnicode(false); + entity.Property(e => e.Email) + .HasMaxLength(255) + .IsUnicode(false); + entity.Property(e => e.FirstName) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.FirstNameTranscription) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.LastName) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.LastNameTranscription) + .HasMaxLength(100) + .IsUnicode(false); + entity.Property(e => e.PostalCode) + .HasMaxLength(20) + .IsUnicode(false); + + entity.HasOne(d => d.CountryCodeNavigation).WithMany(p => p.People) + .HasForeignKey(d => d.CountryCode) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_Person_Country"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Registration"); + + entity.HasIndex(e => new { e.PersonId, e.SportId, e.EventId }, "UQ_Registration_PersonId_SportId_EventId").IsUnique(); + + entity.Property(e => e.RegisteredAt).HasDefaultValueSql("(sysutcdatetime())", "DF_Registration_RegisteredAt"); + + entity.HasOne(d => d.Event).WithMany(p => p.Registrations) + .HasForeignKey(d => d.EventId) + .HasConstraintName("FK_Registration_Event"); + + entity.HasOne(d => d.Person).WithMany(p => p.Registrations) + .HasForeignKey(d => d.PersonId) + .HasConstraintName("FK_Registration_Person"); + + entity.HasOne(d => d.Sport).WithMany(p => p.Registrations) + .HasForeignKey(d => d.SportId) + .HasConstraintName("FK_Registration_Sport"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Sport"); + + entity.HasIndex(e => e.Name, "UQ_Sport_Name").IsUnique(); + + entity.Property(e => e.Name) + .HasMaxLength(100) + .IsUnicode(false); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/Events-MVC/Events.EF/Data/Postgres/EventsContext.cs b/Events-MVC/Events.EF/Data/Postgres/EventsContext.cs new file mode 100644 index 0000000..1e66779 --- /dev/null +++ b/Events-MVC/Events.EF/Data/Postgres/EventsContext.cs @@ -0,0 +1,165 @@ +// This file has been auto generated by EF Core Power Tools. +#nullable enable +using System; +using System.Collections.Generic; +using Events.EF.Models; +using Microsoft.EntityFrameworkCore; + +namespace Events.EF.Data.Postgres; + +public partial class EventsContext : DbContext +{ + public EventsContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet Countries { get; set; } + + public virtual DbSet Events { get; set; } + + public virtual DbSet People { get; set; } + + public virtual DbSet Registrations { get; set; } + + public virtual DbSet Sports { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Code).HasName("country_pkey"); + + entity.ToTable("country"); + + entity.HasIndex(e => e.Name, "country_name_key").IsUnique(); + + entity.Property(e => e.Code) + .HasMaxLength(3) + .HasColumnName("code"); + entity.Property(e => e.Alpha3) + .HasMaxLength(3) + .IsFixedLength() + .HasColumnName("alpha3"); + entity.Property(e => e.Name) + .HasMaxLength(100) + .HasColumnName("name"); + entity.Property(e => e.Translations) + .HasColumnType("jsonb") + .HasColumnName("translations"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("event_pkey"); + + entity.ToTable("event"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.EventDate).HasColumnName("event_date"); + entity.Property(e => e.Name) + .HasMaxLength(150) + .HasColumnName("name"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("person_pkey"); + + entity.ToTable("person"); + + entity.HasIndex(e => new { e.DocumentNumber, e.CountryCode }, "person_document_number_country_code_key").IsUnique(); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.AddressCountry) + .HasMaxLength(100) + .HasColumnName("address_country"); + entity.Property(e => e.AddressLine) + .HasMaxLength(200) + .HasColumnName("address_line"); + entity.Property(e => e.BirthDate).HasColumnName("birth_date"); + entity.Property(e => e.City) + .HasMaxLength(100) + .HasColumnName("city"); + entity.Property(e => e.ContactPhone) + .HasMaxLength(50) + .HasColumnName("contact_phone"); + entity.Property(e => e.CountryCode) + .HasMaxLength(3) + .HasColumnName("country_code"); + entity.Property(e => e.DocumentNumber) + .HasMaxLength(50) + .HasColumnName("document_number"); + entity.Property(e => e.Email) + .HasMaxLength(255) + .HasColumnName("email"); + entity.Property(e => e.FirstName) + .HasMaxLength(100) + .HasColumnName("first_name"); + entity.Property(e => e.FirstNameTranscription) + .HasMaxLength(100) + .HasColumnName("first_name_transcription"); + entity.Property(e => e.LastName) + .HasMaxLength(100) + .HasColumnName("last_name"); + entity.Property(e => e.LastNameTranscription) + .HasMaxLength(100) + .HasColumnName("last_name_transcription"); + entity.Property(e => e.PostalCode) + .HasMaxLength(20) + .HasColumnName("postal_code"); + + entity.HasOne(d => d.CountryCodeNavigation).WithMany(p => p.People) + .HasForeignKey(d => d.CountryCode) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("person_country_code_fkey"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("registration_pkey"); + + entity.ToTable("registration"); + + entity.HasIndex(e => new { e.PersonId, e.SportId, e.EventId }, "registration_person_id_sport_id_event_id_key").IsUnique(); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.EventId).HasColumnName("event_id"); + entity.Property(e => e.PersonId).HasColumnName("person_id"); + entity.Property(e => e.RegisteredAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("registered_at"); + entity.Property(e => e.SportId).HasColumnName("sport_id"); + + entity.HasOne(d => d.Event).WithMany(p => p.Registrations) + .HasForeignKey(d => d.EventId) + .HasConstraintName("registration_event_id_fkey"); + + entity.HasOne(d => d.Person).WithMany(p => p.Registrations) + .HasForeignKey(d => d.PersonId) + .HasConstraintName("registration_person_id_fkey"); + + entity.HasOne(d => d.Sport).WithMany(p => p.Registrations) + .HasForeignKey(d => d.SportId) + .HasConstraintName("registration_sport_id_fkey"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("sport_pkey"); + + entity.ToTable("sport"); + + entity.HasIndex(e => e.Name, "sport_name_key").IsUnique(); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Name) + .HasMaxLength(100) + .HasColumnName("name"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} \ No newline at end of file diff --git a/Events-MVC/Events.EF/Events.EF.csproj b/Events-MVC/Events.EF/Events.EF.csproj new file mode 100644 index 0000000..7cae43e --- /dev/null +++ b/Events-MVC/Events.EF/Events.EF.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/Events-MVC/Events.EF/Models/Country.cs b/Events-MVC/Events.EF/Models/Country.cs new file mode 100644 index 0000000..3203a07 --- /dev/null +++ b/Events-MVC/Events.EF/Models/Country.cs @@ -0,0 +1,19 @@ +// This file has been auto generated by EF Core Power Tools. +#nullable enable +using System; +using System.Collections.Generic; + +namespace Events.EF.Models; + +public partial class Country +{ + public string Code { get; set; } = null!; + + public string Alpha3 { get; set; } = null!; + + public string Name { get; set; } = null!; + + public string? Translations { get; set; } + + public virtual ICollection People { get; set; } = new List(); +} \ No newline at end of file diff --git a/Events-MVC/Events.EF/Models/Event.cs b/Events-MVC/Events.EF/Models/Event.cs new file mode 100644 index 0000000..13b99f6 --- /dev/null +++ b/Events-MVC/Events.EF/Models/Event.cs @@ -0,0 +1,17 @@ +// This file has been auto generated by EF Core Power Tools. +#nullable enable +using System; +using System.Collections.Generic; + +namespace Events.EF.Models; + +public partial class Event +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; + + public DateOnly EventDate { get; set; } + + public virtual ICollection Registrations { get; set; } = new List(); +} \ No newline at end of file diff --git a/Events-MVC/Events.EF/Models/Person.cs b/Events-MVC/Events.EF/Models/Person.cs new file mode 100644 index 0000000..c6df0ab --- /dev/null +++ b/Events-MVC/Events.EF/Models/Person.cs @@ -0,0 +1,41 @@ +// This file has been auto generated by EF Core Power Tools. +#nullable enable +using System; +using System.Collections.Generic; + +namespace Events.EF.Models; + +public partial class Person +{ + public int Id { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public string FirstNameTranscription { get; set; } = null!; + + public string LastNameTranscription { get; set; } = null!; + + public string? AddressLine { get; set; } + + public string? PostalCode { get; set; } + + public string? City { get; set; } + + public string? AddressCountry { get; set; } + + public string? Email { get; set; } + + public string? ContactPhone { get; set; } + + public DateOnly BirthDate { get; set; } + + public string DocumentNumber { get; set; } = null!; + + public string CountryCode { get; set; } = null!; + + public virtual Country CountryCodeNavigation { get; set; } = null!; + + public virtual ICollection Registrations { get; set; } = new List(); +} diff --git a/Events-MVC/Events.EF/Models/Registration.cs b/Events-MVC/Events.EF/Models/Registration.cs new file mode 100644 index 0000000..460d2b1 --- /dev/null +++ b/Events-MVC/Events.EF/Models/Registration.cs @@ -0,0 +1,25 @@ +// This file has been auto generated by EF Core Power Tools. +#nullable enable +using System; +using System.Collections.Generic; + +namespace Events.EF.Models; + +public partial class Registration +{ + public int Id { get; set; } + + public int PersonId { get; set; } + + public int SportId { get; set; } + + public int EventId { get; set; } + + public DateTime RegisteredAt { get; set; } + + public virtual Event Event { get; set; } = null!; + + public virtual Person Person { get; set; } = null!; + + public virtual Sport Sport { get; set; } = null!; +} \ No newline at end of file diff --git a/Events-MVC/Events.EF/Models/Sport.cs b/Events-MVC/Events.EF/Models/Sport.cs new file mode 100644 index 0000000..8b126d6 --- /dev/null +++ b/Events-MVC/Events.EF/Models/Sport.cs @@ -0,0 +1,15 @@ +// This file has been auto generated by EF Core Power Tools. +#nullable enable +using System; +using System.Collections.Generic; + +namespace Events.EF.Models; + +public partial class Sport +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; + + public virtual ICollection Registrations { get; set; } = new List(); +} \ No newline at end of file diff --git a/Events-MVC/Events.EF/efpt.mssql.config.json b/Events-MVC/Events.EF/efpt.mssql.config.json new file mode 100644 index 0000000..6df4a7a --- /dev/null +++ b/Events-MVC/Events.EF/efpt.mssql.config.json @@ -0,0 +1,70 @@ +{ + "CodeGenerationMode": 6, + "ContextClassName": "EventsContext", + "ContextNamespace": null, + "FilterSchemas": false, + "IncludeConnectionString": false, + "IrregularWords": null, + "MinimumProductVersion": "2.6.1465", + "ModelNamespace": null, + "OutputContextPath": "Data\/MSSQL", + "OutputPath": "Models", + "PluralRules": null, + "PreserveCasingWithRegex": true, + "ProjectRootNamespace": "Events.EF", + "Schemas": null, + "SelectedHandlebarsLanguage": 2, + "SelectedToBeGenerated": 0, + "SingularRules": null, + "T4TemplatePath": null, + "Tables": [ + { + "Name": "[dbo].[Country]", + "ObjectType": 0 + }, + { + "Name": "[dbo].[Event]", + "ObjectType": 0 + }, + { + "Name": "[dbo].[Person]", + "ObjectType": 0 + }, + { + "Name": "[dbo].[Registration]", + "ObjectType": 0 + }, + { + "Name": "[dbo].[Sport]", + "ObjectType": 0 + } + ], + "UiHint": null, + "UncountableWords": null, + "UseAsyncStoredProcedureCalls": true, + "UseBoolPropertiesWithoutDefaultSql": false, + "UseDatabaseNames": false, + "UseDatabaseNamesForRoutines": true, + "UseDateOnlyTimeOnly": true, + "UseDbContextSplitting": false, + "UseDecimalDataAnnotationForSprocResult": true, + "UseFluentApiOnly": true, + "UseHandleBars": false, + "UseHierarchyId": false, + "UseInflector": true, + "UseInternalAccessModifiersForSprocsAndFunctions": false, + "UseLegacyPluralizer": false, + "UseManyToManyEntity": false, + "UseNoDefaultConstructor": true, + "UseNoNavigations": false, + "UseNoObjectFilter": false, + "UseNodaTime": false, + "UseNullableReferences": true, + "UsePrefixNavigationNaming": false, + "UseSchemaFolders": false, + "UseSchemaNamespaces": false, + "UseSpatial": false, + "UseT4": false, + "UseT4Split": false, + "UseTypedTvpParameters": true +} \ No newline at end of file diff --git a/Events-MVC/Events.EF/efpt.postgres.config.json b/Events-MVC/Events.EF/efpt.postgres.config.json new file mode 100644 index 0000000..38b602d --- /dev/null +++ b/Events-MVC/Events.EF/efpt.postgres.config.json @@ -0,0 +1,70 @@ +{ + "CodeGenerationMode": 6, + "ContextClassName": "EventsContext", + "ContextNamespace": null, + "FilterSchemas": false, + "IncludeConnectionString": false, + "IrregularWords": null, + "MinimumProductVersion": "2.6.1465", + "ModelNamespace": null, + "OutputContextPath": "Data\/Postgres", + "OutputPath": "Models", + "PluralRules": null, + "PreserveCasingWithRegex": true, + "ProjectRootNamespace": "Events.EF", + "Schemas": null, + "SelectedHandlebarsLanguage": 2, + "SelectedToBeGenerated": 0, + "SingularRules": null, + "T4TemplatePath": null, + "Tables": [ + { + "Name": "public.country", + "ObjectType": 0 + }, + { + "Name": "public.event", + "ObjectType": 0 + }, + { + "Name": "public.person", + "ObjectType": 0 + }, + { + "Name": "public.registration", + "ObjectType": 0 + }, + { + "Name": "public.sport", + "ObjectType": 0 + } + ], + "UiHint": null, + "UncountableWords": null, + "UseAsyncStoredProcedureCalls": true, + "UseBoolPropertiesWithoutDefaultSql": false, + "UseDatabaseNames": false, + "UseDatabaseNamesForRoutines": true, + "UseDateOnlyTimeOnly": true, + "UseDbContextSplitting": false, + "UseDecimalDataAnnotationForSprocResult": true, + "UseFluentApiOnly": true, + "UseHandleBars": false, + "UseHierarchyId": false, + "UseInflector": true, + "UseInternalAccessModifiersForSprocsAndFunctions": false, + "UseLegacyPluralizer": false, + "UseManyToManyEntity": false, + "UseNoDefaultConstructor": true, + "UseNoNavigations": false, + "UseNoObjectFilter": false, + "UseNodaTime": false, + "UseNullableReferences": true, + "UsePrefixNavigationNaming": false, + "UseSchemaFolders": false, + "UseSchemaNamespaces": false, + "UseSpatial": false, + "UseT4": false, + "UseT4Split": false, + "UseTypedTvpParameters": true +} \ No newline at end of file diff --git a/Events-MVC/Events.MVC/Constants.cs b/Events-MVC/Events.MVC/Constants.cs new file mode 100644 index 0000000..1164f48 --- /dev/null +++ b/Events-MVC/Events.MVC/Constants.cs @@ -0,0 +1,67 @@ +namespace Events.MVC; + +public static class Constants +{ + public static class TempDataKeys + { + public const string ToastMessage = "ToastMessage"; + public const string ToastVariant = "ToastVariant"; + public const string ToastTitle = "ToastTitle"; + } + + public static class ToastVariants + { + public const string Success = "success"; + public const string Error = "error"; + } + + public static class ToastTitles + { + public const string Success = "Success"; + public const string Error = "Error"; + public const string Notification = "Notification"; + } + + public static class ViewDataKeys + { + public const string Title = "Title"; + public const string HeaderActionLabel = "HeaderActionLabel"; + public const string HeaderActionTarget = "HeaderActionTarget"; + public const string CreatePersonModel = "CreatePersonModel"; + public const string Prefix = "Prefix"; + public const string CanRemoveRows = "CanRemoveRows"; + } + + public static class HtmxHeaders + { + public const string Request = "HX-Request"; + public const string Retarget = "HX-Retarget"; + public const string Reswap = "HX-Reswap"; + public const string Trigger = "HX-Trigger"; + } + + public static class HtmxEvents + { + public const string ShowToast = "show-toast"; + public const string CountryCreated = "country-created"; + public const string EventCreated = "event-created"; + public const string PersonCreated = "person-created"; + public const string RegistrationCreated = "registration-created"; + public const string SportCreated = "sport-created"; + } + + public static class HtmxSwap + { + public const string OuterHtml = "outerHTML"; + } + + public static class Messages + { + public const string EmailOrContactPhoneRequired = "Email or contact phone is required."; + public const string CountriesRequiredForPeople = "At least one country must be created before adding people."; + public const string EventsRequiredForRegistrations = "At least one event must be created before adding registrations."; + public const string SportsRequiredForRegistrations = "At least one sport must be created before adding registrations."; + public const string PeopleRequiredForRegistrations = "At least one person must be created before adding registrations."; + public const string RegistrationDependenciesRequired = "At least one event, one person, and one sport are required before adding registrations."; + } +} diff --git a/Events-MVC/Events.MVC/Controllers/CountriesController.cs b/Events-MVC/Events.MVC/Controllers/CountriesController.cs new file mode 100644 index 0000000..e3c792b --- /dev/null +++ b/Events-MVC/Events.MVC/Controllers/CountriesController.cs @@ -0,0 +1,347 @@ +using System.Text.Json; +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.MVC.Models; +using Events.MVC.Models.Countries; +using Events.MVC.Util.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Sieve.Models; +using Sieve.Services; + +namespace Events.MVC.Controllers; + +public class CountriesController : Controller +{ + private readonly EventsContext ctx; + private readonly ISieveProcessor sieveProcessor; + private readonly PagingSettings pagingSettings; + + public CountriesController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptions pagingSettings) + { + this.ctx = ctx; + this.sieveProcessor = sieveProcessor; + this.pagingSettings = pagingSettings.Value; + } + + public async Task Index(SieveModel sieveModel) + { + var viewModel = await BuildCountriesListAsync(sieveModel); + if (Request.Headers.ContainsKey(Constants.HtmxHeaders.Request)) + { + return PartialView("_CountriesList", viewModel); + } + + return View(viewModel); + } + + [HttpGet] + public async Task Row(string id) + { + var country = await ctx.Countries + .AsNoTracking() + .Select(c => new CountryViewModel + { + Code = c.Code, + Alpha3 = c.Alpha3, + Name = c.Name + }) + .FirstOrDefaultAsync(c => c.Code == id); + + if (country is null) + { + return NotFound(); + } + + return PartialView("_CountryRow", country); + } + + [HttpGet] + public async Task EditRow(string id) + { + var country = await ctx.Countries + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Code == id); + + if (country is null) + { + return NotFound(); + } + + return PartialView("_CountryEditRow", MapCountryToViewModel(country, includeTranslations: true)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(CountryViewModel model, SieveModel sieveModel) + { + NormalizeTranslations(model); + ValidateTranslations(model); + + if (!ModelState.IsValid) + { + Response.Headers[Constants.HtmxHeaders.Retarget] = "#create-country-form"; + Response.Headers[Constants.HtmxHeaders.Reswap] = Constants.HtmxSwap.OuterHtml; + return PartialView("_CreateCountryForm", model); + } + + var country = new Country + { + Code = model.Code.Trim().ToUpperInvariant(), + Alpha3 = model.Alpha3.Trim().ToUpperInvariant(), + Name = model.Name.Trim(), + Translations = SerializeTranslations(model.Translations) + }; + + ctx.Countries.Add(country); + await ctx.SaveChangesAsync(); + + Response.Headers[Constants.HtmxHeaders.Trigger] = JsonSerializer.Serialize(new Dictionary + { + [Constants.HtmxEvents.CountryCreated] = true, + [Constants.HtmxEvents.ShowToast] = new + { + variant = Constants.ToastVariants.Success, + title = Constants.ToastTitles.Success, + message = $"Country '{country.Name}' was added successfully." + } + }); + + var viewModel = await BuildCountriesListAsync(sieveModel); + return PartialView("_CountriesList", viewModel); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(string id, CountryViewModel model) + { + if (!string.Equals(id, model.Code, StringComparison.OrdinalIgnoreCase)) + { + return BadRequest(); + } + + NormalizeTranslations(model); + ValidateTranslations(model); + + if (!ModelState.IsValid) + { + return PartialView("_CountryEditRow", model); + } + + var country = await ctx.Countries.FirstOrDefaultAsync(c => c.Code == id); + if (country is null) + { + return NotFound(); + } + + country.Alpha3 = model.Alpha3.Trim().ToUpperInvariant(); + country.Name = model.Name.Trim(); + country.Translations = SerializeTranslations(model.Translations); + await ctx.SaveChangesAsync(); + + return PartialView("_CountryRow", new CountryViewModel + { + Code = country.Code, + Alpha3 = country.Alpha3, + Name = country.Name + }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(string id, SieveModel sieveModel) + { + var country = await ctx.Countries + .Include(c => c.People) + .FirstOrDefaultAsync(c => c.Code == id); + + if (country is null) + { + return NotFound(); + } + + if (country.People.Count > 0) + { + Response.StatusCode = StatusCodes.Status409Conflict; + return Content("The country cannot be deleted because related people exist."); + } + + ctx.Countries.Remove(country); + var deletedName = country.Name; + await ctx.SaveChangesAsync(); + + Response.Headers[Constants.HtmxHeaders.Trigger] = JsonSerializer.Serialize(new Dictionary + { + [Constants.HtmxEvents.ShowToast] = new + { + variant = Constants.ToastVariants.Success, + title = Constants.ToastTitles.Success, + message = $"Country '{deletedName}' was deleted successfully." + } + }); + + var viewModel = await BuildCountriesListAsync(sieveModel); + return PartialView("_CountriesList", viewModel); + } + + private async Task> BuildCountriesListAsync(SieveModel sieveModel) + { + sieveModel.SetDefaultPagingAndSorting(pagingSettings.PageSize, "Name"); + var normalizedFilters = sieveModel.Filters?.Trim() ?? string.Empty; + var nameFilter = SieveModelExtensions.ExtractFilterValue(normalizedFilters, "Name"); + + var baseQuery = ctx.Countries + .AsNoTracking() + .Select(c => new CountryViewModel + { + Code = c.Code, + Alpha3 = c.Alpha3, + Name = c.Name + }); + + var totalCount = await baseQuery.CountAsync(); + var filteredCount = string.IsNullOrWhiteSpace(normalizedFilters) + ? totalCount + : await sieveProcessor + .Apply( + sieveModel, + baseQuery, + applyFiltering: true, + applySorting: false, + applyPagination: false) + .CountAsync(); + + var pagingInfo = new PagingInfo + { + FilteredItemsCount = filteredCount, + TotalItemsCount = totalCount, + ItemsPerPage = sieveModel.PageSize!.Value, + CurrentPage = sieveModel.Page!.Value, + Sorts = sieveModel.Sorts ?? "Name", + Filters = normalizedFilters, + NameFilter = nameFilter + }; + + if (pagingInfo.CurrentPage > pagingInfo.TotalPages) + { + pagingInfo.CurrentPage = pagingInfo.TotalPages; + sieveModel.Page = pagingInfo.CurrentPage; + } + + var countries = await sieveProcessor + .Apply(sieveModel, baseQuery) + .ToListAsync(); + + return new PagedList(countries, pagingInfo); + } + + private void ValidateTranslations(CountryViewModel model) + { + var languages = new HashSet(StringComparer.OrdinalIgnoreCase); + var hasErrors = false; + + for (var i = 0; i < model.Translations.Count; i++) + { + var translation = model.Translations[i] ?? new CountryTranslationViewModel(); + var language = translation.LanguageCode?.Trim() ?? string.Empty; + var name = translation.Name?.Trim() ?? string.Empty; + + if (string.IsNullOrEmpty(language) && string.IsNullOrEmpty(name)) + { + continue; + } + + if (string.IsNullOrEmpty(language)) + { + ModelState.AddModelError($"Translations[{i}].LanguageCode", "Enter a language code."); + hasErrors = true; + } + + if (string.IsNullOrEmpty(name)) + { + ModelState.AddModelError($"Translations[{i}].Name", "Enter a translation."); + hasErrors = true; + } + + if (!string.IsNullOrEmpty(language) && !languages.Add(language)) + { + ModelState.AddModelError($"Translations[{i}].LanguageCode", "The language code has already been entered."); + hasErrors = true; + } + } + + if (hasErrors) + { + ModelState.AddModelError(string.Empty, "Check the translations. Every row must have both a language code and a translation, and language codes must be unique."); + } + } + + private static void NormalizeTranslations(CountryViewModel model) + { + model.Translations ??= []; + + model.Translations = model.Translations + .Where(t => t is not null) + .Select(t => new CountryTranslationViewModel + { + LanguageCode = t!.LanguageCode?.Trim().ToLowerInvariant() ?? string.Empty, + Name = t.Name?.Trim() ?? string.Empty + }) + .ToList(); + } + + private static CountryViewModel MapCountryToViewModel(Country country, bool includeTranslations) + { + return new CountryViewModel + { + Code = country.Code, + Alpha3 = country.Alpha3, + Name = country.Name, + Translations = includeTranslations ? ParseTranslations(country.Translations) : [] + }; + } + + private static List ParseTranslations(string? translationsJson) + { + if (string.IsNullOrWhiteSpace(translationsJson)) + { + return []; + } + + try + { + var translations = JsonSerializer.Deserialize>(translationsJson); + if (translations is null) + { + return []; + } + + return translations + .OrderBy(t => t.Key, StringComparer.OrdinalIgnoreCase) + .Select(t => new CountryTranslationViewModel + { + LanguageCode = t.Key, + Name = t.Value + }) + .ToList(); + } + catch (JsonException) + { + return []; + } + } + + private static string? SerializeTranslations(IEnumerable translations) + { + var dictionary = translations + .Where(t => t is not null && !string.IsNullOrWhiteSpace(t.LanguageCode) && !string.IsNullOrWhiteSpace(t.Name)) + .ToDictionary(t => t.LanguageCode.Trim().ToLowerInvariant(), t => t.Name.Trim(), StringComparer.OrdinalIgnoreCase); + + return dictionary.Count == 0 ? null : JsonSerializer.Serialize(dictionary); + } +} diff --git a/Events-MVC/Events.MVC/Controllers/EventsController.cs b/Events-MVC/Events.MVC/Controllers/EventsController.cs new file mode 100644 index 0000000..b2470dc --- /dev/null +++ b/Events-MVC/Events.MVC/Controllers/EventsController.cs @@ -0,0 +1,241 @@ +using System.Text.Json; +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.MVC.Models; +using Events.MVC.Models.Events; +using Events.MVC.Util.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Sieve.Models; +using Sieve.Services; + +namespace Events.MVC.Controllers; + +public class EventsController : Controller +{ + private readonly EventsContext ctx; + private readonly ISieveProcessor sieveProcessor; + private readonly PagingSettings pagingSettings; + + public EventsController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptions pagingSettings) + { + this.ctx = ctx; + this.sieveProcessor = sieveProcessor; + this.pagingSettings = pagingSettings.Value; + } + + public async Task Index(SieveModel sieveModel) + { + var viewModel = await BuildEventsListAsync(sieveModel); + if (Request.Headers.ContainsKey(Constants.HtmxHeaders.Request)) + { + return PartialView("_EventsList", viewModel); + } + + return View(viewModel); + } + + [HttpGet] + public async Task Row(int id) + { + var eventModel = await ctx.Events + .AsNoTracking() + .Select(e => new EventViewModel + { + Id = e.Id, + Name = e.Name, + EventDate = e.EventDate, + ParticipantsCount = e.Registrations.Count + }) + .FirstOrDefaultAsync(e => e.Id == id); + + if (eventModel is null) + { + return NotFound(); + } + + return PartialView("_EventRow", eventModel); + } + + [HttpGet] + public async Task EditRow(int id) + { + var eventModel = await ctx.Events + .AsNoTracking() + .Select(e => new EventViewModel + { + Id = e.Id, + Name = e.Name, + EventDate = e.EventDate + }) + .FirstOrDefaultAsync(e => e.Id == id); + + if (eventModel is null) + { + return NotFound(); + } + + return PartialView("_EventEditRow", eventModel); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(EventViewModel model, SieveModel sieveModel) + { + if (!ModelState.IsValid) + { + Response.Headers[Constants.HtmxHeaders.Retarget] = "#create-event-form"; + Response.Headers[Constants.HtmxHeaders.Reswap] = Constants.HtmxSwap.OuterHtml; + return PartialView("_CreateEventForm", model); + } + + var eventEntity = new Event + { + Name = model.Name, + EventDate = model.EventDate + }; + ctx.Events.Add(eventEntity); + await ctx.SaveChangesAsync(); + + Response.Headers[Constants.HtmxHeaders.Trigger] = JsonSerializer.Serialize(new Dictionary + { + [Constants.HtmxEvents.EventCreated] = true, + [Constants.HtmxEvents.ShowToast] = new + { + variant = Constants.ToastVariants.Success, + title = Constants.ToastTitles.Success, + message = $"Event '{model.Name}' was added successfully." + } + }); + + var viewModel = await BuildEventsListAsync(sieveModel); + return PartialView("_EventsList", viewModel); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(int id, EventViewModel model) + { + if (id != model.Id) + { + return BadRequest(); + } + + if (!ModelState.IsValid) + { + return PartialView("_EventEditRow", model); + } + + var existingEvent = await ctx.Events.FirstOrDefaultAsync(e => e.Id == id); + if (existingEvent is null) + { + return NotFound(); + } + + existingEvent.Name = model.Name; + existingEvent.EventDate = model.EventDate; + await ctx.SaveChangesAsync(); + + return PartialView("_EventRow", new EventViewModel + { + Id = existingEvent.Id, + Name = existingEvent.Name, + EventDate = existingEvent.EventDate, + ParticipantsCount = await ctx.Registrations.CountAsync(r => r.EventId == existingEvent.Id) + }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(int id, SieveModel sieveModel) + { + var eventEntity = await ctx.Events + .Include(e => e.Registrations) + .FirstOrDefaultAsync(e => e.Id == id); + + if (eventEntity is null) + { + return NotFound(); + } + + if (eventEntity.Registrations.Count > 0) + { + Response.StatusCode = StatusCodes.Status409Conflict; + return Content("The event cannot be deleted because registrations exist."); + } + + ctx.Events.Remove(eventEntity); + var deletedName = eventEntity.Name; + await ctx.SaveChangesAsync(); + + Response.Headers[Constants.HtmxHeaders.Trigger] = JsonSerializer.Serialize(new Dictionary + { + [Constants.HtmxEvents.ShowToast] = new + { + variant = Constants.ToastVariants.Success, + title = Constants.ToastTitles.Success, + message = $"Event '{deletedName}' was deleted successfully." + } + }); + + var viewModel = await BuildEventsListAsync(sieveModel); + return PartialView("_EventsList", viewModel); + } + + private async Task> BuildEventsListAsync(SieveModel sieveModel) + { + sieveModel.SetDefaultPagingAndSorting(pagingSettings.PageSize, "EventDate"); + var normalizedFilters = sieveModel.Filters?.Trim() ?? string.Empty; + var nameFilter = SieveModelExtensions.ExtractFilterValue(normalizedFilters, "Name"); + + var baseQuery = ctx.Events + .AsNoTracking() + .Select(e => new EventViewModel + { + Id = e.Id, + Name = e.Name, + EventDate = e.EventDate, + ParticipantsCount = e.Registrations.Count + }); + + var totalCount = await baseQuery.CountAsync(); + var filteredCount = string.IsNullOrWhiteSpace(normalizedFilters) + ? totalCount + : await sieveProcessor + .Apply( + sieveModel, + baseQuery, + applyFiltering: true, + applySorting: false, + applyPagination: false) + .CountAsync(); + + var pagingInfo = new PagingInfo + { + FilteredItemsCount = filteredCount, + TotalItemsCount = totalCount, + ItemsPerPage = sieveModel.PageSize!.Value, + CurrentPage = sieveModel.Page!.Value, + Sorts = sieveModel.Sorts ?? "EventDate", + Filters = normalizedFilters, + NameFilter = nameFilter + }; + + if (pagingInfo.CurrentPage > pagingInfo.TotalPages) + { + pagingInfo.CurrentPage = pagingInfo.TotalPages; + sieveModel.Page = pagingInfo.CurrentPage; + } + + var events = await sieveProcessor + .Apply(sieveModel, baseQuery) + .ToListAsync(); + + return new PagedList(events, pagingInfo); + } +} diff --git a/Events-MVC/Events.MVC/Controllers/HomeController.cs b/Events-MVC/Events.MVC/Controllers/HomeController.cs new file mode 100644 index 0000000..59573bc --- /dev/null +++ b/Events-MVC/Events.MVC/Controllers/HomeController.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; +using Events.MVC.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Events.MVC.Controllers; + +public class HomeController : Controller +{ + public IActionResult Index() + { + return View(); + } + + public IActionResult Error() + { + return View(new ErrorViewModel + { + RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier + }); + } +} diff --git a/Events-MVC/Events.MVC/Controllers/PeopleController.cs b/Events-MVC/Events.MVC/Controllers/PeopleController.cs new file mode 100644 index 0000000..8dce544 --- /dev/null +++ b/Events-MVC/Events.MVC/Controllers/PeopleController.cs @@ -0,0 +1,337 @@ +using System.Text.Json; +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.MVC.Models; +using Events.MVC.Models.People; +using Events.MVC.Util.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Sieve.Models; +using Sieve.Services; + +namespace Events.MVC.Controllers; + +public class PeopleController : Controller +{ + private readonly EventsContext ctx; + private readonly ISieveProcessor sieveProcessor; + private readonly PagingSettings pagingSettings; + + public PeopleController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptions pagingSettings) + { + this.ctx = ctx; + this.sieveProcessor = sieveProcessor; + this.pagingSettings = pagingSettings.Value; + } + + public async Task Index(SieveModel sieveModel) + { + if (!await ctx.Countries.AsNoTracking().AnyAsync()) + { + TempData[Constants.TempDataKeys.ToastVariant] = Constants.ToastVariants.Error; + TempData[Constants.TempDataKeys.ToastTitle] = Constants.ToastTitles.Error; + TempData[Constants.TempDataKeys.ToastMessage] = Constants.Messages.CountriesRequiredForPeople; + return RedirectToAction("Index", "Countries"); + } + + var viewModel = await BuildPeopleListAsync(sieveModel); + if (Request.Headers.ContainsKey(Constants.HtmxHeaders.Request)) + { + return PartialView("_PeopleList", viewModel); + } + + ViewData[Constants.ViewDataKeys.CreatePersonModel] = new PersonViewModel + { + CountryOptions = await GetCountryOptionsAsync() + }; + return View(viewModel); + } + + [HttpGet] + public async Task Row(int id) + { + var person = await ctx.People + .AsNoTracking() + .Select(p => new PersonViewModel + { + Id = p.Id, + FirstName = p.FirstName, + LastName = p.LastName, + FirstNameTranscription = p.FirstNameTranscription, + LastNameTranscription = p.LastNameTranscription, + FullName = p.FirstName + " " + p.LastName, + FullNameTranscription = p.FirstNameTranscription + " " + p.LastNameTranscription, + BirthDate = p.BirthDate, + CountryName = p.CountryCodeNavigation.Name, + RegistrationsCount = p.Registrations.Count + }) + .FirstOrDefaultAsync(p => p.Id == id); + + if (person is null) + { + return NotFound(); + } + + return PartialView("_PersonRow", person); + } + + [HttpGet] + public async Task EditRow(int id) + { + var person = await ctx.People + .AsNoTracking() + .Select(p => new PersonViewModel + { + Id = p.Id, + FirstName = p.FirstName, + LastName = p.LastName, + FirstNameTranscription = p.FirstNameTranscription, + LastNameTranscription = p.LastNameTranscription, + AddressLine = p.AddressLine, + PostalCode = p.PostalCode, + City = p.City, + AddressCountry = p.AddressCountry, + Email = p.Email, + ContactPhone = p.ContactPhone, + BirthDate = p.BirthDate, + DocumentNumber = p.DocumentNumber, + CountryCode = p.CountryCode + }) + .FirstOrDefaultAsync(p => p.Id == id); + + if (person is null) + { + return NotFound(); + } + + person.CountryOptions = await GetCountryOptionsAsync(person.CountryCode); + return PartialView("_PersonEditRow", person); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(PersonViewModel model, SieveModel sieveModel) + { + if (!ModelState.IsValid) + { + model.CountryOptions = await GetCountryOptionsAsync(model.CountryCode); + Response.Headers[Constants.HtmxHeaders.Retarget] = "#create-person-form"; + Response.Headers[Constants.HtmxHeaders.Reswap] = Constants.HtmxSwap.OuterHtml; + return PartialView("_CreatePersonForm", model); + } + + var person = new Person + { + FirstName = model.FirstName.TrimToNull(), + LastName = model.LastName.TrimToNull(), + FirstNameTranscription = model.FirstNameTranscription.Trim(), + LastNameTranscription = model.LastNameTranscription.Trim(), + AddressLine = model.AddressLine.TrimToNull(), + PostalCode = model.PostalCode.TrimToNull(), + City = model.City.TrimToNull(), + AddressCountry = model.AddressCountry.TrimToNull(), + Email = model.Email.TrimToNull(), + ContactPhone = model.ContactPhone.TrimToNull(), + BirthDate = model.BirthDate!.Value, + DocumentNumber = model.DocumentNumber.Trim(), + CountryCode = model.CountryCode.Trim().ToUpperInvariant() + }; + + ctx.People.Add(person); + await ctx.SaveChangesAsync(); + + Response.Headers[Constants.HtmxHeaders.Trigger] = JsonSerializer.Serialize(new Dictionary + { + [Constants.HtmxEvents.PersonCreated] = true, + [Constants.HtmxEvents.ShowToast] = new + { + variant = Constants.ToastVariants.Success, + title = Constants.ToastTitles.Success, + message = $"Person '{person.FirstName} {person.LastName}' was added successfully." + } + }); + + var viewModel = await BuildPeopleListAsync(sieveModel); + return PartialView("_PeopleList", viewModel); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(int id, PersonViewModel model) + { + if (id != model.Id) + { + return BadRequest(); + } + + if (!ModelState.IsValid) + { + model.CountryOptions = await GetCountryOptionsAsync(model.CountryCode); + return PartialView("_PersonEditRow", model); + } + + var person = await ctx.People.FirstOrDefaultAsync(p => p.Id == id); + if (person is null) + { + return NotFound(); + } + + person.FirstName = model.FirstName.TrimToNull(); + person.LastName = model.LastName.TrimToNull(); + person.FirstNameTranscription = model.FirstNameTranscription.Trim(); + person.LastNameTranscription = model.LastNameTranscription.Trim(); + person.AddressLine = model.AddressLine.TrimToNull(); + person.PostalCode = model.PostalCode.TrimToNull(); + person.City = model.City.TrimToNull(); + person.AddressCountry = model.AddressCountry.TrimToNull(); + person.Email = model.Email.TrimToNull(); + person.ContactPhone = model.ContactPhone.TrimToNull(); + person.BirthDate = model.BirthDate!.Value; + person.DocumentNumber = model.DocumentNumber.Trim(); + person.CountryCode = model.CountryCode.Trim().ToUpperInvariant(); + await ctx.SaveChangesAsync(); + + var rowModel = await ctx.People + .AsNoTracking() + .Where(p => p.Id == id) + .Select(p => new PersonViewModel + { + Id = p.Id, + FirstName = p.FirstName, + LastName = p.LastName, + FirstNameTranscription = p.FirstNameTranscription, + LastNameTranscription = p.LastNameTranscription, + FullName = p.FirstName + " " + p.LastName, + FullNameTranscription = p.FirstNameTranscription + " " + p.LastNameTranscription, + BirthDate = p.BirthDate, + CountryName = p.CountryCodeNavigation.Name, + RegistrationsCount = p.Registrations.Count + }) + .FirstAsync(); + + return PartialView("_PersonRow", rowModel); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(int id, SieveModel sieveModel) + { + var person = await ctx.People + .Include(p => p.Registrations) + .FirstOrDefaultAsync(p => p.Id == id); + + if (person is null) + { + return NotFound(); + } + + if (person.Registrations.Count > 0) + { + Response.StatusCode = StatusCodes.Status409Conflict; + return Content("The person cannot be deleted because registrations exist."); + } + + ctx.People.Remove(person); + var deletedName = $"{person.FirstName} {person.LastName}"; + await ctx.SaveChangesAsync(); + + Response.Headers[Constants.HtmxHeaders.Trigger] = JsonSerializer.Serialize(new Dictionary + { + [Constants.HtmxEvents.ShowToast] = new + { + variant = Constants.ToastVariants.Success, + title = Constants.ToastTitles.Success, + message = $"Person '{deletedName}' was deleted successfully." + } + }); + + var viewModel = await BuildPeopleListAsync(sieveModel); + return PartialView("_PeopleList", viewModel); + } + + private async Task BuildPeopleListAsync(SieveModel sieveModel) + { + sieveModel.SetDefaultPagingAndSorting(pagingSettings.PageSize, "LastNameTranscription"); + var normalizedFilters = sieveModel.Filters?.Trim() ?? string.Empty; + var nameFilter = SieveModelExtensions.ExtractFilterValue(normalizedFilters, "FullNameTranscription"); + var countryFilter = SieveModelExtensions.ExtractFilterValue(normalizedFilters, "CountryCode", "=="); + + var baseQuery = ctx.People + .AsNoTracking() + .Select(p => new PersonViewModel + { + Id = p.Id, + FirstName = p.FirstName, + LastName = p.LastName, + FirstNameTranscription = p.FirstNameTranscription, + LastNameTranscription = p.LastNameTranscription, + FullName = p.FirstName + " " + p.LastName, + FullNameTranscription = p.FirstNameTranscription + " " + p.LastNameTranscription, + CountryCode = p.CountryCode, + BirthDate = p.BirthDate, + CountryName = p.CountryCodeNavigation.Name, + RegistrationsCount = p.Registrations.Count + }); + + var totalCount = await baseQuery.CountAsync(); + var filteredCount = string.IsNullOrWhiteSpace(normalizedFilters) + ? totalCount + : await sieveProcessor + .Apply( + sieveModel, + baseQuery, + applyFiltering: true, + applySorting: false, + applyPagination: false) + .CountAsync(); + + var pagingInfo = new PagingInfo + { + FilteredItemsCount = filteredCount, + TotalItemsCount = totalCount, + ItemsPerPage = sieveModel.PageSize!.Value, + CurrentPage = sieveModel.Page!.Value, + Sorts = sieveModel.Sorts ?? "LastNameTranscription", + Filters = normalizedFilters, + NameFilter = nameFilter + }; + + if (pagingInfo.CurrentPage > pagingInfo.TotalPages) + { + pagingInfo.CurrentPage = pagingInfo.TotalPages; + sieveModel.Page = pagingInfo.CurrentPage; + } + + var people = await sieveProcessor + .Apply(sieveModel, baseQuery) + .ToListAsync(); + + return new PeoplePageViewModel + { + People = new PagedList(people, pagingInfo), + CountryOptions = await GetCountryOptionsAsync(countryFilter), + CountryFilter = countryFilter + }; + } + + private async Task> GetCountryOptionsAsync(string? selectedCode = null) + { + return await ctx.Countries + .AsNoTracking() + .OrderBy(c => c.Name) + .Select(c => new SelectListItem + { + Value = c.Code, + Text = c.Name, + Selected = c.Code == selectedCode + }) + .ToListAsync(); + } + +} diff --git a/Events-MVC/Events.MVC/Controllers/RegistrationsController.cs b/Events-MVC/Events.MVC/Controllers/RegistrationsController.cs new file mode 100644 index 0000000..955c23d --- /dev/null +++ b/Events-MVC/Events.MVC/Controllers/RegistrationsController.cs @@ -0,0 +1,430 @@ +using System.Text.Json; +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.MVC.Models; +using Events.MVC.Models.Registrations; +using Events.MVC.Util.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Sieve.Models; +using Sieve.Services; + +namespace Events.MVC.Controllers; + +public class RegistrationsController : Controller +{ + private readonly EventsContext ctx; + private readonly ISieveProcessor sieveProcessor; + private readonly PagingSettings pagingSettings; + + public RegistrationsController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptions pagingSettings) + { + this.ctx = ctx; + this.sieveProcessor = sieveProcessor; + this.pagingSettings = pagingSettings.Value; + } + + public async Task Index(int? eventId, SieveModel sieveModel) + { + var events = await GetEventOptionsAsync(eventId); + if (events.Count == 0) + { + TempData[Constants.TempDataKeys.ToastVariant] = Constants.ToastVariants.Error; + TempData[Constants.TempDataKeys.ToastTitle] = Constants.ToastTitles.Error; + TempData[Constants.TempDataKeys.ToastMessage] = Constants.Messages.EventsRequiredForRegistrations; + return RedirectToAction("Index", "Events"); + } + + if (!await ctx.Sports.AsNoTracking().AnyAsync()) + { + TempData[Constants.TempDataKeys.ToastVariant] = Constants.ToastVariants.Error; + TempData[Constants.TempDataKeys.ToastTitle] = Constants.ToastTitles.Error; + TempData[Constants.TempDataKeys.ToastMessage] = Constants.Messages.SportsRequiredForRegistrations; + return RedirectToAction("Index", "Sports"); + } + + if (!await ctx.People.AsNoTracking().AnyAsync()) + { + TempData[Constants.TempDataKeys.ToastVariant] = Constants.ToastVariants.Error; + TempData[Constants.TempDataKeys.ToastTitle] = Constants.ToastTitles.Error; + TempData[Constants.TempDataKeys.ToastMessage] = Constants.Messages.PeopleRequiredForRegistrations; + return RedirectToAction("Index", "People"); + } + + var selectedEventId = eventId ?? int.Parse(events[0].Value); + MarkSelectedEvent(events, selectedEventId); + var viewModel = await BuildPageViewModelAsync(selectedEventId, sieveModel, events); + + if (Request.Headers.ContainsKey(Constants.HtmxHeaders.Request)) + { + return PartialView("_RegistrationsPanel", viewModel); + } + + return View(viewModel); + } + + [HttpGet] + public async Task Row(int id, int eventId) + { + var registration = await ctx.Registrations + .AsNoTracking() + .Where(r => r.Id == id && r.EventId == eventId) + .Select(r => new RegistrationViewModel + { + Id = r.Id, + EventId = r.EventId, + PersonId = r.PersonId, + SportId = r.SportId, + PersonName = r.Person.FirstName + " " + r.Person.LastName, + PersonTranscription = r.Person.FirstNameTranscription + " " + r.Person.LastNameTranscription, + CountryCode = r.Person.CountryCode, + CountryName = r.Person.CountryCodeNavigation.Name, + SportName = r.Sport.Name, + RegisteredAt = r.RegisteredAt + }) + .FirstOrDefaultAsync(); + + if (registration is null) + { + return NotFound(); + } + + return PartialView("_RegistrationRow", registration); + } + + [HttpGet] + public async Task EditRow(int id, int eventId) + { + var registration = await ctx.Registrations + .AsNoTracking() + .Where(r => r.Id == id && r.EventId == eventId) + .Select(r => new RegistrationViewModel + { + Id = r.Id, + EventId = r.EventId, + PersonId = r.PersonId, + SportId = r.SportId, + RegisteredAt = r.RegisteredAt + }) + .FirstOrDefaultAsync(); + + if (registration is null) + { + return NotFound(); + } + + await PopulateRegistrationOptionsAsync(registration); + return PartialView("_RegistrationEditRow", registration); + } + + [HttpGet] + public async Task PersonSuggestions(string? personLookup, string? countryFilter) + { + if (string.IsNullOrWhiteSpace(personLookup)) + { + return PartialView("_PersonSuggestions", Array.Empty()); + } + + var searchTerm = personLookup.Trim().ToLowerInvariant(); + var query = ctx.People + .AsNoTracking() + .Where(p => + p.FirstNameTranscription.ToLower().Contains(searchTerm) || + p.LastNameTranscription.ToLower().Contains(searchTerm) || + (p.FirstNameTranscription + " " + p.LastNameTranscription).ToLower().Contains(searchTerm)) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(countryFilter)) + { + query = query.Where(p => p.CountryCode == countryFilter); + } + + var suggestions = await query + .OrderBy(p => p.LastName) + .ThenBy(p => p.FirstName) + .Take(10) + .Select(p => new SelectListItem + { + Value = p.Id.ToString(), + Text = p.FirstName + " " + p.LastName + "|" + p.FirstNameTranscription + " " + p.LastNameTranscription + }) + .ToListAsync(); + + return PartialView("_PersonSuggestions", suggestions); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(RegistrationViewModel model, SieveModel sieveModel) + { + if (!await CanCreateRegistrationsAsync()) + { + Response.StatusCode = StatusCodes.Status409Conflict; + return Content(Constants.Messages.RegistrationDependenciesRequired); + } + + if (!ModelState.IsValid) + { + await PopulateRegistrationOptionsAsync(model); + Response.Headers[Constants.HtmxHeaders.Retarget] = "#create-registration-form"; + Response.Headers[Constants.HtmxHeaders.Reswap] = Constants.HtmxSwap.OuterHtml; + return PartialView("_CreateRegistrationForm", model); + } + + var registration = new Registration + { + EventId = model.EventId, + PersonId = model.PersonId, + SportId = model.SportId + }; + + ctx.Registrations.Add(registration); + await ctx.SaveChangesAsync(); + + Response.Headers[Constants.HtmxHeaders.Trigger] = JsonSerializer.Serialize(new Dictionary + { + [Constants.HtmxEvents.RegistrationCreated] = true, + [Constants.HtmxEvents.ShowToast] = new + { + variant = Constants.ToastVariants.Success, + title = Constants.ToastTitles.Success, + message = "Registration was added successfully." + } + }); + + var viewModel = await BuildPageViewModelAsync(model.EventId, sieveModel); + return PartialView("_RegistrationsPanel", viewModel); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(int id, RegistrationViewModel model) + { + if (id != model.Id) + { + return BadRequest(); + } + + if (!ModelState.IsValid) + { + await PopulateRegistrationOptionsAsync(model); + return PartialView("_RegistrationEditRow", model); + } + + var registration = await ctx.Registrations.FirstOrDefaultAsync(r => r.Id == id && r.EventId == model.EventId); + if (registration is null) + { + return NotFound(); + } + + registration.PersonId = model.PersonId; + registration.SportId = model.SportId; + await ctx.SaveChangesAsync(); + + var rowModel = await ctx.Registrations + .AsNoTracking() + .Where(r => r.Id == id) + .Select(r => new RegistrationViewModel + { + Id = r.Id, + EventId = r.EventId, + PersonId = r.PersonId, + SportId = r.SportId, + PersonName = r.Person.FirstName + " " + r.Person.LastName, + PersonTranscription = r.Person.FirstNameTranscription + " " + r.Person.LastNameTranscription, + CountryCode = r.Person.CountryCode, + CountryName = r.Person.CountryCodeNavigation.Name, + SportName = r.Sport.Name, + RegisteredAt = r.RegisteredAt + }) + .FirstAsync(); + + return PartialView("_RegistrationRow", rowModel); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(int id, int eventId, SieveModel sieveModel) + { + var registration = await ctx.Registrations.FirstOrDefaultAsync(r => r.Id == id && r.EventId == eventId); + if (registration is null) + { + return NotFound(); + } + + ctx.Registrations.Remove(registration); + await ctx.SaveChangesAsync(); + + Response.Headers[Constants.HtmxHeaders.Trigger] = JsonSerializer.Serialize(new Dictionary + { + [Constants.HtmxEvents.ShowToast] = new + { + variant = Constants.ToastVariants.Success, + title = Constants.ToastTitles.Success, + message = "Registration was deleted successfully." + } + }); + + var viewModel = await BuildPageViewModelAsync(eventId, sieveModel); + return PartialView("_RegistrationsPanel", viewModel); + } + + private async Task BuildPageViewModelAsync(int selectedEventId, SieveModel sieveModel, List? eventOptions = null) + { + sieveModel.SetDefaultPagingAndSorting(pagingSettings.PageSize, "RegisteredAt"); + var normalizedFilters = sieveModel.Filters?.Trim() ?? string.Empty; + var nameFilter = SieveModelExtensions.ExtractFilterValue(normalizedFilters, "PersonTranscription"); + var countryFilter = SieveModelExtensions.ExtractFilterValue(normalizedFilters, "CountryCode", "=="); + + var baseQuery = ctx.Registrations + .AsNoTracking() + .Where(r => r.EventId == selectedEventId) + .Select(r => new RegistrationViewModel + { + Id = r.Id, + EventId = r.EventId, + PersonId = r.PersonId, + SportId = r.SportId, + PersonName = r.Person.FirstName + " " + r.Person.LastName, + PersonTranscription = r.Person.FirstNameTranscription + " " + r.Person.LastNameTranscription, + CountryCode = r.Person.CountryCode, + CountryName = r.Person.CountryCodeNavigation.Name, + SportName = r.Sport.Name, + RegisteredAt = r.RegisteredAt + }); + + var totalCount = await baseQuery.CountAsync(); + var filteredCount = string.IsNullOrWhiteSpace(normalizedFilters) + ? totalCount + : await sieveProcessor + .Apply(sieveModel, baseQuery, applyFiltering: true, applySorting: false, applyPagination: false) + .CountAsync(); + + var pagingInfo = new PagingInfo + { + FilteredItemsCount = filteredCount, + TotalItemsCount = totalCount, + ItemsPerPage = sieveModel.PageSize!.Value, + CurrentPage = sieveModel.Page!.Value, + Sorts = sieveModel.Sorts ?? "RegisteredAt", + Filters = normalizedFilters, + NameFilter = nameFilter + }; + + if (pagingInfo.CurrentPage > pagingInfo.TotalPages) + { + pagingInfo.CurrentPage = pagingInfo.TotalPages; + sieveModel.Page = pagingInfo.CurrentPage; + } + + var registrationsData = await sieveProcessor + .Apply(sieveModel, baseQuery) + .ToListAsync(); + var registrations = new PagedList(registrationsData, pagingInfo); + + eventOptions ??= await GetEventOptionsAsync(selectedEventId); + MarkSelectedEvent(eventOptions, selectedEventId); + var selectedEventName = eventOptions.FirstOrDefault(e => e.Selected)?.Text ?? string.Empty; + var canCreate = await CanCreateRegistrationsAsync(); + var countryOptions = await GetCountryOptionsAsync(countryFilter); + + var createModel = new RegistrationViewModel + { + EventId = selectedEventId + }; + await PopulateRegistrationOptionsAsync(createModel); + + return new RegistrationsPageViewModel + { + SelectedEventId = selectedEventId, + SelectedEventName = selectedEventName, + EventOptions = eventOptions, + CountryOptions = countryOptions, + CountryFilter = countryFilter, + Registrations = registrations, + CreateModel = createModel, + CanCreate = canCreate, + CreateDisabledMessage = canCreate ? null : Constants.Messages.RegistrationDependenciesRequired + }; + } + + private async Task PopulateRegistrationOptionsAsync(RegistrationViewModel model) + { + if (model.PersonId > 0) + { + model.PersonLookup = await ctx.People + .AsNoTracking() + .Where(p => p.Id == model.PersonId) + .Select(p => p.FirstName + " " + p.LastName + " (" + p.FirstNameTranscription + " " + p.LastNameTranscription + ")") + .FirstOrDefaultAsync() ?? string.Empty; + } + else + { + model.PersonLookup = string.Empty; + } + + model.SportOptions = await ctx.Sports + .AsNoTracking() + .OrderBy(s => s.Name) + .Select(s => new SelectListItem + { + Value = s.Id.ToString(), + Text = s.Name, + Selected = s.Id == model.SportId + }) + .ToListAsync(); + } + + private async Task> GetEventOptionsAsync(int? selectedEventId) + { + var events = await ctx.Events + .AsNoTracking() + .OrderBy(e => e.EventDate) + .ThenBy(e => e.Name) + .ToListAsync(); + + return events + .Select(e => new SelectListItem + { + Value = e.Id.ToString(), + Text = $"{e.Name} ({e.EventDate:dd.MM.yyyy.})", + Selected = e.Id == selectedEventId + }) + .ToList(); + } + + private async Task CanCreateRegistrationsAsync() + { + return await ctx.Events.AsNoTracking().AnyAsync() + && await ctx.People.AsNoTracking().AnyAsync() + && await ctx.Sports.AsNoTracking().AnyAsync(); + } + + private async Task> GetCountryOptionsAsync(string? selectedCountryCode) + { + return await ctx.Countries + .AsNoTracking() + .OrderBy(c => c.Name) + .Select(c => new SelectListItem + { + Value = c.Code, + Text = c.Name, + Selected = c.Code == selectedCountryCode + }) + .ToListAsync(); + } + + private static void MarkSelectedEvent(IEnumerable events, int selectedEventId) + { + foreach (var eventOption in events) + { + eventOption.Selected = string.Equals(eventOption.Value, selectedEventId.ToString(), StringComparison.Ordinal); + } + } +} diff --git a/Events-MVC/Events.MVC/Controllers/SportsController.cs b/Events-MVC/Events.MVC/Controllers/SportsController.cs new file mode 100644 index 0000000..cb071f6 --- /dev/null +++ b/Events-MVC/Events.MVC/Controllers/SportsController.cs @@ -0,0 +1,228 @@ +using System.Text.Json; +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.MVC.Models; +using Events.MVC.Models.Sports; +using Events.MVC.Util.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Sieve.Models; +using Sieve.Services; + +namespace Events.MVC.Controllers; + +public class SportsController : Controller +{ + private readonly EventsContext ctx; + private readonly ISieveProcessor sieveProcessor; + private readonly PagingSettings pagingSettings; + + public SportsController(EventsContext ctx, ISieveProcessor sieveProcessor, IOptions pagingSettings) + { + this.ctx = ctx; + this.sieveProcessor = sieveProcessor; + this.pagingSettings = pagingSettings.Value; + } + + public async Task Index(SieveModel sieveModel) + { + var viewModel = await BuildSportsListAsync(sieveModel); + if (Request.Headers.ContainsKey(Constants.HtmxHeaders.Request)) + { + return PartialView("_SportsList", viewModel); + } + + return View(viewModel); + } + + [HttpGet] + public async Task Row(int id) + { + var sport = await ctx.Sports + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == id); + + if (sport is null) + { + return NotFound(); + } + + return PartialView("_SportRow", new SportViewModel + { + Id = sport.Id, + Name = sport.Name + }); + } + + [HttpGet] + public async Task EditRow(int id) + { + var sport = await ctx.Sports + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == id); + + if (sport is null) + { + return NotFound(); + } + + return PartialView("_SportEditRow", new SportViewModel + { + Id = sport.Id, + Name = sport.Name + }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(SportViewModel model, SieveModel sieveModel) + { + if (!ModelState.IsValid) + { + Response.Headers[Constants.HtmxHeaders.Retarget] = "#create-sport-form"; + Response.Headers[Constants.HtmxHeaders.Reswap] = Constants.HtmxSwap.OuterHtml; + return PartialView("_CreateSportForm", model); + } + + var sport = new Sport + { + Name = model.Name + }; + ctx.Sports.Add(sport); + await ctx.SaveChangesAsync(); + + Response.Headers[Constants.HtmxHeaders.Trigger] = JsonSerializer.Serialize(new Dictionary + { + [Constants.HtmxEvents.SportCreated] = true, + [Constants.HtmxEvents.ShowToast] = new + { + variant = Constants.ToastVariants.Success, + title = Constants.ToastTitles.Success, + message = $"Sport '{model.Name}' was added successfully." + } + }); + var viewModel = await BuildSportsListAsync(sieveModel); + return PartialView("_SportsList", viewModel); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(int id, SportViewModel model) + { + if (id != model.Id) + { + return BadRequest(); + } + + if (!ModelState.IsValid) + { + return PartialView("_SportEditRow", model); + } + + var existingSport = await ctx.Sports.FirstOrDefaultAsync(s => s.Id == id); + if (existingSport is null) + { + return NotFound(); + } + + existingSport.Name = model.Name; + await ctx.SaveChangesAsync(); + + return PartialView("_SportRow", new SportViewModel + { + Id = existingSport.Id, + Name = existingSport.Name + }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(int id, SieveModel sieveModel) + { + var sport = await ctx.Sports + .Include(s => s.Registrations) + .FirstOrDefaultAsync(s => s.Id == id); + + if (sport is null) + { + return NotFound(); + } + + if (sport.Registrations.Count > 0) + { + Response.StatusCode = StatusCodes.Status409Conflict; + return Content("The sport cannot be deleted because registrations exist."); + } + + ctx.Sports.Remove(sport); + var deletedName = sport.Name; + await ctx.SaveChangesAsync(); + + Response.Headers[Constants.HtmxHeaders.Trigger] = JsonSerializer.Serialize(new Dictionary + { + [Constants.HtmxEvents.ShowToast] = new + { + variant = Constants.ToastVariants.Success, + title = Constants.ToastTitles.Success, + message = $"Sport '{deletedName}' was deleted successfully." + } + }); + var viewModel = await BuildSportsListAsync(sieveModel); + return PartialView("_SportsList", viewModel); + } + + private async Task> BuildSportsListAsync(SieveModel sieveModel) + { + sieveModel.SetDefaultPagingAndSorting(pagingSettings.PageSize, "Name"); + var normalizedFilters = sieveModel.Filters?.Trim() ?? string.Empty; + var nameFilter = SieveModelExtensions.ExtractFilterValue(normalizedFilters, "Name"); + + var baseQuery = ctx.Sports + .AsNoTracking() + .Select(s => new SportViewModel + { + Id = s.Id, + Name = s.Name + }); + + var totalCount = await baseQuery.CountAsync(); + var filteredCount = string.IsNullOrWhiteSpace(normalizedFilters) + ? totalCount + : await sieveProcessor + .Apply( + sieveModel, + baseQuery, + applyFiltering: true, + applySorting: false, + applyPagination: false) + .CountAsync(); + var pagingInfo = new PagingInfo + { + FilteredItemsCount = filteredCount, + TotalItemsCount = totalCount, + ItemsPerPage = sieveModel.PageSize!.Value, + CurrentPage = sieveModel.Page!.Value, + Sorts = sieveModel.Sorts ?? "Name", + Filters = normalizedFilters, + NameFilter = nameFilter + }; + + if (pagingInfo.CurrentPage > pagingInfo.TotalPages) + { + pagingInfo.CurrentPage = pagingInfo.TotalPages; + sieveModel.Page = pagingInfo.CurrentPage; + } + + var sports = await sieveProcessor + .Apply(sieveModel, baseQuery) + .ToListAsync(); + + return new PagedList(sports, pagingInfo); + } + +} diff --git a/Events-MVC/Events.MVC/Events.MVC.csproj b/Events-MVC/Events.MVC/Events.MVC.csproj new file mode 100644 index 0000000..7b40f80 --- /dev/null +++ b/Events-MVC/Events.MVC/Events.MVC.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + + + + PI + + + + $(DefineConstants);MSSQL + + + + + + + + + + + + diff --git a/Events-MVC/Events.MVC/Models/Countries/CountryTranslationViewModel.cs b/Events-MVC/Events.MVC/Models/Countries/CountryTranslationViewModel.cs new file mode 100644 index 0000000..371fd0c --- /dev/null +++ b/Events-MVC/Events.MVC/Models/Countries/CountryTranslationViewModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Events.MVC.Models.Countries; + +public class CountryTranslationViewModel +{ + [Display(Name = "Language")] + [StringLength(10)] + public string LanguageCode { get; set; } = string.Empty; + + [Display(Name = "Translation")] + [StringLength(200)] + public string Name { get; set; } = string.Empty; +} diff --git a/Events-MVC/Events.MVC/Models/Countries/CountryViewModel.cs b/Events-MVC/Events.MVC/Models/Countries/CountryViewModel.cs new file mode 100644 index 0000000..efecb24 --- /dev/null +++ b/Events-MVC/Events.MVC/Models/Countries/CountryViewModel.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using Sieve.Attributes; + +namespace Events.MVC.Models.Countries; + +public class CountryViewModel +{ + [Display(Name = "Code")] + [Required] + [StringLength(3, MinimumLength = 2)] + [Sieve(CanSort = true, CanFilter = true)] + public string Code { get; set; } = string.Empty; + + [Display(Name = "Alpha-3")] + [Required] + [StringLength(3, MinimumLength = 3)] + [Sieve(CanSort = true, CanFilter = true)] + public string Alpha3 { get; set; } = string.Empty; + + [Display(Name = "Name")] + [Required] + [StringLength(100)] + [Sieve(CanSort = true, CanFilter = true)] + public string Name { get; set; } = string.Empty; + + public List Translations { get; set; } = []; +} diff --git a/Events-MVC/Events.MVC/Models/ErrorViewModel.cs b/Events-MVC/Events.MVC/Models/ErrorViewModel.cs new file mode 100644 index 0000000..b6f5ade --- /dev/null +++ b/Events-MVC/Events.MVC/Models/ErrorViewModel.cs @@ -0,0 +1,8 @@ +namespace Events.MVC.Models; + +public class ErrorViewModel +{ + public string? RequestId { get; set; } + + public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); +} diff --git a/Events-MVC/Events.MVC/Models/Events/EventViewModel.cs b/Events-MVC/Events.MVC/Models/Events/EventViewModel.cs new file mode 100644 index 0000000..b7adc8e --- /dev/null +++ b/Events-MVC/Events.MVC/Models/Events/EventViewModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using Sieve.Attributes; + +namespace Events.MVC.Models.Events; + +public class EventViewModel +{ + [Display(Name = "ID")] + [Sieve(CanSort = true)] + public int Id { get; set; } + + [Display(Name = "Name")] + [Required] + [StringLength(150)] + [Sieve(CanSort = true, CanFilter = true)] + public string Name { get; set; } = string.Empty; + + [Display(Name = "Date")] + [DataType(DataType.Date)] + [Sieve(CanSort = true)] + public DateOnly EventDate { get; set; } + + [Display(Name = "Participants")] + [Sieve(CanSort = true)] + public int ParticipantsCount { get; set; } +} diff --git a/Events-MVC/Events.MVC/Models/PagedList.cs b/Events-MVC/Events.MVC/Models/PagedList.cs new file mode 100644 index 0000000..154a324 --- /dev/null +++ b/Events-MVC/Events.MVC/Models/PagedList.cs @@ -0,0 +1,3 @@ +namespace Events.MVC.Models; + +public record PagedList(List Data, PagingInfo PagingInfo); diff --git a/Events-MVC/Events.MVC/Models/PagingInfo.cs b/Events-MVC/Events.MVC/Models/PagingInfo.cs new file mode 100644 index 0000000..4161fbc --- /dev/null +++ b/Events-MVC/Events.MVC/Models/PagingInfo.cs @@ -0,0 +1,39 @@ +namespace Events.MVC.Models; + +public class PagingInfo +{ + public int TotalItemsCount { get; set; } + + public int FilteredItemsCount { get; set; } + + public int ItemsPerPage { get; set; } + + public int CurrentPage { get; set; } + + public string Sorts { get; set; } = "Name"; + + public string Filters { get; set; } = string.Empty; + + public string NameFilter { get; set; } = string.Empty; + + public int TotalPages => Math.Max(1, (int)Math.Ceiling((decimal)FilteredItemsCount / ItemsPerPage)); + + public bool IsFiltered => !string.IsNullOrWhiteSpace(Filters); + + public string ToggleSort(string propertyName) + { + return string.Equals(Sorts, propertyName, StringComparison.OrdinalIgnoreCase) + ? $"-{propertyName}" + : propertyName; + } + + public bool IsSortedBy(string propertyName) + { + return string.Equals(Sorts.TrimStart('-'), propertyName, StringComparison.OrdinalIgnoreCase); + } + + public bool IsDescending() + { + return Sorts.StartsWith("-", StringComparison.Ordinal); + } +} diff --git a/Events-MVC/Events.MVC/Models/PagingSettings.cs b/Events-MVC/Events.MVC/Models/PagingSettings.cs new file mode 100644 index 0000000..2c9e71c --- /dev/null +++ b/Events-MVC/Events.MVC/Models/PagingSettings.cs @@ -0,0 +1,10 @@ +namespace Events.MVC.Models; + +public class PagingSettings +{ + public const string SectionName = "Paging"; + + public int PageSize { get; set; } = 2; + + public int PageOffset { get; set; } = 5; +} diff --git a/Events-MVC/Events.MVC/Models/People/PeoplePageViewModel.cs b/Events-MVC/Events.MVC/Models/People/PeoplePageViewModel.cs new file mode 100644 index 0000000..642c416 --- /dev/null +++ b/Events-MVC/Events.MVC/Models/People/PeoplePageViewModel.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Events.MVC.Models.People; + +public class PeoplePageViewModel +{ + public PagedList People { get; set; } = new([], new PagingInfo()); + + public List CountryOptions { get; set; } = []; + + public string CountryFilter { get; set; } = string.Empty; +} diff --git a/Events-MVC/Events.MVC/Models/People/PersonViewModel.cs b/Events-MVC/Events.MVC/Models/People/PersonViewModel.cs new file mode 100644 index 0000000..7ceb187 --- /dev/null +++ b/Events-MVC/Events.MVC/Models/People/PersonViewModel.cs @@ -0,0 +1,100 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.Rendering; +using Sieve.Attributes; + +namespace Events.MVC.Models.People; + +public class PersonViewModel : IValidatableObject +{ + [Sieve(CanSort = true)] + public int Id { get; set; } + + [Display(Name = "First name")] + [StringLength(100)] + public string? FirstName { get; set; } + + [Display(Name = "Last name")] + [StringLength(100)] + public string? LastName { get; set; } + + [Display(Name = "First name (transcription)")] + [Required] + [StringLength(100)] + [Sieve(CanSort = true)] + public string FirstNameTranscription { get; set; } = string.Empty; + + [Display(Name = "Last name (transcription)")] + [Required] + [StringLength(100)] + [Sieve(CanSort = true)] + public string LastNameTranscription { get; set; } = string.Empty; + + [Display(Name = "Address")] + [StringLength(200)] + public string? AddressLine { get; set; } + + [Display(Name = "Postal code")] + [StringLength(20)] + public string? PostalCode { get; set; } + + [Display(Name = "City")] + [StringLength(100)] + public string? City { get; set; } + + [Display(Name = "Address country")] + [StringLength(100)] + public string? AddressCountry { get; set; } + + [Display(Name = "E-mail")] + [EmailAddress] + [StringLength(255)] + [Sieve(CanSort = true)] + public string? Email { get; set; } + + [Display(Name = "Phone")] + [StringLength(50)] + public string? ContactPhone { get; set; } + + [Display(Name = "Birth date")] + [Required] + [Sieve(CanSort = true)] + public DateOnly? BirthDate { get; set; } + + [Display(Name = "Document number")] + [Required] + [StringLength(50)] + public string DocumentNumber { get; set; } = string.Empty; + + [Display(Name = "Country")] + [Required] + [StringLength(3)] + [Sieve(CanFilter = true)] + public string CountryCode { get; set; } = string.Empty; + + [Display(Name = "Full name")] + public string FullName { get; set; } = string.Empty; + + [Display(Name = "Full name (transcription)")] + [Sieve(CanFilter = true)] + public string FullNameTranscription { get; set; } = string.Empty; + + [Display(Name = "Country")] + [Sieve(CanSort = true)] + public string CountryName { get; set; } = string.Empty; + + [Display(Name = "Registrations")] + [Sieve(CanSort = true)] + public int RegistrationsCount { get; set; } + + public List CountryOptions { get; set; } = []; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(ContactPhone)) + { + yield return new ValidationResult( + Constants.Messages.EmailOrContactPhoneRequired, + new[] { nameof(Email), nameof(ContactPhone) }); + } + } +} diff --git a/Events-MVC/Events.MVC/Models/Registrations/RegistrationViewModel.cs b/Events-MVC/Events.MVC/Models/Registrations/RegistrationViewModel.cs new file mode 100644 index 0000000..f66193f --- /dev/null +++ b/Events-MVC/Events.MVC/Models/Registrations/RegistrationViewModel.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.Rendering; +using Sieve.Attributes; + +namespace Events.MVC.Models.Registrations; + +public class RegistrationViewModel +{ + [Sieve(CanSort = true)] + public int Id { get; set; } + + [Required] + [Range(1, int.MaxValue)] + public int EventId { get; set; } + + [Display(Name = "Person")] + [Required] + [Range(1, int.MaxValue)] + public int PersonId { get; set; } + + [Display(Name = "Sport")] + [Required] + [Range(1, int.MaxValue)] + public int SportId { get; set; } + + [Display(Name = "Person")] + public string PersonLookup { get; set; } = string.Empty; + + [Display(Name = "Person")] + [Sieve(CanSort = true, CanFilter = true)] + public string PersonName { get; set; } = string.Empty; + + [Display(Name = "Transcription")] + [Sieve(CanFilter = true)] + public string PersonTranscription { get; set; } = string.Empty; + + [Display(Name = "Country")] + [Sieve(CanFilter = true)] + public string CountryCode { get; set; } = string.Empty; + + public string CountryName { get; set; } = string.Empty; + + [Display(Name = "Sport")] + [Sieve(CanSort = true)] + public string SportName { get; set; } = string.Empty; + + [Display(Name = "Registered at")] + [Sieve(CanSort = true)] + public DateTime RegisteredAt { get; set; } + + public List SportOptions { get; set; } = []; +} diff --git a/Events-MVC/Events.MVC/Models/Registrations/RegistrationsPageViewModel.cs b/Events-MVC/Events.MVC/Models/Registrations/RegistrationsPageViewModel.cs new file mode 100644 index 0000000..5e39fbd --- /dev/null +++ b/Events-MVC/Events.MVC/Models/Registrations/RegistrationsPageViewModel.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Events.MVC.Models.Registrations; + +public class RegistrationsPageViewModel +{ + public int SelectedEventId { get; set; } + + public string SelectedEventName { get; set; } = string.Empty; + + public List EventOptions { get; set; } = []; + + public List CountryOptions { get; set; } = []; + + public string CountryFilter { get; set; } = string.Empty; + + public PagedList Registrations { get; set; } = new([], new PagingInfo + { + ItemsPerPage = 10, + CurrentPage = 1 + }); + + public RegistrationViewModel CreateModel { get; set; } = new(); + + public bool CanCreate { get; set; } + + public string? CreateDisabledMessage { get; set; } +} diff --git a/Events-MVC/Events.MVC/Models/Sports/SportViewModel.cs b/Events-MVC/Events.MVC/Models/Sports/SportViewModel.cs new file mode 100644 index 0000000..de22939 --- /dev/null +++ b/Events-MVC/Events.MVC/Models/Sports/SportViewModel.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Sieve.Attributes; + +namespace Events.MVC.Models.Sports; + +public class SportViewModel +{ + [Sieve(CanSort = true)] + public int Id { get; set; } + + [Display(Name = "Name")] + [Required] + [StringLength(100)] + [Sieve(CanSort = true, CanFilter = true)] + public string Name { get; set; } = string.Empty; +} diff --git a/Events-MVC/Events.MVC/Program.cs b/Events-MVC/Events.MVC/Program.cs new file mode 100644 index 0000000..d29ccf8 --- /dev/null +++ b/Events-MVC/Events.MVC/Program.cs @@ -0,0 +1,42 @@ +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.MVC.Models; +using Events.MVC.Util.Middleware; +using Microsoft.EntityFrameworkCore; +using Sieve.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllersWithViews(options => + options.Filters.Add()); +#if POSTGRES +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("EventsPostgres"))); +#else +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("EventsMssql"))); +#endif +builder.Services.AddScoped(); +builder.Services.Configure(builder.Configuration.GetSection(PagingSettings.SectionName)); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Home/Error"); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); + +app.UseRouting(); + +app.MapDefaultControllerRoute(); + +app.Run(); + +public partial class Program; diff --git a/Events-MVC/Events.MVC/Properties/launchSettings.json b/Events-MVC/Events.MVC/Properties/launchSettings.json new file mode 100644 index 0000000..a8a48e7 --- /dev/null +++ b/Events-MVC/Events.MVC/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Events.MVC": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7126;http://localhost:5200", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Events-MVC/Events.MVC/Util/Extensions/ExceptionExtensions.cs b/Events-MVC/Events.MVC/Util/Extensions/ExceptionExtensions.cs new file mode 100644 index 0000000..18825fb --- /dev/null +++ b/Events-MVC/Events.MVC/Util/Extensions/ExceptionExtensions.cs @@ -0,0 +1,28 @@ +using System.Text; + +namespace Events.MVC.Util.Extensions +{ + /// + /// Class with useful extensions for exceptions handling + /// + public static class ExceptionExtensions + { + /// + /// return complete hierarchy of an exception. It checks whether the exception has inner exception, + /// and if it has, then it appends inner exception message. + /// Then it looks for inner exception of the inner exceptions, and so on. + /// + /// Exception which message hiearchy should be obtained + /// String containing all exception hierarchy messages + public static string CompleteExceptionMessage(this Exception? exc) + { + StringBuilder sb = new(); + while (exc != null) + { + sb.AppendLine(exc.Message); + exc = exc.InnerException; + } + return sb.ToString(); + } + } +} diff --git a/Events-MVC/Events.MVC/Util/Extensions/ModelStateExtensions.cs b/Events-MVC/Events.MVC/Util/Extensions/ModelStateExtensions.cs new file mode 100644 index 0000000..058288a --- /dev/null +++ b/Events-MVC/Events.MVC/Util/Extensions/ModelStateExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.Text; + +namespace Events.MVC.Util.Extensions +{ + public static class ModelStateExtensions + { + public static string GetErrorsString(this ModelStateDictionary modelState) + { + StringBuilder sb = new StringBuilder(); + foreach (var modelStateEntry in modelState) + { + if (modelStateEntry.Value.Errors.Count > 0) + { + string key = modelStateEntry.Key; + string error = string.Join(", ", modelStateEntry.Value.Errors.Select(e => e.ErrorMessage)); + sb.AppendFormat("{0}: {1}; ", key, error); + } + } + return sb.ToString(); + } + } +} diff --git a/Events-MVC/Events.MVC/Util/Extensions/SieveModelExtensions.cs b/Events-MVC/Events.MVC/Util/Extensions/SieveModelExtensions.cs new file mode 100644 index 0000000..fc02892 --- /dev/null +++ b/Events-MVC/Events.MVC/Util/Extensions/SieveModelExtensions.cs @@ -0,0 +1,59 @@ +using Sieve.Models; + +namespace Events.MVC.Util.Extensions; + +public static class SieveModelExtensions +{ + public static void SetDefaultPagingAndSorting(this SieveModel sieveModel, int defaultPageSize, string defaultSort) + { + sieveModel.Page ??= 1; + + if (sieveModel.Page < 1) + { + sieveModel.Page = 1; + } + + if (sieveModel.PageSize is null || sieveModel.PageSize <= 0) + { + sieveModel.PageSize = defaultPageSize; + } + + if (string.IsNullOrWhiteSpace(sieveModel.Sorts)) + { + sieveModel.Sorts = defaultSort; + } + } + + public static string ExtractFilterValue(this SieveModel sieveModel, string propertyName) + { + var filters = sieveModel.Filters?.Trim() ?? string.Empty; + return ExtractFilterValue(filters, propertyName); + } + + public static string ExtractFilterValue(string filters, string propertyName) + { + return ExtractFilterValue(filters, propertyName, "@=*", "@="); + } + + public static string ExtractFilterValue(string filters, string propertyName, params string[] operators) + { + if (string.IsNullOrWhiteSpace(filters)) + { + return string.Empty; + } + + foreach (var filter in filters.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + foreach (var filterOperator in operators) + { + var prefix = $"{propertyName}{filterOperator}"; + if (filter.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return filter[prefix.Length..]; + } + } + } + + return string.Empty; + } +} diff --git a/Events-MVC/Events.MVC/Util/Extensions/StringExtensions.cs b/Events-MVC/Events.MVC/Util/Extensions/StringExtensions.cs new file mode 100644 index 0000000..4f80329 --- /dev/null +++ b/Events-MVC/Events.MVC/Util/Extensions/StringExtensions.cs @@ -0,0 +1,10 @@ +namespace Events.MVC.Util.Extensions; + +public static class StringExtensions +{ + public static string? TrimToNull(this string? value) + { + var trimmed = value?.Trim(); + return string.IsNullOrEmpty(trimmed) ? null : trimmed; + } +} diff --git a/Events-MVC/Events.MVC/Util/Middleware/ProblemDetailsForSqlException.cs b/Events-MVC/Events.MVC/Util/Middleware/ProblemDetailsForSqlException.cs new file mode 100644 index 0000000..7ac5ac9 --- /dev/null +++ b/Events-MVC/Events.MVC/Util/Middleware/ProblemDetailsForSqlException.cs @@ -0,0 +1,73 @@ +using Events.MVC.Util.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace Events.MVC.Util.Middleware; + +public class ProblemDetailsForSqlException : ExceptionFilterAttribute +{ + private readonly ILogger logger; + + public ProblemDetailsForSqlException(ILogger logger) + { + this.logger = logger; + } + + public override void OnException(ExceptionContext context) + { + Exception? exception = context.Exception; + PostgresException? postgresException = null; + + while (exception is not null) + { + if (exception is PostgresException currentPostgresException) + { + postgresException = currentPostgresException; + break; + } + + if (exception is DbUpdateException dbUpdateException && dbUpdateException.InnerException is not null) + { + exception = dbUpdateException.InnerException; + continue; + } + + exception = exception.InnerException; + } + + if (postgresException is null) + { + base.OnException(context); + return; + } + + ProblemDetails problemDetails = postgresException.SqlState switch + { + PostgresErrorCodes.UniqueViolation => new ProblemDetails + { + Title = "Duplicate data", + Detail = "A record with the same data already exists." + }, + PostgresErrorCodes.ForeignKeyViolation => new ProblemDetails + { + Title = "Related data", + Detail = "The operation is not allowed because related data exists." + }, + _ => new ProblemDetails + { + Title = "Database error", + Detail = $"An error occurred while saving data to the database. {postgresException.MessageText}" + } + }; + + logger.LogDebug("Database exception: {message}", context.Exception.CompleteExceptionMessage()); + context.ExceptionHandled = true; + context.Result = new ObjectResult(problemDetails) + { + ContentTypes = { "application/problem+json" }, + StatusCode = StatusCodes.Status500InternalServerError + }; + } +} diff --git a/Events-MVC/Events.MVC/Util/TagHelpers/PagerTagHelper.cs b/Events-MVC/Events.MVC/Util/TagHelpers/PagerTagHelper.cs new file mode 100644 index 0000000..a3e79d6 --- /dev/null +++ b/Events-MVC/Events.MVC/Util/TagHelpers/PagerTagHelper.cs @@ -0,0 +1,157 @@ +using Events.MVC.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.Options; + +namespace Events.MVC.Util.TagHelpers; + +[HtmlTargetElement("pager", Attributes = "page-info,page-action")] +public class PagerTagHelper : TagHelper +{ + private readonly IUrlHelperFactory urlHelperFactory; + private readonly PagingSettings pagingSettings; + + public PagerTagHelper(IUrlHelperFactory urlHelperFactory, IOptions pagingSettings) + { + this.urlHelperFactory = urlHelperFactory; + this.pagingSettings = pagingSettings.Value; + } + + [ViewContext] + [HtmlAttributeNotBound] + public ViewContext ViewContext { get; set; } = null!; + + public PagingInfo PageInfo { get; set; } = new(); + + public string PageAction { get; set; } = string.Empty; + + public string PageTitle { get; set; } = "Unesite broj stranice"; + + public string? PageTarget { get; set; } + + public string? PageSwap { get; set; } + + public bool PagePushUrl { get; set; } + + [HtmlAttributeName(DictionaryAttributePrefix = "page-route-")] + public Dictionary PageRouteValues { get; set; } = []; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "nav"; + output.Attributes.SetAttribute("aria-label", "Pager"); + + var paginationList = new TagBuilder("ul"); + paginationList.AddCssClass("pagination"); + paginationList.AddCssClass("mb-0"); + + var firstPageInRange = Math.Max(1, PageInfo.CurrentPage - pagingSettings.PageOffset); + var lastPageInRange = Math.Min(PageInfo.TotalPages, PageInfo.CurrentPage + pagingSettings.PageOffset); + + if (firstPageInRange > 1) + { + paginationList.InnerHtml.AppendHtml(BuildListItemForPage(1, "1..")); + } + + for (var page = firstPageInRange; page <= lastPageInRange; page++) + { + paginationList.InnerHtml.AppendHtml( + page == PageInfo.CurrentPage + ? BuildListItemForCurrentPage(page) + : BuildListItemForPage(page)); + } + + if (lastPageInRange < PageInfo.TotalPages) + { + paginationList.InnerHtml.AppendHtml(BuildListItemForPage(PageInfo.TotalPages, $"..{PageInfo.TotalPages}")); + } + + output.Content.AppendHtml(paginationList); + } + + private TagBuilder BuildListItemForPage(int page) + { + return BuildListItemForPage(page, page.ToString()); + } + + private TagBuilder BuildListItemForPage(int page, string text) + { + var urlHelper = urlHelperFactory.GetUrlHelper(ViewContext); + var url = urlHelper.Action(PageAction, BuildRouteValues(page)) ?? string.Empty; + + var anchor = new TagBuilder("a"); + anchor.InnerHtml.Append(text); + anchor.Attributes["href"] = url; + anchor.AddCssClass("page-link"); + ApplyHtmxAttributes(anchor, url); + + var listItem = new TagBuilder("li"); + listItem.AddCssClass("page-item"); + listItem.InnerHtml.AppendHtml(anchor); + return listItem; + } + + private TagBuilder BuildListItemForCurrentPage(int page) + { + var urlHelper = urlHelperFactory.GetUrlHelper(ViewContext); + var urlTemplate = urlHelper.Action(PageAction, BuildRouteValues("__page__")) ?? string.Empty; + + var input = new TagBuilder("input"); + input.Attributes["type"] = "text"; + input.Attributes["value"] = page.ToString(); + input.Attributes["data-current"] = page.ToString(); + input.Attributes["data-min"] = "1"; + input.Attributes["data-max"] = PageInfo.TotalPages.ToString(); + input.Attributes["data-url-template"] = urlTemplate; + input.Attributes["title"] = PageTitle; + if (!string.IsNullOrWhiteSpace(PageTarget)) + { + input.Attributes["data-target"] = PageTarget; + } + + if (!string.IsNullOrWhiteSpace(PageSwap)) + { + input.Attributes["data-swap"] = PageSwap; + } + + input.Attributes["data-push-url"] = PagePushUrl.ToString().ToLowerInvariant(); + input.AddCssClass("page-link"); + input.AddCssClass("pagebox"); + + var listItem = new TagBuilder("li"); + listItem.AddCssClass("page-item"); + listItem.AddCssClass("active"); + listItem.InnerHtml.AppendHtml(input); + + return listItem; + } + + private void ApplyHtmxAttributes(TagBuilder tagBuilder, string url) + { + if (string.IsNullOrWhiteSpace(PageTarget)) + { + return; + } + + tagBuilder.Attributes["hx-get"] = url; + tagBuilder.Attributes["hx-target"] = PageTarget; + tagBuilder.Attributes["hx-swap"] = string.IsNullOrWhiteSpace(PageSwap) ? "outerHTML" : PageSwap; + if (PagePushUrl) + { + tagBuilder.Attributes["hx-push-url"] = "true"; + } + } + + private RouteValueDictionary BuildRouteValues(object pageValue) + { + var routeValues = new RouteValueDictionary(PageRouteValues.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value)); + routeValues["page"] = pageValue; + routeValues["pageSize"] = PageInfo.ItemsPerPage; + routeValues["sorts"] = PageInfo.Sorts; + routeValues["filters"] = PageInfo.Filters; + return routeValues; + } +} diff --git a/Events-MVC/Events.MVC/Views/Countries/Index.cshtml b/Events-MVC/Events.MVC/Views/Countries/Index.cshtml new file mode 100644 index 0000000..b215e9f --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Countries/Index.cshtml @@ -0,0 +1,171 @@ +@model PagedList + +@{ + ViewData[Constants.ViewDataKeys.Title] = "Countries"; + ViewData[Constants.ViewDataKeys.HeaderActionLabel] = "New country"; + ViewData[Constants.ViewDataKeys.HeaderActionTarget] = "#create-country-panel"; +} + +
+
+
+
+

Add a new country

+
+ +
+
+
+
+ + +
+ +@section Scripts { + +} diff --git a/Events-MVC/Events.MVC/Views/Countries/_CountriesList.cshtml b/Events-MVC/Events.MVC/Views/Countries/_CountriesList.cshtml new file mode 100644 index 0000000..e58bb0b --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Countries/_CountriesList.cshtml @@ -0,0 +1,171 @@ +@model PagedList + +
+
+
+ + + + +
+ +
+ + + + + +
+

Countries list

+ @(Model.PagingInfo.IsFiltered ? $"{Model.PagingInfo.FilteredItemsCount} / {Model.PagingInfo.TotalItemsCount}" : Model.PagingInfo.TotalItemsCount.ToString()) +
+ +
+ + + + @if (Model.PagingInfo.IsFiltered) + { + + Clear + + } +
+
+ + + +
+
+ Page @Model.PagingInfo.CurrentPage of @Model.PagingInfo.TotalPages +
+ + + + +
+
+ + +
+
+
diff --git a/Events-MVC/Events.MVC/Views/Countries/_CountryEditRow.cshtml b/Events-MVC/Events.MVC/Views/Countries/_CountryEditRow.cshtml new file mode 100644 index 0000000..c2e1788 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Countries/_CountryEditRow.cshtml @@ -0,0 +1,44 @@ +@model Events.MVC.Models.Countries.CountryViewModel + + + @Model.Code + +
+ @Html.AntiForgeryToken() + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+ + diff --git a/Events-MVC/Events.MVC/Views/Countries/_CountryRow.cshtml b/Events-MVC/Events.MVC/Views/Countries/_CountryRow.cshtml new file mode 100644 index 0000000..8c1184e --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Countries/_CountryRow.cshtml @@ -0,0 +1,33 @@ +@model Events.MVC.Models.Countries.CountryViewModel + + + @Model.Code + @Model.Alpha3 + @Model.Name + +
+ + +
+ @Html.AntiForgeryToken() + +
+
+ + diff --git a/Events-MVC/Events.MVC/Views/Countries/_CountryTranslationsEditor.cshtml b/Events-MVC/Events.MVC/Views/Countries/_CountryTranslationsEditor.cshtml new file mode 100644 index 0000000..3316b1c --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Countries/_CountryTranslationsEditor.cshtml @@ -0,0 +1,47 @@ +@model IList + +@{ + var prefix = (string?)ViewData[Constants.ViewDataKeys.Prefix] ?? "Translations"; + var canRemoveRows = (bool?)ViewData[Constants.ViewDataKeys.CanRemoveRows] ?? true; + var rows = Model.Count == 0 ? [new Events.MVC.Models.Countries.CountryTranslationViewModel()] : Model; +} + +
+
+ Translations + +
+ +
+ @for (var i = 0; i < rows.Count; i++) + { +
+
+ +
+
+ +
+
+ +
+
+ } +
+ + + +
Enter a language code such as en, de, or hr.
+
diff --git a/Events-MVC/Events.MVC/Views/Countries/_CreateCountryForm.cshtml b/Events-MVC/Events.MVC/Views/Countries/_CreateCountryForm.cshtml new file mode 100644 index 0000000..57a1d69 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Countries/_CreateCountryForm.cshtml @@ -0,0 +1,36 @@ +@model Events.MVC.Models.Countries.CountryViewModel + +
+ @Html.AntiForgeryToken() +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+ +
+ +
+
diff --git a/Events-MVC/Events.MVC/Views/Events/Index.cshtml b/Events-MVC/Events.MVC/Views/Events/Index.cshtml new file mode 100644 index 0000000..8caf52b --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Events/Index.cshtml @@ -0,0 +1,43 @@ +@model PagedList + +@{ + ViewData[Constants.ViewDataKeys.Title] = "Events"; + ViewData[Constants.ViewDataKeys.HeaderActionLabel] = "New event"; + ViewData[Constants.ViewDataKeys.HeaderActionTarget] = "#create-event-panel"; +} + +
+
+
+
+

Add a new event

+
+ +
+
+
+
+ + +
+ +@section Scripts { + +} diff --git a/Events-MVC/Events.MVC/Views/Events/_CreateEventForm.cshtml b/Events-MVC/Events.MVC/Views/Events/_CreateEventForm.cshtml new file mode 100644 index 0000000..1cf5812 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Events/_CreateEventForm.cshtml @@ -0,0 +1,26 @@ +@model Events.MVC.Models.Events.EventViewModel + +
+ @Html.AntiForgeryToken() +
+
+ + + +
+
+ + + +
+
+ +
+
+
diff --git a/Events-MVC/Events.MVC/Views/Events/_EventEditRow.cshtml b/Events-MVC/Events.MVC/Views/Events/_EventEditRow.cshtml new file mode 100644 index 0000000..1093d4e --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Events/_EventEditRow.cshtml @@ -0,0 +1,40 @@ +@model Events.MVC.Models.Events.EventViewModel + + + @Model.Id + +
+ @Html.AntiForgeryToken() + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ + diff --git a/Events-MVC/Events.MVC/Views/Events/_EventRow.cshtml b/Events-MVC/Events.MVC/Views/Events/_EventRow.cshtml new file mode 100644 index 0000000..1392093 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Events/_EventRow.cshtml @@ -0,0 +1,42 @@ +@model Events.MVC.Models.Events.EventViewModel + + + @Model.Id + @Model.Name + @Model.EventDate.ToString("dd.MM.yyyy.") + @Model.ParticipantsCount + +
+ + Registrations + + + + +
+ @Html.AntiForgeryToken() + +
+
+ + diff --git a/Events-MVC/Events.MVC/Views/Events/_EventsList.cshtml b/Events-MVC/Events.MVC/Views/Events/_EventsList.cshtml new file mode 100644 index 0000000..899f43d --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Events/_EventsList.cshtml @@ -0,0 +1,186 @@ +@model PagedList + +
+
+
+ + + + +
+ +
+ + + + + +
+

Events list

+ @(Model.PagingInfo.IsFiltered ? $"{Model.PagingInfo.FilteredItemsCount} / {Model.PagingInfo.TotalItemsCount}" : Model.PagingInfo.TotalItemsCount.ToString()) +
+ +
+ + + + @if (Model.PagingInfo.IsFiltered) + { + + Clear + + } +
+
+ + + +
+
+ Page @Model.PagingInfo.CurrentPage of @Model.PagingInfo.TotalPages +
+ + + + +
+
+ + +
+
+
diff --git a/Events-MVC/Events.MVC/Views/Home/Error.cshtml b/Events-MVC/Events.MVC/Views/Home/Error.cshtml new file mode 100644 index 0000000..2a2956a --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Home/Error.cshtml @@ -0,0 +1,15 @@ +@model Events.MVC.Models.ErrorViewModel + +@{ + ViewData[Constants.ViewDataKeys.Title] = Constants.ToastTitles.Error; +} + +
+
+

An error occurred.

+ @if (Model.ShowRequestId) + { +

Request ID: @Model.RequestId

+ } +
+
diff --git a/Events-MVC/Events.MVC/Views/Home/Index.cshtml b/Events-MVC/Events.MVC/Views/Home/Index.cshtml new file mode 100644 index 0000000..f3a521e --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Home/Index.cshtml @@ -0,0 +1,10 @@ +@{ + ViewData[Constants.ViewDataKeys.Title] = "Home"; +} + +
+
+

Events

+

This sample demonstrates an ASP.NET Core MVC application for managing sports events, countries, people, and registrations using htmx.

+
+
diff --git a/Events-MVC/Events.MVC/Views/People/Index.cshtml b/Events-MVC/Events.MVC/Views/People/Index.cshtml new file mode 100644 index 0000000..fe5dd21 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/People/Index.cshtml @@ -0,0 +1,38 @@ +@model Events.MVC.Models.People.PeoplePageViewModel + +@{ + ViewData[Constants.ViewDataKeys.Title] = "People"; + ViewData[Constants.ViewDataKeys.HeaderActionLabel] = "New person"; + ViewData[Constants.ViewDataKeys.HeaderActionTarget] = "#create-person-panel"; +} + +
+
+
+
+

Add a new person

+
+ +
+
+
+
+ + +
+ +@section Scripts { + +} diff --git a/Events-MVC/Events.MVC/Views/People/_CreatePersonForm.cshtml b/Events-MVC/Events.MVC/Views/People/_CreatePersonForm.cshtml new file mode 100644 index 0000000..6952cde --- /dev/null +++ b/Events-MVC/Events.MVC/Views/People/_CreatePersonForm.cshtml @@ -0,0 +1,84 @@ +@model Events.MVC.Models.People.PersonViewModel + +
+ @Html.AntiForgeryToken() +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+
diff --git a/Events-MVC/Events.MVC/Views/People/_PeopleList.cshtml b/Events-MVC/Events.MVC/Views/People/_PeopleList.cshtml new file mode 100644 index 0000000..f660f61 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/People/_PeopleList.cshtml @@ -0,0 +1,150 @@ +@model Events.MVC.Models.People.PeoplePageViewModel + +
+
+
+ + + + + +
+ +
+ + + + + +
+

People list

+ @(Model.People.PagingInfo.IsFiltered ? $"{Model.People.PagingInfo.FilteredItemsCount} / {Model.People.PagingInfo.TotalItemsCount}" : Model.People.PagingInfo.TotalItemsCount.ToString()) +
+ +
+ + + + + + @if (Model.People.PagingInfo.IsFiltered) + { + + Clear + + } +
+
+ + + +
+
+ Page @Model.People.PagingInfo.CurrentPage of @Model.People.PagingInfo.TotalPages +
+ + + + +
+
+ + +
+
+
diff --git a/Events-MVC/Events.MVC/Views/People/_PersonEditRow.cshtml b/Events-MVC/Events.MVC/Views/People/_PersonEditRow.cshtml new file mode 100644 index 0000000..8a73bdc --- /dev/null +++ b/Events-MVC/Events.MVC/Views/People/_PersonEditRow.cshtml @@ -0,0 +1,100 @@ +@model Events.MVC.Models.People.PersonViewModel + + + @Model.Id + +
+ @Html.AntiForgeryToken() + +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+ +
+
+
+ + diff --git a/Events-MVC/Events.MVC/Views/People/_PersonRow.cshtml b/Events-MVC/Events.MVC/Views/People/_PersonRow.cshtml new file mode 100644 index 0000000..3f27f92 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/People/_PersonRow.cshtml @@ -0,0 +1,37 @@ +@model Events.MVC.Models.People.PersonViewModel + + + @Model.Id + @Model.FirstNameTranscription + @Model.LastNameTranscription + @Model.FullName + @Model.BirthDate?.ToString("dd.MM.yyyy.") + @Model.CountryName + @Model.RegistrationsCount + +
+ + +
+ @Html.AntiForgeryToken() + +
+
+ + diff --git a/Events-MVC/Events.MVC/Views/Registrations/Index.cshtml b/Events-MVC/Events.MVC/Views/Registrations/Index.cshtml new file mode 100644 index 0000000..43873eb --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Registrations/Index.cshtml @@ -0,0 +1,85 @@ +@model Events.MVC.Models.Registrations.RegistrationsPageViewModel + +@{ + ViewData[Constants.ViewDataKeys.Title] = "Registrations"; + ViewData[Constants.ViewDataKeys.HeaderActionLabel] = "New registration"; + ViewData[Constants.ViewDataKeys.HeaderActionTarget] = "#create-registration-panel"; +} + + + +@section Scripts { + +} diff --git a/Events-MVC/Events.MVC/Views/Registrations/_CreateRegistrationForm.cshtml b/Events-MVC/Events.MVC/Views/Registrations/_CreateRegistrationForm.cshtml new file mode 100644 index 0000000..d5c2b81 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Registrations/_CreateRegistrationForm.cshtml @@ -0,0 +1,44 @@ +@model Events.MVC.Models.Registrations.RegistrationViewModel + +
+ @Html.AntiForgeryToken() + +
+
+
+ + +
+ +
+
+ +
+
+ + + +
+
+ +
+
+
diff --git a/Events-MVC/Events.MVC/Views/Registrations/_PersonSuggestions.cshtml b/Events-MVC/Events.MVC/Views/Registrations/_PersonSuggestions.cshtml new file mode 100644 index 0000000..b5c5833 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Registrations/_PersonSuggestions.cshtml @@ -0,0 +1,23 @@ +@model IReadOnlyList + +@if (Model.Count > 0) +{ + @foreach (var person in Model) + { + var parts = person.Text.Split('|', 2); + var originalName = parts[0]; + var transcription = parts.Length > 1 ? parts[1] : string.Empty; + + } +} diff --git a/Events-MVC/Events.MVC/Views/Registrations/_RegistrationEditRow.cshtml b/Events-MVC/Events.MVC/Views/Registrations/_RegistrationEditRow.cshtml new file mode 100644 index 0000000..ab062ff --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Registrations/_RegistrationEditRow.cshtml @@ -0,0 +1,64 @@ +@model Events.MVC.Models.Registrations.RegistrationViewModel + + + @Model.Id + +
+ @Html.AntiForgeryToken() + + +
+
+
+ + +
+ +
+
+ +
+
+ + + +
+
+ + +
+
+ +
+
+ +
+
+
+ + diff --git a/Events-MVC/Events.MVC/Views/Registrations/_RegistrationRow.cshtml b/Events-MVC/Events.MVC/Views/Registrations/_RegistrationRow.cshtml new file mode 100644 index 0000000..ffca437 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Registrations/_RegistrationRow.cshtml @@ -0,0 +1,39 @@ +@model Events.MVC.Models.Registrations.RegistrationViewModel + + + @Model.Id + + @Model.PersonName + @Model.PersonTranscription + + @Model.CountryName + @Model.SportName + @Model.RegisteredAt.ToString("dd.MM.yyyy. HH:mm") + +
+ + +
+ @Html.AntiForgeryToken() + +
+
+ + diff --git a/Events-MVC/Events.MVC/Views/Registrations/_RegistrationsList.cshtml b/Events-MVC/Events.MVC/Views/Registrations/_RegistrationsList.cshtml new file mode 100644 index 0000000..287fcdc --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Registrations/_RegistrationsList.cshtml @@ -0,0 +1,149 @@ +@model Events.MVC.Models.Registrations.RegistrationsPageViewModel + +
+
+
+ + + + + + +
+ +
+ + + + + + +
+

Registrations list

+ @(Model.Registrations.PagingInfo.IsFiltered ? $"{Model.Registrations.PagingInfo.FilteredItemsCount} / {Model.Registrations.PagingInfo.TotalItemsCount}" : Model.Registrations.PagingInfo.TotalItemsCount.ToString()) +
+ +
+ + + + + + @if (Model.Registrations.PagingInfo.IsFiltered) + { + + Clear + + } +
+
+ + @if (!Model.CanCreate && !string.IsNullOrWhiteSpace(Model.CreateDisabledMessage)) + { +
@Model.CreateDisabledMessage
+ } + + + +
+
+ Page @Model.Registrations.PagingInfo.CurrentPage of @Model.Registrations.PagingInfo.TotalPages +
+ + + + + +
+
+ + +
+
+
diff --git a/Events-MVC/Events.MVC/Views/Registrations/_RegistrationsPanel.cshtml b/Events-MVC/Events.MVC/Views/Registrations/_RegistrationsPanel.cshtml new file mode 100644 index 0000000..44a68ec --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Registrations/_RegistrationsPanel.cshtml @@ -0,0 +1,43 @@ +@model Events.MVC.Models.Registrations.RegistrationsPageViewModel + +
+
+
+
+
+ + +
+
+ Registrations for: @Model.SelectedEventName +
+
+
+
+ +
+
+
+

Add a new registration

+
+ +
+
+
+
+ + +
diff --git a/Events-MVC/Events.MVC/Views/Shared/_Layout.cshtml b/Events-MVC/Events.MVC/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000..91909b1 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Shared/_Layout.cshtml @@ -0,0 +1,157 @@ + + + + + + @ViewData[Constants.ViewDataKeys.Title] - Events + + + + +
+ +
+ +
+ @RenderBody() +
+ +
+
+
+ Notification + +
+
+
+
+ + + + + + @await RenderSectionAsync("Scripts", required: false) + + diff --git a/Events-MVC/Events.MVC/Views/Sports/Index.cshtml b/Events-MVC/Events.MVC/Views/Sports/Index.cshtml new file mode 100644 index 0000000..aa2d5b7 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Sports/Index.cshtml @@ -0,0 +1,38 @@ +@model PagedList + +@{ + ViewData[Constants.ViewDataKeys.Title] = "Sports"; + ViewData[Constants.ViewDataKeys.HeaderActionLabel] = "New sport"; + ViewData[Constants.ViewDataKeys.HeaderActionTarget] = "#create-sport-panel"; +} + +
+
+
+
+

Add a new sport

+
+ +
+
+
+
+ + +
+ +@section Scripts { + +} diff --git a/Events-MVC/Events.MVC/Views/Sports/_CreateSportForm.cshtml b/Events-MVC/Events.MVC/Views/Sports/_CreateSportForm.cshtml new file mode 100644 index 0000000..109e7eb --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Sports/_CreateSportForm.cshtml @@ -0,0 +1,21 @@ +@model Events.MVC.Models.Sports.SportViewModel + +
+ @Html.AntiForgeryToken() +
+
+ + + +
+
+ +
+
+
diff --git a/Events-MVC/Events.MVC/Views/Sports/_SportEditRow.cshtml b/Events-MVC/Events.MVC/Views/Sports/_SportEditRow.cshtml new file mode 100644 index 0000000..46cef6c --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Sports/_SportEditRow.cshtml @@ -0,0 +1,36 @@ +@model Events.MVC.Models.Sports.SportViewModel + + + @Model.Id + +
+ @Html.AntiForgeryToken() + +
+
+ + +
+
+ +
+
+ +
+
+
+ + diff --git a/Events-MVC/Events.MVC/Views/Sports/_SportRow.cshtml b/Events-MVC/Events.MVC/Views/Sports/_SportRow.cshtml new file mode 100644 index 0000000..3872cb5 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Sports/_SportRow.cshtml @@ -0,0 +1,32 @@ +@model Events.MVC.Models.Sports.SportViewModel + + + @Model.Id + @Model.Name + +
+ + +
+ @Html.AntiForgeryToken() + +
+
+ + diff --git a/Events-MVC/Events.MVC/Views/Sports/_SportsList.cshtml b/Events-MVC/Events.MVC/Views/Sports/_SportsList.cshtml new file mode 100644 index 0000000..3dd8a3c --- /dev/null +++ b/Events-MVC/Events.MVC/Views/Sports/_SportsList.cshtml @@ -0,0 +1,156 @@ +@model PagedList + +
+
+
+ + + + +
+ +
+ + + + + +
+

Sports list

+ @(Model.PagingInfo.IsFiltered ? $"{Model.PagingInfo.FilteredItemsCount} / {Model.PagingInfo.TotalItemsCount}" : Model.PagingInfo.TotalItemsCount.ToString()) +
+ +
+ + + + @if (Model.PagingInfo.IsFiltered) + { + + Clear + + } +
+
+ +
+ + + + + + + + + + @if (Model.Data.Count == 0) + { + + + + } + else + { + @foreach (var sport in Model.Data) + { + + } + } + +
+ + ID@(Model.PagingInfo.IsSortedBy("Id") ? (Model.PagingInfo.IsDescending() ? " ↓" : " ↑") : "") + + + + Name@(Model.PagingInfo.IsSortedBy("Name") ? (Model.PagingInfo.IsDescending() ? " ↓" : " ↑") : "") + +
No sports to display.
+
+ +
+
+ Page @Model.PagingInfo.CurrentPage of @Model.PagingInfo.TotalPages +
+ + + + +
+
+ + +
+
+
diff --git a/Events-MVC/Events.MVC/Views/_ViewImports.cshtml b/Events-MVC/Events.MVC/Views/_ViewImports.cshtml new file mode 100644 index 0000000..0882e23 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@using Events.EF.Models +@using Events.MVC +@using Events.MVC.Models +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Events.MVC diff --git a/Events-MVC/Events.MVC/Views/_ViewStart.cshtml b/Events-MVC/Events.MVC/Views/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/Events-MVC/Events.MVC/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/Events-MVC/Events.MVC/appsettings.Development.json b/Events-MVC/Events.MVC/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Events-MVC/Events.MVC/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Events-MVC/Events.MVC/appsettings.json b/Events-MVC/Events.MVC/appsettings.json new file mode 100644 index 0000000..b155c1b --- /dev/null +++ b/Events-MVC/Events.MVC/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Paging": { + "PageSize": 10, + "PageOffset": 5 + }, + "ConnectionStrings": { + "EventsMssql": "Data Source=.,1433;Initial Catalog=Events;User Id=sport;Password=go and look in the secrets file;TrustServerCertificate=True", + "EventsPostgres": "Host=localhost;Port=5432;Database=events;Username=sport;Password=go and look in the secrets file;Persist Security Info=True;" + } +} diff --git a/Events-MVC/Events.MVC/libman.json b/Events-MVC/Events.MVC/libman.json new file mode 100644 index 0000000..b971940 --- /dev/null +++ b/Events-MVC/Events.MVC/libman.json @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "defaultProvider": "jsdelivr", + "libraries": [ + { + "library": "bootstrap@5.3.3", + "destination": "wwwroot/lib/bootstrap/", + "files": [ + "dist/css/bootstrap.min.css", + "dist/js/bootstrap.bundle.min.js" + ] + }, + { + "library": "htmx.org@2.0.4", + "destination": "wwwroot/lib/htmx.org/", + "files": [ + "dist/htmx.min.js" + ] + } + ] +} diff --git a/Events-MVC/Events.MVC/wwwroot/css/site.css b/Events-MVC/Events.MVC/wwwroot/css/site.css new file mode 100644 index 0000000..f2aae60 --- /dev/null +++ b/Events-MVC/Events.MVC/wwwroot/css/site.css @@ -0,0 +1,55 @@ +body { + min-height: 100vh; +} + +.navbar-brand { + letter-spacing: 0.02em; +} + +.card { + border-radius: 1rem; +} + +.table > :not(caption) > * > * { + padding-top: 0.9rem; + padding-bottom: 0.9rem; +} + +.pagebox { + width: 3.5rem; + min-width: 3.5rem; + padding-left: 0.4rem; + padding-right: 0.4rem; + text-align: center; +} + +#app-toast { + min-width: 18rem; +} + +#app-toast.toast-success { + background-color: #198754; +} + +#app-toast.toast-error { + background-color: #dc3545; +} + +#app-toast .toast-body { + color: #fff; +} + +#app-toast .btn-close { + filter: invert(1); +} + +.registration-person-suggestions { + position: absolute; + top: calc(100% + 0.25rem); + left: 0; + right: 0; + z-index: 1050; + max-height: 16rem; + overflow-y: auto; +} + diff --git a/Events-MVC/Events.MVC/wwwroot/js/pager.js b/Events-MVC/Events.MVC/wwwroot/js/pager.js new file mode 100644 index 0000000..e0057cb --- /dev/null +++ b/Events-MVC/Events.MVC/wwwroot/js/pager.js @@ -0,0 +1,61 @@ +(function () { + function validRange(value, min, max) { + if (!/^\d+$/.test(value)) { + return false; + } + + var page = parseInt(value, 10); + return page >= min && page <= max; + } + + function goToPage(input) { + var value = input.value.trim(); + var min = parseInt(input.dataset.min, 10); + var max = parseInt(input.dataset.max, 10); + + if (!validRange(value, min, max)) { + input.value = input.dataset.current || ""; + return; + } + + var url = (input.dataset.urlTemplate || "").replace("__page__", value); + if (!url) { + return; + } + + var target = input.dataset.target; + var swap = input.dataset.swap || "outerHTML"; + var pushUrl = input.dataset.pushUrl === "true"; + + if (window.htmx && target) { + htmx.ajax("GET", url, { + target: target, + swap: swap, + pushUrl: pushUrl + }); + return; + } + + window.location.href = url; + } + + document.addEventListener("focusin", function (event) { + if (event.target.matches(".pagebox")) { + event.target.select(); + } + }); + + document.addEventListener("keydown", function (event) { + if (!event.target.matches(".pagebox")) { + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + goToPage(event.target); + } + else if (event.key === "Escape") { + event.target.value = event.target.dataset.current || ""; + } + }); +})(); diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/CountriesCrudShould.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/CountriesCrudShould.cs new file mode 100644 index 0000000..97f02d1 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/CountriesCrudShould.cs @@ -0,0 +1,76 @@ +using System.Net; +using Events.Tests.IntegrationTests.Infrastructure; + +namespace Events.Tests.IntegrationTests; + +public class CountriesCrudShould +{ + [Fact] + public async Task CreateCountry() + { + await using var factory = new CustomWebApplicationFactory(); + using var client = factory.CreateClient(); + + var response = await AntiforgeryRequestHelper.PostFormAsync( + client, + "/Countries", + "/Countries/Create", + [ + new("Code", "DE"), + new("Alpha3", "DEU"), + new("Name", "Germany"), + new("Translations[0].LanguageCode", "hr"), + new("Translations[0].Name", "Germany") + ]); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Germany", html); + } + + [Fact] + public async Task EditCountry() + { + await using var factory = new CustomWebApplicationFactory(ctx => ctx.Countries.Add(TestDataSeederCountry())); + using var client = factory.CreateClient(); + + var response = await AntiforgeryRequestHelper.PostFormAsync( + client, + "/Countries", + "/Countries/Edit/HR", + [ + new("Code", "HR"), + new("Alpha3", "HRV"), + new("Name", "Republic of Croatia"), + new("Translations[0].LanguageCode", ""), + new("Translations[0].Name", "") + ]); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Republic of Croatia", html); + } + + [Fact] + public async Task DeleteCountry() + { + await using var factory = new CustomWebApplicationFactory(ctx => ctx.Countries.Add(TestDataSeederCountry())); + using var client = factory.CreateClient(); + + var response = await AntiforgeryRequestHelper.PostFormAsync(client, "/Countries", "/Countries/Delete/HR", []); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.DoesNotContain("Croatia", html); + } + + private static Events.EF.Models.Country TestDataSeederCountry() + { + return new() + { + Code = "HR", + Alpha3 = "HRV", + Name = "Croatia" + }; + } +} diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/Events.Tests.IntegrationTests.csproj b/Events-MVC/Tests/Events.Tests.IntegrationTests/Events.Tests.IntegrationTests.csproj new file mode 100644 index 0000000..d6ed666 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/Events.Tests.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/EventsCrudShould.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/EventsCrudShould.cs new file mode 100644 index 0000000..344ac8a --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/EventsCrudShould.cs @@ -0,0 +1,54 @@ +using System.Net; +using Events.Tests.IntegrationTests.Infrastructure; + +namespace Events.Tests.IntegrationTests; + +public class EventsCrudShould +{ + [Fact] + public async Task CreateEvent() + { + await using var factory = new CustomWebApplicationFactory(); + using var client = factory.CreateClient(); + + var response = await AntiforgeryRequestHelper.PostFormAsync( + client, + "/Events", + "/Events/Create", + [new("Name", "Autumn Cup"), new("EventDate", "2026-09-10")]); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Autumn Cup", html); + } + + [Fact] + public async Task EditEvent() + { + await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedEvents); + using var client = factory.CreateClient(); + + var response = await AntiforgeryRequestHelper.PostFormAsync( + client, + "/Events", + "/Events/Edit/100", + [new("Id", "100"), new("Name", "Updated Games"), new("EventDate", "2026-04-20")]); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Updated Games", html); + } + + [Fact] + public async Task DeleteEvent() + { + await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedEvents); + using var client = factory.CreateClient(); + + var response = await AntiforgeryRequestHelper.PostFormAsync(client, "/Events", "/Events/Delete/100", []); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.DoesNotContain("Spring Games", html); + } +} diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/EventsPageShould.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/EventsPageShould.cs new file mode 100644 index 0000000..6b534d9 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/EventsPageShould.cs @@ -0,0 +1,25 @@ +using System.Net; +using Events.Tests.IntegrationTests.Infrastructure; + +namespace Events.Tests.IntegrationTests; + +public class EventsPageShould +{ + [Fact] + public async Task ShowParticipantsCountAndRegistrationsLinkWhenRegistrationsExist() + { + await using var factory = new CustomWebApplicationFactory(ctx => + { + TestDataSeeder.SeedRegistrationsScenario(ctx); + }); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/Events"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Spring Games", html); + Assert.Contains("/Registrations?eventId=100", html); + Assert.Contains(">2<", html); + } +} diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/GlobalUsings.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/HomePageShould.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/HomePageShould.cs new file mode 100644 index 0000000..c039af9 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/HomePageShould.cs @@ -0,0 +1,20 @@ +using System.Net; +using Events.Tests.IntegrationTests.Infrastructure; + +namespace Events.Tests.IntegrationTests; + +public class HomePageShould +{ + [Fact] + public async Task ReturnSuccessAndContainEnglishDescription() + { + await using var factory = new CustomWebApplicationFactory(); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("This sample demonstrates an ASP.NET Core MVC application", html); + } +} diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/Infrastructure/AntiforgeryRequestHelper.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/Infrastructure/AntiforgeryRequestHelper.cs new file mode 100644 index 0000000..dd30d17 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/Infrastructure/AntiforgeryRequestHelper.cs @@ -0,0 +1,40 @@ +using System.Net.Http.Headers; +using System.Text.RegularExpressions; + +namespace Events.Tests.IntegrationTests.Infrastructure; + +internal static partial class AntiforgeryRequestHelper +{ + private const string AntiforgeryFieldName = "__RequestVerificationToken"; + + public static async Task PostFormAsync( + HttpClient client, + string pageUrl, + string postUrl, + IEnumerable> fields) + { + var pageHtml = await client.GetStringAsync(pageUrl); + var token = ExtractAntiforgeryToken(pageHtml); + + var payload = fields + .Append(new KeyValuePair(AntiforgeryFieldName, token)) + .ToArray(); + + using var request = new HttpRequestMessage(HttpMethod.Post, postUrl) + { + Content = new FormUrlEncodedContent(payload!) + }; + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html")); + return await client.SendAsync(request); + } + + private static string ExtractAntiforgeryToken(string html) + { + var match = AntiforgeryTokenRegex().Match(html); + Assert.True(match.Success, "Expected antiforgery token field was not found in the HTML response."); + return match.Groups["token"].Value; + } + + [GeneratedRegex("]*name=\"__RequestVerificationToken\"[^>]*value=\"(?[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex AntiforgeryTokenRegex(); +} diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/Infrastructure/CustomWebApplicationFactory.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/Infrastructure/CustomWebApplicationFactory.cs new file mode 100644 index 0000000..707c0fd --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/Infrastructure/CustomWebApplicationFactory.cs @@ -0,0 +1,51 @@ +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Events.Tests.IntegrationTests.Infrastructure; + +public class CustomWebApplicationFactory : WebApplicationFactory +{ + private readonly Action? seed; + private readonly InMemoryDatabaseRoot databaseRoot = new(); + private readonly string databaseName = Guid.NewGuid().ToString(); + + public CustomWebApplicationFactory(Action? seed = null) + { + this.seed = seed; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.ConfigureServices(services => + { + services.RemoveAll(typeof(DbContextOptions)); + services.RemoveAll(typeof(IDbContextOptionsConfiguration)); + services.RemoveAll(typeof(EventsContext)); + + services.AddDbContext(options => + options + .UseInMemoryDatabase(databaseName, databaseRoot) + .ConfigureWarnings(warnings => warnings.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning))); + + var serviceProvider = services.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Database.EnsureCreated(); + seed?.Invoke(ctx); + ctx.SaveChanges(); + }); + } +} diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/Infrastructure/TestDataSeeder.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/Infrastructure/TestDataSeeder.cs new file mode 100644 index 0000000..d0b29c6 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/Infrastructure/TestDataSeeder.cs @@ -0,0 +1,140 @@ +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; + +namespace Events.Tests.IntegrationTests.Infrastructure; + +internal static class TestDataSeeder +{ + public static void SeedSports(EventsContext ctx) + { + ctx.Sports.AddRange( + new Sport { Id = 1, Name = "Football" }, + new Sport { Id = 2, Name = "Basketball" }); + } + + public static void SeedPeople(EventsContext ctx) + { + ctx.Countries.Add(new Country + { + Code = "HR", + Alpha3 = "HRV", + Name = "Croatia" + }); + + ctx.People.Add(new Person + { + Id = 1, + FirstName = "Ivan", + LastName = "Horvat", + FirstNameTranscription = "Ivan", + LastNameTranscription = "Horvat", + AddressLine = "Ilica 1", + PostalCode = "10000", + City = "Zagreb", + AddressCountry = "Croatia", + Email = "ivan.horvat@example.com", + ContactPhone = "+38591111222", + BirthDate = new DateOnly(1990, 5, 1), + DocumentNumber = "DOC-1", + CountryCode = "HR" + }); + } + + public static void SeedEvents(EventsContext ctx) + { + ctx.Events.AddRange( + new Event { Id = 100, Name = "Spring Games", EventDate = new DateOnly(2026, 4, 15) }, + new Event { Id = 200, Name = "Summer Cup", EventDate = new DateOnly(2026, 6, 20) }); + } + + public static void SeedRegistrationsScenario(EventsContext ctx) + { + ctx.Countries.AddRange( + new Country + { + Code = "HR", + Alpha3 = "HRV", + Name = "Croatia" + }, + new Country + { + Code = "DE", + Alpha3 = "DEU", + Name = "Germany" + }); + + ctx.People.AddRange( + new Person + { + Id = 1, + FirstName = "Ivan", + LastName = "Horvat", + FirstNameTranscription = "Ivan", + LastNameTranscription = "Horvat", + AddressLine = "Ilica 1", + PostalCode = "10000", + City = "Zagreb", + AddressCountry = "Croatia", + Email = "ivan.horvat@example.com", + ContactPhone = "+38591111222", + BirthDate = new DateOnly(1990, 5, 1), + DocumentNumber = "DOC-1", + CountryCode = "HR" + }, + new Person + { + Id = 2, + FirstName = "Johann", + LastName = "Schmidt", + FirstNameTranscription = "Johann", + LastNameTranscription = "Schmidt", + AddressLine = "Unter den Linden 5", + PostalCode = "10117", + City = "Berlin", + AddressCountry = "Germany", + Email = "johann.schmidt@example.com", + ContactPhone = "+49170111222", + BirthDate = new DateOnly(1988, 3, 12), + DocumentNumber = "DOC-2", + CountryCode = "DE" + }); + + ctx.Sports.AddRange( + new Sport { Id = 10, Name = "Football" }, + new Sport { Id = 20, Name = "Basketball" }); + + ctx.Events.AddRange( + new Event { Id = 100, Name = "Spring Games", EventDate = new DateOnly(2026, 4, 15) }, + new Event { Id = 200, Name = "Summer Cup", EventDate = new DateOnly(2026, 6, 20) }); + + ctx.Registrations.AddRange( + new Registration + { + Id = 1000, + EventId = 100, + PersonId = 1, + SportId = 10, + RegisteredAt = new DateTime(2026, 3, 1, 9, 30, 0, DateTimeKind.Utc) + }, + new Registration + { + Id = 1001, + EventId = 100, + PersonId = 2, + SportId = 20, + RegisteredAt = new DateTime(2026, 3, 2, 10, 0, 0, DateTimeKind.Utc) + }, + new Registration + { + Id = 1002, + EventId = 200, + PersonId = 1, + SportId = 20, + RegisteredAt = new DateTime(2026, 3, 3, 11, 0, 0, DateTimeKind.Utc) + }); + } +} diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/PeopleCrudShould.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/PeopleCrudShould.cs new file mode 100644 index 0000000..ed11d96 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/PeopleCrudShould.cs @@ -0,0 +1,89 @@ +using System.Net; +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.Tests.IntegrationTests.Infrastructure; + +namespace Events.Tests.IntegrationTests; + +public class PeopleCrudShould +{ + [Fact] + public async Task CreatePerson() + { + await using var factory = new CustomWebApplicationFactory(ctx => ctx.Countries.Add(new Country { Code = "HR", Alpha3 = "HRV", Name = "Croatia" })); + using var client = factory.CreateClient(); + + var response = await AntiforgeryRequestHelper.PostFormAsync( + client, + "/People", + "/People/Create", + [ + new("FirstName", "Ana"), + new("LastName", "Kovac"), + new("FirstNameTranscription", "Ana"), + new("LastNameTranscription", "Kovac"), + new("Email", "ana.kovac@example.com"), + new("ContactPhone", "+38591123456"), + new("BirthDate", "1995-01-01"), + new("CountryCode", "HR"), + new("DocumentNumber", "DOC-2"), + new("AddressLine", "Main Street 1"), + new("PostalCode", "10000"), + new("City", "Zagreb"), + new("AddressCountry", "Croatia") + ]); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Ana Kovac", html); + } + + [Fact] + public async Task EditPerson() + { + await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedPeople); + using var client = factory.CreateClient(); + + var response = await AntiforgeryRequestHelper.PostFormAsync( + client, + "/People", + "/People/Edit/1", + [ + new("Id", "1"), + new("FirstName", "Ivan"), + new("LastName", "Kovac"), + new("FirstNameTranscription", "Ivan"), + new("LastNameTranscription", "Kovac"), + new("Email", "ivan.kovac@example.com"), + new("ContactPhone", "+38591111222"), + new("BirthDate", "1990-05-01"), + new("CountryCode", "HR"), + new("DocumentNumber", "DOC-1"), + new("AddressLine", "Ilica 1"), + new("PostalCode", "10000"), + new("City", "Zagreb"), + new("AddressCountry", "Croatia") + ]); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Ivan Kovac", html); + } + + [Fact] + public async Task DeletePerson() + { + await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedPeople); + using var client = factory.CreateClient(); + + var response = await AntiforgeryRequestHelper.PostFormAsync(client, "/People", "/People/Delete/1", []); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.DoesNotContain("Ivan Horvat", html); + } +} diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/PeopleGuardShould.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/PeopleGuardShould.cs new file mode 100644 index 0000000..8d16e0d --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/PeopleGuardShould.cs @@ -0,0 +1,23 @@ +using System.Net; +using Events.Tests.IntegrationTests.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Events.Tests.IntegrationTests; + +public class PeopleGuardShould +{ + [Fact] + public async Task RedirectToCountriesWhenPeoplePageIsRequestedWithoutCountries() + { + await using var factory = new CustomWebApplicationFactory(); + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + var response = await client.GetAsync("/People"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/Countries", response.Headers.Location?.OriginalString); + } +} diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/PeoplePageShould.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/PeoplePageShould.cs new file mode 100644 index 0000000..e90f78d --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/PeoplePageShould.cs @@ -0,0 +1,74 @@ +using System.Net; +using Events.Tests.IntegrationTests.Infrastructure; + +namespace Events.Tests.IntegrationTests; + +public class PeoplePageShould +{ + [Fact] + public async Task ReturnPageWithPeopleListWhenCountryAndPersonExist() + { + await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedPeople); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/People"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("People list", html); + Assert.Contains("First name", html); + Assert.Contains("Last name", html); + Assert.Contains("Ivan Horvat", html); + Assert.Contains("Croatia", html); + } + + [Fact] + public async Task RenderNativeFullNameAndTranscribedNamesInSeparateColumns() + { + await using var factory = new CustomWebApplicationFactory(ctx => + { + ctx.Countries.Add(new Events.EF.Models.Country + { + Code = "UA", + Alpha3 = "UKR", + Name = "Ukraine" + }); + + ctx.People.Add(new Events.EF.Models.Person + { + Id = 1, + FirstName = "Олексій", + LastName = "Шевченко", + FirstNameTranscription = "Oleksii", + LastNameTranscription = "Shevchenko", + AddressLine = "Khreshchatyk 1", + PostalCode = "01001", + City = "Kyiv", + AddressCountry = "Ukraine", + Email = "oleksii.shevchenko@example.com", + ContactPhone = "+38050111222", + BirthDate = new DateOnly(1991, 6, 1), + DocumentNumber = "DOC-1", + CountryCode = "UA" + }); + }); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/People"); + var html = await response.Content.ReadAsStringAsync(); + var decodedHtml = WebUtility.HtmlDecode(html); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("", decodedHtml); + Assert.Contains("Олексій Шевченко", decodedHtml); + Assert.Contains("Oleksii", decodedHtml); + Assert.Contains("Shevchenko", decodedHtml); + + var firstNameColumnIndex = decodedHtml.IndexOf("Oleksii", StringComparison.Ordinal); + var lastNameColumnIndex = decodedHtml.IndexOf("Shevchenko", StringComparison.Ordinal); + var nativeFullNameColumnIndex = decodedHtml.IndexOf("Олексій Шевченко", StringComparison.Ordinal); + + Assert.True(firstNameColumnIndex < lastNameColumnIndex); + Assert.True(lastNameColumnIndex < nativeFullNameColumnIndex); + } +} diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/RegistrationsCrudShould.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/RegistrationsCrudShould.cs new file mode 100644 index 0000000..275ce98 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/RegistrationsCrudShould.cs @@ -0,0 +1,61 @@ +using System.Net; +using Events.Tests.IntegrationTests.Infrastructure; + +namespace Events.Tests.IntegrationTests; + +public class RegistrationsCrudShould +{ + [Fact] + public async Task CreateRegistration() + { + await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedRegistrationsScenario); + using var client = factory.CreateClient(); + + var response = await AntiforgeryRequestHelper.PostFormAsync( + client, + "/Registrations?eventId=100", + "/Registrations/Create", + [new("EventId", "100"), new("PersonId", "1"), new("SportId", "20"), new("PersonLookup", "Ivan Horvat")]); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Basketball", html); + } + + [Fact] + public async Task EditRegistration() + { + await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedRegistrationsScenario); + using var client = factory.CreateClient(); + + var response = await AntiforgeryRequestHelper.PostFormAsync( + client, + "/Registrations?eventId=100", + "/Registrations/Edit/1000", + [ + new("Id", "1000"), + new("EventId", "100"), + new("PersonId", "2"), + new("SportId", "20"), + new("PersonLookup", "Johann Schmidt") + ]); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Johann Schmidt", html); + Assert.Contains("Basketball", html); + } + + [Fact] + public async Task DeleteRegistration() + { + await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedRegistrationsScenario); + using var client = factory.CreateClient(); + + var response = await AntiforgeryRequestHelper.PostFormAsync(client, "/Registrations?eventId=100", "/Registrations/Delete/1000?eventId=100", []); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.DoesNotContain(">1000<", html); + } +} diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/RegistrationsGuardShould.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/RegistrationsGuardShould.cs new file mode 100644 index 0000000..6732eb0 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/RegistrationsGuardShould.cs @@ -0,0 +1,84 @@ +using System.Net; +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.Tests.IntegrationTests.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Events.Tests.IntegrationTests; + +public class RegistrationsGuardShould +{ + [Fact] + public async Task RedirectToSportsWhenRegistrationsPageIsRequestedWithoutSports() + { + await using var factory = new CustomWebApplicationFactory(ctx => + { + ctx.Countries.Add(new Country + { + Code = "HR", + Alpha3 = "HRV", + Name = "Croatia" + }); + ctx.People.Add(new Person + { + Id = 1, + FirstName = "Ivan", + LastName = "Horvat", + FirstNameTranscription = "Ivan", + LastNameTranscription = "Horvat", + AddressLine = "Ilica 1", + PostalCode = "10000", + City = "Zagreb", + AddressCountry = "Croatia", + Email = "ivan.horvat@example.com", + ContactPhone = "+38591111222", + BirthDate = new DateOnly(1990, 5, 1), + DocumentNumber = "DOC-1", + CountryCode = "HR" + }); + ctx.Events.Add(new Event + { + Id = 100, + Name = "Spring Games", + EventDate = new DateOnly(2026, 4, 15) + }); + }); + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + var response = await client.GetAsync("/Registrations"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/Sports", response.Headers.Location?.OriginalString); + } + + [Fact] + public async Task RedirectToPeopleWhenRegistrationsPageIsRequestedWithoutPeople() + { + await using var factory = new CustomWebApplicationFactory(ctx => + { + ctx.Sports.Add(new Sport { Id = 10, Name = "Football" }); + ctx.Events.Add(new Event + { + Id = 100, + Name = "Spring Games", + EventDate = new DateOnly(2026, 4, 15) + }); + }); + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + var response = await client.GetAsync("/Registrations"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/People", response.Headers.Location?.OriginalString); + } +} diff --git a/Events-MVC/Tests/Events.Tests.IntegrationTests/RegistrationsPageShould.cs b/Events-MVC/Tests/Events.Tests.IntegrationTests/RegistrationsPageShould.cs new file mode 100644 index 0000000..a614ca0 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.IntegrationTests/RegistrationsPageShould.cs @@ -0,0 +1,57 @@ +using System.Net; +using Events.Tests.IntegrationTests.Infrastructure; + +namespace Events.Tests.IntegrationTests; + +public class RegistrationsPageShould +{ + [Fact] + public async Task ReturnEventSpecificRegistrationsForSelectedEvent() + { + await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedRegistrationsScenario); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/Registrations?eventId=100"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Registrations for: Spring Games", html); + Assert.Contains("Ivan Horvat", html); + Assert.Contains("Johann Schmidt", html); + Assert.Contains("Croatia", html); + Assert.Contains("Germany", html); + } + + [Fact] + public async Task ReturnFilteredPartialForHtmxRequestWithCountryFilter() + { + await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedRegistrationsScenario); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add(Events.MVC.Constants.HtmxHeaders.Request, "true"); + + var response = await client.GetAsync("/Registrations?eventId=100&filters=CountryCode==HR"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("id=\"registrations-panel\"", html); + Assert.Contains("Ivan Horvat", html); + Assert.DoesNotContain("Johann Schmidt", html); + Assert.DoesNotContain(" + + + net10.0 + enable + enable + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/Events-MVC/Tests/Events.Tests.UITests/GlobalUsings.cs b/Events-MVC/Tests/Events.Tests.UITests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UITests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/Events-MVC/Tests/Events.Tests.UITests/HomeAndSportsPageTests.cs b/Events-MVC/Tests/Events.Tests.UITests/HomeAndSportsPageTests.cs new file mode 100644 index 0000000..98770ac --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UITests/HomeAndSportsPageTests.cs @@ -0,0 +1,77 @@ +using Events.Tests.UITests.Infrastructure; +using Microsoft.Playwright; + +namespace Events.Tests.UITests; + +public class HomeAndSportsPageTests +{ + [Fact] + public async Task HomePageShouldDisplayEnglishDescription() + { + await using var harness = await UiTestHarness.CreateAsync(); + + await harness.Page.GotoAsync($"{harness.RootUrl}/"); + + await Assertions.Expect(harness.Page.GetByRole(AriaRole.Heading, new() { Name = "Events" })).ToBeVisibleAsync(); + await Assertions.Expect(harness.Page.GetByText("This sample demonstrates an ASP.NET Core MVC application")).ToBeVisibleAsync(); + await Assertions.Expect(harness.Page.GetByRole(AriaRole.Link, new() { Name = "Sports" })).ToBeVisibleAsync(); + } + + [Fact] + public async Task SportsPageShouldDisplayExistingSportsAndAllowOpeningCreatePanel() + { + await using var harness = await UiTestHarness.CreateAsync(); + var expectedSports = new[] { "Running", "Chess", "Swimming" }; + + await harness.Page.GotoAsync($"{harness.RootUrl}/Sports"); + + await Assertions.Expect(harness.Page.GetByRole(AriaRole.Heading, new() { Name = "Sports list" })).ToBeVisibleAsync(); + await Assertions.Expect(harness.Page.Locator("#sports-table-body tr").First).ToBeVisibleAsync(); + await Assertions.Expect(harness.Page.GetByPlaceholder("Search by sport name")).ToBeVisibleAsync(); + foreach (var expectedSport in expectedSports) + { + await Assertions.Expect(harness.Page.Locator("#sports-list")).ToContainTextAsync(expectedSport); + } + + await harness.Page.GetByRole(AriaRole.Button, new() { Name = "New sport" }).ClickAsync(); + + await Assertions.Expect(harness.Page.GetByRole(AriaRole.Heading, new() { Name = "Add a new sport" })).ToBeVisibleAsync(); + await Assertions.Expect( + harness.Page.Locator("#create-sport-form").GetByLabel("Name", new() { Exact = true })) + .ToBeVisibleAsync(); + } + + [Fact] + public async Task SportsPageShouldCreateSportAndShowSuccessToast() + { + await using var harness = await UiTestHarness.CreateAsync(); + var sportName = $"UI Test Sport {Guid.NewGuid():N}"; + + await harness.Page.GotoAsync($"{harness.RootUrl}/Sports"); + await harness.Page.GetByRole(AriaRole.Button, new() { Name = "New sport" }).ClickAsync(); + + var createForm = harness.Page.Locator("#create-sport-form"); + await createForm.GetByLabel("Name", new() { Exact = true }).FillAsync(sportName); + await createForm.GetByRole(AriaRole.Button, new() { Name = "Add sport" }).ClickAsync(); + + await Assertions.Expect(harness.Page.Locator("#app-toast")).ToBeVisibleAsync(); + await Assertions.Expect(harness.Page.Locator("#app-toast-title")).ToHaveTextAsync("Success"); + await Assertions.Expect(harness.Page.Locator("#app-toast-body")).ToContainTextAsync($"Sport '{sportName}' was added successfully."); + + await harness.Page.GetByPlaceholder("Search by sport name").FillAsync(sportName); + await harness.Page.GetByRole(AriaRole.Button, new() { Name = "Filter" }).ClickAsync(); + + await Assertions.Expect(harness.Page.Locator("#sports-list")).ToContainTextAsync(sportName); + + var sportRow = harness.Page.Locator("#sports-table-body tr").Filter(new() { HasText = sportName }); + await Assertions.Expect(sportRow).ToHaveCountAsync(1); + + harness.Page.Dialog += async (_, dialog) => await dialog.AcceptAsync(); + await sportRow.GetByRole(AriaRole.Button, new() { Name = "Delete" }).ClickAsync(); + + await Assertions.Expect(harness.Page.Locator("#app-toast")).ToBeVisibleAsync(); + await Assertions.Expect(harness.Page.Locator("#app-toast-title")).ToHaveTextAsync("Success"); + await Assertions.Expect(harness.Page.Locator("#app-toast-body")).ToContainTextAsync($"Sport '{sportName}' was deleted successfully."); + await Assertions.Expect(harness.Page.Locator("#sports-list")).Not.ToContainTextAsync(sportName); + } +} diff --git a/Events-MVC/Tests/Events.Tests.UITests/Infrastructure/UiTestHarness.cs b/Events-MVC/Tests/Events.Tests.UITests/Infrastructure/UiTestHarness.cs new file mode 100644 index 0000000..5e5c4e2 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UITests/Infrastructure/UiTestHarness.cs @@ -0,0 +1,196 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Playwright; + +namespace Events.Tests.UITests.Infrastructure; + +internal sealed class UiTestHarness : IAsyncDisposable +{ + private readonly IPlaywright playwright; + private readonly Process appProcess; + private readonly StringBuilder processOutput; + + private UiTestHarness( + IPlaywright playwright, + IBrowser browser, + IBrowserContext browserContext, + IPage page, + Process appProcess, + StringBuilder processOutput, + string rootUrl) + { + this.playwright = playwright; + this.appProcess = appProcess; + this.processOutput = processOutput; + Browser = browser; + BrowserContext = browserContext; + Page = page; + RootUrl = rootUrl; + } + + public IBrowser Browser { get; } + + public IBrowserContext BrowserContext { get; } + + public IPage Page { get; } + + public string RootUrl { get; } + + public static async Task CreateAsync() + { + var port = FindFreePort(); + var rootUrl = $"http://127.0.0.1:{port}"; + var mvcProjectPath = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "..", "..", "..", "..", "..", + "Events.MVC", "Events.MVC.csproj")); + + var processOutput = new StringBuilder(); + var startInfo = new ProcessStartInfo("dotnet", $"run --project \"{mvcProjectPath}\" --urls {rootUrl}") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "UITest"; +#if POSTGRES + startInfo.Environment["ConnectionStrings__EventsPostgres"] = ResolveUiTestConnectionString(); +#else + startInfo.Environment["ConnectionStrings__EventsMssql"] = ResolveUiTestConnectionString(); +#endif + + var appProcess = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start the MVC app process for UI tests."); + + appProcess.OutputDataReceived += (_, args) => + { + if (args.Data is not null) + { + processOutput.AppendLine(args.Data); + } + }; + appProcess.ErrorDataReceived += (_, args) => + { + if (args.Data is not null) + { + processOutput.AppendLine(args.Data); + } + }; + appProcess.BeginOutputReadLine(); + appProcess.BeginErrorReadLine(); + + await WaitForServerAsync(rootUrl, appProcess, processOutput); + + IPlaywright playwright; + try + { + playwright = await Playwright.CreateAsync(); + } + catch + { + await StopProcessAsync(appProcess); + throw; + } + + try + { + var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true + }); + var browserContext = await browser.NewContextAsync(new BrowserNewContextOptions + { + BaseURL = rootUrl + }); + var page = await browserContext.NewPageAsync(); + + return new UiTestHarness(playwright, browser, browserContext, page, appProcess, processOutput, rootUrl); + } + catch (PlaywrightException) + { + await StopProcessAsync(appProcess); + playwright.Dispose(); + throw new InvalidOperationException( + "Playwright browser is not installed. Run 'dotnet tool install --global Microsoft.Playwright.CLI' and then 'playwright install'."); + } + } + + public async ValueTask DisposeAsync() + { + await BrowserContext.DisposeAsync(); + await Browser.DisposeAsync(); + playwright.Dispose(); + await StopProcessAsync(appProcess); + } + + private static int FindFreePort() + { + using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + } + + private static async Task WaitForServerAsync(string rootUrl, Process appProcess, StringBuilder processOutput) + { + using var httpClient = new HttpClient(); + var timeoutAt = DateTime.UtcNow.AddSeconds(30); + + while (DateTime.UtcNow < timeoutAt) + { + if (appProcess.HasExited) + { + throw new InvalidOperationException( + $"MVC app process exited before the UI test server became ready.{Environment.NewLine}{processOutput}"); + } + + try + { + using var response = await httpClient.GetAsync(rootUrl); + if ((int)response.StatusCode < 500) + { + return; + } + } + catch + { + } + + await Task.Delay(250); + } + + throw new InvalidOperationException( + $"Timed out while waiting for the UI test server at {rootUrl}.{Environment.NewLine}{processOutput}"); + } + + private static string ResolveUiTestConnectionString() + { + var configuration = new ConfigurationBuilder() + .AddUserSecrets(optional: true) + .Build(); + + var connectionString = configuration.GetConnectionString("EventDB-Test"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + "The EventDB-Test connection string must be available so UI tests can connect to the selected provider's test database."); + } + + return connectionString; + } + + private static async Task StopProcessAsync(Process appProcess) + { + if (appProcess.HasExited) + { + appProcess.Dispose(); + return; + } + + appProcess.Kill(entireProcessTree: true); + await appProcess.WaitForExitAsync(); + appProcess.Dispose(); + } +} diff --git a/Events-MVC/Tests/Events.Tests.UITests/README.md b/Events-MVC/Tests/Events.Tests.UITests/README.md new file mode 100644 index 0000000..ff07809 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UITests/README.md @@ -0,0 +1,43 @@ +# Events.Tests.UITests + +This project contains Playwright-based UI tests for `Events-MVC`. + +## Prerequisites + +- .NET SDK 10.0 +- Playwright CLI +- Playwright browser binaries + +## Playwright Installation + +Install the Playwright CLI once: + +```powershell +dotnet tool install --global Microsoft.Playwright.CLI +``` + +Install browser binaries: + +```powershell +playwright install +``` + +## Running the UI Tests + +Run the full UI test project: + +```powershell +dotnet test Events-MVC\Tests\Events.Tests.UITests\Events.Tests.UITests.csproj +``` + +Run a single test: + +```powershell +dotnet test Events-MVC\Tests\Events.Tests.UITests\Events.Tests.UITests.csproj --filter HomeAndSportsPageTests.HomePageShouldDisplayEnglishDescription +``` + +## Notes + +- The UI test harness starts the MVC application automatically +- UI tests connect the MVC application to the selected provider's test database from `ConnectionStrings:EventDB-Test` +- The browser is currently configured in headless mode diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/CountriesControllerShould.cs b/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/CountriesControllerShould.cs new file mode 100644 index 0000000..22ace6a --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/CountriesControllerShould.cs @@ -0,0 +1,120 @@ +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.MVC.Controllers; +using Events.MVC.Models.Countries; +using Events.Tests.UnitTests.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Events.Tests.UnitTests.Controllers; + +public class CountriesControllerShould +{ + [Fact] + public async Task ReturnPartialViewWithExpectedViewModelForExistingRow() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Row("HR"); + + var partial = Assert.IsType(result); + Assert.Equal("_CountryRow", partial.ViewName); + Assert.Equal("Croatia", Assert.IsType(partial.Model).Name); + } + + [Fact] + public async Task CreateCountry() + { + await using var ctx = ControllerTestContext.CreateContext(); + var controller = CreateController(ctx); + + var result = await controller.Create( + new CountryViewModel + { + Code = "de", + Alpha3 = "deu", + Name = "Germany", + Translations = + [ + new CountryTranslationViewModel { LanguageCode = "hr", Name = "Germany" } + ] + }, + ControllerTestContext.EmptySieveModel()); + + var partial = Assert.IsType(result); + Assert.Equal("_CountriesList", partial.ViewName); + var country = await ctx.Countries.SingleAsync(); + Assert.Equal("DE", country.Code); + Assert.Equal("Germany", country.Name); + Assert.Contains("was added successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString()); + } + + [Fact] + public async Task EditCountry() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Edit("HR", new CountryViewModel + { + Code = "HR", + Alpha3 = "HRV", + Name = "Republic of Croatia", + Translations = [] + }); + + var partial = Assert.IsType(result); + Assert.Equal("_CountryRow", partial.ViewName); + Assert.Equal("Republic of Croatia", (await ctx.Countries.SingleAsync()).Name); + } + + [Fact] + public async Task DeleteCountry() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + var result = await controller.Delete("HR", ControllerTestContext.EmptySieveModel()); + + var partial = Assert.IsType(result); + Assert.Equal("_CountriesList", partial.ViewName); + Assert.Empty(ctx.Countries); + Assert.Contains("was deleted successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString()); + } + + [Fact] + public async Task ReturnConflictWhenDeletingCountryWithRelatedPeople() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + ctx.People.Add(ControllerTestContext.CreatePerson()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + var result = await controller.Delete("HR", ControllerTestContext.EmptySieveModel()); + + var content = Assert.IsType(result); + Assert.Equal(409, controller.Response.StatusCode); + Assert.Equal("The country cannot be deleted because related people exist.", content.Content); + } + + private static CountriesController CreateController(EventsContext ctx, bool useSieve = true) + { + return new CountriesController( + ctx, + useSieve ? ControllerTestContext.CreateSieveProcessor() : null!, + ControllerTestContext.CreatePagingOptions()) + .WithTempData(); + } +} diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/EventsControllerShould.cs b/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/EventsControllerShould.cs new file mode 100644 index 0000000..85f6ae6 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/EventsControllerShould.cs @@ -0,0 +1,106 @@ +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.MVC.Controllers; +using Events.MVC.Models.Events; +using Events.Tests.UnitTests.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Events.Tests.UnitTests.Controllers; + +public class EventsControllerShould +{ + [Fact] + public async Task ReturnPartialViewWithExpectedViewModelForExistingRow() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Events.Add(ControllerTestContext.CreateEvent()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Row(100); + + var partial = Assert.IsType(result); + Assert.Equal("_EventRow", partial.ViewName); + Assert.Equal("Spring Games", Assert.IsType(partial.Model).Name); + } + + [Fact] + public async Task CreateEvent() + { + await using var ctx = ControllerTestContext.CreateContext(); + var controller = CreateController(ctx); + + var result = await controller.Create( + new EventViewModel { Name = "Autumn Cup", EventDate = new DateOnly(2026, 9, 10) }, + ControllerTestContext.EmptySieveModel()); + + var partial = Assert.IsType(result); + Assert.Equal("_EventsList", partial.ViewName); + Assert.Contains(ctx.Events, e => e.Name == "Autumn Cup"); + Assert.Contains("was added successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString()); + } + + [Fact] + public async Task EditEvent() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Events.Add(ControllerTestContext.CreateEvent()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Edit(100, new EventViewModel { Id = 100, Name = "Updated Games", EventDate = new DateOnly(2026, 5, 20) }); + + var partial = Assert.IsType(result); + Assert.Equal("_EventRow", partial.ViewName); + Assert.Equal("Updated Games", (await ctx.Events.SingleAsync()).Name); + } + + [Fact] + public async Task DeleteEvent() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Events.Add(ControllerTestContext.CreateEvent()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + var result = await controller.Delete(100, ControllerTestContext.EmptySieveModel()); + + var partial = Assert.IsType(result); + Assert.Equal("_EventsList", partial.ViewName); + Assert.Empty(ctx.Events); + Assert.Contains("was deleted successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString()); + } + + [Fact] + public async Task ReturnConflictWhenDeletingEventWithRegistrations() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + ctx.People.Add(ControllerTestContext.CreatePerson()); + ctx.Events.Add(ControllerTestContext.CreateEvent()); + ctx.Sports.Add(ControllerTestContext.CreateSport()); + ctx.Registrations.Add(new Registration { Id = 1000, EventId = 100, PersonId = 1, SportId = 10 }); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + var result = await controller.Delete(100, ControllerTestContext.EmptySieveModel()); + + var content = Assert.IsType(result); + Assert.Equal(409, controller.Response.StatusCode); + Assert.Equal("The event cannot be deleted because registrations exist.", content.Content); + } + + private static EventsController CreateController(EventsContext ctx, bool useSieve = true) + { + return new EventsController( + ctx, + useSieve ? ControllerTestContext.CreateSieveProcessor() : null!, + ControllerTestContext.CreatePagingOptions()) + .WithTempData(); + } +} diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/PeopleControllerShould.cs b/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/PeopleControllerShould.cs new file mode 100644 index 0000000..e27ced9 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/PeopleControllerShould.cs @@ -0,0 +1,246 @@ +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.MVC.Controllers; +using Events.MVC.Models; +using Events.MVC.Models.People; +using Events.Tests.UnitTests.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Sieve.Models; + +namespace Events.Tests.UnitTests.Controllers; + +public class PeopleControllerShould +{ + [Fact] + public async Task RedirectToCountriesAndSetToastWhenIndexIsRequestedWithoutCountries() + { + await using var ctx = ControllerTestContext.CreateContext(); + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Index(new SieveModel()); + + var redirect = Assert.IsType(result); + Assert.Equal("Index", redirect.ActionName); + Assert.Equal("Countries", redirect.ControllerName); + Assert.Equal(Events.MVC.Constants.Messages.CountriesRequiredForPeople, controller.TempData[Events.MVC.Constants.TempDataKeys.ToastMessage]); + } + + [Fact] + public async Task CreatePerson() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + var result = await controller.Create( + new PersonViewModel + { + FirstName = "Ana", + LastName = "Kovac", + FirstNameTranscription = "Ana", + LastNameTranscription = "Kovac", + AddressLine = "Main Street 1", + PostalCode = "10000", + City = "Zagreb", + AddressCountry = "Croatia", + Email = "ana.kovac@example.com", + ContactPhone = "+38591123456", + BirthDate = new DateOnly(1995, 1, 1), + DocumentNumber = "DOC-2", + CountryCode = "hr" + }, + ControllerTestContext.EmptySieveModel()); + + var partial = Assert.IsType(result); + Assert.Equal("_PeopleList", partial.ViewName); + var model = Assert.IsType(partial.Model); + Assert.Contains(model.People.Data, p => p.FullName == "Ana Kovac" && p.CountryName == "Croatia"); + Assert.Contains("was added successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString()); + } + + [Fact] + public async Task ReturnPartialViewWithExpectedPeopleListForPersonAddedBeforeIndexRead() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + await controller.Create( + new PersonViewModel + { + FirstName = "Ana", + LastName = "Kovac", + FirstNameTranscription = "Ana", + LastNameTranscription = "Kovac", + AddressLine = "Main Street 1", + PostalCode = "10000", + City = "Zagreb", + AddressCountry = "Croatia", + Email = "ana.kovac@example.com", + ContactPhone = "+38591123456", + BirthDate = new DateOnly(1995, 1, 1), + DocumentNumber = "DOC-2", + CountryCode = "hr" + }, + ControllerTestContext.EmptySieveModel()); + + controller.Request.Headers[Events.MVC.Constants.HtmxHeaders.Request] = "true"; + + var result = await controller.Index(new SieveModel + { + Filters = "FirstName==Ana" + }); + + var partial = Assert.IsType(result); + Assert.Equal("_PeopleList", partial.ViewName); + var model = Assert.IsType(partial.Model); + var person = Assert.Single(model.People.Data); + Assert.Equal("Ana Kovac", person.FullName); + Assert.Equal("Croatia", person.CountryName); + } + + [Fact] + public async Task EditPerson() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + ctx.People.Add(ControllerTestContext.CreatePerson()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Edit(1, new PersonViewModel + { + Id = 1, + FirstName = "Ivan", + LastName = "Kovac", + FirstNameTranscription = "Ivan", + LastNameTranscription = "Kovac", + AddressLine = "Updated Street 2", + PostalCode = "10000", + City = "Zagreb", + AddressCountry = "Croatia", + Email = "ivan.kovac@example.com", + ContactPhone = "+38591111222", + BirthDate = new DateOnly(1990, 5, 1), + DocumentNumber = "DOC-1", + CountryCode = "HR" + }); + + var partial = Assert.IsType(result); + Assert.Equal("_PersonRow", partial.ViewName); + Assert.Equal("Kovac", (await ctx.People.SingleAsync()).LastName); + } + + [Fact] + public async Task DeletePerson() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + ctx.People.Add(ControllerTestContext.CreatePerson()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + var result = await controller.Delete(1, ControllerTestContext.EmptySieveModel()); + + var partial = Assert.IsType(result); + Assert.Equal("_PeopleList", partial.ViewName); + Assert.Empty(ctx.People); + Assert.Contains("was deleted successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString()); + } + + [Fact] + public async Task FilterPeopleByTranscribedFullName() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + ctx.People.AddRange( + ControllerTestContext.CreatePerson(id: 1, firstName: "Ђорђе", lastName: "Петровић"), + ControllerTestContext.CreatePerson(id: 2, firstName: "Ana", lastName: "Kovac")); + await ctx.SaveChangesAsync(); + + var person = await ctx.People.SingleAsync(p => p.Id == 1); + person.FirstNameTranscription = "Djordje"; + person.LastNameTranscription = "Petrovic"; + await ctx.SaveChangesAsync(); + + var controller = CreateController(ctx); + + var result = await controller.Index(new SieveModel + { + Filters = "FullNameTranscription@=*Petrovic" + }); + + var view = Assert.IsType(result); + var model = Assert.IsType(view.Model); + var people = Assert.Single(model.People.Data); + Assert.Equal(1, people.Id); + Assert.Equal("Ђорђе Петровић", people.FullName); + } + + [Fact] + public async Task FilterPeopleByCountry() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.AddRange( + ControllerTestContext.CreateCountry(), + ControllerTestContext.CreateCountry(code: "SI", alpha3: "SVN", name: "Slovenia")); + ctx.People.AddRange( + ControllerTestContext.CreatePerson(id: 1, countryCode: "HR", firstName: "Ivan", lastName: "Horvat"), + ControllerTestContext.CreatePerson(id: 2, countryCode: "SI", firstName: "Ana", lastName: "Novak")); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + var result = await controller.Index(new SieveModel + { + Filters = "CountryCode==SI" + }); + + var view = Assert.IsType(result); + var model = Assert.IsType(view.Model); + var person = Assert.Single(model.People.Data); + Assert.Equal("Ana Novak", person.FullName); + Assert.Equal("SI", model.CountryFilter); + Assert.Contains(model.CountryOptions, option => option.Value == "SI" && option.Selected); + } + + [Fact] + public async Task ReturnConflictWhenDeletingPersonWithRegistrations() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + ctx.People.Add(ControllerTestContext.CreatePerson()); + ctx.Events.Add(ControllerTestContext.CreateEvent()); + ctx.Sports.Add(ControllerTestContext.CreateSport()); + ctx.Registrations.Add(new Registration + { + Id = 1000, + EventId = 100, + PersonId = 1, + SportId = 10 + }); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + var result = await controller.Delete(1, ControllerTestContext.EmptySieveModel()); + + var content = Assert.IsType(result); + Assert.Equal(409, controller.Response.StatusCode); + Assert.Equal("The person cannot be deleted because registrations exist.", content.Content); + } + + private static PeopleController CreateController(EventsContext ctx, bool useSieve = true) + { + return new PeopleController( + ctx, + useSieve ? ControllerTestContext.CreateSieveProcessor() : null!, + ControllerTestContext.CreatePagingOptions()) + .WithTempData(); + } +} diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/RegistrationsControllerShould.cs b/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/RegistrationsControllerShould.cs new file mode 100644 index 0000000..7589085 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/RegistrationsControllerShould.cs @@ -0,0 +1,157 @@ +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.MVC.Controllers; +using Events.MVC.Models.Registrations; +using Events.Tests.UnitTests.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Sieve.Models; + +namespace Events.Tests.UnitTests.Controllers; + +public class RegistrationsControllerShould +{ + [Fact] + public async Task RedirectToEventsWhenIndexIsRequestedWithoutEvents() + { + await using var ctx = ControllerTestContext.CreateContext(); + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Index(null, new SieveModel()); + + var redirect = Assert.IsType(result); + Assert.Equal("Events", redirect.ControllerName); + Assert.Equal(Events.MVC.Constants.Messages.EventsRequiredForRegistrations, controller.TempData[Events.MVC.Constants.TempDataKeys.ToastMessage]); + } + + [Fact] + public async Task RedirectToSportsWhenIndexIsRequestedWithoutSports() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Events.Add(new Event { Id = 1, Name = "Event 1", EventDate = new DateOnly(2026, 3, 23) }); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Index(1, new SieveModel()); + + var redirect = Assert.IsType(result); + Assert.Equal("Sports", redirect.ControllerName); + Assert.Equal(Events.MVC.Constants.Messages.SportsRequiredForRegistrations, controller.TempData[Events.MVC.Constants.TempDataKeys.ToastMessage]); + } + + [Fact] + public async Task RedirectToPeopleWhenIndexIsRequestedWithoutPeople() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Events.Add(new Event { Id = 1, Name = "Event 1", EventDate = new DateOnly(2026, 3, 23) }); + ctx.Sports.Add(new Sport { Id = 1, Name = "Football" }); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Index(1, new SieveModel()); + + var redirect = Assert.IsType(result); + Assert.Equal("People", redirect.ControllerName); + Assert.Equal(Events.MVC.Constants.Messages.PeopleRequiredForRegistrations, controller.TempData[Events.MVC.Constants.TempDataKeys.ToastMessage]); + } + + [Fact] + public async Task CreateRegistration() + { + await using var ctx = ControllerTestContext.CreateContext(); + SeedRegistrationDependencies(ctx); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + var result = await controller.Create( + new RegistrationViewModel + { + EventId = 100, + PersonId = 1, + SportId = 10 + }, + ControllerTestContext.EmptySieveModel()); + + var partial = Assert.IsType(result); + Assert.Equal("_RegistrationsPanel", partial.ViewName); + Assert.Single(ctx.Registrations); + Assert.Contains("Registration was added successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString()); + } + + [Fact] + public async Task EditRegistration() + { + await using var ctx = ControllerTestContext.CreateContext(); + SeedRegistrationDependencies(ctx); + ctx.People.Add(ControllerTestContext.CreatePerson(id: 2, firstName: "Ana", lastName: "Kovac")); + ctx.Sports.Add(ControllerTestContext.CreateSport(id: 20, name: "Volleyball")); + ctx.Registrations.Add(new Registration { Id = 1000, EventId = 100, PersonId = 1, SportId = 10 }); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Edit(1000, new RegistrationViewModel + { + Id = 1000, + EventId = 100, + PersonId = 2, + SportId = 20 + }); + + var partial = Assert.IsType(result); + Assert.Equal("_RegistrationRow", partial.ViewName); + var registration = await ctx.Registrations.SingleAsync(); + Assert.Equal(2, registration.PersonId); + Assert.Equal(20, registration.SportId); + } + + [Fact] + public async Task DeleteRegistration() + { + await using var ctx = ControllerTestContext.CreateContext(); + SeedRegistrationDependencies(ctx); + ctx.Registrations.Add(new Registration { Id = 1000, EventId = 100, PersonId = 1, SportId = 10 }); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + var result = await controller.Delete(1000, 100, ControllerTestContext.EmptySieveModel()); + + var partial = Assert.IsType(result); + Assert.Equal("_RegistrationsPanel", partial.ViewName); + Assert.Empty(ctx.Registrations); + Assert.Contains("Registration was deleted successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString()); + } + + [Fact] + public async Task ReturnConflictWhenCreatingRegistrationWithoutDependencies() + { + await using var ctx = ControllerTestContext.CreateContext(); + var controller = CreateController(ctx); + + var result = await controller.Create(new RegistrationViewModel { EventId = 100, PersonId = 1, SportId = 10 }, ControllerTestContext.EmptySieveModel()); + + var content = Assert.IsType(result); + Assert.Equal(409, controller.Response.StatusCode); + Assert.Equal("At least one event, one person, and one sport are required before adding registrations.", content.Content); + } + + private static RegistrationsController CreateController(EventsContext ctx, bool useSieve = true) + { + return new RegistrationsController( + ctx, + useSieve ? ControllerTestContext.CreateSieveProcessor() : null!, + ControllerTestContext.CreatePagingOptions()) + .WithTempData(); + } + + private static void SeedRegistrationDependencies(EventsContext ctx) + { + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + ctx.People.Add(ControllerTestContext.CreatePerson()); + ctx.Events.Add(ControllerTestContext.CreateEvent()); + ctx.Sports.Add(ControllerTestContext.CreateSport()); + } +} diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/SportsControllerShould.cs b/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/SportsControllerShould.cs new file mode 100644 index 0000000..ad5d01c --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/Controllers/SportsControllerShould.cs @@ -0,0 +1,184 @@ +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.MVC.Controllers; +using Events.MVC.Models; +using Events.MVC.Models.Sports; +using Events.Tests.UnitTests.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Moq; +using Sieve.Models; + +namespace Events.Tests.UnitTests.Controllers; + +public class SportsControllerShould +{ + [Fact] + public async Task ReturnPartialViewWithExpectedViewModelForExistingRow() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Sports.Add(new Sport { Id = 5, Name = "Basketball" }); + await ctx.SaveChangesAsync(); + + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Row(5); + + var partial = Assert.IsType(result); + Assert.Equal("_SportRow", partial.ViewName); + var model = Assert.IsType(partial.Model); + Assert.Equal(5, model.Id); + Assert.Equal("Basketball", model.Name); + } + + [Fact] + public async Task ReturnNotFoundForMissingRow() + { + await using var ctx = ControllerTestContext.CreateContext(); + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Row(404); + + Assert.IsType(result); + } + + [Fact] + public async Task CreateSport() + { + await using var ctx = ControllerTestContext.CreateContext(); + var controller = CreateController(ctx); + + var result = await controller.Create(new SportViewModel { Name = "Volleyball" }, ControllerTestContext.EmptySieveModel()); + + var partial = Assert.IsType(result); + Assert.Equal("_SportsList", partial.ViewName); + Assert.Contains(ctx.Sports, s => s.Name == "Volleyball"); + Assert.Contains("was added successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString()); + } + + [Fact] + public async Task ReturnPagedSportsWhenIndexIsRequestedUsingMockedPagingOptions() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Sports.AddRange( + new Sport { Id = 1, Name = "Athletics" }, + new Sport { Id = 2, Name = "Basketball" }, + new Sport { Id = 3, Name = "Cycling" }); + await ctx.SaveChangesAsync(); + + var optionsMock = new Mock>(); + optionsMock + .SetupGet(options => options.Value) + .Returns(new PagingSettings { PageSize = 2 }); + + var controller = new SportsController( + ctx, + ControllerTestContext.CreateSieveProcessor(), + optionsMock.Object) + .WithTempData(); + + var result = await controller.Index(new SieveModel()); + + var view = Assert.IsType(result); + var model = Assert.IsType>(view.Model); + Assert.Equal(2, model.Data.Count); + Assert.Equal(2, model.PagingInfo.ItemsPerPage); + Assert.Equal(3, model.PagingInfo.TotalItemsCount); + Assert.Equal(3, model.PagingInfo.FilteredItemsCount); + } + + [Fact] + public async Task PopulateModelStateValidationErrorsForMissingName() + { + await using var ctx = ControllerTestContext.CreateContext(); + var controller = CreateController(ctx); + var invalidModel = new SportViewModel { Name = string.Empty }; + + var result = await controller.Create(invalidModel, ControllerTestContext.EmptySieveModel()); + + Assert.False( + controller.ModelState.IsValid, + "This assertion intentionally demonstrates an incorrect expectation: unit tests do not run the MVC validation pipeline automatically."); + Assert.True( + controller.ModelState.ContainsKey(nameof(SportViewModel.Name)), + "This assertion intentionally demonstrates an incorrect expectation: without MVC model validation, ModelState should not contain a validation entry for Name."); + Assert.Contains( + controller.ModelState[nameof(SportViewModel.Name)]!.Errors, + error => error.ErrorMessage == "The Name field is required."); + + var partial = Assert.IsType(result); + Assert.Equal("_CreateSportForm", partial.ViewName); + } + + [Fact] + public async Task EditSport() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Sports.Add(ControllerTestContext.CreateSport()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx, useSieve: false); + + var result = await controller.Edit(10, new SportViewModel { Id = 10, Name = "Volleyball" }); + + var partial = Assert.IsType(result); + Assert.Equal("_SportRow", partial.ViewName); + Assert.Equal("Volleyball", (await ctx.Sports.SingleAsync()).Name); + } + + [Fact] + public async Task DeleteSport() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Sports.Add(ControllerTestContext.CreateSport()); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + var result = await controller.Delete(10, ControllerTestContext.EmptySieveModel()); + + var partial = Assert.IsType(result); + Assert.Equal("_SportsList", partial.ViewName); + Assert.Empty(ctx.Sports); + Assert.Contains("was deleted successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString()); + } + + [Fact] + public async Task ReturnConflictWhenDeletingSportWithRegistrations() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + ctx.People.Add(ControllerTestContext.CreatePerson()); + ctx.Events.Add(ControllerTestContext.CreateEvent()); + ctx.Sports.Add(ControllerTestContext.CreateSport()); + ctx.Registrations.Add(new Registration + { + Id = 1000, + EventId = 100, + PersonId = 1, + SportId = 10 + }); + await ctx.SaveChangesAsync(); + var controller = CreateController(ctx); + + var result = await controller.Delete(10, ControllerTestContext.EmptySieveModel()); + + var content = Assert.IsType(result); + Assert.Equal(409, controller.Response.StatusCode); + Assert.Equal("The sport cannot be deleted because registrations exist.", content.Content); + } + + private static SportsController CreateController(EventsContext ctx, bool useSieve = true) + { + var controller = new SportsController( + ctx, + useSieve ? ControllerTestContext.CreateSieveProcessor() : null!, + ControllerTestContext.CreatePagingOptions()) + .WithTempData(); + + return controller; + } +} diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/Events.Tests.UnitTests.csproj b/Events-MVC/Tests/Events.Tests.UnitTests/Events.Tests.UnitTests.csproj new file mode 100644 index 0000000..8afac6d --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/Events.Tests.UnitTests.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + enable + enable + false + true + Erasmus-STA-2026 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + PreserveNewest + + + + diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/GlobalUsings.cs b/Events-MVC/Tests/Events.Tests.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/Infrastructure/ControllerTestContext.cs b/Events-MVC/Tests/Events.Tests.UnitTests/Infrastructure/ControllerTestContext.cs new file mode 100644 index 0000000..7c52b23 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/Infrastructure/ControllerTestContext.cs @@ -0,0 +1,98 @@ +#if POSTGRES +using Events.EF.Data.Postgres; +#else +using Events.EF.Data.MSSQL; +#endif +using Events.EF.Models; +using Events.MVC.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Sieve.Models; +using Sieve.Services; + +namespace Events.Tests.UnitTests.Infrastructure; + +internal static class ControllerTestContext +{ + public static EventsContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new EventsContext(options); + } + + public static IOptions CreatePagingOptions(int pageSize = 10) + { + return Options.Create(new PagingSettings { PageSize = pageSize }); + } + + public static SieveModel EmptySieveModel() + { + return new SieveModel(); + } + + public static ISieveProcessor CreateSieveProcessor() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddScoped(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + public static Country CreateCountry(string code = "HR", string alpha3 = "HRV", string name = "Croatia") + { + return new Country + { + Code = code, + Alpha3 = alpha3, + Name = name + }; + } + + public static Person CreatePerson(int id = 1, string countryCode = "HR", string firstName = "Ivan", string lastName = "Horvat") + { + return new Person + { + Id = id, + FirstName = firstName, + LastName = lastName, + FirstNameTranscription = firstName, + LastNameTranscription = lastName, + AddressLine = "Ilica 1", + PostalCode = "10000", + City = "Zagreb", + AddressCountry = "Croatia", + Email = $"{firstName.ToLowerInvariant()}.{lastName.ToLowerInvariant()}@example.com", + ContactPhone = "+38591111222", + BirthDate = new DateOnly(1990, 5, 1), + DocumentNumber = $"DOC-{id}", + CountryCode = countryCode + }; + } + + public static Event CreateEvent(int id = 100, string name = "Spring Games") + { + return new Event + { + Id = id, + Name = name, + EventDate = new DateOnly(2026, 4, 15) + }; + } + + public static Sport CreateSport(int id = 10, string name = "Football") + { + return new Sport + { + Id = id, + Name = name + }; + } +} diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/Infrastructure/ControllerTestExtensions.cs b/Events-MVC/Tests/Events.Tests.UnitTests/Infrastructure/ControllerTestExtensions.cs new file mode 100644 index 0000000..0e55380 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/Infrastructure/ControllerTestExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; + +namespace Events.Tests.UnitTests.Infrastructure; + +internal static class ControllerTestExtensions +{ + public static T WithTempData(this T controller) where T : Controller + { + var httpContext = new DefaultHttpContext(); + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + controller.TempData = new TempDataDictionary(httpContext, new TestTempDataProvider()); + return controller; + } +} diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/Infrastructure/ProviderSpecificQueryShould.cs b/Events-MVC/Tests/Events.Tests.UnitTests/Infrastructure/ProviderSpecificQueryShould.cs new file mode 100644 index 0000000..d0c4d5c --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/Infrastructure/ProviderSpecificQueryShould.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Npgsql; + +namespace Events.Tests.UnitTests.Infrastructure; + +public class ProviderSpecificQueryShould +{ + [Fact] + public async Task ReturnMatchingRowsWhenUsingILikeWithInMemoryProvider() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + ctx.People.Add(ControllerTestContext.CreatePerson()); + await ctx.SaveChangesAsync(); + + var people = await ctx.People + .Where(person => person.FirstName != null && Microsoft.EntityFrameworkCore.EF.Functions.ILike(person.FirstName, "%iv%")) + .ToListAsync(); + + Assert.Single(people); + Assert.Equal("Ivan", people[0].FirstName); + } + + [Fact] + public async Task ThrowInvalidOperationExceptionWhenUsingILikeWithInMemoryProvider() + { + await using var ctx = ControllerTestContext.CreateContext(); + ctx.Countries.Add(ControllerTestContext.CreateCountry()); + ctx.People.Add(ControllerTestContext.CreatePerson()); + await ctx.SaveChangesAsync(); + + var exception = await Assert.ThrowsAsync(async () => + await ctx.People + .Where(person => person.FirstName != null && Microsoft.EntityFrameworkCore.EF.Functions.ILike(person.FirstName, "%iv%")) + .ToListAsync()); + + Assert.Contains("ILike", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteILikeWhenUsingPostgreSqlProvider() + { + var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false) + .AddUserSecrets(optional: true) + .Build(); + + var productionConnectionString = configuration.GetConnectionString("EventDB-Test"); + Assert.False( + string.IsNullOrWhiteSpace(productionConnectionString), + "The EventDB-Test connection string must be available so the PostgreSQL-backed provider test can connect to the PostgreSQL copy."); + + var connectionStringBuilder = new NpgsqlConnectionStringBuilder(productionConnectionString) + { + SslMode = SslMode.Disable + }; + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionStringBuilder.ConnectionString) + .Options; + + await using var ctx = new Events.EF.Data.Postgres.EventsContext(options); + + var people = await ctx.People + .Where(person => person.FirstName != null && Microsoft.EntityFrameworkCore.EF.Functions.ILike(person.FirstName, "%iv%")) + .Take(20) + .ToListAsync(); + + Assert.NotEmpty(people); + Assert.All( + people, + person => Assert.Contains( + "iv", + person.FirstName, + StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/Infrastructure/TestTempDataProvider.cs b/Events-MVC/Tests/Events.Tests.UnitTests/Infrastructure/TestTempDataProvider.cs new file mode 100644 index 0000000..abfed1e --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/Infrastructure/TestTempDataProvider.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ViewFeatures; + +namespace Events.Tests.UnitTests.Infrastructure; + +internal sealed class TestTempDataProvider : ITempDataProvider +{ + private Dictionary values = []; + + public IDictionary LoadTempData(HttpContext context) + { + return new Dictionary(values); + } + + public void SaveTempData(HttpContext context, IDictionary values) + { + this.values = new Dictionary(values); + } +} diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/Models/PagingInfoTests.cs b/Events-MVC/Tests/Events.Tests.UnitTests/Models/PagingInfoTests.cs new file mode 100644 index 0000000..1188e17 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/Models/PagingInfoTests.cs @@ -0,0 +1,100 @@ +using Events.MVC.Models; + +namespace Events.Tests.UnitTests.Models; + +public class PagingInfoTests +{ + public static IEnumerable TotalPagesCases => + [ + [5, 10, 1], + [20, 10, 2], + [25, 10, 3] + ]; + + [Fact] + public void TotalPages_ReturnsAtLeastOne() + { + var pagingInfo = new PagingInfo + { + FilteredItemsCount = 0, + ItemsPerPage = 10, + CurrentPage = 1 + }; + + Assert.Equal(1, pagingInfo.TotalPages); + } + + [Fact] + public void TotalPages_RoundsUpWhenFilteredItemsDoNotDivideEvenly() + { + var pagingInfo = new PagingInfo + { + FilteredItemsCount = 21, + ItemsPerPage = 10, + CurrentPage = 1 + }; + + Assert.Equal(3, pagingInfo.TotalPages); + } + + [Theory] + [InlineData(1, 10, 1)] + [InlineData(10, 10, 1)] + [InlineData(11, 10, 2)] + [InlineData(21, 10, 3)] + public void TotalPages_ReturnsExpectedPageCount(int filteredItemsCount, int itemsPerPage, int expectedTotalPages) + { + var pagingInfo = new PagingInfo + { + FilteredItemsCount = filteredItemsCount, + ItemsPerPage = itemsPerPage, + CurrentPage = 1 + }; + + Assert.Equal(expectedTotalPages, pagingInfo.TotalPages); + } + + [Theory] + [MemberData(nameof(TotalPagesCases))] + public void TotalPages_ReturnsExpectedPageCount_WhenUsingMemberData( + int filteredItemsCount, + int itemsPerPage, + int expectedTotalPages) + { + var pagingInfo = new PagingInfo + { + FilteredItemsCount = filteredItemsCount, + ItemsPerPage = itemsPerPage, + CurrentPage = 1 + }; + + Assert.Equal(expectedTotalPages, pagingInfo.TotalPages); + } + + [Fact] + public void ToggleSort_ReturnsDescending_WhenAlreadySortedBySameProperty() + { + var pagingInfo = new PagingInfo + { + Sorts = "Name", + ItemsPerPage = 10, + CurrentPage = 1 + }; + + Assert.Equal("-Name", pagingInfo.ToggleSort("Name")); + } + + [Fact] + public void IsSortedBy_And_IsDescending_ReflectCurrentState() + { + var pagingInfo = new PagingInfo + { + Sorts = "-RegisteredAt", + ItemsPerPage = 10, + CurrentPage = 1 + }; + + Assert.True(pagingInfo.IsSortedBy("RegisteredAt"), "PagingInfo should report that sorting is applied by RegisteredAt."); + Assert.True(pagingInfo.IsDescending(), "PagingInfo should report descending sort order when the sort expression starts with '-'."); + } +} diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/Util/SieveModelExtensionsTests.cs b/Events-MVC/Tests/Events.Tests.UnitTests/Util/SieveModelExtensionsTests.cs new file mode 100644 index 0000000..3d1677a --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/Util/SieveModelExtensionsTests.cs @@ -0,0 +1,87 @@ +using Events.MVC.Util.Extensions; +using Sieve.Models; + +namespace Events.Tests.UnitTests.Util; + +public class SieveModelExtensionsTests +{ + [Fact] + public void SetDefaultPagingAndSorting_AssignsDefaults_WhenValuesAreMissing() + { + var model = new SieveModel(); + + model.SetDefaultPagingAndSorting(defaultPageSize: 10, defaultSort: "Name"); + + Assert.Equal(1, model.Page); + Assert.Equal(10, model.PageSize); + Assert.Equal("Name", model.Sorts); + } + + [Fact] + public void SetDefaultPagingAndSorting_ClampsInvalidPage_AndPageSize() + { + var model = new SieveModel + { + Page = 0, + PageSize = -5 + }; + + model.SetDefaultPagingAndSorting(defaultPageSize: 20, defaultSort: "RegisteredAt"); + + Assert.Equal(1, model.Page); + Assert.Equal(20, model.PageSize); + Assert.Equal("RegisteredAt", model.Sorts); + } + + [Theory] + [InlineData("Name@=*basketball", "Name", "basketball")] + [InlineData("Name@=basketball", "Name", "basketball")] + [InlineData("PersonTranscription@=*ivan", "PersonTranscription", "ivan")] + [InlineData(" PersonTranscription@=*ivan ", "PersonTranscription", "ivan")] + public void ExtractFilterValue_ReturnsExpectedValue_ForDefaultOperators( + string filters, + string propertyName, + string expected) + { + var value = SieveModelExtensions.ExtractFilterValue(filters, propertyName); + + Assert.Equal(expected, value); + } + + [Theory] + [InlineData("CountryCode==HR", "CountryCode", "HR")] + [InlineData("PersonTranscription@=*ivan,CountryCode==HR", "CountryCode", "HR")] + [InlineData("CountryCode==HR,PersonTranscription@=*ivan", "CountryCode", "HR")] + public void ExtractFilterValue_ReturnsExpectedValue_ForCustomOperator( + string filters, + string propertyName, + string expected) + { + var value = SieveModelExtensions.ExtractFilterValue(filters, propertyName, "=="); + + Assert.Equal(expected, value); + } + + [Fact] + public void ExtractFilterValue_ReturnsEmptyString_WhenPropertyIsMissing() + { + var value = SieveModelExtensions.ExtractFilterValue( + "PersonTranscription@=*ivan,CountryCode==HR", + "Name"); + + Assert.Equal(string.Empty, value); + } + + [Fact] + public void ExtractFilterValue_FromModel_UsesFiltersProperty() + { + var model = new SieveModel + { + Filters = "FullName@=*ana" + }; + + var value = model.ExtractFilterValue("FullName"); + + Assert.Equal("ana", value); + } +} diff --git a/Events-MVC/Tests/Events.Tests.UnitTests/appsettings.json b/Events-MVC/Tests/Events.Tests.UnitTests/appsettings.json new file mode 100644 index 0000000..eed52c0 --- /dev/null +++ b/Events-MVC/Tests/Events.Tests.UnitTests/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "EventDB-Test": "Host=localhost;Port=5433;Database=events;Username=sport;Persist Security Info=True;" + } +} diff --git a/Events-MVC/Tests/README.md b/Events-MVC/Tests/README.md new file mode 100644 index 0000000..24f22a4 --- /dev/null +++ b/Events-MVC/Tests/README.md @@ -0,0 +1,72 @@ +# Test Plan + +## Events.Tests.UnitTests + +- `PagingInfo` and `PagedList` + - `TotalPages` calculation + - `ToggleSort`, `IsSortedBy`, `IsDescending` + - pagination behavior and page bounds +- `SieveModelExtensions` + - normalization of `page`, `pageSize`, and default `sort` + - extracting filter values for `@=*`, `@=`, and `==` +- controller guards and redirects + - `People` without countries + - `Registrations` without events, sports, or people + - verify that the correct `RedirectToActionResult` is returned and the expected toast is stored in `TempData` +- controller result and view model checks + - verify whether an action returns the correct `ViewResult`, `PartialViewResult`, `NotFoundResult`, `BadRequestResult`, or `ContentResult` + - verify that an action returns the expected view model type and expected values inside the view model +- validation and mapping logic from controllers + - `CountriesController` translations `json <-> view model` + - duplicate language validation and empty translation row handling +- small helpers and formatting logic + - toast payload helpers if they are extracted later + - route/pager helper logic that can be tested without a full MVC host + +## Events.Tests.IntegrationTests + +- HTTP tests against the MVC application through `WebApplicationFactory` + - `GET` requests for screens return `200` + - HTMX requests return the correct partial +- redirect and guard scenarios + - `People` without countries redirects to `Countries` + - `Registrations` without events/sports/people redirects to the correct screen +- CRUD happy paths for main screens + - create/edit/delete for `Sports`, `Events`, `Countries`, and `People` + - create/edit/delete for `Registrations` for the selected event +- validation and error flow + - invalid input returns a partial with validation messages + - DB conflicts return readable `ProblemDetails` +- filtering, sorting, and paging + - `Sieve` query strings return expected results + - transcription and country filtering for registrations + +For these tests, the best setup is a dedicated PostgreSQL test container or an isolated test database with seeded data. + +## Events.Tests.UITests + +- end-to-end user flows in the browser + - open a screen, use the collapse form, save a record, see a toast + - paging and sorting without duplicating layout + - HTMX inline edit and cancel +- `Registrations` screen + - changing the event refreshes the table + - person autocomplete works and respects the country filter + - create/edit/delete registration +- navigation smoke tests + - all main links work + - redirect messages through toast are displayed + +For UI tests, Playwright is the most natural choice, with a small smoke suite and a few critical end-to-end flows. + +Suggested local setup: + +```powershell +dotnet tool install --global Microsoft.Playwright.CLI +``` + +Then inside the UI test project: + +```powershell +playwright install +```