From 02c85b9f29dda313c9548773829be5bbd01089d6 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 28 Jul 2026 22:27:32 -0500 Subject: [PATCH 01/11] Add saved view column sizing --- src/Exceptionless.Core/Models/SavedView.cs | 3 + .../Models/SavedViewColumnSettings.cs | 103 ++++++++++++ .../Seed/PredefinedSavedViewConfiguration.cs | 36 ++++- .../Seed/PredefinedSavedViewContentHasher.cs | 24 +++ .../Seed/PredefinedSavedViewsDataSeed.cs | 47 +++++- .../Api/Handlers/SavedViewHandler.cs | 82 +++++++++- .../components/table/options.svelte.test.ts | 11 ++ .../events/components/table/options.svelte.ts | 89 +++++++--- .../saved-views/column-settings.test.ts | 60 +++++++ .../features/saved-views/column-settings.ts | 65 ++++++++ .../column-management-dialog.svelte | 1 + .../components/saved-view-picker.svelte | 14 ++ .../src/lib/features/saved-views/models.ts | 4 +- .../saved-views/use-saved-views.svelte.ts | 29 +++- .../data-table/data-table-body.svelte | 47 +++++- .../src/lib/features/shared/table.svelte.ts | 11 ++ .../ClientApp/src/lib/generated/api.ts | 35 ++++ .../ClientApp/src/lib/generated/schemas.ts | 45 ++++++ .../src/routes/(app)/event/+page.svelte | 5 + .../src/routes/(app)/stack/+page.svelte | 5 + .../src/routes/(app)/stream/+page.svelte | 5 + .../Mapping/SavedViewMapper.cs | 12 +- .../Models/SavedView/NewSavedView.cs | 77 +++++++++ .../Models/SavedView/UpdateSavedView.cs | 7 + .../Models/SavedView/ViewSavedView.cs | 2 + .../Utility/OpenApi/DeltaSchemaTransformer.cs | 47 +++++- .../Exceptionless.Tests/Api/Data/openapi.json | 66 ++++++++ .../Api/Endpoints/SavedViewEndpointTests.cs | 153 ++++++++++++++++++ .../Api/OpenApiSnapshotTests.cs | 15 ++ .../Mapping/SavedViewMapperTests.cs | 38 ++++- .../PredefinedSavedViewContentHasherTests.cs | 50 ++++++ .../Models/SavedViewSerializerTests.cs | 7 + 32 files changed, 1150 insertions(+), 45 deletions(-) create mode 100644 src/Exceptionless.Core/Models/SavedViewColumnSettings.cs create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs index 1c3bcc9393..2083f3622a 100644 --- a/src/Exceptionless.Core/Models/SavedView.cs +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -51,6 +51,9 @@ public partial record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates /// Column display order per dashboard table, excluding utility columns. public List? ColumnOrder { get; set; } + /// Extensible display settings keyed by dashboard column id. + public Dictionary? ColumnSettings { get; set; } + /// Whether dashboard statistic cards are shown for this view. Null means use the default. public bool? ShowStats { get; set; } diff --git a/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs b/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs new file mode 100644 index 0000000000..f3f62eec82 --- /dev/null +++ b/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs @@ -0,0 +1,103 @@ +namespace Exceptionless.Core.Models; + +/// +/// Per-column display settings for a saved view. All properties are optional so new settings can +/// be added without changing the meaning of existing saved views. +/// +public sealed record SavedViewColumnSettings +{ + public const int MaxPosition = 49; + public const int MaxWidth = 1200; + public const int MinWidth = 48; + + /// Whether the column is visible. Null means use the table default. + public bool? Visible { get; set; } + + /// Zero-based display position. Null means use the table default order. + public int? Position { get; set; } + + /// Column width in pixels. Null means use the table default width. + public int? Width { get; set; } + + public static Dictionary? FromLegacy( + IReadOnlyDictionary? columns, + IReadOnlyCollection? columnOrder) + { + return MergeLegacy(null, columns, columnOrder, columns is not null, columnOrder is not null); + } + + public static Dictionary? MergeLegacy( + IReadOnlyDictionary? current, + IReadOnlyDictionary? columns, + IReadOnlyCollection? columnOrder, + bool replaceVisibility, + bool replaceOrder) + { + var result = current?.ToDictionary(entry => entry.Key, entry => entry.Value with { }) + ?? new Dictionary(); + + if (replaceVisibility) + { + foreach (var settings in result.Values) + settings.Visible = null; + + if (columns is not null) + { + foreach (var (columnId, visible) in columns) + GetOrCreate(result, columnId).Visible = visible; + } + } + + if (replaceOrder) + { + foreach (var settings in result.Values) + settings.Position = null; + + int position = 0; + if (columnOrder is not null) + { + foreach (string columnId in columnOrder) + GetOrCreate(result, columnId).Position = position++; + } + } + + foreach (string columnId in result.Where(entry => IsEmpty(entry.Value)).Select(entry => entry.Key).ToArray()) + result.Remove(columnId); + + return result.Count > 0 ? result : null; + } + + public static List? ToLegacyColumnOrder(IReadOnlyDictionary? columnSettings) + { + var result = columnSettings? + .Where(entry => entry.Value.Position.HasValue) + .OrderBy(entry => entry.Value.Position) + .ThenBy(entry => entry.Key, StringComparer.Ordinal) + .Select(entry => entry.Key) + .ToList(); + + return result is { Count: > 0 } ? result : null; + } + + public static Dictionary? ToLegacyColumns(IReadOnlyDictionary? columnSettings) + { + var result = columnSettings? + .Where(entry => entry.Value.Visible.HasValue) + .ToDictionary(entry => entry.Key, entry => entry.Value.Visible!.Value); + + return result is { Count: > 0 } ? result : null; + } + + private static SavedViewColumnSettings GetOrCreate(IDictionary settings, string columnId) + { + if (settings.TryGetValue(columnId, out var existing)) + return existing; + + var created = new SavedViewColumnSettings(); + settings[columnId] = created; + return created; + } + + private static bool IsEmpty(SavedViewColumnSettings settings) + => settings.Visible is null && settings.Position is null && settings.Width is null; +} diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs index 32ff301b91..f75ecbbb61 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs @@ -16,8 +16,15 @@ public static bool Apply(SavedView destination, SavedView source, string key, st changed |= SetIfChanged(destination, source.Time, static (view, value) => view.Time = value, static view => view.Time); changed |= SetIfChanged(destination, source.Sort, static (view, value) => view.Sort = value, static view => view.Sort); changed |= SetIfChanged(destination, source.FilterDefinitions, static (view, value) => view.FilterDefinitions = value, static view => view.FilterDefinitions); - changed |= SetDictionaryIfChanged(destination, source.Columns); - changed |= SetListIfChanged(destination, source.ColumnOrder); + var columns = source.ColumnSettings is null + ? source.Columns + : SavedViewColumnSettings.ToLegacyColumns(source.ColumnSettings); + var columnOrder = source.ColumnSettings is null + ? source.ColumnOrder + : SavedViewColumnSettings.ToLegacyColumnOrder(source.ColumnSettings); + changed |= SetDictionaryIfChanged(destination, columns); + changed |= SetListIfChanged(destination, columnOrder); + changed |= SetColumnSettingsIfChanged(destination, source.ColumnSettings); changed |= SetIfChanged(destination, source.ShowStats, static (view, value) => view.ShowStats = value, static view => view.ShowStats); changed |= SetIfChanged(destination, source.ShowChart, static (view, value) => view.ShowChart = value, static view => view.ShowChart); changed |= SetIfChanged(destination, source.Version, static (view, value) => view.Version = value, static view => view.Version); @@ -44,6 +51,15 @@ private static bool SetIfChanged(SavedView savedView, T value, Action? value) + { + if (ColumnSettingsEqual(savedView.ColumnSettings, value)) + return false; + + savedView.ColumnSettings = CopyColumnSettings(value); + return true; + } + private static bool SetListIfChanged(SavedView savedView, IReadOnlyCollection? value) { if (CollectionEquals(savedView.ColumnOrder, value)) @@ -74,4 +90,20 @@ private static bool DictionaryEquals(IReadOnlyDictionary? left, IR return left.All(kvp => right.TryGetValue(kvp.Key, out bool value) && value == kvp.Value); } + + private static bool ColumnSettingsEqual( + IReadOnlyDictionary? left, + IReadOnlyDictionary? right) + { + if (ReferenceEquals(left, right)) + return true; + + if (left is null || right is null || left.Count != right.Count) + return false; + + return left.All(entry => right.TryGetValue(entry.Key, out var value) && entry.Value == value); + } + + private static Dictionary? CopyColumnSettings(IReadOnlyDictionary? value) + => value?.ToDictionary(entry => entry.Key, entry => entry.Value with { }); } diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs index b7e021c6e3..174e341445 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs @@ -21,6 +21,7 @@ public static string GetContentHash(SavedView savedView) savedView.FilterDefinitions, savedView.Columns, savedView.ColumnOrder, + savedView.ColumnSettings, savedView.ShowStats, savedView.ShowChart); } @@ -44,6 +45,7 @@ public static string GetDefinitionsContentHash(IEnumerable? columns, IReadOnlyCollection? columnOrder, + IReadOnlyDictionary? columnSettings, bool? showStats, bool? showChart) { + if (columnSettings is null) + { + var legacyContent = new + { + name, + slug, + viewType, + filter, + time, + sort, + filterDefinitions = CanonicalizeFilterDefinitions(filterDefinitions), + Columns = columns?.OrderBy(column => column.Key, StringComparer.Ordinal), + columnOrder, + showStats, + showChart + }; + + return SerializeAndHash(legacyContent); + } + var content = new { name, @@ -75,6 +98,7 @@ private static string GetContentHash( filterDefinitions = CanonicalizeFilterDefinitions(filterDefinitions), Columns = columns?.OrderBy(column => column.Key, StringComparer.Ordinal), columnOrder, + ColumnSettings = columnSettings.OrderBy(column => column.Key, StringComparer.Ordinal), showStats, showChart }; diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs index 650b8f862a..ab1c11fd55 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs @@ -129,15 +129,27 @@ private static bool ApplyDefinition(SavedView existing, PredefinedSavedViewDefin changed = true; } - if (!DictionaryEquals(existing.Columns, definition.Columns)) + var columns = definition.ColumnSettings is null + ? definition.Columns + : SavedViewColumnSettings.ToLegacyColumns(definition.ColumnSettings); + if (!DictionaryEquals(existing.Columns, columns)) { - existing.Columns = definition.Columns?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + existing.Columns = columns?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); changed = true; } - if (!CollectionEquals(existing.ColumnOrder, definition.ColumnOrder)) + var columnOrder = definition.ColumnSettings is null + ? definition.ColumnOrder + : SavedViewColumnSettings.ToLegacyColumnOrder(definition.ColumnSettings); + if (!CollectionEquals(existing.ColumnOrder, columnOrder)) { - existing.ColumnOrder = definition.ColumnOrder is null ? null : [.. definition.ColumnOrder]; + existing.ColumnOrder = columnOrder is null ? null : [.. columnOrder]; + changed = true; + } + + if (!ColumnSettingsEqual(existing.ColumnSettings, definition.ColumnSettings)) + { + existing.ColumnSettings = CopyColumnSettings(definition.ColumnSettings); changed = true; } @@ -192,11 +204,18 @@ private static SavedView CreateSavedView(PredefinedSavedViewDefinition definitio FilterDefinitions = GetRawJson(definition.FilterDefinitions), Columns = definition.Columns?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value), ColumnOrder = definition.ColumnOrder is null ? null : [.. definition.ColumnOrder], + ColumnSettings = CopyColumnSettings(definition.ColumnSettings), ShowStats = definition.ShowStats, ShowChart = definition.ShowChart, Version = 1 }; + if (savedView.ColumnSettings is not null) + { + savedView.Columns = SavedViewColumnSettings.ToLegacyColumns(savedView.ColumnSettings); + savedView.ColumnOrder = SavedViewColumnSettings.ToLegacyColumnOrder(savedView.ColumnSettings); + } + savedView.PredefinedContentHash = PredefinedSavedViewContentHasher.GetContentHash(savedView); return savedView; } @@ -223,6 +242,23 @@ private static bool DictionaryEquals(IReadOnlyDictionary? left, IR return left.All(kvp => right.TryGetValue(kvp.Key, out bool value) && value == kvp.Value); } + private static bool ColumnSettingsEqual( + IReadOnlyDictionary? left, + IReadOnlyDictionary? right) + { + if (ReferenceEquals(left, right)) + return true; + + if (left is null || right is null || left.Count != right.Count) + return false; + + return left.All(entry => right.TryGetValue(entry.Key, out var value) && entry.Value == value); + } + + private static Dictionary? CopyColumnSettings( + IReadOnlyDictionary? value) + => value?.ToDictionary(entry => entry.Key, entry => entry.Value with { }); + public static string? GetRawJson(JsonElement? value) { if (value is not { } element || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) @@ -264,6 +300,9 @@ public sealed record PredefinedSavedViewDefinition [JsonPropertyName("columnOrder")] public IReadOnlyCollection? ColumnOrder { get; init; } + [JsonPropertyName("columnSettings")] + public IReadOnlyDictionary? ColumnSettings { get; init; } + [JsonPropertyName("showStats")] public bool? ShowStats { get; init; } diff --git a/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs index c698e33fac..6f642f2158 100644 --- a/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs @@ -153,6 +153,7 @@ public async Task>> Ha FilterDefinitions = definition.FilterDefinitions is { } filterDefinitions ? JsonSerializer.Serialize(filterDefinitions) : null, Columns = definition.Columns is { } columns ? new Dictionary(columns) : null, ColumnOrder = definition.ColumnOrder?.ToList(), + ColumnSettings = CopyColumnSettings(definition.ColumnSettings), ShowStats = definition.ShowStats, ShowChart = definition.ShowChart, Version = 1 @@ -160,7 +161,12 @@ public async Task>> Ha .ToList(); foreach (var savedView in savedViews) + { + if (savedView.ColumnSettings is not null) + SynchronizeColumnConfiguration(savedView); + savedView.PredefinedContentHash = PredefinedSavedViewContentHasher.GetContentHash(savedView); + } if (savedViews.Count > 0) await repository.AddAsync(savedViews, o => o.Cache().ImmediateConsistency()); @@ -216,6 +222,7 @@ public async Task> Handle(UpdateSavedViewMessage message) var changedNames = message.Changes.GetChangedPropertyNames(); message.Changes.Patch(original); + SynchronizeColumnConfiguration(original, changedNames); if (changedNames.Contains(nameof(UpdateSavedView.Slug))) original.Slug = ToSlug(original.Slug); @@ -267,6 +274,7 @@ private async Task> PostImplAsync(NewSavedView value) return Result.BadRequest("Saved view value is required."); var mapped = mapper.MapToSavedView(value); + SynchronizeColumnConfiguration(mapped); mapped.Slug = ToSlug(String.IsNullOrWhiteSpace(mapped.Slug) ? mapped.Name : mapped.Slug); if (String.IsNullOrEmpty(mapped.OrganizationId) && HttpContext.Request.GetAssociatedOrganizationIds().Count > 0) @@ -377,7 +385,9 @@ private async Task> PostImplAsync(NewSavedView value) return Result.Invalid(ValidationError.Create("filter_definitions", "FilterDefinitions must be a valid JSON array.")); } - if (changedNames.Contains(nameof(UpdateSavedView.Columns)) || changedNames.Contains(nameof(UpdateSavedView.ColumnOrder))) + if (changedNames.Contains(nameof(UpdateSavedView.Columns)) + || changedNames.Contains(nameof(UpdateSavedView.ColumnOrder)) + || changedNames.Contains(nameof(UpdateSavedView.ColumnSettings))) { var patchedChanges = new UpdateSavedView(); changes.Patch(patchedChanges); @@ -490,11 +500,39 @@ private static Result PermissionToResult(PermissionResult permission) if (changes.ColumnOrder is not null && changes.ColumnOrder.Count > 50) return new ValidationResult("ColumnOrder cannot exceed 50 items.", [nameof(UpdateSavedView.ColumnOrder)]); + if (changes.ColumnSettings is not null && changes.ColumnSettings.Count > 50) + return new ValidationResult("ColumnSettings cannot exceed 50 items.", [nameof(UpdateSavedView.ColumnSettings)]); + return NewSavedView.ValidateColumnKeys(viewType, changes.Columns) .Concat(NewSavedView.ValidateColumnOrder(viewType, changes.ColumnOrder)) + .Concat(NewSavedView.ValidateColumnSettings(viewType, changes.ColumnSettings)) .FirstOrDefault(); } + private static void SynchronizeColumnConfiguration(SavedView savedView, IEnumerable? changedNames = null) + { + bool columnSettingsChanged = changedNames is null || changedNames.Contains(nameof(UpdateSavedView.ColumnSettings)); + bool columnsChanged = changedNames is null || changedNames.Contains(nameof(UpdateSavedView.Columns)); + bool columnOrderChanged = changedNames is null || changedNames.Contains(nameof(UpdateSavedView.ColumnOrder)); + + if (columnSettingsChanged && savedView.ColumnSettings is not null) + { + savedView.Columns = SavedViewColumnSettings.ToLegacyColumns(savedView.ColumnSettings); + savedView.ColumnOrder = SavedViewColumnSettings.ToLegacyColumnOrder(savedView.ColumnSettings); + return; + } + + if (columnsChanged || columnOrderChanged) + { + savedView.ColumnSettings = SavedViewColumnSettings.MergeLegacy( + savedView.ColumnSettings, + savedView.Columns, + savedView.ColumnOrder, + columnsChanged, + columnOrderChanged); + } + } + // --- Predefined saved views logic --- private async Task EnsurePredefinedSavedViewsCreatedAsync(string organizationId) @@ -670,11 +708,15 @@ private SavedView CreatePredefinedSavedView(string organizationId, PredefinedSav FilterDefinitions = PredefinedSavedViewsDataSeed.GetRawJson(definition.FilterDefinitions), Columns = Copy(definition.Columns), ColumnOrder = definition.ColumnOrder is null ? null : [.. definition.ColumnOrder], + ColumnSettings = CopyColumnSettings(definition.ColumnSettings), ShowStats = definition.ShowStats, ShowChart = definition.ShowChart, Version = 1 }; + if (savedView.ColumnSettings is not null) + SynchronizeColumnConfiguration(savedView); + savedView.PredefinedContentHash = PredefinedSavedViewContentHasher.GetContentHash(savedView); return savedView; } @@ -689,8 +731,15 @@ private static bool ApplyPredefinedSavedView(SavedView savedView, PredefinedSave changed |= SetIfChanged(savedView, definition.Time, static (view, value) => view.Time = value, static view => view.Time); changed |= SetIfChanged(savedView, definition.Sort, static (view, value) => view.Sort = value, static view => view.Sort); changed |= SetIfChanged(savedView, PredefinedSavedViewsDataSeed.GetRawJson(definition.FilterDefinitions), static (view, value) => view.FilterDefinitions = value, static view => view.FilterDefinitions); - changed |= SetDictionaryIfChanged(savedView, definition.Columns); - changed |= SetListIfChanged(savedView, definition.ColumnOrder); + var columns = definition.ColumnSettings is null + ? definition.Columns + : SavedViewColumnSettings.ToLegacyColumns(definition.ColumnSettings); + var columnOrder = definition.ColumnSettings is null + ? definition.ColumnOrder + : SavedViewColumnSettings.ToLegacyColumnOrder(definition.ColumnSettings); + changed |= SetDictionaryIfChanged(savedView, columns); + changed |= SetListIfChanged(savedView, columnOrder); + changed |= SetColumnSettingsIfChanged(savedView, definition.ColumnSettings); changed |= SetIfChanged(savedView, definition.ShowStats, static (view, value) => view.ShowStats = value, static view => view.ShowStats); changed |= SetIfChanged(savedView, definition.ShowChart, static (view, value) => view.ShowChart = value, static view => view.ShowChart); changed |= SetIfChanged(savedView, 1, static (view, value) => view.Version = value, static view => view.Version); @@ -798,6 +847,7 @@ private static PredefinedSavedViewDefinition ToPredefinedSavedView(SavedView sav FilterDefinitions = ParseFilterDefinitions(savedView.FilterDefinitions), Columns = savedView.Columns, ColumnOrder = savedView.ColumnOrder, + ColumnSettings = savedView.ColumnSettings, ShowStats = savedView.ShowStats, ShowChart = savedView.ShowChart }; @@ -853,11 +903,24 @@ private static bool SetListIfChanged(SavedView savedView, IReadOnlyCollection? value) + { + if (ColumnSettingsEqual(savedView.ColumnSettings, value)) + return false; + + savedView.ColumnSettings = CopyColumnSettings(value); + return true; + } + private static Dictionary? Copy(IReadOnlyDictionary? value) { return value is null ? null : value.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); } + private static Dictionary? CopyColumnSettings( + IReadOnlyDictionary? value) + => value?.ToDictionary(entry => entry.Key, entry => entry.Value with { }); + private static bool DictionaryEquals(IReadOnlyDictionary? left, IReadOnlyDictionary? right) { if (left is null) @@ -872,6 +935,19 @@ private static bool DictionaryEquals(IReadOnlyDictionary? left, IR return left.All(kvp => right.TryGetValue(kvp.Key, out var value) && value == kvp.Value); } + private static bool ColumnSettingsEqual( + IReadOnlyDictionary? left, + IReadOnlyDictionary? right) + { + if (left is null) + return right is null; + + if (right is null || left.Count != right.Count) + return false; + + return left.All(entry => right.TryGetValue(entry.Key, out var value) && entry.Value == value); + } + private static string GetUniqueSlug(string slug, IReadOnlyCollection existingViews, string? excludingId) { var baseSlug = ToSlug(slug); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts index 3cd41c3162..1b03a03ff7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts @@ -15,6 +15,17 @@ describe('event table columns', () => { expect(defaultEventColumnVisibility.tags).toBe(false); }); + it('uses a wider resizable project column and keeps utility columns fixed', () => { + const columns = getColumns>(); + const project = columns.find((column) => column.id === 'project'); + const select = columns.find((column) => column.id === 'select'); + const summary = columns.find((column) => column.id === 'summary'); + + expect(project).toMatchObject({ maxSize: 800, minSize: 160, size: 240 }); + expect(select?.enableResizing).toBe(false); + expect(summary?.enableResizing).toBe(false); + }); + it('offers project and tags as hidden optional stack columns', () => { const columns = getColumns>('stack_frequent'); const columnIds = columns.map((column) => column.id); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts index 8e6803f273..f82d4bf988 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts @@ -50,6 +50,7 @@ export function getColumns props.row.getToggleSelectedHandler()({ target: { checked } }) }), enableHiding: false, + enableResizing: false, enableSorting: false, header: ({ table }) => renderComponent(Checkbox, { @@ -64,6 +65,7 @@ export function getColumns renderComponent(Summary, { showStatus: false, showType, summary: prop.row.original }), + enableResizing: false, header: 'Summary', id: 'summary', meta: { @@ -76,9 +78,12 @@ export function getColumns renderComponent(EventsUserIdentitySummaryCell, { summary: prop.row.original }), header: 'User', id: 'user', + maxSize: 480, meta: { class: 'w-28' - } + }, + minSize: 80, + size: 112 }, { accessorKey: nameof>('date'), cell: (prop) => renderComponent(TimeAgo, { value: prop.getValue() }), header: 'Date', id: 'date', + maxSize: 480, meta: { class: 'w-36' - } + }, + minSize: 96, + size: 144 }, { accessorKey: nameof>('tags'), @@ -108,9 +119,12 @@ export function getColumns getSummaryDataValue(row, 'Message'), @@ -118,18 +132,24 @@ export function getColumns>('type'), cell: (prop) => formatTextColumn(prop.getValue()), header: 'Type', id: 'type', + maxSize: 640, meta: { class: 'w-28' - } + }, + minSize: 80, + size: 112 }, { accessorKey: nameof>('version'), @@ -137,27 +157,36 @@ export function getColumns getSummaryDataValue(row, 'Type'), cell: (prop) => formatTextColumn(prop.getValue()), header: 'Exception Type', id: 'exception_type', + maxSize: 800, meta: { class: 'w-36' - } + }, + minSize: 112, + size: 144 }, { accessorFn: (row) => getSource(row), cell: (prop) => formatTextColumn(prop.getValue()), header: 'Source', id: 'source', + maxSize: 800, meta: { class: 'w-40' - } + }, + minSize: 112, + size: 160 }, { accessorFn: (row) => getSummaryDataValue(row, 'Name'), @@ -165,18 +194,24 @@ export function getColumns getSummaryDataValue(row, 'Level'), cell: (prop) => renderComponent(LogLevel, { level: prop.getValue() }), header: 'Level', id: 'level', + maxSize: 240, meta: { class: 'w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem] px-1 text-center' - } + }, + minSize: 64, + size: 72 } ); } else { @@ -187,9 +222,12 @@ export function getColumns>('status'), @@ -197,18 +235,24 @@ export function getColumns renderComponent(StackUsersSummaryCell, { summary: prop.row.original }), enableSorting: false, header: 'Users', id: 'users', + maxSize: 320, meta: { class: 'w-24' - } + }, + minSize: 72, + size: 96 }, { accessorKey: nameof>('total'), @@ -216,9 +260,12 @@ export function getColumns>('first_occurrence'), @@ -226,9 +273,12 @@ export function getColumns>('last_occurrence'), @@ -236,9 +286,12 @@ export function getColumns { + it('builds extensible settings from the current table state', () => { + const result = buildColumnSettings( + ['select', 'summary', 'project'], + ['select', 'project', 'summary'], + { project: true, summary: true }, + { project: 360 } + ); + + expect(result).toEqual({ + project: { position: 0, visible: true, width: 360 }, + summary: { position: 1, visible: true } + }); + }); + + it('uses settings instead of legacy fields when settings are available', () => { + const view = { + column_order: ['summary', 'project'], + column_settings: { + project: { position: 0, visible: true, width: 360 }, + summary: { position: 1, visible: false } + }, + columns: { project: false, summary: true } + } as Pick; + + expect(getSavedColumnOrder(view)).toEqual(['project', 'summary']); + expect(getSavedColumnVisibility(view)).toEqual({ project: true, summary: false }); + expect(getSavedColumnSizing(view)).toEqual({ project: 360 }); + }); + + it('falls back to legacy visibility and order', () => { + const view = { + column_order: ['project', 'summary'], + column_settings: null, + columns: { project: true, summary: false } + } as Pick; + + expect(getSavedColumnOrder(view)).toEqual(['project', 'summary']); + expect(getSavedColumnVisibility(view)).toEqual({ project: true, summary: false }); + expect(getSavedColumnSizing(view)).toEqual({}); + }); + + it('detects changed and reset column widths', () => { + const view = { + column_settings: { + project: { width: 360 } + } + } as Pick; + + expect(savedViewColumnSizingEqual({ project: 360 }, view)).toBe(true); + expect(savedViewColumnSizingEqual({ project: 420 }, view)).toBe(false); + expect(savedViewColumnSizingEqual({}, view)).toBe(false); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts new file mode 100644 index 0000000000..1dc6d6389d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts @@ -0,0 +1,65 @@ +import type { ColumnOrderState, ColumnSizingState, ColumnVisibilityState } from '@tanstack/svelte-table'; + +import type { SavedView, SavedViewColumnSettings } from './models'; + +type SavedColumnState = Pick; + +export function buildColumnSettings( + columnIds: string[], + columnOrder: ColumnOrderState, + columnVisibility: ColumnVisibilityState, + columnSizing: ColumnSizingState +): Record { + const availableColumnIds = columnIds.filter((columnId) => columnId !== 'select'); + const availableColumnIdSet = new Set(availableColumnIds); + const orderedColumnIds = [ + ...columnOrder.filter((columnId, index) => columnId !== 'select' && availableColumnIdSet.has(columnId) && columnOrder.indexOf(columnId) === index), + ...availableColumnIds.filter((columnId) => !columnOrder.includes(columnId)) + ]; + + return Object.fromEntries( + orderedColumnIds.map((columnId, position) => [ + columnId, + { + position, + visible: columnVisibility[columnId] ?? true, + ...(columnSizing[columnId] !== undefined ? { width: Math.round(columnSizing[columnId]) } : {}) + } + ]) + ); +} + +export function getSavedColumnOrder(view: SavedColumnState | undefined): ColumnOrderState { + const settingsOrder = Object.entries(view?.column_settings ?? {}) + .filter((entry): entry is [string, SavedViewColumnSettings & { position: number }] => entry[1].position != null) + .sort(([leftId, left], [rightId, right]) => left.position - right.position || leftId.localeCompare(rightId)) + .map(([columnId]) => columnId); + + return settingsOrder.length > 0 ? settingsOrder : (view?.column_order ?? []); +} + +export function getSavedColumnSizing(view: Pick | undefined): ColumnSizingState { + return Object.fromEntries( + Object.entries(view?.column_settings ?? {}) + .filter((entry): entry is [string, SavedViewColumnSettings & { width: number }] => entry[1].width != null) + .map(([columnId, settings]) => [columnId, settings.width]) + ); +} + +export function getSavedColumnVisibility(view: SavedColumnState | undefined): ColumnVisibilityState { + const settingsVisibility = Object.fromEntries( + Object.entries(view?.column_settings ?? {}) + .filter((entry): entry is [string, SavedViewColumnSettings & { visible: boolean }] => entry[1].visible != null) + .map(([columnId, settings]) => [columnId, settings.visible]) + ); + + return Object.keys(settingsVisibility).length > 0 ? settingsVisibility : (view?.columns ?? {}); +} + +export function savedViewColumnSizingEqual(current: ColumnSizingState | undefined, view: Pick): boolean { + const saved = getSavedColumnSizing(view); + const currentEntries = Object.entries(current ?? {}).filter(([columnId]) => columnId !== 'select'); + const savedEntries = Object.entries(saved); + + return currentEntries.length === savedEntries.length && currentEntries.every(([columnId, width]) => saved[columnId] === Math.round(width)); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte index 758cd0e3ac..3b8d6422d0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte @@ -94,6 +94,7 @@ function resetColumns(): void { table.resetColumnVisibility(); table.resetColumnOrder(); + table.resetColumnSizing(); search = ''; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index 9677cb709d..54492a715f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -27,6 +27,7 @@ import type { NewSavedView, SavedView, UpdateSavedView } from '../models'; import { deleteSavedView, markSavedViewDeleted, patchSavedView, postSavedView, restoreDeletedSavedView } from '../api.svelte'; + import { buildColumnSettings } from '../column-settings'; import ColumnManagementDialog from './column-management-dialog.svelte'; import DeleteViewDialog from './delete-view-dialog.svelte'; import RenameViewDialog from './rename-view-dialog.svelte'; @@ -45,6 +46,7 @@ interface Props { activeSavedView?: SavedView; columnOrder?: string[]; + columnSizing?: Record; columnVisibility?: Record; filters: IFilter[]; isModified: boolean; @@ -65,6 +67,7 @@ let { activeSavedView, columnOrder, + columnSizing, columnVisibility, filters, isModified, @@ -161,6 +164,15 @@ return savedColumnOrder.length > 0 ? savedColumnOrder : undefined; } + function getSavedColumnSettings() { + return buildColumnSettings( + table.getAllLeafColumns().map((column) => column.id), + columnOrder ?? [], + columnVisibility ?? {}, + columnSizing ?? {} + ); + } + async function openDeleteDialog(savedView: SavedView) { viewToDelete = savedView; await tick(); @@ -181,6 +193,7 @@ const savedColumnOrder = getSavedColumnOrder(); const body: NewSavedView = { column_order: savedColumnOrder, + column_settings: getSavedColumnSettings(), columns: columnVisibility, filter: currentFilterString || undefined, filter_definitions: filterDefinitions, @@ -222,6 +235,7 @@ function getUpdateBody(): UpdateSavedView { return { column_order: getSavedColumnOrder() ?? null, + column_settings: getSavedColumnSettings(), columns: columnVisibility, filter: currentFilterString || null, filter_definitions: serializeFilters(filters), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts index ffc1d24f8f..74cb955d3a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts @@ -1,5 +1,5 @@ -import type { NewSavedView, UpdateSavedView, ViewSavedView } from '$generated/api'; +import type { NewSavedView, SavedViewColumnSettings, UpdateSavedView, ViewSavedView } from '$generated/api'; export type SavedView = ViewSavedView; -export type { NewSavedView, UpdateSavedView }; +export type { NewSavedView, SavedViewColumnSettings, UpdateSavedView }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index e9abbc2703..99a05d2bde 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -1,5 +1,5 @@ import type { IFilter } from '$comp/faceted-filter'; -import type { ColumnOrderState, ColumnVisibilityState } from '@tanstack/svelte-table'; +import type { ColumnOrderState, ColumnSizingState, ColumnVisibilityState } from '@tanstack/svelte-table'; import { goto } from '$app/navigation'; import { buildFilterCacheKey, deserializeFilters, serializeFilters } from '$features/events/components/filters/helpers.svelte'; @@ -8,6 +8,7 @@ import { organization } from '$features/organizations/context.svelte'; import type { SavedView } from './models'; import { getSavedViewsByViewQuery } from './api.svelte'; +import { getSavedColumnOrder, getSavedColumnSizing, getSavedColumnVisibility, savedViewColumnSizingEqual } from './column-settings'; import { savedViewHref, savedViewResolvedSlug } from './slugs'; export interface SavedViewQueryParams { @@ -25,6 +26,7 @@ export interface UseSavedViewsOptions { defaultTime?: null | string; filterCacheKey: (filter: null | string) => string; getColumnOrder?: () => ColumnOrderState; + getColumnSizing?: () => ColumnSizingState; getColumnVisibility?: () => ColumnVisibilityState; getFilter?: () => null | string; getFilterDefinitions?: () => string; @@ -34,6 +36,7 @@ export interface UseSavedViewsOptions { getTime?: () => null | string | undefined; queryParams: SavedViewQueryParams; setColumnOrder?: (order: ColumnOrderState) => void; + setColumnSizing?: (sizing: ColumnSizingState) => void; setColumnVisibility?: (visibility: ColumnVisibilityState) => void; setShowChart?: (show: boolean) => void; setShowStats?: (show: boolean) => void; @@ -185,14 +188,16 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return undefined; }); - function applyColumnState(view: Pick | undefined): void { + function applyColumnState(view: Pick | undefined): void { if (options.setColumnVisibility) { - options.setColumnVisibility(view?.columns ?? {}); + options.setColumnVisibility(getSavedColumnVisibility(view)); } if (options.setColumnOrder) { - options.setColumnOrder(view?.column_order ?? []); + options.setColumnOrder(getSavedColumnOrder(view)); } + + options.setColumnSizing?.(getSavedColumnSizing(view)); } function applyDisplayState(view: Pick | undefined): void { @@ -277,11 +282,23 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return true; } - if (options.getColumnVisibility && hasSavedViewColumnChanges(options.getColumnVisibility(), view.columns, options.defaultColumnVisibility)) { + if ( + options.getColumnVisibility && + (view.columns != null || Object.values(view.column_settings ?? {}).some((settings) => settings.visible != null)) && + hasSavedViewColumnChanges(options.getColumnVisibility(), getSavedColumnVisibility(view), options.defaultColumnVisibility) + ) { + return true; + } + + if ( + options.getColumnOrder && + (hasSavedColumnOrder(view.column_order) || Object.values(view.column_settings ?? {}).some((settings) => settings.position != null)) && + !columnOrderEqual(options.getColumnOrder(), getSavedColumnOrder(view)) + ) { return true; } - if (options.getColumnOrder && hasSavedColumnOrder(view.column_order) && !columnOrderEqual(options.getColumnOrder(), view.column_order)) { + if (options.getColumnSizing && !savedViewColumnSizingEqual(options.getColumnSizing(), view)) { return true; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index 3d45cf2c9a..8cfd62c50a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -57,6 +57,14 @@ return classes.filter(Boolean).join(' '); } + function getColumnStyle(column: Cell['column'] | Header['column']): string | undefined { + if (!column.getCanResize() || getVisibleDataColumnCount() === 1) { + return undefined; + } + + return `width: ${column.getSize()}px; min-width: ${column.getSize()}px; max-width: ${column.getSize()}px;`; + } + function getMetaClass(meta: unknown): string { return (meta as { class?: string })?.class ?? ''; } @@ -100,6 +108,23 @@ rowClick(cell.row.original, event); } + function onResizeKeydown(event: KeyboardEvent, header: Header): void { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const delta = event.key === 'ArrowLeft' ? -16 : 16; + table.setColumnSizing((current) => ({ + ...current, + [header.column.id]: Math.min( + header.column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER, + Math.max(header.column.columnDef.minSize ?? 20, header.column.getSize() + delta) + ) + })); + } + function removeWidthClasses(className: string): string { return className .split(' ') @@ -115,8 +140,24 @@ {#each headerGroup.headers as header (header.id)} {@const headerClass = getHeaderColumnClass(header)} - + + {#if header.column.getCanResize()} + + {/if} {/each} @@ -145,12 +186,12 @@ {#if rowHref && cell.row.original} {@const href = rowHref(cell.row.original)} onCellClick(event, cell)} variant="ghost"> - + {:else} - onCellClick(event, cell)}> + onCellClick(event, cell)} style={getColumnStyle(cell.column)}> {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts index d4a5788573..851de4eb10 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts @@ -3,6 +3,7 @@ import type { FetchClientResponse } from '@foundatiofx/fetchclient'; import { type ColumnDef, type ColumnOrderState, + type ColumnSizingState, type ColumnSort, type ColumnVisibilityState, createSortedRowModel, @@ -31,7 +32,9 @@ export interface TableConfiguration[]; configureOptions?: (options: TableOptions) => TableOptions | void; defaultColumnOrder?: ColumnOrderState; + defaultColumnSizing?: ColumnSizingState; defaultColumnVisibility?: ColumnVisibilityState; + enableColumnResizing?: boolean; paginationStrategy: TPaginationStrategy; queryData?: TData[]; queryMeta?: QueryMeta; @@ -107,6 +110,9 @@ export function getSharedTableOptions{}); + // Initialize pagination state from parameters const initialPageIndex = getPageIndexFromParameters(configuration.paginationStrategy, configuration.queryParameters, 0); @@ -255,6 +261,7 @@ export function getSharedTableOptions; column_order?: string[] | null; + column_settings?: null | Record; show_stats?: null | boolean; show_chart?: null | boolean; /** If true, the view will only be visible to the current user. Defaults to false. */ @@ -370,6 +371,7 @@ export interface PredefinedSavedViewDefinition { filterDefinitions?: null | JsonElement; columns?: null | Record; columnOrder?: string[] | null; + columnSettings?: null | Record; showStats?: null | boolean; showChart?: null | boolean; } @@ -388,6 +390,23 @@ export interface ResetPasswordModel { password: string; } +/** + * Per-column display settings for a saved view. All properties are optional so new settings can * be added without changing the meaning of existing saved views. */ +export interface SavedViewColumnSettings { + /** Whether the column is visible. Null means use the table default. */ + visible?: null | boolean; + /** + * Zero-based display position. Null means use the table default order. + * @format int32 + */ + position?: null | number; + /** + * Column width in pixels. Null means use the table default width. + * @format int32 + */ + width?: null | number; +} + export interface Signup { name: string; /** The email address or domain username */ @@ -396,6 +415,20 @@ export interface Signup { invite_token?: null | string; } +export interface SourceMapArtifact { + id: string; + generated_file_url: string; + source_map_url?: null | string; + file_name?: null | string; + /** @format int64 */ + size: number; + is_auto_downloaded: boolean; + /** @format date-time */ + created_utc: string; + /** @format date-time */ + last_used_utc?: null | string; +} + export interface Stack { /** * Unique id that identifies a stack. @@ -503,6 +536,7 @@ export interface UpdateSavedView { filter_definitions?: null | string; columns?: null | Record; column_order?: string[] | null; + column_settings?: null | Record; show_stats?: null | boolean; show_chart?: null | boolean; } @@ -728,6 +762,7 @@ export interface ViewSavedView { filter_definitions?: null | string; columns?: null | Record; column_order?: string[] | null; + column_settings?: null | Record; show_stats?: null | boolean; show_chart?: null | boolean; name: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index c2cd06eb56..abe05d78af 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -212,6 +212,12 @@ export const NewSavedViewSchema = object({ .optional(), columns: record(string(), boolean()).nullable().optional(), column_order: array(string()).nullable().optional(), + column_settings: record( + string(), + lazy(() => SavedViewColumnSettingsSchema), + ) + .nullable() + .optional(), show_stats: boolean().nullable().optional(), show_chart: boolean().nullable().optional(), is_private: boolean().nullable().optional(), @@ -476,6 +482,12 @@ export const PredefinedSavedViewDefinitionSchema = object({ filterDefinitions: unknown().optional(), columns: record(string(), boolean()).nullable().optional(), columnOrder: array(string()).nullable().optional(), + columnSettings: record( + string(), + lazy(() => SavedViewColumnSettingsSchema), + ) + .nullable() + .optional(), showStats: boolean().nullable().optional(), showChart: boolean().nullable().optional(), }); @@ -503,6 +515,15 @@ export const ResetPasswordModelSchema = object({ }); export type ResetPasswordModelFormData = Infer; +export const SavedViewColumnSettingsSchema = object({ + visible: boolean().nullable().optional(), + position: int32().nullable().optional(), + width: int32().nullable().optional(), +}); +export type SavedViewColumnSettingsFormData = Infer< + typeof SavedViewColumnSettingsSchema +>; + export const SignupSchema = object({ name: string().min(1, "Name is required"), email: email(), @@ -516,6 +537,18 @@ export const SignupSchema = object({ }); export type SignupFormData = Infer; +export const SourceMapArtifactSchema = object({ + id: string().min(1, "Id is required"), + generated_file_url: url(), + source_map_url: url().nullable().optional(), + file_name: string().min(1, "File name is required").nullable().optional(), + size: int(), + is_auto_downloaded: boolean(), + created_utc: iso.datetime(), + last_used_utc: iso.datetime().nullable().optional(), +}); +export type SourceMapArtifactFormData = Infer; + export const StackSchema = object({ id: string() .length(24, "Id must be exactly 24 characters") @@ -597,6 +630,12 @@ export const UpdateSavedViewSchema = object({ .optional(), columns: record(string(), boolean()).nullable().optional(), column_order: array(string()).nullable().optional(), + column_settings: record( + string(), + lazy(() => SavedViewColumnSettingsSchema), + ) + .nullable() + .optional(), show_stats: boolean().nullable().optional(), show_chart: boolean().nullable().optional(), }); @@ -821,6 +860,12 @@ export const ViewSavedViewSchema = object({ .optional(), columns: record(string(), boolean()).nullable().optional(), column_order: array(string()).nullable().optional(), + column_settings: record( + string(), + lazy(() => SavedViewColumnSettingsSchema), + ) + .nullable() + .optional(), show_stats: boolean().nullable().optional(), show_chart: boolean().nullable().optional(), name: string().min(1, "Name is required"), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 1bf6f60cd4..d2c100c152 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -254,6 +254,7 @@ defaultTime: DEFAULT_TIME_RANGE, filterCacheKey, getColumnOrder: () => table.state.columnOrder, + getColumnSizing: () => table.state.columnSizing, getColumnVisibility: () => table.state.columnVisibility, getFilter: getEffectiveFilter, getFilterDefinitions: () => serializeFilters(filters ?? []), @@ -263,6 +264,7 @@ getTime: getQueryTime, queryParams, setColumnOrder: (v) => table.setColumnOrder(v), + setColumnSizing: (v) => table.setColumnSizing(v), setColumnVisibility: (v) => table.setColumnVisibility(v), setShowChart: (v) => (showChart = v), setShowStats: (v) => (showStats = v), @@ -685,6 +687,7 @@ }); }, defaultColumnVisibility: defaultEventColumnVisibility, + enableColumnResizing: true, paginationStrategy: 'cursor', get queryData() { return eventsQuery.data?.data ?? []; @@ -698,6 +701,7 @@ }), (state) => ({ columnOrder: state.columnOrder, + columnSizing: state.columnSizing, columnVisibility: state.columnVisibility, pagination: state.pagination }) @@ -941,6 +945,7 @@ table.state.columnOrder, + getColumnSizing: () => table.state.columnSizing, getColumnVisibility: () => table.state.columnVisibility, getFilter: getEffectiveFilter, getFilterDefinitions: () => serializeFilters(filters ?? []), @@ -252,6 +253,7 @@ getTime: getQueryTime, queryParams, setColumnOrder: (v) => table.setColumnOrder(v), + setColumnSizing: (v) => table.setColumnSizing(v), setColumnVisibility: (v) => table.setColumnVisibility(v), setShowChart: (v) => (showChart = v), setShowStats: (v) => (showStats = v), @@ -654,6 +656,7 @@ }); }, defaultColumnVisibility: defaultStackColumnVisibility, + enableColumnResizing: true, paginationStrategy: 'offset', get queryData() { return eventsQuery.data?.data ?? []; @@ -667,6 +670,7 @@ }), (state) => ({ columnOrder: state.columnOrder, + columnSizing: state.columnSizing, columnVisibility: state.columnVisibility, pagination: state.pagination }) @@ -834,6 +838,7 @@ table.state.columnOrder, + getColumnSizing: () => table.state.columnSizing, getColumnVisibility: () => table.state.columnVisibility, getFilterDefinitions: () => serializeFilters(filters ?? []), queryParams, setColumnOrder: (v) => table.setColumnOrder(v), + setColumnSizing: (v) => table.setColumnSizing(v), setColumnVisibility: (v) => table.setColumnVisibility(v), updateFilterCache, view: VIEW @@ -192,6 +194,7 @@ return options; }, defaultColumnVisibility: defaultEventColumnVisibility, + enableColumnResizing: true, paginationStrategy: 'cursor', get queryData() { return queryData; @@ -205,6 +208,7 @@ }), (state) => ({ columnOrder: state.columnOrder, + columnSizing: state.columnSizing, columnVisibility: state.columnVisibility }) ); @@ -325,6 +329,7 @@ MapToViewSavedViews(IEnumerable source); + public ViewSavedView MapToViewSavedView(SavedView source) + { + var result = MapToViewSavedViewCore(source); + result.ColumnSettings ??= SavedViewColumnSettings.FromLegacy(source.Columns, source.ColumnOrder); + return result; + } + + public List MapToViewSavedViews(IEnumerable source) + => source.Select(MapToViewSavedView).ToList(); } diff --git a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs index d08f29873b..fa59a76522 100644 --- a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs @@ -58,6 +58,9 @@ public record NewSavedView : IOwnedByOrganization, IValidatableObject [MaxLength(50)] public List? ColumnOrder { get; set; } + [MaxLength(50)] + public Dictionary? ColumnSettings { get; set; } + public bool? ShowStats { get; set; } public bool? ShowChart { get; set; } @@ -96,6 +99,11 @@ public IEnumerable Validate(ValidationContext validationContex { yield return error; } + + foreach (var error in ValidateColumnSettings(ViewType, ColumnSettings)) + { + yield return error; + } } internal static IEnumerable ValidateColumnKeys(string? view, Dictionary? columns) @@ -149,6 +157,75 @@ internal static IEnumerable ValidateColumnOrder(string? view, } } + internal static IEnumerable ValidateColumnSettings( + string? view, + IReadOnlyDictionary? columnSettings) + { + if (columnSettings is null || columnSettings.Count == 0) + { + yield break; + } + + if (columnSettings.Count > 50) + { + yield return new ValidationResult( + "Column settings cannot exceed 50 items.", + [nameof(ColumnSettings)] + ); + } + + var validKeys = view is not null && ValidColumnIds.TryGetValue(view, out var viewKeys) + ? viewKeys + : AllValidColumnIds; + + foreach (string key in columnSettings.Keys.Where(key => !validKeys.Contains(key))) + { + yield return new ValidationResult( + $"Column settings key '{key}' is not a valid column. Valid columns are: {String.Join(", ", validKeys.Order())}.", + [nameof(ColumnSettings)] + ); + } + + foreach (var (key, settings) in columnSettings) + { + if (settings is null) + { + yield return new ValidationResult( + $"Column settings for '{key}' cannot be null.", + [nameof(ColumnSettings)] + ); + continue; + } + + if (settings.Position is < 0 or > SavedViewColumnSettings.MaxPosition) + { + yield return new ValidationResult( + $"Column settings position for '{key}' must be between 0 and {SavedViewColumnSettings.MaxPosition}.", + [nameof(ColumnSettings)] + ); + } + + if (settings.Width is < SavedViewColumnSettings.MinWidth or > SavedViewColumnSettings.MaxWidth) + { + yield return new ValidationResult( + $"Column settings width for '{key}' must be between {SavedViewColumnSettings.MinWidth} and {SavedViewColumnSettings.MaxWidth} pixels.", + [nameof(ColumnSettings)] + ); + } + } + + foreach (var duplicatePosition in columnSettings + .Where(entry => entry.Value is { Position: not null }) + .GroupBy(entry => entry.Value.Position!.Value) + .Where(group => group.Count() > 1)) + { + yield return new ValidationResult( + $"Column settings position '{duplicatePosition.Key}' cannot be repeated.", + [nameof(ColumnSettings)] + ); + } + } + internal static bool IsValidJsonArray(string json) { try diff --git a/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs index 567981d765..c272077eeb 100644 --- a/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs @@ -22,6 +22,8 @@ public class UpdateSavedView : IValidatableObject public Dictionary? Columns { get; set; } [MaxLength(50)] public List? ColumnOrder { get; set; } + [MaxLength(50)] + public Dictionary? ColumnSettings { get; set; } public bool? ShowStats { get; set; } public bool? ShowChart { get; set; } @@ -44,5 +46,10 @@ public IEnumerable Validate(ValidationContext validationContex { yield return error; } + + foreach (var error in NewSavedView.ValidateColumnSettings(null, ColumnSettings)) + { + yield return error; + } } } diff --git a/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs index 0a87f289e7..d1717b8a70 100644 --- a/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Attributes; +using Exceptionless.Core.Models; using Foundatio.Repositories.Models; namespace Exceptionless.Web.Models; @@ -24,6 +25,7 @@ public record ViewSavedView : IIdentity, IHaveDates public string? FilterDefinitions { get; set; } public Dictionary? Columns { get; set; } public List? ColumnOrder { get; set; } + public Dictionary? ColumnSettings { get; set; } public bool? ShowStats { get; set; } public bool? ShowChart { get; set; } public string Name { get; set; } = null!; diff --git a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs index 953bc61b6e..3e37b98026 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs @@ -14,18 +14,18 @@ public class DeltaSchemaTransformer : IOpenApiSchemaTransformer { private static readonly NullabilityInfoContext NullabilityContext = new(); - public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) { var type = context.JsonTypeInfo.Type; // Check if this is a Delta type if (!IsDeltaType(type)) - return Task.CompletedTask; + return; // Get the inner type T from Delta var innerType = type.GetGenericArguments().FirstOrDefault(); if (innerType is null) - return Task.CompletedTask; + return; // Set the type to object schema.Type = JsonSchemaType.Object; @@ -45,6 +45,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext { bool isNullable = IsPropertyNullable(property); var propertySchema = CreateSchemaForType(property.PropertyType, isNullable); + await PopulateNestedSchemaAsync(propertySchema, property.PropertyType, context, cancellationToken); // Apply data annotations from the inner type's property DataAnnotationHelper.ApplyToSchema(propertySchema, property); @@ -57,7 +58,45 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext // Ensure no required array - all properties are optional for PATCH schema.Required = null; - return Task.CompletedTask; + } + + private static async Task PopulateNestedSchemaAsync( + OpenApiSchema schema, + Type type, + OpenApiSchemaTransformerContext context, + CancellationToken cancellationToken) + { + type = Nullable.GetUnderlyingType(type) ?? type; + + if (type.IsGenericType && type.GetInterfaces().Concat([type]).Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>))) + { + var valueType = type.GetGenericArguments().ElementAtOrDefault(1); + if (valueType is not null && IsComplexType(valueType)) + schema.AdditionalProperties = await context.GetOrCreateSchemaAsync(valueType, null, cancellationToken); + + return; + } + + if (TryGetEnumerableElementType(type, out var elementType) && IsComplexType(elementType)) + schema.Items = await context.GetOrCreateSchemaAsync(elementType, null, cancellationToken); + } + + private static bool IsComplexType(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type != typeof(string) + && type != typeof(bool) + && type != typeof(int) + && type != typeof(long) + && type != typeof(short) + && type != typeof(byte) + && type != typeof(double) + && type != typeof(float) + && type != typeof(decimal) + && type != typeof(DateTime) + && type != typeof(DateTimeOffset) + && type != typeof(Guid) + && !type.IsEnum; } private static bool IsDeltaType(Type type) diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index d09dd765c8..f380b6da7e 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -11384,6 +11384,16 @@ "type": "string" } }, + "column_settings": { + "maxLength": 50, + "type": [ + "null", + "object" + ], + "additionalProperties": { + "$ref": "#/components/schemas/SavedViewColumnSettings" + } + }, "show_stats": { "type": [ "null", @@ -12188,6 +12198,15 @@ "type": "string" } }, + "columnSettings": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "$ref": "#/components/schemas/SavedViewColumnSettings" + } + }, "showStats": { "type": [ "null", @@ -12257,6 +12276,35 @@ } } }, + "SavedViewColumnSettings": { + "type": "object", + "properties": { + "visible": { + "type": [ + "null", + "boolean" + ], + "description": "Whether the column is visible. Null means use the table default." + }, + "position": { + "type": [ + "null", + "integer" + ], + "description": "Zero-based display position. Null means use the table default order.", + "format": "int32" + }, + "width": { + "type": [ + "null", + "integer" + ], + "description": "Column width in pixels. Null means use the table default width.", + "format": "int32" + } + }, + "description": "Per-column display settings for a saved view. All properties are optional so new settings can\nbe added without changing the meaning of existing saved views." + }, "Signup": { "required": [ "name", @@ -12644,6 +12692,15 @@ "type": "string" } }, + "column_settings": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "$ref": "#/components/schemas/SavedViewColumnSettings" + } + }, "show_stats": { "type": [ "null", @@ -13452,6 +13509,15 @@ "type": "string" } }, + "column_settings": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "$ref": "#/components/schemas/SavedViewColumnSettings" + } + }, "show_stats": { "type": [ "null", diff --git a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs index 03cf028cc3..7cae102e08 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs @@ -348,6 +348,90 @@ public async Task PostAsync_NewSavedView_ReturnsAbsoluteLocation() Assert.StartsWith("/api/v2/saved-views/", response.Headers.Location.AbsolutePath); } + [Fact] + public async Task PostAsync_ColumnSettings_SynchronizesLegacyColumnFields() + { + // Arrange + var columnSettings = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true, Width = 420 }, + ["project"] = new() { Position = 1, Visible = true, Width = 240 } + }; + + // Act + var result = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Resizable Project View", + ViewType = "events", + ColumnSettings = columnSettings + }) + .StatusCodeShouldBeCreated() + ); + + // Assert + Assert.NotNull(result); + Assert.Equal(240, result.ColumnSettings?["project"].Width); + Assert.True(result.Columns?["project"]); + Assert.Equal(["summary", "project"], result.ColumnOrder); + + var savedView = await _savedViewRepository.GetByIdAsync(result.Id); + Assert.NotNull(savedView); + Assert.Equal(240, savedView.ColumnSettings?["project"].Width); + Assert.True(savedView.Columns?["project"]); + Assert.Equal(["summary", "project"], savedView.ColumnOrder); + } + + [Fact] + public async Task PatchAsync_LegacyColumns_PreservesColumnWidths() + { + // Arrange + var created = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Legacy Column Update", + ViewType = "events", + ColumnSettings = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true, Width = 420 }, + ["project"] = new() { Position = 1, Visible = true, Width = 240 } + } + }) + .StatusCodeShouldBeCreated() + ); + Assert.NotNull(created); + + // Act + var result = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView + { + Columns = new Dictionary + { + ["summary"] = true, + ["project"] = false + } + }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + Assert.False(result.ColumnSettings?["project"].Visible); + Assert.Equal(240, result.ColumnSettings?["project"].Width); + Assert.Equal(1, result.ColumnSettings?["project"].Position); + } + [Fact] public async Task PostAsync_WithIsPrivate_SetsUserIdOnSavedView() { @@ -2212,6 +2296,75 @@ await SendRequestAsync(r => r ); } + [Theory] + [InlineData(47)] + [InlineData(1201)] + public Task PostAsync_ColumnSettingsWidthOutsideRange_ReturnsUnprocessableEntity(int width) + { + // Arrange & Act & Assert + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = $"Invalid Column Width {width}", + ViewType = "events", + ColumnSettings = new Dictionary + { + ["project"] = new() { Width = width } + } + }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_DuplicateColumnSettingsPosition_ReturnsUnprocessableEntity() + { + // Arrange & Act & Assert + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Duplicate Column Position", + ViewType = "events", + ColumnSettings = new Dictionary + { + ["summary"] = new() { Position = 0 }, + ["project"] = new() { Position = 0 } + } + }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_NullColumnSettingsValue_ReturnsUnprocessableEntity() + { + // Arrange & Act & Assert + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Null Column Settings", + ViewType = "events", + ColumnSettings = new Dictionary + { + ["project"] = null! + } + }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + [Fact] public async Task PatchAsync_NameExceedsMaxLength_ReturnsUnprocessableEntity() { diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 9561adc053..89d4e33600 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -80,6 +80,7 @@ public async Task GetOpenApiJson_Default_ContainsExpectedSchemasAndSecuritySchem Assert.True(schemas.TryGetProperty("Login", out _)); Assert.True(schemas.TryGetProperty("Signup", out _)); Assert.True(schemas.TryGetProperty("NewProject", out _)); + Assert.True(schemas.TryGetProperty("SavedViewColumnSettings", out _)); Assert.True(schemas.TryGetProperty("TokenResult", out _)); Assert.True(schemas.TryGetProperty("ViewOrganization", out _)); @@ -133,6 +134,9 @@ public async Task GetOpenApiJson_MigratedContracts_PreserveControllerMetadata() AssertRequiredJsonRequestBody(paths, "/api/v2/tokens/{id}", "put", "UpdateToken"); AssertRequiredJsonRequestBody(paths, "/api/v2/saved-views/{id}", "patch", "UpdateSavedView"); AssertRequiredJsonRequestBody(paths, "/api/v2/saved-views/{id}", "put", "UpdateSavedView"); + AssertDictionaryValueSchema(document.RootElement, "NewSavedView", "column_settings", "SavedViewColumnSettings"); + AssertDictionaryValueSchema(document.RootElement, "UpdateSavedView", "column_settings", "SavedViewColumnSettings"); + AssertDictionaryValueSchema(document.RootElement, "ViewSavedView", "column_settings", "SavedViewColumnSettings"); AssertRequiredJsonRequestBody(paths, "/api/v2/users/{id}", "patch", "UpdateUser"); AssertRequiredJsonRequestBody(paths, "/api/v2/users/{id}", "put", "UpdateUser"); @@ -239,6 +243,17 @@ private static void AssertOperationTag(JsonElement paths, string path, string ex Assert.Equal(expectedTag, Assert.Single(tags.EnumerateArray()).GetString()); } + private static void AssertDictionaryValueSchema(JsonElement document, string schemaName, string propertyName, string expectedValueSchema) + { + var property = document.GetProperty("components") + .GetProperty("schemas") + .GetProperty(schemaName) + .GetProperty("properties") + .GetProperty(propertyName); + + Assert.Equal($"#/components/schemas/{expectedValueSchema}", property.GetProperty("additionalProperties").GetProperty("$ref").GetString()); + } + private static void AssertOptionalParameter(JsonElement paths, string path, string method, string parameterName) { var parameter = Assert.Single(paths.GetProperty(path) diff --git a/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs b/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs index 7505fd4f52..95e2753db2 100644 --- a/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs @@ -28,7 +28,11 @@ public void MapToSavedView_WithValidNewSavedView_MapsAllProperties() ViewType = "stacks", FilterDefinitions = "[{\"type\":\"status\",\"values\":[\"open\",\"regressed\"]}]", Columns = new Dictionary { ["status"] = true, ["users"] = false }, - ColumnOrder = ["summary", "status", "users"] + ColumnOrder = ["summary", "status", "users"], + ColumnSettings = new Dictionary + { + ["status"] = new() { Position = 1, Visible = true, Width = 180 } + } }; // Act @@ -46,6 +50,7 @@ public void MapToSavedView_WithValidNewSavedView_MapsAllProperties() Assert.True(result.Columns["status"]); Assert.False(result.Columns["users"]); Assert.Equal(["summary", "status", "users"], result.ColumnOrder); + Assert.Equal(180, result.ColumnSettings?["status"].Width); } [Fact] @@ -90,6 +95,7 @@ public void MapToSavedView_WithNullOptionalFields_MapsNulls() Assert.Null(result.FilterDefinitions); Assert.Null(result.Columns); Assert.Null(result.ColumnOrder); + Assert.Null(result.ColumnSettings); } [Fact] @@ -108,6 +114,10 @@ public void MapToViewSavedView_WithValidSavedView_MapsAllProperties() FilterDefinitions = "[{\"type\":\"status\",\"values\":[\"open\"]}]", Columns = new Dictionary { ["status"] = true }, ColumnOrder = ["summary", "status"], + ColumnSettings = new Dictionary + { + ["status"] = new() { Position = 1, Visible = true, Width = 180 } + }, Name = "My View", Time = "[now-30d TO now]", Sort = "-last", @@ -131,6 +141,7 @@ public void MapToViewSavedView_WithValidSavedView_MapsAllProperties() Assert.NotNull(result.Columns); Assert.True(result.Columns["status"]); Assert.Equal(["summary", "status"], result.ColumnOrder); + Assert.Equal(180, result.ColumnSettings?["status"].Width); Assert.Equal("My View", result.Name); Assert.Equal("[now-30d TO now]", result.Time); Assert.Equal("-last", result.Sort); @@ -168,6 +179,31 @@ public void MapToViewSavedView_WithNullOptionalFields_MapsNulls() Assert.Null(result.Sort); } + [Fact] + public void MapToViewSavedView_WithLegacyColumnConfiguration_ProjectsColumnSettings() + { + // Arrange + var source = new SavedView + { + Id = "88cd0826e447a44e78877ab1", + OrganizationId = "537650f3b77efe23a47914f3", + CreatedByUserId = "1ecd0826e447ad1e78822555", + Name = "Legacy View", + ViewType = "events", + Columns = new Dictionary { ["project"] = true }, + ColumnOrder = ["summary", "project"] + }; + + // Act + var result = _mapper.MapToViewSavedView(source); + + // Assert + Assert.NotNull(result.ColumnSettings); + Assert.True(result.ColumnSettings["project"].Visible); + Assert.Equal(1, result.ColumnSettings["project"].Position); + Assert.Null(result.ColumnSettings["project"].Width); + } + [Fact] public void MapToViewSavedViews_WithMultipleSavedViews_MapsAll() { diff --git a/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs b/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs index f637598e1d..5459de1609 100644 --- a/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs +++ b/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs @@ -62,4 +62,54 @@ public void GetContentHash_StringFieldDiffersOnlyBySpaces_ReturnsDifferentHash() // Assert Assert.NotEqual(withSpacesHash, withoutSpacesHash); } + + [Fact] + public void GetContentHash_WithoutColumnSettings_PreservesLegacyHash() + { + // Arrange + var savedView = new SavedView + { + Name = "Open Issues", + Slug = "open-issues", + ViewType = "events" + }; + + // Act + string hash = PredefinedSavedViewContentHasher.GetContentHash(savedView); + + // Assert + Assert.Equal("2275a4a7aa2276c78bd20f21b75ce91afbf035bb3795047ba2ed619fdaff12e3", hash); + } + + [Fact] + public void GetContentHash_ColumnSettingsInsertionOrderDiffers_ReturnsSameHash() + { + // Arrange + var original = new SavedView + { + Name = "Logs", + Slug = "logs", + ViewType = "events", + ColumnSettings = new Dictionary + { + ["project"] = new() { Position = 1, Visible = true, Width = 240 }, + ["summary"] = new() { Position = 0, Visible = true, Width = 420 } + } + }; + var reordered = original with + { + ColumnSettings = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true, Width = 420 }, + ["project"] = new() { Position = 1, Visible = true, Width = 240 } + } + }; + + // Act + string originalHash = PredefinedSavedViewContentHasher.GetContentHash(original); + string reorderedHash = PredefinedSavedViewContentHasher.GetContentHash(reordered); + + // Assert + Assert.Equal(originalHash, reorderedHash); + } } diff --git a/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs index b2fee2a891..aea5f144dd 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs @@ -33,6 +33,11 @@ public void RoundTrip_WithAllProperties_PreservesValues() ["date"] = true, ["status"] = false }, + ColumnSettings = new Dictionary + { + ["title"] = new() { Position = 0, Visible = true, Width = 420 }, + ["status"] = new() { Position = 1, Visible = false, Width = 180 } + }, Name = "Open Issues", Time = "[now-7d TO now]", Version = 1, @@ -61,6 +66,8 @@ public void RoundTrip_WithAllProperties_PreservesValues() Assert.Equal(3, result.Columns.Count); Assert.True(result.Columns["title"]); Assert.False(result.Columns["status"]); + Assert.Equal(420, result.ColumnSettings?["title"].Width); + Assert.False(result.ColumnSettings?["status"].Visible); } [Fact] From 9eb09f6ab6005202845fb9da2e614ed5ae5304f6 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 28 Jul 2026 23:53:32 -0500 Subject: [PATCH 02/11] Migrate saved view column configuration --- .../Migrations/003_MigrateSavedViewColumns.cs | 235 ++++++++++++++++++ src/Exceptionless.Core/Models/SavedView.cs | 8 +- .../Models/SavedViewColumnSettings.cs | 81 ------ .../Seed/PredefinedSavedViewConfiguration.cs | 60 +---- .../Seed/PredefinedSavedViewContentHasher.cs | 30 +-- .../Seed/PredefinedSavedViewsDataSeed.cs | 66 +---- .../Seed/predefined-saved-views.json | 34 +-- .../Api/Handlers/SavedViewHandler.cs | 116 +-------- .../saved-views/column-settings.test.ts | 26 +- .../features/saved-views/column-settings.ts | 18 +- .../components/saved-view-picker.svelte | 16 +- .../saved-views/use-saved-views.svelte.ts | 9 +- .../saved-views/use-saved-views.test.ts | 25 +- .../ClientApp/src/lib/generated/api.ts | 16 +- .../ClientApp/src/lib/generated/schemas.ts | 16 +- .../Mapping/SavedViewMapper.cs | 12 +- .../Models/SavedView/NewSavedView.cs | 107 ++------ .../Models/SavedView/UpdateSavedView.cs | 18 +- .../Models/SavedView/ViewSavedView.cs | 4 +- .../Exceptionless.Tests/Api/Data/openapi.json | 77 +----- .../Api/Endpoints/SavedViewEndpointTests.cs | 172 ++++--------- .../Api/OpenApiSnapshotTests.cs | 6 +- .../Mapping/SavedViewMapperTests.cs | 55 +--- ...MigrateSavedViewColumnsIntegrationTests.cs | 115 +++++++++ .../MigrateSavedViewColumnsMigrationTests.cs | 101 ++++++++ .../PredefinedSavedViewContentHasherTests.cs | 28 +-- .../Models/SavedViewSerializerTests.cs | 18 +- 27 files changed, 636 insertions(+), 833 deletions(-) create mode 100644 src/Exceptionless.Core/Migrations/003_MigrateSavedViewColumns.cs create mode 100644 tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs create mode 100644 tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsMigrationTests.cs diff --git a/src/Exceptionless.Core/Migrations/003_MigrateSavedViewColumns.cs b/src/Exceptionless.Core/Migrations/003_MigrateSavedViewColumns.cs new file mode 100644 index 0000000000..60206e0c49 --- /dev/null +++ b/src/Exceptionless.Core/Migrations/003_MigrateSavedViewColumns.cs @@ -0,0 +1,235 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Elastic.Clients.Elasticsearch; +using Elastic.Clients.Elasticsearch.Core.Search; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Seed; +using Foundatio.Repositories.Elasticsearch.Extensions; +using Foundatio.Repositories.Migrations; +using Foundatio.Serializer; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Migrations; + +public sealed class MigrateSavedViewColumns : MigrationBase +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly ElasticsearchClient _client; + private readonly ExceptionlessElasticConfiguration _configuration; + private readonly ITextSerializer _serializer; + + public MigrateSavedViewColumns( + ExceptionlessElasticConfiguration configuration, + ITextSerializer serializer, + ILoggerFactory loggerFactory) : base(loggerFactory) + { + _client = configuration.Client; + _configuration = configuration; + _serializer = serializer; + + MigrationType = MigrationType.VersionedAndResumable; + Version = 3; + } + + public override async Task RunAsync(MigrationContext context) + { + const int pageSize = 500; + var pointInTime = await _client.OpenPointInTimeAsync( + _configuration.SavedViews.VersionedName, + request => request.KeepAlive("2m"), + context.CancellationToken); + _logger.LogRequest(pointInTime); + + if (!pointInTime.IsValidResponse || String.IsNullOrWhiteSpace(pointInTime.Id)) + throw new InvalidOperationException("Unable to open a point in time for the saved view migration."); + + FieldValue[]? searchAfter = null; + int migrated = 0; + + try + { + do + { + var response = await _client.SearchAsync(request => + { + request + .Pit(pit => pit.Id(pointInTime.Id).KeepAlive("2m")) + .Size(pageSize) + .Sort(sort => sort.Field("_shard_doc")); + + if (searchAfter is not null) + request.SearchAfter(searchAfter); + }, context.CancellationToken); + _logger.LogRequest(response); + + if (!response.IsValidResponse) + throw new InvalidOperationException("Unable to read saved views during the column migration."); + + foreach (var hit in response.Hits) + { + if (hit.Source.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + continue; + + var source = JsonNode.Parse(hit.Source.GetRawText())?.AsObject(); + if (source is null) + continue; + + string? legacyContentHash = GetLegacyContentHash(source); + if (!TryMigrate(source)) + continue; + + UpdatePredefinedContentHash(source, legacyContentHash); + + var indexResponse = await _client.IndexAsync( + source, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(hit.Id), + context.CancellationToken); + _logger.LogRequest(indexResponse); + + if (!indexResponse.IsValidResponse) + throw new InvalidOperationException($"Unable to migrate saved view '{hit.Id}'."); + + migrated++; + } + + searchAfter = response.Hits.Count == pageSize + ? response.Hits.Last().Sort!.ToArray() + : null; + + await context.Lock.RenewAsync(); + } while (searchAfter is not null && !context.CancellationToken.IsCancellationRequested); + } + finally + { + var closeResponse = await _client.ClosePointInTimeAsync( + request => request.Id(pointInTime.Id), + CancellationToken.None); + _logger.LogRequest(closeResponse); + } + + _logger.LogInformation("Migrated {SavedViewCount} saved view column configurations", migrated); + } + + internal static bool TryMigrate(JsonObject source) + { + var columns = source["columns"] as JsonObject; + var columnOrder = source["column_order"] as JsonArray; + bool hasLegacyColumns = columns?.Any(entry => entry.Value is JsonValue value && value.TryGetValue(out _)) == true; + + if (!hasLegacyColumns && columnOrder is null) + return false; + + var migratedColumns = new JsonObject(); + if (columns is not null) + { + foreach (var (columnId, value) in columns) + { + if (value is JsonValue jsonValue && jsonValue.TryGetValue(out bool visible)) + { + migratedColumns[columnId] = new JsonObject + { + ["visible"] = visible + }; + } + else if (value is JsonObject settings) + { + migratedColumns[columnId] = settings.DeepClone(); + } + } + } + + if (columnOrder is not null) + { + for (int position = 0; position < columnOrder.Count; position++) + { + string? columnId = columnOrder[position]?.GetValue(); + if (String.IsNullOrWhiteSpace(columnId)) + continue; + + if (migratedColumns[columnId] is not JsonObject settings) + { + settings = new JsonObject(); + migratedColumns[columnId] = settings; + } + + settings["position"] = position; + } + } + + source["columns"] = migratedColumns.Count > 0 ? migratedColumns : null; + source.Remove("column_order"); + return true; + } + + private void UpdatePredefinedContentHash(JsonObject source, string? legacyContentHash) + { + string? predefinedContentHash = source["predefined_content_hash"]?.GetValue(); + if (String.IsNullOrWhiteSpace(predefinedContentHash)) + return; + + if (!String.Equals(predefinedContentHash, legacyContentHash, StringComparison.Ordinal)) + return; + + var savedView = _serializer.Deserialize(source.ToJsonString(JsonOptions)); + if (savedView is null) + throw new InvalidOperationException("Unable to deserialize a migrated saved view."); + + source["predefined_content_hash"] = PredefinedSavedViewContentHasher.GetContentHash(savedView); + } + + internal static string? GetLegacyContentHash(JsonObject source) + { + var columns = source["columns"] as JsonObject; + if (columns?.Any(entry => entry.Value is not JsonValue value || !value.TryGetValue(out _)) == true) + return null; + + var legacyColumns = columns?.ToDictionary( + entry => entry.Key, + entry => entry.Value!.GetValue()); + var columnOrder = (source["column_order"] as JsonArray)? + .Select(value => value?.GetValue()) + .Where(value => value is not null) + .Cast() + .ToList(); + + var content = new + { + name = source["name"]?.GetValue(), + slug = source["slug"]?.GetValue(), + viewType = source["view_type"]?.GetValue(), + filter = source["filter"]?.GetValue(), + time = source["time"]?.GetValue(), + sort = source["sort"]?.GetValue(), + filterDefinitions = CanonicalizeFilterDefinitions(source["filter_definitions"]?.GetValue()), + Columns = legacyColumns?.OrderBy(column => column.Key, StringComparer.Ordinal), + columnOrder, + showStats = source["show_stats"]?.GetValue(), + showChart = source["show_chart"]?.GetValue() + }; + + return JsonSerializer.Serialize(content).ToSHA256(); + } + + private static string? CanonicalizeFilterDefinitions(string? filterDefinitions) + { + if (String.IsNullOrWhiteSpace(filterDefinitions)) + return filterDefinitions; + + try + { + return JsonNode.Parse(filterDefinitions)?.ToJsonString() ?? filterDefinitions; + } + catch (JsonException) + { + return filterDefinitions; + } + } +} diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs index 2083f3622a..7c0b7c9a77 100644 --- a/src/Exceptionless.Core/Models/SavedView.cs +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -45,14 +45,8 @@ public partial record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates [MaxLength(MaxFilterDefinitionsLength)] public string? FilterDefinitions { get; set; } - /// Column visibility state per dashboard table, keyed by column id. - public Dictionary? Columns { get; set; } - - /// Column display order per dashboard table, excluding utility columns. - public List? ColumnOrder { get; set; } - /// Extensible display settings keyed by dashboard column id. - public Dictionary? ColumnSettings { get; set; } + public Dictionary? Columns { get; set; } /// Whether dashboard statistic cards are shown for this view. Null means use the default. public bool? ShowStats { get; set; } diff --git a/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs b/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs index f3f62eec82..c4746ecdac 100644 --- a/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs +++ b/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs @@ -19,85 +19,4 @@ public sealed record SavedViewColumnSettings /// Column width in pixels. Null means use the table default width. public int? Width { get; set; } - public static Dictionary? FromLegacy( - IReadOnlyDictionary? columns, - IReadOnlyCollection? columnOrder) - { - return MergeLegacy(null, columns, columnOrder, columns is not null, columnOrder is not null); - } - - public static Dictionary? MergeLegacy( - IReadOnlyDictionary? current, - IReadOnlyDictionary? columns, - IReadOnlyCollection? columnOrder, - bool replaceVisibility, - bool replaceOrder) - { - var result = current?.ToDictionary(entry => entry.Key, entry => entry.Value with { }) - ?? new Dictionary(); - - if (replaceVisibility) - { - foreach (var settings in result.Values) - settings.Visible = null; - - if (columns is not null) - { - foreach (var (columnId, visible) in columns) - GetOrCreate(result, columnId).Visible = visible; - } - } - - if (replaceOrder) - { - foreach (var settings in result.Values) - settings.Position = null; - - int position = 0; - if (columnOrder is not null) - { - foreach (string columnId in columnOrder) - GetOrCreate(result, columnId).Position = position++; - } - } - - foreach (string columnId in result.Where(entry => IsEmpty(entry.Value)).Select(entry => entry.Key).ToArray()) - result.Remove(columnId); - - return result.Count > 0 ? result : null; - } - - public static List? ToLegacyColumnOrder(IReadOnlyDictionary? columnSettings) - { - var result = columnSettings? - .Where(entry => entry.Value.Position.HasValue) - .OrderBy(entry => entry.Value.Position) - .ThenBy(entry => entry.Key, StringComparer.Ordinal) - .Select(entry => entry.Key) - .ToList(); - - return result is { Count: > 0 } ? result : null; - } - - public static Dictionary? ToLegacyColumns(IReadOnlyDictionary? columnSettings) - { - var result = columnSettings? - .Where(entry => entry.Value.Visible.HasValue) - .ToDictionary(entry => entry.Key, entry => entry.Value.Visible!.Value); - - return result is { Count: > 0 } ? result : null; - } - - private static SavedViewColumnSettings GetOrCreate(IDictionary settings, string columnId) - { - if (settings.TryGetValue(columnId, out var existing)) - return existing; - - var created = new SavedViewColumnSettings(); - settings[columnId] = created; - return created; - } - - private static bool IsEmpty(SavedViewColumnSettings settings) - => settings.Visible is null && settings.Position is null && settings.Width is null; } diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs index f75ecbbb61..95e4ce00da 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs @@ -16,15 +16,7 @@ public static bool Apply(SavedView destination, SavedView source, string key, st changed |= SetIfChanged(destination, source.Time, static (view, value) => view.Time = value, static view => view.Time); changed |= SetIfChanged(destination, source.Sort, static (view, value) => view.Sort = value, static view => view.Sort); changed |= SetIfChanged(destination, source.FilterDefinitions, static (view, value) => view.FilterDefinitions = value, static view => view.FilterDefinitions); - var columns = source.ColumnSettings is null - ? source.Columns - : SavedViewColumnSettings.ToLegacyColumns(source.ColumnSettings); - var columnOrder = source.ColumnSettings is null - ? source.ColumnOrder - : SavedViewColumnSettings.ToLegacyColumnOrder(source.ColumnSettings); - changed |= SetDictionaryIfChanged(destination, columns); - changed |= SetListIfChanged(destination, columnOrder); - changed |= SetColumnSettingsIfChanged(destination, source.ColumnSettings); + changed |= SetColumnsIfChanged(destination, source.Columns); changed |= SetIfChanged(destination, source.ShowStats, static (view, value) => view.ShowStats = value, static view => view.ShowStats); changed |= SetIfChanged(destination, source.ShowChart, static (view, value) => view.ShowChart = value, static view => view.ShowChart); changed |= SetIfChanged(destination, source.Version, static (view, value) => view.Version = value, static view => view.Version); @@ -33,15 +25,6 @@ public static bool Apply(SavedView destination, SavedView source, string key, st return changed; } - private static bool SetDictionaryIfChanged(SavedView savedView, IReadOnlyDictionary? value) - { - if (DictionaryEquals(savedView.Columns, value)) - return false; - - savedView.Columns = value is null ? null : new Dictionary(value); - return true; - } - private static bool SetIfChanged(SavedView savedView, T value, Action setter, Func getter) { if (EqualityComparer.Default.Equals(getter(savedView), value)) @@ -51,47 +34,16 @@ private static bool SetIfChanged(SavedView savedView, T value, Action? value) + private static bool SetColumnsIfChanged(SavedView savedView, IReadOnlyDictionary? value) { - if (ColumnSettingsEqual(savedView.ColumnSettings, value)) + if (ColumnsEqual(savedView.Columns, value)) return false; - savedView.ColumnSettings = CopyColumnSettings(value); + savedView.Columns = CopyColumns(value); return true; } - private static bool SetListIfChanged(SavedView savedView, IReadOnlyCollection? value) - { - if (CollectionEquals(savedView.ColumnOrder, value)) - return false; - - savedView.ColumnOrder = value is null ? null : [.. value]; - return true; - } - - private static bool CollectionEquals(IReadOnlyCollection? left, IReadOnlyCollection? right) - { - if (ReferenceEquals(left, right)) - return true; - - if (left is null || right is null || left.Count != right.Count) - return false; - - return left.SequenceEqual(right, StringComparer.Ordinal); - } - - private static bool DictionaryEquals(IReadOnlyDictionary? left, IReadOnlyDictionary? right) - { - if (ReferenceEquals(left, right)) - return true; - - if (left is null || right is null || left.Count != right.Count) - return false; - - return left.All(kvp => right.TryGetValue(kvp.Key, out bool value) && value == kvp.Value); - } - - private static bool ColumnSettingsEqual( + private static bool ColumnsEqual( IReadOnlyDictionary? left, IReadOnlyDictionary? right) { @@ -104,6 +56,6 @@ private static bool ColumnSettingsEqual( return left.All(entry => right.TryGetValue(entry.Key, out var value) && entry.Value == value); } - private static Dictionary? CopyColumnSettings(IReadOnlyDictionary? value) + private static Dictionary? CopyColumns(IReadOnlyDictionary? value) => value?.ToDictionary(entry => entry.Key, entry => entry.Value with { }); } diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs index 174e341445..cd5b111718 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs @@ -20,8 +20,6 @@ public static string GetContentHash(SavedView savedView) savedView.Sort, savedView.FilterDefinitions, savedView.Columns, - savedView.ColumnOrder, - savedView.ColumnSettings, savedView.ShowStats, savedView.ShowChart); } @@ -44,8 +42,6 @@ public static string GetDefinitionsContentHash(IEnumerable? columns, - IReadOnlyCollection? columnOrder, - IReadOnlyDictionary? columnSettings, + IReadOnlyDictionary? columns, bool? showStats, bool? showChart) { - if (columnSettings is null) - { - var legacyContent = new - { - name, - slug, - viewType, - filter, - time, - sort, - filterDefinitions = CanonicalizeFilterDefinitions(filterDefinitions), - Columns = columns?.OrderBy(column => column.Key, StringComparer.Ordinal), - columnOrder, - showStats, - showChart - }; - - return SerializeAndHash(legacyContent); - } - var content = new { name, @@ -97,8 +71,6 @@ private static string GetContentHash( sort, filterDefinitions = CanonicalizeFilterDefinitions(filterDefinitions), Columns = columns?.OrderBy(column => column.Key, StringComparer.Ordinal), - columnOrder, - ColumnSettings = columnSettings.OrderBy(column => column.Key, StringComparer.Ordinal), showStats, showChart }; diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs index ab1c11fd55..1c7009c1ba 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs @@ -129,27 +129,9 @@ private static bool ApplyDefinition(SavedView existing, PredefinedSavedViewDefin changed = true; } - var columns = definition.ColumnSettings is null - ? definition.Columns - : SavedViewColumnSettings.ToLegacyColumns(definition.ColumnSettings); - if (!DictionaryEquals(existing.Columns, columns)) + if (!ColumnsEqual(existing.Columns, definition.Columns)) { - existing.Columns = columns?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - changed = true; - } - - var columnOrder = definition.ColumnSettings is null - ? definition.ColumnOrder - : SavedViewColumnSettings.ToLegacyColumnOrder(definition.ColumnSettings); - if (!CollectionEquals(existing.ColumnOrder, columnOrder)) - { - existing.ColumnOrder = columnOrder is null ? null : [.. columnOrder]; - changed = true; - } - - if (!ColumnSettingsEqual(existing.ColumnSettings, definition.ColumnSettings)) - { - existing.ColumnSettings = CopyColumnSettings(definition.ColumnSettings); + existing.Columns = CopyColumns(definition.Columns); changed = true; } @@ -202,47 +184,17 @@ private static SavedView CreateSavedView(PredefinedSavedViewDefinition definitio Time = definition.Time, Sort = definition.Sort, FilterDefinitions = GetRawJson(definition.FilterDefinitions), - Columns = definition.Columns?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value), - ColumnOrder = definition.ColumnOrder is null ? null : [.. definition.ColumnOrder], - ColumnSettings = CopyColumnSettings(definition.ColumnSettings), + Columns = CopyColumns(definition.Columns), ShowStats = definition.ShowStats, ShowChart = definition.ShowChart, Version = 1 }; - if (savedView.ColumnSettings is not null) - { - savedView.Columns = SavedViewColumnSettings.ToLegacyColumns(savedView.ColumnSettings); - savedView.ColumnOrder = SavedViewColumnSettings.ToLegacyColumnOrder(savedView.ColumnSettings); - } - savedView.PredefinedContentHash = PredefinedSavedViewContentHasher.GetContentHash(savedView); return savedView; } - private static bool CollectionEquals(IReadOnlyCollection? left, IReadOnlyCollection? right) - { - if (ReferenceEquals(left, right)) - return true; - - if (left is null || right is null || left.Count != right.Count) - return false; - - return left.SequenceEqual(right, StringComparer.Ordinal); - } - - private static bool DictionaryEquals(IReadOnlyDictionary? left, IReadOnlyDictionary? right) - { - if (ReferenceEquals(left, right)) - return true; - - if (left is null || right is null || left.Count != right.Count) - return false; - - return left.All(kvp => right.TryGetValue(kvp.Key, out bool value) && value == kvp.Value); - } - - private static bool ColumnSettingsEqual( + private static bool ColumnsEqual( IReadOnlyDictionary? left, IReadOnlyDictionary? right) { @@ -255,7 +207,7 @@ private static bool ColumnSettingsEqual( return left.All(entry => right.TryGetValue(entry.Key, out var value) && entry.Value == value); } - private static Dictionary? CopyColumnSettings( + private static Dictionary? CopyColumns( IReadOnlyDictionary? value) => value?.ToDictionary(entry => entry.Key, entry => entry.Value with { }); @@ -295,13 +247,7 @@ public sealed record PredefinedSavedViewDefinition public JsonElement? FilterDefinitions { get; init; } [JsonPropertyName("columns")] - public IReadOnlyDictionary? Columns { get; init; } - - [JsonPropertyName("columnOrder")] - public IReadOnlyCollection? ColumnOrder { get; init; } - - [JsonPropertyName("columnSettings")] - public IReadOnlyDictionary? ColumnSettings { get; init; } + public IReadOnlyDictionary? Columns { get; init; } [JsonPropertyName("showStats")] public bool? ShowStats { get; init; } diff --git a/src/Exceptionless.Core/Seed/predefined-saved-views.json b/src/Exceptionless.Core/Seed/predefined-saved-views.json index 52ab08fb15..e5231f7225 100644 --- a/src/Exceptionless.Core/Seed/predefined-saved-views.json +++ b/src/Exceptionless.Core/Seed/predefined-saved-views.json @@ -17,11 +17,11 @@ } ], "columns": { - "level": false, - "message": false, - "name": false, - "source": false, - "type": false + "level": { "visible": false }, + "message": { "visible": false }, + "name": { "visible": false }, + "source": { "visible": false }, + "type": { "visible": false } }, "showStats": true, "showChart": true @@ -56,12 +56,12 @@ } ], "columns": { - "exception_type": false, - "level": false, - "message": false, - "name": false, - "source": false, - "type": false + "exception_type": { "visible": false }, + "level": { "visible": false }, + "message": { "visible": false }, + "name": { "visible": false }, + "source": { "visible": false }, + "type": { "visible": false } }, "showStats": true, "showChart": true @@ -96,12 +96,12 @@ } ], "columns": { - "exception_type": false, - "level": false, - "message": false, - "name": false, - "source": false, - "type": false + "exception_type": { "visible": false }, + "level": { "visible": false }, + "message": { "visible": false }, + "name": { "visible": false }, + "source": { "visible": false }, + "type": { "visible": false } }, "showStats": true, "showChart": true diff --git a/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs index 6f642f2158..78d4edc886 100644 --- a/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs @@ -151,9 +151,7 @@ public async Task>> Ha Time = definition.Time, Sort = definition.Sort, FilterDefinitions = definition.FilterDefinitions is { } filterDefinitions ? JsonSerializer.Serialize(filterDefinitions) : null, - Columns = definition.Columns is { } columns ? new Dictionary(columns) : null, - ColumnOrder = definition.ColumnOrder?.ToList(), - ColumnSettings = CopyColumnSettings(definition.ColumnSettings), + Columns = CopyColumns(definition.Columns), ShowStats = definition.ShowStats, ShowChart = definition.ShowChart, Version = 1 @@ -161,12 +159,7 @@ public async Task>> Ha .ToList(); foreach (var savedView in savedViews) - { - if (savedView.ColumnSettings is not null) - SynchronizeColumnConfiguration(savedView); - savedView.PredefinedContentHash = PredefinedSavedViewContentHasher.GetContentHash(savedView); - } if (savedViews.Count > 0) await repository.AddAsync(savedViews, o => o.Cache().ImmediateConsistency()); @@ -222,7 +215,6 @@ public async Task> Handle(UpdateSavedViewMessage message) var changedNames = message.Changes.GetChangedPropertyNames(); message.Changes.Patch(original); - SynchronizeColumnConfiguration(original, changedNames); if (changedNames.Contains(nameof(UpdateSavedView.Slug))) original.Slug = ToSlug(original.Slug); @@ -274,7 +266,6 @@ private async Task> PostImplAsync(NewSavedView value) return Result.BadRequest("Saved view value is required."); var mapped = mapper.MapToSavedView(value); - SynchronizeColumnConfiguration(mapped); mapped.Slug = ToSlug(String.IsNullOrWhiteSpace(mapped.Slug) ? mapped.Name : mapped.Slug); if (String.IsNullOrEmpty(mapped.OrganizationId) && HttpContext.Request.GetAssociatedOrganizationIds().Count > 0) @@ -385,9 +376,7 @@ private async Task> PostImplAsync(NewSavedView value) return Result.Invalid(ValidationError.Create("filter_definitions", "FilterDefinitions must be a valid JSON array.")); } - if (changedNames.Contains(nameof(UpdateSavedView.Columns)) - || changedNames.Contains(nameof(UpdateSavedView.ColumnOrder)) - || changedNames.Contains(nameof(UpdateSavedView.ColumnSettings))) + if (changedNames.Contains(nameof(UpdateSavedView.Columns))) { var patchedChanges = new UpdateSavedView(); changes.Patch(patchedChanges); @@ -497,40 +486,7 @@ private static Result PermissionToResult(PermissionResult permission) if (changes.Columns is not null && changes.Columns.Count > 50) return new ValidationResult("Columns cannot exceed 50 items.", [nameof(UpdateSavedView.Columns)]); - if (changes.ColumnOrder is not null && changes.ColumnOrder.Count > 50) - return new ValidationResult("ColumnOrder cannot exceed 50 items.", [nameof(UpdateSavedView.ColumnOrder)]); - - if (changes.ColumnSettings is not null && changes.ColumnSettings.Count > 50) - return new ValidationResult("ColumnSettings cannot exceed 50 items.", [nameof(UpdateSavedView.ColumnSettings)]); - - return NewSavedView.ValidateColumnKeys(viewType, changes.Columns) - .Concat(NewSavedView.ValidateColumnOrder(viewType, changes.ColumnOrder)) - .Concat(NewSavedView.ValidateColumnSettings(viewType, changes.ColumnSettings)) - .FirstOrDefault(); - } - - private static void SynchronizeColumnConfiguration(SavedView savedView, IEnumerable? changedNames = null) - { - bool columnSettingsChanged = changedNames is null || changedNames.Contains(nameof(UpdateSavedView.ColumnSettings)); - bool columnsChanged = changedNames is null || changedNames.Contains(nameof(UpdateSavedView.Columns)); - bool columnOrderChanged = changedNames is null || changedNames.Contains(nameof(UpdateSavedView.ColumnOrder)); - - if (columnSettingsChanged && savedView.ColumnSettings is not null) - { - savedView.Columns = SavedViewColumnSettings.ToLegacyColumns(savedView.ColumnSettings); - savedView.ColumnOrder = SavedViewColumnSettings.ToLegacyColumnOrder(savedView.ColumnSettings); - return; - } - - if (columnsChanged || columnOrderChanged) - { - savedView.ColumnSettings = SavedViewColumnSettings.MergeLegacy( - savedView.ColumnSettings, - savedView.Columns, - savedView.ColumnOrder, - columnsChanged, - columnOrderChanged); - } + return NewSavedView.ValidateColumns(viewType, changes.Columns).FirstOrDefault(); } // --- Predefined saved views logic --- @@ -706,17 +662,12 @@ private SavedView CreatePredefinedSavedView(string organizationId, PredefinedSav Time = definition.Time, Sort = definition.Sort, FilterDefinitions = PredefinedSavedViewsDataSeed.GetRawJson(definition.FilterDefinitions), - Columns = Copy(definition.Columns), - ColumnOrder = definition.ColumnOrder is null ? null : [.. definition.ColumnOrder], - ColumnSettings = CopyColumnSettings(definition.ColumnSettings), + Columns = CopyColumns(definition.Columns), ShowStats = definition.ShowStats, ShowChart = definition.ShowChart, Version = 1 }; - if (savedView.ColumnSettings is not null) - SynchronizeColumnConfiguration(savedView); - savedView.PredefinedContentHash = PredefinedSavedViewContentHasher.GetContentHash(savedView); return savedView; } @@ -731,15 +682,7 @@ private static bool ApplyPredefinedSavedView(SavedView savedView, PredefinedSave changed |= SetIfChanged(savedView, definition.Time, static (view, value) => view.Time = value, static view => view.Time); changed |= SetIfChanged(savedView, definition.Sort, static (view, value) => view.Sort = value, static view => view.Sort); changed |= SetIfChanged(savedView, PredefinedSavedViewsDataSeed.GetRawJson(definition.FilterDefinitions), static (view, value) => view.FilterDefinitions = value, static view => view.FilterDefinitions); - var columns = definition.ColumnSettings is null - ? definition.Columns - : SavedViewColumnSettings.ToLegacyColumns(definition.ColumnSettings); - var columnOrder = definition.ColumnSettings is null - ? definition.ColumnOrder - : SavedViewColumnSettings.ToLegacyColumnOrder(definition.ColumnSettings); - changed |= SetDictionaryIfChanged(savedView, columns); - changed |= SetListIfChanged(savedView, columnOrder); - changed |= SetColumnSettingsIfChanged(savedView, definition.ColumnSettings); + changed |= SetColumnsIfChanged(savedView, definition.Columns); changed |= SetIfChanged(savedView, definition.ShowStats, static (view, value) => view.ShowStats = value, static view => view.ShowStats); changed |= SetIfChanged(savedView, definition.ShowChart, static (view, value) => view.ShowChart = value, static view => view.ShowChart); changed |= SetIfChanged(savedView, 1, static (view, value) => view.Version = value, static view => view.Version); @@ -846,8 +789,6 @@ private static PredefinedSavedViewDefinition ToPredefinedSavedView(SavedView sav Sort = savedView.Sort, FilterDefinitions = ParseFilterDefinitions(savedView.FilterDefinitions), Columns = savedView.Columns, - ColumnOrder = savedView.ColumnOrder, - ColumnSettings = savedView.ColumnSettings, ShowStats = savedView.ShowStats, ShowChart = savedView.ShowChart }; @@ -885,57 +826,20 @@ private static bool SetIfChanged(SavedView savedView, T value, Action? value) + private static bool SetColumnsIfChanged(SavedView savedView, IReadOnlyDictionary? value) { - if (DictionaryEquals(savedView.Columns, value)) + if (ColumnsEqual(savedView.Columns, value)) return false; - savedView.Columns = Copy(value); + savedView.Columns = CopyColumns(value); return true; } - private static bool SetListIfChanged(SavedView savedView, IReadOnlyCollection? value) - { - if ((savedView.ColumnOrder ?? []).SequenceEqual(value ?? [])) - return false; - - savedView.ColumnOrder = value is null ? null : [.. value]; - return true; - } - - private static bool SetColumnSettingsIfChanged(SavedView savedView, IReadOnlyDictionary? value) - { - if (ColumnSettingsEqual(savedView.ColumnSettings, value)) - return false; - - savedView.ColumnSettings = CopyColumnSettings(value); - return true; - } - - private static Dictionary? Copy(IReadOnlyDictionary? value) - { - return value is null ? null : value.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - - private static Dictionary? CopyColumnSettings( + private static Dictionary? CopyColumns( IReadOnlyDictionary? value) => value?.ToDictionary(entry => entry.Key, entry => entry.Value with { }); - private static bool DictionaryEquals(IReadOnlyDictionary? left, IReadOnlyDictionary? right) - { - if (left is null) - return right is null; - - if (right is null) - return false; - - if (left.Count != right.Count) - return false; - - return left.All(kvp => right.TryGetValue(kvp.Key, out var value) && value == kvp.Value); - } - - private static bool ColumnSettingsEqual( + private static bool ColumnsEqual( IReadOnlyDictionary? left, IReadOnlyDictionary? right) { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts index 43c1af3479..2063df8b9b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts @@ -19,39 +19,25 @@ describe('saved view column settings', () => { }); }); - it('uses settings instead of legacy fields when settings are available', () => { + it('reads order, visibility, and width from structured columns', () => { const view = { - column_order: ['summary', 'project'], - column_settings: { + columns: { project: { position: 0, visible: true, width: 360 }, summary: { position: 1, visible: false } - }, - columns: { project: false, summary: true } - } as Pick; + } + } as Pick; expect(getSavedColumnOrder(view)).toEqual(['project', 'summary']); expect(getSavedColumnVisibility(view)).toEqual({ project: true, summary: false }); expect(getSavedColumnSizing(view)).toEqual({ project: 360 }); }); - it('falls back to legacy visibility and order', () => { - const view = { - column_order: ['project', 'summary'], - column_settings: null, - columns: { project: true, summary: false } - } as Pick; - - expect(getSavedColumnOrder(view)).toEqual(['project', 'summary']); - expect(getSavedColumnVisibility(view)).toEqual({ project: true, summary: false }); - expect(getSavedColumnSizing(view)).toEqual({}); - }); - it('detects changed and reset column widths', () => { const view = { - column_settings: { + columns: { project: { width: 360 } } - } as Pick; + } as Pick; expect(savedViewColumnSizingEqual({ project: 360 }, view)).toBe(true); expect(savedViewColumnSizingEqual({ project: 420 }, view)).toBe(false); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts index 1dc6d6389d..9d888dac44 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts @@ -2,7 +2,7 @@ import type { ColumnOrderState, ColumnSizingState, ColumnVisibilityState } from import type { SavedView, SavedViewColumnSettings } from './models'; -type SavedColumnState = Pick; +type SavedColumnState = Pick; export function buildColumnSettings( columnIds: string[], @@ -30,33 +30,31 @@ export function buildColumnSettings( } export function getSavedColumnOrder(view: SavedColumnState | undefined): ColumnOrderState { - const settingsOrder = Object.entries(view?.column_settings ?? {}) + const settingsOrder = Object.entries(view?.columns ?? {}) .filter((entry): entry is [string, SavedViewColumnSettings & { position: number }] => entry[1].position != null) .sort(([leftId, left], [rightId, right]) => left.position - right.position || leftId.localeCompare(rightId)) .map(([columnId]) => columnId); - return settingsOrder.length > 0 ? settingsOrder : (view?.column_order ?? []); + return settingsOrder; } -export function getSavedColumnSizing(view: Pick | undefined): ColumnSizingState { +export function getSavedColumnSizing(view: SavedColumnState | undefined): ColumnSizingState { return Object.fromEntries( - Object.entries(view?.column_settings ?? {}) + Object.entries(view?.columns ?? {}) .filter((entry): entry is [string, SavedViewColumnSettings & { width: number }] => entry[1].width != null) .map(([columnId, settings]) => [columnId, settings.width]) ); } export function getSavedColumnVisibility(view: SavedColumnState | undefined): ColumnVisibilityState { - const settingsVisibility = Object.fromEntries( - Object.entries(view?.column_settings ?? {}) + return Object.fromEntries( + Object.entries(view?.columns ?? {}) .filter((entry): entry is [string, SavedViewColumnSettings & { visible: boolean }] => entry[1].visible != null) .map(([columnId, settings]) => [columnId, settings.visible]) ); - - return Object.keys(settingsVisibility).length > 0 ? settingsVisibility : (view?.columns ?? {}); } -export function savedViewColumnSizingEqual(current: ColumnSizingState | undefined, view: Pick): boolean { +export function savedViewColumnSizingEqual(current: ColumnSizingState | undefined, view: SavedColumnState): boolean { const saved = getSavedColumnSizing(view); const currentEntries = Object.entries(current ?? {}).filter(([columnId]) => columnId !== 'select'); const savedEntries = Object.entries(saved); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index 54492a715f..fe27d81b69 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -157,13 +157,6 @@ isRenameDialogOpen = true; } - function getSavedColumnOrder(): string[] | undefined { - const currentColumnIds = new Set(table.getAllLeafColumns().map((column) => column.id)); - const savedColumnOrder = (columnOrder ?? []).filter((columnId) => columnId !== 'select' && currentColumnIds.has(columnId)); - - return savedColumnOrder.length > 0 ? savedColumnOrder : undefined; - } - function getSavedColumnSettings() { return buildColumnSettings( table.getAllLeafColumns().map((column) => column.id), @@ -190,11 +183,8 @@ } const filterDefinitions = serializeFilters(filters); - const savedColumnOrder = getSavedColumnOrder(); const body: NewSavedView = { - column_order: savedColumnOrder, - column_settings: getSavedColumnSettings(), - columns: columnVisibility, + columns: getSavedColumnSettings(), filter: currentFilterString || undefined, filter_definitions: filterDefinitions, is_private: isPrivate || undefined, @@ -234,9 +224,7 @@ function getUpdateBody(): UpdateSavedView { return { - column_order: getSavedColumnOrder() ?? null, - column_settings: getSavedColumnSettings(), - columns: columnVisibility, + columns: getSavedColumnSettings(), filter: currentFilterString || null, filter_definitions: serializeFilters(filters), show_chart: showChart, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 99a05d2bde..44a6559546 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -101,10 +101,6 @@ export function hasMissingSavedViewSlug(options: { return !!options.slug && !options.activeSavedView && !!options.savedViews && !options.isLoading; } -export function hasSavedColumnOrder(columnOrder: null | string[] | undefined): columnOrder is string[] { - return !!columnOrder?.length; -} - export function hasSavedViewColumnChanges( current: ColumnVisibilityState | undefined, saved: null | Record | undefined, @@ -188,7 +184,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return undefined; }); - function applyColumnState(view: Pick | undefined): void { + function applyColumnState(view: Pick | undefined): void { if (options.setColumnVisibility) { options.setColumnVisibility(getSavedColumnVisibility(view)); } @@ -284,7 +280,6 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur if ( options.getColumnVisibility && - (view.columns != null || Object.values(view.column_settings ?? {}).some((settings) => settings.visible != null)) && hasSavedViewColumnChanges(options.getColumnVisibility(), getSavedColumnVisibility(view), options.defaultColumnVisibility) ) { return true; @@ -292,7 +287,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur if ( options.getColumnOrder && - (hasSavedColumnOrder(view.column_order) || Object.values(view.column_settings ?? {}).some((settings) => settings.position != null)) && + Object.values(view.columns ?? {}).some((settings) => settings.position != null) && !columnOrderEqual(options.getColumnOrder(), getSavedColumnOrder(view)) ) { return true; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts index c140942118..1b0ab25c51 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts @@ -12,7 +12,6 @@ import { getComparableSavedViewFilter, getComparableSavedViewTime, hasMissingSavedViewSlug, - hasSavedColumnOrder, hasSavedViewColumnChanges, savedViewColumnsEqual, type SavedViewQueryParams, @@ -42,7 +41,6 @@ function buildSavedView({ id, name, ...overrides }: Partial & Pick { }); describe('column comparison', () => { - it('treats legacy visibility missing default-hidden columns as unchanged', () => { + it('treats visibility missing default-hidden columns as unchanged', () => { // Arrange const current = { project: false, summary: true, tags: false }; - const legacySaved = { summary: true }; + const saved = { summary: true }; const defaults = { project: false, tags: false }; // Act - const result = savedViewColumnsEqual(current, legacySaved, defaults); + const result = savedViewColumnsEqual(current, saved, defaults); // Assert expect(result).toBe(true); @@ -216,16 +214,15 @@ describe('useSavedViews', () => { it('detects a changed column after applying default visibility', () => { // Arrange const current = { project: true, summary: true, tags: false }; - const legacySaved = { summary: true }; + const saved = { summary: true }; const defaults = { project: false, tags: false }; // Act - const result = savedViewColumnsEqual(current, legacySaved, defaults); + const result = savedViewColumnsEqual(current, saved, defaults); // Assert expect(result).toBe(false); }); - it('marks a saved view as changed when adding a column omitted from its settings', () => { // Arrange const current = { project: true, tags: false }; @@ -260,18 +257,6 @@ describe('useSavedViews', () => { // Assert expect(result).toBe(false); }); - - it('does not compare column order when a saved view omits or clears column order', () => { - // Act & Assert - expect(hasSavedColumnOrder(null)).toBe(false); - expect(hasSavedColumnOrder(undefined)).toBe(false); - expect(hasSavedColumnOrder([])).toBe(false); - }); - - it('compares explicit saved column order', () => { - // Act & Assert - expect(hasSavedColumnOrder(['summary', 'events'])).toBe(true); - }); }); describe('time parameter detection', () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 1f60048b86..420dadd4ae 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -144,9 +144,7 @@ export interface NewSavedView { slug?: null | string; view_type: string; filter_definitions?: null | string; - columns?: null | Record; - column_order?: string[] | null; - column_settings?: null | Record; + columns?: null | Record; show_stats?: null | boolean; show_chart?: null | boolean; /** If true, the view will only be visible to the current user. Defaults to false. */ @@ -369,9 +367,7 @@ export interface PredefinedSavedViewDefinition { time?: null | string; sort?: null | string; filterDefinitions?: null | JsonElement; - columns?: null | Record; - columnOrder?: string[] | null; - columnSettings?: null | Record; + columns?: null | Record; showStats?: null | boolean; showChart?: null | boolean; } @@ -534,9 +530,7 @@ export interface UpdateSavedView { sort?: null | string; slug?: null | string; filter_definitions?: null | string; - columns?: null | Record; - column_order?: string[] | null; - column_settings?: null | Record; + columns?: null | Record; show_stats?: null | boolean; show_chart?: null | boolean; } @@ -760,9 +754,7 @@ export interface ViewSavedView { updated_by_user_id?: null | string; filter?: null | string; filter_definitions?: null | string; - columns?: null | Record; - column_order?: string[] | null; - column_settings?: null | Record; + columns?: null | Record; show_stats?: null | boolean; show_chart?: null | boolean; name: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index abe05d78af..73bd18e397 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -210,9 +210,7 @@ export const NewSavedViewSchema = object({ .max(100000, "Filter definitions must be at most 100000 characters") .nullable() .optional(), - columns: record(string(), boolean()).nullable().optional(), - column_order: array(string()).nullable().optional(), - column_settings: record( + columns: record( string(), lazy(() => SavedViewColumnSettingsSchema), ) @@ -480,9 +478,7 @@ export const PredefinedSavedViewDefinitionSchema = object({ time: string().min(1, "Time is required").nullable().optional(), sort: string().min(1, "Sort is required").nullable().optional(), filterDefinitions: unknown().optional(), - columns: record(string(), boolean()).nullable().optional(), - columnOrder: array(string()).nullable().optional(), - columnSettings: record( + columns: record( string(), lazy(() => SavedViewColumnSettingsSchema), ) @@ -628,9 +624,7 @@ export const UpdateSavedViewSchema = object({ .min(1, "Filter definitions is required") .nullable() .optional(), - columns: record(string(), boolean()).nullable().optional(), - column_order: array(string()).nullable().optional(), - column_settings: record( + columns: record( string(), lazy(() => SavedViewColumnSettingsSchema), ) @@ -858,9 +852,7 @@ export const ViewSavedViewSchema = object({ .min(1, "Filter definitions is required") .nullable() .optional(), - columns: record(string(), boolean()).nullable().optional(), - column_order: array(string()).nullable().optional(), - column_settings: record( + columns: record( string(), lazy(() => SavedViewColumnSettingsSchema), ) diff --git a/src/Exceptionless.Web/Mapping/SavedViewMapper.cs b/src/Exceptionless.Web/Mapping/SavedViewMapper.cs index 761f1d3304..56b185c918 100644 --- a/src/Exceptionless.Web/Mapping/SavedViewMapper.cs +++ b/src/Exceptionless.Web/Mapping/SavedViewMapper.cs @@ -12,15 +12,7 @@ public partial class SavedViewMapper [MapperIgnoreTarget(nameof(SavedView.UpdatedByUserId))] public partial SavedView MapToSavedView(NewSavedView source); - private partial ViewSavedView MapToViewSavedViewCore(SavedView source); + public partial ViewSavedView MapToViewSavedView(SavedView source); - public ViewSavedView MapToViewSavedView(SavedView source) - { - var result = MapToViewSavedViewCore(source); - result.ColumnSettings ??= SavedViewColumnSettings.FromLegacy(source.Columns, source.ColumnOrder); - return result; - } - - public List MapToViewSavedViews(IEnumerable source) - => source.Select(MapToViewSavedView).ToList(); + public partial List MapToViewSavedViews(IEnumerable source); } diff --git a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs index fa59a76522..10aecce6df 100644 --- a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs @@ -53,13 +53,7 @@ public record NewSavedView : IOwnedByOrganization, IValidatableObject public string? FilterDefinitions { get; set; } [MaxLength(50)] - public Dictionary? Columns { get; set; } - - [MaxLength(50)] - public List? ColumnOrder { get; set; } - - [MaxLength(50)] - public Dictionary? ColumnSettings { get; set; } + public Dictionary? Columns { get; set; } public bool? ShowStats { get; set; } @@ -90,109 +84,48 @@ public IEnumerable Validate(ValidationContext validationContex ); } - foreach (var error in ValidateColumnKeys(ViewType, Columns)) - { - yield return error; - } - - foreach (var error in ValidateColumnOrder(ViewType, ColumnOrder)) - { - yield return error; - } - - foreach (var error in ValidateColumnSettings(ViewType, ColumnSettings)) + foreach (var error in ValidateColumns(ViewType, Columns)) { yield return error; } } - internal static IEnumerable ValidateColumnKeys(string? view, Dictionary? columns) + internal static IEnumerable ValidateColumns( + string? view, + IReadOnlyDictionary? columns) { if (columns is null || columns.Count == 0) { yield break; } - var validKeys = view is not null && ValidColumnIds.TryGetValue(view, out var viewKeys) - ? viewKeys - : AllValidColumnIds; - - var invalidKeys = columns.Keys.Where(key => !validKeys.Contains(key)); - foreach (string key in invalidKeys) + if (columns.Count > 50) { yield return new ValidationResult( - $"Column key '{key}' is not a valid column. Valid columns are: {String.Join(", ", validKeys.Order())}.", + "Columns cannot exceed 50 items.", [nameof(Columns)] ); } - } - - internal static IEnumerable ValidateColumnOrder(string? view, IReadOnlyCollection? columnOrder) - { - if (columnOrder is null || columnOrder.Count == 0) - { - yield break; - } var validKeys = view is not null && ValidColumnIds.TryGetValue(view, out var viewKeys) ? viewKeys : AllValidColumnIds; - var invalidKeys = columnOrder.Where(key => !validKeys.Contains(key)).Distinct(); - foreach (string key in invalidKeys) + foreach (string key in columns.Keys.Where(key => !validKeys.Contains(key))) { yield return new ValidationResult( - $"Column order key '{key}' is not a valid column. Valid columns are: {String.Join(", ", validKeys.Order())}.", - [nameof(ColumnOrder)] - ); - } - - var duplicateKeys = columnOrder.GroupBy(key => key).Where(group => group.Count() > 1).Select(group => group.Key); - foreach (string key in duplicateKeys) - { - yield return new ValidationResult( - $"Column order key '{key}' cannot be repeated.", - [nameof(ColumnOrder)] - ); - } - } - - internal static IEnumerable ValidateColumnSettings( - string? view, - IReadOnlyDictionary? columnSettings) - { - if (columnSettings is null || columnSettings.Count == 0) - { - yield break; - } - - if (columnSettings.Count > 50) - { - yield return new ValidationResult( - "Column settings cannot exceed 50 items.", - [nameof(ColumnSettings)] - ); - } - - var validKeys = view is not null && ValidColumnIds.TryGetValue(view, out var viewKeys) - ? viewKeys - : AllValidColumnIds; - - foreach (string key in columnSettings.Keys.Where(key => !validKeys.Contains(key))) - { - yield return new ValidationResult( - $"Column settings key '{key}' is not a valid column. Valid columns are: {String.Join(", ", validKeys.Order())}.", - [nameof(ColumnSettings)] + $"Column key '{key}' is not a valid column. Valid columns are: {String.Join(", ", validKeys.Order())}.", + [nameof(Columns)] ); } - foreach (var (key, settings) in columnSettings) + foreach (var (key, settings) in columns) { if (settings is null) { yield return new ValidationResult( - $"Column settings for '{key}' cannot be null.", - [nameof(ColumnSettings)] + $"Column configuration for '{key}' cannot be null.", + [nameof(Columns)] ); continue; } @@ -200,28 +133,28 @@ internal static IEnumerable ValidateColumnSettings( if (settings.Position is < 0 or > SavedViewColumnSettings.MaxPosition) { yield return new ValidationResult( - $"Column settings position for '{key}' must be between 0 and {SavedViewColumnSettings.MaxPosition}.", - [nameof(ColumnSettings)] + $"Column position for '{key}' must be between 0 and {SavedViewColumnSettings.MaxPosition}.", + [nameof(Columns)] ); } if (settings.Width is < SavedViewColumnSettings.MinWidth or > SavedViewColumnSettings.MaxWidth) { yield return new ValidationResult( - $"Column settings width for '{key}' must be between {SavedViewColumnSettings.MinWidth} and {SavedViewColumnSettings.MaxWidth} pixels.", - [nameof(ColumnSettings)] + $"Column width for '{key}' must be between {SavedViewColumnSettings.MinWidth} and {SavedViewColumnSettings.MaxWidth} pixels.", + [nameof(Columns)] ); } } - foreach (var duplicatePosition in columnSettings + foreach (var duplicatePosition in columns .Where(entry => entry.Value is { Position: not null }) .GroupBy(entry => entry.Value.Position!.Value) .Where(group => group.Count() > 1)) { yield return new ValidationResult( - $"Column settings position '{duplicatePosition.Key}' cannot be repeated.", - [nameof(ColumnSettings)] + $"Column position '{duplicatePosition.Key}' cannot be repeated.", + [nameof(Columns)] ); } } diff --git a/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs index c272077eeb..b32e3e0852 100644 --- a/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs @@ -19,11 +19,7 @@ public class UpdateSavedView : IValidatableObject [MaxLength(SavedView.MaxFilterDefinitionsLength)] public string? FilterDefinitions { get; set; } [MaxLength(50)] - public Dictionary? Columns { get; set; } - [MaxLength(50)] - public List? ColumnOrder { get; set; } - [MaxLength(50)] - public Dictionary? ColumnSettings { get; set; } + public Dictionary? Columns { get; set; } public bool? ShowStats { get; set; } public bool? ShowChart { get; set; } @@ -37,17 +33,7 @@ public IEnumerable Validate(ValidationContext validationContex ); } - foreach (var error in NewSavedView.ValidateColumnKeys(null, Columns)) - { - yield return error; - } - - foreach (var error in NewSavedView.ValidateColumnOrder(null, ColumnOrder)) - { - yield return error; - } - - foreach (var error in NewSavedView.ValidateColumnSettings(null, ColumnSettings)) + foreach (var error in NewSavedView.ValidateColumns(null, Columns)) { yield return error; } diff --git a/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs index d1717b8a70..7ab1f51cc3 100644 --- a/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs @@ -23,9 +23,7 @@ public record ViewSavedView : IIdentity, IHaveDates public string? Filter { get; set; } public string? FilterDefinitions { get; set; } - public Dictionary? Columns { get; set; } - public List? ColumnOrder { get; set; } - public Dictionary? ColumnSettings { get; set; } + public Dictionary? Columns { get; set; } public bool? ShowStats { get; set; } public bool? ShowChart { get; set; } public string Name { get; set; } = null!; diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index f380b6da7e..79cce364dc 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -11365,26 +11365,6 @@ ] }, "columns": { - "maxLength": 50, - "type": [ - "null", - "object" - ], - "additionalProperties": { - "type": "boolean" - } - }, - "column_order": { - "maxItems": 50, - "type": [ - "null", - "array" - ], - "items": { - "type": "string" - } - }, - "column_settings": { "maxLength": 50, "type": [ "null", @@ -12181,24 +12161,6 @@ ] }, "columns": { - "type": [ - "null", - "object" - ], - "additionalProperties": { - "type": "boolean" - } - }, - "columnOrder": { - "type": [ - "null", - "array" - ], - "items": { - "type": "string" - } - }, - "columnSettings": { "type": [ "null", "object" @@ -12674,25 +12636,6 @@ ] }, "columns": { - "type": [ - "null", - "object" - ], - "additionalProperties": { - "type": "boolean" - } - }, - "column_order": { - "maxItems": 50, - "type": [ - "null", - "array" - ], - "items": { - "type": "string" - } - }, - "column_settings": { "type": [ "null", "object" @@ -13492,24 +13435,6 @@ ] }, "columns": { - "type": [ - "null", - "object" - ], - "additionalProperties": { - "type": "boolean" - } - }, - "column_order": { - "type": [ - "null", - "array" - ], - "items": { - "type": "string" - } - }, - "column_settings": { "type": [ "null", "object" @@ -13836,4 +13761,4 @@ "name": "Source Map" } ] -} +} \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs index 7cae102e08..f3dbea225a 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs @@ -245,8 +245,10 @@ public async Task PutPredefinedAsync_AsGlobalAdmin_ReplacesPredefinedDefinitions Filter = "type:log", Time = "last 24 hours", Sort = "-date", - Columns = new Dictionary { ["message"] = true }, - ColumnOrder = ["message"], + Columns = new Dictionary + { + ["message"] = new() { Position = 0, Visible = true } + }, ShowChart = true, ShowStats = true } @@ -283,7 +285,12 @@ public async Task PostAsync_NewSavedView_MapsAllPropertiesToSavedView() Sort = "-date", ViewType = "events", FilterDefinitions = """[{"type":"keyword","value":"status:open"}]""", - ColumnOrder = ["summary", "date", "user"] + Columns = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true }, + ["date"] = new() { Position = 1, Visible = true }, + ["user"] = new() { Position = 2, Visible = true } + } }; // Act @@ -305,7 +312,7 @@ public async Task PostAsync_NewSavedView_MapsAllPropertiesToSavedView() Assert.Equal("[now-7D TO now]", result.Time); Assert.Equal("-date", result.Sort); Assert.Equal("events", result.ViewType); - Assert.Equal(["summary", "date", "user"], result.ColumnOrder); + Assert.Equal(2, result.Columns?["user"].Position); Assert.NotNull(result.FilterDefinitions); Assert.Equal(1, result.Version); Assert.NotNull(result.CreatedByUserId); @@ -318,7 +325,7 @@ public async Task PostAsync_NewSavedView_MapsAllPropertiesToSavedView() Assert.NotNull(savedView); Assert.Equal("Production Errors", savedView.Name); Assert.Equal("-date", savedView.Sort); - Assert.Equal(["summary", "date", "user"], savedView.ColumnOrder); + Assert.Equal(2, savedView.Columns?["user"].Position); } [Fact] @@ -349,7 +356,7 @@ public async Task PostAsync_NewSavedView_ReturnsAbsoluteLocation() } [Fact] - public async Task PostAsync_ColumnSettings_SynchronizesLegacyColumnFields() + public async Task PostAsync_StructuredColumns_PersistsAllSettings() { // Arrange var columnSettings = new Dictionary @@ -368,68 +375,22 @@ public async Task PostAsync_ColumnSettings_SynchronizesLegacyColumnFields() OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Resizable Project View", ViewType = "events", - ColumnSettings = columnSettings + Columns = columnSettings }) .StatusCodeShouldBeCreated() ); // Assert Assert.NotNull(result); - Assert.Equal(240, result.ColumnSettings?["project"].Width); - Assert.True(result.Columns?["project"]); - Assert.Equal(["summary", "project"], result.ColumnOrder); + Assert.Equal(240, result.Columns?["project"].Width); + Assert.True(result.Columns?["project"].Visible); + Assert.Equal(1, result.Columns?["project"].Position); var savedView = await _savedViewRepository.GetByIdAsync(result.Id); Assert.NotNull(savedView); - Assert.Equal(240, savedView.ColumnSettings?["project"].Width); - Assert.True(savedView.Columns?["project"]); - Assert.Equal(["summary", "project"], savedView.ColumnOrder); - } - - [Fact] - public async Task PatchAsync_LegacyColumns_PreservesColumnWidths() - { - // Arrange - var created = await SendRequestAsAsync(r => r - .Post() - .AsGlobalAdminUser() - .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") - .Content(new NewSavedView - { - OrganizationId = SampleDataService.TEST_ORG_ID, - Name = "Legacy Column Update", - ViewType = "events", - ColumnSettings = new Dictionary - { - ["summary"] = new() { Position = 0, Visible = true, Width = 420 }, - ["project"] = new() { Position = 1, Visible = true, Width = 240 } - } - }) - .StatusCodeShouldBeCreated() - ); - Assert.NotNull(created); - - // Act - var result = await SendRequestAsAsync(r => r - .Patch() - .AsGlobalAdminUser() - .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView - { - Columns = new Dictionary - { - ["summary"] = true, - ["project"] = false - } - }) - .StatusCodeShouldBeOk() - ); - - // Assert - Assert.NotNull(result); - Assert.False(result.ColumnSettings?["project"].Visible); - Assert.Equal(240, result.ColumnSettings?["project"].Width); - Assert.Equal(1, result.ColumnSettings?["project"].Position); + Assert.Equal(240, savedView.Columns?["project"].Width); + Assert.True(savedView.Columns?["project"].Visible); + Assert.Equal(1, savedView.Columns?["project"].Position); } [Fact] @@ -1097,13 +1058,12 @@ public async Task PostPredefinedSavedViewAsync_GlobalAdmin_UsesPromotedConfigura savedLogs.Slug = "application-logs"; savedLogs.Filter = "type:log level:error"; savedLogs.ShowChart = false; - savedLogs.Columns = new Dictionary + savedLogs.Columns = new Dictionary { - ["summary"] = true, - ["date"] = true, - ["type"] = false + ["summary"] = new() { Position = 0, Visible = true }, + ["date"] = new() { Position = 1, Visible = true }, + ["type"] = new() { Position = 2, Visible = false } }; - savedLogs.ColumnOrder = ["summary", "date", "type"]; await _savedViewRepository.SaveAsync(savedLogs, o => o.ImmediateConsistency()); await RefreshDataAsync(); @@ -1140,9 +1100,9 @@ public async Task PostPredefinedSavedViewAsync_GlobalAdmin_UsesPromotedConfigura Assert.Equal("type:log level:error", applicationLogs.Filter); Assert.False(applicationLogs.ShowChart); Assert.NotNull(applicationLogs.Columns); - Assert.True(applicationLogs.Columns["summary"]); - Assert.False(applicationLogs.Columns["type"]); - Assert.Equal(new[] { "summary", "date", "type" }, applicationLogs.ColumnOrder); + Assert.True(applicationLogs.Columns["summary"].Visible); + Assert.False(applicationLogs.Columns["type"].Visible); + Assert.Equal(2, applicationLogs.Columns["type"].Position); Assert.DoesNotContain(updatedPredefinedViews, view => IsPredefinedSavedView(view, "events", "Logs")); Assert.Contains(updatedPredefinedViews, view => IsPredefinedSavedView(view, "events", "Errors")); } @@ -2164,7 +2124,7 @@ public Task PostAsync_InvalidColumnKey_ReturnsUnprocessableEntity() OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Bad Columns", ViewType = "events", - Columns = new Dictionary { ["status"] = true } + Columns = new Dictionary { ["status"] = new() { Visible = true } } }) .StatusCodeShouldBeUnprocessableEntity() ); @@ -2184,7 +2144,7 @@ await SendRequestAsync(r => r .AppendPaths("saved-views", created.Id) .Content(new UpdateSavedView { - Columns = new Dictionary { ["INVALID_COLUMN"] = true } + Columns = new Dictionary { ["INVALID_COLUMN"] = new() { Visible = true } } }) .StatusCodeShouldBeUnprocessableEntity() ); @@ -2203,7 +2163,14 @@ public Task PostAsync_ValidColumnKeys_Succeeds() OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Valid Columns", ViewType = "events", - Columns = new Dictionary { ["summary"] = true, ["type"] = false, ["exception_type"] = false, ["level"] = false, ["message"] = false } + Columns = new Dictionary + { + ["summary"] = new() { Visible = true }, + ["type"] = new() { Visible = false }, + ["exception_type"] = new() { Visible = false }, + ["level"] = new() { Visible = false }, + ["message"] = new() { Visible = false } + } }) .StatusCodeShouldBeCreated() ); @@ -2228,8 +2195,7 @@ public Task PostAsync_ProjectAndTagColumnsForSupportedViews_Succeeds(string view OrganizationId = SampleDataService.TEST_ORG_ID, Name = $"Valid {viewType} {column} Column", ViewType = viewType, - Columns = new Dictionary { [column] = false }, - ColumnOrder = [column] + Columns = new Dictionary { [column] = new() { Position = 0, Visible = false } } }) .StatusCodeShouldBeCreated() ); @@ -2250,52 +2216,16 @@ public Task PostAsync_VersionColumnForEventViews_Succeeds(string viewType) OrganizationId = SampleDataService.TEST_ORG_ID, Name = $"Valid {viewType} Version Column", ViewType = viewType, - Columns = new Dictionary { ["summary"] = true, ["version"] = false }, - ColumnOrder = ["summary", "version"] + Columns = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true }, + ["version"] = new() { Position = 1, Visible = false } + } }) .StatusCodeShouldBeCreated() ); } - [Fact] - public Task PostAsync_InvalidColumnOrderKey_ReturnsUnprocessableEntity() - { - // Arrange & Act & Assert - return SendRequestAsync(r => r - .Post() - .AsGlobalAdminUser() - .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") - .Content(new NewSavedView - { - OrganizationId = SampleDataService.TEST_ORG_ID, - Name = "Bad Column Order", - ViewType = "events", - ColumnOrder = ["summary", "status"] - }) - .StatusCodeShouldBeUnprocessableEntity() - ); - } - - [Fact] - public async Task PatchAsync_DuplicateColumnOrderKey_ReturnsUnprocessableEntity() - { - // Arrange - var created = await CreateSavedViewAsync("Patch Column Order Test", "status:open", "events"); - Assert.NotNull(created); - - // Act & Assert - await SendRequestAsync(r => r - .Patch() - .AsGlobalAdminUser() - .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView - { - ColumnOrder = ["summary", "summary"] - }) - .StatusCodeShouldBeUnprocessableEntity() - ); - } - [Theory] [InlineData(47)] [InlineData(1201)] @@ -2311,7 +2241,7 @@ public Task PostAsync_ColumnSettingsWidthOutsideRange_ReturnsUnprocessableEntity OrganizationId = SampleDataService.TEST_ORG_ID, Name = $"Invalid Column Width {width}", ViewType = "events", - ColumnSettings = new Dictionary + Columns = new Dictionary { ["project"] = new() { Width = width } } @@ -2321,7 +2251,7 @@ public Task PostAsync_ColumnSettingsWidthOutsideRange_ReturnsUnprocessableEntity } [Fact] - public Task PostAsync_DuplicateColumnSettingsPosition_ReturnsUnprocessableEntity() + public Task PostAsync_DuplicateColumnPosition_ReturnsUnprocessableEntity() { // Arrange & Act & Assert return SendRequestAsync(r => r @@ -2333,7 +2263,7 @@ public Task PostAsync_DuplicateColumnSettingsPosition_ReturnsUnprocessableEntity OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Duplicate Column Position", ViewType = "events", - ColumnSettings = new Dictionary + Columns = new Dictionary { ["summary"] = new() { Position = 0 }, ["project"] = new() { Position = 0 } @@ -2344,7 +2274,7 @@ public Task PostAsync_DuplicateColumnSettingsPosition_ReturnsUnprocessableEntity } [Fact] - public Task PostAsync_NullColumnSettingsValue_ReturnsUnprocessableEntity() + public Task PostAsync_NullColumnValue_ReturnsUnprocessableEntity() { // Arrange & Act & Assert return SendRequestAsync(r => r @@ -2354,9 +2284,9 @@ public Task PostAsync_NullColumnSettingsValue_ReturnsUnprocessableEntity() .Content(new NewSavedView { OrganizationId = SampleDataService.TEST_ORG_ID, - Name = "Null Column Settings", + Name = "Null Column", ViewType = "events", - ColumnSettings = new Dictionary + Columns = new Dictionary { ["project"] = null! } @@ -2488,9 +2418,9 @@ public async Task PatchAsync_ColumnsExceedsMaxCount_ReturnsUnprocessableEntity() // Build a columns dict with 51 entries (exceeds max of 50). // Count validation fires before key validation, so arbitrary keys work here. - var columns = new Dictionary(); + var columns = new Dictionary(); for (int i = 0; i < 51; i++) - columns[$"col{i}"] = true; + columns[$"col{i}"] = new(); // Act & Assert await SendRequestAsync(r => r diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 89d4e33600..40019c5af4 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -134,9 +134,9 @@ public async Task GetOpenApiJson_MigratedContracts_PreserveControllerMetadata() AssertRequiredJsonRequestBody(paths, "/api/v2/tokens/{id}", "put", "UpdateToken"); AssertRequiredJsonRequestBody(paths, "/api/v2/saved-views/{id}", "patch", "UpdateSavedView"); AssertRequiredJsonRequestBody(paths, "/api/v2/saved-views/{id}", "put", "UpdateSavedView"); - AssertDictionaryValueSchema(document.RootElement, "NewSavedView", "column_settings", "SavedViewColumnSettings"); - AssertDictionaryValueSchema(document.RootElement, "UpdateSavedView", "column_settings", "SavedViewColumnSettings"); - AssertDictionaryValueSchema(document.RootElement, "ViewSavedView", "column_settings", "SavedViewColumnSettings"); + AssertDictionaryValueSchema(document.RootElement, "NewSavedView", "columns", "SavedViewColumnSettings"); + AssertDictionaryValueSchema(document.RootElement, "UpdateSavedView", "columns", "SavedViewColumnSettings"); + AssertDictionaryValueSchema(document.RootElement, "ViewSavedView", "columns", "SavedViewColumnSettings"); AssertRequiredJsonRequestBody(paths, "/api/v2/users/{id}", "patch", "UpdateUser"); AssertRequiredJsonRequestBody(paths, "/api/v2/users/{id}", "put", "UpdateUser"); diff --git a/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs b/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs index 95e2753db2..a1f756479c 100644 --- a/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs @@ -27,11 +27,11 @@ public void MapToSavedView_WithValidNewSavedView_MapsAllProperties() Sort = "-last", ViewType = "stacks", FilterDefinitions = "[{\"type\":\"status\",\"values\":[\"open\",\"regressed\"]}]", - Columns = new Dictionary { ["status"] = true, ["users"] = false }, - ColumnOrder = ["summary", "status", "users"], - ColumnSettings = new Dictionary + Columns = new Dictionary { - ["status"] = new() { Position = 1, Visible = true, Width = 180 } + ["summary"] = new() { Position = 0, Visible = true }, + ["status"] = new() { Position = 1, Visible = true, Width = 180 }, + ["users"] = new() { Position = 2, Visible = false } } }; @@ -47,10 +47,10 @@ public void MapToSavedView_WithValidNewSavedView_MapsAllProperties() Assert.Equal("stacks", result.ViewType); Assert.Equal("[{\"type\":\"status\",\"values\":[\"open\",\"regressed\"]}]", result.FilterDefinitions); Assert.NotNull(result.Columns); - Assert.True(result.Columns["status"]); - Assert.False(result.Columns["users"]); - Assert.Equal(["summary", "status", "users"], result.ColumnOrder); - Assert.Equal(180, result.ColumnSettings?["status"].Width); + Assert.True(result.Columns["status"].Visible); + Assert.False(result.Columns["users"].Visible); + Assert.Equal(1, result.Columns["status"].Position); + Assert.Equal(180, result.Columns["status"].Width); } [Fact] @@ -94,8 +94,6 @@ public void MapToSavedView_WithNullOptionalFields_MapsNulls() Assert.Null(result.Time); Assert.Null(result.FilterDefinitions); Assert.Null(result.Columns); - Assert.Null(result.ColumnOrder); - Assert.Null(result.ColumnSettings); } [Fact] @@ -112,10 +110,9 @@ public void MapToViewSavedView_WithValidSavedView_MapsAllProperties() UpdatedByUserId = "1ecd0826e447ad1e78822666", Filter = "status:open", FilterDefinitions = "[{\"type\":\"status\",\"values\":[\"open\"]}]", - Columns = new Dictionary { ["status"] = true }, - ColumnOrder = ["summary", "status"], - ColumnSettings = new Dictionary + Columns = new Dictionary { + ["summary"] = new() { Position = 0, Visible = true }, ["status"] = new() { Position = 1, Visible = true, Width = 180 } }, Name = "My View", @@ -139,9 +136,9 @@ public void MapToViewSavedView_WithValidSavedView_MapsAllProperties() Assert.Equal("status:open", result.Filter); Assert.Equal("[{\"type\":\"status\",\"values\":[\"open\"]}]", result.FilterDefinitions); Assert.NotNull(result.Columns); - Assert.True(result.Columns["status"]); - Assert.Equal(["summary", "status"], result.ColumnOrder); - Assert.Equal(180, result.ColumnSettings?["status"].Width); + Assert.True(result.Columns["status"].Visible); + Assert.Equal(1, result.Columns["status"].Position); + Assert.Equal(180, result.Columns["status"].Width); Assert.Equal("My View", result.Name); Assert.Equal("[now-30d TO now]", result.Time); Assert.Equal("-last", result.Sort); @@ -174,36 +171,10 @@ public void MapToViewSavedView_WithNullOptionalFields_MapsNulls() Assert.Null(result.Filter); Assert.Null(result.FilterDefinitions); Assert.Null(result.Columns); - Assert.Null(result.ColumnOrder); Assert.Null(result.Time); Assert.Null(result.Sort); } - [Fact] - public void MapToViewSavedView_WithLegacyColumnConfiguration_ProjectsColumnSettings() - { - // Arrange - var source = new SavedView - { - Id = "88cd0826e447a44e78877ab1", - OrganizationId = "537650f3b77efe23a47914f3", - CreatedByUserId = "1ecd0826e447ad1e78822555", - Name = "Legacy View", - ViewType = "events", - Columns = new Dictionary { ["project"] = true }, - ColumnOrder = ["summary", "project"] - }; - - // Act - var result = _mapper.MapToViewSavedView(source); - - // Assert - Assert.NotNull(result.ColumnSettings); - Assert.True(result.ColumnSettings["project"].Visible); - Assert.Equal(1, result.ColumnSettings["project"].Position); - Assert.Null(result.ColumnSettings["project"].Width); - } - [Fact] public void MapToViewSavedViews_WithMultipleSavedViews_MapsAll() { diff --git a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs new file mode 100644 index 0000000000..8a70c45618 --- /dev/null +++ b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs @@ -0,0 +1,115 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Elastic.Clients.Elasticsearch; +using Exceptionless.Core.Migrations; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Seed; +using Foundatio.Lock; +using Foundatio.Repositories; +using Foundatio.Repositories.Migrations; +using Foundatio.Utility; +using Xunit; + +namespace Exceptionless.Tests.Migrations; + +public sealed class MigrateSavedViewColumnsIntegrationTests : IntegrationTestsBase +{ + private readonly ElasticsearchClient _client; + private readonly ExceptionlessElasticConfiguration _configuration; + private readonly ISavedViewRepository _repository; + + public MigrateSavedViewColumnsIntegrationTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _configuration = GetService(); + _client = _configuration.Client; + _repository = GetService(); + } + + protected override void RegisterServices(IServiceCollection services) + { + services.AddTransient(); + services.AddSingleton(EmptyLock.Empty); + base.RegisterServices(services); + } + + [Fact] + public async Task RunAsync_LegacySavedView_ReplacesDocumentWithStructuredColumns() + { + // Arrange + const string savedViewId = "770000000000000000000099"; + const string customizedSavedViewId = "770000000000000000000098"; + var source = JsonNode.Parse( + $$""" + { + "id": "{{savedViewId}}", + "organization_id": "550000000000000000000001", + "created_by_user_id": "660000000000000000000001", + "name": "Legacy Columns", + "slug": "legacy-columns", + "view_type": "events", + "columns": { + "project": true, + "summary": false + }, + "column_order": [ + "summary", + "project" + ], + "version": 1, + "created_utc": "2026-01-01T00:00:00Z", + "updated_utc": "2026-01-01T00:00:00Z" + } + """ + )!.AsObject(); + source["predefined_content_hash"] = MigrateSavedViewColumns.GetLegacyContentHash(source); + var customizedSource = source.DeepClone().AsObject(); + customizedSource["id"] = customizedSavedViewId; + customizedSource["name"] = "Customized Legacy Columns"; + customizedSource["predefined_content_hash"] = "customized-content-hash"; + + var indexResponse = await _client.IndexAsync( + source, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(savedViewId), + TestCancellationToken); + Assert.True(indexResponse.IsValidResponse); + + indexResponse = await _client.IndexAsync( + customizedSource, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(customizedSavedViewId) + .Refresh(Refresh.WaitFor), + TestCancellationToken); + Assert.True(indexResponse.IsValidResponse); + + // Act + var migration = GetService(); + var context = new MigrationContext(GetService(), _logger, TestCancellationToken); + await migration.RunAsync(context); + + // Assert + var savedView = await _repository.GetByIdAsync(savedViewId, o => o.ImmediateConsistency()); + Assert.NotNull(savedView); + Assert.False(savedView.Columns?["summary"].Visible); + Assert.Equal(0, savedView.Columns?["summary"].Position); + Assert.True(savedView.Columns?["project"].Visible); + Assert.Equal(1, savedView.Columns?["project"].Position); + Assert.Equal(PredefinedSavedViewContentHasher.GetContentHash(savedView), savedView.PredefinedContentHash); + + var customizedSavedView = await _repository.GetByIdAsync(customizedSavedViewId, o => o.ImmediateConsistency()); + Assert.NotNull(customizedSavedView); + Assert.Equal("customized-content-hash", customizedSavedView.PredefinedContentHash); + Assert.Equal(1, customizedSavedView.Columns?["project"].Position); + + var getResponse = await _client.GetAsync( + savedViewId, + request => request.Index(_configuration.SavedViews.VersionedName), + TestCancellationToken); + Assert.True(getResponse.IsValidResponse); + Assert.False(getResponse.Source.TryGetProperty("column_order", out _)); + Assert.Equal(JsonValueKind.Object, getResponse.Source.GetProperty("columns").GetProperty("project").ValueKind); + } +} diff --git a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsMigrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsMigrationTests.cs new file mode 100644 index 0000000000..f06c01345f --- /dev/null +++ b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsMigrationTests.cs @@ -0,0 +1,101 @@ +using System.Text.Json.Nodes; +using Exceptionless.Core.Migrations; +using Xunit; + +namespace Exceptionless.Tests.Migrations; + +public sealed class MigrateSavedViewColumnsMigrationTests +{ + [Fact] + public void TryMigrate_LegacyColumnsAndOrder_ConvertsToStructuredColumns() + { + // Arrange + var source = JsonNode.Parse( + """ + { + "columns": { + "project": true, + "summary": false + }, + "column_order": [ + "summary", + "project", + "date" + ] + } + """ + )!.AsObject(); + + // Act + bool migrated = MigrateSavedViewColumns.TryMigrate(source); + + // Assert + Assert.True(migrated); + Assert.False(source.ContainsKey("column_order")); + var columns = Assert.IsType(source["columns"]); + Assert.False(columns["summary"]!["visible"]!.GetValue()); + Assert.Equal(0, columns["summary"]!["position"]!.GetValue()); + Assert.True(columns["project"]!["visible"]!.GetValue()); + Assert.Equal(1, columns["project"]!["position"]!.GetValue()); + Assert.Equal(2, columns["date"]!["position"]!.GetValue()); + Assert.Null(columns["date"]!["visible"]); + } + + [Fact] + public void TryMigrate_StructuredColumnsWithoutLegacyOrder_DoesNotChangeDocument() + { + // Arrange + var source = JsonNode.Parse( + """ + { + "columns": { + "project": { + "visible": true, + "position": 0, + "width": 320 + } + } + } + """ + )!.AsObject(); + string originalJson = source.ToJsonString(); + + // Act + bool migrated = MigrateSavedViewColumns.TryMigrate(source); + + // Assert + Assert.False(migrated); + Assert.Equal(originalJson, source.ToJsonString()); + } + + [Fact] + public void TryMigrate_PartiallyMigratedDocument_PreservesExistingWidths() + { + // Arrange + var source = JsonNode.Parse( + """ + { + "columns": { + "project": { + "visible": true, + "width": 360 + } + }, + "column_order": [ + "project" + ] + } + """ + )!.AsObject(); + + // Act + bool migrated = MigrateSavedViewColumns.TryMigrate(source); + + // Assert + Assert.True(migrated); + var project = source["columns"]!["project"]!; + Assert.True(project["visible"]!.GetValue()); + Assert.Equal(0, project["position"]!.GetValue()); + Assert.Equal(360, project["width"]!.GetValue()); + } +} diff --git a/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs b/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs index 5459de1609..ce5698aedd 100644 --- a/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs +++ b/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs @@ -17,21 +17,20 @@ public void GetContentHash_FilterDefinitionsDifferOnlyByFormattingAndDictionaryI ViewType = "events", Filter = "type:log (status:open OR status:regressed)", FilterDefinitions = """[{"type":"status","value":"open"}]""", - Columns = new Dictionary + Columns = new Dictionary { - ["date"] = true, - ["summary"] = false - }, - ColumnOrder = ["summary", "date"] + ["date"] = new() { Position = 1, Visible = true }, + ["summary"] = new() { Position = 0, Visible = false } + } }; var reformatted = original with { Filter = "type:log (status:open OR status:regressed)", FilterDefinitions = """[{"type":"status", "value":"open"}]""", - Columns = new Dictionary + Columns = new Dictionary { - ["summary"] = false, - ["date"] = true + ["summary"] = new() { Position = 0, Visible = false }, + ["date"] = new() { Position = 1, Visible = true } } }; @@ -64,7 +63,7 @@ public void GetContentHash_StringFieldDiffersOnlyBySpaces_ReturnsDifferentHash() } [Fact] - public void GetContentHash_WithoutColumnSettings_PreservesLegacyHash() + public void GetContentHash_SameView_ReturnsSameHash() { // Arrange var savedView = new SavedView @@ -75,14 +74,15 @@ public void GetContentHash_WithoutColumnSettings_PreservesLegacyHash() }; // Act - string hash = PredefinedSavedViewContentHasher.GetContentHash(savedView); + string firstHash = PredefinedSavedViewContentHasher.GetContentHash(savedView); + string secondHash = PredefinedSavedViewContentHasher.GetContentHash(savedView); // Assert - Assert.Equal("2275a4a7aa2276c78bd20f21b75ce91afbf035bb3795047ba2ed619fdaff12e3", hash); + Assert.Equal(firstHash, secondHash); } [Fact] - public void GetContentHash_ColumnSettingsInsertionOrderDiffers_ReturnsSameHash() + public void GetContentHash_ColumnInsertionOrderDiffers_ReturnsSameHash() { // Arrange var original = new SavedView @@ -90,7 +90,7 @@ public void GetContentHash_ColumnSettingsInsertionOrderDiffers_ReturnsSameHash() Name = "Logs", Slug = "logs", ViewType = "events", - ColumnSettings = new Dictionary + Columns = new Dictionary { ["project"] = new() { Position = 1, Visible = true, Width = 240 }, ["summary"] = new() { Position = 0, Visible = true, Width = 420 } @@ -98,7 +98,7 @@ public void GetContentHash_ColumnSettingsInsertionOrderDiffers_ReturnsSameHash() }; var reordered = original with { - ColumnSettings = new Dictionary + Columns = new Dictionary { ["summary"] = new() { Position = 0, Visible = true, Width = 420 }, ["project"] = new() { Position = 1, Visible = true, Width = 240 } diff --git a/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs index aea5f144dd..290ac3ec82 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs @@ -27,16 +27,11 @@ public void RoundTrip_WithAllProperties_PreservesValues() PredefinedContentHash = "95d71d134b0f8f62c6a70de8ec9819f7a0a0e450577764e9c1c13c863ccbe6ba", Filter = "(status:open OR status:regressed)", FilterDefinitions = """[{"field":"status","operator":"in","values":["open","regressed"]}]""", - Columns = new Dictionary - { - ["title"] = true, - ["date"] = true, - ["status"] = false - }, - ColumnSettings = new Dictionary + Columns = new Dictionary { ["title"] = new() { Position = 0, Visible = true, Width = 420 }, - ["status"] = new() { Position = 1, Visible = false, Width = 180 } + ["date"] = new() { Position = 1, Visible = true }, + ["status"] = new() { Position = 2, Visible = false, Width = 180 } }, Name = "Open Issues", Time = "[now-7d TO now]", @@ -64,10 +59,9 @@ public void RoundTrip_WithAllProperties_PreservesValues() Assert.Equal("stacks", result.ViewType); Assert.NotNull(result.Columns); Assert.Equal(3, result.Columns.Count); - Assert.True(result.Columns["title"]); - Assert.False(result.Columns["status"]); - Assert.Equal(420, result.ColumnSettings?["title"].Width); - Assert.False(result.ColumnSettings?["status"].Visible); + Assert.True(result.Columns["title"].Visible); + Assert.False(result.Columns["status"].Visible); + Assert.Equal(420, result.Columns["title"].Width); } [Fact] From 16bbb8a14a05eb12b3a0f878a5c50ebf0fbdc225 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 29 Jul 2026 09:53:38 -0500 Subject: [PATCH 03/11] Update saved view HTTP samples --- tests/http/saved-views.http | 49 +++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/tests/http/saved-views.http b/tests/http/saved-views.http index eee1d6bf89..6a3b0151d4 100644 --- a/tests/http/saved-views.http +++ b/tests/http/saved-views.http @@ -56,10 +56,20 @@ Content-Type: application/json "sort": "-date", "view_type": "events", "columns": { - "user": true, - "date": true - }, - "column_order": ["summary", "user", "date"] + "summary": { + "visible": true, + "position": 0 + }, + "user": { + "visible": true, + "position": 1 + }, + "date": { + "visible": true, + "position": 2, + "width": 180 + } + } } ### @@ -77,10 +87,19 @@ Content-Type: application/json "filter": "type:error", "view_type": "stream", "columns": { - "user": true, - "date": true + "summary": { + "visible": true, + "position": 0 + }, + "date": { + "visible": true, + "position": 1 + }, + "user": { + "visible": true, + "position": 2 + } }, - "column_order": ["summary", "date", "user"], "is_private": true } @@ -97,7 +116,21 @@ Content-Type: application/json "name": "Open Events (Last 30d)", "time": "[now-30d TO now]", "sort": "date", - "column_order": ["summary", "date", "user"] + "columns": { + "summary": { + "visible": true, + "position": 0 + }, + "date": { + "visible": true, + "position": 1, + "width": 220 + }, + "user": { + "visible": true, + "position": 2 + } + } } ### Save saved view as a predefined saved view From 803dac3fed4563df8a4b2c4af7e97040c6dc7fe4 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 31 Jul 2026 00:16:19 -0500 Subject: [PATCH 04/11] Address saved view migration review feedback --- src/Exceptionless.Core/Bootstrapper.cs | 5 +- .../Jobs/Elastic/MigrationJob.cs | 5 ++ ...umns.cs => 004_MigrateSavedViewColumns.cs} | 38 ++++++++-- src/Exceptionless.Job/JobRunnerOptions.cs | 1 + src/Exceptionless.Job/Program.cs | 2 +- .../saved-views/column-settings.test.ts | 32 ++++++++- .../features/saved-views/column-settings.ts | 8 +++ .../saved-views/use-saved-views.svelte.ts | 20 +----- .../Exceptionless.Tests.csproj | 1 + .../Jobs/JobRunnerOptionsTests.cs | 31 +++++++++ ...MigrateSavedViewColumnsIntegrationTests.cs | 69 +++++++++++++++++++ .../Migrations/MigrationRegistrationTests.cs | 29 ++++++++ 12 files changed, 213 insertions(+), 28 deletions(-) rename src/Exceptionless.Core/Migrations/{003_MigrateSavedViewColumns.cs => 004_MigrateSavedViewColumns.cs} (86%) create mode 100644 tests/Exceptionless.Tests/Jobs/JobRunnerOptionsTests.cs create mode 100644 tests/Exceptionless.Tests/Migrations/MigrationRegistrationTests.cs diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 45597b4327..53ba13ba2c 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -55,7 +55,7 @@ namespace Exceptionless.Core; public class Bootstrapper { - public static void RegisterServices(IServiceCollection services, AppOptions appOptions) + public static void RegisterServices(IServiceCollection services, AppOptions appOptions, bool runDataSeedStartupAction = true) { // Register System.Text.Json options with Exceptionless defaults (snake_case, null handling) services.AddSingleton(_ => new JsonSerializerOptions().ConfigureExceptionlessDefaults()); @@ -81,7 +81,8 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); - services.AddStartupAction(); + if (runDataSeedStartupAction) + services.AddStartupAction(); services.AddStartupAction("Create Sample Data", CreateSampleDataAsync); diff --git a/src/Exceptionless.Core/Jobs/Elastic/MigrationJob.cs b/src/Exceptionless.Core/Jobs/Elastic/MigrationJob.cs index 5ed7a5aad2..2d6c56370a 100644 --- a/src/Exceptionless.Core/Jobs/Elastic/MigrationJob.cs +++ b/src/Exceptionless.Core/Jobs/Elastic/MigrationJob.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Seed; using Foundatio.Jobs; using Foundatio.Repositories.Elasticsearch.Configuration; using Foundatio.Repositories.Migrations; @@ -12,10 +13,12 @@ public class MigrationJob : JobBase { private readonly MigrationManager _migrationManager; private readonly ExceptionlessElasticConfiguration _configuration; + private readonly DataSeedService _dataSeedService; public MigrationJob( MigrationManager migrationManager, ExceptionlessElasticConfiguration configuration, + DataSeedService dataSeedService, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory @@ -23,12 +26,14 @@ ILoggerFactory loggerFactory { _migrationManager = migrationManager; _configuration = configuration; + _dataSeedService = dataSeedService; } protected override async Task RunInternalAsync(JobContext context) { await _configuration.ConfigureIndexesAsync(null, false); await _migrationManager.RunMigrationsAsync(); + await _dataSeedService.SeedAsync(context.CancellationToken); var tasks = _configuration.Indexes.OfType().Select(ReindexIfNecessary); await Task.WhenAll(tasks); diff --git a/src/Exceptionless.Core/Migrations/003_MigrateSavedViewColumns.cs b/src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs similarity index 86% rename from src/Exceptionless.Core/Migrations/003_MigrateSavedViewColumns.cs rename to src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs index 60206e0c49..8b29c8e2e1 100644 --- a/src/Exceptionless.Core/Migrations/003_MigrateSavedViewColumns.cs +++ b/src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs @@ -34,7 +34,7 @@ public MigrateSavedViewColumns( _serializer = serializer; MigrationType = MigrationType.VersionedAndResumable; - Version = 3; + Version = 4; } public override async Task RunAsync(MigrationContext context) @@ -60,6 +60,7 @@ public override async Task RunAsync(MigrationContext context) { request .Pit(pit => pit.Id(pointInTime.Id).KeepAlive("2m")) + .SeqNoPrimaryTerm() .Size(pageSize) .Sort(sort => sort.Field("_shard_doc")); @@ -80,22 +81,30 @@ public override async Task RunAsync(MigrationContext context) if (source is null) continue; + if (hit.SeqNo is null || hit.PrimaryTerm is null) + throw new InvalidOperationException($"Unable to read concurrency metadata for saved view '{hit.Id}'."); + string? legacyContentHash = GetLegacyContentHash(source); if (!TryMigrate(source)) continue; UpdatePredefinedContentHash(source, legacyContentHash); - var indexResponse = await _client.IndexAsync( + var indexResponse = await IndexMigratedDocumentAsync( source, - request => request - .Index(_configuration.SavedViews.VersionedName) - .Id(hit.Id), + hit.Id, + hit.SeqNo.Value, + hit.PrimaryTerm.Value, context.CancellationToken); _logger.LogRequest(indexResponse); if (!indexResponse.IsValidResponse) - throw new InvalidOperationException($"Unable to migrate saved view '{hit.Id}'."); + { + string reason = indexResponse.ApiCallDetails.HttpStatusCode == 409 + ? " because it changed while the migration was running" + : String.Empty; + throw new InvalidOperationException($"Unable to migrate saved view '{hit.Id}'{reason}. The resumable migration can be run again safely."); + } migrated++; } @@ -118,6 +127,23 @@ public override async Task RunAsync(MigrationContext context) _logger.LogInformation("Migrated {SavedViewCount} saved view column configurations", migrated); } + internal Task IndexMigratedDocumentAsync( + JsonObject source, + string id, + long sequenceNumber, + long primaryTerm, + CancellationToken cancellationToken) + { + return _client.IndexAsync( + source, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(id) + .IfSeqNo(sequenceNumber) + .IfPrimaryTerm(primaryTerm), + cancellationToken); + } + internal static bool TryMigrate(JsonObject source) { var columns = source["columns"] as JsonObject; diff --git a/src/Exceptionless.Job/JobRunnerOptions.cs b/src/Exceptionless.Job/JobRunnerOptions.cs index a8faf591e8..70dd715485 100644 --- a/src/Exceptionless.Job/JobRunnerOptions.cs +++ b/src/Exceptionless.Job/JobRunnerOptions.cs @@ -93,6 +93,7 @@ public JobRunnerOptions(string[] args) public bool MailMessage { get; } public bool MaintainIndexes { get; } public bool Migration { get; } + public bool RunDataSeedStartupAction => !Migration; public bool StackStatus { get; } public bool StackEventCount { get; } public bool WebHooks { get; } diff --git a/src/Exceptionless.Job/Program.cs b/src/Exceptionless.Job/Program.cs index 03f2903a2a..773bf8f3c9 100644 --- a/src/Exceptionless.Job/Program.cs +++ b/src/Exceptionless.Job/Program.cs @@ -127,7 +127,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) AddJobs(services, jobOptions); services.AddAppOptions(options); - Bootstrapper.RegisterServices(services, options); + Bootstrapper.RegisterServices(services, options, jobOptions.RunDataSeedStartupAction); Insulation.Bootstrapper.RegisterServices(services, options, true); }) .AddApm(apmConfig); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts index 2063df8b9b..8b3f713236 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it } from 'vitest'; import type { SavedView } from './models'; -import { buildColumnSettings, getSavedColumnOrder, getSavedColumnSizing, getSavedColumnVisibility, savedViewColumnSizingEqual } from './column-settings'; +import { + buildColumnSettings, + getSavedColumnOrder, + getSavedColumnSizing, + getSavedColumnVisibility, + savedViewColumnOrderEqual, + savedViewColumnSizingEqual +} from './column-settings'; describe('saved view column settings', () => { it('builds extensible settings from the current table state', () => { @@ -43,4 +50,27 @@ describe('saved view column settings', () => { expect(savedViewColumnSizingEqual({ project: 420 }, view)).toBe(false); expect(savedViewColumnSizingEqual({}, view)).toBe(false); }); + + it('compares only columns with explicitly saved positions', () => { + const view = { + columns: { + date: { visible: true }, + project: { position: 0 }, + summary: { position: 1 } + } + } as Pick; + + expect(savedViewColumnOrderEqual(['select', 'project', 'date', 'summary', 'tags'], view)).toBe(true); + expect(savedViewColumnOrderEqual(['select', 'summary', 'date', 'project', 'tags'], view)).toBe(false); + }); + + it('treats views without saved positions as unchanged', () => { + const view = { + columns: { + project: { visible: true } + } + } as Pick; + + expect(savedViewColumnOrderEqual(['select', 'date', 'project'], view)).toBe(true); + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts index 9d888dac44..472e00e833 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts @@ -54,6 +54,14 @@ export function getSavedColumnVisibility(view: SavedColumnState | undefined): Co ); } +export function savedViewColumnOrderEqual(current: ColumnOrderState | undefined, view: SavedColumnState): boolean { + const savedOrder = getSavedColumnOrder(view); + const savedColumnIds = new Set(savedOrder); + const currentSavedOrder = (current ?? []).filter((columnId) => columnId !== 'select' && savedColumnIds.has(columnId)); + + return currentSavedOrder.length === savedOrder.length && currentSavedOrder.every((columnId, index) => columnId === savedOrder[index]); +} + export function savedViewColumnSizingEqual(current: ColumnSizingState | undefined, view: SavedColumnState): boolean { const saved = getSavedColumnSizing(view); const currentEntries = Object.entries(current ?? {}).filter(([columnId]) => columnId !== 'select'); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 44a6559546..375a4a5c4e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -8,7 +8,7 @@ import { organization } from '$features/organizations/context.svelte'; import type { SavedView } from './models'; import { getSavedViewsByViewQuery } from './api.svelte'; -import { getSavedColumnOrder, getSavedColumnSizing, getSavedColumnVisibility, savedViewColumnSizingEqual } from './column-settings'; +import { getSavedColumnOrder, getSavedColumnSizing, getSavedColumnVisibility, savedViewColumnOrderEqual, savedViewColumnSizingEqual } from './column-settings'; import { savedViewHref, savedViewResolvedSlug } from './slugs'; export interface SavedViewQueryParams { @@ -285,11 +285,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return true; } - if ( - options.getColumnOrder && - Object.values(view.columns ?? {}).some((settings) => settings.position != null) && - !columnOrderEqual(options.getColumnOrder(), getSavedColumnOrder(view)) - ) { + if (options.getColumnOrder && !savedViewColumnOrderEqual(options.getColumnOrder(), view)) { return true; } @@ -384,18 +380,6 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur }; } -function columnOrderEqual(a: ColumnOrderState | undefined, b: null | string[] | undefined): boolean { - const normalize = (value: null | string[] | undefined) => (value ?? []).filter((columnId) => columnId !== 'select'); - const aOrder = normalize(a); - const bOrder = normalize(b); - - if (aOrder.length !== bOrder.length) { - return false; - } - - return aOrder.every((columnId, index) => columnId === bOrder[index]); -} - function normalizeFilterDefinitions(value: null | string | undefined): string { if (!value) { return '[]'; diff --git a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj index 8278f2a81e..7304429e53 100644 --- a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj +++ b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/tests/Exceptionless.Tests/Jobs/JobRunnerOptionsTests.cs b/tests/Exceptionless.Tests/Jobs/JobRunnerOptionsTests.cs new file mode 100644 index 0000000000..7f5d4bb503 --- /dev/null +++ b/tests/Exceptionless.Tests/Jobs/JobRunnerOptionsTests.cs @@ -0,0 +1,31 @@ +using Exceptionless.Job; +using Xunit; + +namespace Exceptionless.Tests.Jobs; + +public sealed class JobRunnerOptionsTests +{ + [Fact] + public void RunDataSeedStartupAction_AllJobs_ReturnsFalse() + { + var options = new JobRunnerOptions([]); + + Assert.False(options.RunDataSeedStartupAction); + } + + [Fact] + public void RunDataSeedStartupAction_MigrationJob_ReturnsFalse() + { + var options = new JobRunnerOptions([nameof(JobRunnerOptions.Migration)]); + + Assert.False(options.RunDataSeedStartupAction); + } + + [Fact] + public void RunDataSeedStartupAction_NonMigrationJob_ReturnsTrue() + { + var options = new JobRunnerOptions([nameof(JobRunnerOptions.EventPosts)]); + + Assert.True(options.RunDataSeedStartupAction); + } +} diff --git a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs index 8a70c45618..62829b2bf2 100644 --- a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs +++ b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs @@ -112,4 +112,73 @@ public async Task RunAsync_LegacySavedView_ReplacesDocumentWithStructuredColumns Assert.False(getResponse.Source.TryGetProperty("column_order", out _)); Assert.Equal(JsonValueKind.Object, getResponse.Source.GetProperty("columns").GetProperty("project").ValueKind); } + + [Fact] + public async Task IndexMigratedDocumentAsync_ConcurrentUpdate_DoesNotOverwriteDocument() + { + // Arrange + const string savedViewId = "770000000000000000000097"; + var source = JsonNode.Parse( + $$""" + { + "id": "{{savedViewId}}", + "name": "Legacy Columns", + "columns": { + "project": true + } + } + """ + )!.AsObject(); + + var indexResponse = await _client.IndexAsync( + source, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(savedViewId) + .Refresh(Refresh.WaitFor), + TestCancellationToken); + Assert.True(indexResponse.IsValidResponse); + + var staleDocument = await _client.GetAsync( + savedViewId, + request => request.Index(_configuration.SavedViews.VersionedName), + TestCancellationToken); + Assert.True(staleDocument.IsValidResponse); + Assert.NotNull(staleDocument.SeqNo); + Assert.NotNull(staleDocument.PrimaryTerm); + + var concurrentSource = source.DeepClone().AsObject(); + concurrentSource["name"] = "Edited During Migration"; + indexResponse = await _client.IndexAsync( + concurrentSource, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(savedViewId) + .Refresh(Refresh.WaitFor), + TestCancellationToken); + Assert.True(indexResponse.IsValidResponse); + + Assert.True(MigrateSavedViewColumns.TryMigrate(source)); + var migration = GetService(); + + // Act + indexResponse = await migration.IndexMigratedDocumentAsync( + source, + savedViewId, + staleDocument.SeqNo.Value, + staleDocument.PrimaryTerm.Value, + TestCancellationToken); + + // Assert + Assert.False(indexResponse.IsValidResponse); + Assert.Equal(409, indexResponse.ApiCallDetails.HttpStatusCode); + + var currentDocument = await _client.GetAsync( + savedViewId, + request => request.Index(_configuration.SavedViews.VersionedName), + TestCancellationToken); + Assert.True(currentDocument.IsValidResponse); + Assert.Equal("Edited During Migration", currentDocument.Source.GetProperty("name").GetString()); + Assert.Equal(JsonValueKind.True, currentDocument.Source.GetProperty("columns").GetProperty("project").ValueKind); + } } diff --git a/tests/Exceptionless.Tests/Migrations/MigrationRegistrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrationRegistrationTests.cs new file mode 100644 index 0000000000..8b9a1b495a --- /dev/null +++ b/tests/Exceptionless.Tests/Migrations/MigrationRegistrationTests.cs @@ -0,0 +1,29 @@ +using Foundatio.Repositories.Migrations; +using Xunit; + +namespace Exceptionless.Tests.Migrations; + +public sealed class MigrationRegistrationTests : TestWithServices +{ + public MigrationRegistrationTests(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void RegisteredVersionedMigrations_AllVersions_AreUnique() + { + var duplicateVersions = GetService>() + .DistinctBy(migration => migration.GetType()) + .Where(migration => migration.MigrationType is MigrationType.Versioned or MigrationType.VersionedAndResumable) + .GroupBy(migration => migration.Version) + .Where(group => group.Count() > 1) + .Select(group => new + { + Version = group.Key, + Migrations = group.Select(migration => migration.GetType().Name).Order().ToArray() + }) + .ToList(); + + Assert.Empty(duplicateVersions); + } +} From 5e177baeaf622f69582928a7974ee2e406e55574 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 1 Aug 2026 14:21:20 -0500 Subject: [PATCH 05/11] Harden saved view column migration --- .../Migrations/004_MigrateSavedViewColumns.cs | 21 ++++++++++++------- .../src/lib/features/shared/table.svelte.ts | 1 + ...MigrateSavedViewColumnsIntegrationTests.cs | 20 ++++++++++++++++++ 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs b/src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs index 8b29c8e2e1..c0add098f3 100644 --- a/src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs +++ b/src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs @@ -49,6 +49,7 @@ public override async Task RunAsync(MigrationContext context) if (!pointInTime.IsValidResponse || String.IsNullOrWhiteSpace(pointInTime.Id)) throw new InvalidOperationException("Unable to open a point in time for the saved view migration."); + string pointInTimeId = pointInTime.Id; FieldValue[]? searchAfter = null; int migrated = 0; @@ -59,7 +60,7 @@ public override async Task RunAsync(MigrationContext context) var response = await _client.SearchAsync(request => { request - .Pit(pit => pit.Id(pointInTime.Id).KeepAlive("2m")) + .Pit(pit => pit.Id(pointInTimeId).KeepAlive("2m")) .SeqNoPrimaryTerm() .Size(pageSize) .Sort(sort => sort.Field("_shard_doc")); @@ -72,6 +73,9 @@ public override async Task RunAsync(MigrationContext context) if (!response.IsValidResponse) throw new InvalidOperationException("Unable to read saved views during the column migration."); + if (!String.IsNullOrWhiteSpace(response.PitId)) + pointInTimeId = response.PitId; + foreach (var hit in response.Hits) { if (hit.Source.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) @@ -85,11 +89,11 @@ public override async Task RunAsync(MigrationContext context) throw new InvalidOperationException($"Unable to read concurrency metadata for saved view '{hit.Id}'."); string? legacyContentHash = GetLegacyContentHash(source); - if (!TryMigrate(source)) + bool columnsMigrated = TryMigrate(source); + bool contentHashMigrated = UpdatePredefinedContentHash(source, legacyContentHash); + if (!columnsMigrated && !contentHashMigrated) continue; - UpdatePredefinedContentHash(source, legacyContentHash); - var indexResponse = await IndexMigratedDocumentAsync( source, hit.Id, @@ -119,7 +123,7 @@ public override async Task RunAsync(MigrationContext context) finally { var closeResponse = await _client.ClosePointInTimeAsync( - request => request.Id(pointInTime.Id), + request => request.Id(pointInTimeId), CancellationToken.None); _logger.LogRequest(closeResponse); } @@ -195,20 +199,21 @@ internal static bool TryMigrate(JsonObject source) return true; } - private void UpdatePredefinedContentHash(JsonObject source, string? legacyContentHash) + private bool UpdatePredefinedContentHash(JsonObject source, string? legacyContentHash) { string? predefinedContentHash = source["predefined_content_hash"]?.GetValue(); if (String.IsNullOrWhiteSpace(predefinedContentHash)) - return; + return false; if (!String.Equals(predefinedContentHash, legacyContentHash, StringComparison.Ordinal)) - return; + return false; var savedView = _serializer.Deserialize(source.ToJsonString(JsonOptions)); if (savedView is null) throw new InvalidOperationException("Unable to deserialize a migrated saved view."); source["predefined_content_hash"] = PredefinedSavedViewContentHasher.GetContentHash(savedView); + return true; } internal static string? GetLegacyContentHash(JsonObject source) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts index 851de4eb10..4aeab62162 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts @@ -249,6 +249,7 @@ export function getSharedTableOptions = { + columnResizeMode: 'onChange', get columns() { return columns(); }, diff --git a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs index 62829b2bf2..8ed78493fa 100644 --- a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs +++ b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs @@ -39,6 +39,7 @@ public async Task RunAsync_LegacySavedView_ReplacesDocumentWithStructuredColumns // Arrange const string savedViewId = "770000000000000000000099"; const string customizedSavedViewId = "770000000000000000000098"; + const string predefinedWithoutColumnsId = "770000000000000000000096"; var source = JsonNode.Parse( $$""" { @@ -67,6 +68,12 @@ public async Task RunAsync_LegacySavedView_ReplacesDocumentWithStructuredColumns customizedSource["id"] = customizedSavedViewId; customizedSource["name"] = "Customized Legacy Columns"; customizedSource["predefined_content_hash"] = "customized-content-hash"; + var predefinedWithoutColumnsSource = source.DeepClone().AsObject(); + predefinedWithoutColumnsSource["id"] = predefinedWithoutColumnsId; + predefinedWithoutColumnsSource["name"] = "Predefined Without Columns"; + predefinedWithoutColumnsSource.Remove("columns"); + predefinedWithoutColumnsSource.Remove("column_order"); + predefinedWithoutColumnsSource["predefined_content_hash"] = MigrateSavedViewColumns.GetLegacyContentHash(predefinedWithoutColumnsSource); var indexResponse = await _client.IndexAsync( source, @@ -76,6 +83,14 @@ public async Task RunAsync_LegacySavedView_ReplacesDocumentWithStructuredColumns TestCancellationToken); Assert.True(indexResponse.IsValidResponse); + indexResponse = await _client.IndexAsync( + predefinedWithoutColumnsSource, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(predefinedWithoutColumnsId), + TestCancellationToken); + Assert.True(indexResponse.IsValidResponse); + indexResponse = await _client.IndexAsync( customizedSource, request => request @@ -104,6 +119,11 @@ public async Task RunAsync_LegacySavedView_ReplacesDocumentWithStructuredColumns Assert.Equal("customized-content-hash", customizedSavedView.PredefinedContentHash); Assert.Equal(1, customizedSavedView.Columns?["project"].Position); + var predefinedWithoutColumns = await _repository.GetByIdAsync(predefinedWithoutColumnsId, o => o.ImmediateConsistency()); + Assert.NotNull(predefinedWithoutColumns); + Assert.Null(predefinedWithoutColumns.Columns); + Assert.Equal(PredefinedSavedViewContentHasher.GetContentHash(predefinedWithoutColumns), predefinedWithoutColumns.PredefinedContentHash); + var getResponse = await _client.GetAsync( savedViewId, request => request.Index(_configuration.SavedViews.VersionedName), From 8158f1710400b3d88f2d8f469f5deba1fce4b337 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 1 Aug 2026 14:47:21 -0500 Subject: [PATCH 06/11] Defer data seeds until migrations complete --- .../Seed/DataSeedService.cs | 20 ++++- ...MigrateSavedViewColumnsIntegrationTests.cs | 79 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Seed/DataSeedService.cs b/src/Exceptionless.Core/Seed/DataSeedService.cs index e8962284a7..0ce94a88c7 100644 --- a/src/Exceptionless.Core/Seed/DataSeedService.cs +++ b/src/Exceptionless.Core/Seed/DataSeedService.cs @@ -1,4 +1,5 @@ using Foundatio.Extensions.Hosting.Startup; +using Foundatio.Repositories.Migrations; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Seed; @@ -6,17 +7,30 @@ namespace Exceptionless.Core.Seed; public class DataSeedService : IStartupAction { private readonly IEnumerable _seeds; + private readonly MigrationManager _migrationManager; private readonly ILogger _logger; - public DataSeedService(IEnumerable seeds, ILoggerFactory loggerFactory) + public DataSeedService(IEnumerable seeds, MigrationManager migrationManager, ILoggerFactory loggerFactory) { _seeds = seeds; + _migrationManager = migrationManager; _logger = loggerFactory.CreateLogger(); } - public Task RunAsync(CancellationToken shutdownToken = default) + public async Task RunAsync(CancellationToken shutdownToken = default) { - return SeedAsync(shutdownToken); + if (_migrationManager.Migrations.Count == 0) + _migrationManager.AddMigrationsFromLoadedAssemblies(); + + var migrationStatus = await _migrationManager.GetMigrationStatus(); + int pendingVersionedMigrationCount = migrationStatus.PendingMigrations.Count(migration => migration.Migration.MigrationType != MigrationType.Repeatable); + if (pendingVersionedMigrationCount > 0) + { + _logger.LogInformation("Skipping data seeds while {PendingMigrationCount} versioned migration(s) are pending", pendingVersionedMigrationCount); + return; + } + + await SeedAsync(shutdownToken); } public async Task SeedAsync(CancellationToken cancellationToken = default) diff --git a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs index 8ed78493fa..81e00a7cfc 100644 --- a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs +++ b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs @@ -33,6 +33,85 @@ protected override void RegisterServices(IServiceCollection services) base.RegisterServices(services); } + [Fact] + public async Task DataSeedStartupAction_PendingMigration_DoesNotReadLegacySavedViews() + { + // Arrange + const string savedViewId = "770000000000000000000095"; + var source = JsonNode.Parse( + $$""" + { + "id": "{{savedViewId}}", + "organization_id": "{{PredefinedSavedViewsDataSeed.SystemOrganizationId}}", + "created_by_user_id": "{{PredefinedSavedViewsDataSeed.SystemUserId}}", + "name": "Legacy Predefined View", + "slug": "legacy-predefined-view", + "view_type": "events", + "columns": { + "level": true + }, + "version": 1, + "created_utc": "2026-01-01T00:00:00Z", + "updated_utc": "2026-01-01T00:00:00Z" + } + """ + )!.AsObject(); + + var migrationStateRepository = GetService(); + await migrationStateRepository.AddAsync(new MigrationState + { + Id = "3", + Version = 3, + MigrationType = MigrationType.Versioned, + StartedUtc = DateTime.UtcNow, + CompletedUtc = DateTime.UtcNow + }); + + var indexResponse = await _client.IndexAsync( + source, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(savedViewId) + .Refresh(Refresh.WaitFor), + TestCancellationToken); + Assert.True(indexResponse.IsValidResponse); + + // Act + await GetService().RunAsync(TestCancellationToken); + + // Assert + var getResponse = await _client.GetAsync( + savedViewId, + request => request.Index(_configuration.SavedViews.VersionedName), + TestCancellationToken); + Assert.True(getResponse.IsValidResponse); + Assert.Equal(JsonValueKind.True, getResponse.Source.GetProperty("columns").GetProperty("level").ValueKind); + } + + [Fact] + public async Task DataSeedStartupAction_OnlyRepeatableMigrationsPending_SeedsData() + { + // Arrange + var migrationStateRepository = GetService(); + await migrationStateRepository.AddAsync(new MigrationState + { + Id = "4", + Version = 4, + MigrationType = MigrationType.VersionedAndResumable, + StartedUtc = DateTime.UtcNow, + CompletedUtc = DateTime.UtcNow + }); + + // Act + await GetService().RunAsync(TestCancellationToken); + + // Assert + var savedViews = await _repository.GetByOrganizationIdAsync( + PredefinedSavedViewsDataSeed.SystemOrganizationId, + o => o.ImmediateConsistency()); + Assert.NotEmpty(savedViews.Documents); + } + [Fact] public async Task RunAsync_LegacySavedView_ReplacesDocumentWithStructuredColumns() { From 0b613bd80c8ac41d78b92f9ba65c784d040e3b8d Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 1 Aug 2026 15:16:13 -0500 Subject: [PATCH 07/11] Rerun saved view migration after legacy writes --- ...SavedViewColumns.cs => 005_MigrateSavedViewColumns.cs} | 2 +- .../Migrations/MigrateSavedViewColumnsIntegrationTests.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/Exceptionless.Core/Migrations/{004_MigrateSavedViewColumns.cs => 005_MigrateSavedViewColumns.cs} (99%) diff --git a/src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs b/src/Exceptionless.Core/Migrations/005_MigrateSavedViewColumns.cs similarity index 99% rename from src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs rename to src/Exceptionless.Core/Migrations/005_MigrateSavedViewColumns.cs index c0add098f3..08f98f446d 100644 --- a/src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs +++ b/src/Exceptionless.Core/Migrations/005_MigrateSavedViewColumns.cs @@ -34,7 +34,7 @@ public MigrateSavedViewColumns( _serializer = serializer; MigrationType = MigrationType.VersionedAndResumable; - Version = 4; + Version = 5; } public override async Task RunAsync(MigrationContext context) diff --git a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs index 81e00a7cfc..1a76a61507 100644 --- a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs +++ b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs @@ -60,8 +60,8 @@ public async Task DataSeedStartupAction_PendingMigration_DoesNotReadLegacySavedV var migrationStateRepository = GetService(); await migrationStateRepository.AddAsync(new MigrationState { - Id = "3", - Version = 3, + Id = "4", + Version = 4, MigrationType = MigrationType.Versioned, StartedUtc = DateTime.UtcNow, CompletedUtc = DateTime.UtcNow @@ -95,8 +95,8 @@ public async Task DataSeedStartupAction_OnlyRepeatableMigrationsPending_SeedsDat var migrationStateRepository = GetService(); await migrationStateRepository.AddAsync(new MigrationState { - Id = "4", - Version = 4, + Id = "5", + Version = 5, MigrationType = MigrationType.VersionedAndResumable, StartedUtc = DateTime.UtcNow, CompletedUtc = DateTime.UtcNow From 16362ae1f5fa564b865875d9d583f729d5a9e9ec Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 1 Aug 2026 15:34:31 -0500 Subject: [PATCH 08/11] Harden saved view migration execution --- .../Migrations/005_MigrateSavedViewColumns.cs | 20 ++++++++++++++++--- ...MigrateSavedViewColumnsIntegrationTests.cs | 9 +++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Migrations/005_MigrateSavedViewColumns.cs b/src/Exceptionless.Core/Migrations/005_MigrateSavedViewColumns.cs index 08f98f446d..a144a40c18 100644 --- a/src/Exceptionless.Core/Migrations/005_MigrateSavedViewColumns.cs +++ b/src/Exceptionless.Core/Migrations/005_MigrateSavedViewColumns.cs @@ -40,9 +40,10 @@ public MigrateSavedViewColumns( public override async Task RunAsync(MigrationContext context) { const int pageSize = 500; + const string pointInTimeKeepAlive = "30m"; var pointInTime = await _client.OpenPointInTimeAsync( _configuration.SavedViews.VersionedName, - request => request.KeepAlive("2m"), + request => request.KeepAlive(pointInTimeKeepAlive), context.CancellationToken); _logger.LogRequest(pointInTime); @@ -57,10 +58,12 @@ public override async Task RunAsync(MigrationContext context) { do { + context.CancellationToken.ThrowIfCancellationRequested(); + var response = await _client.SearchAsync(request => { request - .Pit(pit => pit.Id(pointInTimeId).KeepAlive("2m")) + .Pit(pit => pit.Id(pointInTimeId).KeepAlive(pointInTimeKeepAlive)) .SeqNoPrimaryTerm() .Size(pageSize) .Sort(sort => sort.Field("_shard_doc")); @@ -118,7 +121,18 @@ public override async Task RunAsync(MigrationContext context) : null; await context.Lock.RenewAsync(); - } while (searchAfter is not null && !context.CancellationToken.IsCancellationRequested); + } while (searchAfter is not null); + + if (migrated > 0) + { + var refreshResponse = await _client.Indices.RefreshAsync( + _configuration.SavedViews.VersionedName, + context.CancellationToken); + _logger.LogRequest(refreshResponse); + + if (!refreshResponse.IsValidResponse) + throw new InvalidOperationException("Unable to refresh saved views after the column migration."); + } } finally { diff --git a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs index 1a76a61507..5824bd5584 100644 --- a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs +++ b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs @@ -210,6 +210,15 @@ public async Task RunAsync_LegacySavedView_ReplacesDocumentWithStructuredColumns Assert.True(getResponse.IsValidResponse); Assert.False(getResponse.Source.TryGetProperty("column_order", out _)); Assert.Equal(JsonValueKind.Object, getResponse.Source.GetProperty("columns").GetProperty("project").ValueKind); + + var searchResponse = await _client.SearchAsync( + request => request + .Indices(_configuration.SavedViews.VersionedName) + .Size(100), + TestCancellationToken); + Assert.True(searchResponse.IsValidResponse); + var searchedSavedView = Assert.Single(searchResponse.Hits, hit => hit.Id == savedViewId); + Assert.Equal(JsonValueKind.Object, searchedSavedView.Source.GetProperty("columns").GetProperty("project").ValueKind); } [Fact] From acd467a3c584bafd5d80f93b06b6f19ff00913c9 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 1 Aug 2026 15:52:09 -0500 Subject: [PATCH 09/11] Publish saved view column bounds --- .../Models/SavedViewColumnSettings.cs | 4 ++++ .../ClientApp/src/lib/generated/api.ts | 4 ++++ .../ClientApp/src/lib/generated/schemas.ts | 12 ++++++++++-- tests/Exceptionless.Tests/Api/Data/openapi.json | 4 ++++ .../Exceptionless.Tests/Api/OpenApiSnapshotTests.cs | 11 ++++++++++- 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs b/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs index c4746ecdac..0dca7c0da3 100644 --- a/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs +++ b/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace Exceptionless.Core.Models; /// @@ -14,9 +16,11 @@ public sealed record SavedViewColumnSettings public bool? Visible { get; set; } /// Zero-based display position. Null means use the table default order. + [Range(0, MaxPosition)] public int? Position { get; set; } /// Column width in pixels. Null means use the table default width. + [Range(MinWidth, MaxWidth)] public int? Width { get; set; } } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 420dadd4ae..1dc93962ed 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -394,11 +394,15 @@ export interface SavedViewColumnSettings { /** * Zero-based display position. Null means use the table default order. * @format int32 + * @min 0 + * @max 49 */ position?: null | number; /** * Column width in pixels. Null means use the table default width. * @format int32 + * @min 48 + * @max 1200 */ width?: null | number; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 73bd18e397..1f854f897e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -513,8 +513,16 @@ export type ResetPasswordModelFormData = Infer; export const SavedViewColumnSettingsSchema = object({ visible: boolean().nullable().optional(), - position: int32().nullable().optional(), - width: int32().nullable().optional(), + position: int32() + .min(0, "Position must be at least 0") + .max(49, "Position must be at most 49") + .nullable() + .optional(), + width: int32() + .min(48, "Width must be at least 48") + .max(1200, "Width must be at most 1200") + .nullable() + .optional(), }); export type SavedViewColumnSettingsFormData = Infer< typeof SavedViewColumnSettingsSchema diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 3350224f81..4803392646 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -12235,6 +12235,8 @@ "description": "Whether the column is visible. Null means use the table default." }, "position": { + "maximum": 49, + "minimum": 0, "type": [ "null", "integer" @@ -12243,6 +12245,8 @@ "format": "int32" }, "width": { + "maximum": 1200, + "minimum": 48, "type": [ "null", "integer" diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index ee8ed3c4aa..424cd3c5ba 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Exceptionless.Core.Models; using Exceptionless.Web.Api.Infrastructure; using Microsoft.AspNetCore.TestHost; using Xunit; @@ -81,10 +82,18 @@ public async Task GetOpenApiJson_Default_ContainsExpectedSchemasAndSecuritySchem Assert.True(schemas.TryGetProperty("Login", out _)); Assert.True(schemas.TryGetProperty("Signup", out _)); Assert.True(schemas.TryGetProperty("NewProject", out _)); - Assert.True(schemas.TryGetProperty("SavedViewColumnSettings", out _)); + Assert.True(schemas.TryGetProperty("SavedViewColumnSettings", out var savedViewColumnSettings)); Assert.True(schemas.TryGetProperty("TokenResult", out _)); Assert.True(schemas.TryGetProperty("ViewOrganization", out _)); + var savedViewColumnProperties = savedViewColumnSettings.GetProperty("properties"); + var position = savedViewColumnProperties.GetProperty("position"); + Assert.Equal(0, position.GetProperty("minimum").GetInt32()); + Assert.Equal(SavedViewColumnSettings.MaxPosition, position.GetProperty("maximum").GetInt32()); + var width = savedViewColumnProperties.GetProperty("width"); + Assert.Equal(SavedViewColumnSettings.MinWidth, width.GetProperty("minimum").GetInt32()); + Assert.Equal(SavedViewColumnSettings.MaxWidth, width.GetProperty("maximum").GetInt32()); + Assert.True(securitySchemes.TryGetProperty("Basic", out var basic)); Assert.Equal("http", basic.GetProperty("type").GetString()); Assert.Equal("basic", basic.GetProperty("scheme").GetString()); From 1daa84f3bf73c34789c5ac0b0429ac561794efb3 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 1 Aug 2026 16:09:58 -0500 Subject: [PATCH 10/11] Validate predefined views before replacement --- .../Api/Handlers/SavedViewHandler.cs | 15 +++++--- .../Api/Endpoints/SavedViewEndpointTests.cs | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs index 78d4edc886..3d2cf82ae3 100644 --- a/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs @@ -132,11 +132,11 @@ public async Task>> Ha public async Task>> Handle(ReplacePredefinedSavedViews message) { - foreach (var viewType in NewSavedView.ValidViewTypes) + foreach (var definition in message.Definitions) { - var existingViews = await GetSystemPredefinedSavedViewsAsync(viewType); - if (existingViews.Count > 0) - await repository.RemoveAsync(existingViews.Select(v => v.Id).ToList(), o => o.ImmediateConsistency()); + var validationError = NewSavedView.ValidateColumns(definition.ViewType, definition.Columns).FirstOrDefault(); + if (validationError is not null) + return Result.Invalid(ValidationError.Create("definitions", validationError.ErrorMessage ?? "Invalid column configuration.")); } var savedViews = message.Definitions.Select(definition => new SavedView @@ -161,6 +161,13 @@ public async Task>> Ha foreach (var savedView in savedViews) savedView.PredefinedContentHash = PredefinedSavedViewContentHasher.GetContentHash(savedView); + foreach (var viewType in NewSavedView.ValidViewTypes) + { + var existingViews = await GetSystemPredefinedSavedViewsAsync(viewType); + if (existingViews.Count > 0) + await repository.RemoveAsync(existingViews.Select(v => v.Id).ToList(), o => o.ImmediateConsistency()); + } + if (savedViews.Count > 0) await repository.AddAsync(savedViews, o => o.Cache().ImmediateConsistency()); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs index 920610e84a..af49a4fc9e 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs @@ -284,6 +284,40 @@ public async Task PutPredefinedAsync_AsGlobalAdmin_ReplacesPredefinedDefinitions Assert.Contains(savedViews.Documents, view => String.Equals(view.Name, "API Surface Import", StringComparison.Ordinal)); } + [Fact] + public async Task PutPredefinedAsync_NullColumnSettings_PreservesExistingDefinitions() + { + // Arrange + var existingViews = await GetSystemPredefinedSavedViewsAsync(); + Assert.NotEmpty(existingViews); + var existingIds = existingViews.Select(view => view.Id).Order().ToArray(); + const string definitions = + """ + [{ + "key": "events:invalid-columns", + "name": "Invalid Columns", + "slug": "invalid-columns", + "viewType": "events", + "columns": { + "project": null + } + }] + """; + + // Act + await SendRequestAsync(r => r + .Put() + .AsGlobalAdminUser() + .AppendPaths("saved-views", "predefined") + .Content(definitions, "application/json") + .StatusCodeShouldBeUnprocessableEntity() + ); + + // Assert + var remainingViews = await GetSystemPredefinedSavedViewsAsync(); + Assert.Equal(existingIds, remainingViews.Select(view => view.Id).Order()); + } + [Fact] public async Task PostAsync_NewSavedView_MapsAllPropertiesToSavedView() { From e1caec90e3915b2653dd50f411d945b77730a53e Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 1 Aug 2026 16:29:14 -0500 Subject: [PATCH 11/11] fix column header resizing --- .../components/data-table/data-table-body.svelte | 8 +++++++- .../data-table/data-table-body.svelte.test.ts | 15 +++++++++++++++ .../data-table-body.test-harness.svelte | 12 +++++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index 8cfd62c50a..dc83dc8d81 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -57,6 +57,10 @@ return classes.filter(Boolean).join(' '); } + function getHeaderContentClass(header: Header, headerClass: string): string { + return header.column.getCanResize() ? removeWidthClasses(headerClass) : headerClass; + } + function getColumnStyle(column: Cell['column'] | Header['column']): string | undefined { if (!column.getCanResize() || getVisibleDataColumnCount() === 1) { return undefined; @@ -141,7 +145,9 @@ {#each headerGroup.headers as header (header.id)} {@const headerClass = getHeaderColumnClass(header)} - + {#if header.column.getCanResize()}