Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -97,24 +97,6 @@ final class CreateOrderAddressFormViewModel: AddressFormViewModel, AddressFormVi
override func trackOnLoad() { }

func userDidCancelFlow() { }

/// Dispatches the searchCustomers action when the Search button is tapped.
/// The hardcoded `keyword` is temporary until we implement the rest of the feature:
/// https://github.com/woocommerce/woocommerce-ios/issues/7741
///
func customerSearchTapped() {
let action = CustomerAction.searchCustomers(
siteID: siteID,
keyword: "hello") { result in
switch result {
case .success(_):
print("Success")
case .failure(let error):
print(error)
}
}
stores.dispatch(action)
}
}

private extension CreateOrderAddressFormViewModel {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,17 @@ final class CustomerSearchUICommand: SearchUICommand {
}

func createStarterViewController() -> UIViewController? {
nil
createEmptyStateViewController()
}

func configureEmptyStateViewControllerBeforeDisplay(viewController: EmptyStateViewController, searchKeyword: String) {
let boldSearchKeyword = NSAttributedString(string: searchKeyword,
attributes: [.font: EmptyStateViewController.Config.messageFont.bold])
let format = Localization.emptySearchResults
let message = NSMutableAttributedString(string: format)

message.replaceFirstOccurrence(of: "%@", with: boldSearchKeyword)
viewController.configure(.simple(message: message, image: .emptySearchResultsImage))
}

func createCellViewModel(model: Customer) -> TitleAndSubtitleAndStatusTableViewCell.ViewModel {
Expand All @@ -61,10 +71,6 @@ final class CustomerSearchUICommand: SearchUICommand {
}

func didSelectSearchResult(model: Customer, from viewController: UIViewController, reloadData: () -> Void, updateActionButton: () -> Void) {
// Not implemented yet
print("1 - Customer tapped")
print("2 - Customer ID: \(model.customerID) - Name: \(model.firstName ?? ""))")
// Customer data will go up to EditOrderAddressForm, via OrderCustomerListView completion handler
onDidSelectSearchResult(model)
}

Expand All @@ -75,7 +81,11 @@ final class CustomerSearchUICommand: SearchUICommand {

private extension CustomerSearchUICommand {
enum Localization {
static let searchBarPlaceHolder = NSLocalizedString("Search all customers",
comment: "Customer Search Placeholder")
static let searchBarPlaceHolder = NSLocalizedString(
"Search all customers",
comment: "Customer Search Placeholder")
static let emptySearchResults = NSLocalizedString(
"We're sorry, we couldn't find results for “%@”",
comment: "Message for empty Customers search results. %@ is a placeholder for the text entered by the user.")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Yosemite
import SwiftUI

/// `SwiftUI` wrapper for `SearchViewController` using `CustomerSearchUICommand`
/// TODO: Make it generic
///
struct OrderCustomerListView: UIViewControllerRepresentable {

let siteID: Int64
Expand All @@ -16,15 +16,13 @@ struct OrderCustomerListView: UIViewControllerRepresentable {
storeID: siteID,
command: CustomerSearchUICommand(siteID: siteID, onDidSelectSearchResult: onCustomerTapped),
cellType: TitleAndSubtitleAndStatusTableViewCell.self,
// Must conform to SearchResultCell.
// TODO: Proper cell for this cellType.
cellSeparator: .none
)
let navigationController = WooNavigationController(rootViewController: viewController)
return navigationController
}

func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
// nope
// not implemented
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ protocol AddressFormViewModelProtocol: ObservableObject {
///
func createSecondaryStateViewModel() -> StateSelectorViewModel

/// Callback method when the Customer Search button is tapped
/// Triggers the logic to fill Customer Order details when a Customer is selected
///
func customerSearchTapped()
func customerSelectedFromSearch(customer: Customer)
}

/// Type to hold values from all the form fields
Expand Down Expand Up @@ -401,6 +401,37 @@ open class AddressFormViewModel: ObservableObject {
let secondaryEmailIsValid = secondaryFields.email.isEmpty || EmailFormatValidator.validate(string: fields.email)
return primaryEmailIsValid && secondaryEmailIsValid
}

/// Fills Order AddressFormFields with Customer details
///
func customerSelectedFromSearch(customer: Customer) {
fillCustomerFields(customer: customer)
let addressesDiffer = customer.billing != customer.shipping
showDifferentAddressForm = addressesDiffer
}

private func fillCustomerFields(customer: Customer) {
fields = populate(fields: fields, with: customer, address: customer.billing)
secondaryFields = populate(fields: secondaryFields, with: customer, address: customer.shipping)
}

private func populate(fields: AddressFormFields, with customer: Customer, address: Address?) -> AddressFormFields {
var fields = fields

fields.firstName = customer.firstName ?? ""
fields.lastName = customer.lastName ?? ""
fields.email = customer.email
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fields = populate(fields: fields, with: customer, address: customer.billing)
secondaryFields = populate(fields: secondaryFields, with: customer, address: customer.shipping)
}
private func populate(fields: AddressFormFields, with customer: Customer, address: Address?) -> AddressFormFields {
var fields = fields
fields.firstName = customer.firstName ?? ""
fields.lastName = customer.lastName ?? ""
fields.email = customer.email
fields = populate(fields: fields, with: customer.billing)
secondaryFields = populate(fields: secondaryFields, with: customer.shipping)
}
private func populate(fields: AddressFormFields, with address: Address?) -> AddressFormFields {
var fields = fields
fields.firstName = address.firstName ?? ""
fields.lastName = address.lastName ?? ""
fields.email = address.email

Instead of the name fields on customer, I think we should use address.firstName and address.lastName. Testing Android, with my always-strange test data with different names all over the place, shows that they use these fields to update the data.

This makes sense, because the shipping address may be to a different person, e.g. if it's a gift.

There's address.email, which could differ from customer.email. Android seems to use billingAddress.email there. The email for the secondary fields isn't shown on either platform... but I'm not sure whether it's used in any requests.

It's worth checking that: what happens if we save a different email address in each place? Does it get added to the order twice? Is that what Android does? Is it a bug, one way or the other?

It's also just a nicer function... "Populate address fields with address"

Copy link
Contributor Author

@iamgabrielma iamgabrielma Nov 1, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. I made the changes here: 121ba8a

Something that peaks my interest with this change is that the email property is now an optional, but it can't actually be optional because is a mandatory field for shippable orders. This seems to happen because when we declare both fields and secondaryFields in the protocol, both inherit from AddressFormFields, however these have slightly different parameters in the API (for example shipping details does not have an email field)

what happens if we save a different email address in each place? Does it get added to the order twice? Is that what Android does? Is it a bug, one way or the other?

This seems a bug from our end when declaring the model, as with the email example above, we're reusing the same AddressFormFields model for both shipping and billing, but these are not exactly the same in the API or WC core (it would seem that the API doc is outdated as well, as phone does not appear as property for shipping but in WC core it does). I've opened an issue here: #7993 as most likely this can be improved.

fields.phone = address?.phone ?? ""
fields.company = address?.company ?? ""
fields.address1 = address?.address1 ?? ""
fields.address2 = address?.address2 ?? ""
fields.city = address?.city ?? ""
fields.postcode = address?.postcode ?? ""
fields.country = address?.country ?? ""
fields.state = address?.state ?? ""

return fields
}
}

extension AddressFormViewModel {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,8 @@ struct EditOrderAddressForm<ViewModel: AddressFormViewModelProtocol>: View {
.notice($viewModel.notice)
.sheet(isPresented: $showingCustomerSearch, content: {
OrderCustomerListView(siteID: viewModel.siteID, onCustomerTapped: { customer in
// Not implemented yet.
print("3 - Customer Callback. Fill Order data with Customer details")
print("4 - Customer ID: \(customer.customerID) - Name: \(customer.firstName ?? ""))")
viewModel.customerSelectedFromSearch(customer: customer)
showingCustomerSearch = false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can move to the view model too if you like? Can wait for a future PR

})
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,6 @@ final class EditOrderAddressFormViewModel: AddressFormViewModel, AddressFormView
func userDidCancelFlow() {
analytics.track(event: WooAnalyticsEvent.OrderDetailsEdit.orderDetailEditFlowCanceled(subject: self.analyticsFlowType()))
}

func customerSearchTapped() {}
}

extension EditOrderAddressFormViewModel {
Expand Down
12 changes: 12 additions & 0 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,7 @@
57F2C6CD246DECC10074063B /* SummaryTableViewCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F2C6CC246DECC10074063B /* SummaryTableViewCellViewModelTests.swift */; };
57F42E40253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F42E3F253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift */; };
581D5052274AA2480089B6AD /* View+AutofocusTextModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581D5051274AA2480089B6AD /* View+AutofocusTextModifier.swift */; };
682210ED2909666600814E14 /* CustomerSearchUICommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682210EC2909666600814E14 /* CustomerSearchUICommandTests.swift */; };
6827140F28A3988300E6E3F6 /* DismissableNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6827140E28A3988300E6E3F6 /* DismissableNoticeView.swift */; };
6827141128A5410D00E6E3F6 /* NewSimplePaymentsLocationNoticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6827141028A5410D00E6E3F6 /* NewSimplePaymentsLocationNoticeViewModel.swift */; };
6827141528A671B900E6E3F6 /* NewSimplePaymentsLocationNoticeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6827141428A671B900E6E3F6 /* NewSimplePaymentsLocationNoticeViewController.swift */; };
Expand Down Expand Up @@ -2890,6 +2891,7 @@
57F2C6CC246DECC10074063B /* SummaryTableViewCellViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummaryTableViewCellViewModelTests.swift; sourceTree = "<group>"; };
57F42E3F253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleAndEditableValueTableViewCellViewModelTests.swift; sourceTree = "<group>"; };
581D5051274AA2480089B6AD /* View+AutofocusTextModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AutofocusTextModifier.swift"; sourceTree = "<group>"; };
682210EC2909666600814E14 /* CustomerSearchUICommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerSearchUICommandTests.swift; sourceTree = "<group>"; };
6827140E28A3988300E6E3F6 /* DismissableNoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissableNoticeView.swift; sourceTree = "<group>"; };
6827141028A5410D00E6E3F6 /* NewSimplePaymentsLocationNoticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewSimplePaymentsLocationNoticeViewModel.swift; sourceTree = "<group>"; };
6827141428A671B900E6E3F6 /* NewSimplePaymentsLocationNoticeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewSimplePaymentsLocationNoticeViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -5990,6 +5992,7 @@
573D0ACC2458665C004DE614 /* Search */ = {
isa = PBXGroup;
children = (
682210EB2909664800814E14 /* Customer */,
020C908224C84638001E2BEB /* Product */,
);
path = Search;
Expand Down Expand Up @@ -6165,6 +6168,14 @@
path = TitleAndEditableValueTableViewCell;
sourceTree = "<group>";
};
682210EB2909664800814E14 /* Customer */ = {
isa = PBXGroup;
children = (
682210EC2909666600814E14 /* CustomerSearchUICommandTests.swift */,
);
path = Customer;
sourceTree = "<group>";
};
6856D6E9B1C3C89938DCAD5C /* Testing */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -10593,6 +10604,7 @@
03FBDAFD263EE4E800ACE257 /* CouponListViewModelTests.swift in Sources */,
036F6EA6281847D5006D84F8 /* PaymentCaptureOrchestratorTests.swift in Sources */,
B555531321B57E8800449E71 /* MockUserNotificationsCenterAdapter.swift in Sources */,
682210ED2909666600814E14 /* CustomerSearchUICommandTests.swift in Sources */,
4590B652261C8D1E00A6FCE0 /* WeightFormatterTests.swift in Sources */,
D8C11A6022E2479800D4A88D /* OrderPaymentDetailsViewModelTests.swift in Sources */,
B622BC74289CF19400B10CEC /* WaitingTimeTrackerTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,76 @@ final class EditOrderAddressFormViewModelTests: XCTestCase {
// Then
XCTAssertEqual(notice, AddressFormViewModel.NoticeFactory.createInvalidEmailNotice())
}

func test_OrderAddressForm_billing_fields_are_updated_when_customerSelectedFromSearch() {
// Given
let viewModel = EditOrderAddressFormViewModel(order: Order.fake(), type: .billing)
let customer = Customer.fake().copy(
email: "[email protected]",
firstName: "Johnny",
lastName: "Appleseed",
billing: sampleAddressWithEmptyNullableFields(),
shipping: sampleAddressWithEmptyNullableFields()
)

// When
viewModel.customerSelectedFromSearch(customer: customer)

// Then
XCTAssertEqual(viewModel.fields.email, customer.email)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't forget about assertEqual: the nicer diffs when stuff is wrong are worth it, even though it's annoying having to swap around the actual and expected when you swap from XCTAssertEqual

XCTAssertEqual(viewModel.fields.firstName, customer.firstName)
XCTAssertEqual(viewModel.fields.lastName, customer.lastName)
XCTAssertEqual(viewModel.fields.company, customer.billing?.company)
XCTAssertEqual(viewModel.fields.address1, customer.billing?.address1)
XCTAssertEqual(viewModel.fields.address2, customer.billing?.address2)
XCTAssertEqual(viewModel.fields.city, customer.billing?.city)
XCTAssertEqual(viewModel.fields.state, customer.billing?.state)
XCTAssertEqual(viewModel.fields.postcode, customer.billing?.postcode)
XCTAssertEqual(viewModel.fields.country, customer.billing?.country)
XCTAssertEqual(viewModel.fields.phone, customer.billing?.phone)
}

func test_OrderAddressForm_shipping_fields_are_updated_when_customerSelectedFromSearch() {
// Given
let viewModel = EditOrderAddressFormViewModel(order: Order.fake(), type: .shipping)
let customer = Customer.fake().copy(
email: "[email protected]",
firstName: "Johnny",
lastName: "Appleseed",
billing: sampleAddressWithEmptyNullableFields(),
shipping: sampleAddressWithEmptyNullableFields()
)

// When
viewModel.customerSelectedFromSearch(customer: customer)

// Then
XCTAssertEqual(viewModel.fields.email, customer.email)
XCTAssertEqual(viewModel.fields.firstName, customer.firstName)
XCTAssertEqual(viewModel.fields.lastName, customer.lastName)
XCTAssertEqual(viewModel.fields.company, customer.shipping?.company)
XCTAssertEqual(viewModel.fields.address1, customer.shipping?.address1)
XCTAssertEqual(viewModel.fields.address2, customer.shipping?.address2)
XCTAssertEqual(viewModel.fields.city, customer.shipping?.city)
XCTAssertEqual(viewModel.fields.state, customer.shipping?.state)
XCTAssertEqual(viewModel.fields.postcode, customer.shipping?.postcode)
XCTAssertEqual(viewModel.fields.country, customer.shipping?.country)
XCTAssertEqual(viewModel.fields.phone, customer.shipping?.phone)
}

func test_OrderAddressForm_shows_different_address_form_fields_when_addresses_differ_and_customerSelectedFromSearch() {
// Given
let viewModel = EditOrderAddressFormViewModel(order: Order.fake(), type: .billing)
let billing = sampleAddressWithEmptyNullableFields()
let shipping = Address.fake().copy(address1: "123 different fake street")
let customer = Customer.fake().copy(billing: billing, shipping: shipping)

// When
viewModel.customerSelectedFromSearch(customer: customer)

// Then
XCTAssertTrue(viewModel.showDifferentAddressForm)
}
Comment on lines +776 to +788
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👏👏👏 So good to be able to test this kind of thing with confidence

}

private extension EditOrderAddressFormViewModelTests {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import XCTest
@testable import WooCommerce
import Yosemite

final class CustomerSearchUICommandTests: XCTestCase {
private let sampleSiteID: Int64 = 123

override func setUp() {
super.setUp()
}

override func tearDown() {
super.tearDown()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can delete these

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! 7b58531


func test_searchResultsPredicate_includes_siteID_and_keyword_when_keyword() {
// Given
let command = CustomerSearchUICommand(siteID: sampleSiteID) { _ in }

// When
let predicate = command.searchResultsPredicate(keyword: "some")
let expectedQuery = "siteID == 123 AND ANY searchResults.keyword == \"some\""
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double check if this actually filters down multiple sites.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed 👍 , for 2 customers with the same name and customer ID, in two different sites, only one is shown.


// Then
XCTAssertEqual(predicate?.predicateFormat, expectedQuery)
}

func test_cellViewModel_display_correct_customer_details() {
let command = CustomerSearchUICommand(siteID: sampleSiteID) { _ in }
let customer = Customer(
siteID: sampleSiteID,
customerID: 1,
email: "[email protected]",
firstName: "John",
lastName: "W",
billing: nil,
shipping: nil
)

let cellViewModel = command.createCellViewModel(model: customer)

XCTAssertEqual(cellViewModel.id, String(customer.customerID))
XCTAssertEqual(cellViewModel.title, "\(customer.firstName ?? "") \(customer.lastName ?? "")")
XCTAssertEqual(cellViewModel.subtitle, String(customer.email))
}
}
6 changes: 6 additions & 0 deletions Yosemite/Yosemite/Stores/CustomerStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ public final class CustomerStore: Store {
var customers = [Customer]()
let group = DispatchGroup()
for result in searchResults {
// At the moment, we're not searching through non-registered customers
// As we only search by customer ID, calls to /wc/v3/customers/0 will always fail
// https://github.com/woocommerce/woocommerce-ios/issues/7741
if result.userID == 0 {
continue
}
group.enter()
self.retrieveCustomer(for: siteID, with: result.userID, onCompletion: { result in
if let customer = try? result.get() {
Expand Down
19 changes: 19 additions & 0 deletions Yosemite/YosemiteTests/Stores/CustomerStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,23 @@ final class CustomerStoreTests: XCTestCase {
XCTAssertEqual(storedCustomer?.customerID, dummyCustomerID)
XCTAssertEqual(storedCustomer?.firstName, "John")
}

func test_searchCustomers_returns_no_customers_when_customer_is_not_registered() throws {
// Given
let customerID = 0
network.simulateResponse(requestUrlSuffix: "customers", filename: "wc-analytics-customers")
network.simulateResponse(requestUrlSuffix: "customers/\(customerID)", filename: "customer")

// When
let response: Result<[Networking.Customer], Error> = waitFor { promise in
let action = CustomerAction.searchCustomers(siteID: self.dummySiteID, keyword: self.dummyKeyword) { result in
promise(result)
}
self.dispatcher.dispatch(action)
}

// Then
let customers = try response.get()
XCTAssertEqual(customers.count, 0)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice test! This is business logic which is well worth checking.

However... it doesn't really test what it looks like. I had a go at making it fail (by removing the if result.userID == 0 check in the store) and it still passed.... When you're testing a negative, it's especially worthwhile making sure that the test fails when you expect. Think Red-Green-Refactor, even when you're not actually doing TDD.

The reason is that we don't have anyone in wc-analytics-customer.json with user_id: 0, you'll need to add another entry to that file to get the test to work.

We can improve it further though... here's another way to do it, which more directly checks that the request is not sent, without needing to see what the customer response is:

(N.B. This still needs an analytics customer with user_id: 0 in the json file)

Suggested change
func test_searchCustomers_returns_no_customers_when_customer_is_not_registered() throws {
// Given
let customerID = 0
network.simulateResponse(requestUrlSuffix: "customers", filename: "wc-analytics-customers")
network.simulateResponse(requestUrlSuffix: "customers/\(customerID)", filename: "customer")
// When
let response: Result<[Networking.Customer], Error> = waitFor { promise in
let action = CustomerAction.searchCustomers(siteID: self.dummySiteID, keyword: self.dummyKeyword) { result in
promise(result)
}
self.dispatcher.dispatch(action)
}
// Then
let customers = try response.get()
XCTAssertEqual(customers.count, 0)
}
func test_searchCustomers_returns_no_customers_when_customer_is_not_registered() throws {
// Given
network.simulateResponse(requestUrlSuffix: "customers", filename: "wc-analytics-customers")
// When
waitFor { promise in
let action = CustomerAction.searchCustomers(siteID: self.dummySiteID, keyword: self.dummyKeyword) { result in
promise(())
}
self.dispatcher.dispatch(action)
}
// Then
let requests = network.requestsForResponseData.compactMap { $0 as? JetpackRequest }
XCTAssertFalse(requests.contains(where: { request in
request.path == "customers/0"
}))
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh that's nice! Made the changes on 7b58531

}