From 1c7797ab5cb400a460357ebb9555a13bd4b4f4d8 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 23 Aug 2026 17:14:20 -0500 Subject: [PATCH 01/31] Add saved view home defaults --- src/Exceptionless.Core/Models/Organization.cs | 6 + src/Exceptionless.Core/Models/User.cs | 1 + .../Models/UserOrganizationPreference.cs | 13 + .../Indexes/OrganizationIndex.cs | 3 +- .../Configuration/Indexes/UserIndex.cs | 5 +- .../Interfaces/IUserRepository.cs | 1 + .../Repositories/UserRepository.cs | 8 + .../Services/OrganizationService.cs | 2 + .../Api/Endpoints/SavedViewEndpoints.cs | 57 ++++ .../Api/Handlers/OrganizationHandler.cs | 2 + .../Api/Handlers/SavedViewHandler.cs | 141 ++++++++++ .../Api/Messages/SavedViewMessages.cs | 3 + .../ClientApp/e2e/tests/saved-views.e2e.ts | 68 +++++ .../impersonation-notification.svelte | 2 +- .../lib/features/saved-views/api.svelte.ts | 57 +++- .../components/saved-view-picker.svelte | 103 +++++++- .../lib/features/saved-views/defaults.test.ts | 59 +++++ .../src/lib/features/saved-views/defaults.ts | 10 + .../src/lib/features/saved-views/models.ts | 4 +- .../saved-views/use-saved-views.test.ts | 8 +- .../ClientApp/src/lib/generated/api.ts | 18 ++ .../ClientApp/src/lib/generated/schemas.ts | 34 +++ .../(app)/(components)/layouts/navbar.svelte | 2 +- .../sidebar-organization-switcher.svelte | 9 +- .../(components)/navigation-command.svelte | 2 +- .../ClientApp/src/routes/(app)/+layout.svelte | 8 +- .../ClientApp/src/routes/(app)/+page.svelte | 31 ++- .../routes/(app)/payment/[id]/+page@.svelte | 4 +- .../src/routes/(auth)/login/+page.svelte | 2 +- .../SavedView/UpdateSavedViewDefault.cs | 9 + .../Models/SavedView/ViewSavedViewDefaults.cs | 7 + .../Exceptionless.Tests/Api/Data/openapi.json | 247 +++++++++++++++++- .../Api/Endpoints/SavedViewEndpointTests.cs | 103 ++++++++ 33 files changed, 997 insertions(+), 32 deletions(-) create mode 100644 src/Exceptionless.Core/Models/UserOrganizationPreference.cs create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/defaults.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/defaults.ts create mode 100644 src/Exceptionless.Web/Models/SavedView/UpdateSavedViewDefault.cs create mode 100644 src/Exceptionless.Web/Models/SavedView/ViewSavedViewDefaults.cs diff --git a/src/Exceptionless.Core/Models/Organization.cs b/src/Exceptionless.Core/Models/Organization.cs index 929097651a..4cfa22dac8 100644 --- a/src/Exceptionless.Core/Models/Organization.cs +++ b/src/Exceptionless.Core/Models/Organization.cs @@ -32,6 +32,12 @@ public Organization() [Required] public string Name { get; set; } = null!; + /// + /// The shared saved view used as the default landing view when a user has not selected a personal default. + /// + [ObjectId] + public string? DefaultSavedViewId { get; set; } + [StringLength(2000)] public string? IconFileName { get; set; } diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs index 9e622dce0f..cb6e154e50 100644 --- a/src/Exceptionless.Core/Models/User.cs +++ b/src/Exceptionless.Core/Models/User.cs @@ -23,6 +23,7 @@ public record User : IIdentity, IHaveDates, IValidatableObject public string? PasswordResetToken { get; set; } public DateTime PasswordResetTokenExpiration { get; set; } public ICollection OAuthAccounts { get; init; } = new Collection(); + public ICollection OrganizationPreferences { get; init; } = new Collection(); /// /// Gets or sets the users Full Name. diff --git a/src/Exceptionless.Core/Models/UserOrganizationPreference.cs b/src/Exceptionless.Core/Models/UserOrganizationPreference.cs new file mode 100644 index 0000000000..625e2aff4e --- /dev/null +++ b/src/Exceptionless.Core/Models/UserOrganizationPreference.cs @@ -0,0 +1,13 @@ +using Exceptionless.Core.Attributes; +using Foundatio.Repositories.Models; + +namespace Exceptionless.Core.Models; + +public sealed record UserOrganizationPreference +{ + [ObjectId] + public string OrganizationId { get; set; } = null!; + + [ObjectId] + public string DefaultSavedViewId { get; set; } = null!; +} diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs index 1e17898890..2a73426cad 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs @@ -13,7 +13,7 @@ public sealed class OrganizationIndex : VersionedIndex private const string KEYWORD_LOWERCASE_ANALYZER = "keyword_lowercase"; private readonly ExceptionlessElasticConfiguration _configuration; - public OrganizationIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "organizations", 3) + public OrganizationIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "organizations", 4) { _configuration = configuration; } @@ -25,6 +25,7 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor m .Properties(p => p .SetupDefaults() .Text(e => e.Name, t => t.AddKeywordField()) + .Keyword(e => e.DefaultSavedViewId) .Keyword(e => e.StripeCustomerId) .Boolean(e => e.HasPremiumFeatures) .Keyword(e => e.Features) diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/UserIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/UserIndex.cs index d777ac2013..af9180780a 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/UserIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/UserIndex.cs @@ -12,7 +12,7 @@ public sealed class UserIndex : VersionedIndex private const string KEYWORD_LOWERCASE_ANALYZER = "keyword_lowercase"; private readonly ExceptionlessElasticConfiguration _configuration; - public UserIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "users", 1) + public UserIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "users", 2) { _configuration = configuration; } @@ -32,6 +32,9 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor map) .Keyword(e => e.PasswordResetToken) .Date(e => e.PasswordResetTokenExpiration) .Keyword(e => e.Roles) + .Object(e => e.OrganizationPreferences, o => o.Properties(mp => mp + .Keyword("organization_id") + .Keyword("default_saved_view_id"))) .Object(e => e.OAuthAccounts, o => o.Properties(mp => mp .Keyword("provider") .Keyword("provider_user_id") diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs index 52a0a751ad..cf2067d997 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs @@ -11,4 +11,5 @@ public interface IUserRepository : ISearchableRepository Task GetUserByOAuthProviderAsync(string provider, string providerUserId); Task GetByVerifyEmailAddressTokenAsync(string token); Task> GetByOrganizationIdAsync(string organizationId, CommandOptionsDescriptor? options = null); + Task> GetByDefaultSavedViewIdAsync(string savedViewId, CommandOptionsDescriptor? options = null); } diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index 77cb5296b9..b3ee495a71 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -67,6 +67,14 @@ public Task> GetByOrganizationIdAsync(string organizationId, C return FindAsync(q => q.FieldEquals(u => u.OrganizationIds, organizationId).SortAscending(u => u.EmailAddress), o => commandOptions); } + public Task> GetByDefaultSavedViewIdAsync(string savedViewId, CommandOptionsDescriptor? options = null) + { + if (String.IsNullOrEmpty(savedViewId)) + return Task.FromResult(new FindResults()); + + return FindAsync(q => q.FieldEquals(u => u.OrganizationPreferences.First().DefaultSavedViewId, savedViewId), options); + } + protected override async Task AddDocumentsToCacheAsync(ICollection> findHits, ICommandOptions options, bool isDirtyRead) { await base.AddDocumentsToCacheAsync(findHits, options, isDirtyRead); diff --git a/src/Exceptionless.Core/Services/OrganizationService.cs b/src/Exceptionless.Core/Services/OrganizationService.cs index 5413a5cfe7..e52b261cf2 100644 --- a/src/Exceptionless.Core/Services/OrganizationService.cs +++ b/src/Exceptionless.Core/Services/OrganizationService.cs @@ -93,6 +93,8 @@ public async Task RemoveUsersAsync(Organization organization, string? curr { _logger.LogInformation("Removing user {User} from organization: {OrganizationName} ({Organization})", user.Id, organization.Name, organization.Id); user.OrganizationIds.Remove(organization.Id); + foreach (var preference in user.OrganizationPreferences.Where(preference => String.Equals(preference.OrganizationId, organization.Id, StringComparison.Ordinal)).ToList()) + user.OrganizationPreferences.Remove(preference); usersToUpdate.Add(user); } } diff --git a/src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs index a16f644e5a..d32e1abe9c 100644 --- a/src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs @@ -71,6 +71,63 @@ public static IEndpointRouteBuilder MapSavedViewEndpoints(this IEndpointRouteBui } }); + group.MapGet("organizations/{organizationId:objectid}/saved-view-defaults", async (string organizationId, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new SavedViewMessages.GetSavedViewDefaults(organizationId))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get saved view defaults") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + }, + ResponseDescriptions = new() { + ["200"] = "The current user's and organization's accessible saved view defaults.", + ["404"] = "The organization could not be found.", + } + }); + + group.MapPut("organizations/{organizationId:objectid}/saved-view-defaults/user", async (string organizationId, IMediator mediator, IMediatorResultMapper resultMapper, + [FromBody] UpdateSavedViewDefault savedViewDefault) + => (await mediator.InvokeAsync>(new SavedViewMessages.UpdateUserSavedViewDefault(organizationId, savedViewDefault))).ToHttpResult(resultMapper)) + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Update the current user's saved view default") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The personal saved view default. A null saved view identifier clears the preference.", + RequestBodyRequired = true, + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + }, + ResponseDescriptions = new() { + ["200"] = "The personal saved view default was updated.", + ["404"] = "The organization could not be found.", + ["422"] = "The saved view is not accessible in this organization.", + } + }); + + group.MapPut("organizations/{organizationId:objectid}/saved-view-defaults/organization", async (string organizationId, IMediator mediator, IMediatorResultMapper resultMapper, + [FromBody] UpdateSavedViewDefault savedViewDefault) + => (await mediator.InvokeAsync>(new SavedViewMessages.UpdateOrganizationSavedViewDefault(organizationId, savedViewDefault))).ToHttpResult(resultMapper)) + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Update the organization's saved view default") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The shared saved view default. A null saved view identifier clears the preference.", + RequestBodyRequired = true, + ParameterDescriptions = new() { + ["organizationId"] = "The identifier of the organization.", + }, + ResponseDescriptions = new() { + ["200"] = "The organization saved view default was updated.", + ["404"] = "The organization could not be found.", + ["422"] = "The saved view is private or is not accessible in this organization.", + } + }); + group.MapPost("organizations/{organizationId:objectid}/saved-views", async (string organizationId, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] NewSavedView savedView) => { diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index a64badd407..773f6df36a 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -704,6 +704,8 @@ public async Task Handle(RemoveOrganizationUser message) await organizationService.RemoveUserSavedViewsAsync(organization.Id, user.Id); user.OrganizationIds.Remove(organization.Id); + foreach (var preference in user.OrganizationPreferences.Where(preference => String.Equals(preference.OrganizationId, organization.Id, StringComparison.Ordinal)).ToList()) + user.OrganizationPreferences.Remove(preference); await userRepository.SaveAsync(user, o => o.Cache()); await messagePublisher.PublishAsync(new UserMembershipChanged { diff --git a/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs index 3d2cf82ae3..561ab83bd1 100644 --- a/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs @@ -26,6 +26,7 @@ namespace Exceptionless.Web.Api.Handlers; public partial class SavedViewHandler( ISavedViewRepository repository, IOrganizationRepository organizationRepository, + IUserRepository userRepository, ILockProvider lockProvider, IQueue workItemQueue, ApiMapper mapper, @@ -82,6 +83,71 @@ public async Task> Handle(GetSavedViewById message) return MapToViewModel(model); } + public async Task> Handle(GetSavedViewDefaults message) + { + if (!HttpContext.Request.CanAccessOrganization(message.OrganizationId)) + return Result.NotFound("Organization not found."); + + return await GetSavedViewDefaultsAsync(message.OrganizationId); + } + + public async Task> Handle(UpdateUserSavedViewDefault message) + { + if (!HttpContext.Request.CanAccessOrganization(message.OrganizationId)) + return Result.NotFound("Organization not found."); + + if (message.Default.SavedViewId is not null) + { + var savedView = await GetModelAsync(message.Default.SavedViewId, useCache: false); + if (savedView is null || !String.Equals(savedView.OrganizationId, message.OrganizationId, StringComparison.Ordinal)) + return Result.Invalid(ValidationError.Create("saved_view_id", "The saved view is not accessible in this organization.")); + } + + var user = await userRepository.GetByIdAsync(GetCurrentUserId(), o => o.Cache(false)); + if (user is null) + return Result.NotFound("User not found."); + + foreach (var preference in user.OrganizationPreferences.Where(preference => String.Equals(preference.OrganizationId, message.OrganizationId, StringComparison.Ordinal)).ToList()) + user.OrganizationPreferences.Remove(preference); + + if (message.Default.SavedViewId is not null) + { + user.OrganizationPreferences.Add(new UserOrganizationPreference + { + OrganizationId = message.OrganizationId, + DefaultSavedViewId = message.Default.SavedViewId + }); + } + + await userRepository.SaveAsync(user, o => o.Cache()); + return await GetSavedViewDefaultsAsync(message.OrganizationId); + } + + public async Task> Handle(UpdateOrganizationSavedViewDefault message) + { + if (!HttpContext.Request.CanAccessOrganization(message.OrganizationId)) + return Result.NotFound("Organization not found."); + + if (message.Default.SavedViewId is not null) + { + var savedView = await repository.GetByIdAsync(message.Default.SavedViewId, o => o.Cache(false)); + if (savedView is null + || !String.Equals(savedView.OrganizationId, message.OrganizationId, StringComparison.Ordinal) + || savedView.UserId is not null) + { + return Result.Invalid(ValidationError.Create("saved_view_id", "The organization default must be a shared saved view in this organization.")); + } + } + + var organization = await organizationRepository.GetByIdAsync(message.OrganizationId, o => o.Cache(false)); + if (organization is null) + return Result.NotFound("Organization not found."); + + organization.DefaultSavedViewId = message.Default.SavedViewId; + await organizationRepository.SaveAsync(organization, o => o.Cache().Consistency(Consistency.Immediate)); + return await GetSavedViewDefaultsAsync(message.OrganizationId); + } + public async Task> Handle(CreateSavedView message) { if (!HttpContext.Request.IsInOrganization(message.OrganizationId)) @@ -259,6 +325,7 @@ public async Task> Handle(DeleteSavedViews message) return results.Failure.Count == 1 ? Result.FromResult(PermissionToResult(results.Failure.First())) : results; await repository.RemoveAsync(deletableItems); + await ClearDefaultReferencesAsync(deletableItems); if (results.Failure.Count == 0) return new ModelActionResults(); @@ -454,6 +521,80 @@ private ViewSavedView MapToViewModel(SavedView model) private List MapToViewModels(IEnumerable models) => models.Select(MapToViewModel).ToList(); + private async Task GetSavedViewDefaultsAsync(string organizationId) + { + var user = await userRepository.GetByIdAsync(GetCurrentUserId()); + var organization = await organizationRepository.GetByIdAsync(organizationId); + + ViewSavedView? userDefault = null; + string? userDefaultId = user?.OrganizationPreferences + .FirstOrDefault(preference => String.Equals(preference.OrganizationId, organizationId, StringComparison.Ordinal)) + ?.DefaultSavedViewId; + if (userDefaultId is not null) + { + var savedView = await repository.GetByIdAsync(userDefaultId); + if (savedView is not null + && String.Equals(savedView.OrganizationId, organizationId, StringComparison.Ordinal) + && (savedView.UserId is null || String.Equals(savedView.UserId, GetCurrentUserId(), StringComparison.Ordinal))) + { + userDefault = MapToViewModel(savedView); + } + } + + ViewSavedView? organizationDefault = null; + if (organization?.DefaultSavedViewId is not null) + { + var savedView = await repository.GetByIdAsync(organization.DefaultSavedViewId); + if (savedView is not null + && String.Equals(savedView.OrganizationId, organizationId, StringComparison.Ordinal) + && savedView.UserId is null) + { + organizationDefault = MapToViewModel(savedView); + } + } + + return new ViewSavedViewDefaults + { + UserDefault = userDefault, + OrganizationDefault = organizationDefault + }; + } + + private async Task ClearDefaultReferencesAsync(IReadOnlyCollection deletedSavedViews) + { + var deletedIds = deletedSavedViews.Select(savedView => savedView.Id).ToHashSet(StringComparer.Ordinal); + + foreach (string organizationId in deletedSavedViews.Select(savedView => savedView.OrganizationId).Distinct(StringComparer.Ordinal)) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + if (organization?.DefaultSavedViewId is null || !deletedIds.Contains(organization.DefaultSavedViewId)) + continue; + + organization.DefaultSavedViewId = null; + await organizationRepository.SaveAsync(organization, o => o.Cache().Consistency(Consistency.Immediate)); + } + + var usersById = new Dictionary(StringComparer.Ordinal); + foreach (string savedViewId in deletedIds) + { + var results = await userRepository.GetByDefaultSavedViewIdAsync(savedViewId, o => o.SearchAfterPaging().PageLimit(100)); + do + { + foreach (var user in results.Documents) + usersById.TryAdd(user.Id, user); + } while (await results.NextPageAsync()); + } + + foreach (var user in usersById.Values) + { + foreach (var preference in user.OrganizationPreferences.Where(preference => deletedIds.Contains(preference.DefaultSavedViewId)).ToList()) + user.OrganizationPreferences.Remove(preference); + } + + if (usersById.Count > 0) + await userRepository.SaveAsync(usersById.Values, o => o.Cache()); + } + private string GetCurrentUserId() => HttpContext.Request.GetUser().Id; private static void AfterResultMap(ICollection models) diff --git a/src/Exceptionless.Web/Api/Messages/SavedViewMessages.cs b/src/Exceptionless.Web/Api/Messages/SavedViewMessages.cs index 86fca8cd33..fe5092a36b 100644 --- a/src/Exceptionless.Web/Api/Messages/SavedViewMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/SavedViewMessages.cs @@ -7,6 +7,9 @@ namespace Exceptionless.Web.Api.Messages; public record GetSavedViewsByOrganization(string OrganizationId, int Page, int Limit); public record GetSavedViewsByView(string OrganizationId, string ViewType, int Page, int Limit); public record GetSavedViewById(string Id); +public record GetSavedViewDefaults(string OrganizationId); +public record UpdateUserSavedViewDefault(string OrganizationId, UpdateSavedViewDefault Default); +public record UpdateOrganizationSavedViewDefault(string OrganizationId, UpdateSavedViewDefault Default); public record CreateSavedView(string OrganizationId, NewSavedView SavedView); public record CreatePredefinedSavedViews(string OrganizationId); public record GetPredefinedSavedViews; diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts index d4323356a5..d71b86993b 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts @@ -4,6 +4,74 @@ import { expect, test } from '../fixtures/e2e-test'; import { ExceptionlessE2EJourney } from '../support/exceptionless-journey'; import { getVisibleText } from '../support/page-helpers'; +test('home navigation honors personal and organization saved views and survives deletion', async ({ e2eApi, e2eScenario, page }) => { + const failedApiRequests = captureFailedApiRequests(page); + const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario); + const viewName = `E2E Home ${journey.run.slice(-36)}`; + const viewSlug = savedViewSlug(viewName); + + await test.step('fall back to the first Stacks saved view when no default is configured', async () => { + await page.goto('/next/'); + await expect(page).toHaveURL(/\/next\/stack\/all(?:[?#]|$)/); + await expect(page.getByRole('heading', { name: 'All' })).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('prefer the personal saved view', async () => { + await journey.submitRepresentativeEvent(); + await saveView(page, viewName, journey.referenceId, 'all'); + + await openViewMenu(page); + await page.getByRole('menuitem', { name: 'Set as my home view' }).click(); + await expect(page.getByText(`"${viewName}" is now your home view.`)).toBeVisible(); + + await page.goto('/next/'); + await expect(page).toHaveURL(new RegExp(`/next/event/${escapeRegExp(viewSlug)}(?:[?#]|$)`)); + }); + + await test.step('fall back to the organization saved view after clearing the personal preference', async () => { + await openViewMenu(page); + await page.getByRole('menuitem', { name: 'Set as organization home' }).click(); + await expect(page.getByText(`"${viewName}" is now the organization home view.`)).toBeVisible(); + + await openViewMenu(page); + await page.getByRole('menuitem', { name: 'Clear my home view' }).click(); + await expect(page.getByText('Personal home view cleared.')).toBeVisible(); + + await page.goto('/next/'); + await expect(page).toHaveURL(new RegExp(`/next/event/${escapeRegExp(viewSlug)}(?:[?#]|$)`)); + }); + + await test.step('clear deleted defaults and return to the first Stacks saved view', async () => { + const deletion = await page.evaluate( + async ({ organizationId, token }) => { + const headers = { Authorization: `Bearer ${token}` }; + const defaultsResponse = await fetch(`/api/v2/organizations/${organizationId}/saved-view-defaults`, { headers }); + const defaults = await defaultsResponse.json(); + const response = await fetch(`/api/v2/saved-views/${defaults.organization_default.id}`, { + headers, + method: 'DELETE' + }); + const updatedDefaultsResponse = await fetch(`/api/v2/organizations/${organizationId}/saved-view-defaults`, { + headers: { Authorization: `Bearer ${token}` } + }); + return { + defaults: await updatedDefaultsResponse.json(), + status: response.status + }; + }, + { organizationId: e2eScenario.organizationId, token: e2eScenario.userToken } + ); + expect(deletion.status).toBe(202); + expect(deletion.defaults).not.toHaveProperty('organization_default'); + expect(deletion.defaults).not.toHaveProperty('user_default'); + + await page.goto('/next/'); + await expect(page).toHaveURL(/\/next\/stack\/all(?:[?#]|$)/); + }); + + expect(failedApiRequests).toEqual([]); +}); + test('events saved view can be saved, renamed, loaded, and deleted', async ({ e2eApi, e2eScenario, page }) => { const failedApiRequests = captureFailedApiRequests(page); const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte index 194df6bf1a..5bdce86241 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte @@ -17,8 +17,8 @@ let { name, userOrganizations, ...restProps }: Props = $props(); async function stopImpersonating(): Promise { - await goto(resolve('/(app)/stack')); organization.current = userOrganizations[0]?.id; + await goto(resolve('/')); } 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 7dd981b258..6d6aaf5dfb 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 @@ -6,7 +6,7 @@ import { ChangeType } from '$features/websockets/models'; import { type ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, type QueryClient, useQueryClient } from '@tanstack/svelte-query'; -import type { NewSavedView, SavedView, UpdateSavedView } from './models'; +import type { NewSavedView, SavedView, UpdateSavedView, UpdateSavedViewDefault, ViewSavedViewDefaults } from './models'; export const SAVED_VIEW_REFRESH_DELAY_MS = 1500; export const SAVED_VIEW_QUERY_STALE_TIME_MS = 60 * 1000; @@ -40,7 +40,7 @@ export async function invalidateSavedViewQueries(queryClient: QueryClient, messa } cancelScheduledSavedViewInvalidation(queryClient, organization_id); - await invalidateSavedViewCache(queryClient, organization_id); + await Promise.all([invalidateSavedViewCache(queryClient, organization_id), invalidateSavedViewDefaultQueries(queryClient, organization_id)]); } function cancelScheduledSavedViewInvalidation(queryClient: QueryClient, organizationId: string | undefined) { @@ -74,11 +74,12 @@ function scheduleSavedViewInvalidation(queryClient: QueryClient, organizationId: const key = organizationId ?? ''; timers[key] = setTimeout(() => { delete timers[key]; - void invalidateSavedViewCache(queryClient, organizationId); + void Promise.all([invalidateSavedViewCache(queryClient, organizationId), invalidateSavedViewDefaultQueries(queryClient, organizationId)]); }, SAVED_VIEW_REFRESH_DELAY_MS); } export const queryKeys = { + defaults: (organizationId: string | undefined) => [...queryKeys.type, 'organization', organizationId, 'defaults'] as const, id: (id: string | undefined) => [...queryKeys.type, id] as const, organization: (organizationId: string | undefined) => [...queryKeys.type, 'organization', organizationId] as const, predefined: (organizationId: string | undefined) => [...queryKeys.type, 'organization', organizationId, 'predefined'] as const, @@ -86,6 +87,19 @@ export const queryKeys = { view: (organizationId: string | undefined, view: string | undefined) => [...queryKeys.type, 'organization', organizationId, 'view', view] as const }; +export async function invalidateSavedViewDefaultQueries(queryClient: QueryClient, organizationId: string | undefined) { + if (organizationId) { + await queryClient.invalidateQueries({ + queryKey: queryKeys.defaults(organizationId) + }); + return; + } + + await queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === queryKeys.type[0] && query.queryKey.at(-1) === 'defaults' + }); +} + let deletedSavedViewIds = $state([]); export function deletePredefinedSavedView(request: { route: { id: string | undefined } }) { @@ -129,6 +143,19 @@ export function deleteSavedView(request: { route: { organizationId: string | und })); } +export function getSavedViewDefaultsQuery(request: { route: { organizationId: string | undefined } }) { + return createQuery(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + queryFn: async () => { + const client = useFetchClient(); + const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-view-defaults`); + return response.data!; + }, + queryKey: queryKeys.defaults(request.route.organizationId), + staleTime: SAVED_VIEW_QUERY_STALE_TIME_MS + })); +} + // Cacheable reads intentionally finish after their observer unmounts so navigation can reuse the result instead of aborting and restarting the request. export function getSavedViewsByViewQuery(request: { route: { organizationId: string | undefined; view: string | undefined } }) { return createQuery(() => ({ @@ -239,6 +266,14 @@ export function postSavedView(request: { route: { organizationId: string | undef })); } +export function putOrganizationSavedViewDefault(request: { route: { organizationId: string | undefined } }) { + return putSavedViewDefault(request, 'organization'); +} + +export function putUserSavedViewDefault(request: { route: { organizationId: string | undefined } }) { + return putSavedViewDefault(request, 'user'); +} + export function removeSavedViewFromCaches(queryClient: QueryClient, savedView: SavedView, organizationId: string | undefined = savedView.organization_id) { const evict = (cachedViews: SavedView[] | undefined) => cachedViews?.filter((v) => v.id !== savedView.id); queryClient.setQueryData(queryKeys.view(organizationId, savedView.view_type), evict); @@ -274,3 +309,19 @@ export function upsertSavedViewCache(cachedViews: SavedView[] | undefined, saved return views.map((view) => (view.id === savedView.id ? savedView : view)); } + +function putSavedViewDefault(request: { route: { organizationId: string | undefined } }, scope: 'organization' | 'user') { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + mutationFn: async (data: UpdateSavedViewDefault) => { + const client = useFetchClient(); + const response = await client.putJSON(`organizations/${request.route.organizationId}/saved-view-defaults/${scope}`, data); + return response.data!; + }, + onSuccess: (defaults: ViewSavedViewDefaults) => { + queryClient.setQueryData(queryKeys.defaults(request.route.organizationId), defaults); + } + })); +} 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 63e0e7a9e3..55bd99884c 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 @@ -15,7 +15,9 @@ import { serializeFilters } from '$features/events/components/filters/helpers.svelte'; import { organization } from '$features/organizations/context.svelte'; import { supportsColumnWrapping } from '$features/shared/components/data-table/column-meta'; + import Building2 from '@lucide/svelte/icons/building-2'; import Columns3 from '@lucide/svelte/icons/columns-3'; + import House from '@lucide/svelte/icons/house'; import Pencil from '@lucide/svelte/icons/pencil'; import Plus from '@lucide/svelte/icons/plus'; import Save from '@lucide/svelte/icons/save'; @@ -28,7 +30,16 @@ import type { AutoFillColumnSelection, WrappedColumnIds } from '../column-settings'; import type { NewSavedView, SavedView, UpdateSavedView } from '../models'; - import { deleteSavedView, markSavedViewDeleted, patchSavedView, postSavedView, restoreDeletedSavedView } from '../api.svelte'; + import { + deleteSavedView, + getSavedViewDefaultsQuery, + markSavedViewDeleted, + patchSavedView, + postSavedView, + putOrganizationSavedViewDefault, + putUserSavedViewDefault, + restoreDeletedSavedView + } from '../api.svelte'; import { buildColumnSettings } from '../column-settings'; import ColumnManagementDialog from './column-management-dialog.svelte'; import DeleteViewDialog from './delete-view-dialog.svelte'; @@ -105,6 +116,7 @@ let viewToDelete = $state(null); const organizationId = $derived(organization.current); + const activeView = $derived(activeSavedView); const createMutation = postSavedView({ route: { @@ -127,8 +139,37 @@ } } }); + const defaultsQuery = getSavedViewDefaultsQuery({ + route: { + get organizationId() { + return organizationId; + } + } + }); + const userDefaultMutation = putUserSavedViewDefault({ + route: { + get organizationId() { + return organizationId; + } + } + }); + const organizationDefaultMutation = putOrganizationSavedViewDefault({ + route: { + get organizationId() { + return organizationId; + } + } + }); - const saving = $derived(createMutation.isPending || updateMutation.isPending || removeMutation.isPending); + const saving = $derived( + createMutation.isPending || + updateMutation.isPending || + removeMutation.isPending || + userDefaultMutation.isPending || + organizationDefaultMutation.isPending + ); + const isUserDefault = $derived(!!activeView && defaultsQuery.data?.user_default?.id === activeView.id); + const isOrganizationDefault = $derived(!!activeView && defaultsQuery.data?.organization_default?.id === activeView.id); const currentFilterString = $derived(toFilter(filters.filter((f) => f.type !== 'date'))); // Auto-detect if current filters match an existing saved view for "load existing" hint @@ -154,8 +195,6 @@ }); }); - const activeView = $derived(activeSavedView); - const reorderableColumns = $derived(table.getAllLeafColumns().filter((column) => column.id !== 'select')); async function openSaveDialog() { @@ -265,6 +304,38 @@ } } + async function toggleUserDefault(): Promise { + if (!activeView || !organizationId) { + return; + } + + const clearingDefault = isUserDefault; + try { + await userDefaultMutation.mutateAsync({ + saved_view_id: clearingDefault ? null : activeView.id + }); + toast.success(clearingDefault ? 'Personal home view cleared.' : `"${activeView.name}" is now your home view.`); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to update your home view. Please try again.')); + } + } + + async function toggleOrganizationDefault(): Promise { + if (!activeView || activeView.user_id || !organizationId) { + return; + } + + const clearingDefault = isOrganizationDefault; + try { + await organizationDefaultMutation.mutateAsync({ + saved_view_id: clearingDefault ? null : activeView.id + }); + toast.success(clearingDefault ? 'Organization home view cleared.' : `"${activeView.name}" is now the organization home view.`); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to update the organization home view. Please try again.')); + } + } + async function handleDelete() { if (!viewToDelete || !organizationId) { return; @@ -329,13 +400,31 @@