MVC (layered variant)

This commit is contained in:
Boris Milašinović
2026-04-26 13:40:03 +02:00
parent 0ee1b22f61
commit 1415005b82
50 changed files with 2130 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>MVC_SimpleCRUD_Layered.Application</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
<PackageReference Include="Sieve" Version="2.5.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MVC-SimpleCRUD-Layered.Data\MVC-SimpleCRUD-Layered.Data.csproj" />
</ItemGroup>
</Project>

View File

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

View File

@@ -0,0 +1,37 @@
namespace MVC_SimpleCRUD_Layered.Application.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_Layered.Application.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,3 @@
namespace MVC_SimpleCRUD_Layered.Application.People;
public record CountryOption(string Code, string Name);

View File

@@ -0,0 +1,19 @@
namespace MVC_SimpleCRUD_Layered.Application.People;
public record DeletePersonResult(bool Found, bool Success, string? PersonName, string? ErrorMessage)
{
public static DeletePersonResult NotFound()
{
return new DeletePersonResult(false, false, null, null);
}
public static DeletePersonResult Deleted(string personName)
{
return new DeletePersonResult(true, true, personName, null);
}
public static DeletePersonResult Failed(string personName, string errorMessage)
{
return new DeletePersonResult(true, false, personName, errorMessage);
}
}

View File

@@ -0,0 +1,20 @@
using MVC_SimpleCRUD_Layered.Application.Models;
namespace MVC_SimpleCRUD_Layered.Application.People;
public interface IPeopleService
{
Task<List<PersonInfo>> GetAllForSimpleListAsync();
Task<PagedList<PersonInfo>> GetPagedListAsync(PeopleIndexRequest request);
Task<PersonForm?> GetFormForEditAsync(int id, PeopleIndexRequest returnRequest);
Task<List<CountryOption>> GetCountryOptionsAsync();
Task<SavePersonResult> CreateAsync(PersonForm form);
Task<SavePersonResult> UpdateAsync(int id, PersonForm form);
Task<DeletePersonResult> DeleteAsync(int id);
}

View File

@@ -0,0 +1,12 @@
namespace MVC_SimpleCRUD_Layered.Application.People;
public class PeopleIndexRequest
{
public int Page { get; set; } = 1;
public int PageSize { get; set; }
public string? Sorts { get; set; }
public string? SearchText { get; set; }
}

View File

