From 1056033dfb69726bde06ac119c61495085204c8c Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 26 Feb 2026 22:53:35 -0600 Subject: [PATCH 1/4] feat(saved-views): add organization and private saved views across API and UI Implements saved views end-to-end with repository/index support, API endpoints, and Svelte integration for events/issues/stream dashboards. Adds coverage for controller behavior and Mapperly mappings, including organization-wide vs private visibility and default-view behavior. --- .agents/skills/frontend-architecture/SKILL.md | 6 + .../skills/typescript-conventions/SKILL.md | 9 + src/Exceptionless.Core/Bootstrapper.cs | 1 + src/Exceptionless.Core/Models/Organization.cs | 15 + src/Exceptionless.Core/Models/SavedView.cs | 86 + .../ExceptionlessElasticConfiguration.cs | 2 + .../Indexes/OrganizationIndex.cs | 1 + .../Configuration/Indexes/SavedViewIndex.cs | 42 + .../Interfaces/ISavedViewRepository.cs | 16 + .../Repositories/SavedViewRepository.cs | 60 + .../Services/OrganizationService.cs | 18 +- .../components/filters/helpers.svelte.test.ts | 447 ++++- .../components/filters/helpers.svelte.ts | 87 +- .../lib/features/organizations/api.svelte.ts | 45 + .../lib/features/saved-views/api.svelte.ts | 120 ++ .../components/saved-view-picker.svelte | 615 +++++++ .../src/lib/features/saved-views/index.ts | 3 + .../src/lib/features/saved-views/models.ts | 13 + .../saved-views/use-saved-views.svelte.ts | 248 +++ .../ClientApp/src/lib/generated/api.ts | 56 +- .../ClientApp/src/lib/generated/schemas.ts | 88 +- .../(app)/(components)/layouts/sidebar.svelte | 72 +- .../ClientApp/src/routes/(app)/+layout.svelte | 92 +- .../ClientApp/src/routes/(app)/+page.svelte | 31 +- .../src/routes/(app)/issues/+page.svelte | 31 +- .../[organizationId]/features/+page.svelte | 116 ++ .../[organizationId]/routes.svelte.ts | 8 + .../src/routes/(app)/stream/+page.svelte | 35 +- .../ClientApp/src/routes/routes.svelte.ts | 9 + .../Controllers/OrganizationController.cs | 47 + .../Controllers/SavedViewController.cs | 317 ++++ src/Exceptionless.Web/Mapping/ApiMapper.cs | 12 + .../Mapping/SavedViewMapper.cs | 19 + .../Models/Organization/ViewOrganization.cs | 1 + .../Models/SavedView/NewSavedView.cs | 93 + .../Models/SavedView/UpdateSavedView.cs | 26 + .../Models/SavedView/ViewSavedView.cs | 34 + .../Controllers/Data/openapi.json | 576 +++++++ .../OrganizationControllerTests.cs | 124 ++ .../Controllers/SavedViewControllerTests.cs | 1524 +++++++++++++++++ .../Mapping/SavedViewMapperTests.cs | 198 +++ tests/http/saved-views.http | 93 + 42 files changed, 5407 insertions(+), 29 deletions(-) create mode 100644 src/Exceptionless.Core/Models/SavedView.cs create mode 100644 src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs create mode 100644 src/Exceptionless.Core/Repositories/Interfaces/ISavedViewRepository.cs create mode 100644 src/Exceptionless.Core/Repositories/SavedViewRepository.cs create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/index.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte create mode 100644 src/Exceptionless.Web/Controllers/SavedViewController.cs create mode 100644 src/Exceptionless.Web/Mapping/SavedViewMapper.cs create mode 100644 src/Exceptionless.Web/Models/SavedView/NewSavedView.cs create mode 100644 src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs create mode 100644 src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs create mode 100644 tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs create mode 100644 tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs create mode 100644 tests/http/saved-views.http diff --git a/.agents/skills/frontend-architecture/SKILL.md b/.agents/skills/frontend-architecture/SKILL.md index f28b12c4a5..1504fac612 100644 --- a/.agents/skills/frontend-architecture/SKILL.md +++ b/.agents/skills/frontend-architecture/SKILL.md @@ -218,6 +218,12 @@ import { User } from "$features/users/models"; // $lib/features import { formatDate } from "$shared/formatters"; // $lib/features/shared ``` +## Project Svelte Rules + +- Prefer `$derived` for computed state and `$effect` for side effects. +- Use `untrack()` inside `$effect` when needed to avoid reactive loops. +- Prefer `kit-query-params` (`queryParamsState`) for route query parameter binding instead of ad-hoc URL parsing. + ## Consistency Rule **Before creating anything new, search the codebase for existing patterns.** Consistency is the most important quality of a codebase: diff --git a/.agents/skills/typescript-conventions/SKILL.md b/.agents/skills/typescript-conventions/SKILL.md index d4a35f0a2a..a54a1e5c5c 100644 --- a/.agents/skills/typescript-conventions/SKILL.md +++ b/.agents/skills/typescript-conventions/SKILL.md @@ -16,6 +16,13 @@ description: > - **Minimize diffs**: Change only what's necessary, preserve existing formatting and structure - Match surrounding code style exactly +## Project Frontend Rules + +- Always use braces for all control flow statements (`if`, `for`, `while`, etc.). +- Always use block bodies for arrow functions that return statements; avoid single-expression shorthand in project code. +- Do not use abbreviations in identifiers (`organization`, not `org`; `filter`, not `filt`). +- Avoid inline single-line condition + return patterns; use multi-line blocks for readability. + ## File Naming - Use **kebab-case** for files and directories @@ -36,6 +43,8 @@ import { formatDate, formatNumber } from "$lib/utils/formatters"; import * as utils from "$lib/utils"; ``` +- Named imports are preferred for most modules; namespace imports should be limited to approved patterns (e.g., shadcn composite imports). + ### Allowed Namespace Imports ```typescript diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index f264439dca..f90f3a5559 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -153,6 +153,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Exceptionless.Core/Models/Organization.cs b/src/Exceptionless.Core/Models/Organization.cs index 0c09db2721..d1c75df1b8 100644 --- a/src/Exceptionless.Core/Models/Organization.cs +++ b/src/Exceptionless.Core/Models/Organization.cs @@ -130,6 +130,12 @@ public Organization() /// public bool HasPremiumFeatures { get; set; } + /// + /// Set of enabled feature flags for this organization (e.g., "feature-saved-views"). + /// Feature identifiers are always stored in lowercase. + /// + public ISet Features { get; set; } = new HashSet(); + /// /// Maximum number of users allowed by the current plan. /// @@ -176,3 +182,12 @@ public enum BillingStatus Canceled = 3, Unpaid = 4 } + +/// +/// Well-known organization feature flag identifiers. +/// +public static class OrganizationFeatures +{ + /// Enables the Saved Views feature for the organization. + public const string SavedViews = "feature-saved-views"; +} diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs new file mode 100644 index 0000000000..a394210c96 --- /dev/null +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -0,0 +1,86 @@ +using System.ComponentModel.DataAnnotations; +using Exceptionless.Core.Attributes; +using Foundatio.Repositories.Models; + +namespace Exceptionless.Core.Models; + +/// +/// A saved view captures filter, time range, and display settings for a dashboard page. +/// Org-scoped; optionally user-private when UserId is set. +/// +public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates +{ + /// The set of valid dashboard view identifiers. + public static readonly string[] ValidViews = ["events", "issues", "stream"]; + + /// Valid column IDs per view, matching the TanStack Table column definitions. + public static readonly IReadOnlyDictionary> ValidColumnIds = + new Dictionary> + { + ["events"] = new HashSet { "user", "date" }, + ["issues"] = new HashSet { "status", "users", "events", "first", "last" }, + ["stream"] = new HashSet { "user", "date" } + }; + + /// Union of all valid column IDs across all views. + public static readonly IReadOnlySet AllValidColumnIds = + new HashSet(ValidColumnIds.Values.SelectMany(ids => ids)); + + // Identity + [ObjectId] + public string Id { get; set; } = null!; + + [ObjectId] + [Required] + public string OrganizationId { get; set; } = null!; + + // User associations + /// When set, this view is private to the specified user. Null means org-wide. + [ObjectId] + public string? UserId { get; set; } + + /// The user who originally created this view. + [ObjectId] + [Required] + public string CreatedByUserId { get; set; } = null!; + + /// The user who last modified this view. + [ObjectId] + public string? UpdatedByUserId { get; set; } + + // View configuration + /// Raw Lucene filter query string, e.g. "(status:open OR status:regressed)". Null means no filter (show all). + [MaxLength(2000)] + public string? Filter { get; set; } + + /// JSON array of structured filter objects for UI chip hydration. + [MaxLength(10000)] + public string? FilterDefinitions { get; set; } + + /// Column visibility state per dashboard table, keyed by column id. + public Dictionary? Columns { get; set; } + + /// Whether this view loads automatically when navigating to the page. + public bool IsDefault { get; set; } + + /// Display name shown in the sidebar and picker. + [Required] + [MaxLength(100)] + public string Name { get; set; } = null!; + + /// Date-math time range, e.g. "[now-7d TO now]". Null if no time constraint. + [MaxLength(100)] + public string? Time { get; set; } + + /// Schema version for future filter definition migrations. + public int Version { get; set; } = 1; + + /// Dashboard page identifier: "events", "issues", or "stream". + [Required] + [RegularExpression("^(events|issues|stream)$")] + public string View { get; set; } = null!; + + // Timestamps + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } +} diff --git a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs index 4641056b31..2158adeade 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs @@ -44,6 +44,7 @@ ILoggerFactory loggerFactory AddIndex(Migrations = new MigrationIndex(this, _appOptions.ElasticsearchOptions.ScopePrefix + "migrations", appOptions.ElasticsearchOptions.NumberOfReplicas)); AddIndex(Organizations = new OrganizationIndex(this)); AddIndex(Projects = new ProjectIndex(this)); + AddIndex(SavedViews = new SavedViewIndex(this)); AddIndex(Tokens = new TokenIndex(this)); AddIndex(Users = new UserIndex(this)); AddIndex(WebHooks = new WebHookIndex(this)); @@ -71,6 +72,7 @@ public override void ConfigureGlobalQueryBuilders(ElasticQueryBuilder builder) public MigrationIndex Migrations { get; } public OrganizationIndex Organizations { get; } public ProjectIndex Projects { get; } + public SavedViewIndex SavedViews { get; } public TokenIndex Tokens { get; } public UserIndex Users { get; } public WebHookIndex WebHooks { get; } diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs index 079a916f2e..11a487b319 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs @@ -24,6 +24,7 @@ public override TypeMappingDescriptor ConfigureIndexMapping(TypeMa .Text(f => f.Name(e => e.Name).AddKeywordField()) .Keyword(f => f.Name(u => u.StripeCustomerId)) .Boolean(f => f.Name(u => u.HasPremiumFeatures)) + .Keyword(f => f.Name(u => u.Features)) .Keyword(f => f.Name(u => u.PlanId)) .Keyword(f => f.Name(u => u.PlanName).IgnoreAbove(256)) .Date(f => f.Name(u => u.SubscribeDate)) diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs new file mode 100644 index 0000000000..96b947aa2a --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs @@ -0,0 +1,42 @@ +using Foundatio.Repositories.Elasticsearch.Configuration; +using Foundatio.Repositories.Elasticsearch.Extensions; +using Nest; + +namespace Exceptionless.Core.Repositories.Configuration; + +public sealed class SavedViewIndex : VersionedIndex +{ + internal const string KEYWORD_LOWERCASE_ANALYZER = "keyword_lowercase"; + + private readonly ExceptionlessElasticConfiguration _configuration; + + public SavedViewIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "saved-views", 1) + { + _configuration = configuration; + } + + public override TypeMappingDescriptor ConfigureIndexMapping(TypeMappingDescriptor map) + { + return map + .Dynamic(false) + .Properties(p => p + .SetupDefaults() + .Keyword(f => f.Name(e => e.OrganizationId)) + .Keyword(f => f.Name(e => e.UserId)) + .Keyword(f => f.Name(e => e.CreatedByUserId)) + .Keyword(f => f.Name(e => e.UpdatedByUserId)) + .Text(f => f.Name(e => e.Name).Analyzer(KEYWORD_LOWERCASE_ANALYZER).AddKeywordField()) + .Keyword(f => f.Name(e => e.View)) + .Boolean(f => f.Name(e => e.IsDefault)) + .Number(f => f.Name(e => e.Version).Type(NumberType.Integer))); + } + + public override CreateIndexDescriptor ConfigureIndex(CreateIndexDescriptor idx) + { + return base.ConfigureIndex(idx.Settings(s => s + .Analysis(d => d.Analyzers(b => b.Custom(KEYWORD_LOWERCASE_ANALYZER, c => c.Filters("lowercase").Tokenizer("keyword")))) + .NumberOfShards(_configuration.Options.NumberOfShards) + .NumberOfReplicas(_configuration.Options.NumberOfReplicas) + .Priority(5))); + } +} diff --git a/src/Exceptionless.Core/Repositories/Interfaces/ISavedViewRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/ISavedViewRepository.cs new file mode 100644 index 0000000000..eceda6860d --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Interfaces/ISavedViewRepository.cs @@ -0,0 +1,16 @@ +using Exceptionless.Core.Models; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; + +namespace Exceptionless.Core.Repositories; + +public interface ISavedViewRepository : IRepositoryOwnedByOrganization +{ + Task> GetByViewAsync(string organizationId, string view, CommandOptionsDescriptor? options = null); + Task> GetByViewForUserAsync(string organizationId, string view, string userId, CommandOptionsDescriptor? options = null); + Task> GetByOrganizationForUserAsync(string organizationId, string userId, CommandOptionsDescriptor? options = null); + Task CountByOrganizationIdAsync(string organizationId); + + /// Removes all private saved views belonging to a specific user within an organization. + Task RemovePrivateByUserIdAsync(string organizationId, string userId); +} diff --git a/src/Exceptionless.Core/Repositories/SavedViewRepository.cs b/src/Exceptionless.Core/Repositories/SavedViewRepository.cs new file mode 100644 index 0000000000..36189d2324 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/SavedViewRepository.cs @@ -0,0 +1,60 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories.Configuration; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; +using Nest; + +namespace Exceptionless.Core.Repositories; + +public class SavedViewRepository : RepositoryOwnedByOrganization, ISavedViewRepository +{ + public SavedViewRepository(ExceptionlessElasticConfiguration configuration, AppOptions options) + : base(configuration.SavedViews, null!, options) + { + } + + public Task> GetByViewAsync(string organizationId, string view, CommandOptionsDescriptor? options = null) + { + var filter = Query.Term(e => e.OrganizationId, organizationId) + && Query.Term(e => e.View, view); + + return FindAsync(q => q.ElasticFilter(filter).SortAscending(e => e.Name.Suffix("keyword")), options); + } + + public Task> GetByViewForUserAsync(string organizationId, string view, string userId, CommandOptionsDescriptor? options = null) + { + var filter = Query.Term(e => e.OrganizationId, organizationId) + && Query.Term(e => e.View, view) + && (!Query.Exists(e => e.Field(f => f.UserId)) + || Query.Term(e => e.UserId, userId)); + + return FindAsync(q => q.ElasticFilter(filter).SortAscending(e => e.Name.Suffix("keyword")), options); + } + + public Task> GetByOrganizationForUserAsync(string organizationId, string userId, CommandOptionsDescriptor? options = null) + { + var filter = Query.Term(e => e.OrganizationId, organizationId) + && (!Query.Exists(e => e.Field(f => f.UserId)) + || Query.Term(e => e.UserId, userId)); + + return FindAsync(q => q.ElasticFilter(filter).SortAscending(e => e.Name.Suffix("keyword")), options); + } + + public async Task RemovePrivateByUserIdAsync(string organizationId, string userId) + { + var filter = Query.Term(e => e.OrganizationId, organizationId) + && Query.Term(e => e.UserId, userId); + + var results = await FindAsync(q => q.ElasticFilter(filter), o => o.PageLimit(1000)); + if (results.Total == 0) + return 0; + + await RemoveAsync(results.Documents); + return results.Total; + } + + public async Task CountByOrganizationIdAsync(string organizationId) + { + return await CountAsync(q => q.Organization(organizationId)); + } +} diff --git a/src/Exceptionless.Core/Services/OrganizationService.cs b/src/Exceptionless.Core/Services/OrganizationService.cs index c5acf91701..ebd041b1e5 100644 --- a/src/Exceptionless.Core/Services/OrganizationService.cs +++ b/src/Exceptionless.Core/Services/OrganizationService.cs @@ -13,6 +13,7 @@ public class OrganizationService : IStartupAction private const int BATCH_SIZE = 50; private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; + private readonly ISavedViewRepository _savedViewRepository; private readonly ITokenRepository _tokenRepository; private readonly IUserRepository _userRepository; private readonly IWebHookRepository _webHookRepository; @@ -20,10 +21,11 @@ public class OrganizationService : IStartupAction private readonly UsageService _usageService; private readonly ILogger _logger; - public OrganizationService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, ITokenRepository tokenRepository, IUserRepository userRepository, IWebHookRepository webHookRepository, AppOptions appOptions, UsageService usageService, ILoggerFactory loggerFactory) + public OrganizationService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, ISavedViewRepository savedViewRepository, ITokenRepository tokenRepository, IUserRepository userRepository, IWebHookRepository webHookRepository, AppOptions appOptions, UsageService usageService, ILoggerFactory loggerFactory) { _organizationRepository = organizationRepository; _projectRepository = projectRepository; + _savedViewRepository = savedViewRepository; _tokenRepository = tokenRepository; _userRepository = userRepository; _webHookRepository = webHookRepository; @@ -180,6 +182,19 @@ public Task RemoveWebHooksAsync(Organization organization) return _webHookRepository.RemoveAllByOrganizationIdAsync(organization.Id); } + public Task RemoveSavedViewsAsync(Organization organization) + { + _logger.LogInformation("Removing saved views for {OrganizationName} ({OrganizationId})", organization.Name, organization.Id); + return _savedViewRepository.RemoveAllByOrganizationIdAsync(organization.Id); + } + + /// Removes all private saved views for a user leaving an organization. Org-wide views created by that user are preserved. + public Task RemoveUserSavedViewsAsync(string organizationId, string userId) + { + _logger.LogInformation("Removing private saved views for user {UserId} from organization {OrganizationId}", userId, organizationId); + return _savedViewRepository.RemovePrivateByUserIdAsync(organizationId, userId); + } + public async Task SoftDeleteOrganizationAsync(Organization organization, string currentUserId) { if (organization.IsDeleted) @@ -187,6 +202,7 @@ public async Task SoftDeleteOrganizationAsync(Organization organization, string await RemoveTokensAsync(organization); await RemoveWebHooksAsync(organization); + await RemoveSavedViewsAsync(organization); await CancelSubscriptionsAsync(organization); await RemoveUsersAsync(organization, currentUserId); await CleanupProjectNotificationSettingsAsync(organization, []); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts index c5907c7574..1ac9668db7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts @@ -1,6 +1,21 @@ import { describe, expect, it } from 'vitest'; -import { quoteIfSpecialCharacters } from './helpers.svelte'; +import { deserializeFilters, quoteIfSpecialCharacters, serializeFilters } from './helpers.svelte'; +import { + BooleanFilter, + DateFilter, + KeywordFilter, + LevelFilter, + NumberFilter, + ProjectFilter, + ReferenceFilter, + SessionFilter, + StatusFilter, + StringFilter, + TagFilter, + TypeFilter, + VersionFilter +} from './models.svelte'; describe('helpers.svelte', () => { it('quoteIfSpecialCharacters handles tabs and newlines', () => { @@ -40,9 +55,439 @@ describe('helpers.svelte', () => { it('quoteIfSpecialCharacters quotes all Lucene special characters', () => { const luceneSpecials = ['+', '-', '&&', '||', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '\\', '/']; + for (const char of luceneSpecials) { expect(quoteIfSpecialCharacters(char)).toBe(`"${char}"`); expect(quoteIfSpecialCharacters(`foo${char}bar`)).toBe(`"foo${char}bar"`); } }); }); + +describe('serializeFilters', () => { + it('serializes an empty array', () => { + expect(serializeFilters([])).toBe('[]'); + }); + + it('serializes a BooleanFilter with term and value', () => { + const filters = [new BooleanFilter('is_fixed', true)]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ term: 'is_fixed', type: 'boolean', value: true }); + }); + + it('serializes a DateFilter with term and string value', () => { + const filters = [new DateFilter('date', '2024-01-01')]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ term: 'date', type: 'date', value: '2024-01-01' }); + }); + + it('serializes a KeywordFilter with value', () => { + const filters = [new KeywordFilter('status:open')]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'keyword', value: 'status:open' }); + }); + + it('serializes a LevelFilter with multiple values', () => { + const filters = [new LevelFilter(['Error', 'Fatal'] as never[])]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'level', value: ['Error', 'Fatal'] }); + }); + + it('serializes a NumberFilter with term and value', () => { + const filters = [new NumberFilter('value', 42)]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ term: 'value', type: 'number', value: 42 }); + }); + + it('serializes a ProjectFilter with multiple values', () => { + const filters = [new ProjectFilter(['proj1', 'proj2'])]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'project', value: ['proj1', 'proj2'] }); + }); + + it('serializes a ReferenceFilter with value', () => { + const filters = [new ReferenceFilter('ref-123')]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'reference', value: 'ref-123' }); + }); + + it('serializes a SessionFilter with value', () => { + const filters = [new SessionFilter('session-abc')]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'session', value: 'session-abc' }); + }); + + it('serializes a StatusFilter with multiple values', () => { + const filters = [new StatusFilter(['open', 'regressed'] as never[])]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'status', value: ['open', 'regressed'] }); + }); + + it('serializes a StringFilter with term and value', () => { + const filters = [new StringFilter('error.message', 'null ref')]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ term: 'error.message', type: 'string', value: 'null ref' }); + }); + + it('serializes a TagFilter with values', () => { + const filters = [new TagFilter(['error', 'log'] as never[])]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'tag', value: ['error', 'log'] }); + }); + + it('serializes a TypeFilter with values', () => { + const filters = [new TypeFilter(['error', 'log'] as never[])]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'type', value: ['error', 'log'] }); + }); + + it('serializes a VersionFilter with term and value', () => { + const filters = [new VersionFilter('version', '1.2.3')]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ term: 'version', type: 'version', value: '1.2.3' }); + }); + + it('serializes filters without optional term or value', () => { + const filters = [new BooleanFilter()]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'boolean' }); + }); + + it('serializes multiple filters', () => { + const filters = [new KeywordFilter('error'), new StatusFilter(['open'] as never[]), new BooleanFilter('is_fixed', false)]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result).toHaveLength(3); + expect(result[0].type).toBe('keyword'); + expect(result[1].type).toBe('status'); + expect(result[2].type).toBe('boolean'); + }); +}); + +describe('deserializeFilters', () => { + it('deserializes an empty array', () => { + expect(deserializeFilters('[]')).toEqual([]); + }); + + it('deserializes a BooleanFilter', () => { + const filters = deserializeFilters('[{"type":"boolean","term":"is_fixed","value":true}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(BooleanFilter); + expect((filters[0] as BooleanFilter).term).toBe('is_fixed'); + expect((filters[0] as BooleanFilter).value).toBe(true); + }); + + it('deserializes a DateFilter', () => { + const filters = deserializeFilters('[{"type":"date","term":"date","value":"2024-01-01"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(DateFilter); + expect((filters[0] as DateFilter).term).toBe('date'); + expect((filters[0] as DateFilter).value).toBe('2024-01-01'); + }); + + it('deserializes a KeywordFilter', () => { + const filters = deserializeFilters('[{"type":"keyword","value":"status:open"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(KeywordFilter); + expect((filters[0] as KeywordFilter).value).toBe('status:open'); + }); + + it('deserializes a LevelFilter', () => { + const filters = deserializeFilters('[{"type":"level","value":["Error","Fatal"]}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(LevelFilter); + expect((filters[0] as LevelFilter).value).toEqual(['Error', 'Fatal']); + }); + + it('deserializes a NumberFilter', () => { + const filters = deserializeFilters('[{"type":"number","term":"value","value":42}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(NumberFilter); + expect((filters[0] as NumberFilter).term).toBe('value'); + expect((filters[0] as NumberFilter).value).toBe(42); + }); + + it('deserializes a ProjectFilter', () => { + const filters = deserializeFilters('[{"type":"project","value":["p1","p2"]}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(ProjectFilter); + expect((filters[0] as ProjectFilter).value).toEqual(['p1', 'p2']); + }); + + it('deserializes a ReferenceFilter', () => { + const filters = deserializeFilters('[{"type":"reference","value":"ref-123"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(ReferenceFilter); + expect((filters[0] as ReferenceFilter).value).toBe('ref-123'); + }); + + it('deserializes a SessionFilter', () => { + const filters = deserializeFilters('[{"type":"session","value":"session-abc"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(SessionFilter); + expect((filters[0] as SessionFilter).value).toBe('session-abc'); + }); + + it('deserializes a StatusFilter', () => { + const filters = deserializeFilters('[{"type":"status","value":["open","regressed"]}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(StatusFilter); + expect((filters[0] as StatusFilter).value).toEqual(['open', 'regressed']); + }); + + it('deserializes a StringFilter', () => { + const filters = deserializeFilters('[{"type":"string","term":"error.message","value":"null ref"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(StringFilter); + expect((filters[0] as StringFilter).term).toBe('error.message'); + expect((filters[0] as StringFilter).value).toBe('null ref'); + }); + + it('deserializes a TagFilter', () => { + const filters = deserializeFilters('[{"type":"tag","value":["error","log"]}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(TagFilter); + expect((filters[0] as TagFilter).value).toEqual(['error', 'log']); + }); + + it('deserializes a TypeFilter', () => { + const filters = deserializeFilters('[{"type":"type","value":["error","log"]}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(TypeFilter); + expect((filters[0] as TypeFilter).value).toEqual(['error', 'log']); + }); + + it('deserializes a VersionFilter', () => { + const filters = deserializeFilters('[{"type":"version","term":"version","value":"1.2.3"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(VersionFilter); + expect((filters[0] as VersionFilter).term).toBe('version'); + expect((filters[0] as VersionFilter).value).toBe('1.2.3'); + }); + + it('skips unknown filter types', () => { + const filters = deserializeFilters('[{"type":"unknown","value":"test"},{"type":"keyword","value":"valid"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(KeywordFilter); + }); +}); + +describe('round-trip serialization', () => { + it('round-trips a BooleanFilter', () => { + const original = [new BooleanFilter('is_fixed', true)]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(BooleanFilter); + expect((result[0] as BooleanFilter).term).toBe('is_fixed'); + expect((result[0] as BooleanFilter).value).toBe(true); + }); + + it('round-trips a DateFilter with string value', () => { + const original = [new DateFilter('created_utc', '2024-06-15T00:00:00Z')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as DateFilter).term).toBe('created_utc'); + expect((result[0] as DateFilter).value).toBe('2024-06-15T00:00:00Z'); + }); + + it('round-trips a KeywordFilter', () => { + const original = [new KeywordFilter('status:open OR status:regressed')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as KeywordFilter).value).toBe('status:open OR status:regressed'); + }); + + it('round-trips a LevelFilter with multiple levels', () => { + const original = [new LevelFilter(['Error', 'Warning', 'Fatal'] as never[])]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as LevelFilter).value).toEqual(['Error', 'Warning', 'Fatal']); + }); + + it('round-trips a NumberFilter', () => { + const original = [new NumberFilter('count', 99)]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as NumberFilter).term).toBe('count'); + expect((result[0] as NumberFilter).value).toBe(99); + }); + + it('round-trips a ProjectFilter', () => { + const original = [new ProjectFilter(['abc123', 'def456'])]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as ProjectFilter).value).toEqual(['abc123', 'def456']); + }); + + it('round-trips a ReferenceFilter', () => { + const original = [new ReferenceFilter('ref-xyz')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as ReferenceFilter).value).toBe('ref-xyz'); + }); + + it('round-trips a SessionFilter', () => { + const original = [new SessionFilter('sess-001')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as SessionFilter).value).toBe('sess-001'); + }); + + it('round-trips a StatusFilter', () => { + const original = [new StatusFilter(['open', 'regressed'] as never[])]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as StatusFilter).value).toEqual(['open', 'regressed']); + }); + + it('round-trips a StringFilter', () => { + const original = [new StringFilter('error.type', 'NullReferenceException')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as StringFilter).term).toBe('error.type'); + expect((result[0] as StringFilter).value).toBe('NullReferenceException'); + }); + + it('round-trips a TagFilter', () => { + const original = [new TagFilter(['Critical', 'UI'] as never[])]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as TagFilter).value).toEqual(['Critical', 'UI']); + }); + + it('round-trips a TypeFilter', () => { + const original = [new TypeFilter(['error', 'session'] as never[])]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as TypeFilter).value).toEqual(['error', 'session']); + }); + + it('round-trips a VersionFilter', () => { + const original = [new VersionFilter('version', '2.0.0-beta')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as VersionFilter).term).toBe('version'); + expect((result[0] as VersionFilter).value).toBe('2.0.0-beta'); + }); + + it('round-trips a complex mix of filters', () => { + const original = [ + new KeywordFilter('error'), + new StatusFilter(['open'] as never[]), + new BooleanFilter('is_fixed', false), + new NumberFilter('value', 10), + new StringFilter('error.message', 'Connection timeout'), + new ProjectFilter(['proj-a']), + new VersionFilter('version', '3.1.0') + ]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(7); + expect(result[0]).toBeInstanceOf(KeywordFilter); + expect(result[1]).toBeInstanceOf(StatusFilter); + expect(result[2]).toBeInstanceOf(BooleanFilter); + expect(result[3]).toBeInstanceOf(NumberFilter); + expect(result[4]).toBeInstanceOf(StringFilter); + expect(result[5]).toBeInstanceOf(ProjectFilter); + expect(result[6]).toBeInstanceOf(VersionFilter); + }); + + it('round-trips filters with undefined values', () => { + const original = [new BooleanFilter('is_fixed'), new StringFilter('term')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(2); + expect((result[0] as BooleanFilter).term).toBe('is_fixed'); + expect((result[0] as BooleanFilter).value).toBeUndefined(); + expect((result[1] as StringFilter).term).toBe('term'); + expect((result[1] as StringFilter).value).toBeUndefined(); + }); +}); + +describe('defensive deserialization', () => { + it('returns empty array for invalid JSON', () => { + expect(deserializeFilters('not json')).toEqual([]); + }); + + it('returns empty array for null input', () => { + expect(deserializeFilters(null as unknown as string)).toEqual([]); + }); + + it('returns empty array for undefined input', () => { + expect(deserializeFilters(undefined as unknown as string)).toEqual([]); + }); + + it('returns empty array for empty string', () => { + expect(deserializeFilters('')).toEqual([]); + }); + + it('returns empty array for JSON object (not array)', () => { + expect(deserializeFilters('{"type":"keyword","value":"test"}')).toEqual([]); + }); + + it('returns empty array for JSON number', () => { + expect(deserializeFilters('42')).toEqual([]); + }); + + it('handles missing type field gracefully', () => { + const result = deserializeFilters('[{"value":"test"}]'); + + expect(result).toEqual([]); + }); + + it('handles missing value field gracefully', () => { + const result = deserializeFilters('[{"type":"keyword"}]'); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(KeywordFilter); + }); + + it('handles XSS payload in value without crashing', () => { + const xss = ''; + const result = deserializeFilters(JSON.stringify([{ type: 'keyword', value: xss }])); + + expect(result).toHaveLength(1); + expect((result[0] as KeywordFilter).value).toBe(xss); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts index 4fcf26a9c2..b3677c3e40 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts @@ -3,7 +3,21 @@ import type { IFilter } from '$comp/faceted-filter'; import { organization } from '$features/organizations/context.svelte'; import { SvelteMap } from 'svelte/reactivity'; -import { DateFilter, KeywordFilter, type ProjectFilter, type StringFilter } from './models.svelte'; +import { + BooleanFilter, + DateFilter, + KeywordFilter, + LevelFilter, + NumberFilter, + ProjectFilter, + ReferenceFilter, + SessionFilter, + StatusFilter, + StringFilter, + TagFilter, + TypeFilter, + VersionFilter +} from './models.svelte'; let filterCacheVersion = $state(1); export function filterCacheVersionNumber() { @@ -11,6 +25,12 @@ export function filterCacheVersionNumber() { } const filterCache = new SvelteMap(); +interface SerializedFilter { + term?: string; + type: string; + value?: unknown; +} + export function applyTimeFilter(filters: IFilter[], time: null | string): IFilter[] { const dateFilterIndex = filters.findIndex((f) => f.key === 'date-date'); if (dateFilterIndex >= 0) { @@ -34,6 +54,20 @@ export function clearFilterCache() { filterCacheVersion = 1; } +export function deserializeFilters(json: string): IFilter[] { + try { + const parsed: SerializedFilter[] = JSON.parse(json); + + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.map(reconstructFilter).filter((f): f is IFilter => f !== null); + } catch { + return []; + } +} + export function filterChanged(filters: IFilter[], addedOrUpdated: IFilter): IFilter[] { const index = filters.findIndex((f) => f.id === addedOrUpdated.id); if (index === -1) { @@ -105,6 +139,24 @@ export function quoteIfSpecialCharacters(value?: null | string): null | string | return trimmed; } +export function serializeFilters(filters: IFilter[]): string { + const serialized: SerializedFilter[] = filters.map((filter) => { + const entry: SerializedFilter = { type: filter.type }; + + if ('term' in filter && (filter as { term?: string }).term !== undefined) { + entry.term = (filter as { term?: string }).term; + } + + if ('value' in filter) { + entry.value = (filter as { value?: unknown }).value; + } + + return entry; + }); + + return JSON.stringify(serialized); +} + export function shouldRefreshPersistentEventChanged( filters: IFilter[], filter: null | string, @@ -195,3 +247,36 @@ function processFilterRules(filters: IFilter[]): IFilter[] { return Array.from(uniqueFilters.values()); } + +function reconstructFilter(data: SerializedFilter): IFilter | null { + switch (data.type) { + case 'boolean': + return new BooleanFilter(data.term, data.value as boolean | undefined); + case 'date': + return new DateFilter(data.term, data.value as Date | string | undefined); + case 'keyword': + return new KeywordFilter(data.value as string | undefined); + case 'level': + return new LevelFilter(data.value as [] | undefined); + case 'number': + return new NumberFilter(data.term, data.value as number | undefined); + case 'project': + return new ProjectFilter(data.value as string[] | undefined); + case 'reference': + return new ReferenceFilter(data.value as string | undefined); + case 'session': + return new SessionFilter(data.value as string | undefined); + case 'status': + return new StatusFilter(data.value as [] | undefined); + case 'string': + return new StringFilter(data.term, data.value as string | undefined); + case 'tag': + return new TagFilter(data.value as [] | undefined); + case 'type': + return new TypeFilter(data.value as [] | undefined); + case 'version': + return new VersionFilter(data.term, data.value as string | undefined); + default: + return null; + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts index b3be2e34c2..b99f5ea2fc 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts @@ -30,6 +30,7 @@ export const queryKeys = { list: (mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, 'list', { mode }] as const) : ([...queryKeys.type, 'list'] as const)), postOrganization: () => [...queryKeys.type, 'post-organization'] as const, setBonusOrganization: (id: string | undefined) => [...queryKeys.type, id, 'set-bonus'] as const, + setFeature: (id: string | undefined) => [...queryKeys.type, id, 'set-feature'] as const, suspendOrganization: (id: string | undefined) => [...queryKeys.type, id, 'suspend'] as const, type: ['Organization'] as const, unsuspendOrganization: (id: string | undefined) => [...queryKeys.type, id, 'unsuspend'] as const @@ -133,6 +134,12 @@ export interface PostSuspendOrganizationRequest { }; } +export interface SetOrganizationFeatureRequest { + route: { + id: string | undefined; + }; +} + export function addOrganizationUser(request: AddOrganizationUserRequest) { const queryClient = useQueryClient(); return createMutation<{ emailAddress: string }, ProblemDetails, string>(() => ({ @@ -414,3 +421,41 @@ export function postSuspendOrganization(request: PostSuspendOrganizationRequest) } })); } + +export function removeOrganizationFeature(request: SetOrganizationFeatureRequest) { + const queryClient = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (feature: string) => { + const client = useFetchClient(); + const response = await client.delete(`organizations/${request.route.id}/features/${feature}`); + return response.ok; + }, + mutationKey: queryKeys.setFeature(request.route.id), + onError: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) }); + queryClient.invalidateQueries({ queryKey: queryKeys.list(undefined) }); + } + })); +} + +export function setOrganizationFeature(request: SetOrganizationFeatureRequest) { + const queryClient = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (feature: string) => { + const client = useFetchClient(); + const response = await client.post(`organizations/${request.route.id}/features/${feature}`); + return response.ok; + }, + mutationKey: queryKeys.setFeature(request.route.id), + onError: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) }); + queryClient.invalidateQueries({ queryKey: queryKeys.list(undefined) }); + } + })); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts new file mode 100644 index 0000000000..859f82d602 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts @@ -0,0 +1,120 @@ +import { accessToken } from '$features/auth/index.svelte'; +import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; +import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; +import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; + +import type { NewSavedView, SavedView, UpdateSavedView } from './models'; + +// When a new saved view is added, Elasticsearch needs ~1s to index it. +// Without a delay, the background refetch triggered by this invalidation returns +// stale data that omits the new view, causing the URL param to be cleared. +export async function invalidateSavedViewQueries(queryClient: QueryClient, message: WebSocketMessageValue<'SavedViewChanged'>) { + const { change_type, organization_id } = message; + + if (change_type === ChangeType.Added) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + + if (organization_id) { + await queryClient.invalidateQueries({ queryKey: queryKeys.organization(organization_id) }); + } else { + await queryClient.invalidateQueries({ queryKey: queryKeys.type }); + } +} + +export const queryKeys = { + id: (id: string | undefined) => [...queryKeys.type, id] as const, + organization: (organizationId: string | undefined) => [...queryKeys.type, 'organization', organizationId] as const, + type: ['SavedView'] as const, + view: (organizationId: string | undefined, view: string | undefined) => [...queryKeys.type, 'organization', organizationId, 'view', view] as const +}; + +export function deleteSavedView(request: { route: { ids: string[] } }) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.ids?.length, + mutationFn: async () => { + const client = useFetchClient(); + await client.delete(`saved-views/${request.route.ids.join(',')}`, { + expectedStatusCodes: [202] + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.type }); + } + })); +} + +export function getSavedViewsByViewQuery(request: { route: { organizationId: string | undefined; view: string | undefined } }) { + return createQuery(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId && !!request.route.view, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views/${request.route.view}`, { signal }); + return response.data!; + }, + queryKey: queryKeys.view(request.route.organizationId, request.route.view), + // Saved views are managed via optimistic updates and WebSocket events. + // Disabling focus-triggered refetch prevents the race condition where a dialog + // closing fires a window focus event, causing a refetch that returns stale + // Elasticsearch data (1s indexing delay) and overwrites optimistic cache updates. + refetchOnWindowFocus: false + })); +} + +export function getSavedViewsQuery(request: { route: { organizationId: string | undefined } }) { + return createQuery(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views`, { + signal + }); + return response.data!; + }, + queryKey: queryKeys.organization(request.route.organizationId) + })); +} + +export function patchSavedView(request: { route: { id: string | undefined } }) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async (data: UpdateSavedView) => { + const client = useFetchClient(); + const response = await client.patchJSON(`saved-views/${request.route.id}`, data); + return response.data!; + }, + onSuccess: (savedView: SavedView) => { + queryClient.invalidateQueries({ queryKey: queryKeys.organization(savedView.organization_id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id) }); + } + })); +} + +export function postSavedView(request: { route: { organizationId: string | undefined } }) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + mutationFn: async (data: NewSavedView & { is_private?: boolean }) => { + const client = useFetchClient(); + const { is_private, ...body } = data; + const url = is_private + ? `organizations/${request.route.organizationId}/saved-views?is_private=true` + : `organizations/${request.route.organizationId}/saved-views`; + const response = await client.postJSON(url, body); + return response.data!; + }, + onSuccess: (savedView: SavedView) => { + // Optimistically populate the per-view cache so the new view is immediately + // available when handleSelect fires, before the background invalidation completes. + queryClient.setQueryData(queryKeys.view(request.route.organizationId, savedView.view), (old: SavedView[] | undefined) => + old ? [...old, savedView] : [savedView] + ); + queryClient.invalidateQueries({ queryKey: queryKeys.organization(request.route.organizationId) }); + } + })); +} 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 new file mode 100644 index 0000000000..fba025766e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -0,0 +1,615 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + {#if activeSavedView && isModified} + + Modified View + + + Update "{activeSavedView.name}" + + + + Save as new view + + + + Reset to saved + + + + {/if} + + {#if savedViews.length > 0} + + Saved Views + {#each sortedViews as savedView (savedView.id)} + handleSelect(savedView)}> + + + {#if effectiveView?.id === savedView.id} + + {/if} + + + + {savedView.name} + {#if savedView.is_default} + default + {/if} + {#if savedView.user_id} + private + {/if} + + {#if savedView.filter || savedView.time} + + + {#snippet child({ props: tipProps })} + + {formatViewSummary(savedView)} + + {/snippet} + + + {#if savedView.filter} +

