Skip to content

Commit 15b0c32

Browse files
[Bookings] Use cached tabs visibility (#16292)
2 parents 4b6fed3 + 35cece2 commit 15b0c32

File tree

5 files changed

+182
-24
lines changed

5 files changed

+182
-24
lines changed

RELEASE-NOTES.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

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

WooCommerce/Classes/Bookings/BookingsTabEligibilityChecker.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ final class BookingsTabEligibilityChecker: BookingsTabEligibilityCheckerProtocol
3333
userDefaults.loadCachedBookingsTabVisibility(siteID: site.siteID)
3434
}
3535

36+
/// Checks the initial visibility without the `BookingsTabEligibilityChecker` instsance
37+
/// Used for the initial state check when a site instance hasn't been loaded but a `siteID` is available
38+
static func checkInitialVisibility(
39+
for siteID: Int64,
40+
in userDefaults: UserDefaults = .standard
41+
) -> Bool {
42+
return userDefaults.loadCachedBookingsTabVisibility(siteID: siteID)
43+
}
44+
3645
/// Checks the final visibility of the Bookings tab.
3746
func checkVisibility() async -> Bool {
3847
// Check feature flag

WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabVisibilityChecker.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ final class POSTabVisibilityChecker: POSTabVisibilityCheckerProtocol {
4545
eligibilityService.loadCachedPOSTabVisibility(siteID: site.siteID) ?? false
4646
}
4747

48+
/// Checks the initial visibility without the `POSTabVisibilityChecker` instsance
49+
/// Used for the initial state check when a site instance hasn't been loaded but a `siteID` is available
50+
static func checkInitialVisibility(
51+
for siteID: Int64,
52+
eligibilityService: POSEligibilityServiceProtocol = POSEligibilityService()
53+
) -> Bool {
54+
return eligibilityService.loadCachedPOSTabVisibility(siteID: siteID) ?? false
55+
}
56+
4857
/// Checks the final visibility of the POS tab.
4958
func checkVisibility() async -> Bool {
5059
guard siteCIABEligibilityChecker.isFeatureSupported(.pointOfSale, for: site) else {

WooCommerce/Classes/ViewRelated/MainTabBarController.swift

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ final class MainTabBarController: UITabBarController {
146146
private let posTabVisibilityCheckerFactory: ((_ site: Site) -> POSTabVisibilityCheckerProtocol)
147147
private let posEligibilityService: POSEligibilityServiceProtocol
148148
private let bookingsEligibilityCheckerFactory: ((_ site: Site) -> BookingsTabEligibilityCheckerProtocol)
149+
private let userDefaults: UserDefaults
149150

150151
private var productImageUploadErrorsSubscription: AnyCancellable?
151152

@@ -171,7 +172,8 @@ final class MainTabBarController: UITabBarController {
171172
stores: StoresManager = ServiceLocator.stores,
172173
posTabVisibilityCheckerFactory: ((Site) -> POSTabVisibilityCheckerProtocol)? = nil,
173174
posEligibilityService: POSEligibilityServiceProtocol = POSEligibilityService(),
174-
bookingsEligibilityCheckerFactory: ((Site) -> BookingsTabEligibilityCheckerProtocol)? = nil) {
175+
bookingsEligibilityCheckerFactory: ((Site) -> BookingsTabEligibilityCheckerProtocol)? = nil,
176+
userDefaults: UserDefaults = .standard) {
175177
self.featureFlagService = featureFlagService
176178
self.noticePresenter = noticePresenter
177179
self.productImageUploader = productImageUploader
@@ -184,6 +186,7 @@ final class MainTabBarController: UITabBarController {
184186
self.bookingsEligibilityCheckerFactory = bookingsEligibilityCheckerFactory ?? { site in
185187
BookingsTabEligibilityChecker(site: site)
186188
}
189+
self.userDefaults = userDefaults
187190
super.init(coder: coder)
188191
}
189192

@@ -201,6 +204,7 @@ final class MainTabBarController: UITabBarController {
201204
self.bookingsEligibilityCheckerFactory = { site in
202205
BookingsTabEligibilityChecker(site: site)
203206
}
207+
self.userDefaults = .standard
204208
super.init(coder: coder)
205209
}
206210

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

219223
delegate = self
220224

221-
// POS and Bookings tabs are hidden by default.
222-
updateTabViewControllers(isPOSTabVisible: false, isBookingsTabVisible: false)
225+
// Setup initial visibility for conditional tabs (POS, Bookings)
226+
setupConditionalTabsInitialVisibility()
227+
223228
observeSiteIDForViewControllers()
224229
observeSiteForConditionalTabs()
225230
observeProductImageUploadStatusUpdates()
@@ -333,6 +338,30 @@ final class MainTabBarController: UITabBarController {
333338
}
334339
}
335340
}
341+
342+
private func setupConditionalTabsInitialVisibility() {
343+
guard let siteID = stores.sessionManager.defaultStoreID else {
344+
return
345+
}
346+
347+
setupConditionalTabsInitialVisibility(for: siteID)
348+
}
349+
350+
private func setupConditionalTabsInitialVisibility(for siteID: Int64) {
351+
let isPOSTabVisible = POSTabVisibilityChecker.checkInitialVisibility(
352+
for: siteID,
353+
eligibilityService: posEligibilityService
354+
)
355+
let isBookingsTabVisible = BookingsTabEligibilityChecker.checkInitialVisibility(
356+
for: siteID,
357+
in: userDefaults
358+
)
359+
360+
updateTabViewControllers(
361+
isPOSTabVisible: isPOSTabVisible,
362+
isBookingsTabVisible: isBookingsTabVisible
363+
)
364+
}
336365
}
337366

338367
// MARK: - UITabBarControllerDelegate
@@ -698,12 +727,12 @@ extension MainTabBarController: DeepLinkNavigator {
698727
// MARK: - Site ID observation for updating tab view controllers
699728
//
700729
private extension MainTabBarController {
701-
func observePOSEligibilityForPOSTabVisibility(siteID: Int64) {
702-
guard let posTabVisibilityChecker else {
703-
updateTabViewControllers(isPOSTabVisible: false, isBookingsTabVisible: isBookingsTabVisible)
704-
viewModel.loadHubMenuTabBadge()
705-
return
706-
}
730+
func observePOSEligibilityForPOSTabVisibility(site: Site) {
731+
let siteID = site.siteID
732+
733+
// Configures POS tab coordinator once per logged in site session.
734+
let posTabVisibilityChecker = posTabVisibilityCheckerFactory(site)
735+
self.posTabVisibilityChecker = posTabVisibilityChecker
707736

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

773-
observeConditionalTabsAvailabilityWith(site)
802+
observePOSEligibilityForPOSTabVisibility(site: site)
803+
observeBookingsEligibilityForBookingsTabVisibility(site: site)
774804
}
775805
}
776806

@@ -783,24 +813,14 @@ private extension MainTabBarController {
783813
}
784814
}
785815

786-
func observeConditionalTabsAvailabilityWith(_ site: Site) {
787-
// Configures POS tab coordinator once per logged in site session.
788-
let posTabVisibilityChecker = posTabVisibilityCheckerFactory(site)
789-
self.posTabVisibilityChecker = posTabVisibilityChecker
790-
791-
observePOSEligibilityForPOSTabVisibility(siteID: site.siteID)
792-
793-
// Configures Booking tab.
794-
let bookingsViewController = createBookingsViewController(siteID: site.siteID)
795-
bookingsContainerController.wrappedController = bookingsViewController
796-
observeBookingsEligibilityForBookingsTabVisibility(site: site)
797-
}
798-
799816
func updateViewControllers(siteID: Int64?) {
800817
guard let siteID else {
801818
return
802819
}
803820

821+
// Update conditional tabs initial state for the `siteID`
822+
setupConditionalTabsInitialVisibility(for: siteID)
823+
804824
// Update view model with `siteID` to query correct Orders Status
805825
viewModel.configureOrdersStatusesListener(for: siteID)
806826

@@ -840,6 +860,10 @@ private extension MainTabBarController {
840860
)
841861
posTabCoordinator = coordinator
842862

863+
// Setup bookings wrapped view controller
864+
let bookingsViewController = createBookingsViewController(siteID: siteID)
865+
bookingsContainerController.wrappedController = bookingsViewController
866+
843867
// Updates site ID for the bookings tab to display correct bookings
844868
(bookingsContainerController.wrappedController as? BookingsTabViewHostingController)?.didSwitchStore(id: siteID)
845869
}
@@ -938,7 +962,7 @@ private extension MainTabBarController {
938962
let tab = WooTab.orders
939963
let tabIndex = tab.visibleIndex(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible)
940964

941-
guard let orderTab: UITabBarItem = self.tabBar.items?[tabIndex] else {
965+
guard let orderTab: UITabBarItem = self.tabBar.items?[safe: tabIndex] else {
942966
return
943967
}
944968

WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ final class MainTabBarControllerTests: XCTestCase {
147147
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
148148
let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher())
149149

150+
let siteID: Int64 = 134
151+
stores.updateDefaultStore(storeID: siteID)
152+
stores.updateDefaultStore(.fake().copy(siteID: siteID))
153+
150154
guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
151155
return MainTabBarController(coder: coder,
152156
noticePresenter: noticePresenter,
@@ -177,6 +181,10 @@ final class MainTabBarControllerTests: XCTestCase {
177181
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
178182
let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher())
179183

184+
let siteID: Int64 = 134
185+
stores.updateDefaultStore(storeID: siteID)
186+
stores.updateDefaultStore(.fake().copy(siteID: siteID))
187+
180188
guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
181189
return MainTabBarController(coder: coder,
182190
noticePresenter: noticePresenter,
@@ -206,6 +214,10 @@ final class MainTabBarControllerTests: XCTestCase {
206214
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
207215
let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher())
208216

217+
let siteID: Int64 = 134
218+
stores.updateDefaultStore(storeID: siteID)
219+
stores.updateDefaultStore(.fake().copy(siteID: siteID))
220+
209221
guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
210222
return MainTabBarController(coder: coder,
211223
noticePresenter: noticePresenter,
@@ -235,6 +247,10 @@ final class MainTabBarControllerTests: XCTestCase {
235247
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
236248
let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher())
237249

250+
let siteID: Int64 = 134
251+
stores.updateDefaultStore(storeID: siteID)
252+
stores.updateDefaultStore(.fake().copy(siteID: siteID))
253+
238254
guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
239255
return MainTabBarController(coder: coder,
240256
noticePresenter: noticePresenter,
@@ -276,6 +292,10 @@ final class MainTabBarControllerTests: XCTestCase {
276292
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
277293
let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher())
278294

295+
let siteID: Int64 = 134
296+
stores.updateDefaultStore(storeID: siteID)
297+
stores.updateDefaultStore(.fake().copy(siteID: siteID))
298+
279299
guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
280300
return MainTabBarController(coder: coder,
281301
noticePresenter: noticePresenter,
@@ -318,6 +338,10 @@ final class MainTabBarControllerTests: XCTestCase {
318338
let statusUpdates = PassthroughSubject<ProductImageUploadErrorInfo, Never>()
319339
let productImageUploader = MockProductImageUploader(errors: statusUpdates.eraseToAnyPublisher())
320340

341+
let siteID: Int64 = 134
342+
stores.updateDefaultStore(storeID: siteID)
343+
stores.updateDefaultStore(.fake().copy(siteID: siteID))
344+
321345
guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
322346
return MainTabBarController(coder: coder,
323347
noticePresenter: noticePresenter,
@@ -596,6 +620,96 @@ final class MainTabBarControllerTests: XCTestCase {
596620
assertEqual(true, analyticsProvider.receivedProperties[safe: indexOfEvent]?["is_visible"] as? Bool)
597621
}
598622

623+
func test_initial_tabs_visibility_is_set_from_cache() throws {
624+
// Given
625+
let siteID: Int64 = 1126
626+
let mockPOSEligibilityService = MockPOSEligibilityService()
627+
mockPOSEligibilityService.cachedTabVisibility[siteID] = true
628+
629+
let userDefaults = UserDefaults(suiteName: #function)!
630+
userDefaults.removePersistentDomain(forName: #function)
631+
userDefaults.cacheBookingsTabVisibility(siteID: siteID, isVisible: true)
632+
633+
let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true))
634+
635+
guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
636+
return MainTabBarController(coder: coder,
637+
stores: stores,
638+
posEligibilityService: mockPOSEligibilityService,
639+
userDefaults: userDefaults)
640+
}) else {
641+
XCTFail("Failed to instantiate MainTabBarController")
642+
return
643+
}
644+
645+
// When
646+
stores.updateDefaultStore(storeID: siteID)
647+
XCTAssertNotNil(tabBarController.view) // This triggers viewDidLoad
648+
649+
// Then
650+
let expectedTabs: [WooTab] = [.myStore, .orders, .products, .bookings, .pointOfSale, .hubMenu]
651+
let visibleTabs = WooTab.visibleTabs(isPOSTabVisible: true, isBookingsTabVisible: true)
652+
XCTAssertEqual(tabBarController.viewControllers?.count, expectedTabs.count)
653+
XCTAssertEqual(visibleTabs, expectedTabs)
654+
}
655+
656+
func test_switching_sites_applies_cached_tab_visibility() throws {
657+
// Arrange
658+
let siteA_ID: Int64 = 101
659+
let siteB_ID: Int64 = 202
660+
661+
// Site A: POS visible, Bookings not visible
662+
let mockPOSEligibilityService = MockPOSEligibilityService()
663+
mockPOSEligibilityService.cachedTabVisibility[siteA_ID] = true
664+
mockPOSEligibilityService.cachedTabVisibility[siteB_ID] = false
665+
666+
let userDefaults = UserDefaults(suiteName: #function)!
667+
userDefaults.removePersistentDomain(forName: #function)
668+
userDefaults.cacheBookingsTabVisibility(siteID: siteA_ID, isVisible: false)
669+
userDefaults.cacheBookingsTabVisibility(siteID: siteB_ID, isVisible: true)
670+
671+
let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true))
672+
673+
let tabBarController = try XCTUnwrap(UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in
674+
MainTabBarController(coder: coder,
675+
stores: stores,
676+
posTabVisibilityCheckerFactory: { site in
677+
POSTabVisibilityChecker(site: site, eligibilityService: mockPOSEligibilityService)
678+
},
679+
posEligibilityService: mockPOSEligibilityService,
680+
bookingsEligibilityCheckerFactory: { site in
681+
BookingsTabEligibilityChecker(site: site, userDefaults: userDefaults)
682+
},
683+
userDefaults: userDefaults)
684+
}))
685+
686+
// Trigger viewDidLoad
687+
XCTAssertNotNil(tabBarController.view)
688+
689+
// Action 1: Switch to Site A
690+
stores.updateDefaultStore(storeID: siteA_ID)
691+
stores.updateDefaultStore(.fake().copy(siteID: siteA_ID))
692+
693+
// Assert 1: Site A should have POS, but not Bookings.
694+
XCTAssertEqual(tabBarController.viewControllers?.count, 5, "There should be 5 tabs for Site A")
695+
XCTAssertFalse(tabBarController.tabRootViewControllers.contains(where: { $0 is BookingsTabViewHostingController }),
696+
"Bookings tab should not be visible for Site A")
697+
XCTAssertTrue(tabBarController.tabRootViewControllers.contains(where: { $0 is POSTabViewController }),
698+
"POS tab should be visible for Site A")
699+
700+
701+
// Action 2: Switch to Site B
702+
stores.updateDefaultStore(storeID: siteB_ID)
703+
stores.updateDefaultStore(.fake().copy(siteID: siteB_ID))
704+
705+
// Assert 2: Site B should have Bookings, but not POS.
706+
XCTAssertEqual(tabBarController.viewControllers?.count, 5, "There should be 5 tabs for Site B")
707+
XCTAssertTrue(tabBarController.tabRootViewControllers.contains(where: { $0 is BookingsTabViewHostingController }),
708+
"Bookings tab should be visible for Site B")
709+
XCTAssertFalse(tabBarController.tabRootViewControllers.contains(where: { $0 is POSTabViewController }),
710+
"POS tab should not be visible for Site B")
711+
}
712+
599713
func test_bookings_tab_becomes_invisible_after_being_selected_when_initially_visible_then_eligibility_changes() throws {
600714
// Given
601715
let mockBookingsEligibilityChecker = MockAsyncBookingsEligibilityChecker()

0 commit comments

Comments
 (0)