diff --git a/.gitignore b/.gitignore index dfcfd56f4..b02fdef0a 100644 --- a/.gitignore +++ b/.gitignore @@ -348,3 +348,6 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ + + +**/Files/ diff --git a/README.md b/README.md index 60bf6c4cf..fa540afcb 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,31 @@ # CleanArchitectureWithBlazorServer This is a repository for creating a Blazor Server application following the principles of Clean Architecture ## Live Demo -- Blazor webassembly mode: https://mudblazor.dotnet6.cn/ - Blazor Server mode: https://mudblazor-s.dotnet6.cn/ -## Screenshots -![doc/login_screenshot.png](doc/login_screenshot.png) -![doc/main_screenshot.png](doc/main_screenshot.png) \ No newline at end of file +## Screenshots and video +![doc/main_screenshot.png](doc/main_screenshot.png) + +## Development Enviroment +- Microsoft Visual Studio Community 2022 (64-bit) +- Docker +- .NET 6.0 +## Code generation +- CleanArchitectureCodeGenerator +- https://github.com/neozhu/CleanArchitectureCodeGenerator +## Why develop with blazor server mode +- Develop fast +- Runing fast +- Most simple + +## Characteristic +- Clean principles +- Simple principles +- Easy to start with + +## About +Coming up. + + + +## License +Apache 2.0 \ No newline at end of file diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index f6b2c3d47..4f9367b08 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -1,10 +1,11 @@ - + net6.0 CleanArchitecture.Blazor.Application CleanArchitecture.Blazor.Application enable + enable @@ -23,12 +24,12 @@ - - + + - + - + diff --git a/src/Application/Common/Models/PaginatedData.cs b/src/Application/Common/Models/PaginatedData.cs index 8b3ef7939..63b8718ed 100644 --- a/src/Application/Common/Models/PaginatedData.cs +++ b/src/Application/Common/Models/PaginatedData.cs @@ -5,12 +5,12 @@ namespace CleanArchitecture.Blazor.Application.Common.Models; public class PaginatedData { - public int total { get; set; } - public IEnumerable rows { get; set; } + public int TotalItems { get; set; } + public IEnumerable Items { get; set; } public PaginatedData(IEnumerable items, int total) { - this.rows = items; - this.total = total; + this.Items = items; + this.TotalItems = total; } public static async Task> CreateAsync(IQueryable source, int pageIndex, int pageSize) { diff --git a/src/Application/Common/Models/PaginationFilter.cs b/src/Application/Common/Models/PaginationFilter.cs new file mode 100644 index 000000000..b4268c380 --- /dev/null +++ b/src/Application/Common/Models/PaginationFilter.cs @@ -0,0 +1,23 @@ +namespace CleanArchitecture.Blazor.Application.Common.Models; + +public partial class PaginationFilter : BaseFilter +{ + public int PageNumber { get; set; } + public int PageSize { get; set; } + public string OrderBy { get; set; } + public string SortDirection { get; set; } +} + +public class BaseFilter +{ + public Search AdvancedSearch { get; set; } + + public string Keyword { get; set; } +} + +public partial class Search +{ + public ICollection Fields { get; set; } + public string Keyword { get; set; } + +} diff --git a/src/Application/Features/Products/Commands/AcceptChanges/AcceptChangesProductCommand.cs b/src/Application/Features/Products/Commands/AcceptChanges/AcceptChangesProductCommand.cs deleted file mode 100644 index f16defda2..000000000 --- a/src/Application/Features/Products/Commands/AcceptChanges/AcceptChangesProductCommand.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using CleanArchitecture.Blazor.Application.Features.Products.DTOs; - -namespace CleanArchitecture.Blazor.Application.Features.Products.Commands.AcceptChanges; - -public class AcceptChangesProductsCommand:IRequest - { - public ProductDto[] Items { get; set; } - } - - public class AcceptChangesProductsCommandHandler : IRequestHandler - { - private readonly IApplicationDbContext _context; - private readonly IMapper _mapper; - - public AcceptChangesProductsCommandHandler( - IApplicationDbContext context, - IMapper mapper - ) - { - _context = context; - _mapper = mapper; - } - public async Task Handle(AcceptChangesProductsCommand request, CancellationToken cancellationToken) - { - //TODO:Implementing AcceptChangesProductsCommandHandler method - foreach(var item in request.Items) - { - switch (item.TrackingState) - { - case TrackingState.Added: - var newitem = _mapper.Map(item); - await _context.Products.AddAsync(newitem, cancellationToken); - break; - case TrackingState.Deleted: - var delitem =await _context.Products.FindAsync(new object[] { item.Id }, cancellationToken); - _context.Products.Remove(delitem); - break; - case TrackingState.Modified: - var edititem = await _context.Products.FindAsync(new object[] { item.Id }, cancellationToken); - //ex. edititem.Name = item.Name; - _context.Products.Update(edititem); - break; - case TrackingState.Unchanged: - default: - break; - } - } - - await _context.SaveChangesAsync(cancellationToken); - return Result.Success(); - - } - } - diff --git a/src/Application/Features/Products/Commands/AcceptChanges/AcceptChangesProductCommandValidator.cs b/src/Application/Features/Products/Commands/AcceptChanges/AcceptChangesProductCommandValidator.cs deleted file mode 100644 index b7e17841c..000000000 --- a/src/Application/Features/Products/Commands/AcceptChanges/AcceptChangesProductCommandValidator.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace CleanArchitecture.Blazor.Application.Features.Products.Commands.AcceptChanges; - - public class AcceptChangesProductCommandValidator : AbstractValidator - { - public AcceptChangesProductCommandValidator() - { - //TODO:Implementing AddEditProductCommandValidator method - //ex. RuleFor(v => v.Items) - // .NotNull() - // .NotEmpty(); - throw new System.NotImplementedException(); - - } - } - diff --git a/src/Application/Features/Products/Commands/AddEdit/AddEditProductCommandValidator.cs b/src/Application/Features/Products/Commands/AddEdit/AddEditProductCommandValidator.cs index 1628768e1..01f74f91c 100644 --- a/src/Application/Features/Products/Commands/AddEdit/AddEditProductCommandValidator.cs +++ b/src/Application/Features/Products/Commands/AddEdit/AddEditProductCommandValidator.cs @@ -1,17 +1,30 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. namespace CleanArchitecture.Blazor.Application.Features.Products.Commands.AddEdit; - public class AddEditProductCommandValidator : AbstractValidator +public class AddEditProductCommandValidator : AbstractValidator +{ + public AddEditProductCommandValidator() { - public AddEditProductCommandValidator() - { - //TODO:Implementing AddEditProductCommandValidator method - //ex. RuleFor(v => v.Name) - // .MaximumLength(256) - // .NotEmpty(); - throw new System.NotImplementedException(); - } + + RuleFor(v => v.Name) + .MaximumLength(256) + .NotEmpty(); + RuleFor(v => v.Unit) + .MaximumLength(2) + .NotEmpty(); + RuleFor(v => v.Price) + .GreaterThanOrEqualTo(0); + } + public Func>> ValidateValue => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((AddEditProductCommand)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) + return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; +} + diff --git a/src/Application/Features/Products/Commands/Delete/DeleteProductCommandValidator.cs b/src/Application/Features/Products/Commands/Delete/DeleteProductCommandValidator.cs index 82bf2f7e6..6f49b30f5 100644 --- a/src/Application/Features/Products/Commands/Delete/DeleteProductCommandValidator.cs +++ b/src/Application/Features/Products/Commands/Delete/DeleteProductCommandValidator.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. namespace CleanArchitecture.Blazor.Application.Features.Products.Commands.Delete; @@ -7,18 +7,18 @@ public class DeleteProductCommandValidator : AbstractValidator v.Id).NotNull().GreaterThan(0); - throw new System.NotImplementedException(); + + RuleFor(v => v.Id).NotNull().GreaterThan(0); + } } public class DeleteCheckedProductsCommandValidator : AbstractValidator { public DeleteCheckedProductsCommandValidator() { - //TODO:Implementing DeleteProductCommandValidator method - //ex. RuleFor(v => v.Id).NotNull().NotEmpty(); - throw new System.NotImplementedException(); + + RuleFor(v => v.Id).NotNull().NotEmpty(); + } } diff --git a/src/Application/Features/Products/Commands/Import/ImportProductsCommand.cs b/src/Application/Features/Products/Commands/Import/ImportProductsCommand.cs index cad3203d5..11a1906b8 100644 --- a/src/Application/Features/Products/Commands/Import/ImportProductsCommand.cs +++ b/src/Application/Features/Products/Commands/Import/ImportProductsCommand.cs @@ -40,13 +40,29 @@ IMapper mapper } public async Task Handle(ImportProductsCommand request, CancellationToken cancellationToken) { - //TODO:Implementing ImportProductsCommandHandler method + var result = await _excelService.ImportAsync(request.Data, mappers: new Dictionary> { - //ex. { _localizer["Name"], (row,item) => item.Name = row[_localizer["Name"]]?.ToString() }, - + { _localizer["Product Name"], (row,item) => item.Name = row[_localizer["Product Name"]]?.ToString() }, + { _localizer["Description"], (row,item) => item.Description = row[_localizer["Description"]]?.ToString() }, + { _localizer["Unit"], (row,item) => item.Unit = row[_localizer["Unit"]]?.ToString() }, + { _localizer["Price of unit"], (row,item) => item.Price =row.IsNull(_localizer["Price of unit"])? 0m:Convert.ToDecimal(row[_localizer["Price of unit"]]) }, + { _localizer["Pictures"], (row,item) => item.Pictures =row.IsNull(_localizer["Pictures"])? null:row[_localizer["Pictures"]].ToString().Split(",").ToList() }, }, _localizer["Products"]); - throw new System.NotImplementedException(); + if (result.Succeeded) + { + foreach(var dto in result.Data) + { + var item = _mapper.Map(dto); + await _context.Products.AddAsync(item,cancellationToken); + } + await _context.SaveChangesAsync(cancellationToken); + return Result.Success(); + } + else + { + return Result.Failure(result.Errors); + } } public async Task Handle(CreateProductsTemplateCommand request, CancellationToken cancellationToken) { diff --git a/src/Application/Features/Products/Commands/Import/ImportProductsCommandValidator.cs b/src/Application/Features/Products/Commands/Import/ImportProductsCommandValidator.cs index 4e9b5b7ef..d175c6c12 100644 --- a/src/Application/Features/Products/Commands/Import/ImportProductsCommandValidator.cs +++ b/src/Application/Features/Products/Commands/Import/ImportProductsCommandValidator.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. namespace CleanArchitecture.Blazor.Application.Features.Products.Commands.Import; @@ -7,11 +7,11 @@ public class ImportProductsCommandValidator : AbstractValidator v.Data) - // .NotNull() - // .NotEmpty(); - throw new System.NotImplementedException(); + + RuleFor(v => v.Data) + .NotNull() + .NotEmpty(); + } } diff --git a/src/Application/Features/Products/DTOs/ProductDto.cs b/src/Application/Features/Products/DTOs/ProductDto.cs index 730d3a096..775f931cf 100644 --- a/src/Application/Features/Products/DTOs/ProductDto.cs +++ b/src/Application/Features/Products/DTOs/ProductDto.cs @@ -7,9 +7,11 @@ namespace CleanArchitecture.Blazor.Application.Features.Products.DTOs; public class ProductDto:IMapFrom { - public TrackingState TrackingState { get; set; } public int Id { get; set; } - public string Name { get; set; } - public string Description { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Unit { get; set; } + public decimal Price { get; set; } + public IList? Pictures { get; set; } } diff --git a/src/Application/Features/Products/Queries/Export/ExportProductsQuery.cs b/src/Application/Features/Products/Queries/Export/ExportProductsQuery.cs index c9bae9011..ec74762ec 100644 --- a/src/Application/Features/Products/Queries/Export/ExportProductsQuery.cs +++ b/src/Application/Features/Products/Queries/Export/ExportProductsQuery.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. @@ -8,10 +8,10 @@ namespace CleanArchitecture.Blazor.Application.Features.Products.Queries.Export; public class ExportProductsQuery : IRequest { - public string FilterRules { get; set; } - public string Sort { get; set; } = "Id"; - public string Order { get; set; } = "desc"; - } + public string OrderBy { get; set; } = "Id"; + public string SortDirection { get; set; } = "Desc"; + public string Keyword { get; set; } = String.Empty; +} public class ExportProductsQueryHandler : IRequestHandler @@ -36,16 +36,18 @@ IStringLocalizer localizer public async Task Handle(ExportProductsQuery request, CancellationToken cancellationToken) { - //TODO:Implementing ExportProductsQueryHandler method - var filters = PredicateBuilder.FromFilter(request.FilterRules); - var data = await _context.Products.Where(filters) - .OrderBy("{request.Sort} {request.Order}") + var data = await _context.Products.Where(x=>x.Name.Contains(request.Keyword) || x.Description.Contains(request.Keyword)) + .OrderBy($"{request.OrderBy} {request.SortDirection}") .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(cancellationToken); var result = await _excelService.ExportAsync(data, new Dictionary>() { - //{ _localizer["Id"], item => item.Id }, + { _localizer["Product Name"], item => item.Name }, + { _localizer["Description"], item => item.Description }, + { _localizer["Price of unit"], item => item.Price }, + { _localizer["Unit"], item => item.Unit }, + { _localizer["Pictures"], item => item.Pictures!=null?string.Join(",",item.Pictures):null }, } , _localizer["Products"]); return result; diff --git a/src/Application/Features/Products/Queries/Pagination/ProductsPaginationQuery.cs b/src/Application/Features/Products/Queries/Pagination/ProductsPaginationQuery.cs index 7cba1e0fc..3bf897a9a 100644 --- a/src/Application/Features/Products/Queries/Pagination/ProductsPaginationQuery.cs +++ b/src/Application/Features/Products/Queries/Pagination/ProductsPaginationQuery.cs @@ -1,11 +1,11 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using CleanArchitecture.Blazor.Application.Features.Products.DTOs; namespace CleanArchitecture.Blazor.Application.Features.Products.Queries.Pagination; - public class ProductsWithPaginationQuery : PaginationRequest, IRequest> + public class ProductsWithPaginationQuery : PaginationFilter, IRequest> { } @@ -30,12 +30,10 @@ IStringLocalizer localizer public async Task> Handle(ProductsWithPaginationQuery request, CancellationToken cancellationToken) { - //TODO:Implementing ProductsWithPaginationQueryHandler method - var filters = PredicateBuilder.FromFilter(request.FilterRules); - var data = await _context.Products.Where(filters) - .OrderBy("{request.Sort} {request.Order}") + var data = await _context.Products.Where(x=>x.Name.Contains(request.Keyword) || x.Description.Contains(request.Keyword)) + .OrderBy($"{request.OrderBy} {request.SortDirection}") .ProjectTo(_mapper.ConfigurationProvider) - .PaginatedDataAsync(request.Page, request.Rows); + .PaginatedDataAsync(request.PageNumber, request.PageSize); return data; } } \ No newline at end of file diff --git a/src/Blazor.Server.UI/Blazor.Server.UI.csproj b/src/Blazor.Server.UI/Blazor.Server.UI.csproj index 240d86cda..0baa3926a 100644 --- a/src/Blazor.Server.UI/Blazor.Server.UI.csproj +++ b/src/Blazor.Server.UI/Blazor.Server.UI.csproj @@ -11,12 +11,16 @@ + - - - - - + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -52,7 +56,9 @@ - + + + diff --git a/src/Blazor.Server.UI/Components/Common/CustomError.razor b/src/Blazor.Server.UI/Components/Common/CustomError.razor new file mode 100644 index 000000000..21c243e4d --- /dev/null +++ b/src/Blazor.Server.UI/Components/Common/CustomError.razor @@ -0,0 +1 @@ +Oopsie !! 😔 diff --git a/src/Blazor.Server.UI/Components/Common/CustomValidation.cs b/src/Blazor.Server.UI/Components/Common/CustomValidation.cs new file mode 100644 index 000000000..0bd3a9cb3 --- /dev/null +++ b/src/Blazor.Server.UI/Components/Common/CustomValidation.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; + +namespace Blazor.Server.UI.Components.Common; + +// See https://docs.microsoft.com/en-us/aspnet/core/blazor/forms-validation?view=aspnetcore-6.0#server-validation-with-a-validator-component +public class CustomModelValidation : ComponentBase +{ + private ValidationMessageStore? _messageStore; + + [CascadingParameter] + private EditContext? CurrentEditContext { get; set; } + + protected override void OnInitialized() + { + if (CurrentEditContext is null) + { + throw new InvalidOperationException( + $"{nameof(CustomModelValidation)} requires a cascading " + + $"parameter of type {nameof(EditContext)}. " + + $"For example, you can use {nameof(CustomModelValidation)} " + + $"inside an {nameof(EditForm)}."); + } + + _messageStore = new(CurrentEditContext); + + CurrentEditContext.OnValidationRequested += (s, e) => + _messageStore?.Clear(); + CurrentEditContext.OnFieldChanged += (s, e) => + _messageStore?.Clear(e.FieldIdentifier); + } + + public void DisplayErrors(IDictionary> errors) + { + if (CurrentEditContext is not null && errors is not null) + { + foreach (var err in errors) + { + _messageStore?.Add(CurrentEditContext.Field(err.Key), err.Value); + } + + CurrentEditContext.NotifyValidationStateChanged(); + } + } + + public void ClearErrors() + { + _messageStore?.Clear(); + CurrentEditContext?.NotifyValidationStateChanged(); + } +} \ No newline at end of file diff --git a/src/Blazor.Server.UI/Components/Common/ErrorHandler.razor b/src/Blazor.Server.UI/Components/Common/ErrorHandler.razor new file mode 100644 index 000000000..9467791b0 --- /dev/null +++ b/src/Blazor.Server.UI/Components/Common/ErrorHandler.razor @@ -0,0 +1,2 @@ +@inherits ErrorBoundary +@ChildContent \ No newline at end of file diff --git a/src/Blazor.Server.UI/Components/Common/ErrorHandler.razor.cs b/src/Blazor.Server.UI/Components/Common/ErrorHandler.razor.cs new file mode 100644 index 000000000..3a2fce987 --- /dev/null +++ b/src/Blazor.Server.UI/Components/Common/ErrorHandler.razor.cs @@ -0,0 +1,30 @@ + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace Blazor.Server.UI.Components.Common; + +public partial class ErrorHandler +{ + + public List _receivedExceptions = new(); + + protected override Task OnErrorAsync(Exception exception) + { + _receivedExceptions.Add(exception); + switch (exception) + { + case UnauthorizedAccessException: + Snackbar.Add("Authentication Failed", Severity.Error); + break; + } + return Task.CompletedTask; + } + + public new void Recover() + { + _receivedExceptions.Clear(); + base.Recover(); + } +} \ No newline at end of file diff --git a/src/Blazor.Server.UI/Components/Common/TablePager.razor b/src/Blazor.Server.UI/Components/Common/TablePager.razor new file mode 100644 index 000000000..c5fdc78cc --- /dev/null +++ b/src/Blazor.Server.UI/Components/Common/TablePager.razor @@ -0,0 +1,3 @@ +@inject IStringLocalizer L + + \ No newline at end of file diff --git a/src/Blazor.Server.UI/Components/Dialogs/DeleteConfirmation.razor b/src/Blazor.Server.UI/Components/Dialogs/DeleteConfirmation.razor new file mode 100644 index 000000000..b3c648812 --- /dev/null +++ b/src/Blazor.Server.UI/Components/Dialogs/DeleteConfirmation.razor @@ -0,0 +1,31 @@ +@inject IStringLocalizer L + + + + + + @L["Delete Confirmation"] + + + + @ContentText + + + @L["Cancel"] + @L["Confirm"] + + + +@code { + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public string? ContentText { get; set; } + + void Submit() + { + MudDialog.Close(DialogResult.Ok(true)); + } + void Cancel() => MudDialog.Cancel(); +} diff --git a/src/Blazor.Server.UI/Components/Dialogs/LogoutConfirmation.razor b/src/Blazor.Server.UI/Components/Dialogs/LogoutConfirmation.razor new file mode 100644 index 000000000..82d2e529f --- /dev/null +++ b/src/Blazor.Server.UI/Components/Dialogs/LogoutConfirmation.razor @@ -0,0 +1,35 @@ +@inject IStringLocalizer L + + + + + @L["Logout Confirmation"] + + + + @ContentText + + + @L["Cancel"] + @ButtonText + + + +@code { + [Parameter] public string? ContentText { get; set; } + + [Parameter] public string? ButtonText { get; set; } + + [Parameter] public Color Color { get; set; } + + [CascadingParameter] MudDialogInstance MudDialog { get; set; } = default!; + [Inject] private NavigationManager _navigation { get; set; }= default!; + async Task Submit() + { + Snackbar.Add(@L["Logged out"], MudBlazor.Severity.Info); + MudDialog.Close(DialogResult.Ok(true)); + _navigation.NavigateTo("/account/signout", forceLoad: true); + } + + void Cancel() => MudDialog.Cancel(); +} diff --git a/src/Blazor.Server.UI/Components/EntityTable/AddEditModal.razor b/src/Blazor.Server.UI/Components/EntityTable/AddEditModal.razor new file mode 100644 index 000000000..584d97e2f --- /dev/null +++ b/src/Blazor.Server.UI/Components/EntityTable/AddEditModal.razor @@ -0,0 +1,55 @@ +@typeparam TRequest +@using Blazor.Server.UI.Components.Common +@inject IStringLocalizer L + + + + + + + @if (IsCreate) + { + + @($"{L["Create"]} "); @L[EntityName]; + } + else + { + + @($"{L["Edit"]} "); @L[EntityName] + } + + + + + + + + @if (!IsCreate) + { + + + + } + @EditFormContent(RequestModel) + + + + + + @L["Cancel"] + @if (IsCreate) + { + + @L["Save"] + + } + else + { + + @L["Update"] + + } + + + + diff --git a/src/Blazor.Server.UI/Components/EntityTable/AddEditModal.razor.cs b/src/Blazor.Server.UI/Components/EntityTable/AddEditModal.razor.cs new file mode 100644 index 000000000..0276d4b4b --- /dev/null +++ b/src/Blazor.Server.UI/Components/EntityTable/AddEditModal.razor.cs @@ -0,0 +1,93 @@ +using System.ComponentModel.DataAnnotations; +using Blazor.Server.UI.Components.Common; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace Blazor.Server.UI.Components.EntityTable; + +public partial class AddEditModal : IAddEditModal +{ + [Parameter] + [EditorRequired] + public RenderFragment EditFormContent { get; set; } = default!; + [Parameter] + [EditorRequired] + public TRequest RequestModel { get; set; } = default!; + [Parameter] + [EditorRequired] + public Func SaveFunc { get; set; } = default!; + [Parameter] + public Func? OnInitializedFunc { get; set; } + [Parameter] + [EditorRequired] + public string EntityName { get; set; } = default!; + [Parameter] + public object? Id { get; set; } + + [CascadingParameter] + private MudDialogInstance MudDialog { get; set; } = default!; + + private CustomModelValidation? _customValidation; + + public bool IsCreate => Id is null; + + public void ForceRender() => StateHasChanged(); + + // This should not be necessary anymore, except maybe in the case when the + // UpdateEntityRequest has different validation rules than the CreateEntityRequest. + // If that would happen a lot we can still change the design so this method doesn't need to be called manually. + public bool Validate(object request) + { + var results = new List(); + if (!Validator.TryValidateObject(request, new ValidationContext(request), results, true)) + { + // Convert results to errors + var errors = new Dictionary>(); + foreach (var result in results + .Where(r => !string.IsNullOrWhiteSpace(r.ErrorMessage))) + { + foreach (string field in result.MemberNames) + { + if (errors.ContainsKey(field)) + { + errors[field].Add(result.ErrorMessage!); + } + else + { + errors.Add(field, new List() { result.ErrorMessage! }); + } + } + } + + _customValidation?.DisplayErrors(errors); + + return false; + } + + return true; + } + + protected override Task OnInitializedAsync() => + OnInitializedFunc is not null + ? OnInitializedFunc() + : Task.CompletedTask; + + private async Task SaveAsync() + { + _customValidation?.ClearErrors(); + try + { + await SaveFunc(RequestModel); + Snackbar.Add($"{EntityName} {(IsCreate ? L["Created"] : L["Updated"])}.",Severity.Success); + MudDialog.Close(); + } + catch (Exception ex) + { + Snackbar.Add(ex.Message, Severity.Error); + } + + } + + private void Cancel() => + MudDialog.Cancel(); +} \ No newline at end of file diff --git a/src/Blazor.Server.UI/Components/EntityTable/EntityClientTableContext.cs b/src/Blazor.Server.UI/Components/EntityTable/EntityClientTableContext.cs new file mode 100644 index 000000000..027096cba --- /dev/null +++ b/src/Blazor.Server.UI/Components/EntityTable/EntityClientTableContext.cs @@ -0,0 +1,65 @@ +namespace Blazor.Server.UI.Components.EntityTable; + +/// +/// Initialization Context for the EntityTable Component. +/// Use this one if you want to use Client Paging, Sorting and Filtering. +/// +public class EntityClientTableContext + : EntityTableContext +{ + /// + /// A function that loads all the data for the table from the api and returns a ListResult of TEntity. + /// + public Func?>> LoadDataFunc { get; } + + /// + /// A function that returns a boolean which indicates whether the supplied entity meets the search criteria + /// (the supplied string is the search string entered). + /// + public Func SearchFunc { get; } + + public EntityClientTableContext( + List> fields, + Func?>> loadDataFunc, + Func searchFunc, + Func? idFunc = null, + Func>? getDefaultsFunc = null, + Func? createFunc = null, + Func>? getDetailsFunc = null, + Func? updateFunc = null, + Func? deleteFunc = null, + string? entityName = null, + string? entityNamePlural = null, + string? entityResource = null, + string? searchAction = null, + string? createAction = null, + string? updateAction = null, + string? deleteAction = null, + Func? editFormInitializedFunc = null, + Func? hasExtraActionsFunc = null, + Func? canUpdateEntityFunc = null, + Func? canDeleteEntityFunc = null) + : base( + fields, + idFunc, + getDefaultsFunc, + createFunc, + getDetailsFunc, + updateFunc, + deleteFunc, + entityName, + entityNamePlural, + entityResource, + searchAction, + createAction, + updateAction, + deleteAction, + editFormInitializedFunc, + hasExtraActionsFunc, + canUpdateEntityFunc, + canDeleteEntityFunc) + { + LoadDataFunc = loadDataFunc; + SearchFunc = searchFunc; + } +} \ No newline at end of file diff --git a/src/Blazor.Server.UI/Components/EntityTable/EntityField.cs b/src/Blazor.Server.UI/Components/EntityTable/EntityField.cs new file mode 100644 index 000000000..54c0c682e --- /dev/null +++ b/src/Blazor.Server.UI/Components/EntityTable/EntityField.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Components; + +namespace Blazor.Server.UI.Components.EntityTable; + +public record EntityField(Func ValueFunc, string DisplayName, string SortLabel = "", Type? Type = null, RenderFragment? Template = null) +{ + /// + /// A function that returns the actual value of this field from the supplied entity. + /// + public Func ValueFunc { get; init; } = ValueFunc; + + /// + /// The string that's shown on the UI for this field. + /// + public string DisplayName { get; init; } = DisplayName; + + /// + /// The string that's sent to the api as property to sort on for this field. + /// This is only relevant when using server side sorting. + /// + public string SortLabel { get; init; } = SortLabel; + + /// + /// The type of the field. Default is string, but when boolean, it shows as a checkbox. + /// + public Type? Type { get; init; } = Type; + + /// + /// When supplied this template will be used for this field in stead of the default template. + /// For an example on how to do this, see . + /// + public RenderFragment? Template { get; init; } = Template; + + public bool CheckedForSearch { get; set; } = true; +} \ No newline at end of file diff --git a/src/Blazor.Server.UI/Components/EntityTable/EntityServerTableContext.cs b/src/Blazor.Server.UI/Components/EntityTable/EntityServerTableContext.cs new file mode 100644 index 000000000..dd799cbb5 --- /dev/null +++ b/src/Blazor.Server.UI/Components/EntityTable/EntityServerTableContext.cs @@ -0,0 +1,63 @@ +using CleanArchitecture.Blazor.Application.Common.Models; + +namespace Blazor.Server.UI.Components.EntityTable; + +/// +/// Initialization Context for the EntityTable Component. +/// Use this one if you want to use Server Paging, Sorting and Filtering. +/// +public class EntityServerTableContext + : EntityTableContext +{ + /// + /// A function that loads the specified page from the api with the specified search criteria + /// and returns a PaginatedResult of TEntity. + /// + public Func>> SearchFunc { get; } + public bool EnableAdvancedSearch { get; } + + public EntityServerTableContext( + List> fields, + Func>> searchFunc, + bool enableAdvancedSearch = false, + Func? idFunc = null, + Func>? getDefaultsFunc = null, + Func? createFunc = null, + Func>? getDetailsFunc = null, + Func? updateFunc = null, + Func? deleteFunc = null, + string? entityName = null, + string? entityNamePlural = null, + string? entityResource = null, + string? searchAction = null, + string? createAction = null, + string? updateAction = null, + string? deleteAction = null, + Func? editFormInitializedFunc = null, + Func? hasExtraActionsFunc = null, + Func? canUpdateEntityFunc = null, + Func? canDeleteEntityFunc = null) + : base( + fields, + idFunc, + getDefaultsFunc, + createFunc, + getDetailsFunc, + updateFunc, + deleteFunc, + entityName, + entityNamePlural, + entityResource, + searchAction, + createAction, + updateAction, + deleteAction, + editFormInitializedFunc, + hasExtraActionsFunc, + canUpdateEntityFunc, + canDeleteEntityFunc) + { + SearchFunc = searchFunc; + EnableAdvancedSearch = enableAdvancedSearch; + } +} \ No newline at end of file diff --git a/src/Blazor.Server.UI/Components/EntityTable/EntityTable.razor b/src/Blazor.Server.UI/Components/EntityTable/EntityTable.razor new file mode 100644 index 000000000..1ff63b24b --- /dev/null +++ b/src/Blazor.Server.UI/Components/EntityTable/EntityTable.razor @@ -0,0 +1,151 @@ +@using Blazor.Server.UI.Components.Common +@typeparam TEntity +@typeparam TId +@typeparam TRequest + +@inject IStringLocalizer L + + + + + + + @if (_canSearch && (Context.AdvancedSearchEnabled || AdvancedSearchContent is not null)) + { + + + + @if (Context.AdvancedSearchEnabled) + { +
+ + @foreach (var field in Context.Fields) + { + + } +
+ } + @AdvancedSearchContent + +
+ } + + + +
+ @if (_canCreate) + { + @L["Create"] + } + @L["Reload"] +
+ + @if (_canSearch && !_advancedSearchExpanded) + { + + + } +
+ + + @if (Context.Fields is not null) + { + foreach (var field in Context.Fields) + { + + @if (Context.IsClientContext) + { + @field.DisplayName + } + else + { + @field.DisplayName + } + + } + } + @L["Actions"] + + + + @foreach (var field in Context.Fields) + { + + @if (field.Template is not null) + { + @field.Template(context) + } + else if (field.Type == typeof(bool)) + { + + } + else + { + + } + + } + + @if (ActionsContent is not null) + { + @ActionsContent(context) + } + else if (HasActions) + { + + @if (CanUpdateEntity(context)) + { + @L["Edit"] + } + @if (CanDeleteEntity(context)) + { + @L["Delete"] + } + @if (ExtraActions is not null) + { + @ExtraActions(context) + } + + } + else + { + + @L["No Allowed Actions"] + + } + + + + + + + +
+ +
+ + + +
diff --git a/src/Blazor.Server.UI/Components/EntityTable/EntityTable.razor.cs b/src/Blazor.Server.UI/Components/EntityTable/EntityTable.razor.cs new file mode 100644 index 000000000..8e813013d --- /dev/null +++ b/src/Blazor.Server.UI/Components/EntityTable/EntityTable.razor.cs @@ -0,0 +1,226 @@ + +using Blazor.Server.UI.Components.Dialogs; +using Blazor.Server.UI.Shared; +using CleanArchitecture.Blazor.Application.Common.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using MudBlazor; + +namespace Blazor.Server.UI.Components.EntityTable; + +public partial class EntityTable + where TRequest : new() +{ + [Parameter] + [EditorRequired] + public EntityTableContext Context { get; set; } = default!; + + [Parameter] + public bool Loading { get; set; } + + [Parameter] + public string? SearchString { get; set; } + [Parameter] + public EventCallback SearchStringChanged { get; set; } + + [Parameter] + public RenderFragment? AdvancedSearchContent { get; set; } + + [Parameter] + public RenderFragment? ActionsContent { get; set; } + [Parameter] + public RenderFragment? ExtraActions { get; set; } + [Parameter] + public RenderFragment? ChildRowContent { get; set; } + + [Parameter] + public RenderFragment? EditFormContent { get; set; } + + [CascadingParameter] + protected Task AuthState { get; set; } = default!; + + private bool _canSearch; + private bool _canCreate; + private bool _canUpdate; + private bool _canDelete; + + private bool _advancedSearchExpanded; + + private MudTable _table = default!; + private IEnumerable? _entityList; + private int _totalItems; + + protected override async Task OnInitializedAsync() + { + var state = await AuthState; + _canSearch = await CanDoActionAsync(Context.SearchAction, state); + _canCreate = await CanDoActionAsync(Context.CreateAction, state); + _canUpdate = await CanDoActionAsync(Context.UpdateAction, state); + _canDelete = await CanDoActionAsync(Context.DeleteAction, state); + + await LocalLoadDataAsync(); + } + + public Task ReloadDataAsync() => + Context.IsClientContext + ? LocalLoadDataAsync() + : ServerLoadDataAsync(); + + private async Task CanDoActionAsync(string? action, AuthenticationState state) => + !string.IsNullOrWhiteSpace(action) && + ((bool.TryParse(action, out bool isTrue) && isTrue) || // check if action equals "True", then it's allowed + (Context.EntityResource is { } resource && (await AuthService.AuthorizeAsync(state.User, action)).Succeeded)); + + private bool HasActions => _canUpdate || _canDelete || Context.HasExtraActionsFunc is null || Context.HasExtraActionsFunc(); + private bool CanUpdateEntity(TEntity entity) => _canUpdate && (Context.CanUpdateEntityFunc is null || Context.CanUpdateEntityFunc(entity)); + private bool CanDeleteEntity(TEntity entity) => _canDelete && (Context.CanDeleteEntityFunc is null || Context.CanDeleteEntityFunc(entity)); + + // Client side paging/filtering + private bool LocalSearch(TEntity entity) => + Context.ClientContext?.SearchFunc is { } searchFunc + ? searchFunc(SearchString, entity) + : string.IsNullOrWhiteSpace(SearchString); + + private async Task LocalLoadDataAsync() + { + if (Loading || Context.ClientContext is null) + { + return; + } + + Loading = true; + _entityList = await Context.ClientContext.LoadDataFunc(); + Loading = false; + } + + // Server Side paging/filtering + + private async Task OnSearchStringChanged(string? text = null) + { + await SearchStringChanged.InvokeAsync(SearchString); + + await ServerLoadDataAsync(); + } + + private async Task ServerLoadDataAsync() + { + if (Context.IsServerContext) + { + await _table.ReloadServerData(); + } + } + + private Func>>? ServerReloadFunc => + Context.IsServerContext ? ServerReload : null; + + private async Task> ServerReload(TableState state) + { + if (!Loading && Context.ServerContext is not null) + { + Loading = true; + var filter = GetPaginationFilter(state); + await Context.ServerContext.SearchFunc(filter); + Loading = false; + } + + return new TableData { TotalItems = _totalItems, Items = _entityList }; + } + + private PaginationFilter GetPaginationFilter(TableState state) + { + + + var filter = new PaginationFilter + { + PageSize = state.PageSize, + PageNumber = state.Page + 1, + Keyword = SearchString, + OrderBy = state.SortLabel, + SortDirection = state.SortDirection.ToString() + }; + + if (!Context.AllColumnsChecked) + { + filter.AdvancedSearch = new() + { + Fields = Context.SearchFields, + Keyword = filter.Keyword + }; + filter.Keyword = null; + } + + return filter; + } + + private async Task InvokeModal(TEntity? entity = default) + { + bool isCreate = entity is null; + + var parameters = new DialogParameters() + { + { nameof(AddEditModal.EditFormContent), EditFormContent }, + { nameof(AddEditModal.OnInitializedFunc), Context.EditFormInitializedFunc }, + { nameof(AddEditModal.EntityName), Context.EntityName } + }; + + TRequest requestModel; + + if (isCreate) + { + _ = Context.CreateFunc ?? throw new InvalidOperationException("CreateFunc can't be null!"); + parameters.Add(nameof(AddEditModal.SaveFunc), Context.CreateFunc); + + requestModel = (await Context.GetDefaultsFunc())??new TRequest(); + + } + else + { + _ = Context.IdFunc ?? throw new InvalidOperationException("IdFunc can't be null!"); + var id = Context.IdFunc(entity!); + parameters.Add(nameof(AddEditModal.Id), id); + + _ = Context.UpdateFunc ?? throw new InvalidOperationException("UpdateFunc can't be null!"); + Func saveFunc = entity => Context.UpdateFunc(id, entity); + parameters.Add(nameof(AddEditModal.SaveFunc), saveFunc); + + requestModel =(await Context.GetDetailsFunc(id)) ?? new TRequest(); + } + + parameters.Add(nameof(AddEditModal.RequestModel), requestModel); + + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true, DisableBackdropClick = true }; + + var dialog = DialogService.Show>(string.Empty, parameters, options); + + Context.SetAddEditModalRef(dialog); + + var result = await dialog.Result; + + if (!result.Cancelled) + { + await ReloadDataAsync(); + } + } + + private async Task Delete(TEntity entity) + { + _ = Context.IdFunc ?? throw new InvalidOperationException("IdFunc can't be null!"); + TId id = Context.IdFunc(entity); + + string deleteContent = L["You're sure you want to delete {0} with id '{1}'?"]; + var parameters = new DialogParameters + { + { nameof(DeleteConfirmation.ContentText), string.Format(deleteContent, Context.EntityName, id) } + }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true, DisableBackdropClick = true }; + var dialog = DialogService.Show(L["Delete"], parameters, options); + var result = await dialog.Result; + if (!result.Cancelled) + { + _ = Context.DeleteFunc ?? throw new InvalidOperationException("DeleteFunc can't be null!"); + await Context.DeleteFunc(id); + await ReloadDataAsync(); + } + } +} \ No newline at end of file diff --git a/src/Blazor.Server.UI/Components/EntityTable/EntityTableContext.cs b/src/Blazor.Server.UI/Components/EntityTable/EntityTableContext.cs new file mode 100644 index 000000000..78a258037 --- /dev/null +++ b/src/Blazor.Server.UI/Components/EntityTable/EntityTableContext.cs @@ -0,0 +1,189 @@ +using MudBlazor; + +namespace Blazor.Server.UI.Components.EntityTable; + +/// +/// Abstract base class for the initialization Context of the EntityTable Component. +/// +/// The type of the entity. +/// The type of the id of the entity. +/// The type of the Request which is used on the AddEditModal and which is sent with the CreateFunc and UpdateFunc. +public abstract class EntityTableContext +{ + /// + /// The columns you want to display on the table. + /// + public List> Fields { get; } + + /// + /// A function that returns the Id of the entity. This is only needed when using the CRUD functionality. + /// + public Func? IdFunc { get; } + + /// + /// A function that executes the GetDefaults method on the api (or supplies defaults locally) and returns + /// a Task of Result of TRequest. When not supplied, a TRequest is simply newed up. + /// No need to check for error messages or api exceptions. These are automatically handled by the component. + /// + public Func>? GetDefaultsFunc { get; } + + /// + /// A function that executes the Create method on the api with the supplied entity and returns a Task of Result. + /// No need to check for error messages or api exceptions. These are automatically handled by the component. + /// + public Func? CreateFunc { get; } + + /// + /// A function that executes the GetDetails method on the api with the supplied Id and returns a Task of Result of TRequest. + /// No need to check for error messages or api exceptions. These are automatically handled by the component. + /// When not supplied, the TEntity out of the _entityList is supplied using the IdFunc and converted using mapster. + /// + public Func>? GetDetailsFunc { get; } + + /// + /// A function that executes the Update method on the api with the supplied entity and returns a Task of Result. + /// When not supplied, the TEntity from the list is mapped to TCreateRequest using mapster. + /// No need to check for error messages or api exceptions. These are automatically handled by the component. + /// + public Func? UpdateFunc { get; } + + /// + /// A function that executes the Delete method on the api with the supplied entity id and returns a Task of Result. + /// No need to check for error messages or api exceptions. These are automatically handled by the component. + /// + public Func? DeleteFunc { get; } + + /// + /// The name of the entity. This is used in the title of the add/edit modal and delete confirmation. + /// + public string? EntityName { get; } + + /// + /// The plural name of the entity. This is used in the "Search for ..." placeholder. + /// + public string? EntityNamePlural { get; } + + /// + /// The FSHResource that is representing this entity. This is used in combination with the xxActions to check for permissions. + /// + public string? EntityResource { get; } + + /// + /// The FSHAction name of the search permission. This is FSHAction.Search by default. + /// When empty, no search functionality will be available. + /// When the string is "true", search funtionality will be enabled, + /// otherwise it will only be enabled if the user has permission for this action on the EntityResource. + /// + public string SearchAction { get; } + + /// + /// The permission name of the create permission. This is FSHAction.Create by default. + /// When empty, no create functionality will be available. + /// When the string "true", create funtionality will be enabled, + /// otherwise it will only be enabled if the user has permission for this action on the EntityResource. + /// + public string CreateAction { get; } + + /// + /// The permission name of the update permission. This is FSHAction.Update by default. + /// When empty, no update functionality will be available. + /// When the string is "true", update funtionality will be enabled, + /// otherwise it will only be enabled if the user has permission for this action on the EntityResource. + /// + public string UpdateAction { get; } + + /// + /// The permission name of the delete permission. This is FSHAction.Delete by default. + /// When empty, no delete functionality will be available. + /// When the string is "true", delete funtionality will be enabled, + /// otherwise it will only be enabled if the user has permission for this action on the EntityResource. + /// + public string DeleteAction { get; } + + /// + /// Use this if you want to run initialization during OnInitialized of the AddEdit form. + /// + public Func? EditFormInitializedFunc { get; } + + /// + /// Use this if you want to check for permissions of content in the ExtraActions RenderFragment. + /// The extra actions won't be available when this returns false. + /// + public Func? HasExtraActionsFunc { get; set; } + + /// + /// Use this if you want to disable the update functionality for specific entities in the table. + /// + public Func? CanUpdateEntityFunc { get; set; } + + /// + /// Use this if you want to disable the delete functionality for specific entities in the table. + /// + public Func? CanDeleteEntityFunc { get; set; } + + public EntityTableContext( + List> fields, + Func? idFunc, + Func>? getDefaultsFunc, + Func? createFunc, + Func>? getDetailsFunc, + Func? updateFunc, + Func? deleteFunc, + string? entityName, + string? entityNamePlural, + string? entityResource, + string? searchAction, + string? createAction, + string? updateAction, + string? deleteAction, + Func? editFormInitializedFunc, + Func? hasExtraActionsFunc, + Func? canUpdateEntityFunc, + Func? canDeleteEntityFunc) + { + EntityResource = entityResource; + Fields = fields; + EntityName = entityName; + EntityNamePlural = entityNamePlural; + IdFunc = idFunc; + GetDefaultsFunc = getDefaultsFunc; + CreateFunc = createFunc; + GetDetailsFunc = getDetailsFunc; + UpdateFunc = updateFunc; + DeleteFunc = deleteFunc; + SearchAction = searchAction ?? "Search"; + CreateAction = createAction ?? "Create"; + UpdateAction = updateAction ?? "Update"; + DeleteAction = deleteAction ?? "Delete"; + EditFormInitializedFunc = editFormInitializedFunc; + HasExtraActionsFunc = hasExtraActionsFunc; + CanUpdateEntityFunc = canUpdateEntityFunc; + CanDeleteEntityFunc = canDeleteEntityFunc; + } + + // AddEdit modal + private IDialogReference? _addEditModalRef; + + internal void SetAddEditModalRef(IDialogReference dialog) => + _addEditModalRef = dialog; + + public IAddEditModal AddEditModal => + _addEditModalRef?.Dialog as IAddEditModal + ?? throw new InvalidOperationException("AddEditModal is only available when the modal is shown."); + + // Shortcuts + public EntityClientTableContext? ClientContext => this as EntityClientTableContext; + public EntityServerTableContext? ServerContext => this as EntityServerTableContext; + public bool IsClientContext => ClientContext is not null; + public bool IsServerContext => ServerContext is not null; + + // Advanced Search + public bool AllColumnsChecked => + Fields.All(f => f.CheckedForSearch); + public void AllColumnsCheckChanged(bool checkAll) => + Fields.ForEach(f => f.CheckedForSearch = checkAll); + public bool AdvancedSearchEnabled => + ServerContext?.EnableAdvancedSearch is true; + public List SearchFields => + Fields.Where(f => f.CheckedForSearch).Select(f => f.SortLabel).ToList(); +} \ No newline at end of file diff --git a/src/Blazor.Server.UI/Components/EntityTable/IAddEditModal.cs b/src/Blazor.Server.UI/Components/EntityTable/IAddEditModal.cs new file mode 100644 index 000000000..7dbdd74b5 --- /dev/null +++ b/src/Blazor.Server.UI/Components/EntityTable/IAddEditModal.cs @@ -0,0 +1,9 @@ +namespace Blazor.Server.UI.Components.EntityTable; + +public interface IAddEditModal +{ + TRequest RequestModel { get; } + bool IsCreate { get; } + void ForceRender(); + bool Validate(object request); +} \ No newline at end of file diff --git a/src/Blazor.Server.UI/Components/EntityTable/PaginationResponse.cs b/src/Blazor.Server.UI/Components/EntityTable/PaginationResponse.cs new file mode 100644 index 000000000..ce9befdb0 --- /dev/null +++ b/src/Blazor.Server.UI/Components/EntityTable/PaginationResponse.cs @@ -0,0 +1,9 @@ +namespace Blazor.Server.UI.Components.EntityTable; + +public class PaginationResponse +{ + public List Data { get; set; } = default!; + public int TotalCount { get; set; } + public int CurrentPage { get; set; } = 1; + public int PageSize { get; set; } = 10; +} diff --git a/src/Blazor.Server.UI/Components/Shared/NavMenu.razor b/src/Blazor.Server.UI/Components/Shared/NavMenu.razor index e0a91e381..abd9cd019 100644 --- a/src/Blazor.Server.UI/Components/Shared/NavMenu.razor +++ b/src/Blazor.Server.UI/Components/Shared/NavMenu.razor @@ -45,5 +45,5 @@ - COMPANY - \ No newline at end of file + @Settings.Company + diff --git a/src/Blazor.Server.UI/Components/Shared/NotificationMenu.razor b/src/Blazor.Server.UI/Components/Shared/NotificationMenu.razor index 4db7a93df..8565d7a34 100644 --- a/src/Blazor.Server.UI/Components/Shared/NotificationMenu.razor +++ b/src/Blazor.Server.UI/Components/Shared/NotificationMenu.razor @@ -1,5 +1,5 @@ @inherits MudComponentBase - +@inject IStringLocalizer L @@ -27,7 +27,7 @@ style="min-width: 330px"> - Notifications + @(L["Notifications"]) @if (Notifications != null && Notifications.Any()) @@ -60,7 +60,7 @@ { - You have no unread notifications. + @(L["You have no unread notifications."]) } @@ -70,7 +70,7 @@ OnClick="OnClickViewAll" Style="text-transform:none" Variant="Variant.Text"> - View All + @(L["View All"]) @@ -85,4 +85,4 @@ margin-bottom: -8px; } - \ No newline at end of file + diff --git a/src/Blazor.Server.UI/Components/Shared/SideMenu.razor b/src/Blazor.Server.UI/Components/Shared/SideMenu.razor index 54f0d4db6..a2ee24d37 100644 --- a/src/Blazor.Server.UI/Components/Shared/SideMenu.razor +++ b/src/Blazor.Server.UI/Components/Shared/SideMenu.razor @@ -1,5 +1,7 @@ @using MudBlazor.Extensions @using Blazor.Server.UI.Models.SideMenu +@inject IStringLocalizer L + - Application Name + @L[Settings.AppName] @@ -55,7 +57,7 @@
- @(section.Title) + @(L[section.Title])
@@ -75,7 +77,7 @@ Href="@(menuItem.Href)" Match="NavLinkMatch.All">
- @menuItem.Title + @(L[menuItem.Title]) @if (menuItem.PageStatus != PageStatus.Completed) { @@ -83,7 +85,7 @@ Color="@Color.Primary" Size="Size.Small" Variant="Variant.Text"> - @menuItem.PageStatus.ToDescriptionString() + @(L[menuItem.PageStatus.ToDescriptionString()]) }
@@ -99,7 +101,7 @@ Icon="@(sectionItem.Icon)" Match="NavLinkMatch.All">
- @sectionItem.Title + @(L[sectionItem.Title]) @if (sectionItem.PageStatus != PageStatus.Completed) { @@ -107,7 +109,7 @@ Color="@Color.Primary" Size="Size.Small" Variant="Variant.Text"> - @sectionItem.PageStatus.ToDescriptionString() + @(L[sectionItem.PageStatus.ToDescriptionString()]) }
@@ -119,9 +121,9 @@ - @@2022 Copyright + @Settings.Copyright Privacy Policy - version 1.0 + version @Settings.Version
diff --git a/src/Blazor.Server.UI/Components/Shared/UserMenu.razor b/src/Blazor.Server.UI/Components/Shared/UserMenu.razor index 124c13a91..4b3505cbc 100644 --- a/src/Blazor.Server.UI/Components/Shared/UserMenu.razor +++ b/src/Blazor.Server.UI/Components/Shared/UserMenu.razor @@ -1,3 +1,4 @@ +@inject IStringLocalizer L - Profile + @L["Profile"]
- Settings + @L["Settings"]
- Logout + @L["Logout"]
-
\ No newline at end of file + diff --git a/src/Blazor.Server.UI/Components/Shared/UserMenu.razor.cs b/src/Blazor.Server.UI/Components/Shared/UserMenu.razor.cs index 2e22be19f..eab1b7281 100644 --- a/src/Blazor.Server.UI/Components/Shared/UserMenu.razor.cs +++ b/src/Blazor.Server.UI/Components/Shared/UserMenu.razor.cs @@ -1,17 +1,26 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Identity; using Blazor.Server.UI.Models; +using MudBlazor; +using Blazor.Server.UI.Components.Dialogs; namespace Blazor.Server.UI.Components.Shared; public partial class UserMenu { [Parameter] public string Class { get; set; } - [EditorRequired] [Parameter] public UserModel User { get; set; } - [Inject] private NavigationManager _navigation { get; set; } - private async Task Logout() + [EditorRequired] [Parameter] public UserModel User { get; set; } = default!; + private Task OnLogout() { - - //_navigation.NavigateTo("/pages/authentication/login"); + var parameters = new DialogParameters + { + { nameof(LogoutConfirmation.ContentText), $"{L["You are attempting to log out of application. Do you really want to log out?"]}"}, + { nameof(LogoutConfirmation.ButtonText), $"{L["Logout"]}"}, + { nameof(LogoutConfirmation.Color), Color.Error} + }; + + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + DialogService.Show(L["Logout"], parameters, options); + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/Blazor.Server.UI/Pages/Authentication/Forgot.razor b/src/Blazor.Server.UI/Pages/Authentication/Forgot.razor index 830cddb5a..469908790 100644 --- a/src/Blazor.Server.UI/Pages/Authentication/Forgot.razor +++ b/src/Blazor.Server.UI/Pages/Authentication/Forgot.razor @@ -1,15 +1,16 @@ -@page "/pages/authentication/forgot-password" +@page "/pages/authentication/forgot-password" +@inject IStringLocalizer L @attribute [AllowAnonymous] -Forgot Password? +@L["Forgot Password?"] -Enter the email address linked to your account and you will recieve an email containing a link to reset your password. +@L["Enter the email address linked to your account and you will recieve an email containing a link to reset your password."] -Reset Password +@L["Reset Password"] @code { diff --git a/src/Blazor.Server.UI/Pages/Authentication/Login.razor b/src/Blazor.Server.UI/Pages/Authentication/Login.razor index 94952d2d9..def4c2514 100644 --- a/src/Blazor.Server.UI/Pages/Authentication/Login.razor +++ b/src/Blazor.Server.UI/Pages/Authentication/Login.razor @@ -5,35 +5,36 @@ @using Blazor.Server.UI.Models.Authentication @using System.ComponentModel.DataAnnotations @using System.Security.Claims +@inject IStringLocalizer L - Sign In + @L["Sign In"] - Don't have an account? Sign Up + @L["Don't have an account?"] @L["Sign Up"]
- - Forgot pwd? + + @L["Forgot pwd?"]
@@ -44,9 +45,9 @@ FullWidth="true">Sign In
@code { - [Inject] private UserManager userManager { get; set; } - [Inject] private NavigationManager _navigation { get; set; } - [Inject] private IDataProtectionProvider dataProtectionProvider { get; set; } + [Inject] private UserManager userManager { get; set; } = default!; + [Inject] private NavigationManager _navigation { get; set; }= default!; + [Inject] private IDataProtectionProvider dataProtectionProvider { get; set; }= default!; LoginFormModel model = new LoginFormModel() { UserName = "administrator", @@ -62,17 +63,17 @@ { if (string.IsNullOrWhiteSpace(pw)) { - yield return "Password is required!"; + yield return L["Password is required!"]; yield break; } if (pw.Length < 6) - yield return "Password must be at least of length 6"; + yield return L["Password must be at least of length 6"]; if (!Regex.IsMatch(pw, @"[A-Z]")) - yield return "Password must contain at least one capital letter"; + yield return L["Password must contain at least one capital letter"]; if (!Regex.IsMatch(pw, @"[a-z]")) - yield return "Password must contain at least one lowercase letter"; + yield return L["Password must contain at least one lowercase letter"]; if (!Regex.IsMatch(pw, @"[0-9]")) - yield return "Password must contain at least one digit"; + yield return L["Password must contain at least one digit"]; } void TogglePasswordVisibility() diff --git a/src/Blazor.Server.UI/Pages/Authentication/Register.razor b/src/Blazor.Server.UI/Pages/Authentication/Register.razor index 319dc3a6e..c6f5c1b93 100644 --- a/src/Blazor.Server.UI/Pages/Authentication/Register.razor +++ b/src/Blazor.Server.UI/Pages/Authentication/Register.razor @@ -1,11 +1,11 @@ -@page "/pages/authentication/register" +@page "/pages/authentication/register" @using System.Text.RegularExpressions @using Microsoft.AspNetCore.Identity @using Blazor.Server.UI.Models.Authentication @using System.ComponentModel.DataAnnotations @using System.Security.Claims @using FluentValidation; - +@inject IStringLocalizer L @attribute [AllowAnonymous] @@ -15,27 +15,28 @@ - + Register + FullWidth="true">@L["Register"] @code { - [Inject] private NavigationManager _navigation { get; set; } - [Inject] ISnackbar Snackbar { get; set; } - [Inject] private UserManager userManager { get; set; } + [Inject] private NavigationManager _navigation { get; set; } = default!; + [Inject] private UserManager userManager { get; set; }= default!; MudForm form; RegisterFormModel model = new (); RegisterFormModelFluentValidator registerValidator = new (); diff --git a/src/Blazor.Server.UI/Pages/Authentication/Reset.razor b/src/Blazor.Server.UI/Pages/Authentication/Reset.razor index 0d130fb21..40ac52e61 100644 --- a/src/Blazor.Server.UI/Pages/Authentication/Reset.razor +++ b/src/Blazor.Server.UI/Pages/Authentication/Reset.razor @@ -1,13 +1,14 @@ -@page "/pages/authentication/reset-password" +@page "/pages/authentication/reset-password" +@inject IStringLocalizer L @attribute [AllowAnonymous] - Set new password + @L["Set new password"] - Set New Password + @L["Set New Password"] @code { string Password { get; set; } diff --git a/src/Blazor.Server.UI/Pages/Identity/Roles/PermissionsModel.cs b/src/Blazor.Server.UI/Pages/Identity/Roles/PermissionsModel.cs new file mode 100644 index 000000000..e49132079 --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Identity/Roles/PermissionsModel.cs @@ -0,0 +1,12 @@ +namespace Blazor.Server.UI.Pages.Identity.Roles; + +public class PermissionModel +{ + public string? Description { get; set; } + public string? Group { get; set; } + public string? ClaimType { get; set; } + public string? ClaimValue { get; set; } + public bool Assigned { get; set; } + + public string? RoleId { get; set; } +} diff --git a/src/Blazor.Server.UI/Pages/Identity/Roles/RoleFormModel.cs b/src/Blazor.Server.UI/Pages/Identity/Roles/RoleFormModel.cs new file mode 100644 index 000000000..f5943572b --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Identity/Roles/RoleFormModel.cs @@ -0,0 +1,29 @@ +using FluentValidation; + +namespace Blazor.Server.UI.Pages.Identity.Roles; + +public class RoleFormModel +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } +} + + +public class RoleFormModelValidator : AbstractValidator +{ + public RoleFormModelValidator() + { + RuleFor(v => v.Name) + .MaximumLength(256) + .NotEmpty(); + } + public Func>> ValidateValue => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((RoleFormModel)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) + return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; + +} diff --git a/src/Blazor.Server.UI/Pages/Identity/Roles/Roles.razor b/src/Blazor.Server.UI/Pages/Identity/Roles/Roles.razor new file mode 100644 index 000000000..03d4ecea6 --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Identity/Roles/Roles.razor @@ -0,0 +1,338 @@ +@page "/indentity/roles" +@using Blazor.Server.UI.Components.Dialogs +@using CleanArchitecture.Blazor.Infrastructure.Constants.ClaimTypes +@using Microsoft.AspNetCore.Identity +@using System.ComponentModel +@using System.Reflection +@using System.Security.Claims + +@attribute [Authorize(Policy = Permissions.Roles.View)] +@inject IStringLocalizer L +@Title + + + + + + +
+ Roles + @L["Refresh"] + @if (_canCreate) + { + @L["Create"] + } + @if (_canDelete) + { + @L["Delete"] + } + @if (_canImport) + { + @L["Import Data"] + } + @if (_canExport) + { + @L["Export Data"] + } +
+ + @if (_canSearch) + { + + + } +
+ + + + + + @L["Actions"] + @L["Name"] + @L["Description"] + + + + @if (_canEdit || _canManagePermissions) + { + + @if (_canEdit) + { + @L["Edit"] + } + @if (_canManagePermissions) + { + @L["Set Permissions"] + } + + } + else + { + + @L["No Allowed"] + + } + + @context.Name + @context.Description + + + + + +
+ + <_PermissionsDrawer OnOpenChanged="OnOpenChangedHandler" Open="_showPermissionsDrawer" Permissions="_permissions" OnAssignChanged="OnAssignChangedHandler"> +
+ + Oopsie !! 😔 @context.GetBaseException().Message + +
+ +@code { + + private string CurrentRoleName = string.Empty; + private List RoleList = new List(); + private HashSet SelectItems = new HashSet(); + private string _searchString=string.Empty; + private bool _sortNameByLength; + public string? Title { get; private set; } + [CascadingParameter] + private Task AuthState { get; set; } = default!; + [Inject] + private RoleManager _roleManager { get; set; } = default!; + + private List _permissions = new(); + private IList _assignedClaims=default!; + private bool _canCreate; + private bool _canSearch; + private bool _canEdit; + private bool _canDelete; + private bool _canManagePermissions; + private bool _canImport; + private bool _canExport; + private bool _showPermissionsDrawer ; + protected override async Task OnInitializedAsync() + { + Title = L["Roles"]; + var state = await AuthState; + _canCreate = (await AuthService.AuthorizeAsync(state.User, Permissions.Roles.Create)).Succeeded; + _canSearch = (await AuthService.AuthorizeAsync(state.User, Permissions.Roles.Search)).Succeeded; + _canEdit = (await AuthService.AuthorizeAsync(state.User, Permissions.Roles.Edit)).Succeeded; + _canDelete = (await AuthService.AuthorizeAsync(state.User, Permissions.Roles.Delete)).Succeeded; + _canManagePermissions = (await AuthService.AuthorizeAsync(state.User, Permissions.Roles.ManagePermissions)).Succeeded; + _canImport =false;// (await AuthService.AuthorizeAsync(state.User, Permissions.Users.Import)).Succeeded; + _canExport =false;// (await AuthService.AuthorizeAsync(state.User, Permissions.Users.Export)).Succeeded; + RoleList = await _roleManager.Roles.ToListAsync(); + + } + private Func _quickFilter => x => + { + if (string.IsNullOrWhiteSpace(_searchString)) + return true; + + if (x.Name.Contains(_searchString, StringComparison.OrdinalIgnoreCase)) + return true; + + if (x.Description.Contains(_searchString, StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + }; + private async Task OnRefresh() + { + RoleList = await _roleManager.Roles.ToListAsync(); + } + private async Task OnCreate() + { + var model = new RoleFormModel(); + var parameters = new DialogParameters { ["model"] = model }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = DialogService.Show<_RoleFormDialog>(L["Create a new role"], parameters, options); + var result = await dialog.Result; + if (!result.Cancelled) + { + var applicationRole = new ApplicationRole() + { + Name = model.Name, + Description = model.Description + }; + + var state = await _roleManager.CreateAsync(applicationRole); + if (state.Succeeded) + { + RoleList.Add(applicationRole); + Snackbar.Add($"Create successfully", MudBlazor.Severity.Info); + } + else + { + Snackbar.Add($"{string.Join(",", (state.Errors.Select(x => x.Description).ToArray()))}", MudBlazor.Severity.Error); + } + } + } + private async Task OnEdit(ApplicationRole item) + { + var model = new RoleFormModel() + { + Id = item.Id, + Name = item.Name, + Description = item.Description + }; + var parameters = new DialogParameters { ["model"] = model }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = DialogService.Show<_RoleFormDialog>(L["Create a new role"], parameters, options); + var result = await dialog.Result; + if (!result.Cancelled) + { + item.Name = model.Name; + item.Description = model.Description; + + var state = await _roleManager.UpdateAsync(item); + if (state.Succeeded) + { + Snackbar.Add($"Updated successfully", MudBlazor.Severity.Info); + } + else + { + Snackbar.Add($"{string.Join(",", (state.Errors.Select(x => x.Description).ToArray()))}", MudBlazor.Severity.Error); + } + } + } + private async Task OnSetPermissions(ApplicationRole item) + { + CurrentRoleName = item.Name; + _permissions = await GetAllPermissions(item); + _showPermissionsDrawer = true; + + } + + private async Task> GetAllPermissions(ApplicationRole role) + { + _assignedClaims = await _roleManager.GetClaimsAsync(role); + var allPermissions = new List(); + var modules = typeof(Permissions).GetNestedTypes(); + foreach (var module in modules) + { + var moduleName = string.Empty; + var moduleDescription = string.Empty; + if (module.GetCustomAttributes(typeof(DisplayNameAttribute), true) + .FirstOrDefault() is DisplayNameAttribute displayNameAttribute) + moduleName = displayNameAttribute.DisplayName; + + if (module.GetCustomAttributes(typeof(DescriptionAttribute), true) + .FirstOrDefault() is DescriptionAttribute descriptionAttribute) + moduleDescription = descriptionAttribute.Description; + + var fields = module.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy); + foreach (var fi in fields) + { + var propertyValue = fi.GetValue(null); + + if (propertyValue is not null) + { + var claimValue = propertyValue.ToString(); + allPermissions.Add( + new PermissionModel + { + RoleId = role.Id, + ClaimValue = claimValue, + ClaimType = ApplicationClaimTypes.Permission, + Group = moduleName, + Description = moduleDescription, + Assigned = _assignedClaims.Any(x => x.Value == claimValue) + }); + } + } + } + return allPermissions; + } + private Task OnOpenChangedHandler(bool state) + { + _showPermissionsDrawer = state; + return Task.CompletedTask; + } + private async Task OnAssignChangedHandler(PermissionModel model) + { + var roleId=model.RoleId; + var role = await _roleManager.FindByIdAsync(roleId); + model.Assigned = !model.Assigned; + if (model.Assigned) + { + await _roleManager.AddClaimAsync(role,new Claim(model.ClaimType,model.ClaimValue)); + } + else + { + var removed = _assignedClaims.FirstOrDefault(x=>x.Value==model.ClaimValue); + if(removed is not null) + { + await _roleManager.RemoveClaimAsync(role,removed); + } + } + + } + private async Task OnDeleteChecked() + { + string deleteContent = L["You're sure you want to delete selected items:{0}?"]; + var parameters = new DialogParameters + { + { nameof(DeleteConfirmation.ContentText), string.Format(deleteContent, SelectItems.Count) } + }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true, DisableBackdropClick = true }; + var dialog = DialogService.Show(L["Delete"], parameters, options); + var result = await dialog.Result; + if (!result.Cancelled) + { + foreach(var item in SelectItems) + { + await _roleManager.DeleteAsync(item); + RoleList.Remove(item); + } + } + } + + + + +} diff --git a/src/Blazor.Server.UI/Pages/Identity/Roles/_PermissionsDrawer.razor b/src/Blazor.Server.UI/Pages/Identity/Roles/_PermissionsDrawer.razor new file mode 100644 index 000000000..5f69e70ab --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Identity/Roles/_PermissionsDrawer.razor @@ -0,0 +1,59 @@ +@inherits MudComponentBase +@inject IStringLocalizer L + + + + Set Permissions + + @foreach (var group in Permissions.Select(x=>x.Group).Distinct().ToList()) + { + + + + @group + + + + @for(var i=0;i + } + } + + + @L["Assign All"] + + + } + + +@code { + [EditorRequired] [Parameter] public List Permissions { get; set; } = new()!; + [EditorRequired] [Parameter] public bool Open { get; set; } = default!; + [EditorRequired] [Parameter] public EventCallback OnAssignChanged { get; set; } + [EditorRequired] [Parameter] public EventCallback OnOpenChanged { get; set; } + private Task OnAssignAll(string? groupName) + { + for(var i = 0; i < Permissions.Count; i++) + { + if (Permissions[i].Group == groupName) + { + Permissions[i].Assigned = true; + } + } + return Task.CompletedTask; + } +} diff --git a/src/Blazor.Server.UI/Pages/Identity/Roles/_RoleFormDialog.razor b/src/Blazor.Server.UI/Pages/Identity/Roles/_RoleFormDialog.razor new file mode 100644 index 000000000..aa793297c --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Identity/Roles/_RoleFormDialog.razor @@ -0,0 +1,47 @@ +@inherits MudComponentBase +@inject IStringLocalizer L + + + + + + + + + + + + + + + + @L["Cancle"] + @L["Ok"] + + + +@code { + MudForm form = default!; + RoleFormModelValidator modelValidator = new RoleFormModelValidator(); + [EditorRequired] [Parameter] public RoleFormModel model { get; set; } = default!; + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } = default!; + + async Task Submit() { + await form.Validate(); + if (form.IsValid) + { + MudDialog.Close(DialogResult.Ok(true)); + } + + } + void Cancel() => MudDialog.Cancel(); + +} diff --git a/src/Blazor.Server.UI/Pages/Identity/Users/ResetPasswordFormModel.cs b/src/Blazor.Server.UI/Pages/Identity/Users/ResetPasswordFormModel.cs new file mode 100644 index 000000000..6b1048a3d --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Identity/Users/ResetPasswordFormModel.cs @@ -0,0 +1,11 @@ +namespace Blazor.Server.UI.Pages.Identity.Users; + +public class ResetPasswordFormModel +{ + public string? Id { get; set; } + public string? UserName { get; set; } + public string? DisplayName { get; set; } + public string? ProfilePictureDataUrl { get; set; } + public string? Password { get; set; } + public string? ConfirmPassword { get; set; } +} diff --git a/src/Blazor.Server.UI/Pages/Identity/Users/ResetPasswordFormModelValidator.cs b/src/Blazor.Server.UI/Pages/Identity/Users/ResetPasswordFormModelValidator.cs new file mode 100644 index 000000000..9ab67aa04 --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Identity/Users/ResetPasswordFormModelValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +namespace Blazor.Server.UI.Pages.Identity.Users; + +public class ResetPasswordFormModelValidator : AbstractValidator +{ + public ResetPasswordFormModelValidator() + { + RuleFor(p => p.Password).NotEmpty().WithMessage("Your password cannot be empty") + .MinimumLength(6).WithMessage("Your password length must be at least 6.") + .MaximumLength(16).WithMessage("Your password length must not exceed 16.") + .Matches(@"[A-Z]+").WithMessage("Your password must contain at least one uppercase letter.") + .Matches(@"[a-z]+").WithMessage("Your password must contain at least one lowercase letter.") + .Matches(@"[0-9]+").WithMessage("Your password must contain at least one number.") + .Matches(@"[\!\?\*\.]+").WithMessage("Your password must contain at least one (!? *.)."); + RuleFor(x => x.ConfirmPassword) + .Equal(x => x.Password); + } + + public Func>> ValidateValue => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((ResetPasswordFormModel)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) + return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; +} diff --git a/src/Blazor.Server.UI/Pages/Identity/Users/UserFormModel.cs b/src/Blazor.Server.UI/Pages/Identity/Users/UserFormModel.cs new file mode 100644 index 000000000..88bbd2a7e --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Identity/Users/UserFormModel.cs @@ -0,0 +1,15 @@ +namespace Blazor.Server.UI.Pages.Identity.Users; + +public class UserFormModel +{ + public string? Id { get; set; } + public string? UserName { get; set; } + public string? DisplayName { get; set; } + public string? Site { get; set; } + public string? ProfilePictureDataUrl { get; set; } + public string? Email { get; set; } + public string? Password { get; set; } + public string? ConfirmPassword { get; set; } + public string? PhoneNumber { get; set; } + public string[]? AssignRoles { get; set; } +} diff --git a/src/Blazor.Server.UI/Pages/Identity/Users/UserFormModelValidator.cs b/src/Blazor.Server.UI/Pages/Identity/Users/UserFormModelValidator.cs new file mode 100644 index 000000000..be81dd50e --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Identity/Users/UserFormModelValidator.cs @@ -0,0 +1,37 @@ +using FluentValidation; +namespace Blazor.Server.UI.Pages.Identity.Users; + +public class UserFormModelValidator: AbstractValidator +{ + public UserFormModelValidator() + { + RuleFor(v => v.Site) + .MaximumLength(256) + .NotEmpty(); + RuleFor(v => v.UserName) + .MaximumLength(256) + .NotEmpty(); + RuleFor(v => v.Email) + .MaximumLength(256) + .NotEmpty() + .EmailAddress(); + + RuleFor(p => p.Password).NotEmpty().WithMessage("Your password cannot be empty") + .MinimumLength(6).WithMessage("Your password length must be at least 6.") + .MaximumLength(16).WithMessage("Your password length must not exceed 16.") + .Matches(@"[A-Z]+").WithMessage("Your password must contain at least one uppercase letter.") + .Matches(@"[a-z]+").WithMessage("Your password must contain at least one lowercase letter.") + .Matches(@"[0-9]+").WithMessage("Your password must contain at least one number.") + .Matches(@"[\!\?\*\.]+").WithMessage("Your password must contain at least one (!? *.)."); + RuleFor(x => x.ConfirmPassword) + .Equal(x => x.Password); + } + + public Func>> ValidateValue => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((UserFormModel)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) + return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; +} diff --git a/src/Blazor.Server.UI/Pages/Identity/Users/Users.razor b/src/Blazor.Server.UI/Pages/Identity/Users/Users.razor index 19648f417..4289f0372 100644 --- a/src/Blazor.Server.UI/Pages/Identity/Users/Users.razor +++ b/src/Blazor.Server.UI/Pages/Identity/Users/Users.razor @@ -1,31 +1,34 @@ @page "/indentity/users" +@using Blazor.Server.UI.Components.Dialogs @using Microsoft.AspNetCore.Identity - -@attribute [Authorize(Policy = Permissions.Users.View)] @inject IStringLocalizer L +@attribute [Authorize(Policy = Permissions.Users.View)] + @Title -
Users @L["Refresh"] @if (_canCreate) @@ -34,6 +37,7 @@ StartIcon="@Icons.Material.Filled.Add" Size="Size.Small" Style="margin-right: 5px;" + OnClick="OnCreate" IconColor="Color.Surface">@L["Create"] } @if (_canDelete) @@ -43,9 +47,10 @@ Disabled="@(!(SelectItems.Count>0))" Size="Size.Small" Style="margin-right: 5px;" + OnClick="OnDeleteChecked" IconColor="Color.Surface">@L["Delete"] } - @if (_canImport) + @if (_canImport) { } + + + + - @L["Actions"] + @L["Actions"] @L["Site"] @L["User Name"] @L["Dispaly Name"] @@ -83,41 +92,43 @@ - @if(_canEdit || _canManageRoles || _canRestPassword || _canActive) { - - @if (_canEdit) - { - @L["Edit"] - } - @if (_canActive) - { - @if (context.IsActive) + @if (_canEdit) + { + OnEdit(context))>@L["Edit"] + } + @if (_canActive) { - @L["Set Inactive"] + @if (context.IsActive) + { + OnSetActive(context))>@L["Set Inactive"] + } + else + { + OnSetActive(context))>@L["Set Active"] + } + } - else + @if (_canRestPassword) { - @L["Set Active"] + OnResetPassword(context))>@L["Rest Password"] } - } - @if (_canRestPassword) - { - @L["Rest Password"] - } - @if (_canManageRoles) - { - @L["Manage Roles"] - } - - } else { - + } + else + { + - @L["No Allowed"] - + @L["No Allowed"] + } @context.Site @@ -125,7 +136,9 @@ @context.DisplayName @context.Email @context.PhoneNumber - @context.IsActive + + + @context.LockoutEnd @@ -136,14 +149,14 @@ - Oopsie !! 😔 @context.GetBaseException().Message + Oopsie !! 😔 @context.GetBaseException().Message @code { - private IEnumerable UserList = new List(); + private List UserList = new List(); private HashSet SelectItems = new HashSet(); private string _searchString; private bool _sortNameByLength; @@ -151,9 +164,7 @@ [CascadingParameter] protected Task AuthState { get; set; } = default!; [Inject] - protected IAuthorizationService AuthService { get; set; } = default!; - [Inject] - private UserManager _userManager { get; set; } + private UserManager _userManager { get; set; } = default!; private bool _canCreate; private bool _canSearch; @@ -164,6 +175,8 @@ private bool _canRestPassword; private bool _canImport; private bool _canExport; + private bool _loading; + private MudTable _table = default!; protected override async Task OnInitializedAsync() { Title = L["Users"]; @@ -175,8 +188,8 @@ _canActive = (await AuthService.AuthorizeAsync(state.User, Permissions.Users.Active)).Succeeded; _canManageRoles = (await AuthService.AuthorizeAsync(state.User, Permissions.Users.ManageRoles)).Succeeded; _canRestPassword = (await AuthService.AuthorizeAsync(state.User, Permissions.Users.RestPassword)).Succeeded; - _canImport = (await AuthService.AuthorizeAsync(state.User, Permissions.Users.Import)).Succeeded; - _canExport = (await AuthService.AuthorizeAsync(state.User, Permissions.Users.Export)).Succeeded; + _canImport = false;// (await AuthService.AuthorizeAsync(state.User, Permissions.Users.Import)).Succeeded; + _canExport =false;// (await AuthService.AuthorizeAsync(state.User, Permissions.Users.Export)).Succeeded; UserList = await _userManager.Users.ToListAsync(); } @@ -196,8 +209,149 @@ return false; }; - private void click() + private async Task OnRefresh() + { + UserList = await _userManager.Users.ToListAsync(); + } + private async Task OnCreate() { - throw new Exception("test"); + var model = new UserFormModel(); + var parameters = new DialogParameters { ["model"] = model }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = DialogService.Show<_UserFormDialog>(L["Create a new user"], parameters, options); + var result = await dialog.Result; + if (!result.Cancelled) + { + var applicationUser = new ApplicationUser() + { + Site = model.Site, + DisplayName = model.DisplayName, + UserName = model.UserName, + Email = model.Email, + PhoneNumber = model.PhoneNumber, + ProfilePictureDataUrl = model.ProfilePictureDataUrl + }; + var password = model.Password; + var state = await _userManager.CreateAsync(applicationUser, password); + if (state.Succeeded) + { + UserList.Add(applicationUser); + Snackbar.Add($"Create successfully", MudBlazor.Severity.Info); + } + else + { + Snackbar.Add($"{string.Join(",", (state.Errors.Select(x => x.Description).ToArray()))}", MudBlazor.Severity.Error); + } + } + + } + private async Task OnEdit(ApplicationUser item) + { + var roles = await _userManager.GetRolesAsync(item); + var model = new UserFormModel() + { + Id = item.Id, + Site = item.Site, + DisplayName = item.DisplayName, + UserName = item.UserName, + Email = item.Email, + PhoneNumber = item.PhoneNumber, + ProfilePictureDataUrl = item.ProfilePictureDataUrl, + AssignRoles = roles.ToArray() + }; + + var parameters = new DialogParameters { ["model"] = model }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = DialogService.Show<_UserFormDialog>(L["Edit the user"], parameters, options); + var result = await dialog.Result; + if (!result.Cancelled) + { + item.DisplayName = model.DisplayName; + item.UserName = model.UserName; + item.Email = model.Email; + item.PhoneNumber = model.PhoneNumber; + item.ProfilePictureDataUrl = model.ProfilePictureDataUrl; + + var state = await _userManager.UpdateAsync(item); + if (model.AssignRoles is not null && model.AssignRoles.Length > 0) + { + await _userManager.RemoveFromRolesAsync(item, roles); + await _userManager.AddToRolesAsync(item, model.AssignRoles); + } + if (state.Succeeded) + { + Snackbar.Add($"Update successfully", MudBlazor.Severity.Info); + } + else + { + Snackbar.Add($"{string.Join(",", (state.Errors.Select(x => x.Description).ToArray()))}", MudBlazor.Severity.Error); + } + } + + } + private async Task OnDeleteChecked() + { + string deleteContent = L["You're sure you want to delete selected items:{0}?"]; + var parameters = new DialogParameters + { + { nameof(DeleteConfirmation.ContentText), string.Format(deleteContent, SelectItems.Count) } + }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true, DisableBackdropClick = true }; + var dialog = DialogService.Show(L["Delete"], parameters, options); + var result = await dialog.Result; + if (!result.Cancelled) + { + foreach(var item in SelectItems) + { + await _userManager.DeleteAsync(item); + UserList.Remove(item); + } + } + } + private async Task OnSetActive(ApplicationUser item) + { + item.IsActive = !item.IsActive; + var state = await _userManager.UpdateAsync(item); + if (state.Succeeded) + { + Snackbar.Add($"Update successfully", MudBlazor.Severity.Info); + } + else + { + Snackbar.Add($"{string.Join(",", (state.Errors.Select(x => x.Description).ToArray()))}", MudBlazor.Severity.Error); + } + } + + private async Task OnResetPassword(ApplicationUser item) + { + var model = new ResetPasswordFormModel() + { + Id = item.Id, + DisplayName = item.DisplayName, + UserName = item.UserName, + ProfilePictureDataUrl = item.ProfilePictureDataUrl + }; + + var parameters = new DialogParameters { ["model"] = model }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.ExtraSmall }; + var dialog = DialogService.Show<_ResetPasswordDialog>(L["Set Password"], parameters, options); + var result = await dialog.Result; + if (!result.Cancelled) + { + item.DisplayName = model.DisplayName; + item.UserName = model.UserName; + item.ProfilePictureDataUrl = model.ProfilePictureDataUrl; + + var token = await _userManager.GeneratePasswordResetTokenAsync(item); + var state = await _userManager.ResetPasswordAsync(item, token, model.Password); + if (state.Succeeded) + { + Snackbar.Add($"Reset password successfully", MudBlazor.Severity.Info); + } + else + { + Snackbar.Add($"{string.Join(",", (state.Errors.Select(x => x.Description).ToArray()))}", MudBlazor.Severity.Error); + } + } } } diff --git a/src/Blazor.Server.UI/Pages/Identity/Users/_ResetPasswordDialog.razor b/src/Blazor.Server.UI/Pages/Identity/Users/_ResetPasswordDialog.razor new file mode 100644 index 000000000..c7b5efd23 --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Identity/Users/_ResetPasswordDialog.razor @@ -0,0 +1,79 @@ +@inherits MudComponentBase +@inject IStringLocalizer L + + + + + + + + + + + + + + + + + + @L["Cancle"] + @L["Ok"] + + +@code { + MudForm form = default!; + ResetPasswordFormModelValidator modelValidator = new ResetPasswordFormModelValidator(); + [EditorRequired] [Parameter] public ResetPasswordFormModel model { get; set; } = default!; + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } = default!; + bool PasswordVisibility; + InputType PasswordInput = InputType.Password; + string PasswordInputIcon = Icons.Material.Filled.VisibilityOff; + void TogglePasswordVisibility() + { + @if (PasswordVisibility) + { + PasswordVisibility = false; + PasswordInputIcon = Icons.Material.Filled.VisibilityOff; + PasswordInput = InputType.Password; + } + else + { + PasswordVisibility = true; + PasswordInputIcon = Icons.Material.Filled.Visibility; + PasswordInput = InputType.Text; + } + } + async Task Submit() { + await form.Validate(); + if (form.IsValid) + { + MudDialog.Close(DialogResult.Ok(true)); + } + + } + void Cancel() => MudDialog.Cancel(); +} diff --git a/src/Blazor.Server.UI/Pages/Identity/Users/_UserForm.razor b/src/Blazor.Server.UI/Pages/Identity/Users/_UserForm.razor new file mode 100644 index 000000000..001a2f345 --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Identity/Users/_UserForm.razor @@ -0,0 +1,181 @@ +@using Microsoft.AspNetCore.Identity +@using SixLabors.ImageSharp +@using SixLabors.ImageSharp.Formats +@using SixLabors.ImageSharp.Processing + +@inherits MudComponentBase +@inject IStringLocalizer L + + + + + + + +@code { + public class keyvalue { + public string key{get;set;} + public bool value{get;set;} + } + MudForm form = default!; + private List _roles { get; set; } = new (); + + [Inject] private IUploadService _uploadService { get; set; } = default!; + [Inject] private RoleManager _roleManager { get; set; } = default!; + UserFormModelValidator modelValidator = new UserFormModelValidator(); + [EditorRequired] [Parameter] public UserFormModel model { get; set; } = default!; + [EditorRequired] [Parameter] public EventCallback OnFormSubmit { get; set; } + bool PasswordVisibility; + InputType PasswordInput = InputType.Password; + string PasswordInputIcon = Icons.Material.Filled.VisibilityOff; + protected override async Task OnInitializedAsync() + { + var array = await _roleManager.Roles.Select(x => x.Name).ToListAsync(); + + foreach(var role in array) + { + if(model.AssignRoles!=null && model.AssignRoles.Contains(role)) + { + _roles.Add(new(){key=role,value= true}); + } + else + { + _roles.Add(new() {key=role,value= false}); + } + } + + } + void TogglePasswordVisibility() + { + @if (PasswordVisibility) + { + PasswordVisibility = false; + PasswordInputIcon = Icons.Material.Filled.VisibilityOff; + PasswordInput = InputType.Password; + } + else + { + PasswordVisibility = true; + PasswordInputIcon = Icons.Material.Filled.Visibility; + PasswordInput = InputType.Text; + } + } + + private async Task UploadPhoto(InputFileChangeEventArgs e) + { + var filestream = e.File.OpenReadStream(); + var imgstream=new MemoryStream(); + await filestream.CopyToAsync(imgstream); + imgstream.Position = 0; + using (var outStream = new MemoryStream()) + { + using (var image = Image.Load(imgstream, out IImageFormat format)) + { + image.Mutate( + i => i.Resize(new ResizeOptions() { Mode = ResizeMode.Crop, Size = new SixLabors.ImageSharp.Size(128, 128) })); + image.Save(outStream, format); + var filename = e.File.Name; + var fi = new FileInfo(filename); + var ext = fi.Extension; + var result = await _uploadService.UploadAsync(new UploadRequest() + { + Data = outStream.ToArray(), + FileName = Guid.NewGuid().ToString() + ext, + Extension = ext, + UploadType = UploadType.ProfilePicture + }); + model.ProfilePictureDataUrl = result; + //Do your validations here + Snackbar.Add($"upload successfully", MudBlazor.Severity.Info); + } + } + } + public async Task Submit() + { + await form.Validate(); + if (form.IsValid) + { + model.AssignRoles = _roles.Where(x => x.value).Select(x => x.key).ToArray(); + await OnFormSubmit.InvokeAsync(model); + } + } +} diff --git a/src/Blazor.Server.UI/Pages/Identity/Users/_UserFormDialog.razor b/src/Blazor.Server.UI/Pages/Identity/Users/_UserFormDialog.razor new file mode 100644 index 000000000..c574bf3f5 --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Identity/Users/_UserFormDialog.razor @@ -0,0 +1,29 @@ +@inherits MudComponentBase +@inject IStringLocalizer L + + + <_UserForm @ref="@_userForm" model="model" OnFormSubmit="OnFormSubmitHandler"> + + + @L["Cancle"] + @L["Ok"] + + + +@code { + private _UserForm _userForm=default!; + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] + public UserFormModel model { get; set; } = default!; + protected async Task Submit() { + await _userForm.Submit(); + } + void Cancel() => MudDialog.Cancel(); + + protected Task OnFormSubmitHandler(UserFormModel model) + { + MudDialog.Close(DialogResult.Ok(model)); + return Task.CompletedTask; + } +} diff --git a/src/Blazor.Server.UI/Pages/Products/Products.razor b/src/Blazor.Server.UI/Pages/Products/Products.razor new file mode 100644 index 000000000..8ea823b70 --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Products/Products.razor @@ -0,0 +1,306 @@ +@page "/pages/products" +@using Blazor.Server.UI.Components.Dialogs +@using CleanArchitecture.Blazor.Application.Features.Products.Commands.Delete +@using CleanArchitecture.Blazor.Application.Features.Products.Commands.Import +@using CleanArchitecture.Blazor.Application.Features.Products.DTOs +@using CleanArchitecture.Blazor.Application.Features.Products.Queries.Export +@using CleanArchitecture.Blazor.Application.Features.Products.Queries.Pagination +@using CleanArchitecture.Blazor.Application.Features.Products.Commands.AddEdit + +@inject IJSRuntime JS +@inject IStringLocalizer L +@Title + + + + +
+ Product + @L["Refresh"] + @if (_canCreate) + { + @L["Create"] + } + @if (_canDelete) + { + @L["Delete"] + } + @if (_canImport) + { +
+ + +
+ + + + + @L["Actions"] + @L["Product Name"] + @L["Price"] + @L["Unit"] + + + + + @if (_canEdit || _canDelete) + { + + @if (_canEdit) + { + @L["Edit"] + } + @if (_canDelete) + { + @L["Delete"] + } + + } + else + { + + @L["No Allowed"] + + } + + + @context.Name + @context.Description + + @context.Price.ToString("C2") + @context.Unit + + + No matching records found + + + Loading... + + + + +
+ +@code { + public string? Title { get; private set; } + private HashSet _selectedItems = new HashSet(); + private MudTable _table = default!; + private int _totalItems; + private string _searchString = string.Empty; + private bool _loading; + [Inject] + private ISender _mediator { get; set; } = default!; + [CascadingParameter] + protected Task AuthState { get; set; } = default!; + + + private bool _canSearch; + private bool _canCreate; + private bool _canEdit; + private bool _canDelete; + private bool _canImport; + private bool _canExport; + protected override async Task OnInitializedAsync() + { + Title = L["Products"]; + var state = await AuthState; + _canCreate = (await AuthService.AuthorizeAsync(state.User, Permissions.Products.Create)).Succeeded; + _canSearch = (await AuthService.AuthorizeAsync(state.User, Permissions.Products.Search)).Succeeded; + _canEdit = (await AuthService.AuthorizeAsync(state.User, Permissions.Products.Edit)).Succeeded; + _canDelete = (await AuthService.AuthorizeAsync(state.User, Permissions.Products.Delete)).Succeeded; + _canImport = (await AuthService.AuthorizeAsync(state.User, Permissions.Products.Import)).Succeeded; + _canExport = (await AuthService.AuthorizeAsync(state.User, Permissions.Products.Export)).Succeeded; + + } + private async Task> ServerReload(TableState state) + { + _loading = true; + var request = new ProductsWithPaginationQuery() + { + Keyword = _searchString, + OrderBy = string.IsNullOrEmpty(state.SortLabel) ? "Id" : state.SortLabel, + SortDirection = (state.SortDirection == SortDirection.None ? SortDirection.Descending.ToString() : state.SortDirection.ToString()), + PageNumber = state.Page + 1, + PageSize = state.PageSize + }; + var result = await _mediator.Send(request); + _loading = false; + return new TableData() { TotalItems = result.TotalItems, Items = result.Items }; + + } + private async Task OnSearch(string text) + { + _searchString = text; + await _table.ReloadServerData(); + } + private async Task OnRefresh() + { + _searchString = string.Empty; + await _table.ReloadServerData(); + } + private async Task OnCreate() + { + var command = new AddEditProductCommand(); + var parameters = new DialogParameters + { + { nameof(_ProductFormDialog.model),command }, + }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + var dialog = DialogService.Show<_ProductFormDialog>(L["Create a new product"], parameters, options); + var state = await dialog.Result; + if (!state.Cancelled) + { + var result = await _mediator.Send(command); + await _table.ReloadServerData(); + Snackbar.Add($"Created successfully", MudBlazor.Severity.Info); + } + } + private async Task OnEdit(ProductDto dto) + { + var command = new AddEditProductCommand() + { + Id = dto.Id, + Name = dto.Name, + Description = dto.Description, + Pictures = dto.Pictures, + Unit = dto.Unit, + Price = dto.Price + }; + var parameters = new DialogParameters + { + { nameof(_ProductFormDialog.model),command }, + }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + var dialog = DialogService.Show<_ProductFormDialog>(L["Edit the product"], parameters, options); + var state = await dialog.Result; + if (!state.Cancelled) + { + var result = await _mediator.Send(command); + await _table.ReloadServerData(); + Snackbar.Add($"Edited successfully", MudBlazor.Severity.Info); + } + } + + private async Task OnDelete(ProductDto dto) + { + var deleteContent = L["You're sure you want to delete the product:{0}?"]; + var parameters = new DialogParameters + { + { nameof(DeleteConfirmation.ContentText), string.Format(deleteContent, dto.Name) } + }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true, DisableBackdropClick = true }; + var dialog = DialogService.Show(L["Delete"], parameters, options); + var state = await dialog.Result; + if (!state.Cancelled) + { + var command = new DeleteProductCommand() { Id = dto.Id }; + var result = await _mediator.Send(command); + await _table.ReloadServerData(); + Snackbar.Add($"Deleted successfully", MudBlazor.Severity.Info); + } + } + + private async Task OnDeleteChecked() + { + var deleteContent = L["You're sure you want to delete the selected items:{0}?"]; + var parameters = new DialogParameters + { + { nameof(DeleteConfirmation.ContentText), string.Format(deleteContent,_selectedItems.Count) } + }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true, DisableBackdropClick = true }; + var dialog = DialogService.Show(L["Delete"], parameters, options); + var state = await dialog.Result; + if (!state.Cancelled) + { + var command = new DeleteCheckedProductsCommand() { Id = _selectedItems.Select(x => x.Id).ToArray() }; + var result = await _mediator.Send(command); + await _table.ReloadServerData(); + Snackbar.Add($"Deleted successfully", MudBlazor.Severity.Info); + } + } + private async Task OnExport() + { + + var request = new ExportProductsQuery() + { + Keyword = _searchString, + OrderBy = string.IsNullOrEmpty(_table.TableContext.SortFieldLabel) ? "Id" : _table.TableContext.SortFieldLabel, + SortDirection = (_table.TableContext.SortDirection == SortDirection.None ? SortDirection.Descending.ToString() : _table.TableContext.SortDirection.ToString()), + }; + var result = await _mediator.Send(request); + using var streamRef = new DotNetStreamReference(new MemoryStream(result)); + await JS.InvokeVoidAsync("downloadFileFromStream", $"{L["Products"]}.xlsx", streamRef); + } + private async Task OnImportData(InputFileChangeEventArgs e) + { + var stream = new MemoryStream(); + await e.File.OpenReadStream().CopyToAsync(stream); + var command=new ImportProductsCommand() + { + Data=stream.ToArray(), + FileName= e.File.Name + }; + var result = await _mediator.Send(command); + if(result.Succeeded){ + await _table.ReloadServerData(); + Snackbar.Add($"Import data successfully", MudBlazor.Severity.Info); + } + else + { + foreach(var msg in result.Errors) + { + Snackbar.Add($"{msg}", MudBlazor.Severity.Error); + } + } + } +} diff --git a/src/Blazor.Server.UI/Pages/Products/_ProductFormDialog.razor b/src/Blazor.Server.UI/Pages/Products/_ProductFormDialog.razor new file mode 100644 index 000000000..25e97327f --- /dev/null +++ b/src/Blazor.Server.UI/Pages/Products/_ProductFormDialog.razor @@ -0,0 +1,120 @@ +@using CleanArchitecture.Blazor.Application.Features.Products.Commands.AddEdit +@using SixLabors.ImageSharp +@using SixLabors.ImageSharp.Formats +@using SixLabors.ImageSharp.Processing +@inherits MudComponentBase +@inject IStringLocalizer L + + + + + + + + + + + + + + + + + + + + + + + + @L["Cancle"] + @L["Ok"] + + + +@code { + MudForm form = default!; + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } = default!; + [Inject] private IUploadService _uploadService { get; set; } = default!; + AddEditProductCommandValidator modelValidator = new AddEditProductCommandValidator(); + [EditorRequired] [Parameter] public AddEditProductCommand model { get; set; } = default!; + + private IList? _prictures = null; + private async Task UploadFiles(InputFileChangeEventArgs e) + { + var list = new List(); + foreach (var file in e.GetMultipleFiles()) + { + var filestream = file.OpenReadStream(); + var imgstream = new MemoryStream(); + await filestream.CopyToAsync(imgstream); + imgstream.Position = 0; + using (var outStream = new MemoryStream()) + { + using (var image = Image.Load(imgstream, out IImageFormat format)) + { + image.Mutate( + i => i.Resize(new ResizeOptions() { Mode = ResizeMode.Crop, Size = new SixLabors.ImageSharp.Size(640, 320) })); + image.Save(outStream, format); + var filename = file.Name; + var fi = new FileInfo(filename); + var ext = fi.Extension; + var result = await _uploadService.UploadAsync(new UploadRequest() + { + Data = outStream.ToArray(), + FileName = Guid.NewGuid().ToString() + ext, + Extension = ext, + UploadType = UploadType.Product + }); + list.Add(result); + } + } + } + Snackbar.Add($"upload pictures successfully", MudBlazor.Severity.Info); + model.Pictures = list; + } + async Task Submit() + { + await form.Validate(); + if (form.IsValid) + { + MudDialog.Close(DialogResult.Ok(true)); + } + + } + void Cancel() => MudDialog.Cancel(); +} diff --git a/src/Blazor.Server.UI/Pages/_Layout.cshtml b/src/Blazor.Server.UI/Pages/_Layout.cshtml index dd702d7c7..81a2a97ce 100644 --- a/src/Blazor.Server.UI/Pages/_Layout.cshtml +++ b/src/Blazor.Server.UI/Pages/_Layout.cshtml @@ -30,8 +30,27 @@ - - @await RenderSectionAsync("scripts",false) + + diff --git a/src/Blazor.Server.UI/Program.cs b/src/Blazor.Server.UI/Program.cs index 74a141173..ebed4743d 100644 --- a/src/Blazor.Server.UI/Program.cs +++ b/src/Blazor.Server.UI/Program.cs @@ -11,6 +11,7 @@ using CleanArchitecture.Blazor.Infrastructure.Extensions; using Serilog; using Serilog.Events; +using MudBlazor; var builder = WebApplication.CreateBuilder(args); @@ -29,7 +30,18 @@ .AddApplication(); -builder.Services.AddMudServices(); +builder.Services.AddMudServices(config => +{ + config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomRight; + config.SnackbarConfiguration.PreventDuplicates = false; + config.SnackbarConfiguration.NewestOnTop = true; + config.SnackbarConfiguration.ShowCloseIcon = true; + config.SnackbarConfiguration.VisibleStateDuration = 3000; + config.SnackbarConfiguration.HideTransitionDuration = 500; + config.SnackbarConfiguration.ShowTransitionDuration = 500; + config.SnackbarConfiguration.SnackbarVariant = Variant.Filled; +}); +builder.Services.AddMudBlazorDialog(); builder.Services.AddServerSideBlazor(); builder.Services.AddHotKeys(); builder.Services.AddBlazoredLocalStorage(); diff --git a/src/Blazor.Server.UI/Shared/MainLayout.razor b/src/Blazor.Server.UI/Shared/MainLayout.razor index 2571ac14a..bc40a94ba 100644 --- a/src/Blazor.Server.UI/Shared/MainLayout.razor +++ b/src/Blazor.Server.UI/Shared/MainLayout.razor @@ -1,5 +1,5 @@ @inherits LayoutComponentBase; - +@inject IStringLocalizer L @@ -14,7 +14,7 @@ @Body - No authentication is required, click sign in. + @L["authentication is required, click sign in."]
@@ -38,7 +38,7 @@ - + @Body diff --git a/src/Blazor.Server.UI/Shared/MainLayout.razor.cs b/src/Blazor.Server.UI/Shared/MainLayout.razor.cs index 80d8a4b6b..74e1bdb77 100644 --- a/src/Blazor.Server.UI/Shared/MainLayout.razor.cs +++ b/src/Blazor.Server.UI/Shared/MainLayout.razor.cs @@ -4,6 +4,8 @@ using Blazor.Server.UI.Components.Shared; using Blazor.Server.UI.Models; using Toolbelt.Blazor.HotKeys; +using Microsoft.AspNetCore.Components.Authorization; +using CleanArchitecture.Blazor.Infrastructure.Extensions; namespace Blazor.Server.UI.Shared; @@ -44,6 +46,37 @@ public partial class MainLayout : IDisposable }; private readonly MudTheme _theme = new() { + //Shadows = new() { + // Elevation = new string[] + // { + // "none", + // "0 2px 4px -1px rgba(6, 24, 44, 0.2)", + // "0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12)", + // "0 30px 60px rgba(0,0,0,0.12)", + // "0 6px 12px -2px rgba(50,50,93,0.25),0 3px 7px -3px rgba(0,0,0,0.3)", + // "0 50px 100px -20px rgba(50,50,93,0.25),0 30px 60px -30px rgba(0,0,0,0.3)", + // "0px 3px 5px -1px rgba(0,0,0,0.2),0px 6px 10px 0px rgba(0,0,0,0.14),0px 1px 18px 0px rgba(0,0,0,0.12)", + // "0px 4px 5px -2px rgba(0,0,0,0.2),0px 7px 10px 1px rgba(0,0,0,0.14),0px 2px 16px 1px rgba(0,0,0,0.12)", + // "0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12)", + // "0px 5px 6px -3px rgba(0,0,0,0.2),0px 9px 12px 1px rgba(0,0,0,0.14),0px 3px 16px 2px rgba(0,0,0,0.12)", + // "0px 6px 6px -3px rgba(0,0,0,0.2),0px 10px 14px 1px rgba(0,0,0,0.14),0px 4px 18px 3px rgba(0,0,0,0.12)", + // "0px 6px 7px -4px rgba(0,0,0,0.2),0px 11px 15px 1px rgba(0,0,0,0.14),0px 4px 20px 3px rgba(0,0,0,0.12)", + // "0px 7px 8px -4px rgba(0,0,0,0.2),0px 12px 17px 2px rgba(0,0,0,0.14),0px 5px 22px 4px rgba(0,0,0,0.12)", + // "0px 7px 8px -4px rgba(0,0,0,0.2),0px 13px 19px 2px rgba(0,0,0,0.14),0px 5px 24px 4px rgba(0,0,0,0.12)", + // "0px 7px 9px -4px rgba(0,0,0,0.2),0px 14px 21px 2px rgba(0,0,0,0.14),0px 5px 26px 4px rgba(0,0,0,0.12)", + // "0px 8px 9px -5px rgba(0,0,0,0.2),0px 15px 22px 2px rgba(0,0,0,0.14),0px 6px 28px 5px rgba(0,0,0,0.12)", + // "0px 8px 10px -5px rgba(0,0,0,0.2),0px 16px 24px 2px rgba(0,0,0,0.14),0px 6px 30px 5px rgba(0,0,0,0.12)", + // "0px 8px 11px -5px rgba(0,0,0,0.2),0px 17px 26px 2px rgba(0,0,0,0.14),0px 6px 32px 5px rgba(0,0,0,0.12)", + // "0px 9px 11px -5px rgba(0,0,0,0.2),0px 18px 28px 2px rgba(0,0,0,0.14),0px 7px 34px 6px rgba(0,0,0,0.12)", + // "0px 9px 12px -6px rgba(0,0,0,0.2),0px 19px 29px 2px rgba(0,0,0,0.14),0px 7px 36px 6px rgba(0,0,0,0.12)", + // "0px 10px 13px -6px rgba(0,0,0,0.2),0px 20px 31px 3px rgba(0,0,0,0.14),0px 8px 38px 7px rgba(0,0,0,0.12)", + // "0px 10px 13px -6px rgba(0,0,0,0.2),0px 21px 33px 3px rgba(0,0,0,0.14),0px 8px 40px 7px rgba(0,0,0,0.12)", + // "0px 10px 14px -6px rgba(0,0,0,0.2),0px 22px 35px 3px rgba(0,0,0,0.14),0px 8px 42px 7px rgba(0,0,0,0.12)", + // "0 50px 100px -20px rgba(50, 50, 93, 0.25), 0 30px 60px -30px rgba(0, 0, 0, 0.30)", + // "2.8px 2.8px 2.2px rgba(0, 0, 0, 0.02),6.7px 6.7px 5.3px rgba(0, 0, 0, 0.028),12.5px 12.5px 10px rgba(0, 0, 0, 0.035),22.3px 22.3px 17.9px rgba(0, 0, 0, 0.042),41.8px 41.8px 33.4px rgba(0, 0, 0, 0.05),100px 100px 80px rgba(0, 0, 0, 0.07)", + // "0px 0px 20px 0px rgba(0, 0, 0, 0.05)" + // } + //}, Palette = new Palette { Primary = "#2d4275", @@ -157,7 +190,7 @@ public partial class MainLayout : IDisposable } }; - private readonly UserModel _user = new() + private UserModel _user = new() { Avatar = "./sample-data/avatar.png", DisplayName = "MudDemo", @@ -174,10 +207,11 @@ public partial class MainLayout : IDisposable private bool _themingDrawerOpen; - [Inject] private IDialogService _dialogService { get; set; } - [Inject] private HotKeys _hotKeys { get; set; } - [Inject] private ILocalStorageService _localStorage { get; set; } - + [Inject] private IDialogService _dialogService { get; set; } = default!; + [Inject] private HotKeys _hotKeys { get; set; } = default!; + [Inject] private ILocalStorageService _localStorage { get; set; } = default!; + [CascadingParameter] + protected Task AuthState { get; set; } = default!; public void Dispose() { _hotKeysContext?.Dispose(); @@ -193,12 +227,18 @@ protected override async Task OnAfterRenderAsync(bool firstRender) StateHasChanged(); } } - protected override Task OnInitializedAsync() + protected override async Task OnInitializedAsync() { - + var state = await AuthState; + _user = new UserModel() + { + Avatar = state.User.GetProfilePictureDataUrl(), + DisplayName = state.User.GetDisplayName(), + Email = state.User.GetEmail(), + Role = state.User.GetRoles().FirstOrDefault() + }; _hotKeysContext = _hotKeys.CreateContext() .Add(ModKeys.Meta, Keys.K, OpenCommandPalette, "Open command palette."); - return Task.CompletedTask; } protected void SideMenuDrawerOpenChangedHandler(bool state) { diff --git a/src/Blazor.Server.UI/Shared/SharedResource.cs b/src/Blazor.Server.UI/Shared/SharedResource.cs new file mode 100644 index 000000000..fea0353b3 --- /dev/null +++ b/src/Blazor.Server.UI/Shared/SharedResource.cs @@ -0,0 +1,5 @@ +namespace Blazor.Server.UI.Shared; + +public class SharedResource +{ +} diff --git a/src/Blazor.Server.UI/_Imports.razor b/src/Blazor.Server.UI/_Imports.razor index 2f3d4aab2..1bc7a2d12 100644 --- a/src/Blazor.Server.UI/_Imports.razor +++ b/src/Blazor.Server.UI/_Imports.razor @@ -1,5 +1,6 @@ @using System.Net.Http @using System.Net.Http.Json +@using CleanArchitecture.Blazor.Infrastructure.Configurations @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @@ -8,19 +9,29 @@ @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.Extensions.Localization @using Microsoft.EntityFrameworkCore +@using Microsoft.AspNetCore.Identity @using Blazored.LocalStorage @using Microsoft.JSInterop @using Blazor.Server.UI @using Blazor.Server.UI.Shared @using MudBlazor +@using MediatR @using Blazor.Server.UI.Components.Shared @using Blazor.Server.UI.Components.Shared.Themes @using Blazor.Server.UI.Components.Index @using Blazor.Server.UI.Components.Charts @using Blazor.Server.UI.Models.Localization - +@using FluentValidation; @using CleanArchitecture.Blazor.Infrastructure.Identity @using CleanArchitecture.Blazor.Application.Constants.Permission +@using CleanArchitecture.Blazor.Application.Common.Interfaces +@using CleanArchitecture.Blazor.Application.Common.Models +@using CleanArchitecture.Blazor.Domain.Enums +@inject DashbordSettings Settings +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IConfiguration Config +@inject IAuthorizationService AuthService @attribute [Authorize] diff --git a/src/Blazor.Server.UI/appsettings.json b/src/Blazor.Server.UI/appsettings.json index 4387d14af..a46663957 100644 --- a/src/Blazor.Server.UI/appsettings.json +++ b/src/Blazor.Server.UI/appsettings.json @@ -15,15 +15,16 @@ "ProxyIP": "", "ApplicationUrl": "" }, - "SmartSettings": { + "DashbordSettings": { "Version": "4.3.2", - "App": "Razor", - "AppName": "Razor Page WebApp", - "AppFlavor": "ASP.NET Core 6.0", + "App": "Blazor", + "AppName": "Blazor Dashbord", + "AppFlavor": "Blazor .NET 6.0", "AppFlavorSubscript": ".NET 6.0", + "Company": "Company", + "Copyright": "@2022 Copyright", "Theme": { "ThemeVersion": "4.5.1", - "IconPrefix": "fal", "Logo": "logo.png", "User": "hualin,zhu", "Email": "new163@163.com", diff --git a/src/Blazor.Server.UI/nav.json b/src/Blazor.Server.UI/nav.json index 078af7fdb..4b1851b52 100644 --- a/src/Blazor.Server.UI/nav.json +++ b/src/Blazor.Server.UI/nav.json @@ -17,7 +17,7 @@ "isParent": true, "menuItems": [ { - "title": "Product", + "title": "Products", "href": "/pages/products", "pageStatus": "completed" } @@ -62,57 +62,12 @@ }, { "title": "Roles", - "href": "/authentication/roles", - "pageStatus": "comingSoon" - } - ] - }, - { - "title": "User", - "icon": "\u003Cpath d=\u0022M0 0h24v24H0z\u0022 fill=\u0022none\u0022/\u003E\u003Cpath d=\u0022M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z\u0022/\u003E", - "pageStatus": "completed", - "isParent": true, - "menuItems": [ - { - "title": "Profile", - "href": "/user/profile", - "pageStatus": "comingSoon" - }, - { - "title": "Cards", - "href": "/user/cards", - "pageStatus": "comingSoon" - }, - { - "title": "List", - "href": "/user/list", - "pageStatus": "comingSoon" - } - ] - }, - { - "title": "Article", - "icon": "\u003Cpath d=\u0022M0 0h24v24H0z\u0022 fill=\u0022none\u0022/\u003E\u003Cpath d=\u0022M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z\u0022/\u003E", - "pageStatus": "completed", - "isParent": true, - "menuItems": [ - { - "title": "Posts", - "href": "/user/posts", - "pageStatus": "comingSoon" - }, - { - "title": "Post", - "href": "/user/post", - "pageStatus": "comingSoon" - }, - { - "title": "New Post", - "href": "/user/newpost", - "pageStatus": "comingSoon" + "href": "/indentity/roles", + "pageStatus": "completed" } ] } + ] } ] diff --git a/src/Domain/Common/AuditableEntity.cs b/src/Domain/Common/AuditableEntity.cs index 33e4dd422..5d9e6f6fe 100644 --- a/src/Domain/Common/AuditableEntity.cs +++ b/src/Domain/Common/AuditableEntity.cs @@ -9,23 +9,23 @@ public interface IEntity } public abstract class AuditableEntity : IEntity { - public DateTime Created { get; set; } + public DateTime? Created { get; set; } - public string CreatedBy { get; set; } + public string? CreatedBy { get; set; } public DateTime? LastModified { get; set; } - public string LastModifiedBy { get; set; } + public string? LastModifiedBy { get; set; } } public interface ISoftDelete { DateTime? Deleted { get; set; } - string DeletedBy { get; set; } + string? DeletedBy { get; set; } } public abstract class AuditableSoftDeleteEntity : AuditableEntity, ISoftDelete { public DateTime? Deleted { get; set; } - public string DeletedBy { get; set; } + public string? DeletedBy { get; set; } } diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj index 2697a78dc..a4f6f0991 100644 --- a/src/Domain/Domain.csproj +++ b/src/Domain/Domain.csproj @@ -1,14 +1,15 @@ - + net6.0 CleanArchitecture.Blazor.Domain CleanArchitecture.Blazor.Domain enable + enable - + diff --git a/src/Domain/Entities/Audit/AuditTrail.cs b/src/Domain/Entities/Audit/AuditTrail.cs index eb0b8c940..e4e24f64a 100644 --- a/src/Domain/Entities/Audit/AuditTrail.cs +++ b/src/Domain/Entities/Audit/AuditTrail.cs @@ -8,14 +8,14 @@ namespace CleanArchitecture.Blazor.Domain.Entities.Audit; public class AuditTrail : IEntity { public int Id { get; set; } - public string UserId { get; set; } + public string? UserId { get; set; } public AuditType AuditType { get; set; } - public string TableName { get; set; } + public string? TableName { get; set; } public DateTime DateTime { get; set; } - public Dictionary OldValues { get; set; } = new(); - public Dictionary NewValues { get; set; } = new(); - public ICollection AffectedColumns { get; set; } - public Dictionary PrimaryKey { get; set; } = new(); + public Dictionary? OldValues { get; set; } = new(); + public Dictionary? NewValues { get; set; } = new(); + public ICollection? AffectedColumns { get; set; } + public Dictionary? PrimaryKey { get; set; } = new(); public List TemporaryProperties { get; } = new(); public bool HasTemporaryProperties => TemporaryProperties.Any(); diff --git a/src/Domain/Entities/Customer.cs b/src/Domain/Entities/Customer.cs index 13b296800..857c3d9e6 100644 --- a/src/Domain/Entities/Customer.cs +++ b/src/Domain/Entities/Customer.cs @@ -6,26 +6,26 @@ namespace CleanArchitecture.Blazor.Domain.Entities; public partial class Customer : AuditableEntity, IHasDomainEvent, IAuditTrial { public int Id { get; set; } - public string Name { get; set; } - public string NameOfEnglish { get; set; } - public string GroupName { get; set; } + public string? Name { get; set; } + public string? NameOfEnglish { get; set; } + public string? GroupName { get; set; } public PartnerType PartnerType { get; set; } - public string Region { get; set; } - public string Sales { get; set; } - public string RegionSalesDirector { get; set; } + public string? Region { get; set; } + public string? Sales { get; set; } + public string? RegionSalesDirector { get; set; } - public string Address { get; set; } + public string? Address { get; set; } - public string AddressOfEnglish { get; set; } + public string? AddressOfEnglish { get; set; } - public string Contact { get; set; } + public string? Contact { get; set; } - public string Email { get; set; } + public string? Email { get; set; } - public string PhoneNumber { get; set; } + public string? PhoneNumber { get; set; } - public string Fax { get; set; } - public string Comments { get; set; } + public string? Fax { get; set; } + public string? Comments { get; set; } public List DomainEvents { get; set; } = new(); } diff --git a/src/Domain/Entities/Document.cs b/src/Domain/Entities/Document.cs index 7672ba8cc..002d67633 100644 --- a/src/Domain/Entities/Document.cs +++ b/src/Domain/Entities/Document.cs @@ -6,11 +6,11 @@ namespace CleanArchitecture.Blazor.Domain.Entities; public class Document : AuditableEntity, IHasDomainEvent { public int Id { get; set; } - public string Title { get; set; } - public string Description { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } public bool IsPublic { get; set; } - public string URL { get; set; } + public string? URL { get; set; } public int DocumentTypeId { get; set; } - public virtual DocumentType DocumentType { get; set; } + public virtual DocumentType DocumentType { get; set; } = default!; public List DomainEvents { get; set; } = new(); } diff --git a/src/Domain/Entities/DocumentType.cs b/src/Domain/Entities/DocumentType.cs index 55eff0d48..49578c583 100644 --- a/src/Domain/Entities/DocumentType.cs +++ b/src/Domain/Entities/DocumentType.cs @@ -6,6 +6,6 @@ namespace CleanArchitecture.Blazor.Domain.Entities; public class DocumentType : AuditableEntity { public int Id { get; set; } - public string Name { get; set; } - public string Description { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } } diff --git a/src/Domain/Entities/KeyValue.cs b/src/Domain/Entities/KeyValue.cs index 6f1145ed7..fccef12e0 100644 --- a/src/Domain/Entities/KeyValue.cs +++ b/src/Domain/Entities/KeyValue.cs @@ -6,9 +6,9 @@ namespace CleanArchitecture.Blazor.Domain.Entities; public class KeyValue : AuditableEntity, IHasDomainEvent { public int Id { get; set; } - public string Name { get; set; } - public string Value { get; set; } - public string Text { get; set; } - public string Description { get; set; } + public string Name { get; set; } = default!; + public string Value { get; set; } = default!; + public string Text { get; set; } = default!; + public string? Description { get; set; } public List DomainEvents { get; set; } = new(); } diff --git a/src/Domain/Entities/Logger/Logger.cs b/src/Domain/Entities/Logger/Logger.cs new file mode 100644 index 000000000..57bf6b60e --- /dev/null +++ b/src/Domain/Entities/Logger/Logger.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace CleanArchitecture.Blazor.Domain.Entities.Log; + +public class Logger : IEntity +{ + public int Id { get; set; } + public string? Message { get; set; } + public string? MessageTemplate { get; set; } + public string Level { get; set; } = default!; + + public DateTime TimeStamp { get; set; } = DateTime.Now; + public string? Exception { get; set; } + public string? UserName { get; set; } + public string? ClientIP { get; set; } + public string? ClientAgent { get; set; } + public string? Properties { get; set; } + public string? LogEvent { get; set; } + +} diff --git a/src/Domain/Entities/Product.cs b/src/Domain/Entities/Product.cs index 06a786e02..dbdd22c4b 100644 --- a/src/Domain/Entities/Product.cs +++ b/src/Domain/Entities/Product.cs @@ -6,6 +6,10 @@ namespace CleanArchitecture.Blazor.Domain.Entities; public class Product : AuditableEntity { public int Id { get; set; } - public string Name { get; set; } - public string Description { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Unit { get; set; } + public decimal Price { get; set; } + public IList? Pictures { get; set; } + } diff --git a/src/Infrastructure/Configurations/DashbordSettings.cs b/src/Infrastructure/Configurations/DashbordSettings.cs new file mode 100644 index 000000000..f303fff72 --- /dev/null +++ b/src/Infrastructure/Configurations/DashbordSettings.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace CleanArchitecture.Blazor.Infrastructure.Configurations; + +public class DashbordSettings +{ + public const string SectionName = nameof(DashbordSettings); + + public string Version { get; set; }="6.0.2"; + public string App { get; set; } = "Dashbord"; + public string AppName { get; set; } = "Admin Dashbord"; + public string AppFlavor { get; set; } = String.Empty; + public string AppFlavorSubscript { get; set; } = String.Empty; + + public string Company { get; set; } = "Company"; + public string Copyright { get; set; } = "@2022 Copyright"; + public Theme Theme { get; set; } = default!; + public Features Features { get; set; } = default!; +} diff --git a/src/Infrastructure/Configurations/SmartSettings.cs b/src/Infrastructure/Configurations/SmartSettings.cs deleted file mode 100644 index 63a48ccf1..000000000 --- a/src/Infrastructure/Configurations/SmartSettings.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace CleanArchitecture.Blazor.Infrastructure.Configurations; - -public class SmartSettings -{ - public const string SectionName = nameof(SmartSettings); - - public string Version { get; set; } - public string App { get; set; } - public string AppName { get; set; } - public string AppFlavor { get; set; } - public string AppFlavorSubscript { get; set; } - public Theme Theme { get; set; } - public Features Features { get; set; } -} diff --git a/src/Infrastructure/Configurations/Theme.cs b/src/Infrastructure/Configurations/Theme.cs index 207f0b260..edb08b2be 100644 --- a/src/Infrastructure/Configurations/Theme.cs +++ b/src/Infrastructure/Configurations/Theme.cs @@ -5,12 +5,11 @@ namespace CleanArchitecture.Blazor.Infrastructure.Configurations; public class Theme { - public string ThemeVersion { get; set; } - public string IconPrefix { get; set; } - public string Logo { get; set; } - public string User { get; set; } - public string Role { get; set; } - public string Email { get; set; } - public string Twitter { get; set; } - public string Avatar { get; set; } + public string ThemeVersion { get; set; } = default!; + public string Logo { get; set; } = default!; + public string User { get; set; } = default!; + public string Role { get; set; } = default!; + public string Email { get; set; } = default!; + public string Twitter { get; set; } = default!; + public string Avatar { get; set; } = default!; } diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index aa10cf88c..40ffb6e89 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -44,8 +44,8 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi options.CheckConsentNeeded = context => true; options.MinimumSameSitePolicy = SameSiteMode.None; }); - services.Configure(configuration.GetSection(SmartSettings.SectionName)); - services.AddSingleton(s => s.GetRequiredService>().Value); + services.Configure(configuration.GetSection(DashbordSettings.SectionName)); + services.AddSingleton(s => s.GetRequiredService>().Value); services.AddSingleton(); services.AddScoped(provider => provider.GetService()); services.AddScoped(); diff --git a/src/Infrastructure/Identity/ApplicationRole.cs b/src/Infrastructure/Identity/ApplicationRole.cs index eb044bd58..461073ab6 100644 --- a/src/Infrastructure/Identity/ApplicationRole.cs +++ b/src/Infrastructure/Identity/ApplicationRole.cs @@ -7,17 +7,18 @@ namespace CleanArchitecture.Blazor.Infrastructure.Identity; public class ApplicationRole : IdentityRole { - public string Description { get; set; } + public string? Description { get; set; } public virtual ICollection RoleClaims { get; set; } public virtual ICollection UserRoles { get; set; } public ApplicationRole() : base() { RoleClaims = new HashSet(); + UserRoles = new HashSet(); } - public ApplicationRole(string roleName, string roleDescription = null) : base(roleName) + public ApplicationRole(string roleName) : base(roleName) { RoleClaims = new HashSet(); - Description = roleDescription; + UserRoles = new HashSet(); } } diff --git a/src/Infrastructure/Identity/ApplicationRoleClaim.cs b/src/Infrastructure/Identity/ApplicationRoleClaim.cs index a25b75d75..82d489286 100644 --- a/src/Infrastructure/Identity/ApplicationRoleClaim.cs +++ b/src/Infrastructure/Identity/ApplicationRoleClaim.cs @@ -7,17 +7,9 @@ namespace CleanArchitecture.Blazor.Infrastructure.Identity; public class ApplicationRoleClaim : IdentityRoleClaim { - public string Description { get; set; } - public string Group { get; set; } - public virtual ApplicationRole Role { get; set; } + public string? Description { get; set; } + public string? Group { get; set; } + public virtual ApplicationRole Role { get; set; } = default!; - public ApplicationRoleClaim() : base() - { - } - - public ApplicationRoleClaim(string roleClaimDescription = null, string roleClaimGroup = null) : base() - { - Description = roleClaimDescription; - Group = roleClaimGroup; - } + } diff --git a/src/Infrastructure/Identity/ApplicationUser.cs b/src/Infrastructure/Identity/ApplicationUser.cs index b90c43561..8b965a876 100644 --- a/src/Infrastructure/Identity/ApplicationUser.cs +++ b/src/Infrastructure/Identity/ApplicationUser.cs @@ -9,13 +9,13 @@ namespace CleanArchitecture.Blazor.Infrastructure.Identity; public class ApplicationUser : IdentityUser { - public string DisplayName { get; set; } - public string Site { get; set; } + public string? DisplayName { get; set; } + public string? Site { get; set; } [Column(TypeName = "text")] - public string ProfilePictureDataUrl { get; set; } + public string? ProfilePictureDataUrl { get; set; } public bool IsActive { get; set; } public bool IsLive { get; set; } - public string RefreshToken { get; set; } + public string? RefreshToken { get; set; } public DateTime RefreshTokenExpiryTime { get; set; } public virtual ICollection UserClaims { get; set; } public virtual ICollection UserRoles { get; set; } diff --git a/src/Infrastructure/Identity/ApplicationUserClaim.cs b/src/Infrastructure/Identity/ApplicationUserClaim.cs index 331c9d2cc..8ba9efead 100644 --- a/src/Infrastructure/Identity/ApplicationUserClaim.cs +++ b/src/Infrastructure/Identity/ApplicationUserClaim.cs @@ -7,15 +7,9 @@ namespace CleanArchitecture.Blazor.Infrastructure.Identity; public class ApplicationUserClaim : IdentityUserClaim { - public string Description { get; set; } - public virtual ApplicationUser User { get; set; } - public ApplicationUserClaim() : base() - { - } + public string? Description { get; set; } + public virtual ApplicationUser User { get; set; } = default!; + - public ApplicationUserClaim(string userClaimDescription = null) : base() - { - Description = userClaimDescription; - - } + } diff --git a/src/Infrastructure/Identity/ApplicationUserLogin.cs b/src/Infrastructure/Identity/ApplicationUserLogin.cs index 28b87bd50..a6a8406fa 100644 --- a/src/Infrastructure/Identity/ApplicationUserLogin.cs +++ b/src/Infrastructure/Identity/ApplicationUserLogin.cs @@ -7,5 +7,5 @@ namespace CleanArchitecture.Blazor.Infrastructure.Identity; public class ApplicationUserLogin : IdentityUserLogin { - public virtual ApplicationUser User { get; set; } + public virtual ApplicationUser User { get; set; } = default!; } diff --git a/src/Infrastructure/Identity/ApplicationUserRole .cs b/src/Infrastructure/Identity/ApplicationUserRole .cs index af6c9e3c1..34f981809 100644 --- a/src/Infrastructure/Identity/ApplicationUserRole .cs +++ b/src/Infrastructure/Identity/ApplicationUserRole .cs @@ -7,6 +7,7 @@ namespace CleanArchitecture.Blazor.Infrastructure.Identity; public class ApplicationUserRole : IdentityUserRole { - public virtual ApplicationUser User { get; set; } - public virtual ApplicationRole Role { get; set; } + public virtual ApplicationUser User { get; set; } = default!; + public virtual ApplicationRole Role { get; set; } = default!; + } diff --git a/src/Infrastructure/Identity/ApplicationUserToken.cs b/src/Infrastructure/Identity/ApplicationUserToken.cs index f2c8ef1b6..60a7b479f 100644 --- a/src/Infrastructure/Identity/ApplicationUserToken.cs +++ b/src/Infrastructure/Identity/ApplicationUserToken.cs @@ -7,5 +7,5 @@ namespace CleanArchitecture.Blazor.Infrastructure.Identity; public class ApplicationUserToken : IdentityUserToken { - public virtual ApplicationUser User { get; set; } + public virtual ApplicationUser User { get; set; } = default!; } diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index 93689554b..b8220284b 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -5,23 +5,24 @@ CleanArchitecture.Blazor.Infrastructure CleanArchitecture.Blazor.Infrastructure enable + enable - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + diff --git a/src/Infrastructure/Middlewares/LocalizationCookiesMiddleware.cs b/src/Infrastructure/Middlewares/LocalizationCookiesMiddleware.cs index a3e4ebb95..cac08fba9 100644 --- a/src/Infrastructure/Middlewares/LocalizationCookiesMiddleware.cs +++ b/src/Infrastructure/Middlewares/LocalizationCookiesMiddleware.cs @@ -33,7 +33,8 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) .Cookies .Append( Provider.CookieName, - CookieRequestCultureProvider.MakeCookieValue(feature.RequestCulture) + CookieRequestCultureProvider.MakeCookieValue(feature.RequestCulture), + new CookieOptions() { Expires = new DateTimeOffset(DateTime.Now.AddMonths(3)) } ); } } diff --git a/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs b/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs index de4ab3e09..5be0f74ae 100644 --- a/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs +++ b/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs @@ -1,10 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using CleanArchitecture.Blazor.Application.Common.Extensions; using CleanArchitecture.Blazor.Application.Common.Extensions; using CleanArchitecture.Blazor.Infrastructure.Constants.Role; -using Microsoft.AspNetCore.Identity; using System.Reflection; @@ -76,8 +74,7 @@ public static async Task SeedSampleDataAsync(ApplicationDbContext context) context.DocumentTypes.Add(new Domain.Entities.DocumentType() { Name = "Image", Description = "Image" }); context.DocumentTypes.Add(new Domain.Entities.DocumentType() { Name = "Other", Description = "Other" }); await context.SaveChangesAsync(); - context.Loggers.Add(new Domain.Entities.Log.Logger() { Message = "Initial add document types", Level = "Information", UserName = "System", TimeStamp = System.DateTime.Now }); - await context.SaveChangesAsync(); + } if (!context.KeyValues.Any()) { @@ -90,21 +87,18 @@ public static async Task SeedSampleDataAsync(ApplicationDbContext context) context.KeyValues.Add(new Domain.Entities.KeyValue() { Name = "Region", Value = "CNS", Text = "CNS", Description = "Region of Customer" }); context.KeyValues.Add(new Domain.Entities.KeyValue() { Name = "Region", Value = "Oversea", Text = "Oversea", Description = "Region of Customer" }); await context.SaveChangesAsync(); - context.Loggers.Add(new Domain.Entities.Log.Logger() { Message = "Initial add key values", Level = "Information", UserName = "System", TimeStamp = System.DateTime.Now }); - await context.SaveChangesAsync(); + } if (!context.Customers.Any()) { context.Customers.Add(new Domain.Entities.Customer() { Name = "SmartAdmin", AddressOfEnglish = "https://wrapbootstrap.com/theme/smartadmin-responsive-webapp-WB0573SK0", GroupName = "SmartAdmin", Address = "https://wrapbootstrap.com/theme/smartadmin-responsive-webapp-WB0573SK0", Sales = "GotBootstrap", RegionSalesDirector = "GotBootstrap", Region = "CNC", NameOfEnglish = "SmartAdmin", PartnerType = Domain.Enums.PartnerType.TP, Contact = "GotBootstrap", Email = "drlantern@gotbootstrap.com" }); await context.SaveChangesAsync(); - - context.Loggers.Add(new Domain.Entities.Log.Logger() { Message = "Initial add customer", Level = "Information", UserName = "System", TimeStamp = System.DateTime.Now }); - - context.Loggers.Add(new Domain.Entities.Log.Logger() { Message = "Debug", Level = "Debug", UserName = "System", TimeStamp = System.DateTime.Now.AddHours(-1) }); - context.Loggers.Add(new Domain.Entities.Log.Logger() { Message = "Error", Level = "Error", UserName = "System", TimeStamp = System.DateTime.Now.AddHours(-1) }); - context.Loggers.Add(new Domain.Entities.Log.Logger() { Message = "Warning", Level = "Warning", UserName = "System", TimeStamp = System.DateTime.Now.AddHours(-2) }); - context.Loggers.Add(new Domain.Entities.Log.Logger() { Message = "Trace", Level = "Trace", UserName = "System", TimeStamp = System.DateTime.Now.AddHours(-4) }); - context.Loggers.Add(new Domain.Entities.Log.Logger() { Message = "Fatal", Level = "Fatal", UserName = "System", TimeStamp = System.DateTime.Now.AddHours(-4) }); + } + if (!context.Products.Any()) + { + context.Products.Add(new Domain.Entities.Product() { Name = "IPhone 13 Pro", Description= "Apple iPhone 13 Pro smartphone. Announced Sep 2021. Features 6.1″ display, Apple A15 Bionic chipset, 3095 mAh battery, 1024 GB storage.", Unit="EA",Price=999.98m }); + context.Products.Add(new Domain.Entities.Product() { Name = "MI 12 Pro", Description = "Xiaomi 12 Pro Android smartphone. Announced Dec 2021. Features 6.73″ display, Snapdragon 8 Gen 1 chipset, 4600 mAh battery, 256 GB storage.", Unit = "EA", Price = 199.00m }); + context.Products.Add(new Domain.Entities.Product() { Name = "MX KEYS Mini", Description = "Logitech MX Keys Mini Introducing MX Keys Mini – a smaller, smarter, and mightier keyboard made for creators. Type with confidence on a keyboard crafted for efficiency, stability, and...", Unit = "PA", Price = 99.90m }); await context.SaveChangesAsync(); } } diff --git a/src/Infrastructure/Persistence/Configurations/IdentityUserConfiguration.cs b/src/Infrastructure/Persistence/Configurations/IdentityUserConfiguration.cs index a83dcc975..031e6cffc 100644 --- a/src/Infrastructure/Persistence/Configurations/IdentityUserConfiguration.cs +++ b/src/Infrastructure/Persistence/Configurations/IdentityUserConfiguration.cs @@ -36,17 +36,7 @@ public void Configure(EntityTypeBuilder builder) .IsRequired(); } } -//public class ApplicationRoleConfiguration : IEntityTypeConfiguration -//{ -// //duplicate definition -// public void Configure(EntityTypeBuilder builder) -// { -// builder.HasMany(e => e.RoleClaims) -// .WithOne() -// .HasForeignKey(uc => uc.RoleId) -// .IsRequired(); -// } -//} + public class ApplicationRoleClaimConfiguration : IEntityTypeConfiguration { diff --git a/src/Infrastructure/Persistence/Configurations/ProductConfiguration.cs b/src/Infrastructure/Persistence/Configurations/ProductConfiguration.cs new file mode 100644 index 000000000..52669334b --- /dev/null +++ b/src/Infrastructure/Persistence/Configurations/ProductConfiguration.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using CleanArchitecture.Blazor.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CleanArchitecture.Infrastructure.Persistence.Configurations; + +public class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Pictures) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions)null), + new ValueComparer>( + (c1, c2) => c1.SequenceEqual(c2), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => c.ToList())); + + + } +}