-
Notifications
You must be signed in to change notification settings - Fork 118
[Woo POS][Historical Orders] Order Details: Email Receipt action (Wireframe UI) (1/1) #16110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Woo POS][Historical Orders] Order Details: Email Receipt action (Wireframe UI) (1/1) #16110
Conversation
PointOfSaleOrderController only works with current order that is currently being build. However, we want to reuse receipt functionality for historical orders as well. Extracting POSReceiptController to a separate entity allows to reuse it.
…gacy API endpoint - send_email from 9.8 allows to pass email and force_email_update parameters - send_order_details requires to update order before sending the receipt
Generated by 🚫 Danger |
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Works as expected 🚀 left some non-blocking comments on the code.
Some observations from testing:
- (minor) In both light and dark modes, the selected order row background color could have a bit more contrast from the unselected rows.
dark | light |
---|---|
![]() |
![]() |
- My test store's time zone is 20 hours behind my device time zone. The date in the POS order UI was first shown in the device time zone, which is different from what's shown in the email, and in the order details UI in the app. Interestingly, after checking the orders tab in the app, the order date became displayed in the store time zone. Another minor consideration is to show the "payment date" instead of the order creation date.
Note the order date in the screenshots.
order UI before sending email | order details | order UI after sending email / checking orders tab (not sure if it's relevant) |
---|---|---|
![]() |
![]() |
![]() |
import struct Yosemite.Order | ||
|
||
final class MockPOSReceiptController: POSReceiptControllerProtocol { | ||
var shouldThrowReceiptError: Bool = false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: maybe this variable could be more generic to allow any type of error instead of PointOfSaleOrderController.PointOfSaleOrderControllerError.noOrder
; or indicating that this variable is for throwing this specific error
@@ -0,0 +1,95 @@ | |||
import Foundation | |||
import Observation |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
super nit: this import doesn't seem needed? It's interesting periphery doesn't detect unused imports.
func sendReceipt(orderID: Int64, recipientEmail: String) async throws | ||
} | ||
|
||
final class POSReceiptController: POSReceiptControllerProtocol { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: how about some other name like POSReceiptSender
/POSReceiptSendingService
? As this class doesn't seem to hold any states for POS, a minor naming suggestion to avoid "controller" based on the POS documentation on controllers:
woocommerce-ios/WooCommerce/Classes/POS/README.md
Lines 48 to 52 in 3914fc5
#### Controllers | |
Each `Controller` handles a piece of global shared state, and provides the interface for interacting with it. | |
Often, the best way to do that is to expose a single enum covering all valid possibilities for that state, rather than exposing model objects. We've not achieved that in all the controllers yet, and some global state hasn't been encapsulated in a controller successfully yet, for example card payments. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for sharing. Yes, I think it's better to rename it to keep controller behavior consistent 👍
@@ -85,7 +88,7 @@ struct PointOfSaleEntryPointView: View { | |||
.environmentObject(posModalManager) | |||
.environmentObject(posSheetManager) | |||
.environmentObject(posCoverManager) | |||
.environment(PointOfSaleOrderListModel(ordersController: ordersController)) | |||
.environment(PointOfSaleOrderListModel(ordersController: ordersController, receiptController: receiptController)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: how about DI'ing PointOfSaleOrderListModel
instead of POSReceiptControllerProtocol
, so that PointOfSaleOrderListModel
is explicitly owned?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I updated it to be the same way as with aggregateModel. Keeping it in the entryPointView as @State
method: .post, | ||
siteID: siteID, | ||
path: path, | ||
parameters: parameters, | ||
availableAsRESTRequest: true) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
super nit: indentation
"billing": [ | ||
"email": emailAddress | ||
] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just wondering, does this affect other billing fields than the email field like the address? I'm guessing/hoping it doesn't. If it does, we might want to stay with the previous order remote update.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, it doesn't affect other billing fields. I tested. It targets only the email address.
@Test | ||
func updatePOSOrder_calls_remote_updatePOSOrderEmail_with_correct_parameters() async throws { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
super nit: could be in the same line? Same for the other test case.
|
||
extension View { | ||
/// Sets the back button icon for all POSPageHeaderView instances in the view hierarchy. | ||
/// - Parameter icon: The system name for the back button icon (e.g., "xmark") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: it looks like this icon only work for system symbols? Maybe can make it more obvious in the function signature.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Renamed to systemName:
to follow the Image(..)
initializer
let mockOrderService = MockPOSOrderService() | ||
let mockReceiptService = MockReceiptService() | ||
let mockAnalyticsProvider = MockAnalyticsProvider() | ||
let mockFeatureFlagService = MockFeatureFlagService() | ||
let mockPluginsService = MockPluginsService() | ||
let sut: POSReceiptController |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: could these be private?
@Test func sendReceipt_tracks_success_with_eligible_for_pos_receipt() async throws { | ||
// Given | ||
let mockPluginsService = MockPluginsService() | ||
mockPluginsService.setMockPlugin(.wooCommerce, | ||
systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", | ||
version: "10.0.0-dev", | ||
active: true)) | ||
|
||
let mockFeatureFlagService = MockFeatureFlagService() | ||
mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true | ||
|
||
let sut = PointOfSaleOrderController(orderService: orderService, | ||
receiptService: receiptService, | ||
analytics: analytics, | ||
featureFlagService: mockFeatureFlagService, | ||
pluginsService: mockPluginsService) | ||
let order = Order.fake() | ||
orderService.orderToReturn = order | ||
|
||
// We need an existing order before we can send a receipt | ||
await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) | ||
analyticsProvider.receivedEvents.removeAll() | ||
analyticsProvider.receivedProperties.removeAll() | ||
|
||
// When | ||
try await sut.sendReceipt(recipientEmail: "[email protected]") | ||
|
||
// Then | ||
let indexOfEvent = try #require(analyticsProvider.receivedEvents.firstIndex(where: { $0 == "receipt_email_success" })) | ||
#expect(analyticsProvider.receivedProperties[indexOfEvent]["eligible_for_pos_receipt"] as? Bool == true) | ||
} | ||
|
||
@Test func sendReceipt_without_order_tracks_failure_without_eligible_for_pos_receipt() async throws { | ||
// Given | ||
let mockFeatureFlagService = MockFeatureFlagService() | ||
mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true | ||
let sut = PointOfSaleOrderController(orderService: orderService, | ||
receiptService: receiptService, | ||
analytics: analytics, | ||
featureFlagService: mockFeatureFlagService) | ||
|
||
// When | ||
do { | ||
try await sut.sendReceipt(recipientEmail: "[email protected]") | ||
} catch { | ||
// Then | ||
let indexOfEvent = try #require(analyticsProvider.receivedEvents.firstIndex(where: { $0 == "receipt_email_failed" })) | ||
#expect(analyticsProvider.receivedProperties[indexOfEvent]["eligible_for_pos_receipt"] == nil) | ||
} | ||
} | ||
|
||
@Test func sendReceipt_tracks_failure_with_eligible_for_pos_receipt() async throws { | ||
// Given | ||
let mockPluginsService = MockPluginsService() | ||
mockPluginsService.setMockPlugin(.wooCommerce, | ||
systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", | ||
version: "10.0.0-dev", | ||
active: true)) | ||
|
||
let mockFeatureFlagService = MockFeatureFlagService() | ||
mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true | ||
let sut = PointOfSaleOrderController(orderService: orderService, | ||
receiptService: receiptService, | ||
analytics: analytics, | ||
featureFlagService: mockFeatureFlagService, | ||
pluginsService: mockPluginsService) | ||
|
||
receiptService.sendReceiptResult = .failure(DotcomError.unknown(code: "test_error", message: "Test error")) | ||
|
||
let order = Order.fake() | ||
orderService.orderToReturn = order | ||
|
||
// We need an existing order before we can send a receipt | ||
await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) | ||
analyticsProvider.receivedEvents.removeAll() | ||
analyticsProvider.receivedProperties.removeAll() | ||
|
||
// When | ||
do { | ||
try await sut.sendReceipt(recipientEmail: "[email protected]") | ||
} catch { | ||
// Then | ||
let indexOfEvent = try #require(analyticsProvider.receivedEvents.firstIndex(where: { $0 == "receipt_email_failed" })) | ||
#expect(analyticsProvider.receivedProperties[indexOfEvent]["eligible_for_pos_receipt"] as? Bool == true) | ||
#expect(analyticsProvider.receivedProperties[indexOfEvent]["error_description"] as? String != nil) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: do you think we can keep the analytics tests, or maybe they are not necessary for some reasons?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I moved them to POSReceiptSenderTests
, they still exist 👍
Thank you for your thorough review, @jaclync! Great comments as always. I addressed everything except the selection color. I will leave these updates after the designer input 👍 |
31d1f6b
to
4ae1b3c
Compare
WOOMOB-1140
Description
Add Email Receipt action to completed orders:
PointOfSaleOrderDetailsView
with a section that has a button "Email receipt" when order status is.completed
.POSSendReceiptView
when button is tapped.POSSendReceiptView
more reusable by injecting receipt sending action.POSPageHeaderView
ViewModifier
to modify back button from outside.OrderController
intoPOSReceiptController
which allows to pass any order ID.Order ID
andemail
address, not the wholeOrder
object.send-email
API endpoint to utilizeforce_email_update
parameter and not require updating order separately.There are enough refactoring in this PR, I'm leaving Order List and Order Details update after the email is sent to another PR:
Steps to reproduce
Email receipt
buttonEmail receipt
view is openedx
closes the viewTesting information
send_email
andsend_order_details
endpointsScreenshots
Simulator.Screen.Recording.-.iPad.Air.13-inch.M3.-.2025-09-09.at.18.53.30.mov
RELEASE-NOTES.txt
if necessary.