diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt index 3d8aca437c..749f2d941b 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt @@ -17,6 +17,7 @@ package org.smartregister.fhircore.quest.ui.register import android.graphics.Bitmap +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf @@ -33,10 +34,13 @@ import com.google.android.fhir.sync.CurrentSyncJobStatus import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlin.math.ceil +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -78,10 +82,12 @@ import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.encodeJson import org.smartregister.fhircore.quest.data.register.RegisterPagingSource import org.smartregister.fhircore.quest.data.register.model.RegisterPagingSourceState +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery import org.smartregister.fhircore.quest.util.extensions.referenceToBitmap import org.smartregister.fhircore.quest.util.extensions.toParamDataMap import timber.log.Timber +@OptIn(FlowPreview::class) @HiltViewModel class RegisterViewModel @Inject @@ -112,6 +118,29 @@ constructor( } private val decodedImageMap = mutableStateMapOf() + private val _searchQueryFlow: MutableSharedFlow = MutableSharedFlow() + + @VisibleForTesting + val debouncedSearchQueryFlow = + _searchQueryFlow.debounce { + val searchText = it.query + when (searchText.length) { + 0 -> 2.milliseconds // when search is cleared + 1, + 2, -> 1000.milliseconds + else -> 500.milliseconds + } + } + + init { + viewModelScope.launch { + debouncedSearchQueryFlow.collect { + val registerId = registerUiState.value.registerId + performSearch(registerId, it) + } + } + } + /** * This function paginates the register data. An optional [clearCache] resets the data in the * cache (this is necessary after a questionnaire has been submitted to refresh the register with @@ -191,26 +220,7 @@ constructor( when (event) { // Search using name or patient logicalId or identifier. Modify to add more search params is RegisterEvent.SearchRegister -> { - if (event.searchQuery.isBlank()) { - val regConfig = retrieveRegisterConfiguration(registerId) - val searchByDynamicQueries = !regConfig.searchBar?.dataFilterFields.isNullOrEmpty() - if (searchByDynamicQueries) { - registerFilterState.value = RegisterFilterState() // Reset queries - } - when { - regConfig.infiniteScroll -> - registerData.value = retrieveCompleteRegisterData(registerId, searchByDynamicQueries) - else -> - retrieveRegisterUiState( - registerId = registerId, - screenTitle = registerUiState.value.screenTitle, - params = registerUiState.value.params.toTypedArray(), - clearCache = searchByDynamicQueries, - ) - } - } else { - filterRegisterData(event.searchQuery.query) - } + viewModelScope.launch { _searchQueryFlow.emit(event.searchQuery) } } is RegisterEvent.MoveToNextPage -> { currentPage.value = currentPage.value.plus(1) @@ -224,6 +234,30 @@ constructor( } } + @VisibleForTesting + fun performSearch(registerId: String, searchQuery: SearchQuery) { + if (searchQuery.isBlank()) { + val regConfig = retrieveRegisterConfiguration(registerId) + val searchByDynamicQueries = !regConfig.searchBar?.dataFilterFields.isNullOrEmpty() + if (searchByDynamicQueries) { + registerFilterState.value = RegisterFilterState() // Reset queries + } + when { + regConfig.infiniteScroll -> + registerData.value = retrieveCompleteRegisterData(registerId, searchByDynamicQueries) + else -> + retrieveRegisterUiState( + registerId = registerId, + screenTitle = registerUiState.value.screenTitle, + params = registerUiState.value.params.toTypedArray(), + clearCache = searchByDynamicQueries, + ) + } + } else { + filterRegisterData(searchQuery.query) + } + } + fun filterRegisterData(searchText: String) { val searchBar = registerUiState.value.registerConfiguration?.searchBar val registerId = registerUiState.value.registerId diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModelTest.kt index 3746f047c9..485922bc9e 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModelTest.kt @@ -29,6 +29,10 @@ import io.mockk.runs import io.mockk.spyk import io.mockk.verify import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.DateType @@ -114,7 +118,7 @@ class RegisterViewModelTest : RobolectricTest() { @Test @kotlinx.coroutines.ExperimentalCoroutinesApi fun testRetrieveRegisterUiState() = runTest { - every { registerViewModel.retrieveRegisterConfiguration(any()) } returns + val registerConfig = RegisterConfiguration( appId = "app", id = registerId, @@ -122,6 +126,10 @@ class RegisterViewModelTest : RobolectricTest() { FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), pageSize = 10, ) + configurationRegistry.configCacheMap[registerId] = registerConfig + registerViewModel.registerUiState.value = + registerViewModel.registerUiState.value.copy(registerId = registerId) + every { registerViewModel.paginateRegisterData(any(), any()) } just runs coEvery { registerRepository.countRegisterData(any()) } returns 200 registerViewModel.retrieveRegisterUiState( @@ -143,8 +151,9 @@ class RegisterViewModelTest : RobolectricTest() { } @Test - fun testOnEventSearchRegister() { - every { registerViewModel.retrieveRegisterConfiguration(any()) } returns + @kotlinx.coroutines.ExperimentalCoroutinesApi + fun testDebounceSearchQueryFlow() = runTest { + val registerConfig = RegisterConfiguration( appId = "app", id = registerId, @@ -152,14 +161,68 @@ class RegisterViewModelTest : RobolectricTest() { FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), pageSize = 10, ) - every { registerViewModel.registerUiState } returns - mutableStateOf(RegisterUiState(registerId = registerId)) + configurationRegistry.configCacheMap[registerId] = registerConfig + registerViewModel.registerUiState.value = + registerViewModel.registerUiState.value.copy(registerId = registerId) + coEvery { registerRepository.countRegisterData(any()) } returns 0L + + val results = mutableListOf() + val debounceJob = launch { + registerViewModel.debouncedSearchQueryFlow.collect { results.add(it.query) } + } + advanceUntilIdle() + // Search with empty string should paginate the data registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery.emptyText)) + + advanceTimeBy(3.milliseconds) + Assert.assertTrue(results.isNotEmpty()) + Assert.assertTrue(results.last().isBlank()) + + registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery("K"))) + registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery("Kh"))) + registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery("Kha"))) + registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery("Khan"))) + + advanceTimeBy(1010.milliseconds) + Assert.assertEquals(2, results.size) + Assert.assertEquals("Khan", results.last()) + debounceJob.cancel() + } + + @Test + fun testPerformSearchWithEmptyQuery() = runTest { + val registerConfig = + RegisterConfiguration( + appId = "app", + id = registerId, + fhirResource = + FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), + pageSize = 10, + ) + configurationRegistry.configCacheMap[registerId] = registerConfig + coEvery { registerRepository.countRegisterData(any()) } returns 0L + + // Search with empty string should paginate the data + registerViewModel.performSearch(registerId, SearchQuery.emptyText) verify { registerViewModel.retrieveRegisterUiState(any(), any(), any(), any()) } + } + + @Test + fun testPerformSearchWithNonEmptyQuery() = runTest { + val registerConfig = + RegisterConfiguration( + appId = "app", + id = registerId, + fhirResource = + FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), + pageSize = 10, + ) + configurationRegistry.configCacheMap[registerId] = registerConfig + coEvery { registerRepository.countRegisterData(any()) } returns 0L // Search for the word 'Khan' should call the filterRegisterData function - registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery("Khan"))) + registerViewModel.performSearch(registerId, SearchQuery("Khan")) verify { registerViewModel.filterRegisterData(any()) } }