diff --git a/ThingConnect.Pulse.Server/Models/ConfigurationDtos.cs b/ThingConnect.Pulse.Server/Models/ConfigurationDtos.cs index f811ef9..649a545 100644 --- a/ThingConnect.Pulse.Server/Models/ConfigurationDtos.cs +++ b/ThingConnect.Pulse.Server/Models/ConfigurationDtos.cs @@ -1,8 +1,36 @@ using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; using ThingConnect.Pulse.Server.Data; namespace ThingConnect.Pulse.Server.Models; +/// +/// Custom validation attribute that allows null values but validates non-null values against a regex pattern. +/// +public sealed class OptionalRegularExpressionAttribute : ValidationAttribute +{ + private readonly Regex _regex; + + public OptionalRegularExpressionAttribute(string pattern) + { + _regex = new Regex(pattern, RegexOptions.Compiled); + } + + public override bool IsValid(object? value) + { + // Allow null values (optional field) + if (value == null) + return true; + + // Allow empty strings (optional field) + if (value is string str && string.IsNullOrEmpty(str)) + return true; + + // Validate non-empty strings against the pattern + return value is string stringValue && _regex.IsMatch(stringValue); + } +} + public class ConfigurationValidationException : Exception { public ValidationErrorsDto ValidationErrors { get; } @@ -91,7 +119,7 @@ public sealed class GroupSection public string? ParentId { get; set; } - [RegularExpression("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", ErrorMessage = "Color must be a valid hex color code")] + [OptionalRegularExpression("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", ErrorMessage = "Color must be a valid hex color code")] public string? Color { get; set; } public int? SortOrder { get; set; } diff --git a/ThingConnect.Pulse.Server/Models/StatusDtos.cs b/ThingConnect.Pulse.Server/Models/StatusDtos.cs index 11df3f8..2b18afe 100644 --- a/ThingConnect.Pulse.Server/Models/StatusDtos.cs +++ b/ThingConnect.Pulse.Server/Models/StatusDtos.cs @@ -37,6 +37,7 @@ public sealed class GroupDto public string Name { get; set; } = default!; public string? ParentId { get; set; } public string? Color { get; set; } + public int? SortOrder { get; set; } } public sealed class PageMetaDto diff --git a/ThingConnect.Pulse.Server/Services/EndpointService.cs b/ThingConnect.Pulse.Server/Services/EndpointService.cs index 48bdd71..817dd1a 100644 --- a/ThingConnect.Pulse.Server/Services/EndpointService.cs +++ b/ThingConnect.Pulse.Server/Services/EndpointService.cs @@ -94,7 +94,8 @@ private EndpointDto MapToEndpointDto(Data.Endpoint endpoint) Id = endpoint.Group.Id, Name = endpoint.Group.Name, ParentId = endpoint.Group.ParentId, - Color = endpoint.Group.Color + Color = endpoint.Group.Color, + SortOrder = endpoint.Group.SortOrder }, Type = endpoint.Type.ToString().ToLower(), Host = endpoint.Host, diff --git a/ThingConnect.Pulse.Server/Services/HistoryService.cs b/ThingConnect.Pulse.Server/Services/HistoryService.cs index 6c1c224..d90f034 100644 --- a/ThingConnect.Pulse.Server/Services/HistoryService.cs +++ b/ThingConnect.Pulse.Server/Services/HistoryService.cs @@ -187,7 +187,8 @@ private EndpointDto MapToEndpointDto(Data.Endpoint endpoint) Id = endpoint.Group.Id, Name = endpoint.Group.Name, ParentId = endpoint.Group.ParentId, - Color = endpoint.Group.Color + Color = endpoint.Group.Color, + SortOrder = endpoint.Group.SortOrder }, Type = endpoint.Type.ToString().ToLower(), Host = endpoint.Host, diff --git a/ThingConnect.Pulse.Server/Services/StatusService.cs b/ThingConnect.Pulse.Server/Services/StatusService.cs index 3fade8c..e6a31a3 100644 --- a/ThingConnect.Pulse.Server/Services/StatusService.cs +++ b/ThingConnect.Pulse.Server/Services/StatusService.cs @@ -53,9 +53,10 @@ public async Task> GetLiveStatusAsync(string? group, str // Get total count for pagination int totalCount = await query.CountAsync(); - // Apply pagination + // Apply pagination with proper group sorting List endpoints = await query - .OrderBy(e => e.GroupId) + .OrderBy(e => e.Group.SortOrder ?? int.MaxValue) + .ThenBy(e => e.Group.Name) .ThenBy(e => e.Name) .ToListAsync(); @@ -119,7 +120,8 @@ public async Task> GetLiveStatusAsync(string? group, str List groups = await _context.Groups .AsNoTracking() - .OrderBy(g => g.Name) + .OrderBy(g => g.SortOrder ?? int.MaxValue) + .ThenBy(g => g.Name) .ToListAsync(); // Cache for 5 minutes since groups don't change frequently @@ -248,7 +250,8 @@ private EndpointDto MapToEndpointDto(Data.Endpoint endpoint) Id = endpoint.Group.Id, Name = endpoint.Group.Name, ParentId = endpoint.Group.ParentId, - Color = endpoint.Group.Color + Color = endpoint.Group.Color, + SortOrder = endpoint.Group.SortOrder }, Type = endpoint.Type.ToString().ToLower(), Host = endpoint.Host, diff --git a/ThingConnect.Pulse.Server/config.schema.json b/ThingConnect.Pulse.Server/config.schema.json index b1ba274..2e00465 100644 --- a/ThingConnect.Pulse.Server/config.schema.json +++ b/ThingConnect.Pulse.Server/config.schema.json @@ -39,8 +39,8 @@ }, "name": { "type": "string", "minLength": 1 }, "parent_id": { "type": ["string", "null"] }, - "color": { "type": "string", "pattern": "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" }, - "sort_order": { "type": ["integer", "string"] } + "color": { "type": ["string", "null"], "pattern": "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" }, + "sort_order": { "type": ["integer", "string", "null"] } }, "additionalProperties": false } diff --git a/ThingConnect.Pulse.Tests/config.schema.json b/ThingConnect.Pulse.Tests/config.schema.json index b1ba274..2e00465 100644 --- a/ThingConnect.Pulse.Tests/config.schema.json +++ b/ThingConnect.Pulse.Tests/config.schema.json @@ -39,8 +39,8 @@ }, "name": { "type": "string", "minLength": 1 }, "parent_id": { "type": ["string", "null"] }, - "color": { "type": "string", "pattern": "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" }, - "sort_order": { "type": ["integer", "string"] } + "color": { "type": ["string", "null"], "pattern": "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" }, + "sort_order": { "type": ["integer", "string", "null"] } }, "additionalProperties": false }