diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/ObserveSiteOrdersState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/ObserveSiteOrdersState.kt index 46de65908d2..abb1b5163f8 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/ObserveSiteOrdersState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/ObserveSiteOrdersState.kt @@ -4,37 +4,36 @@ import com.woocommerce.android.R import com.woocommerce.android.model.DashboardWidget import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.util.CoroutineDispatchers +import com.woocommerce.android.util.observeEvents import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.transformLatest -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.onStart +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.WCOrderStore import org.wordpress.android.fluxc.store.WCOrderStore.HasOrdersResult +import org.wordpress.android.fluxc.store.WCOrderStore.OnOrderStatusOptionsChanged import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) class ObserveSiteOrdersState @Inject constructor( private val selectedSite: SelectedSite, private val orderStore: WCOrderStore, - private val coroutineDispatchers: CoroutineDispatchers + private val coroutineDispatchers: CoroutineDispatchers, + private val dispatcher: Dispatcher ) { operator fun invoke() = selectedSite.observe() .filterNotNull() - .flatMapLatest { orderStore.observeOrderCountForSite(it) } - .map { count -> count != 0 } - .distinctUntilChanged() - .transformLatest { hasOrders -> - if (!hasOrders) { - // This means either the store doesn't have orders, or no orders are cached yet - // Use other approaches to determine if the store has orders - emit(getHasOrdersFromOrderStatusOptions() ?: fetchHasOrdersFromApi()) - } else { - emit(true) - } - }.map { hasOrders -> + .flatMapLatest { observeHasOrdersFromOrdersStatusOptions(it) } + .map { + // Fallback to fetching from API if we can't infer the value from the order status options + it ?: fetchHasOrdersFromApi() + } + .map { hasOrders -> if (hasOrders) { DashboardWidget.Status.Available } else { @@ -44,13 +43,17 @@ class ObserveSiteOrdersState @Inject constructor( } } - private suspend fun getHasOrdersFromOrderStatusOptions() = withContext(coroutineDispatchers.io) { - orderStore.getOrderStatusOptionsForSite(selectedSite.get()) - .filter { it.statusKey != "checkout-draft" } - .takeIf { it.isNotEmpty() } - ?.sumOf { it.statusCount } - ?.let { it != 0 } - } + private fun observeHasOrdersFromOrdersStatusOptions(site: SiteModel) = + dispatcher.observeEvents() + .onStart { emit(OnOrderStatusOptionsChanged(0)) } + .map { + orderStore.getOrderStatusOptionsForSite(site) + .filter { it.statusKey != "checkout-draft" } + .takeIf { it.isNotEmpty() } + ?.any { it.statusCount > 0 } + } + .distinctUntilChanged() + .flowOn(coroutineDispatchers.io) private suspend fun fetchHasOrdersFromApi(): Boolean { return orderStore.fetchHasOrders(selectedSite.get(), null).let { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/main/ObserveProcessingOrdersCount.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/main/ObserveProcessingOrdersCount.kt index 663954ee89d..a9d69210708 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/main/ObserveProcessingOrdersCount.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/main/ObserveProcessingOrdersCount.kt @@ -1,38 +1,52 @@ package com.woocommerce.android.ui.main -import com.woocommerce.android.analytics.AnalyticsEvent -import com.woocommerce.android.analytics.AnalyticsTracker +import com.woocommerce.android.OnChangedException import com.woocommerce.android.extensions.NotificationReceivedEvent import com.woocommerce.android.notifications.NotificationChannelType import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.util.CoroutineDispatchers import com.woocommerce.android.util.WooLog +import com.woocommerce.android.util.dispatchAndAwait import com.woocommerce.android.util.observeEvents import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.withContext import org.greenrobot.eventbus.EventBus import org.wordpress.android.fluxc.Dispatcher import org.wordpress.android.fluxc.action.WCOrderAction +import org.wordpress.android.fluxc.generated.WCOrderActionBuilder import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.CoreOrderStatus import org.wordpress.android.fluxc.store.WCOrderStore +import org.wordpress.android.fluxc.store.WCOrderStore.FetchOrderStatusOptionsPayload import org.wordpress.android.fluxc.store.WCOrderStore.OnOrderChanged import org.wordpress.android.fluxc.store.WCOrderStore.OnOrderStatusOptionsChanged -import org.wordpress.android.fluxc.store.WCOrderStore.OrdersCountResult.Failure -import org.wordpress.android.fluxc.store.WCOrderStore.OrdersCountResult.Success import javax.inject.Inject class ObserveProcessingOrdersCount @Inject constructor( private val dispatcher: Dispatcher, private val wcOrderStore: WCOrderStore, - private val selectedSite: SelectedSite + private val selectedSite: SelectedSite, + private val dispatchers: CoroutineDispatchers ) { - @OptIn(ExperimentalCoroutinesApi::class) + companion object { + // A debounce duration to avoid fetching the value multiple times when there are multiple simultaneous events + private const val DEBOUNCE_DURATION_MS = 200L + } + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) operator fun invoke(): Flow = selectedSite.observe().transformLatest { site -> if (site == null) { emit(null) @@ -42,18 +56,12 @@ class ObserveProcessingOrdersCount @Inject constructor( // Start with the cached value emit(getCachedValue(site)) - // emit updated value - fetchOrdersCount(site)?.let { emit(it) } - // Observe value changes coroutineScope { - dispatcher.observeEvents() - .onEach { - emit(getCachedValue(site)) - } - .launchIn(this) - merge( + wcOrderStore.observeOrderCountForSite(site) + .drop(1) + .distinctUntilChanged(), dispatcher.observeEvents() .filter { @Suppress("DEPRECATION") @@ -70,35 +78,42 @@ class ObserveProcessingOrdersCount @Inject constructor( WooLog.d(WooLog.T.ORDERS, "New order notification received, re-check unfilled orders count") } ) + .debounce(DEBOUNCE_DURATION_MS) .onEach { - fetchOrdersCount(site)?.let { emit(it) } + // Fetch value from API, the value will be emitted when OnOrderStatusOptionsChanged event + // is received below + fetchOrderStatusOptions(site) } .launchIn(this) + + emitAll( + dispatcher.observeEvents() + .map { getCachedValue(site) } + ) } } - private fun getCachedValue(site: SiteModel): Int? = + private suspend fun getCachedValue(site: SiteModel): Int? = withContext(dispatchers.io) { wcOrderStore.getOrderStatusForSiteAndKey(site, CoreOrderStatus.PROCESSING.value)?.statusCount + } - private suspend fun fetchOrdersCount(site: SiteModel): Int? { - return wcOrderStore.fetchOrdersCount(site, CoreOrderStatus.PROCESSING.value).let { - when (it) { - is Success -> { - AnalyticsTracker.track( - AnalyticsEvent.UNFULFILLED_ORDERS_LOADED, - mapOf(AnalyticsTracker.KEY_HAS_UNFULFILLED_ORDERS to it.count) - ) - it.count - } + private suspend fun fetchOrderStatusOptions(site: SiteModel): Result { + val event: OnOrderStatusOptionsChanged = dispatcher.dispatchAndAwait( + WCOrderActionBuilder.newFetchOrderStatusOptionsAction( + FetchOrderStatusOptionsPayload(site) + ) + ) - is Failure -> { - WooLog.e( - WooLog.T.ORDERS, - "Error fetching a count of orders waiting to be fulfilled: ${it.error.message}" - ) - null - } + return when { + event.isError -> { + WooLog.e( + WooLog.T.ORDERS, + "Error fetching order status options: ${event.error.message}" + ) + Result.failure(OnChangedException(event.error)) } + + else -> Result.success(Unit) } } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/FakeDispatcher.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/FakeDispatcher.kt index e7b13cee733..b6eee604d9d 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/FakeDispatcher.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/FakeDispatcher.kt @@ -4,7 +4,7 @@ import org.greenrobot.eventbus.Subscribe import org.wordpress.android.fluxc.Dispatcher import org.wordpress.android.fluxc.annotations.action.Action -class FakeDispatcher : Dispatcher() { +class FakeDispatcher(private val dispatchCallback: Dispatcher.(action: Action<*>) -> Unit = {}) : Dispatcher() { private val listeners = mutableListOf() @Synchronized @@ -31,7 +31,7 @@ class FakeDispatcher : Dispatcher() { } } - override fun dispatch(action: Action<*>?) { - // NO-OP + override fun dispatch(action: Action<*>) { + dispatchCallback(action) } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/main/ObserveProcessingOrdersCountTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/main/ObserveProcessingOrdersCountTest.kt index 9372ffb4d1d..3f1c27146e1 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/main/ObserveProcessingOrdersCountTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/main/ObserveProcessingOrdersCountTest.kt @@ -8,9 +8,12 @@ import com.woocommerce.android.viewmodel.BaseUnitTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle import org.assertj.core.api.Assertions.assertThat import org.greenrobot.eventbus.EventBus import org.junit.Test @@ -24,13 +27,20 @@ import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.CoreOrderStatus import org.wordpress.android.fluxc.store.WCOrderStore import org.wordpress.android.fluxc.store.WCOrderStore.OnOrderChanged import org.wordpress.android.fluxc.store.WCOrderStore.OnOrderStatusOptionsChanged -import org.wordpress.android.fluxc.store.WCOrderStore.OrdersCountResult @OptIn(ExperimentalCoroutinesApi::class) class ObserveProcessingOrdersCountTest : BaseUnitTest() { - private val dispatcher = FakeDispatcher() - private val orderStore: WCOrderStore = mock() + private val dispatcher = FakeDispatcher { action -> + if (action.type == WCOrderAction.FETCH_ORDER_STATUS_OPTIONS) { + emitChange( + OnOrderStatusOptionsChanged(0) + ) + } + } private val site = SiteModel() + private val orderStore: WCOrderStore = mock { + on { observeOrderCountForSite(site) } doReturn emptyFlow() + } private val selectedSite: SelectedSite = mock { on { observe() } doReturn flowOf(site) } @@ -38,7 +48,8 @@ class ObserveProcessingOrdersCountTest : BaseUnitTest() { private val sut = ObserveProcessingOrdersCount( dispatcher = dispatcher, wcOrderStore = orderStore, - selectedSite = selectedSite + selectedSite = selectedSite, + dispatchers = coroutinesTestRule.testDispatchers ) @Test @@ -52,9 +63,6 @@ class ObserveProcessingOrdersCountTest : BaseUnitTest() { @Test fun `when observation begins, then emit the cached count`() = testBlocking { - whenever( - orderStore.fetchOrdersCount(site, CoreOrderStatus.PROCESSING.value) - ).thenReturn(OrdersCountResult.Success(2)) whenever( orderStore.getOrderStatusForSiteAndKey(site, CoreOrderStatus.PROCESSING.value) ).thenReturn( @@ -68,23 +76,8 @@ class ObserveProcessingOrdersCountTest : BaseUnitTest() { assertThat(count).isEqualTo(1) } - @Test - fun `when observation begins, then fetch orders count from API`() = testBlocking { - whenever( - orderStore.fetchOrdersCount(site, CoreOrderStatus.PROCESSING.value) - ).thenReturn(OrdersCountResult.Success(1)) - - val count = sut.invoke().drop(1).first() - - assertThat(count).isEqualTo(1) - } - @Test fun `when order statuses are fetched, then re-emit new count`() = testBlocking { - whenever( - orderStore.fetchOrdersCount(site, CoreOrderStatus.PROCESSING.value) - ).thenReturn(OrdersCountResult.Success(1)) - whenever( orderStore.getOrderStatusForSiteAndKey(site, CoreOrderStatus.PROCESSING.value) ).thenReturn( @@ -105,10 +98,18 @@ class ObserveProcessingOrdersCountTest : BaseUnitTest() { } @Test - fun `when push notification is received, then re-fetch orders count`() = testBlocking { - whenever(orderStore.fetchOrdersCount(site, CoreOrderStatus.PROCESSING.value)) - .thenReturn(OrdersCountResult.Success(1)) - .thenReturn(OrdersCountResult.Success(2)) + fun `when push notification is received, then re-fetch orders status options`() = testBlocking { + whenever( + orderStore.getOrderStatusForSiteAndKey(site, CoreOrderStatus.PROCESSING.value) + ).thenReturn( + WCOrderStatusModel(0).apply { + statusCount = 1 + } + ).thenReturn( + WCOrderStatusModel(0).apply { + statusCount = 2 + } + ) val count = runAndReturnLastValue { EventBus.getDefault().post( @@ -117,20 +118,59 @@ class ObserveProcessingOrdersCountTest : BaseUnitTest() { channel = NotificationChannelType.NEW_ORDER ) ) + advanceUntilIdle() } assertThat(count).isEqualTo(2) } @Test - fun `when an order status is updated, then re-fetch orders count`() = testBlocking { - whenever(orderStore.fetchOrdersCount(site, CoreOrderStatus.PROCESSING.value)) - .thenReturn(OrdersCountResult.Success(1)) - .thenReturn(OrdersCountResult.Success(2)) + fun `when an order status is updated, then re-fetch orders status options`() = testBlocking { + whenever( + orderStore.getOrderStatusForSiteAndKey(site, CoreOrderStatus.PROCESSING.value) + ).thenReturn( + WCOrderStatusModel(0).apply { + statusCount = 1 + } + ).thenReturn( + WCOrderStatusModel(0).apply { + statusCount = 2 + } + ) val count = runAndReturnLastValue { @Suppress("DEPRECATION") dispatcher.emitChange(OnOrderChanged(causeOfChange = WCOrderAction.UPDATE_ORDER_STATUS)) + advanceUntilIdle() + } + + assertThat(count).isEqualTo(2) + } + + @Test + fun `when orders count change, then re-fetch orders status options`() = testBlocking { + whenever(orderStore.observeOrderCountForSite(site)) + .thenReturn( + flow { + emit(1) + delay(1000) + emit(2) + } + ) + whenever( + orderStore.getOrderStatusForSiteAndKey(site, CoreOrderStatus.PROCESSING.value) + ).thenReturn( + WCOrderStatusModel(0).apply { + statusCount = 1 + } + ).thenReturn( + WCOrderStatusModel(0).apply { + statusCount = 2 + } + ) + + val count = runAndReturnLastValue { + advanceUntilIdle() } assertThat(count).isEqualTo(2)