From 3769f96daeca2716b95faa7527fd77ea060479ed Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 20 Aug 2026 09:42:27 -0500 Subject: [PATCH] Revert "Merge pull request #2458 from exceptionless/feature/ui-guided-tours" This reverts commit b178420820d927d7a5d73699ced9f92e6ea4c70a, reversing changes made to 24d6ad72a6659eec918c4655b531c099208912da. --- .../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 - .../e2e/tests/event-effects-chaos.e2e.ts | 9 +- .../ClientApp/e2e/tests/product-tours.e2e.ts | 285 ------ .../ClientApp/package-lock.json | 7 - src/Exceptionless.Web/ClientApp/package.json | 1 - .../components/assistant-panel.svelte | 1 - .../events/components/events-overview.svelte | 147 +-- .../src/lib/features/product-tours/anchors.ts | 40 - .../features/product-tours/catalog.test.ts | 101 -- .../src/lib/features/product-tours/catalog.ts | 447 --------- .../components/product-tour-catalog.svelte | 50 - .../product-tour-feature-announcement.svelte | 44 - .../product-tour-inline-callout.svelte | 30 - .../components/product-tour-welcome.svelte | 48 - .../product-tour-welcome.svelte.test.ts | 43 - .../components/product-tours.svelte | 907 ------------------ .../product-tours/eligibility.test.ts | 24 - .../lib/features/product-tours/eligibility.ts | 9 - .../features/product-tours/product-tours.css | 44 - .../features/product-tours/session.test.ts | 28 - .../src/lib/features/product-tours/session.ts | 28 - .../product-tours/state.svelte.test.ts | 37 - .../features/product-tours/state.svelte.ts | 100 -- .../features/product-tours/telemetry.test.ts | 19 - .../lib/features/product-tours/telemetry.ts | 34 - .../src/lib/features/product-tours/types.ts | 57 -- .../lib/features/saved-views/api.svelte.ts | 12 +- .../components/save-view-dialog.svelte | 103 +- .../components/saved-view-picker.svelte | 43 +- .../saved-views/use-saved-views.svelte.ts | 4 +- .../components/ui/sidebar/sidebar.svelte | 1 - .../stacks/components/stack-card.svelte | 10 +- .../src/lib/features/users/api.svelte.ts | 25 +- .../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 | 4 +- .../(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 | 98 +- .../src/routes/(app)/event/+page.svelte | 52 +- .../(app)/organization/add/+page.svelte | 4 +- .../[projectId]/configure/+page.svelte | 498 +++++----- .../src/routes/(app)/project/add/+page.svelte | 3 +- .../Models/User/UpdateProductTourProgress.cs | 14 - .../Models/User/ViewCurrentUser.cs | 11 +- .../Api/Data/endpoint-manifest.json | 14 - .../Exceptionless.Tests/Api/Data/openapi.json | 139 --- .../Api/Endpoints/UserEndpointTests.cs | 185 ---- .../Api/OpenApiSnapshotTests.cs | 8 - .../Serializer/Models/UserSerializerTests.cs | 55 -- tests/http/users.http | 10 - 61 files changed, 298 insertions(+), 3969 deletions(-) delete mode 100644 src/Exceptionless.Core/Models/Data/ProductTourProgress.cs delete mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/anchors.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-catalog.svelte delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte.test.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.test.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts delete 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 deleted file mode 100644 index 285da7c425..0000000000 --- a/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs +++ /dev/null @@ -1,22 +0,0 @@ -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 cfa2b6e494..9e622dce0f 100644 --- a/src/Exceptionless.Core/Models/User.cs +++ b/src/Exceptionless.Core/Models/User.cs @@ -1,7 +1,6 @@ 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; @@ -24,7 +23,6 @@ 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 3928b0d721..44ce3acd29 100644 --- a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs @@ -37,25 +37,6 @@ 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 b374375d30..92e9bd563e 100644 --- a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs @@ -3,7 +3,6 @@ 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; @@ -15,10 +14,8 @@ 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; @@ -29,7 +26,6 @@ public class UserHandler( IOAuthTokenRepository oauthTokenRepository, IOAuthApplicationRepository oauthApplicationRepository, ICacheClient cacheClient, - ILockProvider lockProvider, IMailer mailer, ApiMapper mapper, IntercomOptions intercomOptions, @@ -37,8 +33,6 @@ 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."); @@ -55,54 +49,6 @@ 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 ca1435b293..7cc329710b 100644 --- a/src/Exceptionless.Web/Api/Messages/UserMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/UserMessages.cs @@ -6,7 +6,6 @@ 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 cf5d84adbf..909b6f700d 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -301,15 +301,6 @@ 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 3f2def61aa..154144230d 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -85,7 +85,6 @@ 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 0cee1689bc..27e53fd48d 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts @@ -219,18 +219,6 @@ 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/event-effects-chaos.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts index 30d70c3f77..029251c335 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,18 +363,13 @@ function recordRequest(diagnostics: RuntimeDiagnostics, request: Request, organi } function recordRequestFailure(diagnostics: RuntimeDiagnostics, request: Request): void { - 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)) { + if (!new URL(request.url()).pathname.startsWith('/api/v2/')) { return; } diagnostics.requestFailures.push({ action: diagnostics.activeAction, - error, + error: request.failure()?.errorText ?? null, 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 deleted file mode 100644 index a6941be444..0000000000 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { expect, test } from '../fixtures/e2e-test'; -import { seedRepresentativeEvent } from '../support/event-data'; - -test.use({ e2eUseGeneratedUser: true }); - -const seededUserTest = test.extend({ e2eUseGeneratedUser: false }); - -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'); - - await startTourFromCommand(page, 'Explore the new UI'); - 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-desktop.png') }); - 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(); - - 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(); -}); - -test('Explore the new UI opens its navigation target on mobile', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { - void _e2eScenario; - await page.setViewportSize({ height: 844, width: 390 }); - 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 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(); -}); - -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'); - - let createdProjectId: string | undefined; - await startTourFromCommand(page, 'Configure a project'); - if (await page.getByRole('alertdialog', { name: 'Create another project?' }).isVisible()) { - await page.getByRole('button', { name: 'Create Project' }).click(); - await expect(page.getByRole('heading', { name: 'Add Project' })).toBeVisible(); - await page.getByLabel('Project Name', { exact: true }).fill(`Tour Project ${e2eScenario.run}`); - await expect(page.locator('.driver-popover').getByText('Name your project', { exact: true })).toBeVisible(); - await page.locator('.driver-popover').getByRole('button', { name: 'Next' }).click(); - await expect(page.locator('.driver-popover').getByText('Continue to configuration')).toBeVisible(); - await page.locator('.driver-popover').getByRole('button', { name: 'Continue' }).click(); - await page.waitForURL(/\/next\/project\/[^/]+\/configure\?redirect=true/); - createdProjectId = page.url().match(/\/project\/([^/]+)\/configure/)?.[1]; - } - - await page.waitForURL(/\/next\/project\/[^/]+\/configure\?redirect=true/); - const tour = page.locator('.driver-popover'); - 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(); - await expect(page.getByText('Connect your application', { exact: true })).toBeVisible(); - await page.screenshot({ fullPage: true, path: testInfo.outputPath('configure-project-inline.png') }); - await page.getByRole('button', { name: 'Continue' }).click(); - await expect(page.getByText('Waiting for your first event')).toBeVisible(); - - try { - const projectId = createdProjectId ?? e2eScenario.projectId; - const projectToken = createdProjectId ? (await e2eApi.getProjectDefaultToken(e2eScenario.userToken, createdProjectId)).id : e2eScenario.projectToken; - await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { - message: e2eScenario.message, - projectId, - projectToken, - referenceId: e2eScenario.referenceId - }); - await expect(page).toHaveURL(/\/next\/event/); - await expect(page.getByText('First event received. Opening Events...')).toBeHidden(); - } finally { - if (createdProjectId) { - await e2eApi.deleteProject(e2eScenario.userToken, createdProjectId); - await e2eApi.waitForProjectDeleted(e2eScenario.userToken, createdProjectId); - } - } -}); - -test('Create a saved view retains a private hydrated view', async ({ e2eScenario, page }, testInfo) => { - await page.setViewportSize({ height: 900, width: 1440 }); - await page.goto('/next/event'); - - await startTourFromCommand(page, 'Create a saved view'); - const tour = page.locator('.driver-popover'); - await expect(tour.getByText('Open View settings')).toBeVisible(); - await page.screenshot({ fullPage: true, path: testInfo.outputPath('saved-view-open.png') }); - await tour.getByRole('button', { name: 'Continue' }).click(); - await expect(tour.getByText('Configure what the view remembers')).toBeVisible(); - await page.screenshot({ fullPage: true, path: testInfo.outputPath('saved-view-settings.png') }); - await tour.getByRole('button', { name: 'Next' }).click(); - await expect(tour.getByText('Create a new view')).toBeVisible(); - await tour.getByRole('button', { name: 'Continue' }).click(); - - const viewName = `Tour View ${e2eScenario.run}`; - await expect(page.getByText('Review and name your view')).toBeVisible(); - await page.getByLabel('Name', { exact: true }).fill(viewName); - await page.getByRole('button', { name: 'Continue' }).click(); - await expect(page.getByText('Keep it private')).toBeVisible(); - await page.screenshot({ fullPage: true, path: testInfo.outputPath('saved-view-private.png') }); - await page.getByRole('button', { name: 'Continue' }).click(); - await expect(page.getByText('Create the saved view')).toBeVisible(); - await page.getByRole('button', { exact: true, name: 'Save' }).click(); - - await expect(page.getByRole('heading', { name: viewName })).toBeVisible({ timeout: 30_000 }); - await expect(page).toHaveURL(/\/next\/event\/[^/]+/); - await expect(page.getByText('Create the saved view')).toBeHidden(); -}); - -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, - projectToken: e2eScenario.projectToken, - referenceId: e2eScenario.referenceId - }); - - await page.setViewportSize({ height: 900, width: 1440 }); - await page.goto('/next/event?time=all'); - 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(); - - 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('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('Understand the grouped issue')).toBeVisible({ timeout: 30_000 }); - await page.screenshot({ fullPage: true, path: testInfo.outputPath('investigate-error-details.png') }); - - 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('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'); - - 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 }); - await page.waitForURL(/\/next\/stack\/[^/]+\/event\/[^/]+/); - - 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) => { - 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.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.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 { - 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 1305b26f61..9333168e47 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -24,7 +24,6 @@ "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.4.13", - "driver.js": "^1.8.0", "layerchart": "^2.1.0", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", @@ -6048,12 +6047,6 @@ "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 2b815760cc..87c7e0ec44 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -94,7 +94,6 @@ "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.4.13", - "driver.js": "^1.8.0", "layerchart": "^2.1.0", "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 7001b41ba7..916b8454b4 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 @@ -508,7 +508,6 @@ 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); } @@ -327,23 +275,6 @@ } } - function continueInvestigationTour(): void { - if (!activeInvestigationStep) { - return; - } - - if (activeInvestigationStep.id === 'filter-stack-events') { - void productTourHost.complete('investigate-error'); - return; - } - - productTourHost.advance('investigate-error', activeInvestigationStep.id); - } - - function dismissInvestigationTour(): void { - productTourHost.dismiss('investigate-error'); - } - function prepareEventAssistantContext(): void { if (event) { assistantPageContext.setPageEvent(event); @@ -363,8 +294,6 @@ $effect(() => { if (event && event.id !== notifiedEventId) { notifiedEventId = event.id; - const eventType = hasErrorOrSimpleError(event) ? 'error' : event.type; - void tick().then(() => productTourHost.eventOpened(eventType)); onEventLoaded?.(event); } }); @@ -378,29 +307,6 @@ }); }); - $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(); @@ -418,45 +324,19 @@ }); -{#if event && isStackInvestigationStep && activeInvestigationStep} - -{/if} - -
+

Stack

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

Event

@@ -468,7 +348,6 @@ {#if event?.stack_id} -
- {/each} - - - 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 deleted file mode 100644 index 910b93917d..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -{#if open} - - -{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte deleted file mode 100644 index bbd88fcc80..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - - 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 deleted file mode 100644 index 837da4fd8d..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - - - 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 deleted file mode 100644 index c87d990aab..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index a715911ce2..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte +++ /dev/null @@ -1,907 +0,0 @@ - - - void onWelcomeSkip()} - onStart={() => void onWelcomeStart()} - {recommended} -/> - -{#if exieAnnouncementOpen && assistantAccess} - void onExieAnnouncementDismiss()} - onStart={() => void onExieAnnouncementStart()} - /> -{/if} - - void startTour(id, catalogSource)} /> - -{#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/eligibility.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts deleted file mode 100644 index eed9c6af2a..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ProductTourStatus } from '$generated/api'; -import { describe, expect, it } from 'vitest'; - -import { shouldOfferProductTourAnnouncement, 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); - }); -}); - -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 deleted file mode 100644 index 6967783729..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/product-tours.css b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css deleted file mode 100644 index dd0469df68..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css +++ /dev/null @@ -1,44 +0,0 @@ -.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; -} - -/* 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 { - transition: none !important; - } -} 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 deleted file mode 100644 index 53221790d7..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 7f618048b8..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts +++ /dev/null @@ -1,28 +0,0 @@ -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.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts deleted file mode 100644 index 817f11a1b8..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -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(); - 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 deleted file mode 100644 index 8d36962e3d..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { ProductTourId, ProductTourLaunchSource } from './types'; - -export type ProductTourHostEvent = - | { eventType?: string; type: 'event-opened' } - | { stepId: string; tourId: ProductTourId; type: 'advance' } - | { tourId: ProductTourId; type: 'completed' | 'dismissed' }; - -type ProductTourHostListener = (event: ProductTourHostEvent) => boolean | Promise | 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.session = undefined; - } - - async complete(tourId: ProductTourId): Promise { - const event: ProductTourHostEvent = { - tourId, - type: 'completed' - }; - const results = await Promise.all([...this.listeners].map((listener) => listener(event))); - return results.every((result) => result !== false); - } - - 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 | 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) { - void listener(event); - } - } -} - -export const productTourHost = new ProductTourHost(); 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 deleted file mode 100644 index 3021de3d1c..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -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' - ); - 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', () => { - 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 deleted file mode 100644 index bda21db74a..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ProductTourKey, ProductTourLaunchSource } from './types'; - -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]+)*$/; - -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 deleted file mode 100644 index 4c42586ee4..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -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; - errorEventAvailability: ProductTourErrorEventAvailability; - isSetupPage: boolean; - openEventType?: string; - organizationId?: string; - pathname: string; - projects: ViewProject[]; -} -export interface ProductTourDefinition { - description: string; - getAvailability: (context: ProductTourContext) => ProductTourAvailability; - getStartAction?: (context: ProductTourContext) => ProductTourStartAction; - getSteps: (context: ProductTourContext) => ProductTourStep[]; - id: ProductTourId; - keywords: readonly string[]; - 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 = 'exie-announcement' | 'welcome' | ProductTourId; - -export type ProductTourLaunchSource = 'automatic' | 'catalog' | 'command-palette' | 'feature-announcement' | 'help-menu'; - -export interface ProductTourListItem extends ProductTourDefinition { - availability: ProductTourAvailability; - progress?: ProductTourProgress; -} - -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; - 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 be01c26312..7dd981b258 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 @@ -135,11 +135,7 @@ 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}`, { - params: { - limit: 1000 - } - }); + const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views/${request.route.view}`); return response.data!; }, queryKey: queryKeys.view(request.route.organizationId, request.route.view), @@ -152,11 +148,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`, { - params: { - limit: 1000 - } - }); + const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views`); 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 3fe0029d30..95fc6808ec 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 @@ -5,8 +5,6 @@ import { Input } from '$comp/ui/input'; 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 { productTourHost } from '$features/product-tours/state.svelte'; import type { SavedView } from '../models'; @@ -21,30 +19,16 @@ } from '../slugs'; interface Props { - defaultPrivate?: boolean; duplicateView?: SavedView; - onCancel?: () => void; onClose: () => void; - onLoadView: (view: SavedView) => Promise | void; + onLoadView: (view: SavedView) => 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(), - pendingCompletion = false, - savedViews, - saving - }: Props = $props(); + let { duplicateView, onClose, onLoadView, onSave, open = $bindable(), savedViews, saving }: Props = $props(); let saveName = $state(''); let saveSlug = $state(''); @@ -96,14 +80,14 @@ }); const visibleNameError = $derived(attemptedSubmit || saveName.length > 0 ? nameError : undefined); const visibleSlugError = $derived(attemptedSubmit || saveName.length > 0 || saveSlug.length > 0 ? slugError : undefined); - const canSave = $derived((pendingCompletion || (!nameError && !slugError)) && !saving); + const canSave = $derived(!nameError && !slugError && !saving); $effect(() => { if (open) { saveName = ''; saveSlug = ''; isSlugDirty = false; - isPrivate = defaultPrivate; + isPrivate = false; attemptedSubmit = false; } }); @@ -129,71 +113,24 @@ await onSave(trimmedName, normalizedSlug, isPrivate); } - - function dismissTour(): void { - onClose(); - onCancel?.(); - } - { - if (nextOpen || !saving) { - open = nextOpen; - if (!nextOpen) { - onCancel?.(); - } - } - }} -> - saving && event.preventDefault()} - onInteractOutside={(event) => saving && event.preventDefault()} - > + + Save View Save the current view configuration for quick access. - {#if defaultPrivate && productTourHost.activeStepId === 'name-view'} - productTourHost.advance('create-saved-view', 'name-view')} - onDismiss={dismissTour} - title="Review and name your view" - tourId="create-saved-view" - /> - {:else if defaultPrivate && productTourHost.activeStepId === 'private-view'} - productTourHost.advance('create-saved-view', 'private-view')} - onDismiss={dismissTour} - title="Keep it private" - tourId="create-saved-view" - /> - {:else if defaultPrivate && productTourHost.activeStepId === 'save-view'} - - {/if} - {#if duplicateView && !pendingCompletion} + {#if duplicateView}
Current filters match "{duplicateView.name}". You can instead, or save with a different name. @@ -209,7 +146,6 @@
{#if visibleNameError}

{visibleNameError}

@@ -234,7 +169,6 @@ aria-invalid={!!visibleSlugError} aria-describedby={visibleSlugError ? 'view-slug-error' : undefined} required - disabled={pendingCompletion} oninput={() => { isSlugDirty = true; }} @@ -243,24 +177,17 @@

{visibleSlugError}

{/if}
-
+
- {defaultPrivate ? 'Required for this guided practice view' : 'Only visible to you'} + 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 da448df149..9287293041 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,7 +14,6 @@ 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 { productTourHost } 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'; @@ -55,9 +54,8 @@ filters: IFilter[]; isModified: boolean; onClearSavedView: () => void; - onLoadView: (view: SavedView) => Promise | void; + onLoadView: (view: SavedView) => void; onResetToSaved: () => void; - onSavedViewCreated?: (view: SavedView) => Promise | void; savedViews: SavedView[]; setAutoFillColumnId: (columnId: AutoFillColumnSelection) => void; setShowChart?: (show: boolean) => void; @@ -82,7 +80,6 @@ onClearSavedView, onLoadView, onResetToSaved, - onSavedViewCreated, savedViews, setAutoFillColumnId, setShowChart, @@ -100,8 +97,6 @@ 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); @@ -128,7 +123,7 @@ } }); - const saving = $derived(createMutation.isPending || updateMutation.isPending || removeMutation.isPending || isCompletingTour); + const saving = $derived(createMutation.isPending || updateMutation.isPending || removeMutation.isPending); const currentFilterString = $derived(toFilter(filters.filter((f) => f.type !== 'date'))); // Auto-detect if current filters match an existing saved view for "load existing" hint @@ -200,7 +195,7 @@ columns: getSavedColumnSettings(), filter: currentFilterString || undefined, filter_definitions: filterDefinitions, - is_private: productTourHost.isActive('create-saved-view') || isPrivate ? true : undefined, + is_private: isPrivate || undefined, name, organization_id: organizationId, show_chart: showChart, @@ -212,25 +207,11 @@ }; try { - 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; + const result = await createMutation.mutateAsync(body); isSaveDialogOpen = false; - await onLoadView(result); + onLoadView(result); toast.success(`Saved view "${result.name}" created.`); } catch (error) { - isCompletingTour = false; toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); } } @@ -310,7 +291,7 @@ {#snippet child({ props })} - {/snippet} - + Saved View {#if activeView} @@ -328,7 +309,7 @@ Save {/if} - + @@ -396,16 +377,8 @@ {duplicateView} {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'); - } - }} {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 22162b905d..59627e249f 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 @@ -345,9 +345,9 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur }) ); - async function handleLoadView(view: SavedView): Promise { + function handleLoadView(view: SavedView) { if (options.baseHref) { - await goto(savedViewHref(view)); + 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 ce02f11c3f..a4122d895a 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,7 +40,6 @@ > -
- - - - -
+ + + +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts index 553ac1fc35..e1b2055359 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts @@ -7,7 +7,7 @@ import { fetchApiJson } from '$features/shared/api/api.svelte'; import { type FetchClientResponse, ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; -import type { OAuthGrant, UpdateEmailAddressResult, UpdateProductTourProgress, UpdateUser, UpdateUserEmailAddress, ViewCurrentUser, ViewUser } from './models'; +import type { OAuthGrant, UpdateEmailAddressResult, UpdateUser, UpdateUserEmailAddress, ViewCurrentUser, ViewUser } from './models'; export async function invalidateUserQueries(queryClient: QueryClient, message: WebSocketMessageValue<'UserChanged'>) { const { id } = message; @@ -41,7 +41,6 @@ export const queryKeys = { organization: (id: string | undefined) => [...queryKeys.type, 'organization', id] as const, patchUser: (id: string | undefined) => [...queryKeys.id(id), 'patch'] as const, postEmailAddress: (id: string | undefined) => [...queryKeys.idEmailAddress(id), 'update'] as const, - productTour: () => [...queryKeys.me(), 'product-tour'] as const, type: ['User'] as const }; @@ -261,28 +260,6 @@ export function postEmailAddress(request: PostEmailAddressRequest) { })); } -export function putCurrentUserProductTour() { - const queryClient = useQueryClient(); - return createMutation(() => ({ - enabled: () => !!accessToken.current, - mutationFn: async ({ progress, tourId }) => { - const client = useFetchClient(); - const response = await client.putJSON(`users/me/product-tours/${tourId}`, progress); - return response.data!; - }, - mutationKey: queryKeys.productTour(), - onError: () => { - queryClient.invalidateQueries({ - queryKey: queryKeys.me() - }); - }, - onSuccess: (data) => { - queryClient.setQueryData(queryKeys.me(), data); - queryClient.setQueryData(queryKeys.id(data.id), data); - } - })); -} - export function resendVerificationEmail(request: ResendVerificationEmailRequest) { return createMutation(() => ({ enabled: () => !!accessToken.current && !!request.route.id, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts index 3a41ba510e..a262d71122 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts @@ -1,20 +1,9 @@ -import type { ProductTourProgress as GeneratedProductTourProgress, ViewCurrentUser as GeneratedViewCurrentUser } from '$generated/api'; - -export type { ViewOAuthGrant as OAuthGrant, UpdateEmailAddressResult, ViewUser } from '$generated/api'; +export type { ViewOAuthGrant as OAuthGrant, UpdateEmailAddressResult, ViewCurrentUser, ViewUser } from '$generated/api'; export interface InviteUserForm { email: string; } -export type ProductTourProgress = GeneratedProductTourProgress; - -export type ProductTourStatus = 'completed' | 'dismissed'; - -export interface UpdateProductTourProgress { - status: ProductTourStatus; - version: number; -} - export interface UpdateUser { email_notifications_enabled?: boolean; full_name?: string; @@ -23,5 +12,3 @@ export interface UpdateUser { export interface UpdateUserEmailAddress { email_address: string; } - -export type ViewCurrentUser = GeneratedViewCurrentUser; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index f44891da73..2411510e46 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -7,11 +7,6 @@ export enum StackStatus { Discarded = "discarded", } -export enum ProductTourStatus { - Dismissed = "dismissed", - Completed = "completed", -} - export enum BillingStatus { Trialing = 0, Active = 1, @@ -20,84 +15,6 @@ export enum BillingStatus { Unpaid = 4, } -export interface AdminAssistantOrganizationUsage { - organization_id: string; - organization_name: string; - plan_id: string; - /** @format date-time */ - last_used_utc: string; - /** @format int64 */ - turns: number; - /** @format int64 */ - completed: number; - /** @format int64 */ - failed: number; - /** @format int64 */ - cancelled: number; - /** @format int64 */ - provider_requests: number; - /** @format int64 */ - tool_calls: number; - /** @format int64 */ - prompt_tokens: number; - /** @format int64 */ - completion_tokens: number; - /** @format double */ - cost_usd: number; - /** @format int64 */ - blocked_by_concurrency: number; - /** @format int64 */ - blocked_by_rate_limit: number; - /** @format int64 */ - blocked_by_token_limit: number; - /** @format int64 */ - blocked_by_cost_limit: number; - /** @format int64 */ - monthly_token_limit?: null | number; - /** @format double */ - monthly_cost_limit_usd?: null | number; - /** @format double */ - token_utilization?: null | number; - /** @format double */ - cost_utilization?: null | number; -} - -export interface AdminAssistantUsageResponse { - /** @format date-time */ - month: string; - /** @format int64 */ - active_organizations: number; - /** @format int64 */ - turns: number; - /** @format int64 */ - prompt_tokens: number; - /** @format int64 */ - completion_tokens: number; - /** @format double */ - cost_usd: number; - organizations: AdminAssistantOrganizationUsage[]; -} - -export interface AssistantAccessResponse { - enabled: boolean; - has_access: boolean; - upgrade_required: boolean; - message?: null | string; -} - -export interface AssistantChatMessage { - role: string; - content: string; -} - -export interface AssistantChatRequest { - messages: AssistantChatMessage[]; - organization_id?: null | string; - project_id?: null | string; - path?: null | string; - conversation_id?: null | string; -} - export interface BillingPlan { id: string; name: string; @@ -469,14 +386,6 @@ export interface ProblemDetails { instance?: null | string; } -export interface ProductTourProgress { - /** @format int32 */ - version: number; - status: ProductTourStatus; - /** @format date-time */ - updated_utc: string; -} - export interface ResetPasswordModel { password_reset_token: string; password: string; @@ -617,16 +526,6 @@ export interface UpdateEvent { description?: null | string; } -export interface UpdateProductTourProgress { - /** - * @format int32 - * @min 1 - * @max 2147483647 - */ - version: number; - status: ProductTourStatus; -} - /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ export interface UpdateProject { name: string; @@ -705,7 +604,6 @@ export interface User { /** @format date-time */ password_reset_token_expiration: string; o_auth_accounts: OAuthAccount[]; - product_tours: Record; /** Gets or sets the users Full Name. */ full_name: string; /** @format email */ @@ -737,7 +635,6 @@ export interface ViewCurrentUser { hash?: null | string; has_local_account: boolean; o_auth_accounts: OAuthAccount[]; - product_tours: Record; /** @pattern ^[a-fA-F0-9]{24}$ */ id: string; organization_ids: string[]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 27672cd1f4..6be8a910b2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -27,7 +27,6 @@ export const StackStatusSchema = zodEnum([ "ignored", "discarded", ]); -export const ProductTourStatusSchema = zodEnum(["dismissed", "completed"]); export const BillingStatusSchema = union([ literal(0), literal(1), @@ -36,81 +35,6 @@ export const BillingStatusSchema = union([ literal(4), ]); -export const AdminAssistantOrganizationUsageSchema = object({ - organization_id: string().min(1, "Organization id is required"), - organization_name: string().min(1, "Organization name is required"), - plan_id: string().min(1, "Plan id is required"), - last_used_utc: iso.datetime(), - turns: int(), - completed: int(), - failed: int(), - cancelled: int(), - provider_requests: int(), - tool_calls: int(), - prompt_tokens: int(), - completion_tokens: int(), - cost_usd: number(), - blocked_by_concurrency: int(), - blocked_by_rate_limit: int(), - blocked_by_token_limit: int(), - blocked_by_cost_limit: int(), - monthly_token_limit: int().nullable(), - monthly_cost_limit_usd: number().nullable(), - token_utilization: number().nullable(), - cost_utilization: number().nullable(), -}); -export type AdminAssistantOrganizationUsageFormData = Infer< - typeof AdminAssistantOrganizationUsageSchema ->; - -export const AdminAssistantUsageResponseSchema = object({ - month: iso.datetime(), - active_organizations: int(), - turns: int(), - prompt_tokens: int(), - completion_tokens: int(), - cost_usd: number(), - organizations: array(lazy(() => AdminAssistantOrganizationUsageSchema)), -}); -export type AdminAssistantUsageResponseFormData = Infer< - typeof AdminAssistantUsageResponseSchema ->; - -export const AssistantAccessResponseSchema = object({ - enabled: boolean(), - has_access: boolean(), - upgrade_required: boolean(), - message: string().min(1, "Message is required").nullable().optional(), -}); -export type AssistantAccessResponseFormData = Infer< - typeof AssistantAccessResponseSchema ->; - -export const AssistantChatMessageSchema = object({ - role: string().min(1, "Role is required"), - content: string().min(1, "Content is required"), -}); -export type AssistantChatMessageFormData = Infer< - typeof AssistantChatMessageSchema ->; - -export const AssistantChatRequestSchema = object({ - messages: array(lazy(() => AssistantChatMessageSchema)), - organization_id: string() - .min(1, "Organization id is required") - .nullable() - .optional(), - project_id: string().min(1, "Project id is required").nullable().optional(), - path: string().min(1, "Path is required").nullable().optional(), - conversation_id: string() - .min(1, "Conversation id is required") - .nullable() - .optional(), -}); -export type AssistantChatRequestFormData = Infer< - typeof AssistantChatRequestSchema ->; - export const BillingPlanSchema = object({ id: string().min(1, "Id is required"), name: string().min(1, "Name is required"), @@ -580,15 +504,6 @@ export const ProblemDetailsSchema = object({ }); export type ProblemDetailsFormData = Infer; -export const ProductTourProgressSchema = object({ - version: int32(), - status: ProductTourStatusSchema, - updated_utc: iso.datetime(), -}); -export type ProductTourProgressFormData = Infer< - typeof ProductTourProgressSchema ->; - export const ResetPasswordModelSchema = object({ password_reset_token: string().length( 40, @@ -705,16 +620,6 @@ export const UpdateEventSchema = object({ }); export type UpdateEventFormData = Infer; -export const UpdateProductTourProgressSchema = object({ - version: int32() - .min(1, "Version must be at least 1") - .max(2147483647, "Version must be at most 2147483647"), - status: ProductTourStatusSchema, -}); -export type UpdateProductTourProgressFormData = Infer< - typeof UpdateProductTourProgressSchema ->; - export const UpdateProjectSchema = object({ name: string().min(1, "Name is required").optional(), delete_bot_data_enabled: boolean().optional(), @@ -789,10 +694,6 @@ export const UserSchema = object({ .optional(), password_reset_token_expiration: iso.datetime(), o_auth_accounts: array(lazy(() => OAuthAccountSchema)), - product_tours: record( - string(), - lazy(() => ProductTourProgressSchema), - ), full_name: string().min(1, "Full name is required"), email_address: email(), avatar_file_name: string() @@ -825,10 +726,6 @@ export const ViewCurrentUserSchema = object({ hash: string().min(1, "Hash is required").nullable().optional(), has_local_account: boolean(), o_auth_accounts: array(lazy(() => OAuthAccountSchema)), - product_tours: record( - string(), - lazy(() => ProductTourProgressSchema), - ), id: string() .length(24, "Id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte index 507a2ec701..e0dc023364 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte @@ -27,7 +27,7 @@
- + {#if isMediumScreenQuery.current} @@ -41,7 +41,6 @@