Simple MVC CRUD. Remove NOT NULL on some attributes in Person

This commit is contained in:
Boris Milašinović
2026-04-25 19:52:11 +02:00
parent 3e38889ada
commit eb04483417
46 changed files with 1989 additions and 26 deletions

View File

@@ -9,25 +9,25 @@ public partial class Person
{
public int Id { get; set; }
public string FirstName { get; set; } = null!;
public string? FirstName { get; set; }
public string LastName { get; set; } = null!;
public string? LastName { get; set; }
public string FirstNameTranscription { get; set; } = null!;
public string LastNameTranscription { get; set; } = null!;
public string AddressLine { get; set; } = null!;
public string? AddressLine { get; set; }
public string PostalCode { get; set; } = null!;
public string? PostalCode { get; set; }
public string City { get; set; } = null!;
public string? City { get; set; }
public string AddressCountry { get; set; } = null!;
public string? AddressCountry { get; set; }
public string Email { get; set; } = null!;
public string? Email { get; set; }
public string ContactPhone { get; set; } = null!;
public string? ContactPhone { get; set; }
public DateOnly BirthDate { get; set; }

View File

@@ -0,0 +1,143 @@
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#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<EventsContext> options)
: base(options)
{
}
public virtual DbSet<Country> Countries { get; set; }
public virtual DbSet<Event> Events { get; set; }
public virtual DbSet<Person> People { get; set; }
public virtual DbSet<Registration> Registrations { get; set; }
public virtual DbSet<Sport> Sports { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Country>(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<Event>(entity =>
{
entity.ToTable("Event");
entity.Property(e => e.Name)
.HasMaxLength(150)
.IsUnicode(false);
});
modelBuilder.Entity<Person>(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<Registration>(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<Sport>(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);
}

View File

@@ -0,0 +1,165 @@
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#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<EventsContext> options)
: base(options)
{
}
public virtual DbSet<Country> Countries { get; set; }
public virtual DbSet<Event> Events { get; set; }
public virtual DbSet<Person> People { get; set; }
public virtual DbSet<Registration> Registrations { get; set; }
public virtual DbSet<Sport> Sports { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Country>(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<Event>(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<Person>(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<Registration>(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<Sport>(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);
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.5" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#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<Person> People { get; set; } = new List<Person>();
}

View File

@@ -0,0 +1,17 @@
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#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<Registration> Registrations { get; set; } = new List<Registration>();
}

View File

@@ -0,0 +1,41 @@
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#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<Registration> Registrations { get; set; } = new List<Registration>();
}

View File

@@ -0,0 +1,25 @@
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#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!;
}

View File

@@ -0,0 +1,15 @@
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#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<Registration> Registrations { get; set; } = new List<Registration>();
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,4 @@
<Solution>
<Project Path="Events.EF/Events.EF.csproj" />
<Project Path="MVC-SimpleCRUD/MVC-SimpleCRUD.csproj" />
</Solution>

View File

@@ -0,0 +1,33 @@
namespace MVC_SimpleCRUD;
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 Danger = "danger";
}
public static class ToastTitles
{
public const string Notification = "Notification";
public const string Error = "Error";
}
public static class ViewDataKeys
{
public const string PagingInfo = "PagingInfo";
}
public static class ValidationMessages
{
public const string EmailOrContactPhoneRequired = "Email or contact phone is required.";
}
}

View File

@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc;
namespace MVC_SimpleCRUD.Controllers;
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
}

View File

@@ -0,0 +1,302 @@
#if POSTGRES
using Events.EF.Data.Postgres;
#else
using Events.EF.Data.MSSQL;
#endif
using Events.EF.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using MVC_SimpleCRUD.Models;
using MVC_SimpleCRUD.People;
using MVC_SimpleCRUD.Util.Extensions;
using Sieve.Models;
using Sieve.Services;
namespace MVC_SimpleCRUD.Controllers;
public class PeopleController : Controller
{
private readonly EventsContext ctx;
private readonly ISieveProcessor sieveProcessor;
private readonly PagingSettings pagingSettings;
public PeopleController(EventsContext ctx, IOptionsSnapshot<PagingSettings> pagingSettings, ISieveProcessor sieveProcessor)
{
this.ctx = ctx;
this.sieveProcessor = sieveProcessor;
this.pagingSettings = pagingSettings.Value;
}
public async Task<IActionResult> Index(int page = 1, int pageSize = 0, string? sorts = null, string? searchText = null)
{
if (page < 1)
{
page = 1;
}
if (pageSize <= 0)
{
pageSize = pagingSettings.PageSize;
}
sorts = sorts.NullIfWhiteSpace() ?? nameof(PersonInfo.LastNameTranscription);
var sieveModel = new SieveModel
{
Page = page,
PageSize = pageSize,
Sorts = sorts
};
int totalCount = await ctx.People.CountAsync();
var query = ctx.People
.Select(p => new PersonInfo
{
Id = p.Id,
FirstName = p.FirstName,
LastName = p.LastName,
FirstNameTranscription = p.FirstNameTranscription,
LastNameTranscription = p.LastNameTranscription,
BirthDate = p.BirthDate,
CountryName = p.CountryCodeNavigation.Name,
});
int filteredCount;
searchText = searchText.NullIfWhiteSpace();
if (searchText is not null)
{
sieveModel.Filters = $"({nameof(PersonInfo.FirstNameTranscription)}|{nameof(PersonInfo.LastNameTranscription)}|{nameof(PersonInfo.CountryName)})@=*{searchText}";
filteredCount = await sieveProcessor.Apply(sieveModel, query, applyPagination: false, applySorting: false)
.CountAsync();
}
else
{
filteredCount = totalCount;
}
var pagingInfo = new PagingInfo
{
FilteredItemsCount = filteredCount,
TotalItemsCount = totalCount,
ItemsPerPage = pageSize,
CurrentPage = page,
Sorts = sorts,
SearchText = searchText
};
if (pagingInfo.CurrentPage > pagingInfo.TotalPages)
{
page = pagingInfo.CurrentPage = pagingInfo.TotalPages;
sieveModel.Page = page;
}
if (filteredCount > 0)
{
query = sieveProcessor.Apply(sieveModel, query);
var people = await query.ToListAsync();
var model = new PagedList<PersonInfo>(people, pagingInfo);
return View(model);
}
else
{
return View(new PagedList<PersonInfo>(new(), pagingInfo));
}
}
[HttpGet]
public async Task<IActionResult> Create(int page = 1, int pageSize = 0, string? sorts = null, string? searchText = null)
{
var model = new PersonForm
{
Page = page,
PageSize = pageSize,
Sorts = sorts,
SearchText = searchText
};
await PrepareDropDownLists(model.CountryCode);
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(PersonForm model)
{
if (!ModelState.IsValid)
{
await PrepareDropDownLists(model.CountryCode);
return View(model);
}
var person = new Person();
CopyToPerson(model, person);
ctx.People.Add(person);
try
{
await ctx.SaveChangesAsync();
ShowToast($"Person {person.FirstNameTranscription} {person.LastNameTranscription} added.");
return RedirectToIndex(model);
}
catch (DbUpdateException exc)
{
ModelState.AddModelError(string.Empty, exc.InnerException?.Message ?? exc.Message);
await PrepareDropDownLists(model.CountryCode);
return View(model);
}
}
[HttpGet]
public async Task<IActionResult> Edit(int id, int page = 1, int pageSize = 0, string? sorts = null, string? searchText = null)
{
var person = await ctx.People.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
if (person is null)
{
ShowToast($"Person with id {id} was not found.", Constants.ToastVariants.Danger, Constants.ToastTitles.Error);
return RedirectToAction(nameof(Index), new { page, pageSize, sorts, searchText });
}
var model = CreateForm(person, page, pageSize, sorts, searchText);
await PrepareDropDownLists(model.CountryCode);
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, PersonForm model)
{
if (model.Id != id)
{
return BadRequest();
}
if (!ModelState.IsValid)
{
await PrepareDropDownLists(model.CountryCode);
return View(model);
}
var person = await ctx.People.FindAsync(id);
if (person is null)
{
ShowToast($"Person with id {id} was not found.", Constants.ToastVariants.Danger, Constants.ToastTitles.Error);
return RedirectToIndex(model);
}
CopyToPerson(model, person);
try
{
await ctx.SaveChangesAsync();
ShowToast($"Person {person.FirstNameTranscription} {person.LastNameTranscription} updated.");
return RedirectToIndex(model);
}
catch (DbUpdateException exc)
{
ModelState.AddModelError(string.Empty, exc.InnerException?.Message ?? exc.Message);
await PrepareDropDownLists(model.CountryCode);
return View(model);
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id, int page = 1, int pageSize = 0, string? sorts = null, string? searchText = null)
{
var person = await ctx.People.FindAsync(id);
if (person is null)
{
ShowToast($"Person with id {id} was not found.", Constants.ToastVariants.Danger, Constants.ToastTitles.Error);
return RedirectToAction(nameof(Index), new { page, pageSize, sorts, searchText });
}
string personName = $"{person.FirstNameTranscription} {person.LastNameTranscription}";
ctx.People.Remove(person);
try
{
await ctx.SaveChangesAsync();
ShowToast($"Person {personName} deleted.");
}
catch (DbUpdateException exc)
{
ShowToast($"Person {personName} could not be deleted. {exc.InnerException?.Message ?? exc.Message}", Constants.ToastVariants.Danger, Constants.ToastTitles.Error);
}
return RedirectToAction(nameof(Index), new { page, pageSize, sorts, searchText });
}
private async Task PrepareDropDownLists(string? selectedCountryCode = null)
{
var countries = await ctx.Countries
.AsNoTracking()
.OrderBy(d => d.Name)
.Select(d => new { d.Name, d.Code })
.ToListAsync();
ViewBag.Countries = new SelectList(countries, nameof(Country.Code), nameof(Country.Name), selectedCountryCode);
}
private static PersonForm CreateForm(Person person, int page, int pageSize, string? sorts, string? searchText)
{
return new PersonForm
{
Id = person.Id,
FirstName = person.FirstName,
LastName = person.LastName,
FirstNameTranscription = person.FirstNameTranscription,
LastNameTranscription = person.LastNameTranscription,
AddressLine = person.AddressLine,
PostalCode = person.PostalCode,
City = person.City,
AddressCountry = person.AddressCountry,
Email = person.Email,
ContactPhone = person.ContactPhone,
BirthDate = person.BirthDate,
DocumentNumber = person.DocumentNumber,
CountryCode = person.CountryCode,
Page = page,
PageSize = pageSize,
Sorts = sorts,
SearchText = searchText
};
}
private static void CopyToPerson(PersonForm model, Person person)
{
person.FirstName = model.FirstName.NullIfWhiteSpace();
person.LastName = model.LastName.NullIfWhiteSpace();
person.FirstNameTranscription = model.FirstNameTranscription;
person.LastNameTranscription = model.LastNameTranscription;
person.AddressLine = model.AddressLine.NullIfWhiteSpace();
person.PostalCode = model.PostalCode.NullIfWhiteSpace();
person.City = model.City.NullIfWhiteSpace();
person.AddressCountry = model.AddressCountry.NullIfWhiteSpace();
person.Email = model.Email.NullIfWhiteSpace();
person.ContactPhone = model.ContactPhone.NullIfWhiteSpace();
person.BirthDate = model.BirthDate!.Value;
person.DocumentNumber = model.DocumentNumber;
person.CountryCode = model.CountryCode;
}
private RedirectToActionResult RedirectToIndex(PersonForm model)
{
return RedirectToAction(nameof(Index), new
{
page = model.Page,
pageSize = model.PageSize,
sorts = model.Sorts,
searchText = model.SearchText
});
}
private void ShowToast(string message, string variant = Constants.ToastVariants.Success, string title = Constants.ToastTitles.Notification)
{
TempData[Constants.TempDataKeys.ToastMessage] = message;
TempData[Constants.TempDataKeys.ToastVariant] = variant;
TempData[Constants.TempDataKeys.ToastTitle] = title;
}
}

View File

@@ -0,0 +1,38 @@
#if POSTGRES
using Events.EF.Data.Postgres;
#else
using Events.EF.Data.MSSQL;
#endif
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MVC_SimpleCRUD.People;
namespace MVC_SimpleCRUD.Controllers;
public class PeopleSimpleController : Controller
{
private readonly EventsContext ctx;
public PeopleSimpleController(EventsContext ctx)
{
this.ctx = ctx;
}
public IActionResult Index()
{
var people = ctx.People
.OrderBy(p => p.LastNameTranscription)
.Select(p => new PersonInfo
{
Id = p.Id,
FirstName = p.FirstName,
LastName = p.LastName,
FirstNameTranscription = p.FirstNameTranscription,
LastNameTranscription = p.LastNameTranscription,
BirthDate = p.BirthDate,
CountryName = p.CountryCodeNavigation.Name,
})
.ToList();
return View(people);
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MVC_SimpleCRUD</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<UserSecretsId>PI</UserSecretsId>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DefineConstants>$(DefineConstants);MSSQL</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.LibraryManager.Build" Version="3.0.71" />
<PackageReference Include="NLog.Web.AspNetCore" Version="6.1.2" />
<PackageReference Include="Sieve" Version="2.5.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Events.EF\Events.EF.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,3 @@
namespace MVC_SimpleCRUD.Models;
public record PagedList<T>(List<T> Data, PagingInfo PagingInfo);

View File

@@ -0,0 +1,37 @@
namespace MVC_SimpleCRUD.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; }
public string? SearchText { get; set; }
public int TotalPages => Math.Max(1, (int)Math.Ceiling((decimal)FilteredItemsCount / ItemsPerPage));
public bool IsFiltered => !string.IsNullOrWhiteSpace(SearchText);
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) == true;
}
}

View File

@@ -0,0 +1,10 @@
namespace MVC_SimpleCRUD.Models;
public class PagingSettings
{
public const string SectionName = "Paging";
public int PageSize { get; set; } = 20;
public int PageOffset { get; set; } = 5;
}

View File

@@ -0,0 +1,77 @@
using System.ComponentModel.DataAnnotations;
using MVC_SimpleCRUD;
namespace MVC_SimpleCRUD.People;
public class PersonForm : IValidatableObject
{
public int? Id { get; set; }
[StringLength(100)]
[Display(Name = "First name (native)")]
public string? FirstName { get; set; }
[StringLength(100)]
[Display(Name = "Last name (native)")]
public string? LastName { get; set; }
[Required, StringLength(100)]
[Display(Name = "First name (English)")]
public string FirstNameTranscription { get; set; } = string.Empty;
[Required, StringLength(100)]
[Display(Name = "Last name (English)")]
public string LastNameTranscription { get; set; } = string.Empty;
[StringLength(200)]
[Display(Name = "Address")]
public string? AddressLine { get; set; }
[StringLength(20)]
[Display(Name = "Postal Code")]
public string? PostalCode { get; set; }
[StringLength(100)]
public string? City { get; set; }
[StringLength(100)]
[Display(Name = "Address Country")]
public string? AddressCountry { get; set; }
[StringLength(255), EmailAddress]
public string? Email { get; set; }
[StringLength(50), Phone]
[Display(Name = "Contact Phone")]
public string? ContactPhone { get; set; }
[Required]
[Display(Name = "Birth Date")]
public DateOnly? BirthDate { get; set; }
[Required, StringLength(50)]
[Display(Name = "Document Number")]
public string DocumentNumber { get; set; } = string.Empty;
[Required, StringLength(3)]
[Display(Name = "Country")]
public string CountryCode { get; set; } = string.Empty;
public int Page { get; set; } = 1;
public int PageSize { get; set; }
public string? Sorts { get; set; }
public string? SearchText { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(ContactPhone))
{
yield return new ValidationResult(
Constants.ValidationMessages.EmailOrContactPhoneRequired,
new[] { nameof(Email), nameof(ContactPhone) });
}
}
}

View File

@@ -0,0 +1,30 @@
using Sieve.Attributes;
namespace MVC_SimpleCRUD.People;
public class PersonInfo
{
[Sieve(CanFilter = true, CanSort = true)]
public int Id { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string? FirstName { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string? LastName { get; set; }
public string OriginalName => ((FirstName ?? string.Empty) + " " + (LastName ?? string.Empty)).Trim();
[Sieve(CanFilter = true, CanSort = true)]
public required string FirstNameTranscription { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public required string LastNameTranscription { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public DateOnly BirthDate { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public required string CountryName { get; set; }
}

View File

@@ -0,0 +1,59 @@
#if POSTGRES
using Events.EF.Data.Postgres;
#else
using Events.EF.Data.MSSQL;
#endif
using Microsoft.EntityFrameworkCore;
using MVC_SimpleCRUD.Models;
using NLog;
using NLog.Web;
using Sieve.Services;
var logger = NLog.LogManager.Setup().GetCurrentClassLogger();
logger.Debug("init main");
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseNLog(new NLogAspNetCoreOptions() { RemoveLoggerFactoryFilter = false });
#region Configure services
builder.Services.AddControllersWithViews();
builder.Services.Configure<PagingSettings>(builder.Configuration.GetSection(PagingSettings.SectionName));
#if POSTGRES
builder.Services.AddDbContext<EventsContext>(options => options.UseNpgsql(builder.Configuration.GetConnectionString("EventsPostgres")));
#else
builder.Services.AddDbContext<EventsContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("EventsMssql")));
#endif
builder.Services.AddScoped<ISieveProcessor, SieveProcessor>();
#endregion
var app = builder.Build();
#region configure middleware pipeline
//middleware order https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/#middleware-order
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseStaticFiles();
app.UseRouting();
app.MapDefaultControllerRoute();
#endregion
app.Run();
}
catch (Exception exception)
{
// NLog: catch setup errors
logger.Error(exception, "Stopped program because of exception");
throw;
}
finally
{
// Ensure to flush and stop internal timers/threads before application-exit (Avoid segmentation fault on Linux)
NLog.LogManager.Shutdown();
}

View File

@@ -0,0 +1,14 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"Events.MVC": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7210",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,9 @@
namespace MVC_SimpleCRUD.Util.Extensions;
public static class StringExtensions
{
public static string? NullIfWhiteSpace(this string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View File

@@ -0,0 +1,128 @@
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;
using MVC_SimpleCRUD.Models;
namespace MVC_SimpleCRUD.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> 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; } = "Enter page number";
[HtmlAttributeName(DictionaryAttributePrefix = "page-route-")]
public Dictionary<string, string> 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");
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;
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 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;
if (!string.IsNullOrWhiteSpace(PageInfo.SearchText))
{
routeValues["searchText"] = PageInfo.SearchText;
}
return routeValues;
}
}

View File

@@ -0,0 +1,10 @@
@{
ViewData["Title"] = "Home";
}
<section class="card border-0 shadow-sm">
<div class="card-body p-4">
<h1 class="h3 mb-3">Events</h1>
<p class="mb-0">This sample demonstrates how to create a simple CRUD ASP.NET Core MVC application using data from Person table</p>
</div>
</section>

View File

@@ -0,0 +1,16 @@
@using MVC_SimpleCRUD.People
@model PersonForm
@{
ViewData["Title"] = "Add person";
ViewData["FormAction"] = "Create";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Add person</h2>
</div>
<partial name="_PersonForm" model="Model" />
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,16 @@
@using MVC_SimpleCRUD.People
@model PersonForm
@{
ViewData["Title"] = "Edit person";
ViewData["FormAction"] = "Edit";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Edit person</h2>
</div>
<partial name="_PersonForm" model="Model" />
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,138 @@
@using MVC_SimpleCRUD.People
@using Microsoft.AspNetCore.Mvc.ViewFeatures
@model PagedList<PersonInfo>
@{
ViewData["Title"] = "People";
}
<h2>People list</h2>
@{
(string PropertyName, string Title, bool Sortable)[] columns =
[
(nameof(PersonInfo.FirstNameTranscription), "First Name", true),
(nameof(PersonInfo.LastNameTranscription), "Last Name", true),
(nameof(PersonInfo.OriginalName), "Original Name", false),
(nameof(PersonInfo.BirthDate), "Birth Date", true),
(nameof(PersonInfo.CountryName), "Country Name", true)
];
var personRowViewData = new ViewDataDictionary(ViewData)
{
{ Constants.ViewDataKeys.PagingInfo, Model.PagingInfo }
};
}
<section class="card border-0 shadow-sm" id="people-list">
<div class="card-body">
<form asp-action="Index" method="get"
class="d-flex justify-content-between align-items-start gap-3 flex-wrap flex-lg-nowrap mb-4">
<input type="hidden" name="page" value="1" />
<input type="hidden" name="pageSize" value="@Model.PagingInfo.ItemsPerPage" />
<input type="hidden" name="sorts" value="@Model.PagingInfo.Sorts" />
<div class="d-flex align-items-center gap-3 pt-2">
<h2 class="h5 mb-0">People list</h2>
<span class="badge text-bg-light">@(Model.PagingInfo.IsFiltered ? $"{Model.PagingInfo.FilteredItemsCount} / {Model.PagingInfo.TotalItemsCount}" : Model.PagingInfo.TotalItemsCount.ToString())</span>
<a asp-action="Create"
asp-route-page="@Model.PagingInfo.CurrentPage"
asp-route-pageSize="@Model.PagingInfo.ItemsPerPage"
asp-route-sorts="@Model.PagingInfo.Sorts"
asp-route-searchText="@Model.PagingInfo.SearchText"
class="btn btn-primary">
Add
</a>
</div>
<div class="d-flex align-items-center gap-2 flex-nowrap ms-auto">
<input id="searchText"
name="searchText"
value="@Model.PagingInfo.SearchText"
class="form-control"
placeholder="Search by country or full name"
aria-label="Filter by country or full name" />
<button type="submit" class="btn btn-outline-primary">Filter</button>
@if (Model.PagingInfo.IsFiltered)
{
<a asp-action="Index"
asp-route-page="1"
asp-route-pageSize="@Model.PagingInfo.ItemsPerPage"
asp-route-sorts="@Model.PagingInfo.Sorts"
class="btn btn-outline-secondary">
Clear
</a>
}
</div>
</form>
<div class="table-responsive">
<table class="table table-sm table-hover" id="table-people">
<thead>
<tr>
@foreach (var column in columns)
{
<th>
@if (column.Sortable)
{
<a asp-action="Index"
asp-route-page="@Model.PagingInfo.CurrentPage"
asp-route-pageSize="@Model.PagingInfo.ItemsPerPage"
asp-route-searchText="@Model.PagingInfo.SearchText"
asp-route-sorts="@Model.PagingInfo.ToggleSort(column.PropertyName)"
class="link-dark link-underline-opacity-0">
@column.Title@(Model.PagingInfo.IsSortedBy(column.PropertyName) ? (Model.PagingInfo.IsDescending() ? " ↓" : " ↑") : "")
</a>
}
else
{
@column.Title
}
</th>
}
<th class="text-end"></th>
</tr>
</thead>
<tbody>
@if (Model.Data.Count == 0)
{
<tr>
<td colspan="6" class="text-body-secondary">No data to display.</td>
</tr>
}
else
{
@foreach (var person in Model.Data)
{
<partial name="_PersonRow" model="person" view-data="personRowViewData" />
}
}
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-4 gap-3 flex-wrap">
<div class="d-flex align-items-center gap-2 flex-wrap">
<small class="text-body-secondary">Page @Model.PagingInfo.CurrentPage of @Model.PagingInfo.TotalPages</small>
<form asp-action="Index" method="get" class="d-inline-flex align-items-center gap-2">
<input type="hidden" name="page" value="1" />
<input type="hidden" name="sorts" value="@Model.PagingInfo.Sorts" />
<input type="hidden" name="searchText" value="@Model.PagingInfo.SearchText" />
<select name="pageSize" class="form-select form-select-sm" style="width: auto;" aria-label="Items per page" onchange="this.form.requestSubmit()">
@{
int[] pageSizeOptions = [10, 20, 50, 100];
}
@foreach (var option in pageSizeOptions)
{
<option value="@option" selected="@(Model.PagingInfo.ItemsPerPage == option)">@option</option>
}
</select>
</form>
</div>
<pager page-info="@Model.PagingInfo"
page-action="Index"
page-title="Enter a page number and press Enter">
</pager>
</div>
</div>
</section>

View File

@@ -0,0 +1,101 @@
@using Microsoft.AspNetCore.Mvc.Rendering
@using MVC_SimpleCRUD.People
@model PersonForm
@{
var formAction = ViewData["FormAction"]?.ToString() ?? "Create";
var countries = ViewBag.Countries as SelectList ?? new SelectList(Array.Empty<SelectListItem>());
}
<form asp-action="@formAction" asp-route-id="@Model.Id" method="post" class="card border-0 shadow-sm">
<div class="card-body">
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<input type="hidden" asp-for="Id" />
<input type="hidden" asp-for="Page" />
<input type="hidden" asp-for="PageSize" />
<input type="hidden" asp-for="Sorts" />
<input type="hidden" asp-for="SearchText" />
<div class="row g-3">
<div class="col-md-6">
<label asp-for="FirstNameTranscription" class="form-label"></label>
<input asp-for="FirstNameTranscription" class="form-control" />
<span asp-validation-for="FirstNameTranscription" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="LastNameTranscription" class="form-label"></label>
<input asp-for="LastNameTranscription" class="form-control" />
<span asp-validation-for="LastNameTranscription" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="FirstName" class="form-label"></label>
<input asp-for="FirstName" class="form-control" />
<span asp-validation-for="FirstName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="LastName" class="form-label"></label>
<input asp-for="LastName" class="form-control" />
<span asp-validation-for="LastName" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="BirthDate" class="form-label"></label>
<input asp-for="BirthDate" type="date" class="form-control" />
<span asp-validation-for="BirthDate" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="DocumentNumber" class="form-label"></label>
<input asp-for="DocumentNumber" class="form-control" />
<span asp-validation-for="DocumentNumber" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="CountryCode" class="form-label"></label>
<select asp-for="CountryCode" asp-items="countries" class="form-select">
<option value="">Select country</option>
</select>
<span asp-validation-for="CountryCode" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="ContactPhone" class="form-label"></label>
<input asp-for="ContactPhone" class="form-control" />
<span asp-validation-for="ContactPhone" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="AddressLine" class="form-label"></label>
<input asp-for="AddressLine" class="form-control" />
<span asp-validation-for="AddressLine" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="PostalCode" class="form-label"></label>
<input asp-for="PostalCode" class="form-control" />
<span asp-validation-for="PostalCode" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="City" class="form-label"></label>
<input asp-for="City" class="form-control" />
<span asp-validation-for="City" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="AddressCountry" class="form-label"></label>
<input asp-for="AddressCountry" class="form-control" />
<span asp-validation-for="AddressCountry" class="text-danger"></span>
</div>
</div>
</div>
<div class="card-footer bg-white d-flex justify-content-end gap-2">
<a asp-action="Index"
asp-route-page="@Model.Page"
asp-route-pageSize="@Model.PageSize"
asp-route-sorts="@Model.Sorts"
asp-route-searchText="@Model.SearchText"
class="btn btn-outline-secondary">
Cancel
</a>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>

View File

@@ -0,0 +1,43 @@
@using MVC_SimpleCRUD.People
@model PersonInfo
@{
var pagingInfo = ViewData[Constants.ViewDataKeys.PagingInfo] as PagingInfo ?? new PagingInfo();
}
<tr>
<td>@Model.FirstNameTranscription</td>
<td>@Model.LastNameTranscription</td>
<td>@Model.OriginalName</td>
<td>@Model.BirthDate.ToString("dd.MM.yyyy.")</td>
<td>@Model.CountryName</td>
<td class="text-end">
<div class="d-inline-flex gap-2">
<a
asp-action="Edit"
asp-route-id="@Model.Id"
asp-route-page="@pagingInfo.CurrentPage"
asp-route-pageSize="@pagingInfo.ItemsPerPage"
asp-route-sorts="@pagingInfo.Sorts"
asp-route-searchText="@pagingInfo.SearchText"
class="btn btn-sm btn-outline-primary"
>
Edit
</a>
<form
asp-action="Delete"
asp-route-id="@Model.Id"
method="post"
class="d-inline">
<input type="hidden" name="page" value="@pagingInfo.CurrentPage" />
<input type="hidden" name="pageSize" value="@pagingInfo.ItemsPerPage" />
<input type="hidden" name="sorts" value="@pagingInfo.Sorts" />
<input type="hidden" name="searchText" value="@pagingInfo.SearchText" />
<button type="submit"
class="btn btn-sm btn-outline-danger delete">
Delete
</button>
</form>
</div>
</td>
</tr>

View File

@@ -0,0 +1,44 @@
@using MVC_SimpleCRUD.People
@model IEnumerable<PersonInfo>
@{
ViewData["Title"] = "People";
}
<h2>People list</h2>
<table class="table table-sm table-hover" id="table-people">
<thead>
<tr>
<th>First name</th>
<th>Last name</th>
<th>Original name</th>
<th>Birth date</th>
<th>Country</th>
</tr>
</thead>
<tbody>
@foreach (var person in Model)
{
<tr>
<td class="text-center">@person.FirstNameTranscription</td>
<td class="text-left">@person.LastNameTranscription</td>
<td class="text-center">@person.OriginalName</td>
<td class="text-center">@person.BirthDate.ToString("yyy-MM-dd")</td>
<td class="text-center">@person.CountryName</td>
</tr>
}
</tbody>
</table>
@section styles{
<link rel="stylesheet" href="https://cdn.datatables.net/2.3.7/css/dataTables.bootstrap5.min.css" />
}
@section scripts{
<script src="https://cdn.datatables.net/2.3.7/js/dataTables.min.js"></script>
<script src="https://cdn.datatables.net/2.3.7/js/dataTables.bootstrap5.min.js"></script>
<script>
$(function () {
new DataTable('#table-people');
});
</script>
}

View File

@@ -0,0 +1,69 @@
@using System.Text.Json
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - Events</title>
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
@RenderSection("Styles", required: false)
</head>
<body class="bg-body-tertiary">
<header class="border-bottom bg-white shadow-sm">
<nav class="navbar navbar-expand-lg">
<div class="container">
<a class="navbar-brand fw-semibold" asp-controller="Home" asp-action="Index">Home</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto gap-lg-2">
<li class="nav-item">
<a class="nav-link" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" asp-controller="PeopleSimple" asp-action="Index">People (simple version)</a>
</li>
<li class="nav-item">
<a class="nav-link" asp-controller="People" asp-action="Index">People</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
<main class="container py-4">
@RenderBody()
</main>
<div class="toast-container position-fixed top-0 end-0 p-3">
<div id="app-toast" class="toast border-0 shadow-sm" role="status" aria-live="polite" aria-atomic="true">
<div id="app-toast-header" class="toast-header">
<strong id="app-toast-title" class="me-auto">Notification</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div id="app-toast-body" class="toast-body"></div>
</div>
</div>
<script src="~/lib/jquery/jquery.min.js"></script>
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
<script src="~/js/pager.js" asp-append-version="true"></script>
<script>
@if (TempData[Constants.TempDataKeys.ToastMessage] is string toastMessage)
{
var initialToastJson = JsonSerializer.Serialize(new
{
variant = TempData[Constants.TempDataKeys.ToastVariant] as string ?? Constants.ToastVariants.Success,
title = TempData[Constants.TempDataKeys.ToastTitle] as string ?? Constants.ToastTitles.Notification,
message = toastMessage
});
@:showAppToast(@Html.Raw(initialToastJson));
}
</script>
@RenderSection("Scripts", required: false)
</body>
</html>

View File

@@ -0,0 +1,2 @@
<script src="~/lib/jquery-validate/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

View File

@@ -0,0 +1,4 @@
@using MVC_SimpleCRUD.Models
@using MVC_SimpleCRUD
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, MVC-SimpleCRUD

View File

@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Paging": {
"PageSize": 10,
"PageOffset": 5
},
"ConnectionStrings": {
"EventsMssql": "Data Source=.,3030;Initial Catalog=Events;User Id=************;Password=**********;TrustServerCertificate=True",
"EventsPostgres": "Host=localhost;Port=5432;Database=events;Username=***********;Password=**************;Persist Security Info=True"
}
}

View File

@@ -0,0 +1,32 @@
{
"version": "1.0",
"defaultProvider": "cdnjs",
"libraries": [
{
"library": "bootstrap@5.3.8",
"destination": "wwwroot/lib/bootstrap/",
"files": [
"css/bootstrap.min.css",
"js/bootstrap.bundle.min.js"
]
},
{
"library": "jquery@3.7.1",
"destination": "wwwroot/lib/jquery/"
},
{
"library": "jquery-validate@1.21.0",
"destination": "wwwroot/lib/jquery-validate/",
"files": [
"jquery.validate.min.js"
]
},
{
"library": "jquery-validation-unobtrusive@4.0.0",
"destination": "wwwroot/lib/jquery-validation-unobtrusive/",
"files": [
"jquery.validate.unobtrusive.min.js"
]
}
]
}

View File

@@ -0,0 +1,16 @@
main {
padding-left: 50px;
padding-right: 50px;
}
.validation-summary-errors { font-weight: bold; color:#a94442;}
.validation-summary-valid { display: none;}
.input-validation-error{
border-color:red;
}
.pagebox {
width: 45px;
}
.pagebox::selection{
background-color:grey;
}

View File

@@ -0,0 +1,48 @@
(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;
}
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 || "";
}
});
})();

