Skip to content
This repository was archived by the owner on Jun 21, 2023. It is now read-only.

Commit ad7d558

Browse files
committed
Save/load drafts for new PR review theads.
1 parent 04e6a90 commit ad7d558

File tree

6 files changed

+297
-18
lines changed

6 files changed

+297
-18
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace GitHub.App.Models.Drafts
2+
{
3+
public class CommentDraft
4+
{
5+
public string Body { get; set; }
6+
}
7+
}
Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
using System.ComponentModel.Composition;
1+
using System;
2+
using System.Collections.Generic;
3+
using System.ComponentModel.Composition;
4+
using System.Reactive.Concurrency;
5+
using System.Reactive.Linq;
6+
using System.Reactive.Subjects;
27
using System.Threading.Tasks;
8+
using GitHub.App.Models.Drafts;
39
using GitHub.Extensions;
410
using GitHub.Models;
11+
using GitHub.Services;
512
using ReactiveUI;
613

714
namespace GitHub.ViewModels
@@ -12,24 +19,39 @@ namespace GitHub.ViewModels
1219
public abstract class CommentThreadViewModel : ReactiveObject, ICommentThreadViewModel
1320
{
1421
readonly ReactiveList<ICommentViewModel> comments = new ReactiveList<ICommentViewModel>();
22+
readonly Dictionary<ICommentViewModel, IObserver<ICommentViewModel>> draftThrottles =
23+
new Dictionary<ICommentViewModel, IObserver<ICommentViewModel>>();
24+
readonly IScheduler timerScheduler;
1525

1626
/// <summary>
1727
/// Initializes a new instance of the <see cref="CommentThreadViewModel"/> class.
1828
/// </summary>
29+
/// <param name="draftStore">The message draft store.</param>
1930
[ImportingConstructor]
20-
public CommentThreadViewModel()
31+
public CommentThreadViewModel(IMessageDraftStore draftStore)
32+
: this(draftStore, DefaultScheduler.Instance)
2133
{
2234
}
2335

2436
/// <summary>
25-
/// Intializes a new instance of the <see cref="CommentThreadViewModel"/> class.
37+
/// Initializes a new instance of the <see cref="CommentThreadViewModel"/> class.
2638
/// </summary>
27-
/// <param name="currentUser">The current user.</param>
28-
protected Task InitializeAsync(ActorModel currentUser)
39+
/// <param name="draftStore">The message draft store.</param>
40+
/// <param name="timerScheduler">
41+
/// The scheduler to use to apply a throttle to message drafts.
42+
/// </param>
43+
[ImportingConstructor]
44+
public CommentThreadViewModel(
45+
IMessageDraftStore draftStore,
46+
IScheduler timerScheduler)
2947
{
30-
Guard.ArgumentNotNull(currentUser, nameof(currentUser));
31-
CurrentUser = new ActorViewModel(currentUser);
32-
return Task.CompletedTask;
48+
Guard.ArgumentNotNull(draftStore, nameof(draftStore));
49+
50+
this.DraftStore = draftStore;
51+
this.timerScheduler = timerScheduler;
52+
53+
comments.ChangeTrackingEnabled = true;
54+
comments.ItemChanged.Subscribe(ItemChanged);
3355
}
3456

3557
/// <inheritdoc/>
@@ -38,6 +60,11 @@ protected Task InitializeAsync(ActorModel currentUser)
3860
/// <inheritdoc/>
3961
public IActorViewModel CurrentUser { get; private set; }
4062

63+
/// <inheritdoc/>
64+
IReadOnlyReactiveList<ICommentViewModel> ICommentThreadViewModel.Comments => comments;
65+
66+
protected IMessageDraftStore DraftStore { get; }
67+
4168
/// <inheritdoc/>
4269
public abstract Task PostComment(string body);
4370

@@ -47,7 +74,65 @@ protected Task InitializeAsync(ActorModel currentUser)
4774
/// <inheritdoc/>
4875
public abstract Task DeleteComment(int pullRequestId, int commentId);
4976

50-
/// <inheritdoc/>
51-
IReadOnlyReactiveList<ICommentViewModel> ICommentThreadViewModel.Comments => comments;
77+
/// <summary>
78+
/// Intializes a new instance of the <see cref="CommentThreadViewModel"/> class.
79+
/// </summary>
80+
/// <param name="currentUser">The current user.</param>
81+
protected Task InitializeAsync(ActorModel currentUser)
82+
{
83+
Guard.ArgumentNotNull(currentUser, nameof(currentUser));
84+
CurrentUser = new ActorViewModel(currentUser);
85+
return Task.CompletedTask;
86+
}
87+
88+
protected virtual CommentDraft BuildDraft(ICommentViewModel comment)
89+
{
90+
return !string.IsNullOrEmpty(comment.Body) ?
91+
new CommentDraft { Body = comment.Body } :
92+
null;
93+
}
94+
95+
protected abstract (string key, string secondaryKey) GetDraftKeys(ICommentViewModel comment);
96+
97+
void ItemChanged(IReactivePropertyChangedEventArgs<ICommentViewModel> e)
98+
{
99+
if (e.PropertyName == nameof(ICommentViewModel.Body) &&
100+
e.Sender.EditState == CommentEditState.Editing)
101+
{
102+
if (!draftThrottles.TryGetValue(e.Sender, out var throttle))
103+
{
104+
var subject = new Subject<ICommentViewModel>();
105+
subject.Throttle(TimeSpan.FromSeconds(1), timerScheduler).Subscribe(UpdateDraft);
106+
draftThrottles.Add(e.Sender, subject);
107+
throttle = subject;
108+
}
109+
110+
throttle.OnNext(e.Sender);
111+
}
112+
else if (e.PropertyName == nameof(ICommentViewModel.EditState) &&
113+
e.Sender.EditState != CommentEditState.Editing)
114+
{
115+
if (draftThrottles.TryGetValue(e.Sender, out var throttle))
116+
{
117+
throttle.OnCompleted();
118+
draftThrottles.Remove(e.Sender);
119+
}
120+
}
121+
}
122+
123+
void UpdateDraft(ICommentViewModel comment)
124+
{
125+
var draft = BuildDraft(comment);
126+
var (key, secondaryKey) = GetDraftKeys(comment);
127+
128+
if (draft != null)
129+
{
130+
DraftStore.UpdateDraft(key, secondaryKey, draft).Forget();
131+
}
132+
else
133+
{
134+
DraftStore.DeleteDraft(key, secondaryKey).Forget();
135+
}
136+
}
52137
}
53138
}

