diff --git a/.github/instructions/csharp.instructions.md b/.github/instructions/csharp.instructions.md index 5ee3b35..2c25261 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. +- 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.Tests/BaseTest.cs b/Client.Tests/BaseTest.cs index 30180c3..585787b 100644 --- a/Client.Tests/BaseTest.cs +++ b/Client.Tests/BaseTest.cs @@ -42,11 +42,12 @@ 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(); TestContext.Services.AddScoped(); TestContext.Services.AddScoped(); TestContext.Services.AddScoped(); @@ -56,7 +57,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/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/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.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 +} 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/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/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index a3c06ed..8138a6a 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -1,9 +1,74 @@ -

Category

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

Loading...

+} +else if (!string.IsNullOrEmpty(ErrorMessage)) +{ +
@ErrorMessage
+} +else +{ +
+ + ((ListCategoryDto)e).Children?.Any() == true) + Expanded="@(e => ((ListCategoryDto)e).IsExpanded)"> + + + +
+ } + diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index ab0f360..aa22c35 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 @@ -7,9 +9,31 @@ 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!; + + [Inject] + private Radzen.DialogService DialogService { get; set; } = default!; + + private List _treeData = []; + private ListCategoryDto _rootNode = new() { Name = "" }; + protected PagedResult Categories { get; set; } = new(); protected string? ErrorMessage { get; set; } protected bool IsLoading { get; set; } + protected ListCategoryDto? SelectedCategory { get; set; } + protected CreateAndUpdateCategoryDto EditModel { get; set; } = new(); + protected bool ShowEditDialog { get; set; } + protected bool IsAddingNew { get; set; } + protected string DialogTitle { get; set; } = string.Empty; + + 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() { @@ -17,7 +41,8 @@ protected override async Task OnInitializedAsync() ErrorMessage = null; try { - Categories = await CategoryService.ListAsync(ModuleState.ModuleId); + Categories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); + CreateTreeStructure(); } catch (Exception ex) { @@ -28,4 +53,678 @@ protected override async Task OnInitializedAsync() IsLoading = false; } } + + private void ShowContextMenu(MouseEventArgs args, ListCategoryDto? category) + { + if (category == null) return; + + SelectedCategory = category; + + // For root node, only show "Add Child Category" + if (category.Id == 0) + { + var rootMenuItems = new List + { + new() { + Text = "Add Category", + Value = "add", + Icon = "add", + } + }; + + ContextMenuService.Open(args, rootMenuItems, OnContextMenuClick); + return; + } + + var menuItems = new List + { + new() { + Text = "Add Child Category", + Value = "add", + Icon = "add", + }, + new() { + Text = "Edit Name", + Value = "edit", + Icon = "edit", + }, + new() { + Text = "Move Up", + Value = "moveup", + Icon = "arrow_upward", + Disabled = !CanMoveUp(category), + }, + new() { + 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() { + 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": + AddChildCategoryInline(); + break; + case "edit": + EditCategoryInline(); + 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) + { + // For root-level categories (ParentId == 0), siblings are in TreeData + if (category.ParentId == 0) + { + return _treeData; + } + + var parent = FindCategoryById([_rootNode], category.ParentId); + if (parent?.Children is List childrenList) + { + return childrenList; + } + + return parent?.Children.ToList() ?? []; + } + + private ListCategoryDto? FindCategoryById(List categories, int id) + { + foreach (var cat in categories) + { + if (cat.Id == id) return cat; + var found = FindCategoryById(cat.Children.ToList(), id); + if (found != null) return found; + } + return null; + } + + private void AddChildCategoryInline() + { + 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, + ParentId = SelectedCategory.Id == 0 ? 0 : SelectedCategory.Id, // If root node, ParentId is 0 + ViewOrder = SelectedCategory.Children.Count, + Children = [] + }; + + // Add to parent's children + SelectedCategory.Children.Add(newNode); + + // Automatically expand the parent node so the new child is visible + SelectedCategory.IsExpanded = true; + + // Set editing state + EditingNode = newNode; + EditingNodeName = string.Empty; + IsInlineAdding = true; + IsInlineEditing = true; + + StateHasChanged(); + } + + private void OnNodeDoubleClick(ListCategoryDto? category) + { + if (category == null || category.Id == 0) return; // Don't allow editing root node + + // Cancel any existing inline editing + CancelInlineEdit(); + + // Set editing state + EditingNode = category; + EditingNodeName = category.Name; + IsInlineAdding = false; + IsInlineEditing = true; + + StateHasChanged(); + } + + private void EditCategoryInline() + { + if (SelectedCategory == null || SelectedCategory.Id == 0) return; // Don't allow editing root node + + // 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)) + { + 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); + + // 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, + 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); + + // Update the node name in-place + EditingNode.Name = EditingNodeName; + + NotificationService.Notify(new Radzen.NotificationMessage + { + Severity = Radzen.NotificationSeverity.Success, + Summary = "Success", + Detail = "Category updated successfully", + Duration = 3000, + }); + } + + // Clear editing state without refreshing the tree + EditingNode = null; + EditingNodeName = string.Empty; + IsInlineEditing = false; + IsInlineAdding = false; + + StateHasChanged(); + } + 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 + ListCategoryDto? parent; + + if (EditingNode.ParentId == 0) + { + parent = _rootNode; + } + else + { + parent = FindCategoryById([_rootNode], 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 (string.Equals(e.Key, "Enter", StringComparison.Ordinal)) + { + await SaveInlineEdit(); + } + else if (string.Equals(e.Key, "Escape", StringComparison.Ordinal)) + { + CancelInlineEdit(); + } + } + + private async Task MoveUp() + { + if (SelectedCategory == null) return; + + try + { + // Find siblings in the current tree structure + var siblings = GetSiblings(SelectedCategory); + var currentIndex = siblings.IndexOf(SelectedCategory); + + if (currentIndex <= 0) return; + + var current = SelectedCategory; + var previous = siblings[currentIndex - 1]; + + // Update on server using dedicated move endpoint + await CategoryService.MoveUpAsync(current.Id, ModuleState.ModuleId); + + // Swap ViewOrder values locally + (current.ViewOrder, previous.ViewOrder) = (previous.ViewOrder, current.ViewOrder); + + // Swap positions in the list + siblings[currentIndex] = previous; + siblings[currentIndex - 1] = current; + + 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 + SelectedCategory = null; + StateHasChanged(); + } + 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 + { + // 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 = SelectedCategory; + var next = siblings[currentIndex + 1]; + + // Update on server using dedicated move endpoint + await CategoryService.MoveDownAsync(current.Id, ModuleState.ModuleId); + + // Swap ViewOrder values locally + (current.ViewOrder, next.ViewOrder) = (next.ViewOrder, current.ViewOrder); + + // Swap positions in the list + siblings[currentIndex] = next; + siblings[currentIndex + 1] = current; + + 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 + SelectedCategory = null; + StateHasChanged(); + } + 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, + }); + } + + 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() + { + IsAddingNew = false; + SelectedCategory = null; + } + + private async Task PromptDeleteCategory() + { + 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 DeleteCategory() + { + if (SelectedCategory == null) return; + + try + { + var categoryToDelete = SelectedCategory; + + await CategoryService.DeleteAsync(categoryToDelete.Id, ModuleState.ModuleId); + await logger.LogInformation("Category Deleted {Id}", categoryToDelete.Id); + + 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 + { + Severity = Radzen.NotificationSeverity.Success, + Summary = "Success", + Detail = "Category deleted successfully", + Duration = 4000, + }); + + SelectedCategory = null; + StateHasChanged(); + } + 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 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()) + { + _treeData = []; + + // Create root node with empty children + _rootNode = new ListCategoryDto + { + Id = 0, + Name = "", + ParentId = -1, + ViewOrder = 0, + IsExpanded = true, + Children = [] + }; + return; + } + + foreach (var category in Categories.Items) + { + category.Children.Clear(); + } + + 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, StringComparer.Ordinal) + .ToList(); + + foreach (var category in Categories.Items) + { + if (category.ParentId != 0 && categoryDict.TryGetValue(category.ParentId, out var parent)) + { + parent.Children.Add(category); + } + } + + SortChildren(_treeData); + + // Create root node with TreeData as children + _rootNode = new ListCategoryDto + { + Id = 0, + Name = "", + ParentId = -1, + ViewOrder = 0, + IsExpanded = true, + Children = _treeData + }; + } + + private void SortChildren(List categories) + { + foreach (var category in categories) + { + if (category.Children.Any()) + { + category.Children = category.Children + .OrderBy(c => c.ViewOrder) + .ThenBy(c => c.Name, StringComparer.Ordinal) + .ToList(); + + SortChildren(category.Children.ToList()); + } + } + } + + private Task OnNodeExpand(Radzen.TreeExpandEventArgs args) + { + if (args.Value is ListCategoryDto category) + { + category.IsExpanded = true; + } + return Task.CompletedTask; + } + + private void OnNodeCollapse(Radzen.TreeEventArgs args) + { + if (args.Value is ListCategoryDto category) + { + category.IsExpanded = false; + } + } } diff --git a/Client/Modules/FileHub/Component.razor b/Client/Modules/FileHub/Component.razor deleted file mode 100644 index e69de29..0000000 diff --git a/Client/Modules/FileHub/Edit.razor.cs b/Client/Modules/FileHub/Edit.razor.cs index 868bab0..5dfd8bc 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 6ba523e..4b74df7 100644 --- a/Client/Modules/FileHub/Index.razor.cs +++ b/Client/Modules/FileHub/Index.razor.cs @@ -8,11 +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(ModulePath() + "Module.js"), + new Script("_content/Radzen.Blazor/Radzen.Blazor.js"), + ]; private List? _filehubs; @@ -36,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 67addce..b7dac2c 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 @@ -22,6 +24,8 @@ public record ListCategoryDto public required string Name { get; set; } public int ViewOrder { get; set; } public int ParentId { get; set; } + public bool IsExpanded { get; set; } + public IList Children { get; set; } = []; } public record CreateAndUpdateCategoryDto @@ -37,56 +41,52 @@ 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); + Task MoveUpAsync(int id, int moduleId); + Task MoveDownAsync(int id, int moduleId); } -/// -/// Service implementation for Category operations -/// -public class CategoryService(HttpClient http, SiteState siteState) : ServiceBase(http, siteState), ICategoryService +public class CategoryService : ModuleService, 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()); - } + private readonly HttpClient _httpClient; - public Task CreateAsync(int moduleId, CreateAndUpdateCategoryDto dto) + public CategoryService(HttpClient http, SiteState siteState) + : base(http, siteState, "ictace/fileHub/categories") { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PostJsonAsync(url, dto); + _httpClient = http; } - public Task UpdateAsync(int id, int moduleId, CreateAndUpdateCategoryDto dto) + /// + /// 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) { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PutJsonAsync(url, dto); + 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 Task DeleteAsync(int id, int moduleId) + /// + /// 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) { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return DeleteAsync(url); + 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/ModuleService.cs b/Client/Services/Common/ModuleService.cs new file mode 100644 index 0000000..f1790ac --- /dev/null +++ b/Client/Services/Common/ModuleService.cs @@ -0,0 +1,63 @@ +// 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); + + /// + /// Gets a single entity by ID. + /// + public virtual Task GetAsync(int id, int moduleId) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return GetJsonAsync(url); + } + + /// + /// Lists entities with pagination. + /// + 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()); + } + + /// + /// Creates a new entity. + /// + public virtual Task CreateAsync(int moduleId, TCreateUpdateDto dto) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}", EntityNames.Module, moduleId); + return PostJsonAsync(url, dto); + } + + /// + /// Updates an existing entity. + /// + public virtual Task UpdateAsync(int id, int moduleId, TCreateUpdateDto dto) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return PutJsonAsync(url, dto); + } + + /// + /// Deletes an entity. + /// + 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); - } } 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(); } } 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 diff --git a/Directory.Packages.props b/Directory.Packages.props index 10a855e..da74502 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + @@ -27,10 +28,10 @@ - + - + \ No newline at end of file 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/ICTAce.FileHub.slnx b/ICTAce.FileHub.slnx index ee293e3..258b280 100644 --- a/ICTAce.FileHub.slnx +++ b/ICTAce.FileHub.slnx @@ -17,6 +17,7 @@ + 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/MoveDownHandlerTests.cs b/Server.Tests/Features/Categories/MoveDownHandlerTests.cs new file mode 100644 index 0000000..5d3e16e --- /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..92d0990 --- /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.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.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); } diff --git a/Server.Tests/Helpers/TestDbContexts.cs b/Server.Tests/Helpers/TestDbContexts.cs index 9d79b9f..aee345a 100644 --- a/Server.Tests/Helpers/TestDbContexts.cs +++ b/Server.Tests/Helpers/TestDbContexts.cs @@ -108,7 +108,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(); @@ -186,7 +186,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/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/Create.cs b/Server/Features/Categories/Create.cs index 3a0c536..035264c 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..6c3a395 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..cebda71 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/MoveDown.cs b/Server/Features/Categories/MoveDown.cs new file mode 100644 index 0000000..af7721a --- /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..9e6a692 --- /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; + } +} diff --git a/Server/Features/Categories/Update.cs b/Server/Features/Categories/Update.cs index 7c772db..acf7ba8 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 ); } diff --git a/Server/Persistence/ApplicationContext.cs b/Server/Persistence/ApplicationContext.cs index a9cb721..ff4af51 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("ICTAce_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; }