View File

@@ -0,0 +1,30 @@
$(function () {
$(document).on('click', '.delete', function (event) {
if (!confirm("Delete entry?")) {
event.preventDefault();
}
});
});
function showAppToast(options) {
var toastOptions = options || {};
var variant = toastOptions.variant || "success";
var title = toastOptions.title || "Notification";
var message = toastOptions.message || "";
var toast = document.getElementById("app-toast");
var header = document.getElementById("app-toast-header");
var titleElement = document.getElementById("app-toast-title");
var body = document.getElementById("app-toast-body");
if (!toast || !header || !titleElement || !body) {
return;
}
toast.className = "toast border-0 shadow-sm";
header.className = "toast-header text-bg-" + variant;
titleElement.textContent = title;
body.textContent = message;
var bootstrapToast = new bootstrap.Toast(toast);
bootstrapToast.show();
}

View File

@@ -148,6 +148,7 @@ static List<string> BuildPersonInsertStatements(DatabaseTarget targetDatabase)
var faker = new Faker(locale);
var transliterationLanguage = locale.Split('_')[0];
var useTranscriptionForAddress = nonLatinLocales.Contains(transliterationLanguage);
var addressCountry = GetEnglishCountryName(countryCode);
for (var i = 0; i < peoplePerCountry; i++)
{
@@ -158,7 +159,6 @@ static List<string> BuildPersonInsertStatements(DatabaseTarget targetDatabase)
var city = NormalizeForStorage(faker.Address.City(), transliterationLanguage, useTranscriptionForAddress);
var addressLine = NormalizeForStorage(faker.Address.StreetAddress(), transliterationLanguage, useTranscriptionForAddress);
var postalCode = NormalizeForStorage(faker.Address.ZipCode(), transliterationLanguage, useTranscriptionForAddress);
var addressCountry = NormalizeForStorage(faker.Address.Country(), transliterationLanguage, useTranscriptionForAddress);
var email = faker.Internet.Email(firstNameTranscription, lastNameTranscription);
var contactPhone = NormalizePhoneNumber(faker.Phone.PhoneNumber());
var birthDate = faker.Date.BetweenDateOnly(new DateOnly(1950, 1, 1), new DateOnly(2010, 12, 31));
@@ -211,6 +211,9 @@ static string CreateInsertStatement(
static string EscapeSql(string value) => value.Replace("'", "''");
static string GetEnglishCountryName(string countryCode) =>
new System.Globalization.RegionInfo(countryCode).EnglishName;
static string NormalizeForStorage(string value, string transliterationLanguage, bool useTranscriptionForAddress) =>
useTranscriptionForAddress ? value.Transliterate(transliterationLanguage) : value;

View File

@@ -14,16 +14,16 @@ GO
CREATE TABLE dbo.Person (
Id int IDENTITY(1,1) NOT NULL,
FirstName varchar(100) NOT NULL,
LastName varchar(100) NOT NULL,
FirstName varchar(100) NULL,
LastName varchar(100) NULL,
FirstNameTranscription varchar(100) NOT NULL,
LastNameTranscription varchar(100) NOT NULL,
AddressLine varchar(200) NOT NULL,
PostalCode varchar(20) NOT NULL,
City varchar(100) NOT NULL,
AddressCountry varchar(100) NOT NULL,
Email varchar(255) NOT NULL,
ContactPhone varchar(50) NOT NULL,
AddressLine varchar(200) NULL,
PostalCode varchar(20) NULL,
City varchar(100) NULL,
AddressCountry varchar(100) NULL,
Email varchar(255) NULL,
ContactPhone varchar(50) NULL,
BirthDate date NOT NULL,
DocumentNumber varchar(50) NOT NULL,
CountryCode varchar(3) NOT NULL,

View File

@@ -11,16 +11,16 @@ CREATE TABLE country (
-- PERSONS
CREATE TABLE person (
id SERIAL PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
first_name_transcription VARCHAR(100) NOT NULL,
last_name_transcription VARCHAR(100) NOT NULL,
address_line VARCHAR(200) NOT NULL,
postal_code VARCHAR(20) NOT NULL,
city VARCHAR(100) NOT NULL,
address_country VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
contact_phone VARCHAR(50) NOT NULL,
address_line VARCHAR(200),
postal_code VARCHAR(20),
city VARCHAR(100),
address_country VARCHAR(100),
email VARCHAR(255),
contact_phone VARCHAR(50),
birth_date DATE NOT NULL,
document_number VARCHAR(50) NOT NULL,
country_code VARCHAR(3) NOT NULL,