src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
using System;
22
using System.ComponentModel.Composition;
3+
using System.Globalization;
34
using System.Linq;
45
using System.Threading.Tasks;
6+
using GitHub.App.Models.Drafts;
57
using GitHub.Extensions;
68
using GitHub.Factories;
79
using GitHub.Models;
10+
using GitHub.Primitives;
811
using GitHub.Services;
912
using ReactiveUI;
13+
using static System.FormattableString;
1014

1115
namespace GitHub.ViewModels
1216
{
@@ -25,9 +29,13 @@ public class PullRequestReviewCommentThreadViewModel : CommentThreadViewModel, I
2529
/// <summary>
2630
/// Initializes a new instance of the <see cref="PullRequestReviewCommentThreadViewModel"/> class.
2731
/// </summary>
32+
/// <param name="draftStore">The message draft store.</param>
2833
/// <param name="factory">The view model factory.</param>
2934
[ImportingConstructor]
30-
public PullRequestReviewCommentThreadViewModel(IViewViewModelFactory factory)
35+
public PullRequestReviewCommentThreadViewModel(
36+
IMessageDraftStore draftStore,
37+
IViewViewModelFactory factory)
38+
: base(draftStore)
3139
{
3240
Guard.ArgumentNotNull(factory, nameof(factory));
3341

@@ -75,7 +83,7 @@ public async Task InitializeAsync(
7583
{
7684
Guard.ArgumentNotNull(session, nameof(session));
7785

78-
await base.InitializeAsync(session.User).ConfigureAwait(false);
86+
await base.InitializeAsync(session.User).ConfigureAwait(true);
7987

8088
Session = session;
8189
File = file;
@@ -97,7 +105,7 @@ await vm.InitializeAsync(
97105
if (addPlaceholder)
98106
{
99107
var vm = factory.CreateViewModel<IPullRequestReviewCommentViewModel>();
100-
await vm.InitializeAsPlaceholderAsync(session, this, false).ConfigureAwait(false);
108+
await vm.InitializeAsPlaceholderAsync(session, this, false).ConfigureAwait(true);
101109
Comments.Add(vm);
102110
}
103111
}
@@ -123,6 +131,14 @@ public async Task InitializeNewAsync(
123131
var vm = factory.CreateViewModel<IPullRequestReviewCommentViewModel>();
124132
await vm.InitializeAsPlaceholderAsync(session, this, isEditing).ConfigureAwait(false);
125133
Comments.Add(vm);
134+
135+
var (key, secondaryKey) = GetDraftKeys(vm);
136+
var draft = await DraftStore.GetDraft<CommentDraft>(key, secondaryKey).ConfigureAwait(true);
137+
138+
if (draft != null)
139+
{
140+
vm.Body = draft.Body;
141+
}
126142
}
127143

128144
public override async Task PostComment(string body)
@@ -170,5 +186,29 @@ public override async Task DeleteComment(int pullRequestId, int commentId)
170186
{
171187
await Session.DeleteComment(pullRequestId, commentId).ConfigureAwait(false);
172188
}
189+
190+
public static (string key, string secondaryKey) GetDraftKeys(
191+
UriString cloneUri,
192+
int pullRequestNumber,
193+
string relativePath,
194+
int lineNumber)
195+
{
196+
var key = Invariant($"pr-review-comment|{cloneUri}|{pullRequestNumber}|{relativePath}");
197+
return (key, lineNumber.ToString(CultureInfo.InvariantCulture));
198+
}
199+
200+
protected override CommentDraft BuildDraft(ICommentViewModel comment)
201+
{
202+
return base.BuildDraft(comment);
203+
}
204+
205+
protected override (string key, string secondaryKey) GetDraftKeys(ICommentViewModel comment)
206+
{
207+
return GetDraftKeys(
208+
Session.LocalRepository.CloneUrl.WithOwner(Session.RepositoryOwner),
209+
Session.PullRequest.Number,
210+
File.RelativePath,
211+
LineNumber);
212+
}
173213
}
174214
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using System;
2+
using System.Reactive.Concurrency;
3+
using System.Threading.Tasks;
4+
using GitHub.App.Models.Drafts;
5+
using GitHub.Models;
6+
using GitHub.Services;
7+
using GitHub.ViewModels;
8+
using NSubstitute;
9+
using NUnit.Framework;
10+
using ReactiveUI.Testing;
11+
12+
namespace GitHub.App.UnitTests.ViewModels
13+
{
14+
public class CommentThreadViewModelTests
15+
{
16+
[Test]
17+
public async Task SavesDraftForEditingComment()
18+
{
19+
using (TestUtils.WithScheduler(Scheduler.CurrentThread))
20+
{
21+
var scheduler = new HistoricalScheduler();
22+
var drafts = Substitute.For<IMessageDraftStore>();
23+
var target = CreateTarget(drafts: drafts, scheduler: scheduler);
24+
25+
await target.AddPlaceholder(true);
26+
target.Comments[0].Body = "Edited comment.";
27+
28+
await drafts.DidNotReceiveWithAnyArgs().UpdateDraft<CommentDraft>(null, null, null);
29+
30+
scheduler.AdvanceBy(TimeSpan.FromSeconds(1));
31+
32+
await drafts.Received().UpdateDraft(
33+
"file.cs",
34+
"10",
35+
Arg.Is<CommentDraft>(x => x.Body == "Edited comment."));
36+
}
37+
}
38+
39+
[Test]
40+
public async Task DoesntSaveDraftForNonEditingComment()
41+
{
42+
using (TestUtils.WithScheduler(Scheduler.CurrentThread))
43+
{
44+
var scheduler = new HistoricalScheduler();
45+
var drafts = Substitute.For<IMessageDraftStore>();
46+
var target = CreateTarget(drafts: drafts, scheduler: scheduler);
47+
48+
await target.AddPlaceholder(false);
49+
target.Comments[0].Body = "Edited comment.";
50+
51+
scheduler.AdvanceBy(TimeSpan.FromSeconds(1));
52+
53+
await drafts.DidNotReceiveWithAnyArgs().UpdateDraft<CommentDraft>(null, null, null);
54+
}
55+
}
56+
57+
static Target CreateTarget(
58+
IMessageDraftStore drafts = null,
59+
IScheduler scheduler = null)
60+
{
61+
drafts = drafts ?? Substitute.For<IMessageDraftStore>();
62+
scheduler = scheduler ?? DefaultScheduler.Instance;
63+
64+
return new Target(drafts, scheduler);
65+
}
66+
67+
class Target : CommentThreadViewModel
68+
{
69+
public Target(IMessageDraftStore drafts, IScheduler scheduler)
70+
: base(drafts, scheduler)
71+
{
72+
}
73+
74+
public async Task AddPlaceholder(bool isEditing)
75+
{
76+
var c = new TestComment();
77+
await c.InitializeAsPlaceholderAsync(this, isEditing);
78+
Comments.Add(c);
79+
}
80+
81+
public override Task DeleteComment(int pullRequestId, int commentId) => throw new NotImplementedException();
82+
public override Task EditComment(string id, string body) => throw new NotImplementedException();
83+
public override Task PostComment(string body) => throw new NotImplementedException();
84+
protected override (string key, string secondaryKey) GetDraftKeys(ICommentViewModel comment) => ("file.cs", "10");
85+
}
86+
87+
class TestComment : CommentViewModel
88+
{
89+
public TestComment()
90+
: base(Substitute.For<ICommentService>())
91+
{
92+
}
93+
94+
/// <inheritdoc/>
95+
public async Task InitializeAsPlaceholderAsync(
96+
ICommentThreadViewModel thread,
97+
bool isEditing)
98+
{
99+
await InitializeAsync(
100+
thread,
101+
new ActorModel(),
102+
null,
103+
isEditing ? CommentEditState.Editing : CommentEditState.Placeholder).ConfigureAwait(true);
104+
}
105+
}
106+
}
107+
}

0 commit comments

Comments
 (0)