From f92dae84c6125989d38c08b73e20a5d111a09ca2 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 27 Aug 2026 22:02:25 -0500 Subject: [PATCH 1/5] Harden guided tours and usage reporting --- .../Models/Data/ProductTourProgress.cs | 22 ++ src/Exceptionless.Core/Models/User.cs | 2 + .../Api/Endpoints/AdminEndpoints.cs | 15 + .../Api/Endpoints/UserEndpoints.cs | 22 ++ .../Api/Handlers/AdminHandler.cs | 139 +++++++ .../Api/Handlers/UserHandler.cs | 52 ++- .../Api/Messages/AdminMessages.cs | 1 + .../Api/Messages/UserMessages.cs | 1 + .../ClientApp/e2e/fixtures/api-client.ts | 9 + .../ClientApp/e2e/fixtures/e2e-test.ts | 8 +- .../ClientApp/e2e/tests/product-tours.e2e.ts | 271 ++++++++++++++ .../ClientApp/package-lock.json | 7 + src/Exceptionless.Web/ClientApp/package.json | 1 + .../src/lib/features/admin/api.svelte.ts | 25 ++ .../src/lib/features/admin/models.ts | 2 + .../components/assistant-panel.svelte | 1 + .../events/components/events-overview.svelte | 195 +++++----- .../components/investigation-list-tour.svelte | 29 ++ .../features/product-tours/actions.svelte.ts | 58 +++ .../features/product-tours/catalog.test.ts | 47 +++ .../src/lib/features/product-tours/catalog.ts | 93 +++++ .../product-tour-catalog-dialog.svelte | 50 +++ .../product-tour-welcome-dialog.svelte | 48 +++ ...product-tour-welcome-dialog.svelte.test.ts | 41 +++ .../product-tour-feature-announcement.svelte | 44 +++ .../components/product-tour-host.svelte | 275 ++++++++++++++ .../product-tour-inline-callout.svelte | 30 ++ .../product-tour-shell-spotlight.svelte | 119 ++++++ .../components/product-tour-spotlight.svelte | 74 ++++ .../product-tours/eligibility.test.ts | 24 ++ .../lib/features/product-tours/eligibility.ts | 9 + .../product-tours/session.svelte.test.ts | 42 +++ .../src/lib/features/product-tours/session.ts | 66 ++++ .../product-tours/state.svelte.test.ts | 36 ++ .../features/product-tours/state.svelte.ts | 66 ++++ .../features/product-tours/telemetry.test.ts | 13 + .../lib/features/product-tours/telemetry.ts | 16 + .../src/lib/features/product-tours/types.ts | 56 +++ .../src/lib/features/projects/api.svelte.ts | 3 +- .../components/save-view-dialog.svelte | 104 +++++- .../components/saved-view-picker.svelte | 119 +++++- .../components/ui/sidebar/sidebar.svelte | 1 + .../stacks/components/stack-card.svelte | 11 +- .../src/lib/features/users/api.svelte.ts | 46 ++- .../src/lib/features/users/models.ts | 10 +- .../ClientApp/src/lib/generated/api.ts | 67 ++++ .../ClientApp/src/lib/generated/schemas.ts | 67 ++++ .../(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 | 50 ++- .../ClientApp/src/routes/(app)/+layout.svelte | 107 +++++- .../src/routes/(app)/event/+page.svelte | 135 +++---- .../event/[eventId=objectid]/+page.svelte | 1 + .../src/routes/(app)/event/query-filters.ts | 70 ++++ .../(app)/organization/add/+page.svelte | 40 +- .../[projectId]/configure/+page.svelte | 87 ++++- .../src/routes/(app)/project/add/+page.svelte | 24 +- .../(app)/system/product-tours/+page.svelte | 154 ++++++++ .../src/routes/(app)/system/routes.svelte.ts | 8 + .../Admin/AdminProductTourUsageResponse.cs | 28 ++ .../Models/Admin/ProductTourUsageSource.cs | 44 +++ .../Models/User/UpdateProductTourProgress.cs | 14 + .../Models/User/ViewCurrentUser.cs | 12 +- .../Api/Data/endpoint-manifest.json | 26 ++ .../Exceptionless.Tests/Api/Data/openapi.json | 341 ++++++++++++++++++ .../AdminProductTourUsageEndpointTests.cs | 135 +++++++ .../Api/Endpoints/OAuthGrantEndpointTests.cs | 280 ++++++++++++++ .../Api/Endpoints/ProductTourEndpointTests.cs | 180 +++++++++ .../Api/Endpoints/UserEndpointTests.cs | 251 ------------- .../Api/OpenApiSnapshotTests.cs | 31 ++ .../Serializer/Models/UserSerializerTests.cs | 55 +++ tests/http/admin.http | 4 + tests/http/users.http | 10 + 75 files changed, 4109 insertions(+), 474 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/events/components/investigation-list-tour.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.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/dialogs/product-tour-catalog-dialog.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.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/session.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts 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/ClientApp/src/routes/(app)/event/query-filters.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(app)/system/product-tours/+page.svelte create mode 100644 src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs create mode 100644 src/Exceptionless.Web/Models/Admin/ProductTourUsageSource.cs create mode 100644 src/Exceptionless.Web/Models/User/UpdateProductTourProgress.cs create mode 100644 tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs create mode 100644 tests/Exceptionless.Tests/Api/Endpoints/OAuthGrantEndpointTests.cs create mode 100644 tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.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..fbcf95fd89 --- /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 ProductTourStatus Status { get; set; } + public DateTime UpdatedUtc { get; set; } + public int Version { get; set; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ProductTourStatus +{ + [JsonStringEnumMemberName("completed")] + [EnumMember(Value = "completed")] + Completed, + [JsonStringEnumMemberName("dismissed")] + [EnumMember(Value = "dismissed")] + Dismissed +} diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs index cb6e154e50..cd8146ace8 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; @@ -24,6 +25,7 @@ public record User : IIdentity, IHaveDates, IValidatableObject public DateTime PasswordResetTokenExpiration { get; set; } public ICollection OAuthAccounts { get; init; } = new Collection(); public ICollection OrganizationPreferences { 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/AdminEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs index 7d6bbbbfe4..22bce2dc6e 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs @@ -87,6 +87,14 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); + endpoints.MapGet("api/v2/admin/product-tour-usage", GetProductTourUsageAsync) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .AddEndpointFilter() + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + group.MapPost("change-plan", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string organizationId, string planId) => (await mediator.InvokeAsync>(new AdminChangePlan(organizationId, planId, httpContext))).ToHttpResult(resultMapper)); @@ -130,4 +138,11 @@ private static EventSubmissionSettings CreateEventSubmissionSettings(bool? enabl bool configuredEnabled = !appOptions.EventSubmissionDisabled; return new EventSubmissionSettings(enabledOverride ?? configuredEnabled, configuredEnabled, enabledOverride.HasValue); } + + private static async Task GetProductTourUsageAsync( + IMediator mediator, + IMediatorResultMapper resultMapper, + DateTime? month = null, + int limit = 100) + => (await mediator.InvokeAsync>(new GetAdminProductTourUsage(month, limit))).ToHttpResult(resultMapper); } diff --git a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs index 44ce3acd29..c82519ed57 100644 --- a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Authorization; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Extensions; using Exceptionless.Web.Api.Filters; using Exceptionless.Web.Api.Infrastructure; @@ -37,6 +38,27 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder } }); + group.MapPut("users/me/product-tours/{tourName:regex(^[a-z0-9]+(?:-[a-z0-9]+)*$):maxlength(64)}", async (string tourName, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] UpdateProductTourProgress progress) + => (await mediator.InvokeAsync>(new UserMessages.UpdateCurrentUserProductTour(tourName, progress))).ToHttpResult(resultMapper)) + .Accepts(false, "application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .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() { + ["tourName"] = "The stable product tour name.", + }, + ResponseDescriptions = new() { + ["400"] = "The request body is missing or malformed.", + ["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/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs index 0fe5e82b12..3d5e49e9e3 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -1,3 +1,4 @@ +using Elastic.Clients.Elasticsearch.QueryDsl; using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -17,6 +18,8 @@ using Foundatio.Queues; using Foundatio.Repositories; using Foundatio.Repositories.Migrations; +using Foundatio.Repositories.Models; +using Foundatio.Serializer; using Foundatio.Storage; using Foundatio.Mediator; @@ -38,9 +41,11 @@ public class AdminHandler( BillingPlans plans, IMigrationStateRepository migrationStateRepository, SampleDataService sampleDataService, + ITextSerializer serializer, TimeProvider timeProvider, ILoggerFactory loggerFactory) { + private const string ProductTourSourceField = EventIndex.Alias.Source + ".keyword"; private readonly ILogger _logger = loggerFactory.CreateLogger(); [HandlerEndpoint(HandlerMethod.Get, "settings", Group = "Admin")] @@ -134,6 +139,86 @@ public async Task> Handle(GetAdminAssistantUsage message) rows.Take(limit).ToArray()); } + public async Task> Handle(GetAdminProductTourUsage message) + { + var requestedMonth = message.Month ?? timeProvider.GetUtcNow().UtcDateTime; + var month = new DateTime(requestedMonth.Year, requestedMonth.Month, 1, 0, 0, 0, DateTimeKind.Utc); + var nextMonth = month.AddMonths(1); + int limit = Math.Clamp(message.Limit, 1, 500); + + var countTask = eventRepository.CountAsync((IRepositoryQuery query) => ApplyProductTourUsageFilter(query, month, nextMonth) + .AggregationsExpression("terms:(source~500 sum:count~1 max:date)")); + var recentTask = eventRepository.FindAsync(query => ApplyProductTourUsageFilter(query, month, nextMonth) + .SortDescending(ev => ev.Date), options => options.PageLimit(500)); + + await Task.WhenAll(countTask, recentTask); + + var sourceBuckets = (await countTask).Aggregations.Terms("terms_source")?.Buckets ?? []; + var parsedBuckets = sourceBuckets + .Select(bucket => ProductTourUsageSource.TryParse(bucket.Key, out var source) + ? new ProductTourUsageBucket(source, Convert.ToInt64(bucket.Aggregations.Sum("sum_count")?.Value ?? bucket.Total ?? 0), bucket.Aggregations.Max("max_date")?.Value) + : null) + .OfType() + .ToArray(); + + var groupedBuckets = parsedBuckets.GroupBy(bucket => bucket.Source.TourName, StringComparer.Ordinal).ToArray(); + string[] tourNames = groupedBuckets.Select(group => group.Key).ToArray(); + Task[] uniqueUserTasks = groupedBuckets + .Select(group => eventRepository.CountAsync((IRepositoryQuery query) => ApplyProductTourUsageFilter( + query, + month, + nextMonth, + group.Select(bucket => bucket.Source.Raw).ToArray()) + .AggregationsExpression("cardinality:user"))) + .ToArray(); + CountResult[] uniqueUserResults = await Task.WhenAll(uniqueUserTasks); + var uniqueUsersByTour = tourNames + .Zip(uniqueUserResults, (tourName, result) => new + { + TourName = tourName, + UniqueUsers = Convert.ToInt64(result.Aggregations.Cardinality("cardinality_user")?.Value ?? 0) + }) + .ToDictionary(item => item.TourName, item => item.UniqueUsers, StringComparer.Ordinal); + + var tours = groupedBuckets + .Select(group => + { + long shown = SumEvent(group, ProductTourUsageSource.ShownEvent); + long started = SumEvent(group, ProductTourUsageSource.StartedEvent); + long completed = SumEvent(group, ProductTourUsageSource.CompletedEvent); + long dismissed = SumEvent(group, ProductTourUsageSource.DismissedEvent); + long decisionDenominator = started > 0 ? started : shown; + long uniqueUsers = uniqueUsersByTour[group.Key]; + DateTime? lastRunUtc = group.Select(bucket => bucket.LastUtc).Max(); + + return new AdminProductTourSummary( + group.Key, + shown, + started, + completed, + dismissed, + uniqueUsers, + lastRunUtc, + CalculateRate(completed, decisionDenominator), + CalculateRate(dismissed, decisionDenominator)); + }) + .OrderByDescending(tour => tour.Started) + .ThenBy(tour => tour.Name, StringComparer.Ordinal) + .ToArray(); + + var recentActivity = (await recentTask).Documents + .Select(ev => ProductTourUsageSource.TryParse(ev.Source, out var source) ? CreateActivity(ev, source) : null) + .OfType() + .Take(limit) + .ToArray(); + + return new AdminProductTourUsageResponse( + month, + !String.IsNullOrWhiteSpace(appOptions.ExceptionlessApiKey), + tours, + recentActivity); + } + [HandlerEndpoint(HandlerMethod.Get, "migrations", Group = "Admin")] public async Task> Handle(GetAdminMigrations message) { @@ -172,6 +257,60 @@ public Task> Handle(GetAdminEcho message) }); } + private AdminProductTourActivity CreateActivity(PersistentEvent ev, ProductTourUsageSource source) + { + var user = ev.GetUserIdentity(serializer, _logger); + return new AdminProductTourActivity( + ev.Date.UtcDateTime, + source.Event, + source.LaunchSource, + source.TourName, + user?.Identity, + user?.Name, + source.Version, + ev.Count ?? 1); + } + + private static decimal? CalculateRate(long value, long denominator) + { + return denominator > 0 ? Decimal.Round(value / (decimal)denominator, 4) : null; + } + + private static long SumEvent(IEnumerable buckets, string eventName) + { + return buckets.Where(bucket => String.Equals(bucket.Source.Event, eventName, StringComparison.Ordinal)).Sum(bucket => bucket.Count); + } + + private IRepositoryQuery ApplyProductTourUsageFilter( + IRepositoryQuery query, + DateTime utcStart, + DateTime utcEnd, + IReadOnlyCollection? sources = null) + { + query + .Project(appOptions.InternalProjectId) + .FieldEquals(ev => ev.Type, Event.KnownTypes.FeatureUsage) + .DateRange(utcStart, utcEnd, (PersistentEvent ev) => ev.Date) + .Index(utcStart, utcEnd); + + if (sources is null) + { + return query.ElasticFilter(new PrefixQuery + { + Field = ProductTourSourceField, + Value = ProductTourUsageSource.Prefix + }); + } + + return query.ElasticFilter(new TermsQuery + { + Field = ProductTourSourceField, + Terms = new TermsQueryField(sources.Select(source => (Elastic.Clients.Elasticsearch.FieldValue)source).ToArray()) + }); + } + + private sealed record ProductTourUsageBucket(ProductTourUsageSource Source, long Count, DateTime? LastUtc); + [HandlerEndpoint(HandlerMethod.Get, "assemblies", Group = "Admin")] public Task> Handle(GetAdminAssemblies message) { diff --git a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs index 92e9bd563e..5d6a3b5ffd 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,9 @@ using Exceptionless.Web.Models.OAuth; using Exceptionless.Web.Utility; using Foundatio.Caching; -using Foundatio.Repositories; using Foundatio.Mediator; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; namespace Exceptionless.Web.Api.Handlers; @@ -33,6 +35,7 @@ public class UserHandler( IHttpContextAccessor httpContextAccessor, ILoggerFactory loggerFactory) { + private const int MaximumProductTours = 32; 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 +52,53 @@ public async Task> Handle(GetCurrentUser message) }; } + public async Task> Handle(UpdateCurrentUserProductTour message) + { + bool maximumExceeded = false; + ProductTourProgress? progress = null; + await repository.PatchAsync( + GetCurrentUserId(), + new ActionPatch(user => + { + user.ProductTours.TryGetValue(message.TourName, out var currentProgress); + if (currentProgress is null && user.ProductTours.Count >= MaximumProductTours) + { + maximumExceeded = true; + return false; + } + + if (!ShouldUpdateProductTourProgress(currentProgress, message.Progress)) + { + progress = currentProgress; + return false; + } + + progress = new ProductTourProgress + { + Status = message.Progress.Status!.Value, + UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime, + Version = message.Progress.Version + }; + user.ProductTours[message.TourName] = progress; + return true; + }), + options => options.Cache()); + + if (maximumExceeded) + return Result.Invalid(ValidationError.Create("tour_name", $"A user cannot track more than {MaximumProductTours} product tours.")); + + return progress is null ? Result.NotFound("User not found.") : progress; + } + + private static bool ShouldUpdateProductTourProgress(ProductTourProgress? current, UpdateProductTourProgress requested) + { + return current is null + || requested.Version > current.Version + || (requested.Version == current.Version + && current.Status is ProductTourStatus.Dismissed + && requested.Status is ProductTourStatus.Completed); + } + public async Task>> Handle(GetCurrentUserOAuthGrants message) { var tokens = new List(); diff --git a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs index df738edd85..43f3144691 100644 --- a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs @@ -3,6 +3,7 @@ namespace Exceptionless.Web.Api.Messages; public record GetAdminSettings; public record GetAdminStats; public record GetAdminAssistantUsage(DateTime? Month, int Limit, HttpContext Context); +public record GetAdminProductTourUsage(DateTime? Month, int Limit); public record GetAdminMigrations; public record GetAdminEcho(HttpContext Context); public record GetAdminAssemblies; diff --git a/src/Exceptionless.Web/Api/Messages/UserMessages.cs b/src/Exceptionless.Web/Api/Messages/UserMessages.cs index 7cc329710b..7973ceae86 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 TourName, 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 8b3f0f6eaf..1451c7f25c 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -309,6 +309,15 @@ export class E2EApiClient { await expectStatus(response, [202], 'submit event'); } + async updateProductTour(token: string, tourName: string, version: number, status: 'completed' | 'dismissed'): Promise { + const response = await this.request.put(this.url(`users/me/product-tours/${tourName}`), { + 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..380578c279 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -43,6 +43,7 @@ export interface E2ESecondaryProject { interface E2EFixtures { e2eApi: E2EApiClient; e2eCleanupPassword: string; + e2eDismissProductTourWelcome: boolean; e2eScenario: E2EScenario; e2eSecondaryOrganization: E2ESecondaryOrganization; e2eSecondaryProject: E2ESecondaryProject; @@ -57,7 +58,9 @@ export const test = base.extend({ e2eCleanupPassword: [E2E_TEST_PASSWORD, { option: true }], - e2eScenario: async ({ e2eApi, e2eCleanupPassword, e2eUseGeneratedUser, page }, use, testInfo) => { + e2eDismissProductTourWelcome: [true, { option: true }], + + e2eScenario: async ({ e2eApi, e2eCleanupPassword, e2eDismissProductTourWelcome, e2eUseGeneratedUser, page }, use, testInfo) => { const run = createRunName(e2eApi.environment.runId, testInfo); const userName = `Playwright User ${run}`; const email = `playwright-${run}@exceptionless.test`.toLowerCase(); @@ -85,6 +88,9 @@ 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); + if (e2eDismissProductTourWelcome) { + await e2eApi.updateProductTour(userToken, 'welcome', 1, 'dismissed'); + } await page.addInitScript( ({ organizationId, token }) => { 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..04496a2594 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -0,0 +1,271 @@ +import type { Page, Request, Response } from '@playwright/test'; + +import { E2E_TEST_PASSWORD, expect, test } from '../fixtures/e2e-test'; +import { seedRepresentativeEvent } from '../support/event-data'; +import { createRepresentativeEvent } from '../support/synthetic-event'; + +test.use({ actionTimeout: 15_000, e2eUseGeneratedUser: true }); + +test.describe('first-run welcome', () => { + test.use({ e2eDismissProductTourWelcome: false }); + + test('Browse Guides persists before the catalog opens', async ({ e2eScenario, page }) => { + await test.step(`show the first-run prompt for ${e2eScenario.email}`, async () => { + await page.goto('/next/stack'); + await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeVisible(); + }); + + const persisted = page.waitForResponse(isSuccessfulTourProgress('welcome')); + await page.getByRole('dialog', { name: 'Welcome to Exceptionless' }).getByRole('button', { name: 'Browse Guides' }).click(); + await persisted; + + const catalog = page.getByRole('dialog', { name: 'Guided Tours' }); + await expect(catalog).toBeVisible(); + await catalog.getByRole('button', { name: 'Close' }).click(); + await page.reload(); + await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeHidden(); + }); +}); + +test.describe('shell and identity checkpoints', () => { + test.use({ e2eDismissProductTourWelcome: false }); + + test('supports responsive resume and never carries checkpoints across identities', async ({ e2eApi, e2eScenario, e2eSecondaryOrganization, page }) => { + test.setTimeout(240_000); + const progressWrites: string[] = []; + page.on('request', (request) => { + if (request.method() === 'PUT' && request.url().includes('/api/v2/users/me/product-tours/')) { + progressWrites.push(new URL(request.url()).pathname); + } + }); + + await test.step('closing the welcome persists dismissal', async () => { + await page.goto('/next/stack'); + await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeVisible(); + const dismissed = page.waitForResponse(isSuccessfulTourProgress('welcome')); + await page.keyboard.press('Escape'); + await dismissed; + await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeHidden(); + }); + + await test.step('the shell tour renders on mobile and resumes on desktop with reduced motion', async () => { + await page.setViewportSize({ height: 844, width: 390 }); + await startTourFromCommand(page, 'Explore Exceptionless'); + const tour = page.locator('.driver-popover'); + await expect(page.locator('[data-tour="app-navigation"]')).toBeVisible(); + await expect(tour.getByText('Your workspace navigation')).toBeVisible(); + + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ height: 900, width: 1440 }); + await tour.getByRole('button', { name: 'Continue' }).click(); + await expect(tour.getByText('Find anything quickly')).toBeVisible(); + await page.reload(); + await expect(tour.getByText('Find anything quickly')).toBeVisible(); + + const dismissed = page.waitForResponse(isSuccessfulTourProgress('ui-overview')); + await tour.getByRole('button', { name: 'Close' }).click(); + await dismissed; + await expectProductTourSession(page, false); + }); + + await test.step('an organization change clears an active checkpoint without recording progress', async () => { + await mockAssistantAccess(page); + await page.reload(); + await startTourFromCommand(page, 'Meet Exie'); + await expectProductTourSession(page, true); + const writesBeforeSwitch = progressWrites.length; + + const identityTab = await page.context().newPage(); + await identityTab.goto('/next/stack'); + await identityTab.evaluate((organizationId) => { + window.localStorage.setItem('organization', JSON.stringify(organizationId)); + }, e2eSecondaryOrganization.organizationId); + await identityTab.close(); + await expectProductTourSession(page, false); + expect(progressWrites).toHaveLength(writesBeforeSwitch); + }); + + await test.step('logout clears an active checkpoint without recording progress', async () => { + await startTourFromCommand(page, 'Meet Exie'); + await expectProductTourSession(page, true); + const writesBeforeLogout = progressWrites.length; + + await page.getByRole('button', { name: new RegExp(e2eScenario.userName) }).dispatchEvent('click'); + await page.getByRole('menuitem', { name: 'Log Out' }).dispatchEvent('click'); + await expect(page).toHaveURL(/\/next\/login/); + await expectProductTourSession(page, false); + expect(progressWrites).toHaveLength(writesBeforeLogout); + + e2eScenario.userToken = await e2eApi.login(e2eScenario.email, E2E_TEST_PASSWORD); + }); + }); +}); + +test('domain workflows advance only on real success', async ({ e2eApi, e2eScenario, page }) => { + test.setTimeout(300_000); + + await test.step('project configuration advances after creation and the first event', async () => { + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Configure a project'); + await expect(page.getByRole('heading', { name: 'Add Project' })).toBeVisible(); + + const projectName = `Tour Project ${e2eScenario.run}`; + await page.getByLabel('Project Name', { exact: true }).fill(projectName); + await page.getByRole('button', { name: 'Continue to Client Setup' }).click(); + await page.waitForURL(/\/next\/project\/[^/]+\/configure\?redirect=true/); + const projectId = page.url().match(/\/project\/([^/]+)\/configure/)?.[1]; + expect(projectId).toBeTruthy(); + + await page.locator('[data-tour="project-configure-platform"]').click(); + await page.getByRole('option', { name: 'Browser applications' }).click(); + await page.locator('[data-product-tour-inline="configure-project"]').getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByText('Waiting for your first event')).toBeVisible(); + + try { + const token = await e2eApi.getProjectDefaultToken(e2eScenario.userToken, projectId!); + await e2eApi.submitEvent( + projectId!, + token.id, + createRepresentativeEvent({ + appUrl: e2eApi.environment.appUrl, + message: e2eScenario.message, + referenceId: e2eScenario.referenceId, + runId: e2eApi.environment.runId + }) + ); + await expect(page).toHaveURL(/\/next\/event/); + await expectProductTourSession(page, false); + } finally { + await e2eApi.deleteProject(e2eScenario.userToken, projectId!); + await e2eApi.waitForProjectDeleted(e2eScenario.userToken, projectId!); + } + }); + + await test.step('saved-view progress retry never repeats the successful POST', async () => { + let createRequests = 0; + let progressRequests = 0; + const countSavedViewCreation = (request: Request) => { + const path = new URL(request.url()).pathname; + if (request.method() === 'POST' && /^\/api\/v2\/organizations\/[^/]+\/saved-views$/.test(path)) createRequests += 1; + }; + const progressRoute = (url: URL) => url.pathname === '/api/v2/users/me/product-tours/create-saved-view'; + page.on('request', countSavedViewCreation); + await page.route(progressRoute, async (route) => { + progressRequests += 1; + if (progressRequests === 1) { + await route.fulfill({ json: { title: 'Injected progress failure' }, status: 500 }); + return; + } + + await route.continue(); + }); + + try { + await page.goto('/next/event'); + await startTourFromCommand(page, 'Create a saved view'); + await expectProductTourSession(page, true); + const tour = page.locator('.driver-popover'); + await tour.getByRole('button', { name: 'Continue' }).click(); + await tour.getByRole('button', { name: 'Continue' }).click(); + + await page.getByLabel('Name', { exact: true }).fill(`Tour View ${e2eScenario.run}`); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { exact: true, name: 'Save' }).click(); + await expect(page.getByText('Retry guide completion')).toBeVisible(); + expect(createRequests).toBe(1); + + await page.reload(); + await expect(page.getByRole('button', { name: 'Retry guide completion' })).toBeVisible(); + const completed = page.waitForResponse(isSuccessfulTourProgress('create-saved-view')); + await page.getByRole('button', { name: 'Retry guide completion' }).click(); + await completed; + await expect.poll(() => createRequests).toBe(1); + await expectProductTourSession(page, false); + } finally { + page.off('request', countSavedViewCreation); + await page.unroute(progressRoute); + } + }); + + await test.step('investigation advances when a real error opens', async () => { + await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eScenario.message, + projectId: e2eScenario.projectId, + projectToken: e2eScenario.projectToken, + referenceId: e2eScenario.referenceId + }); + await page.goto('/next/event?time=all&type=error'); + await expect(page.getByText(e2eScenario.message).first()).toBeVisible({ timeout: 30_000 }); + await startTourFromCommand(page, 'Investigate an error'); + await page.locator('.driver-popover').getByRole('button', { name: 'Continue' }).click(); + await page.locator('tr').filter({ hasText: e2eScenario.message }).first().click(); + const callout = page.locator('[data-product-tour-inline="investigate-error"]'); + await expect(callout.getByText('Understand the grouped issue')).toBeVisible(); + for (const title of ['Triage deliberately', 'Inspect the occurrence', 'Begin with the overview', 'Compare every occurrence']) { + await callout.getByRole('button', { name: 'Continue' }).click(); + await expect(callout.getByText(title)).toBeVisible(); + } + + const completed = page.waitForResponse(isSuccessfulTourProgress('investigate-error')); + await callout.getByRole('button', { name: 'Finish guide' }).click(); + await completed; + await expectProductTourSession(page, false); + await page.reload(); + await expect(page.locator('[data-product-tour-inline="investigate-error"]')).toBeHidden(); + }); + + await test.step('Exie opens context without provider submission', async () => { + await mockAssistantAccess(page); + let chatRequests = 0; + const countChatRequest = (request: Request) => { + if (new URL(request.url()).pathname === '/api/v2/assistant/chat') chatRequests += 1; + }; + page.on('request', countChatRequest); + + try { + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Meet Exie'); + const tour = page.locator('.driver-popover'); + await tour.getByRole('button', { name: 'Continue' }).click(); + await expect(tour.getByText('You control every request')).toBeVisible(); + expect(chatRequests).toBe(0); + } finally { + page.off('request', countChatRequest); + } + }); +}); + +async function expectProductTourSession(page: Page, present: boolean): Promise { + const assertion = expect.poll(() => page.evaluate(() => sessionStorage.getItem('exceptionless.product-tour'))); + if (present) { + await assertion.not.toBeNull(); + } else { + await assertion.toBeNull(); + } +} + +function isSuccessfulTourProgress(tourName: string) { + return (response: Response): boolean => { + const path = new URL(response.url()).pathname; + return response.request().method() === 'PUT' && path === `/api/v2/users/me/product-tours/${tourName}` && response.status() === 200; + }; +} + +async function mockAssistantAccess(page: Page): Promise { + await page.route( + (url) => url.pathname === '/api/v2/assistant/access', + (route) => route.fulfill({ json: { enabled: true, has_access: true, message: null, upgrade_required: false } }) + ); +} + +async function startTourFromCommand(page: Page, title: string): Promise { + const announcementStart = page.getByRole('button', { name: 'See how it works' }); + if (title === 'Meet Exie' && (await announcementStart.isVisible())) { + await announcementStart.click(); + return; + } + + 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 23a4f6bd0e..483f1f2f02 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.14", + "driver.js": "^1.8.0", "layerchart": "^2.3.0", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", @@ -6069,6 +6070,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 f3fc1b0a20..f9d84a2dbd 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -94,6 +94,7 @@ "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.4.14", + "driver.js": "^1.8.0", "layerchart": "^2.3.0", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts index 63f51aab4f..1d35d410b5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts @@ -6,6 +6,7 @@ import type { AdminAssistantSettings, AdminAssistantUsage, AdminEventSubmissionSettings, + AdminProductTourUsage, AdminStats, ElasticsearchInfo, ElasticsearchSnapshotsResponse, @@ -32,6 +33,7 @@ export const queryKeys = { eventSubmissionSettings: ['admin', 'event-submission-settings'] as const, migrations: ['admin', 'migrations'] as const, oauthApplications: ['admin', 'oauth-applications'] as const, + productTourUsage: (month: string) => ['admin', 'product-tour-usage', month] as const, snapshots: ['admin', 'elasticsearch', 'snapshots'] as const, stats: ['admin', 'stats'] as const }; @@ -98,6 +100,29 @@ export function getAdminAssistantUsageQuery(month: () => string) { })); } +export function getAdminProductTourUsageQuery(month: () => string) { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/product-tour-usage', { + params: { + limit: 100, + month: `${month()}-01` + }, + signal + }); + + if (!response.ok) { + throw response.problem; + } + + return response.data!; + }, + queryKey: queryKeys.productTourUsage(month()), + staleTime: 60 * 1000 + })); +} + export function getAdminStatsQuery() { return createQuery(() => ({ queryFn: async ({ signal }: { signal: AbortSignal }) => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts index e5bd45b444..d899ce0ce3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts @@ -1,4 +1,5 @@ import type { + AdminProductTourUsageResponse, AssistantModelSettings, CountResult, EventSubmissionSettings, @@ -50,6 +51,7 @@ export type AdminAssistantUsage = { }; export type AdminEventSubmissionSettings = EventSubmissionSettings; +export type AdminProductTourUsage = AdminProductTourUsageResponse; export type AdminStats = { events: CountResult; 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 916b8454b4..7001b41ba7 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,6 +508,7 @@ void; handleError: (problem: ProblemDetails) => void; id: string; + loadProjectDetails?: boolean; onEventLoaded?: (event: PersistentEvent) => void; onNavigate?: (eventId: string) => void; prepareStackAssistantContext?: () => void; @@ -57,6 +60,7 @@ filterChanged, handleError, id, + loadProjectDetails = true, onEventLoaded, onNavigate, prepareStackAssistantContext @@ -127,6 +131,7 @@ const navigation = $derived(eventQuery.data?.navigation); const projectQuery = getProjectQuery({ + enabled: () => loadProjectDetails, route: { get id() { return event?.project_id; @@ -156,13 +161,44 @@ let activeTab = $state('Overview'); let tabs = $derived(getTabs(event, projectQuery.data)); - let tabsListRef = $state(null); - let canScrollTabsLeft = $state(false); - let canScrollTabsRight = $state(false); let draggedPromotedTab = $state(null); let notifiedEventId = $state(''); let showJsonDialog = $state(false); + const tourActions = createProductTourActions(); + const investigationCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'investigate-error' ? productTourCheckpoint.current : undefined); + const investigationCopy = $derived.by(() => { + switch (investigationCheckpoint?.checkpointName) { + case 'event-occurrence': + return { + description: 'This occurrence contains its timestamp, raw JSON, and navigation to nearby events.', + title: 'Inspect the occurrence' + }; + case 'filter-stack-events': + return { + description: 'Show all events filters the list to this stack when you are ready to compare occurrences.', + title: 'Compare every occurrence' + }; + case 'stack-summary': + return { + description: 'Use the grouped stack title, affected users, occurrence count, and trend to judge scope and impact.', + title: 'Understand the grouped issue' + }; + case 'stack-triage': + return { + description: 'Status and options change shared issue state. Review them here; this guide will not invoke them.', + title: 'Triage deliberately' + }; + case 'tab-overview': + return { + description: 'Overview summarizes the message and useful event fields. Choose other tabs when the evidence calls for them.', + title: 'Begin with the overview' + }; + default: + return undefined; + } + }); + $effect(() => { if (shouldResetActiveEventTab(!!event, projectQuery.isPending, tabs, activeTab)) { activeTab = 'Overview'; @@ -173,29 +209,6 @@ return !!projectQuery.data?.promoted_tabs?.includes(tab); } - function updateTabsOverflow(): void { - if (!tabsListRef) { - canScrollTabsLeft = false; - canScrollTabsRight = false; - return; - } - - const maxScrollLeft = tabsListRef.scrollWidth - tabsListRef.clientWidth; - canScrollTabsLeft = tabsListRef.scrollLeft > 1; - canScrollTabsRight = tabsListRef.scrollLeft < maxScrollLeft - 1; - } - - function scrollTabs(direction: 'left' | 'right'): void { - if (!tabsListRef) { - return; - } - - tabsListRef.scrollBy({ - behavior: 'smooth', - left: direction === 'left' ? -tabsListRef.clientWidth / 2 : tabsListRef.clientWidth / 2 - }); - } - function onPromoted(title: string): void { activeTab = title; } @@ -282,6 +295,35 @@ } } + async function continueInvestigationTour(): Promise { + const checkpoint = investigationCheckpoint; + if (!checkpoint) { + return; + } + switch (checkpoint.checkpointName) { + case 'event-occurrence': + productTourCheckpoint.advance(checkpoint, 'tab-overview'); + break; + case 'stack-summary': + productTourCheckpoint.advance(checkpoint, 'stack-triage'); + break; + case 'stack-triage': + productTourCheckpoint.advance(checkpoint, 'event-occurrence'); + break; + case 'tab-overview': + productTourCheckpoint.advance(checkpoint, 'filter-stack-events'); + break; + default: + await tourActions.complete(checkpoint); + } + } + + async function dismissInvestigationTour(): Promise { + if (investigationCheckpoint) { + await tourActions.dismiss(investigationCheckpoint); + } + } + function prepareEventAssistantContext(): void { if (event) { assistantPageContext.setPageEvent(event); @@ -301,39 +343,28 @@ $effect(() => { if (event && event.id !== notifiedEventId) { notifiedEventId = event.id; - onEventLoaded?.(event); - } - }); - - $effect(() => { - const tabCount = tabs.length; - void tick().then(() => { - if (tabCount === tabs.length) { - updateTabsOverflow(); + const checkpoint = investigationCheckpoint; + if (checkpoint?.checkpointName === 'choose-error' && hasErrorOrSimpleError(event)) { + productTourCheckpoint.advance(checkpoint, 'stack-summary'); } - }); - }); - - onMount(() => { - updateTabsOverflow(); - - const resizeObserver = new ResizeObserver(updateTabsOverflow); - if (tabsListRef) { - resizeObserver.observe(tabsListRef); + onEventLoaded?.(event); } - - window.addEventListener('resize', updateTabsOverflow); - - return () => { - resizeObserver.disconnect(); - window.removeEventListener('resize', updateTabsOverflow); - }; }); -
+{#if event && investigationCopy && ['stack-summary', 'stack-triage'].includes(investigationCheckpoint?.checkpointName ?? '')} + +{/if} + +

Stack

- {#if event?.stack_id} + {#if loadProjectDetails && event?.stack_id} -
+{#if event && investigationCopy && ['event-occurrence', 'filter-stack-events'].includes(investigationCheckpoint?.checkpointName ?? '')} + +{/if} + +

Event

@@ -355,6 +397,7 @@ {#if event?.stack_id} - {/if} - +
+ {#each tabs as tab (tab)} handlePromotedTabDragStart(event, tab)} ondragover={(event) => handlePromotedTabDragOver(event, tab)} ondrop={(event) => handlePromotedTabDrop(event, tab)} @@ -433,17 +471,6 @@ > {/each} - {#if canScrollTabsRight} - - {/if}
{#each tabs as tab (tab)} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte new file mode 100644 index 0000000000..29f1dcb8de --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-list-tour.svelte @@ -0,0 +1,29 @@ + + +{#if checkpoint?.checkpointName === 'filter-errors'} + { + productTourCheckpoint.advance(current, 'choose-error'); + }} + target="[data-tour='event-filters']" + title="Start with the right errors" + /> +{:else if checkpoint?.checkpointName === 'choose-error'} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts new file mode 100644 index 0000000000..611213ff6c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts @@ -0,0 +1,58 @@ +import { submitFeatureUsage } from '$features/auth/exceptionless-session'; +import { putCurrentUserProductTour } from '$features/users/api.svelte'; +import { ProductTourStatus } from '$features/users/models'; +import { toast } from 'svelte-sonner'; + +import type { ProductTourCheckpoint, ProductTourKey, ProductTourLaunchSource } from './types'; + +import { getProductTour } from './catalog'; +import { productTourCheckpoint } from './state.svelte'; +import { buildProductTourTelemetryEvent, type ProductTourTelemetryEvent } from './telemetry'; + +export function createProductTourActions() { + const progressMutation = putCurrentUserProductTour(); + + async function complete(checkpoint: ProductTourCheckpoint): Promise { + return finish(checkpoint, ProductTourStatus.Completed); + } + + async function dismiss(checkpoint: ProductTourCheckpoint): Promise { + return finish(checkpoint, ProductTourStatus.Dismissed); + } + + async function finish(checkpoint: ProductTourCheckpoint, status: ProductTourStatus): Promise { + const definition = getProductTour(checkpoint.tourName); + try { + await progressMutation.mutateAsync({ + progress: { + status, + version: definition.version + }, + tourName: checkpoint.tourName + }); + } catch { + toast.error('We could not save your guided-tour progress. Please try again.'); + return false; + } + + if (!productTourCheckpoint.clear(checkpoint)) { + return false; + } + void track(status === ProductTourStatus.Completed ? 'completed' : 'dismissed', checkpoint.tourName, definition.version, checkpoint.source); + return true; + } + + return { + complete, + dismiss, + progressMutation + }; +} + +export async function track(event: ProductTourTelemetryEvent, name: ProductTourKey, version: number, source: ProductTourLaunchSource): Promise { + try { + await submitFeatureUsage(buildProductTourTelemetryEvent(event, name, version, source)); + } catch (error) { + console.warn('Unable to submit product tour telemetry.', error); + } +} 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..c17eb9f560 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import type { ProductTourContext } from './types'; + +import { getProductTourItems, getRecommendedProductTourName, productTourCatalog } from './catalog'; + +function context(overrides: Partial = {}): ProductTourContext { + return { + errorEventAvailability: 'available', + isSetupPage: false, + organizationId: 'organization-id', + pathname: '/next', + projects: [], + ...overrides + }; +} + +describe('product tour catalog', () => { + it('contains only durable metadata for the five named tours', () => { + expect(productTourCatalog.map((tour) => tour.name)).toEqual([ + 'ui-overview', + 'configure-project', + 'create-saved-view', + 'investigate-error', + 'meet-exie' + ]); + expect(productTourCatalog.every((tour) => tour.version > 0 && tour.keywords.length > 0)).toBe(true); + expect(JSON.stringify(productTourCatalog)).not.toContain('data-tour'); + }); + + it('recommends setup until an organization has configured projects', () => { + expect(getRecommendedProductTourName(context({ organizationId: undefined }))).toBe('configure-project'); + expect(getRecommendedProductTourName(context({ projects: [{ is_configured: false }] }))).toBe('configure-project'); + expect(getRecommendedProductTourName(context({ projects: [{ is_configured: true }] }))).toBe('ui-overview'); + }); + + it('reports availability separately from catalog metadata', () => { + const items = getProductTourItems( + context({ + assistantAccess: { enabled: false, has_access: false, upgrade_required: false }, + errorEventAvailability: 'empty' + }) + ); + expect(items.find((item) => item.name === 'meet-exie')?.currentAvailability.available).toBe(false); + expect(items.find((item) => item.name === 'investigate-error')?.currentAvailability.available).toBe(false); + }); +}); 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..307807a260 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts @@ -0,0 +1,93 @@ +import type { ProductTourProgress } from '$features/users/models'; + +import { resolve } from '$app/paths'; + +import type { ProductTourContext, ProductTourDefinition, ProductTourListItem, ProductTourName } from './types'; + +function requireApplicationShell(context: ProductTourContext) { + return context.isSetupPage || !context.organizationId + ? { available: false, reason: 'Finish organization setup to explore Exceptionless.' } + : { available: true }; +} + +function requireError(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. Try again shortly.' }; + if (context.errorEventAvailability === 'empty') return { available: false, reason: 'Send 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.' }; +} + +export const productTourCatalog: readonly ProductTourDefinition[] = [ + { + availability: requireApplicationShell, + description: 'Learn navigation, command search, saved views, Exie, and where to get help.', + initialCheckpoint: 'navigation', + keywords: ['navigation', 'ui', 'search', 'command', 'help', 'saved views'], + name: 'ui-overview', + startingRoute: () => resolve('/'), + title: 'Explore Exceptionless', + version: 1 + }, + { + availability: () => ({ available: true }), + description: 'Create or resume a project, connect an SDK, and wait for its first real event.', + initialCheckpoint: 'project-name', + keywords: ['add project', 'configure', 'sdk', 'api key', 'first event'], + name: 'configure-project', + startingRoute: (context) => (context.organizationId ? resolve('/(app)/project/add') : resolve('/(app)/organization/add')), + title: 'Configure a project', + version: 1 + }, + { + availability: requireOrganization, + description: 'Save the current Events configuration as a private view that only you can see.', + initialCheckpoint: 'open-view-menu', + keywords: ['saved view', 'filter', 'columns', 'private', 'dashboard'], + name: 'create-saved-view', + startingRoute: () => resolve('/(app)/event'), + title: 'Create a saved view', + version: 1 + }, + { + availability: requireError, + description: 'Open a real error, assess its stack and status, then inspect the occurrence.', + initialCheckpoint: 'filter-errors', + keywords: ['error report', 'event details', 'exception', 'filter', 'stack', 'triage'], + name: 'investigate-error', + startingRoute: () => `${resolve('/(app)/event')}?time=all&type=error`, + title: 'Investigate an error', + version: 1 + }, + { + availability: (context) => + context.assistantAccess?.enabled ? { available: true } : { available: false, reason: 'Exie is not enabled by this Exceptionless installation.' }, + description: 'See how Exie uses the current page as context without sending a prompt.', + initialCheckpoint: 'open-exie', + keywords: ['exie', 'assistant', 'ai', 'help', 'investigate'], + name: 'meet-exie', + startingRoute: () => resolve('/'), + title: 'Meet Exie', + version: 1 + } +] as const; + +export function getProductTour(name: ProductTourName): ProductTourDefinition { + return productTourCatalog.find((tour) => tour.name === name)!; +} + +export function getProductTourItems(context: ProductTourContext, progress: Record = {}): ProductTourListItem[] { + return productTourCatalog.map((definition) => ({ + ...definition, + currentAvailability: definition.availability(context), + progress: progress[definition.name] + })); +} + +export function getRecommendedProductTourName(context: ProductTourContext): ProductTourName { + return !context.organizationId || context.projects.some((project) => !project.is_configured) ? 'configure-project' : 'ui-overview'; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte new file mode 100644 index 0000000000..942730a992 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte @@ -0,0 +1,50 @@ + + + + + + Guided Tours + Learn Exceptionless with short guides that use your real data. + + +
+ {#each items as item (item.name)} +
+
+
+
+ {#if item.progress?.status === 'completed' && item.progress.version >= item.version} + Completed + {/if} +
+
+

{item.title}

+

{item.description}

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

{item.currentAvailability.reason}

+ {/if} +
+ +
+ {/each} +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte new file mode 100644 index 0000000000..3fde333876 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte @@ -0,0 +1,48 @@ + + + + + +
+
+ Welcome to Exceptionless + 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/dialogs/product-tour-welcome-dialog.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts new file mode 100644 index 0000000000..a3744dc382 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts @@ -0,0 +1,41 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import ProductTourWelcomeDialog from './product-tour-welcome-dialog.svelte'; + +const recommended = { + availability: vi.fn(() => ({ available: true })), + currentAvailability: { available: true }, + description: 'Learn navigation and search.', + initialCheckpoint: 'navigation' as const, + keywords: ['navigation'], + name: 'ui-overview' as const, + startingRoute: vi.fn(() => '/next'), + title: 'Explore Exceptionless', + version: 1 +}; + +describe('ProductTourWelcomeDialog', () => { + it('records dismissal from Escape', async () => { + const onBrowse = vi.fn(); + const onDismiss = vi.fn(); + const onStart = vi.fn(); + render(ProductTourWelcomeDialog, { onBrowse, onDismiss, onStart, open: true, recommended }); + + await fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(onBrowse).not.toHaveBeenCalled(); + expect(onDismiss).toHaveBeenCalledOnce(); + expect(onStart).not.toHaveBeenCalled(); + }); + + it('provides Browse Guides and Skip choices', async () => { + const onBrowse = vi.fn(); + const onDismiss = vi.fn(); + render(ProductTourWelcomeDialog, { onBrowse, onDismiss, 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(onDismiss).toHaveBeenCalledOnce(); + }); +}); 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-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte new file mode 100644 index 0000000000..08e6b4c308 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -0,0 +1,275 @@ + + + + +{#if exieAnnouncementOpen && assistantAccess} + +{/if} + + startTour(name, catalogSource)} /> + +{#if checkpoint && (checkpoint.tourName === 'meet-exie' || checkpoint.tourName === 'ui-overview')} + {#key checkpoint} + + {/key} +{/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 new file mode 100644 index 0000000000..325b931d75 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte @@ -0,0 +1,30 @@ + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte new file mode 100644 index 0000000000..cc38a9ab01 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte @@ -0,0 +1,119 @@ + + +{#if spotlight && (!isAnyOverlayOpen || checkpoint.tourName === 'meet-exie')} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte new file mode 100644 index 0000000000..2f80f74774 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte @@ -0,0 +1,74 @@ + 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..eed9c6af2a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000000..6967783729 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts @@ -0,0 +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/session.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts new file mode 100644 index 0000000000..a4a104d26b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { ProductTourCheckpoint } from './types'; + +import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; + +const checkpoint: ProductTourCheckpoint = { + checkpointName: 'choose-error', + organizationId: 'organization-id', + phase: { type: 'active' }, + source: 'command-palette', + tourName: 'investigate-error', + userId: 'user-id' +}; + +describe('product tour session', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('round-trips an explicit checkpoint', () => { + writeProductTourSession(checkpoint); + expect(readProductTourSession()).toEqual(checkpoint); + clearProductTourSession(); + expect(sessionStorage).toHaveLength(0); + }); + + it.each([ + '{not-json', + JSON.stringify({ ...checkpoint, tourName: 'unknown-tour' }), + JSON.stringify({ ...checkpoint, checkpointName: 'unknown-step' }), + JSON.stringify({ ...checkpoint, source: 'unknown-source' }), + JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created' } }), + JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created', viewId: 'view-id' } }), + JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-loaded', viewId: 'view-id' } }), + JSON.stringify({ ...checkpoint, userId: 42 }) + ])('clears malformed or unknown stored state: %s', (value) => { + sessionStorage.setItem('exceptionless.product-tour', value); + expect(readProductTourSession()).toBeUndefined(); + expect(sessionStorage).toHaveLength(0); + }); +}); 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..3baa4db065 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts @@ -0,0 +1,66 @@ +import type { ProductTourCheckpoint, ProductTourLaunchSource, ProductTourName, ProductTourPhase } from './types'; + +import { PRODUCT_TOUR_CHECKPOINTS } from './types'; + +const SESSION_KEY = 'exceptionless.product-tour'; +const SOURCES: readonly ProductTourLaunchSource[] = ['automatic', 'catalog', 'command-palette', 'feature-announcement', 'help-menu']; + +export function clearProductTourSession(storage: Pick = sessionStorage): void { + storage.removeItem(SESSION_KEY); +} + +export function readProductTourSession(storage: Pick = sessionStorage): ProductTourCheckpoint | undefined { + try { + const value = storage.getItem(SESSION_KEY); + if (!value) return undefined; + + const candidate: unknown = JSON.parse(value); + if (!isProductTourCheckpoint(candidate)) { + clearProductTourSession(storage); + return undefined; + } + + return candidate; + } catch { + clearProductTourSession(storage); + return undefined; + } +} + +export function writeProductTourSession(checkpoint: ProductTourCheckpoint, storage: Pick = sessionStorage): void { + storage.setItem(SESSION_KEY, JSON.stringify(checkpoint)); +} + +function isPhase(value: unknown, tourName: string, checkpointName: unknown): value is ProductTourPhase { + if (!isRecord(value) || typeof value.type !== 'string') return false; + if (value.type === 'active') return true; + return ( + tourName === 'create-saved-view' && + checkpointName === 'view-created' && + (value.type === 'saved-view-created' || value.type === 'saved-view-loaded') && + typeof value.viewId === 'string' && + !!value.viewId + ); +} + +function isProductTourCheckpoint(value: unknown): value is ProductTourCheckpoint { + if (!isRecord(value) || typeof value.userId !== 'string' || !value.userId || typeof value.tourName !== 'string') return false; + if (value.organizationId !== undefined && typeof value.organizationId !== 'string') return false; + if (!isProductTourLaunchSource(value.source) || !isProductTourName(value.tourName)) return false; + + const checkpoints: readonly string[] = PRODUCT_TOUR_CHECKPOINTS[value.tourName]; + if (typeof value.checkpointName !== 'string' || !checkpoints.includes(value.checkpointName)) return false; + return isPhase(value.phase, value.tourName, value.checkpointName); +} + +function isProductTourLaunchSource(value: unknown): value is ProductTourLaunchSource { + return typeof value === 'string' && (SOURCES as readonly string[]).includes(value); +} + +function isProductTourName(value: string): value is ProductTourName { + return Object.hasOwn(PRODUCT_TOUR_CHECKPOINTS, value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} 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..a4abfb8833 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { ProductTourCheckpoint } from './types'; + +import { productTourCheckpoint } from './state.svelte'; + +const checkpoint: ProductTourCheckpoint = { + checkpointName: 'navigation', + organizationId: 'organization-id', + phase: { type: 'active' }, + source: 'catalog', + tourName: 'ui-overview', + userId: 'user-id' +}; + +describe('product tour checkpoint store', () => { + beforeEach(() => productTourCheckpoint.clear()); + + it('does not let stale work advance or clear a newer tour', () => { + const first = productTourCheckpoint.start(checkpoint); + const second = productTourCheckpoint.start({ ...checkpoint, source: 'help-menu' }); + + expect(productTourCheckpoint.advance(first, 'command-search')).toBeUndefined(); + expect(productTourCheckpoint.clear(first)).toBe(false); + expect(productTourCheckpoint.current).toBe(second); + }); + + it('clears a checkpoint restored for another identity', () => { + productTourCheckpoint.start(checkpoint); + productTourCheckpoint.clear(); + sessionStorage.setItem('exceptionless.product-tour', JSON.stringify(checkpoint)); + + expect(productTourCheckpoint.restore('another-user', 'organization-id')).toBeUndefined(); + expect(sessionStorage.getItem('exceptionless.product-tour')).toBeNull(); + }); +}); 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..5f67e71c8d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts @@ -0,0 +1,66 @@ +import type { ProductTourCheckpoint, ProductTourCheckpointName, ProductTourPhase } from './types'; + +import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; + +class ProductTourCheckpointStore { + current = $state.raw(); + + advance( + expected: ProductTourCheckpoint, + checkpointName: ProductTourCheckpointName, + phase: ProductTourPhase = { + type: 'active' + }, + organizationId = expected.organizationId + ) { + if (this.current !== expected) { + return undefined; + } + return this.save({ + ...expected, + checkpointName, + organizationId, + phase + }); + } + + clear(expected?: ProductTourCheckpoint): boolean { + if (expected && this.current !== expected) { + return false; + } + this.current = undefined; + clearProductTourSession(); + return true; + } + + restore(userId: string, organizationId?: string): ProductTourCheckpoint | undefined { + if (this.current) { + return this.current; + } + + const stored = readProductTourSession(); + if (!stored) { + return undefined; + } + + if (stored.userId !== userId || stored.organizationId !== organizationId) { + clearProductTourSession(); + return undefined; + } + + this.current = stored; + return stored; + } + + start(checkpoint: ProductTourCheckpoint): ProductTourCheckpoint { + return this.save(checkpoint); + } + + private save(checkpoint: ProductTourCheckpoint): ProductTourCheckpoint { + this.current = checkpoint; + writeProductTourSession(checkpoint); + return checkpoint; + } +} + +export const productTourCheckpoint = new ProductTourCheckpointStore(); 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..a7ff2b5165 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { buildProductTourTelemetryEvent } from './telemetry'; + +describe('product tour telemetry', () => { + it('records stable lifecycle events without resource data', () => { + expect(buildProductTourTelemetryEvent('started', 'ui-overview', 1, 'command-palette')).toBe('product-tour.started.ui-overview.v1.command-palette'); + }); + + it('rejects invalid versions', () => { + 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..0c8ecc8429 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts @@ -0,0 +1,16 @@ +import type { ProductTourKey, ProductTourLaunchSource } from './types'; + +export type ProductTourTelemetryEvent = 'completed' | 'dismissed' | 'shown' | 'started'; + +export function buildProductTourTelemetryEvent( + event: ProductTourTelemetryEvent, + name: ProductTourKey, + version: number, + source: ProductTourLaunchSource +): string { + if (!Number.isSafeInteger(version) || version < 1) { + throw new Error('Product tour telemetry requires a positive version.'); + } + + return ['product-tour', event, name, `v${version}`, source].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..7d438257a7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts @@ -0,0 +1,56 @@ +import type { AssistantAccess } from '$features/assistant/models'; +import type { ViewProject } from '$features/projects/models'; +import type { ProductTourProgress } from '$features/users/models'; + +export const PRODUCT_TOUR_CHECKPOINTS = { + 'configure-project': ['organization-name', 'project-name', 'choose-platform', 'sdk-instructions', 'wait-for-event'], + 'create-saved-view': ['open-view-menu', 'review-settings', 'name-view', 'private-view', 'save-view', 'view-created'], + 'investigate-error': ['filter-errors', 'choose-error', 'stack-summary', 'stack-triage', 'event-occurrence', 'tab-overview', 'filter-stack-events'], + 'meet-exie': ['open-exie', 'exie-context'], + 'ui-overview': ['navigation', 'command-search', 'saved-views', 'exie', 'help'] +} as const; + +export interface ProductTourAvailability { + available: boolean; + reason?: string; +} +export interface ProductTourCheckpoint { + checkpointName: ProductTourCheckpointName; + organizationId?: string; + phase: ProductTourPhase; + source: ProductTourLaunchSource; + tourName: ProductTourName; + userId: string; +} +export type ProductTourCheckpointName = (typeof PRODUCT_TOUR_CHECKPOINTS)[ProductTourName][number]; +export interface ProductTourContext { + assistantAccess?: AssistantAccess; + errorEventAvailability: 'available' | 'empty' | 'error' | 'loading'; + isSetupPage: boolean; + organizationId?: string; + pathname: string; + projects: Pick[]; +} +export interface ProductTourDefinition { + availability: (context: ProductTourContext) => ProductTourAvailability; + description: string; + initialCheckpoint: ProductTourCheckpointName; + keywords: readonly string[]; + name: ProductTourName; + startingRoute: (context: ProductTourContext) => string; + title: string; + version: number; +} + +export type ProductTourKey = 'exie-announcement' | 'welcome' | ProductTourName; + +export type ProductTourLaunchSource = 'automatic' | 'catalog' | 'command-palette' | 'feature-announcement' | 'help-menu'; + +export interface ProductTourListItem extends ProductTourDefinition { + currentAvailability: ProductTourAvailability; + progress?: ProductTourProgress; +} + +export type ProductTourName = keyof typeof PRODUCT_TOUR_CHECKPOINTS; + +export type ProductTourPhase = { type: 'active' } | { type: 'saved-view-created' | 'saved-view-loaded'; viewId: string }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts index 71fa94d9e7..2d7b449265 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts @@ -140,6 +140,7 @@ export interface GetProjectIntegrationNotificationSettingsRequest { } export interface GetProjectRequest { + enabled?: () => boolean; refetchInterval?: false | number; route: { id: string | undefined; @@ -440,7 +441,7 @@ export function getProjectQuery(request: GetProjectRequest) { const id = request.route.id; return { - enabled: () => !!accessToken.current && !!id, + enabled: () => !!accessToken.current && !!id && (request.enabled?.() ?? true), queryFn: async ({ signal }: { signal: AbortSignal }) => { const client = useFetchClient(); const response = await client.getJSON(`projects/${id}`, { 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..645228ecd3 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,13 @@ - - + { + 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 duplicateView} + {#if defaultPrivate && tourCheckpointName === 'name-view'} + onTourContinue?.('private-view')} + onDismiss={dismissTour} + title="Review and name your view" + tourName="create-saved-view" + /> + {:else if defaultPrivate && tourCheckpointName === 'private-view'} + onTourContinue?.('save-view')} + onDismiss={dismissTour} + title="Keep it private" + tourName="create-saved-view" + /> + {:else if defaultPrivate && (tourCheckpointName === 'save-view' || tourCheckpointName === 'view-created')} + + {/if} + {#if duplicateView && !pendingCompletion}
Current filters match "{duplicateView.name}". You can instead, or save with a different name. @@ -146,6 +213,7 @@
{#if visibleNameError}

{visibleNameError}

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

{visibleSlugError}

{/if}
-
+
- 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 2cee06835d..81fbdd3dcf 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 @@ -15,6 +15,9 @@ import { serializeFilters } from '$features/events/components/filters/helpers.svelte'; import { getOrganizationQuery, getOrganizationsQuery } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; + import { createProductTourActions } from '$features/product-tours/actions.svelte'; + import ProductTourSpotlight from '$features/product-tours/components/product-tour-spotlight.svelte'; + import { productTourCheckpoint } from '$features/product-tours/state.svelte'; import { supportsColumnWrapping } from '$features/shared/components/data-table/column-meta'; import { getMeQuery } from '$features/users/api.svelte'; import Building2 from '@lucide/svelte/icons/building-2'; @@ -69,7 +72,7 @@ filters: IFilter[]; isModified: boolean; onClearSavedView: () => Promise; - onLoadView: (view: SavedView) => void; + onLoadView: (view: SavedView) => Promise | void; onResetToSaved: () => void; onSavedViewUpdated: (view: SavedView) => void; savedViews: SavedView[]; @@ -114,12 +117,23 @@ wrappedColumnIds }: Props = $props(); - let isSaveDialogOpen = $state(false); + let isSaveDialogOpenManually = $state(false); let isRenameDialogOpen = $state(false); let isDeleteDialogOpen = $state(false); let isColumnDialogOpen = $state(false); let isMenuOpen = $state(false); let viewToDelete = $state(null); + const tourActions = createProductTourActions(); + const savedViewCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'create-saved-view' ? productTourCheckpoint.current : undefined); + const isSaveDialogOpen = $derived( + isSaveDialogOpenManually || savedViewCheckpoint?.phase.type === 'saved-view-created' || savedViewCheckpoint?.phase.type === 'saved-view-loaded' + ); + const pendingTourView = $derived.by(() => { + const phase = savedViewCheckpoint?.phase; + return phase?.type === 'saved-view-created' || phase?.type === 'saved-view-loaded' + ? savedViews.find((savedView) => savedView.id === phase.viewId) + : undefined; + }); const organizationId = $derived(organization.current); const activeView = $derived(activeSavedView); @@ -218,7 +232,7 @@ async function openSaveDialog() { await tick(); - isSaveDialogOpen = true; + isSaveDialogOpenManually = true; } async function openRenameDialog() { @@ -259,6 +273,35 @@ return; } + const checkpoint = savedViewCheckpoint; + if (checkpoint?.phase.type === 'saved-view-loaded') { + if (await tourActions.complete(checkpoint)) { + isSaveDialogOpenManually = false; + } + return; + } + + if (checkpoint?.phase.type === 'saved-view-created') { + if (!pendingTourView) { + toast.error('The created view could not be loaded. Refresh and try again.'); + return; + } + + try { + await onLoadView(pendingTourView); + const loadedCheckpoint = productTourCheckpoint.advance(checkpoint, 'view-created', { + type: 'saved-view-loaded', + viewId: checkpoint.phase.viewId + }); + if (loadedCheckpoint && (await tourActions.complete(loadedCheckpoint))) { + isSaveDialogOpenManually = false; + } + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to load the created view. Please try again.')); + } + return; + } + const filterDefinitions = serializeFilters(filters); const body: NewSavedView = { columns: getSavedColumnSettings(), @@ -277,8 +320,25 @@ try { const result = await createMutation.mutateAsync(body); - isSaveDialogOpen = false; - onLoadView(result); + if (checkpoint) { + const createdCheckpoint = productTourCheckpoint.advance(checkpoint, 'view-created', { + type: 'saved-view-created', + viewId: result.id + }); + await onLoadView(result); + const loadedCheckpoint = createdCheckpoint + ? productTourCheckpoint.advance(createdCheckpoint, 'view-created', { + type: 'saved-view-loaded', + viewId: result.id + }) + : undefined; + if (loadedCheckpoint && (await tourActions.complete(loadedCheckpoint))) { + isSaveDialogOpenManually = false; + } + } else { + isSaveDialogOpenManually = false; + await onLoadView(result); + } toast.success(`Saved view "${result.name}" created.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); @@ -393,7 +453,7 @@ {#snippet child({ props })} - {/snippet} - + Saved View {#if activeView} @@ -493,16 +553,57 @@ {#if isSaveDialogOpen} { + if (savedViewCheckpoint) { + await tourActions.dismiss(savedViewCheckpoint); + } + }} {savedViews} {saving} onSave={handleSave} - onClose={() => (isSaveDialogOpen = false)} + onClose={() => (isSaveDialogOpenManually = false)} + onTourContinue={(checkpointName) => { + const checkpoint = savedViewCheckpoint; + if (checkpoint) { + productTourCheckpoint.advance(checkpoint, checkpointName); + } + }} + pendingCompletion={savedViewCheckpoint?.phase.type === 'saved-view-created' || savedViewCheckpoint?.phase.type === 'saved-view-loaded'} + tourCheckpointName={savedViewCheckpoint?.checkpointName} {onLoadView} /> {/if} +{#if savedViewCheckpoint?.checkpointName === 'open-view-menu'} + { + isMenuOpen = true; + productTourCheckpoint.advance(checkpoint, 'review-settings'); + }} + target="[data-tour='saved-view-trigger']" + title="Open View settings" + /> +{:else if savedViewCheckpoint?.checkpointName === 'review-settings'} + { + isMenuOpen = false; + isSaveDialogOpenManually = true; + productTourCheckpoint.advance(checkpoint, 'name-view'); + }} + target="[data-tour='saved-view-settings']" + title="Configure what the view remembers" + /> +{/if} + {#if isRenameDialogOpen && activeView} @@ -184,10 +185,12 @@
- - - - +
+ + + + +
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 4747a521fa..643f6774e6 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,16 @@ 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, UpdateUser, UpdateUserEmailAddress, ViewCurrentUser, ViewUser } from './models'; +import type { + OAuthGrant, + ProductTourProgress, + UpdateEmailAddressResult, + UpdateProductTourProgress, + UpdateUser, + UpdateUserEmailAddress, + ViewCurrentUser, + ViewUser +} from './models'; export async function invalidateUserQueries(queryClient: QueryClient, message: WebSocketMessageValue<'UserChanged'>) { const { id } = message; @@ -41,6 +50,7 @@ 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: (tourName: string | undefined) => [...queryKeys.me(), 'product-tours', tourName] as const, type: ['User'] as const }; @@ -68,6 +78,11 @@ export interface PostEmailAddressRequest { }; } +export interface PutCurrentUserProductTourRequest { + progress: UpdateProductTourProgress; + tourName: string; +} + export interface ResendVerificationEmailRequest { route: { id: string | undefined; @@ -260,6 +275,35 @@ export function postEmailAddress(request: PostEmailAddressRequest) { })); } +export function putCurrentUserProductTour() { + const queryClient = useQueryClient(); + return createMutation(() => ({ + enabled: () => !!accessToken.current, + mutationFn: async ({ progress, tourName }) => { + const client = useFetchClient(); + const response = await client.putJSON(`users/me/product-tours/${tourName}`, progress); + return response.data!; + }, + mutationKey: queryKeys.productTour(undefined), + onSuccess: (progress, { tourName }) => { + const currentUser = queryClient.getQueryData(queryKeys.me()); + if (!currentUser) { + return; + } + + const updatedUser = { + ...currentUser, + product_tours: { + ...currentUser.product_tours, + [tourName]: progress + } + }; + queryClient.setQueryData(queryKeys.me(), updatedUser); + queryClient.setQueryData(queryKeys.id(currentUser.id), updatedUser); + } + })); +} + 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 a262d71122..67d005712d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts @@ -1,4 +1,12 @@ -export type { ViewOAuthGrant as OAuthGrant, UpdateEmailAddressResult, ViewCurrentUser, ViewUser } from '$generated/api'; +export { ProductTourStatus } from '$generated/api'; +export type { + ViewOAuthGrant as OAuthGrant, + ProductTourProgress, + UpdateEmailAddressResult, + UpdateProductTourProgress, + ViewCurrentUser, + ViewUser +} from '$generated/api'; export interface InviteUserForm { email: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index d009f96c39..83d39d7c0b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -7,6 +7,11 @@ export enum StackStatus { Discarded = "discarded", } +export enum ProductTourStatus { + Completed = "completed", + Dismissed = "dismissed", +} + export enum BillingStatus { Trialing = 0, Active = 1, @@ -73,6 +78,48 @@ export interface AdminAssistantUsageResponse { organizations: AdminAssistantOrganizationUsage[]; } +export interface AdminProductTourActivity { + /** @format date-time */ + date_utc: string; + event: string; + launch_source: string; + tour_name: string; + user_identity?: null | string; + user_name?: null | string; + /** @format int32 */ + version: number; + /** @format int64 */ + count: number; +} + +export interface AdminProductTourSummary { + name: string; + /** @format int64 */ + shown: number; + /** @format int64 */ + started: number; + /** @format int64 */ + completed: number; + /** @format int64 */ + dismissed: number; + /** @format int64 */ + unique_users: number; + /** @format date-time */ + last_run_utc?: null | string; + /** @format double */ + completion_rate?: null | number; + /** @format double */ + dismissal_rate?: null | number; +} + +export interface AdminProductTourUsageResponse { + /** @format date-time */ + month: string; + telemetry_configured: boolean; + tours: AdminProductTourSummary[]; + recent_activity: AdminProductTourActivity[]; +} + export interface AssistantAccessResponse { enabled: boolean; has_access: boolean; @@ -494,6 +541,14 @@ export interface ProblemDetails { instance?: null | string; } +export interface ProductTourProgress { + status: ProductTourStatus; + /** @format date-time */ + updated_utc: string; + /** @format int32 */ + version: number; +} + export interface ResetPasswordModel { password_reset_token: string; password: string; @@ -648,6 +703,16 @@ export interface UpdateEventSubmissionSettings { enabled?: null | boolean; } +export interface UpdateProductTourProgress { + status?: null | ProductTourStatus; + /** + * @format int32 + * @min 1 + * @max 2147483647 + */ + version: number; +} + /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ export interface UpdateProject { name: string; @@ -732,6 +797,7 @@ export interface User { password_reset_token_expiration: string; o_auth_accounts: OAuthAccount[]; organization_preferences: UserOrganizationPreference[]; + product_tours: Record; /** Gets or sets the users Full Name. */ full_name: string; /** @format email */ @@ -771,6 +837,7 @@ export interface ViewCurrentUser { has_local_account: boolean; o_auth_accounts: OAuthAccount[]; organization_preferences: UserOrganizationPreference[]; + 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 f7e0970212..057b61d0e4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -27,6 +27,7 @@ export const StackStatusSchema = zodEnum([ "ignored", "discarded", ]); +export const ProductTourStatusSchema = zodEnum(["completed", "dismissed"]); export const BillingStatusSchema = union([ literal(0), literal(1), @@ -75,6 +76,45 @@ export type AdminAssistantUsageResponseFormData = Infer< typeof AdminAssistantUsageResponseSchema >; +export const AdminProductTourActivitySchema = object({ + date_utc: iso.datetime(), + event: string().min(1, "Event is required"), + launch_source: string().min(1, "Launch source is required"), + tour_name: string().min(1, "Tour name is required"), + user_identity: string().min(1, "User identity is required").nullable(), + user_name: string().min(1, "User name is required").nullable(), + version: int32(), + count: int(), +}); +export type AdminProductTourActivityFormData = Infer< + typeof AdminProductTourActivitySchema +>; + +export const AdminProductTourSummarySchema = object({ + name: string().min(1, "Name is required"), + shown: int(), + started: int(), + completed: int(), + dismissed: int(), + unique_users: int(), + last_run_utc: iso.datetime().nullable(), + completion_rate: number().nullable(), + dismissal_rate: number().nullable(), +}); +export type AdminProductTourSummaryFormData = Infer< + typeof AdminProductTourSummarySchema +>; + +export const AdminProductTourUsageResponseSchema = object({ + month: iso.datetime(), + telemetry_configured: boolean(), + tours: array(lazy(() => AdminProductTourSummarySchema)), + recent_activity: array(lazy(() => AdminProductTourActivitySchema)), +}); +export type AdminProductTourUsageResponseFormData = Infer< + typeof AdminProductTourUsageResponseSchema +>; + export const AssistantAccessResponseSchema = object({ enabled: boolean(), has_access: boolean(), @@ -626,6 +666,15 @@ export const ProblemDetailsSchema = object({ }); export type ProblemDetailsFormData = Infer; +export const ProductTourProgressSchema = object({ + status: ProductTourStatusSchema, + updated_utc: iso.datetime(), + version: int32(), +}); +export type ProductTourProgressFormData = Infer< + typeof ProductTourProgressSchema +>; + export const ResetPasswordModelSchema = object({ password_reset_token: string().length( 40, @@ -768,6 +817,16 @@ export type UpdateEventSubmissionSettingsFormData = Infer< typeof UpdateEventSubmissionSettingsSchema >; +export const UpdateProductTourProgressSchema = object({ + status: ProductTourStatusSchema, + version: int32() + .min(1, "Version must be at least 1") + .max(2147483647, "Version must be at most 2147483647"), +}); +export type UpdateProductTourProgressFormData = Infer< + typeof UpdateProductTourProgressSchema +>; + export const UpdateProjectSchema = object({ name: string().min(1, "Name is required").optional(), delete_bot_data_enabled: boolean().optional(), @@ -854,6 +913,10 @@ export const UserSchema = object({ password_reset_token_expiration: iso.datetime(), o_auth_accounts: array(lazy(() => OAuthAccountSchema)), organization_preferences: array(lazy(() => UserOrganizationPreferenceSchema)), + product_tours: record( + string(), + lazy(() => ProductTourProgressSchema), + ), full_name: string().min(1, "Full name is required"), email_address: email(), avatar_file_name: string() @@ -899,6 +962,10 @@ export const ViewCurrentUserSchema = object({ has_local_account: boolean(), o_auth_accounts: array(lazy(() => OAuthAccountSchema)), organization_preferences: array(lazy(() => UserOrganizationPreferenceSchema)), + 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 039baf20f5..6084aeef2c 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,6 +41,7 @@ + {/if} + {#each tabs as tab (tab)} {/each} + {#if canScrollTabsRight} + + {/if}
{#each tabs as tab (tab)} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts index 611213ff6c..2a2764fb8e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts @@ -38,7 +38,7 @@ export function createProductTourActions() { if (!productTourCheckpoint.clear(checkpoint)) { return false; } - void track(status === ProductTourStatus.Completed ? 'completed' : 'dismissed', checkpoint.tourName, definition.version, checkpoint.source); + await track(status === ProductTourStatus.Completed ? 'completed' : 'dismissed', checkpoint.tourName, definition.version, checkpoint.source); return true; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte index 08e6b4c308..d0b49f5a71 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -181,7 +181,7 @@ tourName: name, userId: currentUser.id }); - void Promise.all([track('shown', name, item.version, source), track('started', name, item.version, source)]); + await Promise.all([track('shown', name, item.version, source), track('started', name, item.version, source)]); const destination = item.startingRoute(context); if (`${pathname}${window.location.search}` !== destination) { @@ -214,7 +214,7 @@ return; } welcomeHandled = true; - void track('completed', 'welcome', WELCOME_VERSION, 'automatic'); + await track('completed', 'welcome', WELCOME_VERSION, 'automatic'); await startTour(recommended.name, 'automatic'); } @@ -223,7 +223,7 @@ return; } welcomeHandled = true; - void track('completed', 'welcome', WELCOME_VERSION, 'automatic'); + await track('completed', 'welcome', WELCOME_VERSION, 'automatic'); openCatalog('catalog'); } @@ -232,14 +232,14 @@ return; } welcomeHandled = true; - void track('dismissed', 'welcome', WELCOME_VERSION, 'automatic'); + await track('dismissed', 'welcome', WELCOME_VERSION, 'automatic'); } async function onExieAnnouncementStart(): Promise { if (!(await recordPreference('exie-announcement', EXIE_ANNOUNCEMENT_VERSION, ProductTourStatus.Completed))) { return; } - void track('completed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + await track('completed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); await startTour('meet-exie', 'feature-announcement'); } @@ -247,7 +247,7 @@ if (!(await recordPreference('exie-announcement', EXIE_ANNOUNCEMENT_VERSION, ProductTourStatus.Dismissed))) { return; } - void track('dismissed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + await track('dismissed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); } function getItem(name: ProductTourName): ProductTourListItem { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts index 2d7b449265..71fa94d9e7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts @@ -140,7 +140,6 @@ export interface GetProjectIntegrationNotificationSettingsRequest { } export interface GetProjectRequest { - enabled?: () => boolean; refetchInterval?: false | number; route: { id: string | undefined; @@ -441,7 +440,7 @@ export function getProjectQuery(request: GetProjectRequest) { const id = request.route.id; return { - enabled: () => !!accessToken.current && !!id && (request.enabled?.() ?? true), + enabled: () => !!accessToken.current && !!id, queryFn: async ({ signal }: { signal: AbortSignal }) => { const client = useFetchClient(); const response = await client.getJSON(`projects/${id}`, { 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 4c3a0a6601..f0e404b244 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 @@ -98,7 +98,7 @@ export interface UseSavedViewsReturn { autoFillColumnId: AutoFillColumnSelection; canModifySavedView: boolean; handleClearSavedView: () => Promise; - handleLoadView: (view: SavedView) => void; + handleLoadView: (view: SavedView) => Promise; handleResetToSaved: () => void; handleSavedViewUpdated: (view: SavedView) => void; hydratedSavedViewId: string | undefined; @@ -1199,9 +1199,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/sessions/components/sessions-dashboard-chart.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-dashboard-chart.svelte index ee57bbeeb2..b76f528af6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-dashboard-chart.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-dashboard-chart.svelte @@ -49,50 +49,51 @@ ]; - - - Math.max(d.sessions, d.users))))]} - {series} - axis={false} - grid={false} - brush={{ - onBrushEnd: (e) => { - if (!e.brush.active) { - return; - } + + {#if isLoading} + + {:else} + + Math.max(d.sessions, d.users))))]} + {series} + axis={false} + grid={false} + brush={{ + onBrushEnd: (e) => { + if (!e.brush.active) { + return; + } - const [start, end] = e.brush.x; - if (start instanceof Date && end instanceof Date) { - onRangeSelect?.(start, end); + const [start, end] = e.brush.x; + if (start instanceof Date && end instanceof Date) { + onRangeSelect?.(start, end); + } } - } - }} - props={{ - area: { - curve: curveLinear - }, - canvas: { - class: 'cursor-crosshair' - }, - svg: { - class: 'cursor-crosshair' - } - }} - > - {#snippet tooltip()} - (v instanceof Date ? formatDateLabel(v) : typeof v === 'number' ? formatDateLabel(new Date(v)) : String(v))} - /> - {/snippet} - - - {#if isLoading} - + }} + props={{ + area: { + curve: curveLinear + }, + canvas: { + class: 'cursor-crosshair' + }, + svg: { + class: 'cursor-crosshair' + } + }} + > + {#snippet tooltip()} + (v instanceof Date ? formatDateLabel(v) : typeof v === 'number' ? formatDateLabel(new Date(v)) : String(v))} + /> + {/snippet} + + {/if} 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 643f6774e6..e4feb3e218 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 @@ -282,6 +282,11 @@ export function putCurrentUserProductTour() { mutationFn: async ({ progress, tourName }) => { const client = useFetchClient(); const response = await client.putJSON(`users/me/product-tours/${tourName}`, progress); + + if (!response.ok) { + throw response.problem; + } + return response.data!; }, mutationKey: queryKeys.productTour(undefined), 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 347fe88087..5d41eb6981 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -1,7 +1,4 @@ -{#if event && investigationCopy && ['stack-summary', 'stack-triage'].includes(investigationCheckpoint?.checkpointName ?? '')} - -{/if} +

Stack

@@ -423,16 +346,7 @@ {/if}
-{#if event && investigationCopy && ['event-occurrence', 'filter-stack-events'].includes(investigationCheckpoint?.checkpointName ?? '')} - -{/if} +
@@ -489,15 +403,7 @@ {#if event} - {#if investigationCheckpoint?.checkpointName === 'tab-overview' && investigationCopy} - - {/if} +
{#if canScrollTabsLeft} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte new file mode 100644 index 0000000000..8c7afe291d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/investigation-detail-tour.svelte @@ -0,0 +1,114 @@ + + +{#if event && checkpoint && copy} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts index 2a2764fb8e..e2e2951648 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts @@ -5,7 +5,6 @@ import { toast } from 'svelte-sonner'; import type { ProductTourCheckpoint, ProductTourKey, ProductTourLaunchSource } from './types'; -import { getProductTour } from './catalog'; import { productTourCheckpoint } from './state.svelte'; import { buildProductTourTelemetryEvent, type ProductTourTelemetryEvent } from './telemetry'; @@ -21,12 +20,11 @@ export function createProductTourActions() { } async function finish(checkpoint: ProductTourCheckpoint, status: ProductTourStatus): Promise { - const definition = getProductTour(checkpoint.tourName); try { await progressMutation.mutateAsync({ progress: { status, - version: definition.version + version: checkpoint.version }, tourName: checkpoint.tourName }); @@ -38,7 +36,7 @@ export function createProductTourActions() { if (!productTourCheckpoint.clear(checkpoint)) { return false; } - await track(status === ProductTourStatus.Completed ? 'completed' : 'dismissed', checkpoint.tourName, definition.version, checkpoint.source); + await track(status === ProductTourStatus.Completed ? 'completed' : 'dismissed', checkpoint.tourName, checkpoint.version, checkpoint.source); return true; } 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 c17eb9f560..13f1c376f8 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 @@ -16,6 +16,8 @@ function context(overrides: Partial = {}): ProductTourContex } describe('product tour catalog', () => { + const versions = Object.fromEntries(productTourCatalog.map((tour) => [tour.name, 1])); + it('contains only durable metadata for the five named tours', () => { expect(productTourCatalog.map((tour) => tour.name)).toEqual([ 'ui-overview', @@ -24,7 +26,7 @@ describe('product tour catalog', () => { 'investigate-error', 'meet-exie' ]); - expect(productTourCatalog.every((tour) => tour.version > 0 && tour.keywords.length > 0)).toBe(true); + expect(productTourCatalog.every((tour) => tour.keywords.length > 0)).toBe(true); expect(JSON.stringify(productTourCatalog)).not.toContain('data-tour'); }); @@ -39,9 +41,15 @@ describe('product tour catalog', () => { context({ assistantAccess: { enabled: false, has_access: false, upgrade_required: false }, errorEventAvailability: 'empty' - }) + }), + versions ); expect(items.find((item) => item.name === 'meet-exie')?.currentAvailability.available).toBe(false); expect(items.find((item) => item.name === 'investigate-error')?.currentAvailability.available).toBe(false); }); + + it('uses server versions as the availability boundary', () => { + const items = getProductTourItems(context(), {}); + expect(items.every((item) => !item.currentAvailability.available && item.version === 0)).toBe(true); + }); }); 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 307807a260..8b26d1f198 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 @@ -30,8 +30,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ keywords: ['navigation', 'ui', 'search', 'command', 'help', 'saved views'], name: 'ui-overview', startingRoute: () => resolve('/'), - title: 'Explore Exceptionless', - version: 1 + title: 'Explore Exceptionless' }, { availability: () => ({ available: true }), @@ -40,8 +39,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ keywords: ['add project', 'configure', 'sdk', 'api key', 'first event'], name: 'configure-project', startingRoute: (context) => (context.organizationId ? resolve('/(app)/project/add') : resolve('/(app)/organization/add')), - title: 'Configure a project', - version: 1 + title: 'Configure a project' }, { availability: requireOrganization, @@ -50,8 +48,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ keywords: ['saved view', 'filter', 'columns', 'private', 'dashboard'], name: 'create-saved-view', startingRoute: () => resolve('/(app)/event'), - title: 'Create a saved view', - version: 1 + title: 'Create a saved view' }, { availability: requireError, @@ -60,8 +57,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ keywords: ['error report', 'event details', 'exception', 'filter', 'stack', 'triage'], name: 'investigate-error', startingRoute: () => `${resolve('/(app)/event')}?time=all&type=error`, - title: 'Investigate an error', - version: 1 + title: 'Investigate an error' }, { availability: (context) => @@ -71,21 +67,25 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ keywords: ['exie', 'assistant', 'ai', 'help', 'investigate'], name: 'meet-exie', startingRoute: () => resolve('/'), - title: 'Meet Exie', - version: 1 + title: 'Meet Exie' } ] as const; -export function getProductTour(name: ProductTourName): ProductTourDefinition { - return productTourCatalog.find((tour) => tour.name === name)!; -} - -export function getProductTourItems(context: ProductTourContext, progress: Record = {}): ProductTourListItem[] { - return productTourCatalog.map((definition) => ({ - ...definition, - currentAvailability: definition.availability(context), - progress: progress[definition.name] - })); +export function getProductTourItems( + context: ProductTourContext, + versions: Record, + progress: Record = {} +): ProductTourListItem[] { + return productTourCatalog.map((definition) => { + const version = versions[definition.name] ?? 0; + return { + ...definition, + currentAvailability: + version > 0 ? definition.availability(context) : { available: false, reason: 'This guided tour is not supported by the server.' }, + progress: progress[definition.name], + version + }; + }); } export function getRecommendedProductTourName(context: ProductTourContext): ProductTourName { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/create-saved-view-tour.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/create-saved-view-tour.svelte new file mode 100644 index 0000000000..adc4c9d0a5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/create-saved-view-tour.svelte @@ -0,0 +1,179 @@ + + +{#if checkpoint?.checkpointName === 'open-view-menu'} + { + openMenu(); + productTourCheckpoint.advance(active, 'review-settings'); + }} + target="[data-tour='saved-view-trigger']" + title="Open View settings" + /> +{:else if checkpoint?.checkpointName === 'review-settings'} + { + closeMenu(); + openSaveDialog(); + productTourCheckpoint.advance(active, 'name-view'); + }} + target="[data-tour='saved-view-settings']" + title="Configure what the view remembers" + /> +{:else if checkpoint?.checkpointName === 'name-view'} + { + productTourCheckpoint.advance(active, 'private-view'); + }} + target="[data-tour='saved-view-name']" + title="Name your view" + /> +{:else if checkpoint?.checkpointName === 'private-view'} + { + productTourCheckpoint.advance(active, 'save-view'); + }} + target="[data-tour='saved-view-private']" + title="Keep it private" + /> +{:else if checkpoint?.checkpointName === 'save-view'} + +{:else if checkpoint?.phase.type === 'saved-view-created' || checkpoint?.phase.type === 'saved-view-loaded'} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte index d0b49f5a71..735ee4de5b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -37,8 +37,6 @@ stateSettled: boolean; } - const WELCOME_VERSION = 1; - const EXIE_ANNOUNCEMENT_VERSION = 1; const SYSTEM_PATH = resolve('/(app)/system'); let { @@ -74,9 +72,11 @@ pathname, projects }); - const items = $derived(getProductTourItems(context, currentUser?.product_tours)); + const items = $derived(getProductTourItems(context, currentUser?.product_tour_versions ?? {}, currentUser?.product_tours)); const recommended = $derived(items.find((item) => item.name === getRecommendedProductTourName(context)) ?? items[0]!); const checkpoint = $derived(productTourCheckpoint.current); + const welcomeVersion = $derived(currentUser?.product_tour_versions.welcome ?? 0); + const exieAnnouncementVersion = $derived(currentUser?.product_tour_versions['exie-announcement'] ?? 0); const welcomeOpen = $derived( !!( stateSettled && @@ -87,7 +87,8 @@ !isImpersonating && !isSetupPage && !pathname.startsWith(SYSTEM_PATH) && - shouldOfferProductTourWelcome(currentUser.product_tours?.welcome, WELCOME_VERSION) + welcomeVersion > 0 && + shouldOfferProductTourWelcome(currentUser.product_tours?.welcome, welcomeVersion) ) ); const exieAnnouncementOpen = $derived( @@ -102,8 +103,9 @@ !welcomeOpen && !catalogOpen && !isAnyOverlayOpen && - !shouldOfferProductTourWelcome(currentUser.product_tours?.welcome, WELCOME_VERSION) && - shouldOfferProductTourAnnouncement(currentUser.product_tours?.['exie-announcement'], EXIE_ANNOUNCEMENT_VERSION) + !shouldOfferProductTourWelcome(currentUser.product_tours?.welcome, welcomeVersion) && + exieAnnouncementVersion > 0 && + shouldOfferProductTourAnnouncement(currentUser.product_tours?.['exie-announcement'], exieAnnouncementVersion) ) ); @@ -128,10 +130,10 @@ return; } - const impression = `${currentUser.id}:${WELCOME_VERSION}`; + const impression = `${currentUser.id}:${welcomeVersion}`; if (welcomeOpen && lastTrackedWelcomeImpression !== impression) { lastTrackedWelcomeImpression = impression; - void track('shown', 'welcome', WELCOME_VERSION, 'automatic'); + void track('shown', 'welcome', welcomeVersion, 'automatic'); } }); @@ -140,10 +142,10 @@ return; } - const impression = `${currentUser.id}:${EXIE_ANNOUNCEMENT_VERSION}`; + const impression = `${currentUser.id}:${exieAnnouncementVersion}`; if (exieAnnouncementOpen && lastTrackedAnnouncementImpression !== impression) { lastTrackedAnnouncementImpression = impression; - void track('shown', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + void track('shown', 'exie-announcement', exieAnnouncementVersion, 'feature-announcement'); } }); @@ -154,7 +156,7 @@ catalogOpen = true; } - export async function startTour(name: ProductTourName, source: ProductTourLaunchSource = 'catalog'): Promise { + export async function startTour(name: Name, source: ProductTourLaunchSource = 'catalog'): Promise { if (!currentUser) { return; } @@ -171,16 +173,7 @@ closeOverlays(); catalogOpen = false; - const next = productTourCheckpoint.start({ - checkpointName: item.initialCheckpoint, - organizationId, - phase: { - type: 'active' - }, - source, - tourName: name, - userId: currentUser.id - }); + const next = productTourCheckpoint.start(name, item.initialCheckpoint, source, currentUser.id, item.version, organizationId); await Promise.all([track('shown', name, item.version, source), track('started', name, item.version, source)]); const destination = item.startingRoute(context); @@ -210,48 +203,48 @@ } async function onWelcomeStart(): Promise { - if (!(await recordPreference('welcome', WELCOME_VERSION, ProductTourStatus.Completed))) { + if (!(await recordPreference('welcome', welcomeVersion, ProductTourStatus.Completed))) { return; } welcomeHandled = true; - await track('completed', 'welcome', WELCOME_VERSION, 'automatic'); + await track('completed', 'welcome', welcomeVersion, 'automatic'); await startTour(recommended.name, 'automatic'); } async function onWelcomeBrowse(): Promise { - if (!(await recordPreference('welcome', WELCOME_VERSION, ProductTourStatus.Completed))) { + if (!(await recordPreference('welcome', welcomeVersion, ProductTourStatus.Completed))) { return; } welcomeHandled = true; - await track('completed', 'welcome', WELCOME_VERSION, 'automatic'); + await track('completed', 'welcome', welcomeVersion, 'automatic'); openCatalog('catalog'); } async function onWelcomeSkip(): Promise { - if (!(await recordPreference('welcome', WELCOME_VERSION, ProductTourStatus.Dismissed))) { + if (!(await recordPreference('welcome', welcomeVersion, ProductTourStatus.Dismissed))) { return; } welcomeHandled = true; - await track('dismissed', 'welcome', WELCOME_VERSION, 'automatic'); + await track('dismissed', 'welcome', welcomeVersion, 'automatic'); } async function onExieAnnouncementStart(): Promise { - if (!(await recordPreference('exie-announcement', EXIE_ANNOUNCEMENT_VERSION, ProductTourStatus.Completed))) { + if (!(await recordPreference('exie-announcement', exieAnnouncementVersion, ProductTourStatus.Completed))) { return; } - await track('completed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + await track('completed', 'exie-announcement', exieAnnouncementVersion, 'feature-announcement'); await startTour('meet-exie', 'feature-announcement'); } async function onExieAnnouncementDismiss(): Promise { - if (!(await recordPreference('exie-announcement', EXIE_ANNOUNCEMENT_VERSION, ProductTourStatus.Dismissed))) { + if (!(await recordPreference('exie-announcement', exieAnnouncementVersion, ProductTourStatus.Dismissed))) { return; } - await track('dismissed', 'exie-announcement', EXIE_ANNOUNCEMENT_VERSION, 'feature-announcement'); + await track('dismissed', 'exie-announcement', exieAnnouncementVersion, 'feature-announcement'); } - function getItem(name: ProductTourName): ProductTourListItem { - return items.find((item) => item.name === name)!; + function getItem(name: Name): ProductTourListItem { + return items.find((item) => item.name === name)! as ProductTourListItem; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte index 2f80f74774..a00e3de4e7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte @@ -8,6 +8,7 @@ interface Props { checkpoint: ProductTourCheckpoint; + continueLabel?: string; description: string; onDismiss: (checkpoint: ProductTourCheckpoint) => Promise; onNext?: (checkpoint: ProductTourCheckpoint) => Promise | void; @@ -16,7 +17,7 @@ title: string; } - let { checkpoint, description, onDismiss, onNext, side, target, title }: Props = $props(); + let { checkpoint, continueLabel = 'Continue', description, onDismiss, onNext, side, target, title }: Props = $props(); let activeDriver: Driver | undefined; onMount(() => { @@ -38,7 +39,7 @@ element: target, popover: { description, - doneBtnText: 'Continue', + doneBtnText: continueLabel, onNextClick: onNext ? async () => { await onNext(checkpoint); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts index a4a104d26b..a14662905a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts @@ -10,7 +10,8 @@ const checkpoint: ProductTourCheckpoint = { phase: { type: 'active' }, source: 'command-palette', tourName: 'investigate-error', - userId: 'user-id' + userId: 'user-id', + version: 1 }; describe('product tour session', () => { @@ -30,6 +31,7 @@ describe('product tour session', () => { JSON.stringify({ ...checkpoint, tourName: 'unknown-tour' }), JSON.stringify({ ...checkpoint, checkpointName: 'unknown-step' }), JSON.stringify({ ...checkpoint, source: 'unknown-source' }), + JSON.stringify({ ...checkpoint, version: 0 }), JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created' } }), JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created', viewId: 'view-id' } }), JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-loaded', viewId: 'view-id' } }), 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 index 3baa4db065..0b52603cc7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts @@ -1,9 +1,11 @@ +import { ProductTourLaunchSource as ProductTourLaunchSourceContract } from '$generated/api'; + import type { ProductTourCheckpoint, ProductTourLaunchSource, ProductTourName, ProductTourPhase } from './types'; import { PRODUCT_TOUR_CHECKPOINTS } from './types'; const SESSION_KEY = 'exceptionless.product-tour'; -const SOURCES: readonly ProductTourLaunchSource[] = ['automatic', 'catalog', 'command-palette', 'feature-announcement', 'help-menu']; +const SOURCES = new Set(Object.values(ProductTourLaunchSourceContract)); export function clearProductTourSession(storage: Pick = sessionStorage): void { storage.removeItem(SESSION_KEY); @@ -44,7 +46,16 @@ function isPhase(value: unknown, tourName: string, checkpointName: unknown): val } function isProductTourCheckpoint(value: unknown): value is ProductTourCheckpoint { - if (!isRecord(value) || typeof value.userId !== 'string' || !value.userId || typeof value.tourName !== 'string') return false; + if ( + !isRecord(value) || + typeof value.userId !== 'string' || + !value.userId || + typeof value.tourName !== 'string' || + typeof value.version !== 'number' || + !Number.isSafeInteger(value.version) || + value.version < 1 + ) + return false; if (value.organizationId !== undefined && typeof value.organizationId !== 'string') return false; if (!isProductTourLaunchSource(value.source) || !isProductTourName(value.tourName)) return false; @@ -54,7 +65,7 @@ function isProductTourCheckpoint(value: unknown): value is ProductTourCheckpoint } function isProductTourLaunchSource(value: unknown): value is ProductTourLaunchSource { - return typeof value === 'string' && (SOURCES as readonly string[]).includes(value); + return typeof value === 'string' && SOURCES.has(value); } function isProductTourName(value: string): value is ProductTourName { 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 a4abfb8833..d1d4d2f6f7 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 @@ -10,15 +10,30 @@ const checkpoint: ProductTourCheckpoint = { phase: { type: 'active' }, source: 'catalog', tourName: 'ui-overview', - userId: 'user-id' + userId: 'user-id', + version: 1 }; describe('product tour checkpoint store', () => { beforeEach(() => productTourCheckpoint.clear()); it('does not let stale work advance or clear a newer tour', () => { - const first = productTourCheckpoint.start(checkpoint); - const second = productTourCheckpoint.start({ ...checkpoint, source: 'help-menu' }); + const first = productTourCheckpoint.start( + checkpoint.tourName, + checkpoint.checkpointName, + checkpoint.source, + checkpoint.userId, + checkpoint.version, + checkpoint.organizationId + ); + const second = productTourCheckpoint.start( + checkpoint.tourName, + checkpoint.checkpointName, + 'help-menu', + checkpoint.userId, + checkpoint.version, + checkpoint.organizationId + ); expect(productTourCheckpoint.advance(first, 'command-search')).toBeUndefined(); expect(productTourCheckpoint.clear(first)).toBe(false); @@ -26,7 +41,14 @@ describe('product tour checkpoint store', () => { }); it('clears a checkpoint restored for another identity', () => { - productTourCheckpoint.start(checkpoint); + productTourCheckpoint.start( + checkpoint.tourName, + checkpoint.checkpointName, + checkpoint.source, + checkpoint.userId, + checkpoint.version, + checkpoint.organizationId + ); productTourCheckpoint.clear(); sessionStorage.setItem('exceptionless.product-tour', JSON.stringify(checkpoint)); 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 5f67e71c8d..7ba2fcb1d4 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,27 +1,28 @@ -import type { ProductTourCheckpoint, ProductTourCheckpointName, ProductTourPhase } from './types'; +import type { ProductTourCheckpoint, ProductTourCheckpointName, ProductTourLaunchSource, ProductTourName, ProductTourPhase } from './types'; import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; class ProductTourCheckpointStore { current = $state.raw(); - advance( - expected: ProductTourCheckpoint, - checkpointName: ProductTourCheckpointName, - phase: ProductTourPhase = { + advance( + expected: ProductTourCheckpoint, + checkpointName: ProductTourCheckpointName, + phase: ProductTourPhase = { type: 'active' }, organizationId = expected.organizationId - ) { + ): ProductTourCheckpoint | undefined { if (this.current !== expected) { return undefined; } - return this.save({ + const next = { ...expected, checkpointName, organizationId, phase - }); + } as ProductTourCheckpoint; + return this.save(next); } clear(expected?: ProductTourCheckpoint): boolean { @@ -52,11 +53,29 @@ class ProductTourCheckpointStore { return stored; } - start(checkpoint: ProductTourCheckpoint): ProductTourCheckpoint { + start( + tourName: Name, + checkpointName: ProductTourCheckpointName, + source: ProductTourLaunchSource, + userId: string, + version: number, + organizationId?: string + ): ProductTourCheckpoint { + const checkpoint = { + checkpointName, + organizationId, + phase: { + type: 'active' + }, + source, + tourName, + userId, + version + } as ProductTourCheckpoint; return this.save(checkpoint); } - private save(checkpoint: ProductTourCheckpoint): ProductTourCheckpoint { + private save(checkpoint: ProductTourCheckpoint): ProductTourCheckpoint { this.current = checkpoint; writeProductTourSession(checkpoint); return checkpoint; 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 0c8ecc8429..8eeb6df23d 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,8 @@ +import type { ProductTourTelemetryEvent as ProductTourTelemetryEventContract } from '$generated/api'; + import type { ProductTourKey, ProductTourLaunchSource } from './types'; -export type ProductTourTelemetryEvent = 'completed' | 'dismissed' | 'shown' | 'started'; +export type ProductTourTelemetryEvent = `${ProductTourTelemetryEventContract}`; export function buildProductTourTelemetryEvent( event: ProductTourTelemetryEvent, 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 7d438257a7..87d794902b 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 @@ -1,6 +1,7 @@ import type { AssistantAccess } from '$features/assistant/models'; import type { ViewProject } from '$features/projects/models'; import type { ProductTourProgress } from '$features/users/models'; +import type { ProductTourLaunchSource as ProductTourLaunchSourceContract } from '$generated/api'; export const PRODUCT_TOUR_CHECKPOINTS = { 'configure-project': ['organization-name', 'project-name', 'choose-platform', 'sdk-instructions', 'wait-for-event'], @@ -14,15 +15,18 @@ export interface ProductTourAvailability { available: boolean; reason?: string; } -export interface ProductTourCheckpoint { - checkpointName: ProductTourCheckpointName; - organizationId?: string; - phase: ProductTourPhase; - source: ProductTourLaunchSource; - tourName: ProductTourName; - userId: string; -} -export type ProductTourCheckpointName = (typeof PRODUCT_TOUR_CHECKPOINTS)[ProductTourName][number]; +export type ProductTourCheckpoint = Name extends ProductTourName + ? { + checkpointName: ProductTourCheckpointName; + organizationId?: string; + phase: ProductTourPhase; + source: ProductTourLaunchSource; + tourName: Name; + userId: string; + version: number; + } + : never; +export type ProductTourCheckpointName = (typeof PRODUCT_TOUR_CHECKPOINTS)[Name][number]; export interface ProductTourContext { assistantAccess?: AssistantAccess; errorEventAvailability: 'available' | 'empty' | 'error' | 'loading'; @@ -31,26 +35,27 @@ export interface ProductTourContext { pathname: string; projects: Pick[]; } -export interface ProductTourDefinition { +export interface ProductTourDefinition { availability: (context: ProductTourContext) => ProductTourAvailability; description: string; - initialCheckpoint: ProductTourCheckpointName; + initialCheckpoint: ProductTourCheckpointName; keywords: readonly string[]; - name: ProductTourName; + name: Name; startingRoute: (context: ProductTourContext) => string; title: string; - version: number; } export type ProductTourKey = 'exie-announcement' | 'welcome' | ProductTourName; -export type ProductTourLaunchSource = 'automatic' | 'catalog' | 'command-palette' | 'feature-announcement' | 'help-menu'; +export type ProductTourLaunchSource = `${ProductTourLaunchSourceContract}`; -export interface ProductTourListItem extends ProductTourDefinition { +export interface ProductTourListItem extends ProductTourDefinition { currentAvailability: ProductTourAvailability; progress?: ProductTourProgress; + version: number; } export type ProductTourName = keyof typeof PRODUCT_TOUR_CHECKPOINTS; -export type ProductTourPhase = { type: 'active' } | { type: 'saved-view-created' | 'saved-view-loaded'; viewId: string }; +export type ProductTourPhase = + (Name extends 'create-saved-view' ? { type: 'saved-view-created' | 'saved-view-loaded'; viewId: string } : never) | { type: 'active' }; 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 645228ecd3..4c5d0cb627 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,13 +1,10 @@ { - if (nextOpen || !saving) { - open = nextOpen; - if (!nextOpen) { - onCancel?.(); - } + if (!nextOpen) { + onClose(); } }} > - saving && event.preventDefault()} - onInteractOutside={(event) => saving && event.preventDefault()} - > + Save View Save the current view configuration for quick access. - {#if defaultPrivate && tourCheckpointName === 'name-view'} - onTourContinue?.('private-view')} - onDismiss={dismissTour} - title="Review and name your view" - tourName="create-saved-view" - /> - {:else if defaultPrivate && tourCheckpointName === 'private-view'} - onTourContinue?.('save-view')} - onDismiss={dismissTour} - title="Keep it private" - tourName="create-saved-view" - /> - {:else if defaultPrivate && (tourCheckpointName === 'save-view' || tourCheckpointName === 'view-created')} - - {/if} - {#if duplicateView && !pendingCompletion} + {#if duplicateView}
Current filters match "{duplicateView.name}". You can instead, or save with a different name. @@ -222,7 +163,6 @@ aria-describedby={visibleNameError ? 'view-name-error' : undefined} required autofocus - disabled={pendingCompletion} /> {#if visibleNameError}

{visibleNameError}

@@ -238,7 +178,6 @@ aria-invalid={!!visibleSlugError} aria-describedby={visibleSlugError ? 'view-slug-error' : undefined} required - disabled={pendingCompletion} oninput={() => { isSlugDirty = true; }} @@ -250,14 +189,14 @@
- {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 81fbdd3dcf..74ad57afcf 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 @@ -15,9 +15,7 @@ import { serializeFilters } from '$features/events/components/filters/helpers.svelte'; import { getOrganizationQuery, getOrganizationsQuery } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; - import { createProductTourActions } from '$features/product-tours/actions.svelte'; - import ProductTourSpotlight from '$features/product-tours/components/product-tour-spotlight.svelte'; - import { productTourCheckpoint } from '$features/product-tours/state.svelte'; + import CreateSavedViewTour from '$features/product-tours/components/create-saved-view-tour.svelte'; import { supportsColumnWrapping } from '$features/shared/components/data-table/column-meta'; import { getMeQuery } from '$features/users/api.svelte'; import Building2 from '@lucide/svelte/icons/building-2'; @@ -117,23 +115,13 @@ wrappedColumnIds }: Props = $props(); - let isSaveDialogOpenManually = $state(false); + let isSaveDialogOpen = $state(false); let isRenameDialogOpen = $state(false); let isDeleteDialogOpen = $state(false); let isColumnDialogOpen = $state(false); let isMenuOpen = $state(false); let viewToDelete = $state(null); - const tourActions = createProductTourActions(); - const savedViewCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'create-saved-view' ? productTourCheckpoint.current : undefined); - const isSaveDialogOpen = $derived( - isSaveDialogOpenManually || savedViewCheckpoint?.phase.type === 'saved-view-created' || savedViewCheckpoint?.phase.type === 'saved-view-loaded' - ); - const pendingTourView = $derived.by(() => { - const phase = savedViewCheckpoint?.phase; - return phase?.type === 'saved-view-created' || phase?.type === 'saved-view-loaded' - ? savedViews.find((savedView) => savedView.id === phase.viewId) - : undefined; - }); + let createSavedViewTour = $state(); const organizationId = $derived(organization.current); const activeView = $derived(activeSavedView); @@ -232,7 +220,7 @@ async function openSaveDialog() { await tick(); - isSaveDialogOpenManually = true; + isSaveDialogOpen = true; } async function openRenameDialog() { @@ -273,32 +261,8 @@ return; } - const checkpoint = savedViewCheckpoint; - if (checkpoint?.phase.type === 'saved-view-loaded') { - if (await tourActions.complete(checkpoint)) { - isSaveDialogOpenManually = false; - } - return; - } - - if (checkpoint?.phase.type === 'saved-view-created') { - if (!pendingTourView) { - toast.error('The created view could not be loaded. Refresh and try again.'); - return; - } - - try { - await onLoadView(pendingTourView); - const loadedCheckpoint = productTourCheckpoint.advance(checkpoint, 'view-created', { - type: 'saved-view-loaded', - viewId: checkpoint.phase.viewId - }); - if (loadedCheckpoint && (await tourActions.complete(loadedCheckpoint))) { - isSaveDialogOpenManually = false; - } - } catch (error) { - toast.error(getErrorMessage(error, 'Failed to load the created view. Please try again.')); - } + const tour = createSavedViewTour; + if (tour && !tour.validateSave(isPrivate)) { return; } @@ -320,25 +284,9 @@ try { const result = await createMutation.mutateAsync(body); - if (checkpoint) { - const createdCheckpoint = productTourCheckpoint.advance(checkpoint, 'view-created', { - type: 'saved-view-created', - viewId: result.id - }); - await onLoadView(result); - const loadedCheckpoint = createdCheckpoint - ? productTourCheckpoint.advance(createdCheckpoint, 'view-created', { - type: 'saved-view-loaded', - viewId: result.id - }) - : undefined; - if (loadedCheckpoint && (await tourActions.complete(loadedCheckpoint))) { - isSaveDialogOpenManually = false; - } - } else { - isSaveDialogOpenManually = false; - await onLoadView(result); - } + const tourCompletion = tour ? tour.created(result) : onLoadView(result); + isSaveDialogOpen = false; + await tourCompletion; toast.success(`Saved view "${result.name}" created.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); @@ -553,56 +501,25 @@ {#if isSaveDialogOpen} { - if (savedViewCheckpoint) { - await tourActions.dismiss(savedViewCheckpoint); - } - }} {savedViews} {saving} onSave={handleSave} - onClose={() => (isSaveDialogOpenManually = false)} - onTourContinue={(checkpointName) => { - const checkpoint = savedViewCheckpoint; - if (checkpoint) { - productTourCheckpoint.advance(checkpoint, checkpointName); - } - }} - pendingCompletion={savedViewCheckpoint?.phase.type === 'saved-view-created' || savedViewCheckpoint?.phase.type === 'saved-view-loaded'} - tourCheckpointName={savedViewCheckpoint?.checkpointName} + onClose={() => createSavedViewTour?.closed()} {onLoadView} /> {/if} -{#if savedViewCheckpoint?.checkpointName === 'open-view-menu'} - { - isMenuOpen = true; - productTourCheckpoint.advance(checkpoint, 'review-settings'); - }} - target="[data-tour='saved-view-trigger']" - title="Open View settings" - /> -{:else if savedViewCheckpoint?.checkpointName === 'review-settings'} - { - isMenuOpen = false; - isSaveDialogOpenManually = true; - productTourCheckpoint.advance(checkpoint, 'name-view'); - }} - target="[data-tour='saved-view-settings']" - title="Configure what the view remembers" - /> -{/if} + (isMenuOpen = false)} + openMenu={() => (isMenuOpen = true)} + openSaveDialog={() => (isSaveDialogOpen = true)} + {onLoadView} + {savedViews} +/> {#if isRenameDialogOpen && activeView} ; + product_tour_versions: 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 6730b7b830..1d64508108 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -27,7 +27,20 @@ export const StackStatusSchema = zodEnum([ "ignored", "discarded", ]); +export const ProductTourTelemetryEventSchema = zodEnum([ + "completed", + "dismissed", + "shown", + "started", +]); export const ProductTourStatusSchema = zodEnum(["completed", "dismissed"]); +export const ProductTourLaunchSourceSchema = zodEnum([ + "automatic", + "catalog", + "command-palette", + "feature-announcement", + "help-menu", +]); export const BillingStatusSchema = union([ literal(0), literal(1), @@ -78,8 +91,8 @@ export type AdminAssistantUsageResponseFormData = Infer< export const AdminProductTourActivitySchema = object({ date_utc: iso.datetime(), - event: string().min(1, "Event is required"), - launch_source: string().min(1, "Launch source is required"), + event: ProductTourTelemetryEventSchema, + launch_source: ProductTourLaunchSourceSchema, tour_name: string().min(1, "Tour name is required"), user_identity: string().min(1, "User identity is required").nullable(), user_name: string().min(1, "User name is required").nullable(), @@ -965,6 +978,7 @@ export const ViewCurrentUserSchema = object({ string(), lazy(() => ProductTourProgressSchema), ), + product_tour_versions: record(string(), number()).optional(), 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)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 31b0e14b6f..106a3964c9 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -719,6 +719,7 @@ pathname: page.url.pathname, projects }, + meQuery.data?.product_tour_versions ?? {}, meQuery.data?.product_tours ) : [] diff --git a/src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs b/src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs index 04b1b616f8..475360e1ed 100644 --- a/src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs +++ b/src/Exceptionless.Web/Models/Admin/AdminProductTourUsageResponse.cs @@ -1,3 +1,5 @@ +using Exceptionless.Core.Models.Data; + namespace Exceptionless.Web.Models.Admin; public sealed record AdminProductTourUsageResponse( @@ -18,8 +20,8 @@ public sealed record AdminProductTourSummary( public sealed record AdminProductTourActivity( DateTime DateUtc, - string Event, - string LaunchSource, + ProductTourTelemetryEvent Event, + ProductTourLaunchSource LaunchSource, string TourName, string? UserIdentity, string? UserName, diff --git a/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs b/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs index 6e42c21ab6..59b483518a 100644 --- a/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs +++ b/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs @@ -32,6 +32,7 @@ public ViewCurrentUser(User user, IntercomOptions options) public ICollection OAuthAccounts { get; set; } public ICollection OrganizationPreferences { get; set; } public IDictionary ProductTours { get; set; } = new Dictionary(StringComparer.Ordinal); + public IReadOnlyDictionary ProductTourVersions { get; } = Exceptionless.Core.Models.Data.ProductTours.Versions; private static string? HMACSHA256HashString(string value, IntercomOptions options) { diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 645546ae9b..d9feb4e4ff 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -11736,10 +11736,10 @@ "format": "date-time" }, "event": { - "type": "string" + "$ref": "#/components/schemas/ProductTourTelemetryEvent" }, "launch_source": { - "type": "string" + "$ref": "#/components/schemas/ProductTourLaunchSource" }, "tour_name": { "type": "string" @@ -13384,6 +13384,22 @@ } } }, + "ProductTourLaunchSource": { + "enum": [ + "automatic", + "catalog", + "command-palette", + "feature-announcement", + "help-menu" + ], + "x-enumNames": [ + "Automatic", + "Catalog", + "CommandPalette", + "FeatureAnnouncement", + "HelpMenu" + ] + }, "ProductTourProgress": { "required": [ "status", @@ -13415,6 +13431,20 @@ "Dismissed" ] }, + "ProductTourTelemetryEvent": { + "enum": [ + "completed", + "dismissed", + "shown", + "started" + ], + "x-enumNames": [ + "Completed", + "Dismissed", + "Shown", + "Started" + ] + }, "ResetPasswordModel": { "required": [ "password_reset_token", @@ -14272,6 +14302,14 @@ "$ref": "#/components/schemas/ProductTourProgress" } }, + "product_tour_versions": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + }, + "readOnly": true + }, "id": { "maxLength": 24, "minLength": 24, @@ -15112,4 +15150,4 @@ "name": "Source Map" } ] -} +} \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs index e68ddc0fb9..4b579de86a 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories; @@ -45,6 +46,20 @@ public async Task UpdateCurrentUserProductTourAsync_NewProgress_PersistsAndRetur Assert.Equal(progress, persistedUser.ProductTours["ui-overview"]); } + [Fact] + public async Task GetCurrentUserAsync_ReturnsAuthoritativeProductTourVersions() + { + var currentUser = await SendRequestAsAsync(request => request + .AsTestOrganizationUser() + .AppendPaths("users", "me") + .StatusCodeShouldBeOk()); + + var versions = currentUser.GetProperty("product_tour_versions") + .Deserialize>(); + + Assert.Equal(ProductTours.Versions, versions); + } + [Fact] public async Task UpdateCurrentUserProductTourAsync_OlderProgress_PreservesStoredValue() { diff --git a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs index dd037d56b7..196877c9d5 100644 --- a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs @@ -64,14 +64,14 @@ await CreateDataAsync(builder => .UserIdentity("user-7"); }); - var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, month, month.AddMonths(1)); + var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, month, month.AddMonths(1), recentLimit: 3); - Assert.Equal(5, result.RecentEvents.Count); + Assert.Equal(3, result.RecentEvents.Count); Assert.Equal(2, result.Tours.Count); var overview = Assert.Single(result.Tours, tour => String.Equals(tour.Name, ProductTours.UiOverview, StringComparison.Ordinal)); Assert.Equal(3, overview.UniqueUsers); - Assert.Equal(4, overview.Buckets.Where(bucket => String.Equals(bucket.Source.Event, "started", StringComparison.Ordinal)).Sum(bucket => bucket.Count)); - Assert.Equal(1, overview.Buckets.Where(bucket => String.Equals(bucket.Source.Event, "completed", StringComparison.Ordinal)).Sum(bucket => bucket.Count)); + Assert.Equal(4, overview.Buckets.Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Started).Sum(bucket => bucket.Count)); + Assert.Equal(1, overview.Buckets.Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Completed).Sum(bucket => bucket.Count)); Assert.Equal(month.AddDays(8), overview.Buckets.Max(bucket => bucket.LastUtc)); var welcome = Assert.Single(result.Tours, tour => String.Equals(tour.Name, ProductTours.Welcome, StringComparison.Ordinal));