From 8801b88a9fda9acd57e11ca480382f62033a065b Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 30 Nov 2025 01:13:59 +1100 Subject: [PATCH 01/22] fix database inheritance --- Client/Modules/FileHub/Category.razor | 2 +- Server/Persistence/ApplicationContext.cs | 11 ++++++++++- Server/Persistence/Entities/Category.cs | 6 +++++- .../EntityBuilders/CategoryEntityBuilder.cs | 4 +++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index a3c06ed..971d787 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -1,6 +1,6 @@ 

Category

-Use Syncfusion Blazor diff --git a/Server/Persistence/ApplicationContext.cs b/Server/Persistence/ApplicationContext.cs index a9cb721..26855f2 100644 --- a/Server/Persistence/ApplicationContext.cs +++ b/Server/Persistence/ApplicationContext.cs @@ -14,6 +14,15 @@ protected override void OnModelCreating(ModelBuilder builder) base.OnModelCreating(builder); builder.Entity().ToTable(ActiveDatabase.RewriteName("Company_SampleModule")); - builder.Entity().ToTable(ActiveDatabase.RewriteName("FileHub_Category")); + + builder.Entity(entity => + { + entity.ToTable(ActiveDatabase.RewriteName("FileHub_Category")); + + entity.HasOne(c => c.ParentCategory) + .WithMany(c => c.Subcategories) + .HasForeignKey(c => c.ParentId) + .OnDelete(DeleteBehavior.Restrict); + }); } } diff --git a/Server/Persistence/Entities/Category.cs b/Server/Persistence/Entities/Category.cs index e13ab13..2d16070 100644 --- a/Server/Persistence/Entities/Category.cs +++ b/Server/Persistence/Entities/Category.cs @@ -15,5 +15,9 @@ public class Category : AuditableModuleBase public int ViewOrder { get; set; } - public int ParentId { get; set; } + public int? ParentId { get; set; } + + public Category? ParentCategory { get; set; } + + public ICollection? Subcategories { get; set; } } diff --git a/Server/Persistence/Migrations/EntityBuilders/CategoryEntityBuilder.cs b/Server/Persistence/Migrations/EntityBuilders/CategoryEntityBuilder.cs index b9bb32c..502bb36 100644 --- a/Server/Persistence/Migrations/EntityBuilders/CategoryEntityBuilder.cs +++ b/Server/Persistence/Migrations/EntityBuilders/CategoryEntityBuilder.cs @@ -7,12 +7,14 @@ public class CategoryEntityBuilder : AuditableBaseEntityBuilder _primaryKey = new("PK_ICTAce_FileHub_Category", x => x.Id); private readonly ForeignKey _moduleForeignKey = new("FK_ICTAce_FileHub_Category_Module", x => x.ModuleId, "Module", "ModuleId", ReferentialAction.Cascade); + private readonly ForeignKey _parentCategoryForeignKey = new("FK_ICTAce_FileHub_Category_ParentCategory", x => x.ParentId, _entityTableName, "Id", ReferentialAction.Restrict); public CategoryEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) { EntityTableName = _entityTableName; PrimaryKey = _primaryKey; ForeignKeys.Add(_moduleForeignKey); + ForeignKeys.Add(_parentCategoryForeignKey); } protected override CategoryEntityBuilder BuildTable(ColumnsBuilder table) @@ -21,7 +23,7 @@ protected override CategoryEntityBuilder BuildTable(ColumnsBuilder table) ModuleId = AddIntegerColumn(table, "ModuleId"); Name = AddStringColumn(table, "Name", 100); ViewOrder = AddIntegerColumn(table, "ViewOrder"); - ParentId = AddIntegerColumn(table, "ParentId"); + ParentId = AddIntegerColumn(table, "ParentId", nullable: true); AddAuditableColumns(table); return this; } From 341162f01eb8753bfb376d476c39ec554f08a7dc Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 30 Nov 2025 01:18:06 +1100 Subject: [PATCH 02/22] add radzen --- Client/ICTAce.FileHub.Client.csproj | 1 + Directory.Packages.props | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Client/ICTAce.FileHub.Client.csproj b/Client/ICTAce.FileHub.Client.csproj index 2a0ee35..8e26930 100644 --- a/Client/ICTAce.FileHub.Client.csproj +++ b/Client/ICTAce.FileHub.Client.csproj @@ -14,6 +14,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 8fd6e97..7f8848f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + @@ -26,10 +27,10 @@ - + - + \ No newline at end of file From 41991d837c93240770f1ea27481e73034ceda304 Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 30 Nov 2025 16:41:28 +1100 Subject: [PATCH 03/22] abstract class ModuleService --- Client/Services/CategoryService.cs | 45 ++--------------------- Client/Services/Common/ModuleService.cs | 48 +++++++++++++++++++++++++ Client/Services/SampleModuleService.cs | 39 ++------------------ 3 files changed, 54 insertions(+), 78 deletions(-) create mode 100644 Client/Services/Common/ModuleService.cs diff --git a/Client/Services/CategoryService.cs b/Client/Services/CategoryService.cs index 67addce..fa4f149 100644 --- a/Client/Services/CategoryService.cs +++ b/Client/Services/CategoryService.cs @@ -37,56 +37,17 @@ public record CreateAndUpdateCategoryDto public int ParentId { get; set; } } -/// -/// Service interface for Category operations -/// public interface ICategoryService { Task GetAsync(int id, int moduleId); - Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10); - Task CreateAsync(int moduleId, CreateAndUpdateCategoryDto dto); - Task UpdateAsync(int id, int moduleId, CreateAndUpdateCategoryDto dto); - Task DeleteAsync(int id, int moduleId); } -/// -/// Service implementation for Category operations -/// -public class CategoryService(HttpClient http, SiteState siteState) : ServiceBase(http, siteState), ICategoryService +public class CategoryService(HttpClient http, SiteState siteState) + : ModuleService(http, siteState, "ictace/fileHub/categories"), + ICategoryService { - private string Apiurl => CreateApiUrl("ictace/fileHub/categories"); - - public Task GetAsync(int id, int moduleId) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return GetJsonAsync(url); - } - - public Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}&pageNumber={pageNumber}&pageSize={pageSize}", EntityNames.Module, moduleId); - return GetJsonAsync>(url, new PagedResult()); - } - - public Task CreateAsync(int moduleId, CreateAndUpdateCategoryDto dto) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PostJsonAsync(url, dto); - } - - public Task UpdateAsync(int id, int moduleId, CreateAndUpdateCategoryDto dto) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PutJsonAsync(url, dto); - } - - public Task DeleteAsync(int id, int moduleId) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return DeleteAsync(url); - } } diff --git a/Client/Services/Common/ModuleService.cs b/Client/Services/Common/ModuleService.cs new file mode 100644 index 0000000..6ca3271 --- /dev/null +++ b/Client/Services/Common/ModuleService.cs @@ -0,0 +1,48 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Services.Common; + +/// +/// Generic service implementation for module-scoped CRUD operations +/// +/// DTO type for get operations +/// DTO type for list operations +/// DTO type for create and update operations +public abstract class ModuleService( + HttpClient http, + SiteState siteState, + string apiPath) + : ServiceBase(http, siteState) +{ + private string Apiurl => CreateApiUrl(apiPath); + + public virtual Task GetAsync(int id, int moduleId) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return GetJsonAsync(url); + } + + public virtual Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}&pageNumber={pageNumber}&pageSize={pageSize}", EntityNames.Module, moduleId); + return GetJsonAsync>(url, new PagedResult()); + } + + public virtual Task CreateAsync(int moduleId, TCreateUpdateDto dto) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}", EntityNames.Module, moduleId); + return PostJsonAsync(url, dto); + } + + public virtual Task UpdateAsync(int id, int moduleId, TCreateUpdateDto dto) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return PutJsonAsync(url, dto); + } + + public virtual Task DeleteAsync(int id, int moduleId) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return DeleteAsync(url); + } +} diff --git a/Client/Services/SampleModuleService.cs b/Client/Services/SampleModuleService.cs index a8b5f14..f41aba1 100644 --- a/Client/Services/SampleModuleService.cs +++ b/Client/Services/SampleModuleService.cs @@ -30,47 +30,14 @@ public record CreateAndUpdateSampleModuleDto public interface ISampleModuleService { Task GetAsync(int id, int moduleId); - Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10); - Task CreateAsync(int moduleId, CreateAndUpdateSampleModuleDto dto); - Task UpdateAsync(int id, int moduleId, CreateAndUpdateSampleModuleDto dto); - Task DeleteAsync(int id, int moduleId); } -public class SampleModuleService(HttpClient http, SiteState siteState) : ServiceBase(http, siteState), ISampleModuleService +public class SampleModuleService(HttpClient http, SiteState siteState) + : ModuleService(http, siteState, "company/sampleModules"), + ISampleModuleService { - private string Apiurl => CreateApiUrl("company/sampleModules"); - - public Task GetAsync(int id, int moduleId) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return GetJsonAsync(url); - } - - public Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}&pageNumber={pageNumber}&pageSize={pageSize}", EntityNames.Module, moduleId); - return GetJsonAsync>(url, new PagedResult()); - } - - public Task CreateAsync(int moduleId, CreateAndUpdateSampleModuleDto dto) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PostJsonAsync(url, dto); - } - - public Task UpdateAsync(int id, int moduleId, CreateAndUpdateSampleModuleDto dto) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PutJsonAsync(url, dto); - } - - public Task DeleteAsync(int id, int moduleId) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return DeleteAsync(url); - } } From 5849071024e9065f315a442e6348fd54c7cd1893 Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Sun, 30 Nov 2025 16:47:37 +1100 Subject: [PATCH 04/22] Enhance category management with tree view and context menu #5 --- Client/Modules/FileHub/Category.razor | 87 +++++++++++++++++++++++- Client/Modules/FileHub/Category.razor.cs | 17 +++++ Client/_Imports.razor | 2 + 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index a3c06ed..de9b02c 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -1,9 +1,90 @@ -

Category

