Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
1c7797a
Add saved view home defaults
ejsmith Aug 23, 2026
db91d85
Update endpoint manifest snapshot
ejsmith Aug 23, 2026
07b90ec
Align navigation tests with saved view defaults
ejsmith Aug 23, 2026
6068f27
Address saved view default review feedback
ejsmith Aug 23, 2026
18a1b92
Allow global admin personal saved view defaults
ejsmith Aug 23, 2026
d6cb744
Clear saved view defaults on organization deletion
ejsmith Aug 23, 2026
3391ffe
Refresh saved view default cache entries
ejsmith Aug 23, 2026
90de53b
Make saved view default cleanup recoverable
ejsmith Aug 23, 2026
f40737f
Serialize saved view default mutations
ejsmith Aug 23, 2026
8d0b296
Harden saved view default synchronization
ejsmith Aug 24, 2026
3478d08
Merge remote-tracking branch 'origin/main' into feature/saved-view-ho…
ejsmith Aug 24, 2026
c93b140
Document saved view delete conflicts
ejsmith Aug 24, 2026
ccd20a7
Patch saved view defaults atomically
ejsmith Aug 24, 2026
326233e
Detect stale user and organization saves
ejsmith Aug 24, 2026
44eec9e
Resolve duplicate saved view preferences
ejsmith Aug 24, 2026
e74a2b9
Serialize saved view defaults with membership changes
ejsmith Aug 24, 2026
95fc529
Make saved view default cleanup side effect free
ejsmith Aug 24, 2026
8491d73
Serialize organization deletion with saved view defaults
ejsmith Aug 24, 2026
68a546b
Preserve optimistic versions through repository caching
ejsmith Aug 24, 2026
ac334b6
Make organization deletion conflict safe
ejsmith Aug 24, 2026
f9e33a8
Allow organization deletion cleanup to resume
ejsmith Aug 24, 2026
a3cd0a4
Update versioned entity API contract
ejsmith Aug 24, 2026
08057cf
Merge remote-tracking branch 'origin/main' into feature/saved-view-ho…
ejsmith Aug 24, 2026
a7c28f0
Make versioned organization operations conflict safe
ejsmith Aug 24, 2026
2afd291
Harden versioned saved view operations
ejsmith Aug 24, 2026
36d05e9
Make billing persistence conflict safe
ejsmith Aug 24, 2026
adbe4d3
Simplify saved view default persistence
ejsmith Aug 24, 2026
b5bd198
Preserve saved view default consistency
ejsmith Aug 24, 2026
89ddb4e
Merge remote-tracking branch 'origin/main' into feature/saved-view-ho…
ejsmith Aug 24, 2026
eaeb0a6
Clean up saved view defaults on organization deletion
ejsmith Aug 24, 2026
af188ba
Avoid indexing saved view defaults
ejsmith Aug 24, 2026
ede97f4
Merge remote-tracking branch 'origin/main' into feature/saved-view-ho…
ejsmith Aug 24, 2026
4514839
Update saved view home E2E setup
ejsmith Aug 24, 2026
a211b5a
Simplify saved view default loading
ejsmith Aug 24, 2026
8a416c1
Handle complete saved view startup data
ejsmith Aug 24, 2026
5bb8b0b
Merge branch 'main' into feature/saved-view-home-defaults
ejsmith Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/Exceptionless.Core/Models/Organization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ public Organization()
[Required]
public string Name { get; set; } = null!;

/// <summary>
/// The shared saved view used as the default landing view when a user has not selected a personal default.
/// </summary>
[ObjectId]
public string? DefaultSavedViewId { get; set; }

[StringLength(2000)]
public string? IconFileName { get; set; }

Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Core/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public record User : IIdentity, IHaveDates, IValidatableObject
public string? PasswordResetToken { get; set; }
public DateTime PasswordResetTokenExpiration { get; set; }
public ICollection<OAuthAccount> OAuthAccounts { get; init; } = new Collection<OAuthAccount>();
public ICollection<UserOrganizationPreference> OrganizationPreferences { get; init; } = new Collection<UserOrganizationPreference>();

