diff --git a/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs b/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs new file mode 100644 index 0000000000..33b1ca5ea4 --- /dev/null +++ b/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs @@ -0,0 +1,13 @@ +namespace Exceptionless.Core.Models.Data; + +public record ProductTourProgress +{ + public ProductTourStatus Status { get; set; } + public int Version { get; set; } +} + +public enum ProductTourStatus +{ + Completed = 1, + Dismissed = 2 +} diff --git a/src/Exceptionless.Core/Models/Data/ProductTours.cs b/src/Exceptionless.Core/Models/Data/ProductTours.cs new file mode 100644 index 0000000000..532fbba990 --- /dev/null +++ b/src/Exceptionless.Core/Models/Data/ProductTours.cs @@ -0,0 +1,103 @@ +using System.Collections.Frozen; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Exceptionless.Core.Models.Data; + +public static class ProductTours +{ + public const string AppOverview = "app-overview"; + public const string AppWelcome = "app-welcome"; + public const string ExieAnnouncement = "exie-announcement"; + public const string ExieOverview = "exie-overview"; + public const string EventInvestigate = "event-investigate"; + public const string ProjectConfigure = "project-configure"; + public const string SavedViewCreate = "saved-view-create"; + + public static FrozenDictionary Definitions { get; } = new[] + { + new ProductTourDefinition(AppOverview, 1, ProductTourKind.Guide), + new ProductTourDefinition(AppWelcome, 1, ProductTourKind.Prompt), + new ProductTourDefinition(ExieAnnouncement, 1, ProductTourKind.Prompt), + new ProductTourDefinition(ExieOverview, 1, ProductTourKind.Guide), + new ProductTourDefinition(EventInvestigate, 1, ProductTourKind.Guide), + new ProductTourDefinition(ProjectConfigure, 1, ProductTourKind.Guide), + new ProductTourDefinition(SavedViewCreate, 1, ProductTourKind.Guide) + }.ToFrozenDictionary(definition => definition.Name, StringComparer.Ordinal); + + public static bool IsKnown(string name) => Definitions.ContainsKey(name); + + public static bool IsValid(string name, int version) + { + return Definitions.TryGetValue(name, out var definition) && version > 0 && version <= definition.CurrentVersion; + } + + public static string CreateTelemetrySource( + ProductTourTelemetryEvent telemetryEvent, + string tourName, + int version, + ProductTourLaunchSource launchSource) + { + return $"product-tour.{GetTelemetryName(telemetryEvent)}.{tourName}.v{version}.{GetLaunchSourceName(launchSource)}"; + } + + private static string GetTelemetryName(ProductTourTelemetryEvent telemetryEvent) => telemetryEvent switch + { + ProductTourTelemetryEvent.Completed => "completed", + ProductTourTelemetryEvent.Dismissed => "dismissed", + ProductTourTelemetryEvent.Shown => "shown", + ProductTourTelemetryEvent.Started => "started", + _ => throw new ArgumentOutOfRangeException(nameof(telemetryEvent), telemetryEvent, "Unknown product tour telemetry event.") + }; + + private static string GetLaunchSourceName(ProductTourLaunchSource launchSource) => launchSource switch + { + ProductTourLaunchSource.Welcome => "welcome", + ProductTourLaunchSource.Catalog => "catalog", + ProductTourLaunchSource.CommandPalette => "command-palette", + ProductTourLaunchSource.FeatureAnnouncement => "feature-announcement", + ProductTourLaunchSource.HelpMenu => "help-menu", + _ => throw new ArgumentOutOfRangeException(nameof(launchSource), launchSource, "Unknown product tour launch source.") + }; +} + +public sealed record ProductTourDefinition(string Name, int CurrentVersion, ProductTourKind Kind); + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ProductTourKind +{ + [JsonStringEnumMemberName("guide")] + [EnumMember(Value = "guide")] + Guide, + [JsonStringEnumMemberName("prompt")] + [EnumMember(Value = "prompt")] + Prompt +} + +public enum ProductTourTelemetryEvent +{ + Completed, + Dismissed, + Shown, + Started +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ProductTourLaunchSource +{ + [JsonStringEnumMemberName("welcome")] + [EnumMember(Value = "welcome")] + Welcome, + [JsonStringEnumMemberName("catalog")] + [EnumMember(Value = "catalog")] + Catalog, + [JsonStringEnumMemberName("command-palette")] + [EnumMember(Value = "command-palette")] + CommandPalette, + [JsonStringEnumMemberName("feature-announcement")] + [EnumMember(Value = "feature-announcement")] + FeatureAnnouncement, + [JsonStringEnumMemberName("help-menu")] + [EnumMember(Value = "help-menu")] + HelpMenu +} diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs index 8168c0302e..d11525d864 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; @@ -25,6 +26,7 @@ public record User : IIdentity, IHaveDates, IValidatableObject public ICollection OAuthAccounts { get; init; } = new Collection(); public ICollection OrganizationPreferences { get; init; } = new Collection(); public ICollection SavedViewOrders { 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.Core/Repositories/EventRepository.cs b/src/Exceptionless.Core/Repositories/EventRepository.cs index 3fba37a69d..2699e9ee10 100644 --- a/src/Exceptionless.Core/Repositories/EventRepository.cs +++ b/src/Exceptionless.Core/Repositories/EventRepository.cs @@ -1,10 +1,12 @@ using Elastic.Clients.Elasticsearch.QueryDsl; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories.Configuration; using Exceptionless.Core.Repositories.Queries; using Exceptionless.Core.Validation; using Exceptionless.DateTimeExtensions; using Foundatio.Repositories; +using Foundatio.Repositories.Elasticsearch.Extensions; using Foundatio.Repositories.Models; namespace Exceptionless.Core.Repositories; @@ -82,6 +84,85 @@ public Task> GetByReferenceIdAsync(string projectId return FindAsync(q => q.Project(projectId).FieldEquals(e => e.ReferenceId, referenceId).SortDescending(e => e.Date), o => o.PageLimit(10)); } + public async Task GetProductTourUsageAsync(string projectId, DateTime? utcStart, DateTime utcEnd) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); + if (utcStart.HasValue && utcEnd <= utcStart) + throw new ArgumentOutOfRangeException(nameof(utcEnd), "The end date must be later than the start date."); + + var sourcesByName = ProductTours.Definitions.Values + .SelectMany(definition => CreateProductTourSources(definition.Name, definition.CurrentVersion)) + .ToDictionary(source => source.Raw, StringComparer.Ordinal); + string[] allSources = sourcesByName.Keys.ToArray(); + string sourceField = InferField(ev => ev.Source); + string countField = InferField(ev => ev.Count); + string dateField = InferField(ev => ev.Date); + if (!utcStart.HasValue) + { + DateTime? retainedStart = _options.MaximumRetentionDays > 0 + ? _timeProvider.GetUtcNow().UtcDateTime.SubtractDays(_options.MaximumRetentionDays) + : null; + var bounds = await CountAsync(query => ApplyProductTourUsageFilter(query, projectId, retainedStart, utcEnd, allSources) + .AggregationsExpression($"min:{dateField}")); + utcStart = bounds.Aggregations.Min($"min_{dateField}")?.Value; + if (!utcStart.HasValue) + { + return new ProductTourUsageResult([]); + } + } + + var aggregation = await CountAsync(query => ApplyProductTourUsageFilter(query, projectId, utcStart, utcEnd, allSources) + .AggregationsExpression($"terms:({sourceField}~{allSources.Length} sum:{countField}~1 max:{dateField} date:({dateField} sum:{countField}~1))")); + + var sourceBuckets = aggregation.Aggregations.Terms($"terms_{sourceField}")?.Buckets ?? []; + var usage = sourceBuckets + .Select(bucket => sourcesByName.TryGetValue(bucket.Key, out var source) + ? new ProductTourUsageBucket( + source, + Convert.ToInt64(bucket.Aggregations.Sum($"sum_{countField}")?.Value ?? bucket.Total.GetValueOrDefault()), + bucket.Aggregations.Max($"max_{dateField}")?.Value, + (bucket.Aggregations.DateHistogram($"date_{dateField}")?.Buckets ?? []) + .Where(period => period.Date < utcEnd) + .Select(period => new ProductTourUsagePeriod(period.Date, Convert.ToInt64(period.Aggregations.Sum($"sum_{countField}")?.Value ?? period.Total.GetValueOrDefault()))) + .ToArray()) + : null) + .OfType() + .ToArray(); + return new ProductTourUsageResult(usage); + } + + private static IRepositoryQuery ApplyProductTourUsageFilter( + IRepositoryQuery query, + string projectId, + DateTime? utcStart, + DateTime utcEnd, + string[] sources) + { + query = query + .Project(projectId) + .FieldEquals(ev => ev.Type, Event.KnownTypes.FeatureUsage) + .FieldEquals(ev => ev.Source, sources) + .FieldLessThan(ev => ev.Date, utcEnd); + + if (utcStart.HasValue) + return query.DateRange(utcStart, utcEnd, (PersistentEvent ev) => ev.Date).Index(utcStart, utcEnd); + + return query.DateRange(null, utcEnd, (PersistentEvent ev) => ev.Date); + } + + private static ProductTourUsageSource[] CreateProductTourSources(string tourName, int currentVersion) + { + return Enumerable.Range(1, currentVersion) + .SelectMany(version => Enum.GetValues().SelectMany(telemetryEvent => Enum.GetValues().Select(launchSource => + new ProductTourUsageSource( + ProductTours.CreateTelemetrySource(telemetryEvent, tourName, version, launchSource), + telemetryEvent, + tourName, + version, + launchSource)))) + .ToArray(); + } + public async Task GetPreviousAndNextEventIdsAsync(PersistentEvent ev, AppFilter? systemFilter = null, DateTime? utcStart = null, DateTime? utcEnd = null) { var previous = GetPreviousEventIdAsync(ev, systemFilter, utcStart, utcEnd); diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs index c7c2272cbc..03731764c5 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs @@ -13,6 +13,7 @@ public interface IEventRepository : IRepositoryOwnedByOrganizationAndProject UpdateSessionStartLastActivityAsync(string id, DateTime lastActivityUtc, bool isSessionEnd = false, bool hasError = false, bool sendNotifications = true); Task RemoveAllAsync(string organizationId, string? clientIpAddress, DateTime? utcStart, DateTime? utcEnd, CommandOptionsDescriptor? options = null); Task RemoveAllByStackIdsAsync(string[] stackIds); + Task GetProductTourUsageAsync(string projectId, DateTime? utcStart, DateTime utcEnd); } public static class EventRepositoryExtensions diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs index 4835daa616..6c5f355915 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Foundatio.Repositories; using Foundatio.Repositories.Models; @@ -12,4 +13,5 @@ public interface IUserRepository : ISearchableRepository Task GetUserByOAuthProviderAsync(string provider, string providerUserId); Task GetByVerifyEmailAddressTokenAsync(string token); Task> GetByOrganizationIdAsync(string organizationId, CommandOptionsDescriptor? options = null); + Task UpdateProductTourProgressAsync(string userId, string tourName, ProductTourProgress progress); } diff --git a/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs b/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs new file mode 100644 index 0000000000..3d774ed916 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs @@ -0,0 +1,16 @@ +using Exceptionless.Core.Models.Data; + +namespace Exceptionless.Core.Repositories; + +public sealed record ProductTourUsageResult(IReadOnlyCollection Buckets); + +public sealed record ProductTourUsageBucket(ProductTourUsageSource Source, long Count, DateTime? LastUtc, IReadOnlyCollection Activity); + +public sealed record ProductTourUsagePeriod(DateTime DateUtc, long Count); + +public sealed record ProductTourUsageSource( + string Raw, + ProductTourTelemetryEvent Event, + string TourName, + int Version, + ProductTourLaunchSource LaunchSource); diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index 91919a6beb..d24eac6859 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -1,7 +1,9 @@ using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories.Configuration; using Exceptionless.Core.Validation; using Foundatio.Repositories; +using Foundatio.Repositories.Exceptions; using Foundatio.Repositories.Models; using Foundatio.Repositories.Options; using User = Exceptionless.Core.Models.User; @@ -78,6 +80,41 @@ public Task> GetByOrganizationIdAsync(string organizationId, C return FindAsync(q => q.FieldEquals(u => u.OrganizationIds, organizationId).SortAscending(u => u.EmailAddress), o => commandOptions); } + public async Task UpdateProductTourProgressAsync(string userId, string tourName, ProductTourProgress progress) + { + const string script = """ + if (ctx._source.product_tours == null) { + ctx._source.product_tours = [:]; + } + + def current = ctx._source.product_tours[params.tourName]; + if (current != null && (current.version > params.version || + (current.version == params.version && (current.status == params.completedStatus || current.status == params.status)))) { + ctx.op = 'none'; + } else { + ctx._source.product_tours[params.tourName] = ['status': params.status, 'version': params.version]; + } + """; + var patch = new ScriptPatch(script.TrimScript()) + { + Params = new Dictionary + { + ["completedStatus"] = (int)ProductTourStatus.Completed, + ["status"] = (int)progress.Status, + ["tourName"] = tourName, + ["version"] = progress.Version + } + }; + + await PatchAsync(userId, patch, options => options.Cache()); + + var user = await GetByIdAsync(userId, options => options.Cache(false)); + if (user is null || !user.ProductTours.TryGetValue(tourName, out var storedProgress)) + throw new DocumentNotFoundException(userId); + + return storedProgress; + } + protected override async Task AddDocumentsToCacheAsync(ICollection> findHits, ICommandOptions options, bool isDirtyRead) { await base.AddDocumentsToCacheAsync(findHits, options, isDirtyRead); diff --git a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs index 7d6bbbbfe4..ba509c9587 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs @@ -87,6 +87,15 @@ 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) + .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity) + .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 +139,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? start = null, + DateTime? end = null) + => (await mediator.InvokeAsync>(new GetAdminProductTourUsage(start, end))).ToHttpResult(resultMapper); } diff --git a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs index 44ce3acd29..96f0464734 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") + .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..6bd8acc8a3 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -3,10 +3,12 @@ using Exceptionless.Core.Extensions; using Exceptionless.Core.Messaging.Models; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Models.WorkItems; using Exceptionless.Core.Queues.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Services; using Exceptionless.Core.Utility; using Exceptionless.DateTimeExtensions; using Exceptionless.Web.Api.Messages; @@ -17,6 +19,7 @@ using Foundatio.Queues; using Foundatio.Repositories; using Foundatio.Repositories.Migrations; +using Foundatio.Repositories.Models; using Foundatio.Storage; using Foundatio.Mediator; @@ -38,6 +41,7 @@ public class AdminHandler( BillingPlans plans, IMigrationStateRepository migrationStateRepository, SampleDataService sampleDataService, + UsageService usageService, TimeProvider timeProvider, ILoggerFactory loggerFactory) { @@ -134,6 +138,71 @@ public async Task> Handle(GetAdminAssistantUsage message) rows.Take(limit).ToArray()); } + public async Task> Handle(GetAdminProductTourUsage message) + { + DateTime utcEnd = message.End?.ToUniversalTime() ?? timeProvider.GetUtcNow().UtcDateTime; + DateTime? utcStart = message.Start?.ToUniversalTime(); + if (utcStart.HasValue && utcStart >= utcEnd) + { + return Result.Invalid(ValidationError.Create("start", "Start must be earlier than end.")); + } + + var project = await projectRepository.GetByIdAsync(appOptions.InternalProjectId, options => options.Cache()); + var organization = project is { IsDeleted: false } + ? await organizationRepository.GetByIdAsync(project.OrganizationId, options => options.Cache()) + : null; + bool collectionAvailable = organization is { IsDeleted: false } && await usageService.GetEventsLeftAsync(organization.Id) > 0; + var usage = await eventRepository.GetProductTourUsageAsync(appOptions.InternalProjectId, utcStart, utcEnd); + var bucketsByTour = usage.Buckets + .GroupBy(bucket => (bucket.Source.TourName, bucket.Source.Version)) + .ToDictionary(group => group.Key); + var tours = ProductTours.Definitions.Values + .SelectMany(definition => Enumerable.Range(1, definition.CurrentVersion).Select(version => + { + IEnumerable buckets = bucketsByTour.TryGetValue((definition.Name, version), out var matchingBuckets) + ? matchingBuckets + : []; + + return new ProductTourSummary( + definition.Name, + version, + definition.Kind, + SumEvent(buckets, ProductTourTelemetryEvent.Shown), + SumEvent(buckets, ProductTourTelemetryEvent.Started), + SumEvent(buckets, ProductTourTelemetryEvent.Completed), + SumEvent(buckets, ProductTourTelemetryEvent.Dismissed), + buckets.Select(bucket => bucket.LastUtc).Max(), + buckets + .Where(bucket => bucket.Source.Event is ProductTourTelemetryEvent.Started) + .GroupBy(bucket => bucket.Source.LaunchSource) + .Select(group => new ProductTourStartSource(group.Key, group.Sum(bucket => bucket.Count))) + .OrderBy(source => source.Source) + .ToArray(), + buckets + .SelectMany(bucket => bucket.Activity.Select(period => (period.DateUtc, period.Count, bucket.Source.Event))) + .GroupBy(period => period.DateUtc) + .OrderBy(group => group.Key) + .Select(group => new ProductTourActivity( + group.Key, + group.Where(period => period.Event == ProductTourTelemetryEvent.Shown).Sum(period => period.Count), + group.Where(period => period.Event == ProductTourTelemetryEvent.Started).Sum(period => period.Count), + group.Where(period => period.Event == ProductTourTelemetryEvent.Completed).Sum(period => period.Count), + group.Where(period => period.Event == ProductTourTelemetryEvent.Dismissed).Sum(period => period.Count))) + .ToArray()); + })) + .OrderBy(tour => tour.Name, StringComparer.Ordinal) + .ThenBy(tour => tour.Version) + .ToArray(); + + return new ProductTourUsageResponse( + !message.Start.HasValue ? usage.Buckets.SelectMany(bucket => bucket.Activity).Where(period => period.Count > 0).Select(period => (DateTime?)period.DateUtc).Min() : utcStart, + utcEnd, + tours) + { + CollectionAvailable = collectionAvailable + }; + } + [HandlerEndpoint(HandlerMethod.Get, "migrations", Group = "Admin")] public async Task> Handle(GetAdminMigrations message) { @@ -172,6 +241,11 @@ public Task> Handle(GetAdminEcho message) }); } + private static long SumEvent(IEnumerable buckets, ProductTourTelemetryEvent telemetryEvent) + { + return buckets.Where(bucket => bucket.Source.Event == telemetryEvent).Sum(bucket => bucket.Count); + } + [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 8a517f525d..18656626b6 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.Exceptions; namespace Exceptionless.Web.Api.Handlers; @@ -49,6 +51,32 @@ public async Task> Handle(GetCurrentUser message) }; } + public async Task> Handle(UpdateCurrentUserProductTour message) + { + if (!ProductTours.IsKnown(message.TourName)) + return Result.Invalid(ValidationError.Create("tour_name", "Unknown product tour.")); + + if (!ProductTours.IsValid(message.TourName, message.Progress.Version)) + return Result.Invalid(ValidationError.Create("version", "The product tour version is not supported.")); + + try + { + var progress = await repository.UpdateProductTourProgressAsync( + GetCurrentUserId(), + message.TourName, + new ProductTourProgress + { + Status = message.Progress.Status!.Value, + Version = message.Progress.Version + }); + return progress; + } + catch (DocumentNotFoundException) + { + return Result.NotFound("User not found."); + } + } + 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..9e548dee9d 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? Start, DateTime? End); 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/.gitignore b/src/Exceptionless.Web/ClientApp/.gitignore index 246cecff7e..e21726351b 100644 --- a/src/Exceptionless.Web/ClientApp/.gitignore +++ b/src/Exceptionless.Web/ClientApp/.gitignore @@ -1,4 +1,5 @@ test-results +playwright-report node_modules # Output diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index 8b3f0f6eaf..2397c48f3b 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -22,6 +22,7 @@ export interface E2EOrganization { export interface E2EProject { id: string; + is_configured?: boolean; name: string; organization_id?: string; } @@ -285,10 +286,11 @@ export class E2EApiClient { throw new Error(`Timed out waiting for ${path} email sent to ${email}`); } - async signup(name: string, email: string, password: string): Promise { + async signup(name: string, email: string, password: string, inviteToken?: string): Promise { const response = await this.request.post(this.url('auth/signup'), { data: { email, + invite_token: inviteToken, name, password } @@ -309,6 +311,15 @@ export class E2EApiClient { await expectStatus(response, [202], 'submit event'); } + async updateProductTour(token: string, tourName: string, version: number, status: 1 | 2): 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)), @@ -317,6 +328,32 @@ export class E2EApiClient { ); } + async waitForInvitationListed(token: string, organizationId: string, inviteToken: string): Promise { + await waitForCondition( + async () => { + const response = await this.request.get(this.url('organizations'), { + headers: this.authHeaders(token), + params: { filter: `id:${organizationId}` } + }); + await expectStatus(response, [200], 'find indexed invitation'); + const organizations = await readJson(response); + return ( + Array.isArray(organizations) && + organizations.some((value) => { + const organization = toRecord(value, 'organization'); + return ( + organization.id === organizationId && + Array.isArray(organization.invites) && + organization.invites.some((invite) => toRecord(invite, 'invitation').token === inviteToken) + ); + }) + ); + }, + 30_000, + 'Timed out waiting for the test invitation to be indexed' + ); + } + async waitForOrganizationDeleted(token: string, organizationId: string, timeoutMs = 30_000): Promise { await waitForCondition( async () => !(await this.getOrganization(token, organizationId)), @@ -460,6 +497,7 @@ function toProject(value: unknown): E2EProject { return { id: getRequiredString(record, 'id', 'project response'), + is_configured: typeof record.is_configured === 'boolean' ? record.is_configured : undefined, name: getRequiredString(record, 'name', 'project response'), organization_id: getOptionalString(record, 'organization_id') }; diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts index 154144230d..83f3a7265f 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -43,10 +43,13 @@ export interface E2ESecondaryProject { interface E2EFixtures { e2eApi: E2EApiClient; e2eCleanupPassword: string; + e2eDismissProductTourWelcome: boolean; e2eScenario: E2EScenario; e2eSecondaryOrganization: E2ESecondaryOrganization; e2eSecondaryProject: E2ESecondaryProject; e2eUseGeneratedUser: boolean; + e2eUseInvitedUser: boolean; + e2eUserInvitation: undefined | { organizationId: string; token: string }; effectDepthGuard: void; } @@ -57,7 +60,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, e2eUserInvitation, page }, use, testInfo) => { const run = createRunName(e2eApi.environment.runId, testInfo); const userName = `Playwright User ${run}`; const email = `playwright-${run}@exceptionless.test`.toLowerCase(); @@ -72,19 +77,26 @@ export const test = base.extend({ let generatedUserSignupAttempted = false; try { - if (!e2eUseGeneratedUser && !e2eApi.environment.isProduction && e2eApi.environment.email && e2eApi.environment.password) { + if (!e2eUseGeneratedUser && !e2eUserInvitation && !e2eApi.environment.isProduction && e2eApi.environment.email && e2eApi.environment.password) { userToken = await e2eApi.login(); } else { generatedUserSignupAttempted = true; - userToken = await e2eApi.signup(userName, email, E2E_TEST_PASSWORD); + userToken = await e2eApi.signup(userName, email, E2E_TEST_PASSWORD, e2eUserInvitation?.token); createdUser = true; } const organization = await e2eApi.createOrganization(userToken, organizationName); organizationId = organization.id; + if (e2eUserInvitation) { + await e2eApi.deleteOrganizationUser(userToken, e2eUserInvitation.organizationId, email); + await e2eApi.waitForOrganizationNotListed(userToken, e2eUserInvitation.organizationId); + } 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, 'app-welcome', 1, 2); + } await page.addInitScript( ({ organizationId, token }) => { @@ -232,6 +244,32 @@ export const test = base.extend({ e2eUseGeneratedUser: [false, { option: true }], + e2eUseInvitedUser: [false, { option: true }], + + e2eUserInvitation: async ({ e2eApi, e2eUseInvitedUser }, use, testInfo) => { + if (!e2eUseInvitedUser) { + await use(undefined); + return; + } + if (e2eApi.environment.isProduction) { + throw new Error('Invited test users require local Mailpit.'); + } + + const run = createRunName(e2eApi.environment.runId, testInfo); + const email = `playwright-${run}@exceptionless.test`.toLowerCase(); + const ownerToken = await e2eApi.login(); + const organization = await e2eApi.createOrganization(ownerToken, `${E2E_ORGANIZATION_NAME_PREFIX} Invitations ${run}`); + try { + await e2eApi.inviteOrganizationUser(ownerToken, organization.id, email); + const inviteToken = await e2eApi.pollForMailToken(email, 'signup'); + await e2eApi.waitForInvitationListed(ownerToken, organization.id, inviteToken); + await use({ organizationId: organization.id, token: inviteToken }); + } finally { + await e2eApi.deleteOrganization(ownerToken, organization.id); + await e2eApi.waitForOrganizationDeleted(ownerToken, organization.id); + } + }, + effectDepthGuard: [ async ({ page }, use) => { const errors = new Set(); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts index 729f62728e..b1383e69d5 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts @@ -4,6 +4,7 @@ import { expect, test } from '../fixtures/e2e-test'; test('dashboard charts stay mounted while list data refreshes', async ({ e2eApi, page }) => { const userToken = await e2eApi.login(); + await e2eApi.updateProductTour(userToken, 'app-welcome', 1, 2); const organizations = await e2eApi.getOrganizations(userToken); const organizationId = organizations[0]?.id; expect(organizationId).toBeTruthy(); @@ -16,9 +17,9 @@ test('dashboard charts stay mounted while list data refreshes', async ({ e2eApi, { organizationId, token: userToken } ); - await verifyChartRefresh(page, '/next/stack', (route) => isOrganizationEventListRequest(route, organizationId!, 'stack_frequent')); - await verifyChartRefresh(page, '/next/event', (route) => isOrganizationEventListRequest(route, organizationId!, 'summary')); - await verifyChartRefresh(page, '/next/sessions', (route) => { + await verifyChartRefresh(page, '/next/stack/all', (route) => isOrganizationEventListRequest(route, organizationId!, 'stack_frequent')); + await verifyChartRefresh(page, '/next/event/all', (route) => isOrganizationEventListRequest(route, organizationId!, 'summary')); + await verifyChartRefresh(page, '/next/sessions/all', (route) => { return new URL(route.request().url()).pathname === `/api/v2/organizations/${organizationId}/events/sessions`; }); }); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts index 731f23a2d1..76b84be994 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts @@ -3,38 +3,45 @@ import { E2E_TEST_PASSWORD, expect, test } from '../fixtures/e2e-test'; const RESET_PASSWORD = `${E2E_TEST_PASSWORD}-reset`; test.skip(process.env.E2E_ENV === 'production', 'Password recovery requires local Mailpit.'); -test.use({ e2eCleanupPassword: RESET_PASSWORD, e2eUseGeneratedUser: true }); - -test('user can reset a forgotten password and log in @signup', async ({ e2eApi, e2eScenario, page }) => { - await test.step('request a password reset through the UI', async () => { - await page.goto('/next/forgot-password'); - await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); - await page.getByRole('button', { name: 'Send Reset Email' }).click(); - - await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); - await expect(page.getByText('Please check your inbox for the password reset email.')).toBeVisible(); - }); - - const resetToken = await test.step('read the reset link from local mail', async () => { - return await e2eApi.pollForMailToken(e2eScenario.email, 'reset-password'); - }); - - await test.step('change the password through the emailed route', async () => { - await page.goto(`/next/reset-password/${encodeURIComponent(resetToken)}`); - await page.getByLabel('New Password', { exact: true }).fill(RESET_PASSWORD); - await page.getByLabel('Confirm Password', { exact: true }).fill(RESET_PASSWORD); - await page.getByRole('button', { name: 'Change Password' }).click(); - - await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); - await expect(page.getByText('You have successfully changed your password.')).toBeVisible(); - }); - - await test.step('log in with the new password', async () => { - await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); - await page.getByPlaceholder('Enter password').fill(RESET_PASSWORD); - await page.getByRole('button', { exact: true, name: 'Login' }).click(); - - await expect(page.getByRole('heading', { name: 'All' })).toBeVisible({ timeout: 30_000 }); - await expect(page).toHaveURL(/\/next\/stack\/all(?:[?#]|$)/); - }); +test.use({ e2eCleanupPassword: RESET_PASSWORD, e2eUseInvitedUser: true }); + +test('user can reset a forgotten password and log in @signup', async ({ browser, e2eApi, e2eScenario }) => { + const recoveryContext = await browser.newContext({ baseURL: e2eApi.environment.appUrl, ignoreHTTPSErrors: true }); + const page = await recoveryContext.newPage(); + + try { + await test.step('request a password reset through the UI', async () => { + await page.goto('/next/forgot-password'); + await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); + await page.getByRole('button', { name: 'Send Reset Email' }).click(); + + await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); + await expect(page.getByText('Please check your inbox for the password reset email.')).toBeVisible(); + }); + + const resetToken = await test.step('read the reset link from local mail', async () => { + return await e2eApi.pollForMailToken(e2eScenario.email, 'reset-password'); + }); + + await test.step('change the password through the emailed route', async () => { + await page.goto(`/next/reset-password/${encodeURIComponent(resetToken)}`); + await page.getByLabel('New Password', { exact: true }).fill(RESET_PASSWORD); + await page.getByLabel('Confirm Password', { exact: true }).fill(RESET_PASSWORD); + await page.getByRole('button', { name: 'Change Password' }).click(); + + await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); + await expect(page.getByText('You have successfully changed your password.')).toBeVisible(); + }); + + await test.step('log in with the new password', async () => { + await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); + await page.getByPlaceholder('Enter password').fill(RESET_PASSWORD); + await page.getByRole('button', { exact: true, name: 'Login' }).click(); + + await expect(page.getByRole('heading', { name: 'All' })).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/next\/stack\/all(?:[?#]|$)/); + }); + } finally { + await recoveryContext.close(); + } }); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tour-dashboard.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tour-dashboard.e2e.ts new file mode 100644 index 0000000000..ad35ee2c66 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tour-dashboard.e2e.ts @@ -0,0 +1,203 @@ +import type { ProductTourUsageResponse } from '../../src/lib/generated/api'; + +import { expect, test } from '../fixtures/e2e-test'; + +test('real dashboard matches repository totals across rolling, month, and history ranges', async ({ e2eApi, page }, testInfo) => { + // Arrange: use the real local API, including its empty/unavailable-storage response. + const token = await e2eApi.login(); + await page.addInitScript((token) => localStorage.setItem('satellizer_token', token), token); + + // Act & Assert + for (const period of ['Last 30 days', 'Show month', 'Available history']) { + const pending = page.waitForResponse((response) => new URL(response.url()).pathname === '/api/v2/admin/product-tour-usage'); + if (period === 'Last 30 days') { + await page.goto('/next/system/product-tours'); + } else { + await page.getByRole('button', { name: /^Usage period:/ }).click(); + await page.getByRole('button', { exact: true, name: period }).click(); + } + const response = await pending; + expect(response.status()).toBe(200); + const usage: ProductTourUsageResponse = await response.json(); + const overview = usage.tours.find((tour) => tour.name === 'app-overview'); + expect(overview).toBeDefined(); + const card = page.getByLabel('Explore Exceptionless usage', { exact: true }); + if (overview!.shown + overview!.started + overview!.completed + overview!.dismissed === 0) { + await expect(card.getByText('No recorded activity in this period.', { exact: true })).toBeVisible(); + } else { + const totals = card.getByRole('list', { name: 'Period totals' }); + await expect(totals).toContainText(`Started ${overview!.started}`); + await expect(totals).toContainText(`Completed ${overview!.completed}`); + await expect(totals).toContainText(`Dismissed ${overview!.dismissed}`); + } + await expect(page.getByText('Guide activity', { exact: true })).toBeVisible(); + await expect(page.getByText('Failed to load guided-tour usage. Please try again.')).toHaveCount(0); + await page.screenshot({ animations: 'disabled', path: testInfo.outputPath(`local-api-${period.toLowerCase().replaceAll(' ', '-')}.png`) }); + } +}); + +test('empty activity stays distinct from an unavailable collector', async ({ e2eApi, page }) => { + // Arrange: isolated empty response; no changes to local storage or retained events. + const token = await e2eApi.login(); + await page.addInitScript((token) => localStorage.setItem('satellizer_token', token), token); + let collectionAvailable = true; + await page.route('**/api/v2/admin/product-tour-usage*', (route) => + route.fulfill({ + json: { + collection_available: collectionAvailable, + tours: [ + { + activity: [], + completed: 0, + dismissed: 0, + kind: 'guide', + name: 'app-overview', + shown: 0, + start_sources: [], + started: 0, + version: 1 + } + ], + utc_end: new Date().toISOString(), + utc_start: null + } + }) + ); + + // Act & Assert + for (const available of [true, false]) { + collectionAvailable = available; + await page.goto('/next/system/product-tours'); + const card = page.getByLabel('Explore Exceptionless usage', { exact: true }); + await expect(card.getByText('No recorded activity in this period.', { exact: true })).toBeVisible(); + await expect(card.getByRole('slider')).toHaveCount(0); + await expect(page.getByText('Guide activity collection is unavailable', { exact: true })).toHaveCount(available ? 0 : 1); + } +}); + +test('synthetic activity charts support keyboard, compact ranges, and light/dark layouts', async ({ e2eApi, page }, testInfo) => { + // Arrange: isolated response fixture; no synthetic events are written to storage. + await page.emulateMedia({ reducedMotion: 'reduce' }); + const token = await e2eApi.login(); + await page.addInitScript((token) => localStorage.setItem('satellizer_token', token), token); + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const activity = Array.from({ length: 30 }, (_, index) => ({ + completed: 3 + (index % 5), + date_utc: new Date(today.getTime() - (29 - index) * 86_400_000).toISOString(), + dismissed: index % 3, + shown: 15 + (index % 9), + started: 8 + (index % 7) + })); + const sum = (key: 'completed' | 'dismissed' | 'shown' | 'started') => activity.reduce((total, day) => total + day[key], 0); + await page.route('**/api/v2/admin/product-tour-usage*', (route) => { + const history = !new URL(route.request().url()).searchParams.has('start'); + const periods = history + ? activity.map((period, index) => ({ + ...period, + date_utc: new Date(today.getTime() - (30 - index) * 6 * 3_600_000).toISOString() + })) + : activity; + return route.fulfill({ + json: { + collection_available: true, + tours: ['app-overview', 'project-configure', 'saved-view-create', 'app-welcome'].map((name) => ({ + activity: periods, + completed: sum('completed'), + dismissed: sum('dismissed'), + kind: name === 'app-welcome' ? 'prompt' : 'guide', + last_run_utc: new Date().toISOString(), + name, + shown: sum('shown'), + start_sources: [{ count: sum('started'), source: 'catalog' }], + started: sum('started'), + version: 1 + })), + utc_end: new Date().toISOString(), + utc_start: periods[0].date_utc + } + }); + }); + + // Act & Assert + await page.goto('/next/system/product-tours'); + await expect(page.getByRole('button', { name: 'Usage period: Last 30 days' })).toBeVisible(); + const chart = page.getByRole('slider').first(); + await chart.focus(); + await page.keyboard.press('Home'); + await expect(chart).toHaveAttribute('aria-valuenow', '0'); + await expect(chart).toHaveAttribute('aria-valuetext', /Started: 8.*Completed: 3.*Dismissed: 0/); + await page.keyboard.press('ArrowRight'); + await expect(chart).toHaveAttribute('aria-valuenow', '1'); + await expect(chart).toHaveAttribute('aria-valuetext', /Started: 9/); + await expect(page.getByRole('tooltip')).toBeVisible(); + await expect(page.getByRole('tooltip').getByText('9', { exact: true })).toBeVisible(); + await page.keyboard.press('Tab'); + await expect(page.getByText('Most common exit:', { exact: false })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Steps and entry points' })).toHaveCount(0); + const details = page.getByRole('button', { name: 'Explore Exceptionless activity details' }); + await details.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog', { exact: true, name: 'Explore Exceptionless' })).toBeVisible(); + await expect(page.getByRole('list', { name: 'Guide entry points' })).toContainText('100.0%'); + await page.keyboard.press('Escape'); + await expect(details).toBeFocused(); + await page.getByRole('button', { name: 'Usage period: Last 30 days' }).click(); + await page.getByRole('button', { exact: true, name: 'Available history' }).click(); + await expect(page.getByRole('button', { name: 'Usage period: Available history' })).toBeVisible(); + await expect(page.getByText('Guide activity', { exact: true })).toBeVisible(); + await expect(chart).toHaveAttribute('aria-valuemax', '29'); + await chart.focus(); + await page.keyboard.press('Home'); + await expect(chart).toHaveAttribute('aria-valuetext', /Started: 8.*Completed: 3/); + await page.keyboard.press('ArrowRight'); + await expect(chart).toHaveAttribute('aria-valuetext', /Started: 9/); + await expect(page.getByRole('tooltip').getByText('9', { exact: true })).toBeVisible(); + await page.keyboard.press('Tab'); + await page.evaluate(() => { + const label = document.createElement('div'); + label.textContent = 'SYNTHETIC LOCAL FIXTURE — NOT CUSTOMER ACTIVITY'; + label.style.cssText = + 'position:fixed;bottom:8px;left:8px;z-index:9999;background:#111;color:#fff;padding:6px 10px;font:10px sans-serif;border-radius:4px'; + document.body.append(label); + }); + for (const [theme, width] of [ + ['dark', 1440], + ['light', 1440], + ['dark', 390] + ] as const) { + await page.setViewportSize({ height: 960, width }); + await page.evaluate((theme) => { + document.documentElement.classList.toggle('dark', theme === 'dark'); + document.documentElement.classList.toggle('light', theme === 'light'); + }, theme); + await expect(chart).toBeVisible(); + await expect + .poll(async () => { + const labels = await chart.locator('.lc-axis-tick-label').evaluateAll((elements) => + elements.map((element) => ({ + bounds: element.getBoundingClientRect().toJSON(), + placement: element.closest('.lc-axis')?.getAttribute('data-placement'), + text: element.textContent + })) + ); + const zero = labels.find((label) => label.placement === 'left' && label.text === '0'); + const firstDate = labels.find((label) => label.placement === 'bottom'); + return zero && firstDate ? firstDate.bounds.top - zero.bounds.bottom : 0; + }) + .toBeGreaterThanOrEqual(6); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ animations: 'disabled', path: testInfo.outputPath(`synthetic-chart-${theme}-${width}.png`) }); + await details.click(); + const popover = page.getByRole('dialog', { exact: true, name: 'Explore Exceptionless' }); + await expect(popover).toBeVisible(); + await expect + .poll(async () => { + const bounds = await popover.boundingBox(); + return bounds !== null && bounds.x >= 16 && bounds.x + bounds.width <= width - 16; + }) + .toBe(true); + await page.screenshot({ animations: 'disabled', path: testInfo.outputPath(`synthetic-details-${theme}-${width}.png`) }); + await page.keyboard.press('Escape'); + } +}); 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..3a301321f6 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -0,0 +1,580 @@ +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, e2eUseInvitedUser: true }); + +test.describe('first-run welcome', () => { + test.use({ e2eDismissProductTourWelcome: false }); + + for (const [tourName, dismissLabel] of [ + ['app-welcome', 'Close welcome'], + ['exie-announcement', 'Dismiss Exie announcement'] + ] as const) { + test(`${tourName} stays dismissed when telemetry and session storage are unavailable`, async ({ e2eApi, e2eScenario, page }) => { + // Arrange + await page.route('**/api/v2/events', (route) => route.abort()); + if (tourName === 'exie-announcement') { + await e2eApi.updateProductTour(e2eScenario.userToken, 'app-welcome', 1, 2); + } + await mockAssistantAccess(page); + await page.goto('/next/stack'); + const dismiss = page.getByRole('button', { name: dismissLabel }); + await expect(dismiss).toBeVisible(); + + // Act + const persisted = page.waitForResponse(isSuccessfulTourProgress(tourName)); + await dismiss.click(); + expect(await (await persisted).json()).toMatchObject({ status: 2, version: 1 }); + await page.addInitScript(() => + Object.defineProperty(window, 'sessionStorage', { + get() { + throw new DOMException('Storage denied', 'SecurityError'); + } + }) + ); + const reloadedUser = page.waitForResponse((response) => new URL(response.url()).pathname === '/api/v2/users/me' && response.status() === 200); + const reloadedProjects = page.waitForResponse( + (response) => new URL(response.url()).pathname === `/api/v2/organizations/${e2eScenario.organizationId}/projects` && response.status() === 200 + ); + await page.reload(); + + // Assert + expect(await (await reloadedUser).json()).toMatchObject({ + product_tours: { [tourName]: { status: 2, version: 1 } } + }); + await reloadedProjects; + await expect(page.getByRole('button', { name: 'Search Exceptionless' })).toBeVisible(); + if (tourName === 'app-welcome') { + // A different, unseen invitation remains eligible; saved outcomes do not hide unrelated guides. + await expect(page.getByRole('button', { name: 'Dismiss Exie announcement' })).toBeVisible(); + } + await expect(dismiss).toBeHidden(); + }); + } + + test('a manual guide does not compete with or accept the pending welcome', async ({ e2eScenario, page }) => { + // Arrange + const welcome = page.getByRole('region', { name: 'Welcome to Exceptionless' }); + await test.step(`show the pending welcome for ${e2eScenario.email}`, async () => { + await page.goto('/next/stack/all'); + await expect(welcome).toBeVisible(); + }); + const invitationWrites: Request[] = []; + page.on('request', (request) => { + if (request.method() === 'PUT' && new URL(request.url()).pathname.endsWith('/product-tours/app-welcome')) { + invitationWrites.push(request); + } + }); + + // Act + await startTourFromCommand(page, 'Explore Exceptionless'); + + // Assert + const guide = page.locator('.driver-popover'); + await expect(guide.getByText('Your workspace navigation')).toBeVisible(); + await expect(welcome).toBeHidden(); + await guide.getByRole('button', { name: 'End guide' }).click(); + await expect(guide).toBeHidden(); + await expect(welcome).toBeHidden(); + expect(invitationWrites).toEqual([]); + }); + + test('Browse Guides persists before the catalog opens', async ({ e2eScenario, page }, testInfo) => { + await test.step(`show the first-run prompt for ${e2eScenario.email}`, async () => { + await page.goto('/next/stack'); + await expect(page.getByRole('region', { name: 'Welcome to Exceptionless' })).toBeVisible(); + await expect(page.getByRole('dialog')).toBeHidden(); + await page.screenshot({ path: testInfo.outputPath('welcome-desktop.png') }); + await page.getByRole('button', { name: 'Search Exceptionless' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('region', { name: 'Welcome to Exceptionless' })).toBeVisible(); + }); + + const persisted = page.waitForResponse(isSuccessfulTourProgress('app-welcome')); + await page.getByRole('region', { 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('region', { name: 'Welcome to Exceptionless' })).toBeHidden(); + }); + + test('the compact mobile welcome respects reduced motion and starts the recommended setup', async ({ e2eScenario, page }, testInfo) => { + // Arrange + await page.setViewportSize({ height: 844, width: 390 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/next/stack'); + const welcome = page.getByRole('region', { name: 'Welcome to Exceptionless' }); + await expect(welcome).toBeVisible(); + + // Act + const presentation = await welcome.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + return { animation: getComputedStyle(element).animationName, bottom: bounds.bottom, height: bounds.height, left: bounds.left, right: bounds.right }; + }); + + // Assert + expect(presentation.animation).toBe('none'); + expect(presentation.left).toBeGreaterThanOrEqual(16); + expect(presentation.right).toBeLessThanOrEqual(374); + expect(presentation.bottom).toBeLessThanOrEqual(828); + expect(presentation.height).toBeLessThan(220); + await expect(page.getByRole('dialog')).toBeHidden(); + await page.screenshot({ path: testInfo.outputPath('welcome-mobile.png') }); + const persisted = page.waitForResponse(isSuccessfulTourProgress('app-welcome')); + await welcome.getByRole('button', { name: 'Continue setup' }).click(); + await persisted; + await expect(page).toHaveURL(new RegExp(`/next/project/(?:add|${e2eScenario.projectId}/configure)`)); + await expect(welcome).toBeHidden(); + }); + + test('a failed close remains retryable and successful dismissal survives reload', async ({ e2eScenario, page }) => { + // Arrange + const welcome = page.getByRole('region', { name: 'Welcome to Exceptionless' }); + await test.step(`show the welcome for ${e2eScenario.email}`, async () => { + await page.goto('/next/stack'); + await expect(welcome).toBeVisible(); + }); + const progressRoute = '**/api/v2/users/me/product-tours/app-welcome'; + await page.route(progressRoute, (route) => route.fulfill({ json: { title: 'Injected progress failure' }, status: 500 })); + + // Act + await welcome.getByRole('button', { name: 'Close welcome' }).click(); + + // Assert + await expect(page.getByText('We could not save your guided-tour preference. Please try again.')).toBeVisible(); + await expect(welcome).toBeVisible(); + await page.unroute(progressRoute); + const persisted = page.waitForResponse(isSuccessfulTourProgress('app-welcome')); + await welcome.getByRole('button', { name: 'Close welcome' }).click(); + await persisted; + await page.reload(); + await expect(welcome).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'); + const welcome = page.getByRole('region', { name: 'Welcome to Exceptionless' }); + await expect(welcome).toBeVisible(); + const dismissed = page.waitForResponse(isSuccessfulTourProgress('app-welcome')); + await welcome.getByRole('button', { name: 'Close welcome' }).focus(); + await page.keyboard.press('Escape'); + await dismissed; + await expect(welcome).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 }); + const closeButton = tour.getByRole('button', { name: 'End guide' }); + await expect(closeButton).toHaveText('×'); + const closeBounds = await closeButton.boundingBox(); + const titleBounds = await tour.locator('.driver-popover-title').boundingBox(); + const descriptionBounds = await tour.locator('.driver-popover-description').boundingBox(); + const continueBounds = await tour.getByRole('button', { name: 'Continue' }).boundingBox(); + expect(closeBounds).not.toBeNull(); + expect(titleBounds).not.toBeNull(); + expect(descriptionBounds).not.toBeNull(); + expect(continueBounds?.height).toBe(32); + expect(closeBounds?.height).toBe(32); + expect(titleBounds!.x + titleBounds!.width).toBeLessThanOrEqual(closeBounds!.x); + expect(closeBounds!.y + closeBounds!.height).toBeLessThanOrEqual(descriptionBounds!.y); + await tour.getByRole('button', { name: 'Continue' }).click(); + await expect(tour.getByText('Use the command palette')).toBeVisible(); + await page.reload(); + await expect(tour.getByText('Use the command palette')).toBeVisible(); + + const dismissed = page.waitForResponse(isSuccessfulTourProgress('app-overview')); + await tour.getByRole('button', { name: 'End guide' }).click(); + await dismissed; + await expectProductTourSession(page, false); + }); + + await test.step('every shell target remains visible on mobile', async () => { + await mockAssistantAccess(page); + await page.reload(); + await page.setViewportSize({ height: 844, width: 390 }); + await startTourFromCommand(page, 'Explore Exceptionless'); + const tour = page.locator('.driver-popover'); + + for (const [title, target] of [ + ['Your workspace navigation', '[data-tour="app-navigation"]'], + ['Use the command palette', '[data-tour="command-search"]'], + ['Find your saved views', '[data-tour="saved-view-navigation"]'], + ['Ask Exie with context', '[data-tour="exie-trigger"]'], + ['Find your next guide', '[data-tour="guided-tours-menu-item"]'] + ] as const) { + await expect(tour.getByText(title)).toBeVisible(); + await expect(page.locator(target)).toBeVisible(); + if (title !== 'Find your next guide') { + await tour.getByRole('button', { name: 'Continue' }).click(); + } + } + + const guidedTours = page.getByRole('menuitem', { exact: true, name: 'Guided Tours…' }); + await expect(guidedTours).toBeVisible(); + await expect(guidedTours).toHaveClass(/driver-active-element/); + await expect(page.getByRole('menuitem', { exact: true, name: 'Help' })).toHaveAttribute('data-state', 'open'); + await expect(guidedTours).toBeInViewport(); + const completed = page.waitForResponse(isSuccessfulTourProgress('app-overview')); + await tour.getByRole('button', { name: 'Browse guides' }).click(); + await completed; + await expectProductTourSession(page, false); + await expect(page.getByRole('dialog', { exact: true, name: 'Guided Tours' })).toBeVisible(); + await page.keyboard.press('Escape'); + }); + + await test.step('an organization change clears an active checkpoint even when projects fail to load', async () => { + await mockAssistantAccess(page); + await page.reload(); + await startTourFromCommand(page, 'Meet Exie'); + await expectProductTourSession(page, true); + const writesBeforeSwitch = progressWrites.length; + const projectsRoute = `**/api/v2/organizations/${e2eSecondaryOrganization.organizationId}/projects*`; + await page.route(projectsRoute, (route) => route.fulfill({ json: { title: 'Injected project lookup failure' }, status: 500 })); + + 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 page.getByRole('button', { name: 'Search Exceptionless' }).click(); + await page.getByRole('dialog').getByText('Guided Tours…', { exact: true }).click(); + const catalog = page.getByRole('dialog', { name: 'Guided Tours' }); + await expect(catalog.getByRole('button', { exact: true, name: 'Restart Explore Exceptionless' })).toBeEnabled(); + await expect(catalog.getByRole('button', { exact: true, name: 'Start Configure a project' })).toBeDisabled(); + await expect(catalog.getByText('Projects could not be loaded. Try again shortly.', { exact: true })).toBeVisible(); + await page.keyboard.press('Escape'); + await page.unroute(projectsRoute); + await page.reload(); + }); + + await test.step('logout clears an active checkpoint without recording progress', async () => { + await page.setViewportSize({ height: 900, width: 1440 }); + 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('project guide preserves the current SDK selection', async ({ e2eScenario, page }) => { + // Arrange + await page.route('**/api/v2/organizations/*/projects*', async (route) => { + await route.fulfill({ json: [] }); + }); + await page.goto(`/next/project/${e2eScenario.projectId}/configure?type=dotnet-legacy-mvc`); + await expect(page.locator('[data-tour="project-configure-platform"]')).toContainText('ASP.NET MVC'); + + // Act + await startTourFromCommand(page, 'Configure a project'); + + // Assert + await expect(page.getByRole('button', { exact: true, name: 'End guide' })).toBeVisible(); + await expect(page.locator('.driver-popover')).toHaveCount(0); + await expect(page.locator('[data-tour="project-configure-platform"]')).toContainText('ASP.NET MVC'); + expect(new URL(page.url()).searchParams.get('type')).toBe('dotnet-legacy-mvc'); + expect(new URL(page.url()).searchParams.get('redirect')).toBe('true'); + expect(new URL(page.url()).pathname).toBe(`/next/project/${e2eScenario.projectId}/configure`); +}); + +test('a saved-view guide allows submitting the form before finishing its steps', async ({ e2eScenario, page }) => { + // Arrange + await page.goto('/next/event'); + await startTourFromCommand(page, 'Create a saved view'); + const guide = page.locator('.driver-popover'); + await guide.getByRole('button', { name: 'Open View' }).click(); + await guide.getByRole('button', { name: 'Save As…' }).click(); + const name = page.getByLabel('Name', { exact: true }); + await name.fill(`Early Save ${e2eScenario.run}`); + const completed = page.waitForResponse(isSuccessfulTourProgress('saved-view-create')); + + // Act + await name.press('Enter'); + + // Assert + await completed; + await expectProductTourSession(page, false); + await expect(page.getByText('Your saved view is ready', { exact: true })).toBeVisible(); +}); + +test('domain workflows advance only on real success', async ({ e2eApi, e2eScenario, page }) => { + test.setTimeout(300_000); + + await test.step('project configuration advances after setup and the first event', async () => { + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Configure a project'); + await page.waitForURL(/\/next\/project\/(?:add|[^/]+\/configure)/); + + let createdProject = false; + let projectId = page.url().match(/\/project\/([^/]+)\/configure/)?.[1]; + if (!projectId) { + createdProject = true; + await expect(page.getByRole('heading', { name: 'Add Project' })).toBeVisible(); + await page.getByLabel('Project Name', { exact: true }).fill(`Tour Project ${e2eScenario.run}`); + await page.getByRole('button', { name: 'Continue to Client Setup' }).click(); + await page.waitForURL(/\/next\/project\/[^/]+\/configure\?redirect=true/); + projectId = page.url().match(/\/project\/([^/]+)\/configure/)?.[1]; + } else { + expect(projectId).toBe(e2eScenario.projectId); + } + + expect(projectId).toBeTruthy(); + + const projectProgressRoute = (url: URL) => url.pathname === '/api/v2/users/me/product-tours/project-configure'; + try { + await page.locator('[data-tour="project-configure-platform"]').click(); + await page.getByRole('option', { name: 'Browser applications' }).click(); + await expect(page.getByText('Waiting for your first event')).toBeVisible(); + await expect(page.locator('.driver-popover')).toHaveCount(0); + await expect(page.locator('.driver-overlay')).toHaveCount(0); + await expect(page.locator('[data-tour="project-sdk-instructions"]')).toBeVisible(); + await expect(page.getByRole('button', { exact: true, name: 'End guide' })).toBeVisible(); + + const instructionButtons = page.locator('[data-tour="project-sdk-instructions"]').getByRole('button'); + const reachedButtons = new Set(); + await page.locator('[data-tour="project-configure-platform"]').focus(); + for (let tab = 0; tab < 40 && reachedButtons.size < (await instructionButtons.count()); tab++) { + const focusedIndex = await instructionButtons.evaluateAll((buttons) => buttons.indexOf(document.activeElement as HTMLButtonElement)); + if (focusedIndex >= 0) { + reachedButtons.add(focusedIndex); + } + await page.keyboard.press('Tab'); + } + expect(reachedButtons.size).toBe(await instructionButtons.count()); + + let projectProgressRequests = 0; + await page.route(projectProgressRoute, async (route) => { + projectProgressRequests += 1; + await route.fulfill({ json: { title: 'Injected progress failure' }, status: 500 }); + }); + 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, true); + await expect.poll(() => projectProgressRequests).toBe(1); + await expect.poll(async () => (await e2eApi.getProject(e2eScenario.userToken, projectId!))?.is_configured).toBe(true); + + await page.unroute(projectProgressRoute); + const completed = page.waitForResponse(isSuccessfulTourProgress('project-configure')); + await page.goto(`/next/project/${projectId}/configure`); + await completed; + await expectProductTourSession(page, false); + } finally { + await page.unroute(projectProgressRoute); + if (createdProject) { + 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/saved-view-create'; + 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: 'Open View' }).click(); + await expect(page.locator('[data-tour="saved-view-save-as"]')).toHaveClass(/driver-active-element/); + await tour.getByRole('button', { name: 'Save As…' }).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('saved-view-create')); + 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('.driver-popover').getByRole('button', { name: 'Open first error' }).click(); + const callout = page.locator('.driver-popover'); + await expect(callout.getByText('Understand the grouped issue')).toBeVisible(); + for (const title of ['Review the issue status', '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('event-investigate')); + await callout.getByRole('button', { name: 'Finish guide' }).click(); + await completed; + await expectProductTourSession(page, false); + await page.reload(); + await expect(page.locator('.driver-popover')).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: 'Open Exie' }).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(); + } +} + +test('completion survives unavailable telemetry and session storage', async ({ e2eScenario, page }) => { + // Arrange + await page.route('**/api/v2/events', (route) => route.abort()); + const tour = page.locator('.driver-popover'); + await page.addInitScript(() => + Object.defineProperty(window, 'sessionStorage', { + get() { + throw new DOMException('Storage denied', 'SecurityError'); + } + }) + ); + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Explore Exceptionless'); + for (const title of ['Your workspace navigation', 'Use the command palette', 'Find your saved views']) { + await expect(tour.getByText(title)).toBeVisible(); + await tour.getByRole('button', { name: 'Continue' }).click(); + } + await expect(tour.getByText('Find your next guide')).toBeVisible(); + const completed = page.waitForResponse(isSuccessfulTourProgress('app-overview')); + await tour.getByRole('button', { name: 'Browse guides' }).click(); + const response = await completed; + + // Assert + expect(await response.json()).toMatchObject({ status: 1, version: 1 }); + await expect(page.getByRole('dialog', { name: 'Guided Tours' })).toBeVisible(); + expect(e2eScenario.email).toContain('@exceptionless.test'); +}); + +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('Guided Tours…', { exact: true }).click(); + const catalog = page.getByRole('dialog', { name: 'Guided Tours' }); + const tour = catalog.getByRole('region', { name: title }); + await tour.getByRole('button', { name: /^(Continue|Restart|Start) / }).click(); +} diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts index da9ab73284..51518373d6 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts @@ -32,6 +32,11 @@ test('home navigation honors personal and organization saved views and survives await openViewMenu(page); await page.getByRole('menuitem', { name: 'Save As...' }).click(); const dialog = page.getByRole('dialog', { name: 'Save View' }); + await expect(dialog.getByRole('switch', { exact: true, name: 'Private' })).not.toBeChecked(); + await dialog.getByRole('button', { exact: true, name: 'Cancel' }).click(); + await expect(dialog).toBeHidden(); + await openViewMenu(page); + await page.getByRole('menuitem', { name: 'Save As...' }).click(); await dialog.getByLabel('Name', { exact: true }).fill(viewName); await dialog.getByRole('button', { name: 'Save' }).click(); await expect(dialog).toBeHidden({ timeout: 30_000 }); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/sessions-saved-views.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/sessions-saved-views.e2e.ts index 985fa05363..aae7d272a3 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/sessions-saved-views.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/sessions-saved-views.e2e.ts @@ -273,7 +273,9 @@ test('Sessions ignore legacy structured Type filters and Type URL parameters', a async function captureEvidence(page: Page, fileName: string): Promise { const outputDirectory = process.env.DOGFOOD_OUTPUT; - if (!outputDirectory) return; + if (!outputDirectory) { + return; + } const resolvedDirectory = path.resolve(outputDirectory); mkdirSync(resolvedDirectory, { recursive: true }); diff --git a/src/Exceptionless.Web/ClientApp/eslint.config.js b/src/Exceptionless.Web/ClientApp/eslint.config.js index 0e7fe79173..e681380c26 100644 --- a/src/Exceptionless.Web/ClientApp/eslint.config.js +++ b/src/Exceptionless.Web/ClientApp/eslint.config.js @@ -41,7 +41,8 @@ export default ts.config( }, { rules: { - '@tanstack/query/exhaustive-deps': 'off' + '@tanstack/query/exhaustive-deps': 'off', + curly: ['error', 'all'] } }, { @@ -52,7 +53,6 @@ export default ts.config( rules: { '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }], '@stylistic/object-curly-newline': ['error', { ObjectExpression: { minProperties: 1 } }], - curly: ['error', 'all'], 'padding-line-between-statements': ['error', { blankLine: 'always', next: ['if', 'while', 'for', 'do'], prev: 'block-like' }] } }, 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 824e988995..1526ee6510 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 @@ -13,11 +13,14 @@ import type { OAuthApplication, OAuthApplicationRequest, PredefinedSavedViewDefinition, + ProductTourUsageResponse, UpdateAssistantEnabledSettingsRequest, UpdateAssistantSettingsRequest, UpdateEventSubmissionSettingsRequest } from './models'; +import { getProductTourUsageParams, type ProductTourUsageRange } from './product-tour-usage'; + export type GetOAuthApplicationsParams = { criteria?: string; limit?: number; @@ -44,6 +47,7 @@ export const queryKeys = { migrations: ['admin', 'migrations'] as const, oauthApplication: (id: string | undefined) => [...queryKeys.oauthApplications, id] as const, oauthApplications: ['admin', 'oauth-applications'] as const, + productTourUsage: (range: ProductTourUsageRange) => ['admin', 'product-tour-usage', range] as const, snapshots: ['admin', 'elasticsearch', 'snapshots'] as const, stats: ['admin', 'stats'] as const }; @@ -110,6 +114,30 @@ export function getAdminAssistantUsageQuery(month: () => string) { })); } +export function getAdminProductTourUsageQuery(range: () => ProductTourUsageRange) { + return createQuery(() => { + const selectedRange = range(); + + return { + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/product-tour-usage', { + params: getProductTourUsageParams(selectedRange), + signal + }); + + if (!response.ok) { + throw response.problem; + } + + return response.data!; + }, + queryKey: queryKeys.productTourUsage(selectedRange), + staleTime: 60 * 1000 + }; + }); +} + export function getAdminStatsQuery() { return createQuery(() => ({ queryFn: async ({ signal }: { signal: AbortSignal }) => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-activity-popover.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-activity-popover.svelte new file mode 100644 index 0000000000..6b57f622ad --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-activity-popover.svelte @@ -0,0 +1,49 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + {title} + Activity in the selected period, not unique people. + {#if tour.kind === 'guide'} + {#if tour.start_sources.length} +
+ Opened from +
    + {#each tour.start_sources as source (source.source)} +
  • + {source.source.replaceAll('-', ' ')}: + {#if tour.started > 0}( of starts){/if} +
  • + {/each} +
+
+ {/if} + {#if !tour.start_sources.length} + No entry-point activity recorded in this period. + {/if} + {:else} + Shown counts invitation displays; Accepted counts choosing to start or browse guides, or open Exie. + {/if} +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-activity-popover.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-activity-popover.svelte.test.ts new file mode 100644 index 0000000000..0f6924d9fa --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-activity-popover.svelte.test.ts @@ -0,0 +1,60 @@ +import type { ProductTourSummary } from '$generated/api'; + +import { ProductTourKind, ProductTourLaunchSource } from '$generated/api'; +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, describe, expect, it } from 'vitest'; + +import ProductTourActivityPopover from './product-tour-activity-popover.svelte'; + +const tour: ProductTourSummary = { + activity: [], + completed: 2, + dismissed: 1, + kind: ProductTourKind.Guide, + name: 'app-overview', + shown: 0, + start_sources: [{ count: 4, source: ProductTourLaunchSource.CommandPalette }], + started: 4, + version: 1 +}; + +afterEach(cleanup); + +describe('ProductTourActivityPopover', () => { + it('keeps diagnostic counts behind a named info button', async () => { + // Arrange + render(ProductTourActivityPopover, { title: 'Explore Exceptionless', tour }); + expect(screen.queryByText(/Most common exit/)).toBeNull(); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Explore Exceptionless activity details' })); + + // Assert + expect(screen.getByRole('dialog', { name: 'Explore Exceptionless' })).toBeTruthy(); + expect(screen.getByRole('list', { name: 'Guide entry points' }).textContent).toContain('100.0%'); + }); + + it('explains missing diagnostics without inventing step counts', async () => { + // Arrange + render(ProductTourActivityPopover, { title: 'Explore Exceptionless', tour: { ...tour, start_sources: [] } }); + + // Act + await fireEvent.click(screen.getByRole('button')); + + // Assert + expect(screen.getByText('No entry-point activity recorded in this period.')).toBeTruthy(); + expect(screen.queryByText(/Most common exit/)).toBeNull(); + }); + + it('explains invitation acceptance without presenting guide steps', async () => { + // Arrange + render(ProductTourActivityPopover, { title: 'Welcome invitation', tour: { ...tour, kind: ProductTourKind.Prompt } }); + + // Act + await fireEvent.click(screen.getByRole('button')); + + // Assert + expect(screen.getByText(/Accepted counts choosing to start or browse guides, or open Exie/)).toBeTruthy(); + expect(screen.queryByRole('list', { name: 'Guide entry points' })).toBeNull(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-activity.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-activity.svelte new file mode 100644 index 0000000000..b5df938d83 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-activity.svelte @@ -0,0 +1,185 @@ + + +
+ {#if total === 0} + No recorded activity in this period. + {:else} +
    + {#each keys as key (key)} +
  • + {config[key].label} + +
  • + {/each} +
+ context?.tooltip.hide()} + > + scale.ticks?.(3).filter((value: number) => globalThis.Number.isInteger(value)) + } + }} + > + {#snippet marks({ context })} + {#each context.series.visibleSeries as item (item.key)} + + {#if data.length === 1} + + {/if} + {/each} + {/snippet} + {#snippet tooltip()} (value instanceof Date ? formatPeriod(value) : '')} + indicator="line" + />{/snippet} + + +
+ Use Left and Right arrows to inspect dates, or Home and End to jump to the first and last date. Dates are UTC. + + Date (UTC){#each keys as key (key)}{config[key].label}{/each} + {#each data as period (period.date_utc)}{formatPeriod(period.date)}{#each keys as key (key)}{/each}{/each} + +
+ {/if} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-activity.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-activity.svelte.test.ts new file mode 100644 index 0000000000..2eac420cf1 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-activity.svelte.test.ts @@ -0,0 +1,96 @@ +import type { ProductTourSummary } from '$generated/api'; + +import { formatDateLabel } from '$features/shared/dates'; +import { ProductTourKind, ProductTourLaunchSource } from '$generated/api'; +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import ProductTourActivity from './product-tour-activity.svelte'; + +const tour: ProductTourSummary = { + activity: [{ completed: 1, date_utc: '2026-01-01T00:00:00Z', dismissed: 0, shown: 0, started: 1 }], + completed: 1, + dismissed: 0, + kind: ProductTourKind.Guide, + name: 'app-overview', + shown: 0, + start_sources: [{ count: 1, source: ProductTourLaunchSource.CommandPalette }], + started: 1, + version: 1 +}; + +beforeEach(() => { + Object.defineProperty(Element.prototype, 'animate', { configurable: true, value: vi.fn(() => ({ cancel() {}, finished: Promise.resolve() })) }); + vi.stubGlobal( + 'ResizeObserver', + class { + disconnect() {} + observe() {} + unobserve() {} + } + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + Reflect.deleteProperty(Element.prototype, 'animate'); +}); + +describe('ProductTourActivity', () => { + it('makes exact daily counts keyboard accessible without relying on color', async () => { + // Arrange + render(ProductTourActivity, { + end: '2026-01-03T00:00:00Z', + start: '2026-01-01T00:00:00Z', + tour: { ...tour, activity: [...tour.activity, { completed: 0, date_utc: '2026-01-02T00:00:00Z', dismissed: 0, shown: 0, started: 0 }] } + }); + const chart = screen.getByRole('slider'); + + // Act + await fireEvent.keyDown(chart, { key: 'Home' }); + + // Assert + expect(chart.getAttribute('aria-valuenow')).toBe('0'); + expect(chart.getAttribute('aria-valuetext')).toContain('Completed: 1'); + await fireEvent.keyDown(chart, { key: 'ArrowRight' }); + expect(chart.getAttribute('aria-valuenow')).toBe('1'); + expect(chart.getAttribute('aria-valuetext')).toContain('Completed: 0'); + }); + + it('labels invitation acceptance without a redundant started series', () => { + // Act + render(ProductTourActivity, { end: '2026-02-01T00:00:00Z', tour: { ...tour, kind: ProductTourKind.Prompt } }); + + // Assert + expect(screen.getByLabelText('Period totals').textContent).toContain('Accepted'); + expect(screen.getByLabelText('Period totals').textContent).not.toContain('Started'); + }); + it('shows the chart without disclosures while preserving screen-reader access to values', () => { + // Act + render(ProductTourActivity, { end: '2026-02-01T00:00:00Z', start: '2026-01-01T00:00:00Z', tour }); + + // Assert + expect(screen.getByLabelText(/Recorded guide activity/)).toBeTruthy(); + expect(screen.queryByText('View details')).toBeNull(); + const table = screen.getByRole('table', { name: 'Guide activity by date' }); + expect(table.closest('.sr-only')).not.toBeNull(); + expect(table.textContent).toContain( + formatDateLabel(new Date(tour.activity[0]!.date_utc), undefined, { includeRelative: false, month: 'short', timeZone: 'UTC' }) + ); + expect(table.closest('details')).toBeNull(); + }); + + it('shows a collection empty state instead of zero-valued completion metrics', () => { + // Arrange + const empty = { ...tour, activity: [], completed: 0, start_sources: [], started: 0 }; + + // Act + render(ProductTourActivity, { end: '2026-02-01T00:00:00Z', tour: empty }); + + // Assert + expect(screen.getByText('No recorded activity in this period.')).toBeTruthy(); + expect(screen.queryByLabelText('Selected guide totals')).toBeNull(); + expect(screen.queryByText('View details')).toBeNull(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-period.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-period.svelte new file mode 100644 index 0000000000..d48107bdff --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-period.svelte @@ -0,0 +1,83 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + +
{ + event.preventDefault(); + selectMonth(); + }} + > + + + Month (UTC) + + + + + +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-period.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-period.svelte.test.ts new file mode 100644 index 0000000000..45f5a49b26 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/product-tour-period.svelte.test.ts @@ -0,0 +1,33 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import ProductTourPeriod from './product-tour-period.svelte'; + +describe('ProductTourPeriod', () => { + it('edits the selected month inside the popover rather than adding a toolbar input', async () => { + // Arrange + render(ProductTourPeriod, { range: { kind: 'month', month: '2020-08' } }); + expect(screen.queryByLabelText('Month (UTC)')).toBeNull(); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Usage period: August 2020' })); + + // Assert + expect((screen.getByLabelText('Month (UTC)') as HTMLInputElement).value).toBe('2020-08'); + }); + + it('switches history back to the remembered month using the same trigger', async () => { + // Arrange + render(ProductTourPeriod, { range: { kind: 'month', month: '2020-08' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Usage period: August 2020' })); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Available history' })); + await fireEvent.click(screen.getByRole('button', { name: 'Usage period: Available history' })); + await fireEvent.click(screen.getByRole('button', { name: 'Show month' })); + + // Assert + expect(screen.getByRole('button', { name: 'Usage period: August 2020' })).toBeTruthy(); + expect(screen.queryByLabelText('Month (UTC)')).toBeNull(); + }); +}); 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 cf424cf37d..06fc5b56d6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts @@ -7,6 +7,8 @@ import type { UpdateEventSubmissionSettings } from '$generated/api'; +export type { ProductTourUsageResponse } from '$generated/api'; + export enum MigrationType { Versioned = 0, VersionedAndResumable = 1, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.test.ts new file mode 100644 index 0000000000..b9528b32c2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { getProductTourActivity, getProductTourUsageParams } from './product-tour-usage'; + +describe('product tour usage helpers', () => { + it('maps each immutable range to the API query parameters', () => { + expect(getProductTourUsageParams({ kind: 'month', month: '2026-08' })).toEqual({ end: '2026-09-01T00:00:00.000Z', start: '2026-08-01T00:00:00.000Z' }); + expect(getProductTourUsageParams({ kind: 'history' })).toEqual({}); + expect(getProductTourUsageParams({ days: 30, kind: 'days' }, new Date('2026-03-02T12:00:00Z'))).toEqual({ start: '2026-02-01T00:00:00.000Z' }); + }); + + it('preserves exact server buckets across a month boundary, including empty and subdaily periods', () => { + // Arrange + const activity = [ + { completed: 0, date_utc: '2026-08-31T12:00:00Z', dismissed: 0, shown: 2, started: 1 }, + { completed: 0, date_utc: '2026-08-31T18:00:00Z', dismissed: 0, shown: 0, started: 0 }, + { completed: 1, date_utc: '2026-09-01T00:00:00Z', dismissed: 0, shown: 3, started: 2 } + ]; + + // Act + const data = getProductTourActivity(activity, '2026-08-31T12:00:00Z', '2026-09-02T00:00:00Z', new Date('2026-09-03T00:00:00Z')); + + // Assert + expect(data).toEqual(activity.map((period) => ({ ...period, date: new Date(period.date_utc) }))); + }); + + it('trims padding before retained activity and after now without generating new buckets', () => { + // Arrange + const activity = [1, 3, 5, 7].map((day) => ({ + completed: 0, + date_utc: `2026-08-0${day}T00:00:00Z`, + dismissed: 0, + shown: 0, + started: 0 + })); + + // Act + const data = getProductTourActivity(activity, '2026-08-03T00:00:00Z', '2026-09-01T00:00:00Z', new Date('2026-08-06T12:00:00Z')); + + // Assert + expect(data.map((period) => period.date_utc)).toEqual(['2026-08-03T00:00:00Z', '2026-08-05T00:00:00Z']); + }); + + it('excludes the upper date boundary', () => { + // Arrange + const activity = [ + { completed: 0, date_utc: '2026-02-01T00:00:00Z', dismissed: 0, shown: 0, started: 5 }, + { completed: 0, date_utc: '2026-03-01T00:00:00Z', dismissed: 0, shown: 0, started: 0 } + ]; + + // Act + const data = getProductTourActivity(activity, null, '2026-03-01T00:00:00Z', new Date('2026-04-01T00:00:00Z')); + + // Assert + expect(data).toHaveLength(1); + expect(data[0]?.started).toBe(5); + }); + + it('keeps activity in a histogram bucket that begins before the requested start', () => { + // Arrange + const activity = [{ completed: 0, date_utc: '2026-08-03T00:00:00Z', dismissed: 0, shown: 0, started: 1 }]; + + // Act + const data = getProductTourActivity(activity, '2026-08-03T01:00:00Z', '2026-08-04T00:00:00Z', new Date('2026-08-05T00:00:00Z')); + + // Assert + expect(data[0]?.started).toBe(1); + }); + + it('does not invent a start date for empty unlimited history', () => { + expect(getProductTourActivity([], null, '2026-04-01T00:00:00Z')).toEqual([]); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.ts new file mode 100644 index 0000000000..dcd334e0d1 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/product-tour-usage.ts @@ -0,0 +1,44 @@ +import type { ProductTourActivity } from '$generated/api'; + +export type ProductTourUsageRange = + | { + days: number; + kind: 'days'; + } + | { + kind: 'history'; + } + | { + kind: 'month'; + month: string; + }; + +export function getProductTourActivity( + activity: ProductTourActivity[], + start: null | string | undefined, + end: string, + now = new Date() +): (ProductTourActivity & { date: Date })[] { + const endDate = new Date(Math.min(new Date(end).getTime(), now.getTime())); + const startDate = start ? new Date(start) : undefined; + return activity + .map((period) => ({ ...period, date: new Date(period.date_utc) })) + .filter( + (period) => + (!startDate || period.date >= startDate || period.shown + period.started + period.completed + period.dismissed > 0) && period.date < endDate + ); +} + +export function getProductTourUsageParams(range: ProductTourUsageRange, now = new Date()): Record { + if (range.kind === 'days') { + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1 - range.days)); + return { start: start.toISOString() }; + } + if (range.kind === 'history') { + return {}; + } + + const start = new Date(`${range.month}-01T00:00:00Z`); + const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1)); + return { end: end.toISOString(), start: start.toISOString() }; +} 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 @@ (); function getTabs(event?: null | PersistentEvent, project?: ViewProject): TabType[] { if (!event) { @@ -333,11 +335,13 @@

Stack

+ {#if event?.stack_id} (tourStackId = stack.id)} prepareAssistantContext={assistantResource === 'event' ? prepareEventAssistantContext : prepareStackAssistantContext} > {/if} @@ -355,6 +359,7 @@ {#if event?.stack_id} + +
+ + +
+ + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-welcome.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-welcome.svelte new file mode 100644 index 0000000000..14ceb4d61d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-welcome.svelte @@ -0,0 +1,49 @@ + + +{#if open} + +
+ Welcome to Exceptionless + +
+ {recommended.description} +
+ + +
+
+{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-welcome.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-welcome.svelte.test.ts new file mode 100644 index 0000000000..a6fadb8d29 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-welcome.svelte.test.ts @@ -0,0 +1,91 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import ProductTourWelcome from './product-tour-welcome.svelte'; + +const recommended = { + availability: vi.fn(() => ({ available: true })), + canResume: () => true, + currentAvailability: { available: true }, + description: 'Learn navigation and search.', + keywords: ['navigation'], + name: 'app-overview' as const, + start: vi.fn(() => ({ checkpointName: 'navigation' as const, route: '/next' })), + title: 'Explore Exceptionless', + version: 1 +}; + +describe('ProductTourWelcome', () => { + it('records dismissal from Escape inside the welcome', async () => { + const onBrowse = vi.fn(); + const onDismiss = vi.fn(); + const onStart = vi.fn(); + render(ProductTourWelcome, { onBrowse, onDismiss, onStart, open: true, recommended }); + + await fireEvent.keyDown(screen.getByRole('button', { name: 'Close welcome' }), { key: 'Escape' }); + expect(onBrowse).not.toHaveBeenCalled(); + expect(onDismiss).toHaveBeenCalledOnce(); + expect(onStart).not.toHaveBeenCalled(); + }); + + it('provides browse and close choices', async () => { + const onBrowse = vi.fn(); + const onDismiss = vi.fn(); + render(ProductTourWelcome, { 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: 'Close welcome' })); + expect(onDismiss).toHaveBeenCalledOnce(); + }); + + it('offers only the recommended action without a modal or taking focus', async () => { + // Arrange + const onStart = vi.fn(); + const focusedElement = document.activeElement; + + // Act + render(ProductTourWelcome, { onBrowse: vi.fn(), onDismiss: vi.fn(), onStart, open: true, recommended }); + + // Assert + expect(screen.getByRole('region', { name: 'Welcome to Exceptionless' })).toBeTruthy(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(document.activeElement).toBe(focusedElement); + expect(screen.getAllByRole('button')).toHaveLength(3); + expect(screen.getByText(recommended.description)).toBeTruthy(); + await fireEvent.click(screen.getByRole('button', { name: recommended.title })); + expect(onStart).toHaveBeenCalledOnce(); + }); + + it('offers setup when that is the recommendation', () => { + render(ProductTourWelcome, { + onBrowse: vi.fn(), + onDismiss: vi.fn(), + onStart: vi.fn(), + open: true, + recommended: { ...recommended, name: 'project-configure', title: 'Configure a project' } + }); + + expect(screen.getByRole('button', { name: 'Continue setup' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Explore Exceptionless' })).toBeNull(); + }); + + it('does not dismiss unrelated Escape presses or allow actions while saving', async () => { + const onDismiss = vi.fn(); + render(ProductTourWelcome, { busy: true, onBrowse: vi.fn(), onDismiss, onStart: vi.fn(), open: true, recommended }); + + await fireEvent.keyDown(document.body, { key: 'Escape' }); + await fireEvent.keyDown(screen.getByRole('button', { name: 'Close welcome' }), { key: 'Escape' }); + + expect(onDismiss).not.toHaveBeenCalled(); + for (const button of screen.getAllByRole('button')) { + expect(button.hasAttribute('disabled')).toBe(true); + } + }); + + it('does not render when closed', () => { + render(ProductTourWelcome, { onBrowse: vi.fn(), onDismiss: vi.fn(), onStart: vi.fn(), recommended }); + + expect(screen.queryByRole('region')).toBeNull(); + }); +}); 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..e41c74e9ae --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte @@ -0,0 +1,78 @@ + + + + + + Guided Tours + Choose a short, step-by-step guide. Guides use your workspace, not sample data. + + +
    + {#each items as item (item.name)} + {@const Icon = icons[item.name]} + {@const completed = item.progress?.status === ProductTourStatus.Completed && item.progress.version >= item.version} + {@const actionLabel = resumableTourName === item.name ? 'Continue' : activeTourName === item.name || completed ? 'Restart' : 'Start'} +
  • +
    +
    +
  • + {/each} +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte.test.ts new file mode 100644 index 0000000000..9c9e562d7d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte.test.ts @@ -0,0 +1,74 @@ +import { ProductTourStatus } from '$features/users/models'; +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getProductTourItems } from '../../catalog'; +import ProductTourCatalogDialog from './product-tour-catalog-dialog.svelte'; + +describe('ProductTourCatalogDialog', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(async () => { + cleanup(); + await vi.runOnlyPendingTimersAsync(); + vi.useRealTimers(); + }); + it('distinguishes guides and preserves restart, continue, and unavailable actions', async () => { + // Arrange + const items = getProductTourItems( + { + errorEventAvailability: 'empty', + isProjectConfigurePage: false, + isSetupPage: false, + organizationId: 'organization', + pathname: '/next', + projects: [] + }, + { 'app-overview': { status: ProductTourStatus.Completed, version: 1 } } + ); + const onStart = vi.fn(async () => {}); + render(ProductTourCatalogDialog, { items, onStart, open: true, ready: true, resumableTourName: 'saved-view-create' }); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Restart Explore Exceptionless' })); + await fireEvent.click(screen.getByRole('button', { name: 'Continue Create a saved view' })); + screen.getByRole('button', { name: 'Start Investigate an error' }).click(); + + // Assert + expect(onStart.mock.calls).toEqual([['app-overview'], ['saved-view-create']]); + expect(screen.getByText('Completed')).toBeTruthy(); + const unavailable = screen.getByRole('button', { name: 'Start Investigate an error' }); + expect(unavailable.hasAttribute('disabled')).toBe(true); + expect(document.getElementById(unavailable.getAttribute('aria-describedby')!)?.textContent).toContain('Send an error report'); + expect(screen.getByRole('list', { name: 'Available guides' })).toBeTruthy(); + expect(screen.getAllByRole('listitem')).toHaveLength(5); + const icons = items.map((item) => { + const icon = screen.getByRole('region', { name: item.title }).querySelector('svg'); + expect(icon?.getAttribute('aria-hidden')).toBe('true'); + return icon?.innerHTML; + }); + expect(new Set(icons).size).toBe(5); + }); + + it('keeps the picker focused on outcomes without step counts or documentation detours', () => { + // Arrange + const items = getProductTourItems({ + errorEventAvailability: 'empty', + isProjectConfigurePage: false, + isSetupPage: false, + pathname: '/next', + projects: [] + }); + const onStart = vi.fn(); + + // Act + render(ProductTourCatalogDialog, { items, onStart, open: true, ready: false }); + + // Assert + for (const item of items) { + expect(screen.getByText(item.description)).toBeTruthy(); + } + expect(screen.queryAllByRole('link')).toHaveLength(0); + expect(screen.queryByText(/\d+ steps/)).toBeNull(); + expect(onStart).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-description.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-description.svelte new file mode 100644 index 0000000000..ed3c602e7f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-description.svelte @@ -0,0 +1,29 @@ + + + + {#if typeof description === 'string'} + {description} + {:else} + {@render description()} + {/if} + +{#if shortcuts.length} +
+ {#each shortcuts as { label, shortcut } (label)} + + {label} + {formatKeyboardShortcut(shortcut.keys)} + + {/each} +
+{/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..2fa41b672f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -0,0 +1,409 @@ + + + + +{#if exieAnnouncementOpen && assistantAccess} + +{/if} + + startTour(name, catalogSource)} + ready={hostStateSettled && !!currentUser} + resumableTourName={checkpoint && isActiveTourRenderable(checkpoint) ? checkpoint.tourName : undefined} +/> + +{#if checkpoint && (checkpoint.tourName === 'exie-overview' || checkpoint.tourName === 'app-overview')} + {#key checkpoint} + + {/key} +{/if} 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..2bcad547fd --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte @@ -0,0 +1,212 @@ + + +{#if spotlight && targetReady && (!isAnyOverlayOpen || helpTarget || checkpoint.tourName === 'exie-overview')} + {#key helpTarget} + 0 ? back : undefined} + shortcuts={spotlight.shortcuts} + side={isHelpStep ? 'top' : undefined} + stepCount={steps.length} + stepNumber={stepIndex + 1} + target={helpTarget ?? spotlight.target} + title={spotlight.title} + /> + {/key} +{/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..e7455cf1b9 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte @@ -0,0 +1,303 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte.test.ts new file mode 100644 index 0000000000..4032392b69 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte.test.ts @@ -0,0 +1,99 @@ +import { appKeyboardShortcuts } from '$features/shared/keyboard-shortcuts'; +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ProductTourCheckpoint } from '../models'; + +import ProductTourSpotlight from './product-tour-spotlight.svelte'; +vi.mock('../activity', () => ({ submitProductTourActivity: vi.fn() })); + +const checkpoint: ProductTourCheckpoint = { checkpointName: 'command-search', source: 'catalog', tourName: 'app-overview', userId: 'user', version: 1 }; + +describe('ProductTourSpotlight', () => { + let target: HTMLButtonElement; + + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + disconnect() {} + observe() {} + } + ); + target = document.createElement('button'); + target.scrollIntoView = vi.fn(); + document.body.append(target); + }); + + afterEach(() => { + cleanup(); + target.remove(); + vi.unstubAllGlobals(); + }); + + it('renders safe text and the shared Kbd component in the driver popover', async () => { + // Arrange / Act + render(ProductTourSpotlight, { + props: { + checkpoint, + description: 'Search ', + onDismiss: vi.fn(async () => true), + shortcuts: [{ label: 'Search', shortcut: appKeyboardShortcuts.commandPalette }], + target, + title: 'Search' + } + }); + + // Assert + expect(await screen.findByText('Search ')).toBeTruthy(); + const key = screen.getByText('/'); + expect(key.tagName).toBe('KBD'); + expect(key.getAttribute('data-slot')).toBe('kbd'); + expect(screen.queryByRole('button', { name: 'Back' })).toBeNull(); + expect(screen.getByText('Step 2 of 5')).toBeTruthy(); + cleanup(); + expect(document.querySelector('.product-tour-popover')).toBeNull(); + }); + + it('omits progress when checkpoints include work outside the guide', async () => { + // Arrange / Act + render(ProductTourSpotlight, { + props: { checkpoint, description: 'Choose a platform', onDismiss: vi.fn(async () => true), showProgress: false, target, title: 'Setup' } + }); + + // Assert + expect(await screen.findByText('Choose a platform')).toBeTruthy(); + expect(screen.queryByText(/Step \d of \d/)).toBeNull(); + expect(screen.getByRole('button', { name: 'End guide' })).toBeTruthy(); + }); + + it('enables Back only when the caller provides a safe previous step', async () => { + // Arrange + const onPrevious = vi.fn(); + const onNext = vi.fn(); + render(ProductTourSpotlight, { + props: { checkpoint, description: 'Search', onDismiss: vi.fn(async () => true), onNext, onPrevious, target, title: 'Search' } + }); + const back = await screen.findByRole('button', { name: 'Back' }); + + // Act + await fireEvent.click(back); + + // Assert + expect(back.hasAttribute('disabled')).toBe(false); + expect(onPrevious).toHaveBeenCalledExactlyOnceWith(checkpoint); + expect(onNext).not.toHaveBeenCalled(); + }); + + it('ignores Escape keyup from a closing overlay but handles a fresh Escape press', async () => { + // Arrange + const onDismiss = vi.fn(async () => true); + render(ProductTourSpotlight, { props: { checkpoint, description: 'Search', onDismiss, target, title: 'Search' } }); + + // Act / Assert + await fireEvent.keyUp(window, { key: 'Escape' }); + expect(onDismiss).not.toHaveBeenCalled(); + await fireEvent.keyDown(window, { key: 'Escape' }); + expect(onDismiss).toHaveBeenCalledExactlyOnceWith(checkpoint); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/saved-view-create-tour.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/saved-view-create-tour.svelte new file mode 100644 index 0000000000..6c2729f7b9 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/saved-view-create-tour.svelte @@ -0,0 +1,144 @@ + + +{#if checkpoint?.checkpointName === 'open-view-menu'} + +{:else if checkpoint?.checkpointName === 'review-settings'} + { + closeMenu(); + productTourCheckpoint.advance(active, 'open-view-menu'); + }} + onNext={async () => { + closeMenu(); + await openSaveDialog(); + }} + target="[data-tour='saved-view-save-as']" + title="Save your current view" + > + {#snippet description()} + Select Save As… to name a copy of your current filters and layout. Your existing view stays unchanged. + {/snippet} + +{: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, 'name-view'); + }} + onNext={(active) => { + productTourCheckpoint.advance(active, 'save-view'); + }} + target="[data-tour='saved-view-private']" + title="Keep it private" + /> +{:else if checkpoint?.checkpointName === 'save-view'} + { + productTourCheckpoint.advance(active, 'private-view'); + }} + target="[data-tour='saved-view-submit']" + title="Create the saved view" + > + {#snippet description()} + Select Save in the form to create your view and finish the guide. + {/snippet} + +{:else if checkpoint?.checkpointName === 'view-created' && !completionPending} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/controls.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/controls.svelte.ts new file mode 100644 index 0000000000..0857f6ecde --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/controls.svelte.ts @@ -0,0 +1,18 @@ +import { getContext, setContext } from 'svelte'; + +interface ProductTourControls { + closeOverlays: () => void; + getGuidedToursTarget: () => HTMLElement | undefined; + openCatalog: () => void; + showGuidedToursMenu: () => Promise; +} + +const PRODUCT_TOUR_CONTROLS_CONTEXT_KEY = Symbol.for('exceptionless-product-tour-controls'); + +export function setProductTourControls(controls: ProductTourControls): void { + setContext(PRODUCT_TOUR_CONTROLS_CONTEXT_KEY, controls); +} + +export function tryUseProductTourControls(): ProductTourControls | undefined { + return getContext(PRODUCT_TOUR_CONTROLS_CONTEXT_KEY); +} 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..2102982441 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts @@ -0,0 +1,31 @@ +import { ProductTourStatus } from '$generated/api'; +import { describe, expect, it } from 'vitest'; + +import { isProductTourSetupRoute, shouldOfferProductTourInvitation } from './eligibility'; + +describe('product tour setup routes', () => { + it.each(['/(app)/organization/add', '/(app)/project/add', '/(app)/project/[projectId]/configure'])('suppresses automatic tours on %s', (routeId) => { + expect(isProductTourSetupRoute(routeId)).toBe(true); + }); + + it('allows automatic tours after setup', () => { + expect(isProductTourSetupRoute('/(app)/stack')).toBe(false); + expect(isProductTourSetupRoute(null)).toBe(false); + }); +}); + +describe('product tour invitation eligibility', () => { + it('offers an invitation when no progress has been saved', () => { + expect(shouldOfferProductTourInvitation(undefined, 1)).toBe(true); + }); + + it.each([ProductTourStatus.Completed, ProductTourStatus.Dismissed])('only offers a newer invitation after status %s', (status) => { + // Arrange + const progress = { status, version: 2 }; + + // Act / Assert + expect(shouldOfferProductTourInvitation(progress, 1)).toBe(false); + expect(shouldOfferProductTourInvitation(progress, 2)).toBe(false); + expect(shouldOfferProductTourInvitation(progress, 3)).toBe(true); + }); +}); 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..4db1701c89 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts @@ -0,0 +1,11 @@ +import type { ProductTourProgress } from '$features/users/models'; + +const SETUP_ROUTE_IDS = new Set(['/(app)/organization/add', '/(app)/project/[projectId]/configure', '/(app)/project/add']); + +export function isProductTourSetupRoute(routeId: null | string): boolean { + return !!routeId && SETUP_ROUTE_IDS.has(routeId); +} + +export function shouldOfferProductTourInvitation(progress: ProductTourProgress | undefined, version: number): boolean { + return !progress || progress.version < version; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/models.ts new file mode 100644 index 0000000000..b6dee1897d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/models.ts @@ -0,0 +1,69 @@ +import type { AssistantAccess } from '$features/assistant/models'; +import type { ViewProject } from '$features/projects/models'; +import type { KeyboardShortcut } from '$features/shared/keyboard-shortcuts'; +import type { ProductTourProgress } from '$features/users/models'; +export const PRODUCT_TOUR_CHECKPOINTS = { + 'app-overview': ['navigation', 'command-search', 'saved-views', 'exie', 'help'], + 'event-investigate': ['filter-errors', 'choose-error', 'stack-summary', 'stack-triage', 'event-occurrence', 'tab-overview', 'filter-stack-events'], + 'exie-overview': ['open-exie', 'exie-context'], + 'project-configure': ['organization-name', 'project-name', 'choose-platform', 'sdk-instructions', 'wait-for-event', 'event-received'], + 'saved-view-create': ['open-view-menu', 'review-settings', 'name-view', 'private-view', 'save-view', 'view-created'] +} as const; + +export const PRODUCT_TOUR_LAUNCH_SOURCES = ['welcome', 'catalog', 'command-palette', 'feature-announcement', 'help-menu'] as const; + +export interface ProductTourAvailability { + available: boolean; + reason?: string; +} +export type ProductTourCheckpoint = Name extends ProductTourName + ? { + checkpointName: ProductTourCheckpointName; + organizationId?: string; + 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'; + isProjectConfigurePage: boolean; + isSetupPage: boolean; + organizationId?: string; + pathname: string; + projects?: Pick[]; + search?: string; +} +export interface ProductTourDefinition { + availability: (context: ProductTourContext) => ProductTourAvailability; + canResume: (checkpointName: ProductTourCheckpointName, routeId: null | string) => boolean; + description: string; + keywords: readonly string[]; + name: Name; + start: (context: ProductTourContext) => ProductTourStart; + title: string; + version: number; +} +export type ProductTourKey = 'app-welcome' | 'exie-announcement' | ProductTourName; + +export type ProductTourLaunchSource = (typeof PRODUCT_TOUR_LAUNCH_SOURCES)[number]; + +export interface ProductTourListItem extends ProductTourDefinition { + currentAvailability: ProductTourAvailability; + progress?: ProductTourProgress; +} + +export type ProductTourName = keyof typeof PRODUCT_TOUR_CHECKPOINTS; + +export interface ProductTourShortcut { + label: string; + shortcut: KeyboardShortcut; +} + +export interface ProductTourStart { + checkpointName: ProductTourCheckpointName; + route: string; +} 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..0f1ae61ad7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { ProductTourCheckpoint } from './models'; + +import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; + +const checkpoint: ProductTourCheckpoint = { + checkpointName: 'choose-error', + organizationId: 'organization-id', + source: 'command-palette', + tourName: 'event-investigate', + userId: 'user-id', + version: 1 +}; + +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, version: 0 }), + 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); + }); + + it('drops obsolete workflow state from an existing browser session', () => { + sessionStorage.setItem('exceptionless.product-tour', JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created', viewId: 'view-id' } })); + expect(readProductTourSession()).toEqual(checkpoint); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.test.ts new file mode 100644 index 0000000000..9c01883ae6 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; + +const unavailable = { + getItem() { + throw new DOMException('Denied', 'SecurityError'); + }, + removeItem() { + throw new DOMException('Denied', 'SecurityError'); + }, + setItem() { + throw new DOMException('Full', 'QuotaExceededError'); + } +}; + +describe('product-tour session persistence', () => { + it('tolerates unavailable storage on read, write, and clear', () => { + // Arrange + const checkpoint = { checkpointName: 'navigation', source: 'catalog', tourName: 'app-overview', userId: 'user', version: 1 } as const; + + // Act & Assert + expect(readProductTourSession(unavailable)).toBeUndefined(); + expect(() => writeProductTourSession(checkpoint, unavailable)).not.toThrow(); + expect(() => clearProductTourSession(unavailable)).not.toThrow(); + }); + + it('round-trips only the current functional checkpoint', () => { + // Arrange + const checkpoint = { + checkpointName: 'navigation', + source: 'catalog', + tourName: 'app-overview', + userId: 'user', + version: 1 + } as const; + let value: null | string = null; + const storage = { + getItem: () => value, + removeItem: () => { + value = null; + }, + setItem: (_key: string, next: string) => { + value = next; + } + }; + + // Act + writeProductTourSession(checkpoint, storage); + + // Assert + expect(readProductTourSession(storage)).toEqual(checkpoint); + }); +}); 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..d639d69b71 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts @@ -0,0 +1,87 @@ +import type { ProductTourCheckpoint, ProductTourLaunchSource, ProductTourName } from './models'; + +import { PRODUCT_TOUR_CHECKPOINTS, PRODUCT_TOUR_LAUNCH_SOURCES } from './models'; + +const SESSION_KEY = 'exceptionless.product-tour'; +const SOURCES = new Set(PRODUCT_TOUR_LAUNCH_SOURCES); + +export function clearProductTourSession(storage?: Pick): void { + try { + (storage ?? sessionStorage).removeItem(SESSION_KEY); + } catch { + // The guide can still run in memory when browser storage is unavailable. + } +} + +export function readProductTourSession(storage?: Pick): ProductTourCheckpoint | undefined { + try { + const value = (storage ?? sessionStorage).getItem(SESSION_KEY); + if (!value) { + return undefined; + } + + const candidate: unknown = JSON.parse(value); + if (!isProductTourCheckpoint(candidate)) { + clearProductTourSession(storage); + return undefined; + } + + return { + checkpointName: candidate.checkpointName, + organizationId: candidate.organizationId, + source: candidate.source, + tourName: candidate.tourName, + userId: candidate.userId, + version: candidate.version + } as ProductTourCheckpoint; + } catch { + clearProductTourSession(storage); + return undefined; + } +} + +export function writeProductTourSession(checkpoint: ProductTourCheckpoint, storage?: Pick): void { + try { + (storage ?? sessionStorage).setItem(SESSION_KEY, JSON.stringify(checkpoint)); + } catch { + // Persistence is best effort; the in-memory checkpoint remains usable. + } +} + +function isProductTourCheckpoint(value: unknown): value is ProductTourCheckpoint { + 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; + } + + const checkpoints: readonly string[] = PRODUCT_TOUR_CHECKPOINTS[value.tourName]; + if (typeof value.checkpointName !== 'string' || !checkpoints.includes(value.checkpointName)) { + return false; + } + return true; +} + +function isProductTourLaunchSource(value: unknown): value is ProductTourLaunchSource { + return typeof value === 'string' && SOURCES.has(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..d0e001c376 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { ProductTourCheckpoint } from './models'; + +import { productTourCheckpoint } from './state.svelte'; + +const checkpoint: ProductTourCheckpoint = { + checkpointName: 'navigation', + organizationId: 'organization-id', + source: 'catalog', + tourName: 'app-overview', + userId: 'user-id', + version: 1 +}; + +describe('product tour checkpoint store', () => { + beforeEach(() => productTourCheckpoint.clear()); + + it('preserves the current checkpoint across forward and back navigation', () => { + // Arrange + const first = productTourCheckpoint.start('app-overview', 'navigation', 'catalog', 'user', 1); + + // Act & Assert + const second = productTourCheckpoint.advance(first, 'command-search')!; + expect(productTourCheckpoint.current).toBe(second); + const back = productTourCheckpoint.advance(second, 'navigation')!; + expect(back.checkpointName).toBe('navigation'); + const replay = productTourCheckpoint.start('app-overview', 'navigation', 'catalog', 'user', 1); + expect(productTourCheckpoint.current).toBe(replay); + }); + + it('does not let stale work advance or clear a newer tour', () => { + 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); + expect(productTourCheckpoint.current).toBe(second); + }); + + it('clears a checkpoint restored for another identity', () => { + productTourCheckpoint.start( + checkpoint.tourName, + checkpoint.checkpointName, + checkpoint.source, + checkpoint.userId, + checkpoint.version, + checkpoint.organizationId + ); + 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..aa0de5b6a3 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts @@ -0,0 +1,78 @@ +import type { ProductTourCheckpoint, ProductTourCheckpointName, ProductTourLaunchSource, ProductTourName } from './models'; + +import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; + +class ProductTourCheckpointStore { + current = $state.raw(); + + advance( + expected: ProductTourCheckpoint, + checkpointName: ProductTourCheckpointName, + organizationId = expected.organizationId + ): ProductTourCheckpoint | undefined { + if (this.current !== expected) { + return undefined; + } + const next = { + ...expected, + checkpointName, + organizationId + } as ProductTourCheckpoint; + return this.save(next); + } + + 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( + tourName: Name, + checkpointName: ProductTourCheckpointName, + source: ProductTourLaunchSource, + userId: string, + version: number, + organizationId?: string + ): ProductTourCheckpoint { + const checkpoint = { + checkpointName, + organizationId, + source, + tourName, + userId, + version + } as 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/user-cache.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/user-cache.svelte.test.ts new file mode 100644 index 0000000000..704cdb3f38 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/user-cache.svelte.test.ts @@ -0,0 +1,62 @@ +import type { ProductTourProgress, ViewCurrentUser } from '$generated/api'; + +import { putCurrentUserProductTour, queryKeys } from '$features/users/api.svelte'; +import { MutationObserver, type MutationObserverOptions, QueryClient, QueryObserver } from '@tanstack/svelte-query'; +import { describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ putJSON: vi.fn(), useQueryClient: vi.fn() })); +vi.mock('$env/dynamic/public', () => ({ env: {} })); +vi.mock('@foundatiofx/fetchclient', async (importOriginal) => ({ + ...(await importOriginal()), + useFetchClient: () => ({ putJSON: mocks.putJSON }) +})); +vi.mock('@tanstack/svelte-query', async (importOriginal) => ({ + ...(await importOriginal()), + createMutation: (options: () => MutationObserverOptions) => { + const observer = new MutationObserver(mocks.useQueryClient(), options()); + return { mutateAsync: (variables: TVariables) => observer.mutate(variables) }; + }, + useQueryClient: mocks.useQueryClient +})); + +describe('guided-tour user cache invalidation', () => { + it.each([false, true])('refetches authoritative progress without merging the response (account changed: %s)', async (changeAccount) => { + // Arrange + vi.resetAllMocks(); + const queryClient = new QueryClient(); + mocks.useQueryClient.mockReturnValue(queryClient); + const initial = { id: 'first-user', product_tours: {} } as ViewCurrentUser; + const current = { ...initial, id: changeAccount ? 'second-user' : initial.id }; + const serverUser = { ...current, product_tours: { 'app-overview': { status: 1, version: 1 } } } as ViewCurrentUser; + queryClient.setQueryData(queryKeys.me(), initial); + queryClient.setQueryData(queryKeys.id(initial.id), initial); + const refresh = Promise.withResolvers(); + const queryFn = vi.fn(() => refresh.promise); + const observer = new QueryObserver(queryClient, { queryFn, queryKey: queryKeys.me(), staleTime: Infinity }); + const unsubscribe = observer.subscribe(() => {}); + const request = Promise.withResolvers<{ data: ProductTourProgress; ok: boolean }>(); + mocks.putJSON.mockReturnValue(request.promise); + const progress: ProductTourProgress = { status: 2, version: 1 }; + + try { + const pending = putCurrentUserProductTour().mutateAsync({ progress, tourName: 'app-overview' }); + await vi.waitFor(() => expect(mocks.putJSON).toHaveBeenCalledOnce()); + queryClient.setQueryData(queryKeys.me(), current); + + // Act + request.resolve({ data: progress, ok: true }); + await vi.waitFor(() => expect(queryFn).toHaveBeenCalledOnce()); + expect(queryClient.getQueryData(queryKeys.me())).toEqual(current); + refresh.resolve(serverUser); + await pending; + + // Assert + expect(queryClient.getQueryData(queryKeys.me())).toEqual(serverUser); + expect(queryClient.getQueryState(queryKeys.id(initial.id))?.isInvalidated).toBe(true); + expect(queryClient.getQueryData(queryKeys.id(initial.id))).toEqual(initial); + } finally { + unsubscribe(); + queryClient.clear(); + } + }); +}); 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..d8e26fc82f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -5,6 +5,7 @@ import { Input } from '$comp/ui/input'; import { Label } from '$comp/ui/label'; import { Switch } from '$comp/ui/switch'; + import { untrack } from 'svelte'; import type { SavedView } from '../models'; @@ -19,6 +20,7 @@ } from '../slugs'; interface Props { + defaultPrivate?: boolean; duplicateView?: SavedView; onClose: () => void; onLoadView: (view: SavedView) => void; @@ -28,7 +30,7 @@ saving: boolean; } - let { duplicateView, onClose, onLoadView, onSave, open = $bindable(), savedViews, saving }: Props = $props(); + let { defaultPrivate = false, duplicateView, onClose, onLoadView, onSave, open = $bindable(), savedViews, saving }: Props = $props(); let saveName = $state(''); let saveSlug = $state(''); @@ -87,7 +89,7 @@ saveName = ''; saveSlug = ''; isSlugDirty = false; - isPrivate = false; + isPrivate = untrack(() => defaultPrivate); attemptedSubmit = false; } }); @@ -115,7 +117,14 @@ } - + { + if (!nextOpen) { + onClose(); + } + }} +> Save View @@ -146,6 +155,7 @@
{visibleSlugError}

{/if}
-
+
Only visible to you @@ -185,8 +195,8 @@
- - + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte.test.ts new file mode 100644 index 0000000000..7a96b89106 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte.test.ts @@ -0,0 +1,32 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import SaveViewDialog from './save-view-dialog.svelte'; + +describe('SaveViewDialog', () => { + it('preserves the draft when the guide default changes while open', async () => { + const { rerender } = render(SaveViewDialog, { + defaultPrivate: true, + onClose: vi.fn(), + onLoadView: vi.fn(), + onSave: vi.fn(), + open: true, + savedViews: [], + saving: false + }); + await fireEvent.input(screen.getByLabelText('Name'), { target: { value: 'My errors' } }); + + await rerender({ defaultPrivate: false }); + + expect(screen.getByLabelText('Name')).toHaveValue('My errors'); + expect(screen.getByLabelText('URL name')).toHaveValue('my-errors'); + expect(screen.getByRole('switch', { name: 'Private' })).toHaveAttribute('aria-checked', 'true'); + + await rerender({ open: false }); + await rerender({ open: true }); + + expect(screen.getByLabelText('Name')).toHaveValue(''); + expect(screen.getByRole('switch', { name: 'Private' })).toHaveAttribute('aria-checked', 'false'); + }); +}); 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..68aeb68a5f 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,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 SavedViewCreateTour from '$features/product-tours/components/saved-view-create-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'; @@ -120,6 +121,7 @@ let isColumnDialogOpen = $state(false); let isMenuOpen = $state(false); let viewToDelete = $state(null); + let savedViewCreateTour = $state(); const organizationId = $derived(organization.current); const activeView = $derived(activeSavedView); @@ -219,6 +221,7 @@ async function openSaveDialog() { await tick(); isSaveDialogOpen = true; + savedViewCreateTour?.openingSaveDialog(); } async function openRenameDialog() { @@ -259,6 +262,7 @@ return; } + const tour = savedViewCreateTour; const filterDefinitions = serializeFilters(filters); const body: NewSavedView = { columns: getSavedColumnSettings(), @@ -279,6 +283,9 @@ const result = await createMutation.mutateAsync(body); isSaveDialogOpen = false; onLoadView(result); + if (tour) { + await tour.created(); + } toast.success(`Saved view "${result.name}" created.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); @@ -393,7 +400,7 @@ {#snippet child({ props })} - {/snippet} - + Saved View {#if activeView} @@ -411,7 +418,7 @@ Save {/if} - + @@ -494,15 +501,24 @@ {#if isSaveDialogOpen} (isSaveDialogOpen = false)} + onClose={() => savedViewCreateTour?.closed()} {onLoadView} /> {/if} + (isMenuOpen = false)} + {isMenuOpen} + openMenu={() => (isMenuOpen = true)} + {openSaveDialog} +/> + {#if isRenameDialogOpen && activeView} facet.filter.id === f.id); + // Raw-filter drafts can contain multiple filters with the same key. + const sameKeyFacets = facets.filter((facet) => facet.filter.key === f.key); + const existing = + facets.find((facet) => facet.filter.id === f.id) ?? + (sameKeyFacets.length === 1 && filters.filter((candidate) => candidate.key === f.key).length === 1 ? sameKeyFacets[0] : undefined); if (existing) { + if (lastOpenFilterId === existing.filter.id) { + lastOpenFilterId = f.id; + } existing.filter = f; existing.component = builder.component; existing.title = builder.title; @@ -198,7 +205,7 @@ {@render children()} {/if} -{#each visibleFacets as facet (facet.filter.id)} +{#each visibleFacets as facet (facet)} {@const Facet = facet.component}
{ + it('keeps duplicate raw filters distinct when another filter is added', async () => { + // Arrange + const local = new KeywordFilter('error.type:Local'); + const remote = new KeywordFilter('error.type:Remote'); + const view = render(Harness, { changed: vi.fn(), filters: [local], remove: vi.fn() }); + const original = await screen.findByRole('button', { name: /^Raw Filter.*error\.type:Local/ }); + + // Act + await view.rerender({ filters: [local, remote] }); + + // Assert + expect(screen.getByRole('button', { name: /^Raw Filter.*error\.type:Local/ })).toBe(original); + expect(screen.getByRole('button', { name: /^Raw Filter.*error\.type:Remote/ })).not.toBe(original); + + await view.rerender({ filters: [new KeywordFilter('error.type:Local'), new KeywordFilter('error.type:Remote')] }); + expect(screen.getAllByRole('button', { name: /^Raw Filter/ })).toHaveLength(2); + }); + + it('opens a newly added filter after the parent supplies it', async () => { + // Arrange + const changed = vi.fn<(filter: IFilter) => void>(); + const view = render(Harness, { changed, filters: [], remove: vi.fn() }); + await fireEvent.click(screen.getByRole('button', { name: 'Manage filters' })); + + // Act + await fireEvent.click(await screen.findByRole('option', { name: 'Date' })); + expect(changed).toHaveBeenCalledOnce(); + const added = changed.mock.calls[0]![0]; + await view.rerender({ filters: [added] }); + + // Assert + expect(screen.getByRole('button', { name: /^Date/ }).getAttribute('aria-expanded')).toBe('true'); + }); + + it('does not reopen a removed filter when it is added again', async () => { + // Arrange + const view = render(Harness, { changed: vi.fn(), filters: [new DateFilter('date', '[now-90d TO now]')], remove: vi.fn() }); + await fireEvent.click(await screen.findByRole('button', { name: /^Date/ })); + await screen.findByRole('button', { name: 'Last 30 days' }); + + // Act + await view.rerender({ filters: [] }); + await view.rerender({ filters: [new DateFilter('date', '[now-7d TO now]')] }); + + // Assert + expect(screen.getByRole('button', { name: /^Date/ }).getAttribute('aria-expanded')).toBe('false'); + }); + + it('keeps the date picker open when hydration replaces a filter instance', async () => { + // Arrange + const changed = vi.fn(); + const initial = new DateFilter('date', '[now-90d TO now]'); + const hydrated = new DateFilter('date', '[now-90d TO now]'); + const view = render(Harness, { changed, filters: [initial], remove: vi.fn() }); + const trigger = await screen.findByRole('button', { name: /^Date/ }); + await fireEvent.click(trigger); + await screen.findByRole('button', { name: 'Last 30 days' }); + + // Act + await view.rerender({ filters: [hydrated] }); + + // Assert + await waitFor(() => expect(trigger.getAttribute('aria-expanded')).toBe('true')); + expect(screen.getByRole('button', { name: /^Date/ })).toBe(trigger); + await fireEvent.click(screen.getByRole('button', { name: 'Last 30 days' })); + expect(changed).toHaveBeenCalledWith(hydrated); + expect(hydrated.value).toBe('[now-30d TO now]'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.test-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.test-harness.svelte new file mode 100644 index 0000000000..0649f5b3b7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.test-harness.svelte @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte index a4122d895a..ce02f11c3f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte @@ -40,6 +40,7 @@ > - + @@ -192,7 +192,7 @@ -
+
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 ff97db0e6d..1f4f57b2af 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,29 @@ 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); + + if (!response.ok) { + throw response.problem; + } + + return response.data!; + }, + mutationKey: queryKeys.productTour(undefined), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: queryKeys.type + }); + } + })); +} + 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 8db136b95e..306d4ce5d9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -7,6 +7,24 @@ export enum StackStatus { Discarded = "discarded", } +export enum ProductTourStatus { + Completed = 1, + Dismissed = 2, +} + +export enum ProductTourLaunchSource { + Welcome = "welcome", + Catalog = "catalog", + CommandPalette = "command-palette", + FeatureAnnouncement = "feature-announcement", + HelpMenu = "help-menu", +} + +export enum ProductTourKind { + Guide = "guide", + Prompt = "prompt", +} + export enum BillingStatus { Trialing = 0, Active = 1, @@ -494,6 +512,59 @@ export interface ProblemDetails { instance?: null | string; } +export interface ProductTourActivity { + /** @format date-time */ + date_utc: string; + /** @format int64 */ + shown: number; + /** @format int64 */ + started: number; + /** @format int64 */ + completed: number; + /** @format int64 */ + dismissed: number; +} + +export interface ProductTourProgress { + status: ProductTourStatus; + /** @format int32 */ + version: number; +} + +export interface ProductTourStartSource { + source: ProductTourLaunchSource; + /** @format int64 */ + count: number; +} + +export interface ProductTourSummary { + name: string; + /** @format int32 */ + version: number; + kind: ProductTourKind; + /** @format int64 */ + shown: number; + /** @format int64 */ + started: number; + /** @format int64 */ + completed: number; + /** @format int64 */ + dismissed: number; + /** @format date-time */ + last_run_utc?: null | string; + start_sources: ProductTourStartSource[]; + activity: ProductTourActivity[]; +} + +export interface ProductTourUsageResponse { + /** @format date-time */ + utc_start?: null | string; + /** @format date-time */ + utc_end: string; + tours: ProductTourSummary[]; + collection_available: boolean; +} + export interface ResetPasswordModel { password_reset_token: string; password: string; @@ -648,6 +719,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; @@ -737,6 +818,7 @@ export interface User { o_auth_accounts: OAuthAccount[]; organization_preferences: UserOrganizationPreference[]; saved_view_orders: UserSavedViewOrderPreference[]; + product_tours: Record; /** Gets or sets the users Full Name. */ full_name: string; /** @format email */ @@ -784,6 +866,7 @@ export interface ViewCurrentUser { o_auth_accounts: OAuthAccount[]; organization_preferences: UserOrganizationPreference[]; saved_view_orders: UserSavedViewOrderPreference[]; + 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 ec58c99678..f91ccb4942 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -27,6 +27,15 @@ export const StackStatusSchema = zodEnum([ "ignored", "discarded", ]); +export const ProductTourStatusSchema = union([literal(1), literal(2)]); +export const ProductTourLaunchSourceSchema = zodEnum([ + "welcome", + "catalog", + "command-palette", + "feature-announcement", + "help-menu", +]); +export const ProductTourKindSchema = zodEnum(["guide", "prompt"]); export const BillingStatusSchema = union([ literal(0), literal(1), @@ -626,6 +635,57 @@ export const ProblemDetailsSchema = object({ }); export type ProblemDetailsFormData = Infer; +export const ProductTourActivitySchema = object({ + date_utc: iso.datetime(), + shown: int(), + started: int(), + completed: int(), + dismissed: int(), +}); +export type ProductTourActivityFormData = Infer< + typeof ProductTourActivitySchema +>; + +export const ProductTourProgressSchema = object({ + status: ProductTourStatusSchema, + version: int32(), +}); +export type ProductTourProgressFormData = Infer< + typeof ProductTourProgressSchema +>; + +export const ProductTourStartSourceSchema = object({ + source: ProductTourLaunchSourceSchema, + count: int(), +}); +export type ProductTourStartSourceFormData = Infer< + typeof ProductTourStartSourceSchema +>; + +export const ProductTourSummarySchema = object({ + name: string().min(1, "Name is required"), + version: int32(), + kind: ProductTourKindSchema, + shown: int(), + started: int(), + completed: int(), + dismissed: int(), + last_run_utc: iso.datetime().nullable(), + start_sources: array(lazy(() => ProductTourStartSourceSchema)), + activity: array(lazy(() => ProductTourActivitySchema)), +}); +export type ProductTourSummaryFormData = Infer; + +export const ProductTourUsageResponseSchema = object({ + utc_start: iso.datetime().nullable(), + utc_end: iso.datetime(), + tours: array(lazy(() => ProductTourSummarySchema)), + collection_available: boolean(), +}); +export type ProductTourUsageResponseFormData = Infer< + typeof ProductTourUsageResponseSchema +>; + export const ResetPasswordModelSchema = object({ password_reset_token: string().length( 40, @@ -768,6 +828,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(), @@ -862,6 +932,10 @@ export const UserSchema = object({ o_auth_accounts: array(lazy(() => OAuthAccountSchema)), organization_preferences: array(lazy(() => UserOrganizationPreferenceSchema)), saved_view_orders: array(lazy(() => UserSavedViewOrderPreferenceSchema)), + product_tours: record( + string(), + lazy(() => ProductTourProgressSchema), + ), full_name: string().min(1, "Full name is required"), email_address: email(), avatar_file_name: string() @@ -919,6 +993,10 @@ export const ViewCurrentUserSchema = object({ o_auth_accounts: array(lazy(() => OAuthAccountSchema)), organization_preferences: array(lazy(() => UserOrganizationPreferenceSchema)), saved_view_orders: array(lazy(() => UserSavedViewOrderPreferenceSchema)), + 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 dda09bd8e4..f5f87f094c 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 @@ -41,6 +41,7 @@ +
+
+ + {#if !env.PUBLIC_EXCEPTIONLESS_API_KEY} + + Application telemetry is not configured + + Guide activity uses the application's Exceptionless client. Configure application telemetry to collect new activity. Saved guide progress is + unaffected. + + + {/if} + + {#if usage && !usage.collection_available} + + Guide activity collection is unavailable + The internal storage project is unavailable. Guides and saved progress still work; the charts show previously recorded activity. + + {/if} + + {#if usageQuery.isError} + + +

Failed to load guided-tour usage. Please try again.

+ +
+
+ {:else if usageQuery.isPending} +
+ {#each [0, 1, 2, 3] as index (index)} + + {/each} +
+ {:else if usage?.tours.length === 0} + No guided-tour activity was recorded in this period. + {:else} + {@render TourCards(guides)} + {#if invitations.length > 0} +
+

Invitations

+ {@render TourCards(invitations)} +
+ {/if} + {/if} +
+ +{#snippet TourCards(tours: ProductTourSummary[])} +
+ {#each tours as tour (`${tour.name}:${tour.version}`)} + + +
+ {title(tour.name)} v{tour.version} + +
+ {#if tour.last_run_utc} + Last event + {/if} +
+ + {#if usage} + + {/if} + +
+ {/each} +
+{/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/routes.svelte.ts index 1b41f9a74a..c9ca0b4a11 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/routes.svelte.ts @@ -6,6 +6,7 @@ import Database from '@lucide/svelte/icons/database'; import DatabaseZap from '@lucide/svelte/icons/database-zap'; import KeyRound from '@lucide/svelte/icons/key-round'; import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard'; +import Map from '@lucide/svelte/icons/map'; import Play from '@lucide/svelte/icons/play'; import Settings from '@lucide/svelte/icons/settings'; @@ -62,6 +63,13 @@ export function routes(): NavigationItem[] { show: (context) => context.user?.roles?.includes('global') ?? false, title: 'Saved Views' }, + { + group: 'System', + href: resolve('/(app)/system/product-tours'), + icon: Map, + show: (context) => context.user?.roles?.includes('global') ?? false, + title: 'Guided Tours' + }, { group: 'System', href: resolve('/(app)/system/oauth-applications'), diff --git a/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs b/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs new file mode 100644 index 0000000000..24cf7ec7c1 --- /dev/null +++ b/src/Exceptionless.Web/Models/Admin/ProductTourUsageResponse.cs @@ -0,0 +1,27 @@ +using Exceptionless.Core.Models.Data; + +namespace Exceptionless.Web.Models.Admin; + +public sealed record ProductTourUsageResponse( + DateTime? UtcStart, + DateTime UtcEnd, + IReadOnlyCollection Tours) +{ + public bool CollectionAvailable { get; init; } +} + +public sealed record ProductTourSummary( + string Name, + int Version, + ProductTourKind Kind, + long Shown, + long Started, + long Completed, + long Dismissed, + DateTime? LastRunUtc, + IReadOnlyCollection StartSources, + IReadOnlyCollection Activity); + +public sealed record ProductTourStartSource(ProductTourLaunchSource Source, long Count); + +public sealed record ProductTourActivity(DateTime DateUtc, long Shown, long Started, long Completed, long Dismissed); diff --git a/src/Exceptionless.Web/Models/User/UpdateProductTourProgress.cs b/src/Exceptionless.Web/Models/User/UpdateProductTourProgress.cs new file mode 100644 index 0000000000..249d265eb3 --- /dev/null +++ b/src/Exceptionless.Web/Models/User/UpdateProductTourProgress.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using Exceptionless.Core.Models.Data; + +namespace Exceptionless.Web.Models; + +public record UpdateProductTourProgress +{ + [Required] + [EnumDataType(typeof(ProductTourStatus))] + public ProductTourStatus? Status { get; init; } + + [Range(1, Int32.MaxValue)] + public int Version { get; init; } +} diff --git a/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs b/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs index 0fb4c4f5a7..831150634f 100644 --- a/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs +++ b/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs @@ -2,6 +2,7 @@ using System.Text; using Exceptionless.Core.Configuration; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; namespace Exceptionless.Web.Models; @@ -24,6 +25,7 @@ public ViewCurrentUser(User user, IntercomOptions options) Hash = HMACSHA256HashString(user.Id, options); HasLocalAccount = !String.IsNullOrWhiteSpace(user.Password); OAuthAccounts = user.OAuthAccounts; + ProductTours = new Dictionary(user.ProductTours, StringComparer.Ordinal); } public string? Hash { get; set; } @@ -31,6 +33,7 @@ public ViewCurrentUser(User user, IntercomOptions options) public ICollection OAuthAccounts { get; set; } public ICollection OrganizationPreferences { get; set; } public ICollection SavedViewOrders { get; set; } + public IDictionary ProductTours { get; set; } = new Dictionary(StringComparer.Ordinal); private static string? HMACSHA256HashString(string value, IntercomOptions options) { diff --git a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json index d258345337..2b2c591c05 100644 --- a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json +++ b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json @@ -523,6 +523,18 @@ "authorizationRoles": [], "authenticationSchemes": [] }, + { + "method": "GET", + "route": "/api/v2/admin/product-tour-usage", + "displayName": "HTTP: GET api/v2/admin/product-tour-usage =\u003E GetProductTourUsageAsync", + "tags": [], + "allowAnonymous": false, + "authorizationPolicies": [ + "GlobalAdminPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, { "method": "GET", "route": "/api/v2/admin/requeue", @@ -2712,6 +2724,20 @@ "authorizationRoles": [], "authenticationSchemes": [] }, + { + "method": "PUT", + "route": "/api/v2/users/me/product-tours/{tourName:regex(^[a-z0-9]\u002B(?:-[a-z0-9]\u002B)*$):maxlength(64)}", + "displayName": "HTTP: PUT api/v2/users/me/product-tours/{tourName:regex(^[a-z0-9]\u002B(?:-[a-z0-9]\u002B)*$):maxlength(64)}", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, { "method": "POST", "route": "/api/v2/users/unverify-email-address", diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 6e88d09679..d53d9629d5 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -428,6 +428,69 @@ } } }, + "/api/v2/admin/product-tour-usage": { + "get": { + "tags": [ + "AdminEndpoints" + ], + "parameters": [ + { + "name": "start", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "end", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductTourUsageResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + } + } + }, "/.well-known/oauth-authorization-server": { "get": { "tags": [ @@ -3954,6 +4017,80 @@ } } }, + "/api/v2/users/me/product-tours/{tourName}": { + "put": { + "tags": [ + "User" + ], + "summary": "Update current user product tour progress", + "parameters": [ + { + "name": "tourName", + "in": "path", + "description": "The stable product tour name.", + "required": true, + "schema": { + "maxLength": 64, + "pattern": "^[a-z0-9]\u002B(?:-[a-z0-9]\u002B)*$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The versioned product tour outcome.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProductTourProgress" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductTourProgress" + } + } + } + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "The product tour progress is invalid.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The current user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/v2/users/me/oauth-grants": { "get": { "tags": [ @@ -13229,6 +13366,200 @@ } } }, + "ProductTourActivity": { + "required": [ + "date_utc", + "shown", + "started", + "completed", + "dismissed" + ], + "type": "object", + "properties": { + "date_utc": { + "type": "string", + "format": "date-time" + }, + "shown": { + "type": "integer", + "format": "int64" + }, + "started": { + "type": "integer", + "format": "int64" + }, + "completed": { + "type": "integer", + "format": "int64" + }, + "dismissed": { + "type": "integer", + "format": "int64" + } + } + }, + "ProductTourKind": { + "enum": [ + "guide", + "prompt" + ], + "x-enumNames": [ + "Guide", + "Prompt" + ] + }, + "ProductTourLaunchSource": { + "enum": [ + "welcome", + "catalog", + "command-palette", + "feature-announcement", + "help-menu" + ], + "x-enumNames": [ + "Welcome", + "Catalog", + "CommandPalette", + "FeatureAnnouncement", + "HelpMenu" + ] + }, + "ProductTourProgress": { + "required": [ + "status", + "version" + ], + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/ProductTourStatus" + }, + "version": { + "type": "integer", + "format": "int32" + } + } + }, + "ProductTourStartSource": { + "required": [ + "source", + "count" + ], + "type": "object", + "properties": { + "source": { + "$ref": "#/components/schemas/ProductTourLaunchSource" + }, + "count": { + "type": "integer", + "format": "int64" + } + } + }, + "ProductTourStatus": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "x-enumNames": [ + "Completed", + "Dismissed" + ] + }, + "ProductTourSummary": { + "required": [ + "name", + "version", + "kind", + "shown", + "started", + "completed", + "dismissed", + "last_run_utc", + "start_sources", + "activity" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "int32" + }, + "kind": { + "$ref": "#/components/schemas/ProductTourKind" + }, + "shown": { + "type": "integer", + "format": "int64" + }, + "started": { + "type": "integer", + "format": "int64" + }, + "completed": { + "type": "integer", + "format": "int64" + }, + "dismissed": { + "type": "integer", + "format": "int64" + }, + "last_run_utc": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "start_sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductTourStartSource" + } + }, + "activity": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductTourActivity" + } + } + } + }, + "ProductTourUsageResponse": { + "required": [ + "utc_start", + "utc_end", + "tours", + "collection_available" + ], + "type": "object", + "properties": { + "utc_start": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "utc_end": { + "type": "string", + "format": "date-time" + }, + "tours": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductTourSummary" + } + }, + "collection_available": { + "type": "boolean" + } + } + }, "ResetPasswordModel": { "required": [ "password_reset_token", @@ -13637,6 +13968,31 @@ } } }, + "UpdateProductTourProgress": { + "required": [ + "status", + "version" + ], + "type": "object", + "properties": { + "status": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ProductTourStatus" + } + ] + }, + "version": { + "maximum": 2147483647, + "minimum": 1, + "type": "integer", + "format": "int32" + } + } + }, "UpdateProject": { "type": "object", "properties": { @@ -13866,6 +14222,7 @@ "o_auth_accounts", "organization_preferences", "saved_view_orders", + "product_tours", "email_notifications_enabled", "is_email_address_verified", "verify_email_address_token_expiration", @@ -13931,6 +14288,12 @@ "$ref": "#/components/schemas/UserSavedViewOrderPreference" } }, + "product_tours": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ProductTourProgress" + } + }, "full_name": { "type": "string", "description": "Gets or sets the users Full Name." @@ -14062,6 +14425,7 @@ "o_auth_accounts", "organization_preferences", "saved_view_orders", + "product_tours", "id", "organization_ids", "full_name", @@ -14101,6 +14465,12 @@ "$ref": "#/components/schemas/UserSavedViewOrderPreference" } }, + "product_tours": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ProductTourProgress" + } + }, "id": { "maxLength": 24, "minLength": 24, diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs new file mode 100644 index 0000000000..05b6f8229c --- /dev/null +++ b/tests/Exceptionless.Tests/Api/Endpoints/AdminProductTourUsageEndpointTests.cs @@ -0,0 +1,241 @@ +using Exceptionless.Core; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Utility; +using Exceptionless.DateTimeExtensions; +using Exceptionless.Tests.Extensions; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Models.Admin; +using Xunit; + +namespace Exceptionless.Tests.Api.Endpoints; + +public sealed class AdminProductTourUsageEndpointTests : IntegrationTestsBase +{ + private readonly AppOptions _appOptions; + + public AdminProductTourUsageEndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _appOptions = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await GetService().CreateDataAsync(); + } + + [Fact] + public async Task GetProductTourUsageAsync_AsGlobalAdmin_ReturnsMonthlyCountsAndKnownRows() + { + // Arrange + var month = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(month.AddDays(20)); + + await CreateDataAsync(builder => + { + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(2), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.HelpMenu), month.AddDays(3), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(4), "user-2"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Completed, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(5), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Dismissed, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(6), "user-2"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Shown, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Welcome), month.AddDays(1), "user-1", 2); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Welcome), month.AddDays(1).AddHours(1), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Completed, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Welcome), month.AddDays(1).AddHours(2), "user-1"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Dismissed, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Welcome), month.AddDays(2), "user-1"); + AddUsage(builder, "product-tour.started.unknown.v1.unknown-source", month.AddDays(8), "user-4"); + + builder.Event() + .TestProject() + .Type(Event.KnownTypes.FeatureUsage) + .Source("product-tour.started.ignored-tour.v1.catalog") + .Date(month.AddDays(9)) + .UserIdentity("user-5"); + AddUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddMonths(-1), "user-6"); + }); + + // Act + var response = await SendRequestAsAsync(request => request + .AsGlobalAdminUser() + .AppendPaths("admin", "product-tour-usage") + .QueryString("start", "2026-08-01T00:00:00Z") + .QueryString("end", "2026-09-01T00:00:00Z") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(response); + Assert.Equal(month, response.UtcStart); + Assert.Equal(month.AddMonths(1), response.UtcEnd); + Assert.Equal(ProductTours.Definitions.Values.Sum(definition => definition.CurrentVersion), response.Tours.Count); + + var overview = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.AppOverview, StringComparison.Ordinal)); + Assert.Equal(1, overview.Version); + Assert.Equal(ProductTourKind.Guide, overview.Kind); + Assert.Equal(0, overview.Shown); + Assert.Equal(3, overview.Started); + Assert.Equal(1, overview.Completed); + Assert.Equal(1, overview.Dismissed); + Assert.Equal(month.AddDays(6), overview.LastRunUtc); + Assert.Equal(2, Assert.Single(overview.StartSources, source => source.Source == ProductTourLaunchSource.Catalog).Count); + Assert.Equal(1, Assert.Single(overview.StartSources, source => source.Source == ProductTourLaunchSource.HelpMenu).Count); + + var welcome = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.AppWelcome, StringComparison.Ordinal)); + Assert.Equal(ProductTourKind.Prompt, welcome.Kind); + Assert.Equal(2, welcome.Shown); + Assert.Equal(1, welcome.Started); + Assert.Equal(1, welcome.Completed); + Assert.Equal(1, welcome.Dismissed); + Assert.Equal(1, Assert.Single(welcome.StartSources).Count); + Assert.Equal(ProductTourLaunchSource.Welcome, welcome.StartSources.Single().Source); + var welcomeDay = Assert.Single(welcome.Activity, period => period.Shown > 0); + Assert.Equal(2, welcomeDay.Shown); + Assert.Equal(1, welcomeDay.Started); + Assert.Equal(1, welcomeDay.Completed); + Assert.Equal(overview.Started, overview.Activity.Sum(period => period.Started)); + Assert.DoesNotContain(response.Tours, tour => tour.Started > 0 && (tour.Name is "unknown" or "ignored-tour")); + } + + [Fact] + public async Task GetProductTourUsageAsync_History_ReturnsConfiguredAvailableRange() + { + // Arrange + var now = new DateTime(2026, 8, 21, 12, 30, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now); + var retainedEvent = now.SubtractDays(_appOptions.MaximumRetentionDays - 1); + await CreateDataAsync(builder => AddUsage(builder, + ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), + retainedEvent, + "user-1")); + + // Act + var response = await SendRequestAsAsync(request => request + .AsGlobalAdminUser() + .AppendPaths("admin", "product-tour-usage") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(response); + Assert.Equal(now, response.UtcEnd); + var overview = Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.AppOverview, StringComparison.Ordinal)); + Assert.Equal(1, overview.Started); + Assert.Equal(Assert.Single(overview.Activity, period => period.Started > 0).DateUtc, response.UtcStart); + Assert.InRange(overview.Activity.Count, 80, 201); + } + + [Fact] + public Task GetProductTourUsageAsync_ReversedBounds_ReturnsValidationProblem() + { + // Act & Assert + return SendRequestAsync(request => request + .AsGlobalAdminUser() + .AppendPaths("admin", "product-tour-usage") + .QueryString("start", "2026-10-01T00:00:00Z") + .QueryString("end", "2026-09-01T00:00:00Z") + .StatusCodeShouldBeUnprocessableEntity()); + } + + [Fact] + public async Task GetProductTourUsageAsync_RangeAcrossFebruary_PreservesUtcBoundaries() + { + // Arrange + var now = new DateTime(2026, 3, 2, 12, 30, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now); + var start = now.Date.AddDays(-29); + var source = ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog); + await CreateDataAsync(builder => + { + AddUsage(builder, source, start.AddTicks(-1), "outside"); + AddUsage(builder, source, start, "boundary"); + AddUsage(builder, source, now.AddMinutes(-1), "today"); + }); + + // Act + var response = await SendRequestAsAsync(request => request.AsGlobalAdminUser() + .AppendPaths("admin", "product-tour-usage").QueryString("start", start.ToString("O")).StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(response); + Assert.Equal(start, response.UtcStart); + Assert.Equal(now, response.UtcEnd); + Assert.False(response.CollectionAvailable); + Assert.Equal(2, Assert.Single(response.Tours, tour => String.Equals(tour.Name, ProductTours.AppOverview, StringComparison.Ordinal)).Started); + } + + [Theory] + [InlineData("2026-09-01", "2026-09-01")] + [InlineData("2026-10-01", "2026-09-01")] + public Task GetProductTourUsageAsync_InvalidDateRange_ReturnsValidationProblem(string start, string end) + { + // Act & Assert + return SendRequestAsync(request => request.AsGlobalAdminUser().AppendPaths("admin", "product-tour-usage") + .QueryString("start", start).QueryString("end", end).StatusCodeShouldBeUnprocessableEntity()); + } + + [Fact] + public async Task GetProductTourUsageAsync_WithoutBounds_ReturnsEmptyHistoryWithoutInventingStart() + { + // Arrange + var now = new DateTime(2026, 9, 17, 12, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now); + + // Act + var response = await SendRequestAsAsync(request => request + .AsGlobalAdminUser() + .AppendPaths("admin", "product-tour-usage") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(response); + Assert.Null(response.UtcStart); + Assert.Equal(now, response.UtcEnd); + Assert.Equal(ProductTours.Definitions.Values.Sum(definition => definition.CurrentVersion), response.Tours.Count); + Assert.All(response.Tours, tour => + { + Assert.Equal(0, tour.Shown); + Assert.Equal(0, tour.Started); + Assert.Equal(0, tour.Completed); + Assert.Equal(0, tour.Dismissed); + Assert.Empty(tour.StartSources); + Assert.Empty(tour.Activity); + Assert.Null(tour.LastRunUtc); + }); + } + + [Fact] + public Task GetProductTourUsageAsync_StartAfterDefaultEnd_ReturnsValidationError() + { + // Act & Assert + return SendRequestAsync(request => request.AsGlobalAdminUser() + .AppendPaths("admin", "product-tour-usage") + .QueryString("start", "9999-12-01T00:00:00Z") + .StatusCodeShouldBeUnprocessableEntity()); + } + + [Fact] + public Task GetProductTourUsageAsync_AsOrganizationUser_ReturnsForbidden() + { + // Act & Assert + return SendRequestAsync(request => request + .AsTestOrganizationUser() + .AppendPaths("admin", "product-tour-usage") + .StatusCodeShouldBeForbidden()); + } + + private void AddUsage( + DataBuilder builder, + string source, + DateTime dateUtc, + string userIdentity, + int count = 1) + { + builder.Event() + .Organization(SampleDataService.TEST_ORG_ID) + .Project(_appOptions.InternalProjectId) + .Type(Event.KnownTypes.FeatureUsage) + .Source(source) + .Date(dateUtc) + .UserIdentity(userIdentity, $"Name {userIdentity}") + .Mutate(ev => ev.Count = count); + } +} diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs new file mode 100644 index 0000000000..54c66b1ae6 --- /dev/null +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs @@ -0,0 +1,302 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Extensions; +using Exceptionless.Web.Models; +using Foundatio.Repositories; +using Foundatio.Repositories.Exceptions; +using Xunit; + +namespace Exceptionless.Tests.Api.Endpoints; + +public sealed class ProductTourEndpointTests : IntegrationTestsBase +{ + private readonly IUserRepository _userRepository; + + public ProductTourEndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _userRepository = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await GetService().CreateDataAsync(); + } + + [Fact] + public async Task UpdateCurrentUserProductTourAsync_NewProgress_PersistsAndReturnsProgress() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + + // Act + var progress = await SendRequestAsAsync(request => request + .Put() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "app-overview") + .Content(new UpdateProductTourProgress { Status = ProductTourStatus.Dismissed, Version = 1 }) + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(progress); + Assert.Equal(ProductTourStatus.Dismissed, progress.Status); + Assert.Equal(1, progress.Version); + + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(progress, persistedUser.ProductTours["app-overview"]); + } + + [Fact] + public async Task UpdateCurrentUserProductTourAsync_UnchangedProgress_PreservesRecentMembership() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + await UpdateProgressAsync(ProductTours.AppWelcome, ProductTourStatus.Dismissed, 1); + var user = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); + Assert.NotNull(user); + const string organizationId = "000000000000000000000099"; + user.OrganizationIds.Add(organizationId); + await _userRepository.SaveAsync(user, options => options.Cache()); + + // Act + await UpdateProgressAsync(ProductTours.AppWelcome, ProductTourStatus.Dismissed, 1); + + // Assert + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); + var cachedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache()); + Assert.NotNull(persistedUser); + Assert.NotNull(cachedUser); + Assert.Contains(organizationId, persistedUser.OrganizationIds); + Assert.Contains(organizationId, cachedUser.OrganizationIds); + } + + [Fact] + public async Task UpdateCurrentUserProductTourAsync_OlderProgress_PreservesStoredValue() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + currentUser.ProductTours[ProductTours.ExieOverview] = new ProductTourProgress + { + Status = ProductTourStatus.Completed, + Version = 3 + }; + await _userRepository.SaveAsync(currentUser, options => options.Cache().ImmediateConsistency()); + + // Act + var replacement = await UpdateProgressAsync(ProductTours.ExieOverview, ProductTourStatus.Dismissed, 1); + + // Assert + Assert.Equal(ProductTourStatus.Completed, replacement.Status); + Assert.Equal(3, replacement.Version); + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(replacement, persistedUser.ProductTours["exie-overview"]); + } + + [Fact] + public async Task UpdateCurrentUserProductTourAsync_CompletedProgress_ReplacesDismissedProgressForSameVersion() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + await UpdateProgressAsync(ProductTours.ExieOverview, ProductTourStatus.Dismissed, 1); + + // Act + var replacement = await UpdateProgressAsync(ProductTours.ExieOverview, ProductTourStatus.Completed, 1); + + // Assert + Assert.Equal(ProductTourStatus.Completed, replacement.Status); + Assert.Equal(1, replacement.Version); + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(replacement, persistedUser.ProductTours["exie-overview"]); + } + + [Theory] + [InlineData(0)] + [InlineData(999)] + public async Task UpdateCurrentUserProductTourAsync_UnknownStoredStatus_ReplacesWithCompleted(int storedStatus) + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + currentUser.ProductTours[ProductTours.AppOverview] = new ProductTourProgress + { + Status = (ProductTourStatus)storedStatus, + Version = 1 + }; + await _userRepository.SaveAsync(currentUser, options => options.Cache().ImmediateConsistency()); + + // Act + var progress = await UpdateProgressAsync(ProductTours.AppOverview, ProductTourStatus.Completed, 1); + + // Assert + Assert.Equal(ProductTourStatus.Completed, progress.Status); + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(progress, persistedUser.ProductTours[ProductTours.AppOverview]); + } + + [Fact] + public async Task UpdateCurrentUserProductTourAsync_DismissedProgress_PreservesCompletedForSameVersion() + { + // Arrange + await UpdateProgressAsync(ProductTours.AppOverview, ProductTourStatus.Completed, 1); + + // Act + var progress = await UpdateProgressAsync(ProductTours.AppOverview, ProductTourStatus.Dismissed, 1); + + // Assert + Assert.Equal(ProductTourStatus.Completed, progress.Status); + Assert.Equal(1, progress.Version); + } + + [Fact] + public async Task UpdateCurrentUserProductTourAsync_ConcurrentUpdatesPreserveBothTourKeys() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + + // Act + await Task.WhenAll( + UpdateProgressAsync(ProductTours.AppOverview, ProductTourStatus.Completed, 1), + UpdateProgressAsync(ProductTours.SavedViewCreate, ProductTourStatus.Dismissed, 1)); + + // Assert + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(ProductTourStatus.Completed, persistedUser.ProductTours[ProductTours.AppOverview].Status); + Assert.Equal(ProductTourStatus.Dismissed, persistedUser.ProductTours[ProductTours.SavedViewCreate].Status); + } + + [Fact] + public async Task UpdateCurrentUserProductTourAsync_ConcurrentDismissAndCompleteLeavesCompletedProgress() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + + // Act + await Task.WhenAll( + UpdateProgressAsync(ProductTours.ExieOverview, ProductTourStatus.Dismissed, 1), + UpdateProgressAsync(ProductTours.ExieOverview, ProductTourStatus.Completed, 1)); + + // Assert + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(ProductTourStatus.Completed, persistedUser.ProductTours[ProductTours.ExieOverview].Status); + } + + [Fact] + public async Task UpdateProductTourProgressAsync_MissingUser_ThrowsNotFound() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + await _userRepository.RemoveAsync(currentUser.Id, options => options.ImmediateConsistency()); + + // Act & Assert + await Assert.ThrowsAsync(() => _userRepository.UpdateProductTourProgressAsync( + currentUser.Id, + ProductTours.AppOverview, + new ProductTourProgress { Status = ProductTourStatus.Completed, Version = 1 })); + } + + [Fact] + public async Task UpdateCurrentUserProductTourAsync_UnknownTourName_ReturnsUnprocessableEntity() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + + // Act + await SendRequestAsync(request => request + .Put() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "unknown-tour") + .Content(new UpdateProductTourProgress { Status = ProductTourStatus.Completed, Version = 1 }) + .StatusCodeShouldBeUnprocessableEntity()); + + // Assert + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, options => options.Cache(false)); + Assert.NotNull(persistedUser); + Assert.DoesNotContain("unknown-tour", persistedUser.ProductTours); + } + + [Fact] + public Task UpdateCurrentUserProductTourAsync_InvalidTourName_DoesNotMatchRoute() + { + // Act & Assert + return SendRequestAsync(request => request + .Put() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "Invalid--Tour") + .Content(new UpdateProductTourProgress { Status = ProductTourStatus.Completed, Version = 1 }) + .StatusCodeShouldBeNotFound()); + } + + [Fact] + public Task UpdateCurrentUserProductTourAsync_AnonymousUser_ReturnsUnauthorized() + { + // Act & Assert + return SendRequestAsync(request => request + .Put() + .AppendPaths("users", "me", "product-tours", ProductTours.AppWelcome) + .Content(new UpdateProductTourProgress { Status = ProductTourStatus.Dismissed, Version = 1 }) + .StatusCodeShouldBeUnauthorized()); + } + + [Fact] + public Task UpdateCurrentUserProductTourAsync_MissingBody_ReturnsBadRequest() + { + // Act & Assert + return SendRequestAsync(request => request + .Put() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "app-overview") + .StatusCodeShouldBeBadRequest()); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(2)] + public Task UpdateCurrentUserProductTourAsync_UnsupportedVersion_ReturnsUnprocessableEntity(int version) + { + // Act & Assert + return SendRequestAsync(request => request + .Put() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "app-overview") + .Content(new UpdateProductTourProgress { Status = ProductTourStatus.Completed, Version = version }) + .StatusCodeShouldBeUnprocessableEntity()); + } + + [Fact] + public Task UpdateCurrentUserProductTourAsync_UndefinedStatus_ReturnsUnprocessableEntity() + { + // Act & Assert + return SendRequestAsync(request => request + .Put() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "app-overview") + .Content(new { Status = 999, Version = 1 }) + .StatusCodeShouldBeUnprocessableEntity()); + } + + private async Task UpdateProgressAsync(string tourName, ProductTourStatus status, int version) + { + var progress = await SendRequestAsAsync(request => request + .Put() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", tourName) + .Content(new UpdateProductTourProgress { Status = status, Version = version }) + .StatusCodeShouldBeOk()); + + return Assert.IsType(progress); + } + + private async Task GetTestOrganizationUserAsync() + { + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + return Assert.IsType(user); + } +} diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 9d85fa6956..417777c325 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -61,6 +61,17 @@ public async Task GetOpenApiJson_Default_ContainsExpectedRoutesOperationsAndResp Assert.True(projectsPost.TryGetProperty("requestBody", out _)); AssertResponseCodes(projectsPost, "201"); + Assert.True(paths.TryGetProperty("/api/v2/users/me/product-tours/{tourName}", out var productTourPath)); + Assert.True(productTourPath.TryGetProperty("put", out var productTourPut)); + Assert.True(productTourPut.TryGetProperty("requestBody", out _)); + AssertResponseCodes(productTourPut, "200", "404", "422"); + AssertResponseSchema(productTourPut, "200", "ProductTourProgress"); + + Assert.True(paths.TryGetProperty("/api/v2/admin/product-tour-usage", out var productTourUsagePath)); + Assert.True(productTourUsagePath.TryGetProperty("get", out var productTourUsageGet)); + AssertResponseCodes(productTourUsageGet, "200", "400", "401", "403", "422"); + AssertResponseSchema(productTourUsageGet, "200", "ProductTourUsageResponse"); + Assert.True(paths.TryGetProperty("/api/v2/assistant/chat", out var assistantChatPath)); Assert.True(assistantChatPath.TryGetProperty("post", out var assistantChatPost)); AssertResponseCodes(assistantChatPost, "200", "400", "401", "403", "404", "426", "429", "503"); @@ -100,6 +111,12 @@ public async Task GetOpenApiJson_Default_ContainsExpectedSchemasAndSecuritySchem Assert.True(schemas.TryGetProperty("NewProject", out _)); Assert.True(schemas.TryGetProperty("SavedViewColumnSettings", out var savedViewColumnSettings)); Assert.True(schemas.TryGetProperty("TokenResult", out _)); + Assert.True(schemas.TryGetProperty("ProductTourProgress", out _)); + Assert.True(schemas.TryGetProperty("UpdateProductTourProgress", out _)); + Assert.True(schemas.TryGetProperty("ProductTourKind", out _)); + Assert.True(schemas.TryGetProperty("ProductTourStartSource", out _)); + Assert.True(schemas.TryGetProperty("ProductTourSummary", out _)); + Assert.True(schemas.TryGetProperty("ProductTourUsageResponse", out _)); Assert.True(schemas.TryGetProperty("ViewOrganization", out _)); var savedViewColumnProperties = savedViewColumnSettings.GetProperty("properties"); @@ -167,6 +184,7 @@ public async Task GetOpenApiJson_MigratedContracts_PreserveControllerMetadata() AssertDictionaryValueSchema(document.RootElement, "ViewSavedView", "columns", "SavedViewColumnSettings"); AssertRequiredJsonRequestBody(paths, "/api/v2/users/{id}", "patch", "UpdateUser"); AssertRequiredJsonRequestBody(paths, "/api/v2/users/{id}", "put", "UpdateUser"); + AssertRequiredJsonRequestBody(paths, "/api/v2/users/me/product-tours/{tourName}", "put", "UpdateProductTourProgress", "application/json"); AssertRequestContentTypes(paths, "/api/v1/error", "post", "application/json", "text/plain"); AssertRequestContentTypes(paths, "/api/v1/events", "post", "application/json", "text/plain"); @@ -290,6 +308,20 @@ private static void AssertResponseCodes(JsonElement operation, params string[] e Assert.True(responses.TryGetProperty(statusCode, out _), $"Expected response status code '{statusCode}'."); } + private static void AssertResponseSchema(JsonElement operation, string statusCode, string expectedSchema) + { + string? schema = operation + .GetProperty("responses") + .GetProperty(statusCode) + .GetProperty("content") + .GetProperty("application/json") + .GetProperty("schema") + .GetProperty("$ref") + .GetString(); + + Assert.Equal($"#/components/schemas/{expectedSchema}", schema); + } + private static void AssertPathResponseCodes(JsonElement paths, string path, string method, params string[] expectedStatusCodes) { var operation = paths.GetProperty(path).GetProperty(method); @@ -364,13 +396,14 @@ private static void AssertRequestContentTypes(JsonElement paths, string path, st Assert.Equal(expectedContentTypes.Order(), content.EnumerateObject().Select(property => property.Name).Order()); } - private static void AssertRequiredJsonRequestBody(JsonElement paths, string path, string method, string expectedSchema) + private static void AssertRequiredJsonRequestBody(JsonElement paths, string path, string method, string expectedSchema, params string[] expectedContentTypes) { var requestBody = paths.GetProperty(path).GetProperty(method).GetProperty("requestBody"); Assert.True(requestBody.GetProperty("required").GetBoolean()); var content = requestBody.GetProperty("content"); - Assert.Equal(["application/*+json", "application/json"], content.EnumerateObject().Select(property => property.Name).Order()); + string[] contentTypes = expectedContentTypes.Length > 0 ? expectedContentTypes : ["application/*+json", "application/json"]; + Assert.Equal(contentTypes.Order(), content.EnumerateObject().Select(property => property.Name).Order()); foreach (var mediaType in content.EnumerateObject()) Assert.Equal($"#/components/schemas/{expectedSchema}", mediaType.Value.GetProperty("schema").GetProperty("$ref").GetString()); diff --git a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs index 95c5d2b628..2990d10e06 100644 --- a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Exceptionless.Core; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories; @@ -16,6 +17,7 @@ namespace Exceptionless.Tests.Repositories; public sealed class EventRepositoryTests : IntegrationTestsBase { private readonly List> _ids = new(); + private readonly AppOptions _appOptions; private readonly Exceptionless.Helpers.RandomEventGenerator _randomEventGenerator; private readonly EventData _eventData; private readonly IEventRepository _repository; @@ -25,6 +27,7 @@ public sealed class EventRepositoryTests : IntegrationTestsBase public EventRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { + _appOptions = GetService(); _randomEventGenerator = GetService(); _eventData = GetService(); _repository = GetService(); @@ -33,6 +36,213 @@ public EventRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) _serializer = GetService(); } + [Fact] + public async Task GetProductTourUsageAsync_AllSourcesOverNinetyDays_PreservesLargeCounts() + { + // Arrange + var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + string[] sources = ProductTours.Definitions.Values + .SelectMany(definition => Enumerable.Range(1, definition.CurrentVersion) + .SelectMany(version => Enum.GetValues() + .SelectMany(action => Enum.GetValues() + .Select(source => ProductTours.CreateTelemetrySource(action, definition.Name, version, source))))) + .ToArray(); + // Include the source bucket and the parser's padded end-date bucket. Catalog/version growth + // must stay within Elasticsearch's default search.max_buckets before it reaches production. + int maximumPeriods = 201; + Assert.InRange((long)sources.Length * (1 + maximumPeriods + 1), 1, 65_536); + await CreateDataAsync(builder => + { + foreach (string source in sources) + { + foreach (var date in new[] { start, start.AddDays(89) }) + { + builder.Event().Organization(TestConstants.OrganizationId).Project(_appOptions.InternalProjectId) + .Type(Event.KnownTypes.FeatureUsage).Source(source).Date(date) + .Mutate(ev => ev.Count = Int32.MaxValue); + } + } + }); + + // Act + var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, start, start.AddDays(90)); + + // Assert + Assert.Equal(sources.Length, result.Buckets.Count); + Assert.All(result.Buckets, bucket => + { + Assert.Equal(2L * Int32.MaxValue, bucket.Count); + Assert.Equal(bucket.Count, bucket.Activity.Sum(period => period.Count)); + Assert.Equal(2, bucket.Activity.Count(period => period.Count > 0)); + Assert.InRange(bucket.Activity.Count, 2, maximumPeriods); + }); + } + + [Theory] + [InlineData(5)] + [InlineData(180)] + [InlineData(1095)] + public async Task GetProductTourUsageAsync_AutomaticInterval_UsesDateFilterAndPreservesEmptyBuckets(int days) + { + // Arrange + var start = new DateTime(2026, 8, 31, 12, 0, 0, DateTimeKind.Utc); + var end = start.AddDays(days); + TimeProvider.SetUtcNow(end.AddDays(1)); + await CreateDataAsync(builder => + { + foreach (var date in new[] { start.AddTicks(-1), start, end.AddHours(-1), end }) + { + builder.Event().Organization(TestConstants.OrganizationId).Project(_appOptions.InternalProjectId) + .Type(Event.KnownTypes.FeatureUsage) + .Source(ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog)) + .Date(date).Mutate(ev => ev.Count = 3); + } + }); + + // Act + var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, start, end); + + // Assert + var bucket = Assert.Single(result.Buckets); + Assert.Equal(6, bucket.Count); + Assert.Equal(bucket.Count, bucket.Activity.Sum(period => period.Count)); + Assert.InRange(bucket.Activity.Count, 80, 201); + Assert.Equal(2, bucket.Activity.Count(period => period.Count > 0)); + Assert.Contains(bucket.Activity, period => period.Count == 0); + Assert.All(bucket.Activity, period => Assert.True(period.DateUtc < end)); + } + + [Fact] + public async Task GetProductTourUsageAsync_ActivityAcrossFebruary_PreservesCoalescedCounts() + { + // Arrange + var start = new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc); + await CreateDataAsync(builder => + { + foreach (var action in new[] { ProductTourTelemetryEvent.Started, ProductTourTelemetryEvent.Dismissed }) + { + builder.Event().Organization(TestConstants.OrganizationId).Project(_appOptions.InternalProjectId) + .Type(Event.KnownTypes.FeatureUsage) + .Source(ProductTours.CreateTelemetrySource(action, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog)) + .Date(start.AddDays(28)) + .Mutate(ev => ev.Count = 3); + } + }); + + // Act + var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, start, start.AddDays(30)); + + // Assert + Assert.Equal(2, result.Buckets.Count); + Assert.All(result.Buckets, bucket => + { + Assert.Equal(3, bucket.Count); + Assert.InRange(Assert.Single(bucket.Activity, period => period.Count > 0).DateUtc, start.AddDays(27), start.AddDays(28)); + }); + } + + [Fact] + public async Task GetProductTourUsageAsync_KnownSources_ReturnsPerTourAggregations() + { + // Arrange + var month = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); + await CreateDataAsync(builder => + { + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(1), "user-1", 2); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.HelpMenu), month.AddDays(2), "user-1"); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Completed, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddDays(3), "user-2"); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Shown, ProductTours.AppWelcome, 1, ProductTourLaunchSource.Welcome), month.AddDays(4), "user-3"); + AddProductTourUsage(builder, "product-tour.started.app-overview.v2.catalog", month.AddDays(5), "user-4"); + AddProductTourUsage(builder, "product-tour.started.unknown-tour.v1.catalog", month.AddDays(6), "user-5"); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddMonths(-1), "user-6"); + builder.Event() + .Organization(TestConstants.OrganizationId) + .Project(_appOptions.InternalProjectId) + .Type(Event.KnownTypes.FeatureUsage) + .Source(ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog)) + .Date(month.AddDays(8)) + .UserIdentity("user-8"); + builder.Event() + .TestProject() + .Type(Event.KnownTypes.FeatureUsage) + .Source(ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog)) + .Date(month.AddDays(7)) + .UserIdentity("user-7"); + }); + + // Act + var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, month, month.AddMonths(1)); + + // Assert + Assert.Equal(2, result.Buckets.Select(bucket => bucket.Source.TourName).Distinct(StringComparer.Ordinal).Count()); + var overview = result.Buckets.Where(bucket => String.Equals(bucket.Source.TourName, ProductTours.AppOverview, StringComparison.Ordinal)).ToArray(); + Assert.Equal(4, overview.Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Started).Sum(bucket => bucket.Count)); + Assert.Equal(1, overview.Where(bucket => bucket.Source.Event == ProductTourTelemetryEvent.Completed).Sum(bucket => bucket.Count)); + Assert.Equal(month.AddDays(8), overview.Max(bucket => bucket.LastUtc)); + + var welcome = Assert.Single(result.Buckets, bucket => String.Equals(bucket.Source.TourName, ProductTours.AppWelcome, StringComparison.Ordinal)); + Assert.Equal(1, welcome.Count); + + Assert.All(result.Buckets, item => Assert.True(ProductTours.IsValid(item.Source.TourName, item.Source.Version))); + Assert.All(result.Buckets, bucket => Assert.Equal(bucket.Count, bucket.Activity.Sum(period => period.Count))); + var catalogStarts = Assert.Single(overview, bucket => bucket.Source.Event == ProductTourTelemetryEvent.Started && bucket.Source.LaunchSource == ProductTourLaunchSource.Catalog); + Assert.InRange(Assert.Single(catalogStarts.Activity, period => period.Count == 2).DateUtc, month, month.AddDays(1)); + } + + [Fact] + public async Task GetProductTourUsageAsync_WithoutDates_ReturnsAllUsage() + { + // Arrange + var month = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); + await CreateDataAsync(builder => + { + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog), month.AddMonths(-1), "user-1"); + AddProductTourUsage(builder, ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Welcome), month.AddDays(1), "user-2"); + }); + + // Act + var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, null, month.AddMonths(1)); + + // Assert + Assert.Equal(2, result.Buckets.Sum(bucket => bucket.Count)); + Assert.Contains(result.Buckets.SelectMany(bucket => bucket.Activity), period => period.DateUtc <= month.AddMonths(-1) && period.Count == 1); + } + + [Fact] + public async Task GetProductTourUsageAsync_History_UsesRetainedActivityBoundsForAutomaticBuckets() + { + // Arrange + var first = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc); + var end = first.AddDays(7); + TimeProvider.SetUtcNow(end); + string source = ProductTours.CreateTelemetrySource(ProductTourTelemetryEvent.Started, ProductTours.AppOverview, 1, ProductTourLaunchSource.Catalog); + await CreateDataAsync(builder => + { + AddProductTourUsage(builder, source, first, "first"); + AddProductTourUsage(builder, source, end.AddSeconds(-1), "last"); + }); + + // Act + var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, null, end); + + // Assert + var bucket = Assert.Single(result.Buckets); + Assert.Equal(2, bucket.Count); + Assert.Equal(bucket.Count, bucket.Activity.Sum(period => period.Count)); + Assert.InRange(bucket.Activity.Count, 80, 201); + Assert.All(bucket.Activity, period => Assert.InRange(period.DateUtc, first.AddDays(-1), end)); + } + + [Fact] + public async Task GetProductTourUsageAsync_EmptyHistory_DoesNotInventDateBounds() + { + // Act + var result = await _repository.GetProductTourUsageAsync(_appOptions.InternalProjectId, null, TimeProvider.GetUtcNow().UtcDateTime); + + // Assert + Assert.Empty(result.Buckets); + } + [Fact] public async Task GetAsync() { @@ -245,6 +455,18 @@ public async Task RemoveAllByClientIpAndDateAsync() Assert.Empty(events); } + private void AddProductTourUsage(DataBuilder builder, string source, DateTime dateUtc, string userIdentity, int count = 1) + { + builder.Event() + .Organization(TestConstants.OrganizationId) + .Project(_appOptions.InternalProjectId) + .Type(Event.KnownTypes.FeatureUsage) + .Source(source) + .Date(dateUtc) + .UserIdentity(userIdentity) + .Mutate(ev => ev.Count = count); + } + private async Task CreateDataAsync() { var baseDate = DateTime.UtcNow.SubtractHours(1); diff --git a/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs index 9d3e9d4c42..dc1f1ccaae 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Foundatio.Serializer; using Xunit; @@ -238,6 +239,62 @@ public void Deserialize_SnakeCaseJson_PreservesOrganizationIds() Assert.Contains("client", user.Roles); } + [Fact] + public void Deserialize_User_PreservesProductTourProgress() + { + // Arrange + var original = new User + { + Id = "tour-user", + FullName = "Tour User", + EmailAddress = "tour@example.com", + IsEmailAddressVerified = true, + ProductTours = new Dictionary(StringComparer.Ordinal) + { + ["app-welcome"] = new() + { + Version = 1, + Status = ProductTourStatus.Dismissed + }, + ["app-overview"] = new() + { + Version = 2, + Status = ProductTourStatus.Completed + } + } + }; + + // Act + string? json = _serializer.SerializeToString(original); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.Contains("\"status\":2", json); + Assert.NotNull(deserialized); + Assert.Equal(2, deserialized.ProductTours.Count); + Assert.Equal(ProductTourStatus.Dismissed, deserialized.ProductTours["app-welcome"].Status); + Assert.Equal(2, deserialized.ProductTours["app-overview"].Version); + Assert.Equal(ProductTourStatus.Completed, deserialized.ProductTours["app-overview"].Status); + } + + [Fact] + public void Deserialize_LegacyUserWithoutProductTours_ReturnsEmptyCollection() + { + const string json = """ + { + "id": "legacy-user", + "full_name": "Legacy User", + "email_address": "legacy@example.com", + "is_email_address_verified": true + } + """; + + var user = _serializer.Deserialize(json); + + Assert.NotNull(user); + Assert.Empty(user.ProductTours); + } + [Fact] public void Deserialize_SnakeCaseJson_PreservesOAuthAccounts() { diff --git a/tests/http/admin.http b/tests/http/admin.http index ca66456788..9d0087fb64 100644 --- a/tests/http/admin.http +++ b/tests/http/admin.http @@ -54,6 +54,14 @@ Authorization: Bearer {{token}} GET {{apiUrl}}/admin/assistant-usage Authorization: Bearer {{token}} +### Product Tour Usage (explicit UTC range) +GET {{apiUrl}}/admin/product-tour-usage?start=2026-08-01T00:00:00Z&end=2026-08-31T00:00:00Z +Authorization: Bearer {{token}} + +### Product Tour Usage (available retained history; automatic range-based buckets) +GET {{apiUrl}}/admin/product-tour-usage +Authorization: Bearer {{token}} + ### Get Exie Settings GET {{apiUrl}}/admin/assistant-settings Authorization: Bearer {{token}} @@ -116,6 +124,16 @@ Content-Type: application/json "enabled": null } +### Product Tour Usage +# Includes weighted activity in automatic date buckets alongside per-guide totals. +GET {{apiUrl}}/admin/product-tour-usage?start=2026-08-01T00:00:00Z&end=2026-09-01T00:00:00Z +Authorization: Bearer {{token}} + +### + +GET {{apiUrl}}/admin/product-tour-usage +Authorization: Bearer {{token}} + ### Suspend POST {{apiUrl}}/organizations/{{organizationId}}/suspend?code=1 Authorization: Bearer {{token}} diff --git a/tests/http/users.http b/tests/http/users.http index 38b9d2cee5..74801b5b3b 100644 --- a/tests/http/users.http +++ b/tests/http/users.http @@ -26,6 +26,16 @@ Authorization: Bearer {{token}} @avatarFile = ./avatar.png @oauthGrantId = replace-with-oauth-grant-id +### Record Product Tour Progress +PUT {{apiUrl}}/users/me/product-tours/app-overview +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "version": 1, + "status": 1 +} + ### Get OAuth Grants GET {{apiUrl}}/users/me/oauth-grants Authorization: Bearer {{token}}