+@namespace ICTAce.FileHub +@inherits ModuleBase -Use Syncfusion Blazor +@if (IsLoading) +{ +

Loading...

+} +else if (!string.IsNullOrEmpty(ErrorMessage)) +{ +
@ErrorMessage
+} +else +{ +
+ + + + + + + + + + + +
+} @code { + private RadzenContextMenu? contextMenu; + private object? SelectedCategory { get; set; } + private ListCategoryDto? currentCategory; + + private IEnumerable RootCategories => TreeData.Where(c => c.ParentId == 0); + + private void LoadChildren(TreeExpandEventArgs args) + { + var category = args.Value as ListCategoryDto; + if (category != null) + { + var children = TreeData.Where(c => c.ParentId == category.Id).ToList(); + args.Children.Data = children; + args.Children.TextProperty = "Name"; + args.Children.HasChildren = (item) => + { + var cat = item as ListCategoryDto; + return cat != null && TreeData.Any(c => c.ParentId == cat.Id); + }; + } + } + + private void OnContextMenu(MouseEventArgs args, ListCategoryDto? category) + { + if (category == null) return; + + currentCategory = category; + contextMenu?.Open(args, new ContextMenuOptions()); + } + private void OnContextMenuClick(MenuItemEventArgs args) + { + if (currentCategory == null) return; + + switch (args.Value) + { + case "add": + OnAddCategory(currentCategory); + break; + case "edit": + OnEditCategory(currentCategory); + break; + case "delete": + OnDeleteCategory(currentCategory); + break; + } + + contextMenu?.Close(); + } } diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index ab0f360..4395626 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -8,6 +8,7 @@ public partial class Category : ModuleBase private ICategoryService CategoryService { get; set; } = default!; protected PagedResult Categories { get; set; } = new(); + protected List TreeData { get; set; } = new(); protected string? ErrorMessage { get; set; } protected bool IsLoading { get; set; } @@ -18,6 +19,7 @@ protected override async Task OnInitializedAsync() try { Categories = await CategoryService.ListAsync(ModuleState.ModuleId); + TreeData = Categories.Items?.ToList() ?? new List(); } catch (Exception ex) { @@ -28,4 +30,19 @@ protected override async Task OnInitializedAsync() IsLoading = false; } } + + protected void OnAddCategory(ListCategoryDto category) + { + // Handle add subcategory + } + + protected void OnEditCategory(ListCategoryDto category) + { + // Handle edit category + } + + protected void OnDeleteCategory(ListCategoryDto category) + { + // Handle delete category + } } diff --git a/Client/_Imports.razor b/Client/_Imports.razor index 2908186..49aa3c3 100644 --- a/Client/_Imports.razor +++ b/Client/_Imports.razor @@ -22,3 +22,5 @@ @using System.Linq @using System.Net.Http @using System.Net.Http.Json +@using Radzen +@using Radzen.Blazor From a079b3c65f613b0a35f4798fef43c3c754d01d9c Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Sun, 30 Nov 2025 18:40:38 +1100 Subject: [PATCH 05/22] add radzen tree for categories #5 --- Client/Modules/FileHub/Category.razor | 73 ++---------------------- Client/Modules/FileHub/Category.razor.cs | 49 ++++++++++++---- Client/Services/CategoryService.cs | 1 + Server.Tests/Helpers/TestDbContexts.cs | 4 +- Server/Persistence/ApplicationContext.cs | 2 +- 5 files changed, 47 insertions(+), 82 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index de9b02c..3022561 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -14,77 +14,12 @@ else if (!string.IsNullOrEmpty(ErrorMessage)) else {
- - - + + (e as ListCategoryDto).Children.Any())> - - - - - -
} -@code { - private RadzenContextMenu? contextMenu; - private object? SelectedCategory { get; set; } - private ListCategoryDto? currentCategory; - - private IEnumerable RootCategories => TreeData.Where(c => c.ParentId == 0); - - private void LoadChildren(TreeExpandEventArgs args) - { - var category = args.Value as ListCategoryDto; - if (category != null) - { - var children = TreeData.Where(c => c.ParentId == category.Id).ToList(); - args.Children.Data = children; - args.Children.TextProperty = "Name"; - args.Children.HasChildren = (item) => - { - var cat = item as ListCategoryDto; - return cat != null && TreeData.Any(c => c.ParentId == cat.Id); - }; - } - } - - private void OnContextMenu(MouseEventArgs args, ListCategoryDto? category) - { - if (category == null) return; - - currentCategory = category; - contextMenu?.Open(args, new ContextMenuOptions()); - } - - private void OnContextMenuClick(MenuItemEventArgs args) - { - if (currentCategory == null) return; - - switch (args.Value) - { - case "add": - OnAddCategory(currentCategory); - break; - case "edit": - OnEditCategory(currentCategory); - break; - case "delete": - OnDeleteCategory(currentCategory); - break; - } - - contextMenu?.Close(); - } -} diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index 4395626..e57e1c5 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -4,11 +4,12 @@ namespace ICTAce.FileHub; public partial class Category : ModuleBase { + private List TreeData = new(); + [Inject] private ICategoryService CategoryService { get; set; } = default!; protected PagedResult Categories { get; set; } = new(); - protected List TreeData { get; set; } = new(); protected string? ErrorMessage { get; set; } protected bool IsLoading { get; set; } @@ -19,7 +20,7 @@ protected override async Task OnInitializedAsync() try { Categories = await CategoryService.ListAsync(ModuleState.ModuleId); - TreeData = Categories.Items?.ToList() ?? new List(); + CreateTreeStructure(); } catch (Exception ex) { @@ -31,18 +32,46 @@ protected override async Task OnInitializedAsync() } } - protected void OnAddCategory(ListCategoryDto category) + private void CreateTreeStructure() { - // Handle add subcategory - } + if (Categories.Items is null || !Categories.Items.Any()) + { + TreeData = new(); + return; + } - protected void OnEditCategory(ListCategoryDto category) - { - // Handle edit category + var categoryDict = Categories.Items.ToDictionary(c => c.Id, c => c); + + TreeData = Categories.Items + .Where(c => c.ParentId == 0 || !categoryDict.ContainsKey(c.ParentId)) + .OrderBy(c => c.ViewOrder) + .ThenBy(c => c.Name) + .ToList(); + + foreach (var category in Categories.Items) + { + if (category.ParentId != 0 && categoryDict.TryGetValue(category.ParentId, out var parent)) + { + parent.Children.Add(category); + } + } + + SortChildren(TreeData); } - protected void OnDeleteCategory(ListCategoryDto category) + private void SortChildren(List categories) { - // Handle delete category + foreach (var category in categories) + { + if (category.Children.Any()) + { + category.Children = category.Children + .OrderBy(c => c.ViewOrder) + .ThenBy(c => c.Name) + .ToList(); + + SortChildren(category.Children); + } + } } } diff --git a/Client/Services/CategoryService.cs b/Client/Services/CategoryService.cs index fa4f149..96f2b7e 100644 --- a/Client/Services/CategoryService.cs +++ b/Client/Services/CategoryService.cs @@ -22,6 +22,7 @@ public record ListCategoryDto public required string Name { get; set; } public int ViewOrder { get; set; } public int ParentId { get; set; } + public List Children { get; set; } = new(); } public record CreateAndUpdateCategoryDto diff --git a/Server.Tests/Helpers/TestDbContexts.cs b/Server.Tests/Helpers/TestDbContexts.cs index 5d30a9e..fb1e1fe 100644 --- a/Server.Tests/Helpers/TestDbContexts.cs +++ b/Server.Tests/Helpers/TestDbContexts.cs @@ -105,7 +105,7 @@ protected override void OnModelCreating(ModelBuilder builder) // Configure our entities with simple table names (no tenant rewriting for tests) builder.Entity().ToTable("Company_SampleModule"); - builder.Entity().ToTable("FileHub_Category"); + builder.Entity().ToTable("ICTAce_FileHub_Category"); // Ignore Identity entities that we don't need for these tests builder.Ignore(); @@ -181,7 +181,7 @@ protected override void OnModelCreating(ModelBuilder builder) // Configure our entities with simple table names (no tenant rewriting for tests) builder.Entity().ToTable("Company_SampleModule"); - builder.Entity().ToTable("FileHub_Category"); + builder.Entity().ToTable("ICTAce_FileHub_Category"); // Ignore Identity entities that we don't need for these tests builder.Ignore(); diff --git a/Server/Persistence/ApplicationContext.cs b/Server/Persistence/ApplicationContext.cs index 26855f2..ff4af51 100644 --- a/Server/Persistence/ApplicationContext.cs +++ b/Server/Persistence/ApplicationContext.cs @@ -17,7 +17,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity(entity => { - entity.ToTable(ActiveDatabase.RewriteName("FileHub_Category")); + entity.ToTable(ActiveDatabase.RewriteName("ICTAce_FileHub_Category")); entity.HasOne(c => c.ParentCategory) .WithMany(c => c.Subcategories) From 23dc87789af1930d414a6380e2240d59e9dc08f7 Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Tue, 2 Dec 2025 21:25:35 +1100 Subject: [PATCH 06/22] Integrate Radzen components and improve category tree #5 --- Client/Modules/FileHub/Category.razor | 4 +++- Client/Modules/FileHub/Category.razor.cs | 16 +++++++++++++++- Client/Startup/ClientStartup.cs | 4 ++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index 3022561..d23bae0 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -3,6 +3,8 @@

Categories

+ + @if (IsLoading) {

Loading...

@@ -14,7 +16,7 @@ else if (!string.IsNullOrEmpty(ErrorMessage)) else {
- + (e as ListCategoryDto).Children.Any())> diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index e57e1c5..d16c6fe 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -13,13 +13,19 @@ public partial class Category : ModuleBase protected string? ErrorMessage { get; set; } protected bool IsLoading { get; set; } + public override List Resources => new() + { + new Script($"_content/Radzen.Blazor/Radzen.Blazor.js?v={typeof(Radzen.Colors).Assembly.GetName().Version}") + }; + protected override async Task OnInitializedAsync() { IsLoading = true; ErrorMessage = null; try { - Categories = await CategoryService.ListAsync(ModuleState.ModuleId); + // Request all categories (use a large page size to get all items) + Categories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); CreateTreeStructure(); } catch (Exception ex) @@ -40,14 +46,22 @@ private void CreateTreeStructure() return; } + // Clear all children lists before rebuilding the tree + foreach (var category in Categories.Items) + { + category.Children.Clear(); + } + var categoryDict = Categories.Items.ToDictionary(c => c.Id, c => c); + // Get root items (ParentId = 0 or parent doesn't exist in the dataset) TreeData = Categories.Items .Where(c => c.ParentId == 0 || !categoryDict.ContainsKey(c.ParentId)) .OrderBy(c => c.ViewOrder) .ThenBy(c => c.Name) .ToList(); + // Build parent-child relationships foreach (var category in Categories.Items) { if (category.ParentId != 0 && categoryDict.TryGetValue(category.ParentId, out var parent)) diff --git a/Client/Startup/ClientStartup.cs b/Client/Startup/ClientStartup.cs index 1ad5337..5611d54 100644 --- a/Client/Startup/ClientStartup.cs +++ b/Client/Startup/ClientStartup.cs @@ -1,5 +1,7 @@ // Licensed to ICTAce under the MIT license. +using Radzen; + namespace ICTAce.FileHub.Client.Startup; public class ClientStartup : IClientStartup @@ -15,5 +17,7 @@ public void ConfigureServices(IServiceCollection services) { services.AddScoped(); } + + services.AddRadzenComponents(); } } From 2b18b76b0ddbf1e0cb1217af032eb0518daadbb5 Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Fri, 5 Dec 2025 03:53:55 +1100 Subject: [PATCH 07/22] Enhance category management with Radzen components --- Client/GlobalUsings.cs | 1 + Client/Modules/FileHub/Category.razor | 57 ++- Client/Modules/FileHub/Category.razor.cs | 448 ++++++++++++++++++++++- Client/Modules/FileHub/Index.razor.cs | 3 +- Server/Features/Categories/Create.cs | 3 + Server/Features/Categories/Get.cs | 3 + Server/Features/Categories/List.cs | 3 + Server/Features/Categories/Update.cs | 5 +- 8 files changed, 509 insertions(+), 14 deletions(-) diff --git a/Client/GlobalUsings.cs b/Client/GlobalUsings.cs index d5ef826..fba3ce1 100644 --- a/Client/GlobalUsings.cs +++ b/Client/GlobalUsings.cs @@ -4,6 +4,7 @@ global using ICTAce.FileHub.Services; global using ICTAce.FileHub.Services.Common; global using Microsoft.AspNetCore.Components; +global using Microsoft.AspNetCore.Components.Web; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Localization; global using Microsoft.JSInterop; diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index d23bae0..8260b15 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -4,6 +4,7 @@

Categories

+ @if (IsLoading) { @@ -15,13 +16,63 @@ else if (!string.IsNullOrEmpty(ErrorMessage)) } else { -
- +
+ (e as ListCategoryDto).Children.Any())> + HasChildren=@(e => ((ListCategoryDto)e).Children?.Any() == true)> +
+ + @if (ShowEditDialog) + { + +

@DialogTitle

+ + + + + + @if (!IsAddingNew) + { + + + + } + + + + + + +
+ } + + @if (ShowDeleteConfirmation) + { + + + + Are you sure you want to delete the category "@SelectedCategory?.Name"? + + + + + + + + + } } diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index d16c6fe..b430fcc 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -9,14 +9,21 @@ public partial class Category : ModuleBase [Inject] private ICategoryService CategoryService { get; set; } = default!; + [Inject] + private Radzen.NotificationService NotificationService { get; set; } = default!; + + [Inject] + private Radzen.ContextMenuService ContextMenuService { get; set; } = default!; + protected PagedResult Categories { get; set; } = new(); protected string? ErrorMessage { get; set; } protected bool IsLoading { get; set; } - - public override List Resources => new() - { - new Script($"_content/Radzen.Blazor/Radzen.Blazor.js?v={typeof(Radzen.Colors).Assembly.GetName().Version}") - }; + protected ListCategoryDto? SelectedCategory { get; set; } + protected CreateAndUpdateCategoryDto EditModel { get; set; } = new(); + protected bool ShowDeleteConfirmation { get; set; } + protected bool ShowEditDialog { get; set; } + protected bool IsAddingNew { get; set; } + protected string DialogTitle { get; set; } = string.Empty; protected override async Task OnInitializedAsync() { @@ -24,7 +31,6 @@ protected override async Task OnInitializedAsync() ErrorMessage = null; try { - // Request all categories (use a large page size to get all items) Categories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); CreateTreeStructure(); } @@ -38,6 +44,433 @@ protected override async Task OnInitializedAsync() } } + private void ShowContextMenu(MouseEventArgs args, ListCategoryDto? category) + { + if (category == null) return; + + SelectedCategory = category; + + var menuItems = new List + { + new Radzen.ContextMenuItem + { + Text = "Add Child Category", + Value = "add", + Icon = "add" + }, + new Radzen.ContextMenuItem + { + Text = "Edit Name", + Value = "edit", + Icon = "edit" + }, + new Radzen.ContextMenuItem + { + Text = "Move Up", + Value = "moveup", + Icon = "arrow_upward", + Disabled = !CanMoveUp(category) + }, + new Radzen.ContextMenuItem + { + Text = "Move Down", + Value = "movedown", + Icon = "arrow_downward", + Disabled = !CanMoveDown(category) + } + }; + + // Only add delete option if category has no children + if (!category.Children.Any()) + { + menuItems.Add(new Radzen.ContextMenuItem + { + Text = "Delete", + Value = "delete", + Icon = "delete", + Disabled = false + }); + } + + ContextMenuService.Open(args, menuItems, OnContextMenuClick); + } + + private void OnContextMenuClick(Radzen.MenuItemEventArgs args) + { + var action = args.Value?.ToString(); + + switch (action) + { + case "add": + AddChildCategory(); + break; + case "edit": + EditCategory(); + break; + case "moveup": + _ = MoveUp(); + break; + case "movedown": + _ = MoveDown(); + break; + case "delete": + PromptDeleteCategory(); + break; + } + + ContextMenuService.Close(); + } + + private bool CanMoveUp(ListCategoryDto category) + { + var siblings = GetSiblings(category); + var index = siblings.IndexOf(category); + return index > 0; + } + + private bool CanMoveDown(ListCategoryDto category) + { + var siblings = GetSiblings(category); + var index = siblings.IndexOf(category); + return index >= 0 && index < siblings.Count - 1; + } + + private List GetSiblings(ListCategoryDto category) + { + if (category.ParentId == 0) + { + return TreeData; + } + + var parent = FindCategoryById(TreeData, category.ParentId); + return parent?.Children ?? new List(); + } + + private ListCategoryDto? FindCategoryById(List categories, int id) + { + foreach (var cat in categories) + { + if (cat.Id == id) return cat; + var found = FindCategoryById(cat.Children, id); + if (found != null) return found; + } + return null; + } + + private void AddChildCategory() + { + IsAddingNew = true; + ShowEditDialog = true; + ShowDeleteConfirmation = false; + DialogTitle = $"Add Child Category to '{SelectedCategory?.Name}'"; + + EditModel = new CreateAndUpdateCategoryDto + { + Name = string.Empty, + ViewOrder = 0, + ParentId = SelectedCategory?.Id ?? 0 + }; + + StateHasChanged(); + } + + private void EditCategory() + { + if (SelectedCategory == null) return; + + IsAddingNew = false; + ShowEditDialog = true; + ShowDeleteConfirmation = false; + DialogTitle = $"Edit Category '{SelectedCategory.Name}'"; + + EditModel = new CreateAndUpdateCategoryDto + { + Name = SelectedCategory.Name, + ViewOrder = SelectedCategory.ViewOrder, + ParentId = SelectedCategory.ParentId + }; + + StateHasChanged(); + } + + private async Task MoveUp() + { + if (SelectedCategory == null) return; + + try + { + // Get fresh data to find current siblings + var freshCategories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); + + // Find siblings with same parent + var siblings = freshCategories.Items? + .Where(c => c.ParentId == SelectedCategory.ParentId) + .OrderBy(c => c.ViewOrder) + .ThenBy(c => c.Name) + .ToList() ?? new List(); + + var currentIndex = siblings.FindIndex(c => c.Id == SelectedCategory.Id); + + if (currentIndex <= 0) return; + + var current = siblings[currentIndex]; + var previous = siblings[currentIndex - 1]; + + // Swap ViewOrder values + var tempViewOrder = current.ViewOrder; + + // Update current category + await CategoryService.UpdateAsync(current.Id, ModuleState.ModuleId, + new CreateAndUpdateCategoryDto + { + Name = current.Name, + ViewOrder = previous.ViewOrder, + ParentId = current.ParentId + }); + + // Update previous sibling + await CategoryService.UpdateAsync(previous.Id, ModuleState.ModuleId, + new CreateAndUpdateCategoryDto + { + Name = previous.Name, + ViewOrder = tempViewOrder, + ParentId = previous.ParentId + }); + + await logger.LogInformation("Category Moved Up {Id}", SelectedCategory.Id); + + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Success, + Summary = "Success", + Detail = "Category moved up", + Duration = 3000 + }); + + // Clear selection and refresh + SelectedCategory = null; + await RefreshCategories(); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Moving Category Up {Id} {Error}", SelectedCategory.Id, ex.Message); + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Error, + Summary = "Error", + Detail = "Failed to move category", + Duration = 4000 + }); + } + } + + private async Task MoveDown() + { + if (SelectedCategory == null) return; + + try + { + // Get fresh data to find current siblings + var freshCategories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); + + // Find siblings with same parent + var siblings = freshCategories.Items? + .Where(c => c.ParentId == SelectedCategory.ParentId) + .OrderBy(c => c.ViewOrder) + .ThenBy(c => c.Name) + .ToList() ?? new List(); + + var currentIndex = siblings.FindIndex(c => c.Id == SelectedCategory.Id); + + if (currentIndex < 0 || currentIndex >= siblings.Count - 1) return; + + var current = siblings[currentIndex]; + var next = siblings[currentIndex + 1]; + + // Swap ViewOrder values + var tempViewOrder = current.ViewOrder; + + // Update current category + await CategoryService.UpdateAsync(current.Id, ModuleState.ModuleId, + new CreateAndUpdateCategoryDto + { + Name = current.Name, + ViewOrder = next.ViewOrder, + ParentId = current.ParentId + }); + + // Update next sibling + await CategoryService.UpdateAsync(next.Id, ModuleState.ModuleId, + new CreateAndUpdateCategoryDto + { + Name = next.Name, + ViewOrder = tempViewOrder, + ParentId = next.ParentId + }); + + await logger.LogInformation("Category Moved Down {Id}", SelectedCategory.Id); + + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Success, + Summary = "Success", + Detail = "Category moved down", + Duration = 3000 + }); + + // Clear selection and refresh + SelectedCategory = null; + await RefreshCategories(); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Moving Category Down {Id} {Error}", SelectedCategory.Id, ex.Message); + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Error, + Summary = "Error", + Detail = "Failed to move category", + Duration = 4000 + }); + } + } + + private async Task SaveCategory() + { + if (string.IsNullOrWhiteSpace(EditModel.Name)) + { + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Warning, + Summary = "Validation Error", + Detail = "Category name is required", + Duration = 4000 + }); + return; + } + + try + { + if (IsAddingNew) + { + // Create new category + var id = await CategoryService.CreateAsync(ModuleState.ModuleId, EditModel); + await logger.LogInformation("Category Created {Id}", id); + + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Success, + Summary = "Success", + Detail = "Category created successfully", + Duration = 4000 + }); + } + else if (SelectedCategory != null) + { + // Update existing category + await CategoryService.UpdateAsync(SelectedCategory.Id, ModuleState.ModuleId, EditModel); + await logger.LogInformation("Category Updated {Id}", SelectedCategory.Id); + + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Success, + Summary = "Success", + Detail = "Category updated successfully", + Duration = 4000 + }); + } + + ShowEditDialog = false; + IsAddingNew = false; + SelectedCategory = null; + await RefreshCategories(); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Saving Category {Error}", ex.Message); + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Error, + Summary = "Error", + Detail = "Failed to save category", + Duration = 4000 + }); + } + } + + private void CancelEdit() + { + ShowEditDialog = false; + IsAddingNew = false; + SelectedCategory = null; + } + + private void PromptDeleteCategory() + { + ShowDeleteConfirmation = true; + ShowEditDialog = false; + StateHasChanged(); + } + + private async Task ConfirmDeleteCategory() + { + if (SelectedCategory == null) return; + + try + { + await CategoryService.DeleteAsync(SelectedCategory.Id, ModuleState.ModuleId); + await logger.LogInformation("Category Deleted {Id}", SelectedCategory.Id); + + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Success, + Summary = "Success", + Detail = "Category deleted successfully", + Duration = 4000 + }); + + SelectedCategory = null; + ShowDeleteConfirmation = false; + await RefreshCategories(); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Deleting Category {Id} {Error}", SelectedCategory.Id, ex.Message); + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Error, + Summary = "Error", + Detail = "Failed to delete category", + Duration = 4000 + }); + } + } + + private void CancelDelete() + { + ShowDeleteConfirmation = false; + SelectedCategory = null; + } + + private async Task RefreshCategories() + { + IsLoading = true; + try + { + Categories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); + CreateTreeStructure(); + } + catch (Exception ex) + { + ErrorMessage = $"Failed to reload categories: {ex.Message}"; + } + finally + { + IsLoading = false; + StateHasChanged(); + } + } + private void CreateTreeStructure() { if (Categories.Items is null || !Categories.Items.Any()) @@ -46,7 +479,6 @@ private void CreateTreeStructure() return; } - // Clear all children lists before rebuilding the tree foreach (var category in Categories.Items) { category.Children.Clear(); @@ -54,14 +486,12 @@ private void CreateTreeStructure() var categoryDict = Categories.Items.ToDictionary(c => c.Id, c => c); - // Get root items (ParentId = 0 or parent doesn't exist in the dataset) TreeData = Categories.Items .Where(c => c.ParentId == 0 || !categoryDict.ContainsKey(c.ParentId)) .OrderBy(c => c.ViewOrder) .ThenBy(c => c.Name) .ToList(); - // Build parent-child relationships foreach (var category in Categories.Items) { if (category.ParentId != 0 && categoryDict.TryGetValue(category.ParentId, out var parent)) diff --git a/Client/Modules/FileHub/Index.razor.cs b/Client/Modules/FileHub/Index.razor.cs index 6ba523e..72b16d0 100644 --- a/Client/Modules/FileHub/Index.razor.cs +++ b/Client/Modules/FileHub/Index.razor.cs @@ -11,7 +11,8 @@ public partial class Index public override List Resources => new List() { new Stylesheet(ModulePath() + "Module.css"), - new Script(ModulePath() + "Module.js") + new Script(ModulePath() + "Module.js"), + new Script("_content/Radzen.Blazor/Radzen.Blazor.js") }; private List? _filehubs; diff --git a/Server/Features/Categories/Create.cs b/Server/Features/Categories/Create.cs index 3a0c536..70cc0a2 100644 --- a/Server/Features/Categories/Create.cs +++ b/Server/Features/Categories/Create.cs @@ -27,5 +27,8 @@ public Task Handle(CreateCategoryRequest request, CancellationToken cancell [Mapper] internal sealed partial class CreateMapper { + [MapProperty(nameof(CreateCategoryRequest.ParentId), nameof(Persistence.Entities.Category.ParentId), Use = nameof(ConvertParentId))] internal partial Persistence.Entities.Category ToEntity(CreateCategoryRequest request); + + private int? ConvertParentId(int parentId) => parentId == 0 ? null : parentId; } diff --git a/Server/Features/Categories/Get.cs b/Server/Features/Categories/Get.cs index 796d245..b5d4e48 100644 --- a/Server/Features/Categories/Get.cs +++ b/Server/Features/Categories/Get.cs @@ -22,5 +22,8 @@ public class GetHandler(HandlerServices services) [Mapper] internal sealed partial class GetMapper { + [MapProperty(nameof(Persistence.Entities.Category.ParentId), nameof(GetCategoryDto.ParentId), Use = nameof(ConvertParentId))] public partial GetCategoryDto ToGetResponse(Persistence.Entities.Category category); + + private int ConvertParentId(int? parentId) => parentId ?? 0; } diff --git a/Server/Features/Categories/List.cs b/Server/Features/Categories/List.cs index a63baf9..ba728cf 100644 --- a/Server/Features/Categories/List.cs +++ b/Server/Features/Categories/List.cs @@ -23,5 +23,8 @@ public class ListHandler(HandlerServices services) [Mapper] internal sealed partial class ListMapper { + [MapProperty(nameof(Persistence.Entities.Category.ParentId), nameof(ListCategoryDto.ParentId), Use = nameof(ConvertParentId))] public partial ListCategoryDto ToListResponse(Persistence.Entities.Category category); + + private int ConvertParentId(int? parentId) => parentId ?? 0; } diff --git a/Server/Features/Categories/Update.cs b/Server/Features/Categories/Update.cs index 7c772db..3796913 100644 --- a/Server/Features/Categories/Update.cs +++ b/Server/Features/Categories/Update.cs @@ -14,12 +14,15 @@ public class UpdateHandler(HandlerServices services) { public Task Handle(UpdateCategoryRequest request, CancellationToken cancellationToken) { + // Convert ParentId of 0 to null for root-level categories + int? parentId = request.ParentId == 0 ? null : request.ParentId; + return HandleUpdateAsync( request: request, setPropertyCalls: setter => setter .SetProperty(e => e.Name, request.Name) .SetProperty(e => e.ViewOrder, request.ViewOrder) - .SetProperty(e => e.ParentId, request.ParentId), + .SetProperty(e => e.ParentId, parentId), cancellationToken: cancellationToken ); } From 582f58c95d765eb24f944ecff32da207f610524a Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 7 Dec 2025 13:35:08 +1100 Subject: [PATCH 08/22] backend move up and move down --- Client/Services/CategoryService.cs | 31 +- .../Categories/MoveDownHandlerTests.cs | 281 ++++++++++++++++++ .../Features/Categories/MoveUpHandlerTests.cs | 281 ++++++++++++++++++ Server/Features/Categories/Controller.cs | 76 +++++ Server/Features/Categories/MoveDown.cs | 70 +++++ Server/Features/Categories/MoveUp.cs | 70 +++++ 6 files changed, 806 insertions(+), 3 deletions(-) create mode 100644 Server.Tests/Features/Categories/MoveDownHandlerTests.cs create mode 100644 Server.Tests/Features/Categories/MoveUpHandlerTests.cs create mode 100644 Server/Features/Categories/MoveDown.cs create mode 100644 Server/Features/Categories/MoveUp.cs diff --git a/Client/Services/CategoryService.cs b/Client/Services/CategoryService.cs index 96f2b7e..e69050c 100644 --- a/Client/Services/CategoryService.cs +++ b/Client/Services/CategoryService.cs @@ -1,5 +1,7 @@ // Licensed to ICTAce under the MIT license. +using System.Net.Http.Json; + namespace ICTAce.FileHub.Services; public record GetCategoryDto @@ -45,10 +47,33 @@ public interface ICategoryService Task CreateAsync(int moduleId, CreateAndUpdateCategoryDto dto); Task UpdateAsync(int id, int moduleId, CreateAndUpdateCategoryDto dto); Task DeleteAsync(int id, int moduleId); + Task MoveUpAsync(int id, int moduleId); + Task MoveDownAsync(int id, int moduleId); } -public class CategoryService(HttpClient http, SiteState siteState) - : ModuleService(http, siteState, "ictace/fileHub/categories"), - ICategoryService +public class CategoryService : ModuleService, ICategoryService { + private readonly HttpClient _httpClient; + + public CategoryService(HttpClient http, SiteState siteState) + : base(http, siteState, "ictace/fileHub/categories") + { + _httpClient = http; + } + + public async Task MoveUpAsync(int id, int moduleId) + { + var url = $"api/ictace/fileHub/categories/{id}/move-up?moduleId={moduleId}"; + var response = await _httpClient.PatchAsync(url, null).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync().ConfigureAwait(false); + } + + public async Task MoveDownAsync(int id, int moduleId) + { + var url = $"api/ictace/fileHub/categories/{id}/move-down?moduleId={moduleId}"; + var response = await _httpClient.PatchAsync(url, null).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync().ConfigureAwait(false); + } } diff --git a/Server.Tests/Features/Categories/MoveDownHandlerTests.cs b/Server.Tests/Features/Categories/MoveDownHandlerTests.cs new file mode 100644 index 0000000..f63f5d1 --- /dev/null +++ b/Server.Tests/Features/Categories/MoveDownHandlerTests.cs @@ -0,0 +1,281 @@ +// Licensed to ICTAce under the MIT license. + +using CategoryHandlers = ICTAce.FileHub.Features.Categories; +using static ICTAce.FileHub.Server.Tests.Helpers.CategoryTestHelpers; + +namespace ICTAce.FileHub.Server.Tests.Features.Categories; + +public class MoveDownHandlerTests : HandlerTestBase +{ + [Test] + public async Task Handle_WithValidRequest_SwapsViewOrderWithNextSibling() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Category 1", viewOrder: 1, parentId: 0), + CreateTestEntity(id: 2, name: "Category 2", viewOrder: 2, parentId: 0)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveDownHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveDownCategoryRequest + { + Id = 1, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(1); + + var category1 = await GetFromCommandDbAsync(options, 1).ConfigureAwait(false); + var category2 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + + await Assert.That(category1!.ViewOrder).IsEqualTo(2); + await Assert.That(category2!.ViewOrder).IsEqualTo(1); + + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithLastItem_ReturnsSuccessWithoutChanges() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Category 1", viewOrder: 1, parentId: 0), + CreateTestEntity(id: 2, name: "Category 2", viewOrder: 2, parentId: 0)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveDownHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveDownCategoryRequest + { + Id = 2, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(2); + + var category1 = await GetFromCommandDbAsync(options, 1).ConfigureAwait(false); + var category2 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + + await Assert.That(category1!.ViewOrder).IsEqualTo(1); + await Assert.That(category2!.ViewOrder).IsEqualTo(2); + + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithInvalidId_ReturnsMinusOne() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Category 1", viewOrder: 1)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveDownHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveDownCategoryRequest + { + Id = 999, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(-1); + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithUnauthorizedUser_ReturnsMinusOne() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Category 1", viewOrder: 1), + CreateTestEntity(id: 2, name: "Category 2", viewOrder: 2)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveDownHandler( + CreateCommandHandlerServices(options, isAuthorized: false)); + + var request = new CategoryHandlers.MoveDownCategoryRequest + { + Id = 1, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(-1); + + var category1 = await GetFromCommandDbAsync(options, 1).ConfigureAwait(false); + var category2 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + + await Assert.That(category1!.ViewOrder).IsEqualTo(1); + await Assert.That(category2!.ViewOrder).IsEqualTo(2); + + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithHierarchy_OnlySwapsWithinSameParent() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Parent 1", viewOrder: 1, parentId: 0), + CreateTestEntity(id: 2, name: "Child 1-1", viewOrder: 2, parentId: 1), + CreateTestEntity(id: 3, name: "Child 1-2", viewOrder: 3, parentId: 1), + CreateTestEntity(id: 4, name: "Parent 2", viewOrder: 4, parentId: 0)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveDownHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveDownCategoryRequest + { + Id = 2, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(2); + + var child1 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + var child2 = await GetFromCommandDbAsync(options, 3).ConfigureAwait(false); + + await Assert.That(child1!.ViewOrder).IsEqualTo(3); + await Assert.That(child2!.ViewOrder).IsEqualTo(2); + + var parent1 = await GetFromCommandDbAsync(options, 1).ConfigureAwait(false); + var parent2 = await GetFromCommandDbAsync(options, 4).ConfigureAwait(false); + + await Assert.That(parent1!.ViewOrder).IsEqualTo(1); + await Assert.That(parent2!.ViewOrder).IsEqualTo(4); + + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithMultipleCategories_SwapsOnlyWithImmediateNext() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Category 1", viewOrder: 1, parentId: 0), + CreateTestEntity(id: 2, name: "Category 2", viewOrder: 2, parentId: 0), + CreateTestEntity(id: 3, name: "Category 3", viewOrder: 3, parentId: 0), + CreateTestEntity(id: 4, name: "Category 4", viewOrder: 4, parentId: 0)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveDownHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveDownCategoryRequest + { + Id = 2, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(2); + + var category1 = await GetFromCommandDbAsync(options, 1).ConfigureAwait(false); + var category2 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + var category3 = await GetFromCommandDbAsync(options, 3).ConfigureAwait(false); + var category4 = await GetFromCommandDbAsync(options, 4).ConfigureAwait(false); + + await Assert.That(category1!.ViewOrder).IsEqualTo(1); + await Assert.That(category2!.ViewOrder).IsEqualTo(3); + await Assert.That(category3!.ViewOrder).IsEqualTo(2); + await Assert.That(category4!.ViewOrder).IsEqualTo(4); + + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithNonSequentialViewOrders_SwapsCorrectly() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Category 1", viewOrder: 10, parentId: 0), + CreateTestEntity(id: 2, name: "Category 2", viewOrder: 20, parentId: 0), + CreateTestEntity(id: 3, name: "Category 3", viewOrder: 30, parentId: 0)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveDownHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveDownCategoryRequest + { + Id = 1, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(1); + + var category1 = await GetFromCommandDbAsync(options, 1).ConfigureAwait(false); + var category2 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + + await Assert.That(category1!.ViewOrder).IsEqualTo(20); + await Assert.That(category2!.ViewOrder).IsEqualTo(10); + + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithChildAtBottomOfParent_ReturnsSuccessWithoutChanges() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Parent", viewOrder: 1, parentId: 0), + CreateTestEntity(id: 2, name: "Child 1", viewOrder: 2, parentId: 1), + CreateTestEntity(id: 3, name: "Child 2", viewOrder: 3, parentId: 1)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveDownHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveDownCategoryRequest + { + Id = 3, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(3); + + var child1 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + var child2 = await GetFromCommandDbAsync(options, 3).ConfigureAwait(false); + + await Assert.That(child1!.ViewOrder).IsEqualTo(2); + await Assert.That(child2!.ViewOrder).IsEqualTo(3); + + await connection.CloseAsync().ConfigureAwait(false); + } +} diff --git a/Server.Tests/Features/Categories/MoveUpHandlerTests.cs b/Server.Tests/Features/Categories/MoveUpHandlerTests.cs new file mode 100644 index 0000000..7f180dd --- /dev/null +++ b/Server.Tests/Features/Categories/MoveUpHandlerTests.cs @@ -0,0 +1,281 @@ +// Licensed to ICTAce under the MIT license. + +using CategoryHandlers = ICTAce.FileHub.Features.Categories; +using static ICTAce.FileHub.Server.Tests.Helpers.CategoryTestHelpers; + +namespace ICTAce.FileHub.Server.Tests.Features.Categories; + +public class MoveUpHandlerTests : HandlerTestBase +{ + [Test] + public async Task Handle_WithValidRequest_SwapsViewOrderWithPreviousSibling() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Category 1", viewOrder: 1, parentId: 0), + CreateTestEntity(id: 2, name: "Category 2", viewOrder: 2, parentId: 0)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveUpHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveUpCategoryRequest + { + Id = 2, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(2); + + var category1 = await GetFromCommandDbAsync(options, 1).ConfigureAwait(false); + var category2 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + + await Assert.That(category1!.ViewOrder).IsEqualTo(2); + await Assert.That(category2!.ViewOrder).IsEqualTo(1); + + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithFirstItem_ReturnsSuccessWithoutChanges() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Category 1", viewOrder: 1, parentId: 0), + CreateTestEntity(id: 2, name: "Category 2", viewOrder: 2, parentId: 0)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveUpHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveUpCategoryRequest + { + Id = 1, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(1); + + var category1 = await GetFromCommandDbAsync(options, 1).ConfigureAwait(false); + var category2 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + + await Assert.That(category1!.ViewOrder).IsEqualTo(1); + await Assert.That(category2!.ViewOrder).IsEqualTo(2); + + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithInvalidId_ReturnsMinusOne() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Category 1", viewOrder: 1)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveUpHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveUpCategoryRequest + { + Id = 999, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(-1); + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithUnauthorizedUser_ReturnsMinusOne() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Category 1", viewOrder: 1), + CreateTestEntity(id: 2, name: "Category 2", viewOrder: 2)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveUpHandler( + CreateCommandHandlerServices(options, isAuthorized: false)); + + var request = new CategoryHandlers.MoveUpCategoryRequest + { + Id = 2, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(-1); + + var category1 = await GetFromCommandDbAsync(options, 1).ConfigureAwait(false); + var category2 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + + await Assert.That(category1!.ViewOrder).IsEqualTo(1); + await Assert.That(category2!.ViewOrder).IsEqualTo(2); + + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithHierarchy_OnlySwapsWithinSameParent() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Parent 1", viewOrder: 1, parentId: 0), + CreateTestEntity(id: 2, name: "Child 1-1", viewOrder: 2, parentId: 1), + CreateTestEntity(id: 3, name: "Child 1-2", viewOrder: 3, parentId: 1), + CreateTestEntity(id: 4, name: "Parent 2", viewOrder: 4, parentId: 0)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveUpHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveUpCategoryRequest + { + Id = 3, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(3); + + var child1 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + var child2 = await GetFromCommandDbAsync(options, 3).ConfigureAwait(false); + + await Assert.That(child1!.ViewOrder).IsEqualTo(3); + await Assert.That(child2!.ViewOrder).IsEqualTo(2); + + var parent1 = await GetFromCommandDbAsync(options, 1).ConfigureAwait(false); + var parent2 = await GetFromCommandDbAsync(options, 4).ConfigureAwait(false); + + await Assert.That(parent1!.ViewOrder).IsEqualTo(1); + await Assert.That(parent2!.ViewOrder).IsEqualTo(4); + + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithMultipleCategories_SwapsOnlyWithImmediatePrevious() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Category 1", viewOrder: 1, parentId: 0), + CreateTestEntity(id: 2, name: "Category 2", viewOrder: 2, parentId: 0), + CreateTestEntity(id: 3, name: "Category 3", viewOrder: 3, parentId: 0), + CreateTestEntity(id: 4, name: "Category 4", viewOrder: 4, parentId: 0)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveUpHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveUpCategoryRequest + { + Id = 3, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(3); + + var category1 = await GetFromCommandDbAsync(options, 1).ConfigureAwait(false); + var category2 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + var category3 = await GetFromCommandDbAsync(options, 3).ConfigureAwait(false); + var category4 = await GetFromCommandDbAsync(options, 4).ConfigureAwait(false); + + await Assert.That(category1!.ViewOrder).IsEqualTo(1); + await Assert.That(category2!.ViewOrder).IsEqualTo(3); + await Assert.That(category3!.ViewOrder).IsEqualTo(2); + await Assert.That(category4!.ViewOrder).IsEqualTo(4); + + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithNonSequentialViewOrders_SwapsCorrectly() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Category 1", viewOrder: 10, parentId: 0), + CreateTestEntity(id: 2, name: "Category 2", viewOrder: 20, parentId: 0), + CreateTestEntity(id: 3, name: "Category 3", viewOrder: 30, parentId: 0)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveUpHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveUpCategoryRequest + { + Id = 3, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(3); + + var category2 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + var category3 = await GetFromCommandDbAsync(options, 3).ConfigureAwait(false); + + await Assert.That(category2!.ViewOrder).IsEqualTo(30); + await Assert.That(category3!.ViewOrder).IsEqualTo(20); + + await connection.CloseAsync().ConfigureAwait(false); + } + + [Test] + public async Task Handle_WithChildAtTopOfParent_ReturnsSuccessWithoutChanges() + { + // Arrange + var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); + await SeedCommandDataAsync(options, + CreateTestEntity(id: 1, name: "Parent", viewOrder: 1, parentId: 0), + CreateTestEntity(id: 2, name: "Child 1", viewOrder: 2, parentId: 1), + CreateTestEntity(id: 3, name: "Child 2", viewOrder: 3, parentId: 1)).ConfigureAwait(false); + + var handler = new CategoryHandlers.MoveUpHandler( + CreateCommandHandlerServices(options, isAuthorized: true)); + + var request = new CategoryHandlers.MoveUpCategoryRequest + { + Id = 2, + ModuleId = 1 + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + await Assert.That(result).IsEqualTo(2); + + var child1 = await GetFromCommandDbAsync(options, 2).ConfigureAwait(false); + var child2 = await GetFromCommandDbAsync(options, 3).ConfigureAwait(false); + + await Assert.That(child1!.ViewOrder).IsEqualTo(2); + await Assert.That(child2!.ViewOrder).IsEqualTo(3); + + await connection.CloseAsync().ConfigureAwait(false); + } +} diff --git a/Server/Features/Categories/Controller.cs b/Server/Features/Categories/Controller.cs index 59c9409..de6bac6 100644 --- a/Server/Features/Categories/Controller.cs +++ b/Server/Features/Categories/Controller.cs @@ -201,4 +201,80 @@ public async Task DeleteAsync( return NoContent(); } + + [HttpPatch("{id}/move-up")] + [Authorize(Policy = PolicyNames.EditModule)] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> MoveUpAsync( + int id, + [FromQuery] int moduleId, + CancellationToken cancellationToken = default) + { + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized Category MoveUp Attempt Id={Id} in ModuleId={ModuleId}", id, moduleId); + return Forbid(); + } + + if (id <= 0) + { + return BadRequest("Invalid Category ID"); + } + + var command = new MoveUpCategoryRequest + { + ModuleId = moduleId, + Id = id, + }; + + var result = await _mediator.Send(command, cancellationToken).ConfigureAwait(false); + + if (result == -1) + { + return NotFound(); + } + + return Ok(result); + } + + [HttpPatch("{id}/move-down")] + [Authorize(Policy = PolicyNames.EditModule)] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> MoveDownAsync( + int id, + [FromQuery] int moduleId, + CancellationToken cancellationToken = default) + { + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized Category MoveDown Attempt Id={Id} in ModuleId={ModuleId}", id, moduleId); + return Forbid(); + } + + if (id <= 0) + { + return BadRequest("Invalid Category ID"); + } + + var command = new MoveDownCategoryRequest + { + ModuleId = moduleId, + Id = id, + }; + + var result = await _mediator.Send(command, cancellationToken).ConfigureAwait(false); + + if (result == -1) + { + return NotFound(); + } + + return Ok(result); + } } diff --git a/Server/Features/Categories/MoveDown.cs b/Server/Features/Categories/MoveDown.cs new file mode 100644 index 0000000..b50ef9c --- /dev/null +++ b/Server/Features/Categories/MoveDown.cs @@ -0,0 +1,70 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Categories; + +public record MoveDownCategoryRequest : EntityRequestBase, IRequest; + +public class MoveDownHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler +{ + public async Task Handle(MoveDownCategoryRequest request, CancellationToken cancellationToken) + { + var alias = GetAlias(); + + if (!IsAuthorized(alias.SiteId, request.ModuleId, PermissionNames.Edit)) + { + Logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized Category MoveDown Attempt {Id} {ModuleId}", request.Id, request.ModuleId); + return -1; + } + + using var db = CreateDbContext(); + + var currentCategory = await db.Category + .Where(c => c.Id == request.Id && c.ModuleId == request.ModuleId) + .Select(c => new { c.Id, c.ViewOrder, c.ParentId }) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (currentCategory is null) + { + Logger.Log(LogLevel.Warning, this, LogFunction.Update, + "Category Not Found {Id}", request.Id); + return -1; + } + + var nextCategory = await db.Category + .Where(c => c.ModuleId == request.ModuleId + && c.ParentId == currentCategory.ParentId + && c.ViewOrder > currentCategory.ViewOrder) + .OrderBy(c => c.ViewOrder) + .Select(c => new { c.Id, c.ViewOrder }) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (nextCategory is null) + { + Logger.Log(LogLevel.Information, this, LogFunction.Update, + "Category Already at Bottom {Id}", request.Id); + return request.Id; + } + + var currentViewOrder = currentCategory.ViewOrder; + var nextViewOrder = nextCategory.ViewOrder; + + await db.Category + .Where(c => c.Id == currentCategory.Id) + .ExecuteUpdateAsync(setter => setter.SetProperty(c => c.ViewOrder, nextViewOrder), cancellationToken) + .ConfigureAwait(false); + + await db.Category + .Where(c => c.Id == nextCategory.Id) + .ExecuteUpdateAsync(setter => setter.SetProperty(c => c.ViewOrder, currentViewOrder), cancellationToken) + .ConfigureAwait(false); + + Logger.Log(LogLevel.Information, this, LogFunction.Update, + "Category Moved Down {Id}", request.Id); + + return request.Id; + } +} diff --git a/Server/Features/Categories/MoveUp.cs b/Server/Features/Categories/MoveUp.cs new file mode 100644 index 0000000..0bf8fe2 --- /dev/null +++ b/Server/Features/Categories/MoveUp.cs @@ -0,0 +1,70 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Categories; + +public record MoveUpCategoryRequest : EntityRequestBase, IRequest; + +public class MoveUpHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler +{ + public async Task Handle(MoveUpCategoryRequest request, CancellationToken cancellationToken) + { + var alias = GetAlias(); + + if (!IsAuthorized(alias.SiteId, request.ModuleId, PermissionNames.Edit)) + { + Logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized Category MoveUp Attempt {Id} {ModuleId}", request.Id, request.ModuleId); + return -1; + } + + using var db = CreateDbContext(); + + var currentCategory = await db.Category + .Where(c => c.Id == request.Id && c.ModuleId == request.ModuleId) + .Select(c => new { c.Id, c.ViewOrder, c.ParentId }) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (currentCategory is null) + { + Logger.Log(LogLevel.Warning, this, LogFunction.Update, + "Category Not Found {Id}", request.Id); + return -1; + } + + var previousCategory = await db.Category + .Where(c => c.ModuleId == request.ModuleId + && c.ParentId == currentCategory.ParentId + && c.ViewOrder < currentCategory.ViewOrder) + .OrderByDescending(c => c.ViewOrder) + .Select(c => new { c.Id, c.ViewOrder }) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (previousCategory is null) + { + Logger.Log(LogLevel.Information, this, LogFunction.Update, + "Category Already at Top {Id}", request.Id); + return request.Id; + } + + var currentViewOrder = currentCategory.ViewOrder; + var previousViewOrder = previousCategory.ViewOrder; + + await db.Category + .Where(c => c.Id == currentCategory.Id) + .ExecuteUpdateAsync(setter => setter.SetProperty(c => c.ViewOrder, previousViewOrder), cancellationToken) + .ConfigureAwait(false); + + await db.Category + .Where(c => c.Id == previousCategory.Id) + .ExecuteUpdateAsync(setter => setter.SetProperty(c => c.ViewOrder, currentViewOrder), cancellationToken) + .ConfigureAwait(false); + + Logger.Log(LogLevel.Information, this, LogFunction.Update, + "Category Moved Up {Id}", request.Id); + + return request.Id; + } +} From 05f910919e551c72f7f630f6c0e13dc8d8cd7ed4 Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 7 Dec 2025 14:09:42 +1100 Subject: [PATCH 09/22] add comma --- .github/instructions/csharp.instructions.md | 1 + Client/Modules/FileHub/Category.razor.cs | 44 +++++++++---------- .../Categories/MoveDownHandlerTests.cs | 16 +++---- .../Features/Categories/MoveUpHandlerTests.cs | 16 +++---- Server.Tests/Helpers/CategoryTestHelpers.cs | 8 ++-- 5 files changed, 43 insertions(+), 42 deletions(-) diff --git a/.github/instructions/csharp.instructions.md b/.github/instructions/csharp.instructions.md index 5ee3b35..f61b411 100644 --- a/.github/instructions/csharp.instructions.md +++ b/.github/instructions/csharp.instructions.md @@ -30,6 +30,7 @@ applyTo: '**/*.cs' - Use pattern matching and switch expressions wherever possible. - Use `nameof` instead of string literals when referring to member names. - Ensure that XML doc comments are created for any public APIs. When applicable, include `` and `` documentation in the comments. +- Add a trailing comma after the last value in multi-line arrays, collection initializers, object initializers, and enum declarations to prevent MA0007 warnings. ## Project Setup and Structure diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index b430fcc..cf9cc42 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -56,28 +56,28 @@ private void ShowContextMenu(MouseEventArgs args, ListCategoryDto? category) { Text = "Add Child Category", Value = "add", - Icon = "add" + Icon = "add", }, new Radzen.ContextMenuItem { Text = "Edit Name", Value = "edit", - Icon = "edit" + Icon = "edit", }, new Radzen.ContextMenuItem { Text = "Move Up", Value = "moveup", Icon = "arrow_upward", - Disabled = !CanMoveUp(category) + Disabled = !CanMoveUp(category), }, new Radzen.ContextMenuItem { Text = "Move Down", Value = "movedown", Icon = "arrow_downward", - Disabled = !CanMoveDown(category) - } + Disabled = !CanMoveDown(category), + }, }; // Only add delete option if category has no children @@ -88,7 +88,7 @@ private void ShowContextMenu(MouseEventArgs args, ListCategoryDto? category) Text = "Delete", Value = "delete", Icon = "delete", - Disabled = false + Disabled = false, }); } @@ -168,7 +168,7 @@ private void AddChildCategory() { Name = string.Empty, ViewOrder = 0, - ParentId = SelectedCategory?.Id ?? 0 + ParentId = SelectedCategory?.Id ?? 0, }; StateHasChanged(); @@ -187,7 +187,7 @@ private void EditCategory() { Name = SelectedCategory.Name, ViewOrder = SelectedCategory.ViewOrder, - ParentId = SelectedCategory.ParentId + ParentId = SelectedCategory.ParentId, }; StateHasChanged(); @@ -225,7 +225,7 @@ await CategoryService.UpdateAsync(current.Id, ModuleState.ModuleId, { Name = current.Name, ViewOrder = previous.ViewOrder, - ParentId = current.ParentId + ParentId = current.ParentId, }); // Update previous sibling @@ -234,7 +234,7 @@ await CategoryService.UpdateAsync(previous.Id, ModuleState.ModuleId, { Name = previous.Name, ViewOrder = tempViewOrder, - ParentId = previous.ParentId + ParentId = previous.ParentId, }); await logger.LogInformation("Category Moved Up {Id}", SelectedCategory.Id); @@ -244,7 +244,7 @@ await CategoryService.UpdateAsync(previous.Id, ModuleState.ModuleId, Severity = Radzen.NotificationSeverity.Success, Summary = "Success", Detail = "Category moved up", - Duration = 3000 + Duration = 3000, }); // Clear selection and refresh @@ -259,7 +259,7 @@ await CategoryService.UpdateAsync(previous.Id, ModuleState.ModuleId, Severity = Radzen.NotificationSeverity.Error, Summary = "Error", Detail = "Failed to move category", - Duration = 4000 + Duration = 4000, }); } } @@ -296,7 +296,7 @@ await CategoryService.UpdateAsync(current.Id, ModuleState.ModuleId, { Name = current.Name, ViewOrder = next.ViewOrder, - ParentId = current.ParentId + ParentId = current.ParentId, }); // Update next sibling @@ -305,7 +305,7 @@ await CategoryService.UpdateAsync(next.Id, ModuleState.ModuleId, { Name = next.Name, ViewOrder = tempViewOrder, - ParentId = next.ParentId + ParentId = next.ParentId, }); await logger.LogInformation("Category Moved Down {Id}", SelectedCategory.Id); @@ -315,7 +315,7 @@ await CategoryService.UpdateAsync(next.Id, ModuleState.ModuleId, Severity = Radzen.NotificationSeverity.Success, Summary = "Success", Detail = "Category moved down", - Duration = 3000 + Duration = 3000, }); // Clear selection and refresh @@ -330,7 +330,7 @@ await CategoryService.UpdateAsync(next.Id, ModuleState.ModuleId, Severity = Radzen.NotificationSeverity.Error, Summary = "Error", Detail = "Failed to move category", - Duration = 4000 + Duration = 4000, }); } } @@ -344,7 +344,7 @@ private async Task SaveCategory() Severity = Radzen.NotificationSeverity.Warning, Summary = "Validation Error", Detail = "Category name is required", - Duration = 4000 + Duration = 4000, }); return; } @@ -362,7 +362,7 @@ private async Task SaveCategory() Severity = Radzen.NotificationSeverity.Success, Summary = "Success", Detail = "Category created successfully", - Duration = 4000 + Duration = 4000, }); } else if (SelectedCategory != null) @@ -376,7 +376,7 @@ private async Task SaveCategory() Severity = Radzen.NotificationSeverity.Success, Summary = "Success", Detail = "Category updated successfully", - Duration = 4000 + Duration = 4000, }); } @@ -393,7 +393,7 @@ private async Task SaveCategory() Severity = Radzen.NotificationSeverity.Error, Summary = "Error", Detail = "Failed to save category", - Duration = 4000 + Duration = 4000, }); } } @@ -426,7 +426,7 @@ private async Task ConfirmDeleteCategory() Severity = Radzen.NotificationSeverity.Success, Summary = "Success", Detail = "Category deleted successfully", - Duration = 4000 + Duration = 4000, }); SelectedCategory = null; @@ -441,7 +441,7 @@ private async Task ConfirmDeleteCategory() Severity = Radzen.NotificationSeverity.Error, Summary = "Error", Detail = "Failed to delete category", - Duration = 4000 + Duration = 4000, }); } } diff --git a/Server.Tests/Features/Categories/MoveDownHandlerTests.cs b/Server.Tests/Features/Categories/MoveDownHandlerTests.cs index f63f5d1..5d3e16e 100644 --- a/Server.Tests/Features/Categories/MoveDownHandlerTests.cs +++ b/Server.Tests/Features/Categories/MoveDownHandlerTests.cs @@ -22,7 +22,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveDownCategoryRequest { Id = 1, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -55,7 +55,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveDownCategoryRequest { Id = 2, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -87,7 +87,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveDownCategoryRequest { Id = 999, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -113,7 +113,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveDownCategoryRequest { Id = 1, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -148,7 +148,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveDownCategoryRequest { Id = 2, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -189,7 +189,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveDownCategoryRequest { Id = 2, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -227,7 +227,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveDownCategoryRequest { Id = 1, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -261,7 +261,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveDownCategoryRequest { Id = 3, - ModuleId = 1 + ModuleId = 1, }; // Act diff --git a/Server.Tests/Features/Categories/MoveUpHandlerTests.cs b/Server.Tests/Features/Categories/MoveUpHandlerTests.cs index 7f180dd..92d0990 100644 --- a/Server.Tests/Features/Categories/MoveUpHandlerTests.cs +++ b/Server.Tests/Features/Categories/MoveUpHandlerTests.cs @@ -22,7 +22,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveUpCategoryRequest { Id = 2, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -55,7 +55,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveUpCategoryRequest { Id = 1, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -87,7 +87,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveUpCategoryRequest { Id = 999, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -113,7 +113,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveUpCategoryRequest { Id = 2, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -148,7 +148,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveUpCategoryRequest { Id = 3, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -189,7 +189,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveUpCategoryRequest { Id = 3, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -227,7 +227,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveUpCategoryRequest { Id = 3, - ModuleId = 1 + ModuleId = 1, }; // Act @@ -261,7 +261,7 @@ await SeedCommandDataAsync(options, var request = new CategoryHandlers.MoveUpCategoryRequest { Id = 2, - ModuleId = 1 + ModuleId = 1, }; // Act diff --git a/Server.Tests/Helpers/CategoryTestHelpers.cs b/Server.Tests/Helpers/CategoryTestHelpers.cs index 3d64f8b..39499d5 100644 --- a/Server.Tests/Helpers/CategoryTestHelpers.cs +++ b/Server.Tests/Helpers/CategoryTestHelpers.cs @@ -124,12 +124,12 @@ public static async Task GetCountFromQueryDbAsync( { using var context = new TestApplicationCommandContext(options); var query = context.Category.AsQueryable(); - + if (moduleId.HasValue) { query = query.Where(c => c.ModuleId == moduleId.Value); } - + return await query.OrderBy(c => c.ViewOrder).ThenBy(c => c.Name).ToListAsync().ConfigureAwait(false); } @@ -142,12 +142,12 @@ public static async Task GetCountFromQueryDbAsync( { using var context = new TestApplicationQueryContext(options); var query = context.Category.AsQueryable(); - + if (moduleId.HasValue) { query = query.Where(c => c.ModuleId == moduleId.Value); } - + return await query.OrderBy(c => c.ViewOrder).ThenBy(c => c.Name).ToListAsync().ConfigureAwait(false); } From d714c1211a7619c993204ef3a706d8c19fd6fee4 Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 7 Dec 2025 19:29:48 +1100 Subject: [PATCH 10/22] inline add and edit --- Client/Modules/FileHub/Category.razor | 40 +++- Client/Modules/FileHub/Category.razor.cs | 228 +++++++++++++++++------ 2 files changed, 208 insertions(+), 60 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index 8260b15..1778332 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -24,12 +24,36 @@ else
@@ -43,14 +67,14 @@ else - + @if (!IsAddingNew) { } - + @@ -66,7 +90,7 @@ else Are you sure you want to delete the category "@SelectedCategory?.Name"? - + diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index cf9cc42..d38e990 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -25,6 +25,12 @@ public partial class Category : ModuleBase protected bool IsAddingNew { get; set; } protected string DialogTitle { get; set; } = string.Empty; + // Inline editing properties + protected ListCategoryDto? EditingNode { get; set; } + protected string EditingNodeName { get; set; } = string.Empty; + protected bool IsInlineEditing { get; set; } + protected bool IsInlineAdding { get; set; } + protected override async Task OnInitializedAsync() { IsLoading = true; @@ -52,28 +58,24 @@ private void ShowContextMenu(MouseEventArgs args, ListCategoryDto? category) var menuItems = new List { - new Radzen.ContextMenuItem - { - Text = "Add Child Category", + new() { + Text = "Add Child Category", Value = "add", Icon = "add", }, - new Radzen.ContextMenuItem - { - Text = "Edit Name", + new() { + Text = "Edit Name", Value = "edit", Icon = "edit", }, - new Radzen.ContextMenuItem - { - Text = "Move Up", + new() { + Text = "Move Up", Value = "moveup", Icon = "arrow_upward", Disabled = !CanMoveUp(category), }, - new Radzen.ContextMenuItem - { - Text = "Move Down", + new() { + Text = "Move Down", Value = "movedown", Icon = "arrow_downward", Disabled = !CanMoveDown(category), @@ -83,9 +85,9 @@ private void ShowContextMenu(MouseEventArgs args, ListCategoryDto? category) // Only add delete option if category has no children if (!category.Children.Any()) { - menuItems.Add(new Radzen.ContextMenuItem - { - Text = "Delete", + menuItems.Add(new Radzen.ContextMenuItem + { + Text = "Delete", Value = "delete", Icon = "delete", Disabled = false, @@ -102,10 +104,10 @@ private void OnContextMenuClick(Radzen.MenuItemEventArgs args) switch (action) { case "add": - AddChildCategory(); + AddChildCategoryInline(); break; case "edit": - EditCategory(); + EditCategoryInline(); break; case "moveup": _ = MoveUp(); @@ -157,42 +159,164 @@ private List GetSiblings(ListCategoryDto category) return null; } - private void AddChildCategory() + private void AddChildCategoryInline() { - IsAddingNew = true; - ShowEditDialog = true; - ShowDeleteConfirmation = false; - DialogTitle = $"Add Child Category to '{SelectedCategory?.Name}'"; - - EditModel = new CreateAndUpdateCategoryDto + if (SelectedCategory == null) return; + + // Cancel any existing inline editing + CancelInlineEdit(); + + // Create a temporary new node + var newNode = new ListCategoryDto { + Id = -1, // Temporary ID Name = string.Empty, - ViewOrder = 0, - ParentId = SelectedCategory?.Id ?? 0, + ParentId = SelectedCategory.Id, + ViewOrder = SelectedCategory.Children.Count, + Children = [], }; - + + // Add to parent's children + SelectedCategory.Children.Add(newNode); + + // Set editing state + EditingNode = newNode; + EditingNodeName = string.Empty; + IsInlineAdding = true; + IsInlineEditing = true; + StateHasChanged(); } - private void EditCategory() + private void EditCategoryInline() { - if (SelectedCategory == null) return; + if (SelectedCategory == null) + { + return; + } - IsAddingNew = false; - ShowEditDialog = true; - ShowDeleteConfirmation = false; - DialogTitle = $"Edit Category '{SelectedCategory.Name}'"; - - EditModel = new CreateAndUpdateCategoryDto + // Cancel any existing inline editing + CancelInlineEdit(); + + // Set editing state + EditingNode = SelectedCategory; + EditingNodeName = SelectedCategory.Name; + IsInlineAdding = false; + IsInlineEditing = true; + + StateHasChanged(); + } + + private async Task SaveInlineEdit() + { + if (EditingNode == null || string.IsNullOrWhiteSpace(EditingNodeName)) { - Name = SelectedCategory.Name, - ViewOrder = SelectedCategory.ViewOrder, - ParentId = SelectedCategory.ParentId, - }; - + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Warning, + Summary = "Validation Error", + Detail = "Category name is required", + Duration = 4000, + }); + return; + } + + try + { + if (IsInlineAdding) + { + // Create new category + var createDto = new CreateAndUpdateCategoryDto + { + Name = EditingNodeName, + ViewOrder = EditingNode.ViewOrder, + ParentId = EditingNode.ParentId, + }; + + var id = await CategoryService.CreateAsync(ModuleState.ModuleId, createDto); + await logger.LogInformation("Category Created {Id}", id); + + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Success, + Summary = "Success", + Detail = "Category created successfully", + Duration = 3000, + }); + } + else + { + // Update existing category + var updateDto = new CreateAndUpdateCategoryDto + { + Name = EditingNodeName, + ViewOrder = EditingNode.ViewOrder, + ParentId = EditingNode.ParentId, + }; + + await CategoryService.UpdateAsync(EditingNode.Id, ModuleState.ModuleId, updateDto); + await logger.LogInformation("Category Updated {Id}", EditingNode.Id); + + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Success, + Summary = "Success", + Detail = "Category updated successfully", + Duration = 3000, + }); + } + + // Clear editing state + CancelInlineEdit(); + + // Refresh tree + await RefreshCategories(); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Saving Category {Error}", ex.Message); + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Error, + Summary = "Error", + Detail = "Failed to save category", + Duration = 4000, + }); + } + } + + private void CancelInlineEdit() + { + if (IsInlineAdding && EditingNode != null) + { + // Remove the temporary node from the tree + var parent = FindCategoryById(TreeData, EditingNode.ParentId); + if (parent != null) + { + parent.Children.Remove(EditingNode); + } + } + + EditingNode = null; + EditingNodeName = string.Empty; + IsInlineEditing = false; + IsInlineAdding = false; + StateHasChanged(); } + private async Task HandleKeyPress(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + await SaveInlineEdit(); + } + else if (e.Key == "Escape") + { + CancelInlineEdit(); + } + } + private async Task MoveUp() { if (SelectedCategory == null) return; @@ -201,7 +325,7 @@ private async Task MoveUp() { // Get fresh data to find current siblings var freshCategories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); - + // Find siblings with same parent var siblings = freshCategories.Items? .Where(c => c.ParentId == SelectedCategory.ParentId) @@ -210,7 +334,7 @@ private async Task MoveUp() .ToList() ?? new List(); var currentIndex = siblings.FindIndex(c => c.Id == SelectedCategory.Id); - + if (currentIndex <= 0) return; var current = siblings[currentIndex]; @@ -218,9 +342,9 @@ private async Task MoveUp() // Swap ViewOrder values var tempViewOrder = current.ViewOrder; - + // Update current category - await CategoryService.UpdateAsync(current.Id, ModuleState.ModuleId, + await CategoryService.UpdateAsync(current.Id, ModuleState.ModuleId, new CreateAndUpdateCategoryDto { Name = current.Name, @@ -238,7 +362,7 @@ await CategoryService.UpdateAsync(previous.Id, ModuleState.ModuleId, }); await logger.LogInformation("Category Moved Up {Id}", SelectedCategory.Id); - + NotificationService.Notify(new Radzen.NotificationMessage { Severity = Radzen.NotificationSeverity.Success, @@ -272,7 +396,7 @@ private async Task MoveDown() { // Get fresh data to find current siblings var freshCategories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); - + // Find siblings with same parent var siblings = freshCategories.Items? .Where(c => c.ParentId == SelectedCategory.ParentId) @@ -281,7 +405,7 @@ private async Task MoveDown() .ToList() ?? new List(); var currentIndex = siblings.FindIndex(c => c.Id == SelectedCategory.Id); - + if (currentIndex < 0 || currentIndex >= siblings.Count - 1) return; var current = siblings[currentIndex]; @@ -289,7 +413,7 @@ private async Task MoveDown() // Swap ViewOrder values var tempViewOrder = current.ViewOrder; - + // Update current category await CategoryService.UpdateAsync(current.Id, ModuleState.ModuleId, new CreateAndUpdateCategoryDto @@ -309,7 +433,7 @@ await CategoryService.UpdateAsync(next.Id, ModuleState.ModuleId, }); await logger.LogInformation("Category Moved Down {Id}", SelectedCategory.Id); - + NotificationService.Notify(new Radzen.NotificationMessage { Severity = Radzen.NotificationSeverity.Success, @@ -356,7 +480,7 @@ private async Task SaveCategory() // Create new category var id = await CategoryService.CreateAsync(ModuleState.ModuleId, EditModel); await logger.LogInformation("Category Created {Id}", id); - + NotificationService.Notify(new Radzen.NotificationMessage { Severity = Radzen.NotificationSeverity.Success, @@ -370,7 +494,7 @@ private async Task SaveCategory() // Update existing category await CategoryService.UpdateAsync(SelectedCategory.Id, ModuleState.ModuleId, EditModel); await logger.LogInformation("Category Updated {Id}", SelectedCategory.Id); - + NotificationService.Notify(new Radzen.NotificationMessage { Severity = Radzen.NotificationSeverity.Success, @@ -420,7 +544,7 @@ private async Task ConfirmDeleteCategory() { await CategoryService.DeleteAsync(SelectedCategory.Id, ModuleState.ModuleId); await logger.LogInformation("Category Deleted {Id}", SelectedCategory.Id); - + NotificationService.Notify(new Radzen.NotificationMessage { Severity = Radzen.NotificationSeverity.Success, From 4b4a212699b6ae0f40e932f50264ce643afb9f2b Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 7 Dec 2025 20:29:37 +1100 Subject: [PATCH 11/22] add root --- Client/Modules/FileHub/Category.razor | 9 +- Client/Modules/FileHub/Category.razor.cs | 112 ++++++++++++++++++++--- 2 files changed, 106 insertions(+), 15 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index 1778332..2e36b94 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -17,7 +17,7 @@ else if (!string.IsNullOrEmpty(ErrorMessage)) else {
- + ((ListCategoryDto)e).Children?.Any() == true)> @@ -25,6 +25,7 @@ else @{ var category = (context.Value as ListCategoryDto); var isEditing = IsInlineEditing && EditingNode != null && EditingNode.Id == category?.Id; + var isRootNode = category?.Id == 0; } @if (isEditing) @@ -50,7 +51,11 @@ else { + style="cursor: pointer; font-weight: @(isRootNode ? "bold" : "normal");"> + @if (isRootNode) + { + + } @category?.Name } diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index d38e990..fdd5437 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -5,6 +5,7 @@ namespace ICTAce.FileHub; public partial class Category : ModuleBase { private List TreeData = new(); + private ListCategoryDto RootNode = new() { Name = "All Categories" }; [Inject] private ICategoryService CategoryService { get; set; } = default!; @@ -24,7 +25,7 @@ public partial class Category : ModuleBase protected bool ShowEditDialog { get; set; } protected bool IsAddingNew { get; set; } protected string DialogTitle { get; set; } = string.Empty; - + // Inline editing properties protected ListCategoryDto? EditingNode { get; set; } protected string EditingNodeName { get; set; } = string.Empty; @@ -56,25 +57,46 @@ private void ShowContextMenu(MouseEventArgs args, ListCategoryDto? category) SelectedCategory = category; + // For root node, only show "Add Child Category" + if (category.Id == 0) + { + var rootMenuItems = new List + { + new Radzen.ContextMenuItem + { + Text = "Add Category", + Value = "add", + Icon = "add", + } + }; + + ContextMenuService.Open(args, rootMenuItems, OnContextMenuClick); + return; + } + var menuItems = new List { - new() { + new Radzen.ContextMenuItem + { Text = "Add Child Category", Value = "add", Icon = "add", }, - new() { + new Radzen.ContextMenuItem + { Text = "Edit Name", Value = "edit", Icon = "edit", }, - new() { + new Radzen.ContextMenuItem + { Text = "Move Up", Value = "moveup", Icon = "arrow_upward", Disabled = !CanMoveUp(category), }, - new() { + new Radzen.ContextMenuItem + { Text = "Move Down", Value = "movedown", Icon = "arrow_downward", @@ -139,12 +161,13 @@ private bool CanMoveDown(ListCategoryDto category) private List GetSiblings(ListCategoryDto category) { + // For root-level categories (ParentId == 0), siblings are in TreeData if (category.ParentId == 0) { return TreeData; } - var parent = FindCategoryById(TreeData, category.ParentId); + var parent = FindCategoryById(new List { RootNode }, category.ParentId); return parent?.Children ?? new List(); } @@ -171,9 +194,9 @@ private void AddChildCategoryInline() { Id = -1, // Temporary ID Name = string.Empty, - ParentId = SelectedCategory.Id, + ParentId = SelectedCategory.Id == 0 ? 0 : SelectedCategory.Id, // If root node, ParentId is 0 ViewOrder = SelectedCategory.Children.Count, - Children = [], + Children = new List() }; // Add to parent's children @@ -190,10 +213,7 @@ private void AddChildCategoryInline() private void EditCategoryInline() { - if (SelectedCategory == null) - { - return; - } + if (SelectedCategory == null || SelectedCategory.Id == 0) return; // Don't allow editing root node // Cancel any existing inline editing CancelInlineEdit(); @@ -290,7 +310,17 @@ private void CancelInlineEdit() if (IsInlineAdding && EditingNode != null) { // Remove the temporary node from the tree - var parent = FindCategoryById(TreeData, EditingNode.ParentId); + ListCategoryDto? parent; + + if (EditingNode.ParentId == 0) + { + parent = RootNode; + } + else + { + parent = FindCategoryById(new List { RootNode }, EditingNode.ParentId); + } + if (parent != null) { parent.Children.Remove(EditingNode); @@ -317,6 +347,42 @@ private async Task HandleKeyPress(KeyboardEventArgs e) } } + private void AddChildCategory() + { + IsAddingNew = true; + ShowEditDialog = true; + ShowDeleteConfirmation = false; + DialogTitle = $"Add Child Category to '{SelectedCategory?.Name}'"; + + EditModel = new CreateAndUpdateCategoryDto + { + Name = string.Empty, + ViewOrder = 0, + ParentId = SelectedCategory?.Id ?? 0, + }; + + StateHasChanged(); + } + + private void EditCategory() + { + if (SelectedCategory == null) return; + + IsAddingNew = false; + ShowEditDialog = true; + ShowDeleteConfirmation = false; + DialogTitle = $"Edit Category '{SelectedCategory.Name}'"; + + EditModel = new CreateAndUpdateCategoryDto + { + Name = SelectedCategory.Name, + ViewOrder = SelectedCategory.ViewOrder, + ParentId = SelectedCategory.ParentId, + }; + + StateHasChanged(); + } + private async Task MoveUp() { if (SelectedCategory == null) return; @@ -600,6 +666,16 @@ private void CreateTreeStructure() if (Categories.Items is null || !Categories.Items.Any()) { TreeData = new(); + + // Create root node with empty children + RootNode = new ListCategoryDto + { + Id = 0, + Name = "All Categories", + ParentId = -1, + ViewOrder = 0, + Children = new List() + }; return; } @@ -625,6 +701,16 @@ private void CreateTreeStructure() } SortChildren(TreeData); + + // Create root node with TreeData as children + RootNode = new ListCategoryDto + { + Id = 0, + Name = "All Categories", + ParentId = -1, + ViewOrder = 0, + Children = TreeData + }; } private void SortChildren(List categories) From 4eda84068acdbb222a38b56f38e03907eeb8f511 Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 7 Dec 2025 21:05:48 +1100 Subject: [PATCH 12/22] simplify the new --- .github/instructions/csharp.instructions.md | 1 + Client/Modules/FileHub/Category.razor | 2 +- Client/Modules/FileHub/Category.razor.cs | 63 +++++++++------------ Client/Modules/FileHub/Edit.razor.cs | 6 +- Client/Modules/FileHub/Index.razor.cs | 8 +-- Client/Modules/SampleModule/Edit.razor.cs | 6 +- Client/Modules/SampleModule/Index.razor.cs | 6 +- Client/Services/CategoryService.cs | 2 +- 8 files changed, 44 insertions(+), 50 deletions(-) diff --git a/.github/instructions/csharp.instructions.md b/.github/instructions/csharp.instructions.md index f61b411..110a1b1 100644 --- a/.github/instructions/csharp.instructions.md +++ b/.github/instructions/csharp.instructions.md @@ -31,6 +31,7 @@ applyTo: '**/*.cs' - Use `nameof` instead of string literals when referring to member names. - Ensure that XML doc comments are created for any public APIs. When applicable, include `` and `` documentation in the comments. - Add a trailing comma after the last value in multi-line arrays, collection initializers, object initializers, and enum declarations to prevent MA0007 warnings. +- Use simplified `new()` expressions when the type is evident from the declaration (e.g., `List items = new();` instead of `List items = new List();`) to prevent IDE0090 warnings. ## Project Setup and Structure diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index 2e36b94..a88bca7 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -17,7 +17,7 @@ else if (!string.IsNullOrEmpty(ErrorMessage)) else {
- + ((ListCategoryDto)e).Children?.Any() == true)> diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index fdd5437..9f8169f 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -4,9 +4,6 @@ namespace ICTAce.FileHub; public partial class Category : ModuleBase { - private List TreeData = new(); - private ListCategoryDto RootNode = new() { Name = "All Categories" }; - [Inject] private ICategoryService CategoryService { get; set; } = default!; @@ -16,6 +13,9 @@ public partial class Category : ModuleBase [Inject] private Radzen.ContextMenuService ContextMenuService { get; set; } = default!; + private List _treeData = []; + private ListCategoryDto _rootNode = new() { Name = "All Categories" }; + protected PagedResult Categories { get; set; } = new(); protected string? ErrorMessage { get; set; } protected bool IsLoading { get; set; } @@ -25,8 +25,7 @@ public partial class Category : ModuleBase protected bool ShowEditDialog { get; set; } protected bool IsAddingNew { get; set; } protected string DialogTitle { get; set; } = string.Empty; - - // Inline editing properties + protected ListCategoryDto? EditingNode { get; set; } protected string EditingNodeName { get; set; } = string.Empty; protected bool IsInlineEditing { get; set; } @@ -62,8 +61,7 @@ private void ShowContextMenu(MouseEventArgs args, ListCategoryDto? category) { var rootMenuItems = new List { - new Radzen.ContextMenuItem - { + new() { Text = "Add Category", Value = "add", Icon = "add", @@ -76,27 +74,23 @@ private void ShowContextMenu(MouseEventArgs args, ListCategoryDto? category) var menuItems = new List { - new Radzen.ContextMenuItem - { + new() { Text = "Add Child Category", Value = "add", Icon = "add", }, - new Radzen.ContextMenuItem - { + new() { Text = "Edit Name", Value = "edit", Icon = "edit", }, - new Radzen.ContextMenuItem - { + new() { Text = "Move Up", Value = "moveup", Icon = "arrow_upward", Disabled = !CanMoveUp(category), }, - new Radzen.ContextMenuItem - { + new() { Text = "Move Down", Value = "movedown", Icon = "arrow_downward", @@ -107,8 +101,7 @@ private void ShowContextMenu(MouseEventArgs args, ListCategoryDto? category) // Only add delete option if category has no children if (!category.Children.Any()) { - menuItems.Add(new Radzen.ContextMenuItem - { + menuItems.Add(new() { Text = "Delete", Value = "delete", Icon = "delete", @@ -164,11 +157,11 @@ private List GetSiblings(ListCategoryDto category) // For root-level categories (ParentId == 0), siblings are in TreeData if (category.ParentId == 0) { - return TreeData; + return _treeData; } - var parent = FindCategoryById(new List { RootNode }, category.ParentId); - return parent?.Children ?? new List(); + var parent = FindCategoryById([_rootNode], category.ParentId); + return parent?.Children ?? []; } private ListCategoryDto? FindCategoryById(List categories, int id) @@ -196,7 +189,7 @@ private void AddChildCategoryInline() Name = string.Empty, ParentId = SelectedCategory.Id == 0 ? 0 : SelectedCategory.Id, // If root node, ParentId is 0 ViewOrder = SelectedCategory.Children.Count, - Children = new List() + Children = [] }; // Add to parent's children @@ -311,16 +304,16 @@ private void CancelInlineEdit() { // Remove the temporary node from the tree ListCategoryDto? parent; - + if (EditingNode.ParentId == 0) { - parent = RootNode; + parent = _rootNode; } else { - parent = FindCategoryById(new List { RootNode }, EditingNode.ParentId); + parent = FindCategoryById([_rootNode], EditingNode.ParentId); } - + if (parent != null) { parent.Children.Remove(EditingNode); @@ -397,7 +390,7 @@ private async Task MoveUp() .Where(c => c.ParentId == SelectedCategory.ParentId) .OrderBy(c => c.ViewOrder) .ThenBy(c => c.Name) - .ToList() ?? new List(); + .ToList() ?? []; var currentIndex = siblings.FindIndex(c => c.Id == SelectedCategory.Id); @@ -468,7 +461,7 @@ private async Task MoveDown() .Where(c => c.ParentId == SelectedCategory.ParentId) .OrderBy(c => c.ViewOrder) .ThenBy(c => c.Name) - .ToList() ?? new List(); + .ToList() ?? []; var currentIndex = siblings.FindIndex(c => c.Id == SelectedCategory.Id); @@ -665,16 +658,16 @@ private void CreateTreeStructure() { if (Categories.Items is null || !Categories.Items.Any()) { - TreeData = new(); - + _treeData = []; + // Create root node with empty children - RootNode = new ListCategoryDto + _rootNode = new ListCategoryDto { Id = 0, Name = "All Categories", ParentId = -1, ViewOrder = 0, - Children = new List() + Children = [] }; return; } @@ -686,7 +679,7 @@ private void CreateTreeStructure() var categoryDict = Categories.Items.ToDictionary(c => c.Id, c => c); - TreeData = Categories.Items + _treeData = Categories.Items .Where(c => c.ParentId == 0 || !categoryDict.ContainsKey(c.ParentId)) .OrderBy(c => c.ViewOrder) .ThenBy(c => c.Name) @@ -700,16 +693,16 @@ private void CreateTreeStructure() } } - SortChildren(TreeData); + SortChildren(_treeData); // Create root node with TreeData as children - RootNode = new ListCategoryDto + _rootNode = new ListCategoryDto { Id = 0, Name = "All Categories", ParentId = -1, ViewOrder = 0, - Children = TreeData + Children = _treeData }; } diff --git a/Client/Modules/FileHub/Edit.razor.cs b/Client/Modules/FileHub/Edit.razor.cs index 868bab0..249d73e 100644 --- a/Client/Modules/FileHub/Edit.razor.cs +++ b/Client/Modules/FileHub/Edit.razor.cs @@ -14,10 +14,10 @@ public partial class Edit public override string Title => "Manage FileHub"; - public override List Resources => new List() - { + public override List Resources => + [ new Stylesheet(ModulePath() + "Module.css") - }; + ]; private ElementReference form; private bool _validated; diff --git a/Client/Modules/FileHub/Index.razor.cs b/Client/Modules/FileHub/Index.razor.cs index 72b16d0..4d15d44 100644 --- a/Client/Modules/FileHub/Index.razor.cs +++ b/Client/Modules/FileHub/Index.razor.cs @@ -8,12 +8,12 @@ public partial class Index [Inject] protected NavigationManager NavigationManager { get; set; } = default!; [Inject] protected IStringLocalizer Localizer { get; set; } = default!; - public override List Resources => new List() - { + public override List Resources => + [ new Stylesheet(ModulePath() + "Module.css"), new Script(ModulePath() + "Module.js"), new Script("_content/Radzen.Blazor/Radzen.Blazor.js") - }; + ]; private List? _filehubs; @@ -37,7 +37,7 @@ private async Task Delete(ListSampleModuleDto filehub) { await FileHubService.DeleteAsync(filehub.Id, ModuleState.ModuleId).ConfigureAwait(true); await logger.LogInformation("FileHub Deleted {Id}", filehub.Id).ConfigureAwait(true); - + var pagedResult = await FileHubService.ListAsync(ModuleState.ModuleId).ConfigureAwait(true); _filehubs = pagedResult?.Items?.ToList(); StateHasChanged(); diff --git a/Client/Modules/SampleModule/Edit.razor.cs b/Client/Modules/SampleModule/Edit.razor.cs index c246ce0..e1c9a3d 100644 --- a/Client/Modules/SampleModule/Edit.razor.cs +++ b/Client/Modules/SampleModule/Edit.razor.cs @@ -14,10 +14,10 @@ public partial class Edit public override string Title => "Manage SampleModule"; - public override List Resources => new List() - { + public override List Resources => + [ new Stylesheet(ModulePath() + "Module.css") - }; + ]; private ElementReference form; private bool _validated; diff --git a/Client/Modules/SampleModule/Index.razor.cs b/Client/Modules/SampleModule/Index.razor.cs index a3c4eec..fc1159a 100644 --- a/Client/Modules/SampleModule/Index.razor.cs +++ b/Client/Modules/SampleModule/Index.razor.cs @@ -8,11 +8,11 @@ public partial class Index [Inject] protected NavigationManager NavigationManager { get; set; } = default!; [Inject] protected IStringLocalizer Localizer { get; set; } = default!; - public override List Resources => new List() - { + public override List Resources => + [ new Stylesheet(ModulePath() + "Module.css"), new Script(ModulePath() + "Module.js") - }; + ]; private List? _samplesModules; diff --git a/Client/Services/CategoryService.cs b/Client/Services/CategoryService.cs index e69050c..e6a4bdd 100644 --- a/Client/Services/CategoryService.cs +++ b/Client/Services/CategoryService.cs @@ -24,7 +24,7 @@ public record ListCategoryDto public required string Name { get; set; } public int ViewOrder { get; set; } public int ParentId { get; set; } - public List Children { get; set; } = new(); + public List Children { get; set; } = []; } public record CreateAndUpdateCategoryDto From 2507c064b9ff9f1ff70724cd84455e052b79ca80 Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 7 Dec 2025 23:26:54 +1100 Subject: [PATCH 13/22] remove white spaces --- Client.Tests/BaseTest.cs | 6 +++--- Client.Tests/Mocks/MockLogService.cs | 6 +++--- Client/Modules/FileHub/Edit.razor.cs | 2 +- Client/Modules/FileHub/Index.razor.cs | 2 +- EndToEnd.Tests/ApplicationHealthTests.cs | 2 +- .../Features/Categories/GetHandlerTests.cs | 4 ++-- .../Features/Categories/ListHandlerTests.cs | 6 +++--- .../Features/Categories/UpdateHandlerTests.cs | 4 ++-- Server.Tests/Features/Common/HandlerBaseTests.cs | 16 ++++++++-------- Server.Tests/Features/Common/PaginationTests.cs | 2 +- .../Features/SampleModule/ListHandlerTests.cs | 4 ++-- Server/Features/Categories/Create.cs | 2 +- Server/Features/Categories/Get.cs | 2 +- Server/Features/Categories/List.cs | 2 +- Server/Features/Categories/MoveDown.cs | 10 +++++----- Server/Features/Categories/MoveUp.cs | 10 +++++----- Server/Features/Categories/Update.cs | 2 +- 17 files changed, 41 insertions(+), 41 deletions(-) diff --git a/Client.Tests/BaseTest.cs b/Client.Tests/BaseTest.cs index 30180c3..9e939ad 100644 --- a/Client.Tests/BaseTest.cs +++ b/Client.Tests/BaseTest.cs @@ -42,9 +42,9 @@ protected BaseTest() // Initialize SiteState with proper data var siteState = new SiteState(); TestContext.Services.AddSingleton(siteState); - + TestContext.Services.AddLogging(); - + TestContext.Services.AddScoped(); TestContext.Services.AddScoped(); TestContext.Services.AddScoped(); @@ -56,7 +56,7 @@ protected BaseTest() TestContext.Services.AddScoped(); TestContext.Services.AddScoped(); TestContext.Services.AddScoped(); - + // Add authentication state provider TestContext.Services.AddScoped(); diff --git a/Client.Tests/Mocks/MockLogService.cs b/Client.Tests/Mocks/MockLogService.cs index 946b43a..7789e40 100644 --- a/Client.Tests/Mocks/MockLogService.cs +++ b/Client.Tests/Mocks/MockLogService.cs @@ -22,7 +22,7 @@ public class MockLogService : ILogService public Task GetLogAsync(int logId) => Task.FromResult(new Log()); - public Task> GetLogsAsync(int siteId, string level, string function, int rows) + public Task> GetLogsAsync(int siteId, string level, string function, int rows) => Task.FromResult(new List()); public Task Log(int? pageId, int? moduleId, int? userId, string category, string feature, LogFunction function, Oqtane.Shared.LogLevel level, Exception? exception, string message, params object[] args) @@ -41,7 +41,7 @@ public Task Log(int? pageId, int? moduleId, int? userId, string category, string Args = args, Timestamp = DateTime.UtcNow }); - + return Task.CompletedTask; } @@ -62,7 +62,7 @@ public Task Log(Alias? alias, int? pageId, int? moduleId, int? userId, string ca Args = args, Timestamp = DateTime.UtcNow }); - + return Task.CompletedTask; } diff --git a/Client/Modules/FileHub/Edit.razor.cs b/Client/Modules/FileHub/Edit.razor.cs index 249d73e..5dfd8bc 100644 --- a/Client/Modules/FileHub/Edit.razor.cs +++ b/Client/Modules/FileHub/Edit.razor.cs @@ -14,7 +14,7 @@ public partial class Edit public override string Title => "Manage FileHub"; - public override List Resources => + public override List Resources => [ new Stylesheet(ModulePath() + "Module.css") ]; diff --git a/Client/Modules/FileHub/Index.razor.cs b/Client/Modules/FileHub/Index.razor.cs index 4d15d44..4b74df7 100644 --- a/Client/Modules/FileHub/Index.razor.cs +++ b/Client/Modules/FileHub/Index.razor.cs @@ -12,7 +12,7 @@ public partial class Index [ new Stylesheet(ModulePath() + "Module.css"), new Script(ModulePath() + "Module.js"), - new Script("_content/Radzen.Blazor/Radzen.Blazor.js") + new Script("_content/Radzen.Blazor/Radzen.Blazor.js"), ]; private List? _filehubs; diff --git a/EndToEnd.Tests/ApplicationHealthTests.cs b/EndToEnd.Tests/ApplicationHealthTests.cs index a0d628e..4b48f14 100644 --- a/EndToEnd.Tests/ApplicationHealthTests.cs +++ b/EndToEnd.Tests/ApplicationHealthTests.cs @@ -77,7 +77,7 @@ public async Task Navigate_ToBaseUrl_RendersBodyContent() // Verification: Body element should exist and have content var body = Page.Locator("body"); await Expect(body).ToBeVisibleAsync().ConfigureAwait(false); - + var bodyContent = await body.TextContentAsync().ConfigureAwait(false); await Assert.That(bodyContent).IsNotNull(); await Assert.That(bodyContent).IsNotEmpty(); diff --git a/Server.Tests/Features/Categories/GetHandlerTests.cs b/Server.Tests/Features/Categories/GetHandlerTests.cs index 5d42445..fbd785b 100644 --- a/Server.Tests/Features/Categories/GetHandlerTests.cs +++ b/Server.Tests/Features/Categories/GetHandlerTests.cs @@ -102,7 +102,7 @@ public async Task Handle_VerifiesAuditFields_ArePopulated() var createdOn = DateTime.UtcNow.AddDays(-5); var modifiedOn = DateTime.UtcNow.AddDays(-1); - await SeedQueryDataAsync(options, + await SeedQueryDataAsync(options, CreateTestEntity(id: 1, moduleId: 1, name: "Test Category", viewOrder: 1, parentId: 0, createdBy: "creator", createdOn: createdOn, modifiedBy: "modifier", modifiedOn: modifiedOn)).ConfigureAwait(false); @@ -129,7 +129,7 @@ public async Task Handle_WithParentCategory_ReturnsCorrectParentId() { // Arrange var (connection, options) = await CreateQueryDatabaseAsync().ConfigureAwait(false); - await SeedQueryDataAsync(options, + await SeedQueryDataAsync(options, CreateTestEntity(id: 1, name: "Parent Category", parentId: 0), CreateTestEntity(id: 2, name: "Child Category", parentId: 1)).ConfigureAwait(false); diff --git a/Server.Tests/Features/Categories/ListHandlerTests.cs b/Server.Tests/Features/Categories/ListHandlerTests.cs index f18a95e..cd14eea 100644 --- a/Server.Tests/Features/Categories/ListHandlerTests.cs +++ b/Server.Tests/Features/Categories/ListHandlerTests.cs @@ -60,7 +60,7 @@ public async Task Handle_WithPagination_ReturnsCorrectPage() { // Arrange var (connection, options) = await CreateQueryDatabaseAsync().ConfigureAwait(false); - + for (int i = 1; i <= 25; i++) { await SeedQueryDataAsync(options, CreateTestEntity(id: i, name: $"Category {i}", viewOrder: i)).ConfigureAwait(false); @@ -170,7 +170,7 @@ public async Task Handle_WithLastPagePartiallyFilled_ReturnsRemainingItems() { // Arrange var (connection, options) = await CreateQueryDatabaseAsync().ConfigureAwait(false); - + for (int i = 1; i <= 15; i++) { await SeedQueryDataAsync(options, CreateTestEntity(id: i, name: $"Category {i}", viewOrder: i)).ConfigureAwait(false); @@ -212,7 +212,7 @@ await SeedQueryDataAsync(options, // Assert await Assert.That(result).IsNotNull(); await Assert.That(result!.Items).HasCount().EqualTo(2); - + var parent = result.Items.First(c => string.Equals(c.Name, "Parent", StringComparison.Ordinal)); var child = result.Items.First(c => string.Equals(c.Name, "Child", StringComparison.Ordinal)); diff --git a/Server.Tests/Features/Categories/UpdateHandlerTests.cs b/Server.Tests/Features/Categories/UpdateHandlerTests.cs index 2a50a53..beae9fe 100644 --- a/Server.Tests/Features/Categories/UpdateHandlerTests.cs +++ b/Server.Tests/Features/Categories/UpdateHandlerTests.cs @@ -103,7 +103,7 @@ public async Task Handle_UpdatesParentId_Successfully() { // Arrange var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); - await SeedCommandDataAsync(options, + await SeedCommandDataAsync(options, CreateTestEntity(id: 1, name: "Parent Category", parentId: 0), CreateTestEntity(id: 2, name: "Child Category", parentId: 0)).ConfigureAwait(false); @@ -168,7 +168,7 @@ public async Task Handle_UpdatesOnlyName_KeepsOtherFieldsIntact() { // Arrange var (connection, options) = await CreateCommandDatabaseAsync().ConfigureAwait(false); - await SeedCommandDataAsync(options, + await SeedCommandDataAsync(options, CreateTestEntity(id: 1, name: "Original", viewOrder: 5, parentId: 0)).ConfigureAwait(false); var handler = new CategoryHandlers.UpdateHandler( diff --git a/Server.Tests/Features/Common/HandlerBaseTests.cs b/Server.Tests/Features/Common/HandlerBaseTests.cs index 7936701..d58b609 100644 --- a/Server.Tests/Features/Common/HandlerBaseTests.cs +++ b/Server.Tests/Features/Common/HandlerBaseTests.cs @@ -75,16 +75,16 @@ public async Task HandleCreateAsync_AutoAssignsId() CreateCommandHandlerServices(options, isAuthorized: true)); // Act - Create multiple entities - var id1 = await handler.Handle(new CreateSampleModuleRequest - { - ModuleId = 1, - Name = "First" + var id1 = await handler.Handle(new CreateSampleModuleRequest + { + ModuleId = 1, + Name = "First" }, CancellationToken.None).ConfigureAwait(false); - var id2 = await handler.Handle(new CreateSampleModuleRequest - { - ModuleId = 1, - Name = "Second" + var id2 = await handler.Handle(new CreateSampleModuleRequest + { + ModuleId = 1, + Name = "Second" }, CancellationToken.None).ConfigureAwait(false); // Assert - IDs are auto-incremented diff --git a/Server.Tests/Features/Common/PaginationTests.cs b/Server.Tests/Features/Common/PaginationTests.cs index 10c9ec6..a8ac5cb 100644 --- a/Server.Tests/Features/Common/PaginationTests.cs +++ b/Server.Tests/Features/Common/PaginationTests.cs @@ -172,7 +172,7 @@ await Helpers.SampleModuleTestHelpers.SeedQueryDataAsync(options, var page1List = page1!.Items.ToList(); var page2List = page2!.Items.ToList(); var page3List = page3!.Items.ToList(); - + await Assert.That(page1List[0].Name).IsEqualTo("Apple"); await Assert.That(page1List[1].Name).IsEqualTo("Banana"); await Assert.That(page2List[0].Name).IsEqualTo("Mango"); diff --git a/Server.Tests/Features/SampleModule/ListHandlerTests.cs b/Server.Tests/Features/SampleModule/ListHandlerTests.cs index 8e2fb10..e4dffff 100644 --- a/Server.Tests/Features/SampleModule/ListHandlerTests.cs +++ b/Server.Tests/Features/SampleModule/ListHandlerTests.cs @@ -71,7 +71,7 @@ await SeedQueryDataAsync(options, await Assert.That(result.Items.Count()).IsEqualTo(2); await Assert.That(result.PageNumber).IsEqualTo(2); await Assert.That(result.PageSize).IsEqualTo(2); - + // Items should be "Charlie" and "Delta" (sorted alphabetically, page 2) var items = result.Items.ToList(); await Assert.That(items[0].Name).IsEqualTo("Charlie"); @@ -160,7 +160,7 @@ await SeedQueryDataAsync(options, await Assert.That(result).IsNotNull(); await Assert.That(result!.TotalCount).IsEqualTo(2); await Assert.That(result.Items.Count()).IsEqualTo(2); - + var items = result.Items.ToList(); await Assert.That(items.All(x => x.Name.StartsWith("Module 1", StringComparison.Ordinal))).IsTrue(); diff --git a/Server/Features/Categories/Create.cs b/Server/Features/Categories/Create.cs index 70cc0a2..035264c 100644 --- a/Server/Features/Categories/Create.cs +++ b/Server/Features/Categories/Create.cs @@ -29,6 +29,6 @@ internal sealed partial class CreateMapper { [MapProperty(nameof(CreateCategoryRequest.ParentId), nameof(Persistence.Entities.Category.ParentId), Use = nameof(ConvertParentId))] internal partial Persistence.Entities.Category ToEntity(CreateCategoryRequest request); - + private int? ConvertParentId(int parentId) => parentId == 0 ? null : parentId; } diff --git a/Server/Features/Categories/Get.cs b/Server/Features/Categories/Get.cs index b5d4e48..6c3a395 100644 --- a/Server/Features/Categories/Get.cs +++ b/Server/Features/Categories/Get.cs @@ -24,6 +24,6 @@ internal sealed partial class GetMapper { [MapProperty(nameof(Persistence.Entities.Category.ParentId), nameof(GetCategoryDto.ParentId), Use = nameof(ConvertParentId))] public partial GetCategoryDto ToGetResponse(Persistence.Entities.Category category); - + private int ConvertParentId(int? parentId) => parentId ?? 0; } diff --git a/Server/Features/Categories/List.cs b/Server/Features/Categories/List.cs index ba728cf..cebda71 100644 --- a/Server/Features/Categories/List.cs +++ b/Server/Features/Categories/List.cs @@ -25,6 +25,6 @@ internal sealed partial class ListMapper { [MapProperty(nameof(Persistence.Entities.Category.ParentId), nameof(ListCategoryDto.ParentId), Use = nameof(ConvertParentId))] public partial ListCategoryDto ToListResponse(Persistence.Entities.Category category); - + private int ConvertParentId(int? parentId) => parentId ?? 0; } diff --git a/Server/Features/Categories/MoveDown.cs b/Server/Features/Categories/MoveDown.cs index b50ef9c..af7721a 100644 --- a/Server/Features/Categories/MoveDown.cs +++ b/Server/Features/Categories/MoveDown.cs @@ -13,7 +13,7 @@ public async Task Handle(MoveDownCategoryRequest request, CancellationToken if (!IsAuthorized(alias.SiteId, request.ModuleId, PermissionNames.Edit)) { - Logger.Log(LogLevel.Error, this, LogFunction.Security, + Logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Category MoveDown Attempt {Id} {ModuleId}", request.Id, request.ModuleId); return -1; } @@ -28,13 +28,13 @@ public async Task Handle(MoveDownCategoryRequest request, CancellationToken if (currentCategory is null) { - Logger.Log(LogLevel.Warning, this, LogFunction.Update, + Logger.Log(LogLevel.Warning, this, LogFunction.Update, "Category Not Found {Id}", request.Id); return -1; } var nextCategory = await db.Category - .Where(c => c.ModuleId == request.ModuleId + .Where(c => c.ModuleId == request.ModuleId && c.ParentId == currentCategory.ParentId && c.ViewOrder > currentCategory.ViewOrder) .OrderBy(c => c.ViewOrder) @@ -44,7 +44,7 @@ public async Task Handle(MoveDownCategoryRequest request, CancellationToken if (nextCategory is null) { - Logger.Log(LogLevel.Information, this, LogFunction.Update, + Logger.Log(LogLevel.Information, this, LogFunction.Update, "Category Already at Bottom {Id}", request.Id); return request.Id; } @@ -62,7 +62,7 @@ await db.Category .ExecuteUpdateAsync(setter => setter.SetProperty(c => c.ViewOrder, currentViewOrder), cancellationToken) .ConfigureAwait(false); - Logger.Log(LogLevel.Information, this, LogFunction.Update, + Logger.Log(LogLevel.Information, this, LogFunction.Update, "Category Moved Down {Id}", request.Id); return request.Id; diff --git a/Server/Features/Categories/MoveUp.cs b/Server/Features/Categories/MoveUp.cs index 0bf8fe2..9e6a692 100644 --- a/Server/Features/Categories/MoveUp.cs +++ b/Server/Features/Categories/MoveUp.cs @@ -13,7 +13,7 @@ public async Task Handle(MoveUpCategoryRequest request, CancellationToken c if (!IsAuthorized(alias.SiteId, request.ModuleId, PermissionNames.Edit)) { - Logger.Log(LogLevel.Error, this, LogFunction.Security, + Logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Category MoveUp Attempt {Id} {ModuleId}", request.Id, request.ModuleId); return -1; } @@ -28,13 +28,13 @@ public async Task Handle(MoveUpCategoryRequest request, CancellationToken c if (currentCategory is null) { - Logger.Log(LogLevel.Warning, this, LogFunction.Update, + Logger.Log(LogLevel.Warning, this, LogFunction.Update, "Category Not Found {Id}", request.Id); return -1; } var previousCategory = await db.Category - .Where(c => c.ModuleId == request.ModuleId + .Where(c => c.ModuleId == request.ModuleId && c.ParentId == currentCategory.ParentId && c.ViewOrder < currentCategory.ViewOrder) .OrderByDescending(c => c.ViewOrder) @@ -44,7 +44,7 @@ public async Task Handle(MoveUpCategoryRequest request, CancellationToken c if (previousCategory is null) { - Logger.Log(LogLevel.Information, this, LogFunction.Update, + Logger.Log(LogLevel.Information, this, LogFunction.Update, "Category Already at Top {Id}", request.Id); return request.Id; } @@ -62,7 +62,7 @@ await db.Category .ExecuteUpdateAsync(setter => setter.SetProperty(c => c.ViewOrder, currentViewOrder), cancellationToken) .ConfigureAwait(false); - Logger.Log(LogLevel.Information, this, LogFunction.Update, + Logger.Log(LogLevel.Information, this, LogFunction.Update, "Category Moved Up {Id}", request.Id); return request.Id; diff --git a/Server/Features/Categories/Update.cs b/Server/Features/Categories/Update.cs index 3796913..acf7ba8 100644 --- a/Server/Features/Categories/Update.cs +++ b/Server/Features/Categories/Update.cs @@ -16,7 +16,7 @@ public Task Handle(UpdateCategoryRequest request, CancellationToken cancell { // Convert ParentId of 0 to null for root-level categories int? parentId = request.ParentId == 0 ? null : request.ParentId; - + return HandleUpdateAsync( request: request, setPropertyCalls: setter => setter From b5817c7d48a76528f829210736f68ab1daaf019a Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Mon, 8 Dec 2025 23:06:37 +1100 Subject: [PATCH 14/22] meziantou --- .github/instructions/csharp.instructions.md | 1 - .../meziantou-analyzer-rules.instructions.md | 453 ++++++++++++++++++ Client/Modules/FileHub/Category.razor.cs | 18 +- Client/Services/CategoryService.cs | 2 +- ICTAce.FileHub.slnx | 1 + 5 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 .github/instructions/meziantou-analyzer-rules.instructions.md diff --git a/.github/instructions/csharp.instructions.md b/.github/instructions/csharp.instructions.md index 110a1b1..2c25261 100644 --- a/.github/instructions/csharp.instructions.md +++ b/.github/instructions/csharp.instructions.md @@ -30,7 +30,6 @@ applyTo: '**/*.cs' - Use pattern matching and switch expressions wherever possible. - Use `nameof` instead of string literals when referring to member names. - Ensure that XML doc comments are created for any public APIs. When applicable, include `` and `` documentation in the comments. -- Add a trailing comma after the last value in multi-line arrays, collection initializers, object initializers, and enum declarations to prevent MA0007 warnings. - Use simplified `new()` expressions when the type is evident from the declaration (e.g., `List items = new();` instead of `List items = new List();`) to prevent IDE0090 warnings. ## Project Setup and Structure diff --git a/.github/instructions/meziantou-analyzer-rules.instructions.md b/.github/instructions/meziantou-analyzer-rules.instructions.md new file mode 100644 index 0000000..6396077 --- /dev/null +++ b/.github/instructions/meziantou-analyzer-rules.instructions.md @@ -0,0 +1,453 @@ +--- +description: 'Meziantou.Analyzer rules for enforcing C# best practices in design, usage, security, performance, and style' +applyTo: '**/*.cs' +--- + +# Meziantou.Analyzer Rules + +This file contains guidelines based on Meziantou.Analyzer rules to enforce best practices in C# development. + +## General Instructions + +Follow all Meziantou.Analyzer rules to ensure code quality, performance, security, and maintainability. The analyzer enforces best practices across multiple categories: Usage, Performance, Design, Security, Style, and Naming. + +## Usage Rules + +### String Comparison and Culture + +- **MA0001**: Always specify StringComparison when comparing strings (e.g., `StringComparison.OrdinalIgnoreCase`) +- **MA0002**: Provide IEqualityComparer or IComparer when using collections or LINQ methods +- **MA0006**: Use `String.Equals` instead of equality operator (`==`) for string comparisons +- **MA0021**: Use `StringComparer.GetHashCode` instead of `string.GetHashCode` for hash-based collections +- **MA0024**: Use an explicit StringComparer when possible (e.g., in dictionaries, sets) +- **MA0074**: Avoid implicit culture-sensitive methods; specify culture explicitly +- **MA0127**: Use `String.Equals` instead of is pattern for string comparisons + +### Format Providers and Globalization + +- **MA0011**: Specify IFormatProvider when formatting or parsing values +- **MA0075**: Do not use implicit culture-sensitive ToString +- **MA0076**: Do not use implicit culture-sensitive ToString in interpolated strings + +### Task and Async/Await + +- **MA0004**: Use `Task.ConfigureAwait(false)` in library code to avoid deadlocks +- **MA0022**: Return `Task.FromResult` instead of returning null from async methods +- **MA0032**: Use overloads with CancellationToken argument when available +- **MA0040**: Forward the CancellationToken parameter to methods that accept one +- **MA0042**: Do not use blocking calls (e.g., `.Result`, `.Wait()`) in async methods +- **MA0045**: Do not use blocking calls in sync methods; make the method async instead +- **MA0079**: Forward CancellationToken using `.WithCancellation()` for IAsyncEnumerable +- **MA0080**: Use a cancellation token with `.WithCancellation()` when iterating IAsyncEnumerable +- **MA0100**: Await task before disposing of resources +- **MA0129**: Await task in using statement +- **MA0134**: Observe result of async calls +- **MA0147**: Avoid async void methods for delegates +- **MA0155**: Do not use async void methods (except event handlers) + +### Argument Validation + +- **MA0015**: Specify the parameter name in ArgumentException +- **MA0043**: Use nameof operator in ArgumentException +- **MA0050**: Validate arguments correctly in iterator methods +- **MA0131**: ArgumentNullException.ThrowIfNull should not be used with non-nullable types + +### Exception Handling + +- **MA0012**: Do not raise reserved exception types (e.g., NullReferenceException, IndexOutOfRangeException) +- **MA0013**: Types should not extend System.ApplicationException +- **MA0014**: Do not raise System.ApplicationException +- **MA0027**: Prefer rethrowing an exception implicitly using `throw;` instead of `throw ex;` +- **MA0054**: Embed the caught exception as innerException when rethrowing +- **MA0072**: Do not throw from a finally block +- **MA0086**: Do not throw from a finalizer + +### Events + +- **MA0019**: Use `EventArgs.Empty` instead of creating new instance +- **MA0085**: Anonymous delegates should not be used to unsubscribe from events +- **MA0091**: Sender should be 'this' for instance events +- **MA0092**: Sender should be 'null' for static events +- **MA0093**: EventArgs should not be null when raising an event + +### Collections and LINQ + +- **MA0103**: Use `SequenceEqual` instead of equality operator for arrays/sequences +- **MA0099**: Use explicit enum value instead of 0 in enums + +### Regex + +- **MA0009**: Add regex evaluation timeout to prevent ReDoS attacks + +### DateTime and DateTimeOffset + +- **MA0132**: Do not convert implicitly to DateTimeOffset +- **MA0133**: Use DateTimeOffset instead of relying on implicit conversion +- **MA0113**: Use `DateTime.UnixEpoch` instead of new DateTime(1970, 1, 1) +- **MA0114**: Use `DateTimeOffset.UnixEpoch` instead of constructing manually + +### Process Execution + +- **MA0161**: UseShellExecute must be explicitly set when using Process.Start +- **MA0162**: Use Process.Start overload with ProcessStartInfo +- **MA0163**: UseShellExecute must be false when redirecting standard input or output + +### Pattern Matching + +- **MA0141**: Use pattern matching instead of inequality operators for null check +- **MA0142**: Use pattern matching instead of equality operators for null check +- **MA0148**: Use pattern matching instead of equality operators for discrete values +- **MA0149**: Use pattern matching instead of inequality operators for discrete values +- **MA0171**: Use pattern matching instead of HasValue for Nullable check + +### Other Usage Rules + +- **MA0037**: Remove empty statements +- **MA0060**: The value returned by Stream.Read/Stream.ReadAsync is not used +- **MA0101**: String contains an implicit end of line character +- **MA0108**: Remove redundant argument value +- **MA0128**: Use 'is' operator instead of SequenceEqual for constant arrays +- **MA0130**: GetType() should not be used on System.Type instances +- **MA0136**: Raw string contains an implicit end of line character +- **MA0165**: Make interpolated string instead of concatenation +- **MA0166**: Forward the TimeProvider to methods that take one +- **MA0167**: Use an overload with a TimeProvider argument + +## Performance Rules + +### Array and Collection Optimization + +- **MA0005**: Use `Array.Empty()` instead of `new T[0]` or `new T[] {}` +- **MA0020**: Use direct methods instead of LINQ (e.g., `List.Count` instead of `Count()`) +- **MA0029**: Combine LINQ methods when possible +- **MA0030**: Remove useless OrderBy call +- **MA0031**: Optimize `Enumerable.Count()` usage (use `Count` property when available) +- **MA0063**: Use Where before OrderBy for better performance +- **MA0078**: Use 'Cast' instead of 'Select' to cast +- **MA0098**: Use indexer instead of LINQ methods (e.g., `list[0]` instead of `First()`) +- **MA0112**: Use 'Count > 0' instead of 'Any()' when Count property is available +- **MA0159**: Use 'Order' instead of 'OrderBy' when ordering by self +- **MA0160**: Use ContainsKey instead of TryGetValue when value is not needed + +### String and StringBuilder + +- **MA0028**: Optimize StringBuilder usage +- **MA0044**: Remove useless ToString call +- **MA0089**: Optimize string method usage +- **MA0111**: Use string.Create instead of FormattableString for performance + +### Struct and Memory Layout + +- **MA0008**: Add StructLayoutAttribute to structs for explicit memory layout +- **MA0065**: Avoid using default ValueType.Equals or HashCode for struct equality +- **MA0066**: Hash table unfriendly types should not be used in hash tables +- **MA0102**: Make member readonly when possible +- **MA0168**: Use readonly struct for 'in' or 'ref readonly' parameters + +### Regex Optimization + +- **MA0023**: Add RegexOptions.ExplicitCapture to improve performance +- **MA0110**: Use the Regex source generator (.NET 7+) + +### Closure and Lambda Optimization + +- **MA0105**: Use lambda parameters instead of using a closure +- **MA0106**: Avoid closure by using overload with 'factoryArgument' parameter + +### Task and Async Optimization + +- **MA0152**: Use Unwrap instead of using await twice + +### Other Performance Rules + +- **MA0052**: Replace constant `Enum.ToString` with nameof +- **MA0120**: Use InvokeVoidAsync when the returned value is not used (Blazor) +- **MA0144**: Use System.OperatingSystem to check the current OS instead of RuntimeInformation +- **MA0158**: Use System.Threading.Lock (.NET 9+) instead of object lock +- **MA0176**: Optimize GUID creation (prefer parsing over constructors) +- **MA0178**: Use TimeSpan.Zero instead of TimeSpan.FromXXX(0) + +## Design Rules + +### Class and Type Design + +- **MA0010**: Mark attributes with AttributeUsageAttribute +- **MA0016**: Prefer using collection abstraction instead of implementation +- **MA0017**: Abstract types should not have public or internal constructors +- **MA0018**: Do not declare static members on generic types (deprecated, use CA1000) +- **MA0025**: Implement functionality instead of throwing NotImplementedException +- **MA0026**: Fix TODO comments +- **MA0033**: Do not tag instance fields with ThreadStaticAttribute +- **MA0036**: Make class static when all members are static +- **MA0038**: Make method static when possible (deprecated, use CA1822) +- **MA0041**: Make property static when possible (deprecated, use CA1822) +- **MA0046**: Use EventHandler to declare events +- **MA0047**: Declare types in namespaces +- **MA0048**: File name must match type name +- **MA0049**: Type name should not match containing namespace +- **MA0051**: Method is too long +- **MA0053**: Make class or record sealed when not intended for inheritance +- **MA0055**: Do not use finalizers +- **MA0056**: Do not call overridable members in constructor +- **MA0061**: Method overrides should not change default values +- **MA0062**: Non-flags enums should not be marked with FlagsAttribute +- **MA0064**: Avoid locking on publicly accessible instance +- **MA0067**: Use `Guid.Empty` instead of `new Guid()` +- **MA0068**: Invalid parameter name for nullable attribute +- **MA0069**: Non-constant static fields should not be visible +- **MA0070**: Obsolete attributes should include explanations +- **MA0081**: Method overrides should not omit params keyword +- **MA0082**: NaN should not be used in comparisons +- **MA0083**: ConstructorArgument parameters should exist in constructors +- **MA0084**: Local variables should not hide other symbols +- **MA0087**: Parameters with [DefaultParameterValue] should also be marked [Optional] +- **MA0088**: Use [DefaultParameterValue] instead of [DefaultValue] +- **MA0090**: Remove empty else/finally block +- **MA0104**: Do not create a type with a name from the BCL +- **MA0107**: Do not use object.ToString (too generic) +- **MA0109**: Consider adding an overload with Span or Memory +- **MA0121**: Do not overwrite parameter value +- **MA0140**: Both if and else branches have identical code +- **MA0143**: Primary constructor parameters should be readonly +- **MA0150**: Do not call the default object.ToString explicitly +- **MA0169**: Use Equals method instead of operator for types without operator overload +- **MA0170**: Type cannot be used as an attribute argument +- **MA0172**: Both sides of the logical operation are identical +- **MA0173**: Use LazyInitializer.EnsureInitialized for lazy initialization + +### Interface Implementation + +- **MA0077**: A class that provides Equals(T) should implement IEquatable +- **MA0094**: A class that provides CompareTo(T) should implement IComparable +- **MA0095**: A class that implements IEquatable should override Equals(object) +- **MA0096**: A class that implements IComparable should also implement IEquatable +- **MA0097**: A class that implements IComparable or IComparable should override comparison operators + +### Method Naming + +- **MA0137**: Use 'Async' suffix when a method returns an awaitable type +- **MA0138**: Do not use 'Async' suffix when a method does not return an awaitable type +- **MA0156**: Use 'Async' suffix when a method returns IAsyncEnumerable +- **MA0157**: Do not use 'Async' suffix when a method does not return IAsyncEnumerable + +### Blazor-Specific Design + +- **MA0115**: Unknown component parameter +- **MA0116**: Parameters with [SupplyParameterFromQuery] should also be marked as [Parameter] +- **MA0117**: Parameters with [EditorRequired] should also be marked as [Parameter] +- **MA0118**: [JSInvokable] methods must be public +- **MA0119**: JSRuntime must not be used in OnInitialized or OnInitializedAsync +- **MA0122**: Parameters with [SupplyParameterFromQuery] are only valid in routable components (@page) + +### Logging Design + +- **MA0123**: Sequence number must be a constant in LoggerMessage +- **MA0124**: Log parameter type is not valid +- **MA0125**: The list of log parameter types contains an invalid type +- **MA0126**: The list of log parameter types contains a duplicate +- **MA0135**: The log parameter has no configured type +- **MA0139**: Log parameter type is not valid +- **MA0153**: Do not log symbols decorated with DataClassificationAttribute directly + +### UnsafeAccessor + +- **MA0145**: Signature for [UnsafeAccessorAttribute] method is not valid +- **MA0146**: Name must be set explicitly on local functions with UnsafeAccessor + +### XML Comments + +- **MA0151**: DebuggerDisplay must contain valid members +- **MA0154**: Use langword in XML comments (e.g., ``) + +## Security Rules + +- **MA0009**: Add regex evaluation timeout to prevent ReDoS attacks +- **MA0039**: Do not write your own certificate validation method +- **MA0035**: Do not use dangerous threading methods (e.g., Thread.Abort, Thread.Suspend) + +## Style Rules + +### Code Formatting + +- **MA0003**: Add parameter name to improve readability (for boolean/null arguments) +- **MA0007**: Add a comma after the last value in enums +- **MA0071**: Avoid using redundant else +- **MA0073**: Avoid comparison with bool constant (e.g., `if (condition == true)`) +- **MA0164**: Use parentheses to make 'not' pattern clearer +- **MA0174**: Record should use explicit 'class' keyword +- **MA0175**: Record should not use explicit 'class' keyword +- **MA0177**: Use single-line XML comment syntax when possible + +## Naming Rules + +- **MA0057**: Class name should end with 'Attribute' for attribute classes +- **MA0058**: Class name should end with 'Exception' for exception classes +- **MA0059**: Class name should end with 'EventArgs' for EventArgs classes + +## Blazor-Specific Rules + +Apply these rules when working with Blazor components: + +- **MA0115**: Ensure all component parameters are defined correctly +- **MA0116**: Mark parameters with [SupplyParameterFromQuery] as [Parameter] +- **MA0117**: Mark parameters with [EditorRequired] as [Parameter] +- **MA0118**: Make [JSInvokable] methods public +- **MA0119**: Do not use JSRuntime in OnInitialized or OnInitializedAsync +- **MA0120**: Use InvokeVoidAsync when the return value is not needed +- **MA0122**: Only use [SupplyParameterFromQuery] in routable components (@page) + +## Code Examples + +### Good Example - String Comparison + +```csharp +// Correct: Specify StringComparison +if (string.Equals(str1, str2, StringComparison.OrdinalIgnoreCase)) +{ + // ... +} + +// Correct: Use StringComparer in collections +var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); +``` + +### Bad Example - String Comparison + +```csharp +// Avoid: Missing StringComparison +if (str1 == str2) // MA0006 +{ + // ... +} + +// Avoid: Missing StringComparer +var dictionary = new Dictionary(); // MA0002 +``` + +### Good Example - ConfigureAwait + +```csharp +// Correct: Use ConfigureAwait(false) in library code +await httpClient.GetAsync(url).ConfigureAwait(false); +``` + +### Bad Example - ConfigureAwait + +```csharp +// Avoid: Missing ConfigureAwait +await httpClient.GetAsync(url); // MA0004 +``` + +### Good Example - CancellationToken + +```csharp +// Correct: Forward CancellationToken +public async Task ProcessAsync(CancellationToken cancellationToken) +{ + await DoWorkAsync(cancellationToken); +} +``` + +### Bad Example - CancellationToken + +```csharp +// Avoid: Not forwarding CancellationToken +public async Task ProcessAsync(CancellationToken cancellationToken) +{ + await DoWorkAsync(); // MA0040 +} +``` + +### Good Example - Array.Empty + +```csharp +// Correct: Use Array.Empty() +return Array.Empty(); +``` + +### Bad Example - Array.Empty + +```csharp +// Avoid: Allocating empty array +return new string[0]; // MA0005 +return new string[] { }; // MA0005 +``` + +### Good Example - Exception Handling + +```csharp +// Correct: Rethrow without losing stack trace +catch (Exception) +{ + // Cleanup + throw; +} + +// Correct: Include inner exception +catch (IOException ex) +{ + throw new CustomException("Failed to read file", ex); +} +``` + +### Bad Example - Exception Handling + +```csharp +// Avoid: Loses stack trace +catch (Exception ex) +{ + throw ex; // MA0027 +} + +// Avoid: Missing inner exception +catch (IOException ex) +{ + throw new CustomException("Failed to read file"); // MA0054 +} +``` + +### Good Example - LINQ Optimization + +```csharp +// Correct: Use Count property +if (list.Count > 0) +{ + // ... +} + +// Correct: Use indexer +var first = list[0]; +``` + +### Bad Example - LINQ Optimization + +```csharp +// Avoid: Unnecessary LINQ method +if (list.Any()) // MA0112 (when Count is available) +{ + // ... +} + +// Avoid: LINQ when indexer is available +var first = list.First(); // MA0098 +``` + +## Configuration Notes + +You can configure rule severity in `.editorconfig`: + +```ini +[*.cs] +# Disable specific rule +dotnet_diagnostic.MA0004.severity = none + +# Change severity +dotnet_diagnostic.MA0026.severity = suggestion +``` + +## Validation + +- Build the solution to ensure all Meziantou.Analyzer rules are followed +- Review analyzer warnings in the Error List +- Use code fixes where available to automatically correct violations +- Configure rule severity in .editorconfig based on project requirements diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index 9f8169f..a14e070 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -161,7 +161,7 @@ private List GetSiblings(ListCategoryDto category) } var parent = FindCategoryById([_rootNode], category.ParentId); - return parent?.Children ?? []; + return parent?.Children.ToList() ?? []; } private ListCategoryDto? FindCategoryById(List categories, int id) @@ -169,7 +169,7 @@ private List GetSiblings(ListCategoryDto category) foreach (var cat in categories) { if (cat.Id == id) return cat; - var found = FindCategoryById(cat.Children, id); + var found = FindCategoryById(cat.Children.ToList(), id); if (found != null) return found; } return null; @@ -330,11 +330,11 @@ private void CancelInlineEdit() private async Task HandleKeyPress(KeyboardEventArgs e) { - if (e.Key == "Enter") + if (string.Equals(e.Key, "Enter", StringComparison.Ordinal)) { await SaveInlineEdit(); } - else if (e.Key == "Escape") + else if (string.Equals(e.Key, "Escape", StringComparison.Ordinal)) { CancelInlineEdit(); } @@ -389,7 +389,7 @@ private async Task MoveUp() var siblings = freshCategories.Items? .Where(c => c.ParentId == SelectedCategory.ParentId) .OrderBy(c => c.ViewOrder) - .ThenBy(c => c.Name) + .ThenBy(c => c.Name, StringComparer.Ordinal) .ToList() ?? []; var currentIndex = siblings.FindIndex(c => c.Id == SelectedCategory.Id); @@ -460,7 +460,7 @@ private async Task MoveDown() var siblings = freshCategories.Items? .Where(c => c.ParentId == SelectedCategory.ParentId) .OrderBy(c => c.ViewOrder) - .ThenBy(c => c.Name) + .ThenBy(c => c.Name, StringComparer.Ordinal) .ToList() ?? []; var currentIndex = siblings.FindIndex(c => c.Id == SelectedCategory.Id); @@ -682,7 +682,7 @@ private void CreateTreeStructure() _treeData = Categories.Items .Where(c => c.ParentId == 0 || !categoryDict.ContainsKey(c.ParentId)) .OrderBy(c => c.ViewOrder) - .ThenBy(c => c.Name) + .ThenBy(c => c.Name, StringComparer.Ordinal) .ToList(); foreach (var category in Categories.Items) @@ -714,10 +714,10 @@ private void SortChildren(List categories) { category.Children = category.Children .OrderBy(c => c.ViewOrder) - .ThenBy(c => c.Name) + .ThenBy(c => c.Name, StringComparer.Ordinal) .ToList(); - SortChildren(category.Children); + SortChildren(category.Children.ToList()); } } } diff --git a/Client/Services/CategoryService.cs b/Client/Services/CategoryService.cs index e6a4bdd..aee4fdf 100644 --- a/Client/Services/CategoryService.cs +++ b/Client/Services/CategoryService.cs @@ -24,7 +24,7 @@ public record ListCategoryDto public required string Name { get; set; } public int ViewOrder { get; set; } public int ParentId { get; set; } - public List Children { get; set; } = []; + public IList Children { get; set; } = []; } public record CreateAndUpdateCategoryDto diff --git a/ICTAce.FileHub.slnx b/ICTAce.FileHub.slnx index ee293e3..258b280 100644 --- a/ICTAce.FileHub.slnx +++ b/ICTAce.FileHub.slnx @@ -17,6 +17,7 @@ + From 12485cfa807093d6275395278e1c099e10528fd4 Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Mon, 8 Dec 2025 23:49:36 +1100 Subject: [PATCH 15/22] no refresh and 3 retry --- Client/Modules/FileHub/Category.razor.cs | 118 ++++++++----------- Client/Services/CategoryService.cs | 34 ++++-- Client/Services/Common/HttpRetryHelper.cs | 131 ++++++++++++++++++++++ Client/Services/Common/ModuleService.cs | 53 +++++++-- 4 files changed, 246 insertions(+), 90 deletions(-) create mode 100644 Client/Services/Common/HttpRetryHelper.cs diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index a14e070..2a122e6 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -249,6 +249,10 @@ private async Task SaveInlineEdit() var id = await CategoryService.CreateAsync(ModuleState.ModuleId, createDto); await logger.LogInformation("Category Created {Id}", id); + // Update the temporary node in-place with the real ID and name + EditingNode.Id = id; + EditingNode.Name = EditingNodeName; + NotificationService.Notify(new Radzen.NotificationMessage { Severity = Radzen.NotificationSeverity.Success, @@ -270,6 +274,9 @@ private async Task SaveInlineEdit() await CategoryService.UpdateAsync(EditingNode.Id, ModuleState.ModuleId, updateDto); await logger.LogInformation("Category Updated {Id}", EditingNode.Id); + // Update the node name in-place + EditingNode.Name = EditingNodeName; + NotificationService.Notify(new Radzen.NotificationMessage { Severity = Radzen.NotificationSeverity.Success, @@ -279,11 +286,13 @@ private async Task SaveInlineEdit() }); } - // Clear editing state - CancelInlineEdit(); + // Clear editing state without refreshing the tree + EditingNode = null; + EditingNodeName = string.Empty; + IsInlineEditing = false; + IsInlineAdding = false; - // Refresh tree - await RefreshCategories(); + StateHasChanged(); } catch (Exception ex) { @@ -382,43 +391,24 @@ private async Task MoveUp() try { - // Get fresh data to find current siblings - var freshCategories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); - - // Find siblings with same parent - var siblings = freshCategories.Items? - .Where(c => c.ParentId == SelectedCategory.ParentId) - .OrderBy(c => c.ViewOrder) - .ThenBy(c => c.Name, StringComparer.Ordinal) - .ToList() ?? []; - - var currentIndex = siblings.FindIndex(c => c.Id == SelectedCategory.Id); + // Find siblings in the current tree structure + var siblings = GetSiblings(SelectedCategory); + var currentIndex = siblings.IndexOf(SelectedCategory); if (currentIndex <= 0) return; - var current = siblings[currentIndex]; + var current = SelectedCategory; var previous = siblings[currentIndex - 1]; - // Swap ViewOrder values - var tempViewOrder = current.ViewOrder; + // Update on server using dedicated move endpoint + await CategoryService.MoveUpAsync(current.Id, ModuleState.ModuleId); - // Update current category - await CategoryService.UpdateAsync(current.Id, ModuleState.ModuleId, - new CreateAndUpdateCategoryDto - { - Name = current.Name, - ViewOrder = previous.ViewOrder, - ParentId = current.ParentId, - }); + // Swap ViewOrder values locally + (current.ViewOrder, previous.ViewOrder) = (previous.ViewOrder, current.ViewOrder); - // Update previous sibling - await CategoryService.UpdateAsync(previous.Id, ModuleState.ModuleId, - new CreateAndUpdateCategoryDto - { - Name = previous.Name, - ViewOrder = tempViewOrder, - ParentId = previous.ParentId, - }); + // Swap positions in the list + siblings[currentIndex] = previous; + siblings[currentIndex - 1] = current; await logger.LogInformation("Category Moved Up {Id}", SelectedCategory.Id); @@ -430,9 +420,9 @@ await CategoryService.UpdateAsync(previous.Id, ModuleState.ModuleId, Duration = 3000, }); - // Clear selection and refresh + // Clear selection SelectedCategory = null; - await RefreshCategories(); + StateHasChanged(); } catch (Exception ex) { @@ -453,43 +443,24 @@ private async Task MoveDown() try { - // Get fresh data to find current siblings - var freshCategories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); - - // Find siblings with same parent - var siblings = freshCategories.Items? - .Where(c => c.ParentId == SelectedCategory.ParentId) - .OrderBy(c => c.ViewOrder) - .ThenBy(c => c.Name, StringComparer.Ordinal) - .ToList() ?? []; - - var currentIndex = siblings.FindIndex(c => c.Id == SelectedCategory.Id); + // Find siblings in the current tree structure + var siblings = GetSiblings(SelectedCategory); + var currentIndex = siblings.IndexOf(SelectedCategory); if (currentIndex < 0 || currentIndex >= siblings.Count - 1) return; - var current = siblings[currentIndex]; + var current = SelectedCategory; var next = siblings[currentIndex + 1]; - // Swap ViewOrder values - var tempViewOrder = current.ViewOrder; + // Update on server using dedicated move endpoint + await CategoryService.MoveDownAsync(current.Id, ModuleState.ModuleId); - // Update current category - await CategoryService.UpdateAsync(current.Id, ModuleState.ModuleId, - new CreateAndUpdateCategoryDto - { - Name = current.Name, - ViewOrder = next.ViewOrder, - ParentId = current.ParentId, - }); + // Swap ViewOrder values locally + (current.ViewOrder, next.ViewOrder) = (next.ViewOrder, current.ViewOrder); - // Update next sibling - await CategoryService.UpdateAsync(next.Id, ModuleState.ModuleId, - new CreateAndUpdateCategoryDto - { - Name = next.Name, - ViewOrder = tempViewOrder, - ParentId = next.ParentId, - }); + // Swap positions in the list + siblings[currentIndex] = next; + siblings[currentIndex + 1] = current; await logger.LogInformation("Category Moved Down {Id}", SelectedCategory.Id); @@ -501,9 +472,9 @@ await CategoryService.UpdateAsync(next.Id, ModuleState.ModuleId, Duration = 3000, }); - // Clear selection and refresh + // Clear selection SelectedCategory = null; - await RefreshCategories(); + StateHasChanged(); } catch (Exception ex) { @@ -601,8 +572,13 @@ private async Task ConfirmDeleteCategory() try { - await CategoryService.DeleteAsync(SelectedCategory.Id, ModuleState.ModuleId); - await logger.LogInformation("Category Deleted {Id}", SelectedCategory.Id); + var categoryToDelete = SelectedCategory; + await CategoryService.DeleteAsync(categoryToDelete.Id, ModuleState.ModuleId); + await logger.LogInformation("Category Deleted {Id}", categoryToDelete.Id); + + // Remove from parent's children in-place + var siblings = GetSiblings(categoryToDelete); + siblings.Remove(categoryToDelete); NotificationService.Notify(new Radzen.NotificationMessage { @@ -614,7 +590,7 @@ private async Task ConfirmDeleteCategory() SelectedCategory = null; ShowDeleteConfirmation = false; - await RefreshCategories(); + StateHasChanged(); } catch (Exception ex) { diff --git a/Client/Services/CategoryService.cs b/Client/Services/CategoryService.cs index aee4fdf..f3d2c2d 100644 --- a/Client/Services/CategoryService.cs +++ b/Client/Services/CategoryService.cs @@ -61,19 +61,37 @@ public CategoryService(HttpClient http, SiteState siteState) _httpClient = http; } + /// + /// Moves a category up in the sort order with automatic retry on transient failures. + /// + /// The category ID to move. + /// The module ID. + /// The updated category ID. public async Task MoveUpAsync(int id, int moduleId) { - var url = $"api/ictace/fileHub/categories/{id}/move-up?moduleId={moduleId}"; - var response = await _httpClient.PatchAsync(url, null).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync().ConfigureAwait(false); + return await HttpRetryHelper.ExecuteWithRetryAsync(async () => + { + var url = $"api/ictace/fileHub/categories/{id}/move-up?moduleId={moduleId}"; + var response = await _httpClient.PatchAsync(url, null).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync().ConfigureAwait(false); + }).ConfigureAwait(false); } + /// + /// Moves a category down in the sort order with automatic retry on transient failures. + /// + /// The category ID to move. + /// The module ID. + /// The updated category ID. public async Task MoveDownAsync(int id, int moduleId) { - var url = $"api/ictace/fileHub/categories/{id}/move-down?moduleId={moduleId}"; - var response = await _httpClient.PatchAsync(url, null).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync().ConfigureAwait(false); + return await HttpRetryHelper.ExecuteWithRetryAsync(async () => + { + var url = $"api/ictace/fileHub/categories/{id}/move-down?moduleId={moduleId}"; + var response = await _httpClient.PatchAsync(url, null).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync().ConfigureAwait(false); + }).ConfigureAwait(false); } } diff --git a/Client/Services/Common/HttpRetryHelper.cs b/Client/Services/Common/HttpRetryHelper.cs new file mode 100644 index 0000000..eb88bdc --- /dev/null +++ b/Client/Services/Common/HttpRetryHelper.cs @@ -0,0 +1,131 @@ +// Licensed to ICTAce under the MIT license. + +using System.Net; + +namespace ICTAce.FileHub.Services.Common; + +/// +/// Provides retry functionality for HTTP operations with exponential backoff. +/// +public static class HttpRetryHelper +{ + /// + /// Default maximum number of retry attempts. + /// + public const int DefaultMaxRetries = 3; + + /// + /// Default initial delay between retries in milliseconds. + /// + public const int DefaultInitialDelayMs = 500; + + /// + /// Executes an HTTP operation with retry logic using exponential backoff. + /// + /// The return type of the operation. + /// The async operation to execute. + /// Maximum number of retry attempts (default: 3). + /// Initial delay in milliseconds before first retry (default: 500ms). + /// Cancellation token to cancel the operation. + /// The result of the operation. + /// Thrown when all retry attempts fail. + public static async Task ExecuteWithRetryAsync( + Func> operation, + int maxRetries = DefaultMaxRetries, + int initialDelayMs = DefaultInitialDelayMs, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(operation); + + var lastException = default(Exception); + + for (var attempt = 0; attempt <= maxRetries; attempt++) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + return await operation().ConfigureAwait(false); + } + catch (HttpRequestException ex) when (IsTransientError(ex) && attempt < maxRetries) + { + lastException = ex; + var delay = CalculateDelay(attempt, initialDelayMs); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && attempt < maxRetries) + { + // Timeout - treat as transient + lastException = ex; + var delay = CalculateDelay(attempt, initialDelayMs); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + } + + throw new HttpRequestException( + $"Operation failed after {maxRetries + 1} attempts.", + lastException); + } + + /// + /// Executes an HTTP operation with retry logic using exponential backoff (void return). + /// + /// The async operation to execute. + /// Maximum number of retry attempts (default: 3). + /// Initial delay in milliseconds before first retry (default: 500ms). + /// Cancellation token to cancel the operation. + /// Thrown when all retry attempts fail. + public static async Task ExecuteWithRetryAsync( + Func operation, + int maxRetries = DefaultMaxRetries, + int initialDelayMs = DefaultInitialDelayMs, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(operation); + + await ExecuteWithRetryAsync( + async () => + { + await operation().ConfigureAwait(false); + return true; + }, + maxRetries, + initialDelayMs, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Determines if an HTTP exception represents a transient error that should be retried. + /// + private static bool IsTransientError(HttpRequestException ex) + { + // Retry on connection failures, timeouts, and server errors (5xx) + if (ex.StatusCode.HasValue) + { + var statusCode = (int)ex.StatusCode.Value; + return statusCode >= 500 || // Server errors + ex.StatusCode == HttpStatusCode.RequestTimeout || + ex.StatusCode == HttpStatusCode.TooManyRequests; + } + + // No status code means connection failure - retry + return true; + } + + /// + /// Calculates the delay for the next retry attempt using exponential backoff with jitter. + /// + private static TimeSpan CalculateDelay(int attempt, int initialDelayMs) + { + // Exponential backoff: initialDelay * 2^attempt + var exponentialDelay = initialDelayMs * Math.Pow(2, attempt); + + // Add jitter (±25%) to prevent thundering herd + var jitter = Random.Shared.NextDouble() * 0.5 + 0.75; // 0.75 to 1.25 + var delayWithJitter = exponentialDelay * jitter; + + // Cap at 30 seconds + var cappedDelay = Math.Min(delayWithJitter, 30000); + + return TimeSpan.FromMilliseconds(cappedDelay); + } +} diff --git a/Client/Services/Common/ModuleService.cs b/Client/Services/Common/ModuleService.cs index 6ca3271..1e1f7d2 100644 --- a/Client/Services/Common/ModuleService.cs +++ b/Client/Services/Common/ModuleService.cs @@ -3,7 +3,8 @@ namespace ICTAce.FileHub.Services.Common; /// -/// Generic service implementation for module-scoped CRUD operations +/// Generic service implementation for module-scoped CRUD operations. +/// All operations include automatic retry with exponential backoff for transient failures. /// /// DTO type for get operations /// DTO type for list operations @@ -16,33 +17,63 @@ public abstract class ModuleService( { private string Apiurl => CreateApiUrl(apiPath); + /// + /// Gets a single entity by ID with automatic retry on transient failures. + /// public virtual Task GetAsync(int id, int moduleId) { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return GetJsonAsync(url); + return HttpRetryHelper.ExecuteWithRetryAsync(() => + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return GetJsonAsync(url); + }); } + /// + /// Lists entities with pagination and automatic retry on transient failures. + /// public virtual Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10) { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}&pageNumber={pageNumber}&pageSize={pageSize}", EntityNames.Module, moduleId); - return GetJsonAsync>(url, new PagedResult()); + return HttpRetryHelper.ExecuteWithRetryAsync(() => + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}&pageNumber={pageNumber}&pageSize={pageSize}", EntityNames.Module, moduleId); + return GetJsonAsync>(url, new PagedResult()); + }); } + /// + /// Creates a new entity with automatic retry on transient failures. + /// public virtual Task CreateAsync(int moduleId, TCreateUpdateDto dto) { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PostJsonAsync(url, dto); + return HttpRetryHelper.ExecuteWithRetryAsync(() => + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}", EntityNames.Module, moduleId); + return PostJsonAsync(url, dto); + }); } + /// + /// Updates an existing entity with automatic retry on transient failures. + /// public virtual Task UpdateAsync(int id, int moduleId, TCreateUpdateDto dto) { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PutJsonAsync(url, dto); + return HttpRetryHelper.ExecuteWithRetryAsync(() => + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return PutJsonAsync(url, dto); + }); } + /// + /// Deletes an entity with automatic retry on transient failures. + /// public virtual Task DeleteAsync(int id, int moduleId) { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return DeleteAsync(url); + return HttpRetryHelper.ExecuteWithRetryAsync(() => + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return DeleteAsync(url); + }); } } From 6f963b53439270acbfd217e8841ec2c484f4dd4d Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Tue, 9 Dec 2025 02:24:32 +1100 Subject: [PATCH 16/22] Refactor category UI to use dialog-based interactions --- Client/Modules/FileHub/Category.razor | 42 +--------- Client/Modules/FileHub/Category.razor.cs | 100 +++++++++++------------ 2 files changed, 48 insertions(+), 94 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index a88bca7..2a3fdf8 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -5,6 +5,7 @@ + @if (IsLoading) { @@ -63,45 +64,6 @@ else
- - @if (ShowEditDialog) - { - -

@DialogTitle

- - - - - - @if (!IsAddingNew) - { - - - - } - - - - - - -
- } - - @if (ShowDeleteConfirmation) - { - - - - Are you sure you want to delete the category "@SelectedCategory?.Name"? - - - - - - - - - } + } diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index 2a122e6..1f44578 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -1,5 +1,7 @@ // Licensed to ICTAce under the MIT license. +using Oqtane.Modules.Controls; + namespace ICTAce.FileHub; public partial class Category : ModuleBase @@ -13,6 +15,9 @@ public partial class Category : ModuleBase [Inject] private Radzen.ContextMenuService ContextMenuService { get; set; } = default!; + [Inject] + private Radzen.DialogService DialogService { get; set; } = default!; + private List _treeData = []; private ListCategoryDto _rootNode = new() { Name = "All Categories" }; @@ -21,7 +26,6 @@ public partial class Category : ModuleBase protected bool IsLoading { get; set; } protected ListCategoryDto? SelectedCategory { get; set; } protected CreateAndUpdateCategoryDto EditModel { get; set; } = new(); - protected bool ShowDeleteConfirmation { get; set; } protected bool ShowEditDialog { get; set; } protected bool IsAddingNew { get; set; } protected string DialogTitle { get; set; } = string.Empty; @@ -161,6 +165,11 @@ private List GetSiblings(ListCategoryDto category) } var parent = FindCategoryById([_rootNode], category.ParentId); + if (parent?.Children is List childrenList) + { + return childrenList; + } + return parent?.Children.ToList() ?? []; } @@ -349,42 +358,6 @@ private async Task HandleKeyPress(KeyboardEventArgs e) } } - private void AddChildCategory() - { - IsAddingNew = true; - ShowEditDialog = true; - ShowDeleteConfirmation = false; - DialogTitle = $"Add Child Category to '{SelectedCategory?.Name}'"; - - EditModel = new CreateAndUpdateCategoryDto - { - Name = string.Empty, - ViewOrder = 0, - ParentId = SelectedCategory?.Id ?? 0, - }; - - StateHasChanged(); - } - - private void EditCategory() - { - if (SelectedCategory == null) return; - - IsAddingNew = false; - ShowEditDialog = true; - ShowDeleteConfirmation = false; - DialogTitle = $"Edit Category '{SelectedCategory.Name}'"; - - EditModel = new CreateAndUpdateCategoryDto - { - Name = SelectedCategory.Name, - ViewOrder = SelectedCategory.ViewOrder, - ParentId = SelectedCategory.ParentId, - }; - - StateHasChanged(); - } - private async Task MoveUp() { if (SelectedCategory == null) return; @@ -534,7 +507,6 @@ private async Task SaveCategory() }); } - ShowEditDialog = false; IsAddingNew = false; SelectedCategory = null; await RefreshCategories(); @@ -554,31 +526,58 @@ private async Task SaveCategory() private void CancelEdit() { - ShowEditDialog = false; IsAddingNew = false; SelectedCategory = null; } - private void PromptDeleteCategory() + private async Task PromptDeleteCategory() { - ShowDeleteConfirmation = true; - ShowEditDialog = false; - StateHasChanged(); + if (SelectedCategory == null) return; + + var confirmed = await DialogService.Confirm( + $"Are you sure you want to delete the category \"{SelectedCategory.Name}\"?", + "Delete Category", + new Radzen.ConfirmOptions + { + OkButtonText = "Yes, Delete", + CancelButtonText = "Cancel", + AutoFocusFirstElement = true + }); + + if (confirmed == true) + { + await DeleteCategory(); + } + else + { + SelectedCategory = null; + } } - private async Task ConfirmDeleteCategory() + private async Task DeleteCategory() { if (SelectedCategory == null) return; try { var categoryToDelete = SelectedCategory; + await CategoryService.DeleteAsync(categoryToDelete.Id, ModuleState.ModuleId); await logger.LogInformation("Category Deleted {Id}", categoryToDelete.Id); - // Remove from parent's children in-place - var siblings = GetSiblings(categoryToDelete); - siblings.Remove(categoryToDelete); + if (categoryToDelete.ParentId == 0) + { + _treeData.Remove(categoryToDelete); + _rootNode.Children.Remove(categoryToDelete); + } + else + { + var parent = FindCategoryById([_rootNode], categoryToDelete.ParentId); + if (parent != null) + { + parent.Children.Remove(categoryToDelete); + } + } NotificationService.Notify(new Radzen.NotificationMessage { @@ -589,7 +588,6 @@ private async Task ConfirmDeleteCategory() }); SelectedCategory = null; - ShowDeleteConfirmation = false; StateHasChanged(); } catch (Exception ex) @@ -605,12 +603,6 @@ private async Task ConfirmDeleteCategory() } } - private void CancelDelete() - { - ShowDeleteConfirmation = false; - SelectedCategory = null; - } - private async Task RefreshCategories() { IsLoading = true; From 0142a9a605e9898debf75d804400c0c0c32ab369 Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Mon, 15 Dec 2025 02:29:32 +1100 Subject: [PATCH 17/22] Add unit tests and mock service for Category feature --- Client.Tests/BaseTest.cs | 1 + Client.Tests/Mocks/MockCategoryService.cs | 205 +++++++ Client.Tests/Modules/FileHub/CategoryTests.cs | 501 ++++++++++++++++++ 3 files changed, 707 insertions(+) create mode 100644 Client.Tests/Mocks/MockCategoryService.cs create mode 100644 Client.Tests/Modules/FileHub/CategoryTests.cs diff --git a/Client.Tests/BaseTest.cs b/Client.Tests/BaseTest.cs index 9e939ad..585787b 100644 --- a/Client.Tests/BaseTest.cs +++ b/Client.Tests/BaseTest.cs @@ -47,6 +47,7 @@ protected BaseTest() TestContext.Services.AddScoped(); TestContext.Services.AddScoped(); + TestContext.Services.AddScoped(); TestContext.Services.AddScoped(); TestContext.Services.AddScoped(); TestContext.Services.AddScoped(); diff --git a/Client.Tests/Mocks/MockCategoryService.cs b/Client.Tests/Mocks/MockCategoryService.cs new file mode 100644 index 0000000..3f5aecc --- /dev/null +++ b/Client.Tests/Mocks/MockCategoryService.cs @@ -0,0 +1,205 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Client.Tests.Mocks; + +public class MockCategoryService : ICategoryService +{ + private readonly List _categories = []; + private int _nextId = 1; + + public MockCategoryService() + { + _categories.Add(new GetCategoryDto + { + Id = 1, + ModuleId = 1, + Name = "Test Category 1", + ViewOrder = 0, + ParentId = 0, + CreatedBy = "Test User", + CreatedOn = DateTime.Now.AddDays(-10), + ModifiedBy = "Test User", + ModifiedOn = DateTime.Now.AddDays(-5) + }); + + _categories.Add(new GetCategoryDto + { + Id = 2, + ModuleId = 1, + Name = "Test Category 2", + ViewOrder = 1, + ParentId = 0, + CreatedBy = "Test User", + CreatedOn = DateTime.Now.AddDays(-8), + ModifiedBy = "Test User", + ModifiedOn = DateTime.Now.AddDays(-3) + }); + + _categories.Add(new GetCategoryDto + { + Id = 3, + ModuleId = 1, + Name = "Test Category 1.1", + ViewOrder = 0, + ParentId = 1, + CreatedBy = "Test User", + CreatedOn = DateTime.Now.AddDays(-7), + ModifiedBy = "Test User", + ModifiedOn = DateTime.Now.AddDays(-2) + }); + + _nextId = 4; + } + + public Task GetAsync(int id, int moduleId) + { + var category = _categories.FirstOrDefault(c => c.Id == id && c.ModuleId == moduleId); + if (category == null) + { + throw new InvalidOperationException($"Category with Id {id} and ModuleId {moduleId} not found"); + } + return Task.FromResult(category); + } + + public Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10) + { + var items = _categories + .Where(c => c.ModuleId == moduleId) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .Select(c => new ListCategoryDto + { + Id = c.Id, + Name = c.Name, + ViewOrder = c.ViewOrder, + ParentId = c.ParentId, + Children = [] + }) + .ToList(); + + var totalCount = _categories.Count(c => c.ModuleId == moduleId); + + var pagedResult = new PagedResult + { + Items = items, + TotalCount = totalCount, + PageNumber = pageNumber, + PageSize = pageSize + }; + + return Task.FromResult(pagedResult); + } + + public Task CreateAsync(int moduleId, CreateAndUpdateCategoryDto dto) + { + var newCategory = new GetCategoryDto + { + Id = _nextId++, + ModuleId = moduleId, + Name = dto.Name, + ViewOrder = dto.ViewOrder, + ParentId = dto.ParentId, + CreatedBy = "Test User", + CreatedOn = DateTime.Now, + ModifiedBy = "Test User", + ModifiedOn = DateTime.Now + }; + + _categories.Add(newCategory); + return Task.FromResult(newCategory.Id); + } + + public Task UpdateAsync(int id, int moduleId, CreateAndUpdateCategoryDto dto) + { + var category = _categories.FirstOrDefault(c => c.Id == id && c.ModuleId == moduleId); + if (category == null) + { + throw new InvalidOperationException($"Category with Id {id} and ModuleId {moduleId} not found"); + } + + category.Name = dto.Name; + category.ViewOrder = dto.ViewOrder; + category.ParentId = dto.ParentId; + category.ModifiedBy = "Test User"; + category.ModifiedOn = DateTime.Now; + + return Task.FromResult(category.Id); + } + + public Task DeleteAsync(int id, int moduleId) + { + var category = _categories.FirstOrDefault(c => c.Id == id && c.ModuleId == moduleId); + if (category != null) + { + _categories.Remove(category); + } + return Task.CompletedTask; + } + + public Task MoveUpAsync(int id, int moduleId) + { + var category = _categories.FirstOrDefault(c => c.Id == id && c.ModuleId == moduleId); + if (category == null) + { + return Task.FromResult(-1); + } + + var siblings = _categories + .Where(c => c.ModuleId == moduleId && c.ParentId == category.ParentId) + .OrderBy(c => c.ViewOrder) + .ToList(); + + var currentIndex = siblings.IndexOf(category); + if (currentIndex > 0) + { + var previous = siblings[currentIndex - 1]; + (category.ViewOrder, previous.ViewOrder) = (previous.ViewOrder, category.ViewOrder); + } + + return Task.FromResult(category.Id); + } + + public Task MoveDownAsync(int id, int moduleId) + { + var category = _categories.FirstOrDefault(c => c.Id == id && c.ModuleId == moduleId); + if (category == null) + { + return Task.FromResult(-1); + } + + var siblings = _categories + .Where(c => c.ModuleId == moduleId && c.ParentId == category.ParentId) + .OrderBy(c => c.ViewOrder) + .ToList(); + + var currentIndex = siblings.IndexOf(category); + if (currentIndex >= 0 && currentIndex < siblings.Count - 1) + { + var next = siblings[currentIndex + 1]; + (category.ViewOrder, next.ViewOrder) = (next.ViewOrder, category.ViewOrder); + } + + return Task.FromResult(category.Id); + } + + public void ClearData() + { + _categories.Clear(); + _nextId = 1; + } + + public void AddTestData(GetCategoryDto category) + { + _categories.Add(category); + } + + public int GetCategoryCount() + { + return _categories.Count; + } + + public List GetAllCategories() + { + return _categories; + } +} diff --git a/Client.Tests/Modules/FileHub/CategoryTests.cs b/Client.Tests/Modules/FileHub/CategoryTests.cs new file mode 100644 index 0000000..1067eda --- /dev/null +++ b/Client.Tests/Modules/FileHub/CategoryTests.cs @@ -0,0 +1,501 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Client.Tests.Modules.FileHub; + +public class CategoryTests : BaseTest +{ + private readonly MockNavigationManager? _mockNavigationManager; + private readonly MockCategoryService? _mockCategoryService; + + public CategoryTests() + { + _mockNavigationManager = TestContext.Services.GetRequiredService() as MockNavigationManager; + _mockCategoryService = TestContext.Services.GetRequiredService() as MockCategoryService; + TestContext.JSInterop.Setup("Oqtane.Interop.formValid", _ => true).SetResult(true); + } + + #region Service Dependency Tests + + [Test] + public async Task CategoryComponent_ServiceDependencies_AreConfigured() + { + await Assert.That(_mockCategoryService).IsNotNull(); + await Assert.That(_mockNavigationManager).IsNotNull(); + + var logService = TestContext.Services.GetService(); + await Assert.That(logService).IsNotNull(); + } + + #endregion + + #region Service Layer Tests - CRUD Operations + + [Test] + public async Task ServiceLayer_CreateAsync_AddsNewCategory() + { + var initialCount = _mockCategoryService!.GetCategoryCount(); + + var dto = new CreateAndUpdateCategoryDto + { + Name = "New Test Category", + ViewOrder = 2, + ParentId = 0 + }; + + var newId = await _mockCategoryService.CreateAsync(1, dto); + + await Assert.That(newId).IsGreaterThan(0); + await Assert.That(_mockCategoryService.GetCategoryCount()).IsEqualTo(initialCount + 1); + + var created = await _mockCategoryService.GetAsync(newId, 1); + await Assert.That(created.Name).IsEqualTo("New Test Category"); + await Assert.That(created.ViewOrder).IsEqualTo(2); + await Assert.That(created.ParentId).IsEqualTo(0); + } + + [Test] + public async Task ServiceLayer_CreateAsync_AddsChildCategory() + { + var initialCount = _mockCategoryService!.GetCategoryCount(); + + var dto = new CreateAndUpdateCategoryDto + { + Name = "New Child Category", + ViewOrder = 1, + ParentId = 1 + }; + + var newId = await _mockCategoryService.CreateAsync(1, dto); + + await Assert.That(newId).IsGreaterThan(0); + await Assert.That(_mockCategoryService.GetCategoryCount()).IsEqualTo(initialCount + 1); + + var created = await _mockCategoryService.GetAsync(newId, 1); + await Assert.That(created.Name).IsEqualTo("New Child Category"); + await Assert.That(created.ParentId).IsEqualTo(1); + } + + [Test] + public async Task ServiceLayer_UpdateAsync_ModifiesExistingCategory() + { + var dto = new CreateAndUpdateCategoryDto + { + Name = "Updated Category Name", + ViewOrder = 5, + ParentId = 0 + }; + + await _mockCategoryService!.UpdateAsync(1, 1, dto); + + var updated = await _mockCategoryService.GetAsync(1, 1); + await Assert.That(updated.Name).IsEqualTo("Updated Category Name"); + await Assert.That(updated.ViewOrder).IsEqualTo(5); + await Assert.That(updated.Id).IsEqualTo(1); + } + + [Test] + public async Task ServiceLayer_GetAsync_ReturnsCorrectCategory() + { + var category1 = await _mockCategoryService!.GetAsync(1, 1); + var category2 = await _mockCategoryService.GetAsync(2, 1); + + await Assert.That(category1.Id).IsEqualTo(1); + await Assert.That(category1.Name).IsEqualTo("Test Category 1"); + await Assert.That(category1.ParentId).IsEqualTo(0); + + await Assert.That(category2.Id).IsEqualTo(2); + await Assert.That(category2.Name).IsEqualTo("Test Category 2"); + await Assert.That(category2.ParentId).IsEqualTo(0); + } + + [Test] + public async Task ServiceLayer_GetAsync_ReturnsChildCategory() + { + var category = await _mockCategoryService!.GetAsync(3, 1); + + await Assert.That(category.Id).IsEqualTo(3); + await Assert.That(category.Name).IsEqualTo("Test Category 1.1"); + await Assert.That(category.ParentId).IsEqualTo(1); + } + + [Test] + public async Task ServiceLayer_ListAsync_ReturnsCategories() + { + var result = await _mockCategoryService!.ListAsync(1, pageNumber: 1, pageSize: 10); + + await Assert.That(result).IsNotNull(); + await Assert.That(result.Items).IsNotNull(); + await Assert.That(result.Items.Count).IsGreaterThan(0); + await Assert.That(result.TotalCount).IsEqualTo(3); + } + + [Test] + public async Task ServiceLayer_ListAsync_SupportsPagination() + { + var page1 = await _mockCategoryService!.ListAsync(1, pageNumber: 1, pageSize: 2); + var page2 = await _mockCategoryService.ListAsync(1, pageNumber: 2, pageSize: 2); + + await Assert.That(page1.Items.Count).IsEqualTo(2); + await Assert.That(page1.PageNumber).IsEqualTo(1); + await Assert.That(page2.PageNumber).IsEqualTo(2); + await Assert.That(page1.TotalCount).IsEqualTo(3); + } + + [Test] + public async Task ServiceLayer_DeleteAsync_RemovesCategory() + { + var initialCount = _mockCategoryService!.GetCategoryCount(); + + await _mockCategoryService.DeleteAsync(2, 1); + + await Assert.That(_mockCategoryService.GetCategoryCount()).IsEqualTo(initialCount - 1); + } + + #endregion + + #region Service Layer Tests - Move Operations + + [Test] + public async Task ServiceLayer_MoveUpAsync_MovesCategory() + { + var category2 = await _mockCategoryService!.GetAsync(2, 1); + var originalViewOrder = category2.ViewOrder; + + var result = await _mockCategoryService.MoveUpAsync(2, 1); + + await Assert.That(result).IsEqualTo(2); + + var updated = await _mockCategoryService.GetAsync(2, 1); + await Assert.That(updated.ViewOrder).IsLessThan(originalViewOrder); + } + + [Test] + public async Task ServiceLayer_MoveUpAsync_ReturnsSameIdWhenCannotMoveUp() + { + var result = await _mockCategoryService!.MoveUpAsync(1, 1); + await Assert.That(result).IsEqualTo(1); + } + + [Test] + public async Task ServiceLayer_MoveDownAsync_MovesCategory() + { + var category1 = await _mockCategoryService!.GetAsync(1, 1); + var originalViewOrder = category1.ViewOrder; + + var result = await _mockCategoryService.MoveDownAsync(1, 1); + + await Assert.That(result).IsEqualTo(1); + + var updated = await _mockCategoryService.GetAsync(1, 1); + await Assert.That(updated.ViewOrder).IsGreaterThan(originalViewOrder); + } + + [Test] + public async Task ServiceLayer_MoveDownAsync_ReturnsSameIdWhenCannotMoveDown() + { + var result = await _mockCategoryService!.MoveDownAsync(2, 1); + await Assert.That(result).IsEqualTo(2); + } + + [Test] + public async Task ServiceLayer_MoveAsync_ReturnsMinusOneForNonExistentCategory() + { + var result = await _mockCategoryService!.MoveUpAsync(999, 1); + await Assert.That(result).IsEqualTo(-1); + + result = await _mockCategoryService.MoveDownAsync(999, 1); + await Assert.That(result).IsEqualTo(-1); + } + + #endregion + + #region State Management Tests + + [Test] + public async Task ModuleState_ForCategoryComponent_HasRequiredProperties() + { + var moduleState = CreateModuleState(1, 1, "Test Module"); + + await Assert.That(moduleState.ModuleId).IsEqualTo(1); + await Assert.That(moduleState.PageId).IsEqualTo(1); + await Assert.That(moduleState.ModuleDefinition).IsNotNull(); + await Assert.That(moduleState.PermissionList).IsNotNull(); + await Assert.That(moduleState.PermissionList.Any(p => p.PermissionName == "Edit")).IsTrue(); + } + + [Test] + public async Task PageState_ForCategoryComponent_IsConfigured() + { + var pageState = CreatePageState("Index"); + + await Assert.That(pageState.Action).IsEqualTo("Index"); + await Assert.That(pageState.QueryString).IsNotNull(); + await Assert.That(pageState.Page).IsNotNull(); + await Assert.That(pageState.Alias).IsNotNull(); + await Assert.That(pageState.Site).IsNotNull(); + } + + #endregion + + #region Form Validation Tests + + [Test] + public async Task FormValidation_ValidData_Passes() + { + var dto = new CreateAndUpdateCategoryDto + { + Name = "Valid Category Name", + ViewOrder = 0, + ParentId = 0 + }; + + var validationResults = new List(); + var context = new System.ComponentModel.DataAnnotations.ValidationContext(dto); + var isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateObject( + dto, context, validationResults, validateAllProperties: true); + + await Assert.That(isValid).IsTrue(); + await Assert.That(validationResults.Count).IsEqualTo(0); + } + + [Test] + public async Task FormValidation_EmptyName_Fails() + { + var dto = new CreateAndUpdateCategoryDto + { + Name = string.Empty, + ViewOrder = 0, + ParentId = 0 + }; + + var validationResults = new List(); + var context = new System.ComponentModel.DataAnnotations.ValidationContext(dto); + var isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateObject( + dto, context, validationResults, validateAllProperties: true); + + await Assert.That(isValid).IsFalse(); + await Assert.That(validationResults.Count).IsGreaterThan(0); + await Assert.That(validationResults.Any(v => v.MemberNames.Contains("Name"))).IsTrue(); + } + + [Test] + public async Task FormValidation_NameTooLong_Fails() + { + var dto = new CreateAndUpdateCategoryDto + { + Name = new string('A', 101), + ViewOrder = 0, + ParentId = 0 + }; + + var validationResults = new List(); + var context = new System.ComponentModel.DataAnnotations.ValidationContext(dto); + var isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateObject( + dto, context, validationResults, validateAllProperties: true); + + await Assert.That(isValid).IsFalse(); + await Assert.That(validationResults.Any(v => v.ErrorMessage?.Contains("100") == true)).IsTrue(); + } + + [Test] + public async Task FormValidation_NegativeViewOrder_Fails() + { + var dto = new CreateAndUpdateCategoryDto + { + Name = "Valid Name", + ViewOrder = -1, + ParentId = 0 + }; + + var validationResults = new List(); + var context = new System.ComponentModel.DataAnnotations.ValidationContext(dto); + var isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateObject( + dto, context, validationResults, validateAllProperties: true); + + await Assert.That(isValid).IsFalse(); + await Assert.That(validationResults.Any(v => v.MemberNames.Contains("ViewOrder"))).IsTrue(); + } + + [Test] + public async Task FormValidation_NegativeParentId_Fails() + { + var dto = new CreateAndUpdateCategoryDto + { + Name = "Valid Name", + ViewOrder = 0, + ParentId = -1 + }; + + var validationResults = new List(); + var context = new System.ComponentModel.DataAnnotations.ValidationContext(dto); + var isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateObject( + dto, context, validationResults, validateAllProperties: true); + + await Assert.That(isValid).IsFalse(); + await Assert.That(validationResults.Any(v => v.MemberNames.Contains("ParentId"))).IsTrue(); + } + + [Test] + public async Task FormValidation_ValidChildCategory_Passes() + { + var dto = new CreateAndUpdateCategoryDto + { + Name = "Valid Child Category", + ViewOrder = 1, + ParentId = 1 + }; + + var validationResults = new List(); + var context = new System.ComponentModel.DataAnnotations.ValidationContext(dto); + var isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateObject( + dto, context, validationResults, validateAllProperties: true); + + await Assert.That(isValid).IsTrue(); + await Assert.That(validationResults.Count).IsEqualTo(0); + } + + #endregion + + #region Navigation Tests + + [Test] + public async Task NavigationManager_Reset_ClearsHistory() + { + _mockNavigationManager!.Reset(); + + await Assert.That(_mockNavigationManager.Uri).IsEqualTo("https://localhost:5001/"); + await Assert.That(_mockNavigationManager.BaseUri).IsEqualTo("https://localhost:5001/"); + } + + #endregion + + #region Mock Service Helper Tests + + [Test] + public async Task MockService_HasTestData() + { + var count = _mockCategoryService!.GetCategoryCount(); + await Assert.That(count).IsGreaterThan(0); + await Assert.That(count).IsEqualTo(3); + } + + [Test] + public async Task MockService_GetAllCategories_ReturnsAllData() + { + var categories = _mockCategoryService!.GetAllCategories(); + + await Assert.That(categories).IsNotNull(); + await Assert.That(categories.Count).IsEqualTo(3); + await Assert.That(categories.Any(c => c.ParentId == 0)).IsTrue(); + await Assert.That(categories.Any(c => c.ParentId == 1)).IsTrue(); + } + + [Test] + public async Task MockService_ClearData_RemovesAllCategories() + { + _mockCategoryService!.ClearData(); + + await Assert.That(_mockCategoryService.GetCategoryCount()).IsEqualTo(0); + } + + [Test] + public async Task MockService_AddTestData_IncreasesCount() + { + var initialCount = _mockCategoryService!.GetCategoryCount(); + + _mockCategoryService.AddTestData(new GetCategoryDto + { + Id = 99, + ModuleId = 1, + Name = "Manually Added Category", + ViewOrder = 99, + ParentId = 0, + CreatedBy = "Test", + CreatedOn = DateTime.Now, + ModifiedBy = "Test", + ModifiedOn = DateTime.Now + }); + + await Assert.That(_mockCategoryService.GetCategoryCount()).IsEqualTo(initialCount + 1); + } + + #endregion + + #region Error Handling Tests + + [Test] + public async Task ServiceLayer_GetAsync_ThrowsForNonExistentCategory() + { + await Assert.That(async () => await _mockCategoryService!.GetAsync(999, 1)) + .ThrowsException(); + } + + [Test] + public async Task ServiceLayer_UpdateAsync_ThrowsForNonExistentCategory() + { + var dto = new CreateAndUpdateCategoryDto + { + Name = "Updated Name", + ViewOrder = 0, + ParentId = 0 + }; + + await Assert.That(async () => await _mockCategoryService!.UpdateAsync(999, 1, dto)) + .ThrowsException(); + } + + #endregion + + #region Tree Structure Tests + + [Test] + public async Task ServiceLayer_ListAsync_ReturnsHierarchicalStructure() + { + var result = await _mockCategoryService!.ListAsync(1, pageNumber: 1, pageSize: 10); + + await Assert.That(result.Items).IsNotNull(); + await Assert.That(result.Items.Any(c => c.ParentId == 0)).IsTrue(); + await Assert.That(result.Items.Any(c => c.ParentId == 1)).IsTrue(); + } + + [Test] + public async Task ServiceLayer_CreateAsync_SupportsMultipleLevels() + { + // Create level 2 category + var dto = new CreateAndUpdateCategoryDto + { + Name = "Level 2 Category", + ViewOrder = 0, + ParentId = 3 + }; + + var newId = await _mockCategoryService!.CreateAsync(1, dto); + var created = await _mockCategoryService.GetAsync(newId, 1); + + await Assert.That(created.ParentId).IsEqualTo(3); + await Assert.That(created.Name).IsEqualTo("Level 2 Category"); + } + + [Test] + public async Task ServiceLayer_MoveOperations_RespectParentId() + { + // Get the initial ViewOrder values for categories 1 and 2 (both have ParentId 0) + var cat1Before = await _mockCategoryService!.GetAsync(1, 1); + var cat2Before = await _mockCategoryService.GetAsync(2, 1); + + var cat1InitialViewOrder = cat1Before.ViewOrder; + var cat2InitialViewOrder = cat2Before.ViewOrder; + + // Move category 1 down + await _mockCategoryService.MoveDownAsync(1, 1); + + var cat1After = await _mockCategoryService.GetAsync(1, 1); + var cat2After = await _mockCategoryService.GetAsync(2, 1); + + // Verify that the ViewOrder values have been swapped + await Assert.That(cat1After.ViewOrder).IsEqualTo(cat2InitialViewOrder); + await Assert.That(cat2After.ViewOrder).IsEqualTo(cat1InitialViewOrder); + } + + #endregion +} From 0b645a990193a2eb2ed8f17ab40125817030efe7 Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Mon, 15 Dec 2025 23:30:36 +1100 Subject: [PATCH 18/22] remove retry --- Client/Services/CategoryService.cs | 26 ++--- Client/Services/Common/HttpRetryHelper.cs | 131 ---------------------- Client/Services/Common/ModuleService.cs | 46 +++----- 3 files changed, 25 insertions(+), 178 deletions(-) delete mode 100644 Client/Services/Common/HttpRetryHelper.cs diff --git a/Client/Services/CategoryService.cs b/Client/Services/CategoryService.cs index f3d2c2d..f8fcee6 100644 --- a/Client/Services/CategoryService.cs +++ b/Client/Services/CategoryService.cs @@ -62,36 +62,30 @@ public CategoryService(HttpClient http, SiteState siteState) } /// - /// Moves a category up in the sort order with automatic retry on transient failures. + /// Moves a category up in the sort order. /// /// The category ID to move. /// The module ID. /// The updated category ID. public async Task MoveUpAsync(int id, int moduleId) { - return await HttpRetryHelper.ExecuteWithRetryAsync(async () => - { - var url = $"api/ictace/fileHub/categories/{id}/move-up?moduleId={moduleId}"; - var response = await _httpClient.PatchAsync(url, null).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync().ConfigureAwait(false); - }).ConfigureAwait(false); + var url = $"api/ictace/fileHub/categories/{id}/move-up?moduleId={moduleId}"; + var response = await _httpClient.PatchAsync(url, null).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync().ConfigureAwait(false); } /// - /// Moves a category down in the sort order with automatic retry on transient failures. + /// Moves a category down in the sort order. /// /// The category ID to move. /// The module ID. /// The updated category ID. public async Task MoveDownAsync(int id, int moduleId) { - return await HttpRetryHelper.ExecuteWithRetryAsync(async () => - { - var url = $"api/ictace/fileHub/categories/{id}/move-down?moduleId={moduleId}"; - var response = await _httpClient.PatchAsync(url, null).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync().ConfigureAwait(false); - }).ConfigureAwait(false); + var url = $"api/ictace/fileHub/categories/{id}/move-down?moduleId={moduleId}"; + var response = await _httpClient.PatchAsync(url, null).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync().ConfigureAwait(false); } } diff --git a/Client/Services/Common/HttpRetryHelper.cs b/Client/Services/Common/HttpRetryHelper.cs deleted file mode 100644 index eb88bdc..0000000 --- a/Client/Services/Common/HttpRetryHelper.cs +++ /dev/null @@ -1,131 +0,0 @@ -// Licensed to ICTAce under the MIT license. - -using System.Net; - -namespace ICTAce.FileHub.Services.Common; - -/// -/// Provides retry functionality for HTTP operations with exponential backoff. -/// -public static class HttpRetryHelper -{ - /// - /// Default maximum number of retry attempts. - /// - public const int DefaultMaxRetries = 3; - - /// - /// Default initial delay between retries in milliseconds. - /// - public const int DefaultInitialDelayMs = 500; - - /// - /// Executes an HTTP operation with retry logic using exponential backoff. - /// - /// The return type of the operation. - /// The async operation to execute. - /// Maximum number of retry attempts (default: 3). - /// Initial delay in milliseconds before first retry (default: 500ms). - /// Cancellation token to cancel the operation. - /// The result of the operation. - /// Thrown when all retry attempts fail. - public static async Task ExecuteWithRetryAsync( - Func> operation, - int maxRetries = DefaultMaxRetries, - int initialDelayMs = DefaultInitialDelayMs, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(operation); - - var lastException = default(Exception); - - for (var attempt = 0; attempt <= maxRetries; attempt++) - { - try - { - cancellationToken.ThrowIfCancellationRequested(); - return await operation().ConfigureAwait(false); - } - catch (HttpRequestException ex) when (IsTransientError(ex) && attempt < maxRetries) - { - lastException = ex; - var delay = CalculateDelay(attempt, initialDelayMs); - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - } - catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && attempt < maxRetries) - { - // Timeout - treat as transient - lastException = ex; - var delay = CalculateDelay(attempt, initialDelayMs); - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - } - } - - throw new HttpRequestException( - $"Operation failed after {maxRetries + 1} attempts.", - lastException); - } - - /// - /// Executes an HTTP operation with retry logic using exponential backoff (void return). - /// - /// The async operation to execute. - /// Maximum number of retry attempts (default: 3). - /// Initial delay in milliseconds before first retry (default: 500ms). - /// Cancellation token to cancel the operation. - /// Thrown when all retry attempts fail. - public static async Task ExecuteWithRetryAsync( - Func operation, - int maxRetries = DefaultMaxRetries, - int initialDelayMs = DefaultInitialDelayMs, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(operation); - - await ExecuteWithRetryAsync( - async () => - { - await operation().ConfigureAwait(false); - return true; - }, - maxRetries, - initialDelayMs, - cancellationToken).ConfigureAwait(false); - } - - /// - /// Determines if an HTTP exception represents a transient error that should be retried. - /// - private static bool IsTransientError(HttpRequestException ex) - { - // Retry on connection failures, timeouts, and server errors (5xx) - if (ex.StatusCode.HasValue) - { - var statusCode = (int)ex.StatusCode.Value; - return statusCode >= 500 || // Server errors - ex.StatusCode == HttpStatusCode.RequestTimeout || - ex.StatusCode == HttpStatusCode.TooManyRequests; - } - - // No status code means connection failure - retry - return true; - } - - /// - /// Calculates the delay for the next retry attempt using exponential backoff with jitter. - /// - private static TimeSpan CalculateDelay(int attempt, int initialDelayMs) - { - // Exponential backoff: initialDelay * 2^attempt - var exponentialDelay = initialDelayMs * Math.Pow(2, attempt); - - // Add jitter (±25%) to prevent thundering herd - var jitter = Random.Shared.NextDouble() * 0.5 + 0.75; // 0.75 to 1.25 - var delayWithJitter = exponentialDelay * jitter; - - // Cap at 30 seconds - var cappedDelay = Math.Min(delayWithJitter, 30000); - - return TimeSpan.FromMilliseconds(cappedDelay); - } -} diff --git a/Client/Services/Common/ModuleService.cs b/Client/Services/Common/ModuleService.cs index 1e1f7d2..f1790ac 100644 --- a/Client/Services/Common/ModuleService.cs +++ b/Client/Services/Common/ModuleService.cs @@ -4,7 +4,6 @@ namespace ICTAce.FileHub.Services.Common; /// /// Generic service implementation for module-scoped CRUD operations. -/// All operations include automatic retry with exponential backoff for transient failures. /// /// DTO type for get operations /// DTO type for list operations @@ -18,62 +17,47 @@ public abstract class ModuleService( private string Apiurl => CreateApiUrl(apiPath); /// - /// Gets a single entity by ID with automatic retry on transient failures. + /// Gets a single entity by ID. /// public virtual Task GetAsync(int id, int moduleId) { - return HttpRetryHelper.ExecuteWithRetryAsync(() => - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return GetJsonAsync(url); - }); + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return GetJsonAsync(url); } /// - /// Lists entities with pagination and automatic retry on transient failures. + /// Lists entities with pagination. /// public virtual Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10) { - return HttpRetryHelper.ExecuteWithRetryAsync(() => - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}&pageNumber={pageNumber}&pageSize={pageSize}", EntityNames.Module, moduleId); - return GetJsonAsync>(url, new PagedResult()); - }); + var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}&pageNumber={pageNumber}&pageSize={pageSize}", EntityNames.Module, moduleId); + return GetJsonAsync>(url, new PagedResult()); } /// - /// Creates a new entity with automatic retry on transient failures. + /// Creates a new entity. /// public virtual Task CreateAsync(int moduleId, TCreateUpdateDto dto) { - return HttpRetryHelper.ExecuteWithRetryAsync(() => - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PostJsonAsync(url, dto); - }); + var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}", EntityNames.Module, moduleId); + return PostJsonAsync(url, dto); } /// - /// Updates an existing entity with automatic retry on transient failures. + /// Updates an existing entity. /// public virtual Task UpdateAsync(int id, int moduleId, TCreateUpdateDto dto) { - return HttpRetryHelper.ExecuteWithRetryAsync(() => - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PutJsonAsync(url, dto); - }); + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return PutJsonAsync(url, dto); } /// - /// Deletes an entity with automatic retry on transient failures. + /// Deletes an entity. /// public virtual Task DeleteAsync(int id, int moduleId) { - return HttpRetryHelper.ExecuteWithRetryAsync(() => - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return DeleteAsync(url); - }); + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return DeleteAsync(url); } } From 764369578cb1d9a8b309be94faa27fc93971c08f Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Mon, 15 Dec 2025 23:48:19 +1100 Subject: [PATCH 19/22] rename root --- Client/Modules/FileHub/Category.razor.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index 1f44578..76ec46e 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -19,7 +19,7 @@ public partial class Category : ModuleBase private Radzen.DialogService DialogService { get; set; } = default!; private List _treeData = []; - private ListCategoryDto _rootNode = new() { Name = "All Categories" }; + private ListCategoryDto _rootNode = new() { Name = "" }; protected PagedResult Categories { get; set; } = new(); protected string? ErrorMessage { get; set; } @@ -632,7 +632,7 @@ private void CreateTreeStructure() _rootNode = new ListCategoryDto { Id = 0, - Name = "All Categories", + Name = "", ParentId = -1, ViewOrder = 0, Children = [] @@ -667,7 +667,7 @@ private void CreateTreeStructure() _rootNode = new ListCategoryDto { Id = 0, - Name = "All Categories", + Name = "", ParentId = -1, ViewOrder = 0, Children = _treeData From 6ebbdc50976ab6dd8d01d315825a22a4d6b6e14e Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Tue, 16 Dec 2025 00:18:43 +1100 Subject: [PATCH 20/22] expand category --- Client/Modules/FileHub/Category.razor | 8 ++++++-- Client/Modules/FileHub/Category.razor.cs | 22 ++++++++++++++++++++++ Client/Services/CategoryService.cs | 1 + 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index 2a3fdf8..65f07f2 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -18,10 +18,14 @@ else if (!string.IsNullOrEmpty(ErrorMessage)) else {
- + ((ListCategoryDto)e).Children?.Any() == true)> + HasChildren=@(e => ((ListCategoryDto)e).Children?.Any() == true) + Expanded="@(e => ((ListCategoryDto)e).IsExpanded)">