Skip to content

Commit 72b4360

Browse files
Merge branch 'trunk' into feature/7547-wrong-account-help-center
2 parents 121acfb + 4ea3d06 commit 72b4360

File tree

12 files changed

+427
-9
lines changed

12 files changed

+427
-9
lines changed

Experiments/Experiments/DefaultFeatureFlagService.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
3333
return true
3434
case .promptToEnableCodInIppOnboarding:
3535
return true
36+
case .orderCreationSearchCustomers:
37+
return buildConfig == .localDeveloper || buildConfig == .alpha
3638
default:
3739
return true
3840
}

Experiments/Experiments/FeatureFlag.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,8 @@ public enum FeatureFlag: Int {
6969
/// Whether to include the Cash on Delivery enable step in In-Person Payment onboarding
7070
///
7171
case promptToEnableCodInIppOnboarding
72+
73+
/// Enables the Search Customers functionality in the Order Creation screen
74+
///
75+
case orderCreationSearchCustomers
7276
}

RELEASE-NOTES.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
10.5
44
-----
55
- [*] Help center: Added help center web page with FAQs for "Wrong WordPress.com account error" screen. [https://github.com/woocommerce/woocommerce-ios/pull/7747]
6+
- [**] Products: Now you can duplicate products from the More menu of the product detail screen. [https://github.com/woocommerce/woocommerce-ios/pull/7727]
7+
68

79
10.4
810
-----

WooCommerce/Classes/Analytics/WooAnalyticsStat.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,10 @@ public enum WooAnalyticsStat: String {
407407
case addProductSuccess = "add_product_success"
408408
case addProductFailed = "add_product_failed"
409409

410+
// MARK: Duplicate Product events
411+
case duplicateProductSuccess = "duplicate_product_success"
412+
case duplicateProductFailed = "duplicate_product_failed"
413+
410414
// MARK: Edit Product Events
411415
//
412416
case productDetailLoaded = "product_detail_loaded"
@@ -512,6 +516,7 @@ public enum WooAnalyticsStat: String {
512516
// MARK: Product Settings
513517
//
514518
case productDetailViewSettingsButtonTapped = "product_detail_view_settings_button_tapped"
519+
case productDetailDuplicateButtonTapped = "product_detail_duplicate_button_tapped"
515520
case productSettingsDoneButtonTapped = "product_settings_done_button_tapped"
516521
case productSettingsStatusTapped = "product_settings_status_tapped"
517522
case productSettingsVisibilityTapped = "product_settings_visibility_tapped"

WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormRemoteActionUseCase.swift

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ final class ProductFormRemoteActionUseCase {
1010
}
1111
typealias AddProductCompletion = (_ result: Result<ResultData, ProductUpdateError>) -> Void
1212
typealias EditProductCompletion = (_ productResult: Result<ResultData, ProductUpdateError>) -> Void
13+
typealias DuplicateProductCompletion = (_ result: Result<ResultData, ProductUpdateError>) -> Void
1314

1415
private let stores: StoresManager
1516

@@ -24,28 +25,80 @@ final class ProductFormRemoteActionUseCase {
2425
/// - onCompletion: Called when the remote process finishes.
2526
func addProduct(product: EditableProductModel,
2627
password: String?,
28+
successEventName: WooAnalyticsStat = .addProductSuccess,
29+
failureEventName: WooAnalyticsStat = .addProductFailed,
2730
onCompletion: @escaping AddProductCompletion) {
2831
addProductRemotely(product: product) { productResult in
2932
switch productResult {
3033
case .failure(let error):
31-
ServiceLocator.analytics.track(.addProductFailed, withError: error)
34+
ServiceLocator.analytics.track(failureEventName, withError: error)
3235
onCompletion(.failure(error))
3336
case .success(let product):
3437
// `self` is retained because the use case is not usually strongly held.
3538
self.updatePasswordRemotely(product: product, password: password) { passwordResult in
3639
switch passwordResult {
3740
case .failure(let error):
38-
ServiceLocator.analytics.track(.addProductFailed, withError: error)
41+
ServiceLocator.analytics.track(failureEventName, withError: error)
3942
onCompletion(.failure(.passwordCannotBeUpdated))
4043
case .success(let password):
41-
ServiceLocator.analytics.track(.addProductSuccess)
44+
ServiceLocator.analytics.track(successEventName)
4245
onCompletion(.success(ResultData(product: product, password: password)))
4346
}
4447
}
4548
}
4649
}
4750
}
4851

52+
/// Adds a copy of the input product remotely. The new product will have an updated name, no SKU and its status will be Draft.
53+
/// - Parameters:
54+
/// - originalProduct: The product to be duplicated remotely.
55+
/// - onCompletion: Called when the remote process finishes.
56+
func duplicateProduct(originalProduct: EditableProductModel,
57+
password: String?,
58+
onCompletion: @escaping DuplicateProductCompletion) {
59+
let productModelToSave: EditableProductModel = {
60+
let newName = String(format: Localization.copyProductName, originalProduct.name)
61+
let copiedProduct = originalProduct.product.copy(
62+
productID: 0,
63+
name: newName,
64+
statusKey: ProductStatus.draft.rawValue,
65+
sku: .some(nil) // just resetting SKU to nil for simplicity
66+
)
67+
return EditableProductModel(product: copiedProduct)
68+
}()
69+
70+
let successEventName: WooAnalyticsStat = .duplicateProductSuccess
71+
let failureEventName: WooAnalyticsStat = .duplicateProductFailed
72+
73+
addProduct(product: productModelToSave,
74+
password: password,
75+
successEventName: successEventName,
76+
failureEventName: failureEventName) { result in
77+
switch result {
78+
case .success(let data):
79+
guard data.product.productType == .variable else {
80+
return onCompletion(.success(data))
81+
}
82+
// `self` is retained because the use case is not usually strongly held.
83+
self.duplicateVariations(originalProduct.product.variations,
84+
from: originalProduct.productID,
85+
to: data.product,
86+
onCompletion: { result in
87+
switch result {
88+
case .success(let product):
89+
ServiceLocator.analytics.track(successEventName)
90+
onCompletion(.success(ResultData(product: product, password: data.password)))
91+
case .failure(let error):
92+
ServiceLocator.analytics.track(failureEventName, withError: error)
93+
onCompletion(.failure(error))
94+
}
95+
})
96+
case .failure(let error):
97+
onCompletion(.failure(error))
98+
}
99+
}
100+
}
101+
49102
/// Edits a product and its password remotely.
50103
/// - Parameters:
51104
/// - product: The product to be updated remotely.
@@ -205,4 +258,96 @@ private extension ProductFormRemoteActionUseCase {
205258
}
206259
stores.dispatch(passwordUpdateAction)
207260
}
261+
262+
func duplicateVariations(_ variationIDs: [Int64],
263+
from oldProductID: Int64,
264+
to newProduct: EditableProductModel,
265+
onCompletion: @escaping (Result<EditableProductModel, ProductUpdateError>) -> Void) {
266+
Task { [weak self] in
267+
guard let self = self else { return }
268+
// Retrieves and duplicate product variations
269+
await withTaskGroup(of: Void.self, body: { group in
270+
for id in variationIDs {
271+
group.addTask {
272+
guard let variation = await self.retrieveProductVariation(variationID: id, siteID: newProduct.siteID, productID: oldProductID) else {
273+
return
274+
}
275+
let newVariation = CreateProductVariation(regularPrice: variation.regularPrice ?? "", attributes: variation.attributes)
276+
await self.duplicateProductVariation(newVariation, parent: newProduct)
277+
}
278+
}
279+
})
280+
281+
// Fetches the updated product and return
282+
do {
283+
let productModel = try await retrieveProduct(id: newProduct.productID, siteID: newProduct.siteID)
284+
await MainActor.run {
285+
let updatedProduct = EditableProductModel(product: productModel)
286+
onCompletion(.success(updatedProduct))
287+
}
288+
} catch let error {
289+
await MainActor.run {
290+
onCompletion(.failure(.unknown(error: AnyError(error))))
291+
}
292+
}
293+
}
294+
}
295+
296+
func retrieveProduct(id: Int64, siteID: Int64) async throws -> Product {
297+
try await withCheckedThrowingContinuation { [weak self] continuation in
298+
let action = ProductAction.retrieveProduct(siteID: siteID, productID: id) { result in
299+
switch result {
300+
case .success(let product):
301+
continuation.resume(returning: product)
302+
case .failure(let error):
303+
continuation.resume(throwing: error)
304+
}
305+
}
306+
DispatchQueue.main.async { [weak self] in
307+
self?.stores.dispatch(action)
308+
}
309+
} as Product
310+
}
311+
312+
func retrieveProductVariation(variationID: Int64, siteID: Int64, productID: Int64) async -> ProductVariation? {
313+
await withCheckedContinuation { [weak self] continuation in
314+
let action = ProductVariationAction.retrieveProductVariation(siteID: siteID,
315+
productID: productID,
316+
variationID: variationID,
317+
onCompletion: { result in
318+
switch result {
319+
case .success(let variation):
320+
continuation.resume(returning: variation)
321+
case .failure:
322+
continuation.resume(returning: nil)
323+
}
324+
})
325+
DispatchQueue.main.async { [weak self] in
326+
self?.stores.dispatch(action)
327+
}
328+
} as ProductVariation?
329+
}
330+
331+
func duplicateProductVariation(_ newVariation: CreateProductVariation, parent: EditableProductModel) async {
332+
await withCheckedContinuation { [weak self] continuation in
333+
let createAction = ProductVariationAction.createProductVariation(
334+
siteID: parent.siteID,
335+
productID: parent.productID,
336+
newVariation: newVariation) { result in
337+
continuation.resume(returning: ())
338+
}
339+
DispatchQueue.main.async { [weak self] in
340+
self?.stores.dispatch(createAction)
341+
}
342+
} as Void
343+
}
344+
}
345+
346+
private extension ProductFormRemoteActionUseCase {
347+
enum Localization {
348+
static let copyProductName = NSLocalizedString(
349+
"%1$@ Copy",
350+
comment: "The default name for a duplicated product, with %1$@ being the original name. Reads like: Ramen Copy"
351+
)
352+
}
208353
}

WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController+Helpers.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,15 @@ extension ProductFormViewController {
3535
}
3636
/// Product Confirmation Save alert
3737
///
38-
func presentProductConfirmationSaveAlert() {
38+
func presentProductConfirmationSaveAlert(title: String = Localization.Alert.presentProductConfirmationSaveAlert) {
3939
let contextNoticePresenter: NoticePresenter = {
4040
let noticePresenter = DefaultNoticePresenter()
4141
noticePresenter.presentingViewController = self
4242
return noticePresenter
4343
}()
44-
contextNoticePresenter.enqueue(notice: .init(title: Localization.Alert.presentProductConfirmationSaveAlert))
44+
contextNoticePresenter.enqueue(notice: .init(title: title))
4545
}
46+
4647
/// Product Confirmation Delete alert
4748
///
4849
func presentProductConfirmationDeleteAlert(completion: @escaping (_ isConfirmed: Bool) -> ()) {
@@ -93,6 +94,8 @@ extension ProductFormViewController {
9394
displayInProgressView(title: Localization.ProgressView.productSavingTitle, message: Localization.ProgressView.productSavingMessage)
9495
case .saveVariation:
9596
displayInProgressView(title: Localization.ProgressView.productVariationTitle, message: Localization.ProgressView.productVariationMessage)
97+
case .duplicate:
98+
displayInProgressView(title: Localization.ProgressView.productDuplicatingTitle, message: Localization.ProgressView.productDuplicatingMessage)
9699
}
97100
}
98101

@@ -124,6 +127,7 @@ private enum Localization {
124127
// Product saved or updated
125128
static let presentProductConfirmationSaveAlert = NSLocalizedString("Product saved",
126129
comment: "Title of the alert when a user is saving a product")
130+
127131
// Product type change
128132
static let productTypeChangeTitle = NSLocalizedString("Are you sure you want to change the product type?",
129133
comment: "Title of the alert when a user is changing the product type")
@@ -170,6 +174,11 @@ private enum Localization {
170174
static let productSavingMessage = NSLocalizedString("Please wait while we save this product to your store",
171175
comment: "Message of the in-progress UI while saving a Product as draft remotely")
172176

177+
static let productDuplicatingTitle = NSLocalizedString("Duplicating your product...",
178+
comment: "Title of the in-progress UI while duplicating a Product remotely")
179+
static let productDuplicatingMessage = NSLocalizedString("Please wait while we save a copy of this product to your store",
180+
comment: "Message of the in-progress UI while duplicating a Product as draft remotely")
181+
173182
static let productDeletionTitle = NSLocalizedString("Placing your product in the trash...",
174183
comment: "Title of the in-progress UI while deleting the Product remotely")
175184
static let productDeletionMessage = NSLocalizedString("Please wait while we update your store details",

WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,13 @@ final class ProductFormViewController<ViewModel: ProductFormViewModelProtocol>:
251251
}
252252
}
253253

254+
if viewModel.canDuplicateProduct() {
255+
actionSheet.addDefaultActionWithTitle(ActionSheetStrings.duplicate) { [weak self] _ in
256+
ServiceLocator.analytics.track(.productDetailDuplicateButtonTapped)
257+
self?.duplicateProduct()
258+
}
259+
}
260+
254261
if viewModel.canDeleteProduct() {
255262
actionSheet.addDestructiveActionWithTitle(ActionSheetStrings.delete) { [weak self] _ in
256263
self?.displayDeleteProductAlert()
@@ -738,11 +745,8 @@ private extension ProductFormViewController {
738745
}
739746
}
740747

741-
func displayError(error: ProductUpdateError?) {
742-
let title = NSLocalizedString("Cannot update product", comment: "The title of the alert when there is an error updating the product")
743-
748+
func displayError(error: ProductUpdateError?, title: String = Localization.updateProductError) {
744749
let message = error?.errorDescription
745-
746750
displayErrorAlert(title: title, message: message)
747751
}
748752

@@ -774,6 +778,27 @@ private extension ProductFormViewController {
774778
SharingHelper.shareURL(url: url, title: product.name, from: view, in: self)
775779
}
776780

781+
func duplicateProduct() {
782+
showSavingProgress(.duplicate)
783+
viewModel.duplicateProduct(onCompletion: { [weak self] result in
784+
switch result {
785+
case .failure(let error):
786+
DDLogError("⛔️ Error duplicating Product: \(error)")
787+
788+
// Dismisses the in-progress UI then presents the error alert.
789+
self?.navigationController?.dismiss(animated: true) {
790+
self?.displayError(error: error, title: Localization.duplicateProductError)
791+
}
792+
case .success:
793+
// Dismisses the in-progress UI, then presents the confirmation alert.
794+
self?.navigationController?.dismiss(animated: true) {
795+
let alertTitle = Localization.presentProductCopiedAlert
796+
self?.presentProductConfirmationSaveAlert(title: alertTitle)
797+
}
798+
}
799+
})
800+
}
801+
777802
func displayDeleteProductAlert() {
778803
let showVariationsText = viewModel is ProductVariationFormViewModel
779804
if showVariationsText {
@@ -1519,6 +1544,12 @@ private enum Localization {
15191544
let titleFormat = NSLocalizedString("Variation #%1$@", comment: "Navigation bar title for variation. Parameters: %1$@ - Product variation ID")
15201545
return String.localizedStringWithFormat(titleFormat, variationID)
15211546
}
1547+
static let updateProductError = NSLocalizedString("Cannot update product", comment: "The title of the alert when there is an error updating the product")
1548+
static let duplicateProductError = NSLocalizedString(
1549+
"Cannot duplicate product",
1550+
comment: "The title of the alert when there is an error duplicating the product"
1551+
)
1552+
static let presentProductCopiedAlert = NSLocalizedString("Product copied", comment: "Title of the alert when a user has copied a product")
15221553
}
15231554

15241555
private enum ActionSheetStrings {
@@ -1530,6 +1561,7 @@ private enum ActionSheetStrings {
15301561
static let delete = NSLocalizedString("Delete", comment: "Button title Delete in Edit Product More Options Action Sheet")
15311562
static let productSettings = NSLocalizedString("Product Settings", comment: "Button title Product Settings in Edit Product More Options Action Sheet")
15321563
static let cancel = NSLocalizedString("Cancel", comment: "Button title Cancel in Edit Product More Options Action Sheet")
1564+
static let duplicate = NSLocalizedString("Duplicate", comment: "Button title to duplicate a product in Product More Options Action Sheet")
15331565
}
15341566

15351567
private enum Constants {

WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModel.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,10 @@ extension ProductFormViewModel {
246246
func canDeleteProduct() -> Bool {
247247
formType == .edit
248248
}
249+
250+
func canDuplicateProduct() -> Bool {
251+
formType == .edit
252+
}
249253
}
250254

251255
// MARK: Action handling
@@ -457,6 +461,22 @@ extension ProductFormViewModel {
457461
}
458462
}
459463

464+
func duplicateProduct(onCompletion: @escaping (Result<ProductModel, ProductUpdateError>) -> Void) {
465+
let remoteActionUseCase = ProductFormRemoteActionUseCase()
466+
remoteActionUseCase.duplicateProduct(originalProduct: product,
467+
password: password) { [weak self] result in
468+
guard let self = self else { return }
469+
switch result {
470+
case .failure(let error):
471+
onCompletion(.failure(error))
472+
case .success(let data):
473+
self.resetProduct(data.product)
474+
self.resetPassword(data.password)
475+
onCompletion(.success(data.product))
476+
}
477+
}
478+
}
479+
460480
func deleteProductRemotely(onCompletion: @escaping (Result<Void, ProductUpdateError>) -> Void) {
461481
let remoteActionUseCase = ProductFormRemoteActionUseCase()
462482
remoteActionUseCase.deleteProduct(product: product) { result in

0 commit comments

Comments
 (0)