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 @@
Reset to Saved
-
+ {/if}
+
+ {#if activeView}
+
+
+ Home
+
+
+ {isUserDefault ? 'Clear my home view' : 'Set as my home view'}
+
+ {#if !activeView.user_id}
+
+
+ {isOrganizationDefault ? 'Clear organization home' : 'Set as organization home'}
+
+ {/if}
+
+
+
openDeleteDialog(activeView)}>
Delete "{activeView.name}"
- {/if}
-
+
+ {/if}
{#if setShowStats || setShowChart}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/defaults.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/defaults.test.ts
new file mode 100644
index 0000000000..82ba8bf37a
--- /dev/null
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/defaults.test.ts
@@ -0,0 +1,110 @@
+import type { UserOrganizationPreference } from '$generated/api';
+
+import { describe, expect, it } from 'vitest';
+
+import type { SavedView } from './models';
+
+import { getSavedViewDefaultHref, resolveSavedViewDefaults } from './defaults';
+
+function savedView(overrides: Partial = {}): SavedView {
+ return {
+ created_by_user_id: 'user-id',
+ created_utc: '2026-08-23T00:00:00Z',
+ id: 'saved-view-id',
+ name: 'Home',
+ organization_id: 'organization-id',
+ slug: 'home',
+ updated_utc: '2026-08-23T00:00:00Z',
+ version: 1,
+ view_type: 'stacks',
+ ...overrides
+ };
+}
+
+describe('getSavedViewDefaultHref', () => {
+ it('uses the personal default before the organization default', () => {
+ const savedViews = [
+ savedView({ id: 'organization-default', slug: 'organization-home' }),
+ savedView({ id: 'user-default', slug: 'my-home', view_type: 'events' })
+ ];
+ const defaults = resolveSavedViewDefaults({
+ organizationDefaultSavedViewId: 'organization-default',
+ organizationId: 'organization-id',
+ organizationPreferences: [preference('user-default')],
+ savedViews
+ });
+
+ expect(getSavedViewDefaultHref(defaults, savedViews)).toBe('/next/event/my-home');
+ });
+
+ it('uses the organization default when there is no personal default', () => {
+ const savedViews = [savedView({ id: 'organization-default', slug: 'organization-home' })];
+ const defaults = resolveSavedViewDefaults({
+ organizationDefaultSavedViewId: 'organization-default',
+ organizationId: 'organization-id',
+ savedViews
+ });
+
+ expect(getSavedViewDefaultHref(defaults, savedViews)).toBe('/next/stack/organization-home');
+ });
+
+ it('skips missing duplicate personal defaults', () => {
+ const savedViews = [savedView({ id: 'valid-default', slug: 'valid-home' })];
+ const defaults = resolveSavedViewDefaults({
+ organizationId: 'organization-id',
+ organizationPreferences: [preference('missing-default'), preference('valid-default'), preference('valid-default')],
+ savedViews
+ });
+
+ expect(defaults.userDefault?.id).toBe('valid-default');
+ expect(getSavedViewDefaultHref(defaults, savedViews)).toBe('/next/stack/valid-home');
+ });
+
+ it('ignores a private organization default', () => {
+ const savedViews = [
+ savedView({ id: 'private-default', slug: 'private-home', user_id: 'user-id', view_type: 'events' }),
+ savedView({ id: 'first-stack-view', slug: 'all' })
+ ];
+ const defaults = resolveSavedViewDefaults({
+ organizationDefaultSavedViewId: 'private-default',
+ organizationId: 'organization-id',
+ savedViews
+ });
+
+ expect(defaults.organizationDefault).toBeUndefined();
+ expect(getSavedViewDefaultHref(defaults, savedViews)).toBe('/next/stack/all');
+ });
+
+ it('falls back to the first Stacks saved view when no configured default is available', () => {
+ const savedViews = [
+ savedView({ id: 'event-view', slug: 'recent', view_type: 'events' }),
+ savedView({ id: 'first-stack-view', slug: 'all' }),
+ savedView({ id: 'second-stack-view', slug: 'errors' })
+ ];
+
+ expect(getSavedViewDefaultHref({}, savedViews)).toBe('/next/stack/all');
+ });
+
+ it('falls back to the built-in Stacks route when there are no Stacks saved views', () => {
+ expect(getSavedViewDefaultHref({}, [])).toBe('/next/stack');
+ expect(getSavedViewDefaultHref({}, undefined)).toBe('/next/stack');
+ });
+
+ it('builds stream saved view links using the saved view identifier', () => {
+ const savedViews = [savedView({ id: 'stream-default', view_type: 'stream' })];
+ const defaults = resolveSavedViewDefaults({
+ organizationId: 'organization-id',
+ organizationPreferences: [preference('stream-default')],
+ savedViews
+ });
+
+ expect(getSavedViewDefaultHref(defaults, savedViews)).toBe('/next/stream?saved=stream-default');
+ });
+});
+
+function preference(defaultSavedViewId: string): UserOrganizationPreference {
+ return {
+ default_saved_view_id: defaultSavedViewId,
+ organization_id: 'organization-id'
+ };
+}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/defaults.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/defaults.ts
new file mode 100644
index 0000000000..3a1735f88e
--- /dev/null
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/defaults.ts
@@ -0,0 +1,43 @@
+import type { UserOrganizationPreference } from '$generated/api';
+
+import { resolve } from '$app/paths';
+
+import type { SavedView } from './models';
+
+import { savedViewHref } from './slugs';
+
+export interface ResolvedSavedViewDefaults {
+ organizationDefault?: SavedView;
+ userDefault?: SavedView;
+}
+
+interface ResolveSavedViewDefaultsOptions {
+ organizationDefaultSavedViewId?: null | string;
+ organizationId?: string;
+ organizationPreferences?: null | UserOrganizationPreference[];
+ savedViews?: null | SavedView[];
+}
+
+export function getSavedViewDefaultHref(defaults: ResolvedSavedViewDefaults, savedViews: null | SavedView[] | undefined = []): string {
+ const savedView = defaults.userDefault ?? defaults.organizationDefault ?? savedViews?.find((savedView) => savedView.view_type === 'stacks');
+ return savedView ? savedViewHref(savedView) : resolve('/(app)/stack');
+}
+
+export function resolveSavedViewDefaults(options: ResolveSavedViewDefaultsOptions): ResolvedSavedViewDefaults {
+ const savedViews = (options.savedViews ?? []).filter((savedView) => savedView.organization_id === options.organizationId);
+ const savedViewsById = new Map(savedViews.map((savedView) => [savedView.id, savedView]));
+ const userDefaultIds = [
+ ...new Set(
+ (options.organizationPreferences ?? [])
+ .filter((preference) => preference.organization_id === options.organizationId)
+ .map((preference) => preference.default_saved_view_id)
+ )
+ ].sort();
+ const userDefault = userDefaultIds.map((savedViewId) => savedViewsById.get(savedViewId)).find((savedView) => !!savedView);
+ const organizationDefault = options.organizationDefaultSavedViewId ? savedViewsById.get(options.organizationDefaultSavedViewId) : undefined;
+
+ return {
+ organizationDefault: organizationDefault?.user_id ? undefined : organizationDefault,
+ userDefault
+ };
+}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts
index 74cb955d3a..33bac39cb5 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts
@@ -1,5 +1,5 @@
-import type { NewSavedView, SavedViewColumnSettings, UpdateSavedView, ViewSavedView } from '$generated/api';
+import type { NewSavedView, SavedViewColumnSettings, UpdateSavedView, UpdateSavedViewDefault, ViewSavedView } from '$generated/api';
export type SavedView = ViewSavedView;
-export type { NewSavedView, SavedViewColumnSettings, UpdateSavedView };
+export type { NewSavedView, SavedViewColumnSettings, UpdateSavedView, UpdateSavedViewDefault };
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 8d8f47c4d4..4c3a0a6601 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
@@ -97,7 +97,7 @@ export interface UseSavedViewsReturn {
activeSavedView: SavedView | undefined;
autoFillColumnId: AutoFillColumnSelection;
canModifySavedView: boolean;
- handleClearSavedView: () => void;
+ handleClearSavedView: () => Promise;
handleLoadView: (view: SavedView) => void;
handleResetToSaved: () => void;
handleSavedViewUpdated: (view: SavedView) => void;
@@ -1305,13 +1305,13 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur
serverHydratedSavedViewId = view.id;
}
- function handleClearSavedView() {
+ async function handleClearSavedView(): Promise {
clearSavedViewQueryParams(options.queryParams);
applyColumnState(undefined);
applyDisplayState(undefined);
if (options.baseHref) {
- goto(options.baseHref);
+ await goto(options.baseHref);
}
}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts
index 86c0bf70f2..0083e3d46d 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts
@@ -831,6 +831,7 @@ describe('useSavedViews', () => {
const otherView = buildSavedView({ id: 'view-2', name: 'Other View' });
queryClient.setQueryData(queryKeys.organization(TEST_ORG_ID), [view, otherView]);
queryClient.setQueryData(queryKeys.view(TEST_ORG_ID, 'stacks'), [view, otherView]);
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(async () => {});
// Act
await invalidateSavedViewQueries(queryClient, {
@@ -844,6 +845,7 @@ describe('useSavedViews', () => {
// Assert - view removed from both caches without refetch
expect(queryClient.getQueryData(queryKeys.organization(TEST_ORG_ID))).toEqual([otherView]);
expect(queryClient.getQueryData(queryKeys.view(TEST_ORG_ID, 'stacks'))).toEqual([otherView]);
+ expect(invalidateSpy).not.toHaveBeenCalled();
});
it('preserves a pending reconciliation when a cached view is removed', async () => {
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts
index e1b2055359..4747a521fa 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts
@@ -271,6 +271,28 @@ export function resendVerificationEmail(request: ResendVerificationEmailRequest)
}));
}
+export function setCurrentUserSavedViewDefault(queryClient: QueryClient, organizationId: string, savedViewId: null | string) {
+ const currentUser = queryClient.getQueryData(queryKeys.me());
+ if (!currentUser) {
+ return;
+ }
+
+ const organizationPreferences = currentUser.organization_preferences.filter((preference) => preference.organization_id !== organizationId);
+ if (savedViewId) {
+ organizationPreferences.push({
+ default_saved_view_id: savedViewId,
+ organization_id: organizationId
+ });
+ }
+
+ const updatedUser = {
+ ...currentUser,
+ organization_preferences: organizationPreferences
+ };
+ queryClient.setQueryData(queryKeys.me(), updatedUser);
+ queryClient.setQueryData(queryKeys.id(currentUser.id), updatedUser);
+}
+
export function uploadUserAvatar(request: UserAvatarRequest) {
const queryClient = useQueryClient();
return createMutation(() => ({
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.test.ts
new file mode 100644
index 0000000000..fe4b82c251
--- /dev/null
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.test.ts
@@ -0,0 +1,41 @@
+import { QueryClient } from '@tanstack/svelte-query';
+import { describe, expect, it } from 'vitest';
+
+import type { ViewCurrentUser } from './models';
+
+import { queryKeys, setCurrentUserSavedViewDefault } from './api.svelte';
+
+describe('setCurrentUserSavedViewDefault', () => {
+ it('replaces duplicate organization preferences in the current-user cache', () => {
+ const queryClient = new QueryClient();
+ const currentUser = {
+ id: 'user-id',
+ organization_preferences: [
+ { default_saved_view_id: 'old-view-id', organization_id: 'organization-id' },
+ { default_saved_view_id: 'duplicate-view-id', organization_id: 'organization-id' },
+ { default_saved_view_id: 'other-view-id', organization_id: 'other-organization-id' }
+ ]
+ } as ViewCurrentUser;
+ queryClient.setQueryData(queryKeys.me(), currentUser);
+
+ setCurrentUserSavedViewDefault(queryClient, 'organization-id', 'new-view-id');
+
+ expect(queryClient.getQueryData(queryKeys.me())?.organization_preferences).toEqual([
+ { default_saved_view_id: 'other-view-id', organization_id: 'other-organization-id' },
+ { default_saved_view_id: 'new-view-id', organization_id: 'organization-id' }
+ ]);
+ });
+
+ it('clears the organization preference', () => {
+ const queryClient = new QueryClient();
+ const currentUser = {
+ id: 'user-id',
+ organization_preferences: [{ default_saved_view_id: 'old-view-id', organization_id: 'organization-id' }]
+ } as ViewCurrentUser;
+ queryClient.setQueryData(queryKeys.me(), currentUser);
+
+ setCurrentUserSavedViewDefault(queryClient, 'organization-id', null);
+
+ expect(queryClient.getQueryData(queryKeys.me())?.organization_preferences).toEqual([]);
+ });
+});
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts
index 4aa44bc735..2f8965a728 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts
+++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts
@@ -548,6 +548,11 @@ export interface UpdateSavedView {
show_chart?: null | boolean;
}
+export interface UpdateSavedViewDefault {
+ /** @pattern ^[a-fA-F0-9]{24}$ */
+ saved_view_id?: null | string;
+}
+
/** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */
export interface UpdateToken {
is_disabled: boolean;
@@ -606,6 +611,7 @@ export interface User {
/** @format date-time */
password_reset_token_expiration: string;
o_auth_accounts: OAuthAccount[];
+ organization_preferences: UserOrganizationPreference[];
/** Gets or sets the users Full Name. */
full_name: string;
/** @format email */
@@ -633,10 +639,18 @@ export interface UserDescription {
data?: null | Record;
}
+export interface UserOrganizationPreference {
+ /** @pattern ^[a-fA-F0-9]{24}$ */
+ organization_id: string;
+ /** @pattern ^[a-fA-F0-9]{24}$ */
+ default_saved_view_id: string;
+}
+
export interface ViewCurrentUser {
hash?: null | string;
has_local_account: boolean;
o_auth_accounts: OAuthAccount[];
+ organization_preferences: UserOrganizationPreference[];
/** @pattern ^[a-fA-F0-9]{24}$ */
id: string;
organization_ids: string[];
@@ -683,6 +697,8 @@ export interface ViewOrganization {
/** @format date-time */
updated_utc: string;
name: string;
+ /** @pattern ^[a-fA-F0-9]{24}$ */
+ default_saved_view_id?: null | string;
icon_url?: null | string;
plan_id: string;
plan_name: string;
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts
index 5ff2c3aec1..2e37b6a941 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts
+++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts
@@ -649,6 +649,17 @@ export const UpdateSavedViewSchema = object({
});
export type UpdateSavedViewFormData = Infer;
+export const UpdateSavedViewDefaultSchema = object({
+ saved_view_id: string()
+ .length(24, "Saved view id must be exactly 24 characters")
+ .regex(/^[a-fA-F0-9]{24}$/, "Saved view id has invalid format")
+ .nullable()
+ .optional(),
+});
+export type UpdateSavedViewDefaultFormData = Infer<
+ typeof UpdateSavedViewDefaultSchema
+>;
+
export const UpdateTokenSchema = object({
is_disabled: boolean().optional(),
notes: string().min(1, "Notes is required").nullable().optional(),
@@ -695,6 +706,9 @@ export const UserSchema = object({
.optional(),
password_reset_token_expiration: iso.datetime(),
o_auth_accounts: array(lazy(() => OAuthAccountSchema)),
+ organization_preferences: array(
+ lazy(() => UserOrganizationPreferenceSchema),
+ ),
full_name: string().min(1, "Full name is required"),
email_address: email(),
avatar_file_name: string()
@@ -723,10 +737,25 @@ export const UserDescriptionSchema = object({
});
export type UserDescriptionFormData = Infer;
+export const UserOrganizationPreferenceSchema = object({
+ organization_id: string()
+ .length(24, "Organization id must be exactly 24 characters")
+ .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format"),
+ default_saved_view_id: string()
+ .length(24, "Default saved view id must be exactly 24 characters")
+ .regex(/^[a-fA-F0-9]{24}$/, "Default saved view id has invalid format"),
+});
+export type UserOrganizationPreferenceFormData = Infer<
+ typeof UserOrganizationPreferenceSchema
+>;
+
export const ViewCurrentUserSchema = object({
hash: string().min(1, "Hash is required").nullable().optional(),
has_local_account: boolean(),
o_auth_accounts: array(lazy(() => OAuthAccountSchema)),
+ organization_preferences: array(
+ lazy(() => UserOrganizationPreferenceSchema),
+ ),
id: string()
.length(24, "Id must be exactly 24 characters")
.regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"),
@@ -773,6 +802,11 @@ export const ViewOrganizationSchema = object({
created_utc: iso.datetime(),
updated_utc: iso.datetime(),
name: string().min(1, "Name is required"),
+ default_saved_view_id: string()
+ .length(24, "Default saved view id must be exactly 24 characters")
+ .regex(/^[a-fA-F0-9]{24}$/, "Default saved view id has invalid format")
+ .nullable()
+ .optional(),
icon_url: url().nullable().optional(),
plan_id: string().min(1, "Plan id is required"),
plan_name: string().min(1, "Plan name is required"),
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte
index e0dc023364..039baf20f5 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte
@@ -29,7 +29,7 @@
-
+
{#if isMediumScreenQuery.current}
{:else}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte
index d48844f839..f2806c46f8 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte
@@ -64,7 +64,7 @@
)?.focus();
}
- function onOrganizationSelected(organization: ViewOrganization): void {
+ async function onOrganizationSelected(organization: ViewOrganization): Promise {
if (sidebar.isMobile) {
sidebar.toggle();
}
@@ -74,16 +74,17 @@
}
currentOrganizationId = organization.id;
+ await goto(resolve('/'));
}
async function handleImpersonate(organization: ViewOrganization): Promise {
- await goto(resolve('/(app)/stack'));
currentOrganizationId = organization.id;
+ await goto(resolve('/'));
}
async function stopImpersonating(): Promise {
- await goto(resolve('/(app)/stack'));
currentOrganizationId = organizations[0]?.id;
+ await goto(resolve('/'));
}
async function navigateTo(href: string): Promise {
@@ -164,7 +165,7 @@
{#if organizations.length > 0}
{#each organizations as organization (organization.name)}
onOrganizationSelected(organization)}
+ onSelect={() => void onOrganizationSelected(organization)}
data-current-organization={organization.id === currentOrganizationId && !isImpersonating ? 'true' : undefined}
class="gap-2 p-2"
>
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte
index df3ebbb032..d1cbdcbe14 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte
@@ -397,7 +397,7 @@
async function switchToOrganization(organizationItem: ViewOrganization): Promise {
closeCommandWindow();
organization.current = organizationItem.id;
- await goto(resolve('/(app)/stack'));
+ await goto(resolve('/'));
}
async function openImpersonateOrganizationDialog(): Promise {
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte
index 49b45fe8f0..e1d5fa562b 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte
@@ -157,7 +157,7 @@
}
function getAssistantReturnHref(): string {
- return normalizeAssistantHref(page.url.searchParams.get('from')) ?? resolve('/(app)/stack');
+ return normalizeAssistantHref(page.url.searchParams.get('from')) ?? resolve('/');
}
function normalizeAssistantHref(value: null | string): string | undefined {
@@ -230,8 +230,8 @@
async function stopImpersonating(): Promise {
isCommandOpen = false;
- await goto(resolve('/(app)/stack'));
organization.current = organizations[0]?.id;
+ await goto(resolve('/'));
}
useMiddleware(async (ctx, next) => {
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte
index 35bb3a0aee..6aa7ce9be2 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte
@@ -1,9 +1,52 @@
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte
index a42c535b3f..7e86b96997 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte
@@ -33,7 +33,7 @@
import { ariaInvalid, getFormErrorMessages, mapFieldErrors, problemDetailsToFormErrors } from '$shared/validation';
import { createForm } from '@tanstack/svelte-form';
- const defaultRedirect = resolve('/(app)/stack');
+ const defaultRedirect = resolve('/');
const redirectUrl = getSafeRedirectUrl(page.url.searchParams.get('redirect'), defaultRedirect);
const form = createForm(() => ({
diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs
index 90bdd3fffd..5c46965b96 100644
--- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs
+++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs
@@ -12,6 +12,8 @@ public record ViewOrganization : IIdentity, IData, IHaveDates
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
public string Name { get; set; } = null!;
+ [ObjectId]
+ public string? DefaultSavedViewId { get; set; }
public string? IconUrl { get; set; }
public string PlanId { get; set; } = null!;
public string PlanName { get; set; } = null!;
diff --git a/src/Exceptionless.Web/Models/SavedView/UpdateSavedViewDefault.cs b/src/Exceptionless.Web/Models/SavedView/UpdateSavedViewDefault.cs
new file mode 100644
index 0000000000..9bdd6a723d
--- /dev/null
+++ b/src/Exceptionless.Web/Models/SavedView/UpdateSavedViewDefault.cs
@@ -0,0 +1,9 @@
+using Exceptionless.Core.Attributes;
+
+namespace Exceptionless.Web.Models;
+
+public sealed record UpdateSavedViewDefault
+{
+ [ObjectId]
+ public string? SavedViewId { get; init; }
+}
diff --git a/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs b/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs
index d0106044e4..18f8827830 100644
--- a/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs
+++ b/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs
@@ -18,6 +18,7 @@ public ViewCurrentUser(User user, IntercomOptions options)
IsEmailAddressVerified = user.IsEmailAddressVerified;
IsActive = user.IsActive;
Roles = user.Roles;
+ OrganizationPreferences = user.OrganizationPreferences;
Hash = HMACSHA256HashString(user.Id, options);
HasLocalAccount = !String.IsNullOrWhiteSpace(user.Password);
@@ -27,6 +28,7 @@ public ViewCurrentUser(User user, IntercomOptions options)
public string? Hash { get; set; }
public bool HasLocalAccount { get; set; }
public ICollection OAuthAccounts { get; set; }
+ public ICollection OrganizationPreferences { get; set; }
private static string? HMACSHA256HashString(string value, IntercomOptions options)
{
diff --git a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json
index 100eaae183..c1fe82e9be 100644
--- a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json
+++ b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json
@@ -1398,6 +1398,34 @@
"authorizationRoles": [],
"authenticationSchemes": []
},
+ {
+ "method": "PUT",
+ "route": "/api/v2/organizations/{organizationId:objectid}/saved-view-defaults/organization",
+ "displayName": "HTTP: PUT api/v2/organizations/{organizationId:objectid}/saved-view-defaults/organization",
+ "tags": [
+ "SavedView"
+ ],
+ "allowAnonymous": false,
+ "authorizationPolicies": [
+ "UserPolicy"
+ ],
+ "authorizationRoles": [],
+ "authenticationSchemes": []
+ },
+ {
+ "method": "PUT",
+ "route": "/api/v2/organizations/{organizationId:objectid}/saved-view-defaults/user",
+ "displayName": "HTTP: PUT api/v2/organizations/{organizationId:objectid}/saved-view-defaults/user",
+ "tags": [
+ "SavedView"
+ ],
+ "allowAnonymous": false,
+ "authorizationPolicies": [
+ "UserPolicy"
+ ],
+ "authorizationRoles": [],
+ "authenticationSchemes": []
+ },
{
"method": "GET",
"route": "/api/v2/organizations/{organizationId:objectid}/saved-views",
diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json
index 232f5b6cec..898ea373d7 100644
--- a/tests/Exceptionless.Tests/Api/Data/openapi.json
+++ b/tests/Exceptionless.Tests/Api/Data/openapi.json
@@ -3102,6 +3102,142 @@
}
}
},
+ "/api/v2/organizations/{organizationId}/saved-view-defaults/user": {
+ "put": {
+ "tags": [
+ "SavedView"
+ ],
+ "summary": "Update the current user\u0027s saved view default",
+ "parameters": [
+ {
+ "name": "organizationId",
+ "in": "path",
+ "description": "The identifier of the organization.",
+ "required": true,
+ "schema": {
+ "pattern": "^[a-zA-Z\\d]{24,36}$",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "The personal saved view default. A null saved view identifier clears the preference.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateSavedViewDefault"
+ }
+ },
+ "application/*\u002Bjson": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateSavedViewDefault"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "The personal saved view default was updated.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateSavedViewDefault"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "The organization could not be found.",
+ "content": {
+ "application/problem\u002Bjson": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "The saved view is not accessible in this organization.",
+ "content": {
+ "application/problem\u002Bjson": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v2/organizations/{organizationId}/saved-view-defaults/organization": {
+ "put": {
+ "tags": [
+ "SavedView"
+ ],
+ "summary": "Update the organization\u0027s saved view default",
+ "parameters": [
+ {
+ "name": "organizationId",
+ "in": "path",
+ "description": "The identifier of the organization.",
+ "required": true,
+ "schema": {
+ "pattern": "^[a-zA-Z\\d]{24,36}$",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "The shared saved view default. A null saved view identifier clears the preference.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateSavedViewDefault"
+ }
+ },
+ "application/*\u002Bjson": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateSavedViewDefault"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "The organization saved view default was updated.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateSavedViewDefault"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "The organization could not be found.",
+ "content": {
+ "application/problem\u002Bjson": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "The saved view is private or is not accessible in this organization.",
+ "content": {
+ "application/problem\u002Bjson": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v2/organizations/{organizationId}/saved-views/predefined": {
"post": {
"tags": [
@@ -13189,6 +13325,20 @@
},
"description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType."
},
+ "UpdateSavedViewDefault": {
+ "type": "object",
+ "properties": {
+ "saved_view_id": {
+ "maxLength": 24,
+ "minLength": 24,
+ "pattern": "^[a-fA-F0-9]{24}$",
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
"UpdateToken": {
"type": "object",
"properties": {
@@ -13303,6 +13453,7 @@
"organization_ids",
"password_reset_token_expiration",
"o_auth_accounts",
+ "organization_preferences",
"email_notifications_enabled",
"is_email_address_verified",
"verify_email_address_token_expiration",
@@ -13356,6 +13507,12 @@
"$ref": "#/components/schemas/OAuthAccount"
}
},
+ "organization_preferences": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/UserOrganizationPreference"
+ }
+ },
"full_name": {
"type": "string",
"description": "Gets or sets the users Full Name."
@@ -13435,10 +13592,32 @@
}
}
},
+ "UserOrganizationPreference": {
+ "required": [
+ "organization_id",
+ "default_saved_view_id"
+ ],
+ "type": "object",
+ "properties": {
+ "organization_id": {
+ "maxLength": 24,
+ "minLength": 24,
+ "pattern": "^[a-fA-F0-9]{24}$",
+ "type": "string"
+ },
+ "default_saved_view_id": {
+ "maxLength": 24,
+ "minLength": 24,
+ "pattern": "^[a-fA-F0-9]{24}$",
+ "type": "string"
+ }
+ }
+ },
"ViewCurrentUser": {
"required": [
"has_local_account",
"o_auth_accounts",
+ "organization_preferences",
"id",
"organization_ids",
"full_name",
@@ -13466,6 +13645,12 @@
"$ref": "#/components/schemas/OAuthAccount"
}
},
+ "organization_preferences": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/UserOrganizationPreference"
+ }
+ },
"id": {
"maxLength": 24,
"minLength": 24,
@@ -13654,6 +13839,15 @@
"name": {
"type": "string"
},
+ "default_saved_view_id": {
+ "maxLength": 24,
+ "minLength": 24,
+ "pattern": "^[a-fA-F0-9]{24}$",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"icon_url": {
"type": [
"null",
diff --git a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs
index 7391e33ee1..941325c529 100644
--- a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs
+++ b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs
@@ -1397,6 +1397,212 @@ public Task PatchAsync_NonExistentFilter_ReturnsNotFound()
);
}
+ [Fact]
+ public async Task PutSavedViewDefaults_PersonalAndOrganizationDefaults_AppearInStartupResponses()
+ {
+ var sharedView = await CreateSavedViewAsync("Shared Home", "status:open", "events");
+ var privateView = await CreateSavedViewAsync("Private Home", "status:regressed", "stacks", isPrivate: true);
+ Assert.NotNull(sharedView);
+ Assert.NotNull(privateView);
+
+ await SendRequestAsync(r => r
+ .Put()
+ .AsGlobalAdminUser()
+ .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-view-defaults", "organization")
+ .Content(new UpdateSavedViewDefault { SavedViewId = sharedView.Id })
+ .StatusCodeShouldBeOk()
+ );
+
+ await SendRequestAsync(r => r
+ .Put()
+ .AsGlobalAdminUser()
+ .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-view-defaults", "user")
+ .Content(new UpdateSavedViewDefault { SavedViewId = privateView.Id })
+ .StatusCodeShouldBeOk()
+ );
+
+ var organizationPreferences = await GetCurrentUserOrganizationPreferencesAsync();
+ var organization = await SendRequestAsAsync(r => r
+ .AsGlobalAdminUser()
+ .AppendPaths("organizations", SampleDataService.TEST_ORG_ID)
+ .StatusCodeShouldBeOk()
+ );
+
+ Assert.Contains(organizationPreferences, preference => preference.OrganizationId == SampleDataService.TEST_ORG_ID && preference.DefaultSavedViewId == privateView.Id);
+ Assert.NotNull(organization);
+ Assert.Equal(sharedView.Id, organization.DefaultSavedViewId);
+ }
+
+ [Fact]
+ public async Task PutOrganizationSavedViewDefault_PrivateView_ReturnsUnprocessableEntity()
+ {
+ var privateView = await CreateSavedViewAsync("Private Organization Home", "status:open", "events", isPrivate: true);
+ Assert.NotNull(privateView);
+
+ await SendRequestAsync(r => r
+ .Put()
+ .AsGlobalAdminUser()
+ .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-view-defaults", "organization")
+ .Content(new UpdateSavedViewDefault { SavedViewId = privateView.Id })
+ .StatusCodeShouldBeUnprocessableEntity()
+ );
+ }
+
+ [Fact]
+ public async Task GetStartupResponses_MissingSavedViews_ReturnsStoredReferences()
+ {
+ var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL);
+ var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID);
+ Assert.NotNull(user);
+ Assert.NotNull(organization);
+
+ foreach (var preference in user.OrganizationPreferences.Where(preference => preference.OrganizationId == organization.Id).ToList())
+ user.OrganizationPreferences.Remove(preference);
+
+ user.OrganizationPreferences.Add(new UserOrganizationPreference
+ {
+ OrganizationId = organization.Id,
+ DefaultSavedViewId = "000000000000000000000001"
+ });
+ organization.DefaultSavedViewId = "000000000000000000000002";
+ await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache());
+ await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache());
+
+ var organizationPreferences = await GetCurrentUserOrganizationPreferencesAsync();
+ var organizationView = await SendRequestAsAsync(r => r
+ .AsGlobalAdminUser()
+ .AppendPaths("organizations", organization.Id)
+ .StatusCodeShouldBeOk()
+ );
+
+ Assert.Contains(organizationPreferences, preference => preference.OrganizationId == organization.Id && preference.DefaultSavedViewId == "000000000000000000000001");
+ Assert.NotNull(organizationView);
+ Assert.Equal("000000000000000000000002", organizationView.DefaultSavedViewId);
+ }
+
+ [Fact]
+ public async Task GetCurrentUser_DuplicatePreferences_ReturnsStoredPreferences()
+ {
+ var validView = await CreateSavedViewAsync("Legacy Duplicate Home", "status:open", "stacks");
+ var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL);
+ Assert.NotNull(validView);
+ Assert.NotNull(user);
+
+ foreach (var preference in user.OrganizationPreferences.Where(preference => preference.OrganizationId == SampleDataService.TEST_ORG_ID).ToList())
+ user.OrganizationPreferences.Remove(preference);
+
+ user.OrganizationPreferences.Add(new UserOrganizationPreference
+ {
+ OrganizationId = SampleDataService.TEST_ORG_ID,
+ DefaultSavedViewId = "000000000000000000000000"
+ });
+ user.OrganizationPreferences.Add(new UserOrganizationPreference
+ {
+ OrganizationId = SampleDataService.TEST_ORG_ID,
+ DefaultSavedViewId = validView.Id
+ });
+ await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache());
+
+ var organizationPreferences = await GetCurrentUserOrganizationPreferencesAsync();
+
+ var preferences = organizationPreferences.Where(preference => preference.OrganizationId == SampleDataService.TEST_ORG_ID).ToList();
+ Assert.Equal(2, preferences.Count);
+ Assert.Contains(preferences, preference => preference.DefaultSavedViewId == validView.Id);
+ }
+
+ [Fact]
+ public async Task PutUserSavedViewDefault_GlobalAdministratorOutsideOrganization_AllowsSharedViewButRejectsPrivateView()
+ {
+ var sharedView = await SendRequestAsAsync(r => r
+ .Post()
+ .AsFreeOrganizationUser()
+ .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "saved-views")
+ .Content(new NewSavedView
+ {
+ OrganizationId = SampleDataService.FREE_ORG_ID,
+ Name = "Shared Free Organization Home",
+ Filter = "status:open",
+ ViewType = "stacks"
+ })
+ .StatusCodeShouldBeCreated()
+ );
+ var privateView = await SendRequestAsAsync(r => r
+ .Post()
+ .AsFreeOrganizationUser()
+ .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "saved-views")
+ .Content(new NewSavedView
+ {
+ OrganizationId = SampleDataService.FREE_ORG_ID,
+ Name = "Private Free Organization Home",
+ Filter = "status:regressed",
+ ViewType = "stacks",
+ IsPrivate = true
+ })
+ .StatusCodeShouldBeCreated()
+ );
+
+ Assert.NotNull(sharedView);
+ Assert.NotNull(privateView);
+
+ await SendRequestAsync(r => r
+ .Put()
+ .AsGlobalAdminUser()
+ .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "saved-view-defaults", "user")
+ .Content(new UpdateSavedViewDefault { SavedViewId = sharedView.Id })
+ .StatusCodeShouldBeOk()
+ );
+
+ await SendRequestAsync(r => r
+ .Put()
+ .AsGlobalAdminUser()
+ .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "saved-view-defaults", "user")
+ .Content(new UpdateSavedViewDefault { SavedViewId = privateView.Id })
+ .StatusCodeShouldBeUnprocessableEntity()
+ );
+
+ var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL);
+
+ Assert.NotNull(user);
+ Assert.Contains(user.OrganizationPreferences, preference => preference.OrganizationId == SampleDataService.FREE_ORG_ID && preference.DefaultSavedViewId == sharedView.Id);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_DefaultSavedView_ClearsOrganizationReferenceAndLeavesUserReference()
+ {
+ var sharedView = await CreateSavedViewAsync("Delete Default Home", "status:open", "events");
+ Assert.NotNull(sharedView);
+
+ await SendRequestAsync(r => r
+ .Put()
+ .AsGlobalAdminUser()
+ .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-view-defaults", "user")
+ .Content(new UpdateSavedViewDefault { SavedViewId = sharedView.Id })
+ .StatusCodeShouldBeOk()
+ );
+ await SendRequestAsync(r => r
+ .Put()
+ .AsGlobalAdminUser()
+ .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-view-defaults", "organization")
+ .Content(new UpdateSavedViewDefault { SavedViewId = sharedView.Id })
+ .StatusCodeShouldBeOk()
+ );
+
+ await SendRequestAsync(r => r
+ .Delete()
+ .AsGlobalAdminUser()
+ .AppendPaths("saved-views", sharedView.Id)
+ .StatusCodeShouldBeAccepted()
+ );
+
+ var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL);
+ var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID);
+
+ Assert.NotNull(user);
+ Assert.Contains(user.OrganizationPreferences, preference => preference.DefaultSavedViewId == sharedView.Id);
+ Assert.NotNull(organization);
+ Assert.Null(organization.DefaultSavedViewId);
+ }
+
[Fact]
public async Task DeleteAsync_OwnOrganizationWideFilter_Succeeds()
{
@@ -2776,4 +2982,22 @@ private static bool IsPredefinedSavedView(ViewSavedView savedView)
|| IsPredefinedSavedView(savedView, "stacks", "Most Used Features");
}
+ private async Task> GetCurrentUserOrganizationPreferencesAsync()
+ {
+ var currentUser = await SendRequestAsAsync(r => r
+ .AsGlobalAdminUser()
+ .AppendPath("users/me")
+ .StatusCodeShouldBeOk()
+ );
+
+ return currentUser.GetProperty("organization_preferences")
+ .EnumerateArray()
+ .Select(preference => new UserOrganizationPreference
+ {
+ OrganizationId = preference.GetProperty("organization_id").GetString()!,
+ DefaultSavedViewId = preference.GetProperty("default_saved_view_id").GetString()!
+ })
+ .ToList();
+ }
+
}
diff --git a/tests/http/saved-views.http b/tests/http/saved-views.http
index 3522d27963..d34137033f 100644
--- a/tests/http/saved-views.http
+++ b/tests/http/saved-views.http
@@ -77,6 +77,42 @@ Content-Type: application/json
@savedViewId = {{newSavedView.response.body.$.id}}
+### Set personal saved view default
+PUT {{apiUrl}}/organizations/{{organizationId}}/saved-view-defaults/user
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{
+ "saved_view_id": "{{savedViewId}}"
+}
+
+### Clear personal saved view default
+PUT {{apiUrl}}/organizations/{{organizationId}}/saved-view-defaults/user
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{
+ "saved_view_id": null
+}
+
+### Set organization saved view default
+PUT {{apiUrl}}/organizations/{{organizationId}}/saved-view-defaults/organization
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{
+ "saved_view_id": "{{savedViewId}}"
+}
+
+### Clear organization saved view default
+PUT {{apiUrl}}/organizations/{{organizationId}}/saved-view-defaults/organization
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{
+ "saved_view_id": null
+}
+
### Create private saved view
POST {{apiUrl}}/organizations/{{organizationId}}/saved-views
Authorization: Bearer {{token}}