diff --git a/src/GitHub.App/SampleData/CommentThreadViewModelDesigner.cs b/src/GitHub.App/SampleData/CommentThreadViewModelDesigner.cs index b9366a883e..bc8f3955bb 100644 --- a/src/GitHub.App/SampleData/CommentThreadViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/CommentThreadViewModelDesigner.cs @@ -1,23 +1,21 @@ -using System; -using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; -using System.Reactive; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; using GitHub.ViewModels; using ReactiveUI; namespace GitHub.SampleData { [SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses")] - public class CommentThreadViewModelDesigner : ICommentThreadViewModel + public class CommentThreadViewModelDesigner : ViewModelBase, ICommentThreadViewModel { - public ObservableCollection Comments { get; } - = new ObservableCollection(); + public IReadOnlyReactiveList Comments { get; } + = new ReactiveList(); public IActorViewModel CurrentUser { get; set; } = new ActorViewModel { Login = "shana" }; - public ReactiveCommand PostComment { get; } - public ReactiveCommand, Unit> EditComment { get; } - public ReactiveCommand, Unit> DeleteComment { get; } + public Task DeleteComment(int pullRequestId, int commentId) => Task.CompletedTask; + public Task EditComment(string id, string body) => Task.CompletedTask; + public Task PostComment(string body) => Task.CompletedTask; } } diff --git a/src/GitHub.App/SampleData/CommentViewModelDesigner.cs b/src/GitHub.App/SampleData/CommentViewModelDesigner.cs index 33c4b0bd98..425f536ad7 100644 --- a/src/GitHub.App/SampleData/CommentViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/CommentViewModelDesigner.cs @@ -24,7 +24,7 @@ public CommentViewModelDesigner() public bool IsSubmitting { get; set; } public bool CanDelete { get; } = true; public ICommentThreadViewModel Thread { get; } - public DateTimeOffset UpdatedAt => DateTime.Now.Subtract(TimeSpan.FromDays(3)); + public DateTimeOffset CreatedAt => DateTime.Now.Subtract(TimeSpan.FromDays(3)); public IActorViewModel Author { get; set; } public Uri WebUrl { get; } diff --git a/src/GitHub.App/ViewModels/CommentThreadViewModel.cs b/src/GitHub.App/ViewModels/CommentThreadViewModel.cs index 67f1f570f8..1fae1484b2 100644 --- a/src/GitHub.App/ViewModels/CommentThreadViewModel.cs +++ b/src/GitHub.App/ViewModels/CommentThreadViewModel.cs @@ -1,77 +1,53 @@ -using System; -using System.Collections.ObjectModel; -using System.Reactive; +using System.ComponentModel.Composition; +using System.Threading.Tasks; using GitHub.Extensions; using GitHub.Models; -using GitHub.ViewModels; using ReactiveUI; -namespace GitHub.InlineReviews.ViewModels +namespace GitHub.ViewModels { /// /// Base view model for a thread of comments. /// public abstract class CommentThreadViewModel : ReactiveObject, ICommentThreadViewModel { - ReactiveCommand postComment; - ReactiveCommand, Unit> editComment; - ReactiveCommand, Unit> deleteComment; + readonly ReactiveList comments = new ReactiveList(); + + /// + /// Initializes a new instance of the class. + /// + [ImportingConstructor] + public CommentThreadViewModel() + { + } /// /// Intializes a new instance of the class. /// /// The current user. - protected CommentThreadViewModel(ActorModel currentUser) + protected Task InitializeAsync(ActorModel currentUser) { Guard.ArgumentNotNull(currentUser, nameof(currentUser)); - - Comments = new ObservableCollection(); CurrentUser = new ActorViewModel(currentUser); + return Task.CompletedTask; } /// - public ObservableCollection Comments { get; } + public IReactiveList Comments => comments; /// - public ReactiveCommand PostComment - { - get { return postComment; } - protected set - { - Guard.ArgumentNotNull(value, nameof(value)); - postComment = value; - - // We want to ignore thrown exceptions from PostComment - the error should be handled - // by the CommentViewModel that trigged PostComment.Execute(); - value.ThrownExceptions.Subscribe(_ => { }); - } - } + public IActorViewModel CurrentUser { get; private set; } - public ReactiveCommand, Unit> EditComment - { - get { return editComment; } - protected set - { - Guard.ArgumentNotNull(value, nameof(value)); - editComment = value; - - value.ThrownExceptions.Subscribe(_ => { }); - } - } + /// + IReadOnlyReactiveList ICommentThreadViewModel.Comments => comments; - public ReactiveCommand, Unit> DeleteComment - { - get { return deleteComment; } - protected set - { - Guard.ArgumentNotNull(value, nameof(value)); - deleteComment = value; + /// + public abstract Task PostComment(string body); - value.ThrownExceptions.Subscribe(_ => { }); - } - } + /// + public abstract Task EditComment(string id, string body); /// - public IActorViewModel CurrentUser { get; } + public abstract Task DeleteComment(int pullRequestId, int commentId); } } diff --git a/src/GitHub.App/ViewModels/CommentViewModel.cs b/src/GitHub.App/ViewModels/CommentViewModel.cs index 5a55f0ae36..fade1742f0 100644 --- a/src/GitHub.App/ViewModels/CommentViewModel.cs +++ b/src/GitHub.App/ViewModels/CommentViewModel.cs @@ -7,74 +7,46 @@ using GitHub.Logging; using GitHub.Models; using GitHub.Services; -using GitHub.ViewModels; using ReactiveUI; using Serilog; -namespace GitHub.InlineReviews.ViewModels +namespace GitHub.ViewModels { /// - /// View model for an issue or pull request comment. + /// Base view model for an issue or pull request comment. /// - public class CommentViewModel : ReactiveObject, ICommentViewModel + public abstract class CommentViewModel : ReactiveObject, ICommentViewModel { static readonly ILogger log = LogManager.ForContext(); - ICommentService commentService; + readonly ICommentService commentService; + readonly ObservableAsPropertyHelper canDelete; + string id; + IActorViewModel author; + IActorViewModel currentUser; string body; string errorMessage; bool isReadOnly; bool isSubmitting; CommentEditState state; - DateTimeOffset updatedAt; + DateTimeOffset createdAt; + ICommentThreadViewModel thread; string undoBody; - ObservableAsPropertyHelper canDelete; /// /// Initializes a new instance of the class. /// - /// The comment service - /// The thread that the comment is a part of. - /// The current user. - /// The pull request id of the comment. - /// The GraphQL ID of the comment. - /// The database id of the comment. - /// The comment body. - /// The comment edit state. - /// The author of the comment. - /// The modified date of the comment. - /// - protected CommentViewModel( - ICommentService commentService, - ICommentThreadViewModel thread, - IActorViewModel currentUser, - int pullRequestId, - string commentId, - int databaseId, - string body, - CommentEditState state, - IActorViewModel author, - DateTimeOffset updatedAt, - Uri webUrl) + /// The comment service. + public CommentViewModel(ICommentService commentService) { - this.commentService = commentService; - Guard.ArgumentNotNull(thread, nameof(thread)); - Guard.ArgumentNotNull(currentUser, nameof(currentUser)); - Guard.ArgumentNotNull(author, nameof(author)); + Guard.ArgumentNotNull(commentService, nameof(commentService)); - Thread = thread; - CurrentUser = currentUser; - Id = commentId; - DatabaseId = databaseId; - PullRequestId = pullRequestId; - Body = body; - EditState = state; - Author = author; - UpdatedAt = updatedAt; - WebUrl = webUrl; + this.commentService = commentService; var canDeleteObservable = this.WhenAnyValue( x => x.EditState, - x => x == CommentEditState.None && author.Login == currentUser.Login); + x => x.Author, + x => x.CurrentUser, + (editState, author, currentUser) => editState == CommentEditState.None && author?.Login == currentUser?.Login); canDelete = canDeleteObservable.ToProperty(this, x => x.CanDelete); @@ -82,18 +54,20 @@ protected CommentViewModel( var canEdit = this.WhenAnyValue( x => x.EditState, - x => x == CommentEditState.Placeholder || (x == CommentEditState.None && author.Login == currentUser.Login)); + x => x.Author, + x => x.CurrentUser, + (editState, author, currentUser) => editState == CommentEditState.Placeholder || + (editState == CommentEditState.None && author?.Login == currentUser?.Login)); BeginEdit = ReactiveCommand.Create(DoBeginEdit, canEdit); AddErrorHandler(BeginEdit); CommitEdit = ReactiveCommand.CreateFromTask( DoCommitEdit, - Observable.CombineLatest( - this.WhenAnyValue(x => x.IsReadOnly), - this.WhenAnyValue(x => x.Body, x => !string.IsNullOrWhiteSpace(x)), - this.WhenAnyObservable(x => x.Thread.PostComment.CanExecute), - (readOnly, hasBody, canPost) => !readOnly && hasBody && canPost)); + this.WhenAnyValue( + x => x.IsReadOnly, + x => x.Body, + (ro, body) => !ro && !string.IsNullOrWhiteSpace(body))); AddErrorHandler(CommitEdit); CancelEdit = ReactiveCommand.Create(DoCancelEdit, CommitEdit.IsExecuting.Select(x => !x)); @@ -104,31 +78,131 @@ protected CommentViewModel( this.WhenAnyValue(x => x.Id).Select(x => x != null)); } + /// + public string Id + { + get => id; + private set => this.RaiseAndSetIfChanged(ref id, value); + } + + /// + public int DatabaseId { get; private set; } + + /// + public int PullRequestId { get; private set; } + + /// + public IActorViewModel Author + { + get => author; + private set => this.RaiseAndSetIfChanged(ref author, value); + } + + /// + public IActorViewModel CurrentUser + { + get => currentUser; + private set => this.RaiseAndSetIfChanged(ref currentUser, value); + } + + /// + public string Body + { + get => body; + set => this.RaiseAndSetIfChanged(ref body, value); + } + + /// + public string ErrorMessage + { + get => errorMessage; + private set => this.RaiseAndSetIfChanged(ref errorMessage, value); + } + + /// + public CommentEditState EditState + { + get => state; + private set => this.RaiseAndSetIfChanged(ref state, value); + } + + /// + public bool IsReadOnly + { + get => isReadOnly; + set => this.RaiseAndSetIfChanged(ref isReadOnly, value); + } + + /// + public bool IsSubmitting + { + get => isSubmitting; + protected set => this.RaiseAndSetIfChanged(ref isSubmitting, value); + } + + /// + public bool CanDelete => canDelete.Value; + + /// + public DateTimeOffset CreatedAt + { + get => createdAt; + private set => this.RaiseAndSetIfChanged(ref createdAt, value); + } + + /// + public ICommentThreadViewModel Thread + { + get => thread; + private set => this.RaiseAndSetIfChanged(ref thread, value); + } + + /// + public Uri WebUrl { get; private set; } + + /// + public ReactiveCommand BeginEdit { get; } + + /// + public ReactiveCommand CancelEdit { get; } + + /// + public ReactiveCommand CommitEdit { get; } + + /// + public ReactiveCommand OpenOnGitHub { get; } + + /// + public ReactiveCommand Delete { get; } + /// - /// Initializes a new instance of the class. + /// Initializes the view model with data. /// - /// Comment Service /// The thread that the comment is a part of. /// The current user. - /// The comment model. - protected CommentViewModel( - ICommentService commentService, + /// The comment model. May be null. + /// The comment edit state. + protected Task InitializeAsync( ICommentThreadViewModel thread, ActorModel currentUser, - CommentModel model) - : this( - commentService, - thread, - new ActorViewModel(currentUser), - model.PullRequestId, - model.Id, - model.DatabaseId, - model.Body, - CommentEditState.None, - new ActorViewModel(model.Author), - model.CreatedAt, - new Uri(model.Url)) + CommentModel comment, + CommentEditState state) { + Guard.ArgumentNotNull(thread, nameof(thread)); + Guard.ArgumentNotNull(currentUser, nameof(currentUser)); + + Thread = thread; + CurrentUser = new ActorViewModel(currentUser); + Id = comment?.Id; + DatabaseId = comment?.DatabaseId ?? 0; + PullRequestId = comment?.PullRequestId ?? 0; + Body = comment?.Body; + EditState = state; + Author = comment != null ? new ActorViewModel(comment.Author) : CurrentUser; + CreatedAt = comment?.CreatedAt ?? DateTimeOffset.MinValue; + WebUrl = comment?.Url != null ? new Uri(comment.Url) : null; + + return Task.CompletedTask; } protected void AddErrorHandler(ReactiveCommand command) @@ -145,7 +219,7 @@ async Task DoDelete() ErrorMessage = null; IsSubmitting = true; - await Thread.DeleteComment.Execute(new Tuple(PullRequestId, DatabaseId)); + await Thread.DeleteComment(PullRequestId, DatabaseId).ConfigureAwait(true); } catch (Exception e) { @@ -190,11 +264,11 @@ async Task DoCommitEdit() if (Id == null) { - await Thread.PostComment.Execute(Body); + await Thread.PostComment(Body).ConfigureAwait(true); } else { - await Thread.EditComment.Execute(new Tuple(Id, Body)); + await Thread.EditComment(Id, Body).ConfigureAwait(true); } } catch (Exception e) @@ -208,88 +282,5 @@ async Task DoCommitEdit() IsSubmitting = false; } } - - /// - public string Id { get; private set; } - - /// - public int DatabaseId { get; private set; } - - /// - public int PullRequestId { get; private set; } - - /// - public string Body - { - get { return body; } - set { this.RaiseAndSetIfChanged(ref body, value); } - } - - /// - public string ErrorMessage - { - get { return this.errorMessage; } - private set { this.RaiseAndSetIfChanged(ref errorMessage, value); } - } - - /// - public CommentEditState EditState - { - get { return state; } - private set { this.RaiseAndSetIfChanged(ref state, value); } - } - - /// - public bool IsReadOnly - { - get { return isReadOnly; } - set { this.RaiseAndSetIfChanged(ref isReadOnly, value); } - } - - /// - public bool IsSubmitting - { - get { return isSubmitting; } - protected set { this.RaiseAndSetIfChanged(ref isSubmitting, value); } - } - - public bool CanDelete - { - get { return canDelete.Value; } - } - - /// - public DateTimeOffset UpdatedAt - { - get { return updatedAt; } - private set { this.RaiseAndSetIfChanged(ref updatedAt, value); } - } - - /// - public IActorViewModel CurrentUser { get; } - - /// - public ICommentThreadViewModel Thread { get; } - - /// - public IActorViewModel Author { get; } - - /// - public Uri WebUrl { get; } - - /// - public ReactiveCommand BeginEdit { get; } - - /// - public ReactiveCommand CancelEdit { get; } - - /// - public ReactiveCommand CommitEdit { get; } - - /// - public ReactiveCommand OpenOnGitHub { get; } - - /// - public ReactiveCommand Delete { get; } } } diff --git a/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs b/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs new file mode 100644 index 0000000000..db225e9c88 --- /dev/null +++ b/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs @@ -0,0 +1,174 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + /// + /// A thread of pull request review comments. + /// + [Export(typeof(IPullRequestReviewCommentThreadViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestReviewCommentThreadViewModel : CommentThreadViewModel, IPullRequestReviewCommentThreadViewModel + { + readonly IViewViewModelFactory factory; + readonly ObservableAsPropertyHelper needsPush; + IPullRequestSessionFile file; + bool isNewThread; + + /// + /// Initializes a new instance of the class. + /// + /// The view model factory. + [ImportingConstructor] + public PullRequestReviewCommentThreadViewModel(IViewViewModelFactory factory) + { + Guard.ArgumentNotNull(factory, nameof(factory)); + + this.factory = factory; + + needsPush = this.WhenAnyValue( + x => x.File.CommitSha, + x => x.IsNewThread, + (sha, isNew) => isNew && sha == null) + .ToProperty(this, x => x.NeedsPush); + } + + /// + public IPullRequestSession Session { get; private set; } + + /// + public IPullRequestSessionFile File + { + get => file; + private set => this.RaiseAndSetIfChanged(ref file, value); + } + + /// + public int LineNumber { get; private set; } + + /// + public DiffSide Side { get; private set; } + + public bool IsNewThread + { + get => isNewThread; + private set => this.RaiseAndSetIfChanged(ref isNewThread, value); + } + + /// + public bool NeedsPush => needsPush.Value; + + /// + public async Task InitializeAsync( + IPullRequestSession session, + IPullRequestSessionFile file, + PullRequestReviewModel review, + IInlineCommentThreadModel thread, + bool addPlaceholder) + { + Guard.ArgumentNotNull(session, nameof(session)); + + await base.InitializeAsync(session.User).ConfigureAwait(false); + + Session = session; + File = file; + LineNumber = thread.LineNumber; + Side = thread.DiffLineType == DiffChangeType.Delete ? DiffSide.Left : DiffSide.Right; + + foreach (var comment in thread.Comments) + { + var vm = factory.CreateViewModel(); + await vm.InitializeAsync( + session, + this, + review, + comment.Comment, + CommentEditState.None).ConfigureAwait(false); + Comments.Add(vm); + } + + if (addPlaceholder) + { + var vm = factory.CreateViewModel(); + await vm.InitializeAsPlaceholderAsync(session, this, false).ConfigureAwait(false); + Comments.Add(vm); + } + } + + /// + public async Task InitializeNewAsync( + IPullRequestSession session, + IPullRequestSessionFile file, + int lineNumber, + DiffSide side, + bool isEditing) + { + Guard.ArgumentNotNull(session, nameof(session)); + + await base.InitializeAsync(session.User).ConfigureAwait(false); + + Session = session; + File = file; + LineNumber = lineNumber; + Side = side; + IsNewThread = true; + + var vm = factory.CreateViewModel(); + await vm.InitializeAsPlaceholderAsync(session, this, isEditing).ConfigureAwait(false); + Comments.Add(vm); + } + + public override async Task PostComment(string body) + { + Guard.ArgumentNotNull(body, nameof(body)); + + if (IsNewThread) + { + var diffPosition = File.Diff + .SelectMany(x => x.Lines) + .FirstOrDefault(x => + { + var line = Side == DiffSide.Left ? x.OldLineNumber : x.NewLineNumber; + return line == LineNumber + 1; + }); + + if (diffPosition == null) + { + throw new InvalidOperationException("Unable to locate line in diff."); + } + + await Session.PostReviewComment( + body, + File.CommitSha, + File.RelativePath.Replace("\\", "/"), + File.Diff, + diffPosition.DiffLineNumber).ConfigureAwait(false); + } + else + { + var replyId = Comments[0].Id; + await Session.PostReviewComment(body, replyId).ConfigureAwait(false); + } + } + + public override async Task EditComment(string id, string body) + { + Guard.ArgumentNotNull(id, nameof(id)); + Guard.ArgumentNotNull(body, nameof(body)); + + await Session.EditComment(id, body).ConfigureAwait(false); + } + + public override async Task DeleteComment(int pullRequestId, int commentId) + { + await Session.DeleteComment(pullRequestId, commentId).ConfigureAwait(false); + } + } +} diff --git a/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs new file mode 100644 index 0000000000..8a90eaaae3 --- /dev/null +++ b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs @@ -0,0 +1,113 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + /// + /// View model for a pull request review comment. + /// + [Export(typeof(IPullRequestReviewCommentViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestReviewCommentViewModel : CommentViewModel, IPullRequestReviewCommentViewModel + { + readonly ObservableAsPropertyHelper canStartReview; + readonly ObservableAsPropertyHelper commitCaption; + IPullRequestSession session; + bool isPending; + + /// + /// Initializes a new instance of the class. + /// + /// The comment service + [ImportingConstructor] + public PullRequestReviewCommentViewModel(ICommentService commentService) + : base(commentService) + { + var pendingAndIsNew = this.WhenAnyValue( + x => x.IsPending, + x => x.Id, + (isPending, id) => (isPending, isNewComment: id == null)); + + canStartReview = pendingAndIsNew + .Select(arg => !arg.isPending && arg.isNewComment) + .ToProperty(this, x => x.CanStartReview); + + commitCaption = pendingAndIsNew + .Select(arg => !arg.isNewComment ? Resources.UpdateComment : arg.isPending ? Resources.AddReviewComment : Resources.AddSingleComment) + .ToProperty(this, x => x.CommitCaption); + + StartReview = ReactiveCommand.CreateFromTask(DoStartReview, CommitEdit.CanExecute); + AddErrorHandler(StartReview); + } + + /// + public async Task InitializeAsync( + IPullRequestSession session, + ICommentThreadViewModel thread, + PullRequestReviewModel review, + PullRequestReviewCommentModel comment, + CommentEditState state) + { + Guard.ArgumentNotNull(session, nameof(session)); + + await InitializeAsync(thread, session.User, comment, state).ConfigureAwait(true); + this.session = session; + IsPending = review.State == PullRequestReviewState.Pending; + } + + /// + public async Task InitializeAsPlaceholderAsync( + IPullRequestSession session, + ICommentThreadViewModel thread, + bool isEditing) + { + Guard.ArgumentNotNull(session, nameof(session)); + + await InitializeAsync( + thread, + session.User, + null, + isEditing ? CommentEditState.Editing : CommentEditState.Placeholder).ConfigureAwait(true); + this.session = session; + } + + /// + public bool CanStartReview => canStartReview.Value; + + /// + public string CommitCaption => commitCaption.Value; + + /// + public bool IsPending + { + get => isPending; + private set => this.RaiseAndSetIfChanged(ref isPending, value); + } + + /// + public ReactiveCommand StartReview { get; } + + async Task DoStartReview() + { + IsSubmitting = true; + + try + { + await session.StartReview().ConfigureAwait(false); + await CommitEdit.Execute(); + } + finally + { + IsSubmitting = false; + } + } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/ICommentThreadViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ICommentThreadViewModel.cs index b64f641bc4..a902e9dbb8 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ICommentThreadViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/ICommentThreadViewModel.cs @@ -1,6 +1,5 @@ using System; -using System.Collections.ObjectModel; -using System.Reactive; +using System.Threading.Tasks; using ReactiveUI; namespace GitHub.ViewModels @@ -8,12 +7,12 @@ namespace GitHub.ViewModels /// /// A comment thread. /// - public interface ICommentThreadViewModel + public interface ICommentThreadViewModel : IViewModel { /// /// Gets the comments in the thread. /// - ObservableCollection Comments { get; } + IReadOnlyReactiveList Comments { get; } /// /// Gets the current user under whos account new comments will be created. @@ -23,16 +22,16 @@ public interface ICommentThreadViewModel /// /// Called by a comment in the thread to post itself as a new comment to the API. /// - ReactiveCommand PostComment { get; } + Task PostComment(string body); /// /// Called by a comment in the thread to post itself as an edit to a comment to the API. /// - ReactiveCommand, Unit> EditComment { get; } + Task EditComment(string id, string body); /// - /// Called by a comment in the thread to send a delete of the comment to the API. + /// Called by a comment in the thread to delete the comment on the API. /// - ReactiveCommand, Unit> DeleteComment { get; } + Task DeleteComment(int pullRequestId, int commentId); } } diff --git a/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs index b9f62fa66a..5914b0ddbd 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs @@ -31,6 +31,11 @@ public interface ICommentViewModel : IViewModel /// int PullRequestId { get; } + /// + /// Gets the author of the comment. + /// + IActorViewModel Author { get; } + /// /// Gets or sets the body of the comment. /// @@ -63,14 +68,9 @@ public interface ICommentViewModel : IViewModel bool CanDelete { get; } /// - /// Gets the modified date of the comment. + /// Gets the creation date of the comment. /// - DateTimeOffset UpdatedAt { get; } - - /// - /// Gets the author of the comment. - /// - IActorViewModel Author { get; } + DateTimeOffset CreatedAt { get; } /// /// Gets the thread that the comment is a part of. diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentThreadViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentThreadViewModel.cs new file mode 100644 index 0000000000..67c13ae2ba --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentThreadViewModel.cs @@ -0,0 +1,76 @@ +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; + +namespace GitHub.ViewModels +{ + /// + /// A thread of pull request review comments. + /// + public interface IPullRequestReviewCommentThreadViewModel : ICommentThreadViewModel + { + /// + /// Gets the current pull request review session. + /// + IPullRequestSession Session { get; } + + /// + /// Gets the file that the comment is on. + /// + IPullRequestSessionFile File { get; } + + /// + /// Gets the 0-based line number that the comment in on. + /// + int LineNumber { get; } + + /// + /// Gets the side of the diff that the comment is on. + /// + DiffSide Side { get; } + + /// + /// Gets a value indicating whether the thread is a new thread being authored, that is not + /// yet present on the server. + /// + bool IsNewThread { get; } + + /// + /// Gets a value indicating whether the user must commit and push their changes before + /// leaving a comment on the requested line. + /// + bool NeedsPush { get; } + + /// + /// Initializes the view model with data. + /// + /// The pull request session. + /// The file that the comment is on. + /// The associated review. + /// The thread. + /// + /// Whether to add a placeholder comment at the end of the thread. + /// + Task InitializeAsync( + IPullRequestSession session, + IPullRequestSessionFile file, + PullRequestReviewModel review, + IInlineCommentThreadModel thread, + bool addPlaceholder); + + /// + /// Initializes the view model as a new thread being authored. + /// + /// The pull request session. + /// The file that the comment is on. + /// The 0-based line number of the thread. + /// The side of the diff. + /// Whether to start the placeholder in edit state. + Task InitializeNewAsync( + IPullRequestSession session, + IPullRequestSessionFile file, + int lineNumber, + DiffSide side, + bool isEditing); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentViewModel.cs new file mode 100644 index 0000000000..175c09f3f0 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentViewModel.cs @@ -0,0 +1,64 @@ +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + /// + /// View model for a pull request review comment. + /// + public interface IPullRequestReviewCommentViewModel : ICommentViewModel + { + /// + /// Gets a value indicating whether the user can start a new review with this comment. + /// + bool CanStartReview { get; } + + /// + /// Gets the caption for the "Commit" button. + /// + /// + /// This will be "Add a single comment" when not in review mode and "Add review comment" + /// when in review mode. + /// + string CommitCaption { get; } + + /// + /// Gets a value indicating whether this comment is part of a pending pull request review. + /// + bool IsPending { get; } + + /// + /// Gets a command which will commit a new comment and start a review. + /// + ReactiveCommand StartReview { get; } + + /// + /// Initializes the view model with data. + /// + /// The pull request session. + /// The thread that the comment is a part of. + /// The associated pull request review. + /// The comment model. + /// The comment edit state. + Task InitializeAsync( + IPullRequestSession session, + ICommentThreadViewModel thread, + PullRequestReviewModel review, + PullRequestReviewCommentModel comment, + CommentEditState state); + + /// + /// Initializes the view model as a placeholder. + /// + /// The pull request session. + /// The thread that the comment is a part of. + /// Whether to start the placeholder in edit mode. + Task InitializeAsPlaceholderAsync( + IPullRequestSession session, + ICommentThreadViewModel thread, + bool isEditing); + } +} \ No newline at end of file diff --git a/src/GitHub.InlineReviews/GitHub.InlineReviews.csproj b/src/GitHub.InlineReviews/GitHub.InlineReviews.csproj index 7f7a3a475f..7404ed2695 100644 --- a/src/GitHub.InlineReviews/GitHub.InlineReviews.csproj +++ b/src/GitHub.InlineReviews/GitHub.InlineReviews.csproj @@ -106,9 +106,6 @@ - - - PullRequestFileMarginView.xaml @@ -133,7 +130,6 @@ - PullRequestStatusView.xaml diff --git a/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSource.cs b/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSource.cs index 21e63fbddd..5cb05a60df 100644 --- a/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSource.cs +++ b/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSource.cs @@ -16,19 +16,19 @@ class InlineCommentPeekableItemSource : IPeekableItemSource readonly IPullRequestSessionManager sessionManager; readonly INextInlineCommentCommand nextCommentCommand; readonly IPreviousInlineCommentCommand previousCommentCommand; - readonly ICommentService commentService; + readonly IViewViewModelFactory factory; public InlineCommentPeekableItemSource(IInlineCommentPeekService peekService, IPullRequestSessionManager sessionManager, INextInlineCommentCommand nextCommentCommand, IPreviousInlineCommentCommand previousCommentCommand, - ICommentService commentService) + IViewViewModelFactory factory) { this.peekService = peekService; this.sessionManager = sessionManager; this.nextCommentCommand = nextCommentCommand; this.previousCommentCommand = previousCommentCommand; - this.commentService = commentService; + this.factory = factory; } public void AugmentPeekSession(IPeekSession session, IList peekableItems) @@ -41,7 +41,7 @@ public void AugmentPeekSession(IPeekSession session, IList peekab sessionManager, nextCommentCommand, previousCommentCommand, - commentService); + factory); viewModel.Initialize().Forget(); peekableItems.Add(new InlineCommentPeekableItem(viewModel)); } diff --git a/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSourceProvider.cs b/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSourceProvider.cs index 65558b3d83..79f390814d 100644 --- a/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSourceProvider.cs +++ b/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSourceProvider.cs @@ -1,8 +1,6 @@ -using System; -using System.ComponentModel.Composition; +using System.ComponentModel.Composition; using GitHub.Commands; using GitHub.Factories; -using GitHub.InlineReviews.Commands; using GitHub.InlineReviews.Services; using GitHub.Services; using Microsoft.VisualStudio.Language.Intellisense; @@ -20,7 +18,7 @@ class InlineCommentPeekableItemSourceProvider : IPeekableItemSourceProvider readonly IPullRequestSessionManager sessionManager; readonly INextInlineCommentCommand nextCommentCommand; readonly IPreviousInlineCommentCommand previousCommentCommand; - readonly ICommentService commentService; + readonly IViewViewModelFactory factory; [ImportingConstructor] public InlineCommentPeekableItemSourceProvider( @@ -28,13 +26,13 @@ public InlineCommentPeekableItemSourceProvider( IPullRequestSessionManager sessionManager, INextInlineCommentCommand nextCommentCommand, IPreviousInlineCommentCommand previousCommentCommand, - ICommentService commentService) + IViewViewModelFactory factory) { this.peekService = peekService; this.sessionManager = sessionManager; this.nextCommentCommand = nextCommentCommand; this.previousCommentCommand = previousCommentCommand; - this.commentService = commentService; + this.factory = factory; } public IPeekableItemSource TryCreatePeekableItemSource(ITextBuffer textBuffer) @@ -44,7 +42,7 @@ public IPeekableItemSource TryCreatePeekableItemSource(ITextBuffer textBuffer) sessionManager, nextCommentCommand, previousCommentCommand, - commentService); + factory); } } } diff --git a/src/GitHub.InlineReviews/ViewModels/IPullRequestReviewCommentViewModel.cs b/src/GitHub.InlineReviews/ViewModels/IPullRequestReviewCommentViewModel.cs deleted file mode 100644 index 3e5c492237..0000000000 --- a/src/GitHub.InlineReviews/ViewModels/IPullRequestReviewCommentViewModel.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Reactive; -using GitHub.ViewModels; -using ReactiveUI; - -namespace GitHub.InlineReviews.ViewModels -{ - /// - /// View model for a pull request review comment. - /// - public interface IPullRequestReviewCommentViewModel : ICommentViewModel - { - /// - /// Gets a value indicating whether the user can start a new review with this comment. - /// - bool CanStartReview { get; } - - /// - /// Gets the caption for the "Commit" button. - /// - /// - /// This will be "Add a single comment" when not in review mode and "Add review comment" - /// when in review mode. - /// - string CommitCaption { get; } - - /// - /// Gets a value indicating whether this comment is part of a pending pull request review. - /// - bool IsPending { get; } - - /// - /// Gets a command which will commit a new comment and start a review. - /// - ReactiveCommand StartReview { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.InlineReviews/ViewModels/InlineCommentPeekViewModel.cs b/src/GitHub.InlineReviews/ViewModels/InlineCommentPeekViewModel.cs index 5be41258ef..80cc3d6192 100644 --- a/src/GitHub.InlineReviews/ViewModels/InlineCommentPeekViewModel.cs +++ b/src/GitHub.InlineReviews/ViewModels/InlineCommentPeekViewModel.cs @@ -4,17 +4,13 @@ using System.Reactive; using System.Reactive.Linq; using System.Threading.Tasks; -using GitHub.Api; using GitHub.Commands; using GitHub.Extensions; using GitHub.Extensions.Reactive; using GitHub.Factories; -using GitHub.InlineReviews.Commands; -using GitHub.InlineReviews.Peek; using GitHub.InlineReviews.Services; using GitHub.Logging; using GitHub.Models; -using GitHub.Primitives; using GitHub.Services; using GitHub.ViewModels; using Microsoft.VisualStudio.Language.Intellisense; @@ -33,10 +29,10 @@ public sealed class InlineCommentPeekViewModel : ReactiveObject, IDisposable readonly IInlineCommentPeekService peekService; readonly IPeekSession peekSession; readonly IPullRequestSessionManager sessionManager; - readonly ICommentService commentService; + readonly IViewViewModelFactory factory; IPullRequestSession session; IPullRequestSessionFile file; - ICommentThreadViewModel thread; + IPullRequestReviewCommentThreadViewModel thread; IDisposable fileSubscription; IDisposable sessionSubscription; IDisposable threadSubscription; @@ -52,24 +48,26 @@ public InlineCommentPeekViewModel(IInlineCommentPeekService peekService, IPullRequestSessionManager sessionManager, INextInlineCommentCommand nextCommentCommand, IPreviousInlineCommentCommand previousCommentCommand, - ICommentService commentService) + IViewViewModelFactory factory) { Guard.ArgumentNotNull(peekService, nameof(peekService)); Guard.ArgumentNotNull(peekSession, nameof(peekSession)); Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); Guard.ArgumentNotNull(nextCommentCommand, nameof(nextCommentCommand)); Guard.ArgumentNotNull(previousCommentCommand, nameof(previousCommentCommand)); + Guard.ArgumentNotNull(factory, nameof(factory)); this.peekService = peekService; this.peekSession = peekSession; this.sessionManager = sessionManager; - this.commentService = commentService; + this.factory = factory; triggerPoint = peekSession.GetTriggerPoint(peekSession.TextView.TextBuffer); peekSession.Dismissed += (s, e) => Dispose(); Close = this.WhenAnyValue(x => x.Thread) - .SelectMany(x => x is NewInlineCommentThreadViewModel + .Where(x => x != null) + .SelectMany(x => x.IsNewThread ? x.Comments.Single().CancelEdit.SelectUnit() : Observable.Never()); @@ -91,7 +89,7 @@ public InlineCommentPeekViewModel(IInlineCommentPeekService peekService, /// /// Gets the thread of comments to display. /// - public ICommentThreadViewModel Thread + public IPullRequestReviewCommentThreadViewModel Thread { get { return thread; } private set { this.RaiseAndSetIfChanged(ref thread, value); } @@ -145,7 +143,7 @@ public async Task Initialize() } fileSubscription?.Dispose(); - fileSubscription = file.LinesChanged.Subscribe(LinesChanged); + fileSubscription = file.LinesChanged.ObserveOn(RxApp.MainThreadScheduler).Subscribe(LinesChanged); } async void LinesChanged(IReadOnlyList> lines) @@ -182,13 +180,15 @@ async Task UpdateThread() x.LineNumber == lineNumber && ((leftBuffer && x.DiffLineType == DiffChangeType.Delete) || (!leftBuffer && x.DiffLineType != DiffChangeType.Delete))); - if (thread != null) + Thread = factory.CreateViewModel(); + + if (thread?.Comments.Count > 0) { - Thread = new InlineCommentThreadViewModel(commentService, session, thread.Comments); + await Thread.InitializeAsync(session, file, thread.Comments[0].Review, thread, true); } else { - Thread = new NewInlineCommentThreadViewModel(commentService, session, file, lineNumber, leftBuffer); + await Thread.InitializeNewAsync(session, file, lineNumber, side, true); } if (!string.IsNullOrWhiteSpace(placeholderBody)) diff --git a/src/GitHub.InlineReviews/ViewModels/InlineCommentThreadViewModel.cs b/src/GitHub.InlineReviews/ViewModels/InlineCommentThreadViewModel.cs deleted file mode 100644 index 5d067e9094..0000000000 --- a/src/GitHub.InlineReviews/ViewModels/InlineCommentThreadViewModel.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Reactive.Linq; -using System.Threading.Tasks; -using GitHub.Extensions; -using GitHub.InlineReviews.Services; -using GitHub.Models; -using GitHub.Services; -using ReactiveUI; - -namespace GitHub.InlineReviews.ViewModels -{ - /// - /// A thread of inline comments (aka Pull Request Review Comments). - /// - public class InlineCommentThreadViewModel : CommentThreadViewModel - { - /// - /// Initializes a new instance of the class. - /// - /// The comment service - /// The current PR review session. - /// The comments to display in this inline review. - public InlineCommentThreadViewModel(ICommentService commentService, IPullRequestSession session, - IEnumerable comments) - : base(session.User) - { - Guard.ArgumentNotNull(session, nameof(session)); - - Session = session; - - PostComment = ReactiveCommand.CreateFromTask(DoPostComment); - EditComment = ReactiveCommand.CreateFromTask>(DoEditComment); - DeleteComment = ReactiveCommand.CreateFromTask>(DoDeleteComment); - - foreach (var comment in comments) - { - Comments.Add(new PullRequestReviewCommentViewModel( - session, - commentService, - this, - CurrentUser, - comment.Review, - comment.Comment)); - } - - Comments.Add(PullRequestReviewCommentViewModel.CreatePlaceholder(session, commentService, this, CurrentUser)); - } - - /// - /// Gets the current pull request review session. - /// - public IPullRequestSession Session { get; } - - async Task DoPostComment(string body) - { - Guard.ArgumentNotNull(body, nameof(body)); - - var replyId = Comments[0].Id; - await Session.PostReviewComment(body, replyId); - } - - async Task DoEditComment(Tuple idAndBody) - { - Guard.ArgumentNotNull(idAndBody, nameof(idAndBody)); - - await Session.EditComment(idAndBody.Item1, idAndBody.Item2); - } - - async Task DoDeleteComment(Tuple item) - { - Guard.ArgumentNotNull(item, nameof(item)); - - await Session.DeleteComment(item.Item1, item.Item2); - } - } -} diff --git a/src/GitHub.InlineReviews/ViewModels/NewInlineCommentThreadViewModel.cs b/src/GitHub.InlineReviews/ViewModels/NewInlineCommentThreadViewModel.cs deleted file mode 100644 index 0d70c0e361..0000000000 --- a/src/GitHub.InlineReviews/ViewModels/NewInlineCommentThreadViewModel.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Threading.Tasks; -using GitHub.Extensions; -using GitHub.InlineReviews.Services; -using GitHub.Models; -using GitHub.Services; -using ReactiveUI; - -namespace GitHub.InlineReviews.ViewModels -{ - /// - /// A new inline comment thread that is being authored. - /// - public class NewInlineCommentThreadViewModel : CommentThreadViewModel - { - bool needsPush; - - /// - /// Initializes a new instance of the class. - /// - /// The comment service - /// The current PR review session. - /// The file being commented on. - /// The 0-based line number in the file. - /// - /// True if the comment is being left on the left-hand-side of a diff; otherwise false. - /// - public NewInlineCommentThreadViewModel(ICommentService commentService, - IPullRequestSession session, - IPullRequestSessionFile file, - int lineNumber, - bool leftComparisonBuffer) - : base(session.User) - { - Guard.ArgumentNotNull(session, nameof(session)); - Guard.ArgumentNotNull(file, nameof(file)); - - Session = session; - File = file; - LineNumber = lineNumber; - LeftComparisonBuffer = leftComparisonBuffer; - - PostComment = ReactiveCommand.CreateFromTask( - DoPostComment, - this.WhenAnyValue(x => x.NeedsPush, x => !x)); - - EditComment = ReactiveCommand.Create>(_ => { }); - DeleteComment = ReactiveCommand.Create>(_ => { }); - - var placeholder = PullRequestReviewCommentViewModel.CreatePlaceholder(session, commentService, this, CurrentUser); - placeholder.BeginEdit.Execute().Subscribe(); - this.WhenAnyValue(x => x.NeedsPush).Subscribe(x => placeholder.IsReadOnly = x); - Comments.Add(placeholder); - - file.WhenAnyValue(x => x.CommitSha).Subscribe(x => NeedsPush = x == null); - } - - /// - /// Gets the file that the comment will be left on. - /// - public IPullRequestSessionFile File { get; } - - /// - /// Gets the 0-based line number in the file that the comment will be left on. - /// - public int LineNumber { get; } - - /// - /// Gets a value indicating whether comment is being left on the left-hand-side of a diff. - /// - public bool LeftComparisonBuffer { get; } - - /// - /// Gets the current pull request review session. - /// - public IPullRequestSession Session { get; } - - /// - /// Gets a value indicating whether the user must commit and push their changes before - /// leaving a comment on the requested line. - /// - public bool NeedsPush - { - get { return needsPush; } - private set { this.RaiseAndSetIfChanged(ref needsPush, value); } - } - - async Task DoPostComment(string body) - { - Guard.ArgumentNotNull(body, nameof(body)); - - var diffPosition = File.Diff - .SelectMany(x => x.Lines) - .FirstOrDefault(x => - { - var line = LeftComparisonBuffer ? x.OldLineNumber : x.NewLineNumber; - return line == LineNumber + 1; - }); - - if (diffPosition == null) - { - throw new InvalidOperationException("Unable to locate line in diff."); - } - - await Session.PostReviewComment( - body, - File.CommitSha, - File.RelativePath.Replace("\\", "/"), - File.Diff, - diffPosition.DiffLineNumber); - } - } -} diff --git a/src/GitHub.InlineReviews/ViewModels/PullRequestReviewCommentViewModel.cs b/src/GitHub.InlineReviews/ViewModels/PullRequestReviewCommentViewModel.cs deleted file mode 100644 index 885ce0834d..0000000000 --- a/src/GitHub.InlineReviews/ViewModels/PullRequestReviewCommentViewModel.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Threading.Tasks; -using GitHub.Extensions; -using GitHub.InlineReviews.Services; -using GitHub.Logging; -using GitHub.Models; -using GitHub.Services; -using GitHub.ViewModels; -using GitHub.VisualStudio.UI; -using ReactiveUI; -using Serilog; - -namespace GitHub.InlineReviews.ViewModels -{ - /// - /// View model for a pull request review comment. - /// - public class PullRequestReviewCommentViewModel : CommentViewModel, IPullRequestReviewCommentViewModel - { - readonly IPullRequestSession session; - ObservableAsPropertyHelper canStartReview; - ObservableAsPropertyHelper commitCaption; - - /// - /// Initializes a new instance of the class. - /// - /// The pull request session. - /// The comment service - /// The thread that the comment is a part of. - /// The current user. - /// The pull request id of the comment. - /// The GraphQL ID of the comment. - /// The database id of the comment. - /// The comment body. - /// The comment edit state. - /// The author of the comment. - /// The modified date of the comment. - /// Whether this is a pending comment. - /// - public PullRequestReviewCommentViewModel( - IPullRequestSession session, - ICommentService commentService, - ICommentThreadViewModel thread, - IActorViewModel currentUser, - int pullRequestId, - string commentId, - int databaseId, - string body, - CommentEditState state, - IActorViewModel author, - DateTimeOffset updatedAt, - bool isPending, - Uri webUrl) - : base(commentService, thread, currentUser, pullRequestId, commentId, databaseId, body, state, author, updatedAt, webUrl) - { - Guard.ArgumentNotNull(session, nameof(session)); - - this.session = session; - IsPending = isPending; - - var pendingReviewAndIdObservable = Observable.CombineLatest( - session.WhenAnyValue(x => x.HasPendingReview, x => !x), - this.WhenAnyValue(model => model.Id).Select(i => i == null), - (hasPendingReview, isNewComment) => new { hasPendingReview, isNewComment }); - - canStartReview = pendingReviewAndIdObservable - .Select(arg => arg.hasPendingReview && arg.isNewComment) - .ToProperty(this, x => x.CanStartReview); - - commitCaption = pendingReviewAndIdObservable - .Select(arg => !arg.isNewComment ? Resources.UpdateComment : arg.hasPendingReview ? Resources.AddSingleComment : Resources.AddReviewComment) - .ToProperty(this, x => x.CommitCaption); - - StartReview = ReactiveCommand.CreateFromTask(DoStartReview, CommitEdit.CanExecute); - AddErrorHandler(StartReview); - } - - /// - /// Initializes a new instance of the class. - /// - /// The pull request session. - /// Comment Service - /// The thread that the comment is a part of. - /// The current user. - /// The associated pull request review. - /// The comment model. - public PullRequestReviewCommentViewModel( - IPullRequestSession session, - ICommentService commentService, - ICommentThreadViewModel thread, - IActorViewModel currentUser, - PullRequestReviewModel review, - PullRequestReviewCommentModel model) - : this( - session, - commentService, - thread, - currentUser, - model.PullRequestId, - model.Id, - model.DatabaseId, - model.Body, - CommentEditState.None, - new ActorViewModel(model.Author), - model.CreatedAt, - review.State == PullRequestReviewState.Pending, - model.Url != null ? new Uri(model.Url) : null) - { - } - - /// - /// Creates a placeholder comment which can be used to add a new comment to a thread. - /// - /// The pull request session. - /// Comment Service - /// The comment thread. - /// The current user. - /// THe placeholder comment. - public static CommentViewModel CreatePlaceholder( - IPullRequestSession session, - ICommentService commentService, - ICommentThreadViewModel thread, - IActorViewModel currentUser) - { - return new PullRequestReviewCommentViewModel( - session, - commentService, - thread, - currentUser, - 0, - null, - 0, - string.Empty, - CommentEditState.Placeholder, - currentUser, - DateTimeOffset.MinValue, - false, - null); - } - - /// - public bool CanStartReview => canStartReview.Value; - - /// - public string CommitCaption => commitCaption.Value; - - /// - public bool IsPending { get; } - - /// - public ReactiveCommand StartReview { get; } - - async Task DoStartReview() - { - IsSubmitting = true; - - try - { - await session.StartReview(); - await CommitEdit.Execute(); - } - finally - { - IsSubmitting = false; - } - } - } -} diff --git a/src/GitHub.VisualStudio.UI/Views/CommentView.xaml.cs b/src/GitHub.VisualStudio.UI/Views/CommentView.xaml.cs index 94ef9d8989..7adba505d4 100644 --- a/src/GitHub.VisualStudio.UI/Views/CommentView.xaml.cs +++ b/src/GitHub.VisualStudio.UI/Views/CommentView.xaml.cs @@ -1,6 +1,5 @@ using System; using System.Windows.Input; -using GitHub.InlineReviews.ViewModels; using GitHub.Services; using GitHub.UI; using GitHub.ViewModels; diff --git a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs new file mode 100644 index 0000000000..d86548b660 --- /dev/null +++ b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels; +using NSubstitute; +using NUnit.Framework; +using ReactiveUI.Testing; + +namespace GitHub.InlineReviews.UnitTests.ViewModels +{ + public class PullRequestReviewCommentThreadViewModelTests + { + [Test] + public async Task CreatesComments() + { + var target = await CreateTarget( + comments: CreateComments("Comment 1", "Comment 2")); + + Assert.That(3, Is.EqualTo(target.Comments.Count)); + Assert.That( + target.Comments.Select(x => x.Body), + Is.EqualTo(new[] + { + "Comment 1", + "Comment 2", + null, + })); + + Assert.That( + new[] + { + CommentEditState.None, + CommentEditState.None, + CommentEditState.Placeholder, + }, + Is.EqualTo(target.Comments.Select(x => x.EditState))); + } + + [Test] + public async Task PlaceholderCommitEnabledWhenCommentHasBody() + { + var target = await CreateTarget( + comments: CreateComments("Comment 1")); + + Assert.That(target.Comments[1].CommitEdit.CanExecute(null), Is.False); + + target.Comments[1].Body = "Foo"; + Assert.That(target.Comments[1].CommitEdit.CanExecute(null), Is.True); + } + + [Test] + public async Task PostsCommentInReplyToCorrectComment() + { + using (TestUtils.WithScheduler(Scheduler.CurrentThread)) + { + var session = CreateSession(); + var target = await CreateTarget( + session: session, + comments: CreateComments("Comment 1", "Comment 2")); + + target.Comments[2].Body = "New Comment"; + await target.Comments[2].CommitEdit.Execute(); + + session.Received(1).PostReviewComment("New Comment", "1"); + } + } + + async Task CreateTarget( + IViewViewModelFactory factory = null, + IPullRequestSession session = null, + IPullRequestSessionFile file = null, + PullRequestReviewModel review = null, + IEnumerable comments = null) + { + factory = factory ?? CreateFactory(); + session = session ?? CreateSession(); + file = file ?? Substitute.For(); + review = review ?? new PullRequestReviewModel(); + comments = comments ?? CreateComments(); + + var thread = Substitute.For(); + thread.Comments.Returns(comments.ToList()); + + var result = new PullRequestReviewCommentThreadViewModel(factory); + await result.InitializeAsync(session, file, review, thread, true); + return result; + } + + InlineCommentModel CreateComment(string id, string body) + { + return new InlineCommentModel + { + Comment = new PullRequestReviewCommentModel + { + Id = id, + Body = body, + }, + Review = new PullRequestReviewModel(), + }; + } + + IEnumerable CreateComments(params string[] bodies) + { + var id = 1; + + foreach (var body in bodies) + { + yield return CreateComment((id++).ToString(), body); + } + } + + IViewViewModelFactory CreateFactory() + { + var result = Substitute.For(); + var commentService = Substitute.For(); + result.CreateViewModel().Returns(_ => + new PullRequestReviewCommentViewModel(commentService)); + return result; + } + + IPullRequestSession CreateSession() + { + var result = Substitute.For(); + result.User.Returns(new ActorModel { Login = "Viewer" }); + result.RepositoryOwner.Returns("owner"); + result.LocalRepository.Name.Returns("repo"); + result.LocalRepository.Owner.Returns("shouldnt-be-used"); + result.PullRequest.Returns(new PullRequestDetailModel + { + Number = 47, + }); + return result; + } + } +} diff --git a/test/GitHub.InlineReviews.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs similarity index 58% rename from test/GitHub.InlineReviews.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs rename to test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs index d11a572c0e..814125d4b3 100644 --- a/test/GitHub.InlineReviews.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs @@ -1,8 +1,6 @@ using System; using System.Reactive.Linq; using System.Threading.Tasks; -using GitHub.InlineReviews.Services; -using GitHub.InlineReviews.ViewModels; using GitHub.Models; using GitHub.Services; using GitHub.ViewModels; @@ -10,36 +8,38 @@ using NUnit.Framework; using ReactiveUI; -namespace GitHub.InlineReviews.UnitTests.ViewModels +namespace GitHub.App.UnitTests.ViewModels { public class PullRequestReviewCommentViewModelTests { public class TheCanStartReviewProperty { [Test] - public void IsFalseWhenSessionHasPendingReview() + public async Task IsFalseWhenSessionHasPendingReview() { - var session = CreateSession(true); - var target = CreateTarget(session); + var session = CreateSession(); + var target = await CreateTarget( + session: session, + review: CreateReview(PullRequestReviewState.Pending)); Assert.That(target.CanStartReview, Is.False); } [Test] - public void IsTrueWhenSessionHasNoPendingReview() + public async Task IsTrueWhenSessionHasNoPendingReview() { - var session = CreateSession(false); - var target = CreateTarget(session); + var session = CreateSession(); + var target = await CreateTarget(session); Assert.That(target.CanStartReview, Is.True); } [Test] - public void IsFalseWhenEditingExistingComment() + public async Task IsFalseWhenEditingExistingComment() { - var session = CreateSession(false); + var session = CreateSession(); var pullRequestReviewCommentModel = new PullRequestReviewCommentModel { Id = "1" }; - var target = CreateTarget(session, comment: pullRequestReviewCommentModel); + var target = await CreateTarget(session, comment: pullRequestReviewCommentModel); Assert.That(target.CanStartReview, Is.False); } @@ -48,18 +48,21 @@ public void IsFalseWhenEditingExistingComment() public class TheBeginEditProperty { [Test] - public void CanBeExecutedForPlaceholders() + public async Task CanBeExecutedForPlaceholders() { var session = CreateSession(); var thread = CreateThread(); var currentUser = Substitute.For(); var commentService = Substitute.For(); - var target = PullRequestReviewCommentViewModel.CreatePlaceholder(session, commentService, thread, currentUser); + var target = new PullRequestReviewCommentViewModel(commentService); + + await target.InitializeAsPlaceholderAsync(session, thread, false); + Assert.That(target.BeginEdit.CanExecute(new object()), Is.True); } [Test] - public void CanBeExecutedForCommentsByTheSameAuthor() + public async Task CanBeExecutedForCommentsByTheSameAuthor() { var session = CreateSession(); var thread = CreateThread(); @@ -67,12 +70,12 @@ public void CanBeExecutedForCommentsByTheSameAuthor() var currentUser = new ActorModel { Login = "CurrentUser" }; var comment = new PullRequestReviewCommentModel { Author = currentUser }; - var target = CreateTarget(session, null, thread, currentUser, null, comment); + var target = await CreateTarget(session, null, thread, currentUser, null, comment); Assert.That(target.BeginEdit.CanExecute(new object()), Is.True); } [Test] - public void CannotBeExecutedForCommentsByAnotherAuthor() + public async Task CannotBeExecutedForCommentsByAnotherAuthor() { var session = CreateSession(); var thread = CreateThread(); @@ -81,7 +84,7 @@ public void CannotBeExecutedForCommentsByAnotherAuthor() var otherUser = new ActorModel { Login = "OtherUser" }; var comment = new PullRequestReviewCommentModel { Author = otherUser }; - var target = CreateTarget(session, null, thread, currentUser, null, comment); + var target = await CreateTarget(session, null, thread, currentUser, null, comment); Assert.That(target.BeginEdit.CanExecute(new object()), Is.False); } } @@ -89,18 +92,21 @@ public void CannotBeExecutedForCommentsByAnotherAuthor() public class TheDeleteProperty { [Test] - public void CannotBeExecutedForPlaceholders() + public async Task CannotBeExecutedForPlaceholders() { var session = CreateSession(); var thread = CreateThread(); var currentUser = Substitute.For(); var commentService = Substitute.For(); - var target = PullRequestReviewCommentViewModel.CreatePlaceholder(session, commentService, thread, currentUser); + var target = new PullRequestReviewCommentViewModel(commentService); + + await target.InitializeAsPlaceholderAsync(session, thread, false); + Assert.That(target.Delete.CanExecute(new object()), Is.False); } [Test] - public void CanBeExecutedForCommentsByTheSameAuthor() + public async Task CanBeExecutedForCommentsByTheSameAuthor() { var session = CreateSession(); var thread = CreateThread(); @@ -108,12 +114,12 @@ public void CanBeExecutedForCommentsByTheSameAuthor() var currentUser = new ActorModel { Login = "CurrentUser" }; var comment = new PullRequestReviewCommentModel { Author = currentUser }; - var target = CreateTarget(session, null, thread, currentUser, null, comment); + var target = await CreateTarget(session, null, thread, currentUser, null, comment); Assert.That(target.Delete.CanExecute(new object()), Is.True); } [Test] - public void CannotBeExecutedForCommentsByAnotherAuthor() + public async Task CannotBeExecutedForCommentsByAnotherAuthor() { var session = CreateSession(); var thread = CreateThread(); @@ -122,7 +128,7 @@ public void CannotBeExecutedForCommentsByAnotherAuthor() var otherUser = new ActorModel { Login = "OtherUser" }; var comment = new PullRequestReviewCommentModel { Author = otherUser }; - var target = CreateTarget(session, null, thread, currentUser, null, comment); + var target = await CreateTarget(session, null, thread, currentUser, null, comment); Assert.That(target.Delete.CanExecute(new object()), Is.False); } } @@ -130,29 +136,31 @@ public void CannotBeExecutedForCommentsByAnotherAuthor() public class TheCommitCaptionProperty { [Test] - public void IsAddReviewCommentWhenSessionHasPendingReview() + public async Task IsAddReviewCommentWhenSessionHasPendingReview() { - var session = CreateSession(true); - var target = CreateTarget(session); + var session = CreateSession(); + var target = await CreateTarget( + session: session, + review: CreateReview(PullRequestReviewState.Pending)); Assert.That(target.CommitCaption, Is.EqualTo("Add review comment")); } [Test] - public void IsAddSingleCommentWhenSessionHasNoPendingReview() + public async Task IsAddSingleCommentWhenSessionHasNoPendingReview() { - var session = CreateSession(false); - var target = CreateTarget(session); + var session = CreateSession(); + var target = await CreateTarget(session); Assert.That(target.CommitCaption, Is.EqualTo("Add a single comment")); } [Test] - public void IsUpdateCommentWhenEditingExistingComment() + public async Task IsUpdateCommentWhenEditingExistingComment() { - var session = CreateSession(false); + var session = CreateSession(); var pullRequestReviewCommentModel = new PullRequestReviewCommentModel { Id = "1" }; - var target = CreateTarget(session, comment: pullRequestReviewCommentModel); + var target = await CreateTarget(session, comment: pullRequestReviewCommentModel); Assert.That(target.CommitCaption, Is.EqualTo("Update comment")); } @@ -161,28 +169,30 @@ public void IsUpdateCommentWhenEditingExistingComment() public class TheStartReviewCommand { [Test] - public void IsDisabledWhenSessionHasPendingReview() + public async Task IsDisabledWhenSessionHasPendingReview() { - var session = CreateSession(true); - var target = CreateTarget(session); + var session = CreateSession(); + var target = await CreateTarget( + session: session, + review: CreateReview(PullRequestReviewState.Pending)); Assert.That(target.StartReview.CanExecute(null), Is.False); } [Test] - public void IsDisabledWhenSessionHasNoPendingReview() + public async Task IsDisabledWhenSessionHasNoPendingReview() { - var session = CreateSession(false); - var target = CreateTarget(session); + var session = CreateSession(); + var target = await CreateTarget(session); Assert.That(target.StartReview.CanExecute(null), Is.False); } [Test] - public void IsEnabledWhenSessionHasNoPendingReviewAndBodyNotEmpty() + public async Task IsEnabledWhenSessionHasNoPendingReviewAndBodyNotEmpty() { - var session = CreateSession(false); - var target = CreateTarget(session); + var session = CreateSession(); + var target = await CreateTarget(session); target.Body = "body"; @@ -190,10 +200,10 @@ public void IsEnabledWhenSessionHasNoPendingReviewAndBodyNotEmpty() } [Test] - public void CallsSessionStartReview() + public async Task CallsSessionStartReview() { - var session = CreateSession(false); - var target = CreateTarget(session); + var session = CreateSession(); + var target = await CreateTarget(session); target.Body = "body"; target.StartReview.Execute(); @@ -202,7 +212,7 @@ public void CallsSessionStartReview() } } - static PullRequestReviewCommentViewModel CreateTarget( + static async Task CreateTarget( IPullRequestSession session = null, ICommentService commentService = null, ICommentThreadViewModel thread = null, @@ -215,30 +225,27 @@ static PullRequestReviewCommentViewModel CreateTarget( thread = thread ?? CreateThread(); currentUser = currentUser ?? new ActorModel { Login = "CurrentUser" }; comment = comment ?? new PullRequestReviewCommentModel(); - review = review ?? CreateReview(comment); - - return new PullRequestReviewCommentViewModel( - session, - commentService, - thread, - new ActorViewModel(currentUser), - review, - comment); + review = review ?? CreateReview(PullRequestReviewState.Approved, comment); + + var result = new PullRequestReviewCommentViewModel(commentService); + await result.InitializeAsync(session, thread, review, comment, CommentEditState.None); + return result; } - static IPullRequestSession CreateSession( - bool hasPendingReview = false) + static IPullRequestSession CreateSession() { var result = Substitute.For(); - result.HasPendingReview.Returns(hasPendingReview); - result.User.Returns(new ActorModel()); + result.User.Returns(new ActorModel { Login = "CurrentUser" }); return result; } - static PullRequestReviewModel CreateReview(params PullRequestReviewCommentModel[] comments) + static PullRequestReviewModel CreateReview( + PullRequestReviewState state, + params PullRequestReviewCommentModel[] comments) { return new PullRequestReviewModel { + State = state, Comments = comments, }; } @@ -247,7 +254,6 @@ static ICommentThreadViewModel CreateThread( bool canPost = true) { var result = Substitute.For(); - result.PostComment.Returns(ReactiveCommand.CreateFromTask(_ => Task.CompletedTask)); return result; } } diff --git a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs index 4d79bd2d2c..f25f965e09 100644 --- a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs +++ b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs @@ -21,6 +21,8 @@ using NUnit.Framework; using GitHub.Commands; using GitHub.ViewModels; +using ReactiveUI.Testing; +using System.Reactive.Concurrency; namespace GitHub.InlineReviews.UnitTests.ViewModels { @@ -39,15 +41,15 @@ public async Task ThreadIsCreatedForExistingComments() CreateSessionManager(), Substitute.For(), Substitute.For(), - Substitute.For()); + CreateFactory()); await target.Initialize(); // There should be an existing comment and a reply placeholder. - Assert.That(target.Thread, Is.InstanceOf(typeof(InlineCommentThreadViewModel))); + Assert.That(target.Thread.IsNewThread, Is.False); Assert.That(target.Thread.Comments.Count, Is.EqualTo(2)); Assert.That(target.Thread.Comments[0].Body, Is.EqualTo("Existing comment")); - Assert.That(target.Thread.Comments[1].Body, Is.EqualTo(string.Empty)); + Assert.That(target.Thread.Comments[1].Body, Is.EqualTo(null)); Assert.That(target.Thread.Comments[1].EditState, Is.EqualTo(CommentEditState.Placeholder)); } @@ -61,12 +63,12 @@ public async Task ThreadIsCreatedForNewComment() CreateSessionManager(), Substitute.For(), Substitute.For(), - Substitute.For()); + CreateFactory()); await target.Initialize(); - Assert.That(target.Thread, Is.InstanceOf(typeof(NewInlineCommentThreadViewModel))); - Assert.That(target.Thread.Comments[0].Body, Is.EqualTo(string.Empty)); + Assert.That(target.Thread.IsNewThread, Is.True); + Assert.That(target.Thread.Comments[0].Body, Is.EqualTo(null)); Assert.That(target.Thread.Comments[0].EditState, Is.EqualTo(CommentEditState.Editing)); } @@ -87,58 +89,61 @@ public async Task ShouldGetRelativePathFromTextBufferInfoIfPresent() sessionManager, Substitute.For(), Substitute.For(), - Substitute.For()); + CreateFactory()); await target.Initialize(); // There should be an existing comment and a reply placeholder. - Assert.That(target.Thread, Is.InstanceOf(typeof(InlineCommentThreadViewModel))); + Assert.That(target.Thread.IsNewThread, Is.False); Assert.That(target.Thread.Comments.Count, Is.EqualTo(2)); Assert.That(target.Thread.Comments[0].Body, Is.EqualTo("Existing comment")); - Assert.That(target.Thread.Comments[1].Body, Is.EqualTo(string.Empty)); + Assert.That(target.Thread.Comments[1].Body, Is.EqualTo(null)); Assert.That(target.Thread.Comments[1].EditState, Is.EqualTo(CommentEditState.Placeholder)); } [Test] public async Task SwitchesFromNewThreadToExistingThreadWhenCommentPosted() { - var sessionManager = CreateSessionManager(); - var peekSession = CreatePeekSession(); - var target = new InlineCommentPeekViewModel( - CreatePeekService(lineNumber: 8), - peekSession, - sessionManager, - Substitute.For(), - Substitute.For(), - Substitute.For()); - - await target.Initialize(); - Assert.That(target.Thread, Is.InstanceOf(typeof(NewInlineCommentThreadViewModel))); - - target.Thread.Comments[0].Body = "New Comment"; - - sessionManager.CurrentSession - .When(x => x.PostReviewComment( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any>(), - Arg.Any())) - .Do(async x => - { - // Simulate the thread being added to the session. - var file = await sessionManager.GetLiveFile( - RelativePath, - peekSession.TextView, - peekSession.TextView.TextBuffer); - var newThread = CreateThread(8, "New Comment"); - file.InlineCommentThreads.Returns(new[] { newThread }); - RaiseLinesChanged(file, Tuple.Create(8, DiffSide.Right)); - }); - - await target.Thread.Comments[0].CommitEdit.Execute(); - - Assert.That(target.Thread, Is.InstanceOf(typeof(InlineCommentThreadViewModel))); + using (TestUtils.WithScheduler(Scheduler.CurrentThread)) + { + var sessionManager = CreateSessionManager(); + var peekSession = CreatePeekSession(); + var target = new InlineCommentPeekViewModel( + CreatePeekService(lineNumber: 8), + peekSession, + sessionManager, + Substitute.For(), + Substitute.For(), + CreateFactory()); + + await target.Initialize(); + Assert.That(target.Thread.IsNewThread, Is.True); + + target.Thread.Comments[0].Body = "New Comment"; + + sessionManager.CurrentSession + .When(x => x.PostReviewComment( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any())) + .Do(async _ => + { + // Simulate the thread being added to the session. + var file = await sessionManager.GetLiveFile( + RelativePath, + peekSession.TextView, + peekSession.TextView.TextBuffer); + var newThread = CreateThread(8, "New Comment"); + file.InlineCommentThreads.Returns(new[] { newThread }); + RaiseLinesChanged(file, Tuple.Create(8, DiffSide.Right)); + }); + + await target.Thread.Comments[0].CommitEdit.Execute(); + + Assert.That(target.Thread.IsNewThread, Is.False); + } } [Test] @@ -152,11 +157,11 @@ public async Task RefreshesWhenSessionInlineCommentThreadsChanges() sessionManager, Substitute.For(), Substitute.For(), - Substitute.For()); + CreateFactory()); await target.Initialize(); - Assert.That(target.Thread, Is.InstanceOf(typeof(InlineCommentThreadViewModel))); + Assert.That(target.Thread.IsNewThread, Is.False); Assert.That(target.Thread.Comments.Count, Is.EqualTo(2)); var file = await sessionManager.GetLiveFile( @@ -171,108 +176,117 @@ public async Task RefreshesWhenSessionInlineCommentThreadsChanges() [Test] public async Task RetainsCommentBeingEditedWhenSessionRefreshed() { - var sessionManager = CreateSessionManager(); - var peekSession = CreatePeekSession(); - var target = new InlineCommentPeekViewModel( - CreatePeekService(lineNumber: 10), - CreatePeekSession(), - sessionManager, - Substitute.For(), - Substitute.For(), - Substitute.For()); - - await target.Initialize(); - - Assert.That(target.Thread.Comments.Count, Is.EqualTo(2)); - - var placeholder = target.Thread.Comments.Last(); - placeholder.BeginEdit.Execute().Subscribe(); - placeholder.Body = "Comment being edited"; - - var file = await sessionManager.GetLiveFile( - RelativePath, - peekSession.TextView, - peekSession.TextView.TextBuffer); - AddCommentToExistingThread(file); - - placeholder = target.Thread.Comments.Last(); - Assert.That(target.Thread.Comments.Count, Is.EqualTo(3)); - Assert.That(placeholder.EditState, Is.EqualTo(CommentEditState.Editing)); - Assert.That(placeholder.Body, Is.EqualTo("Comment being edited")); + using (TestUtils.WithScheduler(Scheduler.CurrentThread)) + { + var sessionManager = CreateSessionManager(); + var peekSession = CreatePeekSession(); + var target = new InlineCommentPeekViewModel( + CreatePeekService(lineNumber: 10), + CreatePeekSession(), + sessionManager, + Substitute.For(), + Substitute.For(), + CreateFactory()); + + await target.Initialize(); + + Assert.That(target.Thread.Comments.Count, Is.EqualTo(2)); + + var placeholder = target.Thread.Comments.Last(); + placeholder.BeginEdit.Execute().Subscribe(); + placeholder.Body = "Comment being edited"; + + var file = await sessionManager.GetLiveFile( + RelativePath, + peekSession.TextView, + peekSession.TextView.TextBuffer); + AddCommentToExistingThread(file); + + placeholder = target.Thread.Comments.Last(); + Assert.That(target.Thread.Comments.Count, Is.EqualTo(3)); + Assert.That(placeholder.EditState, Is.EqualTo(CommentEditState.Editing)); + Assert.That(placeholder.Body, Is.EqualTo("Comment being edited")); + } } [Test] public async Task CommittingEditDoesntRetainSubmittedCommentInPlaceholderAfterPost() { - var sessionManager = CreateSessionManager(); - var peekSession = CreatePeekSession(); - var target = new InlineCommentPeekViewModel( - CreatePeekService(lineNumber: 10), - peekSession, - sessionManager, - Substitute.For(), - Substitute.For(), - Substitute.For()); - - await target.Initialize(); - - Assert.That(target.Thread.Comments.Count, Is.EqualTo(2)); - - sessionManager.CurrentSession.PostReviewComment(null, null) - .ReturnsForAnyArgs(async x => - { - var file = await sessionManager.GetLiveFile( - RelativePath, - peekSession.TextView, - peekSession.TextView.TextBuffer); - AddCommentToExistingThread(file); - }); - - var placeholder = target.Thread.Comments.Last(); - await placeholder.BeginEdit.Execute(); - placeholder.Body = "Comment being edited"; - await placeholder.CommitEdit.Execute(); - - placeholder = target.Thread.Comments.Last(); - Assert.That(placeholder.EditState, Is.EqualTo(CommentEditState.Placeholder)); - Assert.That(placeholder.Body, Is.EqualTo(string.Empty)); + using (TestUtils.WithScheduler(Scheduler.CurrentThread)) + { + var sessionManager = CreateSessionManager(); + var peekSession = CreatePeekSession(); + var target = new InlineCommentPeekViewModel( + CreatePeekService(lineNumber: 10), + peekSession, + sessionManager, + Substitute.For(), + Substitute.For(), + CreateFactory()); + + await target.Initialize(); + + Assert.That(target.Thread.Comments.Count, Is.EqualTo(2)); + + sessionManager.CurrentSession.PostReviewComment(null, null) + .ReturnsForAnyArgs(async x => + { + var file = await sessionManager.GetLiveFile( + RelativePath, + peekSession.TextView, + peekSession.TextView.TextBuffer); + AddCommentToExistingThread(file); + }); + + var placeholder = target.Thread.Comments.Last(); + await placeholder.BeginEdit.Execute(); + placeholder.Body = "Comment being edited"; + await placeholder.CommitEdit.Execute(); + + placeholder = target.Thread.Comments.Last(); + Assert.That(placeholder.EditState, Is.EqualTo(CommentEditState.Placeholder)); + Assert.That(placeholder.Body, Is.EqualTo(null)); + } } [Test] public async Task StartingReviewDoesntRetainSubmittedCommentInPlaceholderAfterPost() { - var sessionManager = CreateSessionManager(); - var peekSession = CreatePeekSession(); - var target = new InlineCommentPeekViewModel( - CreatePeekService(lineNumber: 10), - peekSession, - sessionManager, - Substitute.For(), - Substitute.For(), - Substitute.For()); - - await target.Initialize(); - - Assert.That(target.Thread.Comments.Count, Is.EqualTo(2)); - - sessionManager.CurrentSession.StartReview() - .ReturnsForAnyArgs(async x => - { - var file = await sessionManager.GetLiveFile( - RelativePath, - peekSession.TextView, - peekSession.TextView.TextBuffer); - RaiseLinesChanged(file, Tuple.Create(10, DiffSide.Right)); - }); - - var placeholder = (IPullRequestReviewCommentViewModel)target.Thread.Comments.Last(); - await placeholder.BeginEdit.Execute(); - placeholder.Body = "Comment being edited"; - await placeholder.StartReview.Execute(); - - placeholder = (IPullRequestReviewCommentViewModel)target.Thread.Comments.Last(); - Assert.That(placeholder.EditState, Is.EqualTo(CommentEditState.Placeholder)); - Assert.That(placeholder.Body, Is.EqualTo(string.Empty)); + using (TestUtils.WithScheduler(Scheduler.CurrentThread)) + { + var sessionManager = CreateSessionManager(); + var peekSession = CreatePeekSession(); + var target = new InlineCommentPeekViewModel( + CreatePeekService(lineNumber: 10), + peekSession, + sessionManager, + Substitute.For(), + Substitute.For(), + CreateFactory()); + + await target.Initialize(); + + Assert.That(target.Thread.Comments.Count, Is.EqualTo(2)); + + sessionManager.CurrentSession.StartReview() + .ReturnsForAnyArgs(async x => + { + var file = await sessionManager.GetLiveFile( + RelativePath, + peekSession.TextView, + peekSession.TextView.TextBuffer); + RaiseLinesChanged(file, Tuple.Create(10, DiffSide.Right)); + }); + + var placeholder = (IPullRequestReviewCommentViewModel)target.Thread.Comments.Last(); + await placeholder.BeginEdit.Execute(); + placeholder.Body = "Comment being edited"; + await placeholder.StartReview.Execute(); + + placeholder = (IPullRequestReviewCommentViewModel)target.Thread.Comments.Last(); + Assert.That(placeholder.EditState, Is.EqualTo(CommentEditState.Placeholder)); + Assert.That(placeholder.Body, Is.EqualTo(null)); + } } void AddCommentToExistingThread(IPullRequestSessionFile file) @@ -311,6 +325,17 @@ InlineCommentModel CreateComment(string body) }; } + IViewViewModelFactory CreateFactory() + { + var commentService = Substitute.For(); + var result = Substitute.For(); + result.CreateViewModel().Returns(_ => + new PullRequestReviewCommentViewModel(commentService)); + result.CreateViewModel().Returns(_ => + new PullRequestReviewCommentThreadViewModel(result)); + return result; + } + IInlineCommentThreadModel CreateThread(int lineNumber, params string[] comments) { var result = Substitute.For(); diff --git a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentThreadViewModelTests.cs b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentThreadViewModelTests.cs deleted file mode 100644 index 724d6b824a..0000000000 --- a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentThreadViewModelTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Reactive.Linq; -using GitHub.InlineReviews.Services; -using GitHub.InlineReviews.ViewModels; -using GitHub.Models; -using GitHub.Services; -using GitHub.ViewModels; -using NSubstitute; -using NUnit.Framework; - -namespace GitHub.InlineReviews.UnitTests.ViewModels -{ - public class InlineCommentThreadViewModelTests - { - [Test] - public void CreatesComments() - { - var target = new InlineCommentThreadViewModel( - Substitute.For(), - CreateSession(), - CreateComments("Comment 1", "Comment 2")); - - Assert.That(3, Is.EqualTo(target.Comments.Count)); - Assert.That( - new[] - { - "Comment 1", - "Comment 2", - string.Empty - }, - Is.EqualTo(target.Comments.Select(x => x.Body))); - - Assert.That( - new[] - { - CommentEditState.None, - CommentEditState.None, - CommentEditState.Placeholder, - }, - Is.EqualTo(target.Comments.Select(x => x.EditState))); - } - - [Test] - public void PlaceholderCommitEnabledWhenCommentHasBody() - { - var target = new InlineCommentThreadViewModel( - Substitute.For(), - CreateSession(), - CreateComments("Comment 1")); - - Assert.That(target.Comments[1].CommitEdit.CanExecute(null), Is.False); - - target.Comments[1].Body = "Foo"; - Assert.That(target.Comments[1].CommitEdit.CanExecute(null), Is.True); - } - - [Test] - public void PostsCommentInReplyToCorrectComment() - { - var session = CreateSession(); - var target = new InlineCommentThreadViewModel( - Substitute.For(), - session, - CreateComments("Comment 1", "Comment 2")); - - target.Comments[2].Body = "New Comment"; - target.Comments[2].CommitEdit.Execute(); - - session.Received(1).PostReviewComment("New Comment", "1"); - } - - InlineCommentModel CreateComment(string id, string body) - { - return new InlineCommentModel - { - Comment = new PullRequestReviewCommentModel - { - Id = id, - Body = body, - }, - Review = new PullRequestReviewModel(), - }; - } - - IEnumerable CreateComments(params string[] bodies) - { - var id = 1; - - foreach (var body in bodies) - { - yield return CreateComment((id++).ToString(CultureInfo.InvariantCulture), body); - } - } - - IPullRequestSession CreateSession() - { - var result = Substitute.For(); - result.User.Returns(new ActorModel { Login = "Viewer" }); - result.RepositoryOwner.Returns("owner"); - result.LocalRepository.Name.Returns("repo"); - result.LocalRepository.Owner.Returns("shouldnt-be-used"); - result.PullRequest.Returns(new PullRequestDetailModel - { - Number = 47, - }); - return result; - } - } -} diff --git a/test/GitHub.InlineReviews.UnitTests/ViewModels/NewInlineCommentThreadViewModelTests.cs b/test/GitHub.InlineReviews.UnitTests/ViewModels/NewInlineCommentThreadViewModelTests.cs deleted file mode 100644 index 6ba73823c8..0000000000 --- a/test/GitHub.InlineReviews.UnitTests/ViewModels/NewInlineCommentThreadViewModelTests.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel; -using GitHub.InlineReviews.Services; -using GitHub.InlineReviews.ViewModels; -using GitHub.Models; -using GitHub.Services; -using GitHub.ViewModels; -using NSubstitute; -using NUnit.Framework; - -namespace GitHub.InlineReviews.UnitTests.ViewModels -{ - public class NewInlineCommentThreadViewModelTests - { - [Test] - public void CreatesReplyPlaceholder() - { - var target = new NewInlineCommentThreadViewModel( - Substitute.For(), - CreateSession(), - Substitute.For(), - 10, - false); - - Assert.That(target.Comments, Has.One.Items); - Assert.That(target.Comments[0].Body, Is.EqualTo(string.Empty)); - Assert.That(target.Comments[0].EditState, Is.EqualTo(CommentEditState.Editing)); - } - - [Test] - public void NeedsPushTracksFileCommitSha() - { - var file = CreateFile(); - var target = new NewInlineCommentThreadViewModel( - Substitute.For(), - CreateSession(), - file, - 10, - false); - - Assert.That(target.NeedsPush, Is.False); - Assert.That(target.PostComment.CanExecute(false), Is.True); - - file.CommitSha.Returns((string)null); - RaisePropertyChanged(file, nameof(file.CommitSha)); - Assert.That(target.NeedsPush, Is.True); - Assert.That(target.PostComment.CanExecute(false), Is.False); - - file.CommitSha.Returns("COMMIT_SHA"); - RaisePropertyChanged(file, nameof(file.CommitSha)); - Assert.That(target.NeedsPush, Is.False); - Assert.That(target.PostComment.CanExecute(false), Is.True); - } - - [Test] - public void PlaceholderCommitEnabledWhenCommentHasBodyAndPostCommentIsEnabled() - { - var file = CreateFile(); - var target = new NewInlineCommentThreadViewModel( - Substitute.For(), - CreateSession(), - file, - 10, - false); - - file.CommitSha.Returns((string)null); - RaisePropertyChanged(file, nameof(file.CommitSha)); - Assert.That(target.Comments[0].CommitEdit.CanExecute(null), Is.False); - - target.Comments[0].Body = "Foo"; - Assert.That(target.Comments[0].CommitEdit.CanExecute(null), Is.False); - - file.CommitSha.Returns("COMMIT_SHA"); - RaisePropertyChanged(file, nameof(file.CommitSha)); - Assert.That(target.Comments[0].CommitEdit.CanExecute(null), Is.True); - } - - [Test] - public void PostsCommentToCorrectAddedLine() - { - var session = CreateSession(); - var file = CreateFile(); - var target = new NewInlineCommentThreadViewModel( - Substitute.For(), - session, file, 10, false); - - target.Comments[0].Body = "New Comment"; - target.Comments[0].CommitEdit.Execute(); - - session.Received(1).PostReviewComment( - "New Comment", - "COMMIT_SHA", - "file.cs", - Arg.Any>(), - 5); - } - - [Test] - public void AddsCommentToCorrectDeletedLine() - { - var session = CreateSession(); - var file = CreateFile(); - - file.Diff.Returns(new[] - { - new DiffChunk - { - Lines = - { - new DiffLine { OldLineNumber = 17, DiffLineNumber = 7 } - } - } - }); - - var target = new NewInlineCommentThreadViewModel( - Substitute.For(), - session, file, 16, true); - - target.Comments[0].Body = "New Comment"; - target.Comments[0].CommitEdit.Execute(); - - session.Received(1).PostReviewComment( - "New Comment", - "COMMIT_SHA", - "file.cs", - Arg.Any>(), - 7); - } - - IPullRequestSessionFile CreateFile() - { - var result = Substitute.For(); - result.CommitSha.Returns("COMMIT_SHA"); - result.Diff.Returns(new[] - { - new DiffChunk - { - Lines = - { - new DiffLine { NewLineNumber = 11, DiffLineNumber = 5 } - } - } - }); - result.RelativePath.Returns("file.cs"); - return result; - } - - IPullRequestSession CreateSession() - { - var result = Substitute.For(); - result.RepositoryOwner.Returns("owner"); - result.LocalRepository.Name.Returns("repo"); - result.LocalRepository.Owner.Returns("shouldnt-be-used"); - result.PullRequest.Returns(new PullRequestDetailModel { Number = 47 }); - result.User.Returns(new ActorModel()); - return result; - } - - void RaisePropertyChanged(T o, string propertyName) - where T : INotifyPropertyChanged - { - o.PropertyChanged += Raise.Event(new PropertyChangedEventArgs(propertyName)); - } - } -}