Skip to content

Commit ade4848

Browse files
authored
Merge pull request #7791 from woocommerce/issue/7777-reply-to-reviews-action
[Review Replies] Send product review reply to remote
2 parents 5e34f47 + d1ffe5b commit ade4848

File tree

4 files changed

+271
-7
lines changed

4 files changed

+271
-7
lines changed

WooCommerce/Classes/ViewRelated/Reviews/ReviewDetailsViewController.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,8 @@ private extension ReviewDetailsViewController {
387387
commentCell.onReply = { [weak self] in
388388
guard let self else { return }
389389

390-
let reviewReplyViewController = ReviewReplyHostingController(viewModel: ReviewReplyViewModel())
390+
let reviewReplyViewModel = ReviewReplyViewModel(siteID: self.siteID, reviewID: self.productReview.reviewID)
391+
let reviewReplyViewController = ReviewReplyHostingController(viewModel: reviewReplyViewModel)
391392
self.present(reviewReplyViewController, animated: true)
392393
}
393394
}

WooCommerce/Classes/ViewRelated/Reviews/ReviewReply.swift

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,72 @@
11
import Foundation
22
import SwiftUI
3+
import Combine
34

45
/// Hosting controller that wraps a `ReviewDetailsReply` view.
56
///
67
final class ReviewReplyHostingController: UIHostingController<ReviewReply>, UIAdaptivePresentationControllerDelegate {
78

89
private let viewModel: ReviewReplyViewModel
910

10-
init(viewModel: ReviewReplyViewModel) {
11+
/// Presents notices in the tab bar context.
12+
///
13+
private let systemNoticePresenter: NoticePresenter
14+
15+
/// Presents notices in the current modal presentation context.
16+
///
17+
private lazy var modalNoticePresenter: NoticePresenter = {
18+
let presenter = DefaultNoticePresenter()
19+
presenter.presentingViewController = self
20+
return presenter
21+
}()
22+
23+
/// Emits the intent to present a `ReviewReplyNotice`
24+
///
25+
private lazy var presentNoticePublisher = {
26+
viewModel.presentNoticeSubject.eraseToAnyPublisher()
27+
}()
28+
29+
/// References to keep the Combine subscriptions alive within the lifecycle of the object.
30+
///
31+
private var subscriptions: Set<AnyCancellable> = []
32+
33+
init(viewModel: ReviewReplyViewModel,
34+
noticePresenter: NoticePresenter = ServiceLocator.noticePresenter) {
1135
self.viewModel = viewModel
36+
self.systemNoticePresenter = noticePresenter
1237
super.init(rootView: ReviewReply(viewModel: viewModel))
1338

1439
// Needed because a `SwiftUI` cannot be dismissed when being presented by a UIHostingController
1540
rootView.dismiss = { [weak self] in
1641
self?.dismiss(animated: true, completion: nil)
1742
}
1843

44+
// Observe the present notice intent.
45+
presentNoticePublisher
46+
.compactMap { $0 }
47+
.sink { [weak self] notice in
48+
switch notice {
49+
case .success:
50+
self?.systemNoticePresenter.enqueue(notice: Notice(title: Localization.success, feedbackType: .success))
51+
case .error:
52+
// Include a notice identifier so the the error can be presented as a system notification even if the app is closed.
53+
let noticeIdentifier = UUID().uuidString
54+
let notice = Notice(title: Localization.error,
55+
feedbackType: .error,
56+
notificationInfo: NoticeNotificationInfo(identifier: noticeIdentifier),
57+
actionTitle: Localization.retry) { [weak self] in
58+
self?.viewModel.sendReply(onCompletion: { [weak self] success in
59+
if success {
60+
self?.dismiss(animated: true, completion: nil)
61+
}
62+
})
63+
}
64+
65+
self?.modalNoticePresenter.enqueue(notice: notice)
66+
}
67+
}
68+
.store(in: &subscriptions)
69+
1970
// Set presentation delegate to track the user dismiss flow event
2071
presentationController?.delegate = self
2172
}
@@ -78,9 +129,19 @@ struct ReviewReply: View {
78129
}
79130
}
80131

132+
enum ReviewReplyNotice: Equatable {
133+
case success
134+
case error
135+
}
136+
81137
// MARK: Constants
82138
private enum Localization {
83139
static let title = NSLocalizedString("Reply to Product Review", comment: "Title for the product review reply screen")
84140
static let send = NSLocalizedString("Send", comment: "Text for the send button in the product review reply screen")
85141
static let cancel = NSLocalizedString("Cancel", comment: "Text for the cancel button in the product review reply screen")
142+
143+
// Notice strings
144+
static let success = NSLocalizedString("Reply sent!", comment: "Notice text after sending a reply to a product review successfully")
145+
static let error = NSLocalizedString("There was an error sending the reply", comment: "Notice text after failing to send a reply to a product review")
146+
static let retry = NSLocalizedString("Retry", comment: "Retry Action")
86147
}

WooCommerce/Classes/ViewRelated/Reviews/ReviewReplyViewModel.swift

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import Yosemite
66
///
77
final class ReviewReplyViewModel: ObservableObject {
88

9+
private let siteID: Int64
10+
11+
/// ID for the product review being replied to.
12+
///
13+
private let reviewID: Int64
14+
915
/// New reply to send
1016
///
1117
@Published var newReply: String = ""
@@ -18,7 +24,20 @@ final class ReviewReplyViewModel: ObservableObject {
1824
///
1925
private let performingNetworkRequest: CurrentValueSubject<Bool, Never> = .init(false)
2026

21-
init() {
27+
/// Action dispatcher
28+
///
29+
private let stores: StoresManager
30+
31+
/// Trigger to present a `ReviewReplyNotice`
32+
///
33+
let presentNoticeSubject = PassthroughSubject<ReviewReplyNotice, Never>()
34+
35+
init(siteID: Int64,
36+
reviewID: Int64,
37+
stores: StoresManager = ServiceLocator.stores) {
38+
self.siteID = siteID
39+
self.reviewID = reviewID
40+
self.stores = stores
2241
bindNavigationTrailingItemPublisher()
2342
}
2443

@@ -27,8 +46,33 @@ final class ReviewReplyViewModel: ObservableObject {
2746
/// Use this method to send the reply and invoke a completion block when finished
2847
///
2948
func sendReply(onCompletion: @escaping (Bool) -> Void) {
30-
// TODO: Call CommentAction.replyToComment to send the reply to remote
31-
// Set `performingNetworkRequest` to true while the request is being performed
49+
guard newReply.isNotEmpty else {
50+
return
51+
}
52+
53+
let action = CommentAction.replyToComment(siteID: siteID, commentID: reviewID, content: newReply) { [weak self] result in
54+
guard let self = self else { return }
55+
56+
self.performingNetworkRequest.send(false)
57+
58+
switch result {
59+
case .success(let status):
60+
// If the comment isn't approved, log it (to help support)
61+
if status != .approved {
62+
DDLogInfo("Reply to product review succeeded with comment status: \(status)")
63+
}
64+
65+
self.presentNoticeSubject.send(.success)
66+
onCompletion(true)
67+
case .failure(let error):
68+
self.presentNoticeSubject.send(.error)
69+
DDLogError("⛔️ Error replying to product review: \(error)")
70+
onCompletion(false)
71+
}
72+
}
73+
74+
performingNetworkRequest.send(true)
75+
stores.dispatch(action)
3276
}
3377
}
3478

WooCommerce/WooCommerceTests/ViewRelated/Reviews/ReviewReplyViewModelTests.swift

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import XCTest
2+
import Combine
23
@testable import WooCommerce
4+
@testable import Yosemite
35

46
class ReviewReplyViewModelTests: XCTestCase {
57

8+
private let sampleSiteID: Int64 = 12345
9+
10+
private let sampleReviewID: Int64 = 7
11+
12+
private var subscriptions: [AnyCancellable] = []
13+
614
func test_send_button_is_disabled_when_reply_content_is_empty() {
715
// Given
8-
let viewModel = ReviewReplyViewModel()
16+
let viewModel = ReviewReplyViewModel(siteID: sampleSiteID, reviewID: sampleReviewID)
917

1018
// When
1119
let navigationItem = viewModel.navigationTrailingItem
@@ -16,12 +24,162 @@ class ReviewReplyViewModelTests: XCTestCase {
1624

1725
func test_send_button_is_enabled_when_reply_is_entered() {
1826
// Given
19-
let viewModel = ReviewReplyViewModel()
27+
let viewModel = ReviewReplyViewModel(siteID: sampleSiteID, reviewID: sampleReviewID)
2028

2129
// When
2230
viewModel.newReply = "New reply"
2331

2432
// Then
2533
assertEqual(viewModel.navigationTrailingItem, .send(enabled: true))
2634
}
35+
36+
func test_loading_indicator_enabled_during_network_request() {
37+
// Given
38+
let stores = MockStoresManager(sessionManager: .testingInstance)
39+
let viewModel = ReviewReplyViewModel(siteID: sampleSiteID, reviewID: sampleReviewID, stores: stores)
40+
viewModel.newReply = "New reply"
41+
42+
// When
43+
let navigationItem: ReviewReplyNavigationItem = waitFor { promise in
44+
stores.whenReceivingAction(ofType: CommentAction.self) { action in
45+
switch action {
46+
case .replyToComment:
47+
promise(viewModel.navigationTrailingItem)
48+
default:
49+
XCTFail("Received unsupported action: \(action)")
50+
}
51+
}
52+
viewModel.sendReply { _ in }
53+
}
54+
55+
// Then
56+
XCTAssertEqual(navigationItem, .loading)
57+
}
58+
59+
func test_send_button_renabled_after_network_request_completes() {
60+
// Given
61+
let stores = MockStoresManager(sessionManager: .testingInstance)
62+
let viewModel = ReviewReplyViewModel(siteID: sampleSiteID, reviewID: sampleReviewID, stores: stores)
63+
stores.whenReceivingAction(ofType: CommentAction.self) { action in
64+
switch action {
65+
case let .replyToComment(_, _, _, onCompletion):
66+
onCompletion(.failure(NSError(domain: "", code: 0)))
67+
default:
68+
XCTFail("Received unsupported action: \(action)")
69+
}
70+
}
71+
72+
// When
73+
viewModel.newReply = "New reply"
74+
let navigationItem: ReviewReplyNavigationItem = waitFor { promise in
75+
viewModel.sendReply { _ in
76+
promise(viewModel.navigationTrailingItem)
77+
}
78+
}
79+
80+
// Then
81+
XCTAssertEqual(navigationItem, .send(enabled: true))
82+
}
83+
84+
func test_sendReply_completion_block_returns_true_after_successful_network_request() {
85+
// Given
86+
let stores = MockStoresManager(sessionManager: .testingInstance)
87+
let viewModel = ReviewReplyViewModel(siteID: sampleSiteID, reviewID: sampleReviewID, stores: stores)
88+
stores.whenReceivingAction(ofType: CommentAction.self) { action in
89+
switch action {
90+
case let .replyToComment(_, _, _, onCompletion):
91+
onCompletion(.success(.approved))
92+
default:
93+
XCTFail("Received unsupported action: \(action)")
94+
}
95+
}
96+
97+
// When
98+
viewModel.newReply = "New reply"
99+
let successResponse: Bool = waitFor { promise in
100+
viewModel.sendReply { successResponse in
101+
promise(successResponse)
102+
}
103+
}
104+
105+
// Then
106+
XCTAssertTrue(successResponse)
107+
}
108+
109+
func test_sendReply_completion_block_returns_false_after_failed_network_request() {
110+
// Given
111+
let stores = MockStoresManager(sessionManager: .testingInstance)
112+
let viewModel = ReviewReplyViewModel(siteID: sampleSiteID, reviewID: sampleReviewID, stores: stores)
113+
stores.whenReceivingAction(ofType: CommentAction.self) { action in
114+
switch action {
115+
case let .replyToComment(_, _, _, onCompletion):
116+
onCompletion(.failure(NSError(domain: "", code: 0)))
117+
default:
118+
XCTFail("Received unsupported action: \(action)")
119+
}
120+
}
121+
122+
// When
123+
viewModel.newReply = "New reply"
124+
let successResponse: Bool = waitFor { promise in
125+
viewModel.sendReply { successResponse in
126+
promise(successResponse)
127+
}
128+
}
129+
130+
// Then
131+
XCTAssertFalse(successResponse)
132+
}
133+
134+
func test_view_model_triggers_success_notice_after_reply_is_sent_successfully() {
135+
// Given
136+
let stores = MockStoresManager(sessionManager: .testingInstance)
137+
let viewModel = ReviewReplyViewModel(siteID: sampleSiteID, reviewID: sampleReviewID, stores: stores)
138+
stores.whenReceivingAction(ofType: CommentAction.self) { action in
139+
switch action {
140+
case let .replyToComment(_, _, _, onCompletion):
141+
onCompletion(.success(.approved))
142+
default:
143+
XCTFail("Received unsupported action: \(action)")
144+
}
145+
}
146+
147+
var noticeTypes: [ReviewReplyNotice] = []
148+
viewModel.presentNoticeSubject.sink { notice in
149+
noticeTypes.append(notice)
150+
}.store(in: &subscriptions)
151+
152+
// When
153+
viewModel.newReply = "New reply"
154+
viewModel.sendReply { _ in }
155+
156+
// Then
157+
XCTAssertEqual(noticeTypes, [.success])
158+
}
159+
160+
func test_view_model_triggers_error_notice_using_modal_notice_presenter_after_reply_fails() {
161+
// Given
162+
let stores = MockStoresManager(sessionManager: .testingInstance)
163+
let viewModel = ReviewReplyViewModel(siteID: sampleSiteID, reviewID: sampleReviewID, stores: stores)
164+
stores.whenReceivingAction(ofType: CommentAction.self) { action in
165+
switch action {
166+
case let .replyToComment(_, _, _, onCompletion):
167+
onCompletion(.failure(NSError(domain: "", code: 0)))
168+
default:
169+
XCTFail("Received unsupported action: \(action)")
170+
}
171+
}
172+
173+
var noticeTypes: [ReviewReplyNotice] = []
174+
viewModel.presentNoticeSubject.sink { notice in
175+
noticeTypes.append(notice)
176+
}.store(in: &subscriptions)
177+
178+
// When
179+
viewModel.newReply = "New reply"
180+
viewModel.sendReply { _ in }
181+
182+
// Then
183+
XCTAssertEqual(noticeTypes, [.error])
184+
}
27185
}

0 commit comments

Comments
 (0)