Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
45a91ee
Chart wayfinder map for on-demand project-page history
claude Aug 6, 2026
ef9f0ad
Resolve wayfinder ticket: hg history on-demand UX
claude Aug 6, 2026
4b2c924
Resolve wayfinder ticket: FwLite history server API
claude Aug 6, 2026
6169ce2
Resolve wayfinder ticket: combined history layout
claude Aug 6, 2026
6879a4a
Resolve final wayfinder ticket: implementation spec
claude Aug 6, 2026
3f39da8
Load project history on demand and add FieldWorks Lite history
claude Aug 6, 2026
52d8702
[skip ci] remove HarmonyCommit and replace with ServerCommit directly…
hahn-kev Aug 10, 2026
0ac3e69
update harmony version so gql can handle ServerCommit, fix metadata r…
hahn-kev Aug 11, 2026
9e1104a
allow sorting and filtering harmony commits in gql. Show hybrid date …
hahn-kev Aug 11, 2026
b2528cc
use icon component
hahn-kev Aug 11, 2026
e629b9f
Click Show history in Playwright and restore FwLite commit list.
hahn-kev Aug 13, 2026
44d2b58
remove planning files
hahn-kev Aug 17, 2026
50b581c
clean up harmony commit tests and remove resolver since it's no longe…
hahn-kev Aug 17, 2026
56a1520
add commit author id as title tooltip
hahn-kev Aug 17, 2026
0b7e48d
show author Id in tooltip, show client Id and version when in dev mode
hahn-kev Aug 17, 2026
39aa08d
correct test
hahn-kev Aug 17, 2026
5062b27
corrected the sort order of commits
hahn-kev Aug 17, 2026
7f0c554
Embed projected skip/take for harmonyCommits so paging stays in the p…
hahn-kev Aug 19, 2026
602b11c
Merge branch 'develop' into on-demand-history-loading
hahn-kev Aug 19, 2026
2b7ea63
Merge remote-tracking branch 'origin/develop' into on-demand-history-…
myieye Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions backend/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<PackageVersion Include="Gridify.EntityFramework" Version="2.19.1" />
<PackageVersion Include="HotChocolate.AspNetCore" Version="$(HotChocolateVersion)" />
<PackageVersion Include="HotChocolate.AspNetCore.Authorization" Version="$(HotChocolateVersion)" />
<PackageVersion Include="HotChocolate.Data" Version="$(HotChocolateVersion)" />
<PackageVersion Include="HotChocolate.Data.EntityFramework" Version="$(HotChocolateVersion)" />
<PackageVersion Include="HotChocolate.Diagnostics" Version="$(HotChocolateVersion)" />
<PackageVersion Include="HotChocolate.Types.Analyzers" Version="$(HotChocolateVersion)" />
Expand Down Expand Up @@ -109,9 +110,9 @@
<PackageVersion Include="SIL.Chorus.LibChorus" Version="6.0.0-beta0074" />
<PackageVersion Include="SIL.Chorus.Mercurial" Version="6.5.1.43" />
<PackageVersion Include="SIL.ChorusPlugin.LfMergeBridge" Version="4.3.0-beta0048" />
<PackageVersion Include="SIL.Harmony" Version="0.2.1-rc.241" />
<PackageVersion Include="SIL.Harmony.Core" Version="0.2.1-rc.241" />
<PackageVersion Include="SIL.Harmony.Linq2db" Version="0.2.1-rc.241" />
<PackageVersion Include="SIL.Harmony" Version="0.2.1-rc.266" />
<PackageVersion Include="SIL.Harmony.Core" Version="0.2.1-rc.266" />
<PackageVersion Include="SIL.Harmony.Linq2db" Version="0.2.1-rc.266" />
<PackageVersion Include="SIL.Core" Version="18.0.0-beta0032" />
<PackageVersion Include="SIL.LCModel" Version="11.0.0-beta0165" />
<PackageVersion Include="SIL.LCModel.FixData" Version="11.0.0-beta0165" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Text.Json;
using SIL.Harmony.Core;

namespace LexBoxApi.GraphQL.CustomTypes;

