Skip to content

Commit 023402a

Browse files
authored
Merge pull request #7789 from woocommerce/issue/7777-reply-to-reviews-text-view
[Review Replies] Add a "Reply to Product View" screen in Product Review detail.
2 parents 7e4f77b + 315f679 commit 023402a

File tree

5 files changed

+186
-2
lines changed

5 files changed

+186
-2
lines changed

WooCommerce/Classes/ViewRelated/Reviews/ReviewDetailsViewController.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,8 +384,11 @@ private extension ReviewDetailsViewController {
384384
self.moderateReview(siteID: reviewSiteID, reviewID: reviewID, doneStatus: .spam, undoStatus: .unspam)
385385
}
386386

387-
commentCell.onReply = {
388-
// TODO: Open a text view to send a comment reply to the product review
387+
commentCell.onReply = { [weak self] in
388+
guard let self else { return }
389+
390+
let reviewReplyViewController = ReviewReplyHostingController(viewModel: ReviewReplyViewModel())
391+
self.present(reviewReplyViewController, animated: true)
389392
}
390393
}
391394
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
/// Hosting controller that wraps a `ReviewDetailsReply` view.
5+
///
6+
final class ReviewReplyHostingController: UIHostingController<ReviewReply>, UIAdaptivePresentationControllerDelegate {
7+
8+
private let viewModel: ReviewReplyViewModel
9+
10+
init(viewModel: ReviewReplyViewModel) {
11+
self.viewModel = viewModel
12+
super.init(rootView: ReviewReply(viewModel: viewModel))
13+
14+
// Needed because a `SwiftUI` cannot be dismissed when being presented by a UIHostingController
15+
rootView.dismiss = { [weak self] in
16+
self?.dismiss(animated: true, completion: nil)
17+
}
18+
19+
// Set presentation delegate to track the user dismiss flow event
20+
presentationController?.delegate = self
21+
}
22+
23+
required dynamic init?(coder aDecoder: NSCoder) {
24+
fatalError("init(coder:) has not been implemented")
25+
}
26+
}
27+
28+
/// Allows merchant to reply to a product review.
29+
///
30+
struct ReviewReply: View {
31+
32+
/// Set this closure with UIKit dismiss code. Needed because we need access to the UIHostingController `dismiss` method.
33+
///
34+
var dismiss: (() -> Void) = {}
35+
36+
/// View Model for the view
37+
///
38+
@ObservedObject private(set) var viewModel: ReviewReplyViewModel
39+
40+
var body: some View {
41+
NavigationView {
42+
TextEditor(text: $viewModel.newReply)
43+
.focused()
44+
.padding()
45+
.navigationTitle(Localization.title)
46+
.navigationBarTitleDisplayMode(.inline)
47+
.toolbar {
48+
ToolbarItem(placement: .cancellationAction) {
49+
Button(Localization.cancel, action: {
50+
dismiss()
51+
})
52+
}
53+
ToolbarItem(placement: .confirmationAction) {
54+
navigationBarTrailingItem()
55+
}
56+
}
57+
}
58+
.wooNavigationBarStyle()
59+
.navigationViewStyle(.stack)
60+
}
61+
62+
/// Decides if the navigation trailing item should be a send button or a loading indicator.
63+
///
64+
@ViewBuilder private func navigationBarTrailingItem() -> some View {
65+
switch viewModel.navigationTrailingItem {
66+
case .send(let enabled):
67+
Button(Localization.send) {
68+
viewModel.sendReply { success in
69+
if success {
70+
dismiss()
71+
}
72+
}
73+
}
74+
.disabled(!enabled)
75+
case .loading:
76+
ProgressView()
77+
}
78+
}
79+
}
80+
81+
// MARK: Constants
82+
private enum Localization {
83+
static let title = NSLocalizedString("Reply to Product Review", comment: "Title for the product review reply screen")
84+
static let send = NSLocalizedString("Send", comment: "Text for the send button in the product review reply screen")
85+
static let cancel = NSLocalizedString("Cancel", comment: "Text for the cancel button in the product review reply screen")
86+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import Foundation
2+
import Combine
3+
import Yosemite
4+
5+
/// View model for the `ReviewReply` screen.
6+
///
7+
final class ReviewReplyViewModel: ObservableObject {
8+
9+
/// New reply to send
10+
///
11+
@Published var newReply: String = ""
12+
13+
/// Defaults to a disabled send button.
14+
///
15+
@Published private(set) var navigationTrailingItem: ReviewReplyNavigationItem = .send(enabled: false)
16+
17+
/// Tracks if a network request is being performed.
18+
///
19+
private let performingNetworkRequest: CurrentValueSubject<Bool, Never> = .init(false)
20+
21+
init() {
22+
bindNavigationTrailingItemPublisher()
23+
}
24+
25+
/// Called when the user taps on the Send button.
26+
///
27+
/// Use this method to send the reply and invoke a completion block when finished
28+
///
29+
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
32+
}
33+
}
34+
35+
// MARK: Helper Methods
36+
private extension ReviewReplyViewModel {
37+
/// Calculates what navigation trailing item should be shown depending on our internal state.
38+
///
39+
func bindNavigationTrailingItemPublisher() {
40+
Publishers.CombineLatest($newReply, performingNetworkRequest)
41+
.map { newReply, performingNetworkRequest in
42+
guard !performingNetworkRequest else {
43+
return .loading
44+
}
45+
return .send(enabled: newReply.isNotEmpty)
46+
}
47+
.assign(to: &$navigationTrailingItem)
48+
}
49+
}
50+
51+
/// Representation of possible navigation bar trailing buttons
52+
///
53+
enum ReviewReplyNavigationItem: Equatable {
54+
case send(enabled: Bool)
55+
case loading
56+
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1355,6 +1355,9 @@
13551355
CC2A08062863222500510C4B /* orders_3337_add_fee.json in Resources */ = {isa = PBXBuildFile; fileRef = CC2A08052863222500510C4B /* orders_3337_add_fee.json */; };
13561356
CC2A0808286337A300510C4B /* CustomerNoteScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2A0807286337A300510C4B /* CustomerNoteScreen.swift */; };
13571357
CC2E72F727B6BFB800A62872 /* ProductVariationFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2E72F627B6BFB800A62872 /* ProductVariationFormatterTests.swift */; };
1358+
CC3B35DB28E5A6830036B097 /* ReviewReply.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3B35DA28E5A6830036B097 /* ReviewReply.swift */; };
1359+
CC3B35DD28E5A6EA0036B097 /* ReviewReplyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3B35DC28E5A6EA0036B097 /* ReviewReplyViewModel.swift */; };
1360+
CC3B35DF28E5BE6F0036B097 /* ReviewReplyViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3B35DE28E5BE6F0036B097 /* ReviewReplyViewModelTests.swift */; };
13581361
CC440E1E2770C6AF0074C264 /* ProductInOrderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC440E1D2770C6AF0074C264 /* ProductInOrderViewModel.swift */; };
13591362
CC4A4E962655273D00B75DCD /* ShippingLabelPaymentMethods.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC4A4E952655273D00B75DCD /* ShippingLabelPaymentMethods.swift */; };
13601363
CC4A4ED82655478D00B75DCD /* ShippingLabelPaymentMethodsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC4A4ED72655478D00B75DCD /* ShippingLabelPaymentMethodsViewModel.swift */; };
@@ -3265,6 +3268,9 @@
32653268
CC2A08052863222500510C4B /* orders_3337_add_fee.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = orders_3337_add_fee.json; sourceTree = "<group>"; };
32663269
CC2A0807286337A300510C4B /* CustomerNoteScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerNoteScreen.swift; sourceTree = "<group>"; };
32673270
CC2E72F627B6BFB800A62872 /* ProductVariationFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationFormatterTests.swift; sourceTree = "<group>"; };
3271+
CC3B35DA28E5A6830036B097 /* ReviewReply.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewReply.swift; sourceTree = "<group>"; };
3272+
CC3B35DC28E5A6EA0036B097 /* ReviewReplyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewReplyViewModel.swift; sourceTree = "<group>"; };
3273+
CC3B35DE28E5BE6F0036B097 /* ReviewReplyViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewReplyViewModelTests.swift; sourceTree = "<group>"; };
32683274
CC440E1D2770C6AF0074C264 /* ProductInOrderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductInOrderViewModel.swift; sourceTree = "<group>"; };
32693275
CC4A4E952655273D00B75DCD /* ShippingLabelPaymentMethods.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPaymentMethods.swift; sourceTree = "<group>"; };
32703276
CC4A4ED72655478D00B75DCD /* ShippingLabelPaymentMethodsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPaymentMethodsViewModel.swift; sourceTree = "<group>"; };
@@ -6901,6 +6907,8 @@
69016907
D8C2A28E231BD00500F503E9 /* ReviewsViewModel.swift */,
69026908
B5F8B7DF2194759100DAB7E2 /* ReviewDetailsViewController.swift */,
69036909
B5F8B7E4219478FA00DAB7E2 /* ReviewDetailsViewController.xib */,
6910+
CC3B35DA28E5A6830036B097 /* ReviewReply.swift */,
6911+
CC3B35DC28E5A6EA0036B097 /* ReviewReplyViewModel.swift */,
69046912
023A059824135F2600E3FC99 /* ReviewsViewController.swift */,
69056913
023A059924135F2600E3FC99 /* ReviewsViewController.xib */,
69066914
5718852B2465D9EC00E2486F /* ReviewsCoordinator.swift */,
@@ -7112,6 +7120,7 @@
71127120
isa = PBXGroup;
71137121
children = (
71147122
BAA34C1F2787494300846F3C /* ReviewsViewControllerTests.swift */,
7123+
CC3B35DE28E5BE6F0036B097 /* ReviewReplyViewModelTests.swift */,
71157124
);
71167125
path = Reviews;
71177126
sourceTree = "<group>";
@@ -10228,6 +10237,7 @@
1022810237
45B4F0262860BD0A00F3B16E /* WCShipCTAView.swift in Sources */,
1022910238
020BE74D23B1F5EB007FE54C /* TitleAndTextFieldTableViewCell.swift in Sources */,
1023010239
023D692E2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift in Sources */,
10240+
CC3B35DD28E5A6EA0036B097 /* ReviewReplyViewModel.swift in Sources */,
1023110241
CC72BB6427BD842500837876 /* DisclosureIndicator.swift in Sources */,
1023210242
77E53EC52510C193003D385F /* ProductDownloadListViewController+Droppable.swift in Sources */,
1023310243
3F50FE4328CAEBA800C89201 /* AppLocalizedString.swift in Sources */,
@@ -10390,6 +10400,7 @@
1039010400
DE3404E828B4B96800CF0D97 /* NonAtomicSiteViewModel.swift in Sources */,
1039110401
D8815B0126385E3F00EDAD62 /* CardPresentModalTapCard.swift in Sources */,
1039210402
773077EE251E943700178696 /* ProductDownloadFileViewController.swift in Sources */,
10403+
CC3B35DB28E5A6830036B097 /* ReviewReply.swift in Sources */,
1039310404
45D1CF4523BAC2A500945A36 /* ProductTaxClassListSelectorDataSource.swift in Sources */,
1039410405
319A626127ACAE3400BC96C3 /* InPersonPaymentsPluginChoicesView.swift in Sources */,
1039510406
6856D31F941A33BAE66F394D /* KeyboardFrameAdjustmentProvider.swift in Sources */,
@@ -10611,6 +10622,7 @@
1061110622
020C908424C84652001E2BEB /* ProductListMultiSelectorSearchUICommandTests.swift in Sources */,
1061210623
746FC23D2200A62B00C3096C /* DateWooTests.swift in Sources */,
1061310624
31F21B5A263CB41A0035B50A /* MockCardPresentPaymentsStoresManager.swift in Sources */,
10625+
CC3B35DF28E5BE6F0036B097 /* ReviewReplyViewModelTests.swift in Sources */,
1061410626
02F5F80E246102240000613A /* FilterProductListViewModel+numberOfActiveFiltersTests.swift in Sources */,
1061510627
021AEF9C2407B07300029D28 /* ProductImageStatus+HelpersTests.swift in Sources */,
1061610628
024A543622BA84DB00F4F38E /* DeveloperEmailCheckerTests.swift in Sources */,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import XCTest
2+
@testable import WooCommerce
3+
4+
class ReviewReplyViewModelTests: XCTestCase {
5+
6+
func test_send_button_is_disabled_when_reply_content_is_empty() {
7+
// Given
8+
let viewModel = ReviewReplyViewModel()
9+
10+
// When
11+
let navigationItem = viewModel.navigationTrailingItem
12+
13+
// Then
14+
assertEqual(navigationItem, .send(enabled: false))
15+
}
16+
17+
func test_send_button_is_enabled_when_reply_is_entered() {
18+
// Given
19+
let viewModel = ReviewReplyViewModel()
20+
21+
// When
22+
viewModel.newReply = "New reply"
23+
24+
// Then
25+
assertEqual(viewModel.navigationTrailingItem, .send(enabled: true))
26+
}
27+
}

0 commit comments

Comments
 (0)