-
-
Notifications
You must be signed in to change notification settings - Fork 507
Add adaptive guided tours to the new UI #2506
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2d4e4ea
5042273
fd0af38
3f89b02
d0b005f
160a65b
c58579c
1a0f39c
f792d3a
ee7f1ec
d38f77e
abb75e0
87e40fc
d89eade
e3b243e
d439b0b
6fc668e
d653198
90d9da6
9033385
0437ca5
aa4bf1c
d8cc361
0258980
ebaf034
2c61327
55f72a9
9ec62d2
5ecdefc
75bd890
039ca0e
850ef76
b506eea
a85bfa5
335c13a
b1018d9
bf2c3ea
0b67631
0fbc67e
dae155a
6e2df00
164eacf
cff4741
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, ProductTourDefinition> 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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| using Exceptionless.Core.Models.Data; | ||
|
|
||
| namespace Exceptionless.Core.Repositories; | ||
|
|
||
| public sealed record ProductTourUsageResult(IReadOnlyCollection<ProductTourUsageBucket> Buckets); | ||
|
|
||
| public sealed record ProductTourUsageBucket(ProductTourUsageSource Source, long Count, DateTime? LastUtc, IReadOnlyCollection<ProductTourUsagePeriod> Activity); | ||
|
|
||
| public sealed record ProductTourUsagePeriod(DateTime DateUtc, long Count); | ||
|
|
||
| public sealed record ProductTourUsageSource( | ||
| string Raw, | ||
| ProductTourTelemetryEvent Event, | ||
| string TourName, | ||
| int Version, | ||
| ProductTourLaunchSource LaunchSource); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<FindResults<User>> GetByOrganizationIdAsync(string organizationId, C | |
| return FindAsync(q => q.FieldEquals(u => u.OrganizationIds, organizationId).SortAscending(u => u.EmailAddress), o => commandOptions); | ||
| } | ||
|
|
||
| public async Task<ProductTourProgress> UpdateProductTourProgressAsync(string userId, string tourName, ProductTourProgress progress) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this truely needed for an update, and or do we have great integration test coverage around htis? |
||
| { | ||
| 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<string, object> | ||
| { | ||
| ["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<FindHit<User>> findHits, ICommandOptions options, bool isDirtyRead) | ||
| { | ||
| await base.AddDocumentsToCacheAsync(findHits, options, isDirtyRead); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<AutoValidationEndpointFilter>() | ||
| .Produces<ProductTourUsageResponse>(StatusCodes.Status200OK) | ||
| .ProducesProblem(StatusCodes.Status400BadRequest) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what produces a 400, any invalid parameters would be a 422. |
||
| .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity) | ||
| .Produces(StatusCodes.Status401Unauthorized) | ||
| .Produces(StatusCodes.Status403Forbidden); | ||
|
|
||
| group.MapPost("change-plan", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper, string organizationId, string planId) | ||
| => (await mediator.InvokeAsync<Result<object>>(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<HttpIResult> GetProductTourUsageAsync( | ||
| IMediator mediator, | ||
| IMediatorResultMapper<HttpIResult> resultMapper, | ||
| DateTime? start = null, | ||
| DateTime? end = null) | ||
| => (await mediator.InvokeAsync<Result<object>>(new GetAdminProductTourUsage(start, end))).ToHttpResult(resultMapper); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HttpIResult> resultMapper, [FromBody] UpdateProductTourProgress progress) | ||
| => (await mediator.InvokeAsync<Result<ProductTourProgress>>(new UserMessages.UpdateCurrentUserProductTour(tourName, progress))).ToHttpResult(resultMapper)) | ||
| .Accepts<UpdateProductTourProgress>(false, "application/json") | ||
| .Produces<ProductTourProgress>() | ||
| .ProducesProblem(StatusCodes.Status400BadRequest) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what produces a 400? 422 is preferred for any validation errors. |
||
| .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<HttpIResult> resultMapper) | ||
| => (await mediator.InvokeAsync<Result<IReadOnlyCollection<ViewOAuthGrant>>>(new UserMessages.GetCurrentUserOAuthGrants())).ToHttpResult(resultMapper)) | ||
| .Produces<IReadOnlyCollection<ViewOAuthGrant>>() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
shouldn't we have one for started? or would that be dismissed?