Skip to content

Commit 277e418

Browse files
hahn-kevmyieye
andauthored
Add activity filters to the activity view (#2357)
* Enhance HistoryService with new activity querying features - Updated ProjectActivity method to accept additional parameters for filtering by author and sorting. - Introduced ListActivityAuthors and ListActivityChangeTypes methods to retrieve distinct authors and change types. - Added ActivityQuery record to encapsulate query parameters. - Implemented frontend components for filtering activities by author and change type. - Added tests for new functionality in HistoryService. This commit improves the activity tracking capabilities and enhances the user experience by allowing more granular filtering of activities. * Support multiselect activity filters * Add history view integration to activity items - Introduced a "History" button in `ActivityItem` to display related history entries. - Enhanced `HistoryView` with support for selecting a specific commit. - Updated HistoryView.svelte to svelte 5 * Update locale files to include "History" translation for ActivityItem * rewrite ListActivityChangeTypes to use sql group by and count instead of doing that in memory * simplify some queries by using commit.ChangeEntities instead of bringing in a seperate queryable * Prevent filters overflowing column --------- Co-authored-by: Tim Haasdyk <tim_haasdyk@sil.org>
1 parent 5ae0554 commit 277e418

30 files changed

Lines changed: 1622 additions & 156 deletions

backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,27 @@ public Task<IObjectWithId> GetObject(Guid commitId, Guid entityId)
1414
}
1515

1616
[JSInvokable]
17-
public async ValueTask<ProjectActivity[]> ProjectActivity(int skip, int take)
17+
public async ValueTask<ProjectActivity[]> ProjectActivity(
18+
int skip,
19+
int take,
20+
string[]? authorFilterKeys = null,
21+
string[]? changeTypeKeys = null,
22+
ActivitySort sort = ActivitySort.NewestFirst)
1823
{
19-
return await historyService.ProjectActivity(skip, take).ToArrayAsync();
24+
return await historyService.ProjectActivity(skip, take,
25+
new ActivityQuery(authorFilterKeys, changeTypeKeys, sort)).ToArrayAsync();
26+
}
27+
28+
[JSInvokable]
29+
public Task<ActivityAuthor[]> ListActivityAuthors()
30+
{
31+
return historyService.ListActivityAuthors();
32+
}
33+
34+
[JSInvokable]
35+
public Task<ActivityChangeType[]> ListActivityChangeTypes()
36+
{
37+
return historyService.ListActivityChangeTypes();
2038
}
2139

2240
[JSInvokable]

backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
175175
typeof(FwLiteConfig),
176176
typeof(HistoryLineItem),
177177
typeof(ProjectActivity),
178+
typeof(ActivityAuthor),
179+
typeof(ActivityChangeType),
180+
typeof(ActivityQuery),
178181
typeof(ChangeContext),
179182
typeof(ChangeEntity<IChange>),
180183
typeof(IChange),
@@ -185,6 +188,7 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
185188
typeof(AvailableUpdate),
186189
], exportBuilder => exportBuilder.WithPublicProperties());
187190

191+
builder.ExportAsEnum<ActivitySort>().UseString();
188192
builder.ExportAsEnum<FwEventType>().UseString();
189193
builder.ExportAsEnum<LogLevel>().UseString(false);
190194
var eventJsAttrs = typeof(IFwEvent).GetCustomAttributes<JsonDerivedTypeAttribute>();

backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,16 @@ public static IEndpointConventionBuilder MapActivities(this WebApplication app)
1818
});
1919
return Task.CompletedTask;
2020
});
21-
group.MapGet("/", (HistoryService historyService, int skip, int take) => historyService.ProjectActivity(skip, take));
21+
group.MapGet("/", (
22+
HistoryService historyService,
23+
int skip = 0,
24+
int take = 100,
25+
string[]? authorFilterKeys = null,
26+
string[]? changeTypeKeys = null,
27+
ActivitySort sort = ActivitySort.NewestFirst) =>
28+
historyService.ProjectActivity(skip, take, new ActivityQuery(authorFilterKeys, changeTypeKeys, sort)));
29+
group.MapGet("/authors", (HistoryService historyService) => historyService.ListActivityAuthors());
30+
group.MapGet("/change-types", (HistoryService historyService) => historyService.ListActivityChangeTypes());
2231
return group;
2332
}
2433
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
using LcmCrdt.Changes;
2+
using LcmCrdt.Utils;
3+
using Microsoft.EntityFrameworkCore;
4+
using MiniLcm.Tests.AutoFakerHelpers;
5+
using SIL.Harmony.Core;
6+
using Soenneker.Utils.AutoBogus;
7+
8+
namespace LcmCrdt.Tests;
9+
10+
public class HistoryServiceActivityTests : IAsyncLifetime, IAsyncDisposable
11+
{
12+
private static readonly AutoFaker AutoFaker = new(AutoFakerDefault.MakeConfig(["en"]));
13+
private MiniLcmApiFixture _fixture = null!;
14+
15+
private HistoryService Service => _fixture.GetService<HistoryService>();
16+
private DataModel DataModel => _fixture.DataModel;
17+
private Guid ClientId => _fixture.GetService<CurrentProjectService>().ProjectData.ClientId;
18+
19+
public async Task InitializeAsync()
20+
{
21+
_fixture = MiniLcmApiFixture.Create();
22+
await _fixture.InitializeAsync();
23+
}
24+
25+
public async Task DisposeAsync() => await _fixture.DisposeAsync();
26+
27+
async ValueTask IAsyncDisposable.DisposeAsync() => await DisposeAsync();
28+
29+
[Fact]
30+
public async Task ListActivityAuthors_ReturnsDistinctAuthorsWithCounts()
31+
{
32+
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
33+
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
34+
await AddEntryCommit(new CommitMetadata { AuthorName = "Bob", AuthorId = "bob-id" });
35+
36+
var authors = await Service.ListActivityAuthors();
37+
38+
authors.Should().Contain(a => a.AuthorId == "alice-id" && a.AuthorName == "Alice" && a.CommitCount == 2);
39+
authors.Should().Contain(a => a.AuthorId == "bob-id" && a.AuthorName == "Bob" && a.CommitCount == 1);
40+
}
41+
42+
[Fact]
43+
public async Task ListActivityChangeTypes_IncludesCreateEntry()
44+
{
45+
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
46+
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
47+
await AddNewPublicationCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
48+
await AddNewPartOfSpeechCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
49+
50+
var changeTypes = await Service.ListActivityChangeTypes();
51+
52+
changeTypes.Should().Contain(t => t.Key == nameof(CreateEntryChange) && t.CommitCount >= 2);
53+
changeTypes.Should().Contain(t => t.Key == nameof(CreatePublicationChange) && t.CommitCount >= 1);
54+
changeTypes.Should().Contain(t => t.Key == nameof(CreatePartOfSpeechChange) && t.CommitCount >= 1);
55+
}
56+
57+
[Fact]
58+
public async Task ProjectActivity_FiltersByAuthorId()
59+
{
60+
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
61+
await AddEntryCommit(new CommitMetadata { AuthorName = "Bob", AuthorId = "bob-id" });
62+
63+
var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync();
64+
65+
activities.Should().OnlyContain(a => a.Metadata.AuthorId == "alice-id");
66+
activities.Should().HaveCountGreaterThanOrEqualTo(1);
67+
}
68+
69+
[Fact]
70+
public async Task ProjectActivity_AuthorFilterKeys_ExcludesUnselectedAuthors()
71+
{
72+
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
73+
await AddEntryCommit(new CommitMetadata { AuthorName = "FieldWorks" });
74+
75+
var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync();
76+
77+
activities.Should().NotContain(a => a.Metadata.AuthorName == "FieldWorks");
78+
activities.Should().Contain(a => a.Metadata.AuthorName == "Alice");
79+
}
80+
81+
[Fact]
82+
public async Task ProjectActivity_SortsOldestFirst()
83+
{
84+
await AddEntryCommit(new CommitMetadata { AuthorName = "First", AuthorId = "first" }, "alpha");
85+
await Task.Delay(5);
86+
await AddEntryCommit(new CommitMetadata { AuthorName = "Second", AuthorId = "second" }, "beta");
87+
88+
var activities = await Service.ProjectActivity(0, 1000, new ActivityQuery(Sort: ActivitySort.OldestFirst)).ToArrayAsync();
89+
var firstIndex = Array.FindIndex(activities, a => a.Metadata.AuthorId == "first");
90+
var secondIndex = Array.FindIndex(activities, a => a.Metadata.AuthorId == "second");
91+
firstIndex.Should().BeGreaterThanOrEqualTo(0);
92+
secondIndex.Should().BeGreaterThan(firstIndex);
93+
}
94+
95+
[Fact]
96+
public async Task ProjectActivity_SyncedNewestFirst_PlacesUnsyncedFirst()
97+
{
98+
var syncedCommit = await AddEntryCommit(new CommitMetadata { AuthorName = "Synced", AuthorId = "synced" }, "synced-entry");
99+
await SetSyncDate(syncedCommit.Id, new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero));
100+
await AddEntryCommit(new CommitMetadata { AuthorName = "Unsynced", AuthorId = "unsynced" }, "unsynced-entry");
101+
102+
var activities = await Service.ProjectActivity(0, 1000, new ActivityQuery(Sort: ActivitySort.SyncedNewestFirst)).ToArrayAsync();
103+
var commitAuthors = activities.Select(a => a.Metadata.AuthorId).Where(a => a is not null);
104+
commitAuthors.Should().ContainInOrder(["unsynced", "synced"]);
105+
}
106+
107+
[Fact]
108+
public async Task ProjectActivity_SyncedOldestFirst_PlacesUnsyncedLast()
109+
{
110+
var syncedCommit = await AddEntryCommit(new CommitMetadata { AuthorName = "Synced", AuthorId = "synced" }, "synced-entry");
111+
await SetSyncDate(syncedCommit.Id, new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero));
112+
await AddEntryCommit(new CommitMetadata { AuthorName = "Unsynced", AuthorId = "unsynced" }, "unsynced-entry");
113+
114+
var activities = await Service.ProjectActivity(0, 1000, new ActivityQuery(Sort: ActivitySort.SyncedOldestFirst)).ToArrayAsync();
115+
var commitAuthors = activities.Select(a => a.Metadata.AuthorId).Where(a => a is not null);
116+
commitAuthors.Should().ContainInOrder(["synced", "unsynced"]);
117+
}
118+
119+
[Fact]
120+
public async Task ProjectActivity_PaginationRespectsFilters()
121+
{
122+
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
123+
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
124+
await AddEntryCommit(new CommitMetadata { AuthorName = "Bob", AuthorId = "bob-id" });
125+
126+
var page = await Service.ProjectActivity(0, 1, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync();
127+
128+
page.Should().HaveCount(1);
129+
page[0].Metadata.AuthorId.Should().Be("alice-id");
130+
}
131+
132+
[Fact]
133+
public async Task ProjectActivity_FiltersByChangeTypeKeys()
134+
{
135+
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
136+
await AddNewPublicationCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
137+
138+
var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: [nameof(CreateEntryChange)])).ToArrayAsync();
139+
140+
activities.Should().OnlyContain(a => a.ChangeTypes.Contains(nameof(CreateEntryChange)));
141+
activities.Should().HaveCountGreaterThanOrEqualTo(1);
142+
activities.Should().NotContain(a => a.ChangeTypes.Contains(nameof(CreatePublicationChange)));
143+
}
144+
145+
[Fact]
146+
public async Task ProjectActivity_ChangeTypeKeys_FiltersMultipleTypes()
147+
{
148+
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
149+
await AddNewPublicationCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
150+
await AddNewPartOfSpeechCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
151+
(await Service.ProjectActivity(0, 100, new ActivityQuery()).ToArrayAsync())
152+
.Should().Contain(a => a.ChangeTypes.Contains(nameof(CreatePartOfSpeechChange)));
153+
154+
var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: [nameof(CreateEntryChange), nameof(CreatePublicationChange)])).ToArrayAsync();
155+
156+
activities.Should().OnlyContain(a => a.ChangeTypes.Any(t => t == nameof(CreateEntryChange) || t == nameof(CreatePublicationChange)));
157+
activities.Should().NotContain(a => a.ChangeTypes.Contains(nameof(CreatePartOfSpeechChange)));
158+
}
159+
160+
[Fact]
161+
public async Task ProjectActivity_IncludesChangeTypes()
162+
{
163+
await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" });
164+
165+
var activity = await Service.ProjectActivity(0, 1).SingleAsync();
166+
167+
activity.ChangeTypes.Should().Contain("CreateEntryChange");
168+
}
169+
170+
private async Task<Commit> AddEntryCommit(CommitMetadata metadata, string? headword = null)
171+
{
172+
var entry = headword is null
173+
? await AutoFaker.EntryReadyForCreation(_fixture.Api)
174+
: new Entry { Id = Guid.NewGuid(), LexemeForm = new MultiString { ["en"] = headword } };
175+
return await DataModel.AddChange(ClientId, new CreateEntryChange(entry), metadata);
176+
}
177+
178+
private async Task<Commit> AddNewPublicationCommit(CommitMetadata metadata, string publicationName = "Test Publication")
179+
{
180+
return await DataModel.AddChange(ClientId, new CreatePublicationChange(Guid.NewGuid(), new MultiString
181+
{
182+
["en"] = publicationName
183+
}), metadata);
184+
}
185+
186+
private async Task<Commit> AddNewPartOfSpeechCommit(CommitMetadata metadata, string partOfSpeechName = "Test Part of Speech")
187+
{
188+
return await DataModel.AddChange(ClientId, new CreatePartOfSpeechChange(Guid.NewGuid(), new MultiString
189+
{
190+
["en"] = partOfSpeechName
191+
}), metadata);
192+
}
193+
194+
private async Task SetSyncDate(Guid commitId, DateTimeOffset syncDate)
195+
{
196+
var db = _fixture.DbContext;
197+
var commit = await db.Set<Commit>().SingleAsync(c => c.Id == commitId);
198+
commit.SetSyncDate(syncDate);
199+
db.Entry(commit).Property(c => c.Metadata).IsModified = true;
200+
await db.SaveChangesAsync();
201+
}
202+
}

0 commit comments

Comments
 (0)