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/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..ca738a2768 100644 --- a/src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs @@ -71,6 +71,48 @@ public static IEndpointRouteBuilder MapSavedViewEndpoints(this IEndpointRouteBui } }); + 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..0477e05a5a 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,68 @@ public async Task> Handle(GetSavedViewById message) return MapToViewModel(model); } + public async Task> Handle(UpdateUserSavedViewDefault message) + { + if (!HttpContext.Request.CanAccessOrganization(message.OrganizationId)) + return Result.NotFound("Organization not found."); + + if (await organizationRepository.GetByIdAsync(message.OrganizationId) is null) + 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 && !String.Equals(savedView.UserId, GetCurrentUserId(), 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 message.Default; + } + + public async Task> Handle(UpdateOrganizationSavedViewDefault message) + { + if (!HttpContext.Request.CanAccessOrganization(message.OrganizationId)) + return Result.NotFound("Organization not found."); + + var organization = await organizationRepository.GetByIdAsync(message.OrganizationId, o => o.Cache(false)); + if (organization is null) + 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.")); + } + } + + organization.DefaultSavedViewId = message.Default.SavedViewId; + await organizationRepository.SaveAsync(organization, o => o.Cache().Consistency(Consistency.Immediate)); + return message.Default; + } + public async Task> Handle(CreateSavedView message) { if (!HttpContext.Request.IsInOrganization(message.OrganizationId)) @@ -258,6 +321,7 @@ public async Task> Handle(DeleteSavedViews message) if (deletableItems.Count == 0) return results.Failure.Count == 1 ? Result.FromResult(PermissionToResult(results.Failure.First())) : results; + await ClearDefaultReferencesAsync(deletableItems); await repository.RemoveAsync(deletableItems); if (results.Failure.Count == 0) @@ -454,6 +518,21 @@ private ViewSavedView MapToViewModel(SavedView model) private List MapToViewModels(IEnumerable models) => models.Select(MapToViewModel).ToList(); + 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)); + } + } + 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..8cebe8940e 100644 --- a/src/Exceptionless.Web/Api/Messages/SavedViewMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/SavedViewMessages.cs @@ -7,6 +7,8 @@ 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 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/authentication.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/authentication.e2e.ts index 2862be6b5b..e5a8df0f0a 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/authentication.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/authentication.e2e.ts @@ -21,15 +21,15 @@ test('user can recover from a failed login, restore the session, and log out', a await page.getByPlaceholder('Enter password').fill(E2E_TEST_PASSWORD); await page.getByRole('button', { exact: true, name: 'Login' }).click(); - await expect(page.getByRole('heading', { name: 'Stacks' })).toBeVisible({ timeout: 30_000 }); - await expect(page).toHaveURL(/\/next\/stack(?:[?#]|$)/); + await expect(page.getByRole('heading', { name: 'All' })).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/next\/stack\/all(?:[?#]|$)/); }); await test.step('restore the authenticated application after a reload', async () => { await page.reload(); - await expect(page.getByRole('heading', { name: 'Stacks' })).toBeVisible({ timeout: 30_000 }); - await expect(page).toHaveURL(/\/next\/stack(?:[?#]|$)/); + await expect(page.getByRole('heading', { name: 'All' })).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/next\/stack\/all(?:[?#]|$)/); }); await test.step('log out through the user menu', async () => { diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/exie-full-page.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/exie-full-page.e2e.ts index d74ba3e8dd..7e471f43bb 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/exie-full-page.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/exie-full-page.e2e.ts @@ -53,7 +53,7 @@ test('Exie opens from navigation and expands without losing the conversation', a await test.step('expand the side panel and retain its conversation and source URL', async () => { await page.getByRole('link', { name: 'Collapse Exie to side panel' }).click(); - await expect(page).toHaveURL(/\/next\/stack(?:[?#]|$)/); + await expect(page).toHaveURL(/\/next\/stack\/all(?:[?#]|$)/); await expect(page.locator('[data-assistant-panel]')).toBeVisible(); await page.getByRole('button', { name: 'Close Exie' }).click(); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts index 81f9cb046c..731f23a2d1 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts @@ -34,6 +34,7 @@ test('user can reset a forgotten password and log in @signup', async ({ e2eApi, await page.getByPlaceholder('Enter password').fill(RESET_PASSWORD); await page.getByRole('button', { exact: true, name: 'Login' }).click(); - await expect(page.getByRole('heading', { name: 'Stacks' })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('heading', { name: 'All' })).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/next\/stack\/all(?:[?#]|$)/); }); }); 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 1b88e2bf6a..d3f9169f23 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,97 @@ 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, request }) => { + const failedApiRequests = captureFailedApiRequests(page); + const savedViewListLimits: string[] = []; + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.pathname === `/api/v2/organizations/${e2eScenario.organizationId}/saved-views`) { + savedViewListLimits.push(url.searchParams.get('limit') ?? ''); + } + }); + 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 expect.poll(() => savedViewListLimits).toContain('100'); + }); + + await test.step('prefer the personal saved view', async () => { + await journey.submitRepresentativeEvent(); + await page.goto(`/next/event?reference=${encodeURIComponent(journey.referenceId)}&time=all`); + await expect(getVisibleText(page, journey.message)).toBeVisible({ timeout: 30_000 }); + + await openViewMenu(page); + await page.getByRole('menuitem', { name: 'Save As...' }).click(); + const dialog = page.getByRole('dialog', { name: 'Save View' }); + await dialog.getByLabel('Name', { exact: true }).fill(viewName); + await dialog.getByRole('button', { name: 'Save' }).click(); + await expect(dialog).toBeHidden({ timeout: 30_000 }); + await expect(page.getByRole('heading', { name: viewName })).toBeVisible({ timeout: 30_000 }); + + 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 expect + .poll( + async () => { + const response = await request.get(`/api/v2/organizations/${e2eScenario.organizationId}/saved-views/events`, { + headers: { Authorization: `Bearer ${e2eScenario.userToken}` } + }); + const savedViews = response.ok() ? ((await response.json()) as { name: string }[]) : []; + return savedViews.some((savedView) => savedView.name === viewName); + }, + { timeout: 30_000 } + ) + .toBe(true); + + 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, viewName }) => { + const headers = { Authorization: `Bearer ${token}` }; + const savedViewsResponse = await fetch(`/api/v2/organizations/${organizationId}/saved-views/events`, { headers }); + const savedViews = await savedViewsResponse.json(); + const savedView = savedViews.find((view: { name: string }) => view.name === viewName); + const response = await fetch(`/api/v2/saved-views/${savedView.id}`, { + headers, + method: 'DELETE' + }); + return response.status; + }, + { organizationId: e2eScenario.organizationId, token: e2eScenario.userToken, viewName } + ); + expect(deletion).toBe(202); + + 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, request }) => { const failedApiRequests = captureFailedApiRequests(page); const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario); 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 36d2bcaf31..241189f845 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 @@ -681,6 +681,13 @@ export function removeOrganizationFeature(request: SetOrganizationFeatureRequest })); } +export function setOrganizationDefaultSavedView(queryClient: QueryClient, organizationId: string, savedViewId: null | string) { + updateOrganizationCaches(queryClient, organizationId, (organization) => ({ + ...organization, + default_saved_view_id: savedViewId + })); +} + export function setOrganizationFeature(request: SetOrganizationFeatureRequest) { const queryClient = useQueryClient(); return createMutation(() => ({ @@ -729,21 +736,29 @@ export function uploadOrganizationIcon(request: OrganizationIconRequest) { } function updateOrganizationCache(queryClient: QueryClient, id: string | undefined, organization: ViewOrganization) { - queryClient.setQueryData(queryKeys.id(id, 'stats'), organization); - queryClient.setQueryData(queryKeys.id(id, undefined), organization); + if (!id) { + return; + } + + updateOrganizationCaches(queryClient, id, () => organization); +} + +function updateOrganizationCaches(queryClient: QueryClient, id: string, update: (organization: ViewOrganization) => ViewOrganization) { + queryClient.setQueryData(queryKeys.id(id, 'stats'), (organization) => (organization ? update(organization) : organization)); + queryClient.setQueryData(queryKeys.id(id, undefined), (organization) => (organization ? update(organization) : organization)); queryClient.setQueriesData | undefined>( { queryKey: queryKeys.type }, (response) => { - if (!Array.isArray(response?.data) || !response.data.some((existingOrganization) => existingOrganization.id === organization.id)) { + if (!Array.isArray(response?.data) || !response.data.some((existingOrganization) => existingOrganization.id === id)) { return response; } return { ...response, data: response.data.map((existingOrganization) => { - return existingOrganization.id === organization.id ? organization : existingOrganization; + return existingOrganization.id === id ? update(existingOrganization) : existingOrganization; }) }; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.test.ts index ee43ecbd4f..bb8b24691c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.test.ts @@ -1,7 +1,9 @@ import { QueryClient } from '@tanstack/svelte-query'; import { describe, expect, it, vi } from 'vitest'; -import { invalidateOrganizationUsageQueries, invalidatePlanOverageQueries, queryKeys } from './api.svelte'; +import type { ViewOrganization } from './models'; + +import { invalidateOrganizationUsageQueries, invalidatePlanOverageQueries, queryKeys, setOrganizationDefaultSavedView } from './api.svelte'; describe('invalidatePlanOverageQueries', () => { it('invalidates only the affected organization state', async () => { @@ -37,3 +39,19 @@ describe('invalidateOrganizationUsageQueries', () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.list(undefined) }); }); }); + +describe('setOrganizationDefaultSavedView', () => { + it('updates individual and list caches', () => { + const queryClient = new QueryClient(); + const organization = { id: 'organization-id', name: 'Test' } as ViewOrganization; + queryClient.setQueryData(queryKeys.id(organization.id, undefined), organization); + queryClient.setQueryData([...queryKeys.list(undefined), { params: {} }], { data: [organization] }); + + setOrganizationDefaultSavedView(queryClient, organization.id, 'saved-view-id'); + + expect(queryClient.getQueryData(queryKeys.id(organization.id, undefined))?.default_saved_view_id).toBe('saved-view-id'); + expect(queryClient.getQueryData<{ data: ViewOrganization[] }>([...queryKeys.list(undefined), { params: {} }])?.data[0]?.default_saved_view_id).toBe( + 'saved-view-id' + ); + }); +}); 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..fb75b79288 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 @@ -2,11 +2,13 @@ import type { WorkInProgressResult } from '$features/shared/models'; import type { WebSocketMessageValue } from '$features/websockets/models'; import { accessToken } from '$features/auth/index.svelte'; +import { setOrganizationDefaultSavedView } from '$features/organizations/api.svelte'; +import { setCurrentUserSavedViewDefault } from '$features/users/api.svelte'; 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 } from './models'; export const SAVED_VIEW_REFRESH_DELAY_MS = 1500; export const SAVED_VIEW_QUERY_STALE_TIME_MS = 60 * 1000; @@ -148,7 +150,11 @@ export function getSavedViewsQuery(request: { route: { organizationId: string | enabled: () => !!accessToken.current && !!request.route.organizationId, queryFn: async () => { const client = useFetchClient(); - const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views`); + const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views`, { + params: { + limit: 100 + } + }); return response.data!; }, queryKey: queryKeys.organization(request.route.organizationId), @@ -239,13 +245,25 @@ 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); queryClient.setQueryData(queryKeys.organization(organizationId), evict); queryClient.setQueriesData( { - queryKey: queryKeys.type + predicate: (query) => + query.queryKey[0] === queryKeys.type[0] && + query.queryKey[1] === 'organization' && + query.queryKey[2] === organizationId && + query.queryKey[3] === 'view' }, evict ); @@ -274,3 +292,31 @@ 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<{ default: UpdateSavedViewDefault; organizationId: string | undefined }, ProblemDetails, UpdateSavedViewDefault>(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + mutationFn: async (data: UpdateSavedViewDefault) => { + const client = useFetchClient(); + const organizationId = request.route.organizationId; + const response = await client.putJSON(`organizations/${organizationId}/saved-view-defaults/${scope}`, data); + return { + default: response.data!, + organizationId + }; + }, + onSuccess: ({ default: savedViewDefault, organizationId }) => { + if (!organizationId) { + return; + } + + if (scope === 'user') { + setCurrentUserSavedViewDefault(queryClient, organizationId, savedViewDefault.saved_view_id ?? null); + } else { + setOrganizationDefaultSavedView(queryClient, organizationId, savedViewDefault.saved_view_id ?? null); + } + } + })); +} 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 7353b5d806..2cee06835d 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 @@ -13,9 +13,13 @@ import * as DropdownMenu from '$comp/ui/dropdown-menu'; import { toFilter } from '$features/events/components/filters/helpers.svelte'; import { serializeFilters } from '$features/events/components/filters/helpers.svelte'; + import { getOrganizationQuery, getOrganizationsQuery } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; import { supportsColumnWrapping } from '$features/shared/components/data-table/column-meta'; + import { getMeQuery } from '$features/users/api.svelte'; + 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,8 +32,17 @@ 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, + markSavedViewDeleted, + patchSavedView, + postSavedView, + putOrganizationSavedViewDefault, + putUserSavedViewDefault, + restoreDeletedSavedView + } from '../api.svelte'; import { buildColumnSettings } from '../column-settings'; + import { resolveSavedViewDefaults } from '../defaults'; import ColumnManagementDialog from './column-management-dialog.svelte'; import DeleteViewDialog from './delete-view-dialog.svelte'; import RenameViewDialog from './rename-view-dialog.svelte'; @@ -55,7 +68,7 @@ defaultAutoFillColumnId?: string; filters: IFilter[]; isModified: boolean; - onClearSavedView: () => void; + onClearSavedView: () => Promise; onLoadView: (view: SavedView) => void; onResetToSaved: () => void; onSavedViewUpdated: (view: SavedView) => void; @@ -109,6 +122,27 @@ let viewToDelete = $state(null); const organizationId = $derived(organization.current); + const activeView = $derived(activeSavedView); + const currentUserQuery = getMeQuery(); + const organizationsQuery = getOrganizationsQuery({}); + const membershipOrganization = $derived(organizationsQuery.data?.data?.find((organizationItem) => organizationItem.id === organizationId)); + const organizationIdToLoad = $derived(organizationsQuery.isSuccess && !membershipOrganization ? organizationId : undefined); + const currentOrganizationQuery = getOrganizationQuery({ + route: { + get id() { + return organizationIdToLoad; + } + } + }); + const currentOrganization = $derived(membershipOrganization ?? currentOrganizationQuery.data); + const defaults = $derived.by(() => { + return resolveSavedViewDefaults({ + organizationDefaultSavedViewId: currentOrganization?.default_saved_view_id, + organizationId, + organizationPreferences: currentUserQuery.data?.organization_preferences, + savedViews + }); + }); const createMutation = postSavedView({ route: { @@ -131,8 +165,30 @@ } } }); + 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 && defaults.userDefault?.id === activeView.id); + const isOrganizationDefault = $derived(!!activeView && defaults.organizationDefault?.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 @@ -158,8 +214,6 @@ }); }); - const activeView = $derived(activeSavedView); - const reorderableColumns = $derived(table.getAllLeafColumns().filter((column) => column.id !== 'select')); async function openSaveDialog() { @@ -274,6 +328,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; @@ -283,7 +369,7 @@ const wasActiveView = activeSavedView?.id === target.id; markSavedViewDeleted(target); if (wasActiveView) { - onClearSavedView(); + await onClearSavedView(); } try { @@ -338,13 +424,31 @@