[ObjectType]
public class HarmonyCommitGqlConfiguration : ObjectType<ServerCommit>
{
protected override void Configure(IObjectTypeDescriptor<ServerCommit> descriptor)
{
descriptor.BindFieldsExplicitly();
descriptor.Field(h => h.Id);
descriptor.Field(h => h.HybridDateTime);
descriptor.Field(h => h.ProjectId);
descriptor.Field(h => h.ClientId);
descriptor.Field(h => h.Metadata).Type<JsonType>()
.Resolve(context =>
{
var metadata = context.Parent<ServerCommit>().Metadata;
return JsonSerializer.SerializeToElement(metadata);
});
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using HotChocolate.Types;
using LexBoxApi.GraphQL.Projections;
using LexCore.Entities;

namespace LexBoxApi.GraphQL.CustomTypes;
Expand All @@ -10,7 +12,11 @@ protected override void Configure(IObjectTypeDescriptor<Project> descriptor)
descriptor.Field(p => p.Code).IsProjected();
descriptor.Field(p => p.CreatedDate).IsProjected();
descriptor.Field(p => p.Id).IsProjected(); // Needed for jwt refresh
descriptor.Field(p => p.Type).IsProjected(); // harmonyCommits resolver gates on Type
descriptor.Field(p => p.Users).Use<ProjectMembersVisibilityMiddleware>();
// descriptor.Field("userCount").Resolve(ctx => ctx.Parent<Project>().UserCount);
descriptor.Field(p => p.HarmonyCommits)
.UseProjectedSkipTake()
.UseSorting()
.UseFiltering();
}
}
9 changes: 8 additions & 1 deletion backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using DataAnnotatedModelValidations;
using HotChocolate.Data;
using HotChocolate.Data.Projections.Expressions;
using HotChocolate.Diagnostics;
using LexBoxApi.GraphQL.CustomFilters;
using LexBoxApi.GraphQL.CustomTypes;
using LexBoxApi.GraphQL.Projections;
using LexBoxApi.Services;
using LexCore.Entities;
using LexCore.ServiceInterfaces;
Expand Down Expand Up @@ -52,7 +55,11 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm
descriptor.AddDefaults();
descriptor.AddDeterministicInvariantContainsFilter();
})
.AddProjections()
.AddProjections(d => d.Provider(new QueryableProjectionProvider(p =>
{
p.AddDefaults();
p.RegisterFieldInterceptor(new QueryableSkipTakeInterceptor());
})))
.RegisterDbContextFactory<LexBoxDbContext>()
.ModifyPagingOptions(options =>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using HotChocolate.Types;

namespace LexBoxApi.GraphQL.Projections;

public static class ProjectedSkipTakeExtensions
{
public const int DefaultTake = 100;
public const int MaxTake = 200;
public const string SkipArgumentName = "skip";
public const string TakeArgumentName = "take";

/// <summary>
/// Adds skip/take arguments and marks the field for <see cref="QueryableSkipTakeInterceptor"/>.
/// </summary>
public static IObjectFieldDescriptor UseProjectedSkipTake(this IObjectFieldDescriptor descriptor)
{
ArgumentNullException.ThrowIfNull(descriptor);

descriptor
.Argument(SkipArgumentName, a => a.Type<IntType>().DefaultValue(0))
.Argument(TakeArgumentName, a => a.Type<IntType>().DefaultValue(DefaultTake))
.Extend()
.OnBeforeCreate((_, definition) =>
{
definition.ContextData[ProjectedSkipTakeFeature.ContextDataKey] = true;
});

return descriptor;
}
}
10 changes: 10 additions & 0 deletions backend/LexBoxApi/GraphQL/Projections/ProjectedSkipTakeFeature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace LexBoxApi.GraphQL.Projections;

/// <summary>
/// Context-data key for fields that get skip/take embedded into EF projections by
/// <see cref="QueryableSkipTakeInterceptor"/>. Set via <see cref="ProjectedSkipTakeExtensions.UseProjectedSkipTake"/>.
/// </summary>
public static class ProjectedSkipTakeFeature
{
public const string ContextDataKey = nameof(ProjectedSkipTakeFeature);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.Linq.Expressions;
using System.Reflection;
using HotChocolate.Data.Projections;
using HotChocolate.Data.Projections.Expressions;
using HotChocolate.Data.Projections.Expressions.Handlers;
using HotChocolate.Execution.Internal;
using HotChocolate.Execution.Processing;
using HotChocolate.Resolvers;
using HotChocolate.Types;
using LexCore.Entities;

namespace LexBoxApi.GraphQL.Projections;

/// <summary>
/// Embeds skip/take into nested collection projections so EF translates OFFSET/LIMIT in the
/// parent query subselect. Uses <see cref="Enumerable"/> methods like HC's built-in filter/take
/// interceptors so the expression tree stays EF-translatable.
/// Only runs on fields marked with <see cref="ProjectedSkipTakeFeature"/> via
/// <see cref="ProjectedSkipTakeExtensions.UseProjectedSkipTake"/>.
/// </summary>
public sealed class QueryableSkipTakeInterceptor : IProjectionFieldInterceptor<QueryableProjectionContext>
{
public bool CanHandle(ISelection selection) =>
selection.Field.ContextData.ContainsKey(ProjectedSkipTakeFeature.ContextDataKey)
&& selection.Field.Member is PropertyInfo { CanWrite: true };

public void BeforeProjection(QueryableProjectionContext context, ISelection selection)
{
if (selection is not Selection selectionWithArgs
|| !selectionWithArgs.Arguments.TryCoerceArguments(context.ResolverContext, out var coercedArgs))
{
return;
}

var skip = Math.Max(0, ReadIntArgument(coercedArgs, ProjectedSkipTakeExtensions.SkipArgumentName, 0));
var take = ReadIntArgument(coercedArgs, ProjectedSkipTakeExtensions.TakeArgumentName, ProjectedSkipTakeExtensions.DefaultTake);
if (take > ProjectedSkipTakeExtensions.MaxTake)
{
throw new GraphQLException(
ErrorBuilder.New()
.SetMessage("The maximum allowed items per page were exceeded.")
.SetCode(ErrorCodes.Paging.MaxPaginationItems)
.SetPath(context.ResolverContext.Path)
.SetExtension("requestedItems", take)
.SetExtension("maxAllowedItems", ProjectedSkipTakeExtensions.MaxTake)
.Build());
}

if (take < 1)
{
take = 1;
}

var elementType = ((PropertyInfo)selection.Field.Member!).PropertyType.GetGenericArguments()[0];
var instance = context.PopInstance();

if (skip > 0)
{
instance = Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Skip),
[elementType],
instance,
Expression.Constant(skip));
}

instance = Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Take),
[elementType],
instance,
Expression.Constant(take));

context.PushInstance(instance);
}

public void AfterProjection(QueryableProjectionContext context, ISelection selection)
{
}

private static int ReadIntArgument(IReadOnlyDictionary<string, ArgumentValue> args, string name, int fallback)
{
if (!args.TryGetValue(name, out var argument) || argument.Value is null)
{
return fallback;
}

return argument.Value switch
{
int i => i,
short s => s,
long l => (int)l,
_ => fallback
};
}
}
1 change: 1 addition & 0 deletions backend/LexBoxApi/LexBoxApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
</PackageReference>
<PackageReference Include="HotChocolate.AspNetCore" />
<PackageReference Include="HotChocolate.AspNetCore.Authorization" />
<PackageReference Include="HotChocolate.Data" />
<PackageReference Include="HotChocolate.Data.EntityFramework" />
<PackageReference Include="HotChocolate.Diagnostics" />
<PackageReference Include="HotChocolate.Types.OffsetPagination" />
Expand Down
3 changes: 3 additions & 0 deletions backend/LexCore/Entities/Project.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Linq.Expressions;
using System.Text.Json.Serialization;
using LexCore.ServiceInterfaces;
using SIL.Harmony.Core;
using NeinLinq;

namespace LexCore.Entities;
Expand Down Expand Up @@ -29,6 +30,8 @@ public class Project : EntityBase
public ProjectMigrationStatus ProjectOrigin { get; set; } = ProjectMigrationStatus.Migrated;
public DateTimeOffset? MigratedDate { get; set; } = null;

public List<ServerCommit>? HarmonyCommits { get; set; }

[NotMapped]
[InjectLambda(nameof(SqlUserCount))]
public int UserCount { get; set; }
Expand Down
1 change: 1 addition & 0 deletions backend/LexCore/LexCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
<ItemGroup>
<PackageReference Include="NeinLinq" />
</ItemGroup>
<Import Project="$(MSBuildThisFileDirectory)..\Harmony.Core.References.props" />
</Project>
6 changes: 1 addition & 5 deletions backend/LexData/Entities/CommitEntityConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public void Configure(EntityTypeBuilder<ServerCommit> builder)
builder.ToTable("CrdtCommits");
builder.HasKey(c => c.Id);
builder.ComplexProperty(c => c.HybridDateTime);
builder.HasOne<Project>().WithMany()
builder.HasOne<Project>().WithMany(p => p.HarmonyCommits)
.HasPrincipalKey(project => project.Id)
.HasForeignKey(c => c.ProjectId);
builder.Property(c => c.Metadata).HasConversion(
Expand All @@ -33,10 +33,6 @@ public void Configure(EntityTypeBuilder<ServerCommit> builder)
).HasColumnType("jsonb").IsRequired(false);
}

private static ServerJsonChange Deserialize(string s) => JsonSerializer.Deserialize<ServerJsonChange>(s)!;

private static string Serialize(ServerJsonChange c) => JsonSerializer.Serialize(c);

private class ServerJsonChangeConverter: JsonConverter<ServerJsonChange>
{
public override ServerJsonChange? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions _)
Expand Down
95 changes: 95 additions & 0 deletions backend/Testing/GraphQL/HarmonyCommitQueryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using LexCore.Entities;
using LexData;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SIL.Harmony.Core;
using Testing.Fixtures;

namespace Testing.GraphQL;

[Collection(nameof(TestingServicesFixture))]
[Trait("Category", "RequiresDb")]
public class HarmonyCommitQueryTests
{
private readonly LexBoxDbContext _dbContext;
private readonly IDbContextFactory<LexBoxDbContext> _dbContextFactory;

public HarmonyCommitQueryTests(TestingServicesFixture testing)
{
var serviceProvider = testing.ConfigureServices();
_dbContext = serviceProvider.GetRequiredService<LexBoxDbContext>();
_dbContextFactory = serviceProvider.GetRequiredService<IDbContextFactory<LexBoxDbContext>>();
}

// Each test gets its own project so the resolver's newest-N query only sees that test's commits,
// keeping ordering/limit assertions isolated within the shared RequiresDb collection.
private async Task<Project> CreateProject(ProjectType type = ProjectType.FLEx)
{
var project = new Project
{
Id = Guid.NewGuid(),
Code = "harmony-test-" + Guid.NewGuid().ToString("N")[..8],
Name = "Harmony Commit Resolver Test",
Type = type,
RetentionPolicy = RetentionPolicy.Dev,
IsConfidential = null,
Users = [],
Organizations = [],
LastCommit = null,
};
_dbContext.Add(project);
await _dbContext.SaveChangesAsync();
return project;
}

private async Task SeedCommit(Guid projectId, DateTimeOffset dateTime, long counter, string? authorName)
{
_dbContext.Add(new ServerCommit(Guid.NewGuid())
{
ClientId = Guid.NewGuid(),
HybridDateTime = new HybridDateTime(dateTime, counter),
ProjectId = projectId,
Metadata = new CommitMetadata { AuthorName = authorName },
});
await _dbContext.SaveChangesAsync();
}

private static string? AuthorName(ServerCommit commit) =>
commit.Metadata.AuthorName;

private async Task<ServerCommit[]> QueryCommits(Guid projectId, IDbContextFactory<LexBoxDbContext> dbContextFactory, int limit)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var project = await context.Projects.Include(p => p.HarmonyCommits).FirstAsync(p => p.Id == projectId);
return project.HarmonyCommits?.ToArray() ?? [];
}

[Fact]
public async Task CommitsIncludeTheAuthorNameInMetadata()
{
var project = await CreateProject();
var day = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
await SeedCommit(project.Id, day, 0, "Author1");
await SeedCommit(project.Id, day.AddDays(1), 0, "Author2");
await SeedCommit(project.Id, day.AddDays(2), 0, "Author3");

var commits = await QueryCommits(project.Id, _dbContextFactory, 10);

commits.OrderBy(c => c.HybridDateTime)
.Select(AuthorName)
.Should()
.Equal("Author1", "Author2", "Author3");
}

[Fact]
public async Task NullAuthorNameIsPreservedInMetadata()
{
var project = await CreateProject();
await SeedCommit(project.Id, new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), 0, null);

var commits = await QueryCommits(project.Id, _dbContextFactory, 1);

commits.Should().ContainSingle();
AuthorName(commits[0]).Should().BeNull();
}
}
2 changes: 2 additions & 0 deletions frontend/gql-codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const generationConfig: TypeScriptPluginConfig = {
'UUID': 'string',
'DateTime': 'string | Date',
'Long': 'number',
'Any': 'unknown',
'JSON': 'unknown',
/* eslint-enable @typescript-eslint/naming-convention */
}
};
Expand Down
Loading
Loading