/// <summary>
/// Gets or sets the users Full Name.
Expand Down
13 changes: 13 additions & 0 deletions src/Exceptionless.Core/Models/UserOrganizationPreference.cs
Original file line number Diff line number Diff line change
@@ -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!;
}
2 changes: 2 additions & 0 deletions src/Exceptionless.Core/Services/OrganizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ public async Task<long> 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);
Comment thread
ejsmith marked this conversation as resolved.
Comment thread
ejsmith marked this conversation as resolved.
Comment thread
ejsmith marked this conversation as resolved.
usersToUpdate.Add(user);
}
}
Expand Down
42 changes: 42 additions & 0 deletions src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpIResult> resultMapper,
[FromBody] UpdateSavedViewDefault savedViewDefault)
=> (await mediator.InvokeAsync<Result<UpdateSavedViewDefault>>(new SavedViewMessages.UpdateUserSavedViewDefault(organizationId, savedViewDefault))).ToHttpResult(resultMapper))
.Accepts<UpdateSavedViewDefault>("application/json", "application/*+json")
.Produces<UpdateSavedViewDefault>()
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
Comment thread
ejsmith marked this conversation as resolved.
.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<HttpIResult> resultMapper,
[FromBody] UpdateSavedViewDefault savedViewDefault)
=> (await mediator.InvokeAsync<Result<UpdateSavedViewDefault>>(new SavedViewMessages.UpdateOrganizationSavedViewDefault(organizationId, savedViewDefault))).ToHttpResult(resultMapper))
.Accepts<UpdateSavedViewDefault>("application/json", "application/*+json")
.Produces<UpdateSavedViewDefault>()
.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<HttpIResult> resultMapper,
[FromBody] NewSavedView savedView) =>
{
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,8 @@ public async Task<Result> 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
{
Expand Down
79 changes: 79 additions & 0 deletions src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ namespace Exceptionless.Web.Api.Handlers;
public partial class SavedViewHandler(
ISavedViewRepository repository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
ILockProvider lockProvider,
IQueue<WorkItemData> workItemQueue,
ApiMapper mapper,
Expand Down Expand Up @@ -82,6 +83,68 @@ public async Task<Result<ViewSavedView>> Handle(GetSavedViewById message)
return MapToViewModel(model);
}

public async Task<Result<UpdateSavedViewDefault>> 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."));
Comment thread
ejsmith marked this conversation as resolved.
Comment thread
ejsmith marked this conversation as resolved.
}

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());
Comment thread
ejsmith marked this conversation as resolved.
return message.Default;
}

public async Task<Result<UpdateSavedViewDefault>> 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));
Comment thread
ejsmith marked this conversation as resolved.
return message.Default;
}

public async Task<Result<ViewSavedView>> Handle(CreateSavedView message)
{
if (!HttpContext.Request.IsInOrganization(message.OrganizationId))
Expand Down Expand Up @@ -258,6 +321,7 @@ public async Task<Result<ModelActionResults>> Handle(DeleteSavedViews message)
if (deletableItems.Count == 0)
return results.Failure.Count == 1 ? Result<ModelActionResults>.FromResult(PermissionToResult(results.Failure.First())) : results;

await ClearDefaultReferencesAsync(deletableItems);
Comment thread
ejsmith marked this conversation as resolved.
await repository.RemoveAsync(deletableItems);

if (results.Failure.Count == 0)
Expand Down Expand Up @@ -454,6 +518,21 @@ private ViewSavedView MapToViewModel(SavedView model)

private List<ViewSavedView> MapToViewModels(IEnumerable<SavedView> models) => models.Select(MapToViewModel).ToList();

private async Task ClearDefaultReferencesAsync(IReadOnlyCollection<SavedView> 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<TDestination>(ICollection<TDestination> models)
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptionless.Web/Api/Messages/SavedViewMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(?:[?#]|$)/);
});
});
91 changes: 91 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading