From ff5c2b9e51ffe3ca6de94acd174717dca7e1853b Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Sat, 19 Oct 2024 17:13:49 +0300 Subject: [PATCH] Support search by dynamic queries (#3550) * Support search by dynamic queries Signed-off-by: Elly Kitoto * Add custom search param for Patient name and identifier * Fix failing test --------- Signed-off-by: Elly Kitoto Co-authored-by: Benjamin Mwalimu Co-authored-by: Rkareko Co-authored-by: Rkareko <47570855+Rkareko@users.noreply.github.com> Co-authored-by: Peter Lubell-Doughtie --- .../register/RegisterContentConfig.kt | 1 + .../ui/register/RegisterScreenTest.kt | 18 +- .../quest/ui/register/RegisterScreen.kt | 2 +- .../quest/ui/register/RegisterUiState.kt | 3 +- .../quest/ui/register/RegisterViewModel.kt | 218 ++++++++++++++++-- .../ui/register/RegisterViewModelTest.kt | 2 +- 6 files changed, 208 insertions(+), 36 deletions(-) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterContentConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterContentConfig.kt index 16d6f2a1a5..4dc64dd3e8 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterContentConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterContentConfig.kt @@ -27,4 +27,5 @@ data class RegisterContentConfig( val visible: Boolean? = null, val computedRules: List? = null, val searchByQrCode: Boolean? = null, + val dataFilterFields: List = emptyList(), ) diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/RegisterScreenTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/RegisterScreenTest.kt index 649ab7d7a2..2cf5fd089a 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/RegisterScreenTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/RegisterScreenTest.kt @@ -131,7 +131,7 @@ class RegisterScreenTest { pagesCount = 0, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), - params = emptyMap(), + params = emptyList(), ) val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) @@ -173,7 +173,7 @@ class RegisterScreenTest { pagesCount = 1, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), - params = emptyMap(), + params = emptyList(), ) val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) @@ -218,7 +218,7 @@ class RegisterScreenTest { pagesCount = 1, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), - params = emptyMap(), + params = emptyList(), ) val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) @@ -263,7 +263,7 @@ class RegisterScreenTest { pagesCount = 1, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), - params = emptyMap(), + params = emptyList(), ) val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) @@ -309,7 +309,7 @@ class RegisterScreenTest { pagesCount = 0, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), - params = emptyMap(), + params = emptyList(), ) val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) @@ -356,7 +356,7 @@ class RegisterScreenTest { pagesCount = 0, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), - params = emptyMap(), + params = emptyList(), ) val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) @@ -442,7 +442,7 @@ class RegisterScreenTest { ), ), ), - params = emptyMap(), + params = emptyList(), ) val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) @@ -495,7 +495,7 @@ class RegisterScreenTest { progressPercentage = flowOf(100), isSyncUpload = flowOf(false), currentSyncJobStatus = flowOf(CurrentSyncJobStatus.Succeeded(OffsetDateTime.now())), - params = emptyMap(), + params = emptyList(), ) val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) @@ -547,7 +547,7 @@ class RegisterScreenTest { progressPercentage = flowOf(100), isSyncUpload = flowOf(false), currentSyncJobStatus = flowOf(CurrentSyncJobStatus.Succeeded(OffsetDateTime.now())), - params = emptyMap(), + params = emptyList(), ) val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterScreen.kt index c08daccc6e..515739ff58 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterScreen.kt @@ -297,7 +297,7 @@ fun RegisterScreenWithDataPreview() { pagesCount = 1, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), - params = emptyMap(), + params = emptyList(), ) val searchText = remember { mutableStateOf(SearchQuery.emptyText) } val currentPage = remember { mutableIntStateOf(0) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiState.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiState.kt index 56f8ee43da..49698fa760 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiState.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiState.kt @@ -20,6 +20,7 @@ import com.google.android.fhir.sync.CurrentSyncJobStatus import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration +import org.smartregister.fhircore.engine.domain.model.ActionParameter data class RegisterUiState( val screenTitle: String = "", @@ -32,5 +33,5 @@ data class RegisterUiState( val progressPercentage: Flow = flowOf(0), val isSyncUpload: Flow = flowOf(false), val currentSyncJobStatus: Flow = flowOf(null), - val params: Map = emptyMap(), + val params: List = emptyList(), ) 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 21886825ce..3d8aca437c 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 @@ -41,9 +41,22 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.CodeType +import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.DateType +import org.hl7.fhir.r4.model.DecimalType import org.hl7.fhir.r4.model.Enumerations.DataType +import org.hl7.fhir.r4.model.IntegerType +import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.StringType +import org.hl7.fhir.r4.model.TimeType +import org.hl7.fhir.r4.model.UriType +import org.hl7.fhir.r4.model.UrlType import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration @@ -132,7 +145,7 @@ constructor( resourceDataRulesExecutor = resourceDataRulesExecutor, ruleConfigs = ruleConfigs, fhirResourceConfig = registerFilterState.value.fhirResourceConfig, - actionParameters = registerUiState.value.params, + actionParameters = registerUiState.value.params.toTypedArray().toParamDataMap(), ) .apply { setPatientPagingSourceState( @@ -154,7 +167,11 @@ constructor( // Ensures register configuration is initialized once if (!::registerConfiguration.isInitialized) { registerConfiguration = - configurationRegistry.retrieveConfiguration(ConfigType.Register, registerId, paramMap) + configurationRegistry.retrieveConfiguration( + ConfigType.Register, + registerId, + paramMap, + ) } return registerConfiguration } @@ -176,10 +193,20 @@ constructor( is RegisterEvent.SearchRegister -> { if (event.searchQuery.isBlank()) { val regConfig = retrieveRegisterConfiguration(registerId) - if (regConfig.infiniteScroll) { - registerData.value = retrieveCompleteRegisterData(registerId, false) - } else { - paginateRegisterData(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) @@ -199,23 +226,157 @@ constructor( fun filterRegisterData(searchText: String) { val searchBar = registerUiState.value.registerConfiguration?.searchBar - // computedRules (names of pre-computed rules) must be provided for search to work. - if (searchBar?.computedRules != null) { + val registerId = registerUiState.value.registerId + if (!searchBar?.dataFilterFields.isNullOrEmpty()) { + val dataFilterFields = searchBar?.dataFilterFields + updateRegisterFilterState( + registerId = registerId, + questionnaireResponse = + constructSearchQuestionnaireResponse( + searchText = searchText, + dataFilterFields = searchBar?.dataFilterFields ?: emptyList(), + ), + dataFilterFields = dataFilterFields, + ) + paginateRegisterData(registerId = registerId, loadAll = true, clearCache = true) + } else if (searchBar?.computedRules != null) { registerData.value = - retrieveCompleteRegisterData(registerUiState.value.registerId, false).map { - pagingData: PagingData -> - pagingData.filter { resourceData: ResourceData -> - searchBar.computedRules!!.any { ruleName -> - // if ruleName not found in map return {-1}; check always return false hence no data - val value = resourceData.computedValuesMap[ruleName]?.toString() ?: "{-1}" - value.contains(other = searchText, ignoreCase = true) + retrieveCompleteRegisterData( + registerId = registerId, + forceRefresh = false, + ) + .map { pagingData: PagingData, + -> + pagingData.filter { resourceData: ResourceData -> + searchBar.computedRules!!.any { ruleName -> + // if ruleName not found in map return {-1}; check always return false hence no data + val value = resourceData.computedValuesMap[ruleName]?.toString() ?: "{-1}" + value.contains(other = searchText, ignoreCase = true) + } } } - } } } - fun updateRegisterFilterState(registerId: String, questionnaireResponse: QuestionnaireResponse) { + private fun constructSearchQuestionnaireResponse( + searchText: String, + dataFilterFields: List, + ): QuestionnaireResponse { + val questionnaireResponse = QuestionnaireResponse() + dataFilterFields.forEach { + it.dataQueries.mapToQRItems(questionnaireResponse, searchText) + it.nestedSearchResources?.forEach { nestedSearchConfig -> + nestedSearchConfig.dataQueries.mapToQRItems(questionnaireResponse, searchText) + } + } + return questionnaireResponse + } + + private fun List?.mapToQRItems( + questionnaireResponse: QuestionnaireResponse, + searchText: String, + ) { + this?.forEach { dataQuery -> + dataQuery.filterCriteria.map { filterCriterionConfig -> + questionnaireResponse.addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent( + StringType(filterCriterionConfig.dataFilterLinkId), + ) + .apply { + when (filterCriterionConfig.dataType) { + DataType.QUANTITY -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = Quantity(searchText.toDouble()) + }, + ) + DataType.DATETIME -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = DateTimeType(searchText) + }, + ) + DataType.DATE -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = DateType(searchText) + }, + ) + DataType.TIME -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = TimeType(searchText) + }, + ) + DataType.DECIMAL -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = DecimalType(searchText) + }, + ) + DataType.INTEGER -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = IntegerType(searchText) + }, + ) + DataType.STRING -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = StringType(searchText) + }, + ) + DataType.URI -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = UriType(searchText) + }, + ) + DataType.URL -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = UrlType(searchText) + }, + ) + DataType.REFERENCE -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = Reference(searchText) + }, + ) + DataType.CODING -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("", searchText, "") + }, + ) + DataType.CODEABLECONCEPT -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = CodeableConcept(Coding("", searchText, "")) + }, + ) + DataType.CODE -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = CodeType(searchText) + }, + ) + else -> { + // Type cannot be used in search query + } + } + }, + ) + } + } + } + + fun updateRegisterFilterState( + registerId: String, + questionnaireResponse: QuestionnaireResponse, + dataFilterFields: List? = null, + ) { // Reset filter state if no answer is provided for all the fields if (questionnaireResponse.item.all { !it.hasAnswer() }) { registerFilterState.value = @@ -232,8 +393,7 @@ constructor( val qrItemMap = questionnaireResponse.item.groupBy { it.linkId }.mapValues { it.value.first() } val registerDataFilterFieldsMap = - registerConfiguration.registerFilter - ?.dataFilterFields + (dataFilterFields ?: registerConfiguration.registerFilter?.dataFilterFields) ?.groupBy { it.filterId } ?.mapValues { it.value.first() } @@ -342,7 +502,10 @@ constructor( val answerComponent = qrItemMap[filterCriterionConfig.dataFilterLinkId] answerComponent?.answer?.forEach { itemAnswerComponent -> val criterion = - convertAnswerToFilterCriterion(itemAnswerComponent, filterCriterionConfig) + convertAnswerToFilterCriterion( + itemAnswerComponent, + filterCriterionConfig, + ) if (criterion != null) newFilterCriteria.add(criterion) } } else { @@ -353,7 +516,7 @@ constructor( } private fun convertAnswerToFilterCriterion( - answerComponent: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, + answerComponent: QuestionnaireResponseItemAnswerComponent, oldFilterCriterion: FilterCriterionConfig, ): FilterCriterionConfig? = when { @@ -466,7 +629,10 @@ constructor( retrieveCompleteRegisterData(currentRegisterConfiguration.id, clearCache) } else { _totalRecordsCount.longValue = - registerRepository.countRegisterData(registerId = registerId, paramsMap = paramsMap) + registerRepository.countRegisterData( + registerId = registerId, + paramsMap = paramsMap, + ) // Only count filtered data when queries are updated if (registerFilterState.value.fhirResourceConfig != null) { @@ -477,7 +643,11 @@ constructor( fhirResourceConfig = registerFilterState.value.fhirResourceConfig, ) } - paginateRegisterData(registerId = registerId, loadAll = false, clearCache = clearCache) + paginateRegisterData( + registerId = registerId, + loadAll = false, + clearCache = clearCache, + ) } registerUiState.value = @@ -510,7 +680,7 @@ constructor( progressPercentage = _percentageProgress, isSyncUpload = _isUploadSync, currentSyncJobStatus = _currentSyncJobStatusFlow, - params = paramsMap, + params = params?.toList() ?: emptyList(), ) } } 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 a8e7bf5ee7..3746f047c9 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 @@ -156,7 +156,7 @@ class RegisterViewModelTest : RobolectricTest() { mutableStateOf(RegisterUiState(registerId = registerId)) // Search with empty string should paginate the data registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery.emptyText)) - verify { registerViewModel.paginateRegisterData(any(), any()) } + verify { registerViewModel.retrieveRegisterUiState(any(), any(), any(), any()) } // Search for the word 'Khan' should call the filterRegisterData function registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery("Khan")))