MVC (layered variant)
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace MVC_SimpleCRUD_Layered.Application.Models;
|
||||
|
||||
public record PagedList<T>(List<T> Data, PagingInfo PagingInfo);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace MVC_SimpleCRUD_Layered.Application.People;
|
||||
|
||||
public record CountryOption(string Code, string Name);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user