Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

23.6
-----
- [*] Handle sites configured with `http` siteAddress. [https://github.com/woocommerce/woocommerce-ios/pull/16279]
- [*] Use tabs cache state to display conditional tabs initially. [https://github.com/woocommerce/woocommerce-ios/pull/16292]
- [*] Handle sites configured with `http` siteAddress.
- [*] Fix: Unable to dismiss keyboard when editing Product Title. [https://github.com/woocommerce/woocommerce-ios/pull/16288]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ final class BookingsTabEligibilityChecker: BookingsTabEligibilityCheckerProtocol
userDefaults.loadCachedBookingsTabVisibility(siteID: site.siteID)
}

/// Checks the initial visibility without the `BookingsTabEligibilityChecker` instsance
/// Used for the initial state check when a site instance hasn't been loaded but a `siteID` is available
static func checkInitialVisibility(
for siteID: Int64,
in userDefaults: UserDefaults = .standard
) -> Bool {
return userDefaults.loadCachedBookingsTabVisibility(siteID: siteID)
}

/// Checks the final visibility of the Bookings tab.
func checkVisibility() async -> Bool {
// Check feature flag
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ final class POSTabVisibilityChecker: POSTabVisibilityCheckerProtocol {
eligibilityService.loadCachedPOSTabVisibility(siteID: site.siteID) ?? false
}

/// Checks the initial visibility without the `POSTabVisibilityChecker` instsance
/// Used for the initial state check when a site instance hasn't been loaded but a `siteID` is available
static func checkInitialVisibility(
for siteID: Int64,
eligibilityService: POSEligibilityServiceProtocol = POSEligibilityService()
) -> Bool {
return eligibilityService.loadCachedPOSTabVisibility(siteID: siteID) ?? false
}

/// Checks the final visibility of the POS tab.
func checkVisibility() async -> Bool {
guard siteCIABEligibilityChecker.isFeatureSupported(.pointOfSale, for: site) else {
Expand Down
72 changes: 48 additions & 24 deletions WooCommerce/Classes/ViewRelated/MainTabBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ final class MainTabBarController: UITabBarController {
private let posTabVisibilityCheckerFactory: ((_ site: Site) -> POSTabVisibilityCheckerProtocol)
private let posEligibilityService: POSEligibilityServiceProtocol
private let bookingsEligibilityCheckerFactory: ((_ site: Site) -> BookingsTabEligibilityCheckerProtocol)
private let userDefaults: UserDefaults

private var productImageUploadErrorsSubscription: AnyCancellable?

Expand All @@ -171,7 +172,8 @@ final class MainTabBarController: UITabBarController {
stores: StoresManager = ServiceLocator.stores,
posTabVisibilityCheckerFactory: ((Site) -> POSTabVisibilityCheckerProtocol)? = nil,
posEligibilityService: POSEligibilityServiceProtocol = POSEligibilityService(),
bookingsEligibilityCheckerFactory: ((Site) -> BookingsTabEligibilityCheckerProtocol)? = nil) {
bookingsEligibilityCheckerFactory: ((Site) -> BookingsTabEligibilityCheckerProtocol)? = nil,
userDefaults: UserDefaults = .standard) {
self.featureFlagService = featureFlagService
self.noticePresenter = noticePresenter
self.productImageUploader = productImageUploader
Expand All @@ -184,6 +186,7 @@ final class MainTabBarController: UITabBarController {
self.bookingsEligibilityCheckerFactory = bookingsEligibilityCheckerFactory ?? { site in
BookingsTabEligibilityChecker(site: site)
}
self.userDefaults = userDefaults
super.init(coder: coder)
}

Expand All @@ -201,6 +204,7 @@ final class MainTabBarController: UITabBarController {
self.bookingsEligibilityCheckerFactory = { site in
BookingsTabEligibilityChecker(site: site)
}
self.userDefaults = .standard
super.init(coder: coder)
}

Expand All @@ -218,8 +222,9 @@ final class MainTabBarController: UITabBarController {

delegate = self

// POS and Bookings tabs are hidden by default.
updateTabViewControllers(isPOSTabVisible: false, isBookingsTabVisible: false)
// Setup initial visibility for conditional tabs (POS, Bookings)
setupConditionalTabsInitialVisibility()

observeSiteIDForViewControllers()
observeSiteForConditionalTabs()
observeProductImageUploadStatusUpdates()
Expand Down Expand Up @@ -333,6 +338,30 @@ final class MainTabBarController: UITabBarController {
}
}
}

private func setupConditionalTabsInitialVisibility() {
guard let siteID = stores.sessionManager.defaultStoreID else {
return
}

setupConditionalTabsInitialVisibility(for: siteID)
}

private func setupConditionalTabsInitialVisibility(for siteID: Int64) {
let isPOSTabVisible = POSTabVisibilityChecker.checkInitialVisibility(
for: siteID,
eligibilityService: posEligibilityService
)
let isBookingsTabVisible = BookingsTabEligibilityChecker.checkInitialVisibility(
for: siteID,
in: userDefaults
)

updateTabViewControllers(
isPOSTabVisible: isPOSTabVisible,
isBookingsTabVisible: isBookingsTabVisible
)
}
}

// MARK: - UITabBarControllerDelegate
Expand Down Expand Up @@ -698,12 +727,12 @@ extension MainTabBarController: DeepLinkNavigator {
// MARK: - Site ID observation for updating tab view controllers
//
private extension MainTabBarController {
func observePOSEligibilityForPOSTabVisibility(siteID: Int64) {
guard let posTabVisibilityChecker else {
updateTabViewControllers(isPOSTabVisible: false, isBookingsTabVisible: isBookingsTabVisible)
viewModel.loadHubMenuTabBadge()
return
}
func observePOSEligibilityForPOSTabVisibility(site: Site) {
let siteID = site.siteID

// Configures POS tab coordinator once per logged in site session.
let posTabVisibilityChecker = posTabVisibilityCheckerFactory(site)
self.posTabVisibilityChecker = posTabVisibilityChecker

// Sets POS tab initial visibility based on cached value if available.
let initialVisibility = posTabVisibilityChecker.checkInitialVisibility()
Expand Down Expand Up @@ -770,7 +799,8 @@ private extension MainTabBarController {
return
}

observeConditionalTabsAvailabilityWith(site)
observePOSEligibilityForPOSTabVisibility(site: site)
observeBookingsEligibilityForBookingsTabVisibility(site: site)
}
}

Expand All @@ -783,24 +813,14 @@ private extension MainTabBarController {
}
}

func observeConditionalTabsAvailabilityWith(_ site: Site) {
// Configures POS tab coordinator once per logged in site session.
let posTabVisibilityChecker = posTabVisibilityCheckerFactory(site)
self.posTabVisibilityChecker = posTabVisibilityChecker

observePOSEligibilityForPOSTabVisibility(siteID: site.siteID)

// Configures Booking tab.
let bookingsViewController = createBookingsViewController(siteID: site.siteID)
bookingsContainerController.wrappedController = bookingsViewController
observeBookingsEligibilityForBookingsTabVisibility(site: site)
}

func updateViewControllers(siteID: Int64?) {
guard let siteID else {
return
}

// Update conditional tabs initial state for the `siteID`
setupConditionalTabsInitialVisibility(for: siteID)

// Update view model with `siteID` to query correct Orders Status
viewModel.configureOrdersStatusesListener(for: siteID)

Expand Down Expand Up @@ -840,6 +860,10 @@ private extension MainTabBarController {
)
posTabCoordinator = coordinator

// Setup bookings wrapped view controller
let bookingsViewController = createBookingsViewController(siteID: siteID)
bookingsContainerController.wrappedController = bookingsViewController

// Updates site ID for the bookings tab to display correct bookings
(bookingsContainerController.wrappedController as? BookingsTabViewHostingController)?.didSwitchStore(id: siteID)
}
Expand Down Expand Up @@ -938,7 +962,7 @@ private extension MainTabBarController {
let tab = WooTab.orders
let tabIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible)

guard let orderTab: UITabBarItem = self.tabBar.items?[tabIndex] else {
guard let orderTab: UITabBarItem = self.tabBar.items?[safe: tabIndex] else {
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ final class MainTabBarControllerTests: XCTestCase {
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher())

let siteID: Int64 = 134
stores.updateDefaultStore(storeID: siteID)
stores.updateDefaultStore(.fake().copy(siteID: siteID))

guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
return MainTabBarController(coder: coder,
noticePresenter: noticePresenter,
Expand Down Expand Up @@ -177,6 +181,10 @@ final class MainTabBarControllerTests: XCTestCase {
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher())

let siteID: Int64 = 134
stores.updateDefaultStore(storeID: siteID)
stores.updateDefaultStore(.fake().copy(siteID: siteID))

guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
return MainTabBarController(coder: coder,
noticePresenter: noticePresenter,
Expand Down Expand Up @@ -206,6 +214,10 @@ final class MainTabBarControllerTests: XCTestCase {
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher())

let siteID: Int64 = 134
stores.updateDefaultStore(storeID: siteID)
stores.updateDefaultStore(.fake().copy(siteID: siteID))

guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
return MainTabBarController(coder: coder,
noticePresenter: noticePresenter,
Expand Down Expand Up @@ -235,6 +247,10 @@ final class MainTabBarControllerTests: XCTestCase {
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher())

let siteID: Int64 = 134
stores.updateDefaultStore(storeID: siteID)
stores.updateDefaultStore(.fake().copy(siteID: siteID))

guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
return MainTabBarController(coder: coder,
noticePresenter: noticePresenter,
Expand Down Expand Up @@ -276,6 +292,10 @@ final class MainTabBarControllerTests: XCTestCase {
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher())

let siteID: Int64 = 134
stores.updateDefaultStore(storeID: siteID)
stores.updateDefaultStore(.fake().copy(siteID: siteID))

guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
return MainTabBarController(coder: coder,
noticePresenter: noticePresenter,
Expand Down Expand Up @@ -318,6 +338,10 @@ final class MainTabBarControllerTests: XCTestCase {
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher())

let siteID: Int64 = 134
stores.updateDefaultStore(storeID: siteID)
stores.updateDefaultStore(.fake().copy(siteID: siteID))

guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
return MainTabBarController(coder: coder,
noticePresenter: noticePresenter,
Expand Down Expand Up @@ -596,6 +620,96 @@ final class MainTabBarControllerTests: XCTestCase {
assertEqual(true, analyticsProvider.receivedProperties[safe: indexOfEvent]?["is_visible"] as? Bool)
}

func test_initial_tabs_visibility_is_set_from_cache() throws {
// Given
let siteID: Int64 = 1126
let mockPOSEligibilityService = MockPOSEligibilityService()
mockPOSEligibilityService.cachedTabVisibility[siteID] = true

let userDefaults = UserDefaults(suiteName: #function)!
userDefaults.removePersistentDomain(forName: #function)
userDefaults.cacheBookingsTabVisibility(siteID: siteID, isVisible: true)

let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true))

guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
return MainTabBarController(coder: coder,
stores: stores,
posEligibilityService: mockPOSEligibilityService,
userDefaults: userDefaults)
}) else {
XCTFail("Failed to instantiate MainTabBarController")
return
}

// When
stores.updateDefaultStore(storeID: siteID)
XCTAssertNotNil(tabBarController.view) // This triggers viewDidLoad

// Then
let expectedTabs: [WooTab] = [.myStore, .orders, .products, .bookings, .pointOfSale, .hubMenu]
let visibleTabs = WooTab.visibleTabs(isPOSTabVisible: true, isBookingsTabVisible: true)
XCTAssertEqual(tabBarController.viewControllers?.count, expectedTabs.count)
XCTAssertEqual(visibleTabs, expectedTabs)
}

func test_switching_sites_applies_cached_tab_visibility() throws {
// Arrange
let siteA_ID: Int64 = 101
let siteB_ID: Int64 = 202

// Site A: POS visible, Bookings not visible
let mockPOSEligibilityService = MockPOSEligibilityService()
mockPOSEligibilityService.cachedTabVisibility[siteA_ID] = true
mockPOSEligibilityService.cachedTabVisibility[siteB_ID] = false

let userDefaults = UserDefaults(suiteName: #function)!
userDefaults.removePersistentDomain(forName: #function)
userDefaults.cacheBookingsTabVisibility(siteID: siteA_ID, isVisible: false)
userDefaults.cacheBookingsTabVisibility(siteID: siteB_ID, isVisible: true)

let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true))

let tabBarController = try XCTUnwrap(UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
MainTabBarController(coder: coder,
stores: stores,
posTabVisibilityCheckerFactory: { site in
POSTabVisibilityChecker(site: site, eligibilityService: mockPOSEligibilityService)
},
posEligibilityService: mockPOSEligibilityService,
bookingsEligibilityCheckerFactory: { site in
BookingsTabEligibilityChecker(site: site, userDefaults: userDefaults)
},
userDefaults: userDefaults)
}))

// Trigger viewDidLoad
XCTAssertNotNil(tabBarController.view)

// Action 1: Switch to Site A
stores.updateDefaultStore(storeID: siteA_ID)
stores.updateDefaultStore(.fake().copy(siteID: siteA_ID))

// Assert 1: Site A should have POS, but not Bookings.
XCTAssertEqual(tabBarController.viewControllers?.count, 5, "There should be 5 tabs for Site A")
XCTAssertFalse(tabBarController.tabRootViewControllers.contains(where: { $0 is BookingsTabViewHostingController }),
"Bookings tab should not be visible for Site A")
XCTAssertTrue(tabBarController.tabRootViewControllers.contains(where: { $0 is POSTabViewController }),
"POS tab should be visible for Site A")


// Action 2: Switch to Site B
stores.updateDefaultStore(storeID: siteB_ID)
stores.updateDefaultStore(.fake().copy(siteID: siteB_ID))

// Assert 2: Site B should have Bookings, but not POS.
XCTAssertEqual(tabBarController.viewControllers?.count, 5, "There should be 5 tabs for Site B")
XCTAssertTrue(tabBarController.tabRootViewControllers.contains(where: { $0 is BookingsTabViewHostingController }),
"Bookings tab should be visible for Site B")
XCTAssertFalse(tabBarController.tabRootViewControllers.contains(where: { $0 is POSTabViewController }),
"POS tab should not be visible for Site B")
}

func test_bookings_tab_becomes_invisible_after_being_selected_when_initially_visible_then_eligibility_changes() throws {
// Given
let mockBookingsEligibilityChecker = MockAsyncBookingsEligibilityChecker()
Expand Down