{savedView.filter}

+ {/if} + {#if savedView.time} +

{timeLabels.get(savedView.time) ?? savedView.time}

+ {/if} +
+
+ {:else} + No filters + {/if} +
+
+ +
+ {/each} +
+ + {/if} + + + {#if duplicateView && !activeSavedView} + handleSelect(duplicateView)}> + + Load "{duplicateView.name}" (matches current) + + {/if} + {#if !effectiveView} + + + Save current view + + {/if} + {#if effectiveView} + + + Rename "{effectiveView.name}" + + {#if !effectiveView.user_id && !effectiveView.is_default} + + + Set as default for everyone + + {/if} + + openDeleteDialog(effectiveView)}> + + Delete "{effectiveView.name}" + + {#if !isModified} + + Clear Saved View + {/if} + {/if} + +
+
+ + +{#if saveDialogOpen} + + + + Save View + Save the current view configuration for quick access. + + {#if duplicateView} +
+

+ Current filters match "{duplicateView.name}". You can + instead, or save with a different name. +

+
+ {/if} +
{ + e.preventDefault(); + handleSave(); + }} + > +
+ + +
+
+
+ +

Only visible to you

+
+ { + if (checked) { + saveAsDefault = false; + } + }} + /> +
+ {#if !savePrivate} +
+
+ +

Auto-loads for everyone on page visit

+
+ +
+ {/if} + + + + +
+
+
+{/if} + + +{#if renameDialogOpen} + + + + Rename View + Change the display name for this saved view. + +
{ + e.preventDefault(); + handleRename(); + }} + > +
+ + +
+ + + + +
+
+
+{/if} + + +{#if deleteDialogOpen && deleteTarget} + + + + Delete Saved View + + Are you sure you want to delete "{deleteTarget.name}"? This action cannot be undone. + + + + Cancel + Delete + + + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/index.ts new file mode 100644 index 0000000000..67f34f7bdb --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/index.ts @@ -0,0 +1,3 @@ +export { deserializeFilters, serializeFilters } from '$features/events/components/filters/helpers.svelte'; +export { deleteSavedView, getSavedViewsByViewQuery, getSavedViewsQuery, patchSavedView, postSavedView, queryKeys as savedViewQueryKeys } from './api.svelte'; +export type { NewSavedView, SavedView, UpdateSavedView } from './models'; 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 new file mode 100644 index 0000000000..f9d21ae00d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts @@ -0,0 +1,13 @@ +import type { NewSavedView as GeneratedNewSavedView, UpdateSavedView as GeneratedUpdateSavedView, ViewSavedView } from '$generated/api'; + +export type NewSavedView = Omit & { + filter?: null | string; + is_default?: boolean; +}; + +export type SavedView = ViewSavedView; + +export type UpdateSavedView = Omit & { + columns?: Record; + is_default?: boolean; +}; 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 new file mode 100644 index 0000000000..e8739567b0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -0,0 +1,248 @@ +import type { IFilter } from '$comp/faceted-filter'; +import type { VisibilityState } from '@tanstack/svelte-table'; + +import { deserializeFilters } from '$features/events/components/filters/helpers.svelte'; +import { getOrganizationQuery } from '$features/organizations/api.svelte'; +import { organization } from '$features/organizations/context.svelte'; +import { untrack } from 'svelte'; + +import type { SavedView } from './models'; + +import { getSavedViewsByViewQuery } from './api.svelte'; + +const SAVED_VIEWS_FEATURE = 'feature-saved-views'; + +export interface SavedViewQueryParams { + filter: null | string; + saved: null | string | undefined; + time?: null | string; +} + +export interface UseSavedViewsOptions { + filterCacheKey: (filter: null | string) => string; + getColumnVisibility?: () => VisibilityState; + queryParams: SavedViewQueryParams; + setColumnVisibility?: (visibility: VisibilityState) => void; + updateFilterCache: (key: string, filters: IFilter[]) => void; + view: string; +} + +export interface UseSavedViewsReturn { + activeSavedView: SavedView | undefined; + handleClearSavedView: () => void; + handleLoadView: (id: string) => void; + handleResetToSaved: () => void; + isEnabled: boolean; + isModified: boolean; + savedViews: SavedView[]; +} + +export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsReturn { + const organizationQuery = getOrganizationQuery({ + route: { + get id() { + return organization.current; + } + } + }); + + // Feature flag gate: only enable saved views if the organization has the feature + const isEnabled = $derived(organizationQuery.data?.features?.includes(SAVED_VIEWS_FEATURE) ?? false); + + const savedViewsListQuery = getSavedViewsByViewQuery({ + route: { + get organizationId() { + return isEnabled ? organization.current : undefined; + }, + get view() { + return options.view; + } + } + }); + + // Find the active saved view by ID from the list query (no separate fetch needed) + const activeSavedView = $derived.by(() => { + const savedId = options.queryParams.saved; + if (!savedId) { + return undefined; + } + + const views = savedViewsListQuery.data; + if (!views) { + return undefined; + } + + const found = views.find((v) => v.id === savedId); + return found; + }); + + // Hydrate filters/columns when a saved view loads, or clear params if the view is no longer found. + // lastHydratedId prevents re-hydration on background refetches (which would stomp user edits). + let lastHydratedId = ''; + let hasAttemptedRestore = false; + let lastRestoredOrganizationId = ''; + $effect(() => { + const savedId = options.queryParams.saved; + const isLoading = savedViewsListQuery.isLoading; + const isFetching = savedViewsListQuery.isFetching; + const views = savedViewsListQuery.data; + + if (!savedId || isLoading || !views) { + if (!savedId) { + lastHydratedId = ''; + } + + return; + } + + const view = views.find((v) => v.id === savedId); + + if (!view) { + // Skip while refetching to avoid false-positive clears during cache invalidation + if (isFetching) { + return; + } + + // View not found after a definitive load — clear params and allow auto-restore to re-run + untrack(() => { + options.queryParams.saved = null; + }); + options.queryParams.filter = null; + options.queryParams.time = null; + hasAttemptedRestore = false; + return; + } + + // Already hydrated this view — skip to avoid stomping user edits on background refetch + if (savedId === lastHydratedId) { + return; + } + + lastHydratedId = savedId; + + if (view.filter_definitions) { + const hydrated = deserializeFilters(view.filter_definitions); + options.updateFilterCache(options.filterCacheKey(view.filter ?? null), hydrated); + } + + options.queryParams.filter = view.filter ?? null; + options.queryParams.time = view.time ?? null; + + if (view.columns && options.setColumnVisibility) { + options.setColumnVisibility(view.columns); + } + }); + + // Auto-load default saved view when navigating to page without explicit params + $effect(() => { + const organizationId = organization.current; + const views = savedViewsListQuery.data; + if (!organizationId) { + return; + } + + if (organizationId !== lastRestoredOrganizationId) { + hasAttemptedRestore = false; + lastRestoredOrganizationId = organizationId; + } + if (hasAttemptedRestore) { + return; + } + if (savedViewsListQuery.isLoading) { + return; + } + + hasAttemptedRestore = true; + + const search = window.location.search; + const hasExplicitParams = /[?&]saved(?:[=&]|$)/.test(search) || /[?&]filter(?:[=&]|$)/.test(search) || /[?&]time(?:[=&]|$)/.test(search); + if (hasExplicitParams) { + return; + } + + untrack(() => { + const defaultView = views?.find((v) => v.is_default); + if (defaultView) { + options.queryParams.saved = defaultView.id; + } + }); + }); + + // Detect if current filters or columns differ from the active saved view + const isModified = $derived.by(() => { + const view = activeSavedView; + if (!view || !options.queryParams.saved) { + return false; + } + if ((options.queryParams.filter ?? null) !== (view.filter ?? null)) { + return true; + } + if (view.time && (options.queryParams.time ?? '') !== view.time) { + return true; + } + if (options.getColumnVisibility && !columnsEqual(options.getColumnVisibility(), view.columns)) { + return true; + } + return false; + }); + + function handleLoadView(id: string) { + options.queryParams.saved = id; + } + + function handleResetToSaved() { + const view = activeSavedView; + if (!view) { + return; + } + + if (view.filter_definitions) { + const hydrated = deserializeFilters(view.filter_definitions); + options.updateFilterCache(options.filterCacheKey(view.filter ?? null), hydrated); + } + options.queryParams.filter = view.filter ?? null; + options.queryParams.time = view.time ?? null; + if (view.columns && options.setColumnVisibility) { + options.setColumnVisibility(view.columns); + } + } + + function handleClearSavedView() { + options.queryParams.saved = null; + options.queryParams.filter = null; + options.queryParams.time = null; + if (options.setColumnVisibility) { + options.setColumnVisibility({}); + } + } + + return { + get activeSavedView() { + return activeSavedView; + }, + handleClearSavedView, + handleLoadView, + handleResetToSaved, + get isEnabled() { + return isEnabled; + }, + get isModified() { + return isModified; + }, + get savedViews() { + return savedViewsListQuery.data ?? []; + } + }; +} + +function columnsEqual(a: undefined | VisibilityState, b: null | Record | undefined): boolean { + const aEntries = Object.entries(a ?? {}).sort(([k1], [k2]) => k1.localeCompare(k2)); + const bEntries = Object.entries(b ?? {}).sort(([k1], [k2]) => k1.localeCompare(k2)); + if (aEntries.length !== bEntries.length) { + return false; + } + return aEntries.every(([k, v], i) => { + const bEntry = bEntries[i]; + return bEntry !== undefined && bEntry[0] === k && bEntry[1] === v; + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 856890de12..1693999c37 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -123,11 +123,24 @@ export interface NewProject { delete_bot_data_enabled: boolean; } +export interface NewSavedView { + /** @pattern ^[a-fA-F0-9]{24}$ */ + organization_id: string; + name: string; + filter?: null | string; + time?: null | string; + /** @pattern ^(events|issues|stream)$ */ + view: string; + filter_definitions?: null | string; + columns?: null | Record; + is_default: boolean; +} + export interface NewToken { /** @pattern ^[a-fA-F0-9]{24}$ */ - organization_id?: null | string; + organization_id: string; /** @pattern ^[a-fA-F0-9]{24}$ */ - project_id?: null | string; + project_id: string; /** @pattern ^[a-fA-F0-9]{24}$ */ default_project_id?: null | string; scopes: string[]; @@ -196,7 +209,7 @@ export interface PersistentEvent { */ created_utc: string; /** Used to store primitive data type custom data values for searching the event. */ - idx: Record; + idx?: null | Record; /** The event type (ie. error, log message, feature usage). Check KnownTypes for standard event types. */ type?: null | string; /** The event source (ie. machine name, log name, feature name). */ @@ -342,6 +355,16 @@ export interface UpdateProject { delete_bot_data_enabled: boolean; } +/** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ +export interface UpdateSavedView { + name?: null | string; + filter?: null | string; + time?: null | string; + filter_definitions?: null | string; + columns: unknown[]; + is_default?: null | boolean; +} + /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ export interface UpdateToken { is_disabled: boolean; @@ -474,6 +497,7 @@ export interface ViewOrganization { /** @format date-time */ suspension_date?: null | string; has_premium_features: boolean; + features: string[]; /** @format int32 */ max_users: number; /** @format int32 */ @@ -516,6 +540,32 @@ export interface ViewProject { usage: UsageInfo[]; } +export interface ViewSavedView { + /** @pattern ^[a-fA-F0-9]{24}$ */ + id: string; + /** @pattern ^[a-fA-F0-9]{24}$ */ + organization_id: string; + /** @pattern ^[a-fA-F0-9]{24}$ */ + user_id?: null | string; + /** @pattern ^[a-fA-F0-9]{24}$ */ + created_by_user_id: string; + /** @pattern ^[a-fA-F0-9]{24}$ */ + updated_by_user_id?: null | string; + filter?: null | string; + filter_definitions?: null | string; + columns?: null | Record; + is_default: boolean; + name: string; + time?: null | string; + /** @format int32 */ + version: number; + view: string; + /** @format date-time */ + created_utc: string; + /** @format date-time */ + updated_utc: string; +} + export interface ViewToken { /** @pattern ^[a-fA-F0-9]{24}$ */ id: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 66e65ceef1..27843d2e1e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -161,17 +161,43 @@ export const NewProjectSchema = object({ }); export type NewProjectFormData = Infer; -export const NewTokenSchema = object({ +export const NewSavedViewSchema = object({ organization_id: string() .length(24, "Organization id must be exactly 24 characters") - .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format") + .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format"), + name: string() + .min(1, "Name is required") + .max(100, "Name must be at most 100 characters"), + filter: string() + .min(1, "Filter is required") + .max(2000, "Filter must be at most 2000 characters") .nullable() .optional(), - project_id: string() - .length(24, "Project id must be exactly 24 characters") - .regex(/^[a-fA-F0-9]{24}$/, "Project id has invalid format") + time: string() + .min(1, "Time is required") + .max(100, "Time must be at most 100 characters") + .nullable() + .optional(), + view: string() + .min(1, "View is required") + .regex(/^(events|issues|stream)$/, "View has invalid format"), + filter_definitions: string() + .min(1, "Filter definitions is required") + .max(10000, "Filter definitions must be at most 10000 characters") .nullable() .optional(), + columns: record(string(), boolean()).nullable().optional(), + is_default: boolean(), +}); +export type NewSavedViewFormData = Infer; + +export const NewTokenSchema = object({ + organization_id: string() + .length(24, "Organization id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format"), + project_id: string() + .length(24, "Project id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Project id has invalid format"), default_project_id: string() .length(24, "Default project id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Default project id has invalid format") @@ -243,7 +269,7 @@ export const PersistentEventSchema = object({ .regex(/^[a-fA-F0-9]{24}$/, "Stack id has invalid format"), is_first_occurrence: boolean(), created_utc: iso.datetime(), - idx: record(string(), unknown()), + idx: record(string(), unknown()).nullable().optional(), type: string() .min(1, "Type is required") .max(100, "Type must be at most 100 characters") @@ -372,6 +398,19 @@ export const UpdateProjectSchema = object({ }); export type UpdateProjectFormData = Infer; +export const UpdateSavedViewSchema = object({ + name: string().min(1, "Name is required").nullable().optional(), + filter: string().min(1, "Filter is required").nullable().optional(), + time: string().min(1, "Time is required").nullable().optional(), + filter_definitions: string() + .min(1, "Filter definitions is required") + .nullable() + .optional(), + columns: array(unknown()).optional(), + is_default: boolean().nullable().optional(), +}); +export type UpdateSavedViewFormData = Infer; + export const UpdateTokenSchema = object({ is_disabled: boolean().optional(), notes: string().min(1, "Notes is required").nullable().optional(), @@ -492,6 +531,7 @@ export const ViewOrganizationSchema = object({ .optional(), suspension_date: iso.datetime().nullable().optional(), has_premium_features: boolean(), + features: array(string()), max_users: int32(), max_projects: int32(), project_count: int(), @@ -530,6 +570,42 @@ export const ViewProjectSchema = object({ }); export type ViewProjectFormData = Infer; +export const ViewSavedViewSchema = object({ + id: string() + .length(24, "Id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"), + organization_id: string() + .length(24, "Organization id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format"), + user_id: string() + .length(24, "User id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "User id has invalid format") + .nullable() + .optional(), + created_by_user_id: string() + .length(24, "Created by user id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Created by user id has invalid format"), + updated_by_user_id: string() + .length(24, "Updated by user id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Updated by user id has invalid format") + .nullable() + .optional(), + filter: string().min(1, "Filter is required").nullable().optional(), + filter_definitions: string() + .min(1, "Filter definitions is required") + .nullable() + .optional(), + columns: record(string(), boolean()).nullable().optional(), + is_default: boolean(), + name: string().min(1, "Name is required"), + time: string().min(1, "Time is required").nullable().optional(), + version: int32(), + view: string().min(1, "View is required"), + created_utc: iso.datetime(), + updated_utc: iso.datetime(), +}); +export type ViewSavedViewFormData = Infer; + export const ViewTokenSchema = object({ id: string() .length(24, "Id must be exactly 24 characters") diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte index e8a486d5f2..8faf1bae16 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte @@ -48,16 +48,70 @@ {#each dashboardRoutes as route (route.href)} {@const Icon = route.icon} - - - {#snippet child({ props })} - - - {route.title} - + {#if route.children?.length} + page.url.href.includes(c.href))} + class="group/collapsible" + > + {#snippet child({ props: collapsibleProps })} + + + {#snippet child({ props: triggerProps })} + + {#snippet child({ props: buttonProps })} + + + {route.title} + + + {/snippet} + + {/snippet} + + + + {#each route.children as savedItem (savedItem.href)} + {@const savedId = new URL(savedItem.href, page.url.origin).searchParams.get('saved')} + + + {#snippet child({ props: subProps })} + + {savedItem.title} + + {/snippet} + + + {/each} + + + {/snippet} - - + + {:else} + + + {#snippet child({ props })} + + + {route.title} + + {/snippet} + + + {/if} {/each} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 7b23d00eb6..339cf8bf34 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -1,4 +1,6 @@ + +{#if organizationQuery.isError} + +{:else if !isGlobalAdmin} + +{:else} +
+
+

Features

+ Enable or disable features for this organization. +
+ + +
+ {#if organizationQuery.isLoading} + {#each Array.from({ length: KNOWN_FEATURES.length }, (_, index) => index) as i (`skeleton-${i}`)} +
+
+
+ + +
+ +
+
+ {/each} + {:else} + {#each KNOWN_FEATURES as feature (feature.id)} +
+
+
+
{feature.name}
+ {feature.description} +
+ handleToggleFeature(feature.id, checked)} + /> +
+
+ {/each} + {/if} +
+
+{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts index 8b170727d6..5c215a6cd3 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts @@ -6,6 +6,7 @@ import Billing from '@lucide/svelte/icons/credit-card'; import Folder from '@lucide/svelte/icons/folder'; import Settings from '@lucide/svelte/icons/settings'; import Users from '@lucide/svelte/icons/users'; +import Zap from '@lucide/svelte/icons/zap'; import type { NavigationItem } from '../../../routes.svelte'; @@ -47,6 +48,13 @@ export function routes(): NavigationItem[] { icon: Billing, title: 'Billing' }, + { + group: 'Organization Settings', + href: resolve('/(app)/organization/[organizationId]/features', { organizationId }), + icon: Zap, + show: (ctx) => !!ctx.user?.roles?.includes('global'), + title: 'Features' + }, { group: 'Settings', href: resolve('/(app)/organization/[organizationId]/projects', { organizationId }), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte index 5b81d822c7..bd9e8cf67f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -28,6 +28,8 @@ import OrganizationDefaultsFacetedFilterBuilder from '$features/events/components/filters/organization-defaults-faceted-filter-builder.svelte'; import { getColumns } from '$features/events/components/table/options.svelte'; import { organization } from '$features/organizations/context.svelte'; + import SavedViewPicker from '$features/saved-views/components/saved-view-picker.svelte'; + import { useSavedViews } from '$features/saved-views/use-saved-views.svelte'; import { getSharedTableOptions, isTableEmpty, removeTableData } from '$features/shared/table.svelte'; import { StackStatus } from '$features/stacks/models'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; @@ -42,6 +44,7 @@ import { redirectToEventsWithFilter } from '../redirect-to-events.svelte'; let selectedEventId: null | string = $state(null); + function rowclick(row: EventSummaryModel) { selectedEventId = row.id; } @@ -53,7 +56,8 @@ const DEFAULT_FILTERS = [new ProjectFilter([]), new StatusFilter([StackStatus.Open, StackStatus.Regressed])]; const DEFAULT_PARAMS = { filter: '(status:open OR status:regressed)', - limit: DEFAULT_LIMIT + limit: DEFAULT_LIMIT, + saved: undefined as string | undefined }; function filterCacheKey(filter: null | string): string { @@ -66,15 +70,25 @@ pushHistory: true, schema: { filter: 'string', - limit: 'number' + limit: 'number', + saved: 'string' } }); + const VIEW = 'stream'; + const savedViewsState = useSavedViews({ + filterCacheKey, + getColumnVisibility: () => table.getState().columnVisibility, + queryParams, + setColumnVisibility: (v) => table.setColumnVisibility(v), + updateFilterCache, + view: VIEW + }); + watch( () => organization.current, () => { updateFilterCache(filterCacheKey(DEFAULT_PARAMS.filter), DEFAULT_FILTERS); - //params.$reset(); // Work around for https://github.com/beynar/kit-query-params/issues/7 Object.assign(queryParams, DEFAULT_PARAMS); paused = false; }, @@ -91,13 +105,11 @@ ); async function onFilterChanged(addedOrUpdated: FacetedFilter.IFilter) { - // If this is a stack filter, redirect to the Events page if (addedOrUpdated.type === 'string' && addedOrUpdated.key === 'string-stack') { await redirectToEventsWithFilter(organization.current, addedOrUpdated); return; } - // For all other filters (skipping date filters), apply them to the current page if (addedOrUpdated.type !== 'date') { updateFilters(filterChanged(filters ?? [], addedOrUpdated)); } @@ -253,6 +265,19 @@
+ {#if savedViewsState.isEnabled} + + {/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts index 515d5a67fc..348e76d93d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts @@ -6,13 +6,22 @@ import type { Component } from 'svelte'; import { routes as appRoutes } from './(app)/routes.svelte'; import { routes as authRoutes } from './(auth)/routes.svelte'; +export type NavigationChild = { + href: string; + isDefault?: boolean; + title: string; +}; + export type NavigationItem = { + children?: NavigationChild[]; + defaultViewId?: string; group: string; href: ResolvedPathname | string; icon: Component | typeof Icon; openInNewTab?: boolean; show?: (context: NavigationItemContext) => boolean; title: string; + view?: string; }; export type NavigationItemContext = { diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index 905451ad6c..cc424c0ea3 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -598,6 +598,7 @@ public async Task RemoveUserAsync(string id, string email) user.OrganizationIds.Remove(organization.Id); await _userRepository.SaveAsync(user, o => o.Cache()); + await _organizationService.RemoveUserSavedViewsAsync(organization.Id, user.Id); await _messagePublisher.PublishAsync(new UserMembershipChanged { ChangeType = ChangeType.Removed, @@ -696,6 +697,52 @@ public async Task DeleteDataAsync(string id, string key) return Ok(); } + /// + /// Enable a feature flag + /// + /// The identifier of the organization. + /// The feature flag identifier (e.g., "feature-saved-views"). + /// The feature flag was enabled. + /// The organization was not found. + [HttpPost] + [Route("{id:objectid}/features/{feature:minlength(1)}")] + [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task SetFeatureAsync(string id, string feature) + { + var organization = await GetModelAsync(id, false); + if (organization is null) + return NotFound(); + + organization.Features.Add(feature.Trim().ToLowerInvariant()); + await _repository.SaveAsync(organization, o => o.Cache()); + + return Ok(); + } + + /// + /// Disable a feature flag + /// + /// The identifier of the organization. + /// The feature flag identifier (e.g., "feature-saved-views"). + /// The feature flag was disabled. + /// The organization was not found. + [HttpDelete] + [Route("{id:objectid}/features/{feature:minlength(1)}")] + [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task RemoveFeatureAsync(string id, string feature) + { + var organization = await GetModelAsync(id, false); + if (organization is null) + return NotFound(); + + if (organization.Features.Remove(feature.Trim().ToLowerInvariant())) + await _repository.SaveAsync(organization, o => o.Cache()); + + return Ok(); + } + /// /// Check for unique name /// diff --git a/src/Exceptionless.Web/Controllers/SavedViewController.cs b/src/Exceptionless.Web/Controllers/SavedViewController.cs new file mode 100644 index 0000000000..1def73b681 --- /dev/null +++ b/src/Exceptionless.Web/Controllers/SavedViewController.cs @@ -0,0 +1,317 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Queries.Validation; +using Exceptionless.Core.Repositories; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Exceptionless.App.Controllers.API; + +[Route(API_PREFIX + "/saved-views")] +[Authorize(Policy = AuthorizationRoles.UserPolicy)] +public class SavedViewController : RepositoryApiController +{ + private const int MaxViewsPerOrganization = 100; + private bool _isPrivateRequest; + private readonly IOrganizationRepository _organizationRepository; + + public SavedViewController( + ISavedViewRepository repository, + IOrganizationRepository organizationRepository, + ApiMapper mapper, + IAppQueryValidator validator, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory) + { + _organizationRepository = organizationRepository; + } + + protected override SavedView MapToModel(NewSavedView newModel) => _mapper.MapToSavedView(newModel); + protected override ViewSavedView MapToViewModel(SavedView model) => _mapper.MapToViewSavedView(model); + protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewSavedViews(models); + + private async Task IsFeatureEnabledAsync(string organizationId) + { + var org = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache()); + return org?.Features?.Contains(OrganizationFeatures.SavedViews) ?? false; + } + + /// + /// Get by organization + /// + /// The identifier of the organization. + /// The page parameter is used for pagination. This value must be greater than 0. + /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. + /// The organization could not be found. + [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/saved-views")] + public async Task>> GetByOrganizationAsync(string organizationId, int page = 1, int limit = 25) + { + if (String.IsNullOrEmpty(organizationId) || !CanAccessOrganization(organizationId)) + { + return NotFound(); + } + + // Reads remain available even when the feature is disabled to preserve access to existing saved views. + + page = GetPage(page); + limit = GetLimit(limit); + var results = await _repository.GetByOrganizationForUserAsync(organizationId, CurrentUser.Id, o => o.PageNumber(page).PageLimit(limit)); + + var viewModels = MapToViewModels(results.Documents); + return OkWithResourceLinks(viewModels, results.HasMore && !NextPageExceedsSkipLimit(page, limit), page, results.Total); + } + + /// + /// Get by organization and view + /// + /// The identifier of the organization. + /// The dashboard view (events, issues, stream). + /// The page parameter is used for pagination. This value must be greater than 0. + /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. + /// The organization could not be found. + [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/saved-views/{view}")] + public async Task>> GetByViewAsync(string organizationId, string view, int page = 1, int limit = 25) + { + if (String.IsNullOrEmpty(organizationId) || !CanAccessOrganization(organizationId)) + { + return NotFound(); + } + + if (!SavedView.ValidViews.Contains(view)) + { + return NotFound(); + } + + // Reads remain available even when the feature is disabled to preserve access to existing saved views. + + page = GetPage(page); + limit = GetLimit(limit); + var results = await _repository.GetByViewForUserAsync(organizationId, view, CurrentUser.Id, o => o.PageNumber(page).PageLimit(limit)); + + var viewModels = MapToViewModels(results.Documents); + return OkWithResourceLinks(viewModels, results.HasMore && !NextPageExceedsSkipLimit(page, limit), page, results.Total); + } + + /// + /// Get by id + /// + /// The identifier of the saved view. + /// The saved view could not be found. + [HttpGet("{id:objectid}", Name = "GetSavedViewById")] + public Task> GetAsync(string id) + { + return GetByIdImplAsync(id); + } + + /// + /// Create + /// + /// The identifier of the organization. + /// The saved view. + /// If true, the view will only be visible to the current user. + /// An error occurred while creating the saved view. + /// The saved view already exists. + [HttpPost("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/saved-views")] + [Consumes("application/json")] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task> PostAsync(string organizationId, NewSavedView savedView, [FromQuery] bool is_private = false) + { + if (!IsInOrganization(organizationId)) + { + return BadRequest(); + } + + if (is_private && savedView.IsDefault) + { + ModelState.AddModelError(nameof(NewSavedView.IsDefault), "Private views cannot be set as the default. Default views are organization-wide."); + return ValidationProblem(ModelState); + } + + savedView.OrganizationId = organizationId; + _isPrivateRequest = is_private; + return await PostImplAsync(savedView); + } + + /// + /// Update + /// + /// The identifier of the saved view. + /// The changes + /// An error occurred while updating the saved view. + /// The saved view could not be found. + [HttpPatch("{id:objectid}")] + [HttpPut("{id:objectid}")] + [Consumes("application/json")] + public Task> PatchAsync(string id, Delta changes) + { + return PatchImplAsync(id, changes); + } + + /// + /// Remove + /// + /// A comma-delimited list of saved view identifiers. + /// Accepted + /// One or more validation errors occurred. + /// One or more saved views were not found. + /// An error occurred while deleting one or more saved views. + [HttpDelete("{ids:objectids}")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + public Task> DeleteAsync(string ids) + { + return DeleteImplAsync(ids.FromDelimitedString()); + } + + protected override async Task GetModelAsync(string id, bool useCache = true) + { + if (String.IsNullOrEmpty(id)) + { + return null; + } + + var model = await _repository.GetByIdAsync(id, o => o.Cache(useCache)); + if (model is null) + { + return null; + } + + if (!String.IsNullOrEmpty(model.OrganizationId) && !IsInOrganization(model.OrganizationId)) + { + return null; + } + + if (model.UserId is not null && model.UserId != CurrentUser.Id && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) + { + return null; + } + + return model; + } + + protected override async Task CanAddAsync(SavedView value) + { + if (String.IsNullOrEmpty(value.OrganizationId) || !IsInOrganization(value.OrganizationId)) + { + return PermissionResult.Deny; + } + + if (!await IsFeatureEnabledAsync(value.OrganizationId)) + { + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "The saved views feature is not enabled for this organization."); + } + + var count = await _repository.CountByOrganizationIdAsync(value.OrganizationId); + if (count >= MaxViewsPerOrganization) + { + return PermissionResult.DenyWithMessage($"Organization is limited to {MaxViewsPerOrganization} saved views."); + } + + return await base.CanAddAsync(value); + } + + protected override async Task CanUpdateAsync(SavedView original, Delta changes) + { + if (original.UserId is not null && original.UserId != CurrentUser.Id && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) + { + return PermissionResult.DenyWithNotFound(original.Id); + } + + if (!await IsFeatureEnabledAsync(original.OrganizationId)) + { + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "The saved views feature is not enabled for this organization."); + } + + // Private views cannot be set as the default + if (original.UserId is not null + && changes.GetChangedPropertyNames().Contains(nameof(UpdateSavedView.IsDefault)) + && changes.TryGetPropertyValue(nameof(UpdateSavedView.IsDefault), out object? isDefaultValue) + && isDefaultValue is true) + { + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "Private views cannot be set as the default. Default views are organization-wide."); + } + + if (changes.GetChangedPropertyNames().Contains(nameof(UpdateSavedView.Columns))) + { + var patchedChanges = new UpdateSavedView(); + changes.Patch(patchedChanges); + var validationError = NewSavedView.ValidateColumnKeys(original.View, patchedChanges.Columns).FirstOrDefault(); + if (validationError is not null) + { + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, validationError.ErrorMessage ?? "Invalid column keys."); + } + } + + return await base.CanUpdateAsync(original, changes); + } + + protected override async Task AddModelAsync(SavedView value) + { + value.CreatedUtc = value.UpdatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + value.CreatedByUserId = CurrentUser.Id; + value.Version = 1; + + if (_isPrivateRequest) + { + value.UserId = CurrentUser.Id; + } + + if (value.IsDefault) + { + await ClearDefaultForViewAsync(value.OrganizationId, value.View); + } + + return await base.AddModelAsync(value); + } + + protected override async Task UpdateModelAsync(SavedView original, Delta changes) + { + original.UpdatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + original.UpdatedByUserId = CurrentUser.Id; + + if (changes.GetChangedPropertyNames().Contains(nameof(UpdateSavedView.IsDefault)) + && changes.TryGetPropertyValue(nameof(UpdateSavedView.IsDefault), out object? isDefaultValue) + && isDefaultValue is true) + { + await ClearDefaultForViewAsync(original.OrganizationId, original.View); + } + + return await base.UpdateModelAsync(original, changes); + } + + private async Task ClearDefaultForViewAsync(string organizationId, string view) + { + var existing = await _repository.GetByViewAsync(organizationId, view, o => o.ImmediateConsistency()); + var defaults = existing.Documents.Where(savedView => savedView.IsDefault && savedView.UserId is null).ToList(); + + if (defaults.Count > 0) + { + foreach (var defaultView in defaults) + { + defaultView.IsDefault = false; + } + + await _repository.SaveAsync(defaults, o => o.ImmediateConsistency()); + } + } + + protected override async Task CanDeleteAsync(SavedView value) + { + if (value.UserId is not null && value.UserId != CurrentUser.Id && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) + { + return PermissionResult.DenyWithNotFound(value.Id); + } + + if (!await IsFeatureEnabledAsync(value.OrganizationId)) + { + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "The saved views feature is not enabled for this organization."); + } + + return await base.CanDeleteAsync(value); + } +} diff --git a/src/Exceptionless.Web/Mapping/ApiMapper.cs b/src/Exceptionless.Web/Mapping/ApiMapper.cs index 450c22a472..3038018784 100644 --- a/src/Exceptionless.Web/Mapping/ApiMapper.cs +++ b/src/Exceptionless.Web/Mapping/ApiMapper.cs @@ -15,6 +15,7 @@ public class ApiMapper private readonly UserMapper _userMapper; private readonly WebHookMapper _webHookMapper; private readonly InvoiceMapper _invoiceMapper; + private readonly SavedViewMapper _savedViewMapper; public ApiMapper(TimeProvider timeProvider) { @@ -24,6 +25,7 @@ public ApiMapper(TimeProvider timeProvider) _userMapper = new UserMapper(); _webHookMapper = new WebHookMapper(); _invoiceMapper = new InvoiceMapper(); + _savedViewMapper = new SavedViewMapper(); } // Organization mappings @@ -73,4 +75,14 @@ public InvoiceGridModel MapToInvoiceGridModel(Stripe.Invoice source) public List MapToInvoiceGridModels(IEnumerable source) => _invoiceMapper.MapToInvoiceGridModels(source); + + // SavedView mappings + public SavedView MapToSavedView(NewSavedView source) + => _savedViewMapper.MapToSavedView(source); + + public ViewSavedView MapToViewSavedView(SavedView source) + => _savedViewMapper.MapToViewSavedView(source); + + public List MapToViewSavedViews(IEnumerable source) + => _savedViewMapper.MapToViewSavedViews(source); } diff --git a/src/Exceptionless.Web/Mapping/SavedViewMapper.cs b/src/Exceptionless.Web/Mapping/SavedViewMapper.cs new file mode 100644 index 0000000000..f67863d474 --- /dev/null +++ b/src/Exceptionless.Web/Mapping/SavedViewMapper.cs @@ -0,0 +1,19 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Riok.Mapperly.Abstractions; + +namespace Exceptionless.Web.Mapping; + +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] +public partial class SavedViewMapper +{ + [MapperIgnoreTarget(nameof(SavedView.Version))] + [MapperIgnoreTarget(nameof(SavedView.CreatedByUserId))] + [MapperIgnoreTarget(nameof(SavedView.UpdatedByUserId))] + [MapperIgnoreTarget(nameof(SavedView.UserId))] + public partial SavedView MapToSavedView(NewSavedView source); + + public partial ViewSavedView MapToViewSavedView(SavedView source); + + public partial List MapToViewSavedViews(IEnumerable source); +} diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 9960f29622..6f6270a1e5 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -31,6 +31,7 @@ public record ViewOrganization : IIdentity, IData, IHaveDates public string? SuspensionNotes { get; set; } public DateTime? SuspensionDate { get; set; } public bool HasPremiumFeatures { get; set; } + public ISet Features { get; set; } = new HashSet(); public int MaxUsers { get; set; } public int MaxProjects { get; set; } public long ProjectCount { get; set; } diff --git a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs new file mode 100644 index 0000000000..56a829f19e --- /dev/null +++ b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs @@ -0,0 +1,93 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Exceptionless.Core.Attributes; +using Exceptionless.Core.Models; + +namespace Exceptionless.Web.Models; + +public record NewSavedView : IOwnedByOrganization, IValidatableObject +{ + [ObjectId] + public string OrganizationId { get; set; } = null!; + + [Required] + [MaxLength(100)] + public string Name { get; set; } = null!; + + [MaxLength(2000)] + public string? Filter { get; set; } + + [MaxLength(100)] + public string? Time { get; set; } + + [Required] + [RegularExpression("^(events|issues|stream)$")] + public string View { get; set; } = null!; + + [MaxLength(10000)] + public string? FilterDefinitions { get; set; } + + [MaxLength(50)] + public Dictionary? Columns { get; set; } + + public bool IsDefault { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (!String.IsNullOrEmpty(View) && !SavedView.ValidViews.Contains(View)) + { + yield return new ValidationResult( + $"View must be one of: {String.Join(", ", SavedView.ValidViews)}", + [nameof(View)] + ); + } + + if (!String.IsNullOrEmpty(FilterDefinitions) && !IsValidJsonArray(FilterDefinitions)) + { + yield return new ValidationResult( + "FilterDefinitions must be a valid JSON array", + [nameof(FilterDefinitions)] + ); + } + + foreach (var error in ValidateColumnKeys(View, Columns)) + { + yield return error; + } + } + + internal static IEnumerable ValidateColumnKeys(string? view, Dictionary? columns) + { + if (columns is null || columns.Count == 0) + { + yield break; + } + + var validKeys = view is not null && SavedView.ValidColumnIds.TryGetValue(view, out var viewKeys) + ? viewKeys + : SavedView.AllValidColumnIds; + + var invalidKeys = columns.Keys.Where(key => !validKeys.Contains(key)); + foreach (var key in invalidKeys) + { + yield return new ValidationResult( + $"Column key '{key}' is not a valid column. Valid columns are: {String.Join(", ", validKeys.Order())}.", + [nameof(Columns)] + ); + } + } + + private static bool IsValidJsonArray(string json) + { + try + { + using var document = JsonDocument.Parse(json); + + return document.RootElement.ValueKind == JsonValueKind.Array; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs new file mode 100644 index 0000000000..d78324854d --- /dev/null +++ b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Exceptionless.Web.Models; + +public class UpdateSavedView : IValidatableObject +{ + [MaxLength(100)] + public string? Name { get; set; } + [MaxLength(2000)] + public string? Filter { get; set; } + [MaxLength(100)] + public string? Time { get; set; } + [MaxLength(10000)] + public string? FilterDefinitions { get; set; } + [MaxLength(50)] + public Dictionary? Columns { get; set; } + public bool? IsDefault { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + foreach (var error in NewSavedView.ValidateColumnKeys(null, Columns)) + { + yield return error; + } + } +} diff --git a/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs new file mode 100644 index 0000000000..c597d30758 --- /dev/null +++ b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs @@ -0,0 +1,34 @@ +using Exceptionless.Core.Attributes; +using Foundatio.Repositories.Models; + +namespace Exceptionless.Web.Models; + +public record ViewSavedView : IIdentity, IHaveDates +{ + [ObjectId] + public string Id { get; set; } = null!; + + [ObjectId] + public string OrganizationId { get; set; } = null!; + + [ObjectId] + public string? UserId { get; set; } + + [ObjectId] + public string CreatedByUserId { get; set; } = null!; + + [ObjectId] + public string? UpdatedByUserId { get; set; } + + public string? Filter { get; set; } + public string? FilterDefinitions { get; set; } + public Dictionary? Columns { get; set; } + public bool IsDefault { get; set; } + public string Name { get; set; } = null!; + public string? Time { get; set; } + public int Version { get; set; } + public string View { get; set; } = null!; + + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } +} diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 920ec6aefc..2e4c3a7c62 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -20,6 +20,373 @@ } ], "paths": { + "/api/v2/organizations/{organizationId}/saved-views": { + "get": { + "tags": [ + "SavedView" + ], + "summary": "Get by organization", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 25 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + } + }, + "404": { + "description": "The organization could not be found." + } + } + }, + "post": { + "tags": [ + "SavedView" + ], + "summary": "Create", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "is_private", + "in": "query", + "description": "If true, the view will only be visible to the current user.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "description": "The saved view.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewSavedView" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/NewSavedView" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + }, + "400": { + "description": "An error occurred while creating the saved view." + }, + "409": { + "description": "The saved view already exists." + } + } + } + }, + "/api/v2/organizations/{organizationId}/saved-views/{view}": { + "get": { + "tags": [ + "SavedView" + ], + "summary": "Get by organization and view", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "view", + "in": "path", + "description": "The dashboard view (events, issues, stream).", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 25 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + } + }, + "404": { + "description": "The organization could not be found." + } + } + } + }, + "/api/v2/saved-views/{id}": { + "get": { + "tags": [ + "SavedView" + ], + "summary": "Get by id", + "operationId": "GetSavedViewById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the saved view.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + }, + "404": { + "description": "The saved view could not be found." + } + } + }, + "patch": { + "tags": [ + "SavedView" + ], + "summary": "Update", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the saved view.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The changes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSavedView" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateSavedView" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + }, + "400": { + "description": "An error occurred while updating the saved view." + }, + "404": { + "description": "The saved view could not be found." + } + } + }, + "put": { + "tags": [ + "SavedView" + ], + "summary": "Update", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the saved view.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The changes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSavedView" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateSavedView" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + }, + "400": { + "description": "An error occurred while updating the saved view." + }, + "404": { + "description": "The saved view could not be found." + } + } + } + }, + "/api/v2/saved-views/{ids}": { + "delete": { + "tags": [ + "SavedView" + ], + "summary": "Remove", + "parameters": [ + { + "name": "ids", + "in": "path", + "description": "A comma-delimited list of saved view identifiers.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } + } + }, + "400": { + "description": "One or more validation errors occurred." + }, + "404": { + "description": "One or more saved views were not found." + }, + "500": { + "description": "An error occurred while deleting one or more saved views." + } + } + } + }, "/api/v2/organizations/{organizationId}/tokens": { "get": { "tags": [ @@ -7330,6 +7697,65 @@ } } }, + "NewSavedView": { + "required": [ + "name", + "view", + "organization_id", + "is_default" + ], + "type": "object", + "properties": { + "organization_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": "string" + }, + "name": { + "maxLength": 100, + "type": "string" + }, + "filter": { + "maxLength": 2000, + "type": [ + "null", + "string" + ] + }, + "time": { + "maxLength": 100, + "type": [ + "null", + "string" + ] + }, + "view": { + "pattern": "^(events|issues|stream)$", + "type": "string" + }, + "filter_definitions": { + "maxLength": 10000, + "type": [ + "null", + "string" + ] + }, + "columns": { + "maxLength": 50, + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "boolean" + } + }, + "is_default": { + "type": "boolean" + } + } + }, "NewToken": { "required": [ "organization_id", @@ -7931,6 +8357,45 @@ }, "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." }, + "UpdateSavedView": { + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "filter": { + "type": [ + "null", + "string" + ] + }, + "time": { + "type": [ + "null", + "string" + ] + }, + "filter_definitions": { + "type": [ + "null", + "string" + ] + }, + "columns": { + "type": "array" + }, + "is_default": { + "type": [ + "null", + "boolean" + ] + } + }, + "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." + }, "UpdateToken": { "type": "object", "properties": { @@ -8247,6 +8712,7 @@ "retention_days", "is_suspended", "has_premium_features", + "features", "max_users", "max_projects", "project_count", @@ -8367,6 +8833,13 @@ "has_premium_features": { "type": "boolean" }, + "features": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, "max_users": { "type": "integer", "format": "int32" @@ -8514,6 +8987,106 @@ } } }, + "ViewSavedView": { + "required": [ + "id", + "organization_id", + "created_by_user_id", + "is_default", + "name", + "version", + "view", + "created_utc", + "updated_utc" + ], + "type": "object", + "properties": { + "id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": "string" + }, + "organization_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": "string" + }, + "user_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": [ + "null", + "string" + ] + }, + "created_by_user_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": "string" + }, + "updated_by_user_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": [ + "null", + "string" + ] + }, + "filter": { + "type": [ + "null", + "string" + ] + }, + "filter_definitions": { + "type": [ + "null", + "string" + ] + }, + "columns": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "boolean" + } + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "time": { + "type": [ + "null", + "string" + ] + }, + "version": { + "type": "integer", + "format": "int32" + }, + "view": { + "type": "string" + }, + "created_utc": { + "type": "string", + "format": "date-time" + }, + "updated_utc": { + "type": "string", + "format": "date-time" + } + } + }, "ViewToken": { "required": [ "id", @@ -8743,6 +9316,9 @@ } }, "tags": [ + { + "name": "SavedView" + }, { "name": "Token" }, diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 9514a29281..82d12d3d1d 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -214,6 +214,130 @@ public Task GetAsync_NonExistentOrganization_ReturnsNotFound() ); } + [Fact] + public async Task SetFeatureAsync_AsGlobalAdmin_EnablesFeature() + { + // Act + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "feature-saved-views") + .StatusCodeShouldBeOk() + ); + + // Assert - feature is stored on the organization + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + Assert.Contains("feature-saved-views", organization.Features); + } + + [Fact] + public Task SetFeatureAsync_AsRegularUser_ReturnsForbidden() + { + // Act & Assert + return SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "feature-saved-views") + .StatusCodeShouldBeForbidden() + ); + } + + [Fact] + public Task SetFeatureAsync_NonExistentOrganization_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", "000000000000000000000001", "features", "feature-saved-views") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task RemoveFeatureAsync_AsGlobalAdmin_DisablesFeature() + { + // Arrange - enable the feature first + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "feature-saved-views") + .StatusCodeShouldBeOk() + ); + + var afterEnable = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(afterEnable); + Assert.Contains("feature-saved-views", afterEnable.Features); + + // Act - disable the feature + await SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "feature-saved-views") + .StatusCodeShouldBeOk() + ); + + // Assert - feature is removed + var afterRemove = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(afterRemove); + Assert.DoesNotContain("feature-saved-views", afterRemove.Features); + } + + [Fact] + public Task RemoveFeatureAsync_AsRegularUser_ReturnsForbidden() + { + // Act & Assert + return SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "feature-saved-views") + .StatusCodeShouldBeForbidden() + ); + } + + [Fact] + public async Task SetFeatureAsync_IsCaseInsensitive() + { + // Act - enable with different casing (controller normalizes to lowercase) + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "Feature-Saved-Views") + .StatusCodeShouldBeOk() + ); + + // Assert - stored normalized to lowercase + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + Assert.Contains("feature-saved-views", organization.Features); + Assert.DoesNotContain("Feature-Saved-Views", organization.Features); + } + + [Fact] + public async Task GetAsync_ViewOrganization_IncludesFeaturesCollection() + { + // Arrange - enable a feature + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "feature-saved-views") + .StatusCodeShouldBeOk() + ); + + // Act + var viewOrg = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID) + .StatusCodeShouldBeOk() + ); + + // Assert - Features is included in the ViewOrganization DTO + Assert.NotNull(viewOrg); + Assert.NotNull(viewOrg.Features); + Assert.Contains("feature-saved-views", viewOrg.Features); + } + [Fact] public async Task DeleteAsync_ExistingOrganization_RemovesOrganization() { diff --git a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs new file mode 100644 index 0000000000..e751f7e592 --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs @@ -0,0 +1,1524 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Extensions; +using Exceptionless.Web.Models; +using FluentRest; +using Foundatio.Repositories; +using Xunit; + +namespace Exceptionless.Tests.Controllers; + +public sealed class SavedViewControllerTests : IntegrationTestsBase +{ + private readonly ISavedViewRepository _savedViewRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IUserRepository _userRepository; + private readonly OrganizationService _organizationService; + + public SavedViewControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _savedViewRepository = GetService(); + _organizationRepository = GetService(); + _userRepository = GetService(); + _organizationService = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + var service = GetService(); + await service.CreateDataAsync(); + + // Enable saved views feature for all tests in this class + var org = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + if (org is not null) + { + org.Features.Add(OrganizationFeatures.SavedViews); + await _organizationRepository.SaveAsync(org, o => o.ImmediateConsistency()); + } + } + + + [Fact] + public async Task PostAsync_NewSavedView_MapsAllPropertiesToSavedView() + { + // Arrange + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Production Errors", + Filter = "status:open", + Time = "[now-7D TO now]", + View = "events", + FilterDefinitions = """[{"type":"keyword","value":"status:open"}]""" + }; + + // Act + var viewFilter = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + + // Assert + Assert.NotNull(viewFilter); + Assert.NotNull(viewFilter.Id); + Assert.Equal(SampleDataService.TEST_ORG_ID, viewFilter.OrganizationId); + Assert.Null(viewFilter.UserId); + Assert.Equal("Production Errors", viewFilter.Name); + Assert.Equal("status:open", viewFilter.Filter); + Assert.Equal("[now-7D TO now]", viewFilter.Time); + Assert.Equal("events", viewFilter.View); + Assert.NotNull(viewFilter.FilterDefinitions); + Assert.Equal(1, viewFilter.Version); + Assert.NotNull(viewFilter.CreatedByUserId); + Assert.Null(viewFilter.UpdatedByUserId); + Assert.True(viewFilter.CreatedUtc > DateTime.MinValue); + Assert.True(viewFilter.UpdatedUtc > DateTime.MinValue); + + // Verify persisted + var savedView = await _savedViewRepository.GetByIdAsync(viewFilter.Id); + Assert.NotNull(savedView); + Assert.Equal("Production Errors", savedView.Name); + } + + [Fact] + public async Task PostAsync_WithIsPrivate_SetsUserIdOnSavedView() + { + // Arrange + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "My Private Filter", + Filter = "status:regressed", + View = "issues" + }; + + // Act + var viewFilter = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .QueryString("is_private", "true") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + + // Assert + Assert.NotNull(viewFilter); + Assert.NotNull(viewFilter.UserId); + } + + [Fact] + public async Task PostAsync_WithoutIsPrivate_DoesNotSetUserId() + { + // Arrange + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Organization Wide Filter", + Filter = "status:open", + View = "events" + }; + + // Act + var viewFilter = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + + // Assert + Assert.NotNull(viewFilter); + Assert.Null(viewFilter.UserId); + } + + [Fact] + public Task PostAsync_WithUnauthorizedOrganization_ReturnsBadRequest() + { + // Arrange + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.FREE_ORG_ID, + Name = "Unauthorized Filter", + Filter = "status:open", + View = "events" + }; + + // Act & Assert + return SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeBadRequest() + ); + } + + [Fact] + public async Task PostAsync_AsOrganizationUser_CanCreateSavedView() + { + // Arrange + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Organization User Filter", + Filter = "type:error", + View = "stream" + }; + + // Act + var viewFilter = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + + // Assert + Assert.NotNull(viewFilter); + Assert.Equal("Organization User Filter", viewFilter.Name); + } + + + + + + + + [Fact] + public Task PostAsync_WithEmptyName_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "", + Filter = "status:open", + View = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_WithEmptyFilter_ReturnsCreated() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Show All", + Filter = "", + View = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + } + + [Fact] + public Task PostAsync_WithInvalidView_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Invalid View Filter", + Filter = "status:open", + View = "invalid-view" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Theory] + [InlineData("events")] + [InlineData("issues")] + [InlineData("stream")] + public async Task PostAsync_WithValidView_Succeeds(string view) + { + // Arrange & Act + var viewFilter = await CreateSavedViewAsync($"View Test {view}", "status:open", view); + + // Assert + Assert.NotNull(viewFilter); + Assert.Equal(view, viewFilter.View); + } + + + + [Fact] + public async Task GetAsync_ExistingFilter_ReturnsFilter() + { + // Arrange + var created = await CreateSavedViewAsync("Get Test Filter", "status:open", "events"); + Assert.NotNull(created); + + // Act + var viewFilter = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(viewFilter); + Assert.Equal(created.Id, viewFilter.Id); + Assert.Equal(created.Name, viewFilter.Name); + } + + [Fact] + public Task GetAsync_NonExistentFilter_ReturnsNotFound() + { + return SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("saved-views", "000000000000000000000000") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task GetByOrganizationAsync_ReturnsOrganizationWideAndCurrentUserFilters() + { + // Arrange + var organizationFilter = await CreateSavedViewAsync("Organization Filter", "status:open", "events"); + var privateFilter = await CreateSavedViewAsync("Private Filter", "status:regressed", "events", isPrivate: true); + Assert.NotNull(organizationFilter); + Assert.NotNull(privateFilter); + + // Act + var filters = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(filters); + Assert.True(filters.Count >= 2); + Assert.Contains(filters, f => f.Id == organizationFilter.Id); + Assert.Contains(filters, f => f.Id == privateFilter.Id); + } + + [Fact] + public async Task GetByOrganizationAsync_ExcludesOtherUsersPrivateFilters() + { + // Arrange - Global admin creates a private filter + var privateFilter = await CreateSavedViewAsync("Admin Private", "status:open", "events", isPrivate: true); + Assert.NotNull(privateFilter); + + // Act - Organization user queries the same organization + var filters = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .StatusCodeShouldBeOk() + ); + + // Assert - should not see the admin's private filter + Assert.NotNull(filters); + Assert.DoesNotContain(filters, f => f.Id == privateFilter.Id); + } + + [Fact] + public async Task GetByViewAsync_ReturnsOnlyMatchingViewFilters() + { + // Arrange + var eventsFilter = await CreateSavedViewAsync("Events Only", "status:open", "events"); + var issuesFilter = await CreateSavedViewAsync("Issues Only", "status:regressed", "issues"); + Assert.NotNull(eventsFilter); + Assert.NotNull(issuesFilter); + + // Act + var filters = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "events") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(filters); + Assert.Contains(filters, f => f.Id == eventsFilter.Id); + Assert.DoesNotContain(filters, f => f.Id == issuesFilter.Id); + } + + + + + + [Fact] + public async Task PatchAsync_UpdateName_UpdatesNameAndSetsUpdatedByUserId() + { + // Arrange + var created = await CreateSavedViewAsync("Original Name", "status:open", "events"); + Assert.NotNull(created); + + // Act + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { Name = "Updated Name" }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.Equal("Updated Name", updated.Name); + Assert.NotNull(updated.UpdatedByUserId); + Assert.True(updated.UpdatedUtc >= created.UpdatedUtc); + } + + [Fact] + public async Task PatchAsync_UpdateFilter_UpdatesFilterString() + { + // Arrange + var created = await CreateSavedViewAsync("Filter Update Test", "status:open", "events"); + Assert.NotNull(created); + + // Act + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { Filter = "status:regressed" }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.Equal("status:regressed", updated.Filter); + } + + [Fact] + public async Task PatchAsync_UpdateTime_UpdatesTimeString() + { + // Arrange + var created = await CreateSavedViewAsync("Time Update Test", "status:open", "events"); + Assert.NotNull(created); + + // Act + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { Time = "[now-30D TO now]" }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.Equal("[now-30D TO now]", updated.Time); + } + + [Fact] + public Task PatchAsync_NonExistentFilter_ReturnsNotFound() + { + return SendRequestAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", "000000000000000000000000") + .Content(new UpdateSavedView { Name = "Nope" }) + .StatusCodeShouldBeNotFound() + ); + } + + + + [Fact] + public async Task DeleteAsync_OwnOrganizationWideFilter_Succeeds() + { + // Arrange + var created = await CreateSavedViewAsync("Delete Me", "status:open", "events"); + Assert.NotNull(created); + + // Act + await SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .StatusCodeShouldBeAccepted() + ); + + // Assert + var deleted = await _savedViewRepository.GetByIdAsync(created.Id); + Assert.Null(deleted); + } + + [Fact] + public async Task DeleteAsync_OtherUsersPrivateFilter_ReturnsNotFound() + { + // Arrange - Global admin creates private filter + var privateFilter = await CreateSavedViewAsync("Admin Private Delete", "status:open", "events", isPrivate: true); + Assert.NotNull(privateFilter); + + // Act - Organization user tries to delete it (DenyWithNotFound hides existence) + await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPaths("saved-views", privateFilter.Id) + .StatusCodeShouldBeNotFound() + ); + + // Assert - still exists + var stillExists = await _savedViewRepository.GetByIdAsync(privateFilter.Id); + Assert.NotNull(stillExists); + } + + [Fact] + public async Task DeleteAsync_MultipleFilters_DeletesAll() + { + // Arrange + var first = await CreateSavedViewAsync("Multi Delete 1", "status:open", "events"); + var second = await CreateSavedViewAsync("Multi Delete 2", "status:regressed", "events"); + Assert.NotNull(first); + Assert.NotNull(second); + + // Act + await SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("saved-views", $"{first.Id},{second.Id}") + .StatusCodeShouldBeAccepted() + ); + + // Assert + Assert.Null(await _savedViewRepository.GetByIdAsync(first.Id)); + Assert.Null(await _savedViewRepository.GetByIdAsync(second.Id)); + } + + + + [Fact] + public async Task GetAsync_PrivateFilterByOwner_ReturnsFilter() + { + // Arrange + var created = await CreateSavedViewAsync("Owner Access", "status:open", "events", isPrivate: true); + Assert.NotNull(created); + + // Act - same user who created it + var viewFilter = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(viewFilter); + Assert.Equal(created.Id, viewFilter.Id); + } + + [Fact] + public async Task GetAsync_PrivateFilterByOtherUser_ReturnsNotFound() + { + // Arrange - Global admin creates private filter + var created = await CreateSavedViewAsync("Admin Only", "status:open", "events", isPrivate: true); + Assert.NotNull(created); + + // Act - Organization user tries to get it + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("saved-views", created.Id) + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task PatchAsync_OrganizationWideFilterByOrganizationMember_Succeeds() + { + // Arrange - Global admin creates organization-wide filter + var created = await CreateSavedViewAsync("Shared Filter", "status:open", "events"); + Assert.NotNull(created); + + // Act - Organization user updates it + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsTestOrganizationUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { Name = "Renamed by Organization User" }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.Equal("Renamed by Organization User", updated.Name); + } + + [Fact] + public Task PostAsync_AnonymousUser_ReturnsUnauthorized() + { + // Arrange + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Anonymous Filter", + Filter = "status:open", + View = "events" + }; + + // Act & Assert + return SendRequestAsync(r => r + .Post() + .AsAnonymousUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnauthorized() + ); + } + + + + [Fact] + public async Task PostAsync_ExceedsPerOrgCap_ReturnsBadRequest() + { + // Arrange - Directly seed repository to approach the cap + var filters = new List(); + for (int i = 0; i < 100; i++) + { + filters.Add(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = $"Cap Test {i}", + Filter = "status:open", + View = "events", + Version = 1, + CreatedByUserId = "537650f3b77efe23a47914f0", + CreatedUtc = DateTime.UtcNow, + UpdatedUtc = DateTime.UtcNow + }); + } + await _savedViewRepository.AddAsync(filters, o => o.ImmediateConsistency()); + + // Act - try to add one more + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "One Too Many", + Filter = "status:open", + View = "events" + }; + + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeBadRequest() + ); + } + + + // Feature flag tests + + [Fact] + public async Task PostAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() + { + // Arrange — disable the saved views feature + var org = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(org); + org.Features.Remove(OrganizationFeatures.SavedViews); + await _organizationRepository.SaveAsync(org, o => o.ImmediateConsistency()); + + var newView = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Blocked View", + Filter = "status:open", + View = "events" + }; + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newView) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task PatchAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() + { + // Arrange — create a view directly (bypassing feature check), then disable feature + var savedView = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Existing View", + Filter = "status:open", + View = "events", + Version = 1, + CreatedByUserId = "537650f3b77efe23a47914f0", + CreatedUtc = DateTime.UtcNow, + UpdatedUtc = DateTime.UtcNow + }, o => o.ImmediateConsistency()); + + var org = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(org); + org.Features.Remove(OrganizationFeatures.SavedViews); + await _organizationRepository.SaveAsync(org, o => o.ImmediateConsistency()); + + await SendRequestAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", savedView.Id) + .Content(new UpdateSavedView { Name = "Updated Name" }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task DeleteAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() + { + // Arrange — create a view directly, then disable feature + var savedView = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "View To Delete", + Filter = "status:open", + View = "events", + Version = 1, + CreatedByUserId = "537650f3b77efe23a47914f0", + CreatedUtc = DateTime.UtcNow, + UpdatedUtc = DateTime.UtcNow + }, o => o.ImmediateConsistency()); + + var org = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(org); + org.Features.Remove(OrganizationFeatures.SavedViews); + await _organizationRepository.SaveAsync(org, o => o.ImmediateConsistency()); + + await SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("saved-views", savedView.Id) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + + // Cleanup tests + + [Fact] + public async Task RemoveUser_DeletesPrivateSavedViews_ButPreservesOrganizationWideViews() + { + // Arrange — create an organization-wide view and a private view for the test organization user + var orgUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(orgUser); + + var orgWideView = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Organization Wide", + Filter = "status:open", + View = "events", + CreatedByUserId = orgUser.Id + }); + + var privateView = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + UserId = orgUser.Id, + Name = "My Private View", + Filter = "type:error", + View = "events", + CreatedByUserId = orgUser.Id + }); + + await RefreshDataAsync(); + + // Act — remove the user from the organization via the API + await SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "users", SampleDataService.TEST_ORG_USER_EMAIL) + .StatusCodeShouldBeOk() + ); + + await RefreshDataAsync(); + + // Assert — private view is gone, organization-wide view remains + Assert.Null(await _savedViewRepository.GetByIdAsync(privateView.Id)); + Assert.NotNull(await _savedViewRepository.GetByIdAsync(orgWideView.Id)); + } + + [Fact] + public async Task SoftDeleteOrganization_RemovesAllSavedViews() + { + // Arrange + var orgUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(orgUser); + + await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Organization View", + Filter = "status:open", + View = "events", + CreatedByUserId = orgUser.Id + }); + + await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + UserId = orgUser.Id, + Name = "Private View", + Filter = "type:error", + View = "events", + CreatedByUserId = orgUser.Id + }); + + await RefreshDataAsync(); + + var countBefore = await _savedViewRepository.CountByOrganizationIdAsync(SampleDataService.TEST_ORG_ID); + Assert.True(countBefore >= 2); + + // Act + var organizationRepository = GetService(); + var organization = await organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + await _organizationService.SoftDeleteOrganizationAsync(organization, orgUser.Id); + await RefreshDataAsync(); + + // Assert + var countAfter = await _savedViewRepository.CountByOrganizationIdAsync(SampleDataService.TEST_ORG_ID); + Assert.Equal(0, countAfter); + } + + [Fact] + public async Task RemoveUserSavedViews_OnlyDeletesPrivateViews() + { + // Arrange + var orgUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(orgUser); + + var orgWide = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Organization Wide", + Filter = "status:open", + View = "events", + CreatedByUserId = orgUser.Id + }); + + var privateView = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + UserId = orgUser.Id, + Name = "Private", + Filter = "type:error", + View = "events", + CreatedByUserId = orgUser.Id + }); + + await RefreshDataAsync(); + + // Act + var removed = await _organizationService.RemoveUserSavedViewsAsync(SampleDataService.TEST_ORG_ID, orgUser.Id); + await RefreshDataAsync(); + + // Assert + Assert.Equal(1, removed); + Assert.Null(await _savedViewRepository.GetByIdAsync(privateView.Id)); + Assert.NotNull(await _savedViewRepository.GetByIdAsync(orgWide.Id)); + } + + private async Task CreateSavedViewAsync(string name, string filter, string view, bool isPrivate = false, bool isDefault = false) + { + var newView = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = name, + Filter = filter, + View = view, + IsDefault = isDefault + }; + + var result = await SendRequestAsAsync(r => + { + r.Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newView) + .StatusCodeShouldBeCreated(); + + if (isPrivate) + { + r.QueryString("is_private", "true"); + } + }); + + await RefreshDataAsync(); + return result; + } + + // IsDefault tests + + [Fact] + public async Task PostAsync_WithIsDefault_SetsIsDefaultOnSavedView() + { + // Arrange & Act + var created = await CreateSavedViewAsync("Default Events", "status:open", "events", isDefault: true); + + // Assert + Assert.NotNull(created); + Assert.True(created.IsDefault); + } + + [Fact] + public async Task PostAsync_WithoutIsDefault_DefaultsToFalse() + { + // Arrange & Act + var created = await CreateSavedViewAsync("Not Default", "status:open", "events"); + + // Assert + Assert.NotNull(created); + Assert.False(created.IsDefault); + } + + [Fact] + public async Task PostAsync_NewDefault_ClearsPreviousDefault() + { + // Arrange + var first = await CreateSavedViewAsync("First Default", "status:open", "events", isDefault: true); + Assert.NotNull(first); + Assert.True(first.IsDefault); + + // Act - create another default for same view + var second = await CreateSavedViewAsync("Second Default", "status:regressed", "events", isDefault: true); + Assert.NotNull(second); + Assert.True(second.IsDefault); + + // Assert - first should no longer be default + var firstReloaded = await _savedViewRepository.GetByIdAsync(first.Id); + Assert.NotNull(firstReloaded); + Assert.False(firstReloaded.IsDefault); + } + + [Fact] + public async Task PostAsync_NewDefaultForDifferentView_DoesNotClearOtherViewDefault() + { + // Arrange + var eventsDefault = await CreateSavedViewAsync("Events Default", "status:open", "events", isDefault: true); + Assert.NotNull(eventsDefault); + + // Act - create default for issues view + var issuesDefault = await CreateSavedViewAsync("Issues Default", "status:regressed", "issues", isDefault: true); + Assert.NotNull(issuesDefault); + + // Assert - events default should be unaffected + var eventsReloaded = await _savedViewRepository.GetByIdAsync(eventsDefault.Id); + Assert.NotNull(eventsReloaded); + Assert.True(eventsReloaded.IsDefault); + } + + [Fact] + public async Task PatchAsync_SetIsDefault_ClearsPreviousDefault() + { + // Arrange + var first = await CreateSavedViewAsync("First", "status:open", "events", isDefault: true); + var second = await CreateSavedViewAsync("Second", "status:regressed", "events"); + Assert.NotNull(first); + Assert.NotNull(second); + + // Act - set second as default via PATCH + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", second.Id) + .Content(new UpdateSavedView { IsDefault = true }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.True(updated.IsDefault); + + var firstReloaded = await _savedViewRepository.GetByIdAsync(first.Id); + Assert.NotNull(firstReloaded); + Assert.False(firstReloaded.IsDefault); + } + + [Fact] + public async Task PatchAsync_UnsetIsDefault_RemovesDefault() + { + // Arrange + var created = await CreateSavedViewAsync("Default View", "status:open", "events", isDefault: true); + Assert.NotNull(created); + Assert.True(created.IsDefault); + + // Act + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { IsDefault = false }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.False(updated.IsDefault); + } + + // CanUpdateAsync permission tests + + [Fact] + public async Task PatchAsync_OtherUsersPrivateView_ReturnsNotFound() + { + // Arrange - Global admin creates private view + var privateView = await CreateSavedViewAsync("Admin Private", "status:open", "events", isPrivate: true); + Assert.NotNull(privateView); + + // Act - Organization user tries to update it + await SendRequestAsync(r => r + .Patch() + .AsTestOrganizationUser() + .AppendPaths("saved-views", privateView.Id) + .Content(new UpdateSavedView { Name = "Hacked" }) + .StatusCodeShouldBeNotFound() + ); + + // Assert - name unchanged + var unchanged = await _savedViewRepository.GetByIdAsync(privateView.Id); + Assert.NotNull(unchanged); + Assert.Equal("Admin Private", unchanged.Name); + } + + [Fact] + public async Task PatchAsync_UpdateFilterDefinitions_PersistsJsonBlob() + { + // Arrange + var created = await CreateSavedViewAsync("FilterDef Test", "status:open", "events"); + Assert.NotNull(created); + + // Act + const string filterDefs = """[{"type":"keyword","value":"status:open"},{"type":"boolean","term":"is_first_occurrence","value":true}]"""; + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { FilterDefinitions = filterDefs }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.Equal(filterDefs, updated.FilterDefinitions); + } + + [Fact] + public Task GetByOrganizationAsync_WithUnauthorizedOrg_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", "000000000000000000000000", "saved-views") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task DeleteAsync_NonExistentView_ReturnsNotFound() + { + return SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("saved-views", "000000000000000000000000") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task PostAsync_IsDefaultResponse_IncludesIsDefaultField() + { + // Arrange & Act + var created = await CreateSavedViewAsync("Check Response", "status:open", "events", isDefault: true); + Assert.NotNull(created); + + // Act - fetch back via GET + var fetched = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(fetched); + Assert.True(fetched.IsDefault); + } + + // Security tests + + [Fact] + public Task PostAsync_WithXssInName_StoresLiterally() + { + // XSS in the name should be stored as-is; escaping is the frontend's job + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "", + Filter = "status:open", + View = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + } + + [Fact] + public Task PostAsync_FilterExceedsMaxLength_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Long Filter", + Filter = new string('x', 2001), + View = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_FilterDefinitionsExceedsMaxLength_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Long FilterDefs", + Filter = "status:open", + View = "events", + FilterDefinitions = new string('x', 10001) + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_TimeExceedsMaxLength_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Long Time", + Filter = "status:open", + View = "events", + Time = new string('t', 101) + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_NameExceedsMaxLength_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = new string('n', 101), + Filter = "status:open", + View = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task GetByViewAsync_WithInvalidView_ReturnsNotFound() + { + return SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "dashboard") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task GetByOrganizationAsync_CrossOrganization_ReturnsNotFound() + { + // Arrange - Create a filter in TEST_ORG + await CreateSavedViewAsync("Cross Organization Test", "status:open", "events"); + + // Act - Try to list from FREE_ORG (wrong org for this user) + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "saved-views") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task GetAsync_AnonymousUser_ReturnsUnauthorized() + { + return SendRequestAsync(r => r + .AsAnonymousUser() + .AppendPaths("saved-views", "000000000000000000000000") + .StatusCodeShouldBeUnauthorized() + ); + } + + [Fact] + public Task PatchAsync_AnonymousUser_ReturnsUnauthorized() + { + return SendRequestAsync(r => r + .Patch() + .AsAnonymousUser() + .AppendPaths("saved-views", "000000000000000000000000") + .Content(new UpdateSavedView { Name = "Hacked" }) + .StatusCodeShouldBeUnauthorized() + ); + } + + [Fact] + public Task DeleteAsync_AnonymousUser_ReturnsUnauthorized() + { + return SendRequestAsync(r => r + .Delete() + .AsAnonymousUser() + .AppendPaths("saved-views", "000000000000000000000000") + .StatusCodeShouldBeUnauthorized() + ); + } + + [Fact] + public Task PostAsync_FilterAtMaxLength_Succeeds() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Max Length Filter", + Filter = new string('x', 2000), + View = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + } + + [Fact] + public Task PostAsync_InvalidJsonFilterDefinitions_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Bad JSON", + Filter = "status:open", + View = "events", + FilterDefinitions = "not valid json" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_JsonObjectFilterDefinitions_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "JSON Object", + Filter = "status:open", + View = "events", + FilterDefinitions = """{"type":"keyword","value":"test"}""" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_ValidJsonArrayFilterDefinitions_Succeeds() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Valid JSON Array", + Filter = "status:open", + View = "events", + FilterDefinitions = """[{"type":"keyword","value":"test"}]""" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + } + + [Fact] + public Task PostAsync_EmptyArrayFilterDefinitions_Succeeds() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Empty Array", + Filter = "status:open", + View = "events", + FilterDefinitions = "[]" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + } + + // Private views cannot be default tests + + [Fact] + public Task PostAsync_PrivateAndDefault_ReturnsUnprocessableEntity() + { + // Arrange + var newView = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Private Default", + Filter = "status:open", + View = "events", + IsDefault = true + }; + + // Act & Assert - private + default should be rejected with 422 + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .QueryString("is_private", "true") + .Content(newView) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task PostAsync_PrivateWithoutDefault_Succeeds() + { + // Arrange & Act + var created = await CreateSavedViewAsync("Private Non-Default", "status:open", "events", isPrivate: true); + + // Assert + Assert.NotNull(created); + Assert.False(created.IsDefault); + Assert.NotNull(created.UserId); + } + + [Fact] + public async Task PatchAsync_PrivateViewSetDefault_ReturnsUnprocessableEntity() + { + // Arrange - create a private view + var privateView = await CreateSavedViewAsync("Private View", "status:open", "events", isPrivate: true); + Assert.NotNull(privateView); + Assert.NotNull(privateView.UserId); + + // Act & Assert - trying to set a private view as default should fail with 422 + await SendRequestAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", privateView.Id) + .Content(new UpdateSavedView { IsDefault = true }) + .StatusCodeShouldBeUnprocessableEntity() + ); + + // Verify it's still not default + var reloaded = await _savedViewRepository.GetByIdAsync(privateView.Id); + Assert.NotNull(reloaded); + Assert.False(reloaded.IsDefault); + } + + [Fact] + public async Task PostAsync_OrganizationWideDefault_DoesNotAffectPrivateViews() + { + // Arrange - create a private view (not default) + var privateView = await CreateSavedViewAsync("My Private", "status:open", "events", isPrivate: true); + Assert.NotNull(privateView); + + // Act - create an organization-wide default + var organizationDefault = await CreateSavedViewAsync("Organization Default", "status:regressed", "events", isDefault: true); + Assert.NotNull(organizationDefault); + Assert.True(organizationDefault.IsDefault); + + // Assert - private view should be unaffected + var privateReloaded = await _savedViewRepository.GetByIdAsync(privateView.Id); + Assert.NotNull(privateReloaded); + Assert.False(privateReloaded.IsDefault); + } + + [Fact] + public async Task PostAsync_DefaultForDifferentViews_AreIndependent() + { + // Arrange & Act - create defaults for different views + var eventsDefault = await CreateSavedViewAsync("Events Default", "status:open", "events", isDefault: true); + var issuesDefault = await CreateSavedViewAsync("Issues Default", "status:regressed", "issues", isDefault: true); + var streamDefault = await CreateSavedViewAsync("Stream Default", "type:error", "stream", isDefault: true); + + // Assert - all should be independently default + Assert.NotNull(eventsDefault); + Assert.NotNull(issuesDefault); + Assert.NotNull(streamDefault); + Assert.True(eventsDefault.IsDefault); + Assert.True(issuesDefault.IsDefault); + Assert.True(streamDefault.IsDefault); + + // Verify by reloading + var eventsReloaded = await _savedViewRepository.GetByIdAsync(eventsDefault.Id); + var issuesReloaded = await _savedViewRepository.GetByIdAsync(issuesDefault.Id); + var streamReloaded = await _savedViewRepository.GetByIdAsync(streamDefault.Id); + Assert.True(eventsReloaded!.IsDefault); + Assert.True(issuesReloaded!.IsDefault); + Assert.True(streamReloaded!.IsDefault); + } + + [Fact] + public async Task PatchAsync_UnsetDefault_OnlyAffectsTargetView() + { + // Arrange - set defaults for two views + var eventsDefault = await CreateSavedViewAsync("Events Default", "status:open", "events", isDefault: true); + var issuesDefault = await CreateSavedViewAsync("Issues Default", "status:regressed", "issues", isDefault: true); + Assert.NotNull(eventsDefault); + Assert.NotNull(issuesDefault); + + // Act - unset events default + await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", eventsDefault.Id) + .Content(new UpdateSavedView { IsDefault = false }) + .StatusCodeShouldBeOk() + ); + + // Assert - issues default should be unaffected + var eventsReloaded = await _savedViewRepository.GetByIdAsync(eventsDefault.Id); + var issuesReloaded = await _savedViewRepository.GetByIdAsync(issuesDefault.Id); + Assert.False(eventsReloaded!.IsDefault); + Assert.True(issuesReloaded!.IsDefault); + } + + [Fact] + public Task PostAsync_InvalidColumnKey_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 Columns", + View = "events", + Columns = new Dictionary { ["status"] = true } + }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task PatchAsync_InvalidColumnKey_ReturnsUnprocessableEntity() + { + // Arrange + var created = await CreateSavedViewAsync("Patch Column Test", "status:open", "events"); + Assert.NotNull(created); + + // Act & Assert + await SendRequestAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView + { + Columns = new Dictionary { ["INVALID_COLUMN"] = true } + }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_ValidColumnKeys_Succeeds() + { + // 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 = "Valid Columns", + View = "events", + Columns = new Dictionary { ["user"] = true, ["date"] = false } + }) + .StatusCodeShouldBeCreated() + ); + } + +} diff --git a/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs b/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs new file mode 100644 index 0000000000..e66fcadc2b --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs @@ -0,0 +1,198 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class SavedViewMapperTests +{ + private readonly SavedViewMapper _mapper; + + public SavedViewMapperTests() + { + _mapper = new SavedViewMapper(); + } + + [Fact] + public void MapToSavedView_WithValidNewSavedView_MapsAllProperties() + { + // Arrange + var source = new NewSavedView + { + OrganizationId = "537650f3b77efe23a47914f3", + Name = "Open Issues", + Filter = "(status:open OR status:regressed)", + Time = "[now-7d TO now]", + View = "issues", + FilterDefinitions = "[{\"type\":\"status\",\"values\":[\"open\",\"regressed\"]}]", + Columns = new Dictionary { ["status"] = true, ["users"] = false }, + IsDefault = true + }; + + // Act + var result = _mapper.MapToSavedView(source); + + // Assert + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Equal("Open Issues", result.Name); + Assert.Equal("(status:open OR status:regressed)", result.Filter); + Assert.Equal("[now-7d TO now]", result.Time); + Assert.Equal("issues", result.View); + Assert.Equal("[{\"type\":\"status\",\"values\":[\"open\",\"regressed\"]}]", result.FilterDefinitions); + Assert.NotNull(result.Columns); + Assert.True(result.Columns["status"]); + Assert.False(result.Columns["users"]); + Assert.True(result.IsDefault); + } + + [Fact] + public void MapToSavedView_IgnoredFields_AreNotMapped() + { + // Arrange + var source = new NewSavedView + { + OrganizationId = "537650f3b77efe23a47914f3", + Name = "Test View", + View = "events" + }; + + // Act + var result = _mapper.MapToSavedView(source); + + // Assert - Version, CreatedByUserId, UpdatedByUserId, UserId are ignored by mapper + // Version keeps its C# record initializer default of 1 + Assert.Equal(1, result.Version); + Assert.Null(result.CreatedByUserId); + Assert.Null(result.UpdatedByUserId); + Assert.Null(result.UserId); + } + + [Fact] + public void MapToSavedView_WithNullOptionalFields_MapsNulls() + { + // Arrange + var source = new NewSavedView + { + OrganizationId = "537650f3b77efe23a47914f3", + Name = "Minimal View", + View = "stream" + }; + + // Act + var result = _mapper.MapToSavedView(source); + + // Assert + Assert.Null(result.Filter); + Assert.Null(result.Time); + Assert.Null(result.FilterDefinitions); + Assert.Null(result.Columns); + Assert.False(result.IsDefault); + } + + [Fact] + public void MapToViewSavedView_WithValidSavedView_MapsAllProperties() + { + // Arrange + var now = DateTime.UtcNow; + var source = new SavedView + { + Id = "88cd0826e447a44e78877ab1", + OrganizationId = "537650f3b77efe23a47914f3", + UserId = "1ecd0826e447ad1e78822555", + CreatedByUserId = "1ecd0826e447ad1e78822555", + UpdatedByUserId = "1ecd0826e447ad1e78822666", + Filter = "status:open", + FilterDefinitions = "[{\"type\":\"status\",\"values\":[\"open\"]}]", + Columns = new Dictionary { ["status"] = true }, + IsDefault = false, + Name = "My View", + Time = "[now-30d TO now]", + Version = 1, + View = "issues", + CreatedUtc = now.AddDays(-1), + UpdatedUtc = now + }; + + // Act + var result = _mapper.MapToViewSavedView(source); + + // Assert + Assert.Equal("88cd0826e447a44e78877ab1", result.Id); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Equal("1ecd0826e447ad1e78822555", result.UserId); + Assert.Equal("1ecd0826e447ad1e78822555", result.CreatedByUserId); + Assert.Equal("1ecd0826e447ad1e78822666", result.UpdatedByUserId); + Assert.Equal("status:open", result.Filter); + Assert.Equal("[{\"type\":\"status\",\"values\":[\"open\"]}]", result.FilterDefinitions); + Assert.NotNull(result.Columns); + Assert.True(result.Columns["status"]); + Assert.False(result.IsDefault); + Assert.Equal("My View", result.Name); + Assert.Equal("[now-30d TO now]", result.Time); + Assert.Equal(1, result.Version); + Assert.Equal("issues", result.View); + Assert.Equal(now.AddDays(-1), result.CreatedUtc); + Assert.Equal(now, result.UpdatedUtc); + } + + [Fact] + public void MapToViewSavedView_WithNullOptionalFields_MapsNulls() + { + // Arrange + var source = new SavedView + { + Id = "88cd0826e447a44e78877ab1", + OrganizationId = "537650f3b77efe23a47914f3", + CreatedByUserId = "1ecd0826e447ad1e78822555", + Name = "Organization Wide View", + View = "events", + Version = 1 + }; + + // Act + var result = _mapper.MapToViewSavedView(source); + + // Assert + Assert.Null(result.UserId); + Assert.Null(result.UpdatedByUserId); + Assert.Null(result.Filter); + Assert.Null(result.FilterDefinitions); + Assert.Null(result.Columns); + Assert.Null(result.Time); + } + + [Fact] + public void MapToViewSavedViews_WithMultipleSavedViews_MapsAll() + { + // Arrange + var views = new List + { + new() { Id = "88cd0826e447a44e78877ab1", OrganizationId = "537650f3b77efe23a47914f3", CreatedByUserId = "1ecd0826e447ad1e78822555", Name = "View 1", View = "events" }, + new() { Id = "88cd0826e447a44e78877ab2", OrganizationId = "537650f3b77efe23a47914f3", CreatedByUserId = "1ecd0826e447ad1e78822555", Name = "View 2", View = "issues" }, + new() { Id = "88cd0826e447a44e78877ab3", OrganizationId = "537650f3b77efe23a47914f3", CreatedByUserId = "1ecd0826e447ad1e78822555", Name = "View 3", View = "stream" } + }; + + // Act + var result = _mapper.MapToViewSavedViews(views); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("88cd0826e447a44e78877ab1", result[0].Id); + Assert.Equal("88cd0826e447a44e78877ab2", result[1].Id); + Assert.Equal("88cd0826e447a44e78877ab3", result[2].Id); + } + + [Fact] + public void MapToViewSavedViews_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var views = new List(); + + // Act + var result = _mapper.MapToViewSavedViews(views); + + // Assert + Assert.Empty(result); + } +} diff --git a/tests/http/saved-views.http b/tests/http/saved-views.http new file mode 100644 index 0000000000..553b3a3e56 --- /dev/null +++ b/tests/http/saved-views.http @@ -0,0 +1,93 @@ +@apiUrl = http://localhost:5200/api/v2 +@email = test@localhost +@password = tester +@organizationId = 537650f3b77efe23a47914f3 +@feature = feature-saved-views + +### login to test account +# @name login +POST {{apiUrl}}/auth/login +Content-Type: application/json + +{ + "email": "{{email}}", + "password": "{{password}}" +} + +### + +@token = {{login.response.body.$.token}} + +### Enable saved views feature flag +POST {{apiUrl}}/organizations/{{organizationId}}/features/{{feature}} +Authorization: Bearer {{token}} + +### Get saved views by organization +GET {{apiUrl}}/organizations/{{organizationId}}/saved-views +Authorization: Bearer {{token}} + +### Get saved views by view +GET {{apiUrl}}/organizations/{{organizationId}}/saved-views/events +Authorization: Bearer {{token}} + +### Create organization-wide saved view +# @name newSavedView +POST {{apiUrl}}/organizations/{{organizationId}}/saved-views +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "organization_id": "{{organizationId}}", + "name": "Open Events", + "filter": "status:open", + "time": "[now-7d TO now]", + "view": "events", + "columns": { + "user": true, + "date": true + }, + "is_default": false +} + +### + +@savedViewId = {{newSavedView.response.body.$.id}} + +### Create private saved view +POST {{apiUrl}}/organizations/{{organizationId}}/saved-views?is_private=true +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "organization_id": "{{organizationId}}", + "name": "My Private View", + "filter": "type:error", + "view": "stream", + "columns": { + "user": true, + "date": true + }, + "is_default": false +} + +### Get saved view by id +GET {{apiUrl}}/saved-views/{{savedViewId}} +Authorization: Bearer {{token}} + +### Update saved view +PATCH {{apiUrl}}/saved-views/{{savedViewId}} +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "name": "Open Events (Last 30d)", + "time": "[now-30d TO now]" +} + +### Delete saved view +DELETE {{apiUrl}}/saved-views/{{savedViewId}} +Authorization: Bearer {{token}} + +### Disable saved views feature flag +DELETE {{apiUrl}}/organizations/{{organizationId}}/features/{{feature}} +Authorization: Bearer {{token}} From 5fad974d14684162d748afd214eac085ceb5f3d9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 28 Mar 2026 19:13:49 -0500 Subject: [PATCH 2/4] Address PR #2160 review feedback Fixes for code quality and correctness issues identified in saved-views PR review: - OrganizationController: Add post-trim validation to prevent empty feature flags from whitespace-only input (closes security gap in minlength route constraint) - DeltaSchemaTransformer: Fix Dictionary schema generation to emit object with additionalProperties instead of array (IDictionary detection must run before IEnumerable check) - helpers.svelte.ts: Replace vague `as []` casts with concrete types (LogLevel[], StackStatus[], PersistentEventKnownTypes[]) for type safety - +layout.svelte: Resolve duplicate currentOrganization declaration by renaming Intercom-specific variable - OrganizationControllerTests: Add whitespace-only feature name test coverage - openapi.json: Regenerate snapshot to reflect Dictionary schema fix --- .../components/filters/helpers.svelte.ts | 11 +++++---- .../ClientApp/src/routes/(app)/+layout.svelte | 4 ++-- .../Controllers/OrganizationController.cs | 12 ++++++++-- .../Utility/OpenApi/DeltaSchemaTransformer.cs | 11 ++++++++- .../Controllers/Data/openapi.json | 8 ++++++- .../OrganizationControllerTests.cs | 24 +++++++++++++++++++ 6 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts index b3677c3e40..d8f73deaa7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts @@ -1,4 +1,7 @@ import type { IFilter } from '$comp/faceted-filter'; +import type { PersistentEventKnownTypes } from '$features/events/models'; +import type { LogLevel } from '$features/events/models/event-data'; +import type { StackStatus } from '$features/stacks/models'; import { organization } from '$features/organizations/context.svelte'; import { SvelteMap } from 'svelte/reactivity'; @@ -257,7 +260,7 @@ function reconstructFilter(data: SerializedFilter): IFilter | null { case 'keyword': return new KeywordFilter(data.value as string | undefined); case 'level': - return new LevelFilter(data.value as [] | undefined); + return new LevelFilter(data.value as LogLevel[] | undefined); case 'number': return new NumberFilter(data.term, data.value as number | undefined); case 'project': @@ -267,13 +270,13 @@ function reconstructFilter(data: SerializedFilter): IFilter | null { case 'session': return new SessionFilter(data.value as string | undefined); case 'status': - return new StatusFilter(data.value as [] | undefined); + return new StatusFilter(data.value as StackStatus[] | undefined); case 'string': return new StringFilter(data.term, data.value as string | undefined); case 'tag': - return new TagFilter(data.value as [] | undefined); + return new TagFilter(data.value as PersistentEventKnownTypes[] | undefined); case 'type': - return new TypeFilter(data.value as [] | undefined); + return new TypeFilter(data.value as PersistentEventKnownTypes[] | undefined); case 'version': return new VersionFilter(data.term, data.value as string | undefined); default: diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 339cf8bf34..bf4e31c661 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -204,7 +204,7 @@ } } }); - const currentOrganization = $derived(shouldFetchIntercomOrganization ? currentOrganizationQuery.data : undefined); + const intercomOrganization = $derived(shouldFetchIntercomOrganization ? currentOrganizationQuery.data : undefined); // Simple organization selection - pick first available if none selected $effect(() => { @@ -323,7 +323,7 @@ // Intercom configuration const intercomToken = $derived(intercomAppId ? intercomTokenQuery.data?.token : undefined); - const intercomBootOptions = $derived(buildIntercomBootOptions(meQuery.data, currentOrganization, intercomToken)); + const intercomBootOptions = $derived(buildIntercomBootOptions(meQuery.data, intercomOrganization, intercomToken)); let intercomUnreadCount = $state(0); const isChatEnabled = $derived(!!intercomAppId && !!intercomBootOptions); diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index cc424c0ea3..f7d37f4b4f 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -714,7 +714,11 @@ public async Task SetFeatureAsync(string id, string feature) if (organization is null) return NotFound(); - organization.Features.Add(feature.Trim().ToLowerInvariant()); + var normalizedFeature = feature.Trim().ToLowerInvariant(); + if (String.IsNullOrEmpty(normalizedFeature)) + return BadRequest("Invalid feature flag."); + + organization.Features.Add(normalizedFeature); await _repository.SaveAsync(organization, o => o.Cache()); return Ok(); @@ -737,7 +741,11 @@ public async Task RemoveFeatureAsync(string id, string feature) if (organization is null) return NotFound(); - if (organization.Features.Remove(feature.Trim().ToLowerInvariant())) + var normalizedFeature = feature.Trim().ToLowerInvariant(); + if (String.IsNullOrEmpty(normalizedFeature)) + return BadRequest("Invalid feature flag."); + + if (organization.Features.Remove(normalizedFeature)) await _repository.SaveAsync(organization, o => o.Cache()); return Ok(); diff --git a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs index adfe5250b3..dc2c714b4a 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs @@ -124,9 +124,18 @@ private static OpenApiSchema CreateSchemaForType(Type type, bool isNullable) { schemaType |= JsonSchemaType.String; } + else if (type.IsGenericType && type.GetInterfaces().Concat([type]).Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>))) + { + schemaType |= JsonSchemaType.Object; + var valueType = type.GetGenericArguments().ElementAtOrDefault(1); + if (valueType is not null) + { + schema.AdditionalProperties = CreateSchemaForType(valueType, false); + } + } else if (type.IsArray || (type.IsGenericType && typeof(System.Collections.IEnumerable).IsAssignableFrom(type))) { - schemaType = JsonSchemaType.Array; + schemaType |= JsonSchemaType.Array; } else { diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 2e4c3a7c62..bbbe47b0fd 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -8385,7 +8385,13 @@ ] }, "columns": { - "type": "array" + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "boolean" + } }, "is_default": { "type": [ diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 82d12d3d1d..bb3941f07f 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -338,6 +338,30 @@ await SendRequestAsync(r => r Assert.Contains("feature-saved-views", viewOrg.Features); } + [Fact] + public Task SetFeatureAsync_WhitespaceOnly_ReturnsBadRequest() + { + // Act & Assert + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", " ") + .StatusCodeShouldBeBadRequest() + ); + } + + [Fact] + public Task RemoveFeatureAsync_WhitespaceOnly_ReturnsBadRequest() + { + // Act & Assert + return SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", " ") + .StatusCodeShouldBeBadRequest() + ); + } + [Fact] public async Task DeleteAsync_ExistingOrganization_RemovesOrganization() { From 22d21bebdfdf688d77d5471b090c4f0e68e922dc Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 28 Mar 2026 19:13:49 -0500 Subject: [PATCH 3/4] Address PR #2160 review feedback Fixes for code quality and correctness issues identified in saved-views PR review: - OrganizationController: Add post-trim validation to prevent empty feature flags from whitespace-only input (closes security gap in minlength route constraint) - DeltaSchemaTransformer: Fix Dictionary schema generation to emit object with additionalProperties instead of array (IDictionary detection must run before IEnumerable check) - helpers.svelte.ts: Replace vague `as []` casts with concrete types (LogLevel[], StackStatus[], PersistentEventKnownTypes[]) for type safety - +layout.svelte: Resolve duplicate currentOrganization declaration by renaming Intercom-specific variable - OrganizationControllerTests: Add whitespace-only feature name test coverage - openapi.json: Regenerate snapshot to reflect Dictionary schema fix --- .../saved-views/components/saved-view-picker.svelte | 10 +++++----- .../lib/features/saved-views/use-saved-views.svelte.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) 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 fba025766e..7eb0673449 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 @@ -216,9 +216,9 @@ const filterDefinitions = serializeFilters(filters); const body: UpdateSavedView = { columns: columnVisibility, - filter: currentFilterString || undefined, + filter: currentFilterString || null, filter_definitions: filterDefinitions, - time: time || undefined + time: time || null }; const response = await client.patchJSON(`saved-views/${activeSavedView.id}`, body, { expectedStatusCodes: [422] }); if (response.ok) { @@ -233,9 +233,9 @@ ? { ...v, columns: body.columns ?? v.columns, - filter: body.filter ?? v.filter, - filter_definitions: body.filter_definitions ?? v.filter_definitions, - time: body.time + filter: body.filter !== undefined ? body.filter : v.filter, + filter_definitions: body.filter_definitions !== undefined ? body.filter_definitions : v.filter_definitions, + time: body.time !== undefined ? body.time : v.time } : v ) ?? [] 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 e8739567b0..c405fae1ea 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 @@ -177,7 +177,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur if ((options.queryParams.filter ?? null) !== (view.filter ?? null)) { return true; } - if (view.time && (options.queryParams.time ?? '') !== view.time) { + if ((options.queryParams.time ?? null) !== (view.time ?? null)) { return true; } if (options.getColumnVisibility && !columnsEqual(options.getColumnVisibility(), view.columns)) { From 12760653e15394413282d301877b85f61bd3dad4 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 29 Mar 2026 21:45:03 -0500 Subject: [PATCH 4/4] Fix saved-view cache lag by synchronizing both caches on create/save Sidebar and picker now update immediately when creating or saving views by writing to both queryKeys.view and queryKeys.organization caches optimistically. Delayed websocket invalidation still provides ES-refresh-safe reconciliation. --- AGENTS.md | 4 + .../lib/features/saved-views/api.svelte.ts | 44 ++- .../components/saved-view-picker.svelte | 90 ++--- .../saved-views/use-saved-views.svelte.ts | 23 +- .../saved-views/use-saved-views.test.ts | 319 ++++++++++++++++++ 5 files changed, 407 insertions(+), 73 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts diff --git a/AGENTS.md b/AGENTS.md index 30d6e030e3..037d5c5e33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,3 +82,7 @@ pr-reviewer → security pre-screen (before build!) → dependency audit - Never commit secrets — use environment variables - NuGet feeds are in `NuGet.Config` — don't add sources - Prefer additive documentation updates — don't replace strategic docs wholesale, extend them + +## Frontend Notes + +- Saved-view optimistic writes must update both `queryKeys.view(organizationId, view)` and `queryKeys.organization(organizationId)` caches immediately. `invalidateSavedViewQueries` delays `SavedViewChanged` `Added` and `Saved` WebSocket invalidations for Elasticsearch refresh safety, and the picker still uses local 1.5s invalidation timers for rename/default/delete flows. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts index 859f82d602..1f7e0dcc50 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts @@ -5,13 +5,13 @@ import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanst import type { NewSavedView, SavedView, UpdateSavedView } from './models'; -// When a new saved view is added, Elasticsearch needs ~1s to index it. -// Without a delay, the background refetch triggered by this invalidation returns -// stale data that omits the new view, causing the URL param to be cleared. +// Elasticsearch needs ~1s to reflect saved-view writes. +// Delay Added and Saved invalidations so the background refetch does not +// overwrite optimistic cache updates with stale data while indexing catches up. export async function invalidateSavedViewQueries(queryClient: QueryClient, message: WebSocketMessageValue<'SavedViewChanged'>) { const { change_type, organization_id } = message; - if (change_type === ChangeType.Added) { + if (change_type === ChangeType.Added || change_type === ChangeType.Saved) { await new Promise((resolve) => setTimeout(resolve, 1500)); } @@ -88,8 +88,7 @@ export function patchSavedView(request: { route: { id: string | undefined } }) { return response.data!; }, onSuccess: (savedView: SavedView) => { - queryClient.invalidateQueries({ queryKey: queryKeys.organization(savedView.organization_id) }); - queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id) }); + syncSavedViewCaches(queryClient, savedView); } })); } @@ -109,12 +108,33 @@ export function postSavedView(request: { route: { organizationId: string | undef return response.data!; }, onSuccess: (savedView: SavedView) => { - // Optimistically populate the per-view cache so the new view is immediately - // available when handleSelect fires, before the background invalidation completes. - queryClient.setQueryData(queryKeys.view(request.route.organizationId, savedView.view), (old: SavedView[] | undefined) => - old ? [...old, savedView] : [savedView] - ); - queryClient.invalidateQueries({ queryKey: queryKeys.organization(request.route.organizationId) }); + syncSavedViewCaches(queryClient, savedView); } })); } + +export function syncSavedViewCaches(queryClient: QueryClient, savedView: SavedView, organizationId: string | undefined = savedView.organization_id) { + queryClient.setQueryData(queryKeys.view(organizationId, savedView.view), (cachedViews: SavedView[] | undefined) => + upsertSavedViewCache(cachedViews, savedView) + ); + queryClient.setQueryData(queryKeys.organization(organizationId), (cachedViews: SavedView[] | undefined) => upsertSavedViewCache(cachedViews, savedView)); +} + +export function upsertSavedViewCache(cachedViews: SavedView[] | undefined, savedView: SavedView): SavedView[] { + const views = savedView.is_default + ? (cachedViews ?? []).map((view) => { + if (view.id === savedView.id || view.view !== savedView.view || !view.is_default) { + return view; + } + + return { ...view, is_default: false }; + }) + : (cachedViews ?? []); + const savedViewIndex = views.findIndex((view) => view.id === savedView.id); + + if (savedViewIndex === -1) { + return [...views, savedView]; + } + + return views.map((view) => (view.id === savedView.id ? savedView : view)); +} 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 7eb0673449..4c883b7fac 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 @@ -30,7 +30,7 @@ import type { NewSavedView, SavedView, UpdateSavedView } from '../models'; - import { queryKeys } from '../api.svelte'; + import { queryKeys, syncSavedViewCaches } from '../api.svelte'; // Map date-math time values to friendly labels const timeLabels = new SvelteMap(); @@ -184,14 +184,9 @@ const response = await client.postJSON(url, body, { expectedStatusCodes: [422] }); if (response.ok && response.data) { const savedView = response.data; - // Optimistically add to per-view cache so activeSavedView resolves - // immediately when onLoadView fires, preventing matchingSavedView - // from incorrectly auto-matching an existing view with the same filter. - // We do NOT fire background invalidation here — the Elasticsearch index has - // a refresh delay (~1s), so a refetch would return stale data that omits the - // new view, which would trigger the "not found" effect and clear the URL param. - // The cache stays current via WebSocket events or the user's next navigation. - queryClient.setQueryData(queryKeys.view(organizationId, view), (old: SavedView[] | undefined) => (old ? [...old, savedView] : [savedView])); + // Keep both caches aligned immediately so the picker and sidebar stay in sync + // while the delayed WebSocket invalidation waits for Elasticsearch to refresh. + syncSavedViewCaches(queryClient, savedView, organizationId); saveDialogOpen = false; onLoadView(savedView.id); toast.success(`Saved view "${savedView.name}" created.`); @@ -220,26 +215,11 @@ filter_definitions: filterDefinitions, time: time || null }; - const response = await client.patchJSON(`saved-views/${activeSavedView.id}`, body, { expectedStatusCodes: [422] }); - if (response.ok) { - // Optimistically update the cache with the new filter/time values so the - // hydration effect doesn't revert the user's changes when the background - // refetch returns stale Elasticsearch data. - queryClient.setQueryData( - queryKeys.view(organizationId, view), - (old: SavedView[] | undefined) => - old?.map((v) => - v.id === activeSavedView.id - ? { - ...v, - columns: body.columns ?? v.columns, - filter: body.filter !== undefined ? body.filter : v.filter, - filter_definitions: body.filter_definitions !== undefined ? body.filter_definitions : v.filter_definitions, - time: body.time !== undefined ? body.time : v.time - } - : v - ) ?? [] - ); + const response = await client.patchJSON(`saved-views/${activeSavedView.id}`, body, { expectedStatusCodes: [422] }); + if (response.ok && response.data) { + // Keep both caches aligned immediately so the picker and sidebar stay in sync + // while the delayed WebSocket invalidation waits for Elasticsearch to refresh. + syncSavedViewCaches(queryClient, response.data, organizationId); toast.success(`View "${activeSavedView.name}" updated.`); } else { const message = response.problem?.title ?? 'Failed to update view. Please try again.'; @@ -258,13 +238,31 @@ } saving = true; + const viewId = effectiveView.id; + const viewName = effectiveView.view; + const newName = renameName.trim(); try { - const body: UpdateSavedView = { name: renameName.trim() }; - const response = await client.patchJSON(`saved-views/${effectiveView.id}`, body, { expectedStatusCodes: [422] }); + const body: UpdateSavedView = { name: newName }; + const response = await client.patchJSON(`saved-views/${viewId}`, body, { expectedStatusCodes: [422] }); if (response.ok) { renameDialogOpen = false; - void queryClient.invalidateQueries({ queryKey: queryKeys.type }); toast.success('View renamed.'); + + // Optimistically update the name in all caches immediately (ES has ~1s refresh delay) + const updateViews = (old: SavedView[] | undefined): SavedView[] | undefined => { + if (!old) { + return old; + } + return old.map((v) => (v.id === viewId ? { ...v, name: newName } : v)); + }; + + queryClient.setQueryData(queryKeys.view(organizationId, viewName), updateViews); + queryClient.setQueryData(queryKeys.organization(organizationId), updateViews); + // Delay invalidation to allow Elasticsearch (~1s refresh) to index the rename + // so the background refetch doesn't overwrite the optimistic update + setTimeout(() => { + void queryClient.invalidateQueries({ queryKey: queryKeys.type }); + }, 1500); } else { const message = response.problem?.title ?? 'Failed to rename view. Please try again.'; toast.error(message); @@ -318,33 +316,13 @@ } saving = true; - const viewId = effectiveView.id; - const viewName = effectiveView.view; try { const body: UpdateSavedView = { is_default: true }; - const response = await client.patchJSON(`saved-views/${effectiveView.id}`, body, { expectedStatusCodes: [422] }); - if (response.ok) { + const response = await client.patchJSON(`saved-views/${effectiveView.id}`, body, { expectedStatusCodes: [422] }); + if (response.ok && response.data) { toast.success('Set as default.'); - // Optimistically update the is_default flag in all caches immediately - const updateViews = (old: SavedView[] | undefined): SavedView[] | undefined => { - if (!old) { - return old; - } - return old.map((v) => { - // Clear the old org-wide default for this view - if (v.id !== viewId && v.view === viewName && !v.user_id) { - return { ...v, is_default: false }; - } - if (v.id === viewId) { - return { ...v, is_default: true }; - } - return v; - }); - }; - - queryClient.setQueryData(queryKeys.view(organizationId, viewName), updateViews); - queryClient.setQueryData(queryKeys.organization(organizationId), updateViews); + syncSavedViewCaches(queryClient, response.data, organizationId); // Delay invalidation to allow Elasticsearch (~1s refresh) to index the change // so the background refetch doesn't overwrite the optimistic update setTimeout(() => { @@ -429,7 +407,7 @@ {/snippet} - + {#if savedView.filter}

{savedView.filter}

{/if} 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 c405fae1ea..3a9cc31728 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 @@ -37,6 +37,16 @@ export interface UseSavedViewsReturn { savedViews: SavedView[]; } +export function setTimeQueryParam(queryParams: SavedViewQueryParams, value: null | string): void { + if (supportsTimeQueryParam(queryParams)) { + queryParams.time = value; + } +} + +export function supportsTimeQueryParam(queryParams: SavedViewQueryParams): queryParams is SavedViewQueryParams & { time: null | string | undefined } { + return Object.prototype.hasOwnProperty.call(queryParams, 'time'); +} + export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsReturn { const organizationQuery = getOrganizationQuery({ route: { @@ -49,6 +59,9 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur // Feature flag gate: only enable saved views if the organization has the feature const isEnabled = $derived(organizationQuery.data?.features?.includes(SAVED_VIEWS_FEATURE) ?? false); + // Some routes, such as stream, do not declare a time query parameter. + const supportsTime = supportsTimeQueryParam(options.queryParams); + const savedViewsListQuery = getSavedViewsByViewQuery({ route: { get organizationId() { @@ -108,7 +121,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur options.queryParams.saved = null; }); options.queryParams.filter = null; - options.queryParams.time = null; + setTimeQueryParam(options.queryParams, null); hasAttemptedRestore = false; return; } @@ -126,7 +139,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur } options.queryParams.filter = view.filter ?? null; - options.queryParams.time = view.time ?? null; + setTimeQueryParam(options.queryParams, view.time ?? null); if (view.columns && options.setColumnVisibility) { options.setColumnVisibility(view.columns); @@ -177,7 +190,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur if ((options.queryParams.filter ?? null) !== (view.filter ?? null)) { return true; } - if ((options.queryParams.time ?? null) !== (view.time ?? null)) { + if (supportsTime && (options.queryParams.time ?? null) !== (view.time ?? null)) { return true; } if (options.getColumnVisibility && !columnsEqual(options.getColumnVisibility(), view.columns)) { @@ -201,7 +214,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur options.updateFilterCache(options.filterCacheKey(view.filter ?? null), hydrated); } options.queryParams.filter = view.filter ?? null; - options.queryParams.time = view.time ?? null; + setTimeQueryParam(options.queryParams, view.time ?? null); if (view.columns && options.setColumnVisibility) { options.setColumnVisibility(view.columns); } @@ -210,7 +223,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur function handleClearSavedView() { options.queryParams.saved = null; options.queryParams.filter = null; - options.queryParams.time = null; + setTimeQueryParam(options.queryParams, null); if (options.setColumnVisibility) { options.setColumnVisibility({}); } 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 new file mode 100644 index 0000000000..a68dbd00cf --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts @@ -0,0 +1,319 @@ +import { ChangeType } from '$features/websockets/models'; +import { QueryClient } from '@tanstack/svelte-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SavedView } from './models'; + +import { invalidateSavedViewQueries, queryKeys, syncSavedViewCaches, upsertSavedViewCache } from './api.svelte'; +import { type SavedViewQueryParams, setTimeQueryParam, supportsTimeQueryParam } from './use-saved-views.svelte'; + +function buildSavedView({ id, name, ...overrides }: Partial & Pick): SavedView { + return { + columns: {}, + created_by_user_id: 'user-1', + created_utc: '2024-01-01T00:00:00Z', + filter: null, + filter_definitions: null, + id, + is_default: false, + name, + organization_id: 'org-123', + time: null, + updated_by_user_id: null, + updated_utc: '2024-01-01T00:00:00Z', + user_id: null, + version: 1, + view: 'issues', + ...overrides + }; +} + +describe('useSavedViews', () => { + describe('time parameter detection', () => { + it('detects when time is not in query params (stream page)', () => { + // Arrange + const queryParamsWithoutTime: SavedViewQueryParams = { + filter: null, + saved: undefined + }; + + // Act + const supportsTime = supportsTimeQueryParam(queryParamsWithoutTime); + + // Assert + expect(supportsTime).toBe(false); + }); + + it('detects when time is in query params (issues page)', () => { + // Arrange + const queryParamsWithTime: SavedViewQueryParams = { + filter: null, + saved: undefined, + time: '[now-7d TO now]' + }; + + // Act + const supportsTime = supportsTimeQueryParam(queryParamsWithTime); + + // Assert + expect(supportsTime).toBe(true); + }); + + it('treats time as supported when it exists but is undefined', () => { + // Arrange + const queryParamsTimeUndefined: SavedViewQueryParams = { + filter: null, + saved: undefined, + time: undefined + }; + + // Act + const supportsTime = supportsTimeQueryParam(queryParamsTimeUndefined); + + // Assert + expect(supportsTime).toBe(true); + }); + }); + + describe('time parameter updates', () => { + it('does not write time when the route does not support it', () => { + // Arrange + const target: SavedViewQueryParams = { + filter: null, + saved: undefined + }; + const queryParams = new Proxy(target, { + set(obj, prop, value) { + if (prop === 'time') { + throw new Error(`unexpected time assignment: ${String(value)}`); + } + + return Reflect.set(obj, prop, value); + } + }) as SavedViewQueryParams; + + // Act & Assert + expect(() => { + setTimeQueryParam(queryParams, null); + }).not.toThrow(); + expect('time' in target).toBe(false); + }); + + it('updates time when the route supports it', () => { + // Arrange + const queryParams: SavedViewQueryParams = { + filter: null, + saved: undefined, + time: undefined + }; + + // Act + setTimeQueryParam(queryParams, '[now-15m TO now]'); + + // Assert + expect(queryParams.time).toBe('[now-15m TO now]'); + }); + + it('clears time when the route supports it', () => { + // Arrange + const queryParams: SavedViewQueryParams = { + filter: null, + saved: undefined, + time: '[now-15m TO now]' + }; + + // Act + setTimeQueryParam(queryParams, null); + + // Assert + expect(queryParams.time).toBeNull(); + }); + }); + + describe('saved view websocket invalidation', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('delays Saved websocket invalidation so stale organization data does not overwrite the optimistic cache early', async () => { + // Arrange + const queryClient = new QueryClient(); + const staleSavedView = buildSavedView({ filter: 'type:error', id: 'view-1', name: 'Original View' }); + const optimisticSavedView = { + ...staleSavedView, + filter: 'type:log', + name: 'Updated View' + }; + + queryClient.setQueryData(queryKeys.organization('org-123'), [optimisticSavedView]); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(async (filters) => { + if (filters?.queryKey) { + queryClient.setQueryData(filters.queryKey, [staleSavedView]); + } + }); + + // Act + const invalidatePromise = invalidateSavedViewQueries(queryClient, { + change_type: ChangeType.Saved, + data: {}, + organization_id: 'org-123', + type: 'SavedView' + }); + + await vi.advanceTimersByTimeAsync(1499); + + // Assert + expect(invalidateSpy).not.toHaveBeenCalled(); + expect(queryClient.getQueryData(queryKeys.organization('org-123'))).toEqual([optimisticSavedView]); + + // Act + await vi.advanceTimersByTimeAsync(1); + await invalidatePromise; + + // Assert + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.organization('org-123') }); + expect(queryClient.getQueryData(queryKeys.organization('org-123'))).toEqual([staleSavedView]); + }); + }); + + describe('saved view cache helpers', () => { + it('syncs a created view into both caches immediately', () => { + // Arrange + const queryClient = new QueryClient(); + const existingView = buildSavedView({ id: 'view-1', name: 'Existing View' }); + const createdView = buildSavedView({ id: 'view-2', name: 'New View' }); + + queryClient.setQueryData(queryKeys.view('org-123', 'issues'), [existingView]); + queryClient.setQueryData(queryKeys.organization('org-123'), [existingView]); + + // Act + syncSavedViewCaches(queryClient, createdView); + + // Assert + expect(queryClient.getQueryData(queryKeys.view('org-123', 'issues'))).toEqual([existingView, createdView]); + expect(queryClient.getQueryData(queryKeys.organization('org-123'))).toEqual([existingView, createdView]); + }); + + it('syncs an updated view into both caches immediately', () => { + // Arrange + const queryClient = new QueryClient(); + const existingView = buildSavedView({ filter: 'type:error', id: 'view-1', name: 'Existing View' }); + const otherView = buildSavedView({ id: 'view-2', name: 'Other View' }); + const updatedView = { + ...existingView, + filter: 'type:log', + time: '[now-15m TO now]' + }; + + queryClient.setQueryData(queryKeys.view('org-123', 'issues'), [existingView, otherView]); + queryClient.setQueryData(queryKeys.organization('org-123'), [existingView, otherView]); + + // Act + syncSavedViewCaches(queryClient, updatedView); + + // Assert + expect(queryClient.getQueryData(queryKeys.view('org-123', 'issues'))).toEqual([updatedView, otherView]); + expect(queryClient.getQueryData(queryKeys.organization('org-123'))).toEqual([updatedView, otherView]); + }); + + it('keeps only one default per saved-view type in the cached list', () => { + // Arrange + const currentDefault = buildSavedView({ id: 'view-1', is_default: true, name: 'Current Default' }); + const otherIssuesView = buildSavedView({ id: 'view-2', name: 'Other Issues View' }); + const streamDefault = buildSavedView({ id: 'view-3', is_default: true, name: 'Stream Default', view: 'stream' }); + const newDefault = buildSavedView({ id: 'view-4', is_default: true, name: 'New Default' }); + + // Act + const updatedViews = upsertSavedViewCache([currentDefault, otherIssuesView, streamDefault], newDefault); + + // Assert + expect(updatedViews.filter((view) => view.view === 'issues' && view.is_default)).toEqual([newDefault]); + expect(updatedViews.filter((view) => view.view === 'stream' && view.is_default)).toEqual([streamDefault]); + }); + }); + + describe('rename cache update pattern', () => { + it('correctly updates the name of a specific view in a list', () => { + // Arrange + const views: SavedView[] = [ + { + columns: {}, + created_by_user_id: 'user-1', + created_utc: '2024-01-01T00:00:00Z', + filter: 'type:error', + filter_definitions: null, + id: 'view-1', + is_default: false, + name: 'Old Name', + organization_id: 'org-123', + time: null, + updated_by_user_id: null, + updated_utc: '2024-01-01T00:00:00Z', + user_id: null, + version: 1, + view: 'issues' + }, + { + columns: {}, + created_by_user_id: 'user-1', + created_utc: '2024-01-02T00:00:00Z', + filter: 'type:log', + filter_definitions: null, + id: 'view-2', + is_default: false, + name: 'Other View', + organization_id: 'org-123', + time: null, + updated_by_user_id: null, + updated_utc: '2024-01-02T00:00:00Z', + user_id: null, + version: 1, + view: 'issues' + } + ]; + const viewId = 'view-1'; + const newName = 'New Name'; + + // Act - Pattern used in handleRename optimistic update + const updateViews = (old: SavedView[] | undefined): SavedView[] | undefined => { + if (!old) { + return old; + } + return old.map((v) => (v.id === viewId ? { ...v, name: newName } : v)); + }; + const updated = updateViews(views); + + // Assert + expect(updated).toBeDefined(); + expect(updated).toHaveLength(2); + if (updated) { + expect(updated[0]!.id).toBe('view-1'); + expect(updated[0]!.name).toBe('New Name'); + expect(updated[1]!.id).toBe('view-2'); + expect(updated[1]!.name).toBe('Other View'); + } + }); + + it('handles undefined cache gracefully', () => { + // Arrange + const viewId = 'view-1'; + const newName = 'New Name'; + + // Act - Pattern used in handleRename optimistic update + const updateViews = (old: SavedView[] | undefined): SavedView[] | undefined => { + if (!old) { + return old; + } + return old.map((v) => (v.id === viewId ? { ...v, name: newName } : v)); + }; + const updated = updateViews(undefined); + + // Assert + expect(updated).toBeUndefined(); + }); + }); +});