Skip to content
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

[Product] Update product tag store to migrate Core Data usage and fix a few issues #14511

Merged
merged 24 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
de2310b
Replace deprecated use of writerDerivedStorage
itsmeichigo Nov 26, 2024
149e08f
Remove unused tags as part of the upsertion
itsmeichigo Nov 26, 2024
c646ba1
Handle deleteStoredProductTags in background
itsmeichigo Nov 26, 2024
7450cef
Remove unused method deleteUnusedStoredProductTags and move the logic…
itsmeichigo Nov 26, 2024
5c6a7f4
Add new field count for ProductTag
itsmeichigo Nov 26, 2024
9ee30af
Only upsert product tags with associated products
itsmeichigo Nov 26, 2024
5f103af
Fix test build failure
itsmeichigo Nov 26, 2024
d1c2ba4
Add test for addProductTags to check when tag list is empty
itsmeichigo Nov 26, 2024
c1ddb99
Set default value for count to avoid unnecessary changes
itsmeichigo Nov 26, 2024
56b23b3
Fix test failure for ProductTagStore
itsmeichigo Nov 26, 2024
ad897f4
Add comment explaining the `count` property for ProductTag
itsmeichigo Nov 26, 2024
9a4c9ad
Update unit tests for ProductTagStore
itsmeichigo Nov 26, 2024
7c6fd0a
Remove count property from tag initializers
itsmeichigo Nov 26, 2024
ec63dfe
Merge branch 'trunk' into task/14091-product-tag-store-update
itsmeichigo Nov 26, 2024
2afea69
Update release notes
itsmeichigo Nov 26, 2024
c6b7feb
Fetch all items in one go to avoid multiple fetches
itsmeichigo Nov 26, 2024
501eb95
Merge branch 'trunk' into task/14091-product-tag-store-update
itsmeichigo Nov 28, 2024
e5f3ab2
Revert change to synchronizeProductTags method
itsmeichigo Nov 28, 2024
c1010cc
Revert addition of count property
itsmeichigo Nov 28, 2024
2b06eb8
Revert changes to unit tests
itsmeichigo Nov 28, 2024
31008d3
Update comment on deleteStoredProductTags
itsmeichigo Nov 29, 2024
cd4946a
Display cached tags immediately upon loading tags
itsmeichigo Nov 29, 2024
c77d166
Only trigger tag creation if there are any tags confirmed
itsmeichigo Nov 29, 2024
8cd948a
Update release notes
itsmeichigo Nov 29, 2024
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
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
-----
- [*] Jetpack Setup: Fixed an issue where the WordPress.com authentication fails when using a passwordless account that's already connected to Jetpack [https://github.com/woocommerce/woocommerce-ios/pull/14501]
- [*] Jetpack Setup: Fixed an issue with magic link handling when the app is cold started. [https://github.com/woocommerce/woocommerce-ios/pull/14502]
- [*] Product Tags: Improved the tag list screen by displaying the cached items on first load and fixed issue clearing all tags [https://github.com/woocommerce/woocommerce-ios/pull/14511]
- [**] Media Library: On sites logged in with application password, when picking image from WordPress Media Library, all images will now load correctly. [https://github.com/woocommerce/woocommerce-ios/pull/14444]

21.2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ private extension ProductTagsViewController {
navigationItem.setRightBarButton(UIBarButtonItem(title: Strings.saveButton,
style: .done,
target: self,
action: #selector(addTagsRemotely)),
action: #selector(confirmTags)),
animated: true)
navigationItem.rightBarButtonItem?.isEnabled = true
}
Expand Down Expand Up @@ -207,7 +207,14 @@ extension ProductTagsViewController: KeyboardScrollable {
//
private extension ProductTagsViewController {
func loadTags() {
displayGhostContent(over: tableView)
let cachedTags = self.fetchedTags
if cachedTags.isEmpty {
/// Display the loading state only cached items are unavailable.
displayGhostContent(over: tableView)
} else {
/// Otherwise, display cached items right away.
tagsLoaded(tags: cachedTags.map { $0.name } )
}

let action = ProductTagAction.synchronizeAllProductTags(siteID: product.siteID) { [weak self] error in
if let error = error {
Expand All @@ -232,12 +239,21 @@ private extension ProductTagsViewController {
searchQuery: partialTag)
}

@objc func addTagsRemotely() {
@objc func confirmTags() {
ServiceLocator.analytics.track(.productTagSettingsDoneButtonTapped, withProperties: [
"has_changed_data": hasUnsavedChanges()
])

textView.resignFirstResponder()

guard allTags.isNotEmpty else {
return onCompletion([])
}

createNewTagsRemotely()
}

func createNewTagsRemotely() {
configureRightBarButtonItemAsSpinner()

let action = ProductTagAction.addProductTags(siteID: product.siteID, tags: allTags) { [weak self] (result) in
Expand Down
56 changes: 20 additions & 36 deletions Yosemite/Yosemite/Stores/ProductTagStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ import Storage
public final class ProductTagStore: Store {
private let remote: ProductTagsRemote

private lazy var sharedDerivedStorage: StorageType = {
return storageManager.writerDerivedStorage
}()

public override init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) {
self.remote = ProductTagsRemote(network: network)
super.init(dispatcher: dispatcher, storageManager: storageManager, network: network)
Expand Down Expand Up @@ -80,10 +76,6 @@ private extension ProductTagStore {

switch result {
case .success(let productTags):
if pageNumber == Default.firstPageNumber {
self?.deleteUnusedStoredProductTags(siteID: siteID)
}

self?.upsertStoredProductTagsInBackground(productTags, siteID: siteID) {
onCompletion(.success(productTags))
}
Expand All @@ -96,6 +88,9 @@ private extension ProductTagStore {
/// Create new product tags associated with a given Site ID.
///
func addProductTags(siteID: Int64, tags: [String], onCompletion: @escaping (Result<[ProductTag], Error>) -> Void) {
guard tags.isEmpty == false else {
return onCompletion(.success([]))
}
Comment on lines +91 to +93
Copy link
Member

Choose a reason for hiding this comment

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

I found it interesting that we need this check here, after checking the code, the issue is in the handling of tags in iOS, the logic in ProductTagsViewController could use some optimization, as currently it always tries to create tags even the already added ones, and by extension causes the issue that you noticed.
Normally, the logic should be create just the new tags, and would speed up the process also (when the user picks just existing ones).

I'll create an issue to keep track of this improvement; for now, this fix here should be enough.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since this is a simple fix, I sneaked it in c77d166.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks @itsmeichigo for the additional improvement, this looks good, but my remark touches another point, currently the logic of iOS always triggers an API request to create the tags, but this should be needed only when the user adds a new one that doesn't already exist, when the user picks tags from the suggestions, we don't need to re-create them. The following screen recording shows what I mean (I'm selecting an existing tag, and yet we show a loading indicator and we send an API request):

Simulator.Screen.Recording.-.iPhone.16.-.2024-11-29.at.18.32.01.mp4

I created this ticket #14555 to keep track of this.

remote.createProductTags(for: siteID, names: tags) { [weak self] (result) in
switch result {
case .success(let productTags):
Expand All @@ -114,8 +109,9 @@ private extension ProductTagStore {
remote.deleteProductTags(for: siteID, ids: ids) { [weak self] (result) in
switch result {
case .success(let productTags):
self?.deleteStoredProductTags(siteID: siteID, ids: ids)
onCompletion(.success(productTags))
self?.deleteStoredProductTags(siteID: siteID, ids: ids) {
onCompletion(.success(productTags))
}
case .failure(let error):
onCompletion(.failure(error))
}
Expand All @@ -130,32 +126,19 @@ private extension ProductTagStore {
/// onCompletion will be called on the main thread!
///
func upsertStoredProductTagsInBackground(_ readOnlyProductTags: [Networking.ProductTag],
siteID: Int64,
onCompletion: @escaping () -> Void) {
let derivedStorage = sharedDerivedStorage
derivedStorage.perform { [weak self] in
self?.upsertStoredProductTags(readOnlyProductTags, in: derivedStorage, siteID: siteID)
}

storageManager.saveDerivedType(derivedStorage: derivedStorage) {
DispatchQueue.main.async(execute: onCompletion)
}
}

/// Deletes any Storage.ProductTag that is not associated to a product on the specified `siteID`
///
func deleteUnusedStoredProductTags(siteID: Int64) {
let storage = storageManager.viewStorage
storage.deleteUnusedProductTags(siteID: siteID)
storage.saveIfNeeded()
siteID: Int64,
onCompletion: @escaping () -> Void) {
storageManager.performAndSave({ [weak self] storage in
self?.upsertStoredProductTags(readOnlyProductTags, in: storage, siteID: siteID)
}, completion: onCompletion, on: .main)
}

/// Deletes any Storage.ProductTag with the specified `siteID` and `productID`
/// Deletes any Storage.ProductTag with the specified `siteID` and `tagID`'s
///
func deleteStoredProductTags(siteID: Int64, ids: [Int64]) {
let storage = storageManager.viewStorage
storage.deleteProductTags(siteID: siteID, ids: ids)
storage.saveIfNeeded()
func deleteStoredProductTags(siteID: Int64, ids: [Int64], onCompletion: @escaping () -> Void) {
storageManager.performAndSave({ storage in
storage.deleteProductTags(siteID: siteID, ids: ids)
}, completion: onCompletion, on: .main)
}

}
Expand All @@ -169,12 +152,13 @@ private extension ProductTagStore {
/// - siteID: site ID for looking up the ProductTag.
///
func upsertStoredProductTags(_ readOnlyProductTags: [Networking.ProductTag],
in storage: StorageType,
siteID: Int64) {
in storage: StorageType,
siteID: Int64) {
let storedItems = storage.loadProductTags(siteID: siteID)
// Upserts the ProductTag models from the read-only version
for readOnlyProductTag in readOnlyProductTags {
let storageProductTag: Storage.ProductTag = {
if let storedTag = storage.loadProductTag(siteID: siteID, tagID: readOnlyProductTag.tagID) {
if let storedTag = storedItems.first(where: { $0.tagID == readOnlyProductTag.tagID }) {
return storedTag
}
return storage.insertNewObject(ofType: Storage.ProductTag.self)
Expand Down
49 changes: 20 additions & 29 deletions Yosemite/YosemiteTests/Stores/ProductTagStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ final class ProductTagStoreTests: XCTestCase {
super.tearDown()
}

func testSynchronizeProductTagsReturnsTagsUponSuccessfulResponse() throws {
func test_synchronizeAllProductTags_saves_all_tags_to_storage() throws {
// Given a stubed product-tags network response
network.simulateResponse(requestUrlSuffix: "products/tags", filename: "product-tags-all")
network.simulateResponse(requestUrlSuffix: "products/tags", filename: "product-tags-empty")
Expand Down Expand Up @@ -238,51 +238,42 @@ final class ProductTagStoreTests: XCTestCase {
XCTAssertNotNil(result?.failure)
}

func testAddProductTagReturnsErrorUponEmptyResponse() {
// Given an empty network response
XCTAssertEqual(storedProductTagsCount, 0)

// When dispatching a `addProductTags` action
func test_addProductTags_returns_success_for_empty_tag_list() throws {
// When
var result: Result<[Networking.ProductTag], Error>?
waitForExpectation { (exp) in
let action = ProductTagAction.addProductTags(siteID: sampleSiteID, tags: ["Round toe", "Flat"]) { (aResult) in
let action = ProductTagAction.addProductTags(siteID: sampleSiteID, tags: []) { (aResult) in
result = aResult
exp.fulfill()
}
store.onAction(action)
}

// Then no tags should be stored
XCTAssertEqual(storedProductTagsCount, 0)
XCTAssertNotNil(result?.failure)
}
// Then
let receivedResult = try XCTUnwrap(result)
XCTAssertTrue(receivedResult.isSuccess)

func testSynchronizeProductTagsDeletesUnusedTags() {
// Given some stored product tags without product relationships
let sampleTags = (1...5).map { id in
return sampleTag(tagID: id)
}
sampleTags.forEach { tag in
storageManager.insertSampleProductTag(readOnlyProductTag: tag)
}
XCTAssertEqual(storedProductTagsCount, sampleTags.count)
let addedTags = try receivedResult.get()
XCTAssertTrue(addedTags.isEmpty)
}

// When dispatching a `synchronizeAllProductTags` action
network.simulateResponse(requestUrlSuffix: "products/tags", filename: "product-tags-all")
network.simulateResponse(requestUrlSuffix: "products/tags", filename: "product-tags-empty")
func testAddProductTagReturnsErrorUponEmptyResponse() {
// Given an empty network response
XCTAssertEqual(storedProductTagsCount, 0)

var errorResponse: ProductTagActionError?
// When dispatching a `addProductTags` action
var result: Result<[Networking.ProductTag], Error>?
waitForExpectation { (exp) in
let action = ProductTagAction.synchronizeAllProductTags(siteID: sampleSiteID) { error in
errorResponse = error
let action = ProductTagAction.addProductTags(siteID: sampleSiteID, tags: ["Round toe", "Flat"]) { (aResult) in
result = aResult
exp.fulfill()
}
store.onAction(action)
}

// Then new tag should be stored and old tags should be deleted
XCTAssertEqual(storedProductTagsCount, 4)
XCTAssertNil(errorResponse)
// Then no tags should be stored
XCTAssertEqual(storedProductTagsCount, 0)
XCTAssertNotNil(result?.failure)
}

func testDeleteProductTagDeleteStoredTagSuccessfulResponse() {
Expand Down