From 3d63a85bdd0f69d57b73c3c6255ac8a7746989e6 Mon Sep 17 00:00:00 2001 From: Felix-CodingClimber Date: Thu, 8 Feb 2024 22:00:04 +0100 Subject: [PATCH] Some updated to mudblazor crud components and ModelBase. --- src/DotNetElements.Core/Core/ModelBase.cs | 2 +- .../Core/RelatedEntitiesAttribute.cs | 12 ++ src/DotNetElements.Core/Core/Repository.cs | 47 +++--- .../CrudOptions.cs | 41 ++++++ .../CrudService.cs | 137 ++++++++++++++++++ src/DotNetElements.Web.MudBlazor/CrudTable.cs | 5 +- .../CrudTableOptions.cs | 23 +-- .../DneDataAnnotationsValidator.cs | 33 +++++ .../Extensions/EditContextExtensions.cs | 21 +++ .../Extensions/ServiceCollectionExtensions.cs | 20 +++ .../GlobalUsing.cs | 4 +- 11 files changed, 302 insertions(+), 43 deletions(-) create mode 100644 src/DotNetElements.Web.MudBlazor/CrudOptions.cs create mode 100644 src/DotNetElements.Web.MudBlazor/CrudService.cs create mode 100644 src/DotNetElements.Web.MudBlazor/DneDataAnnotationsValidator.cs create mode 100644 src/DotNetElements.Web.MudBlazor/Extensions/EditContextExtensions.cs create mode 100644 src/DotNetElements.Web.MudBlazor/Extensions/ServiceCollectionExtensions.cs diff --git a/src/DotNetElements.Core/Core/ModelBase.cs b/src/DotNetElements.Core/Core/ModelBase.cs index f5d5f28..308db1b 100644 --- a/src/DotNetElements.Core/Core/ModelBase.cs +++ b/src/DotNetElements.Core/Core/ModelBase.cs @@ -1,7 +1,7 @@ namespace DotNetElements.Core; public interface IModel : IHasKey - where TKey : notnull, IEquatable; + where TKey : notnull, IEquatable; public interface IEditModel where TModel : Model diff --git a/src/DotNetElements.Core/Core/RelatedEntitiesAttribute.cs b/src/DotNetElements.Core/Core/RelatedEntitiesAttribute.cs index 5a102b5..054377f 100644 --- a/src/DotNetElements.Core/Core/RelatedEntitiesAttribute.cs +++ b/src/DotNetElements.Core/Core/RelatedEntitiesAttribute.cs @@ -11,3 +11,15 @@ public RelatedEntitiesAttribute(string[] referenceProperties) } } + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] +public class RelatedEntitiesCollectionsAttribute : Attribute +{ + public string[] ReferenceProperties { get; private init; } + + public RelatedEntitiesCollectionsAttribute(string[] referenceProperties) + { + ReferenceProperties = referenceProperties; + + } +} diff --git a/src/DotNetElements.Core/Core/Repository.cs b/src/DotNetElements.Core/Core/Repository.cs index 664eb1b..c6c15f3 100644 --- a/src/DotNetElements.Core/Core/Repository.cs +++ b/src/DotNetElements.Core/Core/Repository.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using System.Reflection; +using Microsoft.EntityFrameworkCore.ChangeTracking; namespace DotNetElements.Core; @@ -12,6 +13,7 @@ public abstract class Repository : ReadOnlyRepository protected TimeProvider TimeProvider { get; private init; } protected static readonly RelatedEntitiesAttribute? RelatedEntities = typeof(TEntity).GetCustomAttribute(); + protected static readonly RelatedEntitiesCollectionsAttribute? RelatedEntitiesCollections = typeof(TEntity).GetCustomAttribute(); public Repository(TDbContext dbContext, ICurrentUserProvider currentUserProvider, TimeProvider timeProvider) : base(dbContext) { @@ -35,18 +37,11 @@ public virtual async Task> CreateAsync(TEntity entity, Expre if (entity is ICreationAuditedEntity auditedEntity) auditedEntity.SetCreationAudited(CurrentUserProvider.GetCurrentUserId(), TimeProvider.GetUtcNow()); - var createdEntity = Entities.Attach(entity); + EntityEntry createdEntity = Entities.Attach(entity); await DbContext.SaveChangesAsync(); - // todo, review this part. Maybe add parameter returnRelatedEntities? - // (Was needed, so referenced entities got included when returning entity) - // Check if we can batch the loadings - if (RelatedEntities is not null) - { - foreach (string relatedProperty in RelatedEntities.ReferenceProperties) - await createdEntity.Reference(relatedProperty).LoadAsync(); - } + await LoadRelatedEntities(createdEntity); return createdEntity.Entity; } @@ -71,7 +66,7 @@ public virtual async Task> CreateOrUpdateAsync(TKey id, if (entity is ICreationAuditedEntity auditedEntity) auditedEntity.SetCreationAudited(CurrentUserProvider.GetCurrentUserId(), TimeProvider.GetUtcNow()); - var createdEntity = DbContext.Set().Attach(entity); + EntityEntry createdEntity = DbContext.Set().Attach(entity); await DbContext.SaveChangesAsync(); @@ -130,14 +125,7 @@ public virtual async Task> UpdateAsync(TKey id, TFrom return CrudResult.ConcurrencyConflict(); } - // todo, review this part. Maybe add parameter returnRelatedEntities? - // (Was needed, so referenced entities got updated/included) - // Check if we can batch the loadings - if (RelatedEntities is not null) - { - foreach (string relatedProperty in RelatedEntities.ReferenceProperties) - await DbContext.Entry(existingEntity).Reference(relatedProperty).LoadAsync(); - } + await LoadRelatedEntities(existingEntity); return CrudResult.OkIfNotNull(existingEntity, CrudError.Unknown); } @@ -258,4 +246,27 @@ protected async Task SaveChangesWithVersionCheckAsync() return true; } + + private Task LoadRelatedEntities(TEntity entity) + { + return LoadRelatedEntities(DbContext.Entry(entity)); + } + + // todo, review loading related entities. Maybe add parameter returnRelatedEntities? + // (Was needed, so referenced entities got included when returning entity) + // Check if we can batch the loadings + private static async Task LoadRelatedEntities(EntityEntry entity) + { + if (RelatedEntities is not null) + { + foreach (string relatedProperty in RelatedEntities.ReferenceProperties) + await entity.Reference(relatedProperty).LoadAsync(); + } + + if (RelatedEntitiesCollections is not null) + { + foreach (string relatedProperty in RelatedEntitiesCollections.ReferenceProperties) + await entity.Collection(relatedProperty).LoadAsync(); + } + } } \ No newline at end of file diff --git a/src/DotNetElements.Web.MudBlazor/CrudOptions.cs b/src/DotNetElements.Web.MudBlazor/CrudOptions.cs new file mode 100644 index 0000000..56dbb22 --- /dev/null +++ b/src/DotNetElements.Web.MudBlazor/CrudOptions.cs @@ -0,0 +1,41 @@ +namespace DotNetElements.Web.MudBlazor; + +public class CrudOptions +{ + public string BaseEndpointUri { get; private init; } + + private string getAllEndpoint = null!; + public string GetAllEndpoint + { + get => getAllEndpoint; + init + { + getAllEndpoint = $"{BaseEndpointUri.TrimEnd('/')}"; + + if (!string.IsNullOrEmpty(value)) + getAllEndpoint += $"/{value.TrimStart('/')}"; + } + } + + private string getAllWithDetailsEndpoint = null!; + public string GetAllWithDetailsEndpoint + { + get => getAllWithDetailsEndpoint; + init + { + getAllWithDetailsEndpoint = $"{BaseEndpointUri.TrimEnd('/')}"; + + if (!string.IsNullOrEmpty(value)) + getAllWithDetailsEndpoint += $"/{value.TrimStart('/')}"; + } + } + + public string GetDetailsEndpoint(string id) => $"{BaseEndpointUri.TrimEnd('/')}/{id}/details"; + + public CrudOptions(string baseEndpointUri) + { + BaseEndpointUri = baseEndpointUri; + GetAllEndpoint = ""; + GetAllWithDetailsEndpoint = ""; + } +} diff --git a/src/DotNetElements.Web.MudBlazor/CrudService.cs b/src/DotNetElements.Web.MudBlazor/CrudService.cs new file mode 100644 index 0000000..667e199 --- /dev/null +++ b/src/DotNetElements.Web.MudBlazor/CrudService.cs @@ -0,0 +1,137 @@ +namespace DotNetElements.Web.MudBlazor; + +public interface ICrudService + where TKey : notnull, IEquatable + where TModel : IModel + where TDetails : ModelDetails + where TEditModel : IMapFromModel, ICreateNew +{ + Task> CreateEntryAsync(TEditModel editModel); + Task DeleteEntryAsync(TModel model); + Task>> GetAllEntriesAsync(); + Task>>> GetAllEntriesWithDetailsAsync(); + Task GetEntryDetailsAsync(ModelWithDetails modelWithDetails); + Task> UpdateEntryAsync(TEditModel editModel); +} + +public class CrudService : ICrudService where TKey : notnull, IEquatable + where TModel : IModel + where TDetails : ModelDetails + where TEditModel : IMapFromModel, ICreateNew +{ + protected readonly ISnackbar Snackbar; + protected readonly HttpClient HttpClient; + protected readonly CrudOptions Options; + + public CrudService(ISnackbar snackbar, HttpClient httpClient, CrudOptions options) + { + Snackbar = snackbar; + HttpClient = httpClient; + Options = options; + } + + public virtual async Task> CreateEntryAsync(TEditModel editModel) + { + Result result = await HttpClient.PutAsJsonWithResultAsync(Options.BaseEndpointUri, editModel); + + // todo add logging + // todo wrap Snackbar call in bool option NotifyUser + // todo add function OnDeleteSuccess + if (result.IsOk) + { + Snackbar.Add("Entry saved", Severity.Success); + } + else + { + Snackbar.Add("Failed to save entry", Severity.Error); + } + + return result; + } + + public virtual async Task> UpdateEntryAsync(TEditModel editModel) + { + Result result = await HttpClient.PostAsJsonWithResultAsync(Options.BaseEndpointUri, editModel); + + // todo add logging + // todo wrap Snackbar call in bool option NotifyUser + // todo add function OnDeleteSuccess + if (result.IsOk) + { + Snackbar.Add("Changes saved", Severity.Success); + } + else + { + Snackbar.Add("Failed to save changes", Severity.Error); + } + + return result; + } + + public virtual async Task DeleteEntryAsync(TModel model) + { + Result result = await HttpClient.DeleteWithResultAsync(Options.BaseEndpointUri, model); + + // todo add logging + // todo wrap Snackbar call in bool option NotifyUser + // todo add function OnDeleteSuccess + if (result.IsOk) + { + Snackbar.Add("Entry deleted", Severity.Success); + } + else + { + Snackbar.Add("Failed to delete entry", Severity.Error); + } + + return result; + } + + public virtual async Task GetEntryDetailsAsync(ModelWithDetails modelWithDetails) + { + Result detailsResult = await HttpClient.GetFromJsonWithResultAsync(Options.GetDetailsEndpoint(modelWithDetails.Value.Id.ToString())); + + // todo add logging + // todo wrap Snackbar call in bool option NotifyUser + // todo add function OnDeleteSuccess + if (detailsResult.IsFail) + { + Snackbar.Add($"Failed to fetch details.\n{detailsResult.ErrorMessage}", Severity.Error); + return detailsResult; + } + + modelWithDetails.Details = detailsResult.Value; + + return detailsResult; + } + + public virtual async Task>> GetAllEntriesAsync() + { + Result> result = await HttpClient.GetFromJsonWithResultAsync>(Options.GetAllEndpoint); + + // todo add logging + // todo wrap Snackbar call in bool option NotifyUser + // todo add function OnDeleteSuccess + if (result.IsFail) + { + Snackbar.Add("Failed to fetch entries from server", Severity.Error); + } + + return result; + } + + public virtual async Task>>> GetAllEntriesWithDetailsAsync() + { + Result>> result = await HttpClient.GetModelWithDetailsListFromJsonAsync(Options.GetAllEndpoint); + + // todo add logging + // todo wrap Snackbar call in bool option NotifyUser + // todo add function OnDeleteSuccess + if (result.IsFail) + { + Snackbar.Add("Failed to fetch entries from server", Severity.Error); + } + + return result; + } +} diff --git a/src/DotNetElements.Web.MudBlazor/CrudTable.cs b/src/DotNetElements.Web.MudBlazor/CrudTable.cs index 59a15e2..55bfd25 100644 --- a/src/DotNetElements.Web.MudBlazor/CrudTable.cs +++ b/src/DotNetElements.Web.MudBlazor/CrudTable.cs @@ -5,6 +5,7 @@ public static class CrudTable public static readonly DialogOptions DefaultEditDialogOptions = new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, DisableBackdropClick = true }; } +// todo used CrudService public abstract class CrudTable : MudComponentBase where TKey : notnull, IEquatable where TModel : IModel @@ -131,7 +132,7 @@ protected async Task OnShowEntryDetails(ModelWithDetails conte return; } - Result details = await HttpClient.GetFromJsonWithResultAsync(string.Format(options.GetDetailsEndpoint(context.Value.Id.ToString()), context.Value.Id)); + Result details = await HttpClient.GetFromJsonWithResultAsync(options.GetDetailsEndpoint(context.Value.Id.ToString())); if (details.IsFail) { @@ -145,7 +146,7 @@ protected async Task OnShowEntryDetails(ModelWithDetails conte protected async Task UpdateEntries() { - Result>> result = await HttpClient.GetModelWithDetailsListFromJsonAsync(options.GetAllEndpoint); + Result>> result = await HttpClient.GetModelWithDetailsListFromJsonAsync(options.GetAllWithDetailsEndpoint); if (result.IsFail) { diff --git a/src/DotNetElements.Web.MudBlazor/CrudTableOptions.cs b/src/DotNetElements.Web.MudBlazor/CrudTableOptions.cs index f8ac49a..9b1909c 100644 --- a/src/DotNetElements.Web.MudBlazor/CrudTableOptions.cs +++ b/src/DotNetElements.Web.MudBlazor/CrudTableOptions.cs @@ -1,33 +1,14 @@ namespace DotNetElements.Web.MudBlazor; -public class CrudTableOptions +public class CrudTableOptions : CrudOptions { - public string BaseEndpointUri { get; private init; } - - private string getAllEndpoint = null!; - public string GetAllEndpoint - { - get => getAllEndpoint; - init - { - getAllEndpoint = $"{BaseEndpointUri.TrimEnd('/')}"; - - if (!string.IsNullOrEmpty(value)) - getAllEndpoint += $"/{value.TrimStart('/')}"; - } - } - public required string DeleteEntryLabel { get; set; } public required Func DeleteEntryValue { get; set; } - public string GetDetailsEndpoint(string id) => $"{BaseEndpointUri.TrimEnd('/')}/{id}/details"; - public DialogOptions EditDialogOptions { get; init; } - public CrudTableOptions(string baseEndpointUri) + public CrudTableOptions(string baseEndpointUri) : base(baseEndpointUri) { - BaseEndpointUri = baseEndpointUri; - GetAllEndpoint = ""; EditDialogOptions = CrudTable.DefaultEditDialogOptions; } diff --git a/src/DotNetElements.Web.MudBlazor/DneDataAnnotationsValidator.cs b/src/DotNetElements.Web.MudBlazor/DneDataAnnotationsValidator.cs new file mode 100644 index 0000000..45da223 --- /dev/null +++ b/src/DotNetElements.Web.MudBlazor/DneDataAnnotationsValidator.cs @@ -0,0 +1,33 @@ +namespace DotNetElements.Web.MudBlazor; + +public class DneDataAnnotationsValidator : DataAnnotationsValidator +{ +#if DEBUG + [CascadingParameter] EditContext? DebugEditContext { get; set; } + + protected override void OnInitialized() + { + base.OnInitialized(); + + if (DebugEditContext is null) + { + throw new InvalidOperationException($"{nameof(DneDataAnnotationsValidator)} requires a cascading " + + $"parameter of type {nameof(EditContext)}. For example, you can use {nameof(DneDataAnnotationsValidator)} " + + $"inside an EditForm."); + } + + DebugEditContext.OnValidationRequested += DebugEditContext_OnValidationRequested; + } + + protected override void Dispose(bool disposing) + { + if (disposing && DebugEditContext is not null) + DebugEditContext.OnValidationRequested -= DebugEditContext_OnValidationRequested; + } + + private void DebugEditContext_OnValidationRequested(object? sender, ValidationRequestedEventArgs e) + { + DebugEditContext.LogDebugInfo(); // Debug only + } +#endif +} diff --git a/src/DotNetElements.Web.MudBlazor/Extensions/EditContextExtensions.cs b/src/DotNetElements.Web.MudBlazor/Extensions/EditContextExtensions.cs new file mode 100644 index 0000000..2a73df5 --- /dev/null +++ b/src/DotNetElements.Web.MudBlazor/Extensions/EditContextExtensions.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace DotNetElements.Web.MudBlazor.Extensions; + +public static class EditContextExtensions +{ + [Conditional("DEBUG")] + public static void LogDebugInfo(this EditContext? editContext, [CallerMemberName]string? callerMemberName = null) + { + if (editContext is null) + return; + + Console.WriteLine($"DEBUG EditContext validation messages. Context: {callerMemberName}"); + + foreach (string message in editContext.GetValidationMessages()) + { + Console.WriteLine(message); + } + } +} diff --git a/src/DotNetElements.Web.MudBlazor/Extensions/ServiceCollectionExtensions.cs b/src/DotNetElements.Web.MudBlazor/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..5a511b5 --- /dev/null +++ b/src/DotNetElements.Web.MudBlazor/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace DotNetElements.Web.MudBlazor.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCrudService(this IServiceCollection services, CrudOptions options) + where TKey : notnull, IEquatable + where TModel : IModel + where TDetails : ModelDetails + where TEditModel : IMapFromModel, ICreateNew + { + services.AddScoped>(provider => new CrudService( + provider.GetRequiredService(), + provider.GetRequiredService(), + options)); + + return services; + } +} diff --git a/src/DotNetElements.Web.MudBlazor/GlobalUsing.cs b/src/DotNetElements.Web.MudBlazor/GlobalUsing.cs index eb4e935..7792270 100644 --- a/src/DotNetElements.Web.MudBlazor/GlobalUsing.cs +++ b/src/DotNetElements.Web.MudBlazor/GlobalUsing.cs @@ -3,4 +3,6 @@ global using MudBlazor; -global using DotNetElements.Core; \ No newline at end of file +global using DotNetElements.Core; + +global using DotNetElements.Web.MudBlazor.Extensions; \ No newline at end of file