From d5ed193bf755900ce55674899eb78708690250fd Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 11 Aug 2026 15:18:03 -0500 Subject: [PATCH 01/20] Add adaptive guided tours --- .../Models/Data/ProductTourProgress.cs | 22 + src/Exceptionless.Core/Models/User.cs | 2 + .../Api/Endpoints/UserEndpoints.cs | 19 + .../Api/Handlers/UserHandler.cs | 54 ++ .../Api/Messages/UserMessages.cs | 1 + .../ClientApp/e2e/fixtures/api-client.ts | 9 + .../ClientApp/e2e/fixtures/e2e-test.ts | 1 + .../e2e/support/exceptionless-journey.ts | 12 + .../ClientApp/e2e/tests/product-tours.e2e.ts | 48 ++ .../ClientApp/package-lock.json | 7 + src/Exceptionless.Web/ClientApp/package.json | 1 + .../components/assistant-panel.svelte | 1 + .../components/event-detail-sheet.svelte | 4 +- .../src/lib/features/product-tours/anchors.ts | 27 + .../features/product-tours/catalog.test.ts | 51 ++ .../src/lib/features/product-tours/catalog.ts | 283 ++++++++ .../components/product-tour-catalog.svelte | 50 ++ .../components/product-tour-welcome.svelte | 48 ++ .../product-tour-welcome.svelte.test.ts | 43 ++ .../components/product-tours.svelte | 630 ++++++++++++++++++ .../product-tours/eligibility.test.ts | 16 + .../lib/features/product-tours/eligibility.ts | 5 + .../features/product-tours/product-tours.css | 33 + .../features/product-tours/state.svelte.ts | 22 + .../features/product-tours/telemetry.test.ts | 16 + .../lib/features/product-tours/telemetry.ts | 23 + .../src/lib/features/product-tours/types.ts | 49 ++ .../lib/features/saved-views/api.svelte.ts | 6 +- .../components/save-view-dialog.svelte | 46 +- .../components/saved-view-picker.svelte | 14 +- .../src/lib/features/users/api.svelte.ts | 23 +- .../src/lib/features/users/models.ts | 15 +- .../ClientApp/src/lib/generated/api.ts | 103 +++ .../ClientApp/src/lib/generated/schemas.ts | 103 +++ .../(app)/(components)/layouts/navbar.svelte | 2 + .../(components)/layouts/sidebar-user.svelte | 20 +- .../(app)/(components)/layouts/sidebar.svelte | 4 +- .../(components)/navigation-command.svelte | 31 + .../navigation-command.svelte.test.ts | 38 ++ .../ClientApp/src/routes/(app)/+layout.svelte | 58 +- .../src/routes/(app)/event/+page.svelte | 40 +- .../(app)/organization/add/+page.svelte | 4 +- .../[projectId]/configure/+page.svelte | 45 +- .../src/routes/(app)/project/add/+page.svelte | 3 +- .../Models/User/UpdateProductTourProgress.cs | 13 + .../Models/User/ViewCurrentUser.cs | 11 +- .../Exceptionless.Tests/Api/Data/openapi.json | 131 +++- .../Api/Endpoints/UserEndpointTests.cs | 174 +++++ .../Api/OpenApiSnapshotTests.cs | 8 + .../Serializer/Models/UserSerializerTests.cs | 55 ++ tests/http/users.http | 10 + 51 files changed, 2390 insertions(+), 44 deletions(-) create mode 100644 src/Exceptionless.Core/Models/Data/ProductTourProgress.cs create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/anchors.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-catalog.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts create mode 100644 src/Exceptionless.Web/Models/User/UpdateProductTourProgress.cs diff --git a/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs b/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs new file mode 100644 index 0000000000..285da7c425 --- /dev/null +++ b/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs @@ -0,0 +1,22 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Exceptionless.Core.Models.Data; + +public record ProductTourProgress +{ + public int Version { get; set; } + public ProductTourStatus Status { get; set; } + public DateTime UpdatedUtc { get; set; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ProductTourStatus +{ + [JsonStringEnumMemberName("dismissed")] + [EnumMember(Value = "dismissed")] + Dismissed, + [JsonStringEnumMemberName("completed")] + [EnumMember(Value = "completed")] + Completed +} diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs index 9e622dce0f..cfa2b6e494 100644 --- a/src/Exceptionless.Core/Models/User.cs +++ b/src/Exceptionless.Core/Models/User.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; using Exceptionless.Core.Attributes; +using Exceptionless.Core.Models.Data; using Foundatio.Repositories.Models; namespace Exceptionless.Core.Models; @@ -23,6 +24,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 IDictionary ProductTours { get; init; } = new Dictionary(StringComparer.Ordinal); /// /// Gets or sets the users Full Name. diff --git a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs index 44ce3acd29..3928b0d721 100644 --- a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs @@ -37,6 +37,25 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder } }); + group.MapPut("users/me/product-tours/{tourId:minlength(1):maxlength(64)}", async (string tourId, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] UpdateProductTourProgress? progress) + => progress is null ? ApiValidation.MissingRequestBody() : (await mediator.InvokeAsync>(new UserMessages.UpdateCurrentUserProductTour(tourId, progress))).ToHttpResult(resultMapper)) + .Accepts(false, "application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Update current user product tour progress") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The versioned product tour outcome.", + RequestBodyRequired = true, + ParameterDescriptions = new() { + ["tourId"] = "The stable product tour identifier.", + }, + ResponseDescriptions = new() { + ["422"] = "The product tour progress is invalid.", + ["404"] = "The current user could not be found.", + } + }); + group.MapGet("users/me/oauth-grants", async (IMediator mediator, IMediatorResultMapper resultMapper) => (await mediator.InvokeAsync>>(new UserMessages.GetCurrentUserOAuthGrants())).ToHttpResult(resultMapper)) .Produces>() diff --git a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs index 92e9bd563e..b374375d30 100644 --- a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs @@ -3,6 +3,7 @@ using Exceptionless.Core.Extensions; using Exceptionless.Core.Mail; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories; using Exceptionless.DateTimeExtensions; using Exceptionless.Web.Api.Infrastructure; @@ -14,8 +15,10 @@ using Exceptionless.Web.Models.OAuth; using Exceptionless.Web.Utility; using Foundatio.Caching; +using Foundatio.Lock; using Foundatio.Repositories; using Foundatio.Mediator; +using System.Text.RegularExpressions; namespace Exceptionless.Web.Api.Handlers; @@ -26,6 +29,7 @@ public class UserHandler( IOAuthTokenRepository oauthTokenRepository, IOAuthApplicationRepository oauthApplicationRepository, ICacheClient cacheClient, + ILockProvider lockProvider, IMailer mailer, ApiMapper mapper, IntercomOptions intercomOptions, @@ -33,6 +37,8 @@ public class UserHandler( IHttpContextAccessor httpContextAccessor, ILoggerFactory loggerFactory) { + private const int MaximumProductTours = 32; + private static readonly Regex ProductTourIdRegex = new("^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.CultureInvariant); private readonly ICacheClient _cache = new ScopedCacheClient(cacheClient, "User"); private readonly ILogger _logger = loggerFactory.CreateLogger(); private HttpContext HttpContext => httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is unavailable."); @@ -49,6 +55,54 @@ public async Task> Handle(GetCurrentUser message) }; } + public async Task> Handle(UpdateCurrentUserProductTour message) + { + if (!ProductTourIdRegex.IsMatch(message.TourId)) + return Result.Invalid(ValidationError.Create("tour_id", "Tour id can only contain lowercase letters, numbers, and single dashes.")); + + string currentUserId = GetCurrentUserId(); + Result? result = null; + bool lockAcquired = await lockProvider.TryUsingAsync($"product-tours:{currentUserId}", async () => + { + result = await UpdateCurrentUserProductTourAsync(currentUserId, message); + }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10)); + + return lockAcquired && result is not null ? result : Result.Error("Unable to update product tour progress."); + } + + private async Task> UpdateCurrentUserProductTourAsync(string currentUserId, UpdateCurrentUserProductTour message) + { + var currentUser = await GetModelAsync(currentUserId, useCache: false); + if (currentUser is null) + return Result.NotFound("User not found."); + + bool isNewTour = !currentUser.ProductTours.TryGetValue(message.TourId, out var existingProgress); + if (isNewTour && currentUser.ProductTours.Count >= MaximumProductTours) + return Result.Invalid(ValidationError.Create("tour_id", $"A user cannot track more than {MaximumProductTours} product tours.")); + + var requestedStatus = message.Progress.Status!.Value; + if (existingProgress is null + || message.Progress.Version > existingProgress.Version + || (message.Progress.Version == existingProgress.Version + && existingProgress.Status == ProductTourStatus.Dismissed + && requestedStatus == ProductTourStatus.Completed)) + { + currentUser.ProductTours[message.TourId] = new ProductTourProgress + { + Version = message.Progress.Version, + Status = requestedStatus, + UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime + }; + + await repository.SaveAsync(currentUser, o => o.Cache()); + } + + return new ViewCurrentUser(currentUser, intercomOptions) + { + AvatarUrl = GetUserAvatarUrl(currentUser.Id, currentUser.AvatarFileName) + }; + } + public async Task>> Handle(GetCurrentUserOAuthGrants message) { var tokens = new List(); diff --git a/src/Exceptionless.Web/Api/Messages/UserMessages.cs b/src/Exceptionless.Web/Api/Messages/UserMessages.cs index 7cc329710b..ca1435b293 100644 --- a/src/Exceptionless.Web/Api/Messages/UserMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/UserMessages.cs @@ -6,6 +6,7 @@ namespace Exceptionless.Web.Api.Messages; public record GetCurrentUser; public record GetCurrentUserOAuthGrants; public record RevokeCurrentUserOAuthGrant(string Id); +public record UpdateCurrentUserProductTour(string TourId, UpdateProductTourProgress Progress); public record GetUserById(string Id); public record GetUsersByOrganization(string OrganizationId, int Page, int Limit); public record UpdateUserMessage(string Id, Delta Changes); diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index 909b6f700d..cf5d84adbf 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -301,6 +301,15 @@ export class E2EApiClient { await expectStatus(response, [202], 'submit event'); } + async updateProductTour(token: string, tourId: string, version: number, status: 'completed' | 'dismissed'): Promise { + const response = await this.request.put(this.url(`users/me/product-tours/${tourId}`), { + data: { status, version }, + headers: this.authHeaders(token) + }); + + await expectStatus(response, [200], 'update product tour'); + } + async waitForCurrentUserDeleted(token: string, timeoutMs = 30_000): Promise { await waitForCondition( async () => !(await this.getCurrentUser(token)), diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts index 154144230d..3f2def61aa 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -85,6 +85,7 @@ export const test = base.extend({ const project = await e2eApi.createProject(userToken, organization.id, projectName); projectId = project.id; const projectToken = await e2eApi.getProjectDefaultToken(userToken, project.id); + await e2eApi.updateProductTour(userToken, 'welcome', 1, 'dismissed'); await page.addInitScript( ({ organizationId, token }) => { diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts index 27e53fd48d..0cee1689bc 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts @@ -219,6 +219,18 @@ export class ExceptionlessE2EJourney { await expect(setupHeading).toBeVisible({ timeout: 30_000 }); + const welcomeDialog = this.page.getByRole('dialog', { name: 'Welcome to the new Exceptionless UI' }); + await expect(welcomeDialog).toBeVisible({ timeout: 30_000 }); + const skippedResponse = this.page.waitForResponse( + (response) => response.request().method() === 'PUT' && response.url().includes('/api/v2/users/me/product-tours/welcome') + ); + await welcomeDialog.getByRole('button', { name: 'Skip' }).click(); + expect((await skippedResponse).ok()).toBe(true); + await expect(welcomeDialog).toBeHidden(); + await this.page.reload(); + await expect(setupHeading).toBeVisible(); + await expect(welcomeDialog).toBeHidden(); + await this.page.getByLabel('Organization Name', { exact: true }).fill(this.organizationName); await this.page.getByLabel('Project Name', { exact: true }).fill(this.projectName); await this.page.getByRole('button', { name: 'Continue' }).click(); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts new file mode 100644 index 0000000000..170802708b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -0,0 +1,48 @@ +import { expect, test } from '../fixtures/e2e-test'; + +test('completed and dismissed tours remain replayable from command search', async ({ page }) => { + await page.goto('/next/stack'); + + await startTourFromCommand(page, 'Explore the new UI'); + const tour = page.locator('.driver-popover'); + await expect(tour.getByText('Your workspace navigation')).toBeVisible(); + await tour.getByRole('button', { name: 'Close' }).click(); + await expect(tour).toBeHidden(); + + await startTourFromCommand(page, 'Explore the new UI'); + await expect(tour.getByText('Your workspace navigation')).toBeVisible(); + await tour.getByRole('button', { name: 'Close' }).click(); +}); + +test('Meet Exie opens contextual UI without sending a provider request', async ({ page }) => { + let chatRequests = 0; + await page.route('**/api/v2/assistant/access**', async (route) => { + await route.fulfill({ + contentType: 'application/json', + json: { enabled: true, has_access: true, message: null, upgrade_required: false } + }); + }); + page.on('request', (request) => { + if (new URL(request.url()).pathname === '/api/v2/assistant/chat') { + chatRequests += 1; + } + }); + + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Meet Exie'); + + const tour = page.locator('.driver-popover'); + await expect(tour.getByText('Open Exie')).toBeVisible(); + await tour.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('dialog', { name: 'Exie' })).toBeVisible(); + await expect(tour.getByText('You control every request')).toBeVisible(); + expect(chatRequests).toBe(0); + + await tour.getByRole('button', { name: 'Done' }).click(); + expect(chatRequests).toBe(0); +}); + +async function startTourFromCommand(page: import('@playwright/test').Page, title: string): Promise { + await page.getByRole('button', { name: 'Search Exceptionless' }).click(); + await page.getByRole('dialog').getByText(title, { exact: true }).click(); +} diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index 5d0f710610..c28b4371c1 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -24,6 +24,7 @@ "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.4.13", + "driver.js": "^1.8.0", "layerchart": "^2.0.2", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", @@ -5958,6 +5959,12 @@ "url": "https://dotenvx.com" } }, + "node_modules/driver.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.8.0.tgz", + "integrity": "sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index 85f341bc97..478dd41c43 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -92,6 +92,7 @@ "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.4.13", + "driver.js": "^1.8.0", "layerchart": "^2.0.2", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte index 302286e333..8ea7a7560f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte @@ -263,6 +263,7 @@ {/snippet} {#if eventId} - (eventId = newId)} /> +
+ (eventId = newId)} /> +
{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/anchors.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/anchors.ts new file mode 100644 index 0000000000..5bcfb24d63 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/anchors.ts @@ -0,0 +1,27 @@ +export const PRODUCT_TOUR_ANCHORS = { + appNavigation: 'app-navigation', + commandSearch: 'command-search', + eventDetails: 'event-details', + eventList: 'event-list', + exiePanel: 'exie-panel', + exieTrigger: 'exie-trigger', + helpMenu: 'help-menu', + projectConfigureInstructions: 'project-configure-instructions', + projectConfigurePlatform: 'project-configure-platform', + projectConfigureToken: 'project-configure-token', + projectConfigureWaiting: 'project-configure-waiting', + projectName: 'project-name', + projectSetupSubmit: 'project-setup-submit', + savedViewDialog: 'saved-view-dialog', + savedViewName: 'saved-view-name', + savedViewNavigation: 'saved-view-navigation', + savedViewPrivate: 'saved-view-private', + savedViewSaveAs: 'saved-view-save-as', + savedViewSubmit: 'saved-view-submit', + savedViewTrigger: 'saved-view-trigger', + setupOrganizationName: 'setup-organization-name' +} as const; + +export function productTourSelector(anchor: string): string { + return `[data-tour="${anchor}"]`; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts new file mode 100644 index 0000000000..0ac93c23ae --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import type { ProductTourContext } from './types'; + +import { getProductTourItems, getRecommendedProductTourId, productTourCatalog } from './catalog'; + +function context(overrides: Partial = {}): ProductTourContext { + return { + isSetupPage: false, + organizationId: 'organization-id', + pathname: '/next/event', + projects: [], + ...overrides + }; +} + +describe('product tour catalog', () => { + it('contains the five stable, versioned guides with unique step ids', () => { + expect(productTourCatalog.map((tour) => tour.id)).toEqual([ + 'new-ui-overview', + 'configure-project', + 'create-saved-view', + 'investigate-error', + 'meet-exie' + ]); + + for (const tour of productTourCatalog) { + expect(tour.version).toBeGreaterThan(0); + expect(tour.keywords.length).toBeGreaterThan(0); + const steps = tour.getSteps(context()); + expect(new Set(steps.map((step) => step.id)).size).toBe(steps.length); + expect(steps.every((step) => (step.anchor ? step.anchor.length > 0 : true))).toBe(true); + } + }); + + it('recommends setup without an organization or with an unconfigured project', () => { + expect(getRecommendedProductTourId(context({ organizationId: undefined }))).toBe('configure-project'); + expect(getRecommendedProductTourId(context({ projects: [{ is_configured: false } as never] }))).toBe('configure-project'); + expect(getRecommendedProductTourId(context({ projects: [{ is_configured: true } as never] }))).toBe('new-ui-overview'); + }); + + it('returns concrete availability reasons while retaining unavailable guides in the catalog', () => { + const items = getProductTourItems(context({ assistantAccess: { enabled: false } as never, organizationId: undefined })); + const exie = items.find((item) => item.id === 'meet-exie'); + const investigate = items.find((item) => item.id === 'investigate-error'); + + expect(exie?.availability).toEqual({ available: false, reason: 'Exie is not enabled by this Exceptionless installation.' }); + expect(investigate?.availability.available).toBe(false); + expect(investigate?.availability.reason).toBeTruthy(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts new file mode 100644 index 0000000000..13a5aadeb8 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts @@ -0,0 +1,283 @@ +import type { ProductTourProgress } from '$features/users/models'; + +import type { ProductTourContext, ProductTourDefinition, ProductTourListItem } from './types'; + +import { PRODUCT_TOUR_ANCHORS } from './anchors'; + +function requireApplicationShell(context: ProductTourContext) { + if (context.isSetupPage || !context.organizationId) { + return { available: false, reason: 'Finish organization setup to explore the application UI.' }; + } + + return { available: true }; +} + +function requireOrganization(context: ProductTourContext) { + return context.organizationId ? { available: true } : { available: false, reason: 'Create an organization and project first.' }; +} + +export const productTourCatalog: readonly ProductTourDefinition[] = [ + { + description: 'Learn navigation, command search, saved views, Exie, and where to get help.', + getAvailability: requireApplicationShell, + getSteps: (context) => [ + { + anchor: PRODUCT_TOUR_ANCHORS.appNavigation, + description: 'Move between dashboards, saved views, and settings from one consistent navigation area.', + id: 'navigation', + title: 'Your workspace navigation' + }, + { + anchor: PRODUCT_TOUR_ANCHORS.commandSearch, + description: 'Open this search or press / to jump to pages, projects, events, stacks, and actions.', + id: 'command-search', + title: 'Find anything quickly' + }, + { + anchor: PRODUCT_TOUR_ANCHORS.savedViewNavigation, + description: 'Saved views capture filters, time, sorting, charts, stats, and columns for quick reuse.', + id: 'saved-views', + optional: true, + title: 'Reuse configured views' + }, + ...(context.assistantAccess?.enabled + ? [ + { + anchor: PRODUCT_TOUR_ANCHORS.exieTrigger, + description: context.assistantAccess.has_access + ? 'Exie can investigate the page or error you are viewing. You always choose whether to send a prompt.' + : (context.assistantAccess.message ?? 'Exie is available after upgrading your organization plan.'), + id: 'exie', + optional: true, + title: 'Ask Exie with context' + } + ] + : []), + { + anchor: PRODUCT_TOUR_ANCHORS.helpMenu, + description: 'Open Help for documentation, support, keyboard shortcuts, and these guided tours.', + id: 'help', + optional: true, + showDone: true, + title: 'Help is always nearby' + } + ], + id: 'new-ui-overview', + keywords: ['navigation', 'new ui', 'search', 'command', 'help', 'saved views'], + title: 'Explore the new UI', + version: 1 + }, + { + description: 'Create or resume a project, connect an SDK, and wait for its first real event.', + getAvailability: () => ({ available: true }), + getSteps: (context) => { + if (!context.organizationId) { + return [ + { + anchor: PRODUCT_TOUR_ANCHORS.setupOrganizationName, + description: 'Create the organization that will own your projects and error data.', + id: 'organization-name', + title: 'Name your organization' + }, + { + anchor: PRODUCT_TOUR_ANCHORS.projectName, + description: 'Use the application or service name that will send events to Exceptionless.', + id: 'project-name', + title: 'Name your first project' + }, + { + anchor: PRODUCT_TOUR_ANCHORS.projectSetupSubmit, + description: 'Create both records, then continue to the SDK instructions.', + id: 'create-setup', + resumeStepId: 'choose-platform', + title: 'Continue to configuration' + } + ]; + } + + if (context.pathname.includes('/project/add')) { + return [ + { + anchor: PRODUCT_TOUR_ANCHORS.projectName, + description: 'Use the application or service name that will send events to Exceptionless.', + id: 'project-name', + title: 'Name your project' + }, + { + anchor: PRODUCT_TOUR_ANCHORS.projectSetupSubmit, + description: 'Create the project, then continue to its SDK instructions.', + id: 'create-project', + resumeStepId: 'choose-platform', + title: 'Continue to configuration' + } + ]; + } + + return [ + { + anchor: PRODUCT_TOUR_ANCHORS.projectConfigurePlatform, + description: 'Choose the platform that matches the application you are connecting.', + id: 'choose-platform', + title: 'Choose your SDK' + }, + { + anchor: PRODUCT_TOUR_ANCHORS.projectConfigureToken, + description: 'The generated client token identifies this project. Keep it with your application configuration.', + id: 'client-token', + optional: true, + title: 'Use the project token' + }, + { + anchor: PRODUCT_TOUR_ANCHORS.projectConfigureInstructions, + description: 'Follow these instructions in your own application. This guide stays ready while you work outside Exceptionless.', + id: 'sdk-instructions', + presentation: 'inline', + title: 'Connect your application' + }, + { + anchor: PRODUCT_TOUR_ANCHORS.projectConfigureWaiting, + description: 'The guide completes only after this project sends its first real event.', + id: 'wait-for-event', + presentation: 'inline', + showDone: false, + title: 'Waiting for the first event' + } + ]; + }, + id: 'configure-project', + keywords: ['add project', 'configure', 'sdk', 'api key', 'first event'], + title: 'Configure a project', + version: 1 + }, + { + description: 'Save the current Events configuration as a private view that only you can see.', + getAvailability: requireOrganization, + getSteps: () => [ + { + advanceOnClick: true, + anchor: PRODUCT_TOUR_ANCHORS.savedViewTrigger, + description: 'A saved view can capture the current filters, date range, sort, display choices, and columns.', + id: 'open-view-menu', + title: 'Open View settings' + }, + { + advanceOnClick: true, + anchor: PRODUCT_TOUR_ANCHORS.savedViewSaveAs, + description: 'Save As creates a reusable view without changing any existing view.', + id: 'save-as', + title: 'Create a new view', + waitForElement: 5000 + }, + { + anchor: PRODUCT_TOUR_ANCHORS.savedViewName, + description: 'Choose a meaningful name. The URL name is generated automatically.', + id: 'name-view', + presentation: 'inline', + title: 'Name the view', + waitForElement: 5000 + }, + { + anchor: PRODUCT_TOUR_ANCHORS.savedViewPrivate, + description: 'Private is enabled for this guide so the practice view does not affect your organization.', + id: 'private-view', + presentation: 'inline', + title: 'Keep it private' + }, + { + anchor: PRODUCT_TOUR_ANCHORS.savedViewSubmit, + description: 'Save when ready. Completion is recorded only after the view is successfully created and loaded.', + id: 'save-view', + presentation: 'inline', + showDone: false, + title: 'Create the saved view' + } + ], + id: 'create-saved-view', + keywords: ['saved view', 'filter', 'columns', 'private', 'dashboard'], + title: 'Create a saved view', + version: 1 + }, + { + description: 'Open a real error and learn where to find its exception, request, environment, and custom data.', + getAvailability: (context) => { + if (!context.organizationId) { + return { available: false, reason: 'Create and configure a project first.' }; + } + + return context.projects.some((project) => project.event_count > 0) + ? { available: true } + : { available: false, reason: 'Send an error event before starting this guide.' }; + }, + getSteps: () => [ + { + anchor: PRODUCT_TOUR_ANCHORS.eventList, + description: 'Choose an error row to open its detail sheet. This guide never changes stack status.', + id: 'choose-error', + title: 'Open a real error' + }, + { + anchor: PRODUCT_TOUR_ANCHORS.eventDetails, + description: 'Review the summary and the available Exception, Request, Environment, trace, session, and extended-data tabs.', + id: 'inspect-details', + showDone: true, + title: 'Investigate the evidence', + waitForElement: 60000 + } + ], + id: 'investigate-error', + keywords: ['error report', 'event details', 'exception', 'request', 'environment'], + title: 'Investigate an error', + version: 1 + }, + { + description: 'See how Exie uses the current page as context without sending a prompt.', + getAvailability: (context) => + context.assistantAccess?.enabled ? { available: true } : { available: false, reason: 'Exie is not enabled by this Exceptionless installation.' }, + getSteps: (context) => [ + { + advanceOnClick: true, + anchor: PRODUCT_TOUR_ANCHORS.exieTrigger, + description: context.assistantAccess?.has_access + ? 'Open Exie to see the page context available for your next question.' + : (context.assistantAccess?.message ?? 'Open Exie to review the plan requirement.'), + id: 'open-exie', + title: 'Open Exie' + }, + { + anchor: PRODUCT_TOUR_ANCHORS.exiePanel, + description: context.assistantAccess?.has_access + ? 'Exie can investigate with your current organization, project, event, or stack context. Nothing is sent until you choose a prompt; submitted requests use metered provider usage.' + : 'This panel explains the access requirement. The guide will never start a provider request.', + id: 'exie-context', + showDone: true, + title: 'You control every request', + waitForElement: 5000 + } + ], + id: 'meet-exie', + keywords: ['exie', 'assistant', 'ai', 'help', 'investigate'], + title: 'Meet Exie', + version: 1 + } +] as const; + +export function getProductTour(id: ProductTourDefinition['id']): ProductTourDefinition { + const definition = productTourCatalog.find((tour) => tour.id === id); + if (!definition) { + throw new Error(`Unknown product tour: ${id}`); + } + + return definition; +} + +export function getProductTourItems(context: ProductTourContext, progress: Record = {}): ProductTourListItem[] { + return productTourCatalog.map((definition) => ({ + ...definition, + availability: definition.getAvailability(context), + progress: progress[definition.id] + })); +} + +export function getRecommendedProductTourId(context: ProductTourContext): ProductTourDefinition['id'] { + return !context.organizationId || context.projects.some((project) => !project.is_configured) ? 'configure-project' : 'new-ui-overview'; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-catalog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-catalog.svelte new file mode 100644 index 0000000000..c98675df78 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-catalog.svelte @@ -0,0 +1,50 @@ + + + + + + Guided Tours + Learn the new UI with short guides that use your real Exceptionless data. + + +
+ {#each items as item (item.id)} +
+
+
+
+ {#if item.progress?.status === 'completed' && item.progress.version >= item.version} + Completed + {/if} +
+
+

{item.title}

+

{item.description}

+ {#if !item.availability.available} +

{item.availability.reason}

+ {/if} +
+ +
+ {/each} +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte new file mode 100644 index 0000000000..837da4fd8d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte @@ -0,0 +1,48 @@ + + + + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + showCloseButton={false} + > + +
+
+ Welcome to the new Exceptionless UI + Take a short guided tour now, or browse the guides whenever you need them. +
+ +
+

Recommended: {recommended.title}

+

{recommended.description}

+
+ + + +
+ + +
+
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte.test.ts new file mode 100644 index 0000000000..c87d990aab --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte.test.ts @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import ProductTourWelcome from './product-tour-welcome.svelte'; + +const recommended = { + availability: { available: true }, + description: 'Learn navigation and search.', + getAvailability: vi.fn(), + getSteps: vi.fn(), + id: 'new-ui-overview' as const, + keywords: ['navigation'], + title: 'Explore the new UI', + version: 1 +}; + +describe('ProductTourWelcome', () => { + it('records only explicit chooser actions', async () => { + const onBrowse = vi.fn(); + const onSkip = vi.fn(); + const onStart = vi.fn(); + render(ProductTourWelcome, { onBrowse, onSkip, onStart, open: true, recommended }); + + await fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(onBrowse).not.toHaveBeenCalled(); + expect(onSkip).not.toHaveBeenCalled(); + expect(onStart).not.toHaveBeenCalled(); + + await fireEvent.click(screen.getByRole('button', { name: 'Explore the new UI' })); + expect(onStart).toHaveBeenCalledOnce(); + }); + + it('provides Browse Guides and Skip choices', async () => { + const onBrowse = vi.fn(); + const onSkip = vi.fn(); + render(ProductTourWelcome, { onBrowse, onSkip, onStart: vi.fn(), open: true, recommended }); + + await fireEvent.click(screen.getByRole('button', { name: 'Browse Guides' })); + expect(onBrowse).toHaveBeenCalledOnce(); + await fireEvent.click(screen.getByRole('button', { name: 'Skip' })); + expect(onSkip).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte new file mode 100644 index 0000000000..15fa54e750 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -0,0 +1,630 @@ + + + void onWelcomeSkip()} + onStart={() => void onWelcomeStart()} + {recommended} +/> + + void startTour(id, catalogSource)} /> + + + + + Create another project? + + Every accessible project is already configured. A new project uses plan capacity and will remain after the guide. + + + + (pendingConfigureSource = undefined)}>Cancel + void confirmNewProject()}>Create Project + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts new file mode 100644 index 0000000000..d738bf9261 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts @@ -0,0 +1,16 @@ +import { ProductTourStatus } from '$generated/api'; +import { describe, expect, it } from 'vitest'; + +import { shouldOfferProductTourWelcome } from './eligibility'; + +describe('product tour welcome eligibility', () => { + it('offers legacy users and a newer welcome version', () => { + expect(shouldOfferProductTourWelcome(undefined, 1)).toBe(true); + expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Completed, updated_utc: '', version: 1 }, 2)).toBe(true); + }); + + it('suppresses both explicit Start and Skip outcomes for the current version', () => { + expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Completed, updated_utc: '', version: 1 }, 1)).toBe(false); + expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Dismissed, updated_utc: '', version: 1 }, 1)).toBe(false); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts new file mode 100644 index 0000000000..cbdc62f2f9 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts @@ -0,0 +1,5 @@ +import type { ProductTourProgress } from '$features/users/models'; + +export function shouldOfferProductTourWelcome(progress: ProductTourProgress | undefined, welcomeVersion: number): boolean { + return !progress || progress.version < welcomeVersion; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css new file mode 100644 index 0000000000..8b9cb12519 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css @@ -0,0 +1,33 @@ +.driver-popover.product-tour-popover { + background: var(--popover); + color: var(--popover-foreground); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 10px 25px color-mix(in srgb, var(--foreground) 12%, transparent); + font-family: var(--font-sans); + max-width: 22rem; +} + +.product-tour-popover .driver-popover-title { + font-size: 0.95rem; + font-weight: 600; +} + +.product-tour-popover .driver-popover-description, +.product-tour-popover .driver-popover-progress-text { + color: var(--muted-foreground); +} + +.product-tour-popover .driver-popover-footer button { + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 2px); + box-shadow: none; + text-shadow: none; +} + +@media (prefers-reduced-motion: reduce) { + .driver-overlay, + .driver-popover { + transition: none !important; + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts new file mode 100644 index 0000000000..f4998f41ff --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts @@ -0,0 +1,22 @@ +import type { ProductTourId } from './types'; + +class ProductTourRuntimeState { + activeStepId = $state(); + activeTourId = $state(); + + clear(): void { + this.activeStepId = undefined; + this.activeTourId = undefined; + } + + isActive(tourId: ProductTourId): boolean { + return this.activeTourId === tourId; + } + + set(tourId: ProductTourId, stepId?: string): void { + this.activeTourId = tourId; + this.activeStepId = stepId; + } +} + +export const productTourRuntime = new ProductTourRuntimeState(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts new file mode 100644 index 0000000000..8f15e3bcdf --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { buildProductTourTelemetryEvent } from './telemetry'; + +describe('product tour telemetry', () => { + it('uses stable catalog metadata only', () => { + expect(buildProductTourTelemetryEvent('step', 'new-ui-overview', 1, 'command-palette', 'command-search')).toBe( + 'product-tour.step.new-ui-overview.v1.command-palette.command-search' + ); + }); + + it('rejects resource data and invalid versions', () => { + expect(() => buildProductTourTelemetryEvent('step', 'new-ui-overview', 1, 'catalog', 'Customer Project' as never)).toThrow(); + expect(() => buildProductTourTelemetryEvent('started', 'meet-exie', 0, 'catalog')).toThrow(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts new file mode 100644 index 0000000000..e7f43a784d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts @@ -0,0 +1,23 @@ +import type { ProductTourKey, ProductTourLaunchSource } from './types'; + +export type ProductTourTelemetryEvent = 'chooser-shown' | 'chooser-skipped' | 'chooser-started' | 'completed' | 'dismissed' | 'failed' | 'started' | 'step'; + +const SAFE_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function buildProductTourTelemetryEvent( + event: ProductTourTelemetryEvent, + id: ProductTourKey, + version: number, + source: ProductTourLaunchSource, + stepId?: string +): string { + if (!SAFE_SEGMENT.test(event) || !SAFE_SEGMENT.test(id) || !SAFE_SEGMENT.test(source) || (stepId && !SAFE_SEGMENT.test(stepId))) { + throw new Error('Product tour telemetry accepts stable catalog identifiers only.'); + } + + if (!Number.isSafeInteger(version) || version < 1) { + throw new Error('Product tour telemetry requires a positive version.'); + } + + return ['product-tour', event, id, `v${version}`, source, stepId].filter(Boolean).join('.'); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts new file mode 100644 index 0000000000..6c7aeb3ea0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts @@ -0,0 +1,49 @@ +import type { AssistantAccess } from '$features/assistant/models'; +import type { ViewProject } from '$features/projects/models'; +import type { ProductTourProgress } from '$features/users/models'; + +export interface ProductTourAvailability { + available: boolean; + reason?: string; +} +export interface ProductTourContext { + assistantAccess?: AssistantAccess; + isSetupPage: boolean; + organizationId?: string; + pathname: string; + projects: ViewProject[]; +} +export interface ProductTourDefinition { + description: string; + getAvailability: (context: ProductTourContext) => ProductTourAvailability; + getSteps: (context: ProductTourContext) => ProductTourStep[]; + id: ProductTourId; + keywords: readonly string[]; + title: string; + version: number; +} +export type ProductTourId = 'configure-project' | 'create-saved-view' | 'investigate-error' | 'meet-exie' | 'new-ui-overview'; + +export type ProductTourKey = 'welcome' | ProductTourId; + +export type ProductTourLaunchSource = 'automatic' | 'catalog' | 'command-palette' | 'help-menu'; + +export interface ProductTourListItem extends ProductTourDefinition { + availability: ProductTourAvailability; + progress?: ProductTourProgress; +} + +export type ProductTourPresentation = 'inline' | 'spotlight'; + +export interface ProductTourStep { + advanceOnClick?: boolean; + anchor?: string; + description: string; + id: string; + optional?: boolean; + presentation?: ProductTourPresentation; + resumeStepId?: string; + showDone?: boolean; + title: string; + waitForElement?: number; +} 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 a78f3a5336..78a48511d4 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 @@ -129,7 +129,9 @@ export function getSavedViewsByViewQuery(request: { route: { organizationId: str enabled: () => !!accessToken.current && !!request.route.organizationId && !!request.route.view, queryFn: async () => { const client = useFetchClient(); - const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views/${request.route.view}`); + const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views/${request.route.view}`, { + params: { limit: 1000 } + }); return response.data!; }, queryKey: queryKeys.view(request.route.organizationId, request.route.view), @@ -142,7 +144,7 @@ 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: 1000 } }); return response.data!; }, queryKey: queryKeys.organization(request.route.organizationId), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte index 95fc6808ec..4b437d0d1c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -1,10 +1,12 @@ - - + { + if (nextOpen || !saving) { + open = nextOpen; + } + }} +> + saving && event.preventDefault()} + onInteractOutside={(event) => saving && event.preventDefault()} + > Save View Save the current view configuration for quick access. + {#if defaultPrivate} + + + {/if} {#if duplicateView}
@@ -146,6 +177,7 @@
{visibleSlugError}

{/if}
-
+
Only visible to you @@ -185,8 +217,8 @@
- - + 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 fe27d81b69..3328f18b08 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 @@ -14,6 +14,7 @@ import { toFilter } from '$features/events/components/filters/helpers.svelte'; import { serializeFilters } from '$features/events/components/filters/helpers.svelte'; import { organization } from '$features/organizations/context.svelte'; + import { productTourRuntime } from '$features/product-tours/state.svelte'; import Columns3 from '@lucide/svelte/icons/columns-3'; import Pencil from '@lucide/svelte/icons/pencil'; import Plus from '@lucide/svelte/icons/plus'; @@ -51,8 +52,9 @@ filters: IFilter[]; isModified: boolean; onClearSavedView: () => void; - onLoadView: (view: SavedView) => void; + onLoadView: (view: SavedView) => Promise | void; onResetToSaved: () => void; + onSavedViewCreated?: (view: SavedView) => Promise | void; savedViews: SavedView[]; setShowChart?: (show: boolean) => void; setShowStats?: (show: boolean) => void; @@ -74,6 +76,7 @@ onClearSavedView, onLoadView, onResetToSaved, + onSavedViewCreated, savedViews, setShowChart, setShowStats, @@ -201,7 +204,9 @@ try { const result = await createMutation.mutateAsync(body); isSaveDialogOpen = false; - onLoadView(result); + await onLoadView(result); + await onSavedViewCreated?.(result); + document.dispatchEvent(new CustomEvent('product-tour:completed', { detail: { tourId: 'create-saved-view' } })); toast.success(`Saved view "${result.name}" created.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); @@ -280,7 +285,7 @@ {#snippet child({ props })} - + {/if} {/if} -
    +
    1. Choose your project type.

      - + {#if selectedProjectType} {selectedProjectType.platform}: {selectedProjectType.label} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte index 035812ae4e..8d214aee6e 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte @@ -82,6 +82,7 @@ Project Name state.isSubmitting}> {#snippet children(isSubmitting)} - + {/if} + +
+ + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte index 15fa54e750..cbf0fbec9b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -14,7 +14,7 @@ import type { ProductTourContext, ProductTourId, ProductTourKey, ProductTourLaunchSource, ProductTourListItem, ProductTourStep } from '../types'; - import { productTourSelector } from '../anchors'; + import { PRODUCT_TOUR_ANCHORS, productTourSelector } from '../anchors'; import { getProductTour, getProductTourItems, getRecommendedProductTourId } from '../catalog'; import { shouldOfferProductTourWelcome } from '../eligibility'; import { productTourRuntime } from '../state.svelte'; @@ -198,6 +198,16 @@ ); } + async function waitForCompetingOverlaysToClose(timeout = 1000): Promise { + await tick(); + const deadline = performance.now() + timeout; + while (hasCompetingOverlay() && performance.now() < deadline) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + + return !hasCompetingOverlay(); + } + export function openCatalog(source: ProductTourLaunchSource = 'catalog'): void { closeOverlays(); catalogSource = source; @@ -218,8 +228,7 @@ catalogOpen = false; welcomeOpen = false; closeOverlays(); - await tick(); - if (hasCompetingOverlay()) { + if (!(await waitForCompetingOverlaysToClose())) { toast.info('Close the open dialog or panel before starting a guided tour.'); return; } @@ -313,8 +322,10 @@ onCloseClick: () => void dismissTour(id, definition.version, source), onDestroyed: () => { const activeStepId = productTourRuntime.activeStepId; + const preservingNavigation = isNavigatingTour || (driverPath !== '' && driverPath !== pathname); + const preservingPendingEvent = id === 'investigate-error' && (activeStepId === 'choose-error' || readStoredState()?.stepId === 'choose-error'); driverInstance = undefined; - if (!isSuspendingInline && !isNavigatingTour) { + if (!isSuspendingInline && !preservingNavigation && !preservingPendingEvent) { productTourRuntime.clear(); activeSource = undefined; activeOrganizationId = undefined; @@ -324,7 +335,7 @@ } } - if (!isFinishing && !isSuspendingInline && !isNavigatingTour) { + if (!isFinishing && !isSuspendingInline && !preservingNavigation && !preservingPendingEvent) { void recordProgress(id, definition.version, 'dismissed').catch(() => toast.error('We could not save your guided-tour progress.')); } }, @@ -350,12 +361,14 @@ doneBtnText: 'Done', nextBtnText: step.advanceOnClick ? 'Continue' : 'Next', onNextClick: () => void advance(id, definition.version, source, steps, index), - showButtons: getButtons(step, index, steps.length), + showButtons: getButtons(step), title: step.title } })) }); driverPath = pathname; + productTourRuntime.set(id, firstStep.id); + writeStoredState({ source, stepId: firstStep.resumeStepId ?? firstStep.id, tourId: id, version: definition.version }); if (emitStarted) { void track('started', id, definition.version, source); @@ -364,13 +377,10 @@ driverInstance.drive(0); } - function getButtons(step: ProductTourStep, index: number, count: number): Array<'close' | 'next' | 'previous'> { + function getButtons(step: ProductTourStep): Array<'close' | 'next' | 'previous'> { const buttons: Array<'close' | 'next' | 'previous'> = ['close']; - if (index > 0) { - buttons.push('previous'); - } - if (index < count - 1 || step.showDone !== false) { + if (step.showDone !== false) { buttons.push('next'); } @@ -386,6 +396,17 @@ const next = steps[index + 1]; if (!next) { + if (step.advanceOnClick && step.resumeStepId) { + isFinishing = true; + writeStoredState({ source, stepId: step.resumeStepId, tourId: id, version }); + driverInstance?.destroy(); + driverInstance = undefined; + productTourRuntime.clear(); + activeSource = undefined; + activeOrganizationId = undefined; + return; + } + await completeTour(id, version, source); return; } @@ -414,7 +435,17 @@ return; } - driverInstance?.moveNext(); + if (driverInstance && driverPath === pathname) { + driverInstance.moveNext(); + } else { + isNavigatingTour = true; + isFinishing = true; + driverInstance?.destroy(); + driverInstance = undefined; + isNavigatingTour = false; + isFinishing = false; + await launch(id, source, next.id, false); + } } async function ensureTarget(step: ProductTourStep): Promise { @@ -423,6 +454,10 @@ } const selector = productTourSelector(step.anchor); + if (step.anchor === PRODUCT_TOUR_ANCHORS.appNavigation && !document.querySelector(selector)) { + (document.querySelector(productTourSelector('mobile-navigation-trigger')) as HTMLElement | null)?.click(); + } + if (document.querySelector(selector)) { return true; } @@ -594,12 +629,67 @@ void dismissTour(id, definition.version, activeSource); } + function onInlineAdvance(event: Event): void { + const detail = (event as CustomEvent<{ stepId?: string; tourId?: ProductTourId }>).detail; + const id = detail?.tourId; + const stepId = detail?.stepId; + if (!id || !stepId || productTourRuntime.activeTourId !== id || productTourRuntime.activeStepId !== stepId || !activeSource) { + return; + } + + const definition = getProductTour(id); + const steps = definition.getSteps(context).filter((step) => !step.optional || !step.anchor || document.querySelector(productTourSelector(step.anchor))); + const index = steps.findIndex((step) => step.id === stepId); + if (index >= 0) { + void advance(id, definition.version, activeSource, steps, index); + } + } + + function onEventOpened(event: Event): void { + const eventType = (event as CustomEvent<{ eventType?: string }>).detail?.eventType; + const stored = readStoredState(); + const isActiveChooseError = + productTourRuntime.activeTourId === 'investigate-error' && productTourRuntime.activeStepId === 'choose-error' && !!activeSource; + const isResumableChooseError = stored?.tourId === 'investigate-error' && stored.stepId === 'choose-error'; + if (eventType !== 'error' || (!isActiveChooseError && !isResumableChooseError)) { + if (productTourRuntime.activeTourId === 'investigate-error' && eventType && eventType !== 'error') { + toast.info('Choose an error event to continue this guide.'); + } + + return; + } + + const definition = getProductTour('investigate-error'); + if (!isActiveChooseError && stored) { + activeSource = stored.source; + activeOrganizationId = organizationId; + productTourRuntime.set('investigate-error', 'choose-error'); + } + + const source = activeSource ?? stored?.source; + if (!source) { + return; + } + + const steps = definition.getSteps(context).filter((step) => !step.optional || !step.anchor || document.querySelector(productTourSelector(step.anchor))); + const index = steps.findIndex((step) => step.id === 'choose-error'); + if (index >= 0) { + productTourRuntime.set('investigate-error', 'inspect-details'); + writeStoredState({ source, stepId: 'inspect-details', tourId: 'investigate-error', version: definition.version }); + void advance('investigate-error', definition.version, source, steps, index); + } + } + $effect(() => { document.addEventListener('product-tour:completed', onDomainComplete); document.addEventListener('product-tour:dismissed', onDomainDismiss); + document.addEventListener('product-tour:advance', onInlineAdvance); + document.addEventListener('product-tour:event-opened', onEventOpened); return () => { document.removeEventListener('product-tour:completed', onDomainComplete); document.removeEventListener('product-tour:dismissed', onDomainDismiss); + document.removeEventListener('product-tour:advance', onInlineAdvance); + document.removeEventListener('product-tour:event-opened', onEventOpened); }; }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte index 4b437d0d1c..d14d73ea55 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -1,12 +1,12 @@ { if (nextOpen || !saving) { open = nextOpen; + if (!nextOpen) { + onCancel?.(); + } } }} > @@ -136,21 +145,31 @@ Save View Save the current view configuration for quick access. - {#if defaultPrivate} - - + {#if defaultPrivate && productTourRuntime.activeStepId === 'name-view'} + + document.dispatchEvent(new CustomEvent('product-tour:advance', { detail: { stepId: 'name-view', tourId: 'create-saved-view' } }))} + onDismiss={dismissTour} + title="Review and name your view" + tourId="create-saved-view" + /> + {:else if defaultPrivate && productTourRuntime.activeStepId === 'private-view'} + + document.dispatchEvent(new CustomEvent('product-tour:advance', { detail: { stepId: 'private-view', tourId: 'create-saved-view' } }))} + onDismiss={dismissTour} + title="Keep it private" + tourId="create-saved-view" + /> + {:else if defaultPrivate && productTourRuntime.activeStepId === 'save-view'} + {/if} {#if duplicateView}
@@ -159,9 +178,10 @@ instead, or save with a different name. @@ -212,12 +232,19 @@
- Only visible to you + {defaultPrivate ? 'Required for this guided practice view' : 'Only visible to you'}
- +
- + 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 3328f18b08..40ec08b485 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 @@ -190,7 +190,7 @@ columns: getSavedColumnSettings(), filter: currentFilterString || undefined, filter_definitions: filterDefinitions, - is_private: isPrivate || undefined, + is_private: productTourRuntime.isActive('create-saved-view') || isPrivate ? true : undefined, name, organization_id: organizationId, show_chart: showChart, @@ -294,7 +294,7 @@ {/snippet} - + Saved View {#if activeView} @@ -374,6 +374,11 @@ defaultPrivate={productTourRuntime.isActive('create-saved-view')} onSave={handleSave} onClose={() => (isSaveDialogOpen = false)} + onCancel={() => { + if (productTourRuntime.isActive('create-saved-view')) { + document.dispatchEvent(new CustomEvent('product-tour:dismissed', { detail: { tourId: 'create-saved-view' } })); + } + }} {onLoadView} /> {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 93d4e0f796..629d660558 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 @@ -319,9 +319,9 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur }) ); - function handleLoadView(view: SavedView) { + async function handleLoadView(view: SavedView): Promise { if (options.baseHref) { - goto(savedViewHref(view)); + await goto(savedViewHref(view)); return; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte index a4122d895a..ce02f11c3f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte @@ -40,6 +40,7 @@ >
- + {#if isMediumScreenQuery.current} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index cb04a2ad39..fa3180ed53 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -700,7 +700,10 @@ organizationId={organization.current} pathname={page.url.pathname} projects={productTourProjects} - stateSettled={meQuery.isSuccess && organizationsQuery.isSuccess && projectsQuery.isSuccess} + stateSettled={meQuery.isSuccess && + organizationsQuery.isSuccess && + projectsQuery.isSuccess && + (assistantAccessQuery.isSuccess || assistantAccessQuery.isError)} /> - goto(buildEventDetailsHref(newId))} -/> +
+ goto(buildEventDetailsHref(newId))} + /> +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte index e4bab587e1..4b373e6e99 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte @@ -12,6 +12,7 @@ import { openSupportChat } from '$features/intercom/chat'; import { organization } from '$features/organizations/context.svelte'; import { useHideOrganizationNotifications } from '$features/organizations/hooks/use-hide-organization-notifications.svelte'; + import ProductTourInlineCallout from '$features/product-tours/components/product-tour-inline-callout.svelte'; import { productTourRuntime } from '$features/product-tours/state.svelte'; import { getProjectQuery } from '$features/projects/api.svelte'; import { getProjectDefaultTokenQuery, patchToken } from '$features/tokens/api.svelte'; @@ -378,7 +379,7 @@ public partial class App : Application {
- Choose your project type and follow the steps below to connect your application to Exceptionless. + Choose your project type and follow the steps below to connect your application to Exceptionless. {#if isTokenDisabled} @@ -420,303 +421,319 @@ public partial class App : Application { {/if} -
    -
  1. -

    Choose your project type.

    - { - selectedProjectType = projectTypes.find((P) => P.id === value) || null; - queryParams.type = value; - }} - > - - {#if selectedProjectType} - {selectedProjectType.platform}: {selectedProjectType.label} - {:else} - Please select a project type - {/if} - - - {#each Object.entries(projectTypesGroupedByPlatform) as [platform, types = []], index (platform)} - - {platform} - {#each types as type (type.id)} - - {type.label} - - {/each} - - {#if index < Object.keys(projectTypesGroupedByPlatform).length - 1} - - {/if} - {/each} - - -
  2. - - {#if selectedProjectType} - {#if isCommandLine} -
  3. -

    Execute the following in your shell.

    -
    - {#if isBashShell} - - {:else} - - {/if} -
    - -
    -
    -
  4. - {:else if isDotNet} -
  5. -

    Install the {selectedProjectType.package} NuGet package in your .NET project by executing this command from the project directory.

    -
    - -
    - -
    -
    -
  6. - {#if selectedProjectType.package === 'Exceptionless'} -
  7. -

    On app startup, import the Exceptionless namespace and call the client.Startup() extension method to wire up to any runtime - specific error handlers and read any available configuration.

    +
      +
    1. +

      Choose your project type.

      + { + selectedProjectType = projectTypes.find((P) => P.id === value) || null; + queryParams.type = value; + }} + > + + {#if selectedProjectType} + {selectedProjectType.platform}: {selectedProjectType.label} + {:else} + Please select a project type + {/if} + + + {#each Object.entries(projectTypesGroupedByPlatform) as [platform, types = []], index (platform)} + + {platform} + {#each types as type (type.id)} + + {type.label} + + {/each} + + {#if index < Object.keys(projectTypesGroupedByPlatform).length - 1} + + {/if} + {/each} + + +
    2. + + {#if selectedProjectType} + {#if isCommandLine} +
    3. +

      Execute the following in your shell.

      - + {#if isBashShell} + + {:else} + + {/if}
      - +
      -

      This library is platform-agnostic and is compiled against different runtimes. Depending on the referenced runtime, Exceptionless - will attempt to wire up to available error handlers and attempt to discover configuration settings available to that runtime. For - these reasons if you are on a known platform then use the platform specific package to save you time configuring while giving you - more contextual information. For more information and configuration examples please read the Exceptionless Configuration documentation for more information.

    4. - {:else if selectedProjectType.package === 'Exceptionless.AspNetCore'} + {:else if isDotNet}
    5. -

      You must import the Exceptionless namespace and add the following code to register and configure the Exceptionless client.

      -
      - -
      - -
      -

      In order to start gathering unhandled exceptions, you need to register the Exceptionless middleware after building your application - as shown above. Alternatively, you can use different overloads of the AddExceptionless method for additional configuration options.

      Install the {selectedProjectType.package} NuGet package in your .NET project by executing this command from the project directory.

      -
    6. - {:else if selectedProjectType.package === 'Exceptionless.Windows' || selectedProjectType.package === 'Exceptionless.Wpf'} -
    7. -

      Configure your Exceptionless assembly attribute to your projects AssemblyInfo.cs file.

      - +
      - +
    8. - {#if selectedProjectType.package === 'Exceptionless.Wpf'} + {#if selectedProjectType.package === 'Exceptionless'}
    9. Finally, import the Exceptionless namespace and include the following line of code in your App.xaml.cs file to enable reporting - of unhandled exceptions.

      On app startup, import the Exceptionless namespace and call the client.Startup() extension method to wire up to any runtime + specific error handlers and read any available configuration.

      - +
      - +
      +

      This library is platform-agnostic and is compiled against different runtimes. Depending on the referenced runtime, + Exceptionless will attempt to wire up to available error handlers and attempt to discover configuration settings available to + that runtime. For these reasons if you are on a known platform then use the platform specific package to save you time + configuring while giving you more contextual information. For more information and configuration examples please read the Exceptionless Configuration documentation for more information.

    10. - {:else} + {:else if selectedProjectType.package === 'Exceptionless.AspNetCore'}
    11. +

      You must import the Exceptionless namespace and add the following code to register and configure the Exceptionless client.

      +
      + +
      + +
      +

      Finally, import the Exceptionless namespace and include the following line of code in your Program.cs file to enable reporting - of unhandled exceptions.

      In order to start gathering unhandled exceptions, you need to register the Exceptionless middleware after building your + application as shown above. Alternatively, you can use different overloads of the AddExceptionless method for additional + configuration options.

      +
    12. + {:else if selectedProjectType.package === 'Exceptionless.Windows' || selectedProjectType.package === 'Exceptionless.Wpf'} +
    13. +

      Configure your Exceptionless assembly attribute to your projects AssemblyInfo.cs file.

      - +
      - +
    14. + {#if selectedProjectType.package === 'Exceptionless.Wpf'} +
    15. +

      Finally, import the Exceptionless namespace and include the following line of code in your App.xaml.cs file to enable + reporting of unhandled exceptions.

      +
      + +
      + +
      +
      +
    16. + {:else} +
    17. +

      Finally, import the Exceptionless namespace and include the following line of code in your Program.cs file to enable + reporting of unhandled exceptions.

      +
      + +
      + +
      +
      +
    18. + {/if} {/if} - {/if} - {:else if isDotNetLegacy} -
    19. -

      Install the {selectedProjectType.package} NuGet package to your Visual Studio project by running this command in the Package Manager Console.

      -
      - -
      - -
      -
      -
    20. - {#if selectedProjectType.package === 'Exceptionless'} + {:else if isDotNetLegacy}
    21. On app startup, import the Exceptionless namespace and call the client.Startup() extension method to wire up to any runtime - specific error handlers and read any available configuration.

      Install the {selectedProjectType.package} NuGet package to your Visual Studio project by running this command in the Package Manager Console.

      - +
      - +
      -

      For more information and additional configuration methods please read the Exceptionless Configuration documentation for more information.

    22. - {:else if selectedProjectType.package === 'Exceptionless.Windows' || selectedProjectType.package === 'Exceptionless.Wpf'} -
    23. -

      Configure your Exceptionless API key in your project's app.config file, and add it under the Exceptionless section within the file.

      -
      - -
      - + {#if selectedProjectType.package === 'Exceptionless'} +
    24. +

      On app startup, import the Exceptionless namespace and call the client.Startup() extension method to wire up to any runtime + specific error handlers and read any available configuration.

      +
      + +
      + +
      -
- - {#if selectedProjectType.package === 'Exceptionless.Wpf'} +

For more information and additional configuration methods please read the Exceptionless Configuration documentation for more information.

+ + {:else if selectedProjectType.package === 'Exceptionless.Windows' || selectedProjectType.package === 'Exceptionless.Wpf'}
  • Finally, import the Exceptionless namespace and include the following line of code in your App.xaml.cs file to enable reporting - of unhandled exceptions.

    Configure your Exceptionless API key in your project's app.config file, and add it under the Exceptionless section within the + file.

    - +
    - +
  • - {:else} + {#if selectedProjectType.package === 'Exceptionless.Wpf'} +
  • +

    Finally, import the Exceptionless namespace and include the following line of code in your App.xaml.cs file to enable + reporting of unhandled exceptions.

    +
    + +
    + +
    +
    +
  • + {:else} +
  • +

    Finally, import the Exceptionless namespace and include the following line of code in your Program.cs file to enable + reporting of unhandled exceptions.

    +
    + +
    + +
    +
    +
  • + {/if} + {/if} + + {#if selectedProjectType.package === 'Exceptionless.Mvc' || selectedProjectType.package === 'Exceptionless.Web' || selectedProjectType.package === 'Exceptionless.WebApi'}
  • Finally, import the Exceptionless namespace and include the following line of code in your Program.cs file to enable reporting - of unhandled exceptions.

    Configure your Exceptionless API key in your project's web.config file, and add it under the Exceptionless section within the + file.

    - +
    - +
  • {/if} - {/if} - {#if selectedProjectType.package === 'Exceptionless.Mvc' || selectedProjectType.package === 'Exceptionless.Web' || selectedProjectType.package === 'Exceptionless.WebApi'} -
  • -

    Configure your Exceptionless API key in your project's web.config file, and add it under the Exceptionless section within the file.

    -
    - -
    - + {#if selectedProjectType.package === 'Exceptionless.WebApi'} +
  • +

    Finally, you must import the Exceptionless namespace and call method with an instance of HttpConfiguration during the startup of your app.

    +
    + +
    + +
    -
  • - +

    If you are hosting Web API inside of ASP.NET, you would register Exceptionless using GlobalConfiguration.

    +
    + +
    + +
    +
    + + {/if} {/if} - {#if selectedProjectType.package === 'Exceptionless.WebApi'} + {#if isJavaScript && javascriptClientConfiguration}
  • Finally, you must import the Exceptionless namespace and call method with an instance of HttpConfiguration during the startup of your app.

    Install the {javascriptClientConfiguration.packageName} npm package in your JavaScript project by running this + command in the project directory. {javascriptClientConfiguration.installNote ?? ''}

    - -
    - -
    -
    -

    If you are hosting Web API inside of ASP.NET, you would register Exceptionless using GlobalConfiguration.

    -
    - +
    - +
  • - {/if} - {/if} - - {#if isJavaScript && javascriptClientConfiguration} -
  • -

    Install the {javascriptClientConfiguration.packageName} npm package in your JavaScript project by running this command - in the project directory. {javascriptClientConfiguration.installNote ?? ''}

    -
    - -
    - -
    -
    -
  • - {#each javascriptClientConfiguration.extraSteps ?? [] as step (step.description)} + {#each javascriptClientConfiguration.extraSteps ?? [] as step (step.description)} +
  • +

    {step.description}

    +
    + +
    + +
    +
    + {#if step.note} +

    {step.note}

    + {/if} +
  • + {/each}
  • -

    {step.description}

    +

    Configure the ExceptionlessClient with your Exceptionless API key.

    - +
    - +
    - {#if step.note} -

    {step.note}

    - {/if}
  • - {/each} -
  • -

    Configure the ExceptionlessClient with your Exceptionless API key.

    -
    - -
    - -
    -
    -
  • + {/if} {/if} - {/if} - + +
    + + {#if productTourRuntime.isActive('configure-project') && productTourRuntime.activeStepId === 'sdk-instructions'} + + document.dispatchEvent(new CustomEvent('product-tour:advance', { detail: { stepId: 'sdk-instructions', tourId: 'configure-project' } }))} + onDismiss={() => document.dispatchEvent(new CustomEvent('product-tour:dismissed', { detail: { tourId: 'configure-project' } }))} + title="Connect your application" + tourId="configure-project" + /> + {/if} {#if selectedProjectType}

    - +

    + +
    diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/event/[eventId=objectid]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/event/[eventId=objectid]/+page.svelte index 36146ec4c6..683e6e96cc 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/event/[eventId=objectid]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/event/[eventId=objectid]/+page.svelte @@ -45,6 +45,7 @@ async function handleEventLoaded(event: PersistentEvent) { assistantPageContext.setPageEvent(event); + document.dispatchEvent(new CustomEvent('product-tour:event-opened', { detail: { eventType: event.type } })); if (event.id !== eventId || event.stack_id !== stackId) { await goto(buildEventDetailsHref(event.id, event.stack_id), { replaceState: true }); @@ -60,4 +61,6 @@ }); - +
    + +
    From 391594c762175db83dc0a98ffca98f99a914b2b5 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 11 Aug 2026 18:11:23 -0500 Subject: [PATCH 03/20] Harden guided tour prerequisites and mobile flow --- .../ClientApp/e2e/tests/product-tours.e2e.ts | 9 ++ .../features/product-tours/catalog.test.ts | 1 + .../src/lib/features/product-tours/catalog.ts | 22 ++++- .../components/product-tours.svelte | 88 +++++++++++++++++-- .../src/lib/features/product-tours/types.ts | 2 + .../ClientApp/src/routes/(app)/+layout.svelte | 22 +++++ 6 files changed, 134 insertions(+), 10 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index 6d2c0175d3..c7e9e3b4a9 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -29,6 +29,13 @@ test('Explore the new UI opens its navigation target on mobile', async ({ e2eSce const tour = page.locator('.driver-popover'); await expect(tour.getByText('Your workspace navigation')).toBeVisible(); await page.screenshot({ fullPage: true, path: testInfo.outputPath('new-ui-overview-mobile.png') }); + await tour.getByRole('button', { name: 'Next' }).click(); + await expect(tour.getByText('Reuse configured views')).toBeVisible(); + await tour.getByRole('button', { name: 'Next' }).click(); + await expect(tour.getByText('Help is always nearby')).toBeVisible(); + await tour.getByRole('button', { name: 'Next' }).click(); + await expect(tour.getByText('Find anything quickly')).toBeVisible(); + await expect(page.locator('[data-tour="mobile-navigation-trigger"]')).toBeVisible(); await tour.getByRole('button', { name: 'Close' }).click(); }); @@ -55,6 +62,8 @@ test('Configure a project resumes through its first event', async ({ e2eApi, e2e await expect(tour.getByText('Choose your SDK')).toBeVisible(); await page.screenshot({ fullPage: true, path: testInfo.outputPath('configure-project-platform.png') }); + await page.locator('[data-tour="project-configure-platform"]').click(); + await page.getByRole('option', { name: 'Browser applications' }).click(); await page.getByRole('button', { name: 'Next' }).click(); await expect(tour.getByText('Use the project token')).toBeVisible(); await page.getByRole('button', { name: 'Next' }).click(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts index 0ac93c23ae..e5eef5852b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts @@ -6,6 +6,7 @@ import { getProductTourItems, getRecommendedProductTourId, productTourCatalog } function context(overrides: Partial = {}): ProductTourContext { return { + errorEventAvailability: 'available', isSetupPage: false, organizationId: 'organization-id', pathname: '/next/event', diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts index c8f5fab4a5..7d9319929a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts @@ -12,6 +12,26 @@ function requireApplicationShell(context: ProductTourContext) { return { available: true }; } +function requireErrorEvent(context: ProductTourContext) { + if (!context.organizationId) { + return { available: false, reason: 'Create an organization and project first.' }; + } + + if (context.errorEventAvailability === 'loading') { + return { available: false, reason: 'Checking for an accessible error report…' }; + } + + if (context.errorEventAvailability === 'error') { + return { available: false, reason: 'Error reports could not be checked right now. Try again shortly.' }; + } + + if (context.errorEventAvailability === 'empty') { + return { available: false, reason: 'Send or retain an error report before starting this guide.' }; + } + + return { available: true }; +} + function requireOrganization(context: ProductTourContext) { return context.organizationId ? { available: true } : { available: false, reason: 'Create an organization and project first.' }; } @@ -208,7 +228,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ }, { description: 'Open a real error and learn where to find its exception, request, environment, and custom data.', - getAvailability: requireOrganization, + getAvailability: requireErrorEvent, getSteps: () => [ { anchor: PRODUCT_TOUR_ANCHORS.eventList, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte index cbf0fbec9b..fcd31dac6f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -12,7 +12,15 @@ import { tick } from 'svelte'; import { toast } from 'svelte-sonner'; - import type { ProductTourContext, ProductTourId, ProductTourKey, ProductTourLaunchSource, ProductTourListItem, ProductTourStep } from '../types'; + import type { + ProductTourContext, + ProductTourErrorEventAvailability, + ProductTourId, + ProductTourKey, + ProductTourLaunchSource, + ProductTourListItem, + ProductTourStep + } from '../types'; import { PRODUCT_TOUR_ANCHORS, productTourSelector } from '../anchors'; import { getProductTour, getProductTourItems, getRecommendedProductTourId } from '../catalog'; @@ -29,6 +37,7 @@ assistantAccess?: AssistantAccess; closeOverlays: () => void; currentUser?: ViewCurrentUser; + errorEventAvailability: ProductTourErrorEventAvailability; isAnyOverlayOpen: boolean; isImpersonating: boolean; isSetupPage: boolean; @@ -53,6 +62,7 @@ assistantAccess, closeOverlays, currentUser, + errorEventAvailability, isAnyOverlayOpen, isImpersonating, isSetupPage, @@ -81,7 +91,7 @@ let overlayRevision = $state(0); const progressMutation = putCurrentUserProductTour(); - const context = $derived({ assistantAccess, isSetupPage, organizationId, pathname, projects }); + const context = $derived({ assistantAccess, errorEventAvailability, isSetupPage, organizationId, pathname, projects }); const items = $derived(getProductTourItems(context, currentUser?.product_tours)); const recommended = $derived.by(() => { const id = getRecommendedProductTourId(context); @@ -258,7 +268,7 @@ const destination = getDestination(id); const definition = getProductTour(id); - if (destination && destination !== pathname) { + if (destination && (destination !== pathname || id === 'investigate-error')) { writeStoredState({ source, tourId: id, version: definition.version }); await goto(destination); return; @@ -268,10 +278,14 @@ } function getDestination(id: ProductTourId): string | undefined { - if (id === 'create-saved-view' || id === 'investigate-error') { + if (id === 'create-saved-view') { return resolve('/(app)/event'); } + if (id === 'investigate-error') { + return `${resolve('/(app)/event')}?time=all&type=error`; + } + if (id !== 'configure-project') { return undefined; } @@ -286,10 +300,16 @@ async function launch(id: ProductTourId, source: ProductTourLaunchSource, resumeStepId?: string, emitStarted = true): Promise { const definition = getProductTour(id); - const allSteps = definition - .getSteps(context) - .filter((step) => !step.optional || !step.anchor || document.querySelector(productTourSelector(step.anchor))); - let startIndex = resumeStepId ? allSteps.findIndex((step) => step.id === resumeStepId) : 0; + if (id === 'new-ui-overview' && isMobileViewport()) { + await ensureTarget({ anchor: PRODUCT_TOUR_ANCHORS.appNavigation, description: '', id: 'mobile-navigation', title: '' }); + } + + const allSteps = orderStepsForViewport(id, definition.getSteps(context)).filter( + (step) => !step.optional || !step.anchor || document.querySelector(productTourSelector(step.anchor)) + ); + const detailRouteResume = id === 'investigate-error' && resumeStepId === 'choose-error' && /\/(event|stack)\//.test(pathname); + const effectiveResumeStepId = detailRouteResume ? 'inspect-details' : resumeStepId; + let startIndex = effectiveResumeStepId ? allSteps.findIndex((step) => step.id === effectiveResumeStepId) : 0; if (startIndex < 0) { startIndex = 0; } @@ -389,9 +409,26 @@ async function advance(id: ProductTourId, version: number, source: ProductTourLaunchSource, steps: ProductTourStep[], index: number): Promise { const step = steps[index]!; + if (id === 'configure-project' && step.id === 'choose-platform') { + const platform = document.querySelector(productTourSelector(PRODUCT_TOUR_ANCHORS.projectConfigurePlatform)); + if (!platform || /Please select a project type/i.test(platform.textContent ?? '')) { + toast.info('Choose the SDK platform before continuing.'); + return; + } + } + + const previousPath = pathname; + const previousUrlPath = window.location.pathname; if (step.advanceOnClick && step.anchor) { (document.querySelector(productTourSelector(step.anchor)) as HTMLElement | null)?.click(); - await tick(); + if (id === 'configure-project' && step.resumeStepId) { + if (!(await waitForPathChange(previousPath, previousUrlPath))) { + toast.info('Finish the required fields before continuing this guide.'); + return; + } + } else { + await tick(); + } } const next = steps[index + 1]; @@ -411,6 +448,11 @@ return; } + if (id === 'new-ui-overview' && isMobileViewport() && next.id !== 'help' && next.id !== 'saved-views' && next.id !== 'navigation') { + closeMobileNavigation(); + await tick(); + } + if (!(await ensureTarget(next))) { if (next.optional) { driverInstance?.moveNext(); @@ -481,6 +523,34 @@ }); } + function isMobileViewport(): boolean { + return window.matchMedia('(max-width: 767px)').matches; + } + + function orderStepsForViewport(id: ProductTourId, steps: ProductTourStep[]): ProductTourStep[] { + if (id !== 'new-ui-overview' || !isMobileViewport()) { + return steps; + } + + const order = ['navigation', 'saved-views', 'help', 'command-search', 'exie']; + return order.map((stepId) => steps.find((step) => step.id === stepId)).filter((step): step is ProductTourStep => !!step); + } + + function closeMobileNavigation(): void { + if (document.querySelector(productTourSelector(PRODUCT_TOUR_ANCHORS.appNavigation))) { + (document.querySelector(productTourSelector('mobile-navigation-trigger')) as HTMLElement | null)?.click(); + } + } + + async function waitForPathChange(previousPath: string, previousUrlPath: string, timeout = 10000): Promise { + const deadline = performance.now() + timeout; + while (pathname === previousPath && window.location.pathname === previousUrlPath && performance.now() < deadline) { + await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); + } + + return pathname !== previousPath || window.location.pathname !== previousUrlPath; + } + async function onWelcomeStart(): Promise { try { await recordProgress('welcome', WELCOME_VERSION, 'completed'); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts index 6c7aeb3ea0..9e7dc26888 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts @@ -8,6 +8,7 @@ export interface ProductTourAvailability { } export interface ProductTourContext { assistantAccess?: AssistantAccess; + errorEventAvailability: ProductTourErrorEventAvailability; isSetupPage: boolean; organizationId?: string; pathname: string; @@ -22,6 +23,7 @@ export interface ProductTourDefinition { title: string; version: number; } +export type ProductTourErrorEventAvailability = 'available' | 'empty' | 'error' | 'loading'; export type ProductTourId = 'configure-project' | 'create-saved-view' | 'investigate-error' | 'meet-exie' | 'new-ui-overview'; export type ProductTourKey = 'welcome' | ProductTourId; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index fa3180ed53..44d3190e2d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -17,6 +17,7 @@ import { UpgradeRequiredDialog } from '$features/billing'; import { createOrganizationEventNotificationRefresher, + getOrganizationEventsQuery, invalidatePersistentEventQueries, type OrganizationEventNotificationRefresher } from '$features/events/api.svelte'; @@ -421,6 +422,25 @@ const projectsQuery = getProjectsQuery({ params: { limit: 1000 } }); const projects = $derived(projectsQuery.data?.data ?? []); const productTourProjects = $derived(projects.filter((project) => !organization.current || project.organization_id === organization.current)); + const productTourErrorEventsQuery = getOrganizationEventsQuery({ + params: { filter: 'type:error', limit: 1, mode: 'summary', time: 'all' }, + route: { + get organizationId() { + return organization.current; + } + } + }); + const productTourErrorEventAvailability = $derived.by(() => { + if (!organization.current || productTourErrorEventsQuery.isPending) { + return 'loading' as const; + } + + if (productTourErrorEventsQuery.isError) { + return 'error' as const; + } + + return (productTourErrorEventsQuery.data?.data?.length ?? 0) > 0 ? ('available' as const) : ('empty' as const); + }); const impersonatingOrganizationId = $derived.by(() => { // Only consider impersonation if user data is loaded and user has organizations @@ -578,6 +598,7 @@ getProductTourItems( { assistantAccess, + errorEventAvailability: productTourErrorEventAvailability, isSetupPage, organizationId: organization.current, pathname: page.url.pathname, @@ -694,6 +715,7 @@ bind:this={productToursComponent} closeOverlays={closeProductTourOverlays} currentUser={meQuery.data} + errorEventAvailability={productTourErrorEventAvailability} isAnyOverlayOpen={isAnyProductTourOverlayOpen} {isImpersonating} {isSetupPage} From eacffc77869ef6855fbffa169c6d7ef4d3d414c3 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 11 Aug 2026 18:54:56 -0500 Subject: [PATCH 04/20] Fix guided tour mobile and portal interactions --- .../components/product-tours.svelte | 21 ++++++++++++------- .../features/product-tours/product-tours.css | 11 ++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte index fcd31dac6f..74236f06a6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -305,7 +305,7 @@ } const allSteps = orderStepsForViewport(id, definition.getSteps(context)).filter( - (step) => !step.optional || !step.anchor || document.querySelector(productTourSelector(step.anchor)) + (step) => !step.optional || !step.anchor || hasVisibleTarget(productTourSelector(step.anchor)) ); const detailRouteResume = id === 'investigate-error' && resumeStepId === 'choose-error' && /\/(event|stack)\//.test(pathname); const effectiveResumeStepId = detailRouteResume ? 'inspect-details' : resumeStepId; @@ -496,18 +496,18 @@ } const selector = productTourSelector(step.anchor); - if (step.anchor === PRODUCT_TOUR_ANCHORS.appNavigation && !document.querySelector(selector)) { + if (step.anchor === PRODUCT_TOUR_ANCHORS.appNavigation && isMobileViewport() && !hasVisibleTarget(selector)) { (document.querySelector(productTourSelector('mobile-navigation-trigger')) as HTMLElement | null)?.click(); } - if (document.querySelector(selector)) { + if (hasVisibleTarget(selector)) { return true; } const timeout = step.waitForElement ?? 1200; return await new Promise((resolvePromise) => { const observer = new MutationObserver(() => { - if (!document.querySelector(selector)) { + if (!hasVisibleTarget(selector)) { return; } @@ -519,10 +519,15 @@ observer.disconnect(); resolvePromise(false); }, timeout); - observer.observe(document.body, { childList: true, subtree: true }); + observer.observe(document.body, { attributes: true, childList: true, subtree: true }); }); } + function hasVisibleTarget(selector: string): boolean { + const element = document.querySelector(selector); + return !!element && element.getClientRects().length > 0; + } + function isMobileViewport(): boolean { return window.matchMedia('(max-width: 767px)').matches; } @@ -537,7 +542,7 @@ } function closeMobileNavigation(): void { - if (document.querySelector(productTourSelector(PRODUCT_TOUR_ANCHORS.appNavigation))) { + if (hasVisibleTarget(productTourSelector(PRODUCT_TOUR_ANCHORS.appNavigation))) { (document.querySelector(productTourSelector('mobile-navigation-trigger')) as HTMLElement | null)?.click(); } } @@ -708,7 +713,7 @@ } const definition = getProductTour(id); - const steps = definition.getSteps(context).filter((step) => !step.optional || !step.anchor || document.querySelector(productTourSelector(step.anchor))); + const steps = definition.getSteps(context).filter((step) => !step.optional || !step.anchor || hasVisibleTarget(productTourSelector(step.anchor))); const index = steps.findIndex((step) => step.id === stepId); if (index >= 0) { void advance(id, definition.version, activeSource, steps, index); @@ -741,7 +746,7 @@ return; } - const steps = definition.getSteps(context).filter((step) => !step.optional || !step.anchor || document.querySelector(productTourSelector(step.anchor))); + const steps = definition.getSteps(context).filter((step) => !step.optional || !step.anchor || hasVisibleTarget(productTourSelector(step.anchor))); const index = steps.findIndex((step) => step.id === 'choose-error'); if (index >= 0) { productTourRuntime.set('investigate-error', 'inspect-details'); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css index 8b9cb12519..dd0469df68 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css @@ -25,6 +25,17 @@ text-shadow: none; } +/* Driver disables pointer events outside the active spotlight. Select menus are portaled + to the document body, so keep an open menu interactive while a tour is running. */ +.driver-active [data-slot='select-content'], +.driver-active [data-slot='select-content'] * { + pointer-events: auto; +} + +.driver-active [data-slot='select-content'] { + z-index: 10001 !important; +} + @media (prefers-reduced-motion: reduce) { .driver-overlay, .driver-popover { From 3e85747f380f0fe7cf12c983338945c166e2de04 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 11 Aug 2026 19:12:27 -0500 Subject: [PATCH 05/20] Add access-aware Exie feature announcement --- .../ClientApp/e2e/tests/product-tours.e2e.ts | 27 +++++++ .../product-tour-feature-announcement.svelte | 44 +++++++++++ .../components/product-tours.svelte | 73 ++++++++++++++++++- .../product-tours/eligibility.test.ts | 10 ++- .../lib/features/product-tours/eligibility.ts | 4 + .../features/product-tours/telemetry.test.ts | 3 + .../lib/features/product-tours/telemetry.ts | 13 +++- .../src/lib/features/product-tours/types.ts | 4 +- 8 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index c7e9e3b4a9..9f9c2782e1 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -144,6 +144,33 @@ test('Investigate an error advances only after an error report opens', async ({ expect(event.type).toBe('error'); }); +test('Exie announcement can be dismissed without hiding the replayable guide', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { + void _e2eScenario; + await page.route('**/api/v2/assistant/access**', async (route) => { + await route.fulfill({ + contentType: 'application/json', + json: { enabled: true, has_access: true, message: null, upgrade_required: false } + }); + }); + + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto('/next/stack'); + + const announcement = page.locator('[data-product-tour-announcement="exie"]'); + await expect(announcement).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('meet-exie-announcement.png') }); + const dismissed = page.waitForResponse( + (response) => response.url().includes('/product-tours/exie-announcement') && response.request().method() === 'PUT' && response.status() === 200 + ); + await announcement.getByRole('button', { exact: true, name: 'Dismiss' }).click(); + await dismissed; + await page.reload(); + await expect(announcement).toBeHidden(); + + await startTourFromCommand(page, 'Meet Exie'); + await expect(page.locator('.driver-popover').getByText('Open Exie', { exact: true })).toBeVisible(); +}); + test('Meet Exie opens contextual UI without sending a provider request', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { void _e2eScenario; let chatRequests = 0; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte new file mode 100644 index 0000000000..910b93917d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte @@ -0,0 +1,44 @@ + + +{#if open} + + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte index 74236f06a6..2f7bf8be01 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -24,10 +24,11 @@ import { PRODUCT_TOUR_ANCHORS, productTourSelector } from '../anchors'; import { getProductTour, getProductTourItems, getRecommendedProductTourId } from '../catalog'; - import { shouldOfferProductTourWelcome } from '../eligibility'; + import { shouldOfferProductTourAnnouncement, shouldOfferProductTourWelcome } from '../eligibility'; import { productTourRuntime } from '../state.svelte'; import { buildProductTourTelemetryEvent, type ProductTourTelemetryEvent } from '../telemetry'; import ProductTourCatalog from './product-tour-catalog.svelte'; + import ProductTourFeatureAnnouncement from './product-tour-feature-announcement.svelte'; import ProductTourWelcome from './product-tour-welcome.svelte'; import 'driver.js/dist/driver.css'; @@ -55,6 +56,8 @@ } const WELCOME_VERSION = 1; + const EXIE_ANNOUNCEMENT_VERSION = 1; + const EXIE_ANNOUNCEMENT_KEY = 'exie-announcement' as const; const SESSION_KEY = 'exceptionless.product-tour'; const SYSTEM_PATH = resolve('/(app)/system'); @@ -78,6 +81,8 @@ let welcomeHandled = $state(false); let welcomeShown = $state(false); let welcomeBrowsePending = $state(false); + let exieAnnouncementOpen = $state(false); + let exieAnnouncementShown = $state(false); let confirmNewProjectOpen = $state(false); let pendingConfigureSource = $state(); let driverInstance = $state.raw(); @@ -124,6 +129,37 @@ } }); + $effect(() => { + const welcomeProgress = currentUser?.product_tours?.welcome; + const announcementProgress = currentUser?.product_tours?.[EXIE_ANNOUNCEMENT_KEY]; + const isMeaningfulAppRoute = pathname.startsWith('/next/event') || pathname.startsWith('/next/stack'); + const shouldShow = + stateSettled && + !!currentUser && + !!assistantAccess?.enabled && + isMeaningfulAppRoute && + !isSetupPage && + !isImpersonating && + !productTourRuntime.activeTourId && + !exieAnnouncementShown && + !exieAnnouncementOpen && + !welcomeOpen && + !catalogOpen && + !isAnyOverlayOpen && + !hasCompetingOverlay() && + !pathname.startsWith(SYSTEM_PATH) && + !shouldOfferProductTourWelcome(welcomeProgress, WELCOME_VERSION) && + shouldOfferProductTourAnnouncement(announcementProgress, EXIE_ANNOUNCEMENT_VERSION); + + if (!shouldShow) { + return; + } + + exieAnnouncementOpen = true; + exieAnnouncementShown = true; + void track('announcement-shown', EXIE_ANNOUNCEMENT_KEY, EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + }); + $effect(() => { const observer = new MutationObserver(() => (overlayRevision += 1)); observer.observe(document.body, { childList: true }); @@ -237,6 +273,7 @@ catalogOpen = false; welcomeOpen = false; + exieAnnouncementOpen = false; closeOverlays(); if (!(await waitForCompetingOverlaysToClose())) { toast.info('Close the open dialog or panel before starting a guided tour.'); @@ -570,6 +607,31 @@ await startTour(recommended.id, 'automatic'); } + async function onExieAnnouncementStart(): Promise { + try { + await recordProgress(EXIE_ANNOUNCEMENT_KEY, EXIE_ANNOUNCEMENT_VERSION, 'completed'); + } catch { + toast.error('We could not save the Exie announcement preference. Please try again.'); + return; + } + + exieAnnouncementOpen = false; + void track('announcement-started', EXIE_ANNOUNCEMENT_KEY, EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + await startTour('meet-exie', 'feature-announcement'); + } + + async function onExieAnnouncementDismiss(): Promise { + try { + await recordProgress(EXIE_ANNOUNCEMENT_KEY, EXIE_ANNOUNCEMENT_VERSION, 'dismissed'); + } catch { + toast.error('We could not save the Exie announcement preference. Please try again.'); + return; + } + + exieAnnouncementOpen = false; + void track('announcement-dismissed', EXIE_ANNOUNCEMENT_KEY, EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + } + async function onWelcomeSkip(): Promise { try { await recordProgress('welcome', WELCOME_VERSION, 'dismissed'); @@ -777,6 +839,15 @@ {recommended} /> +{#if exieAnnouncementOpen && assistantAccess} + void onExieAnnouncementDismiss()} + onStart={() => void onExieAnnouncementStart()} + /> +{/if} + void startTour(id, catalogSource)} /> diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts index d738bf9261..eed9c6af2a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts @@ -1,7 +1,7 @@ import { ProductTourStatus } from '$generated/api'; import { describe, expect, it } from 'vitest'; -import { shouldOfferProductTourWelcome } from './eligibility'; +import { shouldOfferProductTourAnnouncement, shouldOfferProductTourWelcome } from './eligibility'; describe('product tour welcome eligibility', () => { it('offers legacy users and a newer welcome version', () => { @@ -14,3 +14,11 @@ describe('product tour welcome eligibility', () => { expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Dismissed, updated_utc: '', version: 1 }, 1)).toBe(false); }); }); + +describe('product tour feature announcement eligibility', () => { + it('offers a new announcement version until explicitly recorded', () => { + expect(shouldOfferProductTourAnnouncement(undefined, 1)).toBe(true); + expect(shouldOfferProductTourAnnouncement({ status: ProductTourStatus.Dismissed, updated_utc: '', version: 1 }, 1)).toBe(false); + expect(shouldOfferProductTourAnnouncement({ status: ProductTourStatus.Completed, updated_utc: '', version: 2 }, 1)).toBe(false); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts index cbdc62f2f9..6967783729 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts @@ -1,5 +1,9 @@ import type { ProductTourProgress } from '$features/users/models'; +export function shouldOfferProductTourAnnouncement(progress: ProductTourProgress | undefined, announcementVersion: number): boolean { + return !progress || progress.version < announcementVersion; +} + export function shouldOfferProductTourWelcome(progress: ProductTourProgress | undefined, welcomeVersion: number): boolean { return !progress || progress.version < welcomeVersion; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts index 8f15e3bcdf..3021de3d1c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts @@ -7,6 +7,9 @@ describe('product tour telemetry', () => { expect(buildProductTourTelemetryEvent('step', 'new-ui-overview', 1, 'command-palette', 'command-search')).toBe( 'product-tour.step.new-ui-overview.v1.command-palette.command-search' ); + expect(buildProductTourTelemetryEvent('announcement-started', 'exie-announcement', 1, 'feature-announcement')).toBe( + 'product-tour.announcement-started.exie-announcement.v1.feature-announcement' + ); }); it('rejects resource data and invalid versions', () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts index e7f43a784d..bda21db74a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts @@ -1,6 +1,17 @@ import type { ProductTourKey, ProductTourLaunchSource } from './types'; -export type ProductTourTelemetryEvent = 'chooser-shown' | 'chooser-skipped' | 'chooser-started' | 'completed' | 'dismissed' | 'failed' | 'started' | 'step'; +export type ProductTourTelemetryEvent = + | 'announcement-dismissed' + | 'announcement-shown' + | 'announcement-started' + | 'chooser-shown' + | 'chooser-skipped' + | 'chooser-started' + | 'completed' + | 'dismissed' + | 'failed' + | 'started' + | 'step'; const SAFE_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts index 9e7dc26888..9454eda14d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts @@ -26,9 +26,9 @@ export interface ProductTourDefinition { export type ProductTourErrorEventAvailability = 'available' | 'empty' | 'error' | 'loading'; export type ProductTourId = 'configure-project' | 'create-saved-view' | 'investigate-error' | 'meet-exie' | 'new-ui-overview'; -export type ProductTourKey = 'welcome' | ProductTourId; +export type ProductTourKey = 'exie-announcement' | 'welcome' | ProductTourId; -export type ProductTourLaunchSource = 'automatic' | 'catalog' | 'command-palette' | 'help-menu'; +export type ProductTourLaunchSource = 'automatic' | 'catalog' | 'command-palette' | 'feature-announcement' | 'help-menu'; export interface ProductTourListItem extends ProductTourDefinition { availability: ProductTourAvailability; From 40dd6550959b099c2a9021896a38c6d29058585a Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 11 Aug 2026 20:10:52 -0500 Subject: [PATCH 06/20] Fix error investigation tour route resume --- .../ClientApp/e2e/tests/product-tours.e2e.ts | 23 +++++- .../events/components/events-overview.svelte | 20 +++++ .../src/lib/features/product-tours/catalog.ts | 1 + .../components/product-tours.svelte | 79 ++++++++++++++----- .../ClientApp/src/routes/(app)/+layout.svelte | 1 + 5 files changed, 101 insertions(+), 23 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index 9f9c2782e1..3753c6b36e 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -121,7 +121,7 @@ test('Create a saved view retains a private hydrated view', async ({ e2eScenario await expect(page.getByText('Create the saved view')).toBeHidden(); }); -test('Investigate an error advances only after an error report opens', async ({ e2eApi, e2eScenario, page }, testInfo) => { +test('Investigate an error resumes after navigation and advances only after an error opens', async ({ e2eApi, e2eScenario, page }, testInfo) => { const event = await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { message: e2eScenario.message, projectId: e2eScenario.projectId, @@ -135,12 +135,29 @@ test('Investigate an error advances only after an error report opens', async ({ await startTourFromCommand(page, 'Investigate an error'); const tour = page.locator('.driver-popover'); await expect(tour.getByText('Open a real error')).toBeVisible(); + await expect(page).toHaveURL(/\/next\/event\?time=all&type=error/); + await tour.getByRole('button', { name: 'Close' }).click(); + + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Investigate an error'); + const navigationConfirmation = page.getByRole('alertdialog', { name: 'Open Errors?' }); + await expect(navigationConfirmation).toBeVisible(); + await navigationConfirmation.getByRole('button', { name: 'Open Errors' }).click(); + await expect(tour.getByText('Open a real error')).toBeVisible(); + await expect(page).toHaveURL(/\/next\/event\?time=all&type=error/); await page.screenshot({ fullPage: true, path: testInfo.outputPath('investigate-error-list.png') }); await page.locator('tr').filter({ hasText: e2eScenario.message }).first().click(); - await expect(tour.getByText('Investigate the evidence')).toBeVisible({ timeout: 30_000 }); + const investigationCallout = page.locator('[data-product-tour-inline="investigate-error"]'); + await expect(investigationCallout.getByText('Investigate the evidence')).toBeVisible({ timeout: 30_000 }); await page.screenshot({ fullPage: true, path: testInfo.outputPath('investigate-error-details.png') }); - await tour.getByRole('button', { name: 'Next' }).click(); + await investigationCallout.getByRole('button', { name: 'Continue' }).click(); await expect(page.getByText('Investigate the evidence')).toBeHidden(); + + await page.goto(`/next/event/${event.id}`); + await expect(page.locator('[data-tour="event-details"]')).toBeVisible({ timeout: 30_000 }); + await startTourFromCommand(page, 'Investigate an error'); + await expect(investigationCallout.getByText('Investigate the evidence')).toBeVisible(); + await investigationCallout.getByRole('button', { name: 'End guide' }).click(); expect(event.type).toBe('error'); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index fbbb4bb493..cf44e79bdd 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -15,6 +15,8 @@ import * as EventsFacetedFilter from '$features/events/components/filters'; import { getExtendedDataItems, hasErrorOrSimpleError } from '$features/events/persistent-event'; import { getOrganizationQuery } from '$features/organizations/api.svelte'; + import ProductTourInlineCallout from '$features/product-tours/components/product-tour-inline-callout.svelte'; + import { productTourRuntime } from '$features/product-tours/state.svelte'; import { getProjectQuery, updateProject } from '$features/projects/api.svelte'; import StackCard from '$features/stacks/components/stack-card.svelte'; import Braces from '@lucide/svelte/icons/braces'; @@ -256,6 +258,14 @@ } } + function completeInvestigationTour(): void { + document.dispatchEvent(new CustomEvent('product-tour:completed', { detail: { tourId: 'investigate-error' } })); + } + + function dismissInvestigationTour(): void { + document.dispatchEvent(new CustomEvent('product-tour:dismissed', { detail: { tourId: 'investigate-error' } })); + } + $effect(() => { if (projectQuery.isError) { handleError(projectQuery.error); @@ -300,6 +310,16 @@ }); +{#if event && productTourRuntime.activeTourId === 'investigate-error' && productTourRuntime.activeStepId === 'inspect-details'} + +{/if} +

    Stack

    {#if event?.stack_id} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts index 7d9319929a..1342f578f0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts @@ -241,6 +241,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ anchor: PRODUCT_TOUR_ANCHORS.eventDetails, description: 'Review the summary and the available Exception, Request, Environment, trace, session, and extended-data tabs.', id: 'inspect-details', + presentation: 'inline', showDone: true, title: 'Investigate the evidence', waitForElement: 60000 diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte index 2f7bf8be01..d42a1403ac 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -45,6 +45,7 @@ organizationId?: string; pathname: string; projects: ViewProject[]; + routeKey: string; stateSettled: boolean; } @@ -72,6 +73,7 @@ organizationId, pathname, projects, + routeKey, stateSettled }: Props = $props(); @@ -85,14 +87,16 @@ let exieAnnouncementShown = $state(false); let confirmNewProjectOpen = $state(false); let pendingConfigureSource = $state(); + let confirmErrorNavigationOpen = $state(false); + let pendingInvestigateSource = $state(); let driverInstance = $state.raw(); let activeSource = $state(); let activeOrganizationId = $state(); let isFinishing = false; let isSuspendingInline = false; let isNavigatingTour = false; - let driverPath = ''; - let resumeAttemptedPath = ''; + let driverRoute = ''; + let resumeAttemptedRoute = ''; let overlayRevision = $state(0); const progressMutation = putCurrentUserProductTour(); @@ -167,8 +171,8 @@ }); $effect(() => { - const currentPath = pathname; - if (!stateSettled || driverInstance || resumeAttemptedPath === currentPath) { + const currentRoute = routeKey; + if (!stateSettled || driverInstance || resumeAttemptedRoute === currentRoute) { return; } @@ -190,13 +194,13 @@ return; } - resumeAttemptedPath = currentPath; + resumeAttemptedRoute = currentRoute; void launch(stored.tourId, stored.source, stored.stepId, false); }); $effect(() => { - const currentPath = pathname; - if (!driverInstance || !driverPath || driverPath === currentPath) { + const currentRoute = routeKey; + if (!driverInstance || !driverRoute || driverRoute === currentRoute) { return; } @@ -205,8 +209,8 @@ driverInstance.destroy(); isNavigatingTour = false; isFinishing = false; - driverPath = ''; - resumeAttemptedPath = ''; + driverRoute = ''; + resumeAttemptedRoute = ''; }); $effect(() => { @@ -275,7 +279,8 @@ welcomeOpen = false; exieAnnouncementOpen = false; closeOverlays(); - if (!(await waitForCompetingOverlaysToClose())) { + const canReuseOpenError = id === 'investigate-error' && hasVisibleTarget(productTourSelector(PRODUCT_TOUR_ANCHORS.eventDetails)); + if (!(await waitForCompetingOverlaysToClose()) && !canReuseOpenError) { toast.info('Close the open dialog or panel before starting a guided tour.'); return; } @@ -298,14 +303,36 @@ return; } + if (id === 'investigate-error') { + if (canReuseOpenError) { + await launch(id, source, 'inspect-details'); + return; + } + + if (pathname !== resolve('/(app)/event')) { + pendingInvestigateSource = source; + confirmErrorNavigationOpen = true; + return; + } + } + await navigateOrLaunch(id, source); } + async function confirmErrorNavigation(): Promise { + const source = pendingInvestigateSource; + pendingInvestigateSource = undefined; + confirmErrorNavigationOpen = false; + if (source) { + await navigateOrLaunch('investigate-error', source); + } + } + async function navigateOrLaunch(id: ProductTourId, source: ProductTourLaunchSource): Promise { const destination = getDestination(id); const definition = getProductTour(id); - if (destination && (destination !== pathname || id === 'investigate-error')) { + if (destination && destination !== routeKey) { writeStoredState({ source, tourId: id, version: definition.version }); await goto(destination); return; @@ -379,7 +406,7 @@ onCloseClick: () => void dismissTour(id, definition.version, source), onDestroyed: () => { const activeStepId = productTourRuntime.activeStepId; - const preservingNavigation = isNavigatingTour || (driverPath !== '' && driverPath !== pathname); + const preservingNavigation = isNavigatingTour || (driverRoute !== '' && driverRoute !== routeKey); const preservingPendingEvent = id === 'investigate-error' && (activeStepId === 'choose-error' || readStoredState()?.stepId === 'choose-error'); driverInstance = undefined; if (!isSuspendingInline && !preservingNavigation && !preservingPendingEvent) { @@ -423,7 +450,7 @@ } })) }); - driverPath = pathname; + driverRoute = routeKey; productTourRuntime.set(id, firstStep.id); writeStoredState({ source, stepId: firstStep.resumeStepId ?? firstStep.id, tourId: id, version: definition.version }); @@ -454,12 +481,11 @@ } } - const previousPath = pathname; - const previousUrlPath = window.location.pathname; + const previousRoute = routeKey; if (step.advanceOnClick && step.anchor) { (document.querySelector(productTourSelector(step.anchor)) as HTMLElement | null)?.click(); if (id === 'configure-project' && step.resumeStepId) { - if (!(await waitForPathChange(previousPath, previousUrlPath))) { + if (!(await waitForRouteChange(previousRoute))) { toast.info('Finish the required fields before continuing this guide.'); return; } @@ -514,7 +540,7 @@ return; } - if (driverInstance && driverPath === pathname) { + if (driverInstance && driverRoute === routeKey) { driverInstance.moveNext(); } else { isNavigatingTour = true; @@ -584,13 +610,13 @@ } } - async function waitForPathChange(previousPath: string, previousUrlPath: string, timeout = 10000): Promise { + async function waitForRouteChange(previousRoute: string, timeout = 10000): Promise { const deadline = performance.now() + timeout; - while (pathname === previousPath && window.location.pathname === previousUrlPath && performance.now() < deadline) { + while (routeKey === previousRoute && `${window.location.pathname}${window.location.search}` === previousRoute && performance.now() < deadline) { await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); } - return pathname !== previousPath || window.location.pathname !== previousUrlPath; + return routeKey !== previousRoute || `${window.location.pathname}${window.location.search}` !== previousRoute; } async function onWelcomeStart(): Promise { @@ -864,3 +890,16 @@ + + + + + Open Errors? + This guide starts in Errors so you can choose a real report. Your current page will change. + + + (pendingInvestigateSource = undefined)}>Cancel + void confirmErrorNavigation()}>Open Errors + + + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 44d3190e2d..b38af8524a 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -722,6 +722,7 @@ organizationId={organization.current} pathname={page.url.pathname} projects={productTourProjects} + routeKey={`${page.url.pathname}${page.url.search}`} stateSettled={meQuery.isSuccess && organizationsQuery.isSuccess && projectsQuery.isSuccess && From b5b41b01ddc27e4d18811982a5c7137c4e9431b9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 11 Aug 2026 20:52:28 -0500 Subject: [PATCH 07/20] Harden guided tour coordination --- .../ClientApp/e2e/tests/product-tours.e2e.ts | 21 + .../components/event-detail-sheet.svelte | 5 +- .../events/components/events-overview.svelte | 12 +- .../features/product-tours/catalog.test.ts | 26 ++ .../src/lib/features/product-tours/catalog.ts | 55 ++- .../components/product-tours.svelte | 405 +++++++----------- .../features/product-tours/session.test.ts | 28 ++ .../src/lib/features/product-tours/session.ts | 28 ++ .../features/product-tours/state.svelte.ts | 78 +++- .../src/lib/features/product-tours/types.ts | 8 +- .../components/save-view-dialog.svelte | 14 +- .../components/saved-view-picker.svelte | 12 +- .../ClientApp/src/routes/(app)/+layout.svelte | 7 +- .../event/[eventId=objectid]/+page.svelte | 17 +- .../[projectId]/configure/+page.svelte | 22 +- .../stack/[stackId=objectid]/+page.svelte | 21 +- .../event/[eventId=objectid]/+page.svelte | 5 +- .../Api/Data/endpoint-manifest.json | 14 + .../Exceptionless.Tests/Api/Data/openapi.json | 20 +- 19 files changed, 469 insertions(+), 329 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index 3753c6b36e..eaa78395cd 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -159,6 +159,27 @@ test('Investigate an error resumes after navigation and advances only after an e await expect(investigationCallout.getByText('Investigate the evidence')).toBeVisible(); await investigationCallout.getByRole('button', { name: 'End guide' }).click(); expect(event.type).toBe('error'); + + const usageReferenceId = `pw-e2e-tour-usage-${e2eScenario.run}`.slice(0, 100); + await e2eApi.submitEvent(e2eScenario.projectId, e2eScenario.projectToken, { + data: { e2e_reference: usageReferenceId }, + message: `Feature usage ${e2eScenario.run}`, + reference_id: usageReferenceId, + source: 'playwright-e2e', + type: 'usage' + }); + const usageEvent = await e2eApi.pollForEventByReference(e2eScenario.userToken, e2eScenario.projectId, usageReferenceId); + await page.goto(`/next/event/${usageEvent.id}`); + await expect(page.locator('[data-tour="event-details"][data-event-type="usage"]')).toBeVisible({ timeout: 30_000 }); + + const usageUrl = page.url(); + await startTourFromCommand(page, 'Investigate an error'); + const nonErrorConfirmation = page.getByRole('alertdialog', { name: 'Open Errors?' }); + await expect(nonErrorConfirmation).toBeVisible(); + await nonErrorConfirmation.getByRole('button', { name: 'Cancel' }).click(); + await expect(nonErrorConfirmation).toBeHidden(); + expect(page.url()).toBe(usageUrl); + await expect(investigationCallout).toBeHidden(); }); test('Exie announcement can be dismissed without hiding the replayable guide', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte index fc2c78196d..ed674eb0c8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte @@ -35,7 +35,6 @@ currentEvent = event; currentEventDetails = { eventId: event.id, stackId: event.stack_id }; assistantPageContext.setOverlayEvent(assistantContextOwner, event); - document.dispatchEvent(new CustomEvent('product-tour:event-opened', { detail: { eventType: event.type } })); } function prepareAssistantContext(): void { @@ -80,8 +79,6 @@ {/snippet} {#if eventId} -
    - (eventId = newId)} /> -
    + (eventId = newId)} /> {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index cf44e79bdd..555bf7e667 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -16,7 +16,7 @@ import { getExtendedDataItems, hasErrorOrSimpleError } from '$features/events/persistent-event'; import { getOrganizationQuery } from '$features/organizations/api.svelte'; import ProductTourInlineCallout from '$features/product-tours/components/product-tour-inline-callout.svelte'; - import { productTourRuntime } from '$features/product-tours/state.svelte'; + import { productTourHost } from '$features/product-tours/state.svelte'; import { getProjectQuery, updateProject } from '$features/projects/api.svelte'; import StackCard from '$features/stacks/components/stack-card.svelte'; import Braces from '@lucide/svelte/icons/braces'; @@ -259,11 +259,11 @@ } function completeInvestigationTour(): void { - document.dispatchEvent(new CustomEvent('product-tour:completed', { detail: { tourId: 'investigate-error' } })); + productTourHost.complete('investigate-error'); } function dismissInvestigationTour(): void { - document.dispatchEvent(new CustomEvent('product-tour:dismissed', { detail: { tourId: 'investigate-error' } })); + productTourHost.dismiss('investigate-error'); } $effect(() => { @@ -279,7 +279,7 @@ $effect(() => { if (event && event.id !== notifiedEventId) { notifiedEventId = event.id; - document.dispatchEvent(new CustomEvent('product-tour:event-opened', { detail: { eventType: event.type } })); + productTourHost.eventOpened(event.type); onEventLoaded?.(event); } }); @@ -310,7 +310,7 @@ }); -{#if event && productTourRuntime.activeTourId === 'investigate-error' && productTourRuntime.activeStepId === 'inspect-details'} +{#if event && productTourHost.activeTourId === 'investigate-error' && productTourHost.activeStepId === 'inspect-details'} {/if} -
    +

    Stack

    {#if event?.stack_id} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts index e5eef5852b..55edd17272 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts @@ -49,4 +49,30 @@ describe('product tour catalog', () => { expect(investigate?.availability.available).toBe(false); expect(investigate?.availability.reason).toBeTruthy(); }); + + it('reuses only an open error and otherwise asks before leaving a detail page', () => { + const investigate = productTourCatalog.find((tour) => tour.id === 'investigate-error')!; + + expect(investigate.getStartAction?.(context({ openEventType: 'error', pathname: '/next/event/error-id' }))).toEqual({ + stepId: 'inspect-details', + type: 'launch' + }); + expect(investigate.getStartAction?.(context({ openEventType: 'usage', pathname: '/next/event/usage-id' }))).toMatchObject({ + destination: '/next/event?time=all&type=error', + type: 'confirm-navigation' + }); + expect(investigate.getStartAction?.(context({ pathname: '/next/event' }))).toEqual({ + destination: '/next/event?time=all&type=error', + type: 'navigate' + }); + }); + + it('requires confirmation before setup consumes capacity when every project is configured', () => { + const configure = productTourCatalog.find((tour) => tour.id === 'configure-project')!; + + expect(configure.getStartAction?.(context({ pathname: '/next/stack', projects: [{ is_configured: true } as never] }))).toMatchObject({ + destination: '/next/project/add', + type: 'confirm-navigation' + }); + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts index 1342f578f0..d335992efd 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts @@ -1,9 +1,56 @@ import type { ProductTourProgress } from '$features/users/models'; -import type { ProductTourContext, ProductTourDefinition, ProductTourListItem } from './types'; +import { resolve } from '$app/paths'; + +import type { ProductTourContext, ProductTourDefinition, ProductTourListItem, ProductTourStartAction } from './types'; import { PRODUCT_TOUR_ANCHORS } from './anchors'; +function getConfigureProjectStartAction(context: ProductTourContext): ProductTourStartAction { + if (!context.organizationId) { + return { destination: resolve('/(app)/organization/add'), type: 'navigate' }; + } + + if (context.pathname.includes('/project/add')) { + return { type: 'launch' }; + } + + const project = context.projects.find((item) => !item.is_configured); + if (project?.id) { + return { + destination: `${resolve('/(app)/project/[projectId]/configure', { projectId: project.id })}?redirect=true`, + type: 'navigate' + }; + } + + return { + actionLabel: 'Create Project', + description: 'Every accessible project is already configured. A new project uses plan capacity and will remain after the guide.', + destination: resolve('/(app)/project/add'), + title: 'Create another project?', + type: 'confirm-navigation' + }; +} + +function getInvestigateErrorStartAction(context: ProductTourContext): ProductTourStartAction { + if (context.openEventType === 'error') { + return { stepId: 'inspect-details', type: 'launch' }; + } + + const destination = `${resolve('/(app)/event')}?time=all&type=error`; + if (context.pathname === resolve('/(app)/event')) { + return { destination, type: 'navigate' }; + } + + return { + actionLabel: 'Open Errors', + description: 'This guide starts in Errors so you can choose a real report. Your current page will change.', + destination, + title: 'Open Errors?', + type: 'confirm-navigation' + }; +} + function requireApplicationShell(context: ProductTourContext) { if (context.isSetupPage || !context.organizationId) { return { available: false, reason: 'Finish organization setup to explore the application UI.' }; @@ -79,7 +126,8 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ id: 'help', optional: true, showDone: true, - title: 'Help is always nearby' + title: 'Help is always nearby', + waitForElement: 5000 } ], id: 'new-ui-overview', @@ -90,6 +138,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ { description: 'Create or resume a project, connect an SDK, and wait for its first real event.', getAvailability: () => ({ available: true }), + getStartAction: getConfigureProjectStartAction, getSteps: (context) => { if (!context.organizationId) { return [ @@ -174,6 +223,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ { description: 'Save the current Events configuration as a private view that only you can see.', getAvailability: requireOrganization, + getStartAction: () => ({ destination: resolve('/(app)/event'), type: 'navigate' }), getSteps: () => [ { advanceOnClick: true, @@ -229,6 +279,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ { description: 'Open a real error and learn where to find its exception, request, environment, and custom data.', getAvailability: requireErrorEvent, + getStartAction: getInvestigateErrorStartAction, getSteps: () => [ { anchor: PRODUCT_TOUR_ANCHORS.eventList, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte index d42a1403ac..6f337b70b0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -19,13 +19,15 @@ ProductTourKey, ProductTourLaunchSource, ProductTourListItem, + ProductTourStartAction, ProductTourStep } from '../types'; import { PRODUCT_TOUR_ANCHORS, productTourSelector } from '../anchors'; import { getProductTour, getProductTourItems, getRecommendedProductTourId } from '../catalog'; import { shouldOfferProductTourAnnouncement, shouldOfferProductTourWelcome } from '../eligibility'; - import { productTourRuntime } from '../state.svelte'; + import { clearProductTourSession, readProductTourSession, writeProductTourSession } from '../session'; + import { productTourHost, type ProductTourHostEvent } from '../state.svelte'; import { buildProductTourTelemetryEvent, type ProductTourTelemetryEvent } from '../telemetry'; import ProductTourCatalog from './product-tour-catalog.svelte'; import ProductTourFeatureAnnouncement from './product-tour-feature-announcement.svelte'; @@ -45,21 +47,14 @@ organizationId?: string; pathname: string; projects: ViewProject[]; + requestErrorAvailability: () => void; routeKey: string; stateSettled: boolean; } - interface StoredTourState { - source: ProductTourLaunchSource; - stepId?: string; - tourId: ProductTourId; - version: number; - } - const WELCOME_VERSION = 1; const EXIE_ANNOUNCEMENT_VERSION = 1; const EXIE_ANNOUNCEMENT_KEY = 'exie-announcement' as const; - const SESSION_KEY = 'exceptionless.product-tour'; const SYSTEM_PATH = resolve('/(app)/system'); let { @@ -73,6 +68,7 @@ organizationId, pathname, projects, + requestErrorAvailability, routeKey, stateSettled }: Props = $props(); @@ -85,16 +81,13 @@ let welcomeBrowsePending = $state(false); let exieAnnouncementOpen = $state(false); let exieAnnouncementShown = $state(false); - let confirmNewProjectOpen = $state(false); - let pendingConfigureSource = $state(); - let confirmErrorNavigationOpen = $state(false); - let pendingInvestigateSource = $state(); + let pendingConfirmation = $state<{ + action: Extract; + id: ProductTourId; + source: ProductTourLaunchSource; + }>(); let driverInstance = $state.raw(); - let activeSource = $state(); - let activeOrganizationId = $state(); - let isFinishing = false; - let isSuspendingInline = false; - let isNavigatingTour = false; + let driverTransition: 'active' | 'finishing' | 'inline' | 'navigation' = 'active'; let driverRoute = ''; let resumeAttemptedRoute = ''; let overlayRevision = $state(0); @@ -144,7 +137,7 @@ isMeaningfulAppRoute && !isSetupPage && !isImpersonating && - !productTourRuntime.activeTourId && + !productTourHost.activeTourId && !exieAnnouncementShown && !exieAnnouncementOpen && !welcomeOpen && @@ -176,7 +169,7 @@ return; } - const stored = readStoredState(); + const stored = readProductTourSession(); if (!stored) { return; } @@ -185,16 +178,20 @@ try { storedVersion = getProductTour(stored.tourId).version; } catch { - clearStoredState(); + clearProductTourSession(); return; } if (storedVersion !== stored.version) { - clearStoredState(); + clearProductTourSession(); return; } resumeAttemptedRoute = currentRoute; + if (stored.tourId === 'investigate-error' && stored.stepId === 'choose-error' && /\/(event|stack)\//.test(pathname)) { + return; + } + void launch(stored.tourId, stored.source, stored.stepId, false); }); @@ -204,11 +201,9 @@ return; } - isNavigatingTour = true; - isFinishing = true; + driverTransition = 'navigation'; driverInstance.destroy(); - isNavigatingTour = false; - isFinishing = false; + driverTransition = 'active'; driverRoute = ''; resumeAttemptedRoute = ''; }); @@ -220,18 +215,18 @@ driverInstance?.destroy(); driverInstance = undefined; - productTourRuntime.clear(); - clearStoredState(); + productTourHost.clear(); + clearProductTourSession(); }); $effect(() => { const currentOrganizationId = organizationId; - if (!productTourRuntime.activeTourId || activeOrganizationId === currentOrganizationId) { + if (!productTourHost.activeTourId || productTourHost.organizationId === currentOrganizationId) { return; } - if (productTourRuntime.activeTourId === 'configure-project' && pathname === resolve('/(app)/organization/add')) { - activeOrganizationId = currentOrganizationId; + if (productTourHost.activeTourId === 'configure-project' && pathname === resolve('/(app)/organization/add')) { + productTourHost.set(productTourHost.activeTourId, productTourHost.activeStepId, productTourHost.source, currentOrganizationId); return; } @@ -260,12 +255,13 @@ export function openCatalog(source: ProductTourLaunchSource = 'catalog'): void { closeOverlays(); + requestErrorAvailability(); catalogSource = source; catalogOpen = true; } export async function startTour(id: ProductTourId, source: ProductTourLaunchSource = 'catalog'): Promise { - if (productTourRuntime.activeTourId) { + if (productTourHost.activeTourId) { stopActiveTour(true); } @@ -279,8 +275,9 @@ welcomeOpen = false; exieAnnouncementOpen = false; closeOverlays(); - const canReuseOpenError = id === 'investigate-error' && hasVisibleTarget(productTourSelector(PRODUCT_TOUR_ANCHORS.eventDetails)); - if (!(await waitForCompetingOverlaysToClose()) && !canReuseOpenError) { + const startAction = item.getStartAction?.({ ...context, openEventType: getVisibleEventType() }) ?? { type: 'launch' }; + const canLaunchInsideOverlay = startAction.type === 'launch' && startAction.stepId === 'inspect-details'; + if (!(await waitForCompetingOverlaysToClose()) && !canLaunchInsideOverlay) { toast.info('Close the open dialog or panel before starting a guided tour.'); return; } @@ -297,69 +294,22 @@ void track('chooser-started', id, item.version, source); } - if (id === 'configure-project' && organizationId && !projects.some((project) => !project.is_configured) && !pathname.includes('/project/add')) { - pendingConfigureSource = source; - confirmNewProjectOpen = true; - return; - } - - if (id === 'investigate-error') { - if (canReuseOpenError) { - await launch(id, source, 'inspect-details'); - return; - } - - if (pathname !== resolve('/(app)/event')) { - pendingInvestigateSource = source; - confirmErrorNavigationOpen = true; - return; - } - } - - await navigateOrLaunch(id, source); + await executeStartAction(id, source, startAction); } - async function confirmErrorNavigation(): Promise { - const source = pendingInvestigateSource; - pendingInvestigateSource = undefined; - confirmErrorNavigationOpen = false; - if (source) { - await navigateOrLaunch('investigate-error', source); - } - } - - async function navigateOrLaunch(id: ProductTourId, source: ProductTourLaunchSource): Promise { - const destination = getDestination(id); - const definition = getProductTour(id); - - if (destination && destination !== routeKey) { - writeStoredState({ source, tourId: id, version: definition.version }); - await goto(destination); + async function executeStartAction(id: ProductTourId, source: ProductTourLaunchSource, action: ProductTourStartAction): Promise { + if (action.type === 'confirm-navigation') { + pendingConfirmation = { action, id, source }; return; } - await launch(id, source); - } - - function getDestination(id: ProductTourId): string | undefined { - if (id === 'create-saved-view') { - return resolve('/(app)/event'); - } - - if (id === 'investigate-error') { - return `${resolve('/(app)/event')}?time=all&type=error`; - } - - if (id !== 'configure-project') { - return undefined; - } - - if (!organizationId) { - return resolve('/(app)/organization/add'); + if (action.type === 'navigate' && action.destination !== routeKey) { + writeProductTourSession({ source, tourId: id, version: getProductTour(id).version }); + await goto(action.destination); + return; } - const project = projects.find((item) => !item.is_configured); - return project?.id ? `${resolve('/(app)/project/[projectId]/configure', { projectId: project.id })}?redirect=true` : undefined; + await launch(id, source, action.type === 'launch' ? action.stepId : undefined); } async function launch(id: ProductTourId, source: ProductTourLaunchSource, resumeStepId?: string, emitStarted = true): Promise { @@ -368,12 +318,8 @@ await ensureTarget({ anchor: PRODUCT_TOUR_ANCHORS.appNavigation, description: '', id: 'mobile-navigation', title: '' }); } - const allSteps = orderStepsForViewport(id, definition.getSteps(context)).filter( - (step) => !step.optional || !step.anchor || hasVisibleTarget(productTourSelector(step.anchor)) - ); - const detailRouteResume = id === 'investigate-error' && resumeStepId === 'choose-error' && /\/(event|stack)\//.test(pathname); - const effectiveResumeStepId = detailRouteResume ? 'inspect-details' : resumeStepId; - let startIndex = effectiveResumeStepId ? allSteps.findIndex((step) => step.id === effectiveResumeStepId) : 0; + const allSteps = await getAvailableSteps(id, definition.getSteps(context)); + let startIndex = resumeStepId ? allSteps.findIndex((step) => step.id === resumeStepId) : 0; if (startIndex < 0) { startIndex = 0; } @@ -385,18 +331,14 @@ } if (firstStep.presentation === 'inline') { - activeSource = source; - activeOrganizationId = organizationId; - productTourRuntime.set(id, firstStep.id); - writeStoredState({ source, stepId: firstStep.id, tourId: id, version: definition.version }); + productTourHost.set(id, firstStep.id, source, organizationId); + writeProductTourSession({ source, stepId: firstStep.id, tourId: id, version: definition.version }); void track(emitStarted ? 'started' : 'step', id, definition.version, source, firstStep.id); return; } const { driver } = await import('driver.js'); - activeSource = source; - activeOrganizationId = organizationId; - isFinishing = false; + driverTransition = 'active'; const steps = allSteps.slice(startIndex); driverInstance = driver({ @@ -405,21 +347,20 @@ disableActiveInteraction: false, onCloseClick: () => void dismissTour(id, definition.version, source), onDestroyed: () => { - const activeStepId = productTourRuntime.activeStepId; - const preservingNavigation = isNavigatingTour || (driverRoute !== '' && driverRoute !== routeKey); - const preservingPendingEvent = id === 'investigate-error' && (activeStepId === 'choose-error' || readStoredState()?.stepId === 'choose-error'); + const activeStepId = productTourHost.activeStepId; + const preservingNavigation = driverTransition === 'navigation' || (driverRoute !== '' && driverRoute !== routeKey); + const preservingPendingEvent = + id === 'investigate-error' && (activeStepId === 'choose-error' || readProductTourSession()?.stepId === 'choose-error'); driverInstance = undefined; - if (!isSuspendingInline && !preservingNavigation && !preservingPendingEvent) { - productTourRuntime.clear(); - activeSource = undefined; - activeOrganizationId = undefined; - if (!isFinishing) { - clearStoredState(); + if (driverTransition !== 'inline' && !preservingNavigation && !preservingPendingEvent) { + productTourHost.clear(); + if (driverTransition === 'active') { + clearProductTourSession(); void track('dismissed', id, definition.version, source, activeStepId); } } - if (!isFinishing && !isSuspendingInline && !preservingNavigation && !preservingPendingEvent) { + if (driverTransition === 'active' && !preservingNavigation && !preservingPendingEvent) { void recordProgress(id, definition.version, 'dismissed').catch(() => toast.error('We could not save your guided-tour progress.')); } }, @@ -430,8 +371,8 @@ return; } - productTourRuntime.set(id, step.id); - writeStoredState({ source, stepId: step.resumeStepId ?? step.id, tourId: id, version: definition.version }); + productTourHost.set(id, step.id, source, organizationId); + writeProductTourSession({ source, stepId: step.resumeStepId ?? step.id, tourId: id, version: definition.version }); void track('step', id, definition.version, source, step.id); }, overlayClickBehavior: 'close', @@ -451,8 +392,8 @@ })) }); driverRoute = routeKey; - productTourRuntime.set(id, firstStep.id); - writeStoredState({ source, stepId: firstStep.resumeStepId ?? firstStep.id, tourId: id, version: definition.version }); + productTourHost.set(id, firstStep.id, source, organizationId); + writeProductTourSession({ source, stepId: firstStep.resumeStepId ?? firstStep.id, tourId: id, version: definition.version }); if (emitStarted) { void track('started', id, definition.version, source); @@ -497,13 +438,11 @@ const next = steps[index + 1]; if (!next) { if (step.advanceOnClick && step.resumeStepId) { - isFinishing = true; - writeStoredState({ source, stepId: step.resumeStepId, tourId: id, version }); + driverTransition = 'finishing'; + writeProductTourSession({ source, stepId: step.resumeStepId, tourId: id, version }); driverInstance?.destroy(); driverInstance = undefined; - productTourRuntime.clear(); - activeSource = undefined; - activeOrganizationId = undefined; + productTourHost.clear(); return; } @@ -527,15 +466,11 @@ } if (next.presentation === 'inline') { - isSuspendingInline = true; - isFinishing = true; - writeStoredState({ source, stepId: next.id, tourId: id, version }); + driverTransition = 'inline'; + writeProductTourSession({ source, stepId: next.id, tourId: id, version }); driverInstance?.destroy(); - isSuspendingInline = false; - isFinishing = false; - activeSource = source; - activeOrganizationId = organizationId; - productTourRuntime.set(id, next.id); + driverTransition = 'active'; + productTourHost.set(id, next.id, source, organizationId); void track('step', id, version, source, next.id); return; } @@ -543,12 +478,10 @@ if (driverInstance && driverRoute === routeKey) { driverInstance.moveNext(); } else { - isNavigatingTour = true; - isFinishing = true; + driverTransition = 'navigation'; driverInstance?.destroy(); driverInstance = undefined; - isNavigatingTour = false; - isFinishing = false; + driverTransition = 'active'; await launch(id, source, next.id, false); } } @@ -591,10 +524,22 @@ return !!element && element.getClientRects().length > 0; } + function getVisibleEventType(): string | undefined { + const element = document.querySelector(productTourSelector(PRODUCT_TOUR_ANCHORS.eventDetails)); + return element?.getClientRects().length ? element.dataset.eventType : undefined; + } + function isMobileViewport(): boolean { return window.matchMedia('(max-width: 767px)').matches; } + async function getAvailableSteps(id: ProductTourId, steps: ProductTourStep[]): Promise { + const orderedSteps = orderStepsForViewport(id, steps); + const availability = await Promise.all(orderedSteps.map((step) => (!step.optional || !step.anchor ? Promise.resolve(true) : ensureTarget(step)))); + + return orderedSteps.filter((_, index) => availability[index]); + } + function orderStepsForViewport(id: ProductTourId, steps: ProductTourStep[]): ProductTourStep[] { if (id !== 'new-ui-overview' || !isMobileViewport()) { return steps; @@ -678,65 +623,60 @@ openCatalog('catalog'); } - async function confirmNewProject(): Promise { - const source = pendingConfigureSource ?? 'catalog'; - pendingConfigureSource = undefined; - confirmNewProjectOpen = false; - const definition = getProductTour('configure-project'); - writeStoredState({ source, tourId: definition.id, version: definition.version }); - await goto(resolve('/(app)/project/add')); + async function confirmNavigation(): Promise { + const confirmation = pendingConfirmation; + pendingConfirmation = undefined; + if (confirmation) { + await executeStartAction(confirmation.id, confirmation.source, { destination: confirmation.action.destination, type: 'navigate' }); + } } async function completeTour(id: ProductTourId, version: number, source: ProductTourLaunchSource): Promise { - isFinishing = true; + driverTransition = 'finishing'; try { await recordProgress(id, version, 'completed'); } catch { - isFinishing = false; + driverTransition = 'active'; toast.error('We could not save your guided-tour progress. Please try again.'); return; } - clearStoredState(); + clearProductTourSession(); void track('completed', id, version, source); driverInstance?.destroy(); - productTourRuntime.clear(); - activeSource = undefined; - activeOrganizationId = undefined; + productTourHost.clear(); + driverTransition = 'active'; } async function dismissTour(id: ProductTourId, version: number, source: ProductTourLaunchSource): Promise { - isFinishing = true; - clearStoredState(); + driverTransition = 'finishing'; + clearProductTourSession(); await recordProgress(id, version, 'dismissed').catch(() => toast.error('We could not save your guided-tour progress.')); - void track('dismissed', id, version, source, productTourRuntime.activeStepId); + void track('dismissed', id, version, source, productTourHost.activeStepId); driverInstance?.destroy(); - productTourRuntime.clear(); - activeSource = undefined; - activeOrganizationId = undefined; + productTourHost.clear(); + driverTransition = 'active'; } async function failTour(id: ProductTourId, version: number, stepId: string, source: ProductTourLaunchSource): Promise { - isFinishing = true; - clearStoredState(); + driverTransition = 'finishing'; + clearProductTourSession(); void track('failed', id, version, source, stepId); toast.error('This guide could not find the next screen. You can restart it from Guided Tours.'); driverInstance?.destroy(); - productTourRuntime.clear(); - activeSource = undefined; - activeOrganizationId = undefined; + productTourHost.clear(); + driverTransition = 'active'; } function stopActiveTour(clearSession: boolean): void { - isFinishing = true; + driverTransition = 'finishing'; if (clearSession) { - clearStoredState(); + clearProductTourSession(); } driverInstance?.destroy(); - productTourRuntime.clear(); - activeSource = undefined; - activeOrganizationId = undefined; + productTourHost.clear(); + driverTransition = 'active'; } async function recordProgress(key: string, version: number, status: 'completed' | 'dismissed'): Promise { @@ -753,50 +693,26 @@ await submitFeatureUsage(buildProductTourTelemetryEvent(event, id, version, source, stepId)).catch(() => undefined); } - function readStoredState(): StoredTourState | undefined { - try { - const value = sessionStorage.getItem(SESSION_KEY); - return value ? (JSON.parse(value) as StoredTourState) : undefined; - } catch { - clearStoredState(); - return undefined; - } - } - - function writeStoredState(state: StoredTourState): void { - sessionStorage.setItem(SESSION_KEY, JSON.stringify(state)); - } - - function clearStoredState(): void { - sessionStorage.removeItem(SESSION_KEY); - } - - function onDomainComplete(event: Event): void { - const detail = (event as CustomEvent<{ tourId?: ProductTourId }>).detail; - const id = detail?.tourId; - if (!id || productTourRuntime.activeTourId !== id || !activeSource) { + function onDomainComplete(id: ProductTourId): void { + if (productTourHost.activeTourId !== id || !productTourHost.source) { return; } const definition = getProductTour(id); - void completeTour(id, definition.version, activeSource); + void completeTour(id, definition.version, productTourHost.source); } - function onDomainDismiss(event: Event): void { - const id = (event as CustomEvent<{ tourId?: ProductTourId }>).detail?.tourId; - if (!id || productTourRuntime.activeTourId !== id || !activeSource) { + function onDomainDismiss(id: ProductTourId): void { + if (productTourHost.activeTourId !== id || !productTourHost.source) { return; } const definition = getProductTour(id); - void dismissTour(id, definition.version, activeSource); + void dismissTour(id, definition.version, productTourHost.source); } - function onInlineAdvance(event: Event): void { - const detail = (event as CustomEvent<{ stepId?: string; tourId?: ProductTourId }>).detail; - const id = detail?.tourId; - const stepId = detail?.stepId; - if (!id || !stepId || productTourRuntime.activeTourId !== id || productTourRuntime.activeStepId !== stepId || !activeSource) { + function onInlineAdvance(id: ProductTourId, stepId: string): void { + if (productTourHost.activeTourId !== id || productTourHost.activeStepId !== stepId || !productTourHost.source) { return; } @@ -804,18 +720,17 @@ const steps = definition.getSteps(context).filter((step) => !step.optional || !step.anchor || hasVisibleTarget(productTourSelector(step.anchor))); const index = steps.findIndex((step) => step.id === stepId); if (index >= 0) { - void advance(id, definition.version, activeSource, steps, index); + void advance(id, definition.version, productTourHost.source, steps, index); } } - function onEventOpened(event: Event): void { - const eventType = (event as CustomEvent<{ eventType?: string }>).detail?.eventType; - const stored = readStoredState(); + function onEventOpened(eventType?: string): void { + const stored = readProductTourSession(); const isActiveChooseError = - productTourRuntime.activeTourId === 'investigate-error' && productTourRuntime.activeStepId === 'choose-error' && !!activeSource; + productTourHost.activeTourId === 'investigate-error' && productTourHost.activeStepId === 'choose-error' && !!productTourHost.source; const isResumableChooseError = stored?.tourId === 'investigate-error' && stored.stepId === 'choose-error'; if (eventType !== 'error' || (!isActiveChooseError && !isResumableChooseError)) { - if (productTourRuntime.activeTourId === 'investigate-error' && eventType && eventType !== 'error') { + if (productTourHost.activeTourId === 'investigate-error' && eventType && eventType !== 'error') { toast.info('Choose an error event to continue this guide.'); } @@ -824,12 +739,10 @@ const definition = getProductTour('investigate-error'); if (!isActiveChooseError && stored) { - activeSource = stored.source; - activeOrganizationId = organizationId; - productTourRuntime.set('investigate-error', 'choose-error'); + productTourHost.set('investigate-error', 'choose-error', stored.source, organizationId); } - const source = activeSource ?? stored?.source; + const source = productTourHost.source ?? stored?.source; if (!source) { return; } @@ -837,24 +750,30 @@ const steps = definition.getSteps(context).filter((step) => !step.optional || !step.anchor || hasVisibleTarget(productTourSelector(step.anchor))); const index = steps.findIndex((step) => step.id === 'choose-error'); if (index >= 0) { - productTourRuntime.set('investigate-error', 'inspect-details'); - writeStoredState({ source, stepId: 'inspect-details', tourId: 'investigate-error', version: definition.version }); + productTourHost.set('investigate-error', 'inspect-details', source, organizationId); + writeProductTourSession({ source, stepId: 'inspect-details', tourId: 'investigate-error', version: definition.version }); void advance('investigate-error', definition.version, source, steps, index); } } - $effect(() => { - document.addEventListener('product-tour:completed', onDomainComplete); - document.addEventListener('product-tour:dismissed', onDomainDismiss); - document.addEventListener('product-tour:advance', onInlineAdvance); - document.addEventListener('product-tour:event-opened', onEventOpened); - return () => { - document.removeEventListener('product-tour:completed', onDomainComplete); - document.removeEventListener('product-tour:dismissed', onDomainDismiss); - document.removeEventListener('product-tour:advance', onInlineAdvance); - document.removeEventListener('product-tour:event-opened', onEventOpened); - }; - }); + function onHostEvent(event: ProductTourHostEvent): void { + switch (event.type) { + case 'advance': + onInlineAdvance(event.tourId, event.stepId); + break; + case 'completed': + onDomainComplete(event.tourId); + break; + case 'dismissed': + onDomainDismiss(event.tourId); + break; + case 'event-opened': + onEventOpened(event.eventType); + break; + } + } + + $effect(() => productTourHost.subscribe(onHostEvent)); void startTour(id, catalogSource)} /> - - - - Create another project? - - Every accessible project is already configured. A new project uses plan capacity and will remain after the guide. - - - - (pendingConfigureSource = undefined)}>Cancel - void confirmNewProject()}>Create Project - - - - - - - - Open Errors? - This guide starts in Errors so you can choose a real report. Your current page will change. - - - (pendingInvestigateSource = undefined)}>Cancel - void confirmErrorNavigation()}>Open Errors - - - +{#if pendingConfirmation} + { + if (!open) { + pendingConfirmation = undefined; + } + }} + > + + + {pendingConfirmation.action.title} + {pendingConfirmation.action.description} + + + Cancel + void confirmNavigation()}>{pendingConfirmation.action.actionLabel} + + + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.test.ts new file mode 100644 index 0000000000..53221790d7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; + +describe('product tour session', () => { + it('round-trips stable resume state', () => { + let value: null | string = null; + const storage = { + getItem: () => value, + removeItem: vi.fn(() => (value = null)), + setItem: vi.fn((_key: string, next: string) => (value = next)) + }; + const session = { source: 'command-palette', stepId: 'choose-error', tourId: 'investigate-error', version: 1 } as const; + + writeProductTourSession(session, storage); + expect(readProductTourSession(storage)).toEqual(session); + clearProductTourSession(storage); + expect(value).toBeNull(); + }); + + it('clears malformed state instead of blocking future guides', () => { + const removeItem = vi.fn(); + const storage = { getItem: () => '{not-json', removeItem }; + + expect(readProductTourSession(storage)).toBeUndefined(); + expect(removeItem).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts new file mode 100644 index 0000000000..7f618048b8 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts @@ -0,0 +1,28 @@ +import type { ProductTourId, ProductTourLaunchSource } from './types'; + +export interface StoredProductTourSession { + source: ProductTourLaunchSource; + stepId?: string; + tourId: ProductTourId; + version: number; +} + +const SESSION_KEY = 'exceptionless.product-tour'; + +export function clearProductTourSession(storage: Pick = sessionStorage): void { + storage.removeItem(SESSION_KEY); +} + +export function readProductTourSession(storage: Pick = sessionStorage): StoredProductTourSession | undefined { + try { + const value = storage.getItem(SESSION_KEY); + return value ? (JSON.parse(value) as StoredProductTourSession) : undefined; + } catch { + clearProductTourSession(storage); + return undefined; + } +} + +export function writeProductTourSession(session: StoredProductTourSession, storage: Pick = sessionStorage): void { + storage.setItem(SESSION_KEY, JSON.stringify(session)); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts index f4998f41ff..29d4d17b35 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts @@ -1,22 +1,80 @@ -import type { ProductTourId } from './types'; +import type { ProductTourId, ProductTourLaunchSource } from './types'; -class ProductTourRuntimeState { - activeStepId = $state(); - activeTourId = $state(); +export type ProductTourHostEvent = + | { eventType?: string; type: 'event-opened' } + | { stepId: string; tourId: ProductTourId; type: 'advance' } + | { tourId: ProductTourId; type: 'completed' | 'dismissed' }; + +type ProductTourHostListener = (event: ProductTourHostEvent) => void; + +class ProductTourHost { + get activeStepId(): string | undefined { + return this.session?.stepId; + } + get activeTourId(): ProductTourId | undefined { + return this.session?.tourId; + } + + get organizationId(): string | undefined { + return this.session?.organizationId; + } + + get source(): ProductTourLaunchSource | undefined { + return this.session?.source; + } + + private readonly listeners = new Set(); + + private session = $state<{ + organizationId?: string; + source: ProductTourLaunchSource; + stepId?: string; + tourId: ProductTourId; + }>(); + + advance(tourId: ProductTourId, stepId: string): void { + this.publish({ stepId, tourId, type: 'advance' }); + } clear(): void { - this.activeStepId = undefined; - this.activeTourId = undefined; + this.session = undefined; + } + + complete(tourId: ProductTourId): void { + this.publish({ tourId, type: 'completed' }); + } + + dismiss(tourId: ProductTourId): void { + this.publish({ tourId, type: 'dismissed' }); + } + + eventOpened(eventType?: string): void { + this.publish({ eventType, type: 'event-opened' }); } isActive(tourId: ProductTourId): boolean { return this.activeTourId === tourId; } - set(tourId: ProductTourId, stepId?: string): void { - this.activeTourId = tourId; - this.activeStepId = stepId; + set(tourId: ProductTourId, stepId: string | undefined, source?: ProductTourLaunchSource, organizationId?: string): void { + const activeSource = source ?? this.session?.source; + if (!activeSource) { + throw new Error('A product tour session requires a launch source.'); + } + + this.session = { organizationId: organizationId ?? this.session?.organizationId, source: activeSource, stepId, tourId }; + } + + subscribe(listener: ProductTourHostListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private publish(event: ProductTourHostEvent): void { + for (const listener of this.listeners) { + listener(event); + } } } -export const productTourRuntime = new ProductTourRuntimeState(); +export const productTourHost = new ProductTourHost(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts index 9454eda14d..4c42586ee4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts @@ -10,6 +10,7 @@ export interface ProductTourContext { assistantAccess?: AssistantAccess; errorEventAvailability: ProductTourErrorEventAvailability; isSetupPage: boolean; + openEventType?: string; organizationId?: string; pathname: string; projects: ViewProject[]; @@ -17,6 +18,7 @@ export interface ProductTourContext { export interface ProductTourDefinition { description: string; getAvailability: (context: ProductTourContext) => ProductTourAvailability; + getStartAction?: (context: ProductTourContext) => ProductTourStartAction; getSteps: (context: ProductTourContext) => ProductTourStep[]; id: ProductTourId; keywords: readonly string[]; @@ -25,7 +27,6 @@ export interface ProductTourDefinition { } export type ProductTourErrorEventAvailability = 'available' | 'empty' | 'error' | 'loading'; export type ProductTourId = 'configure-project' | 'create-saved-view' | 'investigate-error' | 'meet-exie' | 'new-ui-overview'; - export type ProductTourKey = 'exie-announcement' | 'welcome' | ProductTourId; export type ProductTourLaunchSource = 'automatic' | 'catalog' | 'command-palette' | 'feature-announcement' | 'help-menu'; @@ -37,6 +38,11 @@ export interface ProductTourListItem extends ProductTourDefinition { export type ProductTourPresentation = 'inline' | 'spotlight'; +export type ProductTourStartAction = + | { actionLabel: string; description: string; destination: string; title: string; type: 'confirm-navigation' } + | { destination: string; type: 'navigate' } + | { stepId?: string; type: 'launch' }; + export interface ProductTourStep { advanceOnClick?: boolean; anchor?: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte index d14d73ea55..aaf5e893b4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -6,7 +6,7 @@ import { Label } from '$comp/ui/label'; import { Switch } from '$comp/ui/switch'; import ProductTourInlineCallout from '$features/product-tours/components/product-tour-inline-callout.svelte'; - import { productTourRuntime } from '$features/product-tours/state.svelte'; + import { productTourHost } from '$features/product-tours/state.svelte'; import type { SavedView } from '../models'; @@ -145,25 +145,23 @@ Save View Save the current view configuration for quick access. - {#if defaultPrivate && productTourRuntime.activeStepId === 'name-view'} + {#if defaultPrivate && productTourHost.activeStepId === 'name-view'} - document.dispatchEvent(new CustomEvent('product-tour:advance', { detail: { stepId: 'name-view', tourId: 'create-saved-view' } }))} + onContinue={() => productTourHost.advance('create-saved-view', 'name-view')} onDismiss={dismissTour} title="Review and name your view" tourId="create-saved-view" /> - {:else if defaultPrivate && productTourRuntime.activeStepId === 'private-view'} + {:else if defaultPrivate && productTourHost.activeStepId === 'private-view'} - document.dispatchEvent(new CustomEvent('product-tour:advance', { detail: { stepId: 'private-view', tourId: 'create-saved-view' } }))} + onContinue={() => productTourHost.advance('create-saved-view', 'private-view')} onDismiss={dismissTour} title="Keep it private" tourId="create-saved-view" /> - {:else if defaultPrivate && productTourRuntime.activeStepId === 'save-view'} + {:else if defaultPrivate && productTourHost.activeStepId === 'save-view'} (isSaveDialogOpen = false)} onCancel={() => { - if (productTourRuntime.isActive('create-saved-view')) { - document.dispatchEvent(new CustomEvent('product-tour:dismissed', { detail: { tourId: 'create-saved-view' } })); + if (productTourHost.isActive('create-saved-view')) { + productTourHost.dismiss('create-saved-view'); } }} {onLoadView} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index b38af8524a..f1ca931358 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -88,6 +88,7 @@ let isImpersonateOrganizationOpen = $state(false); let isUserMenuOpen = $state(false); let productToursComponent = $state(); + let productTourErrorCheckEnabled = $state(false); // Auto-reset premium page state on navigation so pages don't need cleanup beforeNavigate(() => { @@ -96,6 +97,7 @@ function openCommandPalette(): void { commandResetKey += 1; + productTourErrorCheckEnabled = true; isCommandOpen = true; } @@ -171,6 +173,7 @@ } function openGuidedTours(source: ProductTourLaunchSource): void { + productTourErrorCheckEnabled = true; productToursComponent?.openCatalog(source); } @@ -423,6 +426,7 @@ const projects = $derived(projectsQuery.data?.data ?? []); const productTourProjects = $derived(projects.filter((project) => !organization.current || project.organization_id === organization.current)); const productTourErrorEventsQuery = getOrganizationEventsQuery({ + enabled: () => productTourErrorCheckEnabled, params: { filter: 'type:error', limit: 1, mode: 'summary', time: 'all' }, route: { get organizationId() { @@ -431,7 +435,7 @@ } }); const productTourErrorEventAvailability = $derived.by(() => { - if (!organization.current || productTourErrorEventsQuery.isPending) { + if (!organization.current || !productTourErrorCheckEnabled || productTourErrorEventsQuery.isPending) { return 'loading' as const; } @@ -722,6 +726,7 @@ organizationId={organization.current} pathname={page.url.pathname} projects={productTourProjects} + requestErrorAvailability={() => (productTourErrorCheckEnabled = true)} routeKey={`${page.url.pathname}${page.url.search}`} stateSettled={meQuery.isSuccess && organizationsQuery.isSuccess && diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId=objectid]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId=objectid]/+page.svelte index 71f8e495fe..14dbe4b67c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId=objectid]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId=objectid]/+page.svelte @@ -40,7 +40,6 @@ async function handleEventLoaded(event: PersistentEvent) { assistantPageContext.setPageEvent(event); - document.dispatchEvent(new CustomEvent('product-tour:event-opened', { detail: { eventType: event.type } })); await goto(buildEventDetailsHref(event.id, event.stack_id), { replaceState: true }); } @@ -49,12 +48,10 @@ }); -
    - goto(buildEventDetailsHref(newId))} - /> -
    + goto(buildEventDetailsHref(newId))} +/> diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte index 4b373e6e99..82b73bf724 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte @@ -13,7 +13,7 @@ import { organization } from '$features/organizations/context.svelte'; import { useHideOrganizationNotifications } from '$features/organizations/hooks/use-hide-organization-notifications.svelte'; import ProductTourInlineCallout from '$features/product-tours/components/product-tour-inline-callout.svelte'; - import { productTourRuntime } from '$features/product-tours/state.svelte'; + import { productTourHost } from '$features/product-tours/state.svelte'; import { getProjectQuery } from '$features/projects/api.svelte'; import { getProjectDefaultTokenQuery, patchToken } from '$features/tokens/api.svelte'; import EnableTokenDialog from '$features/tokens/components/dialogs/enable-token-dialog.svelte'; @@ -342,7 +342,7 @@ public partial class App : Application { const message = (event as CustomEvent>).detail; if (queryParams.redirect && message.project_id === projectId && message.change_type !== ChangeType.Removed) { - document.dispatchEvent(new CustomEvent('product-tour:completed', { detail: { tourId: 'configure-project' } })); + productTourHost.complete('configure-project'); toast.success('First event received. Opening Events...'); await redirectToEventsWithFilter(organization.current, new ProjectFilter([projectId])); } @@ -358,7 +358,7 @@ public partial class App : Application { return; } - document.dispatchEvent(new CustomEvent('product-tour:completed', { detail: { tourId: 'configure-project' } })); + productTourHost.complete('configure-project'); toast.success('First event received. Opening Events...'); await redirectToEventsWithFilter(organization.current, new ProjectFilter([projectId])); } @@ -408,14 +408,9 @@ public partial class App : Application { Waiting for your first event

    Send an event from your app. When it arrives, we'll open the project Events page automatically.

    - {#if productTourRuntime.isActive('configure-project')} + {#if productTourHost.isActive('configure-project')}

    You can leave this tab while updating your application. The guide will resume here when you return.

    - + {/if}
    @@ -724,12 +719,11 @@ public partial class App : Application {
    - {#if productTourRuntime.isActive('configure-project') && productTourRuntime.activeStepId === 'sdk-instructions'} + {#if productTourHost.isActive('configure-project') && productTourHost.activeStepId === 'sdk-instructions'} - document.dispatchEvent(new CustomEvent('product-tour:advance', { detail: { stepId: 'sdk-instructions', tourId: 'configure-project' } }))} - onDismiss={() => document.dispatchEvent(new CustomEvent('product-tour:dismissed', { detail: { tourId: 'configure-project' } }))} + onContinue={() => productTourHost.advance('configure-project', 'sdk-instructions')} + onDismiss={() => productTourHost.dismiss('configure-project')} title="Connect your application" tourId="configure-project" /> diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/+page.svelte index f73a917822..91df3ae2e9 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/+page.svelte @@ -45,7 +45,6 @@ async function handleEventLoaded(event: PersistentEvent) { assistantPageContext.setPageEvent(event); - document.dispatchEvent(new CustomEvent('product-tour:event-opened', { detail: { eventType: event.type } })); await goto(buildEventDetailsHref(event.id, event.stack_id), { replaceState: true }); } @@ -61,14 +60,12 @@ }); -
    - -
    + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/event/[eventId=objectid]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/event/[eventId=objectid]/+page.svelte index 683e6e96cc..36146ec4c6 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/event/[eventId=objectid]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/event/[eventId=objectid]/+page.svelte @@ -45,7 +45,6 @@ async function handleEventLoaded(event: PersistentEvent) { assistantPageContext.setPageEvent(event); - document.dispatchEvent(new CustomEvent('product-tour:event-opened', { detail: { eventType: event.type } })); if (event.id !== eventId || event.stack_id !== stackId) { await goto(buildEventDetailsHref(event.id, event.stack_id), { replaceState: true }); @@ -61,6 +60,4 @@ }); -
    - -
    + diff --git a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json index 100eaae183..e2f5d79c68 100644 --- a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json +++ b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json @@ -2588,6 +2588,20 @@ "authorizationRoles": [], "authenticationSchemes": [] }, + { + "method": "PUT", + "route": "/api/v2/users/me/product-tours/{tourId:minlength(1):maxlength(64)}", + "displayName": "HTTP: PUT api/v2/users/me/product-tours/{tourId:minlength(1):maxlength(64)}", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, { "method": "POST", "route": "/api/v2/users/unverify-email-address", diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index feca055d08..6a9ab3936c 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -12720,7 +12720,10 @@ "dismissed", "completed" ], - "type": "string" + "x-enumNames": [ + "Dismissed", + "Completed" + ] }, "ResetPasswordModel": { "required": [ @@ -13084,8 +13087,8 @@ }, "UpdateProductTourProgress": { "required": [ - "version", - "status" + "status", + "version" ], "type": "object", "properties": { @@ -13096,7 +13099,14 @@ "format": "int32" }, "status": { - "$ref": "#/components/schemas/ProductTourStatus" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ProductTourStatus" + } + ] } } }, @@ -14306,4 +14316,4 @@ "name": "Source Map" } ] -} +} \ No newline at end of file From 3c35fd1433f062296c9902a19a4bd5ace3f78afb Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 11 Aug 2026 22:37:17 -0500 Subject: [PATCH 08/20] Expand the error investigation guide --- .../ClientApp/e2e/tests/product-tours.e2e.ts | 30 +++- .../events/components/events-overview.svelte | 127 ++++++++++++++-- .../src/lib/features/product-tours/anchors.ts | 12 ++ .../features/product-tours/catalog.test.ts | 25 +++- .../src/lib/features/product-tours/catalog.ts | 136 +++++++++++++++--- .../product-tour-inline-callout.svelte | 5 +- .../components/product-tours.svelte | 23 ++- .../stacks/components/stack-card.svelte | 10 +- .../src/routes/(app)/event/+page.svelte | 2 +- 9 files changed, 319 insertions(+), 51 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index eaa78395cd..efd8a5d23a 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -134,6 +134,8 @@ test('Investigate an error resumes after navigation and advances only after an e await expect(page.getByText(e2eScenario.message)).toBeVisible({ timeout: 30_000 }); await startTourFromCommand(page, 'Investigate an error'); const tour = page.locator('.driver-popover'); + await expect(tour.getByText('Start with the right errors')).toBeVisible(); + await tour.getByRole('button', { name: 'Next' }).click(); await expect(tour.getByText('Open a real error')).toBeVisible(); await expect(page).toHaveURL(/\/next\/event\?time=all&type=error/); await tour.getByRole('button', { name: 'Close' }).click(); @@ -143,20 +145,39 @@ test('Investigate an error resumes after navigation and advances only after an e const navigationConfirmation = page.getByRole('alertdialog', { name: 'Open Errors?' }); await expect(navigationConfirmation).toBeVisible(); await navigationConfirmation.getByRole('button', { name: 'Open Errors' }).click(); + await expect(tour.getByText('Start with the right errors')).toBeVisible(); + await tour.getByRole('button', { name: 'Next' }).click(); await expect(tour.getByText('Open a real error')).toBeVisible(); await expect(page).toHaveURL(/\/next\/event\?time=all&type=error/); await page.screenshot({ fullPage: true, path: testInfo.outputPath('investigate-error-list.png') }); await page.locator('tr').filter({ hasText: e2eScenario.message }).first().click(); const investigationCallout = page.locator('[data-product-tour-inline="investigate-error"]'); - await expect(investigationCallout.getByText('Investigate the evidence')).toBeVisible({ timeout: 30_000 }); + await expect(investigationCallout.getByText('Understand the grouped issue')).toBeVisible({ timeout: 30_000 }); await page.screenshot({ fullPage: true, path: testInfo.outputPath('investigate-error-details.png') }); - await investigationCallout.getByRole('button', { name: 'Continue' }).click(); - await expect(page.getByText('Investigate the evidence')).toBeHidden(); + + for (const title of [ + 'Triage deliberately', + 'Inspect the occurrence', + 'Begin with the overview', + 'Follow the exception', + 'Reconstruct the request', + 'Check where it happened', + 'Review custom context', + 'Compare every occurrence' + ]) { + await investigationCallout.getByRole('button', { name: 'Continue' }).click(); + await expect(investigationCallout.getByText(title)).toBeVisible(); + } + + await expect(page.locator('[data-tour="event-tab-extended-data"]')).toHaveAttribute('data-state', 'active'); + await investigationCallout.getByRole('button', { name: 'Finish guide' }).click(); + await expect(investigationCallout).toBeHidden(); await page.goto(`/next/event/${event.id}`); await expect(page.locator('[data-tour="event-details"]')).toBeVisible({ timeout: 30_000 }); await startTourFromCommand(page, 'Investigate an error'); - await expect(investigationCallout.getByText('Investigate the evidence')).toBeVisible(); + await expect(investigationCallout.getByText('Understand the grouped issue')).toBeVisible(); + await investigationCallout.getByRole('button', { name: 'Continue' }).click(); await investigationCallout.getByRole('button', { name: 'End guide' }).click(); expect(event.type).toBe('error'); @@ -171,6 +192,7 @@ test('Investigate an error resumes after navigation and advances only after an e const usageEvent = await e2eApi.pollForEventByReference(e2eScenario.userToken, e2eScenario.projectId, usageReferenceId); await page.goto(`/next/event/${usageEvent.id}`); await expect(page.locator('[data-tour="event-details"][data-event-type="usage"]')).toBeVisible({ timeout: 30_000 }); + await page.waitForURL(/\/next\/stack\/[^/]+\/event\/[^/]+/); const usageUrl = page.url(); await startTourFromCommand(page, 'Investigate an error'); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index 555bf7e667..107b9cc4e4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -15,6 +15,8 @@ import * as EventsFacetedFilter from '$features/events/components/filters'; import { getExtendedDataItems, hasErrorOrSimpleError } from '$features/events/persistent-event'; import { getOrganizationQuery } from '$features/organizations/api.svelte'; + import { PRODUCT_TOUR_ANCHORS, productTourSelector } from '$features/product-tours/anchors'; + import { investigateErrorSteps } from '$features/product-tours/catalog'; import ProductTourInlineCallout from '$features/product-tours/components/product-tour-inline-callout.svelte'; import { productTourHost } from '$features/product-tours/state.svelte'; import { getProjectQuery, updateProject } from '$features/projects/api.svelte'; @@ -150,6 +152,54 @@ let notifiedEventId = $state(''); let showJsonDialog = $state(false); + const activeInvestigationStep = $derived( + productTourHost.activeTourId === 'investigate-error' ? investigateErrorSteps.find((step) => step.id === productTourHost.activeStepId) : undefined + ); + const activeInvestigationTab = $derived.by(() => { + switch (activeInvestigationStep?.id) { + case 'tab-environment': + return 'Environment'; + case 'tab-exception': + return 'Exception'; + case 'tab-extended-data': + return 'Extended Data'; + case 'tab-overview': + return 'Overview'; + case 'tab-request': + return 'Request'; + case 'tab-session': + return 'Session Events'; + case 'tab-trace': + return 'Trace Log'; + default: + return undefined; + } + }); + const isStackInvestigationStep = $derived(activeInvestigationStep?.id === 'stack-summary' || activeInvestigationStep?.id === 'stack-triage'); + const isEventInvestigationStep = $derived(activeInvestigationStep?.id === 'event-occurrence' || activeInvestigationStep?.id === 'filter-stack-events'); + const isTabInvestigationStep = $derived(activeInvestigationStep?.id.startsWith('tab-') ?? false); + + function getTabTourAnchor(tab: TabType): string | undefined { + switch (tab) { + case 'Environment': + return PRODUCT_TOUR_ANCHORS.eventTabEnvironment; + case 'Exception': + return PRODUCT_TOUR_ANCHORS.eventTabException; + case 'Extended Data': + return PRODUCT_TOUR_ANCHORS.eventTabExtendedData; + case 'Overview': + return PRODUCT_TOUR_ANCHORS.eventTabOverview; + case 'Request': + return PRODUCT_TOUR_ANCHORS.eventTabRequest; + case 'Session Events': + return PRODUCT_TOUR_ANCHORS.eventTabSession; + case 'Trace Log': + return PRODUCT_TOUR_ANCHORS.eventTabTrace; + default: + return undefined; + } + } + function isPromotedTab(tab: TabType): boolean { return !!projectQuery.data?.promoted_tabs?.includes(tab); } @@ -258,8 +308,17 @@ } } - function completeInvestigationTour(): void { - productTourHost.complete('investigate-error'); + function continueInvestigationTour(): void { + if (!activeInvestigationStep) { + return; + } + + if (activeInvestigationStep.id === 'filter-stack-events') { + productTourHost.complete('investigate-error'); + return; + } + + productTourHost.advance('investigate-error', activeInvestigationStep.id); } function dismissInvestigationTour(): void { @@ -279,7 +338,8 @@ $effect(() => { if (event && event.id !== notifiedEventId) { notifiedEventId = event.id; - productTourHost.eventOpened(event.type); + const eventType = hasErrorOrSimpleError(event) ? 'error' : event.type; + void tick().then(() => productTourHost.eventOpened(eventType)); onEventLoaded?.(event); } }); @@ -293,6 +353,26 @@ }); }); + $effect(() => { + const step = activeInvestigationStep; + const tab = activeInvestigationTab; + if (!event || !step) { + return; + } + + if (tab && tabs.includes(tab)) { + activeTab = tab; + } + + void tick().then(() => { + if (productTourHost.activeStepId !== step.id || !step.anchor) { + return; + } + + document.querySelector(productTourSelector(step.anchor))?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + }); + onMount(() => { updateTabsOverflow(); @@ -310,24 +390,40 @@ }); -{#if event && productTourHost.activeTourId === 'investigate-error' && productTourHost.activeStepId === 'inspect-details'} +{#if event && isStackInvestigationStep && activeInvestigationStep} {/if} -
    +

    Stack

    {#if event?.stack_id} - +
    + +
    {/if}
    -
    +{#if event && isEventInvestigationStep && activeInvestigationStep} + +{/if} + +

    Event

    @@ -339,6 +435,7 @@ {#if event?.stack_id} + {/if}
    diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte index 6f337b70b0..56cee7d46e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -9,7 +9,7 @@ import * as AlertDialog from '$comp/ui/alert-dialog'; import { submitFeatureUsage } from '$features/auth/exceptionless-session'; import { putCurrentUserProductTour } from '$features/users/api.svelte'; - import { tick } from 'svelte'; + import { onMount, tick } from 'svelte'; import { toast } from 'svelte-sonner'; import type { @@ -159,10 +159,21 @@ $effect(() => { const observer = new MutationObserver(() => (overlayRevision += 1)); - observer.observe(document.body, { childList: true }); + observer.observe(document.body, { attributes: true, childList: true, subtree: true }); return () => observer.disconnect(); }); + $effect(() => { + void overlayRevision; + if (productTourHost.activeTourId !== 'investigate-error' || productTourHost.activeStepId !== 'choose-error') { + return; + } + + if (getVisibleEventType() === 'error') { + onEventOpened('error'); + } + }); + $effect(() => { const currentRoute = routeKey; if (!stateSettled || driverInstance || resumeAttemptedRoute === currentRoute) { @@ -276,7 +287,7 @@ exieAnnouncementOpen = false; closeOverlays(); const startAction = item.getStartAction?.({ ...context, openEventType: getVisibleEventType() }) ?? { type: 'launch' }; - const canLaunchInsideOverlay = startAction.type === 'launch' && startAction.stepId === 'inspect-details'; + const canLaunchInsideOverlay = startAction.type === 'launch' && startAction.stepId === 'stack-summary'; if (!(await waitForCompetingOverlaysToClose()) && !canLaunchInsideOverlay) { toast.info('Close the open dialog or panel before starting a guided tour.'); return; @@ -750,8 +761,8 @@ const steps = definition.getSteps(context).filter((step) => !step.optional || !step.anchor || hasVisibleTarget(productTourSelector(step.anchor))); const index = steps.findIndex((step) => step.id === 'choose-error'); if (index >= 0) { - productTourHost.set('investigate-error', 'inspect-details', source, organizationId); - writeProductTourSession({ source, stepId: 'inspect-details', tourId: 'investigate-error', version: definition.version }); + productTourHost.set('investigate-error', 'stack-summary', source, organizationId); + writeProductTourSession({ source, stepId: 'stack-summary', tourId: 'investigate-error', version: definition.version }); void advance('investigate-error', definition.version, source, steps, index); } } @@ -773,7 +784,7 @@ } } - $effect(() => productTourHost.subscribe(onHostEvent)); + onMount(() => productTourHost.subscribe(onHostEvent));
    - - - - +
    + + + + +
    diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 4a0a358618..9b80bdd7e5 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -925,7 +925,7 @@

    {pageTitle}

    -
    +
    From 7355e07939c70134790168d371c7bf4254eba23a Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 10:45:10 -0500 Subject: [PATCH 09/20] Keep unreleased investigation tour at version one --- .../ClientApp/src/lib/features/product-tours/catalog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts index 4342e982be..559cec793d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts @@ -391,7 +391,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ id: 'investigate-error', keywords: ['error report', 'event details', 'exception', 'request', 'environment', 'filter', 'stack', 'status', 'triage'], title: 'Investigate an error', - version: 2 + version: 1 }, { description: 'See how Exie uses the current page as context without sending a prompt.', From 7fc5282b1e3a94bcae7cf98dd90a9f9a19686054 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 12:30:53 -0500 Subject: [PATCH 10/20] Avoid guided tour E2E signup limit --- .../ClientApp/e2e/tests/product-tours.e2e.ts | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index efd8a5d23a..f6b5792ff0 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -231,35 +231,39 @@ test('Exie announcement can be dismissed without hiding the replayable guide', a await expect(page.locator('.driver-popover').getByText('Open Exie', { exact: true })).toBeVisible(); }); -test('Meet Exie opens contextual UI without sending a provider request', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { - void _e2eScenario; - let chatRequests = 0; - await page.route('**/api/v2/assistant/access**', async (route) => { - await route.fulfill({ - contentType: 'application/json', - json: { enabled: true, has_access: true, message: null, upgrade_required: false } +test.describe('with the seeded user', () => { + test.use({ e2eUseGeneratedUser: false }); + + test('Meet Exie opens contextual UI without sending a provider request', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { + void _e2eScenario; + let chatRequests = 0; + await page.route('**/api/v2/assistant/access**', async (route) => { + await route.fulfill({ + contentType: 'application/json', + json: { enabled: true, has_access: true, message: null, upgrade_required: false } + }); + }); + await page.route('**/api/v2/assistant/chat**', async (route) => { + chatRequests += 1; + await route.abort(); }); - }); - await page.route('**/api/v2/assistant/chat**', async (route) => { - chatRequests += 1; - await route.abort(); - }); - - await page.setViewportSize({ height: 900, width: 1440 }); - await page.goto('/next/stack'); - await startTourFromCommand(page, 'Meet Exie'); - - const tour = page.locator('.driver-popover'); - await expect(tour.getByText('Open Exie', { exact: true })).toBeVisible(); - await page.screenshot({ fullPage: true, path: testInfo.outputPath('meet-exie-trigger.png') }); - await tour.getByRole('button', { name: 'Continue' }).click(); - await expect(page.getByRole('dialog', { name: 'Exie' })).toBeVisible(); - await expect(tour.getByText('You control every request')).toBeVisible(); - await page.screenshot({ fullPage: true, path: testInfo.outputPath('meet-exie-panel.png') }); - expect(chatRequests).toBe(0); - await tour.getByRole('button', { name: 'Next' }).click(); - expect(chatRequests).toBe(0); + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Meet Exie'); + + const tour = page.locator('.driver-popover'); + await expect(tour.getByText('Open Exie', { exact: true })).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('meet-exie-trigger.png') }); + await tour.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('dialog', { name: 'Exie' })).toBeVisible(); + await expect(tour.getByText('You control every request')).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('meet-exie-panel.png') }); + expect(chatRequests).toBe(0); + + await tour.getByRole('button', { name: 'Next' }).click(); + expect(chatRequests).toBe(0); + }); }); async function startTourFromCommand(page: import('@playwright/test').Page, title: string): Promise { From 769a0f430355592a810b6c95d1a190c2222720db Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 18 Aug 2026 23:40:26 -0500 Subject: [PATCH 11/20] Avoid Exie announcement E2E signup limit --- .../ClientApp/e2e/tests/product-tours.e2e.ts | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index f6b5792ff0..6069515a96 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -204,35 +204,35 @@ test('Investigate an error resumes after navigation and advances only after an e await expect(investigationCallout).toBeHidden(); }); -test('Exie announcement can be dismissed without hiding the replayable guide', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { - void _e2eScenario; - await page.route('**/api/v2/assistant/access**', async (route) => { - await route.fulfill({ - contentType: 'application/json', - json: { enabled: true, has_access: true, message: null, upgrade_required: false } +test.describe('with the seeded user', () => { + test.use({ e2eUseGeneratedUser: false }); + + test('Exie announcement can be dismissed without hiding the replayable guide', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { + void _e2eScenario; + await page.route('**/api/v2/assistant/access**', async (route) => { + await route.fulfill({ + contentType: 'application/json', + json: { enabled: true, has_access: true, message: null, upgrade_required: false } + }); }); - }); - await page.setViewportSize({ height: 900, width: 1440 }); - await page.goto('/next/stack'); + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto('/next/stack'); - const announcement = page.locator('[data-product-tour-announcement="exie"]'); - await expect(announcement).toBeVisible(); - await page.screenshot({ fullPage: true, path: testInfo.outputPath('meet-exie-announcement.png') }); - const dismissed = page.waitForResponse( - (response) => response.url().includes('/product-tours/exie-announcement') && response.request().method() === 'PUT' && response.status() === 200 - ); - await announcement.getByRole('button', { exact: true, name: 'Dismiss' }).click(); - await dismissed; - await page.reload(); - await expect(announcement).toBeHidden(); - - await startTourFromCommand(page, 'Meet Exie'); - await expect(page.locator('.driver-popover').getByText('Open Exie', { exact: true })).toBeVisible(); -}); + const announcement = page.locator('[data-product-tour-announcement="exie"]'); + await expect(announcement).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('meet-exie-announcement.png') }); + const dismissed = page.waitForResponse( + (response) => response.url().includes('/product-tours/exie-announcement') && response.request().method() === 'PUT' && response.status() === 200 + ); + await announcement.getByRole('button', { exact: true, name: 'Dismiss' }).click(); + await dismissed; + await page.reload(); + await expect(announcement).toBeHidden(); -test.describe('with the seeded user', () => { - test.use({ e2eUseGeneratedUser: false }); + await startTourFromCommand(page, 'Meet Exie'); + await expect(page.locator('.driver-popover').getByText('Open Exie', { exact: true })).toBeVisible(); + }); test('Meet Exie opens contextual UI without sending a provider request', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { void _e2eScenario; From 2dd3482a96e1d78afa98ae195700188dae140911 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 19 Aug 2026 00:49:09 -0500 Subject: [PATCH 12/20] Avoid guided tour E2E signup limit --- .../ClientApp/e2e/tests/product-tours.e2e.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index 6069515a96..4c26329ddb 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -3,6 +3,8 @@ import { seedRepresentativeEvent } from '../support/event-data'; test.use({ e2eUseGeneratedUser: true }); +const seededUserTest = test.extend({ e2eUseGeneratedUser: false }); + test('Explore the new UI is replayable from command search', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { void _e2eScenario; await page.setViewportSize({ height: 900, width: 1440 }); @@ -121,7 +123,7 @@ test('Create a saved view retains a private hydrated view', async ({ e2eScenario await expect(page.getByText('Create the saved view')).toBeHidden(); }); -test('Investigate an error resumes after navigation and advances only after an error opens', async ({ e2eApi, e2eScenario, page }, testInfo) => { +seededUserTest('Investigate an error resumes after navigation and advances only after an error opens', async ({ e2eApi, e2eScenario, page }, testInfo) => { const event = await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { message: e2eScenario.message, projectId: e2eScenario.projectId, From 55cebe530e19ff9afaaa252028773b855ab26c45 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 19 Aug 2026 10:07:18 -0500 Subject: [PATCH 13/20] Fix guided tour onboarding readiness --- .../e2e/tests/event-effects-chaos.e2e.ts | 9 +++- .../ClientApp/e2e/tests/product-tours.e2e.ts | 50 +++++++++---------- .../ClientApp/src/routes/(app)/+layout.svelte | 2 +- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts index 029251c335..30d70c3f77 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts @@ -363,13 +363,18 @@ function recordRequest(diagnostics: RuntimeDiagnostics, request: Request, organi } function recordRequestFailure(diagnostics: RuntimeDiagnostics, request: Request): void { - if (!new URL(request.url()).pathname.startsWith('/api/v2/')) { + const path = new URL(request.url()).pathname; + const error = request.failure()?.errorText ?? null; + const isDeliberateRemount = diagnostics.activeAction.endsWith('route remounts'); + const isCanceledProjectRead = request.method() === 'GET' && /^\/api\/v2\/projects\/[a-f0-9]{24}$/.test(path) && error === 'net::ERR_ABORTED'; + // Repeated page.goto calls intentionally tear down detail observers; Chromium reports their superseded project reads as aborted. + if (!path.startsWith('/api/v2/') || (isDeliberateRemount && isCanceledProjectRead)) { return; } diagnostics.requestFailures.push({ action: diagnostics.activeAction, - error: request.failure()?.errorText ?? null, + error, method: request.method(), url: request.url() }); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index 4c26329ddb..9684bcaed1 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -206,35 +206,35 @@ seededUserTest('Investigate an error resumes after navigation and advances only await expect(investigationCallout).toBeHidden(); }); -test.describe('with the seeded user', () => { - test.use({ e2eUseGeneratedUser: false }); - - test('Exie announcement can be dismissed without hiding the replayable guide', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { - void _e2eScenario; - await page.route('**/api/v2/assistant/access**', async (route) => { - await route.fulfill({ - contentType: 'application/json', - json: { enabled: true, has_access: true, message: null, upgrade_required: false } - }); +test('Exie announcement can be dismissed without hiding the replayable guide', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { + void _e2eScenario; + await page.route('**/api/v2/assistant/access**', async (route) => { + await route.fulfill({ + contentType: 'application/json', + json: { enabled: true, has_access: true, message: null, upgrade_required: false } }); + }); - await page.setViewportSize({ height: 900, width: 1440 }); - await page.goto('/next/stack'); + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto('/next/stack'); - const announcement = page.locator('[data-product-tour-announcement="exie"]'); - await expect(announcement).toBeVisible(); - await page.screenshot({ fullPage: true, path: testInfo.outputPath('meet-exie-announcement.png') }); - const dismissed = page.waitForResponse( - (response) => response.url().includes('/product-tours/exie-announcement') && response.request().method() === 'PUT' && response.status() === 200 - ); - await announcement.getByRole('button', { exact: true, name: 'Dismiss' }).click(); - await dismissed; - await page.reload(); - await expect(announcement).toBeHidden(); + const announcement = page.locator('[data-product-tour-announcement="exie"]'); + await expect(announcement).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('meet-exie-announcement.png') }); + const dismissed = page.waitForResponse( + (response) => response.url().includes('/product-tours/exie-announcement') && response.request().method() === 'PUT' && response.status() === 200 + ); + await announcement.getByRole('button', { exact: true, name: 'Dismiss' }).click(); + await dismissed; + await page.reload(); + await expect(announcement).toBeHidden(); + + await startTourFromCommand(page, 'Meet Exie'); + await expect(page.locator('.driver-popover').getByText('Open Exie', { exact: true })).toBeVisible(); +}); - await startTourFromCommand(page, 'Meet Exie'); - await expect(page.locator('.driver-popover').getByText('Open Exie', { exact: true })).toBeVisible(); - }); +test.describe('with the seeded user', () => { + test.use({ e2eUseGeneratedUser: false }); test('Meet Exie opens contextual UI without sending a provider request', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { void _e2eScenario; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index a8d3777b3e..672f970534 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -881,7 +881,7 @@ stateSettled={meQuery.isSuccess && organizationsQuery.isSuccess && projectsQuery.isSuccess && - (assistantAccessQuery.isSuccess || assistantAccessQuery.isError)} + (!organization.current || assistantAccessQuery.isSuccess || assistantAccessQuery.isError)} /> Date: Wed, 19 Aug 2026 10:38:13 -0500 Subject: [PATCH 14/20] Address guided tour review findings --- .../ClientApp/e2e/tests/product-tours.e2e.ts | 19 +++++++++++++++++++ .../components/product-tours.svelte | 8 +++++++- .../Models/User/UpdateProductTourProgress.cs | 1 + .../Api/Endpoints/UserEndpointTests.cs | 11 +++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index 9684bcaed1..c37b4ae16b 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -41,6 +41,25 @@ test('Explore the new UI opens its navigation target on mobile', async ({ e2eSce await tour.getByRole('button', { name: 'Close' }).click(); }); +test('an active tour is cleared when the authenticated app unmounts', async ({ e2eScenario: _e2eScenario, page }) => { + void _e2eScenario; + await page.goto('/next/stack'); + + await startTourFromCommand(page, 'Explore the new UI'); + const tour = page.locator('.driver-popover'); + await expect(tour.getByText('Your workspace navigation')).toBeVisible(); + await expect.poll(() => page.evaluate(() => sessionStorage.getItem('exceptionless.product-tour'))).not.toBeNull(); + + await page.evaluate(async () => { + await fetch('/api/v2/auth/logout', { credentials: 'include' }); + }); + await page.goto('/next/login'); + + await expect(page.getByRole('button', { exact: true, name: 'Login' })).toBeVisible(); + await expect(tour).toBeHidden(); + await expect.poll(() => page.evaluate(() => sessionStorage.getItem('exceptionless.product-tour'))).toBeNull(); +}); + test('Configure a project resumes through its first event', async ({ e2eApi, e2eScenario, page }, testInfo) => { await page.setViewportSize({ height: 900, width: 1440 }); await page.goto('/next/stack'); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte index 6f11cefe97..bfa8b7625d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -856,7 +856,13 @@ } } - onMount(() => productTourHost.subscribe(onHostEvent)); + onMount(() => { + const unsubscribe = productTourHost.subscribe(onHostEvent); + return () => { + unsubscribe(); + stopActiveTour(true); + }; + }); r + .Put() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "invalid-status") + .Content(new { Version = 1, Status = 999 }) + .StatusCodeShouldBeUnprocessableEntity()); + } + [Fact] public async Task AddAdminRoleAsync_AnonymousUser_ReturnsUnauthorized() { From 2775c2b5245f8aee2a61c03d4974cafba3ab469b Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 20 Aug 2026 07:00:22 -0500 Subject: [PATCH 15/20] Keep tour teardown E2E within signup budget --- .../ClientApp/e2e/tests/product-tours.e2e.ts | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index c37b4ae16b..bf2deb4a3b 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -5,7 +5,7 @@ test.use({ e2eUseGeneratedUser: true }); const seededUserTest = test.extend({ e2eUseGeneratedUser: false }); -test('Explore the new UI is replayable from command search', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { +test('Explore the new UI is replayable and clears when the authenticated app unmounts', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { void _e2eScenario; await page.setViewportSize({ height: 900, width: 1440 }); await page.goto('/next/stack'); @@ -19,7 +19,13 @@ test('Explore the new UI is replayable from command search', async ({ e2eScenari await startTourFromCommand(page, 'Explore the new UI'); await expect(tour.getByText('Your workspace navigation')).toBeVisible(); - await tour.getByRole('button', { name: 'Close' }).click(); + + await page.locator('[data-tour="help-menu"]').click({ force: true }); + await page.getByRole('menuitem', { exact: true, name: 'Log Out' }).click({ force: true }); + + await expect(page.getByRole('button', { exact: true, name: 'Login' })).toBeVisible(); + await expect(tour).toBeHidden(); + await expect.poll(() => page.evaluate(() => sessionStorage.getItem('exceptionless.product-tour'))).toBeNull(); }); test('Explore the new UI opens its navigation target on mobile', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { @@ -41,25 +47,6 @@ test('Explore the new UI opens its navigation target on mobile', async ({ e2eSce await tour.getByRole('button', { name: 'Close' }).click(); }); -test('an active tour is cleared when the authenticated app unmounts', async ({ e2eScenario: _e2eScenario, page }) => { - void _e2eScenario; - await page.goto('/next/stack'); - - await startTourFromCommand(page, 'Explore the new UI'); - const tour = page.locator('.driver-popover'); - await expect(tour.getByText('Your workspace navigation')).toBeVisible(); - await expect.poll(() => page.evaluate(() => sessionStorage.getItem('exceptionless.product-tour'))).not.toBeNull(); - - await page.evaluate(async () => { - await fetch('/api/v2/auth/logout', { credentials: 'include' }); - }); - await page.goto('/next/login'); - - await expect(page.getByRole('button', { exact: true, name: 'Login' })).toBeVisible(); - await expect(tour).toBeHidden(); - await expect.poll(() => page.evaluate(() => sessionStorage.getItem('exceptionless.product-tour'))).toBeNull(); -}); - test('Configure a project resumes through its first event', async ({ e2eApi, e2eScenario, page }, testInfo) => { await page.setViewportSize({ height: 900, width: 1440 }); await page.goto('/next/stack'); From 4fb87b2942c35a2c8225547c1f8d162adfdb5104 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 20 Aug 2026 07:16:48 -0500 Subject: [PATCH 16/20] Exercise tour teardown through keyboard logout --- .../ClientApp/e2e/tests/product-tours.e2e.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts index bf2deb4a3b..a6941be444 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -20,10 +20,15 @@ test('Explore the new UI is replayable and clears when the authenticated app unm await startTourFromCommand(page, 'Explore the new UI'); await expect(tour.getByText('Your workspace navigation')).toBeVisible(); - await page.locator('[data-tour="help-menu"]').click({ force: true }); - await page.getByRole('menuitem', { exact: true, name: 'Log Out' }).click({ force: true }); - - await expect(page.getByRole('button', { exact: true, name: 'Login' })).toBeVisible(); + const helpMenu = page.locator('[data-tour="help-menu"]'); + await helpMenu.focus(); + await helpMenu.press('Enter'); + const logOut = page.getByRole('menuitem', { exact: true, name: 'Log Out' }); + await expect(logOut).toBeVisible(); + await logOut.focus(); + await logOut.press('Enter'); + + await expect(page.getByRole('button', { exact: true, name: 'Login' })).toBeVisible({ timeout: 30_000 }); await expect(tour).toBeHidden(); await expect.poll(() => page.evaluate(() => sessionStorage.getItem('exceptionless.product-tour'))).toBeNull(); }); From b88b581489399f19a2cf5a0b78b003a30449f3cd Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 20 Aug 2026 07:20:31 -0500 Subject: [PATCH 17/20] Await guided tour completion before navigation --- .../events/components/events-overview.svelte | 2 +- .../components/product-tours.svelte | 9 +++---- .../product-tours/state.svelte.test.ts | 27 +++++++++++++++++++ .../features/product-tours/state.svelte.ts | 11 ++++---- .../components/saved-view-picker.svelte | 2 +- .../[projectId]/configure/+page.svelte | 4 +-- 6 files changed, 41 insertions(+), 14 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index 7b40142d03..2abf0d584a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -333,7 +333,7 @@ } if (activeInvestigationStep.id === 'filter-stack-events') { - productTourHost.complete('investigate-error'); + void productTourHost.complete('investigate-error'); return; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte index bfa8b7625d..9f37221900 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -771,13 +771,13 @@ await submitFeatureUsage(buildProductTourTelemetryEvent(event, id, version, source, stepId)).catch(() => undefined); } - function onDomainComplete(id: ProductTourId): void { + async function onDomainComplete(id: ProductTourId): Promise { if (productTourHost.activeTourId !== id || !productTourHost.source) { return; } const definition = getProductTour(id); - void completeTour(id, definition.version, productTourHost.source); + await completeTour(id, definition.version, productTourHost.source); } function onDomainDismiss(id: ProductTourId): void { @@ -839,14 +839,13 @@ } } - function onHostEvent(event: ProductTourHostEvent): void { + function onHostEvent(event: ProductTourHostEvent): Promise | void { switch (event.type) { case 'advance': onInlineAdvance(event.tourId, event.stepId); break; case 'completed': - onDomainComplete(event.tourId); - break; + return onDomainComplete(event.tourId); case 'dismissed': onDomainDismiss(event.tourId); break; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts new file mode 100644 index 0000000000..83f2e8b44e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { productTourHost } from './state.svelte'; + +describe('product tour host', () => { + it('waits for completion listeners before resolving', async () => { + let finishPersistence: () => void = () => undefined; + const persistence = new Promise((resolve) => (finishPersistence = resolve)); + let persisted = false; + const unsubscribe = productTourHost.subscribe(async () => { + await persistence; + persisted = true; + }); + + try { + const completion = productTourHost.complete('configure-project'); + await Promise.resolve(); + expect(persisted).toBe(false); + + finishPersistence(); + await completion; + expect(persisted).toBe(true); + } finally { + unsubscribe(); + } + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts index 56c1f0bf2c..8cc9eca242 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts @@ -5,7 +5,7 @@ export type ProductTourHostEvent = | { stepId: string; tourId: ProductTourId; type: 'advance' } | { tourId: ProductTourId; type: 'completed' | 'dismissed' }; -type ProductTourHostListener = (event: ProductTourHostEvent) => void; +type ProductTourHostListener = (event: ProductTourHostEvent) => Promise | void; class ProductTourHost { get activeStepId(): string | undefined { @@ -44,11 +44,12 @@ class ProductTourHost { this.session = undefined; } - complete(tourId: ProductTourId): void { - this.publish({ + async complete(tourId: ProductTourId): Promise { + const event: ProductTourHostEvent = { tourId, type: 'completed' - }); + }; + await Promise.all([...this.listeners].map((listener) => listener(event))); } dismiss(tourId: ProductTourId): void { @@ -90,7 +91,7 @@ class ProductTourHost { private publish(event: ProductTourHostEvent): void { for (const listener of this.listeners) { - listener(event); + void listener(event); } } } 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 71c63c9d53..999f834ba0 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 @@ -214,7 +214,7 @@ isSaveDialogOpen = false; await onLoadView(result); await onSavedViewCreated?.(result); - productTourHost.complete('create-saved-view'); + await productTourHost.complete('create-saved-view'); toast.success(`Saved view "${result.name}" created.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte index 20c12fd16a..e874424b8f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte @@ -429,7 +429,7 @@ public partial class App : Application { const message = (event as CustomEvent>).detail; if (queryParams.redirect && message.project_id === projectId && message.change_type !== ChangeType.Removed) { - productTourHost.complete('configure-project'); + await productTourHost.complete('configure-project'); toast.success('First event received. Opening Events...'); await redirectToEventsWithFilter(organization.current, new ProjectFilter([projectId])); } @@ -445,7 +445,7 @@ public partial class App : Application { return; } - productTourHost.complete('configure-project'); + await productTourHost.complete('configure-project'); toast.success('First event received. Opening Events...'); await redirectToEventsWithFilter(organization.current, new ProjectFilter([projectId])); } From 6af3a5dcb781396518bb30b926dcb3f34e30a47f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 20 Aug 2026 07:35:59 -0500 Subject: [PATCH 18/20] Avoid resuming an active guided tour step --- .../features/product-tours/components/product-tours.svelte | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte index 9f37221900..fb912a2f2b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -196,6 +196,10 @@ return; } + if (productTourHost.activeTourId === stored.tourId && productTourHost.activeStepId === stored.stepId) { + return; + } + let storedVersion: number; try { storedVersion = getProductTour(stored.tourId).version; From 14e093aa384b600305bcc3c10269fd2039189841 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 20 Aug 2026 07:51:02 -0500 Subject: [PATCH 19/20] Propagate guided tour completion failures --- .../components/product-tours.svelte | 17 +++++++---------- .../features/product-tours/state.svelte.test.ts | 12 +++++++++++- .../lib/features/product-tours/state.svelte.ts | 7 ++++--- .../components/saved-view-picker.svelte | 7 +++++-- .../project/[projectId]/configure/+page.svelte | 10 ++++++++-- 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte index fb912a2f2b..a715911ce2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -196,10 +196,6 @@ return; } - if (productTourHost.activeTourId === stored.tourId && productTourHost.activeStepId === stored.stepId) { - return; - } - let storedVersion: number; try { storedVersion = getProductTour(stored.tourId).version; @@ -707,14 +703,14 @@ } } - async function completeTour(id: ProductTourId, version: number, source: ProductTourLaunchSource): Promise { + async function completeTour(id: ProductTourId, version: number, source: ProductTourLaunchSource): Promise { driverTransition = 'finishing'; try { await recordProgress(id, version, 'completed'); } catch { driverTransition = 'active'; toast.error('We could not save your guided-tour progress. Please try again.'); - return; + return false; } clearProductTourSession(); @@ -722,6 +718,7 @@ driverInstance?.destroy(); productTourHost.clear(); driverTransition = 'active'; + return true; } async function dismissTour(id: ProductTourId, version: number, source: ProductTourLaunchSource): Promise { @@ -775,13 +772,13 @@ await submitFeatureUsage(buildProductTourTelemetryEvent(event, id, version, source, stepId)).catch(() => undefined); } - async function onDomainComplete(id: ProductTourId): Promise { + async function onDomainComplete(id: ProductTourId): Promise { if (productTourHost.activeTourId !== id || !productTourHost.source) { - return; + return true; } const definition = getProductTour(id); - await completeTour(id, definition.version, productTourHost.source); + return completeTour(id, definition.version, productTourHost.source); } function onDomainDismiss(id: ProductTourId): void { @@ -843,7 +840,7 @@ } } - function onHostEvent(event: ProductTourHostEvent): Promise | void { + function onHostEvent(event: ProductTourHostEvent): boolean | Promise | void { switch (event.type) { case 'advance': onInlineAdvance(event.tourId, event.stepId); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts index 83f2e8b44e..817f11a1b8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts @@ -18,10 +18,20 @@ describe('product tour host', () => { expect(persisted).toBe(false); finishPersistence(); - await completion; + expect(await completion).toBe(true); expect(persisted).toBe(true); } finally { unsubscribe(); } }); + + it('propagates completion listener failures', async () => { + const unsubscribe = productTourHost.subscribe(() => false); + + try { + await expect(productTourHost.complete('configure-project')).resolves.toBe(false); + } finally { + unsubscribe(); + } + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts index 8cc9eca242..8d36962e3d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts @@ -5,7 +5,7 @@ export type ProductTourHostEvent = | { stepId: string; tourId: ProductTourId; type: 'advance' } | { tourId: ProductTourId; type: 'completed' | 'dismissed' }; -type ProductTourHostListener = (event: ProductTourHostEvent) => Promise | void; +type ProductTourHostListener = (event: ProductTourHostEvent) => boolean | Promise | void; class ProductTourHost { get activeStepId(): string | undefined { @@ -44,12 +44,13 @@ class ProductTourHost { this.session = undefined; } - async complete(tourId: ProductTourId): Promise { + async complete(tourId: ProductTourId): Promise { const event: ProductTourHostEvent = { tourId, type: 'completed' }; - await Promise.all([...this.listeners].map((listener) => listener(event))); + const results = await Promise.all([...this.listeners].map((listener) => listener(event))); + return results.every((result) => result !== false); } dismiss(tourId: ProductTourId): void { 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 999f834ba0..513e1741ab 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 @@ -212,9 +212,12 @@ try { const result = await createMutation.mutateAsync(body); isSaveDialogOpen = false; - await onLoadView(result); await onSavedViewCreated?.(result); - await productTourHost.complete('create-saved-view'); + if (!(await productTourHost.complete('create-saved-view'))) { + return; + } + + await onLoadView(result); toast.success(`Saved view "${result.name}" created.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte index e874424b8f..2770bdd5ef 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte @@ -429,7 +429,10 @@ public partial class App : Application { const message = (event as CustomEvent>).detail; if (queryParams.redirect && message.project_id === projectId && message.change_type !== ChangeType.Removed) { - await productTourHost.complete('configure-project'); + if (!(await productTourHost.complete('configure-project'))) { + return; + } + toast.success('First event received. Opening Events...'); await redirectToEventsWithFilter(organization.current, new ProjectFilter([projectId])); } @@ -445,7 +448,10 @@ public partial class App : Application { return; } - await productTourHost.complete('configure-project'); + if (!(await productTourHost.complete('configure-project'))) { + return; + } + toast.success('First event received. Opening Events...'); await redirectToEventsWithFilter(organization.current, new ProjectFilter([projectId])); } From 9be6a7660a6b966573dece7d669dd822c4d34c92 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 20 Aug 2026 08:05:04 -0500 Subject: [PATCH 20/20] Keep saved view completion retryable --- .../components/save-view-dialog.svelte | 28 +++++++++++++++---- .../components/saved-view-picker.svelte | 23 +++++++++++---- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte index aaf5e893b4..3fe0029d30 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -28,11 +28,23 @@ onLoadView: (view: SavedView) => Promise | void; onSave: (name: string, slug: string, isPrivate: boolean) => Promise; open: boolean; + pendingCompletion?: boolean; savedViews: SavedView[]; saving: boolean; } - let { defaultPrivate = false, duplicateView, onCancel, onClose, onLoadView, onSave, open = $bindable(), savedViews, saving }: Props = $props(); + let { + defaultPrivate = false, + duplicateView, + onCancel, + onClose, + onLoadView, + onSave, + open = $bindable(), + pendingCompletion = false, + savedViews, + saving + }: Props = $props(); let saveName = $state(''); let saveSlug = $state(''); @@ -84,7 +96,7 @@ }); const visibleNameError = $derived(attemptedSubmit || saveName.length > 0 ? nameError : undefined); const visibleSlugError = $derived(attemptedSubmit || saveName.length > 0 || saveSlug.length > 0 ? slugError : undefined); - const canSave = $derived(!nameError && !slugError && !saving); + const canSave = $derived((pendingCompletion || (!nameError && !slugError)) && !saving); $effect(() => { if (open) { @@ -163,13 +175,15 @@ /> {:else if defaultPrivate && productTourHost.activeStepId === 'save-view'} {/if} - {#if duplicateView} + {#if duplicateView && !pendingCompletion}
    Current filters match "{duplicateView.name}". You can @@ -204,6 +218,7 @@ aria-describedby={visibleNameError ? 'view-name-error' : undefined} required autofocus + disabled={pendingCompletion} /> {#if visibleNameError}

    {visibleNameError}

    @@ -219,6 +234,7 @@ aria-invalid={!!visibleSlugError} aria-describedby={visibleSlugError ? 'view-slug-error' : undefined} required + disabled={pendingCompletion} oninput={() => { isSlugDirty = true; }} @@ -232,7 +248,7 @@ {defaultPrivate ? 'Required for this guided practice view' : 'Only visible to you'}
    - +
    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 513e1741ab..da448df149 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 @@ -100,6 +100,8 @@ let isDeleteDialogOpen = $state(false); let isColumnDialogOpen = $state(false); let isMenuOpen = $state(false); + let isCompletingTour = $state(false); + let pendingCreatedView = $state(); let viewToDelete = $state(null); const organizationId = $derived(organization.current); @@ -126,7 +128,7 @@ } }); - const saving = $derived(createMutation.isPending || updateMutation.isPending || removeMutation.isPending); + const saving = $derived(createMutation.isPending || updateMutation.isPending || removeMutation.isPending || isCompletingTour); const currentFilterString = $derived(toFilter(filters.filter((f) => f.type !== 'date'))); // Auto-detect if current filters match an existing saved view for "load existing" hint @@ -210,16 +212,25 @@ }; try { - const result = await createMutation.mutateAsync(body); - isSaveDialogOpen = false; - await onSavedViewCreated?.(result); - if (!(await productTourHost.complete('create-saved-view'))) { + const result = pendingCreatedView ?? (await createMutation.mutateAsync(body)); + if (!pendingCreatedView) { + await onSavedViewCreated?.(result); + } + + isCompletingTour = true; + const completed = await productTourHost.complete('create-saved-view'); + isCompletingTour = false; + if (!completed) { + pendingCreatedView = result; return; } + pendingCreatedView = undefined; + isSaveDialogOpen = false; await onLoadView(result); toast.success(`Saved view "${result.name}" created.`); } catch (error) { + isCompletingTour = false; toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); } } @@ -386,9 +397,11 @@ {savedViews} {saving} defaultPrivate={productTourHost.isActive('create-saved-view')} + pendingCompletion={!!pendingCreatedView} onSave={handleSave} onClose={() => (isSaveDialogOpen = false)} onCancel={() => { + pendingCreatedView = undefined; if (productTourHost.isActive('create-saved-view')) { productTourHost.dismiss('create-saved-view'); }