@@ -0,0 +1,221 @@
#if POSTGRES
using MVC_SimpleCRUD_Layered.Data.Data.Postgres;
#else
using MVC_SimpleCRUD_Layered.Data.Data.MSSQL;
#endif
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using MVC_SimpleCRUD_Layered.Application.Models;
using MVC_SimpleCRUD_Layered.Application.Util.Extensions;
using MVC_SimpleCRUD_Layered.Data.Models;
using Sieve.Models;
using Sieve.Services;
namespace MVC_SimpleCRUD_Layered.Application.People;
public class PeopleService : IPeopleService
{
private readonly EventsContext ctx;
private readonly ISieveProcessor sieveProcessor;
private readonly PagingSettings pagingSettings;
public PeopleService(EventsContext ctx, IOptionsSnapshot<PagingSettings> pagingSettings, ISieveProcessor sieveProcessor)
{
this.ctx = ctx;
this.sieveProcessor = sieveProcessor;
this.pagingSettings = pagingSettings.Value;
}
public async Task<List<PersonInfo>> GetAllForSimpleListAsync()
{
return await ProjectPeople
.OrderBy(p => p.LastNameTranscription)
.ToListAsync();
}
public async Task<PagedList<PersonInfo>> GetPagedListAsync(PeopleIndexRequest request)
{
var page = request.Page < 1 ? 1 : request.Page;
var pageSize = request.PageSize <= 0 ? pagingSettings.PageSize : request.PageSize;
var sorts = request.Sorts.NullIfWhiteSpace() ?? nameof(PersonInfo.LastNameTranscription);
var sieveModel = new SieveModel
{
Page = page,
PageSize = pageSize,
Sorts = sorts
};
var totalCount = await ctx.People.CountAsync();
var query = ProjectPeople;
int filteredCount;
var searchText = request.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)
{
pagingInfo.CurrentPage = pagingInfo.TotalPages;
sieveModel.Page = pagingInfo.CurrentPage;
}
var people = filteredCount > 0
? await sieveProcessor.Apply(sieveModel, query).ToListAsync()
: [];
return new PagedList<PersonInfo>(people, pagingInfo);
}
public async Task<PersonForm?> GetFormForEditAsync(int id, PeopleIndexRequest returnRequest)
{
var person = await ctx.People.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
return person is null ? null : CreateForm(person, returnRequest);
}
public async Task<List<CountryOption>> GetCountryOptionsAsync()
{
return await ctx.Countries
.AsNoTracking()
.OrderBy(d => d.Name)
.Select(d => new CountryOption(d.Code, d.Name))
.ToListAsync();
}
public async Task<SavePersonResult> CreateAsync(PersonForm form)
{
var person = new Person();
CopyToPerson(form, person);
ctx.People.Add(person);
try
{
await ctx.SaveChangesAsync();
return SavePersonResult.Ok(GetDisplayName(person));
}
catch (DbUpdateException exc)
{
return SavePersonResult.Failed(GetErrorMessage(exc));
}
}
public async Task<SavePersonResult> UpdateAsync(int id, PersonForm form)
{
var person = await ctx.People.FindAsync(id);
if (person is null)
{
return SavePersonResult.Failed($"Person with id {id} was not found.");
}
CopyToPerson(form, person);
try
{
await ctx.SaveChangesAsync();
return SavePersonResult.Ok(GetDisplayName(person));
}
catch (DbUpdateException exc)
{
return SavePersonResult.Failed(GetErrorMessage(exc));
}
}
public async Task<DeletePersonResult> DeleteAsync(int id)
{
var person = await ctx.People.FindAsync(id);
if (person is null)
{
return DeletePersonResult.NotFound();
}
var personName = GetDisplayName(person);
ctx.People.Remove(person);
try
{
await ctx.SaveChangesAsync();
return DeletePersonResult.Deleted(personName);
}
catch (DbUpdateException exc)
{
return DeletePersonResult.Failed(personName, GetErrorMessage(exc));
}
}
private IQueryable<PersonInfo> ProjectPeople => 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,
});
private static PersonForm CreateForm(Person person, PeopleIndexRequest returnRequest)
{
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 = returnRequest.Page,
PageSize = returnRequest.PageSize,
Sorts = returnRequest.Sorts,
SearchText = returnRequest.SearchText
};
}
private static void CopyToPerson(PersonForm form, Person person)
{
person.FirstName = form.FirstName.NullIfWhiteSpace();
person.LastName = form.LastName.NullIfWhiteSpace();
person.FirstNameTranscription = form.FirstNameTranscription;
person.LastNameTranscription = form.LastNameTranscription;
person.AddressLine = form.AddressLine.NullIfWhiteSpace();
person.PostalCode = form.PostalCode.NullIfWhiteSpace();
person.City = form.City.NullIfWhiteSpace();
person.AddressCountry = form.AddressCountry.NullIfWhiteSpace();
person.Email = form.Email.NullIfWhiteSpace();
person.ContactPhone = form.ContactPhone.NullIfWhiteSpace();
person.BirthDate = form.BirthDate!.Value;
person.DocumentNumber = form.DocumentNumber;
person.CountryCode = form.CountryCode;
}
private static string GetDisplayName(Person person) => $"{person.FirstNameTranscription} {person.LastNameTranscription}";
private static string GetErrorMessage(DbUpdateException exc) => exc.InnerException?.Message ?? exc.Message;
}

View File

@@ -0,0 +1,75 @@
using System.ComponentModel.DataAnnotations;
namespace MVC_SimpleCRUD_Layered.Application.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(
"Email or contact phone is required.",
new[] { nameof(Email), nameof(ContactPhone) });
}
}
}

View File

@@ -0,0 +1,30 @@
using Sieve.Attributes;
namespace MVC_SimpleCRUD_Layered.Application.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,14 @@
namespace MVC_SimpleCRUD_Layered.Application.People;
public record SavePersonResult(bool Success, string? PersonName, string? ErrorMessage)
{
public static SavePersonResult Ok(string personName)
{
return new SavePersonResult(true, personName, null);
}
public static SavePersonResult Failed(string errorMessage)
{
return new SavePersonResult(false, null, errorMessage);
}
}

View File

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